fix(FN-2545): persist memory audit extraction state and polish MemoryView

- Persist extract-to-audit continuity state in memory insights so extraction progress survives follow-up runs
- Add core and dashboard route tests that cover audit extraction flow and prevent regressions
- Restore lint/typecheck baseline by removing obsolete planning subtask route wiring from dashboard routes
- Apply MemoryView UX refinements with updated component logic and dedicated styling adjustments
This commit is contained in:
Fusion
2026-04-26 02:15:54 -07:00
committed by gsxdsm
parent 785b557aaf
commit 7527a7ddaf
7 changed files with 335 additions and 152 deletions

View File

@@ -14118,6 +14118,31 @@ describe("GET /api/memory/audit", () => {
expect(res.body.workingMemory).toHaveProperty("size");
expect(res.body.workingMemory).toHaveProperty("sectionCount");
});
it("preserves extraction metadata across extract then audit requests", async () => {
writeFileSync(
join(rootDir, ".fusion", "memory", "MEMORY.md"),
"## Architecture\n\nDurable architecture\n\n## Conventions\n\nDurable conventions\n\n## Pitfalls\n\nDurable pitfalls",
);
const extractRes = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(extractRes.status).toBe(200);
expect(extractRes.body.success).toBe(true);
const auditRes = await GET(buildApp(), "/api/memory/audit");
expect(auditRes.status).toBe(200);
expect(auditRes.body.extraction.runAt).toBeTruthy();
expect(auditRes.body.extraction.summary).not.toBe("No extraction runs recorded");
expect(auditRes.body.checks.find((check: { id: string; details: string }) => check.id === "recent-extraction")?.details).not.toContain("No extraction runs recorded");
});
});
describe("GET /api/memory/stats", () => {

View File

@@ -2147,61 +2147,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const heartbeatMonitor = options?.heartbeatMonitor;
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
const REMOTE_MIN_TTL_MS = 60_000;
const REMOTE_MAX_TTL_MS = 86_400_000;
const remoteShortLivedTokens = new Map<string, { expiresAt: number }>();
function generateRemoteToken(): string {
return `rtok_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
}
function maskRemoteToken(token: string): string {
if (token.length <= 8) return "********";
return `${token.slice(0, 4)}${token.slice(-4)}`;
}
async function ensurePersistentRemoteToken(scopedStore: TaskStore): Promise<string> {
const settings = await scopedStore.getSettings();
const existing = typeof settings.remotePersistentToken === "string" ? settings.remotePersistentToken : "";
if (existing) return existing;
const token = generateRemoteToken();
await scopedStore.updateSettings({ remotePersistentToken: token });
return token;
}
function resolveRemoteOrigin(req: Request): string {
const protocol = req.protocol || "http";
const hostHeader = req.get("host") ?? "127.0.0.1:4040";
return `${protocol}://${hostHeader}`;
}
async function buildRemoteUrlForTokenType(
scopedStore: TaskStore,
req: Request,
tokenType: "persistent" | "short-lived",
ttlMs?: number,
): Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
const baseUrl = new URL(resolveRemoteOrigin(req));
let token: string;
let expiresAt: string | null = null;
if (tokenType === "short-lived") {
const ttl = Math.floor(Number(ttlMs ?? 900_000));
if (!Number.isFinite(ttl) || ttl < REMOTE_MIN_TTL_MS || ttl > REMOTE_MAX_TTL_MS) {
throw new ApiError(400, "Short-lived token ttlMs out of range", { code: "INVALID_TTL" });
}
token = generateRemoteToken();
const expiryMs = Date.now() + ttl;
remoteShortLivedTokens.set(token, { expiresAt: expiryMs });
expiresAt = new Date(expiryMs).toISOString();
} else {
token = await ensurePersistentRemoteToken(scopedStore);
}
baseUrl.searchParams.set("token", token);
return { url: baseUrl.toString(), tokenType, expiresAt };
}
/**
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
* Returns false when the monitor's rootDir is set and differs from the store's root.

View File

@@ -14,7 +14,7 @@ interface PlanningSubtaskRouteDeps {
export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: PlanningSubtaskRouteDeps): void {
const { router, getProjectContext, planningLogger, rethrowAsApiError } = ctx;
const { store, aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
const { aiSessionStore, checkSessionLock, parseLastEventId, replayBufferedSSE } = deps;
// ── Planning Mode Routes ──────────────────────────────────────────────────
// UTILITY PATH: Planning and subtask session routes are on a separate control-plane lane.
@@ -185,7 +185,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
// Fetch parent task to inherit model settings if parentTaskId is provided
let parentTask: Awaited<ReturnType<typeof store.getTask>> | undefined;
let parentTask: Awaited<ReturnType<TaskStore["getTask"]>> | undefined;
if (typeof parentTaskId === "string" && parentTaskId.trim()) {
try {
parentTask = await scopedStore.getTask(parentTaskId);
@@ -195,7 +195,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
}
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
for (const item of subtasks) {
@@ -912,7 +912,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
}
}
const createdTasks = [] as Awaited<ReturnType<typeof store.createTask>>[];
const createdTasks = [] as Awaited<ReturnType<TaskStore["createTask"]>>[];
const tempIdToTaskId = new Map<string, string>();
// Create tasks