fix(FN-2127): Quality experimental gate, done-task worktrees, hub layout (#2164)

## Summary

Follow-up on **#2127** (Quality plugin already on `main`). This PR only
lands the remaining Quality deltas that were not merged:

- **Experimental gate fix** — `TaskStore.getSettings()` is async; the
gate now awaits merged settings so enabling
`experimentalFeatures.qualityPlugin` actually works, and status-bearing
errors return structured `{ status, body }` instead of collapsing to
hard failures
- **Done-task QA worktrees** — when a task has no live worktree (typical
after land), preview/task runs create a disposable checkout under
`.fusion/quality-qa/` at the task branch or merge commit so processes
run the **done task’s code**, not project root
- **Hub layout** — shared `ViewHeader` + dashboard spacing/typography so
Quality matches Insights / Compound Engineering / Goals

Scoped to `plugins/fusion-plugin-quality/**` only (rebased onto current
`main`; duplicate plugin-landing commits dropped).

## Test plan

- [ ] Enable **Settings → Experimental → Quality Plugin**, restart if
routes were cold
- [ ] Quality hub: header matches other views; refresh + presets work
- [ ] Done task → QA tab → Start preview uses QA worktree at
branch/merge commit (not project root)
- [ ] Active task with live worktree still uses that worktree
- [ ] Flag off: clear experimental-disabled error (not generic empty
failure)
- [ ] `pnpm --filter @fusion-plugin-examples/quality test` (32 tests)
This commit is contained in:
gsxdsm
2026-07-16 00:16:12 -07:00
committed by GitHub
parent 9a34862586
commit 9b8edf06c4
13 changed files with 1067 additions and 144 deletions

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from "vitest";
import { DatabaseSync } from "@fusion/core";
import { ensureQualitySchema } from "../quality-schema.js";
import {
createQualityRoutes,
loadTaskStoreSettings,
requireQualityExperimental,
} from "../routes/create-routes.js";
function makeCtx(getSettings?: () => unknown) {
const db = new DatabaseSync(":memory:");
ensureQualitySchema(db as never);
return {
taskStore: {
getDatabase: () => db,
getSettings: getSettings ?? (() => Promise.resolve({})),
getRootDir: () => "/tmp",
getTask: vi.fn(),
},
settings: {},
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
} as never;
}
describe("Quality experimental gate settings load", () => {
it("awaits async getSettings and reads experimentalFeatures", async () => {
const settings = await loadTaskStoreSettings(
makeCtx(() =>
Promise.resolve({
experimentalFeatures: { qualityPlugin: true },
testCommand: "pnpm test",
}),
),
);
expect(settings.experimentalFeatures).toEqual({ qualityPlugin: true });
expect(settings.testCommand).toBe("pnpm test");
});
it("treats a bare Promise getSettings result as disabled when features are missing", async () => {
await expect(requireQualityExperimental(makeCtx(() => Promise.resolve({})))).rejects.toMatchObject({
message: expect.stringContaining("experimentalFeatures.qualityPlugin"),
statusCode: 404,
});
});
it("allows the gate when qualityPlugin is true on merged settings", async () => {
await expect(
requireQualityExperimental(
makeCtx(() => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } })),
),
).resolves.toBeUndefined();
});
});
describe("createQualityRoutes experimental wrapping", () => {
it("returns a structured 404 when the experimental flag is off (async settings)", async () => {
const routes = createQualityRoutes();
const presets = routes.find((r) => r.method === "GET" && r.path === "/presets");
expect(presets).toBeDefined();
const result = await presets!.handler({}, makeCtx(() => Promise.resolve({ experimentalFeatures: {} })));
expect(result).toEqual({
status: 404,
body: {
error: "Quality plugin is experimental; enable experimentalFeatures.qualityPlugin to use it",
},
});
});
it("passes through when experimentalFeatures.qualityPlugin is true", async () => {
const routes = createQualityRoutes();
const presets = routes.find((r) => r.method === "GET" && r.path === "/presets");
const result = await presets!.handler(
{},
makeCtx(() => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } })),
);
expect(result).toMatchObject({ presets: expect.any(Array) });
});
it("lists runs when enabled (regression: async settings used to always 404)", 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 } })),
);
expect(result).toEqual({ runs: [] });
});
});

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { pruneTerminalPreviewSessions } from "../preview/preview-sessions.js";
import { candidateTaskCodeRefs, qualityQaWorktreePath } from "../preview/task-code-worktree.js";
function terminalSession(stoppedAt: string) {
return {
@@ -31,3 +32,19 @@ describe("preview session retention", () => {
expect(sessions.has("recent-0")).toBe(false);
});
});
describe("task code worktree helpers", () => {
it("prefers recorded branch, fusion/<id>, then merge sha", () => {
expect(
candidateTaskCodeRefs({
id: "FN-12",
branch: "feature/fn-12",
mergeDetails: { commitSha: "abc1234" },
}),
).toEqual(["feature/fn-12", "fusion/fn-12", "abc1234"]);
});
it("places disposable QA worktrees under .fusion/quality-qa", () => {
expect(qualityQaWorktreePath("/repo", "FN-99")).toBe("/repo/.fusion/quality-qa/fn-99");
});
});

View File

