Skip to content

Commit e3dc1db

Browse files
authored
fix(pipelines): position-aware log truncation and pagination metadata (#310)
* fix(pipelines): position-aware log truncation message and pagination metadata - Replace hardcoded "last N lines" with context-aware messages: positive start shows "Showing lines X-Y of Z", default/negative start shows "Showing last N of Z lines (lines X-Y)" - Add pagination metadata to response: startLine, hasMore, nextStart - Consolidate limit/max_lines into per_page for API consistency - Update unit and integration tests for all truncation variants Closes #309 * fix(pipelines): add max constraint to per_page in logs schema - Add .max(10000) to per_page to prevent excessive memory usage
1 parent 60db194 commit e3dc1db

File tree

4 files changed

+178
-41
lines changed

4 files changed

+178
-41
lines changed

src/entities/pipelines/registry.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ export const pipelinesToolRegistry: ToolRegistry = new Map<string, EnhancedToolD
9191
}
9292

9393
case "logs": {
94-
// TypeScript knows: input has job_id (required), limit, max_lines, start (optional)
95-
const { project_id, job_id, limit, max_lines, start } = input;
94+
// TypeScript knows: input has job_id (required), per_page, start (optional)
95+
const { project_id, job_id, per_page, start } = input;
9696

9797
// Custom handling - trace endpoint returns text, needs line processing
9898
const apiUrl = `${process.env.GITLAB_API_URL}/api/v4/projects/${normalizeProjectId(project_id)}/jobs/${job_id}/trace`;
@@ -109,21 +109,22 @@ export const pipelinesToolRegistry: ToolRegistry = new Map<string, EnhancedToolD
109109
const defaultMaxLines = 200;
110110
let processedLines: string[] = [];
111111

112-
let maxLinesToShow = defaultMaxLines;
113-
if (max_lines !== undefined) {
114-
maxLinesToShow = max_lines;
115-
} else if (limit !== undefined) {
116-
maxLinesToShow = limit;
117-
}
112+
const maxLinesToShow = per_page ?? defaultMaxLines;
118113

119114
let outOfBoundsMessage = "";
115+
// Track the effective start position for pagination metadata
116+
let effectiveStart: number;
120117

121118
if (start !== undefined && start < 0) {
119+
// Negative start: take from end of log
120+
effectiveStart = Math.max(0, totalLines + start);
122121
processedLines = lines.slice(start);
123122
if (processedLines.length > maxLinesToShow) {
123+
effectiveStart = totalLines - maxLinesToShow;
124124
processedLines = processedLines.slice(-maxLinesToShow);
125125
}
126126
} else if (start !== undefined && start >= 0) {
127+
effectiveStart = start;
127128
if (start >= totalLines) {
128129
processedLines = [];
129130
outOfBoundsMessage = `[OUT OF BOUNDS: Start position ${start} exceeds total lines ${totalLines}. Available range: 0-${totalLines - 1}]`;
@@ -135,6 +136,8 @@ export const pipelinesToolRegistry: ToolRegistry = new Map<string, EnhancedToolD
135136
}
136137
}
137138
} else {
139+
// No start specified: take last N lines (default behavior)
140+
effectiveStart = Math.max(0, totalLines - maxLinesToShow);
138141
processedLines = lines.slice(-maxLinesToShow);
139142
}
140143

@@ -144,16 +147,33 @@ export const pipelinesToolRegistry: ToolRegistry = new Map<string, EnhancedToolD
144147
processedLines.unshift(outOfBoundsMessage);
145148
}
146149

147-
if (processedLines.length < totalLines && !outOfBoundsMessage) {
148-
const truncatedCount = totalLines - actualDataLines;
149-
processedLines.unshift(
150-
`[LOG TRUNCATED: Showing last ${actualDataLines} of ${totalLines} lines - ${truncatedCount} lines hidden]`
151-
);
150+
// Position-aware truncation message
151+
if (actualDataLines < totalLines && !outOfBoundsMessage) {
152+
const endLine = effectiveStart + actualDataLines - 1;
153+
let truncationMessage: string;
154+
if (start === undefined || start < 0) {
155+
// Default or negative start: showing tail of log
156+
truncationMessage = `[LOG TRUNCATED: Showing last ${actualDataLines} of ${totalLines} lines (lines ${effectiveStart}-${endLine})]`;
157+
} else {
158+
// Positive start: showing specific range from beginning/middle
159+
truncationMessage = `[LOG TRUNCATED: Showing lines ${effectiveStart}-${endLine} of ${totalLines}]`;
160+
}
161+
processedLines.unshift(truncationMessage);
152162
}
153163

154164
trace = processedLines.join("\n");
155165

156-
return { trace, totalLines, shownLines: actualDataLines };
166+
const hasMore = effectiveStart + actualDataLines < totalLines;
167+
const nextStart = hasMore ? effectiveStart + actualDataLines : null;
168+
169+
return {
170+
trace,
171+
totalLines,
172+
shownLines: actualDataLines,
173+
startLine: effectiveStart,
174+
hasMore,
175+
nextStart,
176+
};
157177
}
158178

