diff --git a/src/lib/actions/sandbox/logs.test.ts b/src/lib/actions/sandbox/logs.test.ts index 903d0a7efd3..bcc3cfbc182 100644 --- a/src/lib/actions/sandbox/logs.test.ts +++ b/src/lib/actions/sandbox/logs.test.ts @@ -219,4 +219,27 @@ describe("showSandboxLogsWithDeps", () => { expect(guidance).toHaveBeenCalledWith("alpha", { retryCommand: "logs" }); expect(result.calls).toEqual([]); }); + + it("surfaces a sparse gateway breadcrumb when OpenShell output dominates the tail", () => { + const gatewayStdout = [ + "[1779488800.000] [gateway] starting HTTP server", + "[1779488815.000] [telegram] [default] bridge did not start within 15s; check channels.telegram.enabled, plugin entries, and gateway log", + ].join("\n"); + const openshellStdout = Array.from( + { length: 200 }, + (_v, i) => `[${1779488900 + i}.000] [sandbox] [INFO ] line ${i}`, + ).join("\n"); + const result = captureLogsRun( + { follow: false, lines: "200", since: null }, + { + settings: { status: 0 }, + sandbox: { status: 0, stdout: `${gatewayStdout}\n` }, + logs: { status: 0, stdout: `${openshellStdout}\n` }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("bridge did not start within 15s"); + expect(result.stdout).toContain("starting HTTP server"); + }); }); diff --git a/src/lib/domain/sandbox/logs.test.ts b/src/lib/domain/sandbox/logs.test.ts index 16c38c28c02..f7f9f1aea4d 100644 --- a/src/lib/domain/sandbox/logs.test.ts +++ b/src/lib/domain/sandbox/logs.test.ts @@ -191,4 +191,39 @@ describe("mergeTailLogLines", () => { it("appends a trailing newline so callers can pipe through process.stdout.write", () => { expect(mergeTailLogLines(["[1] a"], 3).endsWith("\n")).toBe(true); }); + + it("preserves sparse-source content when a chatty source dominates by timestamp", () => { + const gatewayBoot = [ + "[1779488800.000] [gateway] starting HTTP server", + "[1779488815.000] [telegram] [default] bridge did not start within 15s", + ].join("\n"); + const openshellDense = Array.from( + { length: 200 }, + (_v, i) => `[${1779488900 + i}.000] [sandbox] [INFO] log line ${i}`, + ).join("\n"); + const merged = mergeTailLogLines([`${gatewayBoot}\n`, `${openshellDense}\n`], 200); + expect(merged).toContain("bridge did not start within 15s"); + expect(merged).toContain("starting HTTP server"); + }); + + it("inherits a preceding timestamp for an untimestamped diagnostic line in the same source", () => { + const gateway = [ + "[1779488800.000] [gateway] starting provider", + "[telegram] [default] bridge did not start within 15s", + ].join("\n"); + const openshellLater = Array.from( + { length: 50 }, + (_v, i) => `[${1779489000 + i}.000] [sandbox] [INFO] line ${i}`, + ).join("\n"); + const merged = mergeTailLogLines([`${gateway}\n`, `${openshellLater}\n`], 50); + expect(merged).toContain("bridge did not start within 15s"); + }); + + it("caps the total output at maxLines with two chatty sources", () => { + const gateway = Array.from({ length: 500 }, (_v, i) => `[${1000 + i}.000] g${i}`).join("\n"); + const openshell = Array.from({ length: 500 }, (_v, i) => `[${2000 + i}.000] o${i}`).join("\n"); + const merged = mergeTailLogLines([`${gateway}\n`, `${openshell}\n`], 100); + const lines = merged.split("\n").filter((line) => line.length > 0); + expect(lines.length).toBeLessThanOrEqual(100); + }); }); diff --git a/src/lib/domain/sandbox/logs.ts b/src/lib/domain/sandbox/logs.ts index 5850cc32e4b..2472be373f5 100644 --- a/src/lib/domain/sandbox/logs.ts +++ b/src/lib/domain/sandbox/logs.ts @@ -132,22 +132,36 @@ interface ScoredLine { /** * Merge log lines from multiple sources into a single chronologically - * ordered stream and return the last maxLines lines as a single - * string. Lines without their own timestamp inherit the timestamp of - * the previous line from the same source so multi-line log entries - * stay attached to their header. Sort is stable on - * (timestamp, sourceIndex, lineIndex) so identically-timestamped - * lines from different sources interleave deterministically. + * ordered stream and return at most maxLines lines as a single string. + * Lines without their own timestamp inherit the timestamp of the + * previous line from the same source so multi-line log entries stay + * attached to their header. Sort is stable on (timestamp, sourceIndex, + * lineIndex) so identically-timestamped lines from different sources + * interleave deterministically. + * + * Each non-empty source is given a floor of floor(maxLines / + * non-empty-source-count) of its most recent lines so a sparse source + * is not silently squeezed out by a chatty source whose newer + * timestamps would otherwise dominate the global tail. Remaining slots + * up to maxLines are filled with the most recent remaining lines + * across all sources, ranked by timestamp. The final selection is + * returned in chronological order. * * When maxLines is non-positive, all merged lines are returned. */ export function mergeTailLogLines(sources: ReadonlyArray, maxLines: number): string { - const scored: ScoredLine[] = []; + const perSource: ScoredLine[][] = []; + let nonEmptyCount = 0; for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { const raw = sources[sourceIndex] ?? ""; - if (!raw) continue; + if (!raw) { + perSource.push([]); + continue; + } + nonEmptyCount += 1; const lines = raw.split(LINE_SPLIT_RE); if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + const scored: ScoredLine[] = []; let lastSeen: number | null = null; for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { const text = lines[lineIndex]; @@ -160,15 +174,47 @@ export function mergeTailLogLines(sources: ReadonlyArray, maxLines: numb lineIndex, }); } + perSource.push(scored); + } + + if (maxLines <= 0) { + const all = perSource.flat(); + sortChronologically(all); + if (all.length === 0) return ""; + return all.map((entry) => entry.text).join(NEWLINE) + NEWLINE; + } + + const reserved = new Set(); + const reservePerSource = nonEmptyCount > 0 ? Math.floor(maxLines / nonEmptyCount) : 0; + if (reservePerSource > 0) { + for (const scored of perSource) { + if (scored.length === 0) continue; + const tail = scored.slice(-reservePerSource); + for (const entry of tail) reserved.add(entry); + } + } + + const remaining = maxLines - reserved.size; + if (remaining > 0) { + const candidates = perSource.flat().filter((entry) => !reserved.has(entry)); + candidates.sort((a, b) => { + if (a.timestamp !== b.timestamp) return b.timestamp - a.timestamp; + if (a.sourceIndex !== b.sourceIndex) return a.sourceIndex - b.sourceIndex; + return b.lineIndex - a.lineIndex; + }); + for (const entry of candidates.slice(0, remaining)) reserved.add(entry); } - scored.sort((a, b) => { + const final = Array.from(reserved); + sortChronologically(final); + if (final.length === 0) return ""; + return final.map((entry) => entry.text).join(NEWLINE) + NEWLINE; +} + +function sortChronologically(entries: ScoredLine[]): void { + entries.sort((a, b) => { if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp; if (a.sourceIndex !== b.sourceIndex) return a.sourceIndex - b.sourceIndex; return a.lineIndex - b.lineIndex; }); - - const tail = maxLines > 0 ? scored.slice(-maxLines) : scored; - if (tail.length === 0) return ""; - return tail.map((entry) => entry.text).join(NEWLINE) + NEWLINE; }