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:
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
23
plugins/fusion-plugin-quality/src/dashboard-interop.d.ts
vendored
Normal file
23
plugins/fusion-plugin-quality/src/dashboard-interop.d.ts
vendored
Normal 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;
|
||||
}
|
||||
174
plugins/fusion-plugin-quality/src/dashboard-view.css
Normal file
174
plugins/fusion-plugin-quality/src/dashboard-view.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
159
plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts
Normal file
159
plugins/fusion-plugin-quality/src/preview/task-code-worktree.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__/**"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user