159179
/* istanbul ignore next -- unreachable with Zod discriminatedUnion */

src/entities/pipelines/schema-readonly.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -288,19 +288,20 @@ const GetJobLogsSchema = z.object({
288288
action: z.literal("logs").describe("Get job console output/logs"),
289289
project_id: projectIdField,
290290
job_id: requiredId.describe("The ID of the job"),
291-
limit: z
291+
per_page: z
292292
.number()
293+
.int()
294+
.min(1)
295+
.max(10000)
293296
.optional()
294-
.describe("Maximum number of lines to return. Combined with start, acts as line count"),
295-
max_lines: z
296-
.number()
297-
.optional()
298-
.describe("Maximum number of lines to return (alternative to limit)"),
297+
.describe(
298+
"Maximum number of lines to return (default: 200, max: 10000). Use with start for pagination"
299+
),
299300
start: z
300301
.number()
301302
.optional()
302303
.describe(
303-
"Start from specific line number (0-based). Positive from beginning, negative from end (e.g., -100 = last 100 lines)"
304+
"Start from specific line number (0-based). Positive from beginning, negative from end (e.g., -100 = last 100 lines). Use nextStart from previous response to paginate"
304305
),
305306
});
306307

tests/integration/schemas/pipelines.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ describe("Pipelines Schema - GitLab Integration (CQRS)", () => {
595595
action: "logs" as const,
596596
project_id: "123",
597597
job_id: "789",
598-
limit: 100,
598+
per_page: 100,
599599
start: 50,
600600
};
601601

@@ -605,7 +605,7 @@ describe("Pipelines Schema - GitLab Integration (CQRS)", () => {
605605
if (result.success && result.data.action === "logs") {
606606
expect(result.data.project_id).toBe("123");
607607
expect(result.data.job_id).toBe("789");
608-
expect(result.data.limit).toBe(100);
608+
expect(result.data.per_page).toBe(100);
609609
expect(result.data.start).toBe(50);
610610
}
611611

tests/unit/entities/pipelines/registry.test.ts

Lines changed: 134 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -556,7 +556,15 @@ describe("Pipelines Registry - CQRS Tools", () => {
556556
expect(mockEnhancedFetch).toHaveBeenCalledWith(
557557
"https://gitlab.example.com/api/v4/projects/test%2Fproject/jobs/1/trace"
558558
);
559-
expect(result).toEqual({ trace: mockTrace, totalLines: 3, shownLines: 3 });
559+
// All 3 lines fit within default 200, no truncation
560+
expect(result).toEqual({
561+
trace: mockTrace,
562+
totalLines: 3,
563+
shownLines: 3,
564+
startLine: 0,
565+
hasMore: false,
566+
nextStart: null,
567+
});
560568
});
561569

562570
it("should default to 200 lines when no limit specified", async () => {
@@ -575,24 +583,36 @@ describe("Pipelines Registry - CQRS Tools", () => {
575583
action: "logs",
576584
project_id: "test/project",
577585
job_id: "1",
578-
})) as { trace: string; totalLines: number; shownLines: number };
586+
})) as {
587+
trace: string;
588+
totalLines: number;
589+
shownLines: number;
590+
startLine: number;
591+
hasMore: boolean;
592+
nextStart: number | null;
593+
};
579594

580595
expect(result).toHaveProperty("trace");
581596
expect(result).toHaveProperty("totalLines", 300);
582597
expect(result).toHaveProperty("shownLines", 200);
598+
// Default behavior (no start): shows last 200 lines, startLine=100
599+
expect(result).toHaveProperty("startLine", 100);
600+
expect(result).toHaveProperty("hasMore", false);
601+
expect(result).toHaveProperty("nextStart", null);
583602

584603
const trace = result.trace;
585604
const traceLines = trace.split("\n");
586605

587606
expect(traceLines).toHaveLength(201);
588-
expect(trace).toContain("100 lines hidden");
607+
// Default/negative start shows "last N" message
589608
expect(trace).toContain("Showing last 200 of 300 lines");
609+
expect(trace).toContain("lines 100-299");
590610
expect(trace).toContain("Line 101: Some output here");
591611
expect(trace).toContain("Line 300: Some output here");
592612
expect(trace).not.toContain("Line 100: Some output here");
593613
});
594614

