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

@@ -1100,6 +1100,31 @@ describe("memory-insights audit generation", () => {
expect(report.extraction.duplicateCount).toBe(2);
});
it("reuses persisted extraction state when explicit metadata is omitted", async () => {
const runAt = new Date().toISOString();
writeFileSync(
join(tempDir, MEMORY_WORKING_PATH),
"## Architecture\n\nDurable notes\n\n## Conventions\n\nConventions",
);
await processAndAuditInsightExtraction(tempDir, {
rawResponse: JSON.stringify({
summary: "Persisted extraction summary",
insights: [{ category: "pattern", content: "Persisted insight" }],
}),
stepSuccess: true,
runAt,
});
const report = await generateMemoryAudit(tempDir);
expect(report.extraction.runAt).toBe(runAt);
expect(report.extraction.success).toBe(true);
expect(report.extraction.summary).toBe("Persisted extraction summary");
expect(report.checks.find((c) => c.id === "recent-extraction")?.passed).toBe(true);
});
it("should include pruning outcome in report", async () => {
writeFileSync(join(tempDir, MEMORY_WORKING_PATH), "## Architecture\n\nNotes");

View File

@@ -81,6 +81,9 @@ export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
/** Path to memory audit report relative to project root. */
export const MEMORY_AUDIT_PATH = ".fusion/memory-audit.md";
/** Path to persisted memory audit state (latest extraction/pruning metadata). */
export const MEMORY_AUDIT_STATE_PATH = ".fusion/memory-audit-state.json";
/** Default cron schedule for insight extraction: daily at 2 AM. */
export const DEFAULT_INSIGHT_SCHEDULE = "0 2 * * *";
@@ -167,6 +170,17 @@ export interface MemoryAuditCheck {
details: string;
}
/** Persisted extraction metadata used by audits/routes. */
export interface MemoryExtractionMetadata {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
}
/** Result of a memory audit run. */
export interface MemoryAuditReport {
/** ISO-8601 timestamp of the audit. */
@@ -187,15 +201,7 @@ export interface MemoryAuditReport {
lastUpdated?: string;
};
/** Extraction metadata. */
extraction: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
};
extraction: MemoryExtractionMetadata;
/** Pruning operation outcome. */
pruning: {
applied: boolean;
@@ -210,6 +216,13 @@ export interface MemoryAuditReport {
health: "healthy" | "warning" | "issues";
}
/** Persisted state for audit continuity across requests. */
interface MemoryAuditState {
extraction?: MemoryExtractionMetadata;
pruning?: PruneOutcome;
updatedAt: string;
}
/** Input for processing an insight extraction run. */
export interface ProcessRunInput {
/** Raw AI response text from the insight extraction step. */
@@ -326,6 +339,81 @@ export async function writeMemoryAudit(rootDir: string, content: string): Promis
await writeFile(filePath, content, "utf-8");
}
/**
* Read persisted memory audit state (`memory-audit-state.json`).
*
* Returns `null` when no prior state exists.
*/
async function readMemoryAuditState(rootDir: string): Promise<MemoryAuditState | null> {
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
if (!existsSync(filePath)) {
return null;
}
try {
const raw = await readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MemoryAuditState>;
const extraction = isValidExtractionMetadata(parsed.extraction) ? parsed.extraction : undefined;
const pruning = isValidPruneOutcome(parsed.pruning) ? parsed.pruning : undefined;
return {
extraction,
pruning,
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim()
? parsed.updatedAt
: new Date().toISOString(),
};
} catch {
return null;
}
}
/**
* Persist memory audit state (`memory-audit-state.json`).
*/
async function writeMemoryAuditState(rootDir: string, state: MemoryAuditState): Promise<void> {
const filePath = join(rootDir, MEMORY_AUDIT_STATE_PATH);
const dir = join(rootDir, ".fusion");
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, JSON.stringify(state, null, 2), "utf-8");
}
function isValidExtractionMetadata(value: unknown): value is MemoryExtractionMetadata {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<MemoryExtractionMetadata>;
return (
typeof candidate.runAt === "string" &&
typeof candidate.success === "boolean" &&
typeof candidate.insightCount === "number" &&
typeof candidate.duplicateCount === "number" &&
typeof candidate.skippedCount === "number" &&
typeof candidate.summary === "string" &&
(candidate.error === undefined || typeof candidate.error === "string")
);
}
function isValidPruneOutcome(value: unknown): value is PruneOutcome {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<PruneOutcome>;
return (
typeof candidate.applied === "boolean" &&
typeof candidate.reason === "string" &&
typeof candidate.sizeDelta === "number" &&
typeof candidate.originalSize === "number" &&
typeof candidate.newSize === "number"
);
}
// ── AI Prompt Construction ───────────────────────────────────────────
/**
@@ -1103,20 +1191,19 @@ function countInsightsInMarkdown(markdown: string): number {
*/
export async function generateMemoryAudit(
rootDir: string,
lastExtraction?: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
},
lastExtraction?: MemoryExtractionMetadata,
pruningOutcome?: PruneOutcome,
): Promise<MemoryAuditReport> {
const checks: MemoryAuditCheck[] = [];
const now = new Date().toISOString();
const persistedState =
lastExtraction === undefined || pruningOutcome === undefined
? await readMemoryAuditState(rootDir)
: null;
const effectiveExtraction = lastExtraction ?? persistedState?.extraction;
const effectivePruning = pruningOutcome ?? persistedState?.pruning;
// ── Check 1: Working memory file presence ──────────────────────────
const workingMemoryPath = join(rootDir, MEMORY_WORKING_PATH);
const workingMemoryExists = existsSync(workingMemoryPath);
@@ -1261,26 +1348,26 @@ export async function generateMemoryAudit(
}
// ── Check 6: Recent extraction activity ───────────────────────────
if (lastExtraction) {
const extractionAge = Date.now() - new Date(lastExtraction.runAt).getTime();
if (effectiveExtraction) {
const extractionAge = Date.now() - new Date(effectiveExtraction.runAt).getTime();
const oneWeekMs = 7 * 24 * 60 * 60 * 1000;
checks.push({
id: "recent-extraction",
name: "Recent extraction activity",
passed: lastExtraction.success && extractionAge < oneWeekMs,
details: lastExtraction.success
? `Last successful extraction ${formatTimeAgo(lastExtraction.runAt)} (${lastExtraction.insightCount} insights, ${lastExtraction.duplicateCount} duplicates skipped)`
: `Last extraction failed: ${lastExtraction.error || "Unknown error"}`,
passed: effectiveExtraction.success && extractionAge < oneWeekMs,
details: effectiveExtraction.success
? `Last successful extraction ${formatTimeAgo(effectiveExtraction.runAt)} (${effectiveExtraction.insightCount} insights, ${effectiveExtraction.duplicateCount} duplicates skipped)`
: `Last extraction failed: ${effectiveExtraction.error || "Unknown error"}`,
});
// Check 7: Extraction summary quality
checks.push({
id: "extraction-summary",
name: "Extraction produces meaningful summaries",
passed: lastExtraction.success && lastExtraction.summary.length > 10,
details: lastExtraction.success
? `Summary: "${lastExtraction.summary.slice(0, 100)}${lastExtraction.summary.length > 100 ? "..." : ""}"`
passed: effectiveExtraction.success && effectiveExtraction.summary.length > 10,
details: effectiveExtraction.success
? `Summary: "${effectiveExtraction.summary.slice(0, 100)}${effectiveExtraction.summary.length > 100 ? "..." : ""}"`
: "No meaningful summary available",
});
} else {
@@ -1293,14 +1380,14 @@ export async function generateMemoryAudit(
}
// ── Check 8: Pruning outcome ──────────────────────────────────────
if (pruningOutcome) {
if (effectivePruning) {
checks.push({
id: "pruning-applied",
name: "Memory pruning outcome",
passed: pruningOutcome.applied,
details: pruningOutcome.applied
? `Pruning applied: ${pruningOutcome.originalSize}${pruningOutcome.newSize} chars (${pruningOutcome.sizeDelta >= 0 ? "+" : ""}${pruningOutcome.sizeDelta} chars)`
: `Pruning skipped: ${pruningOutcome.reason}`,
passed: effectivePruning.applied,
details: effectivePruning.applied
? `Pruning applied: ${effectivePruning.originalSize}${effectivePruning.newSize} chars (${effectivePruning.sizeDelta >= 0 ? "+" : ""}${effectivePruning.sizeDelta} chars)`
: `Pruning skipped: ${effectivePruning.reason}`,
});
}
@@ -1332,15 +1419,15 @@ export async function generateMemoryAudit(
categories: categoryCounts,
lastUpdated,
},
extraction: lastExtraction
extraction: effectiveExtraction
? {
runAt: lastExtraction.runAt,
success: lastExtraction.success,
insightCount: lastExtraction.insightCount,
duplicateCount: lastExtraction.duplicateCount,
skippedCount: lastExtraction.skippedCount,
summary: lastExtraction.summary,
error: lastExtraction.error,
runAt: effectiveExtraction.runAt,
success: effectiveExtraction.success,
insightCount: effectiveExtraction.insightCount,
duplicateCount: effectiveExtraction.duplicateCount,
skippedCount: effectiveExtraction.skippedCount,
summary: effectiveExtraction.summary,
error: effectiveExtraction.error,
}
: {
runAt: "",
@@ -1350,7 +1437,7 @@ export async function generateMemoryAudit(
skippedCount: 0,
summary: "No extraction runs recorded",
},
pruning: pruningOutcome ?? {
pruning: effectivePruning ?? {
applied: false,
reason: "No pruning run recorded",
sizeDelta: 0,
@@ -1536,15 +1623,7 @@ export async function processAndAuditInsightExtraction(
input: ProcessRunInput,
): Promise<MemoryAuditReport> {
// Track extraction info for the audit
let extractionInfo: {
runAt: string;
success: boolean;
insightCount: number;
duplicateCount: number;
skippedCount: number;
summary: string;
error?: string;
};
let extractionInfo: MemoryExtractionMetadata;
let pruneOutcome: PruneOutcome | undefined;
try {
@@ -1585,6 +1664,19 @@ export async function processAndAuditInsightExtraction(
};
}
// Persist latest extraction/pruning state for future audit reads (best-effort)
try {
await writeMemoryAuditState(rootDir, {
extraction: extractionInfo,
pruning: pruneOutcome,
updatedAt: new Date().toISOString(),
});
} catch (err) {
console.error(
`[memory-audit] Failed to persist audit state: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Generate the audit report with pruning info
const auditReport = await generateMemoryAudit(rootDir, extractionInfo, pruneOutcome);

View File

@@ -175,6 +175,102 @@
align-items: center;
}
.memory-empty-extract-button {
margin-top: var(--space-md);
}
.memory-stat-value--updated {
font-size: var(--space-lg);
}
.memory-capability-row {
display: flex;
gap: var(--space-xs);
margin-top: var(--space-sm);
flex-wrap: wrap;
}
.memory-emphasis-text {
font-weight: 500;
}
.memory-health-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-md);
}
.memory-health-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.memory-health-label {
font-size: var(--space-md);
color: var(--text-muted);
text-transform: uppercase;
margin-bottom: var(--space-xs);
}
.memory-health-detail {
font-size: var(--space-md);
color: var(--text-muted);
}
.memory-health-section {
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--border);
}
.memory-status-text--success {
color: var(--color-success);
}
.memory-status-text--error {
color: var(--color-error);
}
.memory-status-text--warning {
color: var(--color-warning);
}
.memory-status-text--muted {
color: var(--text-muted);
}
.memory-audit-check-content {
flex: 1;
}
.memory-settings-note {
margin-top: var(--space-lg);
font-size: var(--space-md);
color: var(--text-muted);
display: flex;
align-items: center;
gap: var(--space-xs);
flex-wrap: wrap;
}
.memory-settings-note-button {
background: none;
border: none;
color: inherit;
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
.memory-settings-note-button:focus-visible {
outline: none;
border-radius: var(--radius-sm);
box-shadow: var(--focus-ring-strong);
}
.memory-empty-state {
color: var(--text-muted);
padding: var(--space-2xl);
@@ -279,6 +375,10 @@
padding: var(--space-sm) 0;
}
.memory-flex-spacer {
flex: 1;
}
.memory-qmd-card {
border-style: solid;
}
@@ -346,6 +446,10 @@
padding: var(--space-xs) 0;
}
.memory-health-grid {
grid-template-columns: 1fr;
}
.memory-config-section {
margin-top: var(--space-md);
padding-top: var(--space-md);

View File

@@ -462,7 +462,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
<div className="memory-action-bar">
<span className="memory-char-count">{selectedFileContent.length} characters</span>
<div style={{ flex: 1 }} />
<div className="memory-flex-spacer" />
{isWritable && selectedFileContent.length > 0 && (
<button
type="button"
@@ -681,10 +681,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
</p>
<button
type="button"
className="btn btn-primary btn-sm"
className="btn btn-primary btn-sm memory-empty-extract-button"
onClick={handleExtractInsights}
disabled={extracting}
style={{ marginTop: "var(--space-md)" }}
>
{extracting ? (
<>
@@ -710,7 +709,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
</div>
{lastUpdated && (
<div className="memory-stat-card">
<div className="memory-stat-value" style={{ fontSize: "16px" }}>{lastUpdated}</div>
<div className="memory-stat-value memory-stat-value--updated">{lastUpdated}</div>
<div className="memory-stat-label">Last Updated</div>
</div>
)}
@@ -819,7 +818,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
<span className="memory-char-count">Checking qmd availability</span>
</div>
)}
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
<div className="memory-capability-row">
{backendStatus?.capabilities?.readable && (
<span className="memory-capability-badge">Readable</span>
)}
@@ -888,9 +887,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
<div className="memory-engine-card">
<h3>Current Backend</h3>
<div className="memory-engine-status">
<span style={{ fontWeight: 500 }}>{getBackendDisplayName(backendStatus?.currentBackend ?? "unknown")}</span>
<span className="memory-emphasis-text">{getBackendDisplayName(backendStatus?.currentBackend ?? "unknown")}</span>
</div>
<div style={{ display: "flex", gap: "var(--space-xs)", marginTop: "var(--space-sm)", flexWrap: "wrap" }}>
<div className="memory-capability-row">
{backendStatus?.capabilities?.readable && (
<span className="memory-capability-badge">Readable</span>
)}
@@ -909,63 +908,55 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
{/* Health Status Card */}
{auditReport && (
<div className="memory-engine-card">
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "var(--space-md)" }}>
<div className="memory-health-header">
<h3>Health Status</h3>
<span className={`memory-health-badge memory-health-badge--${auditReport.health}`}>
{getHealthBadgeText(auditReport.health)}
</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--space-md)" }}>
<div className="memory-health-grid">
<div>
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
Working Memory
</div>
<div style={{ fontWeight: 500 }}>{auditReport.workingMemory.size} chars</div>
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
<div className="memory-health-label">Working Memory</div>
<div className="memory-emphasis-text">{auditReport.workingMemory.size} chars</div>
<div className="memory-health-detail">
{auditReport.workingMemory.sectionCount} sections
</div>
</div>
<div>
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
Insights Memory
</div>
<div style={{ fontWeight: 500 }}>{auditReport.insightsMemory.size} chars</div>
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
<div className="memory-health-label">Insights Memory</div>
<div className="memory-emphasis-text">{auditReport.insightsMemory.size} chars</div>
<div className="memory-health-detail">
{auditReport.insightsMemory.insightCount} insights
</div>
</div>
</div>
<div style={{ marginTop: "var(--space-md)", paddingTop: "var(--space-md)", borderTop: "1px solid var(--border)" }}>
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
Last Extraction
</div>
<div style={{ fontWeight: 500 }}>
<div className="memory-health-section">
<div className="memory-health-label">Last Extraction</div>
<div className="memory-emphasis-text">
{auditReport.extraction.success ? (
<span style={{ color: "var(--color-success)" }}>Success</span>
<span className="memory-status-text memory-status-text--success">Success</span>
) : (
<span style={{ color: "var(--color-error)" }}>Failed</span>
<span className="memory-status-text memory-status-text--error">Failed</span>
)}
</div>
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
<div className="memory-health-detail">
{auditReport.extraction.summary || `${auditReport.extraction.insightCount} insights extracted`}
</div>
</div>
<div style={{ marginTop: "var(--space-md)", paddingTop: "var(--space-md)", borderTop: "1px solid var(--border)" }}>
<div style={{ fontSize: "12px", color: "var(--text-muted)", textTransform: "uppercase", marginBottom: "var(--space-xs)" }}>
Pruning
</div>
<div style={{ fontWeight: 500 }}>
<div className="memory-health-section">
<div className="memory-health-label">Pruning</div>
<div className="memory-emphasis-text">
{auditReport.pruning.applied ? (
<span style={{ color: "var(--color-warning)" }}>Applied</span>
<span className="memory-status-text memory-status-text--warning">Applied</span>
) : (
<span style={{ color: "var(--text-muted)" }}>Not needed</span>
<span className="memory-status-text memory-status-text--muted">Not needed</span>
)}
</div>
{auditReport.pruning.applied && (
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
<div className="memory-health-detail">
{auditReport.pruning.reason}
</div>
)}
@@ -983,9 +974,9 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
<span className={check.passed ? "memory-audit-check-passed" : "memory-audit-check-failed"}>
{check.passed ? "✓" : "✗"}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500 }}>{check.name}</div>
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>{check.details}</div>
<div className="memory-audit-check-content">
<div className="memory-emphasis-text">{check.name}</div>
<div className="memory-health-detail">{check.details}</div>
</div>
</div>
))}
@@ -1005,10 +996,11 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
</div>
{/* Note about Settings */}
<div style={{ marginTop: "var(--space-lg)", fontSize: "13px", color: "var(--text-muted)" }}>
Note: Change backend type in{' '}
<span
style={{ cursor: "pointer", textDecoration: "underline" }}
<div className="memory-settings-note">
<span>Note: Change backend type in</span>
<button
type="button"
className="memory-settings-note-button"
onClick={() => {
// This would open the settings modal with memory section focused
// For now, just add a toast hint
@@ -1016,7 +1008,7 @@ export function MemoryView({ projectId, addToast }: MemoryViewProps) {
}}
>
Settings Memory
</span>
</button>
</div>
</>
)}

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