diff --git a/packages/cli/src/__tests__/workflow.test.ts b/packages/cli/src/__tests__/workflow.test.ts index 438e844..6cc74b0 100644 --- a/packages/cli/src/__tests__/workflow.test.ts +++ b/packages/cli/src/__tests__/workflow.test.ts @@ -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); }); diff --git a/packages/cli/src/commands/thread.ts b/packages/cli/src/commands/thread.ts index 81ddd44..d19cd4b 100644 --- a/packages/cli/src/commands/thread.ts +++ b/packages/cli/src/commands/thread.ts @@ -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) { diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 5613a36..a0229b0 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -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 }; } // --------------------------------------------------------------------------- diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index 44a1bf0..6d09d15 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -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(() => []), diff --git a/packages/daemon/src/__tests__/hot-reload.test.ts b/packages/daemon/src/__tests__/hot-reload.test.ts index cc2edc6..708c0aa 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -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(() => []), diff --git a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts index 426066e..dc1234c 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -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(), }; diff --git a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts index 49ff165..531a942 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -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(), }; diff --git a/packages/daemon/src/__tests__/workflow-manager.test.ts b/packages/daemon/src/__tests__/workflow-manager.test.ts index 8ff80f2..3d56fbe 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -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(() => []), diff --git a/packages/store/src/__tests__/log-store-crash-recovery.test.ts b/packages/store/src/__tests__/log-store-crash-recovery.test.ts index e784fc1..71adaa1 100644 --- a/packages/store/src/__tests__/log-store-crash-recovery.test.ts +++ b/packages/store/src/__tests__/log-store-crash-recovery.test.ts @@ -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(); + }); }); }); diff --git a/packages/store/src/log-store.ts b/packages/store/src/log-store.ts index 66e2070..88b40a1 100644 --- a/packages/store/src/log-store.ts +++ b/packages/store/src/log-store.ts @@ -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, }; diff --git a/packages/workflow-utils/src/index.ts b/packages/workflow-utils/src/index.ts index 772a5ec..a0fd111 100644 --- a/packages/workflow-utils/src/index.ts +++ b/packages/workflow-utils/src/index.ts @@ -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,