From d8f0b1a268dd9d887c52a8480e852cfa3769c568 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 14 Jul 2026 08:17:36 -0700 Subject: [PATCH] Restore PostgreSQL integration parity (#2089) ## Summary - add asynchronous PostgreSQL parity to research commands and engine execution paths - persist Roadmap, Compound Engineering sessions, and WhatsApp state in PostgreSQL - harden cancellation, concurrency, reconnect, replay-claim, and detached-promise behavior - bundle the PostgreSQL-backed integration implementations in the published CLI This is PR 2 of 2 and is intentionally stacked on #2088. It contains 44 changed files; merge #2088 first, then retarget this PR to `main` if GitHub does not do so automatically. ## Verification - `pnpm check:changesets --strict` - `pnpm lint` - `pnpm test:gate`: 463 tests passed - Compound Engineering plugin: 299 tests passed - Roadmap plugin: 144 tests passed - WhatsApp plugin: 27 tests passed - research CLI: 18 tests passed - `pnpm verify:fast`: all scoped typechecks, builds, CLI build, and boot smoke passed ## Post-Deploy Monitoring & Validation - deploy only after #2088 and verify schema migration `0002` is present - monitor research cancellation, automation claims, agent execution, plugin schema initialization, and unhandled rejections - validate Roadmap ownership, Compound Engineering session recovery, and WhatsApp reconnect/replay deduplication - compare per-project plugin and workflow counts after cutover - restore the pre-deploy backup for data rollback; avoid an in-place schema downgrade --- .changeset/postgres-integration-parity.md | 7 + .../src/commands/__tests__/research.test.ts | 18 + packages/cli/src/commands/db.ts | 96 ++- packages/cli/src/commands/research.ts | 79 ++- packages/cli/tsup.config.ts | 6 +- .../routes/register-agent-runtime-routes.ts | 3 +- .../executor-review-verdicts.test.ts | 3 +- .../src/__tests__/heartbeat-executor.test.ts | 18 +- packages/engine/src/__tests__/triage.test.ts | 2 + packages/engine/src/agent-heartbeat.ts | 2 +- packages/engine/src/agent-tools.ts | 77 ++- packages/engine/src/cron-runner.ts | 5 +- packages/engine/src/tool-availability.ts | 4 +- .../src/__tests__/_harness.ts | 43 +- .../src/__tests__/orchestrator-cancel.test.ts | 40 +- .../orchestrator-executor-seam.test.ts | 10 +- .../orchestrator-interrupt-resume.test.ts | 130 ++-- .../orchestrator-live-output.test.ts | 201 +++++- .../src/__tests__/pg-test-harness.d.ts | 6 + .../src/__tests__/pipeline-store.pg.test.ts | 67 +- .../src/__tests__/session-routes.test.ts | 138 ++-- .../src/__tests__/stage-launch-guard.test.ts | 10 +- .../src/__tests__/stage-skill-loading.test.ts | 38 +- .../src/index.ts | 2 +- .../src/routes/session-routes.ts | 18 +- .../src/session/orchestrator.ts | 326 ++++++--- .../src/session/session-recovery.ts | 6 +- .../src/session/session-store.ts | 152 ++++- .../vitest.config.ts | 24 +- plugins/fusion-plugin-roadmap/package.json | 1 + .../src/__tests__/roadmap-store.pg.test.ts | 607 +++++++++++++++++ .../src/routes/roadmap-routes.ts | 86 +-- .../src/store/async-roadmap-store.ts | 619 ++++++++++++++++++ .../fusion-plugin-whatsapp-chat/package.json | 2 + .../src/__tests__/auth-state.test.ts | 73 ++- .../src/__tests__/connection.test.ts | 213 +++++- .../src/__tests__/index.test.ts | 9 + .../src/__tests__/persistence.pg.test.ts | 97 +++ .../src/auth-state.ts | 73 +-- .../src/connection.ts | 235 +++++-- .../fusion-plugin-whatsapp-chat/src/index.ts | 64 +- .../src/persistence.ts | 276 ++++++++ .../fusion-plugin-whatsapp-chat/tsconfig.json | 3 +- pnpm-lock.yaml | 206 +++++- 44 files changed, 3459 insertions(+), 636 deletions(-) create mode 100644 .changeset/postgres-integration-parity.md create mode 100644 plugins/fusion-plugin-compound-engineering/src/__tests__/pg-test-harness.d.ts create mode 100644 plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts create mode 100644 plugins/fusion-plugin-roadmap/src/store/async-roadmap-store.ts create mode 100644 plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts create mode 100644 plugins/fusion-plugin-whatsapp-chat/src/persistence.ts diff --git a/.changeset/postgres-integration-parity.md b/.changeset/postgres-integration-parity.md new file mode 100644 index 0000000000..9f4c1ca40f --- /dev/null +++ b/.changeset/postgres-integration-parity.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore PostgreSQL persistence across bundled workflows and integrations. +category: fix +dev: Adds async engine, research, roadmap, Compound Engineering, and WhatsApp PostgreSQL parity. diff --git a/packages/cli/src/commands/__tests__/research.test.ts b/packages/cli/src/commands/__tests__/research.test.ts index 26830f071b..41527cfce7 100644 --- a/packages/cli/src/commands/__tests__/research.test.ts +++ b/packages/cli/src/commands/__tests__/research.test.ts @@ -156,6 +156,24 @@ describe("research commands", () => { expect(researchStoreMock.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 }); }); + /* + FNXC:ResearchCliPostgres 2026-07-13-22:38: + The CLI must accept the PostgreSQL-backed research store's promise-returning API instead of requiring the legacy ResearchStore prototype. Awaiting both backends preserves the synchronous test path while proving PG list results reach the operator. + */ + it("lists runs through an async PostgreSQL research store", async () => { + const asyncStore = { + getRun: vi.fn(async () => mockRun), + listRuns: vi.fn(async () => [mockRun]), + createExport: vi.fn(async () => undefined), + }; + storeMock.getResearchStore.mockReturnValueOnce(asyncStore as never); + + await runResearchList({ json: true, status: "completed", limit: 3 }); + + expect(asyncStore.listRuns).toHaveBeenCalledWith({ status: "completed", limit: 3 }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('"runs"')); + }); + it("rejects invalid list status", async () => { await expect(runResearchList({ status: "wat" })).rejects.toThrow("process.exit:1"); expect(errorSpy).toHaveBeenCalledWith("Error: Invalid status: wat"); diff --git a/packages/cli/src/commands/db.ts b/packages/cli/src/commands/db.ts index bd502d1c28..55f31725b0 100644 --- a/packages/cli/src/commands/db.ts +++ b/packages/cli/src/commands/db.ts @@ -4,10 +4,12 @@ import { vacuumAnalyze, resolveBackend, migrateSqliteToPostgres, + completeSqliteMigration, defaultMigrationSources, stampMigratedProjectRows, lookupRegisteredProjectIdByPath, resolveGlobalDir, + DatabaseSync, type MigrationReport, } from "@fusion/core"; import { resolveProject } from "../project-context.js"; @@ -230,10 +232,52 @@ export async function runDbMigrate( } // 5. Run the migrator. + /* + * FNXC:PostgresMigration 2026-07-14-00:05: + * Manual cutover must know the central project identity before copying any + * project-owned table. Resolve it from the target registry or the legacy + * central source and fail closed when ownership is ambiguous; post-copy + * stamping cannot repair plugin and automation rows that were imported + * without their required project partition. + */ + let registeredProjectId: string | undefined; + try { + registeredProjectId = await lookupRegisteredProjectIdByPath(connections.migration, projectRoot); + } catch { + // A fresh target has no schema until the migrator applies the baseline. + } + if (!registeredProjectId) { + const centralSource = presentSources.find((source) => source.pgSchema === "central"); + if (centralSource) { + const legacyCentral = new DatabaseSync(centralSource.sqlitePath); + try { + registeredProjectId = (legacyCentral + .prepare("SELECT id FROM projects WHERE path = ? LIMIT 1") + .get(projectRoot) as { id?: string } | undefined)?.id; + } catch { + registeredProjectId = undefined; + } finally { + legacyCentral.close(); + } + } + } + if (!registeredProjectId) { + console.error( + `fn db migrate: cannot resolve project ownership for "${projectRoot}". ` + + "Register exactly this project in the legacy or PostgreSQL central registry before migrating.", + ); + await connections.close().catch(() => undefined); + process.exit(1); + return; + } + let report: MigrationReport; try { report = await migrateSqliteToPostgres(connections.migration, presentSources, { dryRun, + projectId: registeredProjectId, + migrationKey: `project:${registeredProjectId}`, + deferCompletion: true, }); } catch (error) { console.error(`fn db migrate: migration failed: ${(error as Error).message}`); @@ -244,45 +288,29 @@ export async function runDbMigrate( /* * FNXC:CentralProjectIdentity 2026-07-13-23:10: - * The migrator (migrateSqliteToPostgres) is partition-unaware: it copies - * legacy rows verbatim, so migrated rows land with NULL project_id, a '' config - * key, and rootDir-path-keyed workflow settings — all invisible to bound - * readers (engine, dashboard project-store-resolver, configScope, - * workflow-settings resolver). The first-boot auto-migration stamps these; the - * manual `fn db migrate` path stamped NOTHING, so an operator cutover left the - * board/settings empty. Resolve the registered project id for this cwd by - * matching central.projects.path (the migration just populated central.projects, - * so query AFTER the copy) and re-key the migrated rows. If the project was - * never registered centrally, leave rows unstamped and tell the operator how to - * fix it (unregistered single-project setups use an unbound, unfiltered layer). + * The migrator now receives project identity before copying so every + * project-owned table, including plugins and automations, is partitioned at + * insert time. This final stamp remains necessary for legacy key shapes + * (NULL task/archive ids, empty config key, and rootDir-keyed workflow + * settings) and is a hard failure rather than an invisible partial cutover. */ if (!dryRun) { try { - const registeredProjectId = await lookupRegisteredProjectIdByPath( - connections.migration, - projectRoot, - ); - if (registeredProjectId) { - await stampMigratedProjectRows(connections.migration, { - projectId: registeredProjectId, - rootDir: projectRoot, - }); - console.log( - `fn db migrate: stamped migrated rows with central-registry project id "${registeredProjectId}" (tasks, archived tasks, config, workflow settings).`, - ); - } else { - console.warn( - `fn db migrate: WARNING — no registered project matches path "${projectRoot}" in central.projects; ` + - `migrated rows were left UNSTAMPED (NULL project_id / '' config key / rootDir-keyed workflow settings) ` + - `and will be invisible to project-bound readers. To fix: register the project (e.g. open it once via the ` + - `dashboard/CLI so it is added to central.projects), then re-run \`fn db migrate\` to stamp the rows.`, - ); + await stampMigratedProjectRows(connections.migration, { + projectId: registeredProjectId, + rootDir: projectRoot, + }); + if (report.tables.every((table) => table.skipped || table.verified)) { + await completeSqliteMigration(connections.migration, `project:${registeredProjectId}`); } - } catch (error) { - console.warn( - `fn db migrate: WARNING — post-migration row stamping failed: ${(error as Error).message}. ` + - `Migrated rows may be invisible to project-bound readers; re-run \`fn db migrate\` after confirming the project is registered.`, + console.log( + `fn db migrate: stamped migrated rows with central-registry project id "${registeredProjectId}" (tasks, archived tasks, config, workflow settings).`, ); + } catch (error) { + console.error(`fn db migrate: post-migration project stamping failed: ${(error as Error).message}`); + await connections.close().catch(() => undefined); + process.exit(1); + return; } } diff --git a/packages/cli/src/commands/research.ts b/packages/cli/src/commands/research.ts index fb8cf2a5e6..b2c37abf84 100644 --- a/packages/cli/src/commands/research.ts +++ b/packages/cli/src/commands/research.ts @@ -4,7 +4,6 @@ import { RESEARCH_EXPORT_FORMATS, RESEARCH_RUN_STATUSES, ResearchRunStatus, - ResearchStore, TaskStore, createTaskStoreForBackend, resolveResearchSettings, @@ -31,7 +30,7 @@ import { retryOnLock } from "../lock-retry.js"; * `runResearchCreate`'s non-`waitForCompletion` fire-and-forget branch, * which is intentionally exempted (see the FNXC comment at that call site): * `orchestrator.startRun(runId, query)` is not awaited and the background - * run continues to read/write the SAME store via `getSyncResearchStore(store)` + * run continues to read/write the SAME store via `store.getResearchStore()` * after this function returns — closing it there would truncate an * in-flight run. Discrete board/settings reads that gate run-critical * decisions (`getSettings()` in `getResearchRuntime`) and the `createExport` @@ -77,20 +76,10 @@ interface ResearchExportOptions extends ResearchCommandOptions { output?: string; } -// FNXC:ResearchStore 2026-06-27-12:45: -// The research CLI drives the sync EventEmitter ResearchStore + ResearchOrchestrator. -// In PG backend mode getResearchStore() returns the AsyncResearchStore (CRUD-only), so -// fail with a clean error (caught by handleError → exit 1) instead of mis-typing the -// orchestrator. AI research EXECUTION via the CLI stays unavailable in PG mode; the -// dashboard research routes remain the ported surface. -function getSyncResearchStore(taskStore: TaskStore): ResearchStore { - const resolved = taskStore.getResearchStore(); - if (!(resolved instanceof ResearchStore)) { - throw new Error("Research CLI is not available in PG backend mode."); - } - return resolved; -} - +/* +FNXC:ResearchCliPostgres 2026-07-13-22:38: +Research CLI execution, lifecycle commands, and exports must use the TaskStore-selected backend. Both ResearchStore and AsyncResearchStore expose the same API; callers await every operation so PostgreSQL promises and legacy synchronous returns preserve identical operator behavior. +*/ async function getStore(projectName?: string): Promise { const projectPath = projectName ? await resolveProjectPathOnly(projectName) : undefined; const rootDir = projectPath ?? process.cwd(); @@ -140,7 +129,7 @@ async function getResearchRuntime(store: TaskStore) { }); const orchestrator = new ResearchOrchestrator({ - store: getSyncResearchStore(store), + store: store.getResearchStore(), stepRunner, maxConcurrentRuns: resolved.limits.maxConcurrentRuns, }); @@ -217,7 +206,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise if (!options.waitForCompletion) { // Intentionally-long-lived branch — do NOT close `store` here (see // the function-level FNXC comment above). - const run = getSyncResearchStore(store).getRun(runId); + const run = await store.getResearchStore().getRun(runId); if (options.json) { jsonOut(run); } else { @@ -228,11 +217,7 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise } const maxWaitMs = Math.max(1_000, Math.min(options.maxWaitMs ?? 90_000, resolved.limits.maxDurationMs)); - const completed = await Promise.race([ - runPromise, - new Promise((resolveRun) => setTimeout(() => { - const latest = getSyncResearchStore(store!).getRun(runId); - resolveRun(latest ?? ({ + const fallbackRun = (): ResearchRun => ({ id: runId, query: options.query, status: "running", @@ -241,12 +226,36 @@ export async function runResearchCreate(options: ResearchCreateOptions): Promise tags: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - } as ResearchRun)); - }, maxWaitMs)), - ]); + }); + let timeout: ReturnType | undefined; + let didTimeout = false; + /* + FNXC:ResearchCliPostgres 2026-07-13-23:05: + The completion timeout must be cancelled when the run finishes first; otherwise its later asynchronous PostgreSQL read races the store close and can reject without a handler. If the timeout read itself fails, return the same running snapshot used when no persisted run is available. + */ + const timeoutResult = new Promise((resolveRun) => { + timeout = setTimeout(() => { + didTimeout = true; + void Promise.resolve(store!.getResearchStore().getRun(runId)) + .then((latest) => resolveRun(latest ?? fallbackRun())) + .catch(() => resolveRun(fallbackRun())); + }, maxWaitMs); + }); + let completed = await Promise.race([runPromise, timeoutResult]); + if (timeout) clearTimeout(timeout); - // `waitForCompletion` fully awaited (or timed out on) the run above, so - // unlike the fire-and-forget branch, it is safe to close here. + /* + FNXC:ResearchCliPostgres 2026-07-13-23:52: + A CLI completion timeout is also an ownership boundary: cancel and await the active orchestrator before closing its PostgreSQL pool. Closing the store while the run still persists phases can corrupt lifecycle state and surface late unhandled rejections. + */ + if (didTimeout) { + await orchestrator.cancelRun(runId); + await runPromise.catch(() => undefined); + completed = (await store.getResearchStore().getRun(runId)) ?? completed; + } + + // The run either completed or was cancelled and drained above, so unlike + // the fire-and-forget branch it is safe to close here. await closeStore(); if (options.json) { @@ -267,7 +276,7 @@ export async function runResearchList(options: ResearchListOptions = {}): Promis throw new Error(`Invalid status: ${options.status}`); } - const runs = getSyncResearchStore(store).listRuns({ + const runs = await store.getResearchStore().listRuns({ status: options.status as ResearchRunStatus | undefined, limit: options.limit ? Math.max(1, options.limit) : 20, }); @@ -294,7 +303,7 @@ export async function runResearchList(options: ResearchListOptions = {}): Promis export async function runResearchShow(runId: string, options: ResearchCommandOptions = {}): Promise { try { await withResolvedStore(options.projectName, async (store) => { - const run = getSyncResearchStore(store).getRun(runId); + const run = await store.getResearchStore().getRun(runId); if (!run) throw new Error(`Cited-research run not found: ${runId}`); if (options.json) { @@ -318,7 +327,7 @@ function renderMarkdown(run: ResearchRun): string { export async function runResearchExport(options: ResearchExportOptions): Promise { try { await withResolvedStore(options.projectName, async (store) => { - const run = getSyncResearchStore(store).getRun(options.runId); + const run = await store.getResearchStore().getRun(options.runId); if (!run) throw new Error(`Cited-research run not found: ${options.runId}`); const format = (options.format ?? "markdown") as ResearchExportFormat; @@ -334,7 +343,7 @@ export async function runResearchExport(options: ResearchExportOptions): Promise await writeFile(outputPath, content, "utf8"); await retryOnLock( - async () => getSyncResearchStore(store).createExport(run.id, format, content), + async () => store.getResearchStore().createExport(run.id, format, content), { id: run.id, action: "export research run" }, ); @@ -353,7 +362,7 @@ export async function runResearchExport(options: ResearchExportOptions): Promise export async function runResearchCancel(runId: string, options: ResearchCommandOptions = {}): Promise { try { await withResolvedStore(options.projectName, async (store) => { - const run = getSyncResearchStore(store).getRun(runId); + const run = await store.getResearchStore().getRun(runId); if (!run) throw new Error(`Cited-research run not found: ${runId}`); if (!["queued", "running", "cancelling", "retry_waiting"].includes(run.status)) { @@ -379,7 +388,7 @@ export async function runResearchCancel(runId: string, options: ResearchCommandO export async function runResearchRetry(runId: string, options: ResearchCommandOptions = {}): Promise { try { await withResolvedStore(options.projectName, async (store) => { - const existing = getSyncResearchStore(store).getRun(runId); + const existing = await store.getResearchStore().getRun(runId); if (!existing) throw new Error(`Cited-research run not found: ${runId}`); if (existing.status === "retry_exhausted" || existing.lifecycle?.errorCode === "RETRY_EXHAUSTED") { @@ -394,7 +403,7 @@ export async function runResearchRetry(runId: string, options: ResearchCommandOp // background execution in flight here — safe to close the store below. const { orchestrator } = await getResearchRuntime(store); const newRunId = await orchestrator.retryRun(runId); - const run = getSyncResearchStore(store).getRun(newRunId); + const run = await store.getResearchStore().getRun(newRunId); if (options.json) { jsonOut({ retryOf: runId, run }); diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts index ca2f5babcf..e8bb009835 100644 --- a/packages/cli/tsup.config.ts +++ b/packages/cli/tsup.config.ts @@ -328,8 +328,8 @@ const cliBuildConfig = { }, onSuccess: async () => { // FNXC:RuntimeStartupWiring 2026-06-24-11:15: - // Stage the PostgreSQL schema baseline (0000_initial.sql + meta) into - // dist/migrations so the schema applier can read it at runtime after + // FNXC:AutomationIsolation 2026-07-13-22:37: Stage the complete versioned PostgreSQL migration directory (including automation project isolation) into dist/migrations so existing installations upgrade before project cron runners start. + // Stage the PostgreSQL schema migrations into dist/migrations so the schema applier can read them at runtime after // @fusion/core is bundled into dist/bin.js. Without this, the PG boot // path fails with ENOENT for dist/migrations/0000_initial.sql. if (existsSync(pgMigrationsSrc)) { @@ -341,7 +341,7 @@ const cliBuildConfig = { console.log("Copied PostgreSQL migrations to dist/migrations/"); } else { console.warn( - `WARNING: PostgreSQL migrations source not found at ${pgMigrationsSrc}; DATABASE_URL boot will fail to apply the schema baseline.`, + `WARNING: PostgreSQL migrations source not found at ${pgMigrationsSrc}; DATABASE_URL boot will fail to apply schema migrations.`, ); } if (existsSync(desktopRuntimeDest)) { diff --git a/packages/dashboard/src/routes/register-agent-runtime-routes.ts b/packages/dashboard/src/routes/register-agent-runtime-routes.ts index 39bec8101d..c4157c8200 100644 --- a/packages/dashboard/src/routes/register-agent-runtime-routes.ts +++ b/packages/dashboard/src/routes/register-agent-runtime-routes.ts @@ -657,7 +657,8 @@ export function registerAgentRuntimeRoutes(ctx: ApiRoutesContext, deps: AgentRun -- FNXC:PostgresBackend 2026-06-27-00:40: schema-qualify project.agent_runs -- (the async connection does not put the project schema on search_path). FROM project.agent_runs - WHERE data->>'agentId' = ${req.params.id} + WHERE project_id = ${asyncLayer.projectId ?? ""} + AND data->>'agentId' = ${req.params.id} ORDER BY started_at DESC LIMIT ${limit} `); diff --git a/packages/engine/src/__tests__/executor-review-verdicts.test.ts b/packages/engine/src/__tests__/executor-review-verdicts.test.ts index ecefb591f7..1098cba8e2 100644 --- a/packages/engine/src/__tests__/executor-review-verdicts.test.ts +++ b/packages/engine/src/__tests__/executor-review-verdicts.test.ts @@ -642,6 +642,7 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => { expect(tools.fn_research_list).toBeTypeOf("function"); expect(tools.fn_research_get).toBeTypeOf("function"); expect(tools.fn_research_cancel).toBeTypeOf("function"); + expect(tools.fn_research_retry).toBeTypeOf("function"); }); it("does not register research runtime tools when researchView experimental flag is disabled", async () => { @@ -650,6 +651,7 @@ describe("Code review verdict enforcement - fn_task_update blocking", () => { expect(tools.fn_research_list).toBeUndefined(); expect(tools.fn_research_get).toBeUndefined(); expect(tools.fn_research_cancel).toBeUndefined(); + expect(tools.fn_research_retry).toBeUndefined(); }); it("REVISE tool response text includes re-review instructions", async () => { @@ -1976,4 +1978,3 @@ describe("fn_task_add_dep tool", () => { // ── Usage limit detection in executor ──────────────────────────────── import { UsageLimitPauser } from "../usage-limit-detector.js"; - diff --git a/packages/engine/src/__tests__/heartbeat-executor.test.ts b/packages/engine/src/__tests__/heartbeat-executor.test.ts index 63dc377ba2..e3399e5a0e 100644 --- a/packages/engine/src/__tests__/heartbeat-executor.test.ts +++ b/packages/engine/src/__tests__/heartbeat-executor.test.ts @@ -3055,7 +3055,7 @@ describe("executeHeartbeat", () => { expect(callArgs.tools).toBe("coding"); // fn_artifact_register/list/view, agent config/provisioning, goals/evaluations/identity, // task read discovery, workflow discovery/authoring, task promotion, bounded research, clarification, web fetch, memory, and fn_heartbeat_done. - expect(callArgs.customTools).toHaveLength(40); + expect(callArgs.customTools).toHaveLength(41); expect(callArgs.customTools![0]!.name).toBe("fn_task_create"); expect(callArgs.customTools![1]!.name).toBe("fn_task_log"); expect(callArgs.customTools![2]!.name).toBe("fn_task_document_write"); @@ -3089,14 +3089,15 @@ describe("executeHeartbeat", () => { expect(callArgs.customTools![30]!.name).toBe("fn_research_list"); expect(callArgs.customTools![31]!.name).toBe("fn_research_get"); expect(callArgs.customTools![32]!.name).toBe("fn_research_cancel"); - expect(callArgs.customTools![33]!.name).toBe("fn_workflow_select"); - expect(callArgs.customTools![34]!.name).toBe("fn_task_promote"); - expect(callArgs.customTools![35]!.name).toBe("fn_web_fetch"); - expect(callArgs.customTools![36]!.name).toBe("fn_memory_search"); - expect(callArgs.customTools![37]!.name).toBe("fn_memory_get"); - expect(callArgs.customTools![38]!.name).toBe("fn_memory_append"); + expect(callArgs.customTools![33]!.name).toBe("fn_research_retry"); + expect(callArgs.customTools![34]!.name).toBe("fn_workflow_select"); + expect(callArgs.customTools![35]!.name).toBe("fn_task_promote"); + expect(callArgs.customTools![36]!.name).toBe("fn_web_fetch"); + expect(callArgs.customTools![37]!.name).toBe("fn_memory_search"); + expect(callArgs.customTools![38]!.name).toBe("fn_memory_get"); + expect(callArgs.customTools![39]!.name).toBe("fn_memory_append"); // fn_heartbeat_done is last (terminal tool) - expect(callArgs.customTools![39]!.name).toBe("fn_heartbeat_done"); + expect(callArgs.customTools![40]!.name).toBe("fn_heartbeat_done"); }); it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => { @@ -4202,4 +4203,3 @@ describe("executeHeartbeat", () => { }); // ── Task Creation Tracking Tests ────────────────────────────────────── - diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 316954f5dc..4ae16f736b 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1126,6 +1126,7 @@ describe("fast-mode triage", () => { expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(true); expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(true); expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(true); + expect(capturedTools.some((tool: any) => tool.name === "fn_research_retry")).toBe(true); expect(capturedTools.some((tool: any) => tool.name === "fn_review_spec")).toBe(false); await writeFile(promptPath, "# Task: FN-FAST-004 - Fast\n\n## Mission\n\nShip it."); }); @@ -1177,6 +1178,7 @@ describe("fast-mode triage", () => { expect(capturedTools.some((tool: any) => tool.name === "fn_research_list")).toBe(false); expect(capturedTools.some((tool: any) => tool.name === "fn_research_get")).toBe(false); expect(capturedTools.some((tool: any) => tool.name === "fn_research_cancel")).toBe(false); + expect(capturedTools.some((tool: any) => tool.name === "fn_research_retry")).toBe(false); expect(capturedSystemPrompt).not.toContain("fn_research_run"); }); diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index 99c051a2ba..461ce41503 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -562,7 +562,7 @@ You have coding-capable workspace tools (read/write/edit/bash within worktree bo - fn_read_evaluations and fn_update_identity (available in no-task runs) - fn_reflect_on_performance when reflection is enabled for this run - fn_workflow_list, fn_workflow_get, fn_workflow_validate, fn_workflow_create, fn_workflow_update, fn_workflow_delete, fn_workflow_settings, and fn_trait_list for workflow discovery/authoring -- fn_research_run, fn_research_list, fn_research_get, and fn_research_cancel for bounded research when configured +- fn_research_run, fn_research_list, fn_research_get, fn_research_cancel, and fn_research_retry for bounded research when configured - fn_ask_question to ask the dashboard user for structured clarification - fn_web_fetch - fn_memory_search, fn_memory_get, and fn_memory_append diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index d07a1b509e..bf1fe9cedc 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -14,7 +14,7 @@ import { tmpdir } from "node:os"; import { extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import * as fusionCore from "@fusion/core"; import type { AgentState, AgentCapability, AgentUpdateInput, Artifact, ArtifactCreateInput, ArtifactWithTask, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode } from "@fusion/core"; -import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, ResearchStore, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core"; +import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core"; import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core"; import { ResearchOrchestrator } from "./research-orchestrator.js"; @@ -514,6 +514,10 @@ export const researchCancelParams = Type.Object({ id: Type.String({ description: "Research run ID to cancel" }), }); +export const researchRetryParams = Type.Object({ + id: Type.String({ description: "Failed or cancelled research run ID to retry" }), +}); + export const memoryAppendParams = Type.Object({ scope: Type.Optional(Type.Union([ Type.Literal("project"), @@ -4263,16 +4267,11 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti inFlight: new Map(), }; - // FNXC:ResearchStore 2026-06-27-12:35: - // The ResearchOrchestrator + the research tools' direct reads require the sync - // EventEmitter ResearchStore. In PG backend mode getResearchStore() returns the - // AsyncResearchStore (CRUD-only), so resolve to the sync store or null and degrade - // the research tools — AI research EXECUTION stays unavailable in PG mode (the - // dashboard CRUD/lifecycle surface is the ported boundary). - const resolveSyncResearchStore = (): ResearchStore | null => { - const resolved = options.store.getResearchStore(); - return resolved instanceof ResearchStore ? resolved : null; - }; + /* + FNXC:ResearchAgentTools 2026-07-13-23:45: + Agent research tools must use the TaskStore-selected research backend. Await the shared sync/async API so PostgreSQL supports execution, reads, cancellation, and retry instead of silently degrading to an unavailable tool surface. + */ + const resolveResearchStore = () => options.store.getResearchStore(); const ensureOrchestrator = async (): Promise => { const settings = await options.getSettings(); @@ -4293,11 +4292,6 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti return null; } - const syncResearchStore = resolveSyncResearchStore(); - if (!syncResearchStore) { - return null; - } - if (!orchestratorState.orchestrator) { const stepRunner = new ResearchStepRunner({ providers: availableProviders @@ -4305,7 +4299,7 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti .filter((provider): provider is NonNullable => Boolean(provider)), }); orchestratorState.orchestrator = new ResearchOrchestrator({ - store: syncResearchStore, + store: resolveResearchStore(), stepRunner, maxConcurrentRuns: resolved.limits.maxConcurrentRuns, }); @@ -4343,11 +4337,13 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti }); const runPromise = orchestrator.startRun(runId, params.query); - orchestratorState.inFlight.set(runId, runPromise.then(() => undefined).catch(() => undefined)); - void runPromise.finally(() => orchestratorState.inFlight.delete(runId)); + const trackedRun = runPromise + .then(() => undefined, () => undefined) + .finally(() => orchestratorState.inFlight.delete(runId)); + orchestratorState.inFlight.set(runId, trackedRun); if (!params.wait_for_completion) { - const started = resolveSyncResearchStore()?.getRun(runId); + const started = await resolveResearchStore().getRun(runId); if (!started) { return { content: [{ type: "text" as const, text: `Started research run ${runId} for: ${params.query}` }], @@ -4364,8 +4360,16 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti const completed = await Promise.race([ runPromise, new Promise((resolve) => setTimeout(() => { - const latest = resolveSyncResearchStore()?.getRun(runId); - resolve(latest ?? ({ + void Promise.resolve(resolveResearchStore().getRun(runId)).then((latest) => resolve(latest ?? ({ + id: runId, + query: params.query, + status: "running", + sources: [], + events: [], + tags: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ResearchRun))).catch(() => resolve({ id: runId, query: params.query, status: "running", @@ -4392,10 +4396,10 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti parameters: researchListParams, execute: async (_id: string, params: Static) => { const limit = Math.max(1, Math.min(params.limit ?? 10, 50)); - const runs = resolveSyncResearchStore()?.listRuns({ + const runs = await resolveResearchStore().listRuns({ status: params.status as ResearchRunStatus | undefined, limit, - }) ?? []; + }); const text = runs.length ? runs.map((run) => `- ${run.id} [${run.status}] ${run.query}`).join("\n") : "No research runs found."; @@ -4412,7 +4416,7 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti description: "Get one research run with structured findings and citations.", parameters: researchGetParams, execute: async (_id: string, params: Static) => { - const run = resolveSyncResearchStore()?.getRun(params.id); + const run = await resolveResearchStore().getRun(params.id); if (!run) { return { content: [{ type: "text" as const, text: `Research run ${params.id} not found.` }], @@ -4438,7 +4442,7 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti return researchUnavailable("provider-unavailable", "Research orchestrator is unavailable because research providers are not configured."); } const cancelled = await orchestrator.cancelRun(params.id); - const run = resolveSyncResearchStore()?.getRun(params.id); + const run = await resolveResearchStore().getRun(params.id); if (!run) { return { content: [{ type: "text" as const, text: `Research run ${params.id} not found.` }], @@ -4452,7 +4456,26 @@ export function createResearchTools(options: ResearchToolsOptions): ToolDefiniti }, }; - return [runTool, listTool, getTool, cancelTool]; + const retryTool: ToolDefinition = { + name: "fn_research_retry", + label: "Retry Research Run", + description: "Create a retry from a failed or cancelled research run.", + parameters: researchRetryParams, + execute: async (_id: string, params: Static) => { + const orchestrator = await ensureOrchestrator(); + if (!orchestrator) { + return researchUnavailable("provider-unavailable", "Research orchestrator is unavailable because research providers are not configured."); + } + const newRunId = await orchestrator.retryRun(params.id); + const run = await resolveResearchStore().getRun(newRunId); + return { + content: [{ type: "text" as const, text: `Created retry run ${newRunId} from ${params.id}.` }], + details: run ? formatResearchRunDetails(run) : { runId: newRunId, status: "retry_waiting", summary: null, findings: [], citations: [], error: null, setup: null }, + }; + }, + }; + + return [runTool, listTool, getTool, cancelTool, retryTool]; } export function createPostRoomMessageTool( diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index 28408a56fd..b653e096a1 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -397,7 +397,10 @@ export class CronRunner { /* * FNXC:Automations 2026-06-27-00:00: - * Cron execution must claim the due window in SQLite before running so two runner instances, overlapping project/all pollers, or separate engine processes cannot execute the same still-due row. The in-memory inFlight guard remains useful within one process but is not the cross-process authority. + * Cron execution must claim the due window in storage before running so two runner instances, overlapping project/all pollers, or separate engine processes cannot execute the same still-due row. The in-memory inFlight guard remains useful within one process but is not the cross-process authority. + * + * FNXC:AutomationIsolation 2026-07-13-22:37: + * PostgreSQL claims include the AutomationStore's bound project ID. A project cron runner may claim its own project or global execution lane, but it must never claim another project's command from the shared table. */ const claimed = await this.automationStore.claimDueSchedule(schedule.id, schedule.nextRunAt); if (!claimed) { diff --git a/packages/engine/src/tool-availability.ts b/packages/engine/src/tool-availability.ts index 9aaa87e149..ff033e20a4 100644 --- a/packages/engine/src/tool-availability.ts +++ b/packages/engine/src/tool-availability.ts @@ -19,12 +19,12 @@ export function getResearchToolSurfaceStatus(settings: Partial | undef } const TRIAGE_RESEARCH_GUIDANCE = `## Research tools -When spec work needs missing domain context, you may use research tools (\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`). Keep research bounded to the task at hand, prefer concise queries, and write durable findings into task documents when useful. +When spec work needs missing domain context, you may use research tools (\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`, \`fn_research_retry\`). Keep research bounded to the task at hand, prefer concise queries, and write durable findings into task documents when useful. If research is unavailable or unconfigured, continue planning with repository context and clearly note assumptions.`; const EXECUTOR_RESEARCH_GUIDANCE = `## Research tools When implementation needs external context, you may use research tools ( -\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`) to run bounded research. +\`fn_research_run\`, \`fn_research_list\`, \`fn_research_get\`, \`fn_research_cancel\`, \`fn_research_retry\`) to run bounded research. Keep runs focused and short, and persist durable conclusions into task documents (for example key="research"). If research is disabled or providers are not configured, use the actionable tool response and continue with available local context.`; diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts index 7f05475934..710011a972 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/_harness.ts @@ -1,8 +1,11 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { vi } from "vitest"; -import { Database } from "@fusion/core"; +import { execSync } from "node:child_process"; +import { afterAll, vi } from "vitest"; +import { applySchemaBaseline, createAsyncDataLayer, createConnectionSetFromUrl, type AsyncDataLayer, type ResolvedBackend } from "@fusion/core"; +import { PG_AVAILABLE, pgDescribe } from "@fusion/test-utils/pg-test-harness"; +export { PG_AVAILABLE, pgDescribe }; import type { CreateInteractiveAiSessionFactory, InteractiveAiSession, @@ -11,27 +14,50 @@ import type { } from "@fusion/core"; export interface TestHarness { - db: Database; + layer: AsyncDataLayer; projectRoot: string; ctx: PluginContext; emitted: Array<{ event: string; data: unknown }>; close(): void; } +const dbName = `ce_harness_${process.pid}_${process.env.VITEST_POOL_ID ?? "0"}_${Math.random().toString(36).slice(2, 8)}`.replace(/[^a-zA-Z0-9_]/g, "_"); +const pgUser = process.env.USER ?? "postgres"; +let connections: Awaited> | null = null; +let setupPromise: Promise | null = null; +function admin(statement: string): void { execSync(`psql -h localhost -p 5432 -U ${pgUser} -d postgres -v ON_ERROR_STOP=1 -c "${statement}"`, { stdio: "pipe" }); } +async function setupPostgres(): Promise { + if (connections) return; + admin(`DROP DATABASE IF EXISTS ${dbName}`); admin(`CREATE DATABASE ${dbName}`); + const url = `${process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"}/${dbName}`; + const backend: ResolvedBackend = { mode: "external", runtimeUrl: url, migrationUrl: url, migrationUrlOverridden: false }; + const schema = await createConnectionSetFromUrl(backend, { poolMax: 1, connectTimeoutSeconds: 5 }); + await applySchemaBaseline(schema.migration); await schema.close(); + connections = await createConnectionSetFromUrl(backend, { poolMax: 5, connectTimeoutSeconds: 5 }); +} +afterAll(async () => { + await connections?.close().catch(() => undefined); + connections = null; + if (!PG_AVAILABLE) return; + try { admin(`DROP DATABASE IF EXISTS ${dbName}`); } catch { /* best effort */ } +}); + /** * In-memory DB + a minimal route-style PluginContext whose `taskStore` exposes * `getDatabase()` / `getRootDir()` (the only surfaces the orchestrator uses) and * a recording `emitEvent` so tests can assert observable events. */ -export function makeHarness(): TestHarness { +export async function makeHarness(): Promise { + setupPromise ??= setupPostgres(); + await setupPromise; const projectRoot = mkdtempSync(join(tmpdir(), "ce-session-test-")); - const db = new Database(join(projectRoot, ".fusion"), { inMemory: true }); - db.init(); + const layer = createAsyncDataLayer(connections!, { projectId: `ce-test-${Math.random().toString(36).slice(2)}` }); const emitted: Array<{ event: string; data: unknown }> = []; const taskStore = { - getDatabase: () => db, + getAsyncLayer: () => layer, + isBackendMode: () => true, getRootDir: () => projectRoot, } as unknown as PluginContext["taskStore"]; @@ -46,12 +72,11 @@ export function makeHarness(): TestHarness { }; return { - db, + layer, projectRoot, ctx, emitted, close: () => { - db.close(); rmSync(projectRoot, { recursive: true, force: true }); }, }; diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts index 9a0d77544d..dd39f07546 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-cancel.test.ts @@ -1,8 +1,8 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, expect, it, vi } from "vitest"; import type { InteractiveAiSession } from "@fusion/core"; import { CE_EVENTS, CeOrchestrator } from "../session/orchestrator.js"; import { getCeSessionStore, type CeActivityTurn, type CeSessionStatus } from "../session/session-store.js"; -import { makeHarness, type TestHarness } from "./_harness.js"; +import { makeHarness, pgDescribe, type TestHarness } from "./_harness.js"; interface OrchestratorInternals { live: Map; @@ -22,25 +22,25 @@ function liveHandle(): InteractiveAiSession { }; } -describe("CeOrchestrator.cancel", () => { +pgDescribe("CeOrchestrator.cancel", () => { let h: TestHarness; afterEach(() => { h?.close(); }); - it("interrupts an in-flight session with a live handle, flushes progress, disposes, and emits", () => { - h = makeHarness(); + it("interrupts an in-flight session with a live handle, flushes progress, disposes, and emits", async () => { + h = await makeHarness(); const store = getCeSessionStore(h.ctx); const orch = new CeOrchestrator({ ctx: h.ctx }); - const session = store.update(store.create({ stage: "brainstorm" }).id, { status: "active" })!; + const session = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status: "active" }))!; const handle = liveHandle(); internals(orch).live.set(session.id, handle); internals(orch).activity.set(session.id, [ { kind: "thinking", text: "drafting cancellable progress", at: new Date().toISOString() }, ]); - const cancelled = orch.cancel(session.id)!; + const cancelled = (await orch.cancel(session.id))!; expect(cancelled.status).toBe("interrupted"); expect(cancelled.error).toBe("Cancelled by user"); @@ -55,13 +55,13 @@ describe("CeOrchestrator.cancel", () => { it.each(["launching", "active", "awaiting_input"])( "interrupts %s without requiring a live handle", - (status) => { - h = makeHarness(); + async (status) => { + h = await makeHarness(); const store = getCeSessionStore(h.ctx); const orch = new CeOrchestrator({ ctx: h.ctx }); - const session = store.update(store.create({ stage: "brainstorm" }).id, { status })!; + const session = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status }))!; - const cancelled = orch.cancel(session.id)!; + const cancelled = (await orch.cancel(session.id))!; expect(cancelled.status).toBe("interrupted"); expect(cancelled.error).toBe("Cancelled by user"); @@ -71,31 +71,31 @@ describe("CeOrchestrator.cancel", () => { it.each(["completed", "error", "interrupted"])( "is idempotent for terminal status %s", - (status) => { - h = makeHarness(); + async (status) => { + h = await makeHarness(); const store = getCeSessionStore(h.ctx); const orch = new CeOrchestrator({ ctx: h.ctx }); - const session = store.update(store.create({ stage: "brainstorm" }).id, { + const session = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status, error: status === "completed" ? null : "already settled", - })!; + }))!; const handle = liveHandle(); internals(orch).live.set(session.id, handle); - const cancelled = orch.cancel(session.id)!; + const cancelled = (await orch.cancel(session.id))!; expect(cancelled).toEqual(session); expect(handle.dispose).not.toHaveBeenCalled(); expect(h.emitted).toEqual([]); - expect(store.get(session.id)!.status).toBe(status); + expect((await store.getAsync(session.id))!.status).toBe(status); }, ); - it("returns undefined for an unknown session", () => { - h = makeHarness(); + it("returns undefined for an unknown session", async () => { + h = await makeHarness(); const orch = new CeOrchestrator({ ctx: h.ctx }); - expect(orch.cancel("missing")).toBeUndefined(); + expect(await orch.cancel("missing")).toBeUndefined(); expect(h.emitted).toEqual([]); }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-executor-seam.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-executor-seam.test.ts index fe5c7b98f2..18069c41d8 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-executor-seam.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-executor-seam.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { CeOrchestrator, type CeSessionExecutor } from "../session/orchestrator.js"; -import { makeHarness, type TestHarness } from "./_harness.js"; +import { makeHarness, pgDescribe, type TestHarness } from "./_harness.js"; /** * U9 CE executor seam contract. @@ -12,8 +12,8 @@ import { makeHarness, type TestHarness } from "./_harness.js"; * carries the choice through, per the plugin-skills option-threading learning. */ let h: TestHarness; -beforeEach(() => { - h = makeHarness(); +beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { h.close(); @@ -28,7 +28,7 @@ function makeOrch(executor?: CeSessionExecutor) { }); } -describe("CE executor seam (U9)", () => { +pgDescribe("CE executor seam (U9)", () => { it("defaults to the model backend when no executor option is supplied", () => { expect(makeOrch().resolveExecutor()).toEqual({ kind: "model" }); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts index d2e46d6917..719ad6203d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-interrupt-resume.test.ts @@ -1,9 +1,9 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, expect, it } from "vitest"; import type { InteractiveAiSession, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core"; import { vi } from "vitest"; import { CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; import { CeSessionStore, getCeSessionStore } from "../session/session-store.js"; -import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, pgDescribe, scriptedFactory, type TestHarness } from "./_harness.js"; /** * CHARACTERIZATION TEST — written first (U5 execution note: cover the @@ -24,8 +24,8 @@ const QUESTION: PlanningQuestion = { let h: TestHarness; -beforeEach(() => { - h = makeHarness(); +beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { @@ -54,7 +54,7 @@ function questionThenHangSession(): InteractiveAiSession { }; } -describe("interrupt + resume (no silent loss)", () => { +pgDescribe("interrupt + resume (no silent loss)", () => { it("auto-saves progress on a turn timeout, marks interrupted, emits an event", async () => { const session = questionThenHangSession(); const orch = new CeOrchestrator({ @@ -87,26 +87,26 @@ describe("interrupt + resume (no silent loss)", () => { // with currentQuestion set, lastActivity well past the interval stale band. // Human response time is unbounded, so this is NOT a crashed turn — the // interval rubric must not misclassify it as stale. - const store = new CeSessionStore(h.db); - const created = store.create({ + const store = new CeSessionStore(null, h.layer); + const created = await store.createAsync({ stage: "brainstorm", artifactPath: "docs/plans/2026-06-27-001-topic-plan.md", turnIntervalMs: 1000, }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() }); - store.update(created.id, { + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString() }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION, // 10× interval old → far past the band, yet legitimately awaiting a human. lastActivityAt: Date.now() - 10_000, }); - const recovered = store.recoverStaleSessions(); + const recovered = await store.recoverStaleSessionsAsync(); // Not flagged stale / not recovered — a human wait is not a crashed turn. expect(recovered).not.toContain(created.id); - const after = store.get(created.id)!; + const after = (await store.getAsync(created.id))!; // Awaiting-input session with a question stays resumable, unchanged. expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); @@ -130,14 +130,14 @@ describe("interrupt + resume (no silent loss)", () => { it("answer() rehydrates an old awaiting_input session with no live handle and drives the answer to completion", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const rehydrated = makeScriptedSession([ { type: "question", data: QUESTION }, @@ -191,18 +191,18 @@ describe("interrupt + resume (no silent loss)", () => { it("answer() without a live handle and without a factory reports an honest error without corrupting the question", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const orch = new CeOrchestrator({ ctx: h.ctx, projectRoot: h.projectRoot, turnTimeoutMs: 5000 }); await expect(orch.answer(created.id, "q1", "a")).rejects.toThrow(/cannot be continued in this process/i); - const after = store.get(created.id)!; + const after = (await store.getAsync(created.id))!; expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); expect(after.conversationHistory.some((t) => t.text.includes('"answer"'))).toBe(false); @@ -210,14 +210,14 @@ describe("interrupt + resume (no silent loss)", () => { it("answer() rejects a stale questionId before rehydration and leaves state untouched", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const factory = scriptedFactory(makeScriptedSession([{ type: "question", data: QUESTION }])); const orch = new CeOrchestrator({ ctx: h.ctx, @@ -228,7 +228,7 @@ describe("interrupt + resume (no silent loss)", () => { await expect(orch.answer(created.id, "stale-q", "a")).rejects.toThrow(/q1|stale-q/); expect(factory).not.toHaveBeenCalled(); - const after = store.get(created.id)!; + const after = (await store.getAsync(created.id))!; expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); expect(after.conversationHistory.some((t) => t.text.includes("stale-q"))).toBe(false); @@ -236,8 +236,8 @@ describe("interrupt + resume (no silent loss)", () => { it("answer() preserves the existing not-awaiting guard before rehydration", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.update(created.id, { status: "active", currentQuestion: QUESTION }); + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.updateAsync(created.id, { status: "active", currentQuestion: QUESTION }); const factory = scriptedFactory(makeScriptedSession([{ type: "question", data: QUESTION }])); const orch = new CeOrchestrator({ ctx: h.ctx, @@ -248,19 +248,19 @@ describe("interrupt + resume (no silent loss)", () => { await expect(orch.answer(created.id, "q1", "a")).rejects.toThrow(/not awaiting input/); expect(factory).not.toHaveBeenCalled(); - expect(store.get(created.id)!.status).toBe("active"); + expect((await store.getAsync(created.id))!.status).toBe("active"); }); it("detached answer() rehydrates an old awaiting_input session in the background", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const rehydrated = makeScriptedSession([ { type: "question", data: QUESTION }, { type: "complete", data: { artifact: "# Done\n" } }, @@ -275,9 +275,8 @@ describe("interrupt + resume (no silent loss)", () => { const returned = await orch.answer(created.id, "q1", "a", { detach: true }); expect(returned.session.status).toBe("active"); - await new Promise((resolve) => setImmediate(resolve)); - const after = store.get(created.id)!; - expect(after.status).toBe("completed"); + await vi.waitFor(async () => expect((await store.getAsync(created.id))?.status).toBe("completed")); + const after = (await store.getAsync(created.id))!; expect(rehydrated.answer).toHaveBeenCalledTimes(1); const hasAnswerTurn = after.conversationHistory.some( (t) => t.text === JSON.stringify({ answer: "a", questionId: "q1" }), @@ -285,20 +284,57 @@ describe("interrupt + resume (no silent loss)", () => { expect(hasAnswerTurn).toBe(true); }); + it("keeps detached answer rehydration failure terminal when the interrupted write returns no row", async () => { + const store = getCeSessionStore(h.ctx); + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { + role: "agent", + text: JSON.stringify({ question: QUESTION }), + at: new Date().toISOString(), + }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => { + throw new Error("rehydration failed"); + }), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const updateAsync = store.updateAsync.bind(store); + vi.spyOn(store, "updateAsync").mockImplementation((sessionId, patch) => { + if (patch.status === "interrupted") return Promise.resolve(undefined); + return updateAsync(sessionId, patch); + }); + + const returned = await orch.answer(created.id, "q1", "a", { detach: true }); + expect(returned.session.status).toBe("active"); + await vi.waitFor(async () => expect(await orch.getState(created.id)).toMatchObject({ + status: "interrupted", + error: "rehydration failed", + })); + expect((await store.getAsync(created.id))?.status).toBe("active"); + expect(h.emitted).toContainEqual({ + event: CE_EVENTS.interrupted, + data: { sessionId: created.id, message: "rehydration failed" }, + }); + }); + it("Bug 5: an interrupted/awaiting session with a currentQuestion + history can be resumed (rehydrated) and then ANSWERED to continue to completion", async () => { // Simulate the post-interrupt / post-restart state: a session persisted // mid-question (awaiting_input, currentQuestion set, full history) whose live // handle was disposed and removed from this.live. This is exactly the state // resume() must be able to back with a real live handle. const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 5000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 5000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const sessionId = created.id; // The rehydration factory: replays the opening prompt (yields the question, @@ -347,7 +383,7 @@ describe("interrupt + resume (no silent loss)", () => { await expect(orch.answer(started.session.id, "WRONG-ID", "a")).rejects.toThrow(/q1|WRONG-ID/); // The recovery anchor is intact: still awaiting_input with currentQuestion. - const after = orch.getState(started.session.id)!; + const after = (await orch.getState(started.session.id))!; expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); // No spurious answer turn was appended to history. @@ -360,14 +396,14 @@ describe("interrupt + resume (no silent loss)", () => { expect(accepted.session.status).toBe("interrupted"); }); - it("a crash with no pending question is marked interrupted (progress preserved), not silently dropped", () => { + it("a crash with no pending question is marked interrupted (progress preserved), not silently dropped", async () => { const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm", turnIntervalMs: 1000 }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.update(created.id, { status: "active", lastActivityAt: Date.now() - 10_000 }); + const created = await store.createAsync({ stage: "brainstorm", turnIntervalMs: 1000 }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.updateAsync(created.id, { status: "active", lastActivityAt: Date.now() - 10_000 }); - store.recoverStaleSessions(); - const after = store.get(created.id)!; + await store.recoverStaleSessionsAsync(); + const after = (await store.getAsync(created.id))!; expect(after.status).toBe("interrupted"); expect(after.error).toMatch(/progress preserved/i); expect(after.conversationHistory).toHaveLength(1); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts index 58b1625396..5418564fef 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/orchestrator-live-output.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, @@ -6,8 +6,9 @@ import type { PlanningQuestion, } from "@fusion/core"; import { buildStageSystemPrompt, CeOrchestrator, CE_EVENTS } from "../session/orchestrator.js"; +import { getCeSessionStore, type CeSession } from "../session/session-store.js"; import { getStage } from "../session/stage-registry.js"; -import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, pgDescribe, type TestHarness } from "./_harness.js"; /** * Live working-output + steering-protocol coverage: @@ -23,8 +24,8 @@ import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.j const QUESTION: PlanningQuestion = { id: "q1", type: "text", question: "Topic?" }; let h: TestHarness; -beforeEach(() => { - h = makeHarness(); +beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { h.close(); @@ -39,6 +40,14 @@ function deferred() { return { promise, resolve }; } +function signal() { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** A factory exposing the onProgress hook and a controllable nextEvent. */ @@ -60,7 +69,7 @@ function progressFactory(nextEvent: () => Promise) { return { factory, captured }; } -describe("live working output", () => { +pgDescribe("live working output", () => { it("buffers mid-turn progress, emits observable events, and persists the trace on settle (before the question)", async () => { const evt = deferred(); const { factory, captured } = progressFactory(() => evt.promise); @@ -88,17 +97,17 @@ describe("live working output", () => { expect(live[1].done).toBe(true); expect(live[1].isError).toBeUndefined(); - // Observable progress event emitted (throttled; the first one is immediate). - expect( + // Observable progress is emitted only after its durable liveness write succeeds. + await vi.waitFor(() => expect( h.emitted.some((e) => e.event === CE_EVENTS.turn && (e.data as { kind?: string }).kind === "progress"), - ).toBe(true); + ).toBe(true)); // Settle the turn → buffer flushed into history BEFORE the question record. evt.resolve({ type: "question", data: QUESTION }); - await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("awaiting_input")); + await vi.waitFor(async () => expect((await orch.getState(started.session.id))?.status).toBe("awaiting_input")); expect(orch.getLiveActivity(started.session.id)).toHaveLength(0); - const history = orch.getState(started.session.id)!.conversationHistory; + const history = (await orch.getState(started.session.id))!.conversationHistory; const activityIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"activity"')); const questionIdx = history.findIndex((t) => t.role === "agent" && t.text.startsWith('{"question"')); expect(activityIdx).toBeGreaterThanOrEqual(0); @@ -126,18 +135,18 @@ describe("live working output", () => { await sleep(45); captured.progress!({ type: "thinking", delta: "." }); } - expect(orch.getState(id)?.status).toBe("active"); + expect((await orch.getState(id))?.status).toBe("active"); // Go quiet → interrupted after the inactivity window, trace preserved. - await vi.waitFor(() => expect(orch.getState(id)?.status).toBe("interrupted"), { timeout: 2000 }); - expect(orch.getState(id)?.error).toMatch(/no agent activity/i); - const history = orch.getState(id)!.conversationHistory; + await vi.waitFor(async () => expect((await orch.getState(id))?.status).toBe("interrupted"), { timeout: 2000 }); + expect((await orch.getState(id))?.error).toMatch(/no agent activity/i); + const history = (await orch.getState(id))!.conversationHistory; expect(history.some((t) => t.text.startsWith('{"activity"'))).toBe(true); expect(captured.dispose).toHaveBeenCalled(); }); }); -describe("detached turns (route posture)", () => { +pgDescribe("detached turns (route posture)", () => { it("answer(detach) returns immediately with status active and converges to the next question", async () => { const NEXT: PlanningQuestion = { id: "q2", type: "text", question: "More?" }; const scripted = makeScriptedSession([ @@ -158,8 +167,8 @@ describe("detached turns (route posture)", () => { expect(stepped.session.status).toBe("active"); expect(stepped.session.currentQuestion).toBeNull(); // …and the background turn converges to the next question. - await vi.waitFor(() => expect(orch.getState(started.session.id)?.currentQuestion?.id).toBe("q2")); - expect(orch.getState(started.session.id)?.status).toBe("awaiting_input"); + await vi.waitFor(async () => expect((await orch.getState(started.session.id))?.currentQuestion?.id).toBe("q2")); + expect((await orch.getState(started.session.id))?.status).toBe("awaiting_input"); }); it("start(detach) without a working factory converges to an error state (never silent)", async () => { @@ -173,13 +182,165 @@ describe("detached turns (route posture)", () => { }); const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); expect(started.session.id).toBeTruthy(); - await vi.waitFor(() => expect(orch.getState(started.session.id)?.status).toBe("error")); - expect(orch.getState(started.session.id)?.error).toContain("factory exploded"); + await vi.waitFor(async () => expect((await orch.getState(started.session.id))?.status).toBe("error")); + expect((await orch.getState(started.session.id))?.error).toContain("factory exploded"); expect(h.emitted.map((e) => e.event)).toContain(CE_EVENTS.error); }); + + it("terminates unexpected detached rejections and persists a visible failure", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession([{ type: "question", data: QUESTION }]) })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const internal = orch as unknown as { + runOpeningTurn(sessionId: string): Promise; + }; + vi.spyOn(internal, "runOpeningTurn").mockRejectedValue(new Error("unexpected detached rejection")); + + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + await vi.waitFor(async () => expect((await orch.getState(started.session.id))?.status).toBe("error")); + expect((await orch.getState(started.session.id))?.error).toContain("unexpected detached rejection"); + expect(h.ctx.logger.error).toHaveBeenCalledWith(expect.stringContaining("unexpected detached rejection")); + }); + + it("keeps a detached rejection observably terminal when its primary failure write also rejects", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession([{ type: "question", data: QUESTION }]) })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const internal = orch as unknown as { + runOpeningTurn(sessionId: string): Promise; + }; + vi.spyOn(internal, "runOpeningTurn").mockRejectedValue(new Error("detached operation failed")); + + const store = getCeSessionStore(h.ctx); + const updateAsync = store.updateAsync.bind(store); + vi.spyOn(store, "updateAsync").mockImplementation((sessionId, patch) => { + if (patch.status === "error") return Promise.reject(new Error("primary failure write failed")); + return updateAsync(sessionId, patch); + }); + + const started = await orch.start("brainstorm", { openingMessage: "go", detach: true }); + await vi.waitFor(() => expect(h.ctx.logger.error).toHaveBeenCalledWith( + expect.stringContaining("primary failure write failed"), + )); + + expect((await store.getAsync(started.session.id))?.status).toBe("launching"); + expect(await orch.getState(started.session.id)).toMatchObject({ + status: "error", + error: "detached operation failed", + }); + expect(h.emitted).toContainEqual({ + event: CE_EVENTS.error, + data: { sessionId: started.session.id, message: "detached operation failed" }, + }); + }); + + it("does not let an already-queued progress write invalidate the detached failure fallback", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession([{ type: "question", data: QUESTION }]) })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const store = getCeSessionStore(h.ctx); + const session = (await store.updateAsync( + (await store.createAsync({ stage: "brainstorm" })).id, + { status: "active" }, + ))!; + const progressStarted = signal(); + const releaseProgress = signal(); + const touchActivityAsync = store.touchActivityAsync.bind(store); + vi.spyOn(store, "touchActivityAsync").mockImplementation(async (sessionId, at) => { + progressStarted.resolve(); + await releaseProgress.promise; + return touchActivityAsync(sessionId, at); + }); + const updateAsync = store.updateAsync.bind(store); + vi.spyOn(store, "updateAsync").mockImplementation((sessionId, patch) => { + if (patch.status === "error") return Promise.reject(new Error("primary failure write failed")); + return updateAsync(sessionId, patch); + }); + + const internal = orch as unknown as { + queueProgressPersistence(sessionId: string, at: number, force?: boolean): void; + detachTurn(session: () => CeSession, label: string, operation: Promise): void; + }; + internal.queueProgressPersistence(session.id, session.lastActivityAt + 100, true); + await progressStarted.promise; + internal.detachTurn(() => session, "controlled turn", Promise.reject(new Error("detached operation failed"))); + releaseProgress.resolve(); + + await vi.waitFor(() => expect(h.ctx.logger.error).toHaveBeenCalledWith( + expect.stringContaining("primary failure write failed"), + )); + expect((await store.getAsync(session.id))?.lastActivityAt).toBeGreaterThan(session.lastActivityAt); + expect(await orch.getState(session.id)).toMatchObject({ + status: "error", + error: "detached operation failed", + }); + + await store.updateAsync(session.id, { status: "interrupted", error: "durable recovery advanced" }); + expect(await orch.getState(session.id)).toMatchObject({ + status: "interrupted", + error: "durable recovery advanced", + }); + }); + + it("uses the pre-detach snapshot when the failure read and terminal write both reject", async () => { + const orch = new CeOrchestrator({ + ctx: h.ctx, + createInteractiveAiSession: vi.fn(async () => ({ session: makeScriptedSession([{ type: "question", data: QUESTION }]) })), + projectRoot: h.projectRoot, + turnTimeoutMs: 5000, + }); + const store = getCeSessionStore(h.ctx); + const session = await store.createAsync({ stage: "brainstorm" }); + let accepted = session; + let rejectOperation!: (cause: unknown) => void; + const operation = new Promise((_resolve, reject) => { + rejectOperation = reject; + }); + const internal = orch as unknown as { + detachTurn(session: () => CeSession, label: string, operation: Promise): void; + }; + internal.detachTurn(() => accepted, "controlled turn", operation); + accepted = (await store.updateAsync(session.id, { status: "active" }))!; + + const getAsync = store.getAsync.bind(store); + vi.spyOn(store, "getAsync") + .mockRejectedValueOnce(new Error("failure snapshot read failed")) + .mockImplementation(getAsync); + const updateAsync = store.updateAsync.bind(store); + vi.spyOn(store, "updateAsync").mockImplementation((sessionId, patch) => { + if (patch.status === "error") return Promise.reject(new Error("terminal write failed")); + return updateAsync(sessionId, patch); + }); + + rejectOperation(new Error("detached operation failed")); + await vi.waitFor(() => expect(h.ctx.logger.error).toHaveBeenCalledWith( + expect.stringContaining("terminal write failed"), + )); + + expect(await orch.getState(accepted.id)).toMatchObject({ + status: "error", + error: "detached operation failed", + }); + expect(h.ctx.logger.error).toHaveBeenCalledWith(expect.stringContaining("failure snapshot read failed")); + + await store.updateAsync(accepted.id, { status: "interrupted", error: "durable recovery advanced" }); + expect(await orch.getState(accepted.id)).toMatchObject({ + status: "interrupted", + error: "durable recovery advanced", + }); + }); }); -describe("steering protocol", () => { +pgDescribe("steering protocol", () => { it("the stage system prompt documents direct, value+comment, and feedback-only response shapes", () => { const prompt = buildStageSystemPrompt(getStage("brainstorm")!); expect(prompt).toContain('"value"'); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/pg-test-harness.d.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/pg-test-harness.d.ts new file mode 100644 index 0000000000..cc83e8db02 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/pg-test-harness.d.ts @@ -0,0 +1,6 @@ +declare module "@fusion/test-utils/pg-test-harness" { + import type { describe } from "vitest"; + + export const PG_AVAILABLE: boolean; + export const pgDescribe: typeof describe; +} diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts index 628719d7b5..b77fa8d7f0 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/pipeline-store.pg.test.ts @@ -15,7 +15,7 @@ */ import { execSync } from "node:child_process"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, expect, it } from "vitest"; import { sql } from "drizzle-orm"; import { applySchemaBaseline, @@ -26,13 +26,14 @@ import { type ResolvedBackend, } from "@fusion/core"; import { CePipelineStore } from "../sync/pipeline-store.js"; +import { CeSessionStore, PlanHandoffClaimError } from "../session/session-store.js"; +import { + PG_AVAILABLE, + pgDescribe, +} from "@fusion/test-utils/pg-test-harness"; const PG_TEST_URL_BASE = process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"; -const PG_AVAILABLE = process.env.FUSION_PG_TEST_SKIP !== "1"; - -const pgDescribe = PG_AVAILABLE ? describe : describe.skip; - const PG_USER = process.env.USER ?? "postgres"; function adminExec(statement: string): void { @@ -51,6 +52,7 @@ function uniqueDbName(): string { interface TestCtx { readonly dbName: string; readonly layer: AsyncDataLayer; + readonly layerB: AsyncDataLayer; close(): Promise; } @@ -83,12 +85,14 @@ async function setupCtx(): Promise { poolMax: 5, connectTimeoutSeconds: 5, }); - const layer = createAsyncDataLayer(connections); + const layer = createAsyncDataLayer(connections, { projectId: "ce-project-a" }); + const layerB = createAsyncDataLayer(connections, { projectId: "ce-project-b" }); let closed = false; return { dbName, layer, + layerB, async close() { if (closed) return; closed = true; @@ -117,6 +121,57 @@ afterAll(async () => { }); pgDescribe("CePipelineStore (PG backend mode)", () => { + it("persists sessions and isolates identical lookups by bound project", async () => { + const a = new CeSessionStore(null, ctx!.layer); + const b = new CeSessionStore(null, ctx!.layerB); + expect(await a.listAsync()).toEqual([]); + expect(await b.listAsync()).toEqual([]); + const created = await a.createAsync({ id: "shared-session", stage: "brainstorm" }); + await a.appendHistoryAsync(created.id, { role: "user", text: "hello", at: new Date().toISOString() }); + expect((await a.getAsync(created.id))?.conversationHistory[0]?.text).toBe("hello"); + expect(await b.getAsync(created.id)).toBeUndefined(); + }); + + it("keeps terminal state and every history turn under concurrent liveness and history writes", async () => { + const store = new CeSessionStore(null, ctx!.layer); + const session = await store.createAsync({ id: "session-concurrency", stage: "brainstorm" }); + const turns = Array.from({ length: 12 }, (_, index) => ({ + role: "agent" as const, + text: `turn-${index}`, + at: new Date(1_700_000_000_000 + index).toISOString(), + })); + + /* + * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:24: + * Concurrent PostgreSQL history appends, terminal transitions, and heartbeat touches must compose without last-writer-wins loss. This regression exercises the real database so a read-modify-write implementation deterministically loses one or more independently appended turns. + */ + await Promise.all([ + ...turns.map((turn) => store.appendHistoryAsync(session.id, turn)), + store.updateAsync(session.id, { status: "completed" }), + store.touchActivityAsync(session.id, Date.now() + 1000), + ]); + + const settled = await store.getAsync(session.id); + expect(settled?.status).toBe("completed"); + expect(settled?.conversationHistory.map((turn) => turn.text).sort()).toEqual( + turns.map((turn) => turn.text).sort(), + ); + }); + + it("allows exactly one PostgreSQL Plan handoff claim under concurrent starts", async () => { + const first = new CeSessionStore(null, ctx!.layer); + const second = new CeSessionStore(null, ctx!.layer); + const artifactPath = "/tmp/ce-concurrent-plan.md"; + const results = await Promise.allSettled([ + first.createWithPlanHandoffClaimAsync({ id: "plan-claim-a", stage: "plan" }, artifactPath), + second.createWithPlanHandoffClaimAsync({ id: "plan-claim-b", stage: "plan" }, artifactPath), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + expect(rejected?.reason).toBeInstanceOf(PlanHandoffClaimError); + expect((await first.listAsync({ stage: "plan" })).filter((row) => row.artifactPath === artifactPath)).toHaveLength(1); + }); it("constructs in backend mode (asyncLayer wired, sync db null)", () => { const store = new CePipelineStore(null, ctx!.layer); expect(store.backendMode).toBe(true); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts index 74ab72cba9..0c27c431a8 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/session-routes.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, PlanningQuestion, PluginContext, PluginRouteResponse } from "@fusion/core"; import { createSessionRoutes } from "../routes/session-routes.js"; -import { makeHarness, makeScriptedSession, scriptedFactory, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, pgDescribe, scriptedFactory, type TestHarness } from "./_harness.js"; /** * Routes-level smoke test for the POLLING transport. Exercises validation and @@ -25,8 +25,8 @@ const QUESTION: PlanningQuestion = { }; let h: TestHarness; -beforeEach(() => { - h = makeHarness(); +beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { h.close(); @@ -56,7 +56,7 @@ async function call(method: string, path: string, req: unknown, ctx: PluginConte return (await route(method, path).handler(req, ctx)) as PluginRouteResponse; } -describe("session routes (polling transport)", () => { +pgDescribe("session routes (polling transport)", () => { it("exposes start / answer / resume / get-session-state / list", () => { const paths = createSessionRoutes().map((r) => `${r.method} ${r.path}`); expect(paths).toEqual( @@ -75,22 +75,22 @@ describe("session routes (polling transport)", () => { it("DELETE /sessions/:id discards a session (404 for unknown, gone afterwards, others kept)", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const keep = store.create({ stage: "brainstorm" }); - const drop = store.create({ stage: "plan" }); + const keep = await store.createAsync({ stage: "brainstorm" }); + const drop = await store.createAsync({ stage: "plan" }); const missing = await call("DELETE", "/sessions/:id", { params: { id: "nope" } }, h.ctx); expect(missing.status).toBe(404); const deleted = await call("DELETE", "/sessions/:id", { params: { id: drop.id } }, h.ctx); expect(deleted.status).toBe(200); - expect(store.get(drop.id)).toBeUndefined(); - expect(store.get(keep.id)).toBeDefined(); + expect(await store.getAsync(drop.id)).toBeUndefined(); + expect(await store.getAsync(keep.id)).toBeDefined(); }); it("POST /sessions/:id/cancel interrupts an in-flight session", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const created = store.update(store.create({ stage: "brainstorm" }).id, { status: "active" })!; + const created = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status: "active" }))!; const res = await call("POST", "/sessions/:id/cancel", { params: { id: created.id } }, h.ctx); @@ -110,7 +110,7 @@ describe("session routes (polling transport)", () => { it("POST /sessions/:id/cancel is idempotent for terminal sessions", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const created = store.update(store.create({ stage: "brainstorm" }).id, { status: "completed" })!; + const created = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status: "completed" }))!; const res = await call("POST", "/sessions/:id/cancel", { params: { id: created.id } }, h.ctx); @@ -118,14 +118,14 @@ describe("session routes (polling transport)", () => { const session = (res.body as { session: { status: string; error: string | null } }).session; expect(session.status).toBe("completed"); expect(session.error).toBeNull(); - expect(store.get(created.id)!.status).toBe("completed"); + expect((await store.getAsync(created.id))!.status).toBe("completed"); }); it("GET /sessions lists every session so a client can manage multiple concurrently", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - store.create({ stage: "brainstorm" }); - store.create({ stage: "plan" }); + await store.createAsync({ stage: "brainstorm" }); + await store.createAsync({ stage: "plan" }); const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); expect(res.status).toBe(200); @@ -133,37 +133,72 @@ describe("session routes (polling transport)", () => { expect(sessions.map((s) => s.stage).sort()).toEqual(["brainstorm", "plan"]); }); - it("GET /sessions scopes every session consumer to the requested project", async () => { + it("GET /sessions applies detached terminal fallbacks before status filtering", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const projectA = store.create({ stage: "brainstorm", projectId: "project-a" }); - store.create({ stage: "plan", projectId: "project-b" }); - store.create({ stage: "debug" }); + h.ctx.createInteractiveAiSession = vi.fn(async () => { + throw new Error("detached factory failed"); + }); + const updateAsync = store.updateAsync.bind(store); + vi.spyOn(store, "updateAsync").mockImplementation((sessionId, patch) => { + if (patch.status === "error") return Promise.reject(new Error("terminal write failed")); + return updateAsync(sessionId, patch); + }); - const res = await call("GET", "/sessions", { params: {}, query: { projectId: "project-a" } }, h.ctx); + const started = await call("POST", "/sessions", { + params: {}, + body: { stage: "brainstorm", message: "go" }, + }, h.ctx); + expect(started.status).toBe(201); + const sessionId = (started.body as { session: { id: string } }).session.id; + await vi.waitFor(() => expect(h.ctx.logger.error).toHaveBeenCalledWith( + expect.stringContaining("terminal write failed"), + )); + expect((await store.getAsync(sessionId))?.status).toBe("launching"); + + const all = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); + const errors = await call("GET", "/sessions", { params: {}, query: { status: "error" } }, h.ctx); + const launching = await call("GET", "/sessions", { params: {}, query: { status: "launching" } }, h.ctx); + expect((all.body as { sessions: Array<{ id: string; status: string }> }).sessions).toContainEqual( + expect.objectContaining({ id: sessionId, status: "error" }), + ); + expect((errors.body as { sessions: Array<{ id: string }> }).sessions.map((session) => session.id)).toContain(sessionId); + expect((launching.body as { sessions: Array<{ id: string }> }).sessions.map((session) => session.id)).not.toContain(sessionId); + }); + + it("GET /sessions enforces the task store's bound project over caller-supplied row ownership", async () => { + const { getCeSessionStore } = await import("../session/session-store.js"); + const store = getCeSessionStore(h.ctx); + const projectA = await store.createAsync({ stage: "brainstorm", projectId: "project-a" }); + await store.createAsync({ stage: "plan", projectId: "project-b" }); + await store.createAsync({ stage: "debug" }); + + const res = await call("GET", "/sessions", { params: {}, query: { projectId: h.layer.projectId } }, h.ctx); expect(res.status).toBe(200); const sessions = (res.body as { sessions: Array<{ id: string; projectId: string | null }> }).sessions; - expect(sessions).toEqual([expect.objectContaining({ id: projectA.id, projectId: "project-a" })]); + expect(sessions).toHaveLength(3); + expect(sessions).toContainEqual(expect.objectContaining({ id: projectA.id, projectId: h.layer.projectId })); + expect(sessions.every((session) => session.projectId === h.layer.projectId)).toBe(true); }); it("GET /sessions keeps error, interrupted, awaiting_input, active, and completed rows independently manageable", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const error = store.update(store.create({ stage: "debug" }).id, { + const error = (await store.updateAsync((await store.createAsync({ stage: "debug" })).id, { status: "error", error: "Failed to parse agent response: AI returned no valid JSON.", - })!; - const interrupted = store.update(store.create({ stage: "plan" }).id, { + }))!; + const interrupted = (await store.updateAsync((await store.createAsync({ stage: "plan" })).id, { status: "interrupted", error: "Cancelled by user", - })!; - const awaiting = store.update(store.create({ stage: "brainstorm" }).id, { + }))!; + const awaiting = (await store.updateAsync((await store.createAsync({ stage: "brainstorm" })).id, { status: "awaiting_input", currentQuestion: QUESTION, - })!; - const active = store.update(store.create({ stage: "strategy", turnIntervalMs: 60_000 }).id, { status: "active" })!; - const completed = store.update(store.create({ stage: "work" }).id, { status: "completed" })!; + }))!; + const active = (await store.updateAsync((await store.createAsync({ stage: "strategy", turnIntervalMs: 60_000 })).id, { status: "active" }))!; + const completed = (await store.updateAsync((await store.createAsync({ stage: "work" })).id, { status: "completed" }))!; const res = await call("GET", "/sessions", { params: {}, query: {} }, h.ctx); @@ -181,15 +216,15 @@ describe("session routes (polling transport)", () => { const deleted = await call("DELETE", "/sessions/:id", { params: { id: error.id } }, h.ctx); expect(deleted.status).toBe(200); - expect(store.get(error.id)).toBeUndefined(); - expect(store.get(completed.id)).toBeDefined(); + expect(await store.getAsync(error.id)).toBeUndefined(); + expect(await store.getAsync(completed.id)).toBeDefined(); }); it("GET /sessions recovers stale active rows that have no live route handle", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const zombie = store.create({ stage: "strategy", turnIntervalMs: 1 }); - store.update(zombie.id, { + const zombie = await store.createAsync({ stage: "strategy", turnIntervalMs: 1 }); + await store.updateAsync(zombie.id, { status: "active", currentQuestion: null, lastActivityAt: Date.now() - 10_000, @@ -203,7 +238,7 @@ describe("session routes (polling transport)", () => { status: "interrupted", error: "Session interrupted — progress preserved, resume to continue", }); - expect(store.get(zombie.id)).toMatchObject({ + expect(await store.getAsync(zombie.id)).toMatchObject({ status: "interrupted", error: "Session interrupted — progress preserved, resume to continue", }); @@ -212,8 +247,8 @@ describe("session routes (polling transport)", () => { it("GET /sessions/:id recovers a stale active row before returning it", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const zombie = store.create({ stage: "strategy", turnIntervalMs: 1 }); - store.update(zombie.id, { + const zombie = await store.createAsync({ stage: "strategy", turnIntervalMs: 1 }); + await store.updateAsync(zombie.id, { status: "active", currentQuestion: null, lastActivityAt: Date.now() - 10_000, @@ -226,7 +261,7 @@ describe("session routes (polling transport)", () => { status: "interrupted", error: "Session interrupted — progress preserved, resume to continue", }); - expect(store.get(zombie.id)).toMatchObject({ + expect(await store.getAsync(zombie.id)).toMatchObject({ status: "interrupted", error: "Session interrupted — progress preserved, resume to continue", }); @@ -256,9 +291,11 @@ describe("session routes (polling transport)", () => { const sessionId = (started.body as { session: { id: string; status: string; error: string | null } }).session.id; expect((started.body as { session: { status: string } }).session.status).toBe("launching"); - await new Promise((resolve) => setImmediate(resolve)); - - const polled = await call("GET", "/sessions/:id", { params: { id: sessionId } }, h.ctx); + let polled = await call("GET", "/sessions/:id", { params: { id: sessionId } }, h.ctx); + await vi.waitFor(async () => { + polled = await call("GET", "/sessions/:id", { params: { id: sessionId } }, h.ctx); + expect((polled.body as { session: { status: string } }).session.status).toBe("awaiting_input"); + }); expect(polled.status).toBe(200); expect((polled.body as { session: { status: string; error: string | null; currentQuestion: PlanningQuestion } }).session).toMatchObject({ status: "awaiting_input", @@ -282,7 +319,7 @@ describe("session routes (polling transport)", () => { // Seed a session directly so the poll route has something to return. const { getCeSessionStore } = await import("../session/session-store.js"); - const seeded = getCeSessionStore(h.ctx).create({ stage: "brainstorm" }); + const seeded = await getCeSessionStore(h.ctx).createAsync({ stage: "brainstorm" }); const found = await call("GET", "/sessions/:id", { params: { id: seeded.id } }, h.ctx); expect(found.status).toBe(200); expect((found.body as { session: { id: string } }).session.id).toBe(seeded.id); @@ -296,14 +333,14 @@ describe("session routes (polling transport)", () => { it("POST /sessions/:id/answer rehydrates an old awaiting_input session instead of returning call-resume-first", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm" }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm" }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); h.ctx.createInteractiveAiSession = scriptedFactory( makeScriptedSession([ @@ -321,21 +358,20 @@ describe("session routes (polling transport)", () => { expect(res.status).toBe(200); expect((res.body as { session: { status: string } }).session.status).toBe("active"); - await new Promise((resolve) => setImmediate(resolve)); - expect(store.get(created.id)!.status).toBe("completed"); + await vi.waitFor(async () => expect((await store.getAsync(created.id))?.status).toBe("completed")); }); it("POST /sessions/:id/answer returns an honest no-factory error without corrupting an old awaiting_input session", async () => { const { getCeSessionStore } = await import("../session/session-store.js"); const store = getCeSessionStore(h.ctx); - const created = store.create({ stage: "brainstorm" }); - store.appendHistory(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); - store.appendHistory(created.id, { + const created = await store.createAsync({ stage: "brainstorm" }); + await store.appendHistoryAsync(created.id, { role: "user", text: "kick off", at: new Date().toISOString() }); + await store.appendHistoryAsync(created.id, { role: "agent", text: JSON.stringify({ question: QUESTION }), at: new Date().toISOString(), }); - store.update(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); + await store.updateAsync(created.id, { status: "awaiting_input", currentQuestion: QUESTION }); const res = await call( "POST", @@ -346,7 +382,7 @@ describe("session routes (polling transport)", () => { expect(res.status).toBe(409); expect((res.body as { error: string }).error).toMatch(/cannot be continued in this process/i); expect((res.body as { error: string }).error).not.toMatch(/call resume\(\) first/i); - const after = store.get(created.id)!; + const after = (await store.getAsync(created.id))!; expect(after.status).toBe("awaiting_input"); expect(after.currentQuestion?.id).toBe("q1"); }); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts index 2c9c1755a5..e5636341ba 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-launch-guard.test.ts @@ -1,7 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { CreateInteractiveAiSessionFactory, InteractiveAiSessionEvent, PlanningQuestion } from "@fusion/core"; import { CeOrchestrator } from "../session/orchestrator.js"; -import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, pgDescribe, type TestHarness } from "./_harness.js"; /* FNXC:CompoundEngineering 2026-06-17-13:22: @@ -26,11 +26,11 @@ function debugProtocolSensitiveFactory(question: PlanningQuestion): CreateIntera }); } -describe("CE stage launch guard", () => { +pgDescribe("CE stage launch guard", () => { let h: TestHarness; - beforeEach(() => { - h = makeHarness(); + beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts index d8b268ee71..33de5c0f55 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts @@ -1,17 +1,17 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import type { CreateInteractiveAiSessionFactory } from "@fusion/core"; import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js"; import { getCeSessionStore } from "../session/session-store.js"; import { listStages } from "../session/stage-registry.js"; -import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; +import { makeHarness, makeScriptedSession, pgDescribe, type TestHarness } from "./_harness.js"; let h: TestHarness; -beforeEach(() => { - h = makeHarness(); +beforeEach(async () => { + h = await makeHarness(); }); afterEach(() => { @@ -19,7 +19,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("CE stage skill loading session options", () => { +pgDescribe("CE stage skill loading session options", () => { it.each(listStages())("starts $stageId with its registered skill selected and discoverable", async (stage) => { const capturedOptions: Parameters[0][] = []; const session = makeScriptedSession([{ type: "complete", data: { artifact: `# ${stage.stageId}` } }]); @@ -79,8 +79,8 @@ describe("CE stage skill loading session options", () => { }); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); - const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); - const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: h.layer.projectId }); + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: h.layer.projectId }); expect(plan.session.artifactPath).toBe(brainstorm.session.artifactPath); expect(readFileSync(plan.session.artifactPath!, "utf8")).toContain("artifact_readiness: implementation-ready"); @@ -96,12 +96,12 @@ describe("CE stage skill loading session options", () => { return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; }); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); - const older = await orch.start("brainstorm", { openingMessage: "Older", projectId: "project-a" }); - const newer = await orch.start("brainstorm", { openingMessage: "Newer", projectId: "project-a" }); + const older = await orch.start("brainstorm", { openingMessage: "Older", projectId: h.layer.projectId }); + const newer = await orch.start("brainstorm", { openingMessage: "Newer", projectId: h.layer.projectId }); const plan = await orch.start("plan", { openingMessage: "Plan the selected requirements", - projectId: "project-a", + projectId: h.layer.projectId, sourceSessionId: older.session.id, }); @@ -118,11 +118,11 @@ describe("CE stage skill loading session options", () => { return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; }); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); - const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); - const firstPlan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: h.layer.projectId }); + const firstPlan = await orch.start("plan", { openingMessage: "Plan it", projectId: h.layer.projectId }); const finalized = readFileSync(firstPlan.session.artifactPath!, "utf8"); - const secondPlan = await orch.start("plan", { openingMessage: "Plan again", projectId: "project-a" }); + const secondPlan = await orch.start("plan", { openingMessage: "Plan again", projectId: h.layer.projectId }); expect(secondPlan.session.artifactPath).not.toBe(brainstorm.session.artifactPath); expect(readFileSync(brainstorm.session.artifactPath!, "utf8")).toBe(finalized); @@ -136,10 +136,10 @@ describe("CE stage skill loading session options", () => { return { session: makeScriptedSession([{ type: "complete", data: { artifact } }]) }; }); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); - const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: h.layer.projectId }); const original = readFileSync(brainstorm.session.artifactPath!, "utf8"); - const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: h.layer.projectId }); expect(plan.session.status).toBe("error"); expect(readFileSync(brainstorm.session.artifactPath!, "utf8")).toBe(original); @@ -154,7 +154,7 @@ describe("CE stage skill loading session options", () => { })); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); - const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: "project-a" }); + const brainstorm = await orch.start("brainstorm", { openingMessage: "Frame it", projectId: h.layer.projectId }); const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-b" }); expect(plan.session.artifactPath).not.toBe(brainstorm.session.artifactPath); @@ -165,15 +165,15 @@ describe("CE stage skill loading session options", () => { const outsideArtifact = join(outsideRoot, "requirements.md"); writeFileSync(outsideArtifact, "do not overwrite", "utf8"); const store = getCeSessionStore(h.ctx); - const seeded = store.create({ stage: "brainstorm", projectId: "project-a", artifactPath: outsideArtifact }); - store.update(seeded.id, { status: "completed" }); + const seeded = await store.createAsync({ stage: "brainstorm", projectId: h.layer.projectId, artifactPath: outsideArtifact }); + await store.updateAsync(seeded.id, { status: "completed" }); const factory: CreateInteractiveAiSessionFactory = vi.fn(async () => ({ session: makeScriptedSession([{ type: "complete", data: { artifact: "# Safe plan" } }]), })); const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); try { - const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: "project-a" }); + const plan = await orch.start("plan", { openingMessage: "Plan it", projectId: h.layer.projectId }); expect(plan.session.artifactPath).not.toBe(outsideArtifact); expect(readFileSync(outsideArtifact, "utf8")).toBe("do not overwrite"); diff --git a/plugins/fusion-plugin-compound-engineering/src/index.ts b/plugins/fusion-plugin-compound-engineering/src/index.ts index 2fa8d9d59c..7479cbb4f6 100644 --- a/plugins/fusion-plugin-compound-engineering/src/index.ts +++ b/plugins/fusion-plugin-compound-engineering/src/index.ts @@ -168,7 +168,7 @@ const plugin = definePlugin({ ctx.logger.error(`Compound Engineering agent install failed: ${message}`); } - recoverStaleSessionsForContext(ctx, { reason: "load", force: true, emitEvent: true }); + await recoverStaleSessionsForContext(ctx, { reason: "load", force: true, emitEvent: true }); }, }, // Expose the plugin-local ce-* persona-definition directory to executor / diff --git a/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts b/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts index 351decfa18..3541fac57d 100644 --- a/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts +++ b/plugins/fusion-plugin-compound-engineering/src/routes/session-routes.ts @@ -1,7 +1,7 @@ import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core"; import { CeOrchestrator } from "../session/orchestrator.js"; import { recoverStaleSessionsForContext } from "../session/session-recovery.js"; -import { asCeSessionStatus, getCeSessionStore } from "../session/session-store.js"; +import { asCeSessionStatus } from "../session/session-store.js"; import { getCePipelineStore } from "../sync/pipeline-store.js"; import { asString } from "./route-helpers.js"; @@ -112,7 +112,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] { description: "Cancel an in-flight CE session (stops the agent, keeps the row as interrupted).", handler: async (req: unknown, ctx: PluginContext): Promise => { const id = (req as RouteRequest).params.id; - const session = getOrchestrator(ctx).cancel(id); + const session = await getOrchestrator(ctx).cancel(id); if (!session) return { status: 404, body: { error: `Session ${id} not found` } }; return { status: 200, body: { session } }; }, @@ -123,8 +123,12 @@ export function createSessionRoutes(): PluginRouteDefinition[] { description: "Get current session state, including in-flight working output (liveActivity).", handler: async (req: unknown, ctx: PluginContext): Promise => { const id = (req as RouteRequest).params.id; - recoverStaleSessionsForContext(ctx, { reason: "route" }); - const session = getCeSessionStore(ctx).get(id); + await recoverStaleSessionsForContext(ctx, { reason: "route" }); + /* + * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:43: + * Single-session polling reads through the cached orchestrator so a detached turn whose PostgreSQL failure write also failed is still observably terminal in this process; ordinary and recovered sessions continue to come from the async store. + */ + const session = await getOrchestrator(ctx).getState(id); if (!session) return { status: 404, body: { error: `Session ${id} not found` } }; // Attach the orchestrator's transient mid-turn buffer so a polling // client can watch the agent work while the turn runs. @@ -140,7 +144,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] { path: "/sessions", description: "List CE sessions (optionally filtered by project/status/stage).", handler: async (req: unknown, ctx: PluginContext): Promise => { - recoverStaleSessionsForContext(ctx, { reason: "route" }); + await recoverStaleSessionsForContext(ctx, { reason: "route" }); const query = (req as RouteRequest).query ?? {}; const status = asCeSessionStatus(typeof query.status === "string" ? query.status : undefined); const stage = typeof query.stage === "string" ? query.stage : undefined; @@ -149,7 +153,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] { FNXC:CompoundEngineering 2026-07-10-23:40: Dashboard session collections must be scoped at the route/store boundary so resume, URL restoration, stage state, and history cannot expose another project's Compound Engineering runs. */ - const sessions = getCeSessionStore(ctx).list({ status, stage, projectId }); + const sessions = await getOrchestrator(ctx).listStates({ status, stage, projectId }); return { status: 200, body: { sessions } }; }, }, @@ -162,7 +166,7 @@ export function createSessionRoutes(): PluginRouteDefinition[] { // Go through the orchestrator so an in-flight live handle is disposed, // not just the row removed (a bare store.delete would leave the agent // running unobserved in this process). - const removed = getOrchestrator(ctx).discard(id); + const removed = await getOrchestrator(ctx).discard(id); if (!removed) return { status: 404, body: { error: `Session ${id} not found` } }; return { status: 200, body: { deleted: true } }; }, diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index 536d4fb846..7dedb997bc 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -13,7 +13,7 @@ import { resolveDefaultInstallTargetRoot } from "../skill-installation.js"; import { getCePipelineStore, type CePipelineStore } from "../sync/pipeline-store.js"; import { createCeTaskWithLink } from "../sync/ce-task.js"; import { getDefaultModelId, getDefaultProvider, getDisabledStages } from "../settings.js"; -import type { CeActivityTurn, CeSession, CeSessionStore } from "./session-store.js"; +import type { CeActivityTurn, CeSession, CeSessionStatus, CeSessionStore } from "./session-store.js"; import { getCeSessionStore } from "./session-store.js"; import { getStage, type CeStageDefinition } from "./stage-registry.js"; @@ -58,8 +58,10 @@ export interface CeDerivedTaskSpec { */ const DEFAULT_TURN_TIMEOUT_MS = 120000; -/** Throttle for progress-driven SSE emits + lastActivityAt bumps. */ +/** Throttle for progress-driven SSE emits. */ const PROGRESS_EMIT_INTERVAL_MS = 500; +/** Durable liveness writes are intentionally coarser than live UI events. */ +const PROGRESS_PERSIST_INTERVAL_MS = 5000; /** Caps so a runaway turn cannot grow the live buffer unbounded. */ const MAX_ACTIVITY_TURNS = 200; @@ -71,6 +73,22 @@ const MAX_PERSISTED_ACTIVITY_TURN_CHARS = 4000; const INTERACTIVE_AI_UNAVAILABLE_MESSAGE = "Session cannot be continued in this process: interactive AI sessions are unavailable (no factory on this context). Resume from a route context with the engine loaded."; +/** Excludes liveness-only timestamps so a drained heartbeat cannot supersede a terminal fallback. */ +function durableSessionMutationFingerprint(session: CeSession): string { + return JSON.stringify({ + id: session.id, + stage: session.stage, + status: session.status, + currentQuestion: session.currentQuestion, + conversationHistory: session.conversationHistory, + projectId: session.projectId, + artifactPath: session.artifactPath, + error: session.error, + turnIntervalMs: session.turnIntervalMs, + createdAt: session.createdAt, + }); +} + /** * Observable event names emitted via `ctx.emitEvent`. The no-silent-loss * invariant requires that interrupt/error ALWAYS emit one of these AND persist @@ -267,8 +285,13 @@ export class CeOrchestrator { private readonly lastProgressAt = new Map(); /** Last progress-driven emit per session (throttling). */ private readonly lastProgressEmitAt = new Map(); + /** Last liveness timestamp queued for durable persistence per session. */ + private readonly lastProgressPersistAt = new Map(); /** Sessions currently REPLAYING history (rehydrate) — progress suppressed. */ private readonly replaying = new Set(); + private readonly progressPersistence = new Map>(); + /** Process-local terminal state used only when detached failure persistence itself fails. */ + private readonly detachedFailureFallbacks = new Map(); constructor(deps: OrchestratorDeps) { this.ctx = deps.ctx; @@ -298,18 +321,18 @@ export class CeOrchestrator { * bundled skill (closing the U2/U5 skill-discovery carry-forward). Model * provider/model are setting-gated (U9); omitted keys let the host pick defaults. */ - private buildSessionOptions( + private async buildSessionOptions( stage: CeStageDefinition, sessionId: string, opts: Pick = {}, - ): Parameters[0] { + ): Promise[0]> { const defaultProvider = getDefaultProvider(this.ctx.settings); const defaultModelId = getDefaultModelId(this.ctx.settings); const additionalSkillPaths = resolveStageSkillPaths(); warnIfStageSkillMissing(this.ctx.logger, stage, additionalSkillPaths); return { cwd: this.projectRoot, - systemPrompt: this.buildSystemPrompt(stage, sessionId), + systemPrompt: await this.buildSystemPrompt(stage, sessionId), tools: "coding", requestedSkillNames: [stage.skillId], additionalSkillPaths, @@ -324,9 +347,9 @@ export class CeOrchestrator { }; } - private buildSystemPrompt(stage: CeStageDefinition, sessionId: string): string { + private async buildSystemPrompt(stage: CeStageDefinition, sessionId: string): Promise { const base = buildStageSystemPrompt(stage); - const artifactPath = this.store.get(sessionId)?.artifactPath; + const artifactPath = (await this.store.getAsync(sessionId))?.artifactPath; if (stage.stageId !== PLAN_STAGE_ID || !artifactPath) return base; return `${base}\n\nThe requirements-only unified plan is at ${artifactPath}. Read it and enrich that exact artifact in place to artifact_readiness: implementation-ready; do not create a sibling plan.`; } @@ -376,11 +399,40 @@ export class CeOrchestrator { const nowMs = Date.now(); if (nowMs - (this.lastProgressEmitAt.get(sessionId) ?? 0) >= PROGRESS_EMIT_INTERVAL_MS) { this.lastProgressEmitAt.set(sessionId, nowMs); - // Bump lastActivityAt so the staleness rubric sees an actively-working - // turn as alive; emit so push clients refetch (GET attaches the buffer). - this.store.update(sessionId, {}); this.ctx.emitEvent(CE_EVENTS.turn, { sessionId, kind: "progress" }); } + /* + * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:20: + * Keep streamed UI progress responsive at 500 ms while coalescing PostgreSQL liveness writes to five seconds. The durable write touches timestamps only and every settling path drains the queue, preventing a late heartbeat from reverting history or terminal state. + */ + if (nowMs - (this.lastProgressPersistAt.get(sessionId) ?? 0) >= PROGRESS_PERSIST_INTERVAL_MS) { + this.queueProgressPersistence(sessionId, nowMs); + } + } + + private queueProgressPersistence(sessionId: string, at: number, force = false): void { + if (!force && at - (this.lastProgressPersistAt.get(sessionId) ?? 0) < PROGRESS_PERSIST_INTERVAL_MS) return; + if (at <= (this.lastProgressPersistAt.get(sessionId) ?? 0)) return; + this.lastProgressPersistAt.set(sessionId, at); + const previous = this.progressPersistence.get(sessionId) ?? Promise.resolve(); + const pending = previous + .then(async () => { + await this.store.touchActivityAsync(sessionId, at); + }) + .catch((error: unknown) => { + this.ctx.logger.warn(`Compound Engineering progress persistence failed for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`); + }) + .finally(() => { + if (this.progressPersistence.get(sessionId) === pending) this.progressPersistence.delete(sessionId); + }); + this.progressPersistence.set(sessionId, pending); + } + + /** Flush the newest liveness timestamp before history/status changes or disposal. */ + private async drainProgressPersistence(sessionId: string): Promise { + const latest = this.lastProgressAt.get(sessionId); + if (latest !== undefined) this.queueProgressPersistence(sessionId, latest, true); + await (this.progressPersistence.get(sessionId) ?? Promise.resolve()); } /** Read the in-flight working output for a session (route accessor). */ @@ -392,7 +444,8 @@ export class CeOrchestrator { * Persist a condensed copy of the live activity buffer into history (so the * transcript keeps the working trace after the turn settles), then clear it. */ - private flushActivity(sessionId: string): void { + private async flushActivity(sessionId: string): Promise { + await this.drainProgressPersistence(sessionId); const turns = this.activity.get(sessionId); this.activity.delete(sessionId); if (!turns || turns.length === 0) return; @@ -400,7 +453,7 @@ export class CeOrchestrator { ...t, text: t.text.slice(0, MAX_PERSISTED_ACTIVITY_TURN_CHARS), })); - this.store.appendHistory(sessionId, { + await this.store.appendHistoryAsync(sessionId, { role: "agent", text: JSON.stringify({ activity: { turns: condensed } }), at: new Date().toISOString(), @@ -468,7 +521,7 @@ export class CeOrchestrator { * Brainstorm creates the requirements-only unified plan. A same-project Plan session must carry the selected completed predecessor's safe docs/plans artifact path, accept it only while it remains requirements-only, and atomically claim it with row creation so concurrent starts cannot enrich the same file; absent a compatible handoff, legacy new-file behavior remains available. */ const handoffArtifactPath = stageId === PLAN_STAGE_ID - ? this.findBrainstormHandoffArtifact(opts.projectId ?? null, opts.sourceSessionId) + ? await this.findBrainstormHandoffArtifact(opts.projectId ?? null, opts.sourceSessionId) : null; const sessionInput = { stage: stageId, @@ -477,24 +530,28 @@ export class CeOrchestrator { turnIntervalMs: this.turnTimeoutMs, }; const session = handoffArtifactPath - ? this.store.createWithPlanHandoffClaim(sessionInput, handoffArtifactPath) - : this.store.create(sessionInput); - this.store.appendHistory(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() }); - - const turn = this.runOpeningTurn(session.id, stage, opts.openingMessage); + ? await this.store.createWithPlanHandoffClaimAsync(sessionInput, handoffArtifactPath) + : await this.store.createAsync(sessionInput); + await this.store.appendHistoryAsync(session.id, { role: "user", text: opts.openingMessage, at: new Date().toISOString() }); if (opts.detach) { - // runOpeningTurn never rejects (all failures persist into session state). - void turn; - return { session: this.requireSession(session.id) }; + let accepted = await this.requireSession(session.id); + const turn = Promise.resolve().then(() => this.runOpeningTurn( + session.id, + stage, + opts.openingMessage, + (active) => { accepted = active; }, + )); + this.detachTurn(() => accepted, "opening turn", turn); + return { session: accepted }; } - return turn; + return this.runOpeningTurn(session.id, stage, opts.openingMessage); } /** Resolve the newest durable same-project Brainstorm handoff accepted for in-place Plan enrichment. */ - private findBrainstormHandoffArtifact(projectId: string | null, sourceSessionId?: string): string | null { + private async findBrainstormHandoffArtifact(projectId: string | null, sourceSessionId?: string): Promise { const candidates = sourceSessionId - ? [this.store.get(sourceSessionId)].filter((session): session is CeSession => Boolean(session)) - : this.store.list({ stage: BRAINSTORM_STAGE_ID }); + ? [await this.store.getAsync(sourceSessionId)].filter((session): session is CeSession => Boolean(session)) + : await this.store.listAsync({ stage: BRAINSTORM_STAGE_ID }); const candidate = candidates.find((session) => ( session.stage === BRAINSTORM_STAGE_ID && session.status === "completed" @@ -535,15 +592,17 @@ export class CeOrchestrator { sessionId: string, stage: CeStageDefinition, openingMessage: string, + onAccepted?: (session: CeSession) => void, ): Promise { let interactive; try { - interactive = await this.factory!(this.buildSessionOptions(stage, sessionId)); + interactive = await this.factory!(await this.buildSessionOptions(stage, sessionId)); } catch (err) { - return { session: this.failSession(sessionId, err), event: undefined }; + return { session: await this.failSession(sessionId, err), event: undefined }; } this.live.set(sessionId, interactive.session); - this.store.update(sessionId, { status: "active" }); + const active = await this.store.updateAsync(sessionId, { status: "active" }) ?? await this.requireSession(sessionId); + onAccepted?.(active); return this.runTurn(sessionId, () => interactive.session.prompt(openingMessage), interactive.session); } @@ -554,7 +613,7 @@ export class CeOrchestrator { response: unknown, opts: { detach?: boolean } = {}, ): Promise { - const session = this.requireSession(sessionId); + const session = await this.requireSession(sessionId); if (session.status !== "awaiting_input") { throw new Error(`Session ${sessionId} is not awaiting input (status=${session.status}).`); } @@ -573,20 +632,16 @@ export class CeOrchestrator { throw new Error(INTERACTIVE_AI_UNAVAILABLE_MESSAGE); } - const turn = this.runAnswerTurn(session, questionId, response); if (opts.detach) { // If the process lost its live handle, rehydration can take time. Mirror // resume(detach): mark the row active immediately while the background // turn re-creates the handle and converges through persisted state. - if (!live) { - this.store.update(sessionId, { status: "active", error: null }); - } - // runAnswerTurn never rejects after the preflight guards above (failures - // persist into session state). - void turn; - return { session: this.requireSession(sessionId) }; + const accepted = await this.store.updateAsync(sessionId, { status: "active", currentQuestion: null, error: null }) ?? session; + const turn = Promise.resolve().then(() => this.runAnswerTurn(accepted, questionId, response)); + this.detachTurn(() => accepted, "answer turn", turn); + return { session: accepted }; } - return turn; + return this.runAnswerTurn(session, questionId, response); } private async runAnswerTurn(session: CeSession, questionId: string, response: unknown): Promise { @@ -600,7 +655,7 @@ export class CeOrchestrator { throw new Error(`Session ${sessionId} could not be rehydrated with a live handle.`); } } catch (err) { - const interrupted = this.interruptSession(sessionId, err); + const interrupted = await this.interruptSession(sessionId, err); return { session: interrupted, event: { type: "error", data: { message: interrupted.error ?? "interrupted", cause: err } }, @@ -608,12 +663,12 @@ export class CeOrchestrator { } } - this.store.appendHistory(sessionId, { + await this.store.appendHistoryAsync(sessionId, { role: "user", text: JSON.stringify({ answer: response, questionId }), at: new Date().toISOString(), }); - this.store.update(sessionId, { status: "active", currentQuestion: null }); + await this.store.updateAsync(sessionId, { status: "active", currentQuestion: null }); return this.runTurn(sessionId, () => live.answer(questionId, response), live); } @@ -636,7 +691,7 @@ export class CeOrchestrator { * left `interrupted` with a clear error explaining it can't be continued here. */ async resume(sessionId: string, opts: { detach?: boolean } = {}): Promise { - const session = this.requireSession(sessionId); + const session = await this.requireSession(sessionId); // Terminal / already-answerable-with-a-live-handle cases need no rehydration. if (session.status === "completed") return { session }; @@ -647,14 +702,14 @@ export class CeOrchestrator { // No pending question → nothing to re-prime to. Mark active so the caller can // re-run the turn with fresh input (retry for `error`, resume for others). if (!session.currentQuestion) { - const next = this.store.update(sessionId, { status: "active", error: null }) ?? session; + const next = await this.store.updateAsync(sessionId, { status: "active", error: null }) ?? session; return { session: next }; } // A live handle already exists (e.g. interrupted but not disposed) — just // restore the answerable status. if (this.live.has(sessionId)) { - const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session; + const next = await this.store.updateAsync(sessionId, { status: "awaiting_input", error: null }) ?? session; return { session: next }; } @@ -664,7 +719,7 @@ export class CeOrchestrator { // Honest status: we cannot back an answerable state in this process, so do // not pretend the session is resumable here. Surface a clear error. const next = - this.store.update(sessionId, { + await this.store.updateAsync(sessionId, { status: "interrupted", error: INTERACTIVE_AI_UNAVAILABLE_MESSAGE, }) ?? session; @@ -677,18 +732,18 @@ export class CeOrchestrator { } catch (err) { // Rehydration failed — keep progress, surface the failure, do not // advertise an answerable status we can't back. - return { session: this.interruptSession(sessionId, err) }; + return { session: await this.interruptSession(sessionId, err) }; } - const next = this.store.update(sessionId, { status: "awaiting_input", error: null }) ?? session; + const next = await this.store.updateAsync(sessionId, { status: "awaiting_input", error: null }) ?? session; return { session: next }; })(); if (opts.detach) { // Rehydration replays the conversation against the live model and can be // slow; the route posture marks the session active and converges via - // push/poll. The IIFE never rejects (failures persist into state). - const next = this.store.update(sessionId, { status: "active", error: null }) ?? session; - void rehydration; + // push/poll. + const next = await this.store.updateAsync(sessionId, { status: "active", error: null }) ?? session; + this.detachTurn(() => next, "rehydration", rehydration); return { session: next }; } return rehydration; @@ -717,7 +772,7 @@ export class CeOrchestrator { private async rehydrateReplay(session: CeSession, stage: CeStageDefinition): Promise { const interactive = await this.factory!( - this.buildSessionOptions(stage, session.id, { allowAnswerQuestionIdDrift: true }), + await this.buildSessionOptions(stage, session.id, { allowAnswerQuestionIdDrift: true }), ); const live = interactive.session; @@ -770,8 +825,35 @@ export class CeOrchestrator { } /** Read-through accessor for routes. */ - getState(sessionId: string): CeSession | undefined { - return this.store.get(sessionId); + async getState(sessionId: string): Promise { + const durable = await this.store.getAsync(sessionId); + return this.resolveDetachedFailureFallback(durable, sessionId); + } + + /** + * FNXC:CompoundEngineeringConcurrency 2026-07-14-01:10: + * Session collections must expose the same effective terminal fallback as single-session polling. Apply stage/project constraints in PostgreSQL, overlay detached terminal failures, and only then evaluate status so a durably launching row cannot remain visible as running or disappear from an error-filtered list. + */ + async listStates( + filter: { status?: CeSessionStatus; stage?: string; projectId?: string } = {}, + ): Promise { + const durable = await this.store.listAsync({ stage: filter.stage, projectId: filter.projectId }); + const effective = durable.map((session) => this.resolveDetachedFailureFallback(session) ?? session); + return filter.status ? effective.filter((session) => session.status === filter.status) : effective; + } + + private resolveDetachedFailureFallback(durable: CeSession | undefined, sessionId = durable?.id): CeSession | undefined { + if (!durable) { + if (sessionId) this.detachedFailureFallbacks.delete(sessionId); + return undefined; + } + const fallback = this.detachedFailureFallbacks.get(durable.id); + if (!fallback) return durable; + if (durableSessionMutationFingerprint(durable) !== fallback.durableFingerprint) { + this.detachedFailureFallbacks.delete(durable.id); + return durable; + } + return fallback.session; } /** @@ -780,8 +862,8 @@ export class CeOrchestrator { * preserves the conversation and progress; discard stops the handle AND deletes * the row. Terminal sessions are idempotent no-ops. */ - cancel(sessionId: string): CeSession | undefined { - const session = this.store.get(sessionId); + async cancel(sessionId: string): Promise { + const session = await this.store.getAsync(sessionId); if (!session) return undefined; if (session.status === "completed" || session.status === "error" || session.status === "interrupted") { return session; @@ -789,7 +871,7 @@ export class CeOrchestrator { // Preserve no-silent-loss ordering: interruptSession flushes live activity // before disposeLive clears the transient buffers (same as runTurn failure). - const interrupted = this.interruptSession(sessionId, new Error("Cancelled by user")); + const interrupted = await this.interruptSession(sessionId, new Error("Cancelled by user")); this.disposeLive(sessionId); return interrupted; } @@ -800,9 +882,12 @@ export class CeOrchestrator { * Returns false when the session doesn't exist. Pipeline-link rows are NOT * touched — board tasks the session landed keep their provenance records. */ - discard(sessionId: string): boolean { + async discard(sessionId: string): Promise { + await this.drainProgressPersistence(sessionId); this.disposeLive(sessionId); - return this.store.delete(sessionId); + const deleted = await this.store.deleteAsync(sessionId); + if (deleted) this.detachedFailureFallbacks.delete(sessionId); + return deleted; } /** @@ -829,7 +914,7 @@ export class CeOrchestrator { // Timeout or driver throw → auto-save as interrupted (progress preserved) // and emit an observable event. Never silent loss. watchdog.cancel(); - const session = this.interruptSession(sessionId, err); + const session = await this.interruptSession(sessionId, err); this.disposeLive(sessionId); return { session, event: { type: "error", data: { message: session.error ?? "interrupted", cause: err } } }; } @@ -837,9 +922,9 @@ export class CeOrchestrator { let session: CeSession; try { - session = this.applyEvent(sessionId, event); + session = await this.applyEvent(sessionId, event); } catch (error) { - session = this.failSession(sessionId, error); + session = await this.failSession(sessionId, error); this.disposeLive(sessionId); return { session, @@ -859,41 +944,42 @@ export class CeOrchestrator { } /** Persist a seam event onto the session row + emit the matching observable event. */ - private applyEvent(sessionId: string, event: InteractiveAiSessionEvent): CeSession { + private async applyEvent(sessionId: string, event: InteractiveAiSessionEvent): Promise { + await this.drainProgressPersistence(sessionId); // The turn settled — persist its working trace into history (so the // transcript keeps it) BEFORE the settling record, then clear the buffer. if (event.type === "question" || event.type === "complete" || event.type === "error") { - this.flushActivity(sessionId); + await this.flushActivity(sessionId); } switch (event.type) { case "thinking": case "text": { - this.store.appendHistory(sessionId, { role: "agent", text: event.data, at: new Date().toISOString() }); - const s = this.store.update(sessionId, { status: "active" }) ?? this.requireSession(sessionId); + await this.store.appendHistoryAsync(sessionId, { role: "agent", text: event.data, at: new Date().toISOString() }); + const s = await this.store.updateAsync(sessionId, { status: "active" }) ?? await this.requireSession(sessionId); this.ctx.emitEvent(CE_EVENTS.turn, { sessionId, kind: event.type }); return s; } case "question": { const q: PlanningQuestion = event.data; - this.store.appendHistory(sessionId, { + await this.store.appendHistoryAsync(sessionId, { role: "agent", text: JSON.stringify({ question: q }), at: new Date().toISOString(), }); - const s = this.store.update(sessionId, { status: "awaiting_input", currentQuestion: q }) ?? this.requireSession(sessionId); + const s = await this.store.updateAsync(sessionId, { status: "awaiting_input", currentQuestion: q }) ?? await this.requireSession(sessionId); this.ctx.emitEvent(CE_EVENTS.question, { sessionId, questionId: q.id }); return s; } case "complete": { - const artifactPath = this.writeArtifact(sessionId, event.data); - this.store.appendHistory(sessionId, { + const artifactPath = await this.writeArtifact(sessionId, event.data); + await this.store.appendHistoryAsync(sessionId, { role: "agent", text: JSON.stringify({ complete: true }), at: new Date().toISOString(), }); const s = - this.store.update(sessionId, { status: "completed", currentQuestion: null, artifactPath }) ?? - this.requireSession(sessionId); + await this.store.updateAsync(sessionId, { status: "completed", currentQuestion: null, artifactPath }) ?? + await this.requireSession(sessionId); this.ctx.emitEvent(CE_EVENTS.completed, { sessionId, artifactPath }); return s; } @@ -901,7 +987,7 @@ export class CeOrchestrator { const message = event.data.message; // Error preserves progress (currentQuestion/history untouched) so retry // can resume. Status error; observable event emitted. - const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId); + const s = await this.store.updateAsync(sessionId, { status: "error", error: message }) ?? await this.requireSession(sessionId); this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message }); return s; } @@ -973,31 +1059,71 @@ export class CeOrchestrator { } /** Persist `interrupted` with progress preserved and emit. */ - private interruptSession(sessionId: string, cause: unknown): CeSession { + private async interruptSession(sessionId: string, cause: unknown): Promise { // Keep the working trace: an interrupted turn's output is exactly what the // user needs to see to understand where it stopped. - this.flushActivity(sessionId); + await this.flushActivity(sessionId); const message = cause instanceof Error ? cause.message : String(cause); - const s = - this.store.update(sessionId, { status: "interrupted", error: message }) ?? this.requireSession(sessionId); - this.ctx.emitEvent(CE_EVENTS.interrupted, { sessionId, message }); - return s; + return this.persistTerminalState(sessionId, "interrupted", message); } /** Persist `error` (session-create failure path) and emit. */ - private failSession(sessionId: string, cause: unknown): CeSession { + private async failSession(sessionId: string, cause: unknown): Promise { + await this.drainProgressPersistence(sessionId); const message = cause instanceof Error ? cause.message : String(cause); - const s = this.store.update(sessionId, { status: "error", error: message }) ?? this.requireSession(sessionId); - this.ctx.emitEvent(CE_EVENTS.error, { sessionId, message }); - return s; + return this.persistTerminalState(sessionId, "error", message); + } + + /** + * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:58: + * Every detached terminal transition must be observable even when PostgreSQL returns no updated row. Error and interrupted writes share one process-local fallback that ignores heartbeat-only timestamp movement but yields to any later semantic durable mutation. + */ + private async persistTerminalState( + sessionId: string, + status: "error" | "interrupted", + message: string, + ): Promise { + const durable = await this.store.updateAsync(sessionId, { status, error: message }) ?? await this.requireSession(sessionId); + if (durable.status === "completed" || durable.status === "error" || durable.status === "interrupted") { + if (durable.status === status) { + this.ctx.emitEvent(status === "error" ? CE_EVENTS.error : CE_EVENTS.interrupted, { sessionId, message }); + } + return durable; + } + return this.retainTerminalFallback(durable, status, message); + } + + private retainTerminalFallback( + durable: CeSession, + status: "error" | "interrupted", + message: string, + ): CeSession { + const failedAt = Date.now(); + const fallback: CeSession = { + ...durable, + status, + error: message, + lastActivityAt: failedAt, + updatedAt: new Date(failedAt).toISOString(), + }; + this.detachedFailureFallbacks.set(durable.id, { + durableFingerprint: durableSessionMutationFingerprint(durable), + session: fallback, + }); + this.disposeLive(durable.id); + this.ctx.emitEvent(status === "error" ? CE_EVENTS.error : CE_EVENTS.interrupted, { + sessionId: durable.id, + message, + }); + return fallback; } /** * Write the stage artifact to its conventional location (R10). Accepts either * a `{ artifact: string }` payload or a raw string. Returns the absolute path. */ - private writeArtifact(sessionId: string, data: unknown): string { - const session = this.requireSession(sessionId); + private async writeArtifact(sessionId: string, data: unknown): Promise { + const session = await this.requireSession(sessionId); const stage = getStage(session.stage); const location = stage?.artifactLocation ?? `docs/ce/${session.stage}/`; const content = this.extractArtifactContent(data); @@ -1038,12 +1164,39 @@ export class CeOrchestrator { return JSON.stringify(data, null, 2); } - private requireSession(sessionId: string): CeSession { - const s = this.store.get(sessionId); + private async requireSession(sessionId: string): Promise { + const s = await this.store.getAsync(sessionId); if (!s) throw new Error(`CE session not found: ${sessionId}`); return s; } + /** + * FNXC:CompoundEngineeringConcurrency 2026-07-14-01:57: + * Route-detached model work must capture its accepted semantic state before arming the background rejection handler. If PostgreSQL reads and the terminal write both fail afterward, that snapshot seeds the shared process-local fallback; heartbeat timestamps remain non-semantic and later durable semantic mutations still supersede it. + */ + private detachTurn(accepted: () => CeSession, label: string, operation: Promise): void { + const sessionId = accepted().id; + void operation.catch(async (cause: unknown) => { + const message = cause instanceof Error ? cause.message : String(cause); + this.ctx.logger.error(`Compound Engineering detached ${label} failed for ${sessionId}: ${message}`); + let session = accepted(); + try { + const current = await this.store.getAsync(sessionId); + if (!current) return; + session = current; + } catch (readError) { + this.ctx.logger.error(`Compound Engineering could not read detached ${label} failure state for ${sessionId}: ${readError instanceof Error ? readError.message : String(readError)}`); + } + if (session.status === "completed" || session.status === "error" || session.status === "interrupted") return; + try { + await this.failSession(sessionId, cause); + } catch (persistError) { + this.ctx.logger.error(`Compound Engineering could not persist detached ${label} failure for ${sessionId}: ${persistError instanceof Error ? persistError.message : String(persistError)}`); + this.retainTerminalFallback(session, "error", message); + } + }); + } + private disposeLive(sessionId: string): void { const live = this.live.get(sessionId); if (live) { @@ -1057,5 +1210,6 @@ export class CeOrchestrator { this.activity.delete(sessionId); this.lastProgressAt.delete(sessionId); this.lastProgressEmitAt.delete(sessionId); + this.lastProgressPersistAt.delete(sessionId); } } diff --git a/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts b/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts index 63386bf1aa..443e68c060 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/session-recovery.ts @@ -18,10 +18,10 @@ interface RecoverStaleSessionsOptions { * their in-memory agent handle. Route callers use a TTL because the individual * session endpoint is also the dashboard polling fallback. */ -export function recoverStaleSessionsForContext( +export async function recoverStaleSessionsForContext( ctx: PluginContext, options: RecoverStaleSessionsOptions, -): string[] { +): Promise { const key = ctx.taskStore as object; const now = options.now ?? Date.now(); const ttlMs = options.ttlMs ?? DEFAULT_RECOVERY_SCAN_TTL_MS; @@ -32,7 +32,7 @@ export function recoverStaleSessionsForContext( lastRecoveryScanAt.set(key, now); try { - const recovered = getCeSessionStore(ctx).recoverStaleSessions(now); + const recovered = await getCeSessionStore(ctx).recoverStaleSessionsAsync(now); if (recovered.length > 0) { ctx.logger.info(`Compound Engineering recovered stale session(s) during ${options.reason}: ${recovered.join(", ")}`); if (options.emitEvent) { diff --git a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts index 9efbeee9d8..85f28b4bed 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/session-store.ts @@ -1,5 +1,7 @@ import { randomUUID } from "node:crypto"; -import type { Database, PlanningQuestion, PluginContext } from "@fusion/core"; +import { type AsyncDataLayer, type Database, type PlanningQuestion, type PluginContext } from "@fusion/core"; +import { sql } from "drizzle-orm"; +/* FNXC:CompoundEngineeringPostgres 2026-07-13-23:42: Import SQL construction from Drizzle directly because the CLI's bundled-plugin @fusion/core shim does not expose database query builders. */ import { ensureCeSchema } from "../schema.js"; /** @@ -103,6 +105,19 @@ export class PlanHandoffClaimError extends Error { } } +function isUniqueConstraintError(error: unknown): boolean { + let current: unknown = error; + const seen = new Set(); + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + const candidate = current as { code?: unknown; message?: unknown; cause?: unknown }; + if (candidate.code === "23505") return true; + if (typeof candidate.message === "string" && /duplicate key|unique constraint/i.test(candidate.message)) return true; + current = candidate.cause; + } + return false; +} + /** * Default multiple of the turn interval beyond which a non-terminal session is * considered stale. Mirrors the FN-4172 rubric (`> 3× interval`), interval- @@ -161,7 +176,7 @@ function rowToSession(row: CeSessionRow): CeSession { artifactPath: row.artifactPath, error: row.error, turnIntervalMs: row.turnIntervalMs, - lastActivityAt: row.lastActivityAt, + lastActivityAt: Number(row.lastActivityAt), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -179,7 +194,7 @@ export class CeSessionStore { // SQLite will throw in backend mode until the async path is implemented. private readonly db: Database | null; - constructor(db: Database | null) { + constructor(db: Database | null, private readonly asyncLayer: AsyncDataLayer | null = null) { this.db = db; if (db) ensureCeSchema(db); } @@ -390,6 +405,132 @@ export class CeSessionStore { } return recovered; } + + /** + * FNXC:CompoundEngineeringPostgresPersistence 2026-07-13-22:37: + * Session orchestration uses these async siblings so backend mode persists through the project-bound AsyncDataLayer while SQLite callers retain their established synchronous API. PostgreSQL reads ignore caller-supplied cross-project filters and always enforce the layer's project ID. + */ + async createAsync(input: CreateCeSessionInput): Promise { + if (!this.asyncLayer) return this.create(input); + const projectId = this.requireProjectId(); + const session = this.newSession({ ...input, projectId }); + await this.insertAsync(session); + return session; + } + + async createWithPlanHandoffClaimAsync(input: CreateCeSessionInput, artifactPath: string): Promise { + if (!this.asyncLayer) return this.createWithPlanHandoffClaim(input, artifactPath); + const projectId = this.requireProjectId(); + const session = this.newSession({ ...input, projectId, artifactPath }); + try { + await this.asyncLayer.transactionImmediate(async (tx) => { + await tx.execute(sql`INSERT INTO project.ce_sessions + (id, stage, status, current_question, conversation_history, project_id, artifact_path, error, turn_interval_ms, last_activity_at, created_at, updated_at) + VALUES(${session.id}, ${session.stage}, ${session.status}, NULL, '[]', ${projectId}, ${artifactPath}, NULL, ${session.turnIntervalMs}, ${session.lastActivityAt}, ${session.createdAt}, ${session.updatedAt})`); + await tx.execute(sql`INSERT INTO project.ce_plan_handoff_claims(project_id, artifact_path, session_id, created_at) + VALUES(${projectId}, ${artifactPath}, ${session.id}, ${session.createdAt})`); + }); + } catch (error) { + if (isUniqueConstraintError(error)) { + const rows = await this.asyncLayer.db.execute(sql`SELECT session_id FROM project.ce_plan_handoff_claims WHERE project_id=${projectId} AND artifact_path=${artifactPath} LIMIT 1`) as unknown as Array<{ session_id: string }>; + throw new PlanHandoffClaimError(artifactPath, rows[0]?.session_id ?? "unknown"); + } + throw error; + } + return session; + } + + private requireProjectId(): string { + const projectId = this.asyncLayer?.projectId; + if (!projectId) throw new Error("CE PostgreSQL persistence requires a project-bound data layer"); + return projectId; + } + + private async insertAsync(session: CeSession): Promise { + await this.asyncLayer!.db.execute(sql`INSERT INTO project.ce_sessions + (id, stage, status, current_question, conversation_history, project_id, artifact_path, error, turn_interval_ms, last_activity_at, created_at, updated_at) + VALUES(${session.id}, ${session.stage}, ${session.status}, NULL, ${JSON.stringify(session.conversationHistory)}, ${session.projectId}, ${session.artifactPath}, NULL, ${session.turnIntervalMs}, ${session.lastActivityAt}, ${session.createdAt}, ${session.updatedAt})`); + } + + async getAsync(id: string): Promise { + if (!this.asyncLayer) return this.get(id); + const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id} LIMIT 1`) as unknown as CeSessionRow[]; + return rows[0] ? rowToSession(rows[0]) : undefined; + } + + async listAsync(filter: { status?: CeSessionStatus; stage?: string; projectId?: string } = {}): Promise { + if (!this.asyncLayer) return this.list(filter); + const projectId = this.requireProjectId(); + const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${projectId} AND (${filter.status ?? null}::text IS NULL OR status=${filter.status ?? null}) AND (${filter.stage ?? null}::text IS NULL OR stage=${filter.stage ?? null}) ORDER BY updated_at DESC, id`) as unknown as CeSessionRow[]; + return rows.map(rowToSession); + } + + async updateAsync(id: string, patch: Partial>): Promise { + if (!this.asyncLayer) return this.update(id, patch); + const projectId = this.requireProjectId(); + const updatedAt = new Date().toISOString(); + const lastActivityAt = patch.lastActivityAt ?? Date.now(); + const has = (key: keyof typeof patch): boolean => Object.prototype.hasOwnProperty.call(patch, key); + /* + * FNXC:CompoundEngineeringConcurrency 2026-07-14-00:18: + * PostgreSQL session patches must update only the fields named by the caller. A prior read-modify-write rewrote the entire row, allowing a delayed heartbeat to restore stale history or a pre-terminal status after a question/completion had committed. + */ + const rows = await this.asyncLayer.db.execute(sql` + UPDATE project.ce_sessions SET + status = CASE WHEN ${has("status")} THEN ${patch.status ?? null}::text ELSE status END, + current_question = CASE WHEN ${has("currentQuestion")} THEN ${patch.currentQuestion ? JSON.stringify(patch.currentQuestion) : null}::text ELSE current_question END, + conversation_history = CASE WHEN ${has("conversationHistory")} THEN ${patch.conversationHistory ? JSON.stringify(patch.conversationHistory) : null}::text ELSE conversation_history END, + artifact_path = CASE WHEN ${has("artifactPath")} THEN ${patch.artifactPath ?? null}::text ELSE artifact_path END, + error = CASE WHEN ${has("error")} THEN ${patch.error ?? null}::text ELSE error END, + last_activity_at = ${lastActivityAt}, + updated_at = ${updatedAt} + WHERE project_id=${projectId} AND id=${id} + RETURNING id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" + `) as unknown as CeSessionRow[]; + return rows[0] ? rowToSession(rows[0]) : undefined; + } + + /** Atomically append one history turn so simultaneous progress/terminal writes cannot drop either turn. */ + async appendHistoryAsync(id: string, turn: CeConversationTurn): Promise { + if (!this.asyncLayer) return this.appendHistory(id, turn); + const projectId = this.requireProjectId(); + const now = Date.now(); + const updatedAt = new Date(now).toISOString(); + const rows = await this.asyncLayer.db.execute(sql` + UPDATE project.ce_sessions SET + conversation_history = ((conversation_history::jsonb || jsonb_build_array(${JSON.stringify(turn)}::jsonb))::text), + last_activity_at = ${now}, + updated_at = ${updatedAt} + WHERE project_id=${projectId} AND id=${id} + RETURNING id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" + `) as unknown as CeSessionRow[]; + return rows[0] ? rowToSession(rows[0]) : undefined; + } + + /** Atomically touch only liveness columns; safe to run beside terminal/history mutations. */ + async touchActivityAsync(id: string, at = Date.now()): Promise { + if (!this.asyncLayer) return Boolean(this.update(id, { lastActivityAt: at })); + const rows = await this.asyncLayer.db.execute(sql` + UPDATE project.ce_sessions + SET last_activity_at=${at}, updated_at=${new Date(at).toISOString()} + WHERE project_id=${this.requireProjectId()} AND id=${id} + RETURNING id + `) as unknown as Array<{ id: string }>; + return rows.length > 0; + } + async deleteAsync(id: string): Promise { if (!this.asyncLayer) return this.delete(id); const existing = await this.getAsync(id); if (!existing) return false; await this.asyncLayer.db.execute(sql`DELETE FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND id=${id}`); return true; } + async recoverStaleSessionsAsync(now = Date.now(), multiple = STALE_INTERVAL_MULTIPLE): Promise { + if (!this.asyncLayer) return this.recoverStaleSessions(now, multiple); + const rows = await this.asyncLayer.db.execute(sql`SELECT id, stage, status, current_question AS "currentQuestion", conversation_history AS "conversationHistory", project_id AS "projectId", artifact_path AS "artifactPath", error, turn_interval_ms AS "turnIntervalMs", last_activity_at AS "lastActivityAt", created_at AS "createdAt", updated_at AS "updatedAt" FROM project.ce_sessions WHERE project_id=${this.requireProjectId()} AND status IN ('active', 'launching') AND last_activity_at < (${now}::bigint - (${multiple}::bigint * turn_interval_ms::bigint))`) as unknown as CeSessionRow[]; + const candidates = rows.map(rowToSession); + await Promise.all(candidates.map((session) => this.updateAsync( + session.id, + session.currentQuestion + ? { status: "awaiting_input" } + : { status: "interrupted", error: session.error ?? "Session interrupted — progress preserved, resume to continue" }, + ))); + return candidates.map((session) => session.id); + } } const storeCache = new WeakMap(); @@ -401,8 +542,9 @@ export function getCeSessionStore(ctx: PluginContext): CeSessionStore { if (cached) return cached; // FNXC:RuntimeSatelliteAsync 2026-06-24-22:40: // In backend mode, getDatabase() throws. Guard with isBackendMode() check. - const db = ctx.taskStore.isBackendMode() ? null : ctx.taskStore.getDatabase(); - const store = new CeSessionStore(db); + const layer = ctx.taskStore.getAsyncLayer(); + const db = layer ? null : ctx.taskStore.getDatabase(); + const store = new CeSessionStore(db, layer); storeCache.set(key, store); return store; } diff --git a/plugins/fusion-plugin-compound-engineering/vitest.config.ts b/plugins/fusion-plugin-compound-engineering/vitest.config.ts index 81835fee49..ac39cc7f7a 100644 --- a/plugins/fusion-plugin-compound-engineering/vitest.config.ts +++ b/plugins/fusion-plugin-compound-engineering/vitest.config.ts @@ -27,24 +27,9 @@ FNXC:CompoundEngineeringTests 2026-06-17-19:56: FN-6606 re-ran the loaded CE node/package lane with sync.test.ts and work-bridge.test.ts temporarily unexcluded and could not reproduce either the 5000ms test timeout or the later 10000ms hook timeout. The current HEAD's shared test-isolation fixes now keep the broad lane stable, so restore both files to active coverage and clear the stale quarantine in lockstep with scripts/lib/test-quarantine.json. -FNXC:CompoundEngineeringTests 2026-06-25-11:55: -The SQLite-to-PostgreSQL cutover (feature quarantine-sqlite-internals-tests) quarantines CE plugin tests that fail with 'ctx.taskStore.isBackendMode is not a function' — pre-existing mock drift where the plugin session-store now calls ctx.taskStore.isBackendMode() but the orchestrator/session test mocks do not expose it. Confirmed failing on clean baseline (stash + rerun). Mirrored in scripts/lib/test-quarantine.json; rescue requires updating the CE test mocks to expose isBackendMode/getAsyncLayer. +FNXC:CompoundEngineeringTests 2026-07-13-22:37: +The PostgreSQL session-store port uses getAsyncLayer with a safe SQLite fallback, so the six session/orchestrator files previously excluded for stale TaskStore mocks are active coverage again. The quarantine ledger contains no matching entries; configuration and coverage must remain aligned. */ -const quarantinedCompoundEngineeringTests = [ - // Pre-existing mock drift (isBackendMode not on mock TaskStore): see scripts/lib/test-quarantine.json. - "src/__tests__/orchestrator-cancel.test.ts", - "src/__tests__/orchestrator-executor-seam.test.ts", - "src/__tests__/orchestrator-interrupt-resume.test.ts", - "src/__tests__/orchestrator-live-output.test.ts", - "src/__tests__/session-routes.test.ts", - "src/__tests__/stage-launch-guard.test.ts", - // SQLite-path test, code being removed (delete-sqlite-runtime-final PHASE A): - // constructs a SQLite-backed store. Mirrored in scripts/lib/test-quarantine.json. - // SQLite-path (delete-sqlite-runtime-final SESSION 3 PHASE A): import _harness.ts - // which constructs new Database({inMemory:true}). SQLite runtime being deleted. - // SQLite-path (delete-sqlite-runtime-final SESSION 3 PHASE A): uses makeHarness() - // via _harness.ts which constructs new Database({inMemory:true}). -]; const nodeOnlyDashboardTests = [ "src/dashboard/__tests__/theme-tokens.test.ts", ]; @@ -61,6 +46,10 @@ export default defineConfig({ replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)), }, { find: "@fusion/core", replacement: fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)) }, + { + find: "@fusion/test-utils/pg-test-harness", + replacement: fileURLToPath(new URL("../../packages/core/src/__test-utils__/pg-test-harness.ts", import.meta.url)), + }, { find: "@fusion/plugin-sdk", replacement: fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)), @@ -109,7 +98,6 @@ export default defineConfig({ exclude: [ "src/dashboard/**/__tests__/**/*.test.{ts,tsx}", "src/dashboard/**/*.test.{ts,tsx}", - ...quarantinedCompoundEngineeringTests, ], }, }, diff --git a/plugins/fusion-plugin-roadmap/package.json b/plugins/fusion-plugin-roadmap/package.json index e41ceb2572..72c222f861 100644 --- a/plugins/fusion-plugin-roadmap/package.json +++ b/plugins/fusion-plugin-roadmap/package.json @@ -29,6 +29,7 @@ "dependencies": { "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*", + "drizzle-orm": "^0.45.2", "express": "^5.1.0", "lucide-react": "^0.542.0", "react": "^19.0.0", diff --git a/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts b/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts new file mode 100644 index 0000000000..3f48a75a38 --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/__tests__/roadmap-store.pg.test.ts @@ -0,0 +1,607 @@ +/* + * FNXC:RoadmapPostgresPersistence 2026-07-13-23:40: + * Canonical PostgreSQL coverage exercises the full mutable hierarchy, lifecycle event parity, exports/handoffs, ownership validation, a populated second project, and safe upgrade behavior for pre-partition rows. + */ +import { expect, it, vi } from "vitest"; +import { sql } from "drizzle-orm"; +import type { AsyncDataLayer } from "@fusion/core"; +import { + createTaskStoreForTest, + pgDescribe, +} from "../../../../packages/core/src/__test-utils__/pg-test-harness.js"; +import { roadmapPluginSchemaInit } from "../../../../packages/core/src/postgres/plugin-schema-hook.js"; +import { AsyncRoadmapStore } from "../store/async-roadmap-store.js"; + +function bind(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { + return { ...layer, projectId }; +} + +function errorChain(error: unknown): string { + const messages: string[] = []; + let current = error; + while (current instanceof Error) { + messages.push(current.message); + current = current.cause; + } + return messages.join("\n"); +} + +function deferred(): { + promise: Promise; + resolve: () => void; +} { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** + * FNXC:RoadmapOrderingConcurrency 2026-07-14-00:43: + * PostgreSQL ordering regressions coordinate at the advisory-lock query itself. The first caller holds its real transaction after acquiring the lock, while the second caller signals its lock attempt before PostgreSQL blocks it; this proves serialization without sleeps, polling, or timeout-based assertions. + */ +function controlledOrderingLayer( + layer: AsyncDataLayer, + mode: "hold-after-first-query" | "signal-first-query", +): { + layer: AsyncDataLayer; + reached: Promise; + release: () => void; +} { + const reached = deferred(); + const release = deferred(); + let firstTransaction = true; + const controlled = { + ...layer, + transactionImmediate: async ( + fn: Parameters[0], + options?: Parameters[1], + ): Promise => layer.transactionImmediate(async (tx) => { + if (!firstTransaction) + return fn(tx) as Promise; + firstTransaction = false; + let firstQuery = true; + const proxy = new Proxy(tx, { + get(target, property, receiver) { + if (property !== "execute") + return Reflect.get(target, property, receiver); + return async (...args: Parameters) => { + if (!firstQuery) + return tx.execute(...args); + firstQuery = false; + if (mode === "signal-first-query") { + reached.resolve(); + return tx.execute(...args); + } + const result = await tx.execute(...args); + reached.resolve(); + await release.promise; + return result; + }; + }, + }); + return fn(proxy) as Promise; + }, options), + } as AsyncDataLayer; + return { layer: controlled, reached: reached.promise, release: release.resolve }; +} + +pgDescribe("AsyncRoadmapStore", () => { + it("serializes concurrent milestone appends at the roadmap lock", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_milestone_create_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ + title: "Concurrent milestone creates", + }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const first = new AsyncRoadmapStore(firstControl.layer); + const second = new AsyncRoadmapStore(secondControl.layer); + + const firstCreate = first.createMilestone(roadmap.id, { title: "First" }); + await firstControl.reached; + const secondCreate = second.createMilestone(roadmap.id, { + title: "Second", + }); + await secondControl.reached; + firstControl.release(); + await Promise.all([firstCreate, secondCreate]); + + expect( + (await setup.listMilestones(roadmap.id)).map( + (item) => item.orderIndex, + ), + ).toEqual([0, 1]); + } finally { + await h.teardown(); + } + }); + + it("serializes concurrent feature appends at the roadmap lock", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_feature_create_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ + title: "Concurrent feature creates", + }); + const milestone = await setup.createMilestone(roadmap.id, { + title: "Milestone", + }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const first = new AsyncRoadmapStore(firstControl.layer); + const second = new AsyncRoadmapStore(secondControl.layer); + + const firstCreate = first.createFeature(milestone.id, { title: "First" }); + await firstControl.reached; + const secondCreate = second.createFeature(milestone.id, { + title: "Second", + }); + await secondControl.reached; + firstControl.release(); + await Promise.all([firstCreate, secondCreate]); + + expect( + (await setup.listFeatures(milestone.id)).map( + (item) => item.orderIndex, + ), + ).toEqual([0, 1]); + } finally { + await h.teardown(); + } + }); + + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:24: + * Deterministic PostgreSQL races cover create/create and create/reorder surfaces. A reorder queued behind an append must validate against the committed hierarchy and reject an obsolete client order instead of erasing the append's position. + */ + it("revalidates a feature reorder after a concurrent append commits", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_create_reorder_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Create then reorder" }); + const milestone = await setup.createMilestone(roadmap.id, { + title: "Milestone", + }); + const alpha = await setup.createFeature(milestone.id, { title: "Alpha" }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const creator = new AsyncRoadmapStore(firstControl.layer); + const reorderer = new AsyncRoadmapStore(secondControl.layer); + + const create = creator.createFeature(milestone.id, { title: "Beta" }); + await firstControl.reached; + const reorder = reorderer.reorderFeatures({ + roadmapId: roadmap.id, + milestoneId: milestone.id, + orderedFeatureIds: [alpha.id], + }); + await secondControl.reached; + firstControl.release(); + const beta = await create; + await expect(reorder).rejects.toThrow( + "Expected 2 feature ids but received 1", + ); + + expect((await setup.listFeatures(milestone.id)).map((item) => item.id)).toEqual([ + alpha.id, + beta.id, + ]); + } finally { + await h.teardown(); + } + }); + + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:32: + * Deterministic delete/reorder races prove destructive hierarchy changes hold the roadmap lock through commit. Queued reorders must reject stale complete-ID lists after the delete, while remaining siblings retain the established SQLite gap semantics. + */ + it("revalidates a feature reorder after a concurrent delete commits", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_feature_delete_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Delete feature" }); + const milestone = await setup.createMilestone(roadmap.id, { + title: "Milestone", + }); + const alpha = await setup.createFeature(milestone.id, { title: "Alpha" }); + const beta = await setup.createFeature(milestone.id, { title: "Beta" }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const deleter = new AsyncRoadmapStore(firstControl.layer); + const reorderer = new AsyncRoadmapStore(secondControl.layer); + + const deletion = deleter.deleteFeature(alpha.id); + await firstControl.reached; + const reorder = reorderer.reorderFeatures({ + roadmapId: roadmap.id, + milestoneId: milestone.id, + orderedFeatureIds: [beta.id, alpha.id], + }); + await secondControl.reached; + firstControl.release(); + await deletion; + await expect(reorder).rejects.toThrow( + "Expected 1 feature ids but received 2", + ); + + expect(await setup.listFeatures(milestone.id)).toEqual([ + expect.objectContaining({ id: beta.id, orderIndex: 1 }), + ]); + } finally { + await h.teardown(); + } + }); + + it("revalidates a milestone reorder after a concurrent delete commits", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_milestone_delete_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Delete milestone" }); + const first = await setup.createMilestone(roadmap.id, { title: "First" }); + const second = await setup.createMilestone(roadmap.id, { + title: "Second", + }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const deleter = new AsyncRoadmapStore(firstControl.layer); + const reorderer = new AsyncRoadmapStore(secondControl.layer); + + const deletion = deleter.deleteMilestone(first.id); + await firstControl.reached; + const reorder = reorderer.reorderMilestones({ + roadmapId: roadmap.id, + orderedMilestoneIds: [second.id, first.id], + }); + await secondControl.reached; + firstControl.release(); + await deletion; + await expect(reorder).rejects.toThrow( + "Expected 1 milestone ids but received 2", + ); + + expect(await setup.listMilestones(roadmap.id)).toEqual([ + expect.objectContaining({ id: second.id, orderIndex: 1 }), + ]); + } finally { + await h.teardown(); + } + }); + + it("serializes roadmap cascade deletion against milestone reorder", async () => { + const h = await createTaskStoreForTest({ + prefix: "roadmap_delete_concurrency", + }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Delete roadmap" }); + const milestone = await setup.createMilestone(roadmap.id, { + title: "Milestone", + }); + const firstControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "hold-after-first-query", + ); + const secondControl = controlledOrderingLayer( + bind(h.layer, "project-a"), + "signal-first-query", + ); + const deleter = new AsyncRoadmapStore(firstControl.layer); + const reorderer = new AsyncRoadmapStore(secondControl.layer); + + const deletion = deleter.deleteRoadmap(roadmap.id); + await firstControl.reached; + const reorder = reorderer.reorderMilestones({ + roadmapId: roadmap.id, + orderedMilestoneIds: [milestone.id], + }); + await secondControl.reached; + firstControl.release(); + await deletion; + await expect(reorder).rejects.toThrow(`Roadmap ${roadmap.id} not found`); + expect(await setup.getMilestone(milestone.id)).toBeUndefined(); + } finally { + await h.teardown(); + } + }); + + it("serializes concurrent feature moves against the committed roadmap ordering", async () => { + const h = await createTaskStoreForTest({ prefix: "roadmap_move_concurrency" }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Concurrent moves" }); + const source = await setup.createMilestone(roadmap.id, { title: "Source" }); + const target = await setup.createMilestone(roadmap.id, { title: "Target" }); + const alpha = await setup.createFeature(source.id, { title: "Alpha" }); + const beta = await setup.createFeature(source.id, { title: "Beta" }); + const gamma = await setup.createFeature(source.id, { title: "Gamma" }); + const delta = await setup.createFeature(target.id, { title: "Delta" }); + const firstControl = controlledOrderingLayer(bind(h.layer, "project-a"), "hold-after-first-query"); + const secondControl = controlledOrderingLayer(bind(h.layer, "project-a"), "signal-first-query"); + const first = new AsyncRoadmapStore(firstControl.layer); + const second = new AsyncRoadmapStore(secondControl.layer); + + const firstMove = first.moveFeature({ + roadmapId: roadmap.id, + featureId: gamma.id, + fromMilestoneId: source.id, + toMilestoneId: target.id, + targetOrderIndex: 0, + }); + await firstControl.reached; + const secondMove = second.moveFeature({ + roadmapId: roadmap.id, + featureId: beta.id, + fromMilestoneId: source.id, + toMilestoneId: target.id, + targetOrderIndex: 0, + }); + await secondControl.reached; + firstControl.release(); + await Promise.all([firstMove, secondMove]); + + expect((await setup.listFeatures(source.id)).map((item) => item.id)).toEqual([alpha.id]); + expect((await setup.listFeatures(target.id)).map((item) => item.id)).toEqual([ + beta.id, + gamma.id, + delta.id, + ]); + } finally { + await h.teardown(); + } + }); + + it("revalidates a feature reorder after a concurrent move commits", async () => { + const h = await createTaskStoreForTest({ prefix: "roadmap_reorder_concurrency" }); + try { + const setup = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const roadmap = await setup.createRoadmap({ title: "Concurrent reorder" }); + const source = await setup.createMilestone(roadmap.id, { title: "Source" }); + const target = await setup.createMilestone(roadmap.id, { title: "Target" }); + const alpha = await setup.createFeature(source.id, { title: "Alpha" }); + const beta = await setup.createFeature(source.id, { title: "Beta" }); + const gamma = await setup.createFeature(source.id, { title: "Gamma" }); + const firstControl = controlledOrderingLayer(bind(h.layer, "project-a"), "hold-after-first-query"); + const secondControl = controlledOrderingLayer(bind(h.layer, "project-a"), "signal-first-query"); + const mover = new AsyncRoadmapStore(firstControl.layer); + const reorderer = new AsyncRoadmapStore(secondControl.layer); + + const move = mover.moveFeature({ + roadmapId: roadmap.id, + featureId: beta.id, + fromMilestoneId: source.id, + toMilestoneId: target.id, + targetOrderIndex: 0, + }); + await firstControl.reached; + const reorder = reorderer.reorderFeatures({ + roadmapId: roadmap.id, + milestoneId: source.id, + orderedFeatureIds: [gamma.id, beta.id, alpha.id], + }); + await secondControl.reached; + firstControl.release(); + await move; + await expect(reorder).rejects.toThrow("Expected 2 feature ids but received 3"); + + expect((await setup.listFeatures(source.id)).map((item) => item.id)).toEqual([ + alpha.id, + gamma.id, + ]); + expect((await setup.listFeatures(target.id)).map((item) => item.id)).toEqual([beta.id]); + } finally { + await h.teardown(); + } + }); + + it("preserves CRUD, ordering, move, handoff, event, and project-isolation invariants", async () => { + const h = await createTaskStoreForTest({ prefix: "roadmap_store" }); + try { + const storeA = new AsyncRoadmapStore(bind(h.layer, "project-a")); + const storeB = new AsyncRoadmapStore(bind(h.layer, "project-b")); + const events = { + roadmapUpdated: vi.fn(), + milestoneCreated: vi.fn(), + milestoneUpdated: vi.fn(), + milestoneDeleted: vi.fn(), + milestoneReordered: vi.fn(), + featureCreated: vi.fn(), + featureUpdated: vi.fn(), + featureDeleted: vi.fn(), + featureReordered: vi.fn(), + featureMoved: vi.fn(), + }; + storeA.on("roadmap:updated", events.roadmapUpdated); + storeA.on("milestone:created", events.milestoneCreated); + storeA.on("milestone:updated", events.milestoneUpdated); + storeA.on("milestone:deleted", events.milestoneDeleted); + storeA.on("milestone:reordered", events.milestoneReordered); + storeA.on("feature:created", events.featureCreated); + storeA.on("feature:updated", events.featureUpdated); + storeA.on("feature:deleted", events.featureDeleted); + storeA.on("feature:reordered", events.featureReordered); + storeA.on("feature:moved", events.featureMoved); + + const roadmap = await storeA.createRoadmap({ title: "A" }); + const otherRoadmap = await storeA.createRoadmap({ title: "Other" }); + const first = await storeA.createMilestone(roadmap.id, { title: "First" }); + const second = await storeA.createMilestone(roadmap.id, { title: "Second" }); + const foreign = await storeA.createMilestone(otherRoadmap.id, { title: "Foreign" }); + const alpha = await storeA.createFeature(first.id, { title: "Alpha" }); + const beta = await storeA.createFeature(first.id, { title: "Beta" }); + + await expect(storeA.moveFeature({ + roadmapId: roadmap.id, + featureId: alpha.id, + fromMilestoneId: first.id, + toMilestoneId: foreign.id, + targetOrderIndex: 0, + })).rejects.toThrow("cannot move across roadmaps"); + expect((await storeA.getFeature(alpha.id))?.milestoneId).toBe(first.id); + + await storeA.updateRoadmap(roadmap.id, { title: "A updated" }); + await storeA.updateMilestone(first.id, { title: "First updated" }); + await storeA.updateFeature(alpha.id, { title: "Alpha updated" }); + expect((await storeA.reorderMilestones({ + roadmapId: roadmap.id, + orderedMilestoneIds: [second.id, first.id], + })).map((item) => item.id)).toEqual([second.id, first.id]); + expect((await storeA.reorderFeatures({ + roadmapId: roadmap.id, + milestoneId: first.id, + orderedFeatureIds: [beta.id, alpha.id], + })).map((item) => item.id)).toEqual([beta.id, alpha.id]); + + await storeA.moveFeature({ + roadmapId: roadmap.id, + featureId: alpha.id, + fromMilestoneId: first.id, + toMilestoneId: second.id, + targetOrderIndex: 0, + }); + expect((await storeA.getFeature(alpha.id))?.milestoneId).toBe(second.id); + + const hierarchy = await storeA.getRoadmapWithHierarchy(roadmap.id); + expect(hierarchy?.milestones.flatMap((item) => item.features).map((item) => item.id).sort()).toEqual([alpha.id, beta.id].sort()); + expect((await storeA.getRoadmapExport(roadmap.id)).features).toHaveLength(2); + expect((await storeA.getMissionPlanningHandoff(roadmap.id)).milestones).toHaveLength(2); + expect((await storeA.getRoadmapFeatureHandoff(roadmap.id, second.id, alpha.id)).source.featureId).toBe(alpha.id); + expect(await storeA.listFeatureTaskPlanningHandoffs(roadmap.id)).toHaveLength(2); + + const roadmapB = await storeB.createRoadmap({ title: "B" }); + const milestoneB = await storeB.createMilestone(roadmapB.id, { title: "B milestone" }); + await storeB.createFeature(milestoneB.id, { title: "B feature" }); + expect((await storeB.getRoadmapWithHierarchy(roadmapB.id))?.milestones[0]?.features).toHaveLength(1); + expect(await storeB.getRoadmap(roadmap.id)).toBeUndefined(); + expect((await storeA.listRoadmaps()).map((item) => item.id)).not.toContain(roadmapB.id); + + await storeA.deleteFeature(beta.id); + await storeA.deleteMilestone(first.id); + await storeA.deleteRoadmap(otherRoadmap.id); + expect(events.roadmapUpdated).toHaveBeenCalledTimes(1); + expect(events.milestoneCreated).toHaveBeenCalledTimes(3); + expect(events.milestoneUpdated).toHaveBeenCalledTimes(1); + expect(events.milestoneDeleted).toHaveBeenCalledWith(first.id); + expect(events.milestoneReordered).toHaveBeenCalledTimes(1); + expect(events.featureCreated).toHaveBeenCalledTimes(2); + expect(events.featureUpdated).toHaveBeenCalledTimes(1); + expect(events.featureDeleted).toHaveBeenCalledWith(expect.objectContaining({ id: beta.id })); + expect(events.featureReordered).toHaveBeenCalledTimes(1); + expect(events.featureMoved).toHaveBeenCalledWith(expect.objectContaining({ + feature: expect.objectContaining({ id: alpha.id, milestoneId: second.id }), + fromMilestoneId: first.id, + toMilestoneId: second.id, + })); + } finally { + await h.teardown(); + } + }); + + it("backfills a pre-project hierarchy only when one registered owner exists", async () => { + const h = await createTaskStoreForTest({ prefix: "roadmap_upgrade_single" }); + try { + await h.adminDb.execute(sql.raw(` + ALTER TABLE project.roadmap_features ALTER COLUMN project_id DROP NOT NULL; + ALTER TABLE project.roadmap_milestones ALTER COLUMN project_id DROP NOT NULL; + ALTER TABLE project.roadmaps ALTER COLUMN project_id DROP NOT NULL; + INSERT INTO central.projects(id, name, path, created_at, updated_at) + VALUES ('project-only', 'Only', '/only', '2026-07-13', '2026-07-13'); + INSERT INTO project.roadmaps(id, project_id, title, created_at, updated_at) + VALUES ('RM-OLD', NULL, 'Old', '2026-07-13', '2026-07-13'); + INSERT INTO project.roadmap_milestones(id, project_id, roadmap_id, title, order_index, created_at, updated_at) + VALUES ('RMS-OLD', NULL, 'RM-OLD', 'Old milestone', 0, '2026-07-13', '2026-07-13'); + INSERT INTO project.roadmap_features(id, project_id, milestone_id, title, order_index, created_at, updated_at) + VALUES ('RF-OLD', NULL, 'RMS-OLD', 'Old feature', 0, '2026-07-13', '2026-07-13'); + `)); + + await roadmapPluginSchemaInit.init(h.adminDb); + const ownership = await h.adminDb.execute(sql.raw(` + SELECT project_id FROM project.roadmaps WHERE id='RM-OLD' + UNION ALL SELECT project_id FROM project.roadmap_milestones WHERE id='RMS-OLD' + UNION ALL SELECT project_id FROM project.roadmap_features WHERE id='RF-OLD' + `)) as unknown as Array<{ project_id: string }>; + expect(ownership.map((row) => row.project_id)).toEqual([ + "project-only", + "project-only", + "project-only", + ]); + const nullable = await h.adminDb.execute(sql.raw(` + SELECT is_nullable FROM information_schema.columns + WHERE table_schema='project' + AND table_name IN ('roadmaps','roadmap_milestones','roadmap_features') + AND column_name='project_id' + `)) as unknown as Array<{ is_nullable: string }>; + expect(nullable.every((row) => row.is_nullable === "NO")).toBe(true); + } finally { + await h.teardown(); + } + }); + + it("fails closed when pre-project Roadmap ownership is ambiguous", async () => { + const h = await createTaskStoreForTest({ prefix: "roadmap_upgrade_ambiguous" }); + try { + await h.adminDb.execute(sql.raw(` + ALTER TABLE project.roadmaps ALTER COLUMN project_id DROP NOT NULL; + INSERT INTO central.projects(id, name, path, created_at, updated_at) VALUES + ('project-a', 'A', '/a', '2026-07-13', '2026-07-13'), + ('project-b', 'B', '/b', '2026-07-13', '2026-07-13'); + INSERT INTO project.roadmaps(id, project_id, title, created_at, updated_at) + VALUES ('RM-AMBIGUOUS', NULL, 'Ambiguous', '2026-07-13', '2026-07-13'); + `)); + + let failure: unknown; + try { + await roadmapPluginSchemaInit.init(h.adminDb); + } catch (error) { + failure = error; + } + expect(errorChain(failure)).toContain( + "cannot assign 1 pre-project row(s) across 2 registered projects", + ); + } finally { + await h.teardown(); + } + }); +}); diff --git a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts index 7807e4b9e0..f984ef40a1 100644 --- a/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts +++ b/plugins/fusion-plugin-roadmap/src/routes/roadmap-routes.ts @@ -6,6 +6,7 @@ interface RouteRequest { body?: unknown; } import { RoadmapStore } from "../store/roadmap-store.js"; +import { AsyncRoadmapStore } from "../store/async-roadmap-store.js"; import { generateFeatureSuggestions, generateMilestoneSuggestions, @@ -17,7 +18,8 @@ import { ValidationError as SuggestionValidationError, } from "./roadmap-suggestions.js"; -const roadmapStoreCache = new WeakMap(); +type RoadmapRuntimeStore = RoadmapStore | AsyncRoadmapStore; +const roadmapStoreCache = new WeakMap(); function resolveProjectId(req: RouteRequest): string | undefined { const queryProjectId = paramValue(req.query?.projectId); @@ -29,7 +31,7 @@ function resolveProjectId(req: RouteRequest): string | undefined { return undefined; } -async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise { +async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise { const projectId = resolveProjectId(req); const scopedTaskStore = projectId && ctx.resolveProjectTaskStore ? await ctx.resolveProjectTaskStore(projectId) @@ -48,8 +50,8 @@ async function getRoadmapStore(req: RouteRequest, ctx: PluginContext): Promise(handler: (req: RouteRequest, ctx: PluginContext, roadmapStore: RoadmapStore) => Promise | T | PluginRouteResponse) { +function routeHandler(handler: (req: RouteRequest, ctx: PluginContext, roadmapStore: RoadmapRuntimeStore) => Promise | T | PluginRouteResponse) { return async (req: unknown, ctx: PluginContext): Promise => { const routeRequest = asRequest(req); const roadmapStore = await getRoadmapStore(routeRequest, ctx); @@ -135,12 +137,12 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "POST", path: "/roadmaps", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title: string; description?: string }; try { return { status: 201, - body: roadmapStore.createRoadmap({ + body: await roadmapStore.createRoadmap({ title: validateTitle(body?.title), description: validateDescription(body?.description), }), @@ -153,18 +155,18 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "GET", path: "/roadmaps/:roadmapId", - handler: routeHandler((req, _ctx, roadmapStore) => { - const roadmap = roadmapStore.getRoadmapWithHierarchy(paramValue(req.params.roadmapId)); + handler: routeHandler(async (req, _ctx, roadmapStore) => { + const roadmap = await roadmapStore.getRoadmapWithHierarchy(paramValue(req.params.roadmapId)); return roadmap ? roadmap : notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`); }), }, { method: "PATCH", path: "/roadmaps/:roadmapId", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title?: string; description?: string }; try { - return roadmapStore.updateRoadmap(paramValue(req.params.roadmapId), { + return await roadmapStore.updateRoadmap(paramValue(req.params.roadmapId), { title: body.title !== undefined ? validateTitle(body.title) : undefined, description: body.description !== undefined ? validateDescription(body.description) : undefined, }); @@ -173,19 +175,19 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { } }), }, - { method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler((req, _ctx, roadmapStore) => { - roadmapStore.deleteRoadmap(paramValue(req.params.roadmapId)); + { method: "DELETE", path: "/roadmaps/:roadmapId", handler: routeHandler(async (req, _ctx, roadmapStore) => { + await roadmapStore.deleteRoadmap(paramValue(req.params.roadmapId)); return noContent(); }) }, { method: "POST", path: "/roadmaps/:roadmapId/milestones", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title: string; description?: string }; try { return { status: 201, - body: roadmapStore.createMilestone(paramValue(req.params.roadmapId), { + body: await roadmapStore.createMilestone(paramValue(req.params.roadmapId), { title: validateTitle(body?.title), description: validateDescription(body?.description), }), @@ -198,10 +200,10 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "POST", path: "/roadmaps/:roadmapId/milestones/reorder", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { try { const body = req.body as { orderedMilestoneIds: string[] }; - roadmapStore.reorderMilestones({ roadmapId: paramValue(req.params.roadmapId), orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") }); + await roadmapStore.reorderMilestones({ roadmapId: paramValue(req.params.roadmapId), orderedMilestoneIds: validateStringArray(body?.orderedMilestoneIds, "orderedMilestoneIds") }); return noContent(); } catch (error) { return badRequest(error instanceof Error ? error.message : "Invalid input"); @@ -211,10 +213,10 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "PATCH", path: "/roadmaps/milestones/:milestoneId", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title?: string; description?: string }; try { - return roadmapStore.updateMilestone(paramValue(req.params.milestoneId), { + return await roadmapStore.updateMilestone(paramValue(req.params.milestoneId), { title: body.title !== undefined ? validateTitle(body.title) : undefined, description: body.description !== undefined ? validateDescription(body.description) : undefined, }); @@ -223,19 +225,19 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { } }), }, - { method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler((req, _ctx, roadmapStore) => { - roadmapStore.deleteMilestone(paramValue(req.params.milestoneId)); + { method: "DELETE", path: "/roadmaps/milestones/:milestoneId", handler: routeHandler(async (req, _ctx, roadmapStore) => { + await roadmapStore.deleteMilestone(paramValue(req.params.milestoneId)); return noContent(); }) }, { method: "POST", path: "/roadmaps/milestones/:milestoneId/features", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title: string; description?: string }; try { return { status: 201, - body: roadmapStore.createFeature(paramValue(req.params.milestoneId), { + body: await roadmapStore.createFeature(paramValue(req.params.milestoneId), { title: validateTitle(body?.title), description: validateDescription(body?.description), }), @@ -248,12 +250,12 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "POST", path: "/roadmaps/milestones/:milestoneId/features/reorder", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { try { const body = req.body as { orderedFeatureIds: string[] }; - const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId)); + const milestone = await roadmapStore.getMilestone(paramValue(req.params.milestoneId)); if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`); - roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: paramValue(req.params.milestoneId), orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") }); + await roadmapStore.reorderFeatures({ roadmapId: milestone.roadmapId, milestoneId: paramValue(req.params.milestoneId), orderedFeatureIds: validateStringArray(body?.orderedFeatureIds, "orderedFeatureIds") }); return noContent(); } catch (error) { return badRequest(error instanceof Error ? error.message : "Invalid input"); @@ -263,10 +265,10 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "PATCH", path: "/roadmaps/features/:featureId", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { title?: string; description?: string }; try { - return roadmapStore.updateFeature(paramValue(req.params.featureId), { + return await roadmapStore.updateFeature(paramValue(req.params.featureId), { title: body.title !== undefined ? validateTitle(body.title) : undefined, description: body.description !== undefined ? validateDescription(body.description) : undefined, }); @@ -275,26 +277,26 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { } }), }, - { method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler((req, _ctx, roadmapStore) => { - roadmapStore.deleteFeature(paramValue(req.params.featureId)); + { method: "DELETE", path: "/roadmaps/features/:featureId", handler: routeHandler(async (req, _ctx, roadmapStore) => { + await roadmapStore.deleteFeature(paramValue(req.params.featureId)); return noContent(); }) }, { method: "POST", path: "/roadmaps/features/:featureId/move", - handler: routeHandler((req, _ctx, roadmapStore) => { + handler: routeHandler(async (req, _ctx, roadmapStore) => { const body = req.body as { targetMilestoneId: string; targetIndex: number }; if (!body?.targetMilestoneId) return badRequest("targetMilestoneId is required"); if (typeof body.targetIndex !== "number") return badRequest("targetIndex must be a number"); - const feature = roadmapStore.getFeature(paramValue(req.params.featureId)); + const feature = await roadmapStore.getFeature(paramValue(req.params.featureId)); if (!feature) return notFound(`Feature ${paramValue(req.params.featureId)} not found`); - const fromMilestone = roadmapStore.getMilestone(feature.milestoneId); + const fromMilestone = await roadmapStore.getMilestone(feature.milestoneId); if (!fromMilestone) return notFound(`Source milestone ${feature.milestoneId} not found`); - const toMilestone = roadmapStore.getMilestone(body.targetMilestoneId); + const toMilestone = await roadmapStore.getMilestone(body.targetMilestoneId); if (!toMilestone) return notFound(`Target milestone ${body.targetMilestoneId} not found`); - roadmapStore.moveFeature({ + await roadmapStore.moveFeature({ roadmapId: fromMilestone.roadmapId, featureId: paramValue(req.params.featureId), fromMilestoneId: feature.milestoneId, @@ -309,7 +311,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { method: "POST", path: "/roadmaps/:roadmapId/suggestions/milestones", handler: routeHandler(async (req, ctx, roadmapStore) => { - const roadmap = roadmapStore.getRoadmap(paramValue(req.params.roadmapId)); + const roadmap = await roadmapStore.getRoadmap(paramValue(req.params.roadmapId)); if (!roadmap) return notFound(`Roadmap ${paramValue(req.params.roadmapId)} not found`); try { @@ -343,9 +345,9 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { method: "POST", path: "/roadmaps/milestones/:milestoneId/suggestions/features", handler: routeHandler(async (req, ctx, roadmapStore) => { - const milestone = roadmapStore.getMilestone(paramValue(req.params.milestoneId)); + const milestone = await roadmapStore.getMilestone(paramValue(req.params.milestoneId)); if (!milestone) return notFound(`Milestone ${paramValue(req.params.milestoneId)} not found`); - const roadmap = roadmapStore.getRoadmap(milestone.roadmapId); + const roadmap = await roadmapStore.getRoadmap(milestone.roadmapId); if (!roadmap) return notFound(`Roadmap ${milestone.roadmapId} not found`); try { @@ -363,7 +365,7 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { roadmapDescription: roadmap.description, milestoneTitle: milestone.title, milestoneDescription: milestone.description, - existingFeatureTitles: roadmapStore.listFeatures(milestone.id).map((feature) => feature.title), + existingFeatureTitles: (await roadmapStore.listFeatures(milestone.id)).map((feature) => feature.title), }, body.count, body.prompt, @@ -390,9 +392,9 @@ export function createRoadmapPluginRoutes(): PluginRouteDefinition[] { { method: "GET", path: "/roadmaps/:roadmapId/handoff", - handler: routeHandler((req, _ctx, roadmapStore) => ({ - mission: roadmapStore.getMissionPlanningHandoff(paramValue(req.params.roadmapId)), - features: roadmapStore.listFeatureTaskPlanningHandoffs(paramValue(req.params.roadmapId)), + handler: routeHandler(async (req, _ctx, roadmapStore) => ({ + mission: await roadmapStore.getMissionPlanningHandoff(paramValue(req.params.roadmapId)), + features: await roadmapStore.listFeatureTaskPlanningHandoffs(paramValue(req.params.roadmapId)), })), }, { diff --git a/plugins/fusion-plugin-roadmap/src/store/async-roadmap-store.ts b/plugins/fusion-plugin-roadmap/src/store/async-roadmap-store.ts new file mode 100644 index 0000000000..047982f1de --- /dev/null +++ b/plugins/fusion-plugin-roadmap/src/store/async-roadmap-store.ts @@ -0,0 +1,619 @@ +import { EventEmitter } from "node:events"; +import type { AsyncDataLayer } from "@fusion/core"; +import { sql } from "drizzle-orm"; +/* FNXC:RoadmapPostgresPersistence 2026-07-13-23:42: Import SQL construction from Drizzle directly because bundled plugins resolve @fusion/core through a restricted runtime shim that intentionally exposes types and plugin APIs, not database query builders. */ +import type { RoadmapStoreEvents } from "./roadmap-store.js"; +import type { + Roadmap, + RoadmapCreateInput, + RoadmapUpdateInput, + RoadmapMilestone, + RoadmapMilestoneCreateInput, + RoadmapMilestoneUpdateInput, + RoadmapFeature, + RoadmapFeatureCreateInput, + RoadmapFeatureUpdateInput, + RoadmapMilestoneReorderInput, + RoadmapFeatureReorderInput, + RoadmapFeatureMoveInput, + RoadmapWithHierarchy, + RoadmapExportBundle, + RoadmapMissionPlanningHandoff, + RoadmapFeatureTaskPlanningHandoff, +} from "../roadmap-types.js"; +import { + applyRoadmapFeatureReorder, + applyRoadmapMilestoneReorder, + moveRoadmapFeature, +} from "./roadmap-ordering.js"; + +type RoadmapRow = { + id: string; + title: string; + description: string | null; + created_at: string; + updated_at: string; +}; +type MilestoneRow = { + id: string; + roadmap_id: string; + title: string; + description: string | null; + order_index: number; + created_at: string; + updated_at: string; +}; +type FeatureRow = { + id: string; + milestone_id: string; + title: string; + description: string | null; + order_index: number; + created_at: string; + updated_at: string; +}; + +function nextOrderIndex(items: ReadonlyArray<{ orderIndex: number }>): number { + return items.length === 0 + ? 0 + : Math.max(...items.map((item) => item.orderIndex)) + 1; +} + +/** + * FNXC:RoadmapPostgresPersistence 2026-07-13-22:37: + * Roadmap routes in backend mode must use the bound AsyncDataLayer. Every read and mutation is scoped by project_id because all project plugins share the same PostgreSQL schema. + */ +export class AsyncRoadmapStore extends EventEmitter { + private sequence = 0; + private readonly projectId: string; + constructor(private readonly layer: AsyncDataLayer) { + super(); + this.setMaxListeners(50); + if (!layer.projectId) + throw new Error( + "Roadmap PostgreSQL persistence requires a project-bound data layer", + ); + this.projectId = layer.projectId; + } + private id(prefix: "RM" | "RMS" | "RF"): string { + return `${prefix}-${Date.now().toString(36).toUpperCase()}-${(this.sequence++).toString(36).toUpperCase().padStart(4, "0")}-${Math.random().toString(36).slice(2, 6).toUpperCase()}`; + } + private roadmap(row: RoadmapRow): Roadmap { + return { + id: row.id, + title: row.title, + description: row.description ?? undefined, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + private milestone(row: MilestoneRow): RoadmapMilestone { + return { + id: row.id, + roadmapId: row.roadmap_id, + title: row.title, + description: row.description ?? undefined, + orderIndex: row.order_index, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + private feature(row: FeatureRow): RoadmapFeature { + return { + id: row.id, + milestoneId: row.milestone_id, + title: row.title, + description: row.description ?? undefined, + orderIndex: row.order_index, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + async createRoadmap(input: RoadmapCreateInput): Promise { + const now = new Date().toISOString(); + const roadmap: Roadmap = { + id: this.id("RM"), + title: input.title, + description: input.description, + createdAt: now, + updatedAt: now, + }; + await this.layer.db.execute( + sql`INSERT INTO project.roadmaps(id, project_id, title, description, created_at, updated_at) VALUES(${roadmap.id}, ${this.projectId}, ${roadmap.title}, ${roadmap.description ?? null}, ${now}, ${now})`, + ); + this.emit("roadmap:created", roadmap); + return roadmap; + } + async getRoadmap(id: string): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmaps WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as RoadmapRow[]; + return rows[0] ? this.roadmap(rows[0]) : undefined; + } + async listRoadmaps(): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmaps WHERE project_id=${this.projectId} ORDER BY created_at DESC, id`, + )) as unknown as RoadmapRow[]; + return rows.map((r) => this.roadmap(r)); + } + async updateRoadmap( + id: string, + updates: RoadmapUpdateInput, + ): Promise { + const old = await this.getRoadmap(id); + if (!old) throw new Error(`Roadmap ${id} not found`); + const next = { + ...old, + ...updates, + id, + createdAt: old.createdAt, + updatedAt: new Date().toISOString(), + }; + await this.layer.db.execute( + sql`UPDATE project.roadmaps SET title=${next.title}, description=${next.description ?? null}, updated_at=${next.updatedAt} WHERE project_id=${this.projectId} AND id=${id}`, + ); + this.emit("roadmap:updated", next); + return next; + } + async deleteRoadmap(id: string): Promise { + await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:32: + * Deleting a roadmap cascades through its entire ordered hierarchy, so it must serialize with create, reorder, move, and child delete operations. Holding the roadmap lock through existence validation and cascade prevents a concurrent ordering mutation from returning or emitting a hierarchy that the delete removed mid-transaction. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${id}`}, 0))`, + ); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmaps WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as RoadmapRow[]; + if (!rows[0]) throw new Error(`Roadmap ${id} not found`); + await tx.execute( + sql`DELETE FROM project.roadmaps WHERE project_id=${this.projectId} AND id=${id}`, + ); + }); + this.emit("roadmap:deleted", id); + } + + async createMilestone( + roadmapId: string, + input: RoadmapMilestoneCreateInput, + ): Promise { + const milestone = await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:24: + * Appending a milestone is an ordering mutation. It must hold the same project-and-roadmap transaction lock as reorder and move from the existence check through the insert, so concurrent creates cannot choose the same order index and a reorder cannot overwrite a newly appended item from a stale hierarchy snapshot. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${roadmapId}`}, 0))`, + ); + const roadmapRows = (await tx.execute( + sql`SELECT * FROM project.roadmaps WHERE project_id=${this.projectId} AND id=${roadmapId} LIMIT 1`, + )) as unknown as RoadmapRow[]; + if (!roadmapRows[0]) throw new Error(`Roadmap ${roadmapId} not found`); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND roadmap_id=${roadmapId} ORDER BY order_index, created_at, id`, + )) as unknown as MilestoneRow[]; + const now = new Date().toISOString(); + const created: RoadmapMilestone = { + id: this.id("RMS"), + roadmapId, + title: input.title, + description: input.description, + orderIndex: nextOrderIndex(rows.map((row) => this.milestone(row))), + createdAt: now, + updatedAt: now, + }; + await tx.execute( + sql`INSERT INTO project.roadmap_milestones(id, project_id, roadmap_id, title, description, order_index, created_at, updated_at) VALUES(${created.id}, ${this.projectId}, ${roadmapId}, ${created.title}, ${created.description ?? null}, ${created.orderIndex}, ${now}, ${now})`, + ); + return created; + }); + this.emit("milestone:created", milestone); + return milestone; + } + async getMilestone(id: string): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as MilestoneRow[]; + return rows[0] ? this.milestone(rows[0]) : undefined; + } + async listMilestones(roadmapId: string): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND roadmap_id=${roadmapId} ORDER BY order_index, created_at, id`, + )) as unknown as MilestoneRow[]; + return rows.map((r) => this.milestone(r)); + } + async updateMilestone( + id: string, + updates: RoadmapMilestoneUpdateInput, + ): Promise { + const old = await this.getMilestone(id); + if (!old) throw new Error(`Milestone ${id} not found`); + const next = { + ...old, + ...updates, + id, + roadmapId: old.roadmapId, + createdAt: old.createdAt, + updatedAt: new Date().toISOString(), + }; + await this.layer.db.execute( + sql`UPDATE project.roadmap_milestones SET title=${next.title}, description=${next.description ?? null}, updated_at=${next.updatedAt} WHERE project_id=${this.projectId} AND id=${id}`, + ); + this.emit("milestone:updated", next); + return next; + } + async deleteMilestone(id: string): Promise { + const candidate = await this.getMilestone(id); + if (!candidate) + throw new Error(`Milestone ${id} not found`); + await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:32: + * Milestone deletion is a destructive roadmap-order mutation. Revalidate and cascade-delete it under the shared roadmap lock so a queued reorder observes the committed removal rather than persisting a stale pre-delete list. Deletion intentionally preserves SQLite parity by leaving sibling order indexes unchanged until an explicit reorder normalizes them. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${candidate.roadmapId}`}, 0))`, + ); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as MilestoneRow[]; + const milestone = rows[0] ? this.milestone(rows[0]) : undefined; + if (!milestone) throw new Error(`Milestone ${id} not found`); + if (milestone.roadmapId !== candidate.roadmapId) + throw new Error(`Milestone ${id} changed roadmaps`); + await tx.execute( + sql`DELETE FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${id}`, + ); + }); + this.emit("milestone:deleted", id); + } + + async createFeature( + milestoneId: string, + input: RoadmapFeatureCreateInput, + ): Promise { + const candidateMilestone = await this.getMilestone(milestoneId); + if (!candidateMilestone) + throw new Error(`Milestone ${milestoneId} not found`); + const feature = await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:24: + * Feature appends share the roadmap ordering lock with create, reorder, and move. The milestone and sibling list are authoritative only after this lock is held, and the append stays in that transaction through commit. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${candidateMilestone.roadmapId}`}, 0))`, + ); + const milestoneRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${milestoneId} LIMIT 1`, + )) as unknown as MilestoneRow[]; + const milestone = milestoneRows[0] + ? this.milestone(milestoneRows[0]) + : undefined; + if (!milestone) throw new Error(`Milestone ${milestoneId} not found`); + if (milestone.roadmapId !== candidateMilestone.roadmapId) + throw new Error(`Milestone ${milestoneId} changed roadmaps`); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND milestone_id=${milestoneId} ORDER BY order_index, created_at, id`, + )) as unknown as FeatureRow[]; + const now = new Date().toISOString(); + const created: RoadmapFeature = { + id: this.id("RF"), + milestoneId, + title: input.title, + description: input.description, + orderIndex: nextOrderIndex(rows.map((row) => this.feature(row))), + createdAt: now, + updatedAt: now, + }; + await tx.execute( + sql`INSERT INTO project.roadmap_features(id, project_id, milestone_id, title, description, order_index, created_at, updated_at) VALUES(${created.id}, ${this.projectId}, ${milestoneId}, ${created.title}, ${created.description ?? null}, ${created.orderIndex}, ${now}, ${now})`, + ); + return created; + }); + this.emit("feature:created", feature); + return feature; + } + async getFeature(id: string): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as FeatureRow[]; + return rows[0] ? this.feature(rows[0]) : undefined; + } + async listFeatures(milestoneId: string): Promise { + const rows = (await this.layer.db.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND milestone_id=${milestoneId} ORDER BY order_index, created_at, id`, + )) as unknown as FeatureRow[]; + return rows.map((r) => this.feature(r)); + } + async updateFeature( + id: string, + updates: RoadmapFeatureUpdateInput, + ): Promise { + const old = await this.getFeature(id); + if (!old) throw new Error(`Feature ${id} not found`); + const next = { + ...old, + ...updates, + id, + milestoneId: old.milestoneId, + createdAt: old.createdAt, + updatedAt: new Date().toISOString(), + }; + await this.layer.db.execute( + sql`UPDATE project.roadmap_features SET title=${next.title}, description=${next.description ?? null}, updated_at=${next.updatedAt} WHERE project_id=${this.projectId} AND id=${id}`, + ); + this.emit("feature:updated", next); + return next; + } + async deleteFeature(id: string): Promise { + const candidate = await this.getFeature(id); + if (!candidate) + throw new Error(`Feature ${id} not found`); + const candidateMilestone = await this.getMilestone(candidate.milestoneId); + if (!candidateMilestone) + throw new Error(`Milestone ${candidate.milestoneId} not found`); + const feature = await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-01:32: + * Feature deletion shares the project-and-roadmap lock with every ordering mutation. Re-read its current parent and delete within that transaction so reorder and move cannot commit from a sibling snapshot that still contains the deleted feature; sibling indexes retain SQLite delete semantics until explicit normalization. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${candidateMilestone.roadmapId}`}, 0))`, + ); + const featureRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND id=${id} LIMIT 1`, + )) as unknown as FeatureRow[]; + const committed = featureRows[0] + ? this.feature(featureRows[0]) + : undefined; + if (!committed) throw new Error(`Feature ${id} not found`); + const milestoneRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${committed.milestoneId} LIMIT 1`, + )) as unknown as MilestoneRow[]; + const milestone = milestoneRows[0] + ? this.milestone(milestoneRows[0]) + : undefined; + if (!milestone) throw new Error(`Milestone ${committed.milestoneId} not found`); + if (milestone.roadmapId !== candidateMilestone.roadmapId) + throw new Error(`Feature ${id} changed roadmaps`); + await tx.execute( + sql`DELETE FROM project.roadmap_features WHERE project_id=${this.projectId} AND id=${id}`, + ); + return committed; + }); + this.emit("feature:deleted", feature); + } + + async reorderMilestones( + input: RoadmapMilestoneReorderInput, + ): Promise { + const result = await this.layer.transactionImmediate(async (tx) => { + /* + * FNXC:RoadmapOrderingConcurrency 2026-07-14-00:43: + * Every PostgreSQL reorder and move for one project roadmap must acquire the same transaction-scoped advisory lock before reading its hierarchy. This makes validation and recomputation observe the last committed ordering instead of overwriting it with a pre-transaction snapshot. + */ + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${input.roadmapId}`}, 0))`, + ); + const roadmapRows = (await tx.execute( + sql`SELECT * FROM project.roadmaps WHERE project_id=${this.projectId} AND id=${input.roadmapId} LIMIT 1`, + )) as unknown as RoadmapRow[]; + if (!roadmapRows[0]) + throw new Error(`Roadmap ${input.roadmapId} not found`); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND roadmap_id=${input.roadmapId} ORDER BY order_index, created_at, id`, + )) as unknown as MilestoneRow[]; + const updatedAt = new Date().toISOString(); + const reordered = applyRoadmapMilestoneReorder( + rows.map((row) => this.milestone(row)), + input, + ).map((item) => ({ ...item, updatedAt })); + for (const item of reordered) + await tx.execute( + sql`UPDATE project.roadmap_milestones SET order_index=${item.orderIndex}, updated_at=${item.updatedAt} WHERE project_id=${this.projectId} AND id=${item.id}`, + ); + return reordered; + }); + /* + * FNXC:RoadmapPostgresEvents 2026-07-13-23:40: + * PostgreSQL mutations must publish the same typed lifecycle events as the SQLite RoadmapStore so plugin integrations do not change behavior when persistence backends switch. + */ + this.emit("milestone:reordered", { + roadmapId: input.roadmapId, + milestones: result, + }); + return result; + } + async reorderFeatures( + input: RoadmapFeatureReorderInput, + ): Promise { + const result = await this.layer.transactionImmediate(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${input.roadmapId}`}, 0))`, + ); + const milestoneRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id=${input.milestoneId} LIMIT 1`, + )) as unknown as MilestoneRow[]; + const milestone = milestoneRows[0] + ? this.milestone(milestoneRows[0]) + : undefined; + if (!milestone) + throw new Error(`Milestone ${input.milestoneId} not found`); + if (milestone.roadmapId !== input.roadmapId) + throw new Error( + `Milestone ${input.milestoneId} does not belong to roadmap ${input.roadmapId}`, + ); + const rows = (await tx.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND milestone_id=${input.milestoneId} ORDER BY order_index, created_at, id`, + )) as unknown as FeatureRow[]; + const updatedAt = new Date().toISOString(); + const reordered = applyRoadmapFeatureReorder( + rows.map((row) => this.feature(row)), + input, + ).map((item) => ({ ...item, updatedAt })); + for (const item of reordered) + await tx.execute( + sql`UPDATE project.roadmap_features SET order_index=${item.orderIndex}, updated_at=${item.updatedAt} WHERE project_id=${this.projectId} AND id=${item.id}`, + ); + return reordered; + }); + this.emit("feature:reordered", { + milestoneId: input.milestoneId, + features: result, + }); + return result; + } + async moveFeature(input: RoadmapFeatureMoveInput): Promise { + const movedFeature = await this.layer.transactionImmediate(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`fusion:roadmap-order:${this.projectId}:${input.roadmapId}`}, 0))`, + ); + const milestoneRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_milestones WHERE project_id=${this.projectId} AND id IN (${input.fromMilestoneId}, ${input.toMilestoneId})`, + )) as unknown as MilestoneRow[]; + const fromRow = milestoneRows.find((row) => row.id === input.fromMilestoneId); + const toRow = milestoneRows.find((row) => row.id === input.toMilestoneId); + const from = fromRow ? this.milestone(fromRow) : undefined; + const to = toRow ? this.milestone(toRow) : undefined; + if (!from) + throw new Error(`Source milestone ${input.fromMilestoneId} not found`); + if (!to) + throw new Error(`Destination milestone ${input.toMilestoneId} not found`); + /* + * FNXC:RoadmapMoveOwnership 2026-07-14-00:43: + * Validate feature and milestone ownership only after taking the roadmap ordering lock so a concurrent move cannot invalidate the hierarchy snapshot used to compute this move. + */ + if (from.roadmapId !== input.roadmapId || to.roadmapId !== input.roadmapId) + throw new Error(`Feature ${input.featureId} cannot move across roadmaps`); + const featureRows = (await tx.execute( + sql`SELECT * FROM project.roadmap_features WHERE project_id=${this.projectId} AND milestone_id IN (${input.fromMilestoneId}, ${input.toMilestoneId}) ORDER BY order_index, created_at, id`, + )) as unknown as FeatureRow[]; + const features = featureRows.map((row) => this.feature(row)); + const feature = features.find((item) => item.id === input.featureId); + if (!feature || feature.milestoneId !== input.fromMilestoneId) + throw new Error( + `Feature ${input.featureId} does not belong to source milestone ${input.fromMilestoneId}`, + ); + const updatedAt = new Date().toISOString(); + const moved = moveRoadmapFeature(features, input); + const affectedFeatures = moved.affectedFeatures.map((item) => ({ + ...item, + updatedAt, + })); + for (const item of affectedFeatures) + await tx.execute( + sql`UPDATE project.roadmap_features SET milestone_id=${item.milestoneId}, order_index=${item.orderIndex}, updated_at=${item.updatedAt} WHERE project_id=${this.projectId} AND id=${item.id}`, + ); + const committed = affectedFeatures.find((item) => item.id === input.featureId); + if (!committed) + throw new Error(`Feature ${input.featureId} was not moved`); + return committed; + }); + this.emit("feature:moved", { + feature: movedFeature, + fromMilestoneId: input.fromMilestoneId, + toMilestoneId: input.toMilestoneId, + }); + } + + async getRoadmapWithHierarchy( + id: string, + ): Promise { + const roadmap = await this.getRoadmap(id); + if (!roadmap) return undefined; + const milestones = await this.listMilestones(id); + return { + ...roadmap, + milestones: await Promise.all( + milestones.map(async (m) => ({ + ...m, + features: await this.listFeatures(m.id), + })), + ), + }; + } + async getRoadmapExport(id: string): Promise { + const hierarchy = await this.getRoadmapWithHierarchy(id); + if (!hierarchy) throw new Error(`Roadmap ${id} not found`); + const { milestones, ...roadmap } = hierarchy; + return { + roadmap, + milestones, + features: milestones.flatMap((m) => m.features), + }; + } + async getMissionPlanningHandoff( + id: string, + ): Promise { + const hierarchy = await this.getRoadmapWithHierarchy(id); + if (!hierarchy) throw new Error(`Roadmap ${id} not found`); + return { + sourceRoadmapId: hierarchy.id, + title: hierarchy.title, + description: hierarchy.description, + milestones: hierarchy.milestones.map((m) => ({ + sourceMilestoneId: m.id, + title: m.title, + description: m.description, + orderIndex: m.orderIndex, + features: m.features.map((f) => ({ + sourceFeatureId: f.id, + title: f.title, + description: f.description, + orderIndex: f.orderIndex, + })), + })), + }; + } + async getRoadmapFeatureHandoff( + roadmapId: string, + milestoneId: string, + featureId: string, + ): Promise { + const roadmap = await this.getRoadmap(roadmapId); + const milestone = await this.getMilestone(milestoneId); + const feature = await this.getFeature(featureId); + if (!roadmap) throw new Error(`Roadmap ${roadmapId} not found`); + if (!milestone || milestone.roadmapId !== roadmapId) + throw new Error(`Milestone ${milestoneId} not found`); + if (!feature || feature.milestoneId !== milestoneId) + throw new Error(`Feature ${featureId} not found`); + return { + source: { + roadmapId, + milestoneId, + featureId, + roadmapTitle: roadmap.title, + milestoneTitle: milestone.title, + milestoneOrderIndex: milestone.orderIndex, + featureOrderIndex: feature.orderIndex, + }, + title: feature.title, + description: feature.description, + }; + } + async listFeatureTaskPlanningHandoffs( + id: string, + ): Promise { + const hierarchy = await this.getRoadmapWithHierarchy(id); + if (!hierarchy) throw new Error(`Roadmap ${id} not found`); + return hierarchy.milestones.flatMap((m) => + m.features.map((f) => ({ + source: { + roadmapId: hierarchy.id, + milestoneId: m.id, + featureId: f.id, + roadmapTitle: hierarchy.title, + milestoneTitle: m.title, + milestoneOrderIndex: m.orderIndex, + featureOrderIndex: f.orderIndex, + }, + title: f.title, + description: f.description, + })), + ); + } +} diff --git a/plugins/fusion-plugin-whatsapp-chat/package.json b/plugins/fusion-plugin-whatsapp-chat/package.json index a69d629849..b298143fef 100644 --- a/plugins/fusion-plugin-whatsapp-chat/package.json +++ b/plugins/fusion-plugin-whatsapp-chat/package.json @@ -24,10 +24,12 @@ "dependencies": { "@fusion/plugin-sdk": "workspace:*", "@whiskeysockets/baileys": "^6.7.21", + "drizzle-orm": "^0.45.2", "pino": "^9.9.0", "qrcode": "^1.5.4" }, "devDependencies": { + "@fusion/core": "workspace:*", "@types/node": "^25.5.2", "typescript": "^5.7.0", "vitest": "^4.1.0" diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts index 1df53785f5..0a3cf946fc 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/auth-state.test.ts @@ -5,6 +5,9 @@ function createInMemoryDb() { const creds = new Map(); const keys = new Map(); const makeKey = (category: string, id: string) => `${category}:${id}`; + let transactionSnapshot: { creds: Map; keys: Map } | null = null; + let failKeyId: string | null = null; + let failAuthKeysClear = false; return { prepare(sql: string) { @@ -29,37 +32,57 @@ function createInMemoryDb() { creds.clear(); } if (sql.includes("INSERT INTO whatsapp_auth_keys")) { + if (args[1] === failKeyId) throw new Error("injected auth-key write failure"); keys.set(makeKey(args[0] as string, args[1] as string), args[2] as string); } if (sql.includes("DELETE FROM whatsapp_auth_keys WHERE category")) { keys.delete(makeKey(args[0] as string, args[1] as string)); } if (sql.includes("DELETE FROM whatsapp_auth_keys")) { + if (failAuthKeysClear) throw new Error("injected auth-key clear failure"); keys.clear(); } }, }; }, - exec() {}, + exec(sql: string) { + if (sql === "BEGIN IMMEDIATE") { + transactionSnapshot = { creds: new Map(creds), keys: new Map(keys) }; + } + if (sql === "COMMIT") transactionSnapshot = null; + if (sql === "ROLLBACK" && transactionSnapshot) { + creds.clear(); + for (const [key, value] of transactionSnapshot.creds) creds.set(key, value); + keys.clear(); + for (const [key, value] of transactionSnapshot.keys) keys.set(key, value); + transactionSnapshot = null; + } + }, _creds: creds, _keys: keys, + failAuthKeyWrite(id: string | null) { + failKeyId = id; + }, + failAuthKeyClear(value: boolean) { + failAuthKeysClear = value; + }, }; } describe("auth-state", () => { it("round-trips creds", async () => { const db = createInMemoryDb(); - const auth = createPluginDbAuthState(db as any); + const auth = await createPluginDbAuthState(db as any); auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; await auth.saveCreds(); - const next = createPluginDbAuthState(db as any); + const next = await createPluginDbAuthState(db as any); expect(next.state.creds.me?.id).toBe("123@s.whatsapp.net"); }); it("sets, gets, and deletes key categories", async () => { const db = createInMemoryDb(); - const auth = createPluginDbAuthState(db as any); + const auth = await createPluginDbAuthState(db as any); await auth.state.keys.set({ session: { alpha: { foo: "bar" } as any }, @@ -75,24 +98,60 @@ describe("auth-state", () => { expect((removed as any).alpha).toBeUndefined(); }); + it("rolls back every SQLite auth-key category when a later category write fails", async () => { + const db = createInMemoryDb(); + const auth = await createPluginDbAuthState(db as any); + await auth.state.keys.set({ session: { alpha: { version: "old" } as any } }); + db.failAuthKeyWrite("beta"); + + await expect(auth.state.keys.set({ + session: { alpha: { version: "new" } as any }, + "sender-key": { + beta: { version: "new" } as any, + }, + })).rejects.toThrow("injected auth-key write failure"); + + db.failAuthKeyWrite(null); + const session = await auth.state.keys.get("session", ["alpha"]); + const senderKey = await auth.state.keys.get("sender-key", ["beta"]); + expect((session as any).alpha).toEqual({ version: "old" }); + expect((senderKey as any).beta).toBeUndefined(); + }); + it("clears auth state", async () => { const db = createInMemoryDb(); - const auth = createPluginDbAuthState(db as any); + const auth = await createPluginDbAuthState(db as any); auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; await auth.saveCreds(); await auth.state.keys.set({ session: { alpha: { ok: true } as any } }); - clearAuthState(db as any); + await clearAuthState(db as any); expect(db._creds.size).toBe(0); expect(db._keys.size).toBe(0); }); + it("rolls back credentials and keys when the second SQLite auth clear fails", async () => { + const db = createInMemoryDb(); + const auth = await createPluginDbAuthState(db as any); + auth.state.creds.me = { id: "123@s.whatsapp.net", name: "Fusion" } as any; + await auth.saveCreds(); + await auth.state.keys.set({ session: { alpha: { ok: true } as any } }); + db.failAuthKeyClear(true); + + await expect(clearAuthState(db as any)).rejects.toThrow("injected auth-key clear failure"); + + db.failAuthKeyClear(false); + const restored = await createPluginDbAuthState(db as any); + expect(restored.state.creds.me?.id).toBe("123@s.whatsapp.net"); + expect((await restored.state.keys.get("session", ["alpha"]) as any).alpha).toEqual({ ok: true }); + }); + it("handles corrupt json gracefully", async () => { const db = createInMemoryDb(); db._keys.set("session:bad", "not-json"); - const auth = createPluginDbAuthState(db as any); + const auth = await createPluginDbAuthState(db as any); const loaded = await auth.state.keys.get("session", ["bad"]); expect((loaded as any).bad).toBeUndefined(); }); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts index dcac8b2ce4..7f4fe12064 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/connection.test.ts @@ -6,6 +6,7 @@ const mockState = vi.hoisted(() => { const end = vi.fn(); const logout = vi.fn(); const requestPairingCode = vi.fn().mockResolvedValue("123-456"); + const toDataURL = vi.fn().mockResolvedValue("data:image/png;base64,abc"); const makeWASocket = vi.fn(() => ({ ev: { on: (name: string, handler: (payload: any) => void) => handlers.set(name, handler), @@ -17,7 +18,7 @@ const mockState = vi.hoisted(() => { logout, requestPairingCode, })); - return { handlers, sendMessage, end, logout, requestPairingCode, makeWASocket }; + return { handlers, sendMessage, end, logout, requestPairingCode, makeWASocket, toDataURL }; }); vi.mock("@whiskeysockets/baileys", () => ({ @@ -29,10 +30,21 @@ vi.mock("@whiskeysockets/baileys", () => ({ })); vi.mock("qrcode", () => ({ - default: { toDataURL: vi.fn().mockResolvedValue("data:image/png;base64,abc") }, + default: { toDataURL: mockState.toDataURL }, })); import { WhatsAppConnection } from "../connection.js"; +import { createSqliteWhatsAppPersistence } from "../persistence.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} function createInMemoryDb() { const sessions = new Map(); @@ -55,6 +67,13 @@ function createInMemoryDb() { }, run: (...args: unknown[]) => { if (sql.includes("whatsapp_chat_sessions")) sessions.set(args[0] as string, args[1] as string); + if (sql.includes("DELETE FROM whatsapp_chat_dedupe")) return { changes: 0 }; + if (sql.includes("INSERT OR IGNORE INTO whatsapp_chat_dedupe")) { + const messageId = args[0] as string; + if (dedupe.has(messageId)) return { changes: 0 }; + dedupe.add(messageId); + return { changes: 1 }; + } if (sql.includes("whatsapp_chat_dedupe")) dedupe.add(args[0] as string); if (sql.includes("INSERT INTO whatsapp_auth_creds")) creds.set("creds", args[0] as string); if (sql.includes("DELETE FROM whatsapp_auth_creds")) creds.clear(); @@ -83,10 +102,13 @@ describe("WhatsAppConnection", () => { mockState.makeWASocket.mockClear(); mockState.sendMessage.mockClear(); mockState.end.mockClear(); + mockState.logout.mockReset(); + mockState.toDataURL.mockReset(); + mockState.toDataURL.mockResolvedValue("data:image/png;base64,abc"); }); it("starts and stops idempotently", async () => { - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryDb() as any); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); await connection.start(); await connection.stop(); await connection.stop(); @@ -95,15 +117,27 @@ describe("WhatsAppConnection", () => { }); it("exposes qr updates", async () => { - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryDb() as any); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); await connection.start(); await mockState.handlers.get("connection.update")?.({ qr: "abc" }); expect(connection.getStatus()).toMatchObject({ state: "awaiting-qr", qr: "abc" }); }); + it("logs rejected async EventEmitter listeners", async () => { + const ctx = makeCtx(); + const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + await connection.start(); + mockState.toDataURL.mockRejectedValueOnce(new Error("bad qr")); + + await mockState.handlers.get("connection.update")?.({ qr: "invalid" }); + + expect(ctx.logger.error).toHaveBeenCalledWith("WhatsApp connection update failed", expect.any(Error)); + expect(connection.getStatus()).toMatchObject({ state: "error", lastError: "bad qr" }); + }); + it("reconnects on close unless logged out", async () => { vi.useFakeTimers(); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createInMemoryDb() as any); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); await connection.start(); await mockState.handlers.get("connection.update")?.({ connection: "close", lastDisconnect: { error: new Error("boom") } }); vi.advanceTimersByTime(1000); @@ -117,7 +151,7 @@ describe("WhatsAppConnection", () => { it("drops unsupported inbound traffic", async () => { const reply = vi.fn().mockResolvedValue("hello"); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createInMemoryDb() as any); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); await connection.start(); const upsert = mockState.handlers.get("messages.upsert")!; await upsert({ type: "notify", messages: [{ key: { remoteJid: "abc@g.us", id: "1", fromMe: false }, message: { conversation: "hi" } }] }); @@ -128,7 +162,7 @@ describe("WhatsAppConnection", () => { it("dedupes and handles reply failure with fallback", async () => { const reply = vi.fn().mockRejectedValue(new Error("nope")); - const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createInMemoryDb() as any); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); await connection.start(); const payload = { type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "m-1", fromMe: false }, message: { conversation: "hi" } }] }; await mockState.handlers.get("messages.upsert")?.(payload); @@ -137,6 +171,171 @@ describe("WhatsAppConnection", () => { expect(mockState.sendMessage).toHaveBeenCalledWith("15550001111@s.whatsapp.net", { text: "Sorry, I hit an internal error while processing that message." }); }); + it("atomically claims concurrent duplicate deliveries", async () => { + const reply = vi.fn().mockResolvedValue("one reply"); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + await connection.start(); + const payload = { type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "same-id", fromMe: false }, message: { conversation: "hi" } }] }; + + await Promise.all([ + mockState.handlers.get("messages.upsert")?.(payload), + mockState.handlers.get("messages.upsert")?.(payload), + ]); + + expect(reply).toHaveBeenCalledTimes(1); + expect(mockState.sendMessage).toHaveBeenCalledTimes(1); + }); + + it("serializes concurrent messages from one sender and preserves both turns", async () => { + const firstReplyStarted = deferred(); + const releaseFirstReply = deferred(); + const db = createInMemoryDb(); + const persistence = createSqliteWhatsAppPersistence(db as any); + const reply = vi.fn(async (_ctx: unknown, _sender: string, text: string, history: Array<{ text: string }>) => { + if (text === "first") { + firstReplyStarted.resolve(); + await releaseFirstReply.promise; + } + return `${text}-reply-${history.length}`; + }); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", reply, persistence); + await connection.start(); + const upsert = mockState.handlers.get("messages.upsert")!; + + const first = upsert({ type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "m-first", fromMe: false }, message: { conversation: "first" } }] }); + await firstReplyStarted.promise; + const second = upsert({ type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "m-second", fromMe: false }, message: { conversation: "second" } }] }); + + await Promise.resolve(); + expect(reply).toHaveBeenCalledTimes(1); + releaseFirstReply.resolve(); + await Promise.all([first, second]); + + expect(reply.mock.calls[1]?.[3].map((turn: { text: string }) => turn.text)).toEqual(["first", "first-reply-0"]); + expect((await persistence.loadHistory("15550001111")).map((turn) => turn.text)).toEqual([ + "first", + "first-reply-0", + "second", + "second-reply-2", + ]); + }); + + it("sends an accepted reply before a concurrent stop closes its socket", async () => { + const replyPersisted = deferred(); + const releasePersistedReply = deferred(); + const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const appendHistory = persistence.appendHistory.bind(persistence); + vi.spyOn(persistence, "appendHistory").mockImplementation(async (...args) => { + await appendHistory(...args); + replyPersisted.resolve(); + await releasePersistedReply.promise; + }); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("saved reply"), persistence); + await connection.start(); + const upsert = mockState.handlers.get("messages.upsert")!; + + const inbound = upsert({ type: "notify", messages: [{ key: { remoteJid: "15550001111@s.whatsapp.net", id: "m-stop-race", fromMe: false }, message: { conversation: "hello" } }] }); + await replyPersisted.promise; + const stop = connection.stop(); + + await Promise.resolve(); + expect(mockState.handlers.has("messages.upsert")).toBe(false); + expect(mockState.end).not.toHaveBeenCalled(); + releasePersistedReply.resolve(); + await Promise.all([inbound, stop]); + + expect(mockState.sendMessage).toHaveBeenCalledWith("15550001111@s.whatsapp.net", { text: "saved reply" }); + expect(mockState.sendMessage.mock.invocationCallOrder[0]) + .toBeLessThan(mockState.end.mock.invocationCallOrder[0]!); + }); + + it("attempts server logout through the socket captured before a concurrent stop", async () => { + const logoutStarted = deferred(); + const releaseLogout = deferred(); + mockState.logout.mockImplementationOnce(async () => { + logoutStarted.resolve(); + await releaseLogout.promise; + }); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + await connection.start(); + + const logout = connection.logout(); + await logoutStarted.promise; + await connection.stop(); + releaseLogout.resolve(); + await logout; + + expect(mockState.logout).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["explicit logout", async (connection: WhatsAppConnection) => connection.logout()], + ["logged-out connection update", async () => mockState.handlers.get("connection.update")?.({ + connection: "close", + lastDisconnect: { error: { output: { statusCode: 401 } } }, + })], + ])("drains an accepted credential save before %s clears auth", async (_surface, triggerReset) => { + const saveStarted = deferred(); + const releaseSave = deferred(); + const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const saveCredentials = persistence.saveCredentials.bind(persistence); + vi.spyOn(persistence, "saveCredentials").mockImplementation(async (value) => { + saveStarted.resolve(); + await releaseSave.promise; + await saveCredentials(value); + }); + const clearAuthState = vi.spyOn(persistence, "clearAuthState"); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), persistence); + await connection.start(); + const credentialSave = mockState.handlers.get("creds.update")?.({}); + await saveStarted.promise; + + const reset = triggerReset(connection); + await Promise.resolve(); + expect(mockState.handlers.has("creds.update")).toBe(false); + expect(clearAuthState).not.toHaveBeenCalled(); + releaseSave.resolve(); + await Promise.all([credentialSave, reset]); + + expect(clearAuthState).toHaveBeenCalledTimes(1); + expect(await persistence.loadCredentials()).toBeNull(); + }); + + it("deduplicates explicit and connection-event auth resets for one socket", async () => { + const persistence = createSqliteWhatsAppPersistence(createInMemoryDb() as any); + const clearAuthState = vi.spyOn(persistence, "clearAuthState"); + const connection = new WhatsAppConnection(makeCtx(), "0.1.0", vi.fn().mockResolvedValue("reply"), persistence); + await connection.start(); + const connectionUpdate = mockState.handlers.get("connection.update")!; + mockState.logout.mockImplementationOnce(async () => connectionUpdate({ + connection: "close", + lastDisconnect: { error: { output: { statusCode: 401 } } }, + })); + + await connection.logout(); + + expect(clearAuthState).toHaveBeenCalledTimes(1); + expect(await persistence.loadCredentials()).toBeNull(); + }); + + it("logs reconnect timer rejection instead of leaking it", async () => { + vi.useFakeTimers(); + const ctx = makeCtx(); + const connection = new WhatsAppConnection(ctx, "0.1.0", vi.fn().mockResolvedValue("reply"), createSqliteWhatsAppPersistence(createInMemoryDb() as any)); + await connection.start(); + mockState.makeWASocket.mockImplementationOnce(() => { + throw new Error("reconnect exploded"); + }); + + await mockState.handlers.get("connection.update")?.({ connection: "close", lastDisconnect: { error: new Error("closed") } }); + await vi.advanceTimersByTimeAsync(1000); + + expect(ctx.logger.error).toHaveBeenCalledWith("WhatsApp reconnect failed", expect.any(Error)); + expect(connection.getStatus()).toMatchObject({ state: "error", lastError: "reconnect exploded" }); + await connection.stop(); + vi.useRealTimers(); + }); + it("splits oversized messages", () => { const chunks = WhatsAppConnection.splitMessageForWhatsapp("x".repeat(9000)); expect(chunks.length).toBeGreaterThan(2); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts index c4bf1ff251..77d60d1803 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts @@ -49,11 +49,20 @@ function createInMemoryDb() { return undefined; }, run: (...args: unknown[]) => { + if (sql.includes("INSERT OR IGNORE INTO whatsapp_chat_dedupe")) { + if (dedupe.has(args[0] as string)) return { changes: 0 }; + dedupe.set(args[0] as string, { + sender: args[1] as string, + receivedAt: args[2] as string, + }); + return { changes: 1 }; + } if (sql.includes("INSERT INTO whatsapp_chat_dedupe")) { dedupe.set(args[0] as string, { sender: args[1] as string, receivedAt: args[2] as string, }); + return { changes: 1 }; } if (sql.includes("DELETE FROM whatsapp_chat_dedupe WHERE receivedAt < ?")) { const cutoff = args[0] as string; diff --git a/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts new file mode 100644 index 0000000000..250bcadf4a --- /dev/null +++ b/plugins/fusion-plugin-whatsapp-chat/src/__tests__/persistence.pg.test.ts @@ -0,0 +1,97 @@ +/* + * FNXC:WhatsAppPostgresPersistence 2026-07-13-23:40: + * PostgreSQL persistence coverage uses the repository's reachability-aware harness so unavailable local PostgreSQL skips canonically while available runs prove project isolation, atomic replay claims, overwrites, and destructive auth operations. + */ +import { expect, it, vi } from "vitest"; +import type { AsyncDataLayer } from "@fusion/core"; +import type { PluginContext } from "@fusion/plugin-sdk"; +import { + createTaskStoreForTest, + pgDescribe, +} from "../../../../packages/core/src/__test-utils__/pg-test-harness.js"; +import { createWhatsAppPersistence } from "../persistence.js"; + +function bind(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { + return { ...layer, projectId }; +} + +function context(layer: AsyncDataLayer): PluginContext { + return { + pluginId: "fusion-plugin-whatsapp-chat", + settings: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + emitEvent: vi.fn(), + taskStore: { getAsyncLayer: () => layer } as unknown as PluginContext["taskStore"], + }; +} + +pgDescribe("WhatsAppPersistence PostgreSQL", () => { + it("round-trips and destructively updates state without crossing projects", async () => { + const h = await createTaskStoreForTest({ prefix: "whatsapp_persistence" }); + try { + const a = createWhatsAppPersistence(context(bind(h.layer, "project-a"))); + const b = createWhatsAppPersistence(context(bind(h.layer, "project-b"))); + const first = { role: "user" as const, text: "hello", createdAt: "2026-07-13T00:00:00.000Z" }; + const replacement = { role: "assistant" as const, text: "updated", createdAt: "2026-07-13T00:01:00.000Z" }; + + await a.appendHistory("15551234", [first], 10); + await a.appendHistory("15551234", [replacement], 10); + expect(await a.loadHistory("15551234")).toEqual([first, replacement]); + expect(await b.loadHistory("15551234")).toEqual([]); + + await a.saveCredentials("a-creds"); + await b.saveCredentials("b-creds"); + await a.writeAuthKeys({ + session: { keep: "a-key", remove: "old-key" }, + "sender-key": { sender: "sender-key-value" }, + }); + await a.writeAuthKeys({ session: { remove: null } }); + expect(await a.loadAuthKeys("session", ["keep", "remove"])).toEqual({ keep: "a-key" }); + expect(await a.loadAuthKeys("sender-key", ["sender"])).toEqual({ sender: "sender-key-value" }); + expect(await b.loadCredentials()).toBe("b-creds"); + + await a.clearAuthState(); + expect(await a.loadCredentials()).toBeNull(); + expect(await a.loadAuthKeys("session", ["keep"])).toEqual({}); + expect(await b.loadCredentials()).toBe("b-creds"); + } finally { + await h.teardown(); + } + }); + + it("preserves every concurrent append for one sender", async () => { + const h = await createTaskStoreForTest({ prefix: "whatsapp_history_append" }); + try { + const persistence = createWhatsAppPersistence(context(bind(h.layer, "project-a"))); + const turns = Array.from({ length: 8 }, (_, index) => ({ + role: "user" as const, + text: `message-${index}`, + createdAt: `2026-07-14T00:00:0${index}.000Z`, + })); + + await Promise.all(turns.map((turn) => persistence.appendHistory("15551234", [turn], 20))); + + expect((await persistence.loadHistory("15551234")).map((turn) => turn.text).sort()) + .toEqual(turns.map((turn) => turn.text).sort()); + } finally { + await h.teardown(); + } + }); + + it("allows exactly one concurrent duplicate-delivery claimant per project", async () => { + const h = await createTaskStoreForTest({ prefix: "whatsapp_claim" }); + try { + const a = createWhatsAppPersistence(context(bind(h.layer, "project-a"))); + const b = createWhatsAppPersistence(context(bind(h.layer, "project-b"))); + + const claims = await Promise.all( + Array.from({ length: 8 }, () => a.claimMessage("same-message", "15551234", 7)), + ); + expect(claims.filter(Boolean)).toHaveLength(1); + expect(await a.wasProcessed("same-message")).toBe(true); + expect(await b.claimMessage("same-message", "15551234", 7)).toBe(true); + } finally { + await h.teardown(); + } + }); +}); diff --git a/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts b/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts index 2ecbf30488..dea7035b6d 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/auth-state.ts @@ -1,5 +1,5 @@ import { BufferJSON, initAuthCreds, type AuthenticationState, type AuthenticationCreds, type SignalDataSet, type SignalDataTypeMap } from "@whiskeysockets/baileys"; -import type { PluginDb } from "./index.js"; +import { createSqliteWhatsAppPersistence, type PluginDb, type WhatsAppPersistence } from "./persistence.js"; type AuthStateResult = { state: AuthenticationState; @@ -20,70 +20,51 @@ function serialize(value: unknown): string { return JSON.stringify(value, BufferJSON.replacer); } -function loadCreds(db: PluginDb): AuthenticationCreds { - const row = db.prepare("SELECT value FROM whatsapp_auth_creds WHERE id = 'creds'").get() as AuthRow | undefined; - if (!row) return initAuthCreds(); - return parseStoredValue(row.value) ?? initAuthCreds(); +export async function clearAuthState(db: PluginDb): Promise { + await createSqliteWhatsAppPersistence(db).clearAuthState(); } -export function clearAuthState(db: PluginDb): void { - db.prepare("DELETE FROM whatsapp_auth_creds").run(); - db.prepare("DELETE FROM whatsapp_auth_keys").run(); +export async function createPluginDbAuthState(db: PluginDb): Promise { + return createPersistenceAuthState(createSqliteWhatsAppPersistence(db)); } -export function createPluginDbAuthState(db: PluginDb): AuthStateResult { +/** + * FNXC:WhatsAppPostgresPersistence 2026-07-13-22:37: + * Baileys auth callbacks are already asynchronous, so the runtime auth state uses the backend-neutral persistence contract. The legacy PluginDb helper remains for SQLite compatibility tests and older plugin hosts. + */ +export async function createPersistenceAuthState(persistence: WhatsAppPersistence): Promise { + const storedCredentials = await persistence.loadCredentials(); const state: AuthenticationState = { - creds: loadCreds(db), + creds: storedCredentials + ? parseStoredValue(storedCredentials) ?? initAuthCreds() + : initAuthCreds(), keys: { get: async (type: T, ids: string[]) => { + const stored = await persistence.loadAuthKeys(type, ids); const result: Record = {}; - const select = db.prepare("SELECT value FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); - for (const id of ids) { - const row = select.get(type, id) as AuthRow | undefined; - if (!row) continue; - const parsed = parseStoredValue(row.value); - if (parsed != null) { - result[id] = parsed; - } + for (const [id, raw] of Object.entries(stored)) { + const parsed = parseStoredValue(raw); + if (parsed !== null) result[id] = parsed; } return result; }, set: async (data: SignalDataSet) => { - const upsert = db.prepare(` - INSERT INTO whatsapp_auth_keys(category, keyId, value, updatedAt) - VALUES(?, ?, ?, ?) - ON CONFLICT(category, keyId) - DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt - `); - const remove = db.prepare("DELETE FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); - const now = new Date().toISOString(); - + const batch: Record> = {}; for (const category of Object.keys(data) as Array) { - const categoryEntries = data[category]; - if (!categoryEntries) continue; - for (const id of Object.keys(categoryEntries)) { - const value = categoryEntries[id]; - if (value == null) { - remove.run(category, id); - continue; - } - upsert.run(category, id, serialize(value), now); + const entries = data[category]; + if (!entries) continue; + const values: Record = {}; + for (const [id, value] of Object.entries(entries)) { + values[id] = value == null ? null : serialize(value); } + batch[category] = values; } + await persistence.writeAuthKeys(batch); }, }, }; - return { state, - saveCreds: async () => { - const now = new Date().toISOString(); - db.prepare(` - INSERT INTO whatsapp_auth_creds(id, value, updatedAt) - VALUES('creds', ?, ?) - ON CONFLICT(id) - DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt - `).run(serialize(state.creds), now); - }, + saveCreds: async () => persistence.saveCredentials(serialize(state.creds)), }; } diff --git a/plugins/fusion-plugin-whatsapp-chat/src/connection.ts b/plugins/fusion-plugin-whatsapp-chat/src/connection.ts index fad9113cd9..66914ead48 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/connection.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/connection.ts @@ -2,18 +2,14 @@ import { DisconnectReason, makeWASocket, type ConnectionState, type WAMessage, t import type { PluginContext } from "@fusion/plugin-sdk"; import pino from "pino"; import qrcode from "qrcode"; -import { clearAuthState, createPluginDbAuthState } from "./auth-state.js"; +import { createPersistenceAuthState } from "./auth-state.js"; import { getAllowedSenders, getDedupeRetentionDays, getHistoryTurnLimit, - loadHistory, - markProcessed, - saveHistory, - wasProcessed, type ChatTurn, - type PluginDb, } from "./index.js"; +import type { WhatsAppPersistence } from "./persistence.js"; export type ConnectionStatus = { state: "starting" | "awaiting-qr" | "awaiting-code" | "connected" | "disconnected" | "error"; @@ -71,18 +67,25 @@ export class WhatsAppConnection { private reconnectTimer: ReturnType | null = null; private reconnectAttempt = 0; private stopped = true; - private authState: ReturnType; + private authState: Awaited> | null = null; + private readonly senderQueues = new Map>(); + private readonly inboundOperations = new Set>(); + private readonly credentialSaveOperations = new Set>(); + private readonly authResetOperations = new WeakMap>(); + private readonly credentialUpdateHandlers = new WeakMap Promise>(); + private readonly connectionUpdateHandlers = new WeakMap) => Promise>(); + private acceptCredentialSaves = false; public constructor( private readonly ctx: PluginContext, private readonly fusionVersion: string, private readonly generateReply: ReplyGenerator, - private readonly db: PluginDb, - ) { - this.authState = createPluginDbAuthState(this.db); - } + private readonly persistence: WhatsAppPersistence, + ) {} + public async start(): Promise { + this.authState = await createPersistenceAuthState(this.persistence); this.stopped = false; this.status = { state: "starting" }; await this.connect(); @@ -94,13 +97,22 @@ export class WhatsAppConnection { this.clearReconnectTimer(); const socket = this.sock; - this.sock = null; this.status = { state: "disconnected" }; if (socket) { - socket.ev.off("creds.update", this.authState.saveCreds); - socket.ev.off("connection.update", this.onConnectionUpdate); + this.disableCredentialSaves(socket); + const connectionUpdateHandler = this.connectionUpdateHandlers.get(socket); + if (connectionUpdateHandler) socket.ev.off("connection.update", connectionUpdateHandler); socket.ev.off("messages.upsert", this.onMessagesUpsert); + /** + * FNXC:WhatsAppGracefulStop 2026-07-14-01:28: + * Stop must reject new inbound work but let every already-accepted message send its persisted reply before the active socket closes. Otherwise the mutable socket can become null after history is saved, silently skipping a dedupe-claimed reply with no retry path. + */ + await Promise.allSettled([ + ...this.credentialSaveOperations, + ...this.inboundOperations, + ]); + if (this.sock === socket) this.sock = null; await socket.end(undefined); } } @@ -117,17 +129,31 @@ export class WhatsAppConnection { } public async logout(): Promise { + /** + * FNXC:WhatsAppLogoutRace 2026-07-14-00:42: + * Logout must retain the socket selected at invocation even when stop concurrently clears this.sock. The stable reference ensures Baileys still attempts its server-side logout before local credentials are discarded. + */ + const socket = this.sock; + if (socket) this.disableCredentialSaves(socket); try { - await this.sock?.logout(); + await socket?.logout(); } finally { - clearAuthState(this.db); - this.authState = createPluginDbAuthState(this.db); - this.status = { state: "disconnected" }; + if (socket) { + await this.resetLoggedOutAuth(socket); + if (this.sock === null || this.sock === socket) { + this.status = { state: "disconnected" }; + } + } else { + await this.persistence.clearAuthState(); + this.authState = await createPersistenceAuthState(this.persistence); + this.status = { state: "disconnected" }; + } } } private async connect(): Promise { if (this.stopped) return; + if (!this.authState) this.authState = await createPersistenceAuthState(this.persistence); this.status = { state: "starting" }; const socket = makeWASocket({ @@ -138,14 +164,51 @@ export class WhatsAppConnection { }); this.sock = socket; - socket.ev.on("creds.update", this.authState.saveCreds); - socket.ev.on("connection.update", this.onConnectionUpdate); + this.acceptCredentialSaves = true; + const credentialUpdateHandler = () => this.onCredsUpdate(socket); + const connectionUpdateHandler = (update: Partial) => this.onConnectionUpdate(socket, update); + this.credentialUpdateHandlers.set(socket, credentialUpdateHandler); + this.connectionUpdateHandlers.set(socket, connectionUpdateHandler); + socket.ev.on("creds.update", credentialUpdateHandler); + socket.ev.on("connection.update", connectionUpdateHandler); socket.ev.on("messages.upsert", this.onMessagesUpsert); } - private readonly onConnectionUpdate = async (update: Partial): Promise => { + /* + * FNXC:WhatsAppAsyncListeners 2026-07-13-23:40: + * Baileys uses EventEmitter, which does not observe rejected async listener promises. Every registered callback attaches its own rejection handler so QR/auth/persistence failures are logged and cannot become process-level unhandled rejections. + */ + private onCredsUpdate(socket: WASocket): Promise { + if (this.sock !== socket || !this.acceptCredentialSaves) return Promise.resolve(); + let operation: Promise; + operation = Promise.resolve(this.authState?.saveCreds()) + .catch((error: unknown) => { + this.ctx.logger.error("WhatsApp credential persistence failed", error); + }) + .finally(() => this.credentialSaveOperations.delete(operation)); + this.credentialSaveOperations.add(operation); + return operation; + } + + private onConnectionUpdate( + socket: WASocket, + update: Partial, + ): Promise { + return this.handleConnectionUpdate(socket, update).catch((error: unknown) => { + if (this.sock !== socket) return; + this.status = { + state: "error", + lastError: error instanceof Error ? error.message : String(error), + }; + this.ctx.logger.error("WhatsApp connection update failed", error); + }); + } + + private async handleConnectionUpdate(socket: WASocket, update: Partial): Promise { + if (this.sock !== socket) return; if (update.qr) { const qrDataUrl = await qrcode.toDataURL(update.qr); + if (this.sock !== socket) return; this.ctx.logger.info("WhatsApp pairing QR updated", update.qr); this.status = { state: "awaiting-qr", qr: update.qr, qrDataUrl }; } @@ -158,9 +221,10 @@ export class WhatsAppConnection { if (update.connection === "close") { if (isLoggedOutDisconnect(update.lastDisconnect?.error)) { - clearAuthState(this.db); - this.authState = createPluginDbAuthState(this.db); - this.status = { state: "disconnected", lastError: "loggedOut" }; + await this.resetLoggedOutAuth(socket); + if (this.sock === null || this.sock === socket) { + this.status = { state: "disconnected", lastError: "loggedOut" }; + } return; } @@ -171,9 +235,53 @@ export class WhatsAppConnection { }; this.scheduleReconnect(); } + } + + private disableCredentialSaves(socket: WASocket): void { + const credentialUpdateHandler = this.credentialUpdateHandlers.get(socket); + if (credentialUpdateHandler) socket.ev.off("creds.update", credentialUpdateHandler); + if (this.sock === socket) this.acceptCredentialSaves = false; + } + + private resetLoggedOutAuth(socket: WASocket): Promise { + const existing = this.authResetOperations.get(socket); + if (existing) return existing; + + /** + * FNXC:WhatsAppCredentialReset 2026-07-14-02:08: + * Explicit logout and logged-out connection events must stop accepting credential writes and drain every accepted save before clearing authentication. Otherwise a standalone credentials upsert can finish after the clear and resurrect the stale session; one reset per socket also prevents the two logout surfaces from racing each other. + */ + this.disableCredentialSaves(socket); + const operation = (async () => { + await Promise.allSettled([...this.credentialSaveOperations]); + if (this.sock !== null && this.sock !== socket) return; + await this.persistence.clearAuthState(); + const replacementAuthState = await createPersistenceAuthState(this.persistence); + if (this.sock === null || this.sock === socket) { + this.authState = replacementAuthState; + } + })(); + this.authResetOperations.set(socket, operation); + return operation; + } + + private readonly onMessagesUpsert = ( + upsert: { type?: string; messages?: WAMessage[] }, + ): Promise => { + if (this.stopped) return Promise.resolve(); + let operation: Promise; + operation = this.handleMessagesUpsert(upsert) + .catch((error: unknown) => { + this.ctx.logger.error("WhatsApp inbound listener failed", error); + }) + .finally(() => this.inboundOperations.delete(operation)); + this.inboundOperations.add(operation); + return operation; }; - private readonly onMessagesUpsert = async (upsert: { type?: string; messages?: WAMessage[] }): Promise => { + private async handleMessagesUpsert( + upsert: { type?: string; messages?: WAMessage[] }, + ): Promise { if (upsert.type !== "notify") return; for (const message of upsert.messages ?? []) { @@ -189,43 +297,68 @@ export class WhatsAppConnection { const sender = normalizeSender(jid); const allowedSenders = getAllowedSenders(this.ctx.settings); if (allowedSenders.size === 0 || (!allowedSenders.has(sender) && !allowedSenders.has(jid))) continue; - if (wasProcessed(this.db, messageId)) continue; + if (!(await this.persistence.claimMessage( + messageId, + sender, + getDedupeRetentionDays(this.ctx.settings), + ))) continue; - markProcessed(this.db, messageId, sender, getDedupeRetentionDays(this.ctx.settings)); - - try { - const history = loadHistory(this.db, sender); - const reply = await this.generateReply(this.ctx, sender, text, history); - const now = new Date().toISOString(); - const nextHistory: ChatTurn[] = [ - ...history, - { role: "user" as const, text, createdAt: now }, - { role: "assistant" as const, text: reply, createdAt: now }, - ].slice(-getHistoryTurnLimit(this.ctx.settings)); - saveHistory(this.db, sender, nextHistory); - - for (const chunk of splitMessageForWhatsapp(reply)) { - await this.sock?.sendMessage(jid, { text: chunk }); - } - } catch (error) { - this.ctx.logger.error("WhatsApp chat processing failed", error); + await this.enqueueSender(sender, async () => { try { - await this.sock?.sendMessage(jid, { text: FALLBACK_TEXT }); - } catch { - // no-op + const history = await this.persistence.loadHistory(sender); + const reply = await this.generateReply(this.ctx, sender, text, history); + const now = new Date().toISOString(); + const turns: ChatTurn[] = [ + { role: "user" as const, text, createdAt: now }, + { role: "assistant" as const, text: reply, createdAt: now }, + ]; + await this.persistence.appendHistory(sender, turns, getHistoryTurnLimit(this.ctx.settings)); + + for (const chunk of splitMessageForWhatsapp(reply)) { + await this.sock?.sendMessage(jid, { text: chunk }); + } + } catch (error) { + this.ctx.logger.error("WhatsApp chat processing failed", error); + try { + await this.sock?.sendMessage(jid, { text: FALLBACK_TEXT }); + } catch { + // no-op + } } - } + }); } - }; + } + + private async enqueueSender(sender: string, operation: () => Promise): Promise { + const previous = this.senderQueues.get(sender) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + this.senderQueues.set(sender, current); + try { + await current; + } finally { + if (this.senderQueues.get(sender) === current) this.senderQueues.delete(sender); + } + } private scheduleReconnect(): void { if (this.stopped || this.reconnectTimer) return; const delay = BACKOFF_MS[Math.min(this.reconnectAttempt, BACKOFF_MS.length - 1)] ?? 30000; this.reconnectAttempt += 1; - this.reconnectTimer = setTimeout(async () => { + this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; - await this.connect(); + /* + * FNXC:WhatsAppReconnectErrors 2026-07-13-23:40: + * Timer callbacks have no promise consumer. Attach failure handling at scheduling time, expose the error through connection status/logs, and continue bounded backoff unless the plugin was stopped. + */ + void this.connect().catch((error: unknown) => { + this.status = { + state: "error", + lastError: error instanceof Error ? error.message : String(error), + }; + this.ctx.logger.error("WhatsApp reconnect failed", error); + this.scheduleReconnect(); + }); }, delay); } diff --git a/plugins/fusion-plugin-whatsapp-chat/src/index.ts b/plugins/fusion-plugin-whatsapp-chat/src/index.ts index 22ef9acac4..3803e9a408 100644 --- a/plugins/fusion-plugin-whatsapp-chat/src/index.ts +++ b/plugins/fusion-plugin-whatsapp-chat/src/index.ts @@ -2,19 +2,14 @@ import { definePlugin } from "@fusion/plugin-sdk"; import type { FusionPlugin, PluginContext, PluginRouteDefinition, PluginRouteResponse, PluginSettingSchema } from "@fusion/plugin-sdk"; import { WhatsAppConnection } from "./connection.js"; import { generateReply } from "./reply.js"; +import { createWhatsAppPersistence } from "./persistence.js"; +export { claimMessage, loadHistory, saveHistory, wasProcessed, markProcessed } from "./persistence.js"; +export type { ChatTurn, PluginDb } from "./persistence.js"; const DEFAULT_HISTORY_TURN_LIMIT = 40; const DEFAULT_DEDUPE_RETENTION_DAYS = 7; -export type ChatTurn = { role: "user" | "assistant"; text: string; createdAt: string }; - -export type PluginDb = { - exec(sql: string): void; - prepare(sql: string): { - get(...args: unknown[]): unknown; - run(...args: unknown[]): unknown; - }; -}; +import type { ChatTurn, PluginDb } from "./persistence.js"; const settingsSchema: Record = { pairingMode: { @@ -115,53 +110,6 @@ export function ensureSchema(db: PluginDb): void { `); } -export function loadHistory(db: PluginDb, sender: string): ChatTurn[] { - const row = db.prepare("SELECT history FROM whatsapp_chat_sessions WHERE sender = ?").get(sender) as { history: string } | undefined; - if (!row) return []; - try { - const parsed = JSON.parse(row.history) as ChatTurn[]; - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function saveHistory(db: PluginDb, sender: string, history: ChatTurn[]): void { - const now = new Date().toISOString(); - db.prepare(` - INSERT INTO whatsapp_chat_sessions(sender, history, updatedAt) - VALUES(?, ?, ?) - ON CONFLICT(sender) DO UPDATE SET history = excluded.history, updatedAt = excluded.updatedAt - `).run(sender, JSON.stringify(history), now); -} - -export function wasProcessed(db: PluginDb, messageId: string): boolean { - const row = db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get(messageId) as { found: number } | undefined; - return Boolean(row?.found); -} - -export function markProcessed( - db: PluginDb, - messageId: string, - sender: string, - retentionDays: number = DEFAULT_DEDUPE_RETENTION_DAYS, -): void { - const now = new Date().toISOString(); - const cutoff = new Date(Date.now() - retentionDays * 86_400_000).toISOString(); - db.prepare("DELETE FROM whatsapp_chat_dedupe WHERE receivedAt < ?").run(cutoff); - db.prepare("INSERT INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)").run(messageId, sender, now); -} - - -function getDbFromTaskStore(ctx: PluginContext): PluginDb { - const pluginStore = ctx.taskStore.getPluginStore(); - const db = (pluginStore as unknown as { db?: PluginDb }).db; - if (!db) { - throw new Error("Plugin database unavailable"); - } - return db; -} - function getConnectionOrResponse(ctx: PluginContext): { connection?: WhatsAppConnection; error?: PluginRouteResponse } { const connection = connections.get(getConnectionKey(ctx)); if (!connection) { @@ -244,8 +192,8 @@ const plugin: FusionPlugin = definePlugin({ ensureSchema(db as PluginDb); }, onLoad: async (ctx) => { - const db = getDbFromTaskStore(ctx); - const connection = new WhatsAppConnection(ctx, plugin.manifest.version, generateReply, db); + const persistence = createWhatsAppPersistence(ctx); + const connection = new WhatsAppConnection(ctx, plugin.manifest.version, generateReply, persistence); connections.set(getConnectionKey(ctx), connection); await connection.start(); }, diff --git a/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts b/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts new file mode 100644 index 0000000000..65e7919530 --- /dev/null +++ b/plugins/fusion-plugin-whatsapp-chat/src/persistence.ts @@ -0,0 +1,276 @@ +import type { PluginContext } from "@fusion/plugin-sdk"; +import { sql } from "drizzle-orm"; + +export type ChatTurn = { role: "user" | "assistant"; text: string; createdAt: string }; +export type AuthKeyBatch = Record>; +export type PluginDb = { + exec(sql: string): void; + prepare(sql: string): { get(...args: unknown[]): unknown; run(...args: unknown[]): unknown }; +}; + +const DAY_MS = 86_400_000; + +export interface WhatsAppPersistence { + loadHistory(sender: string): Promise; + appendHistory(sender: string, turns: ChatTurn[], turnLimit: number): Promise; + wasProcessed(messageId: string): Promise; + markProcessed(messageId: string, sender: string, retentionDays: number): Promise; + claimMessage(messageId: string, sender: string, retentionDays: number): Promise; + loadCredentials(): Promise; + saveCredentials(value: string): Promise; + loadAuthKeys(category: string, ids: string[]): Promise>; + writeAuthKeys(batch: AuthKeyBatch): Promise; + clearAuthState(): Promise; +} + +function parseHistory(raw: string | null | undefined): ChatTurn[] { + if (!raw) return []; + try { + const value: unknown = JSON.parse(raw); + return Array.isArray(value) ? value as ChatTurn[] : []; + } catch { + return []; + } +} + +export function loadHistory(db: PluginDb, sender: string): ChatTurn[] { + const row = db.prepare("SELECT history FROM whatsapp_chat_sessions WHERE sender = ?").get(sender) as { history?: string } | undefined; + return parseHistory(row?.history); +} + +export function saveHistory(db: PluginDb, sender: string, history: ChatTurn[]): void { + const now = new Date().toISOString(); + db.prepare(`INSERT INTO whatsapp_chat_sessions(sender, history, updatedAt) VALUES(?, ?, ?) + ON CONFLICT(sender) DO UPDATE SET history = excluded.history, updatedAt = excluded.updatedAt`) + .run(sender, JSON.stringify(history), now); +} + +function withImmediateTransaction(db: PluginDb, operation: () => T): T { + db.exec("BEGIN IMMEDIATE"); + try { + const result = operation(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +/** + * FNXC:WhatsAppConcurrentHistory 2026-07-14-00:42: + * Concurrent deliveries from one sender must append complete user/assistant turns instead of replacing a stale history snapshot. Keep the read, bounded append, and write in one immediate transaction; the connection also serializes reply generation per sender so each reply observes every earlier delivered turn. + */ +export function appendHistory( + db: PluginDb, + sender: string, + turns: ChatTurn[], + turnLimit: number, +): void { + withImmediateTransaction(db, () => { + const history = [...loadHistory(db, sender), ...turns].slice(-turnLimit); + saveHistory(db, sender, history); + }); +} + +export function wasProcessed(db: PluginDb, messageId: string): boolean { + return Boolean(db.prepare("SELECT 1 as found FROM whatsapp_chat_dedupe WHERE messageId = ?").get(messageId)); +} + +export function markProcessed(db: PluginDb, messageId: string, sender: string, retentionDays = 7): void { + claimMessage(db, messageId, sender, retentionDays); +} + +/** + * FNXC:WhatsAppReplayClaim 2026-07-13-23:40: + * Duplicate deliveries can reach concurrent EventEmitter callbacks. Claim a message with one uniqueness-enforced insert and process it only when that insert wins; a separate read followed by insert permits both callbacks to generate and send a reply. + */ +export function claimMessage( + db: PluginDb, + messageId: string, + sender: string, + retentionDays = 7, +): boolean { + const now = new Date().toISOString(); + const cutoff = new Date(Date.now() - retentionDays * DAY_MS).toISOString(); + db.prepare("DELETE FROM whatsapp_chat_dedupe WHERE receivedAt < ?").run(cutoff); + const result = db + .prepare("INSERT OR IGNORE INTO whatsapp_chat_dedupe(messageId, sender, receivedAt) VALUES(?, ?, ?)") + .run(messageId, sender, now) as { changes?: number }; + return Number(result.changes ?? 0) === 1; +} + +export function createSqliteWhatsAppPersistence(db: PluginDb): WhatsAppPersistence { + return { + async loadHistory(sender) { + return loadHistory(db, sender); + }, + async appendHistory(sender, turns, turnLimit) { + appendHistory(db, sender, turns, turnLimit); + }, + async wasProcessed(messageId) { + return wasProcessed(db, messageId); + }, + async markProcessed(messageId, sender, retentionDays) { + markProcessed(db, messageId, sender, retentionDays); + }, + async claimMessage(messageId, sender, retentionDays) { + return claimMessage(db, messageId, sender, retentionDays); + }, + async loadCredentials() { + const row = db.prepare("SELECT value FROM whatsapp_auth_creds WHERE id = 'creds'").get() as { value?: string } | undefined; + return row?.value ?? null; + }, + async saveCredentials(value) { + db.prepare(`INSERT INTO whatsapp_auth_creds(id, value, updatedAt) VALUES('creds', ?, ?) + ON CONFLICT(id) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`) + .run(value, new Date().toISOString()); + }, + async loadAuthKeys(category, ids) { + const result: Record = {}; + const select = db.prepare("SELECT value FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); + for (const id of ids) { + const row = select.get(category, id) as { value?: string } | undefined; + if (row?.value !== undefined) result[id] = row.value; + } + return result; + }, + async writeAuthKeys(batch) { + /** + * FNXC:WhatsAppAuthKeyAtomicity 2026-07-14-01:21: + * A Baileys Signal-key update can rotate several categories in one logical batch. Commit every category's deletes and upserts together so a later category failure cannot leave an earlier category partially rotated. + */ + withImmediateTransaction(db, () => { + const upsert = db.prepare(`INSERT INTO whatsapp_auth_keys(category, keyId, value, updatedAt) VALUES(?, ?, ?, ?) + ON CONFLICT(category, keyId) DO UPDATE SET value = excluded.value, updatedAt = excluded.updatedAt`); + const remove = db.prepare("DELETE FROM whatsapp_auth_keys WHERE category = ? AND keyId = ?"); + const now = new Date().toISOString(); + for (const [category, values] of Object.entries(batch)) { + for (const [id, value] of Object.entries(values)) { + if (value === null) remove.run(category, id); + else upsert.run(category, id, value, now); + } + } + }); + }, + async clearAuthState() { + /** + * FNXC:WhatsAppAuthStateAtomicity 2026-07-14-00:54: + * Credentials and Signal keys form one authentication state. Clear both in one immediate transaction so a failed or interrupted second delete cannot persist credentials without their matching keys, or vice versa. + */ + withImmediateTransaction(db, () => { + db.prepare("DELETE FROM whatsapp_auth_creds").run(); + db.prepare("DELETE FROM whatsapp_auth_keys").run(); + }); + }, + }; +} + +/** + * FNXC:WhatsAppPostgresPersistence 2026-07-13-22:37: + * Backend-mode WhatsApp state must use the bound AsyncDataLayer instead of reaching through PluginStore for its former private SQLite database. Every statement includes project_id because bundled plugins from all projects share the same project schema. + */ +export function createWhatsAppPersistence(ctx: PluginContext): WhatsAppPersistence { + const layer = typeof ctx.taskStore.getAsyncLayer === "function" ? ctx.taskStore.getAsyncLayer() : null; + if (!layer) { + const pluginStore = ctx.taskStore.getPluginStore(); + const db = (pluginStore as unknown as { db?: PluginDb }).db; + if (!db) throw new Error("Plugin database unavailable"); + return createSqliteWhatsAppPersistence(db); + } + + const projectId = layer.projectId; + if (!projectId) throw new Error("WhatsApp PostgreSQL persistence requires a project-bound data layer"); + const db = layer.db; + + return { + async loadHistory(sender) { + const rows = await db.execute(sql`SELECT history FROM project.whatsapp_chat_sessions + WHERE project_id = ${projectId} AND sender = ${sender} LIMIT 1`) as unknown as Array<{ history: string }>; + return parseHistory(rows[0]?.history); + }, + async appendHistory(sender, turns, turnLimit) { + const now = new Date().toISOString(); + await layer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${projectId}:${sender}`}, 0))`); + const rows = await tx.execute(sql`SELECT history FROM project.whatsapp_chat_sessions + WHERE project_id = ${projectId} AND sender = ${sender} LIMIT 1`) as unknown as Array<{ history: string }>; + const history = [...parseHistory(rows[0]?.history), ...turns].slice(-turnLimit); + await tx.execute(sql`INSERT INTO project.whatsapp_chat_sessions(project_id, sender, history, updated_at) + VALUES(${projectId}, ${sender}, ${JSON.stringify(history)}, ${now}) + ON CONFLICT(project_id, sender) DO UPDATE SET history = excluded.history, updated_at = excluded.updated_at`); + }); + }, + async wasProcessed(messageId) { + const rows = await db.execute(sql`SELECT 1 AS found FROM project.whatsapp_chat_dedupe + WHERE project_id = ${projectId} AND message_id = ${messageId} LIMIT 1`) as unknown as unknown[]; + return rows.length > 0; + }, + async markProcessed(messageId, sender, retentionDays) { + const now = new Date().toISOString(); + const cutoff = new Date(Date.now() - retentionDays * DAY_MS).toISOString(); + await layer.transactionImmediate(async (tx) => { + await tx.execute(sql`DELETE FROM project.whatsapp_chat_dedupe WHERE project_id = ${projectId} AND received_at < ${cutoff}`); + await tx.execute(sql`INSERT INTO project.whatsapp_chat_dedupe(project_id, message_id, sender, received_at) + VALUES(${projectId}, ${messageId}, ${sender}, ${now}) ON CONFLICT(project_id, message_id) DO NOTHING`); + }); + }, + async claimMessage(messageId, sender, retentionDays) { + const now = new Date().toISOString(); + const cutoff = new Date(Date.now() - retentionDays * DAY_MS).toISOString(); + return layer.transactionImmediate(async (tx) => { + await tx.execute(sql`DELETE FROM project.whatsapp_chat_dedupe + WHERE project_id = ${projectId} AND received_at < ${cutoff}`); + const claimed = await tx.execute(sql`INSERT INTO project.whatsapp_chat_dedupe(project_id, message_id, sender, received_at) + VALUES(${projectId}, ${messageId}, ${sender}, ${now}) + ON CONFLICT(project_id, message_id) DO NOTHING + RETURNING message_id`) as unknown as Array<{ message_id: string }>; + return claimed.length === 1; + }); + }, + async loadCredentials() { + const rows = await db.execute(sql`SELECT value FROM project.whatsapp_auth_creds + WHERE project_id = ${projectId} AND id = 'creds' LIMIT 1`) as unknown as Array<{ value: string }>; + return rows[0]?.value ?? null; + }, + async saveCredentials(value) { + const now = new Date().toISOString(); + await db.execute(sql`INSERT INTO project.whatsapp_auth_creds(project_id, id, value, updated_at) + VALUES(${projectId}, 'creds', ${value}, ${now}) ON CONFLICT(project_id, id) + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`); + }, + async loadAuthKeys(category, ids) { + if (ids.length === 0) return {}; + const result: Record = {}; + const rows = await db.execute(sql`SELECT key_id, value FROM project.whatsapp_auth_keys + WHERE project_id = ${projectId} AND category = ${category} AND key_id IN (${sql.join(ids.map((id) => sql`${id}`), sql`, `)})`) as unknown as Array<{ key_id: string; value: string }>; + for (const row of rows) result[row.key_id] = row.value; + return result; + }, + async writeAuthKeys(batch) { + const now = new Date().toISOString(); + await layer.transactionImmediate(async (tx) => { + for (const [category, values] of Object.entries(batch)) { + const removals = Object.entries(values).filter(([, value]) => value === null).map(([id]) => id); + const upserts = Object.entries(values).filter((entry): entry is [string, string] => entry[1] !== null); + if (removals.length > 0) { + await tx.execute(sql`DELETE FROM project.whatsapp_auth_keys WHERE project_id = ${projectId} AND category = ${category} + AND key_id IN (${sql.join(removals.map((id) => sql`${id}`), sql`, `)})`); + } + if (upserts.length > 0) { + const rows = upserts.map(([id, value]) => sql`(${projectId}, ${category}, ${id}, ${value}, ${now})`); + await tx.execute(sql`INSERT INTO project.whatsapp_auth_keys(project_id, category, key_id, value, updated_at) + VALUES ${sql.join(rows, sql`, `)} ON CONFLICT(project_id, category, key_id) + DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`); + } + } + }); + }, + async clearAuthState() { + await layer.transactionImmediate(async (tx) => { + await tx.execute(sql`DELETE FROM project.whatsapp_auth_creds WHERE project_id = ${projectId}`); + await tx.execute(sql`DELETE FROM project.whatsapp_auth_keys WHERE project_id = ${projectId}`); + }); + }, + }; +} diff --git a/plugins/fusion-plugin-whatsapp-chat/tsconfig.json b/plugins/fusion-plugin-whatsapp-chat/tsconfig.json index fdc529a99c..a2de636e43 100644 --- a/plugins/fusion-plugin-whatsapp-chat/tsconfig.json +++ b/plugins/fusion-plugin-whatsapp-chat/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c846320008..8392b6604e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,10 +50,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: ^0.80.6 - version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) dockerode: specifier: ^4.0.12 version: 4.0.12 @@ -499,10 +499,10 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@earendil-works/pi-coding-agent': specifier: '*' - version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + version: 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) '@fusion-plugin-examples/droid-runtime': specifier: workspace:* version: link:../../plugins/fusion-plugin-droid-runtime @@ -1158,6 +1158,9 @@ importers: '@fusion/plugin-sdk': specifier: workspace:* version: link:../../packages/plugin-sdk + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.9.0)(pg@8.22.0)(postgres@3.4.9) express: specifier: ^5.1.0 version: 5.2.1 @@ -1204,6 +1207,9 @@ importers: '@whiskeysockets/baileys': specifier: ^6.7.21 version: 6.17.16(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.9.0)(pg@8.22.0)(postgres@3.4.9) pino: specifier: ^9.9.0 version: 9.14.0 @@ -1211,6 +1217,9 @@ importers: specifier: ^1.5.4 version: 1.5.4 devDependencies: + '@fusion/core': + specifier: workspace:* + version: link:../../packages/core '@types/node': specifier: ^25.5.2 version: 25.5.2 @@ -7958,6 +7967,10 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@anthropic-ai/sdk@0.91.1': + dependencies: + json-schema-to-ts: 3.1.1 + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': dependencies: json-schema-to-ts: 3.1.1 @@ -8692,6 +8705,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8748,6 +8775,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-agent-core@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -8777,10 +8818,30 @@ snapshots: - zod '@earendil-works/pi-ai@0.77.0': + dependencies: + '@anthropic-ai/sdk': 0.91.1 + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.1 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0 + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -8858,10 +8919,31 @@ snapshots: - zod '@earendil-works/pi-ai@0.80.6': + dependencies: + '@anthropic-ai/sdk': 0.91.1 + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0 + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)) '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -8903,7 +8985,7 @@ snapshots: dependencies: '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)) + '@google/genai': 1.52.0 '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) '@opentelemetry/api': 1.9.0 '@smithy/node-http-handler': 4.7.3 @@ -8949,6 +9031,35 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.77.0 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + typebox: 1.1.38 + undici: 8.3.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.77.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9067,6 +9178,36 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-ai': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))(ws@8.20.0)(zod@3.25.76) + '@earendil-works/pi-tui': 0.80.6 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@earendil-works/pi-agent-core': 0.80.6(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) @@ -9482,6 +9623,30 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@3.25.76))': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.8 + ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.28.0(zod@3.25.76) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.28.0(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 @@ -10010,6 +10175,29 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.28.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.12(hono@4.12.9) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.9 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/sdk@1.28.0(zod@4.3.6)': dependencies: '@hono/node-server': 1.19.12(hono@4.12.9) @@ -10788,7 +10976,7 @@ snapshots: obug: 2.1.2 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.5.2)(@vitest/coverage-v8@4.1.8)(happy-dom@20.10.1)(jsdom@29.0.1)(vite@6.4.1(@types/node@25.5.2)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.8': dependencies: @@ -14105,6 +14293,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.26.0: {} + openai@6.26.0(ws@8.20.0)(zod@3.25.76): optionalDependencies: ws: 8.20.0