Merge pull request 'fix(cli): include __start__ message in nerve thread show' (#232) from fix/231-thread-show-start-message into main
This commit was merged in pull request #232.
This commit is contained in:
@@ -533,6 +533,25 @@ describe("buildThreadCommandOutput", () => {
|
||||
expect(paginationHint).toContain("--budget 400");
|
||||
});
|
||||
|
||||
it("formats startRow first (chronologically before role rounds) and consumes budget first", () => {
|
||||
const start: ThreadRoundRow = {
|
||||
round: 0,
|
||||
logId: 1,
|
||||
timestamp: 100,
|
||||
message: { role: "__start__", content: "go", meta: {}, timestamp: 100 },
|
||||
};
|
||||
const desc = [row(2, "bbb"), row(1, "aaa")];
|
||||
const { lines, paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-s", start);
|
||||
const text = lines.join("");
|
||||
const idxStart = text.indexOf("[#0 __start__]");
|
||||
const idxA = text.indexOf("\naaa\n");
|
||||
const idxB = text.indexOf("\nbbb\n");
|
||||
expect(idxStart).toBeGreaterThan(-1);
|
||||
expect(idxA).toBeGreaterThan(idxStart);
|
||||
expect(idxB).toBeGreaterThan(idxA);
|
||||
expect(paginationHint).toBeNull();
|
||||
});
|
||||
|
||||
it("default budget constant matches workflow command default", () => {
|
||||
expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000);
|
||||
});
|
||||
|
||||
@@ -117,8 +117,9 @@ const threadShowCommand = defineCommand({
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const startRow = before === 0 ? store.getThreadStartMessage(args.runId) : null;
|
||||
const totalRoleRounds = store.getThreadRoundCount(args.runId);
|
||||
if (totalRoleRounds === 0) {
|
||||
if (totalRoleRounds === 0 && startRow === null) {
|
||||
process.stdout.write(
|
||||
`🧵 Workflow thread: ${run.runId}\n workflow: ${run.workflow}\n status: ${run.status}\n\n📭 No role rounds recorded for this run.\n`,
|
||||
);
|
||||
@@ -143,6 +144,7 @@ const threadShowCommand = defineCommand({
|
||||
descRows,
|
||||
budgetChars,
|
||||
args.runId,
|
||||
startRow,
|
||||
);
|
||||
|
||||
for (const line of lines) {
|
||||
|
||||
@@ -278,20 +278,34 @@ function buildTruncatedSingleRound(
|
||||
/**
|
||||
* Build stdout lines for `nerve thread show`: newest-first selection from
|
||||
* `descRows` until `budgetChars` (including `prefixLines`), then chronological order.
|
||||
* When `startRow` is set (typically the persisted `__start__` frame on the first page only),
|
||||
* it is formatted first and its length is subtracted from the budget before consuming `descRows`.
|
||||
*/
|
||||
export function buildThreadCommandOutput(
|
||||
prefixLines: string[],
|
||||
descRows: ThreadRoundRow[],
|
||||
budgetChars: number,
|
||||
runId: string,
|
||||
startRow: ThreadRoundRow | null = null,
|
||||
): ThreadCommandOutput {
|
||||
const prefixText = prefixLines.join("");
|
||||
let remaining = Math.max(0, budgetChars - prefixText.length);
|
||||
const picked: ThreadRoundRow[] = [];
|
||||
const leadingRoundBlocks: string[] = [];
|
||||
|
||||
const budgetFlag =
|
||||
budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`;
|
||||
|
||||
if (startRow !== null) {
|
||||
const startBlock = formatThreadRoundBlock(startRow);
|
||||
if (startBlock.length <= remaining) {
|
||||
leadingRoundBlocks.push(startBlock);
|
||||
remaining -= startBlock.length;
|
||||
} else {
|
||||
return buildTruncatedSingleRound(startRow, remaining, prefixLines, runId, budgetFlag);
|
||||
}
|
||||
}
|
||||
|
||||
const picked: ThreadRoundRow[] = [];
|
||||
for (const row of descRows) {
|
||||
const block = formatThreadRoundBlock(row);
|
||||
if (block.length <= remaining) {
|
||||
@@ -300,7 +314,13 @@ export function buildThreadCommandOutput(
|
||||
continue;
|
||||
}
|
||||
if (picked.length === 0) {
|
||||
return buildTruncatedSingleRound(row, remaining, prefixLines, runId, budgetFlag);
|
||||
return buildTruncatedSingleRound(
|
||||
row,
|
||||
remaining,
|
||||
[...prefixLines, ...leadingRoundBlocks],
|
||||
runId,
|
||||
budgetFlag,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -312,7 +332,7 @@ export function buildThreadCommandOutput(
|
||||
paginationHint = `\n⏩ Older rounds not shown. Fetch with:\n nerve thread show ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`;
|
||||
}
|
||||
|
||||
return { lines: [...prefixLines, ...blocksAsc], paginationHint };
|
||||
return { lines: [...prefixLines, ...leadingRoundBlocks, ...blocksAsc], paginationHint };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -102,6 +102,7 @@ function makeLogStore(
|
||||
),
|
||||
getThreadRoundCount: vi.fn(() => 0),
|
||||
getThreadRounds: vi.fn(() => []),
|
||||
getThreadStartMessage: vi.fn(() => null),
|
||||
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
||||
close: vi.fn(),
|
||||
getAllWorkflowRuns: vi.fn(() => []),
|
||||
|
||||
@@ -89,6 +89,7 @@ function makeLogStore() {
|
||||
getThreadMessages: vi.fn(() => []),
|
||||
getThreadRoundCount: vi.fn(() => 0),
|
||||
getThreadRounds: vi.fn(() => []),
|
||||
getThreadStartMessage: vi.fn(() => null),
|
||||
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
||||
close: vi.fn(),
|
||||
getAllWorkflowRuns: vi.fn(() => []),
|
||||
|
||||
@@ -80,6 +80,7 @@ function makeMockLogStore() {
|
||||
getThreadMessages: vi.fn(() => []),
|
||||
getThreadRoundCount: vi.fn(() => 0),
|
||||
getThreadRounds: vi.fn(() => []),
|
||||
getThreadStartMessage: vi.fn(() => null),
|
||||
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -96,6 +96,7 @@ function makeLogStore() {
|
||||
getThreadMessages: vi.fn(() => []),
|
||||
getThreadRoundCount: vi.fn(() => 0),
|
||||
getThreadRounds: vi.fn(() => []),
|
||||
getThreadStartMessage: vi.fn(() => null),
|
||||
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -77,6 +77,7 @@ function makeLogStore() {
|
||||
getThreadMessages: vi.fn(() => []),
|
||||
getThreadRoundCount: vi.fn(() => 0),
|
||||
getThreadRounds: vi.fn(() => []),
|
||||
getThreadStartMessage: vi.fn(() => null),
|
||||
archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })),
|
||||
close: vi.fn(),
|
||||
getAllWorkflowRuns: vi.fn(() => []),
|
||||
|
||||
@@ -255,5 +255,51 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => {
|
||||
expect(store.getThreadRoundCount("missing")).toBe(0);
|
||||
expect(store.getThreadRounds("missing", { before: 0, limit: 10 })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("getThreadStartMessage returns __start__ row with round 0 and excludes it from getThreadRounds", () => {
|
||||
store.append({
|
||||
source: "workflow",
|
||||
type: "thread_workflow_message",
|
||||
refId: "run-start",
|
||||
payload: JSON.stringify({
|
||||
role: "__start__",
|
||||
content: "launch",
|
||||
meta: { prompt: "hi" },
|
||||
timestamp: 50,
|
||||
}),
|
||||
timestamp: 50,
|
||||
});
|
||||
store.append({
|
||||
source: "workflow",
|
||||
type: "thread_command_event",
|
||||
refId: "run-start",
|
||||
payload: JSON.stringify({ type: "step_a", role: "alpha", content: "hello", meta: {} }),
|
||||
timestamp: 51,
|
||||
});
|
||||
|
||||
expect(store.getThreadRoundCount("run-start")).toBe(1);
|
||||
|
||||
const start = store.getThreadStartMessage("run-start");
|
||||
expect(start).not.toBeNull();
|
||||
expect(start?.round).toBe(0);
|
||||
expect(start?.message.role).toBe("__start__");
|
||||
expect(start?.message.content).toBe("launch");
|
||||
|
||||
const rounds = store.getThreadRounds("run-start", { before: 0, limit: 50 });
|
||||
expect(rounds).toHaveLength(1);
|
||||
expect(rounds[0].round).toBe(1);
|
||||
expect(rounds[0].message.role).toBe("alpha");
|
||||
});
|
||||
|
||||
it("getThreadStartMessage returns null when no __start__ message", () => {
|
||||
store.append({
|
||||
source: "workflow",
|
||||
type: "thread_command_event",
|
||||
refId: "run-no-start",
|
||||
payload: JSON.stringify({ type: "step_a", role: "alpha", content: "x", meta: {} }),
|
||||
timestamp: 1,
|
||||
});
|
||||
expect(store.getThreadStartMessage("run-no-start")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,6 +166,11 @@ export type LogStore = {
|
||||
* with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). No schema migration — numbering is computed in SQL.
|
||||
*/
|
||||
getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[];
|
||||
/**
|
||||
* The workflow `__start__` message for a run (if persisted), as a {@link ThreadRoundRow}
|
||||
* with `round` 0 — not part of {@link getThreadRoundCount} / {@link getThreadRounds} numbering.
|
||||
*/
|
||||
getThreadStartMessage: (runId: string) => ThreadRoundRow | null;
|
||||
/**
|
||||
* Export logs older than the retention window to `data/archive/logs/YYYY-MM-DD.jsonl`,
|
||||
* then delete those rows and advance `meta.archived_up_to` in one transaction per day
|
||||
@@ -396,6 +401,15 @@ export function createLogStore(dbPath: string): LogStore {
|
||||
LIMIT @lim`,
|
||||
);
|
||||
|
||||
const getThreadStartMessageStmt = sqlite.prepare(
|
||||
`SELECT id, timestamp, payload FROM logs
|
||||
WHERE source = 'workflow' AND type IN ('thread_command_event', 'thread_workflow_message') AND ref_id = ?
|
||||
AND payload IS NOT NULL AND json_valid(payload) = 1
|
||||
AND COALESCE(json_extract(payload, '$.role'), '') = '__start__'
|
||||
ORDER BY id ASC
|
||||
LIMIT 1`,
|
||||
);
|
||||
|
||||
const getActiveWorkflowRunsStmt = sqlite.prepare(
|
||||
"SELECT run_id, workflow, status, timestamp, exit_code FROM workflow_runs WHERE status IN ('queued', 'started') ORDER BY timestamp ASC",
|
||||
);
|
||||
@@ -676,6 +690,16 @@ export function createLogStore(dbPath: string): LogStore {
|
||||
return out;
|
||||
}
|
||||
|
||||
function getThreadStartMessage(runId: string): ThreadRoundRow | null {
|
||||
const row = getThreadStartMessageStmt.get(runId) as
|
||||
| { id: number; timestamp: number; payload: string | null }
|
||||
| undefined;
|
||||
if (row === undefined || row.payload === null) return null;
|
||||
const message = parseRoundPayload(row.payload, row.timestamp);
|
||||
if (message === null) return null;
|
||||
return { round: 0, logId: row.id, timestamp: row.timestamp, message };
|
||||
}
|
||||
|
||||
function archiveDayTx(day: string, start: number, endExclusive: number): void {
|
||||
runInTransaction(sqlite, () => {
|
||||
deleteLogsForDayStmt.run({ start, endExclusive });
|
||||
@@ -743,6 +767,7 @@ export function createLogStore(dbPath: string): LogStore {
|
||||
getThreadMessages,
|
||||
getThreadRoundCount,
|
||||
getThreadRounds,
|
||||
getThreadStartMessage,
|
||||
archiveLogs,
|
||||
close,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ export { createCursorRole } from "./role-cursor.js";
|
||||
export { createHermesRole } from "./role-hermes.js";
|
||||
export { createLlmRole } from "./role-llm.js";
|
||||
export { createReActRole } from "./role-react.js";
|
||||
export { cursorAgent } from "./shared/cursor-agent.js";
|
||||
export { llmExtract } from "./shared/llm-extract.js";
|
||||
export {
|
||||
nerveAgentContext,
|
||||
readNerveYaml,
|
||||
|
||||
Reference in New Issue
Block a user