Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/lib/actions/sandbox/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
35 changes: 35 additions & 0 deletions src/lib/domain/sandbox/logs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
72 changes: 59 additions & 13 deletions src/lib/domain/sandbox/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, 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];
Expand All @@ -160,15 +174,47 @@ export function mergeTailLogLines(sources: ReadonlyArray<string>, 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<ScoredLine>();
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;
}