feat(FN-3863): add stash recovery dashboard surface and API routes

Merged FN-3863 brings stash recovery to the dashboard via three coordinated steps: engine-side orphan stash surfacing API in `merger.ts`, dashboard API routes for stash recovery data, and a new `StashRecoveryView` component with mobile support and inspect-diff row actions, integrated into the header

Fusion-Task-Id: FN-3863
This commit is contained in:
Fusion
2026-05-09 19:44:55 -07:00
committed by gsxdsm
parent e69631ec24
commit d604d090a8
19 changed files with 838 additions and 13 deletions

View File

@@ -0,0 +1,138 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { __test__ } from "../merger.js";
const { listAutostashOrphans, applyAutostashBySha, getAutostashDiff, notifyAutostashOrphans } = __test__;
function git(cwd: string, cmd: string): string {
return execSync(cmd, { cwd, stdio: "pipe" }).toString("utf-8").trim();
}
function initRepo(dir: string): void {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
writeFileSync(join(dir, "file.txt"), "base\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "init"');
}
function createAutostash(dir: string, label: string, content: string): string {
writeFileSync(join(dir, "file.txt"), content);
git(dir, "git add file.txt");
const sha = git(dir, "git stash create");
git(dir, `git stash store -m ${JSON.stringify(label)} ${sha}`);
git(dir, "git reset --hard HEAD");
const list = git(dir, 'git stash list --format="%H %gd %s"');
if (!list.includes(label)) {
git(dir, "git stash drop stash@{0}");
writeFileSync(join(dir, "file.txt"), content);
git(dir, `git stash push -m ${JSON.stringify(label)} file.txt`);
return git(dir, 'git stash list --format="%H" -n 1');
}
return sha;
}
describe("autostash orphan surface", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "fn-autostash-surface-"));
initRepo(dir);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it("lists fusion-merger-autostash entries and ignores unrelated stashes", async () => {
const ts = Date.now();
createAutostash(dir, `fusion-merger-autostash:FN-2001:${ts}`, "feature\n");
writeFileSync(join(dir, "file.txt"), "manual\n");
git(dir, 'git stash push -m "manual" file.txt');
const records = await listAutostashOrphans(dir);
expect(records).toHaveLength(1);
expect(records[0]?.label).toContain("fusion-merger-autostash:FN-2001");
});
it("parses sourceTaskId and createdAt; malformed labels return null fields", async () => {
const ts = Date.now();
createAutostash(dir, `fusion-merger-autostash:FN-2002:${ts}`, "a\n");
createAutostash(dir, "fusion-merger-autostash:FN-2003:not-a-ts", "b\n");
const records = await listAutostashOrphans(dir);
const good = records.find((r) => r.sourceTaskId === "FN-2002");
const bad = records.find((r) => r.label.includes("not-a-ts"));
expect(good?.createdAt).toBe(new Date(ts).toISOString());
expect(bad?.sourceTaskId).toBe("FN-2003");
expect(bad?.createdAt).toBeNull();
});
it("classifies subsumed vs live from diff against HEAD", async () => {
const subsumedSha = createAutostash(dir, `fusion-merger-autostash:FN-2004:${Date.now()}`, "subsumed\n");
writeFileSync(join(dir, "file.txt"), "subsumed\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "subsumed"');
const liveSha = createAutostash(dir, `fusion-merger-autostash:FN-2005:${Date.now()}`, "live\n");
const records = await listAutostashOrphans(dir);
expect(records.find((r) => r.sha === subsumedSha)?.classification).toBe("subsumed");
expect(records.find((r) => r.sha === liveSha)?.classification).toBe("live");
});
it("applies stash on clean tree and reports conflict without dropping stash", async () => {
const label = `fusion-merger-autostash:FN-2006:${Date.now()}`;
const sha = createAutostash(dir, label, "from-stash\n");
const applyOk = await applyAutostashBySha(dir, sha);
expect(applyOk).toEqual({ ok: true });
expect(git(dir, "cat file.txt")).toContain("from-stash");
git(dir, "git checkout -- file.txt");
writeFileSync(join(dir, "file.txt"), "other-change\n");
git(dir, "git add file.txt");
git(dir, 'git commit -m "conflicting commit"');
const conflict = await applyAutostashBySha(dir, sha);
expect(conflict.ok).toBe(false);
if (!conflict.ok) {
expect(conflict.reason).toBe("conflict");
expect(conflict.stderr).toBeTruthy();
}
expect(git(dir, 'git stash list --format="%H %s"')).toContain(sha);
});
it("emits merger:autostashOrphans event with records payload", async () => {
createAutostash(dir, `fusion-merger-autostash:FN-2008:${Date.now()}`, "emit\n");
const store = { emit: vi.fn() } as any;
const records = await notifyAutostashOrphans(store, dir);
expect(records).toHaveLength(1);
expect(store.emit).toHaveBeenCalledWith("merger:autostashOrphans", {
rootDir: dir,
records,
});
});
it("truncates diff output beyond cap", async () => {
const longContent = `${"x".repeat(70000)}\n`;
const sha = createAutostash(dir, `fusion-merger-autostash:FN-2007:${Date.now()}`, longContent);
const diff = await getAutostashDiff(dir, sha);
expect(Buffer.byteLength(diff, "utf-8")).toBeLessThanOrEqual(64 * 1024 + 128);
expect(diff).toContain("… (diff truncated)");
});
});

