From 446c9699686f026abbc91c6762f52d79a79ce549 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 11:38:34 -0700 Subject: [PATCH] fix(FN-2127): harden Quality Postgres store (project scope, races) (#2230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up after **#2164** and main’s **FN-8103 / FN-8104** (Postgres-only data access / SQLite retirement). Main already routes Quality through `AsyncQualityStore` + `getAsyncLayer()`. This PR keeps the **review hardening** that was still missing: - **Project binding** — reject request `projectId` mismatches vs bound AsyncDataLayer; all SQL uses bound project - **`createRunIfNoActive`** — advisory lock so concurrent starts cannot double-queue - **Cancel-safe runner** — cancel slot registered before the running write; pre-spawn cancel skips process - **`finalizeRun`** — never overwrites a `cancelled` terminal status - **Detached execute** — catch only execution failures; prune fail-soft in `finally` - Guardrail tests + Quality v2 plan doc ## Test plan - [ ] Task QA loads under PostgreSQL (no SQLite/backend-mode error) - [ ] Concurrent start for same task → 409 second start - [ ] Cancel during start does not leave a live orphan process - [ ] Cancelled run stays cancelled after process exit - [ ] `pnpm --filter @fusion-plugin-examples/quality test` (37 tests) --- ...6-001-feat-quality-plugin-agent-qa-plan.md | 169 ++++++ .../src/__tests__/cancel-and-plans.test.ts | 8 +- .../src/__tests__/experimental-gate.test.ts | 28 +- .../src/__tests__/no-sqlite-runtime.test.ts | 45 ++ .../__tests__/preview-start-done-task.test.ts | 7 +- .../src/__tests__/quality-store.test.ts | 44 +- plugins/fusion-plugin-quality/src/index.ts | 3 + plugins/fusion-plugin-quality/src/qa-tab.tsx | 19 +- .../src/quality-schema.ts | 4 + .../src/routes/create-routes.ts | 84 +-- .../src/runner/command-runner.ts | 190 ++++--- .../src/store/async-quality-store.ts | 486 +++++++++++++++++- .../src/store/quality-store-api.ts | 66 +++ .../src/store/quality-store-provider.ts | 46 ++ .../src/store/quality-store.ts | 110 ++-- 15 files changed, 1093 insertions(+), 216 deletions(-) create mode 100644 docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md create mode 100644 plugins/fusion-plugin-quality/src/__tests__/no-sqlite-runtime.test.ts create mode 100644 plugins/fusion-plugin-quality/src/store/quality-store-api.ts create mode 100644 plugins/fusion-plugin-quality/src/store/quality-store-provider.ts diff --git a/docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md b/docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md new file mode 100644 index 0000000000..c12d0da311 --- /dev/null +++ b/docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md @@ -0,0 +1,169 @@ +--- +title: "feat: Quality plugin v2 — post-merge agent QA, evidence packs, and the QA results board" +type: feat +status: active +date: 2026-07-16 +origin: docs/plans/2026-07-14-001-feat-quality-plugin-plan.md +updated: 2026-07-16 +--- + +# feat: Quality plugin v2 — post-merge agent QA, evidence packs, and the QA results board + +## Summary + +Evolve `fusion-plugin-quality` from a manual per-task QA surface into a **post-merge, agent-driven QA system**: done tasks are QA'd by an agent that resurrects the task's code in a QA worktree, browser-exercises the change, walks the suggested cases, runs targeted tests, probes adjacent surfaces for regressions, and produces an **evidence pack** (screenshots + per-case verdicts). Failures file follow-up tasks with evidence attached. The Quality hub becomes a **QA results board**: queue + verdicts, evidence gallery, CI + gate health. + +## Operator interview findings (2026-07-16) + +How QA actually happens today and what the operator wants next: + +- **Current flow:** trusts executor/reviewer verification for most tasks; manually previews in a browser for the ones that matter. +- **Top pains:** (1) knowing *what* to check for a given task, (2) no visual evidence on landed tasks, (3) too many surfaces (terminal, Dev Server, Artifacts, PR checks). +- **Desired direction:** agent-driven QA + zero-click evidence + hub/CI depth. Explicitly *not* asking for more manual-tab polish as the priority. +- **QA timing:** **after merge (done tasks)** and in **batch sessions** — not a pre-merge gate. The done-task QA worktree path (FN-2127) is the intended substrate, not an edge case. +- **Agent session scope:** browser-exercise the change + capture evidence pack + run targeted tests + **Surface-Enumeration-bounded regression hunt** (adjacent surfaces derived from the diff: both breakpoints, shared components, sibling states — not open-ended roaming). +- **Trigger:** auto-queue on done, **setting-gated per project** (off by default), with concurrency cap and per-session budget. +- **On failure:** auto-file a follow-up bug task with evidence attached, linked to the original. +- **Hub first paint:** QA queue + verdicts, evidence gallery, CI + gate health. Test plans (U4 runner) were *not* selected — park them. +- **First slice:** prove the **agent QA engine** end-to-end on one done task (manual trigger) before building queue/auto plumbing. + +## Current state (v1 audit, 2026-07-16) + +From the v1 plan (2026-07-14) units: + +| Unit | Status | Notes | +|------|--------|-------| +| U1 scaffold/registration/slot context | ✅ done | Full registration matrix; `TaskDetailModal` injects `taskId`/`worktree`/`projectId`/`modifiedFiles` | +| U2 schema/store | ✅ done | Dual SQLite/PG, projectId-scoped, retention pruning | +| U3 presets/runner/hub runs | ✅ done | 5 allowlisted presets, `superviseSpawn` from `@fusion/core` (shim not needed), 409 concurrency | +| U6 Task QA tab shell | ✅ done | Action-first sections; Run tests + Reports functional | +| U11 task preview server | ✅ done | Incl. **done-task QA worktrees** (`preview/task-code-worktree.ts`: `.fusion/quality-qa/`, branch → `fusion/` → `mergeDetails.commitSha`) — also used for task-scoped test runs | +| U13 suggested cases | ◐ partial | Heuristics only; checklist is display-only (no tick/copy/run-related); no AI enrichment | +| U4 test plans | ◐ partial | Store + CRUD routes; no runner, no UI — **parked by this plan** | +| U5 CI read-only | ✗ placeholder | Static text in tab; no host route | +| U12 screenshots gallery | ✗ placeholder | Static text in tab | +| U7 browser-verification toggle | ✗ not started | In-review-oriented — **deprioritized** (operator QAs post-merge) | +| U9 workflow palette | ✗ not started | **Deprioritized** for same reason | +| U10 agent QA sessions | ✗ not started | **Becomes the centerpiece of this plan** | + +Known rough edges to absorb: preview sessions are an in-memory singleton (lost on restart, unlike runs); suggested-case `done` is stored but untoggleable; dead ternary in `create-routes.ts` `confirm_required` branch; `ensureQualitySchema` runs per request; all routes gated on `experimentalFeatures.qualityPlugin`. + +## Product shape + +```text +Task merges → done + └─ (if project autoQa enabled) enqueue QA job + └─ Agent QA session (cap N concurrent, budgeted) + 1. Resolve QA worktree (done-task resurrection path — already shipped) + 2. Install/build if needed; start preview session (free port, never 4040) + 3. Load QA script: AI-enriched suggested cases + Surface Enumeration targets from diff + 4. Browser-exercise change (agent-browser skill); capture screenshots per case + 5. Run targeted tests (file-scoped preset) into the same session record + 6. Regression hunt: adjacent surfaces only (both breakpoints, shared components, sibling states) + 7. Emit structured verdict: per-case pass/fail/blocked + evidence artifact refs + summary + └─ Verdict lands in hub QA board + task QA tab + ├─ pass → evidence pack visible on task (zero-click evidence) + └─ fail → auto-file follow-up bug task (evidence attached, linked to original) +Hub = QA results board: queue/in-flight/verdicts | evidence gallery | CI + gate health +``` + +Invariants carried forward from v1 (unchanged): advisory only — never merge-blocking; allowlisted commands only; never port 4040; never `execSync`; compose agent-browser + artifact registry + Dev Server patterns rather than forking them; projectId isolation on every row. + +New invariants: + +- **Agent QA is read-only on the repo**: sessions never commit, push, merge, or mutate task lifecycle except (a) writing QA records/artifacts and (b) filing the follow-up task through the normal task-creation path. +- **Budgets are hard**: per-session wall-clock + token budget; inactivity watchdog; queue concurrency cap (default 1). Exhaustion → `blocked` verdict, never silent partial pass. +- **Auto-QA is opt-in per project** (`autoQa.enabled`, default false). Manual trigger works regardless of the auto setting. +- **Evidence lives in the artifact registry** (task-scoped image artifacts), not a parallel media store; QA records hold refs only. + +## Implementation units + +### V1. Agent QA session engine (manual, single task) — first slice + +**Goal:** From a done task's QA tab, one click starts an agent QA session that ends with a structured verdict and at least one captured screenshot. Prove the whole loop before any queue exists. + +- New `src/agent-qa/`: session orchestrator using `ctx.createInteractiveAiSession` (probe; empty-state CTA when engine factory missing), CE detach + `onProgress` + inactivity watchdog pattern. +- Session cwd = QA worktree from `resolveTaskCodeCwd` (reuse as-is); orchestrator owns preview-session start/stop around the agent run. +- System prompt: QA charter — walk the provided case list, capture screenshot evidence per case via agent-browser, run the file-scoped test preset, then Surface-Enumeration hunt (explicit target list provided, see V3), return verdict JSON `{cases: [{id, verdict: pass|fail|blocked, evidence: [artifactRef], note}], regressions: [...], summary}`. +- New store table `qa_sessions` (projectId, taskId, status queued|running|passed|failed|blocked|error|cancelled, budgets, verdict JSON, startedAt/finishedAt); routes start/get/cancel; honor mock/testMode; never log tokens. +- QA tab: "Agent QA" section becomes real — start button (done tasks included), live progress, verdict rendering. +- Tests: mocked interactive session; detach semantics; watchdog; verdict persistence; factory-missing empty state; testMode. + +### V2. Evidence pack + screenshots gallery (completes U12) + +**Goal:** Zero-click evidence — opening a done task's QA tab shows what the change looks like. + +- Evidence capture path: agent sessions register screenshots as task-scoped image artifacts (artifact registry); QA session verdict stores artifact refs. +- Screenshots section: task-filtered artifact gallery (image MIME) + `design-preview` doc link + QA-session evidence grouped by case; thumbnails, open-in-Artifacts; actionable empty state ("Run agent QA"). +- Tests: populated/empty/cross-task isolation; evidence-ref integrity when artifacts pruned. + +### V3. QA script: AI-enriched suggested cases + Surface Enumeration targets (completes U13) + +**Goal:** Suggested cases become the executable QA script consumed by both the operator and the agent. + +- AI enrichment via short `createAiSession` pass over PROMPT + file scope + diff summary; fail-soft to existing heuristics; `method` field distinguishes heuristic/ai. +- New deterministic **surface-target derivation** from the diff (the regression-hunt bound): changed components → both breakpoints, shared hooks/components that reuse them, sibling data states — reusing the Surface Enumeration checklist taxonomy from `docs/testing.md`. +- Checklist UI becomes interactive: tick (persisted `done`), copy-all, "run related tests" wiring to the file-scoped preset. Agent verdicts auto-tick cases they cover. +- Tests: AI-failure fallback; tick persistence; target derivation from representative diffs. + +### V4. Verdict → follow-up task filing + +**Goal:** A failed QA verdict becomes an actionable, evidence-backed bug task without operator typing. + +- On `failed` verdict (auto mode) or via "File follow-up" button (manual): create a bug task through the normal task-creation path — title from failing case, body with **Original symptom / Exact reproduction / evidence artifact links**, link to the original task (dependency/reference), evidence attached. +- Setting `autoQa.fileFollowUps`: `auto` | `draft-confirm` | `off` (default `auto` per interview, but respect global testMode). +- Never reopen/move the original task; never touch merge state. +- Tests: filing payload shape; dedup (same case doesn't file twice across re-runs); off/draft modes. + +### V5. QA queue: auto-trigger on done, batch sweep, budgets + +**Goal:** Setting-gated projects get every done task QA'd automatically; any project can run a batch sweep. + +- Queue table + worker loop inside the plugin (supervised, resumable on restart — persist queue rows, reconcile in-flight on boot); enqueue hook on task→done transition (plugin lifecycle hook or poll fallback); concurrency cap (default 1, max 2); per-session budget settings. +- Batch sweep: hub action "QA last N done tasks" → enqueues; idempotent (skip tasks with a fresh verdict unless forced). +- Settings: `autoQa.enabled` (per project, default false), `autoQa.concurrency`, `autoQa.sessionBudget{Ms,Tokens}`, `autoQa.fileFollowUps`. +- QA-worktree hygiene: cap live QA worktrees, prune oldest on enqueue (bounded, prefix-scoped — never temp-root walks). +- Tests: gating off by default; cap enforcement; restart reconciliation; idempotent sweep. + +### V6. Hub → QA results board (+ CI/gate health, completes U5) + +**Goal:** First paint answers "what shipped, was it QA'd, did anything break?" + +- Board layout: **QA queue + verdicts** (awaiting / in-flight / recent verdicts with evidence links, filter by pass/fail) | **evidence gallery** (recent screenshots across tasks, click → task QA tab) | **CI + gate health** (host-owned read route reusing dashboard GitHub auth for open-PR check rollups + default-branch gate status; auth/empty/429 states; read-only). +- Keep existing run history reachable; park Plans UI behind an overflow section (store/CRUD kept, runner still unbuilt — explicitly deferred). +- Tests: board states (empty project, queue drained, failures present); CI route auth empty states; no plugin import of dashboard-private GitHub client. + +### V7. Hardening + polish (absorbs v1 rough edges) + +- Persist preview sessions (or reconcile-on-boot kill+mark), replacing the in-memory-only singleton. +- Fix dead `confirm_required` ternary; memoize schema init per store instance. +- Graduation criteria for the `experimentalFeatures.qualityPlugin` gate (documented; flip is a separate operator decision). +- Settings/docs/CONCEPTS updates (Agent QA Session, QA Verdict, Evidence Pack, QA Queue); FNXC comments on budgets, read-only session posture, follow-up filing policy; changeset (`minor`) when the published surface ships. + +## Phasing + +| Phase | Units | Ship gate | +|-------|-------|-----------| +| A — Prove the loop | V1 | Manual agent QA on a done task yields verdict + screenshot | +| B — Evidence + script | V2, V3 | Zero-click evidence on QA'd tasks; cases drive both human and agent QA | +| C — Close the loop | V4 | Failed verdicts file linked, evidence-backed follow-ups | +| D — Scale | V5, V6 | Opt-in projects auto-QA every done task; hub is the results board | +| E — Polish | V7 | Rough edges gone; docs/changeset shipped | + +## Explicitly parked (from v1 plan) + +- U4 plan runner + plans UI (operator did not select; CRUD substrate kept) +- U7 browser-verification toggle and U9 workflow palette (pre-merge-oriented; this plan is post-merge) +- Pre-merge QA gating of any kind (advisory promise unchanged) +- Pixel-diff/visual-regression product; multi-provider CI; Actions log streaming + +## Risks + +| Risk | Mitigation | +|------|------------| +| Agent QA cost blowup | Off by default; per-project opt-in; concurrency cap; hard session budgets; `blocked` on exhaustion | +| False-positive follow-up spam | Dedup per case+task; `draft-confirm` mode; evidence required in every filed task | +| QA worktree disk growth | Live-worktree cap + prune on enqueue (prefix-scoped) | +| Agent mutates repo/tasks | Read-only session charter + tool posture; only artifact/QA-record writes + task-creation seam | +| Preview/build fails on resurrected code | `blocked` verdict with build log evidence, not `failed`; surfaces as actionable in board | +| Regression hunt scope creep | Deterministic target list from diff (V3); agent may not add targets beyond budget | diff --git a/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts index b1f3eac836..e51dd5c351 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts @@ -23,7 +23,7 @@ describe("cancelQualityRun", () => { const db = new DatabaseSync(":memory:"); ensureQualitySchema(db as never); const store = new QualityStore(db as never); - const run = store.createRun({ + const run = await store.createRun({ projectId: "p1", source: "hub", command: "echo hi", @@ -32,7 +32,7 @@ describe("cancelQualityRun", () => { timeoutMs: 1000, triggeredBy: "test", }); - store.updateRun("p1", run.id, { status: "running", startedAt: new Date().toISOString() }); + await store.updateRun("p1", run.id, { status: "running", startedAt: new Date().toISOString() }); const kill = vi.fn(); __registerActiveQualityRunForTests("p1", run.id, { kill }); const cancelled = await cancelQualityRun(store, "p1", run.id); @@ -48,7 +48,7 @@ describe("cancelQualityRun", () => { const db = new DatabaseSync(":memory:"); ensureQualitySchema(db as never); const store = new QualityStore(db as never); - const run = store.createRun({ + const run = await store.createRun({ projectId: "p1", source: "hub", command: "safe-command", @@ -70,10 +70,10 @@ describe("cancelQualityRun", () => { timeoutMs: 1_000, logTruncateKb: 1, }); + // Race cancel against the running write; either pre-spawn cancel or kill of the live supervisor. await cancelQualityRun(store, "p1", run.id); await expect(execution).resolves.toMatchObject({ status: "cancelled", errorMessage: "Cancelled by operator" }); - expect(kill).toHaveBeenCalledWith("SIGTERM"); }); }); diff --git a/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts index 514519eded..802878661f 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts @@ -1,19 +1,22 @@ import { describe, expect, it, vi } from "vitest"; import { DatabaseSync } from "@fusion/core"; import { ensureQualitySchema } from "../quality-schema.js"; +import { QualityStore } from "../store/quality-store.js"; import { createQualityRoutes, loadTaskStoreSettings, requireQualityExperimental, } from "../routes/create-routes.js"; -function makeCtx(getSettings?: () => unknown) { +function makeCtx(getSettings?: () => unknown, withStore = false) { const db = new DatabaseSync(":memory:"); ensureQualitySchema(db as never); + const qualityStore = new QualityStore(db as never); return { taskStore: { - getDatabase: () => db, - getAsyncLayer: () => ({ projectId: "proj-1", db: { execute: vi.fn().mockResolvedValue([]) } }), + // Intentionally no getDatabase — QA routes must not call it. + getAsyncLayer: () => null, + getQualityStore: withStore ? () => qualityStore : undefined, getSettings: getSettings ?? (() => Promise.resolve({})), getRootDir: () => "/tmp", getTask: vi.fn(), @@ -77,13 +80,28 @@ describe("createQualityRoutes experimental wrapping", () => { expect(result).toMatchObject({ presets: expect.any(Array) }); }); - it("lists runs when enabled (regression: async settings used to always 404)", async () => { + it("lists runs when enabled via PostgreSQL store (not SQLite getDatabase)", async () => { const routes = createQualityRoutes(); const list = routes.find((r) => r.method === "GET" && r.path === "/runs"); const result = await list!.handler( { query: { projectId: "proj-1" } }, - makeCtx(() => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } })), + makeCtx(() => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } }), true), ); expect(result).toEqual({ runs: [] }); }); + + it("fails closed when AsyncDataLayer is missing (no SQLite fallback)", async () => { + const routes = createQualityRoutes(); + const list = routes.find((r) => r.method === "GET" && r.path === "/runs"); + const result = await list!.handler( + { query: { projectId: "proj-1" } }, + makeCtx(() => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } }), false), + ); + expect(result).toMatchObject({ + status: 500, + body: { + error: expect.stringMatching(/AsyncDataLayer|PostgreSQL|SQLite is not supported/i), + }, + }); + }); }); diff --git a/plugins/fusion-plugin-quality/src/__tests__/no-sqlite-runtime.test.ts b/plugins/fusion-plugin-quality/src/__tests__/no-sqlite-runtime.test.ts new file mode 100644 index 0000000000..52de867235 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/no-sqlite-runtime.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +/* +FNXC:QualityPostgres 2026-07-16-09:03: +Guardrail: QA runtime modules must not call TaskStore.getDatabase() / SQLite. +Production Fusion is PostgreSQL-only; backend mode throws if getDatabase is used. +*/ + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function readSrc(rel: string): string { + return readFileSync(join(root, rel), "utf8"); +} + +/** Strip block/line comments so FNXC docs mentioning getDatabase do not fail the guard. */ +function codeOnly(src: string): string { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +describe("Quality runtime has no SQLite TaskStore access", () => { + it("routes never call getDatabase", () => { + const src = codeOnly(readSrc("routes/create-routes.ts")); + expect(src).not.toMatch(/getDatabase\s*\(/); + expect(src).toMatch(/getQualityStore/); + }); + + it("store provider requires AsyncDataLayer", () => { + const src = codeOnly(readSrc("store/quality-store-provider.ts")); + expect(src).toMatch(/getAsyncLayer/); + expect(src).toMatch(/AsyncQualityStore/); + expect(src).not.toMatch(/getDatabase\s*\(/); + expect(src).not.toMatch(/new QualityStore/); + }); + + it("command runner uses async QualityStoreApi only", () => { + const src = codeOnly(readSrc("runner/command-runner.ts")); + expect(src).toMatch(/QualityStoreApi/); + expect(src).not.toMatch(/getDatabase/); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts b/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts index b0f0d06e60..e60104bac9 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts @@ -5,8 +5,6 @@ import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as core from "@fusion/core"; -import { DatabaseSync } from "@fusion/core"; -import { ensureQualitySchema } from "../quality-schema.js"; import { createQualityRoutes } from "../routes/create-routes.js"; function git(cwd: string, args: string[]): string { @@ -49,11 +47,10 @@ describe("preview start for done tasks", () => { child.stderr = new EventEmitter(); vi.spyOn(core, "superviseSpawn").mockReturnValue({ child, kill: vi.fn() } as never); - const db = new DatabaseSync(":memory:"); - ensureQualitySchema(db as never); + // Preview start does not need a Quality store; ensure we never call getDatabase. const ctx = { taskStore: { - getDatabase: () => db, + getAsyncLayer: () => ({ projectId: "proj", db: { execute: vi.fn() } }), getSettings: () => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } }), getRootDir: () => repo, getTask: vi.fn(async () => ({ diff --git a/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts b/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts index 8427450f22..d4e1726131 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/quality-store.test.ts @@ -3,16 +3,22 @@ import { DatabaseSync } from "@fusion/core"; import { ensureQualitySchema } from "../quality-schema.js"; import { QualityStore } from "../store/quality-store.js"; -describe("QualityStore", () => { +/* +FNXC:QualityPostgres 2026-07-16-09:03: +SQLite QualityStore tests remain for pure domain logic only. Runtime QA never +uses this path — routes bind AsyncQualityStore via getAsyncLayer. +*/ + +describe("QualityStore (unit / SQLite test harness only)", () => { function makeStore() { const db = new DatabaseSync(":memory:"); ensureQualitySchema(db as never); return new QualityStore(db as never); } - it("creates and lists runs scoped by project", () => { + it("creates and lists runs scoped by project", async () => { const store = makeStore(); - store.createRun({ + await store.createRun({ projectId: "p1", source: "hub", command: "pnpm verify:fast", @@ -22,7 +28,7 @@ describe("QualityStore", () => { triggeredBy: "test", presetId: "verify-fast", }); - store.createRun({ + await store.createRun({ projectId: "p2", source: "hub", command: "pnpm verify:fast", @@ -31,13 +37,13 @@ describe("QualityStore", () => { timeoutMs: 60_000, triggeredBy: "test", }); - expect(store.listRuns("p1")).toHaveLength(1); - expect(store.listRuns("p2")).toHaveLength(1); + expect(await store.listRuns("p1")).toHaveLength(1); + expect(await store.listRuns("p2")).toHaveLength(1); }); - it("getRun enforces project ownership", () => { + it("getRun enforces project ownership", async () => { const store = makeStore(); - const run = store.createRun({ + const run = await store.createRun({ projectId: "p1", source: "task-tab", taskId: "FN-1", @@ -47,14 +53,14 @@ describe("QualityStore", () => { timeoutMs: 60_000, triggeredBy: "test", }); - expect(store.getRun("p1", run.id)?.id).toBe(run.id); - expect(store.getRun("p2", run.id)).toBeNull(); + expect((await store.getRun("p1", run.id))?.id).toBe(run.id); + expect(await store.getRun("p2", run.id)).toBeNull(); }); - it("prunes finished runs beyond retention", () => { + it("prunes finished runs beyond retention", async () => { const store = makeStore(); for (let i = 0; i < 5; i++) { - const run = store.createRun({ + const run = await store.createRun({ projectId: "p1", source: "hub", command: `echo ${i}`, @@ -63,27 +69,27 @@ describe("QualityStore", () => { timeoutMs: 1000, triggeredBy: "test", }); - store.updateRun("p1", run.id, { + await store.updateRun("p1", run.id, { status: "passed", finishedAt: new Date().toISOString(), durationMs: 1, }); } - store.pruneRuns("p1", 2); - expect(store.listRuns("p1")).toHaveLength(2); + await store.pruneRuns("p1", 2); + expect(await store.listRuns("p1")).toHaveLength(2); }); - it("saves and loads suggested cases", () => { + it("saves and loads suggested cases", async () => { const store = makeStore(); - store.saveSuggestedCases({ + await store.saveSuggestedCases({ projectId: "p1", taskId: "FN-1", cases: [{ id: "c1", text: "Check login", done: false, source: "heuristic" }], generatedAt: new Date().toISOString(), method: "heuristic", }); - const snap = store.getSuggestedCases("p1", "FN-1"); + const snap = await store.getSuggestedCases("p1", "FN-1"); expect(snap?.cases).toHaveLength(1); - expect(store.getSuggestedCases("p2", "FN-1")).toBeNull(); + expect(await store.getSuggestedCases("p2", "FN-1")).toBeNull(); }); }); diff --git a/plugins/fusion-plugin-quality/src/index.ts b/plugins/fusion-plugin-quality/src/index.ts index 034f4e87d3..7d1da46939 100644 --- a/plugins/fusion-plugin-quality/src/index.ts +++ b/plugins/fusion-plugin-quality/src/index.ts @@ -52,5 +52,8 @@ export default plugin; export { ensureQualitySchema, qualityPostgresSchema } from "./quality-schema.js"; export { QualityStore } from "./store/quality-store.js"; +export { AsyncQualityStore } from "./store/async-quality-store.js"; +export { getQualityStore } from "./store/quality-store-provider.js"; +export type { QualityStoreApi } from "./store/quality-store-api.js"; export { resolvePresetCommand, isQualityPresetId, listPresetCatalog } from "./runner/command-presets.js"; export { buildHeuristicSuggestedCases } from "./suggestions/heuristic-cases.js"; diff --git a/plugins/fusion-plugin-quality/src/qa-tab.tsx b/plugins/fusion-plugin-quality/src/qa-tab.tsx index c81604f26e..0a360e77ad 100644 --- a/plugins/fusion-plugin-quality/src/qa-tab.tsx +++ b/plugins/fusion-plugin-quality/src/qa-tab.tsx @@ -301,12 +301,17 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement { createElement( "div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } }, + /* + FNXC:Quality 2026-07-16-10:55: + Run controls stay enabled without a live worktree. The server creates a + temporary QA checkout for done/cleaned-up tasks (same as preview Start). + */ createElement( "button", { type: "button", className: "btn btn-sm", - disabled: busy || !worktree, + disabled: busy, onClick: () => void startRun("file-scoped"), }, "File-scoped", @@ -316,7 +321,7 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement { { type: "button", className: "btn btn-sm", - disabled: busy || !worktree, + disabled: busy, onClick: () => void startRun("verify-fast"), }, "verify:fast", @@ -326,7 +331,7 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement { { type: "button", className: "btn btn-sm", - disabled: busy || !worktree, + disabled: busy, onClick: () => void startRun("test-gate"), }, "test:gate", @@ -336,14 +341,18 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement { { type: "button", className: "btn btn-sm", - disabled: busy || !worktree, + disabled: busy, onClick: () => void startRun("project-test"), }, "Project test", ), ), !worktree - ? createElement("p", { style: { marginTop: 8, fontSize: 12, opacity: 0.8 } }, "Worktree required for targeted runs.") + ? createElement( + "p", + { style: { marginTop: 8, fontSize: 12, opacity: 0.8 } }, + "No live worktree — runs use a temporary QA checkout of this task's branch/merge commit.", + ) : null, ), diff --git a/plugins/fusion-plugin-quality/src/quality-schema.ts b/plugins/fusion-plugin-quality/src/quality-schema.ts index 83c3b54e85..d1285122c1 100644 --- a/plugins/fusion-plugin-quality/src/quality-schema.ts +++ b/plugins/fusion-plugin-quality/src/quality-schema.ts @@ -3,6 +3,10 @@ import type { Database, PluginPostgresSchemaDefinition } from "@fusion/core"; /* FNXC:Quality 2026-07-14-21:45: Plugin-owned Quality tables via onSchemaInit. projectId on every row for multi-project isolation. + +FNXC:QualityPostgres 2026-07-16-09:03: +ensureQualitySchema is SQLite/unit-test only. Production QA routes use +qualityPostgresSchema + AsyncQualityStore; they never open TaskStore.db. */ export function ensureQualitySchema(db: Database): void { diff --git a/plugins/fusion-plugin-quality/src/routes/create-routes.ts b/plugins/fusion-plugin-quality/src/routes/create-routes.ts index bc1cf5dfcf..b4b195a0bb 100644 --- a/plugins/fusion-plugin-quality/src/routes/create-routes.ts +++ b/plugins/fusion-plugin-quality/src/routes/create-routes.ts @@ -1,10 +1,10 @@ import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk"; -import { AsyncQualityStore } from "../store/async-quality-store.js"; import { isQualityPresetId, listPresetCatalog, resolvePresetCommand } from "../runner/command-presets.js"; import { cancelQualityRun, defaultTimeoutMs, executeQualityRun } from "../runner/command-runner.js"; import { getAllowRootFallback, getDefaultPreviewScript, getLogTruncateKb, getRunRetentionCount } from "../settings.js"; import { buildHeuristicSuggestedCases } from "../suggestions/heuristic-cases.js"; import type { QualityPresetId } from "../store/quality-types.js"; +import { getQualityStore } from "../store/quality-store-provider.js"; import { createPreviewSessionManager } from "../preview/preview-sessions.js"; import { resolveTaskCodeCwd } from "../preview/task-code-worktree.js"; @@ -38,18 +38,12 @@ function requireProjectId(req: Req): string { return id; } -const qualityStoreCache = new WeakMap(); - -function getStore(ctx: PluginContext): AsyncQualityStore { - const key = ctx.taskStore as object; - const cached = qualityStoreCache.get(key); - if (cached) return cached; - const asyncLayer = ctx.taskStore.getAsyncLayer(); - if (!asyncLayer) throw new Error("Quality plugin requires ctx.taskStore.getAsyncLayer() / PostgreSQL AsyncDataLayer"); - const store = new AsyncQualityStore(asyncLayer); - qualityStoreCache.set(key, store); - return store; -} +/* +FNXC:QualityPostgres 2026-07-16-09:03: +Do not call TaskStore.getDatabase() here. QA routes bind only to +getQualityStore → AsyncDataLayer (PostgreSQL). SQLite is unavailable in +backend mode and must never be used for runs/plans/suggestions. +*/ function httpError(status: number, message: string): never { const err = new Error(message) as Error & { statusCode?: number }; @@ -136,7 +130,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { handler: async (req, ctx) => { const r = req as Req; const projectId = requireProjectId(r); - const store = getStore(ctx); + const store = getQualityStore(ctx); const taskId = typeof r.query?.taskId === "string" ? r.query.taskId : undefined; const limit = typeof r.query?.limit === "string" ? Number(r.query.limit) : 50; return { runs: await store.listRuns(projectId, { taskId, limit }) }; @@ -151,7 +145,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { const projectId = requireProjectId(r); const runId = r.params?.runId; if (!runId) httpError(400, "runId required"); - const run = await getStore(ctx).getRun(projectId, runId); + const run = await getQualityStore(ctx).getRun(projectId, runId); if (!run) httpError(404, "Run not found"); return { run }; }, @@ -176,11 +170,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { const confirmFullSuite = body.confirmFullSuite === true; const source = body.source === "hub" ? "hub" : "task-tab"; - const store = getStore(ctx); - const active = await store.findActiveRun(projectId, taskId); - if (active) { - httpError(409, `A run is already active (${active.id})`); - } + const store = getQualityStore(ctx); // Resolve cwd server-side const rootDir = ctx.taskStore.getRootDir?.() ?? process.cwd(); @@ -263,7 +253,12 @@ export function createQualityRoutes(): PluginRouteDefinition[] { } const timeoutMs = defaultTimeoutMs(verificationCommandTimeoutMs); - const run = await store.createRun({ + /* + FNXC:Quality 2026-07-16-09:20: + createRunIfNoActive holds a PG advisory lock for the project/task scope so two + concurrent starts cannot both observe "no active run" and double-queue. + */ + const created = await store.createRunIfNoActive({ projectId, taskId, source, @@ -274,8 +269,17 @@ export function createQualityRoutes(): PluginRouteDefinition[] { timeoutMs, triggeredBy: "operator", }); + if (!created.ok) { + httpError(409, `A run is already active (${created.active.id})`); + } + const run = created.run; // Detach execution — do not block the HTTP response on full suite runtime + /* + FNXC:Quality 2026-07-16-09:20: + Catch only execution failures (not prune). Nested try/catch so a failed + status write cannot become an unhandled rejection. Prune is best-effort. + */ void executeQualityRun({ store, projectId, @@ -285,18 +289,30 @@ export function createQualityRoutes(): PluginRouteDefinition[] { timeoutMs, logTruncateKb: getLogTruncateKb(ctx.settings as Record), }) - .then(async () => { - await store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record)); - }) .catch(async (err) => { ctx.logger?.warn?.( `Quality run ${run.id} failed: ${err instanceof Error ? err.message : String(err)}`, ); - await store.updateRun(projectId, run.id, { - status: "error", - errorMessage: err instanceof Error ? err.message : String(err), - finishedAt: new Date().toISOString(), - }); + try { + await store.finalizeRun(projectId, run.id, { + status: "error", + errorMessage: err instanceof Error ? err.message : String(err), + finishedAt: new Date().toISOString(), + }); + } catch (updateErr) { + ctx.logger?.warn?.( + `Quality run ${run.id} status update failed: ${updateErr instanceof Error ? updateErr.message : String(updateErr)}`, + ); + } + }) + .finally(() => { + void store + .pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record)) + .catch((pruneErr) => { + ctx.logger?.warn?.( + `Quality prune failed: ${pruneErr instanceof Error ? pruneErr.message : String(pruneErr)}`, + ); + }); }); return { run, detached: true }; @@ -311,7 +327,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { const projectId = requireProjectId(r); const runId = r.params?.runId; if (!runId) httpError(400, "runId required"); - const store = getStore(ctx); + const store = getQualityStore(ctx); const run = await store.getRun(projectId, runId); if (!run) httpError(404, "Run not found"); if (run.status !== "queued" && run.status !== "running") { @@ -328,7 +344,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { handler: async (req, ctx) => { const r = req as Req; const projectId = requireProjectId(r); - return { plans: await getStore(ctx).listPlans(projectId) }; + return { plans: await getQualityStore(ctx).listPlans(projectId) }; }, }, { @@ -342,7 +358,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { const name = typeof body.name === "string" ? body.name.trim() : ""; if (!name) httpError(400, "name is required"); const steps = validatePlanSteps(Array.isArray(body.steps) ? body.steps : []); - const plan = await getStore(ctx).createPlan({ projectId, name, steps }); + const plan = await getQualityStore(ctx).createPlan({ projectId, name, steps }); return { plan }; }, }, @@ -355,7 +371,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { const projectId = requireProjectId(r); const taskId = r.params?.taskId; if (!taskId) httpError(400, "taskId required"); - const existing = await getStore(ctx).getSuggestedCases(projectId, taskId); + const existing = await getQualityStore(ctx).getSuggestedCases(projectId, taskId); return { suggestions: existing }; }, }, @@ -391,7 +407,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] { ? task.modifiedFiles.filter((p): p is string => typeof p === "string") : [], }); - const snapshot = await getStore(ctx).saveSuggestedCases({ + const snapshot = await getQualityStore(ctx).saveSuggestedCases({ projectId, taskId, cases, diff --git a/plugins/fusion-plugin-quality/src/runner/command-runner.ts b/plugins/fusion-plugin-quality/src/runner/command-runner.ts index de65e2d172..b231ef18dc 100644 --- a/plugins/fusion-plugin-quality/src/runner/command-runner.ts +++ b/plugins/fusion-plugin-quality/src/runner/command-runner.ts @@ -1,18 +1,29 @@ import { superviseSpawn } from "@fusion/core"; -import type { QualityStore } from "../store/quality-store.js"; -import type { AsyncQualityStore } from "../store/async-quality-store.js"; - -type QualityPersistence = QualityStore | AsyncQualityStore; +import type { QualityStoreApi } from "../store/quality-store-api.js"; import type { TestRun, TestRunStatus } from "../store/quality-types.js"; /* FNXC:Quality 2026-07-14-21:45: Supervised command runner for Quality TestRuns. Uses superviseSpawn (core + packaging shim). Hard timeout with process-group kill; truncates logs; never accepts client command/cwd. + +FNXC:QualityPostgres 2026-07-16-09:03: +Store mutations are async (PostgreSQL AsyncDataLayer). Never assume a sync SQLite store. + +FNXC:Quality 2026-07-16-09:20: +PR #2230 review: register a cancel slot BEFORE the running write so cancel mid-await +still kills the eventual process; finalizeRun so a late cancel is not overwritten. + +FNXC:Quality 2026-07-16-10:55: +Always wrap supervisors in ActiveQualityRun (Signals-only kill) for tsc; delete the +active slot in finally so rejected store writes cannot leak supervisors. */ const HARD_TIMEOUT_MS = 1_800_000; -type ActiveQualityRun = Pick, "kill">; + +type ActiveQualityRun = { + kill: (signal?: NodeJS.Signals) => void; +}; const activeQualityRuns = new Map(); @@ -27,12 +38,16 @@ Keep each live supervisor by project/run so the cancel route can terminate its process group, while the runner's final write preserves the cancelled terminal state if the child closes after that request. */ -export async function cancelQualityRun(store: QualityPersistence, projectId: string, runId: string): Promise { +export async function cancelQualityRun( + store: QualityStoreApi, + projectId: string, + runId: string, +): Promise { const current = await store.getRun(projectId, runId); if (!current || (current.status !== "queued" && current.status !== "running")) return current; activeQualityRuns.get(activeRunKey(projectId, runId))?.kill("SIGTERM"); - return await store.updateRun(projectId, runId, { + return store.updateRun(projectId, runId, { status: "cancelled", finishedAt: new Date().toISOString(), errorMessage: "Cancelled by operator", @@ -48,7 +63,7 @@ export function __registerActiveQualityRunForTests(projectId: string, runId: str } export interface RunCommandOptions { - store: QualityPersistence; + store: QualityStoreApi; projectId: string; runId: string; command: string; @@ -68,7 +83,21 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise | undefined; + + const setActiveKill = (kill: (signal?: NodeJS.Signals) => void) => { + activeQualityRuns.set(key, { kill }); + }; + + setActiveKill((signal) => { + cancelRequested = true; + if (supervised) { + supervised.kill(signal); + } + }); let stdout = ""; let stderr = ""; @@ -78,75 +107,88 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise { - stdout = truncate(stdout + String(chunk), opts.logTruncateKb); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr = truncate(stderr + String(chunk), opts.logTruncateKb); - }); - - const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { - const timer = setTimeout(() => { - timedOut = true; - supervised.kill("SIGTERM"); - setTimeout(() => { - supervised.kill("SIGKILL"); - }, 2_000); - }, timeoutMs); - - child.on("close", (code, signal) => { - clearTimeout(timer); - resolve({ code, signal }); - }); - child.on("error", (err) => { - clearTimeout(timer); - errorMessage = err instanceof Error ? err.message : String(err); - resolve({ code: null, signal: null }); - }); - }); - - exitCode = result.code; - if (timedOut) { - status = "timed_out"; - errorMessage = errorMessage ?? `Timed out after ${timeoutMs}ms`; - } else if (errorMessage) { - status = "error"; - } else if (exitCode === 0) { - status = "passed"; + if (cancelRequested) { + status = "cancelled"; + errorMessage = "Cancelled by operator"; } else { - status = "failed"; - } - } catch (err) { - status = "error"; - errorMessage = err instanceof Error ? err.message : String(err); - } + supervised = superviseSpawn(command, [], { + cwd, + shell: opts.shell !== false, + env: process.env, + }); + setActiveKill((signal) => { + cancelRequested = true; + supervised?.kill(signal); + }); - const finishedAt = new Date().toISOString(); - const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)); - const current = await store.getRun(projectId, runId); - const wasCancelled = current?.status === "cancelled"; - const updated = await store.updateRun(projectId, runId, { - status: wasCancelled ? "cancelled" : status, - exitCode, - errorMessage: wasCancelled ? current.errorMessage ?? "Cancelled by operator" : errorMessage, - finishedAt, - durationMs, - stdout, - stderr, - }); - if (!updated) { - throw new Error(`Quality run ${runId} missing after execution`); + if (cancelRequested) { + supervised.kill("SIGTERM"); + } + + const child = supervised.child; + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout = truncate(stdout + String(chunk), opts.logTruncateKb); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr = truncate(stderr + String(chunk), opts.logTruncateKb); + }); + + const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + const timer = setTimeout(() => { + timedOut = true; + supervised?.kill("SIGTERM"); + setTimeout(() => { + supervised?.kill("SIGKILL"); + }, 2_000); + }, timeoutMs); + + child.on("close", (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + child.on("error", (err) => { + clearTimeout(timer); + errorMessage = err instanceof Error ? err.message : String(err); + resolve({ code: null, signal: null }); + }); + }); + + exitCode = result.code; + if (cancelRequested) { + status = "cancelled"; + errorMessage = "Cancelled by operator"; + } else if (timedOut) { + status = "timed_out"; + errorMessage = errorMessage ?? `Timed out after ${timeoutMs}ms`; + } else if (errorMessage) { + status = "error"; + } else if (exitCode === 0) { + status = "passed"; + } else { + status = "failed"; + } + } + + const finishedAt = new Date().toISOString(); + const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)); + const updated = await store.finalizeRun(projectId, runId, { + status, + exitCode, + errorMessage, + finishedAt, + durationMs, + stdout, + stderr, + }); + if (!updated) { + throw new Error(`Quality run ${runId} missing after execution`); + } + return updated; + } finally { + activeQualityRuns.delete(key); } - activeQualityRuns.delete(activeRunKey(projectId, runId)); - return updated; } export function defaultTimeoutMs(verificationCommandTimeoutMs?: number): number { diff --git a/plugins/fusion-plugin-quality/src/store/async-quality-store.ts b/plugins/fusion-plugin-quality/src/store/async-quality-store.ts index 1e6490150b..adee72a5d1 100644 --- a/plugins/fusion-plugin-quality/src/store/async-quality-store.ts +++ b/plugins/fusion-plugin-quality/src/store/async-quality-store.ts @@ -1,34 +1,468 @@ import { randomUUID } from "node:crypto"; -import type { AsyncDataLayer } from "@fusion/core"; import { sql } from "drizzle-orm"; -import type { CreateTestPlanInput, CreateTestRunInput, QualityPresetId, SuggestedCase, SuggestedCasesSnapshot, TestPlan, TestPlanStatus, TestRun, TestRunStatus } from "./quality-types.js"; +import type { AsyncDataLayer } from "@fusion/core"; +import type { + CreateRunIfNoActiveResult, + QualityStoreApi, + QualityRunPatch, +} from "./quality-store-api.js"; +import type { + CreateTestPlanInput, + CreateTestRunInput, + QualityPresetId, + SuggestedCase, + SuggestedCasesSnapshot, + TestPlan, + TestPlanStatus, + TestRun, + TestRunStatus, +} from "./quality-types.js"; /* -FNXC:QualityPostgresDurability 2026-07-16-10:15: -Task QA previously called TaskStore.getDatabase(), which throws when production -injects PostgreSQL AsyncDataLayer. This store owns durable Quality data through -the project-bound async layer only, so every plugin route remains available in -backend mode and every query explicitly scopes project_id. +FNXC:QualityPostgres 2026-07-16-09:03: +PostgreSQL-backed Quality persistence via the project AsyncDataLayer. Tables live +in schema project.* (quality_test_runs / quality_test_plans / quality_suggested_cases) +created by onPostgresSchemaInit. Every predicate is project_id-scoped. +Import sql from drizzle-orm directly — plugin runtime shims do not re-export it. + +FNXC:Quality 2026-07-16-09:20: +PR #2230 review: always use the bound layer.projectId (reject caller mismatches); +createRunIfNoActive uses a project+task advisory lock so concurrent starts cannot +double-queue; finalizeRun preserves cancelled status against late terminal writes. */ -type RunRow = { id: string; project_id: string; task_id: string | null; plan_id: string | null; source: string; preset_id: string | null; command: string; cwd: string; cwd_kind: string; status: string; exit_code: number | null; error_message: string | null; timeout_ms: number; started_at: string | null; finished_at: string | null; duration_ms: number | null; stdout: string; stderr: string; triggered_by: string; created_at: string; updated_at: string }; -type PlanRow = { id: string; project_id: string; name: string; status: string; steps_json: string; created_at: string; updated_at: string }; -function run(row: RunRow): TestRun { return { id: row.id, projectId: row.project_id, taskId: row.task_id ?? undefined, planId: row.plan_id ?? undefined, source: row.source as TestRun["source"], presetId: (row.preset_id as QualityPresetId | null) ?? undefined, command: row.command, cwd: row.cwd, cwdKind: row.cwd_kind as TestRun["cwdKind"], status: row.status as TestRunStatus, exitCode: row.exit_code ?? undefined, errorMessage: row.error_message ?? undefined, timeoutMs: row.timeout_ms, startedAt: row.started_at ?? undefined, finishedAt: row.finished_at ?? undefined, durationMs: row.duration_ms ?? undefined, stdout: row.stdout ?? "", stderr: row.stderr ?? "", triggeredBy: row.triggered_by, createdAt: row.created_at, updatedAt: row.updated_at }; } -function plan(row: PlanRow): TestPlan { let steps: QualityPresetId[] = []; try { const parsed = JSON.parse(row.steps_json); if (Array.isArray(parsed)) steps = parsed.filter((value): value is QualityPresetId => typeof value === "string"); } catch { /* malformed legacy data is empty */ } return { id: row.id, projectId: row.project_id, name: row.name, status: row.status as TestPlanStatus, steps, createdAt: row.created_at, updatedAt: row.updated_at }; } +type RunRow = { + id: string; + project_id: string; + task_id: string | null; + plan_id: string | null; + source: string; + preset_id: string | null; + command: string; + cwd: string; + cwd_kind: string; + status: string; + exit_code: number | null; + error_message: string | null; + timeout_ms: number; + started_at: string | null; + finished_at: string | null; + duration_ms: number | null; + stdout: string; + stderr: string; + triggered_by: string; + created_at: string; + updated_at: string; +}; -export class AsyncQualityStore { - private readonly projectId: string; - constructor(private readonly layer: AsyncDataLayer) { if (!layer.projectId) throw new Error("Quality plugin requires a project-bound PostgreSQL AsyncDataLayer"); this.projectId = layer.projectId; } - private async runs(query: ReturnType): Promise { return await this.layer.db.execute(query) as unknown as RunRow[]; } - async createRun(input: CreateTestRunInput): Promise { const now = new Date().toISOString(); const id = `qrun_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_runs (project_id,id,task_id,plan_id,source,preset_id,command,cwd,cwd_kind,status,timeout_ms,stdout,stderr,triggered_by,created_at,updated_at) VALUES (${this.projectId},${id},${input.taskId ?? null},${input.planId ?? null},${input.source},${input.presetId ?? null},${input.command},${input.cwd},${input.cwdKind},'queued',${input.timeoutMs},'','',${input.triggeredBy},${now},${now})`); return (await this.getRun(input.projectId, id))!; } - async getRun(projectId: string, id: string): Promise { const rows = await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`); return rows[0] ? run(rows[0]) : null; } - async listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise { const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; const rows = opts?.taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${opts.taskId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`); return rows.map(run); } - async updateRun(projectId: string, id: string, patch: Partial<{ status: TestRunStatus; exitCode: number | null; errorMessage: string | null; startedAt: string | null; finishedAt: string | null; durationMs: number | null; stdout: string; stderr: string }>): Promise { const current = await this.getRun(projectId, id); if (!current) return null; const now = new Date().toISOString(); await this.layer.db.execute(sql`UPDATE project.quality_test_runs SET status=${patch.status ?? current.status},exit_code=${patch.exitCode !== undefined ? patch.exitCode : current.exitCode ?? null},error_message=${patch.errorMessage !== undefined ? patch.errorMessage : current.errorMessage ?? null},started_at=${patch.startedAt !== undefined ? patch.startedAt : current.startedAt ?? null},finished_at=${patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt ?? null},duration_ms=${patch.durationMs !== undefined ? patch.durationMs : current.durationMs ?? null},stdout=${patch.stdout !== undefined ? patch.stdout : current.stdout},stderr=${patch.stderr !== undefined ? patch.stderr : current.stderr},updated_at=${now} WHERE project_id=${this.projectId} AND id=${id}`); return this.getRun(projectId, id); } - async pruneRuns(projectId: string, retention: number): Promise { if (retention <= 0) return 0; const rows = await this.runs(sql`DELETE FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND status NOT IN ('queued','running') AND id NOT IN (SELECT id FROM project.quality_test_runs WHERE project_id=${this.projectId} AND status NOT IN ('queued','running') ORDER BY created_at DESC,id DESC LIMIT ${retention}) RETURNING id`); return rows.length; } - async findActiveRun(projectId: string, taskId?: string): Promise { const rows = taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id IS NULL AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`); return rows[0] ? run(rows[0]) : null; } - async createPlan(input: CreateTestPlanInput): Promise { const now = new Date().toISOString(); const id = `qplan_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_plans(project_id,id,name,status,steps_json,created_at,updated_at) VALUES(${this.projectId},${id},${input.name},${input.status ?? "active"},${JSON.stringify(input.steps)},${now},${now})`); return (await this.getPlan(input.projectId,id))!; } - async getPlan(projectId: string, id: string): Promise { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`) as unknown as PlanRow[]; return rows[0] ? plan(rows[0]) : null; } - async listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise { const rows = await this.layer.db.execute(opts?.includeArchived ? sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY updated_at DESC,id DESC` : sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND status != 'archived' ORDER BY updated_at DESC,id DESC`) as unknown as PlanRow[]; return rows.map(plan); } - async getSuggestedCases(projectId: string, taskId: string): Promise { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_suggested_cases WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} LIMIT 1`) as unknown as Array<{project_id:string;task_id:string;cases_json:string;generated_at:string;method:string}>; if (!rows[0]) return null; let cases: SuggestedCase[]=[]; try { const parsed=JSON.parse(rows[0].cases_json); if(Array.isArray(parsed)) cases=parsed as SuggestedCase[]; } catch { /* malformed legacy data is empty */ } return {projectId:rows[0].project_id,taskId:rows[0].task_id,cases,generatedAt:rows[0].generated_at,method:rows[0].method as SuggestedCasesSnapshot["method"]}; } - async saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise { await this.layer.db.execute(sql`INSERT INTO project.quality_suggested_cases(project_id,task_id,cases_json,generated_at,method) VALUES(${this.projectId},${snapshot.taskId},${JSON.stringify(snapshot.cases)},${snapshot.generatedAt},${snapshot.method}) ON CONFLICT(project_id,task_id) DO UPDATE SET cases_json=excluded.cases_json,generated_at=excluded.generated_at,method=excluded.method`); return snapshot; } +type PlanRow = { + id: string; + project_id: string; + name: string; + status: string; + steps_json: string; + created_at: string; + updated_at: string; +}; + +type CasesRow = { + project_id: string; + task_id: string; + cases_json: string; + generated_at: string; + method: string; +}; + +function mapRun(row: RunRow): TestRun { + return { + id: row.id, + projectId: row.project_id, + taskId: row.task_id ?? undefined, + planId: row.plan_id ?? undefined, + source: row.source as TestRun["source"], + presetId: (row.preset_id as QualityPresetId | null) ?? undefined, + command: row.command, + cwd: row.cwd, + cwdKind: row.cwd_kind as TestRun["cwdKind"], + status: row.status as TestRunStatus, + exitCode: row.exit_code ?? undefined, + errorMessage: row.error_message ?? undefined, + timeoutMs: row.timeout_ms, + startedAt: row.started_at ?? undefined, + finishedAt: row.finished_at ?? undefined, + durationMs: row.duration_ms ?? undefined, + stdout: row.stdout ?? "", + stderr: row.stderr ?? "", + triggeredBy: row.triggered_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapPlan(row: PlanRow): TestPlan { + let steps: QualityPresetId[] = []; + try { + const parsed = JSON.parse(row.steps_json) as unknown; + if (Array.isArray(parsed)) { + steps = parsed.filter((s): s is QualityPresetId => typeof s === "string"); + } + } catch { + steps = []; + } + return { + id: row.id, + projectId: row.project_id, + name: row.name, + status: row.status as TestPlanStatus, + steps, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function asRows(result: unknown): T[] { + if (Array.isArray(result)) return result as T[]; + if (result && typeof result === "object" && Array.isArray((result as { rows?: unknown }).rows)) { + return (result as { rows: T[] }).rows; + } + return []; +} + +export class AsyncQualityStore implements QualityStoreApi { + private readonly projectId: string; + + constructor(private readonly layer: AsyncDataLayer) { + const projectId = layer.projectId?.trim(); + if (!projectId) { + throw new Error("Quality PostgreSQL persistence requires a project-bound AsyncDataLayer"); + } + this.projectId = projectId; + } + + /** + * Routes pass request projectId; the store is bound to one AsyncDataLayer project. + * Reject cross-project access so request data cannot read/write another project's QA rows. + */ + private assertProject(projectId: string): string { + const id = projectId.trim(); + if (id !== this.projectId) { + const err = new Error( + `Quality store project mismatch: bound=${this.projectId} requested=${id}`, + ) as Error & { statusCode?: number }; + err.statusCode = 403; + throw err; + } + return this.projectId; + } + + private activeLockKey(taskId?: string | null): string { + return `fusion:quality-active:${this.projectId}:${taskId?.trim() || "__hub__"}`; + } + + async createRun(input: CreateTestRunInput): Promise { + const projectId = this.assertProject(input.projectId); + const now = new Date().toISOString(); + const id = `qrun_${randomUUID()}`; + await this.layer.db.execute(sql` + INSERT INTO project.quality_test_runs ( + id, project_id, task_id, plan_id, source, preset_id, command, cwd, cwd_kind, + status, timeout_ms, stdout, stderr, triggered_by, created_at, updated_at + ) VALUES ( + ${id}, ${projectId}, ${input.taskId ?? null}, ${input.planId ?? null}, + ${input.source}, ${input.presetId ?? null}, ${input.command}, ${input.cwd}, ${input.cwdKind}, + ${"queued"}, ${input.timeoutMs}, ${""}, ${""}, ${input.triggeredBy}, ${now}, ${now} + ) + `); + const run = await this.getRun(projectId, id); + if (!run) throw new Error(`Quality run ${id} missing after insert`); + return run; + } + + async createRunIfNoActive(input: CreateTestRunInput): Promise { + const projectId = this.assertProject(input.projectId); + const taskId = input.taskId?.trim() || null; + return this.layer.transactionImmediate(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${this.activeLockKey(taskId)}, 0))`, + ); + const activeRows = asRows( + taskId + ? await tx.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${projectId} AND task_id = ${taskId} + AND status IN ('queued', 'running') + ORDER BY created_at DESC + LIMIT 1 + `) + : await tx.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${projectId} AND task_id IS NULL + AND status IN ('queued', 'running') + ORDER BY created_at DESC + LIMIT 1 + `), + ); + if (activeRows[0]) { + return { ok: false as const, active: mapRun(activeRows[0]) }; + } + const now = new Date().toISOString(); + const id = `qrun_${randomUUID()}`; + await tx.execute(sql` + INSERT INTO project.quality_test_runs ( + id, project_id, task_id, plan_id, source, preset_id, command, cwd, cwd_kind, + status, timeout_ms, stdout, stderr, triggered_by, created_at, updated_at + ) VALUES ( + ${id}, ${projectId}, ${taskId}, ${input.planId ?? null}, + ${input.source}, ${input.presetId ?? null}, ${input.command}, ${input.cwd}, ${input.cwdKind}, + ${"queued"}, ${input.timeoutMs}, ${""}, ${""}, ${input.triggeredBy}, ${now}, ${now} + ) + `); + const created = asRows( + await tx.execute( + sql`SELECT * FROM project.quality_test_runs WHERE project_id = ${projectId} AND id = ${id} LIMIT 1`, + ), + ); + if (!created[0]) throw new Error(`Quality run ${id} missing after insert`); + return { ok: true as const, run: mapRun(created[0]) }; + }); + } + + async getRun(projectId: string, id: string): Promise { + const pid = this.assertProject(projectId); + const rows = asRows( + await this.layer.db.execute( + sql`SELECT * FROM project.quality_test_runs WHERE project_id = ${pid} AND id = ${id} LIMIT 1`, + ), + ); + return rows[0] ? mapRun(rows[0]) : null; + } + + async listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise { + const pid = this.assertProject(projectId); + const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; + if (opts?.taskId) { + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${pid} AND task_id = ${opts.taskId} + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + `), + ); + return rows.map(mapRun); + } + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${pid} + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + `), + ); + return rows.map(mapRun); + } + + async updateRun(projectId: string, id: string, patch: QualityRunPatch): Promise { + /* + FNXC:Quality 2026-07-16-10:55: + Terminal statuses (especially cancelled) must not be overwritten by a stale + read-modify-write. SQL CASE keeps cancelled/passed/failed/etc. sticky so a + concurrent cancel cannot be clobbered by mark-running or other patches. + */ + const existing = await this.getRun(projectId, id); + if (!existing) return null; + const pid = this.projectId; + const now = new Date().toISOString(); + const nextStatus = patch.status ?? existing.status; + const exitCode = patch.exitCode !== undefined ? patch.exitCode : (existing.exitCode ?? null); + const nextError = + patch.errorMessage !== undefined ? patch.errorMessage : (existing.errorMessage ?? null); + const startedAt = patch.startedAt !== undefined ? patch.startedAt : (existing.startedAt ?? null); + const finishedAt = + patch.finishedAt !== undefined ? patch.finishedAt : (existing.finishedAt ?? null); + const durationMs = + patch.durationMs !== undefined ? patch.durationMs : (existing.durationMs ?? null); + const stdout = patch.stdout !== undefined ? patch.stdout : existing.stdout; + const stderr = patch.stderr !== undefined ? patch.stderr : existing.stderr; + await this.layer.db.execute(sql` + UPDATE project.quality_test_runs SET + status = CASE + WHEN status IN ('cancelled', 'passed', 'failed', 'timed_out', 'error') THEN status + ELSE ${nextStatus} + END, + exit_code = ${exitCode}, + error_message = CASE + WHEN status = 'cancelled' THEN error_message + ELSE ${nextError} + END, + started_at = COALESCE(started_at, ${startedAt}), + finished_at = COALESCE(${finishedAt}, finished_at), + duration_ms = COALESCE(${durationMs}, duration_ms), + stdout = ${stdout}, + stderr = ${stderr}, + updated_at = ${now} + WHERE project_id = ${pid} AND id = ${id} + `); + return this.getRun(pid, id); + } + + async finalizeRun(projectId: string, id: string, patch: QualityRunPatch): Promise { + // Same sticky-terminal semantics as updateRun (single conditional UPDATE). + return this.updateRun(projectId, id, patch); + } + + async pruneRuns(projectId: string, retention: number): Promise { + const pid = this.assertProject(projectId); + if (retention <= 0) return 0; + const result = await this.layer.db.execute(sql` + DELETE FROM project.quality_test_runs + WHERE project_id = ${pid} + AND status NOT IN ('queued', 'running') + AND id NOT IN ( + SELECT id FROM project.quality_test_runs + WHERE project_id = ${pid} AND status NOT IN ('queued', 'running') + ORDER BY created_at DESC, id DESC + LIMIT ${retention} + ) + `); + const count = + result && typeof result === "object" && "rowCount" in result + ? Number((result as { rowCount?: number }).rowCount ?? 0) + : 0; + return count; + } + + async findActiveRun(projectId: string, taskId?: string): Promise { + const pid = this.assertProject(projectId); + if (taskId) { + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${pid} AND task_id = ${taskId} + AND status IN ('queued', 'running') + ORDER BY created_at DESC + LIMIT 1 + `), + ); + return rows[0] ? mapRun(rows[0]) : null; + } + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_runs + WHERE project_id = ${pid} AND task_id IS NULL + AND status IN ('queued', 'running') + ORDER BY created_at DESC + LIMIT 1 + `), + ); + return rows[0] ? mapRun(rows[0]) : null; + } + + async createPlan(input: CreateTestPlanInput): Promise { + const projectId = this.assertProject(input.projectId); + const now = new Date().toISOString(); + const id = `qplan_${randomUUID()}`; + const status = input.status ?? "active"; + const stepsJson = JSON.stringify(input.steps); + await this.layer.db.execute(sql` + INSERT INTO project.quality_test_plans (id, project_id, name, status, steps_json, created_at, updated_at) + VALUES (${id}, ${projectId}, ${input.name}, ${status}, ${stepsJson}, ${now}, ${now}) + `); + const plan = await this.getPlan(projectId, id); + if (!plan) throw new Error(`Quality plan ${id} missing after insert`); + return plan; + } + + async getPlan(projectId: string, id: string): Promise { + const pid = this.assertProject(projectId); + const rows = asRows( + await this.layer.db.execute( + sql`SELECT * FROM project.quality_test_plans WHERE project_id = ${pid} AND id = ${id} LIMIT 1`, + ), + ); + return rows[0] ? mapPlan(rows[0]) : null; + } + + async listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise { + const pid = this.assertProject(projectId); + if (opts?.includeArchived) { + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_plans WHERE project_id = ${pid} + ORDER BY updated_at DESC, id DESC + `), + ); + return rows.map(mapPlan); + } + const rows = asRows( + await this.layer.db.execute(sql` + SELECT * FROM project.quality_test_plans + WHERE project_id = ${pid} AND status != 'archived' + ORDER BY updated_at DESC, id DESC + `), + ); + return rows.map(mapPlan); + } + + async updatePlan( + projectId: string, + id: string, + patch: Partial<{ name: string; status: TestPlanStatus; steps: QualityPresetId[] }>, + ): Promise { + const existing = await this.getPlan(projectId, id); + if (!existing) return null; + const pid = this.projectId; + const now = new Date().toISOString(); + const name = patch.name ?? existing.name; + const status = patch.status ?? existing.status; + const stepsJson = JSON.stringify(patch.steps ?? existing.steps); + await this.layer.db.execute(sql` + UPDATE project.quality_test_plans + SET name = ${name}, status = ${status}, steps_json = ${stepsJson}, updated_at = ${now} + WHERE project_id = ${pid} AND id = ${id} + `); + return this.getPlan(pid, id); + } + + async getSuggestedCases(projectId: string, taskId: string): Promise { + const pid = this.assertProject(projectId); + const rows = asRows( + await this.layer.db.execute(sql` + SELECT project_id, task_id, cases_json, generated_at, method + FROM project.quality_suggested_cases + WHERE project_id = ${pid} AND task_id = ${taskId} + LIMIT 1 + `), + ); + const row = rows[0]; + if (!row) return null; + let cases: SuggestedCase[] = []; + try { + const parsed = JSON.parse(row.cases_json) as unknown; + if (Array.isArray(parsed)) cases = parsed as SuggestedCase[]; + } catch { + cases = []; + } + return { + projectId: row.project_id, + taskId: row.task_id, + cases, + generatedAt: row.generated_at, + method: row.method as SuggestedCasesSnapshot["method"], + }; + } + + async saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise { + const pid = this.assertProject(snapshot.projectId); + const casesJson = JSON.stringify(snapshot.cases); + await this.layer.db.execute(sql` + INSERT INTO project.quality_suggested_cases (project_id, task_id, cases_json, generated_at, method) + VALUES (${pid}, ${snapshot.taskId}, ${casesJson}, ${snapshot.generatedAt}, ${snapshot.method}) + ON CONFLICT (project_id, task_id) DO UPDATE SET + cases_json = EXCLUDED.cases_json, + generated_at = EXCLUDED.generated_at, + method = EXCLUDED.method + `); + return { ...snapshot, projectId: pid }; + } } diff --git a/plugins/fusion-plugin-quality/src/store/quality-store-api.ts b/plugins/fusion-plugin-quality/src/store/quality-store-api.ts new file mode 100644 index 0000000000..fc1bc56e29 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/store/quality-store-api.ts @@ -0,0 +1,66 @@ +import type { + CreateTestPlanInput, + CreateTestRunInput, + QualityPresetId, + SuggestedCasesSnapshot, + TestPlan, + TestPlanStatus, + TestRun, + TestRunStatus, +} from "./quality-types.js"; + +/* +FNXC:QualityPostgres 2026-07-16-09:03: +Runtime QA surfaces must not touch SQLite. Production Fusion is PostgreSQL-only +(AsyncDataLayer); any path that calls TaskStore.getDatabase() throws +"SQLite Database is not available in backend mode". This async API is the only +store contract routes and the command runner may use. + +FNXC:Quality 2026-07-16-09:20: +PR #2230 review: createRunIfNoActive serializes one-active-run; finalizeRun never +overwrites a cancelled terminal row. +*/ + +export type QualityRunPatch = Partial<{ + status: TestRunStatus; + exitCode: number | null; + errorMessage: string | null; + startedAt: string | null; + finishedAt: string | null; + durationMs: number | null; + stdout: string; + stderr: string; +}>; + +export type CreateRunIfNoActiveResult = + | { ok: true; run: TestRun } + | { ok: false; active: TestRun }; + +export interface QualityStoreApi { + createRun(input: CreateTestRunInput): Promise; + /** + * Atomically reject a second concurrent start for the same project/task scope + * when a queued/running run already exists. + */ + createRunIfNoActive(input: CreateTestRunInput): Promise; + getRun(projectId: string, id: string): Promise; + listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise; + updateRun(projectId: string, id: string, patch: QualityRunPatch): Promise; + /** + * Write terminal run fields without clobbering an operator cancel that landed + * after the runner decided its outcome. + */ + finalizeRun(projectId: string, id: string, patch: QualityRunPatch): Promise; + pruneRuns(projectId: string, retention: number): Promise; + findActiveRun(projectId: string, taskId?: string): Promise; + createPlan(input: CreateTestPlanInput): Promise; + getPlan(projectId: string, id: string): Promise; + listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise; + updatePlan( + projectId: string, + id: string, + patch: Partial<{ name: string; status: TestPlanStatus; steps: QualityPresetId[] }>, + ): Promise; + getSuggestedCases(projectId: string, taskId: string): Promise; + saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise; +} diff --git a/plugins/fusion-plugin-quality/src/store/quality-store-provider.ts b/plugins/fusion-plugin-quality/src/store/quality-store-provider.ts new file mode 100644 index 0000000000..cb37771384 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/store/quality-store-provider.ts @@ -0,0 +1,46 @@ +import type { PluginContext } from "@fusion/plugin-sdk"; +import { AsyncQualityStore } from "./async-quality-store.js"; +import type { QualityStoreApi } from "./quality-store-api.js"; + +/* +FNXC:QualityPostgres 2026-07-16-09:03: +QA route handlers must never call TaskStore.getDatabase() / SQLite. Production +is PostgreSQL-only; getDatabase() throws in backend mode and was the source of +"SQLite Database is not available in backend mode" on the Task QA tab. +Always bind to the project AsyncDataLayer (same pattern as Reports). +*/ + +const qualityStoreCache = new WeakMap(); + +export function getQualityStore(ctx: PluginContext): QualityStoreApi { + const taskStore = ctx.taskStore as { + getAsyncLayer?: () => { projectId?: string; db: unknown } | null; + getQualityStore?: () => QualityStoreApi; + }; + // Test / DI seam (mirrors Reports) — never used to reintroduce SQLite in production. + if (typeof taskStore.getQualityStore === "function") { + return taskStore.getQualityStore(); + } + + const key = ctx.taskStore as object; + const cached = qualityStoreCache.get(key); + if (cached) return cached; + + const layer = typeof taskStore.getAsyncLayer === "function" ? taskStore.getAsyncLayer() : null; + if (!layer) { + const err = new Error( + "Quality plugin requires the project PostgreSQL AsyncDataLayer (SQLite is not supported for QA flows)", + ) as Error & { statusCode?: number }; + err.statusCode = 500; + throw err; + } + + const store = new AsyncQualityStore(layer as ConstructorParameters[0]); + qualityStoreCache.set(key, store); + return store; +} + +/** Clear the WeakMap cache between tests. */ +export function __clearQualityStoreCacheForTests(): void { + // WeakMap has no clear; drop references by replacing via new stores per test key. +} diff --git a/plugins/fusion-plugin-quality/src/store/quality-store.ts b/plugins/fusion-plugin-quality/src/store/quality-store.ts index 00befe8a9e..8b4532bd9f 100644 --- a/plugins/fusion-plugin-quality/src/store/quality-store.ts +++ b/plugins/fusion-plugin-quality/src/store/quality-store.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Database } from "@fusion/core"; +import type { CreateRunIfNoActiveResult, QualityRunPatch, QualityStoreApi } from "./quality-store-api.js"; import type { CreateTestPlanInput, CreateTestRunInput, @@ -93,10 +94,15 @@ function mapPlan(row: PlanRow): TestPlan { }; } -export class QualityStore { +/* +FNXC:QualityPostgres 2026-07-16-09:03: +In-memory SQLite QualityStore remains for unit tests only. Runtime QA routes use +AsyncQualityStore (PostgreSQL) exclusively — never wire this class into routes. +*/ +export class QualityStore implements QualityStoreApi { constructor(private readonly db: Database) {} - createRun(input: CreateTestRunInput): TestRun { + async createRun(input: CreateTestRunInput): Promise { const now = new Date().toISOString(); const id = `qrun_${randomUUID()}`; this.db @@ -121,17 +127,23 @@ export class QualityStore { now, now, ); - return this.getRun(input.projectId, id)!; + return (await this.getRun(input.projectId, id))!; } - getRun(projectId: string, id: string): TestRun | null { + async createRunIfNoActive(input: CreateTestRunInput): Promise { + const active = await this.findActiveRun(input.projectId, input.taskId); + if (active) return { ok: false, active }; + return { ok: true, run: await this.createRun(input) }; + } + + async getRun(projectId: string, id: string): Promise { const row = this.db .prepare(`SELECT * FROM quality_test_runs WHERE id = ? AND project_id = ?`) .get(id, projectId) as RunRow | undefined; return row ? mapRun(row) : null; } - listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): TestRun[] { + async listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise { const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; if (opts?.taskId) { const rows = this.db @@ -153,46 +165,52 @@ export class QualityStore { return rows.map(mapRun); } - updateRun( - projectId: string, - id: string, - patch: Partial<{ - status: TestRunStatus; - exitCode: number | null; - errorMessage: string | null; - startedAt: string | null; - finishedAt: string | null; - durationMs: number | null; - stdout: string; - stderr: string; - }>, - ): TestRun | null { - const existing = this.getRun(projectId, id); + async updateRun(projectId: string, id: string, patch: QualityRunPatch): Promise { + /* + FNXC:Quality 2026-07-16-10:55: + One conditional UPDATE so SQLite tests match AsyncQualityStore sticky + terminal status (cancel cannot be overwritten by a late mark-running). + */ + const existing = await this.getRun(projectId, id); if (!existing) return null; const now = new Date().toISOString(); + const nextStatus = patch.status ?? existing.status; + const exitCode = patch.exitCode !== undefined ? patch.exitCode : (existing.exitCode ?? null); + const nextError = + patch.errorMessage !== undefined ? patch.errorMessage : (existing.errorMessage ?? null); + const startedAt = patch.startedAt !== undefined ? patch.startedAt : (existing.startedAt ?? null); + const finishedAt = + patch.finishedAt !== undefined ? patch.finishedAt : (existing.finishedAt ?? null); + const durationMs = + patch.durationMs !== undefined ? patch.durationMs : (existing.durationMs ?? null); + const stdout = patch.stdout !== undefined ? patch.stdout : existing.stdout; + const stderr = patch.stderr !== undefined ? patch.stderr : existing.stderr; this.db .prepare( `UPDATE quality_test_runs SET - status = ?, + status = CASE + WHEN status IN ('cancelled', 'passed', 'failed', 'timed_out', 'error') THEN status + ELSE ? + END, exit_code = ?, - error_message = ?, - started_at = ?, - finished_at = ?, - duration_ms = ?, + error_message = CASE WHEN status = 'cancelled' THEN error_message ELSE ? END, + started_at = COALESCE(started_at, ?), + finished_at = COALESCE(?, finished_at), + duration_ms = COALESCE(?, duration_ms), stdout = ?, stderr = ?, updated_at = ? WHERE id = ? AND project_id = ?`, ) .run( - patch.status ?? existing.status, - patch.exitCode !== undefined ? patch.exitCode : (existing.exitCode ?? null), - patch.errorMessage !== undefined ? patch.errorMessage : (existing.errorMessage ?? null), - patch.startedAt !== undefined ? patch.startedAt : (existing.startedAt ?? null), - patch.finishedAt !== undefined ? patch.finishedAt : (existing.finishedAt ?? null), - patch.durationMs !== undefined ? patch.durationMs : (existing.durationMs ?? null), - patch.stdout !== undefined ? patch.stdout : existing.stdout, - patch.stderr !== undefined ? patch.stderr : existing.stderr, + nextStatus, + exitCode, + nextError, + startedAt, + finishedAt, + durationMs, + stdout, + stderr, now, id, projectId, @@ -200,7 +218,11 @@ export class QualityStore { return this.getRun(projectId, id); } - pruneRuns(projectId: string, retention: number): number { + async finalizeRun(projectId: string, id: string, patch: QualityRunPatch): Promise { + return this.updateRun(projectId, id, patch); + } + + async pruneRuns(projectId: string, retention: number): Promise { if (retention <= 0) return 0; const result = this.db .prepare( @@ -218,7 +240,7 @@ export class QualityStore { return Number(result.changes ?? 0); } - findActiveRun(projectId: string, taskId?: string): TestRun | null { + async findActiveRun(projectId: string, taskId?: string): Promise { if (taskId) { const row = this.db .prepare( @@ -239,7 +261,7 @@ export class QualityStore { return row ? mapRun(row) : null; } - createPlan(input: CreateTestPlanInput): TestPlan { + async createPlan(input: CreateTestPlanInput): Promise { const now = new Date().toISOString(); const id = `qplan_${randomUUID()}`; this.db @@ -256,17 +278,17 @@ export class QualityStore { now, now, ); - return this.getPlan(input.projectId, id)!; + return (await this.getPlan(input.projectId, id))!; } - getPlan(projectId: string, id: string): TestPlan | null { + async getPlan(projectId: string, id: string): Promise { const row = this.db .prepare(`SELECT * FROM quality_test_plans WHERE id = ? AND project_id = ?`) .get(id, projectId) as PlanRow | undefined; return row ? mapPlan(row) : null; } - listPlans(projectId: string, opts?: { includeArchived?: boolean }): TestPlan[] { + async listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise { if (opts?.includeArchived) { const rows = this.db .prepare( @@ -286,12 +308,12 @@ export class QualityStore { return rows.map(mapPlan); } - updatePlan( + async updatePlan( projectId: string, id: string, patch: Partial<{ name: string; status: TestPlanStatus; steps: QualityPresetId[] }>, - ): TestPlan | null { - const existing = this.getPlan(projectId, id); + ): Promise { + const existing = await this.getPlan(projectId, id); if (!existing) return null; const now = new Date().toISOString(); this.db @@ -310,7 +332,7 @@ export class QualityStore { return this.getPlan(projectId, id); } - getSuggestedCases(projectId: string, taskId: string): SuggestedCasesSnapshot | null { + async getSuggestedCases(projectId: string, taskId: string): Promise { const row = this.db .prepare( `SELECT project_id, task_id, cases_json, generated_at, method @@ -342,7 +364,7 @@ export class QualityStore { }; } - saveSuggestedCases(snapshot: SuggestedCasesSnapshot): SuggestedCasesSnapshot { + async saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise { this.db .prepare( `INSERT INTO quality_suggested_cases (project_id, task_id, cases_json, generated_at, method)