595-
it("should truncate long job trace when limit is provided", async () => {
615+
it("should truncate long job trace when per_page is provided", async () => {
596616
const longTrace = Array(1000).fill("Very long line with lots of content here").join("\n");
597617
mockEnhancedFetch.mockResolvedValueOnce({
598618
ok: true,
@@ -606,17 +626,29 @@ describe("Pipelines Registry - CQRS Tools", () => {
606626
action: "logs",
607627
project_id: "test/project",
608628
job_id: "1",
609-
limit: 50,
610-
})) as { trace: string; totalLines: number; shownLines: number };
629+
per_page: 50,
630+
})) as {
631+
trace: string;
632+
totalLines: number;
633+
shownLines: number;
634+
startLine: number;
635+
hasMore: boolean;
636+
nextStart: number | null;
637+
};
611638

612639
expect(result).toHaveProperty("trace");
613-
expect(result.trace).toContain("lines hidden");
640+
// Default behavior (no start) shows last 50 lines
641+
expect(result.trace).toContain("Showing last 50 of 1000 lines");
614642
expect(result.trace.length).toBeLessThan(longTrace.length);
615643
expect(result.totalLines).toBe(1000);
616644
expect(result.shownLines).toBe(50);
645+
expect(result.startLine).toBe(950);
646+
expect(result.hasMore).toBe(false);
647+
expect(result.nextStart).toBeNull();
617648
});
618649

619-
it("should handle start + limit combination correctly", async () => {
650+
it("should handle start + per_page combination correctly", async () => {
651+
// Positive start=50 with per_page=10: shows lines 50-59 from middle of log
620652
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1} content`);
621653
const fullTrace = lines.join("\n");
622654

@@ -633,21 +665,35 @@ describe("Pipelines Registry - CQRS Tools", () => {
633665
project_id: "test/project",
634666
job_id: "1",
635667
start: 50,
636-
limit: 10,
637-
})) as { trace: string; totalLines: number; shownLines: number };
668+
per_page: 10,
669+
})) as {
670+
trace: string;
671+
totalLines: number;
672+
shownLines: number;
673+
startLine: number;
674+
hasMore: boolean;
675+
nextStart: number | null;
676+
};
638677

639678
const trace = result.trace;
640679
const traceLines = trace.split("\n");
641680

681+
// 10 data lines + 1 truncation header
642682
expect(traceLines).toHaveLength(11);
683+
// Positive start: position-aware message "Showing lines X-Y of Z"
684+
expect(trace).toContain("Showing lines 50-59 of 100");
643685
expect(trace).toContain("Line 51 content");
644686
expect(trace).toContain("Line 60 content");
645687
expect(trace).not.toContain("Line 61 content");
646688
expect(result.totalLines).toBe(100);
647689
expect(result.shownLines).toBe(10);
690+
expect(result.startLine).toBe(50);
691+
expect(result.hasMore).toBe(true);
692+
expect(result.nextStart).toBe(60);
648693
});
649694

650695
it("should handle negative start correctly", async () => {
696+
// Negative start=-50 with per_page=30: takes last 50, then limits to last 30
651697
const lines = Array.from({ length: 200 }, (_, i) => `Line ${i + 1} content`);
652698
const fullTrace = lines.join("\n");
653699

@@ -664,16 +710,28 @@ describe("Pipelines Registry - CQRS Tools", () => {
664710
project_id: "test/project",
665711
job_id: "1",
666712
start: -50,
667-
max_lines: 30,
668-
})) as { trace: string; totalLines: number; shownLines: number };
713+
per_page: 30,
714+
})) as {
715+
trace: string;
716+
totalLines: number;
717+
shownLines: number;
718+
startLine: number;
719+
hasMore: boolean;
720+
nextStart: number | null;
721+
};
669722

670723
const trace = result.trace;
671724

725+
// Negative start: "Showing last N" message
726+
expect(trace).toContain("Showing last 30 of 200 lines");
672727
expect(trace).toContain("Line 171 content");
673728
expect(trace).toContain("Line 200 content");
674729
expect(trace).not.toContain("Line 170 content");
675730
expect(result.totalLines).toBe(200);
676731
expect(result.shownLines).toBe(30);
732+
expect(result.startLine).toBe(170);
733+
expect(result.hasMore).toBe(false);
734+
expect(result.nextStart).toBeNull();
677735
});
678736

679737
it("should handle out of bounds start position", async () => {
@@ -693,12 +751,22 @@ describe("Pipelines Registry - CQRS Tools", () => {
693751
project_id: "test/project",
694752
job_id: "1",
695753
start: 100,
696-
limit: 10,
697-
})) as { trace: string; totalLines: number; shownLines: number };
754+
per_page: 10,
755+
})) as {
756+
trace: string;
757+
totalLines: number;
758+
shownLines: number;
759+
startLine: number;
760+
hasMore: boolean;
761+
nextStart: number | null;
762+
};
698763

699764
expect(result.trace).toContain("OUT OF BOUNDS");
700765
expect(result.totalLines).toBe(50);
701766
expect(result.shownLines).toBe(0);
767+
expect(result.startLine).toBe(100);
768+
expect(result.hasMore).toBe(false);
769+
expect(result.nextStart).toBeNull();
702770
});
703771

704772
// Test for line 89: error handling when fetch returns non-ok response
@@ -720,7 +788,7 @@ describe("Pipelines Registry - CQRS Tools", () => {
720788
).rejects.toThrow("GitLab API error: 404 Not Found");
721789
});
722790

723-
// Test for lines 120-121: partial request message when start + max_lines exceeds total
791+
// Test for partial request: start + per_page exceeds total lines
724792
it("should show partial request message when requested range exceeds available lines", async () => {
725793
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i + 1} content`);
726794
const fullTrace = lines.join("\n");
@@ -738,15 +806,63 @@ describe("Pipelines Registry - CQRS Tools", () => {
738806
project_id: "test/project",
739807
job_id: "1",
740808
start: 40,
741-
max_lines: 20,
742-
})) as { trace: string; totalLines: number; shownLines: number };
809+
per_page: 20,
810+
})) as {
811+
trace: string;
812+
totalLines: number;
813+
shownLines: number;
814+
startLine: number;
815+
hasMore: boolean;
816+
nextStart: number | null;
817+
};
743818