View File

@@ -20,7 +20,16 @@ export { Scheduler, type SchedulerOptions } from "./scheduler.js";
export { MeshLeaseManager, type MeshLeaseManagerOptions, type LeaseRecoveryContext } from "./mesh-lease-manager.js";
export { MissionAutopilot, type MissionAutopilotOptions } from "./mission-autopilot.js";
export { MissionExecutionLoop, type MissionExecutionLoopOptions, type ValidationResult, loopLog } from "./mission-execution-loop.js";
export { aiMergeTask, type MergerOptions } from "./merger.js";
export {
aiMergeTask,
listAutostashOrphans,
applyAutostashBySha,
dropAutostashBySha,
getAutostashDiff,
notifyAutostashOrphans,
type MergerOptions,
type AutostashOrphanRecord,
} from "./merger.js";
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
export { createFnAgent, promptWithFallback, describeModel, setHostExtensionPaths, getHostExtensionPaths, type AgentOptions, type AgentResult } from "./pi.js";

View File

@@ -1044,6 +1044,7 @@ interface AutostashHandle {
}
const AUTOSTASH_LABEL_PREFIX = "fusion-merger-autostash:";
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
/** Return the set of paths a stash commit recorded as changed against its
* parent (HEAD-at-stash-time). Used to compare a new dirty snapshot against
@@ -1246,6 +1247,7 @@ export function parsePorcelainZ(raw: string): Set<string> {
async function listOrphanedAutostashes(
rootDir: string,
): Promise<Array<{ sha: string; ref: string; label: string }>> {
try {
const { stdout } = await execAsync(
`git stash list --format="%H %gd %s"`,
@@ -1274,6 +1276,98 @@ function parseAutostashTaskId(label: string): string | null {
return match?.[1] ?? null;
}
export interface AutostashOrphanRecord {
sha: string;
ref: string;
label: string;
sourceTaskId: string | null;
createdAt: string | null;
changedPaths: string[];
classification: "subsumed" | "live" | "unknown";
}
function parseAutostashCreatedAt(label: string): string | null {
const match = AUTOSTASH_TIMESTAMP_RE.exec(label.trim());
if (!match) return null;
const ts = Number.parseInt(match[1] ?? "", 10);
if (!Number.isFinite(ts)) return null;
return new Date(ts).toISOString();
}
async function classifyAutostashOrphan(rootDir: string, sha: string): Promise<"subsumed" | "live" | "unknown"> {
try {
const stashFiles = await listStashChangedPaths(rootDir, sha);
if (stashFiles.size === 0) return "subsumed";
const pathsArg = [...stashFiles].map(quoteArg).join(" ");
const { stdout: pathDiffOut } = await execAsync(
`git diff --name-only HEAD ${quoteArg(sha)} -- ${pathsArg}`,
{ cwd: rootDir, encoding: "utf-8" },
);
return pathDiffOut.trim() === "" ? "subsumed" : "live";
} catch {
return "unknown";
}
}
export async function listAutostashOrphans(rootDir: string): Promise<AutostashOrphanRecord[]> {
const orphans = await listOrphanedAutostashes(rootDir);
const records: AutostashOrphanRecord[] = [];
for (const orphan of orphans) {
const changedPaths = [...(await listStashChangedPaths(rootDir, orphan.sha))];
records.push({
sha: orphan.sha,
ref: orphan.ref,
label: orphan.label,
sourceTaskId: parseAutostashTaskId(orphan.label),
createdAt: parseAutostashCreatedAt(orphan.label),
changedPaths,
classification: await classifyAutostashOrphan(rootDir, orphan.sha),
});
}
return records;
}
export async function notifyAutostashOrphans(store: TaskStore, rootDir: string): Promise<AutostashOrphanRecord[]> {
const records = await listAutostashOrphans(rootDir);
store.emit("merger:autostashOrphans", { rootDir, records });
return records;
}
export async function applyAutostashBySha(
rootDir: string,
sha: string,
): Promise<{ ok: true } | { ok: false; reason: string; stderr?: string }> {
try {
await execAsync(`git stash apply ${quoteArg(sha)}`, { cwd: rootDir, encoding: "utf-8" });
return { ok: true };
} catch (err: unknown) {
const stderr = err && typeof err === "object" && "stderr" in err ? String((err as { stderr?: string }).stderr ?? "") : "";
const stdout = err && typeof err === "object" && "stdout" in err ? String((err as { stdout?: string }).stdout ?? "") : "";
const message = err instanceof Error ? err.message : String(err);
const details = `${stderr}\n${stdout}\n${message}`;
if (/CONFLICT|could not apply|would be overwritten/i.test(details)) {
return { ok: false, reason: "conflict", stderr: stderr || details };
}
return { ok: false, reason: "apply_failed", stderr: stderr || details };
}
}
export async function getAutostashDiff(rootDir: string, sha: string): Promise<string> {
const maxBytes = 64 * 1024;
const { stdout } = await execAsync(`git stash show -p ${quoteArg(sha)}`, {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 5 * 1024 * 1024,
});
const diff = String(stdout);
if (Buffer.byteLength(diff, "utf-8") <= maxBytes) return diff;
let truncated = diff;
while (Buffer.byteLength(truncated, "utf-8") > maxBytes) {
truncated = truncated.slice(0, Math.max(0, Math.floor(truncated.length * 0.9)));
}
return `${truncated}\n… (diff truncated)`;
}
/**
* Stash any unrelated dirty changes in `rootDir` before a merge runs.
*
@@ -1446,9 +1540,9 @@ async function sweepAutostashOrphans(
)
.catch(() => undefined);
}
}
const AUTOSTASH_TIMESTAMP_RE = /^fusion-merger-autostash:[A-Za-z]+-\d+:(?:race-rescue-\d+:)?(\d+)$/;
await notifyAutostashOrphans(store, rootDir).catch(() => undefined);
}
export async function sweepStaleAutostashes(
rootDir: string,
@@ -1488,6 +1582,10 @@ export const __test__ = {
dropAutostashHandle,
isAutostashLive,
sweepStaleAutostashes,
listAutostashOrphans,
applyAutostashBySha,
getAutostashDiff,
notifyAutostashOrphans,
};
async function stashUnrelatedRootDirChanges(
@@ -1644,7 +1742,7 @@ async function findStashRefBySha(rootDir: string, sha: string): Promise<string |
* with `git rev-parse`, then drop. If the SHA at the ref drifted (race),
* retry up to 5x. Returns whether the drop landed cleanly so callers can
* surface failure to the task feed. */
async function dropAutostashBySha(
export async function dropAutostashBySha(
rootDir: string,
taskId: string,
sha: string,