## 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)
160 lines
5.3 KiB
TypeScript
160 lines
5.3 KiB
TypeScript
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 };
|
|
}
|