744-
// start=40, max_lines=20 means requesting lines 40-59, but only 40-49 exist
819+
// start=40, per_page=20 means requesting lines 40-59, but only 40-49 exist
745820
expect(result.trace).toContain("PARTIAL REQUEST");
746821
expect(result.trace).toContain("Requested 20 lines from position 40");
747822
expect(result.trace).toContain("only 10 lines available");
748823
expect(result.totalLines).toBe(50);
749824
expect(result.shownLines).toBe(10);
825+
expect(result.startLine).toBe(40);
826+
expect(result.hasMore).toBe(false);
827+
expect(result.nextStart).toBeNull();
828+
});
829+
830+
// Test: start=0 shows position-aware message, NOT "last N"
831+
it("should show position-aware message when start=0", async () => {
832+
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1} content`);
833+
const fullTrace = lines.join("\n");
834+
835+
mockEnhancedFetch.mockResolvedValueOnce({
836+
ok: true,
837+
status: 200,
838+
statusText: "OK",
839+
text: jest.fn().mockResolvedValue(fullTrace),
840+
} as never);
841+
842+
const tool = pipelinesToolRegistry.get("browse_pipelines")!;
843+
const result = (await tool.handler({
844+
action: "logs",
845+
project_id: "test/project",
846+
job_id: "1",
847+
start: 0,
848+
per_page: 15,
849+
})) as {
850+
trace: string;
851+
totalLines: number;
852+
shownLines: number;
853+
startLine: number;
854+
hasMore: boolean;
855+
nextStart: number | null;
856+
};
857+
858+
// start=0: should say "Showing lines 0-14 of 100", NOT "Showing last 15"
859+
expect(result.trace).toContain("Showing lines 0-14 of 100");
860+
expect(result.trace).not.toContain("last");
861+
expect(result.totalLines).toBe(100);
862+
expect(result.shownLines).toBe(15);
863+
expect(result.startLine).toBe(0);
864+
expect(result.hasMore).toBe(true);
865+
expect(result.nextStart).toBe(15);
750866
});
751867
});
752868

0 commit comments

Comments
 (0)