@@ -0,0 +1,91 @@
import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
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 {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
describe("preview start for done tasks", () => {
const temps: string[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const dir of temps.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("starts in a QA worktree at the done task merge commit, not project root", async () => {
const repo = mkdtempSync(join(tmpdir(), "quality-preview-done-"));
temps.push(repo);
git(repo, ["init"]);
git(repo, ["config", "user.email", "test@example.com"]);
git(repo, ["config", "user.name", "Test"]);
writeFileSync(join(repo, "readme.md"), "main\n");
git(repo, ["add", "readme.md"]);
git(repo, ["commit", "-m", "init"]);
git(repo, ["checkout", "-b", "fusion/fn-1"]);
writeFileSync(join(repo, "feature.md"), "task\n");
git(repo, ["add", "feature.md"]);
git(repo, ["commit", "-m", "task"]);
const mergeSha = git(repo, ["rev-parse", "HEAD"]);
git(repo, ["checkout", "-"]);
git(repo, ["branch", "-D", "fusion/fn-1"]);
const child = new EventEmitter() as EventEmitter & {
pid: number;
stdout: EventEmitter;
stderr: EventEmitter;
};
child.pid = 4242;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
vi.spyOn(core, "superviseSpawn").mockReturnValue({ child, kill: vi.fn() } as never);
const db = new DatabaseSync(":memory:");
ensureQualitySchema(db as never);
const ctx = {
taskStore: {
getDatabase: () => db,
getSettings: () => Promise.resolve({ experimentalFeatures: { qualityPlugin: true } }),
getRootDir: () => repo,
getTask: vi.fn(async () => ({
id: "FN-1",
worktree: undefined,
column: "done",
mergeDetails: { commitSha: mergeSha },
})),
},
settings: {},
logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn() },
} as never;
const routes = createQualityRoutes();
const start = routes.find((r) => r.method === "POST" && r.path === "/preview/:taskId/start");
const result = (await start!.handler(
{
params: { taskId: "FN-1" },
query: { projectId: "proj" },
body: { projectId: "proj" },
},
ctx,
)) as { session?: { cwd?: string; cwdKind?: string; status?: string; ref?: string } };
expect(result.session?.cwdKind).toBe("qa-worktree");
expect(result.session?.ref).toBe(mergeSha);
expect(result.session?.cwd).toContain(".fusion/quality-qa");
expect(result.session?.status).toBe("running");
expect(core.superviseSpawn).toHaveBeenCalledWith(
"pnpm run dev",
[],
expect.objectContaining({ cwd: result.session?.cwd, shell: true }),
);
});
});

View File

@@ -0,0 +1,101 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { afterEach, describe, expect, it } from "vitest";
import { resolveTaskCodeCwd } from "../preview/task-code-worktree.js";
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
}
describe("resolveTaskCodeCwd", () => {
const temps: string[] = [];
afterEach(() => {
for (const dir of temps.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("uses a live worktree when present on disk", async () => {
const worktree = mkdtempSync(join(tmpdir(), "quality-live-wt-"));
temps.push(worktree);
const result = await resolveTaskCodeCwd({
projectRoot: "/project",
task: { id: "FN-1", worktree },
});
expect(result).toMatchObject({ cwd: worktree, cwdKind: "worktree", created: false });
});
it("creates a disposable QA worktree at the task merge commit for done tasks", async () => {
const repo = mkdtempSync(join(tmpdir(), "quality-repo-"));
temps.push(repo);
git(repo, ["init"]);
git(repo, ["config", "user.email", "test@example.com"]);
git(repo, ["config", "user.name", "Test"]);
writeFileSync(join(repo, "readme.md"), "main\n");
git(repo, ["add", "readme.md"]);
git(repo, ["commit", "-m", "init"]);
git(repo, ["checkout", "-b", "fusion/fn-42"]);
writeFileSync(join(repo, "feature.md"), "task code\n");
git(repo, ["add", "feature.md"]);
git(repo, ["commit", "-m", "task"]);
const mergeSha = git(repo, ["rev-parse", "HEAD"]);
git(repo, ["checkout", "-"]);
// Done-task cleanup often deletes fusion/<id>; fall back to merge commit.
git(repo, ["branch", "-D", "fusion/fn-42"]);
const result = await resolveTaskCodeCwd({
projectRoot: repo,
task: {
id: "FN-42",
worktree: undefined,
branch: undefined,
mergeDetails: { commitSha: mergeSha },
},
});
expect(result.cwdKind).toBe("qa-worktree");
expect(result.created).toBe(true);
expect(result.ref).toBe(mergeSha);
expect(result.cwd).toContain(".fusion/quality-qa");
expect(git(result.cwd, ["rev-parse", "HEAD"])).toBe(mergeSha);
// Task file exists only on the task commit, not on main
expect(() => git(result.cwd, ["cat-file", "-e", "HEAD:feature.md"])).not.toThrow();
});
it("reuses an existing QA worktree path without failing", async () => {
const repo = mkdtempSync(join(tmpdir(), "quality-repo-reuse-"));
temps.push(repo);
git(repo, ["init"]);
git(repo, ["config", "user.email", "test@example.com"]);
git(repo, ["config", "user.name", "Test"]);
writeFileSync(join(repo, "a.txt"), "a\n");
git(repo, ["add", "a.txt"]);
git(repo, ["commit", "-m", "init"]);
const sha = git(repo, ["rev-parse", "HEAD"]);
const first = await resolveTaskCodeCwd({
projectRoot: repo,
task: { id: "FN-7", mergeDetails: { commitSha: sha } },
});
const second = await resolveTaskCodeCwd({
projectRoot: repo,
task: { id: "FN-7", mergeDetails: { commitSha: sha } },
});
expect(second.cwd).toBe(first.cwd);
expect(second.created).toBe(false);
});
it("errors when no worktree and no reachable ref (no project-root fallback)", async () => {
const repo = mkdtempSync(join(tmpdir(), "quality-repo-empty-"));
temps.push(repo);
git(repo, ["init"]);
await expect(
resolveTaskCodeCwd({
projectRoot: repo,
task: { id: "FN-missing" },
}),
).rejects.toThrow(/no live worktree and no reachable branch\/merge commit/i);
});
});

View File

@@ -0,0 +1,23 @@
// Ambient host interop (no runtime dependency on @fusion/dashboard).
// Vite aliases resolve these to the real dashboard sources at build time.
// Mirrors fusion-plugin-compound-engineering/src/dashboard-interop.d.ts.
declare module "@fusion/dashboard/app/plugins/types" {
export interface PluginDashboardViewContext {
projectId?: string;
}
}
declare module "@fusion/dashboard/app/components/ViewHeader" {
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
export interface ViewHeaderProps {
icon: ComponentType<LucideProps>;
title: string;
actions?: ReactNode;
titleId?: string;
}
export function ViewHeader(props: ViewHeaderProps): ReactNode;
}

View File

@@ -0,0 +1,174 @@
/*
FNXC:Quality 2026-07-15-23:30:
Quality hub must match native main-content views (Insights, Goals, Compound Engineering):
shared ViewHeader on top, flex column root, body inset with --space-xl horizontal padding
aligned under the header, card-based content, tokenized gaps/typography — no ad-hoc padding:16
or bare h2/table inline styles.
*/
.quality-view {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
overflow: hidden;
color: var(--text);
}
.quality-view-body {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-lg);
min-width: 0;
min-height: 0;
padding: var(--space-xl) var(--space-xl) var(--space-xl);
box-sizing: border-box;
overflow: auto;
}
.quality-view-lede {
margin: 0;
max-width: 40rem;
font-size: 0.875rem;
line-height: 1.45;
color: var(--text-muted);
}
.quality-view-error {
margin: 0;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--error, #c00) 35%, var(--border));
background: color-mix(in srgb, var(--error, #c00) 8%, var(--surface));
color: var(--error, #c00);
font-size: 0.85rem;
}
.quality-view-empty-project {
margin: 0;
font-size: 0.875rem;
color: var(--text-muted);
}
.quality-runs-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
min-width: 0;
padding: var(--space-md) var(--space-lg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.quality-runs-card__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.quality-runs-card__header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
}
.quality-runs-card__count {
font-size: 0.8rem;
color: var(--text-muted);
}
.quality-runs-table-wrap {
width: 100%;
overflow-x: auto;
}
.quality-runs-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.quality-runs-table th,
.quality-runs-table td {
text-align: left;
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.quality-runs-table th {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
}
.quality-runs-table tbody tr:last-child td {
border-bottom: none;
}
.quality-runs-table__command {
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 0.8rem;
word-break: break-word;
}
.quality-runs-table__empty {
padding: var(--space-lg) var(--space-md) !important;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.quality-status {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.1rem 0.45rem;
border-radius: var(--radius-sm, 4px);
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
background: color-mix(in srgb, var(--text-muted) 12%, transparent);
color: var(--text-muted);
}
.quality-status--passed,
.quality-status--running {
background: color-mix(in srgb, var(--success, #2a9d6e) 16%, transparent);
color: var(--success, #2a9d6e);
}
.quality-status--failed,
.quality-status--error,
.quality-status--timed_out {
background: color-mix(in srgb, var(--error, #c00) 14%, transparent);
color: var(--error, #c00);
}
.quality-status--cancelled,
.quality-status--queued {
background: color-mix(in srgb, var(--warning, #c90) 16%, transparent);
color: var(--warning, #b8860b);
}
.quality-header-count {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
}
.quality-header-actions {
display: contents;
}

View File

@@ -1,9 +1,18 @@
import { createElement, useCallback, useEffect, useState, type ReactElement } from "react";
import "./dashboard-view.css";
import { useCallback, useEffect, useState, type ReactElement } from "react";
import { Play, RefreshCw, ShieldCheck } from "lucide-react";
import { ViewHeader } from "@fusion/dashboard/app/components/ViewHeader";
/*
FNXC:Quality 2026-07-14-21:45:
Quality hub dashboard view — project-wide run history and preset catalog.
Host registers this via registerBundledPluginViews (static registry).
FNXC:Quality 2026-07-15-23:30:
Layout matches native main-content views: shared ViewHeader (ShieldCheck + title),
flex column root, tokenized body inset, card + table for run history, btn-sm header
actions. Removes ad-hoc padding/h2/inline table styles that made Quality look unlike
Insights / Compound Engineering / Goals.
*/
export interface QualityDashboardViewContext {
@@ -21,12 +30,59 @@ interface RunRow {
}
async function fetchRuns(projectId: string): Promise<RunRow[]> {
/*
FNXC:Quality 2026-07-15-23:17:
Surface HTTP failures (including the experimental gate) instead of silently
rendering an empty history that looks like "no runs yet".
*/
const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`);
if (!res.ok) return [];
if (!res.ok) {
const text = await res.text();
let message = text || res.statusText;
try {
const parsed = JSON.parse(text) as { error?: unknown };
if (typeof parsed.error === "string" && parsed.error.trim()) {
message = parsed.error;
}
} catch {
// keep text body
}
throw new Error(message);
}
const data = (await res.json()) as { runs?: RunRow[] };
return data.runs ?? [];
}
function formatWhen(iso: string): string {
const ms = Date.parse(iso);
if (!Number.isFinite(ms)) return iso;
try {
return new Date(ms).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return iso;
}
}
function formatDuration(durationMs?: number): string {
if (durationMs == null) return "—";
if (durationMs < 1000) return `${durationMs}ms`;
return `${Math.round(durationMs / 1000)}s`;
}
function StatusPill({ status }: { status: string }): ReactElement {
const normalized = status.toLowerCase().replace(/\s+/g, "_");
return (
<span className={`quality-status quality-status--${normalized}`} data-status={normalized}>
{status}
</span>
);
}
export function QualityDashboardView({
context,
}: {
@@ -36,6 +92,7 @@ export function QualityDashboardView({
const [runs, setRuns] = useState<RunRow[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [busyPreset, setBusyPreset] = useState<string | null>(null);
const refresh = useCallback(async () => {
if (!projectId) return;
@@ -56,86 +113,152 @@ export function QualityDashboardView({
const startPreset = async (preset: string, confirmFullSuite = false) => {
if (!projectId) return;
const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, preset, source: "hub", confirmFullSuite }),
});
if (!res.ok) {
const text = await res.text();
setError(text || res.statusText);
return;
setBusyPreset(preset);
setError(null);
try {
const res = await fetch(`/api/plugins/fusion-plugin-quality/runs?projectId=${encodeURIComponent(projectId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId, preset, source: "hub", confirmFullSuite }),
});
if (!res.ok) {
/*
FNXC:Quality 2026-07-15-23:17:
Surface structured plugin route errors (e.g. experimental gate) without dumping raw JSON.
*/
const text = await res.text();
try {
const parsed = JSON.parse(text) as { error?: unknown };
if (typeof parsed.error === "string" && parsed.error.trim()) {
setError(parsed.error);
return;
}
} catch {
// fall through
}
setError(text || res.statusText);
return;
}
await refresh();
} finally {
setBusyPreset(null);
}
await refresh();
};
return createElement(
"div",
{ className: "quality-hub", "data-testid": "quality-hub", style: { padding: 16 } },
createElement("h2", { style: { marginTop: 0 } }, "Quality"),
createElement(
"p",
{ style: { opacity: 0.8, maxWidth: 640 } },
"Project-wide test runs. Advisory only — does not change merge eligibility. Prefer Task QA for worktree-scoped preview, screenshots, and suggested cases.",
),
!projectId
? createElement("p", null, "Select a project to view Quality data.")
: createElement(
"div",
null,
createElement(
"div",
{ style: { display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 } },
createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("verify-fast") }, "Run verify:fast"),
createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("test-gate") }, "Run test:gate"),
createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void startPreset("project-test") }, "Run project test"),
createElement("button", { type: "button", className: "btn btn-sm", onClick: () => void refresh() }, "Refresh"),
),
loading ? createElement("p", null, "Loading…") : null,
error ? createElement("p", { role: "alert", style: { color: "var(--error, #c00)" } }, error) : null,
createElement(
"table",
{ style: { width: "100%", borderCollapse: "collapse", fontSize: 13 } },
createElement(
"thead",
null,
createElement(
"tr",
null,
createElement("th", { style: { textAlign: "left", padding: 6 } }, "Status"),
createElement("th", { style: { textAlign: "left", padding: 6 } }, "Preset"),
createElement("th", { style: { textAlign: "left", padding: 6 } }, "Command"),
createElement("th", { style: { textAlign: "left", padding: 6 } }, "Duration"),
createElement("th", { style: { textAlign: "left", padding: 6 } }, "When"),
),
),
createElement(
"tbody",
null,
runs.length === 0
? createElement(
"tr",
null,
createElement("td", { colSpan: 5, style: { padding: 6, opacity: 0.7 } }, "No runs yet."),
)
: runs.map((run) =>
createElement(
"tr",
{ key: run.id },
createElement("td", { style: { padding: 6 } }, run.status),
createElement("td", { style: { padding: 6 } }, run.presetId ?? "—"),
createElement("td", { style: { padding: 6, fontFamily: "monospace" } }, run.command),
createElement(
"td",
{ style: { padding: 6 } },
run.durationMs != null ? `${Math.round(run.durationMs / 1000)}s` : "—",
),
createElement("td", { style: { padding: 6 } }, run.createdAt),
),
),
),
),
),
const actions = projectId ? (
<>
<span className="quality-header-count" data-testid="quality-run-count">
{runs.length} {runs.length === 1 ? "run" : "runs"}
</span>
<button
type="button"
className="btn btn-sm"
disabled={loading || busyPreset != null}
onClick={() => void startPreset("verify-fast")}
data-testid="quality-run-verify-fast"
>
<Play size={14} aria-hidden="true" />
verify:fast
</button>
<button
type="button"
className="btn btn-sm"
disabled={loading || busyPreset != null}
onClick={() => void startPreset("test-gate")}
data-testid="quality-run-test-gate"
>
<Play size={14} aria-hidden="true" />
test:gate
</button>
<button
type="button"
className="btn btn-sm"
disabled={loading || busyPreset != null}
onClick={() => void startPreset("project-test")}
data-testid="quality-run-project-test"
>
<Play size={14} aria-hidden="true" />
project test
</button>
<button
type="button"
className="btn btn-icon btn-sm"
disabled={loading || busyPreset != null}
onClick={() => void refresh()}
aria-label="Refresh Quality runs"
title="Refresh"
data-testid="quality-refresh"
>
<RefreshCw size={14} className={loading ? "spin" : undefined} aria-hidden="true" />
</button>
</>
) : null;
return (
<div className="quality-view" data-testid="quality-hub">
<ViewHeader icon={ShieldCheck} title="Quality" actions={actions} titleId="quality-view-title" />
<div className="quality-view-body">
<p className="quality-view-lede">
Project-wide test runs. Advisory only — does not change merge eligibility. Prefer Task QA for
worktree-scoped preview, screenshots, and suggested cases.
</p>
{!projectId ? (
<p className="quality-view-empty-project">Select a project to view Quality data.</p>
) : (
<>
{error ? (
<p className="quality-view-error" role="alert">
{error}
</p>
) : null}
<section className="quality-runs-card card" data-testid="quality-runs-card">
<header className="quality-runs-card__header">
<h3>Run history</h3>
<span className="quality-runs-card__count">
{loading && runs.length === 0 ? "Loading…" : `${runs.length} total`}
</span>
</header>
<div className="quality-runs-table-wrap">
<table className="quality-runs-table">
<thead>
<tr>
<th scope="col">Status</th>
<th scope="col">Preset</th>
<th scope="col">Command</th>
<th scope="col">Duration</th>
<th scope="col">When</th>
</tr>
</thead>
<tbody>
{runs.length === 0 ? (
<tr>
<td colSpan={5} className="quality-runs-table__empty">
{loading ? "Loading runs…" : "No runs yet. Start a preset from the header."}
</td>
</tr>
) : (
runs.map((run) => (
<tr key={run.id} data-testid="quality-run-row">
<td>
<StatusPill status={run.status} />
</td>
<td>{run.presetId ?? "—"}</td>
<td className="quality-runs-table__command">{run.command}</td>
<td>{formatDuration(run.durationMs)}</td>
<td>{formatWhen(run.createdAt)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
</div>
);
}

View File

@@ -1,13 +1,20 @@
import { createServer } from "node:net";
import { superviseSpawn, type SupervisedChild } from "@fusion/core";
import type { TaskCodeCwdKind } from "./task-code-worktree.js";
/*
FNXC:Quality 2026-07-14-21:45:
Task-scoped preview servers for QA. Supervised spawn, free port (never 4040), worktree cwd.
Composes Dev Server safety ideas without replacing the global Dev Server view.
FNXC:Quality 2026-07-15-23:23:
Done tasks lose their live worktree after land/cleanup. Preview still starts in a disposable
QA worktree checked out at the task branch/merge commit (see task-code-worktree.ts) so the
server runs the done task's code, not project root/mainline.
*/
export type PreviewStatus = "starting" | "running" | "stopped" | "failed";
export type PreviewCwdKind = TaskCodeCwdKind;
export interface PreviewSession {
projectId: string;
@@ -15,6 +22,10 @@ export interface PreviewSession {
status: PreviewStatus;
command: string;
cwd: string;
/** Live task worktree, or disposable QA worktree for done/cleaned-up tasks. */
cwdKind?: PreviewCwdKind;
/** Git ref when cwdKind is qa-worktree. */
ref?: string;
port?: number;
url?: string;
pid?: number;
@@ -96,6 +107,8 @@ export function createPreviewSessionManager() {
projectId: string;
taskId: string;
cwd: string;
cwdKind?: PreviewCwdKind;
ref?: string;
script: string;
}): Promise<PreviewSession> {
pruneTerminalPreviewSessions(sessions);
@@ -122,6 +135,8 @@ export function createPreviewSessionManager() {
status: "starting",
command,
cwd: input.cwd,
cwdKind: input.cwdKind,
ref: input.ref,
port,
url: `http://127.0.0.1:${port}`,
startedAt: new Date().toISOString(),

View File

@@ -0,0 +1,159 @@
import { existsSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
/*
FNXC:Quality 2026-07-15-23:23:
When Quality starts a preview or test run for a task whose live worktree is gone
(typical for done tasks after land/cleanup), recreate a disposable QA worktree
checked out at the task's branch tip or merge commit so the process runs the
done task's code — not bare project root/mainline.
Paths live under <projectRoot>/.fusion/quality-qa/<taskId> so the engine's
`.worktrees` pool sweeps do not treat them as idle task checkouts.
*/
const execFileAsync = promisify(execFile);
export type TaskCodeCwdKind = "worktree" | "qa-worktree";
export interface TaskForCodeWorktree {
id: string;
worktree?: string | null;
branch?: string | null;
mergeDetails?: { commitSha?: string | null } | null;
}
export interface ResolvedTaskCodeCwd {
cwd: string;
cwdKind: TaskCodeCwdKind;
/** Git ref used for a QA worktree (branch name or commit SHA). */
ref?: string;
created: boolean;
}
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
});
return String(stdout ?? "").trim();
}
async function refExists(projectRoot: string, ref: string): Promise<boolean> {
try {
await git(projectRoot, ["rev-parse", "--verify", `${ref}^{commit}`]);
return true;
} catch {
return false;
}
}
function isCommitSha(ref: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(ref);
}
/** Stable on-disk path for a disposable Quality QA worktree. */
export function qualityQaWorktreePath(projectRoot: string, taskId: string): string {
const safe = taskId.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
return join(projectRoot, ".fusion", "quality-qa", safe);
}
/**
* Candidate refs for a done (or worktree-less) task, highest preference first:
* 1. recorded task.branch
* 2. conventional fusion/<taskId> branch (lowercase id)
* 3. mergeDetails.commitSha (landed squash/merge commit)
*/
export function candidateTaskCodeRefs(task: TaskForCodeWorktree): string[] {
const refs: string[] = [];
const branch = typeof task.branch === "string" ? task.branch.trim() : "";
if (branch) refs.push(branch);
const fusionBranch = `fusion/${task.id.toLowerCase()}`;
if (!refs.includes(fusionBranch)) refs.push(fusionBranch);
const mergeSha =
typeof task.mergeDetails?.commitSha === "string" ? task.mergeDetails.commitSha.trim() : "";
if (mergeSha && !refs.includes(mergeSha)) refs.push(mergeSha);
return refs;
}
export async function resolveTaskCodeRef(
projectRoot: string,
task: TaskForCodeWorktree,
): Promise<string | null> {
for (const ref of candidateTaskCodeRefs(task)) {
if (await refExists(projectRoot, ref)) return ref;
}
return null;
}
/**
* Prefer a live task worktree; otherwise ensure a disposable QA worktree at the
* task's branch or merge commit so done-task QA runs the task's code.
*/
export async function resolveTaskCodeCwd(input: {
task: TaskForCodeWorktree;
projectRoot: string;
}): Promise<ResolvedTaskCodeCwd> {
const projectRoot = input.projectRoot.trim() || process.cwd();
const live = typeof input.task.worktree === "string" ? input.task.worktree.trim() : "";
if (live && existsSync(live)) {
return { cwd: live, cwdKind: "worktree", created: false };
}
const ref = await resolveTaskCodeRef(projectRoot, input.task);
if (!ref) {
const tried = candidateTaskCodeRefs(input.task).join(", ") || "(none)";
const err = new Error(
`Cannot resolve code for task ${input.task.id}: no live worktree and no reachable branch/merge commit (tried: ${tried})`,
) as Error & { statusCode?: number };
err.statusCode = 400;
throw err;
}
const path = qualityQaWorktreePath(projectRoot, input.task.id);
if (existsSync(path)) {
// Reuse: hard-reset to the resolved ref so a previous QA session cannot leave stale state.
try {
if (isCommitSha(ref)) {
await git(path, ["checkout", "--detach", ref]);
} else {
await git(path, ["checkout", "--force", ref]);
}
await git(path, ["reset", "--hard", "HEAD"]);
return { cwd: path, cwdKind: "qa-worktree", ref, created: false };
} catch {
// Fall through to recreate if the existing path is broken.
try {
await git(projectRoot, ["worktree", "remove", "--force", path]);
} catch {
// ignore — add --force below may still succeed after prune
}
try {
await git(projectRoot, ["worktree", "prune"]);
} catch {
// ignore
}
}
}
mkdirSync(dirname(path), { recursive: true });
try {
if (isCommitSha(ref)) {
await git(projectRoot, ["worktree", "add", "--detach", "--force", path, ref]);
} else {
await git(projectRoot, ["worktree", "add", "--force", path, ref]);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const wrapped = new Error(
`Failed to create Quality QA worktree for ${input.task.id} at ${ref}: ${message}`,
) as Error & { statusCode?: number };
wrapped.statusCode = 500;
throw wrapped;
}
return { cwd: path, cwdKind: "qa-worktree", ref, created: true };
}

View File

@@ -41,6 +41,9 @@ interface PreviewSession {
url?: string;
port?: number;
command?: string;
cwd?: string;
cwdKind?: "worktree" | "qa-worktree";
ref?: string;
errorMessage?: string;
}
@@ -94,8 +97,21 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement {
},
});
if (!res.ok) {
/*
FNXC:Quality 2026-07-15-23:17:
Prefer the structured { error } body from gated plugin routes over raw JSON text.
*/
const text = await res.text();
throw new Error(text || res.statusText);
let message = text || res.statusText;
try {
const parsed = JSON.parse(text) as { error?: unknown };
if (typeof parsed.error === "string" && parsed.error.trim()) {
message = parsed.error;
}
} catch {
// keep text body
}
throw new Error(message);
}
return res.json();
},
@@ -216,46 +232,58 @@ export function QualityTaskQaTab(props: QualityQaTabProps): ReactElement {
: null,
// Preview server
/*
FNXC:Quality 2026-07-15-23:23:
Always offer Start. Done tasks often have no live worktree after cleanup; the
API creates a disposable QA worktree at the task branch/merge commit so the
preview runs the done task's code.
*/
createElement(
Section,
{ title: "Preview server", testId: "quality-qa-preview" },
!worktree
? createElement("p", { style: { margin: 0, opacity: 0.8 } }, "Checkout/start this task to get a worktree before starting a preview server.")
: createElement(
"div",
null,
createElement(
"p",
{ style: { margin: "0 0 8px", fontSize: 13, opacity: 0.85 } },
preview
? `Status: ${preview.status}${preview.url ? ` · ${preview.url}` : ""}${preview.port ? ` · port ${preview.port}` : ""}`
: "No preview server running for this task.",
),
createElement(
"div",
{ style: { display: "flex", gap: 8, flexWrap: "wrap" } },
createElement(
"button",
{ type: "button", className: "btn btn-sm", disabled: busy || !worktree, onClick: () => void startPreview() },
"Start",
),
createElement(
"button",
{ type: "button", className: "btn btn-sm", disabled: busy || !preview, onClick: () => void stopPreview() },
"Stop",
),
preview?.url
? createElement(
"a",
{ className: "btn btn-sm", href: preview.url, target: "_blank", rel: "noreferrer" },
"Open URL",
)
: null,
),
preview?.errorMessage
? createElement("p", { style: { color: "var(--error, #c00)", fontSize: 12 } }, preview.errorMessage)
: null,
createElement(
"div",
null,
createElement(
"p",
{ style: { margin: "0 0 8px", fontSize: 13, opacity: 0.85 } },
preview
? `Status: ${preview.status}${preview.url ? ` · ${preview.url}` : ""}${preview.port ? ` · port ${preview.port}` : ""}${
preview.cwdKind === "qa-worktree"
? ` · QA worktree${preview.ref ? ` @ ${preview.ref.slice(0, 12)}` : ""}`
: preview.cwdKind === "worktree"
? " · worktree"
: ""
}`
: worktree
? "No preview server running for this task."
: "No live worktree — Start will check out this task's branch/merge commit into a temporary QA worktree.",
),
createElement(
"div",
{ style: { display: "flex", gap: 8, flexWrap: "wrap" } },
createElement(
"button",
{ type: "button", className: "btn btn-sm", disabled: busy, onClick: () => void startPreview() },
"Start",
),
createElement(
"button",
{ type: "button", className: "btn btn-sm", disabled: busy || !preview, onClick: () => void stopPreview() },
"Stop",
),
preview?.url
? createElement(
"a",
{ className: "btn btn-sm", href: preview.url, target: "_blank", rel: "noreferrer" },
"Open URL",
)
: null,
),
preview?.errorMessage
? createElement("p", { style: { color: "var(--error, #c00)", fontSize: 12 } }, preview.errorMessage)
: null,
),
loadErrors.preview
? createElement("p", { role: "alert", style: { color: "var(--error, #c00)", margin: "8px 0 0" } }, loadErrors.preview)
: null,

View File

@@ -8,6 +8,7 @@ import { getAllowRootFallback, getDefaultPreviewScript, getLogTruncateKb, getRun
import { buildHeuristicSuggestedCases } from "../suggestions/heuristic-cases.js";
import type { QualityPresetId } from "../store/quality-types.js";
import { createPreviewSessionManager } from "../preview/preview-sessions.js";
import { resolveTaskCodeCwd } from "../preview/task-code-worktree.js";
/*
FNXC:Quality 2026-07-14-21:45:
@@ -60,15 +61,51 @@ const previewManager = createPreviewSessionManager();
const QUALITY_EXPERIMENTAL_FLAG = "qualityPlugin";
function requireQualityExperimental(ctx: PluginContext): void {
const settings = (typeof (ctx.taskStore as { getSettings?: () => unknown }).getSettings === "function"
? (ctx.taskStore as { getSettings: () => unknown }).getSettings()
: {}) as { experimentalFeatures?: Record<string, boolean> };
if (settings.experimentalFeatures?.[QUALITY_EXPERIMENTAL_FLAG] !== true) {
/*
FNXC:Quality 2026-07-15-23:17:
TaskStore.getSettings() is always async and returns merged global+project settings
(including global experimentalFeatures). Calling it without await made the return
value a Promise; experimentalFeatures was always undefined, so every Quality route
hard-failed even after the operator enabled Settings → Experimental → Quality Plugin.
*/
export async function loadTaskStoreSettings(ctx: PluginContext): Promise<Record<string, unknown>> {
const getSettings = (ctx.taskStore as { getSettings?: () => unknown }).getSettings;
if (typeof getSettings !== "function") return {};
try {
const result = getSettings.call(ctx.taskStore);
if (result != null && typeof (result as PromiseLike<unknown>).then === "function") {
const resolved = await (result as Promise<unknown>);
return resolved && typeof resolved === "object" && !Array.isArray(resolved)
? (resolved as Record<string, unknown>)
: {};
}
return result && typeof result === "object" && !Array.isArray(result)
? (result as Record<string, unknown>)
: {};
} catch {
return {};
}
}
export async function requireQualityExperimental(ctx: PluginContext): Promise<void> {
const settings = await loadTaskStoreSettings(ctx);
const features = settings.experimentalFeatures;
const enabled =
features && typeof features === "object" && !Array.isArray(features)
? (features as Record<string, unknown>)[QUALITY_EXPERIMENTAL_FLAG] === true
: false;
if (!enabled) {
httpError(404, "Quality plugin is experimental; enable experimentalFeatures.qualityPlugin to use it");
}
}
function asHttpError(err: unknown): { statusCode: number; message: string } | null {
if (!(err instanceof Error)) return null;
const statusCode = (err as Error & { statusCode?: unknown }).statusCode;
if (typeof statusCode !== "number" || !Number.isFinite(statusCode)) return null;
return { statusCode, message: err.message };
}
/*
FNXC:Quality 2026-07-15-13:05:
Test plans are execution contracts: silently dropping an unknown requested
@@ -148,27 +185,52 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
// Resolve cwd server-side
const rootDir = ctx.taskStore.getRootDir?.() ?? process.cwd();
let cwd = rootDir;
let cwdKind: "project-root" | "worktree" = "project-root";
let cwdKind: "project-root" | "worktree" | "qa-worktree" = "project-root";
let filePaths: string[] = [];
if (taskId) {
let task: { id: string; worktree?: string; modifiedFiles?: string[]; title?: string };
let task: {
id: string;
worktree?: string;
branch?: string;
modifiedFiles?: string[];
title?: string;
mergeDetails?: { commitSha?: string };
};
try {
task = (await ctx.taskStore.getTask(taskId)) as {
id: string;
worktree?: string;
branch?: string;
modifiedFiles?: string[];
title?: string;
mergeDetails?: { commitSha?: string };
};
} catch {
httpError(404, "Task not found");
}
const worktree = typeof task.worktree === "string" ? task.worktree.trim() : "";
if (worktree) {
cwd = worktree;
cwdKind = "worktree";
} else if (!getAllowRootFallback(ctx.settings as Record<string, unknown>)) {
httpError(400, "Task has no worktree; start/checkout the task first");
/*
FNXC:Quality 2026-07-15-23:23:
Task-scoped runs (including done tasks) must execute in the task's code.
Prefer the live worktree; otherwise create a disposable QA worktree at
the task branch/merge commit. Project-root fallback remains opt-in only.
*/
try {
const resolvedCwd = await resolveTaskCodeCwd({ task, projectRoot: rootDir });
cwd = resolvedCwd.cwd;
cwdKind = resolvedCwd.cwdKind;
} catch (err) {
if (getAllowRootFallback(ctx.settings as Record<string, unknown>)) {
cwd = rootDir;
cwdKind = "project-root";
} else {
const message = err instanceof Error ? err.message : String(err);
const status =
err instanceof Error && typeof (err as Error & { statusCode?: number }).statusCode === "number"
? (err as Error & { statusCode: number }).statusCode
: 400;
httpError(status, message);
}
}
filePaths = Array.isArray(task.modifiedFiles)
? task.modifiedFiles.filter((p): p is string => typeof p === "string")
@@ -182,12 +244,15 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
filePaths = body.filePaths.filter((p): p is string => typeof p === "string");
}
const settings = (typeof (ctx.taskStore as { getSettings?: () => unknown }).getSettings === "function"
? (ctx.taskStore as { getSettings: () => unknown }).getSettings()
: {}) as { testCommand?: string; verificationCommandTimeoutMs?: number };
const settings = await loadTaskStoreSettings(ctx);
const testCommand = typeof settings.testCommand === "string" ? settings.testCommand : undefined;
const verificationCommandTimeoutMs =
typeof settings.verificationCommandTimeoutMs === "number"
? settings.verificationCommandTimeoutMs
: undefined;
const resolved = resolvePresetCommand({
preset,
testCommand: settings.testCommand,
testCommand,
projectRoot: rootDir,
filePaths,
confirmFullSuite,
@@ -197,7 +262,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
httpError(status, resolved.reason);
}
const timeoutMs = defaultTimeoutMs(settings.verificationCommandTimeoutMs);
const timeoutMs = defaultTimeoutMs(verificationCommandTimeoutMs);
const run = store.createRun({
projectId,
taskId,
@@ -357,14 +422,33 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const projectId = requireProjectId(r);
const taskId = r.params?.taskId;
if (!taskId) httpError(400, "taskId required");
let task: { worktree?: string };
let task: {
id?: string;
worktree?: string;
branch?: string;
mergeDetails?: { commitSha?: string };
};
try {
task = (await ctx.taskStore.getTask(taskId)) as { worktree?: string };
task = (await ctx.taskStore.getTask(taskId)) as {
id?: string;
worktree?: string;
branch?: string;
mergeDetails?: { commitSha?: string };
};
} catch {
httpError(404, "Task not found");
}
const worktree = typeof task.worktree === "string" ? task.worktree.trim() : "";
if (!worktree) httpError(400, "Task has no worktree");
/*
FNXC:Quality 2026-07-15-23:23:
Done tasks usually have no live worktree. Create/reuse a disposable QA
worktree checked out at the task branch or merge commit so the preview
server runs the done task's code, not project root/mainline.
*/
const projectRoot = ctx.taskStore.getRootDir?.() ?? process.cwd();
const resolvedCwd = await resolveTaskCodeCwd({
task: { id: task.id ?? taskId, worktree: task.worktree, branch: task.branch, mergeDetails: task.mergeDetails },
projectRoot,
});
const body = asRecord(r.body);
if ("command" in body && typeof body.command === "string") {
// Only allow simple package script names, not free shell
@@ -379,7 +463,9 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const session = await previewManager.start({
projectId,
taskId,
cwd: worktree,
cwd: resolvedCwd.cwd,
cwdKind: resolvedCwd.cwdKind,
ref: resolvedCwd.ref,
script,
});
return { session };
@@ -407,9 +493,23 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
The Quality plugin is an opt-in experiment. Gate every route at the
server boundary so installed bundles cannot run commands until a global
operator explicitly enables experimentalFeatures.qualityPlugin.
FNXC:Quality 2026-07-15-23:17:
Await the experimental gate (async settings) and map statusCode-bearing
errors to PluginRouteResponse. Dashboard catchHandler only preserves status
for ApiError instances; plain Error+statusCode was collapsed to HTTP 500,
so every gated Quality call looked like a hard failure.
*/
requireQualityExperimental(ctx);
return route.handler(req, ctx);
try {
await requireQualityExperimental(ctx);
return await route.handler(req, ctx);
} catch (err) {
const http = asHttpError(err);
if (http) {
return { status: http.statusCode, body: { error: http.message } };
}
throw err;
}
},
}));
}

View File

@@ -15,7 +15,7 @@ export type TestRunStatus =
export type TestRunSource = "hub" | "task-tab" | "workflow" | "agent-qa";
export type CwdKind = "project-root" | "worktree";
export type CwdKind = "project-root" | "worktree" | "qa-worktree";
export type QualityPresetId =
| "project-test"

View File

@@ -4,8 +4,12 @@
"outDir": "dist",
"rootDir": "./src",
"jsx": "react-jsx",
"types": ["react", "node"]
"types": ["react", "node"],
"paths": {
"@fusion/dashboard/app/components/ViewHeader": ["./src/dashboard-interop.d.ts"],
"@fusion/dashboard/app/plugins/types": ["./src/dashboard-interop.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
"exclude": ["src/**/__tests__/**"]
}