fix(FN-2127): harden Quality Postgres store (project scope, races) (#2230)

## 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)
This commit is contained in:
gsxdsm
2026-07-16 11:38:34 -07:00
committed by GitHub
parent 4a797d3804
commit 446c969968
15 changed files with 1093 additions and 216 deletions

View File

@@ -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/<taskId>`, branch → `fusion/<id>` → `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 |

View File

@@ -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");
});
});

View File

@@ -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),
},
});
});
});

View File

@@ -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/);
});
});

View File

@@ -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 () => ({

View File

@@ -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();
});
});

View File

@@ -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";

View File

@@ -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,
),

View File

@@ -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 {

View File

@@ -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<object, AsyncQualityStore>();
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<string, unknown>),
})
.then(async () => {
await store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record<string, unknown>));
})
.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<string, unknown>))
.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,

View File

@@ -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<ReturnType<typeof superviseSpawn>, "kill">;
type ActiveQualityRun = {
kill: (signal?: NodeJS.Signals) => void;
};
const activeQualityRuns = new Map<string, ActiveQualityRun>();
@@ -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<TestRun | null> {
export async function cancelQualityRun(
store: QualityStoreApi,
projectId: string,
runId: string,
): Promise<TestRun | null> {
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<TestRu
const { store, projectId, runId, command, cwd } = opts;
const timeoutMs = Math.min(Math.max(opts.timeoutMs, 1_000), HARD_TIMEOUT_MS);
const startedAt = new Date().toISOString();
await store.updateRun(projectId, runId, { status: "running", startedAt });
const key = activeRunKey(projectId, runId);
let cancelRequested = false;
let supervised: ReturnType<typeof superviseSpawn> | 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<TestRu
let errorMessage: string | null = null;
try {
const supervised = superviseSpawn(command, [], {
cwd,
shell: opts.shell !== false,
env: process.env,
});
activeQualityRuns.set(activeRunKey(projectId, runId), supervised);
await store.updateRun(projectId, runId, { status: "running", startedAt });
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 (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 {

View File

@@ -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<typeof sql>): Promise<RunRow[]> { return await this.layer.db.execute(query) as unknown as RunRow[]; }
async createRun(input: CreateTestRunInput): Promise<TestRun> { 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<TestRun | null> { 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<TestRun[]> { 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<TestRun | null> { 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<number> { 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<TestRun | null> { 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<TestPlan> { 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<TestPlan | null> { 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<TestPlan[]> { 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<SuggestedCasesSnapshot | null> { 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<SuggestedCasesSnapshot> { 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<T>(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<TestRun> {
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<CreateRunIfNoActiveResult> {
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<RunRow>(
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<RunRow>(
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<TestRun | null> {
const pid = this.assertProject(projectId);
const rows = asRows<RunRow>(
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<TestRun[]> {
const pid = this.assertProject(projectId);
const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50;
if (opts?.taskId) {
const rows = asRows<RunRow>(
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<RunRow>(
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<TestRun | null> {
/*
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<TestRun | null> {
// Same sticky-terminal semantics as updateRun (single conditional UPDATE).
return this.updateRun(projectId, id, patch);
}
async pruneRuns(projectId: string, retention: number): Promise<number> {
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<TestRun | null> {
const pid = this.assertProject(projectId);
if (taskId) {
const rows = asRows<RunRow>(
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<RunRow>(
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<TestPlan> {
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<TestPlan | null> {
const pid = this.assertProject(projectId);
const rows = asRows<PlanRow>(
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<TestPlan[]> {
const pid = this.assertProject(projectId);
if (opts?.includeArchived) {
const rows = asRows<PlanRow>(
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<PlanRow>(
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<TestPlan | null> {
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<SuggestedCasesSnapshot | null> {
const pid = this.assertProject(projectId);
const rows = asRows<CasesRow>(
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<SuggestedCasesSnapshot> {
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 };
}
}

View File

@@ -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<TestRun>;
/**
* Atomically reject a second concurrent start for the same project/task scope
* when a queued/running run already exists.
*/
createRunIfNoActive(input: CreateTestRunInput): Promise<CreateRunIfNoActiveResult>;
getRun(projectId: string, id: string): Promise<TestRun | null>;
listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise<TestRun[]>;
updateRun(projectId: string, id: string, patch: QualityRunPatch): Promise<TestRun | null>;
/**
* 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<TestRun | null>;
pruneRuns(projectId: string, retention: number): Promise<number>;
findActiveRun(projectId: string, taskId?: string): Promise<TestRun | null>;
createPlan(input: CreateTestPlanInput): Promise<TestPlan>;
getPlan(projectId: string, id: string): Promise<TestPlan | null>;
listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise<TestPlan[]>;
updatePlan(
projectId: string,
id: string,
patch: Partial<{ name: string; status: TestPlanStatus; steps: QualityPresetId[] }>,
): Promise<TestPlan | null>;
getSuggestedCases(projectId: string, taskId: string): Promise<SuggestedCasesSnapshot | null>;
saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise<SuggestedCasesSnapshot>;
}

View File

@@ -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<object, QualityStoreApi>();
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<typeof AsyncQualityStore>[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.
}

View File

@@ -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<TestRun> {
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<CreateRunIfNoActiveResult> {
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<TestRun | null> {
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<TestRun[]> {
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<TestRun | null> {
/*
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<TestRun | null> {
return this.updateRun(projectId, id, patch);
}
async pruneRuns(projectId: string, retention: number): Promise<number> {
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<TestRun | null> {
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<TestPlan> {
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<TestPlan | null> {
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<TestPlan[]> {
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<TestPlan | null> {
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<SuggestedCasesSnapshot | null> {
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<SuggestedCasesSnapshot> {
this.db
.prepare(
`INSERT INTO quality_suggested_cases (project_id, task_id, cases_json, generated_at, method)