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 d25e8cbfa9
commit 7e8541b92d
19 changed files with 838 additions and 13 deletions

View File

@@ -65,7 +65,7 @@ import { NativeShellConnectionManager } from "./components/NativeShellConnection
import { ShellConnectionStatus } from "./components/ShellConnectionStatus";
import { getShellConnectionNativeResult, type ShellConnectionNativeResult } from "./shell-native";
import type { AiSessionSummary } from "./api";
import { fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
import { api, fetchUnreadCount, fetchTaskDetail, fetchWorkflowSteps } from "./api";
import { getScopedItem, setScopedItem } from "./utils/projectStorage";
import { subscribeSse } from "./sse-bus";
import { AUTH_TOKEN_RECOVERY_REQUIRED_EVENT } from "./auth";
@@ -92,6 +92,7 @@ const SkillsView = lazy(() => import("./components/SkillsView").then((m) => ({ d
const MemoryView = lazy(() => import("./components/MemoryView").then((m) => ({ default: m.MemoryView })));
const DevServerView = lazy(() => import("./components/DevServerView").then((m) => ({ default: m.DevServerView })));
const _TodoView = lazy(() => import("./components/TodoView").then((m) => ({ default: m.TodoView })));
const StashRecoveryView = lazy(() => import("./components/StashRecoveryView").then((m) => ({ default: m.StashRecoveryView })));
// Warm lazy chunks during browser idle so first navigation to each view is
// instant. Each chunk is ~1080 kB; total prefetch finishes well under a
@@ -117,6 +118,7 @@ function prefetchLazyViews() {
void import("./components/MemoryView");
void import("./components/DevServerView");
void import("./components/TodoView");
void import("./components/StashRecoveryView");
});
}
@@ -433,6 +435,7 @@ function AppInner() {
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
const [stashOrphanCount, setStashOrphanCount] = useState(0);
const [approvalBannerCandidate, setApprovalBannerCandidate] = useState<ApprovalBannerCandidate | null>(null);
const taskStatusByIdRef = useRef<Map<string, string | undefined>>(new Map());
const seenApprovalKeysRef = useRef<Set<string>>(new Set());
@@ -546,6 +549,24 @@ function AppInner() {
}
}, [taskView]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const data = await api<{ count: number }>("/stash-recovery/orphans");
if (!cancelled) setStashOrphanCount(data.count ?? 0);
} catch {
if (!cancelled) setStashOrphanCount(0);
}
};
void load();
const timer = window.setInterval(() => void load(), 30000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [currentProject?.id]);
useEffect(() => {
const params = new URLSearchParams();
if (currentProject?.id) {
@@ -1275,6 +1296,16 @@ function AppInner() {
);
}
if (taskView === "stash-recovery") {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<StashRecoveryView />
</Suspense>
</PageErrorBoundary>
);
}
if (taskView === "insights") {
if (!settingsLoaded || !insightsEnabled) {
return null;
@@ -1463,6 +1494,7 @@ function AppInner() {
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
onOpenSchedules={openSchedulesWithNav}
onOpenGitManager={openGitManagerWithNav}
onOpenNodes={handleOpenNodesWithNav}
@@ -1612,6 +1644,7 @@ function AppInner() {
mailboxUnreadCount={mailboxUnreadCount}
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
chatHasUnreadResponse={chatHasUnreadResponse}
stashOrphanCount={stashOrphanCount}
onOpenGitManager={openGitManagerWithNav}
onOpenWorkflowSteps={openWorkflowStepsWithNav}
onOpenSchedules={openSchedulesWithNav}

View File

@@ -14,6 +14,7 @@ const EXPECTED_DOCUMENTED_VIEWS = new Set([
"ResearchView",
"EvalsView",
"TodoView",
"StashRecoveryView",
"SetupWizardModal",
"PluginManager",
"PiExtensionsManager",
@@ -32,6 +33,7 @@ const EXPECTED_APP_LEVEL_VIEWS = new Set([
"MemoryView",
"DevServerView",
"TodoView",
"StashRecoveryView",
]);
function extractLazyLoadedSection(agentsDoc: string): string {
@@ -62,11 +64,11 @@ describe("AGENTS lazy-loaded views inventory", () => {
const section = extractLazyLoadedSection(agentsDoc);
const countMatch = section.match(/These\s+(\d+)\s+views\s+are lazy-loaded/);
expect(countMatch).toBeTruthy();
expect(Number(countMatch?.[1])).toBe(15);
expect(Number(countMatch?.[1])).toBe(16);
const documentedViews = extractBacktickedNamesFromBullets(section);
expect(new Set(documentedViews)).toEqual(EXPECTED_DOCUMENTED_VIEWS);
expect(documentedViews).toHaveLength(15);
expect(documentedViews).toHaveLength(16);
expect(section).toContain("`ResearchView`");
expect(section).toContain("`TodoView`");

View File

@@ -188,6 +188,8 @@ export interface HeaderProps {
mailboxPendingApprovalCount?: number;
/** Whether chat has an unread assistant response */
chatHasUnreadResponse?: boolean;
/** Count of orphaned merger autostashes for stash recovery indicator. */
stashOrphanCount?: number;
onOpenSchedules?: () => void;
onOpenGitManager?: () => void;
onOpenNodes?: () => void;
@@ -257,6 +259,7 @@ export function Header({
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
stashOrphanCount = 0,
onOpenSchedules,
onOpenGitManager,
onOpenNodes,
@@ -1179,7 +1182,7 @@ export function Header({
<>
<button
ref={viewOverflowTriggerRef}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
className={`view-toggle-btn${["research", "skills", "insights", "memory", "dev-server", "devserver", "graph", "stash-recovery"].includes(view) || (experimentalFeatures?.evalsView && view === "evals") || (todosEnabled && todosOpen) || isPluginViewId(view) ? " active" : ""}`}
onClick={() => setIsViewOverflowOpen((prev) => !prev)}
title="More views"
aria-label="More views"
@@ -1210,6 +1213,20 @@ export function Header({
<span>Evals</span>
</button>
)}
<button
className={`view-toggle-overflow-item${view === "stash-recovery" ? " active" : ""}`}
onClick={() => {
onChangeView("stash-recovery");
setIsViewOverflowOpen(false);
}}
role="menuitem"
data-testid="view-overflow-stash-recovery"
>
<History size={14} />
<span>Stash Recovery</span>
{stashOrphanCount > 0 ? <span className="btn-badge">{stashOrphanCount}</span> : null}
</button>
{experimentalFeatures?.researchView && (
<button
className={`view-toggle-overflow-item${view === "research" ? " active" : ""}`}

View File

@@ -12,6 +12,7 @@ import {
Folder,
GitBranch,
Grid3X3,
History,
LayoutGrid,
Lightbulb,
Loader2,
@@ -55,6 +56,7 @@ export interface MobileNavBarProps {
mailboxUnreadCount?: number;
mailboxPendingApprovalCount?: number;
chatHasUnreadResponse?: boolean;
stashOrphanCount?: number;
onOpenGitManager?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenSchedules?: () => void;
@@ -120,6 +122,7 @@ export function MobileNavBar({
mailboxUnreadCount = 0,
mailboxPendingApprovalCount = 0,
chatHasUnreadResponse = false,
stashOrphanCount = 0,
onOpenGitManager,
onOpenWorkflowSteps,
onOpenSchedules,
@@ -235,6 +238,7 @@ export function MobileNavBar({
|| (todosOpen && todoViewEnabled)
|| (view === "skills" && !showSkillsTopLevel)
|| view === "graph"
|| view === "stash-recovery"
|| (isPluginViewId(view) && !topLevelPrimaryPluginViews.some((entry) => buildPluginTaskViewId(entry.pluginId, entry.view.viewId) === view));
return (
@@ -628,6 +632,17 @@ export function MobileNavBar({
<button
type="button"
className="mobile-more-item"
data-testid="mobile-more-item-stash-recovery"
onClick={() => handleMoreAction(() => onChangeView("stash-recovery"))}
>
<History />
<span>Stash Recovery</span>
{stashOrphanCount > 0 ? <span className="mobile-more-item-badge">{formatCount(stashOrphanCount)}</span> : null}
</button>
{experimentalFeatures?.researchView && (
<button
type="button"

View File

@@ -0,0 +1,101 @@
.stash-recovery-view {
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.stash-recovery-header {
display: flex;
align-items: center;
gap: var(--space-md);
}
.stash-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto auto;
gap: var(--space-sm);
align-items: center;
padding: var(--space-sm) 0;
border-bottom: var(--btn-border-width) solid var(--border);
}
.stash-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.stash-field-label {
display: none;
color: var(--text-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.stash-row-actions,
.stash-row-actions-danger {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.stash-action-btn {
white-space: nowrap;
}
.stash-status {
color: var(--text-muted);
grid-column: 1 / -1;
}
.stash-recovery-diff-modal {
max-width: 80vw;
}
.stash-recovery-diff-pre {
margin: 0;
padding: var(--space-md);
max-height: 50vh;
overflow: auto;
background: var(--surface);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 768px) {
.stash-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--space-md);
}
.stash-field-label {
display: inline;
}
.stash-row-actions,
.stash-row-actions-danger {
justify-content: stretch;
}
.stash-action-btn {
flex: 1;
min-height: var(--mobile-nav-height);
}
.stash-row-actions-danger .stash-action-btn {
width: 100%;
}
.stash-recovery-diff-modal {
width: 100%;
max-width: 100%;
}
}

View File

@@ -0,0 +1,144 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../hooks/useConfirm";
import "./StashRecoveryView.css";
type RecordItem = {
sha: string;
sourceTaskId: string | null;
createdAt: string | null;
classification: "subsumed" | "live" | "unknown";
changedPaths: string[];
};
type DiffResponse = {
diff: string;
truncated: boolean;
};
export function StashRecoveryView() {
const { confirm } = useConfirm();
const [records, setRecords] = useState<RecordItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [applyState, setApplyState] = useState<Record<string, string>>({});
const [diffState, setDiffState] = useState<{ sha: string; diff: string; truncated: boolean; loading: boolean; error: string | null } | null>(null);
const load = useCallback(async () => {
try {
setError(null);
const data = await api<{ records: RecordItem[] }>("/stash-recovery/orphans");
setRecords(data.records ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load orphans");
}
}, []);
useEffect(() => {
void load();
}, [load]);
const groups = useMemo(() => {
const map = new Map<string, RecordItem[]>();
for (const record of records) {
const key = record.sourceTaskId ?? "Unknown source";
const existing = map.get(key) ?? [];
existing.push(record);
map.set(key, existing);
}
return Array.from(map.entries());
}, [records]);
const handleApply = useCallback(async (sha: string) => {
const result = await api<{ ok: boolean; reason?: string; stderr?: string }>(`/stash-recovery/orphans/${sha}/apply`, { method: "POST" });
setApplyState((prev) => ({ ...prev, [sha]: result.ok ? "Applied" : result.stderr ?? result.reason ?? "Apply failed" }));
}, []);
const handleDrop = useCallback(async (sha: string) => {
const shouldDrop = await confirm({
title: "Drop orphaned stash?",
message: "This removes the stash entry permanently.",
confirmLabel: "Drop",
danger: true,
});
if (!shouldDrop) return;
await api(`/stash-recovery/orphans/${sha}/drop`, { method: "POST", body: JSON.stringify({ confirm: true }) });
await load();
}, [confirm, load]);
const handleInspectDiff = useCallback(async (sha: string) => {
setDiffState({ sha, diff: "", truncated: false, loading: true, error: null });
try {
const data = await api<DiffResponse>(`/stash-recovery/orphans/${sha}/diff`);
setDiffState({ sha, diff: data.diff ?? "", truncated: Boolean(data.truncated), loading: false, error: null });
} catch (err) {
setDiffState({ sha, diff: "", truncated: false, loading: false, error: err instanceof Error ? err.message : "Failed to load diff" });
}
}, []);
if (records.length === 0 && !error) {
return <div className="card stash-recovery-view"><p>No orphaned merger autostashes found.</p><button className="btn btn-sm" onClick={() => void load()}>Refresh</button></div>;
}
return (
<div className="card stash-recovery-view">
<div className="stash-recovery-header">
<h2>Stash Recovery</h2>
<span>{records.length} orphans</span>
<button className="btn btn-sm" onClick={() => void load()}>Refresh</button>
</div>
{error && <div className="form-error">{error}</div>}
{groups.map(([group, items]) => (
<section key={group}>
<h3>{group}</h3>
{items.map((item) => (
<div key={item.sha} className="stash-row">
<div className="stash-field">
<span className="stash-field-label">SHA</span>
<span>{item.sha.slice(0, 7)}</span>
</div>
<div className="stash-field">
<span className="stash-field-label">Classification</span>
<span>{item.classification}</span>
</div>
<div className="stash-field">
<span className="stash-field-label">Changed paths</span>
<span>{item.changedPaths.length} files</span>
</div>
<div className="stash-row-actions">
<button className="btn btn-sm stash-action-btn" onClick={() => void handleInspectDiff(item.sha)}>Inspect diff</button>
<button className="btn btn-sm stash-action-btn" onClick={() => void handleApply(item.sha)}>Apply</button>
</div>
<div className="stash-row-actions-danger">
<button className="btn btn-sm btn-danger stash-action-btn" onClick={() => void handleDrop(item.sha)}>Drop</button>
</div>
{applyState[item.sha] && <div className="stash-status">{applyState[item.sha]}</div>}
</div>
))}
</section>
))}
{diffState && (
<div className="modal-overlay open" onClick={() => setDiffState(null)}>
<div className="modal stash-recovery-diff-modal" role="dialog" aria-modal="true" aria-label={`Diff for ${diffState.sha}`} onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Diff for {diffState.sha.slice(0, 7)}</h3>
<button className="modal-close" onClick={() => setDiffState(null)} aria-label="Close diff dialog">
&times;
</button>
</div>
{diffState.loading && <p>Loading diff</p>}
{diffState.error && <div className="form-error">{diffState.error}</div>}
{!diffState.loading && !diffState.error && (
<>
<pre className="stash-recovery-diff-pre">{diffState.diff || "No diff output available."}</pre>
{diffState.truncated && <p className="stash-status">Diff output truncated.</p>}
</>
)}
<div className="modal-actions">
<button className="btn" onClick={() => setDiffState(null)}>Close</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StashRecoveryView } from "../StashRecoveryView";
const apiMock = vi.fn();
const confirmMock = vi.fn();
vi.mock("../../api", () => ({ api: (...args: unknown[]) => apiMock(...args) }));
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: confirmMock }) }));
describe("StashRecoveryView", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders empty state", async () => {
apiMock.mockResolvedValueOnce({ records: [] });
render(<StashRecoveryView />);
expect(await screen.findByText(/No orphaned merger autostashes found/i)).toBeInTheDocument();
});
it("renders grouped rows and apply", async () => {
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: "FN-1", createdAt: null, classification: "live", changedPaths: ["a"] }] });
apiMock.mockResolvedValueOnce({ ok: false, reason: "conflict", stderr: "conflict text" });
render(<StashRecoveryView />);
expect(await screen.findByText("FN-1")).toBeInTheDocument();
fireEvent.click(screen.getByText("Apply"));
await waitFor(() => expect(screen.getByText(/conflict text/i)).toBeInTheDocument());
});
it("opens inspect diff modal", async () => {
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: "FN-1", createdAt: null, classification: "live", changedPaths: ["a"] }] });
apiMock.mockResolvedValueOnce({ diff: "patch-content", truncated: false });
render(<StashRecoveryView />);
await screen.findByText("FN-1");
fireEvent.click(screen.getByText("Inspect diff"));
expect(await screen.findByText(/Diff for abcdef1/i)).toBeInTheDocument();
await waitFor(() => expect(screen.getByText("patch-content")).toBeInTheDocument());
});
it("drop requires confirmation", async () => {
apiMock.mockResolvedValueOnce({ records: [{ sha: "abcdef123", sourceTaskId: null, createdAt: null, classification: "live", changedPaths: [] }] });
confirmMock.mockResolvedValueOnce(false);
render(<StashRecoveryView />);
await screen.findByText("Unknown source");
fireEvent.click(screen.getByText("Drop"));
await waitFor(() => expect(confirmMock).toHaveBeenCalled());
expect(apiMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -5,7 +5,7 @@ import { getScopedItem, setScopedItem } from "../utils/projectStorage";
import { getPluginViewId, isPluginViewId, isPluginViewRegistered } from "../plugins/pluginViewRegistry";
export type ViewMode = "overview" | "project";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server";
export type BuiltInTaskView = "board" | "list" | "graph" | "agents" | "missions" | "chat" | "documents" | "research" | "evals" | "skills" | "mailbox" | "insights" | "memory" | "devserver" | "dev-server" | "stash-recovery";
export type PluginTaskView = `plugin:${string}:${string}`;
export type TaskView = BuiltInTaskView | PluginTaskView;
@@ -26,6 +26,7 @@ const BUILT_IN_TASK_VIEWS: readonly BuiltInTaskView[] = [
"memory",
"devserver",
"dev-server",
"stash-recovery",
];
function isBuiltInTaskView(value: string | null): value is BuiltInTaskView {

View File

@@ -0,0 +1,123 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import { request as REQUEST } from "../../test-request.js";
const engineMocks = vi.hoisted(() => ({
listAutostashOrphans: vi.fn(),
getAutostashDiff: vi.fn(),
applyAutostashBySha: vi.fn(),
dropAutostashBySha: vi.fn(),
notifyAutostashOrphans: vi.fn(),
}));
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
return {
...actual,
...engineMocks,
};
});
import { createApiRoutes } from "../../routes.js";
function createMockStore(): TaskStore {
return {
getRootDir: vi.fn(() => "/tmp/project"),
} as unknown as TaskStore;
}
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
describe("stash recovery routes", () => {
beforeEach(() => {
Object.values(engineMocks).forEach((mockFn) => mockFn.mockReset());
});
it("returns orphan records", async () => {
engineMocks.listAutostashOrphans.mockResolvedValue([
{
sha: "abcdef1",
ref: "stash@{0}",
label: "fusion-merger-autostash:FN-1:1",
sourceTaskId: "FN-1",
createdAt: null,
changedPaths: ["file.txt"],
classification: "live",
},
]);
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans");
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(res.body.records[0].sha).toBe("abcdef1");
});
it("returns diff + truncated flag", async () => {
engineMocks.getAutostashDiff.mockResolvedValue("diff text\n… (diff truncated)");
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans/abcdef1/diff");
expect(res.status).toBe(200);
expect(res.body.truncated).toBe(true);
});
it("applies stash with success and conflict responses", async () => {
engineMocks.applyAutostashBySha.mockResolvedValueOnce({ ok: true });
let res = await REQUEST(
buildApp(createMockStore()),
"POST",
"/api/stash-recovery/orphans/abcdef1/apply",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
engineMocks.applyAutostashBySha.mockResolvedValueOnce({ ok: false, reason: "conflict", stderr: "CONFLICT" });
res = await REQUEST(
buildApp(createMockStore()),
"POST",
"/api/stash-recovery/orphans/abcdef1/apply",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(false);
expect(res.body.reason).toBe("conflict");
});
it("requires confirm for drop", async () => {
const app = buildApp(createMockStore());
let res = await REQUEST(
app,
"POST",
"/api/stash-recovery/orphans/abcdef1/drop",
JSON.stringify({}),
{ "content-type": "application/json" },
);
expect(res.status).toBe(400);
engineMocks.dropAutostashBySha.mockResolvedValueOnce({ dropped: true });
res = await REQUEST(
app,
"POST",
"/api/stash-recovery/orphans/abcdef1/drop",
JSON.stringify({ confirm: true }),
{ "content-type": "application/json" },
);
expect(res.status).toBe(200);
expect(engineMocks.dropAutostashBySha).toHaveBeenCalledTimes(1);
});
it("rejects invalid sha before calling engine", async () => {
const res = await REQUEST(buildApp(createMockStore()), "GET", "/api/stash-recovery/orphans/not-valid/diff");
expect(res.status).toBe(400);
expect(engineMocks.getAutostashDiff).not.toHaveBeenCalled();
});
});

View File

@@ -8,6 +8,7 @@ import { createResearchRouter } from "../research-routes.js";
import { createTodoRouter } from "../todo-routes.js";
import { createDevServerRouter } from "../dev-server-routes.js";
import type { AiSessionStore } from "../ai-session-store.js";
import { createStashRecoveryRouter } from "./register-stash-recovery-routes.js";
interface IntegratedRoutersOptions {
router: Router;
@@ -36,6 +37,7 @@ export function registerIntegratedRouters({
router.use("/evals", createEvalsRouter(store));
router.use("/research", createResearchRouter(store));
router.use("/todos", createTodoRouter(store));
router.use("/stash-recovery", createStashRecoveryRouter(store));
}
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {

View File

@@ -0,0 +1,68 @@
import { Router, type Request, type Response } from "express";
import type { TaskStore } from "@fusion/core";
import {
applyAutostashBySha,
dropAutostashBySha,
getAutostashDiff,
listAutostashOrphans,
notifyAutostashOrphans,
} from "@fusion/engine";
import { badRequest } from "../api-error.js";
const SHA_RE = /^[0-9a-f]{7,40}$/;
function validateSha(sha: string): boolean {
return SHA_RE.test(sha);
}
function getRootDir(store: TaskStore): string {
return store.getRootDir();
}
export function createStashRecoveryRouter(store: TaskStore): Router {
const router = Router();
router.get("/orphans", async (_req: Request, res: Response) => {
const rootDir = getRootDir(store);
const records = await listAutostashOrphans(rootDir);
res.json({ count: records.length, records, rootDir });
});
router.get("/orphans/:sha/diff", async (req: Request, res: Response) => {
const sha = String(req.params.sha ?? "").trim();
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
const rootDir = getRootDir(store);
const diff = await getAutostashDiff(rootDir, sha);
const truncated = diff.includes("… (diff truncated)");
res.json({ sha, diff, truncated });
});
router.post("/orphans/:sha/apply", async (req: Request, res: Response) => {
const sha = String(req.params.sha ?? "").trim();
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
const rootDir = getRootDir(store);
const result = await applyAutostashBySha(rootDir, sha);
res.status(200).json(result);
});
router.post("/orphans/:sha/drop", async (req: Request, res: Response) => {
const sha = String(req.params.sha ?? "").trim();
if (!validateSha(sha)) throw badRequest("Invalid stash sha");
if (req.body?.confirm !== true) throw badRequest("confirm: true is required");
const rootDir = getRootDir(store);
const result = await dropAutostashBySha(rootDir, "stash-recovery", sha);
if (!result.dropped) {
res.status(200).json({ ok: false, reason: result.reason ?? "drop_failed" });
return;
}
res.status(200).json({ ok: true });
});
router.post("/refresh", async (_req: Request, res: Response) => {
const rootDir = getRootDir(store);
const records = await notifyAutostashOrphans(store, rootDir);
res.json({ count: records.length, records, rootDir });
});
return router;
}

View File

@@ -11,7 +11,7 @@ const qualityAppTests = [
"app/api/**/*.test.ts",
// Representative workflow/component coverage. Exhaustive modal/view suites
// stay available in the full `dashboard-app` project.
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,AuthTokenRecoveryDialog,Board,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,StashRecoveryView,TaskCard,TaskChangesTab,TaskComments,TaskDocumentsTab,TaskForm,ThemeSelectorSwatchContract,WorkflowResultsTab}.test.tsx",
// Hooks and utilities are fast, user-visible state/formatting behavior.
"app/context/**/*.test.tsx",
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
@@ -22,7 +22,7 @@ const qualityApiTests = [
// Critical HTTP/server behavior: auth, task/project/settings mutation,
// git/GitHub, agents, nodes, chat/files, realtime, and isolation guards.
"src/__tests__/{api-error,auth-middleware,auth-middleware-integration,chat-attachment-routes,chat-routes,file-service,github,github-webhooks,initialize,planning-flow-diagnostics-guardrail,project-routes,project-store-resolver,remote-access-routes,remote-auth,routes-agent-budget,routes-agent-keys,routes-agent-permissions,routes-agent-ratings,routes-agent-runs,routes-agent-soul-memory,routes-agents,routes-automation,routes-git,routes-github,routes-nodes,routes-settings,routes-tasks,server,server-static-assets,server-webhook,server.events,setup-routes,sse,sse-buffer,test-isolation-guard,update-check-route,websocket}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes}.test.ts",
"src/routes/__tests__/{custom-provider-routes,custom-providers,register-docker-node-routes,stash-recovery-routes}.test.ts",
];
export default defineConfig({