From 9b8edf06c4b8c843ba81e4e6b371c652e7d18bba Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 00:16:12 -0700 Subject: [PATCH] fix(FN-2127): Quality experimental gate, done-task worktrees, hub layout (#2164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../src/__tests__/experimental-gate.test.ts | 88 ++++++ .../src/__tests__/preview-sessions.test.ts | 17 ++ .../__tests__/preview-start-done-task.test.ts | 91 ++++++ .../src/__tests__/task-code-worktree.test.ts | 101 +++++++ .../src/dashboard-interop.d.ts | 23 ++ .../src/dashboard-view.css | 174 +++++++++++ .../src/dashboard-view.tsx | 281 +++++++++++++----- .../src/preview/preview-sessions.ts | 15 + .../src/preview/task-code-worktree.ts | 159 ++++++++++ plugins/fusion-plugin-quality/src/qa-tab.tsx | 102 ++++--- .../src/routes/create-routes.ts | 150 ++++++++-- .../src/store/quality-types.ts | 2 +- plugins/fusion-plugin-quality/tsconfig.json | 8 +- 13 files changed, 1067 insertions(+), 144 deletions(-) create mode 100644 plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts create mode 100644 plugins/fusion-plugin-quality/src/__tests__/task-code-worktree.test.ts create mode 100644 plugins/fusion-plugin-quality/src/dashboard-interop.d.ts create mode 100644 plugins/fusion-plugin-quality/src/dashboard-view.css create mode 100644 plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts diff --git a/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts new file mode 100644 index 0000000000..bee1b256f5 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts @@ -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: [] }); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts b/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts index 8cf515df21..c9b4f02e0d 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/preview-sessions.test.ts @@ -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/, 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"); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts b/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts new file mode 100644 index 0000000000..b0f0d06e60 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/preview-start-done-task.test.ts @@ -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 }), + ); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/task-code-worktree.test.ts b/plugins/fusion-plugin-quality/src/__tests__/task-code-worktree.test.ts new file mode 100644 index 0000000000..0286c38e9e --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/task-code-worktree.test.ts @@ -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/; 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); + }); +}); diff --git a/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts b/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts new file mode 100644 index 0000000000..8fbff80c87 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/dashboard-interop.d.ts @@ -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; + title: string; + actions?: ReactNode; + titleId?: string; + } + + export function ViewHeader(props: ViewHeaderProps): ReactNode; +} diff --git a/plugins/fusion-plugin-quality/src/dashboard-view.css b/plugins/fusion-plugin-quality/src/dashboard-view.css new file mode 100644 index 0000000000..dc493a9e60 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/dashboard-view.css @@ -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; +} diff --git a/plugins/fusion-plugin-quality/src/dashboard-view.tsx b/plugins/fusion-plugin-quality/src/dashboard-view.tsx index 098efb5604..433ee4c317 100644 --- a/plugins/fusion-plugin-quality/src/dashboard-view.tsx +++ b/plugins/fusion-plugin-quality/src/dashboard-view.tsx @@ -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 { + /* + 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 ( + + {status} + + ); +} + export function QualityDashboardView({ context, }: { @@ -36,6 +92,7 @@ export function QualityDashboardView({ const [runs, setRuns] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const [busyPreset, setBusyPreset] = useState(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 ? ( + <> + + {runs.length} {runs.length === 1 ? "run" : "runs"} + + + + + + + ) : null; + + return ( +
+ +
+

+ Project-wide test runs. Advisory only — does not change merge eligibility. Prefer Task QA for + worktree-scoped preview, screenshots, and suggested cases. +

+ + {!projectId ? ( +

Select a project to view Quality data.

+ ) : ( + <> + {error ? ( +

+ {error} +

+ ) : null} + +
+
+

Run history

+ + {loading && runs.length === 0 ? "Loading…" : `${runs.length} total`} + +
+
+ + + + + + + + + + + + {runs.length === 0 ? ( + + + + ) : ( + runs.map((run) => ( + + + + + + + + )) + )} + +
StatusPresetCommandDurationWhen
+ {loading ? "Loading runs…" : "No runs yet. Start a preset from the header."} +
+ + {run.presetId ?? "—"}{run.command}{formatDuration(run.durationMs)}{formatWhen(run.createdAt)}
+
+
+ + )} +
+
); } diff --git a/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts b/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts index 38ce251d48..22f1ddb857 100644 --- a/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts +++ b/plugins/fusion-plugin-quality/src/preview/preview-sessions.ts @@ -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 { 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(), diff --git a/plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts b/plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts new file mode 100644 index 0000000000..c3e17c0212 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts @@ -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 /.fusion/quality-qa/ 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 { + 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 { + 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/ 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 { + 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 { + 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 }; +} diff --git a/plugins/fusion-plugin-quality/src/qa-tab.tsx b/plugins/fusion-plugin-quality/src/qa-tab.tsx index be22230bbc..c81604f26e 100644 --- a/plugins/fusion-plugin-quality/src/qa-tab.tsx +++ b/plugins/fusion-plugin-quality/src/qa-tab.tsx @@ -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, diff --git a/plugins/fusion-plugin-quality/src/routes/create-routes.ts b/plugins/fusion-plugin-quality/src/routes/create-routes.ts index dc80821390..24ae41b363 100644 --- a/plugins/fusion-plugin-quality/src/routes/create-routes.ts +++ b/plugins/fusion-plugin-quality/src/routes/create-routes.ts @@ -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 }; - 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> { + 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).then === "function") { + const resolved = await (result as Promise); + return resolved && typeof resolved === "object" && !Array.isArray(resolved) + ? (resolved as Record) + : {}; + } + return result && typeof result === "object" && !Array.isArray(result) + ? (result as Record) + : {}; + } catch { + return {}; + } +} + +export async function requireQualityExperimental(ctx: PluginContext): Promise { + const settings = await loadTaskStoreSettings(ctx); + const features = settings.experimentalFeatures; + const enabled = + features && typeof features === "object" && !Array.isArray(features) + ? (features as Record)[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)) { - 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)) { + 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; + } }, })); } diff --git a/plugins/fusion-plugin-quality/src/store/quality-types.ts b/plugins/fusion-plugin-quality/src/store/quality-types.ts index 8f6eaddb7d..a4b3ea4f84 100644 --- a/plugins/fusion-plugin-quality/src/store/quality-types.ts +++ b/plugins/fusion-plugin-quality/src/store/quality-types.ts @@ -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" diff --git a/plugins/fusion-plugin-quality/tsconfig.json b/plugins/fusion-plugin-quality/tsconfig.json index f03acb585f..5d3276b7c3 100644 --- a/plugins/fusion-plugin-quality/tsconfig.json +++ b/plugins/fusion-plugin-quality/tsconfig.json @@ -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__/**"] }