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:
169
docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md
Normal file
169
docs/plans/2026-07-16-001-feat-quality-plugin-agent-qa-plan.md
Normal 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 |
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
66
plugins/fusion-plugin-quality/src/store/quality-store-api.ts
Normal file
66
plugins/fusion-plugin-quality/src/store/quality-store-api.ts
Normal 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>;
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user