From dcfb00128ddc2c15be022ba643a7864a14c87f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Fri, 24 Apr 2026 05:59:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20add=20nerve=20workflow=20thread=20?= =?UTF-8?q?=20command=20=E2=80=94=20closes=20#77?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the workflow thread CLI command that retrieves workflow execution context (logs, events, state) for a given run. - Add 'nerve workflow thread ' subcommand - Add log-store query API in daemon - Add tests for CLI and log-store - Export new daemon types for thread data 小橘 --- packages/cli/package.json | 3 +- packages/cli/src/__tests__/workflow.test.ts | 97 +++++++- packages/cli/src/commands/workflow.ts | 213 +++++++++++++++++- packages/cli/src/daemon-types.ts | 16 ++ packages/daemon/rslib.config.ts | 1 + .../src/__tests__/crash-recovery.test.ts | 2 + .../daemon/src/__tests__/hot-reload.test.ts | 2 + .../__tests__/kernel-trigger-sense.test.ts | 2 + .../kernel-workflow-integration.test.ts | 2 + .../log-store-crash-recovery.test.ts | 61 +++++ .../src/__tests__/workflow-manager.test.ts | 2 + packages/daemon/src/index.ts | 2 + packages/daemon/src/log-store.ts | 96 ++++++++ pnpm-lock.yaml | 3 + uncaged-nerve-cli-0.2.0.tgz | Bin 0 -> 21079 bytes 15 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 uncaged-nerve-cli-0.2.0.tgz diff --git a/packages/cli/package.json b/packages/cli/package.json index ca1a142..332bd91 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,8 @@ }, "dependencies": { "@uncaged/nerve-core": "workspace:*", - "citty": "^0.1.6" + "citty": "^0.1.6", + "yaml": "^2.8.3" }, "devDependencies": { "@rslib/core": "^0.21.3", diff --git a/packages/cli/src/__tests__/workflow.test.ts b/packages/cli/src/__tests__/workflow.test.ts index aca698d..485d613 100644 --- a/packages/cli/src/__tests__/workflow.test.ts +++ b/packages/cli/src/__tests__/workflow.test.ts @@ -18,13 +18,17 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildInspectOutput, buildListOutput, + buildThreadCommandOutput, + DEFAULT_THREAD_BUDGET_CHARS, + formatThreadRoundBlock, formatTs, getAllWorkflowRuns, + partitionCommandEvent, parseIntArg, statusIcon, } from "../commands/workflow.js"; import { triggerWorkflowViaDaemon } from "../daemon-client.js"; -import type { LogStore, WorkflowRun } from "../daemon-types.js"; +import type { LogStore, ThreadRoundRow, WorkflowRun } from "../daemon-types.js"; // --------------------------------------------------------------------------- // Test helpers @@ -322,6 +326,97 @@ describe("workflow list — integration with real store", () => { }); }); +// --------------------------------------------------------------------------- +// nerve workflow thread — formatting helpers +// --------------------------------------------------------------------------- + +describe("partitionCommandEvent", () => { + it("splits reserved type, role, content from rest", () => { + const p = partitionCommandEvent({ + type: "scan_done", + role: "scanner", + content: "ok", + items: [1, 2], + }); + expect(p.typeStr).toBe("scan_done"); + expect(p.roleStr).toBe("scanner"); + expect(p.contentBody).toBe("ok"); + expect(p.rest).toEqual({ items: [1, 2] }); + }); + + it("uses fallback role and stringifies non-string content", () => { + const p = partitionCommandEvent({ type: "x", content: { n: 1 } }); + expect(p.roleStr).toBe("?"); + expect(p.contentBody).toBe('{"n":1}'); + }); +}); + +describe("formatThreadRoundBlock", () => { + const row: ThreadRoundRow = { + round: 2, + logId: 99, + ts: new Date("2026-01-02T03:04:05.006Z").getTime(), + event: { type: "reply", role: "bot", content: "hi", score: 0.5 }, + }; + + it("includes header, YAML frontmatter for rest, and body", () => { + const text = formatThreadRoundBlock(row); + expect(text).toContain("[#2 bot]"); + expect(text).toContain("type=reply"); + expect(text).toContain("---\n"); + expect(text).toContain("score: 0.5"); + expect(text).toContain("hi"); + expect(text).not.toContain("role:"); + }); +}); + +describe("buildThreadCommandOutput", () => { + function row(n: number, content: string): ThreadRoundRow { + return { + round: n, + logId: 10 + n, + ts: 1000 + n, + event: { type: "ev", role: "r", content, extra: n }, + }; + } + + it("orders rounds chronologically (oldest first in output)", () => { + const desc = [row(3, "ccc"), row(2, "bbb"), row(1, "aaa")]; + const prefix = ["HEADER\n"]; + const { lines, paginationHint } = buildThreadCommandOutput(prefix, desc, 50_000, "run-x"); + const text = lines.join(""); + const idxA = text.indexOf("\naaa\n"); + const idxB = text.indexOf("\nbbb\n"); + const idxC = text.indexOf("\nccc\n"); + expect(idxA).toBeGreaterThan(-1); + expect(idxB).toBeGreaterThan(idxA); + expect(idxC).toBeGreaterThan(idxB); + expect(paginationHint).toBeNull(); + }); + + it("emits pagination hint with --before when oldest shown round is still > 1", () => { + const desc = [row(4, "d"), row(3, "c")]; + const { paginationHint } = buildThreadCommandOutput([], desc, 50_000, "run-y"); + expect(paginationHint).toContain("--before 3"); + expect(paginationHint).toContain("run-y"); + }); + + it("respects budget and hints with non-default --budget in command", () => { + const big = "y".repeat(500); + const desc = [row(2, big), row(1, "a")]; + const { lines, paginationHint } = buildThreadCommandOutput([], desc, 400, "run-z"); + const text = lines.join(""); + expect(text).toContain("[#2"); + expect(text).not.toContain("[#1"); + expect(paginationHint).toContain("--before 2"); + expect(paginationHint).toContain("--budget 400"); + }); + + it("default budget constant matches workflow command default", () => { + expect(DEFAULT_THREAD_BUDGET_CHARS).toBe(8000); + }); +}); + // --------------------------------------------------------------------------- // parseIntArg // --------------------------------------------------------------------------- diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 81c8d40..e7386e9 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -2,14 +2,21 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { defineCommand } from "citty"; +import { stringify } from "yaml"; import { triggerWorkflowViaDaemon } from "../daemon-client.js"; -import type { LogStore, WorkflowRun } from "../daemon-types.js"; +import type { LogStore, ThreadRoundRow, WorkflowRun } from "../daemon-types.js"; import { loadDaemonModule } from "../workspace-daemon.js"; import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js"; export const DEFAULT_PAGE_SIZE = 20; +/** Default max characters for `nerve workflow thread` output (including run header). */ +export const DEFAULT_THREAD_BUDGET_CHARS = 8000; + +/** Max role-round rows read from SQLite per invocation (DESC by round). */ +export const THREAD_ROUNDS_FETCH_LIMIT = 8192; + export function parseIntArg(raw: string, fallback: number): number { const v = Number.parseInt(raw, 10); return Number.isNaN(v) ? fallback : v; @@ -172,6 +179,123 @@ export function buildInspectOutput( return { header, eventLines, paginationHint }; } +// --------------------------------------------------------------------------- +// nerve workflow thread — agent-oriented role rounds +// --------------------------------------------------------------------------- + +export type PartitionedEvent = { + typeStr: string; + roleStr: string; + contentBody: string; + rest: Record; +}; + +/** + * Split a CommandEvent: `type`, `role`, and `content` are reserved for the + * header / body; all other fields are serialized as YAML frontmatter. + */ +export function partitionCommandEvent(event: Record): PartitionedEvent { + const typeStr = + typeof event.type === "string" ? event.type : String(event.type === undefined ? "?" : event.type); + const roleStr = typeof event.role === "string" ? event.role : "?"; + const contentRaw = event.content; + const contentBody = + contentRaw === undefined || contentRaw === null + ? "" + : typeof contentRaw === "string" + ? contentRaw + : JSON.stringify(contentRaw); + const rest: Record = {}; + for (const key of Object.keys(event)) { + if (key === "type" || key === "role" || key === "content") continue; + rest[key] = event[key]; + } + return { typeStr, roleStr, contentBody, rest }; +} + +/** + * One role round as plain text: header line, YAML frontmatter (`rest` only), body (`content`). + */ +export function formatThreadRoundBlock(row: ThreadRoundRow): string { + const { typeStr, roleStr, contentBody, rest } = partitionCommandEvent(row.event); + const yamlBlock = + Object.keys(rest).length === 0 ? "{}\n" : `${stringify(rest, { lineWidth: 100 })}\n`; + return ( + `[#${row.round} ${roleStr}] ${formatTs(row.ts)} type=${typeStr}\n` + + `---\n` + + yamlBlock + + `---\n` + + `${contentBody}\n\n` + ); +} + +export type ThreadCommandOutput = { + lines: string[]; + paginationHint: string | null; +}; + +/** + * Build stdout lines for `nerve workflow thread`: newest-first selection from + * `descRows` until `budgetChars` (including `prefixLines`), then chronological order. + */ +export function buildThreadCommandOutput( + prefixLines: string[], + descRows: ThreadRoundRow[], + budgetChars: number, + runId: string, +): ThreadCommandOutput { + const prefixText = prefixLines.join(""); + let remaining = Math.max(0, budgetChars - prefixText.length); + const picked: ThreadRoundRow[] = []; + + const budgetFlag = + budgetChars === DEFAULT_THREAD_BUDGET_CHARS ? "" : ` --budget ${String(budgetChars)}`; + + for (const row of descRows) { + const block = formatThreadRoundBlock(row); + if (block.length <= remaining) { + picked.push(row); + remaining -= block.length; + continue; + } + if (picked.length === 0) { + const { typeStr, roleStr, contentBody, rest } = partitionCommandEvent(row.event); + const yamlBlock = + Object.keys(rest).length === 0 + ? "{}\n" + : `${stringify(rest, { lineWidth: 100 })}\n`; + const header = + `[#${row.round} ${roleStr}] ${formatTs(row.ts)} type=${typeStr}\n` + `---\n` + yamlBlock + `---\n`; + const maxBody = Math.max(0, remaining - header.length - `[truncated]\n`.length); + const truncated = + maxBody > 0 && contentBody.length > maxBody + ? `${contentBody.slice(0, maxBody)}\n[truncated]\n` + : `${contentBody}\n[truncated]\n`; + const single = header + truncated + "\n"; + const hintRound = row.round; + return { + lines: [...prefixLines, single], + paginationHint: + hintRound > 1 + ? `\n⏩ Older rounds exist. Fetch with:\n nerve workflow thread ${runId} --before ${String(hintRound)}${budgetFlag}\n` + : null, + }; + } + break; + } + + const blocksAsc = picked.map(formatThreadRoundBlock).reverse(); + const shownMinRound = picked.length === 0 ? null : Math.min(...picked.map((r) => r.round)); + let paginationHint: string | null = null; + if (shownMinRound !== null && shownMinRound > 1) { + paginationHint = + `\n⏩ Older rounds not shown. Fetch with:\n` + + ` nerve workflow thread ${runId} --before ${String(shownMinRound)}${budgetFlag}\n`; + } + + return { lines: [...prefixLines, ...blocksAsc], paginationHint }; +} + // --------------------------------------------------------------------------- // nerve workflow list // --------------------------------------------------------------------------- @@ -293,6 +417,92 @@ const workflowInspectCommand = defineCommand({ }, }); +// --------------------------------------------------------------------------- +// nerve workflow thread +// --------------------------------------------------------------------------- + +const workflowThreadCommand = defineCommand({ + meta: { + name: "thread", + description: "Print role rounds for a workflow run (agent-oriented, budget-limited)", + }, + args: { + runId: { + type: "positional", + description: "The run ID to dump role rounds for", + }, + before: { + type: "string", + description: + "Exclusive upper bound on 1-based round index (use with hint from prior output to load older rounds)", + default: "0", + }, + budget: { + type: "string", + description: `Max output characters including header (default: ${String(DEFAULT_THREAD_BUDGET_CHARS)})`, + default: String(DEFAULT_THREAD_BUDGET_CHARS), + }, + }, + async run({ args }) { + const store = await openStore(); + + try { + const before = Math.max(0, parseIntArg(args.before, 0)); + const budgetChars = Math.max(1, parseIntArg(args.budget, DEFAULT_THREAD_BUDGET_CHARS)); + + const run = store.getWorkflowRun(args.runId); + if (run === null) { + process.stderr.write(`❌ No workflow run found with runId: ${args.runId}\n`); + process.exit(1); + } + + const totalRoleRounds = store.getThreadRoundCount(args.runId); + if (totalRoleRounds === 0) { + 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`, + ); + return; + } + + const descRows = store.getThreadRounds(args.runId, { + before, + limit: THREAD_ROUNDS_FETCH_LIMIT, + }); + + const prefixLines = [ + `🧵 Role rounds (workflow thread)\n`, + ` runId: ${run.runId}\n`, + ` workflow: ${run.workflow}\n`, + ` status: ${run.status}\n`, + ` rounds: ${String(totalRoleRounds)} role event(s) total\n\n`, + ]; + + const { lines, paginationHint } = buildThreadCommandOutput( + prefixLines, + descRows, + budgetChars, + args.runId, + ); + + for (const line of lines) { + process.stdout.write(line); + } + if (paginationHint !== null) { + process.stdout.write(paginationHint); + } + + if (descRows.length === 0 && before > 0) { + process.stdout.write(`\n📭 No rounds with index < ${String(before)}.\n`); + } + } finally { + store.close(); + } + }, +}); + // --------------------------------------------------------------------------- // nerve workflow trigger // --------------------------------------------------------------------------- @@ -359,6 +569,7 @@ export const workflowCommand = defineCommand({ subCommands: { list: workflowListCommand, inspect: workflowInspectCommand, + thread: workflowThreadCommand, trigger: workflowTriggerCommand, }, }); diff --git a/packages/cli/src/daemon-types.ts b/packages/cli/src/daemon-types.ts index f03854a..edb2fc2 100644 --- a/packages/cli/src/daemon-types.ts +++ b/packages/cli/src/daemon-types.ts @@ -58,6 +58,20 @@ export type ArchiveLogsResult = { vacuumed: boolean; }; +/** One role round row — keep in sync with daemon `log-store` `ThreadRoundRow`. */ +export type ThreadRoundRow = { + round: number; + logId: number; + ts: number; + event: { type: string; [key: string]: unknown }; +}; + +/** Keep in sync with daemon `log-store` `GetThreadRoundsParams`. */ +export type GetThreadRoundsParams = { + before: number; + limit: number; +}; + /** Subset of daemon LogStore used by the CLI workflow commands. */ export type LogStore = { query: (filter?: LogQuery) => LogEntry[]; @@ -65,6 +79,8 @@ export type LogStore = { getActiveWorkflowRuns: (workflowName?: string) => WorkflowRun[]; getAllWorkflowRuns: (workflowName: string | null) => WorkflowRun[]; upsertWorkflowRun: (entry: Omit, run: WorkflowRun) => LogEntry; + getThreadRoundCount: (runId: string) => number; + getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[]; archiveLogs: (options?: ArchiveLogsOptions) => ArchiveLogsResult; close: () => void; }; diff --git a/packages/daemon/rslib.config.ts b/packages/daemon/rslib.config.ts index 216ba20..7a26194 100644 --- a/packages/daemon/rslib.config.ts +++ b/packages/daemon/rslib.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ entry: { index: "src/index.ts", "sense-worker": "src/sense-worker.ts", + "workflow-worker": "src/workflow-worker.ts", }, }, output: { diff --git a/packages/daemon/src/__tests__/crash-recovery.test.ts b/packages/daemon/src/__tests__/crash-recovery.test.ts index b4eea4c..52f99f5 100644 --- a/packages/daemon/src/__tests__/crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/crash-recovery.test.ts @@ -91,6 +91,8 @@ function makeLogStore( }), getTriggerPayload: vi.fn((): unknown => ({ value: 42 })), getThreadEvents: vi.fn((): Array<{ type: string; [key: string]: unknown }> => [{ type: "thread_start", triggerPayload: {} }]), + getThreadRoundCount: vi.fn(() => 0), + getThreadRounds: vi.fn(() => []), 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 0d56a24..35e5071 100644 --- a/packages/daemon/src/__tests__/hot-reload.test.ts +++ b/packages/daemon/src/__tests__/hot-reload.test.ts @@ -77,6 +77,8 @@ function makeLogStore() { getActiveWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadRoundCount: vi.fn(() => 0), + getThreadRounds: vi.fn(() => []), 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 0b4bc19..b87be6a 100644 --- a/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts +++ b/packages/daemon/src/__tests__/kernel-trigger-sense.test.ts @@ -74,6 +74,8 @@ function makeMockLogStore() { getAllWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadRoundCount: vi.fn(() => 0), + getThreadRounds: vi.fn(() => []), 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 da6c09c..4a8b19a 100644 --- a/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts +++ b/packages/daemon/src/__tests__/kernel-workflow-integration.test.ts @@ -81,6 +81,8 @@ function makeLogStore() { getAllWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadRoundCount: vi.fn(() => 0), + getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), close: vi.fn(), }; diff --git a/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts b/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts index 46e4de5..7145b98 100644 --- a/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts +++ b/packages/daemon/src/__tests__/log-store-crash-recovery.test.ts @@ -195,4 +195,65 @@ describe("LogStore — crash recovery helpers (Phase 3)", () => { expect(result8[0].type).toBe("event_for_8"); }); }); + + describe("getThreadRoundCount / getThreadRounds", () => { + it("excludes thread_start from rounds and assigns ROW_NUMBER in chronological order", () => { + store.append({ + source: "workflow", + type: "thread_command_event", + refId: "run-tr", + payload: JSON.stringify({ type: "thread_start", triggerPayload: { x: 1 } }), + ts: 100, + }); + store.append({ + source: "workflow", + type: "thread_command_event", + refId: "run-tr", + payload: JSON.stringify({ + type: "step_a", + role: "alpha", + content: "hello", + meta: 1, + }), + ts: 101, + }); + store.append({ + source: "workflow", + type: "thread_command_event", + refId: "run-tr", + payload: JSON.stringify({ type: "step_b", role: "beta", content: "world" }), + ts: 102, + }); + + expect(store.getThreadRoundCount("run-tr")).toBe(2); + + const all = store.getThreadRounds("run-tr", { before: 0, limit: 50 }); + expect(all).toHaveLength(2); + expect(all.map((r) => r.round)).toEqual([2, 1]); + expect(all[0].event.type).toBe("step_b"); + expect(all[1].event.type).toBe("step_a"); + }); + + it("getThreadRounds respects exclusive before bound", () => { + for (let i = 0; i < 3; i++) { + store.append({ + source: "workflow", + type: "thread_command_event", + refId: "run-b4", + payload: JSON.stringify({ type: `ev_${i}`, role: "r", content: String(i) }), + ts: 200 + i, + }); + } + + expect(store.getThreadRoundCount("run-b4")).toBe(3); + + const page = store.getThreadRounds("run-b4", { before: 3, limit: 50 }); + expect(page.map((r) => r.round)).toEqual([2, 1]); + }); + + it("returns empty when no role rounds for runId", () => { + expect(store.getThreadRoundCount("missing")).toBe(0); + expect(store.getThreadRounds("missing", { before: 0, limit: 10 })).toHaveLength(0); + }); + }); }); diff --git a/packages/daemon/src/__tests__/workflow-manager.test.ts b/packages/daemon/src/__tests__/workflow-manager.test.ts index 8fb3a50..7227f57 100644 --- a/packages/daemon/src/__tests__/workflow-manager.test.ts +++ b/packages/daemon/src/__tests__/workflow-manager.test.ts @@ -74,6 +74,8 @@ function makeLogStore() { getActiveWorkflowRuns: vi.fn(() => []), getTriggerPayload: vi.fn(() => null), getThreadEvents: vi.fn(() => []), + getThreadRoundCount: vi.fn(() => 0), + getThreadRounds: vi.fn(() => []), archiveLogs: vi.fn(() => ({ days: [], vacuumed: false })), close: vi.fn(), getAllWorkflowRuns: vi.fn(() => []), diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 4d67c94..be51c7b 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -47,6 +47,8 @@ export type { ArchiveLogsDayResult, ArchiveLogsOptions, ArchiveLogsResult, + ThreadRoundRow, + GetThreadRoundsParams, } from "./log-store.js"; export { createWorkflowManager } from "./workflow-manager.js"; diff --git a/packages/daemon/src/log-store.ts b/packages/daemon/src/log-store.ts index 229f5b0..e197085 100644 --- a/packages/daemon/src/log-store.ts +++ b/packages/daemon/src/log-store.ts @@ -83,6 +83,25 @@ export type WorkflowRun = { ts: number; }; +/** One role-produced command-event row with 1-based round index (ROW_NUMBER over role events only). */ +export type ThreadRoundRow = { + round: number; + logId: number; + ts: number; + event: { type: string; [key: string]: unknown }; +}; + +/** Parameters for {@link LogStore.getThreadRounds}. */ +export type GetThreadRoundsParams = { + /** + * Exclusive upper bound on round index (1-based among role events). + * Use `0` to include all rounds (subject to `limit`). + */ + before: number; + /** Maximum rows returned from the DB (DESC by round). */ + limit: number; +}; + export type LogStore = { append: (entry: Omit) => LogEntry; query: (filter?: LogQuery) => LogEntry[]; @@ -120,6 +139,17 @@ export type LogStore = { * Used for crash recovery to rebuild ThreadState. */ getThreadEvents: (runId: string) => Array<{ type: string; [key: string]: unknown }>; + /** + * Count role command events for a run (excludes `thread_start` and invalid payloads). + * Round indices for {@link getThreadRounds} are 1..count in chronological order. + */ + getThreadRoundCount: (runId: string) => number; + /** + * Role rounds for agent-oriented retrieval: each row is one `thread_command_event` + * whose JSON `type` is not `thread_start`, with `round` from ROW_NUMBER() OVER (ORDER BY id ASC). + * No schema migration — numbering is computed in SQL. + */ + getThreadRounds: (runId: string, params: GetThreadRoundsParams) => ThreadRoundRow[]; /** * 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 @@ -279,6 +309,28 @@ export function createLogStore(dbPath: string): LogStore { "SELECT payload FROM logs WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = ? ORDER BY id ASC", ); + const getThreadRoundCountStmt = sqlite.prepare( + `SELECT COUNT(*) AS c FROM logs + WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = ? + AND payload IS NOT NULL AND json_valid(payload) = 1 + AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start'`, + ); + + const getThreadRoundsStmt = sqlite.prepare( + `WITH numbered AS ( + SELECT id, ts, payload, + ROW_NUMBER() OVER (ORDER BY id ASC) AS rn + FROM logs + WHERE source = 'workflow' AND type = 'thread_command_event' AND ref_id = @runId + AND payload IS NOT NULL AND json_valid(payload) = 1 + AND COALESCE(json_extract(payload, '$.type'), '') != 'thread_start' + ) + SELECT id, ts, payload, rn FROM numbered + WHERE (@before = 0 OR rn < @before) + ORDER BY rn DESC + LIMIT @lim`, + ); + const getActiveWorkflowRunsStmt = sqlite.prepare( "SELECT run_id, workflow, status, ts FROM workflow_runs WHERE status IN ('queued', 'started') ORDER BY ts ASC", ); @@ -475,6 +527,48 @@ export function createLogStore(dbPath: string): LogStore { return result; } + function getThreadRoundCount(runId: string): number { + const row = getThreadRoundCountStmt.get(runId) as { c: number } | undefined; + const c = row?.c; + if (c === null || c === undefined) return 0; + return Number(c); + } + + function getThreadRounds(runId: string, params: GetThreadRoundsParams): ThreadRoundRow[] { + const before = params.before; + const lim = params.limit; + if (lim < 1) return []; + + const rows = getThreadRoundsStmt.all({ + runId, + before, + lim, + }) as Array<{ id: number; ts: number; payload: string | null; rn: number }>; + + const out: ThreadRoundRow[] = []; + for (const row of rows) { + if (row.payload === null) continue; + try { + const parsed = JSON.parse(row.payload) as unknown; + if ( + parsed !== null && + typeof parsed === "object" && + typeof (parsed as Record).type === "string" + ) { + out.push({ + round: row.rn, + logId: row.id, + ts: row.ts, + event: parsed as { type: string; [key: string]: unknown }, + }); + } + } catch { + // skip malformed payloads + } + } + return out; + } + function archiveDayTx(day: string, start: number, endExclusive: number): void { runInTransaction(sqlite, () => { deleteLogsForDayStmt.run({ start, endExclusive }); @@ -539,6 +633,8 @@ export function createLogStore(dbPath: string): LogStore { getAllWorkflowRuns, getTriggerPayload, getThreadEvents, + getThreadRoundCount, + getThreadRounds, archiveLogs, close, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eae9647..b83ffc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: citty: specifier: ^0.1.6 version: 0.1.6 + yaml: + specifier: ^2.8.3 + version: 2.8.3 devDependencies: '@rslib/core': specifier: ^0.21.3 diff --git a/uncaged-nerve-cli-0.2.0.tgz b/uncaged-nerve-cli-0.2.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..6c0373eb285ed3190415d8203fa4e7e5ff2d1968 GIT binary patch literal 21079 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