Expose database backup inventory and schedule execution evidence while keeping scheduled backups due after unchanged settings saves. - Display backup files, empty and error states, and automatic schedule details in Settings. - Reconcile the shared backup routine at engine startup and retain its next run when settings are unchanged. - Add API, localization, documentation, and regression coverage for backup status and scheduling. Files changed: .changeset/fn-9040-database-backups-visibility.md | 7 ++ docs/dashboard-guide.md | 2 +- docs/settings-reference.md | 4 + .../src/__tests__/backup-schedule-status.test.ts | 124 +++++++++++++++++++++ .../core/src/automation/global-routine-store.ts | 31 +++++- packages/core/src/backup/backup.ts | 95 ++++++++++++++-- packages/core/src/index.gate.ts | 4 +- packages/core/src/index.ts | 4 +- .../app/api/agents/agent-import-generation.ts | 14 +++ packages/dashboard/app/api/legacy.ts | 1 + .../dashboard/app/components/SettingsModal.tsx | 12 +- .../__tests__/SettingsModal.test-harness.tsx | 7 +- .../__tests__/SettingsModalNodeRouting.test.tsx | 2 +- .../components/__tests__/settings-mobile.test.tsx | 2 +- .../settings/sections/DatabaseBackupsSection.css | 24 ++++ .../settings/sections/DatabaseBackupsSection.tsx | 67 ++++++----- .../__tests__/DatabaseBackupsSection.test.tsx | 45 ++++++++ .../register-system-maintenance-routes.test.ts | 47 +++++++- .../routes/register-system-maintenance-routes.ts | 46 ++++++-- .../src/__tests__/in-process-runtime.pg.test.ts | 15 ++- packages/engine/src/runtimes/in-process-runtime.ts | 13 +++ packages/i18n/locales/en/app.json | 17 ++- packages/i18n/locales/es/app.json | 17 ++- packages/i18n/locales/fr/app.json | 17 ++- packages/i18n/locales/ko/app.json | 17 ++- packages/i18n/locales/pt-BR/app.json | 17 ++- packages/i18n/locales/zh-CN/app.json | 17 ++- packages/i18n/locales/zh-TW/app.json | 17 ++- packages/i18n/src/resources.d.ts | 28 ++++- 29 files changed, 623 insertions(+), 90 deletions(-) Fusion-Task-Id: FN-9040 Fusion-Task-Lineage: 29a803a2-0784-4a99-b7cb-2f67d105254b Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
307 lines
14 KiB
TypeScript
307 lines
14 KiB
TypeScript
/*
|
|
FNXC:PostgresRuntimeComposition 2026-07-14-18:49:
|
|
The production InProcessRuntime must compose one owned PostgreSQL backend across TaskStore, central claims, and missions, then release that backend exactly once. This real-database lifecycle test guards the wiring seam that component-only tests cannot cover.
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { expect, it, vi } from "vitest";
|
|
import {
|
|
createTaskStoreForTest,
|
|
pgDescribe,
|
|
} from "../../../core/src/__test-utils__/pg-test-harness.js";
|
|
|
|
const lifecycle = vi.hoisted(() => ({
|
|
shutdownCalls: 0,
|
|
secretsStore: { listEnvExportable: vi.fn() },
|
|
secretsStoreFailure: undefined as Error | undefined,
|
|
secretsStoreGetter: undefined as ReturnType<typeof vi.spyOn> | undefined,
|
|
}));
|
|
|
|
vi.mock("@fusion/core", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@fusion/core")>();
|
|
return {
|
|
...actual,
|
|
syncBackupRoutine: vi.fn(actual.syncBackupRoutine),
|
|
createTaskStoreForBackend: async (
|
|
options: Parameters<typeof actual.createTaskStoreForBackend>[0],
|
|
) => {
|
|
const boot = await actual.createTaskStoreForBackend(options);
|
|
const shutdown = boot.shutdown;
|
|
lifecycle.secretsStoreGetter = vi.spyOn(boot.taskStore, "getSecretsStore");
|
|
if (lifecycle.secretsStoreFailure) {
|
|
lifecycle.secretsStoreGetter.mockRejectedValue(lifecycle.secretsStoreFailure);
|
|
} else {
|
|
lifecycle.secretsStoreGetter.mockResolvedValue(lifecycle.secretsStore as any);
|
|
}
|
|
return {
|
|
...boot,
|
|
shutdown: async () => {
|
|
lifecycle.shutdownCalls += 1;
|
|
await shutdown();
|
|
},
|
|
};
|
|
},
|
|
};
|
|
});
|
|
|
|
import { CentralCore, listRecall, syncBackupRoutine, type AsyncCentralClaimStore, type RecallCaptureWriterWithTestDrain } from "@fusion/core";
|
|
import { InProcessRuntime } from "../runtimes/in-process-runtime.js";
|
|
|
|
pgDescribe("InProcessRuntime PostgreSQL composition", () => {
|
|
it("shares its PostgreSQL layer with claims and missions and shuts it down once", async () => {
|
|
/*
|
|
FNXC:PostgresRuntimeComposition 2026-07-14-21:33:
|
|
Runtime composition coverage must use the controlled PostgreSQL harness so availability gating and database administration share the repository's bounded asynchronous lifecycle. Runtime and central connections must close in a finally block before the harness drops the database, including when an assertion fails early.
|
|
*/
|
|
lifecycle.shutdownCalls = 0;
|
|
vi.mocked(syncBackupRoutine).mockClear();
|
|
lifecycle.secretsStore.listEnvExportable.mockReset();
|
|
lifecycle.secretsStoreFailure = undefined;
|
|
lifecycle.secretsStoreGetter = undefined;
|
|
const harness = await createTaskStoreForTest({ prefix: "fusion_runtime" });
|
|
const priorDatabaseUrl = process.env.DATABASE_URL;
|
|
let projectDir = "";
|
|
let globalDir = "";
|
|
let central: CentralCore | undefined;
|
|
let runtime: InProcessRuntime | undefined;
|
|
let failedRuntime: InProcessRuntime | undefined;
|
|
|
|
try {
|
|
projectDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-project-"));
|
|
globalDir = await mkdtemp(join(tmpdir(), "fusion-runtime-pg-global-"));
|
|
execFileSync("git", ["init", "-q", projectDir], { stdio: "pipe" });
|
|
await writeFile(join(projectDir, ".gitignore"), ".secrets.env\n");
|
|
await writeFile(join(projectDir, "README.md"), "runtime composition fixture\n");
|
|
execFileSync("git", ["config", "user.email", "runtime-test@example.invalid"], { cwd: projectDir, stdio: "pipe" });
|
|
execFileSync("git", ["config", "user.name", "Fusion Runtime Test"], { cwd: projectDir, stdio: "pipe" });
|
|
execFileSync("git", ["add", "."], { cwd: projectDir, stdio: "pipe" });
|
|
execFileSync("git", ["commit", "-qm", "initialize runtime composition fixture"], { cwd: projectDir, stdio: "pipe" });
|
|
process.env.DATABASE_URL = harness.testUrl;
|
|
|
|
central = new CentralCore(globalDir);
|
|
runtime = new InProcessRuntime({
|
|
projectId: "runtime-composition",
|
|
workingDirectory: projectDir,
|
|
isolationMode: "in-process",
|
|
maxConcurrent: 1,
|
|
maxWorktrees: 1,
|
|
}, central);
|
|
runtime.on("error", () => undefined);
|
|
|
|
await runtime.start();
|
|
const taskStore = runtime.getTaskStore();
|
|
const layer = taskStore.getAsyncLayer();
|
|
expect(runtime.getStatus()).toBe("active");
|
|
expect(taskStore.isBackendMode()).toBe(true);
|
|
/*
|
|
FNXC:SettingsBackups 2026-08-13-23:51:
|
|
A routine row only exists after the engine reconciles persisted backup settings. Assert the
|
|
production startup path calls the core synchronizer with its initialized store before the
|
|
scheduler gets its immediate execution tick.
|
|
*/
|
|
expect(syncBackupRoutine).toHaveBeenCalledOnce();
|
|
expect(syncBackupRoutine).toHaveBeenCalledWith(
|
|
(runtime as unknown as { routineStore?: unknown }).routineStore,
|
|
expect.objectContaining({ autoBackupEnabled: expect.anything() }),
|
|
);
|
|
expect(layer?.projectId).toBe("runtime-composition");
|
|
expect(runtime.getMissionExecutionLoop()).toBeDefined();
|
|
const runtimeInternals = runtime as unknown as {
|
|
usageLimitPauser?: unknown;
|
|
triageProcessor?: { options?: { usageLimitPauser?: unknown } };
|
|
};
|
|
expect(runtimeInternals.usageLimitPauser).toBeDefined();
|
|
expect(runtimeInternals.triageProcessor?.options?.usageLimitPauser)
|
|
.toBe(runtimeInternals.usageLimitPauser);
|
|
|
|
/*
|
|
FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:30:
|
|
Production composition resolves the project store once and gives the exact instance to
|
|
both fresh-worktree consumers. Their existing acquisition coverage verifies the writer;
|
|
this seam test prevents an omitted runtime dependency from silently becoming no-store.
|
|
*/
|
|
const secretsStore = await lifecycle.secretsStoreGetter?.mock.results[0]?.value;
|
|
const runtimeConsumers = runtime as unknown as {
|
|
executor?: { options?: { secretsStore?: unknown; agentStore?: unknown } };
|
|
heartbeatMonitor?: { secretsStore?: unknown; configStore?: unknown };
|
|
scheduler?: { options?: { agentStore?: unknown } };
|
|
triageProcessor?: { options?: { agentStore?: unknown } };
|
|
selfHealingManager?: { options?: { agentStore?: unknown } };
|
|
};
|
|
expect(lifecycle.secretsStoreGetter).toHaveBeenCalled();
|
|
expect(runtimeConsumers.executor?.options?.secretsStore).toBe(secretsStore);
|
|
expect(runtimeConsumers.heartbeatMonitor?.secretsStore).toBe(secretsStore);
|
|
|
|
/*
|
|
FNXC:WorkflowAgentRouting 2026-08-07-22:39:
|
|
Every runtime consumer that resolves a permanent workflow principal must receive the ONE
|
|
long-lived engine AgentStore — asserted across all of them, not only the consumer that
|
|
regressed. FN-8764 gave the executor a fail-closed role-routing gate keyed on
|
|
`options.agentStore` but never wired that option, so a store the runtime had already built
|
|
was simply absent at the seam: every role-classified node (execute / step-execute / review /
|
|
merge) failed closed, the executor's `workflow-principal-*` branch swallowed it as a
|
|
recoverable hold, and the board deadlocked with no log, audit, or task error. The invariant
|
|
is the shared instance at every seam; an undefined here is the deadlock.
|
|
*/
|
|
const runtimeAgentStore = runtime.getAgentStore();
|
|
expect(runtimeAgentStore).toBeDefined();
|
|
expect(runtimeConsumers.executor?.options?.agentStore).toBe(runtimeAgentStore);
|
|
expect(runtimeConsumers.scheduler?.options?.agentStore).toBe(runtimeAgentStore);
|
|
expect(runtimeConsumers.triageProcessor?.options?.agentStore).toBe(runtimeAgentStore);
|
|
expect(runtimeConsumers.selfHealingManager?.options?.agentStore).toBe(runtimeAgentStore);
|
|
expect(runtimeConsumers.heartbeatMonitor?.configStore).toBe(runtimeAgentStore);
|
|
|
|
/*
|
|
FNXC:SecretsEnvRuntimeWiring 2026-08-05-21:58:
|
|
Production coverage must invoke the runtime-created executor and heartbeat monitor, not
|
|
extract their dependency into a helper call. Each consumer owns a distinct fresh-worktree
|
|
path; both must materialize the ignored file and record a redacted write audit.
|
|
*/
|
|
await taskStore.updateSettings({
|
|
testMode: true,
|
|
secretsEnv: { enabled: true, filename: ".secrets.env" },
|
|
});
|
|
lifecycle.secretsStore.listEnvExportable.mockResolvedValue([
|
|
{
|
|
id: "runtime-secret",
|
|
key: "runtime-key",
|
|
exportKey: "RUNTIME_SECRET",
|
|
scope: "project",
|
|
plaintextValue: "runtime-test-value",
|
|
},
|
|
]);
|
|
const assertConsumerMaterializedSecretsEnv = async (taskId: string) => {
|
|
const task = await taskStore.getTask(taskId);
|
|
expect(task?.worktree).toEqual(expect.any(String));
|
|
const exportedKeys = (await readFile(join(task!.worktree!, ".secrets.env"), "utf8"))
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => line.split("=", 1)[0]);
|
|
expect(exportedKeys).toContain("RUNTIME_SECRET");
|
|
const auditEvents = await taskStore.getRunAuditEventsAsync();
|
|
const writeEvent = auditEvents.find((event) =>
|
|
event.target === taskId && event.mutationType === "secret:env-write",
|
|
);
|
|
expect(writeEvent?.metadata).toMatchObject({ keyCount: 1, fingerprint: expect.any(String) });
|
|
expect(auditEvents.some((event) =>
|
|
event.target === taskId
|
|
&& event.mutationType === "secret:env-write-skipped"
|
|
&& event.metadata?.reason === "no-store",
|
|
)).toBe(false);
|
|
};
|
|
|
|
const executorTask = await taskStore.createTask({ description: "executor secrets env" });
|
|
await (runtime.getExecutor() as any).ensureGraphCustomNodeWorktree(
|
|
executorTask,
|
|
await taskStore.getSettings(),
|
|
"test-worktree",
|
|
);
|
|
await assertConsumerMaterializedSecretsEnv(executorTask.id);
|
|
|
|
const heartbeatTask = await taskStore.createTask({ description: "heartbeat secrets env" });
|
|
const agentStore = runtime.getAgentStore()!;
|
|
const heartbeatAgent = await agentStore.createAgent({
|
|
name: "Runtime secrets-env heartbeat agent",
|
|
role: "executor",
|
|
});
|
|
await agentStore.assignTask(heartbeatAgent.id, heartbeatTask.id);
|
|
await runtime.getHeartbeatMonitor()!.executeHeartbeat({
|
|
agentId: heartbeatAgent.id,
|
|
source: "on_demand",
|
|
});
|
|
await assertConsumerMaterializedSecretsEnv(heartbeatTask.id);
|
|
|
|
/*
|
|
FNXC:MemoryRecallCapture 2026-08-11-11:53:
|
|
FN-8933's runtime root must supply the live recall writer to its reflection service; an
|
|
injected unit-test writer cannot detect a missing composition line. Drive the runtime-owned
|
|
service through a completed task and drain only its test seam before reading the real store.
|
|
*/
|
|
const reflectionAgent = await runtime.getAgentStore()!.createAgent({
|
|
name: "Runtime recall composition agent",
|
|
role: "executor",
|
|
});
|
|
const reflectedTask = await taskStore.createTask({ description: "runtime recall composition" });
|
|
await taskStore.updateTask(reflectedTask.id, {
|
|
column: "done",
|
|
status: "completed",
|
|
assignedAgentId: reflectionAgent.id,
|
|
});
|
|
const reflectionService = (runtime as unknown as {
|
|
executor?: { options?: { reflectionService?: { captureTaskPerformance(agentId: string, taskId: string): Promise<unknown>; captureWriter: RecallCaptureWriterWithTestDrain } } };
|
|
}).executor?.options?.reflectionService;
|
|
expect(reflectionService).toBeDefined();
|
|
await reflectionService!.captureTaskPerformance(reflectionAgent.id, reflectedTask.id);
|
|
await reflectionService!.captureWriter.flushPendingCaptures();
|
|
expect(await listRecall(layer!, { limit: 10 })).toEqual(expect.arrayContaining([
|
|
expect.objectContaining({ source: expect.objectContaining({ taskId: reflectedTask.id, agentId: reflectionAgent.id }) }),
|
|
]));
|
|
|
|
const missionStore = taskStore.getMissionStore();
|
|
const mission = await missionStore.createMission({ title: "Runtime composition" });
|
|
expect((await missionStore.getMission(mission.id))?.title).toBe("Runtime composition");
|
|
|
|
const claimStore = (runtime as unknown as { leaseCentralClaimStore: AsyncCentralClaimStore })
|
|
.leaseCentralClaimStore;
|
|
const claimed = await claimStore.tryClaimTask({
|
|
projectId: "runtime-composition",
|
|
taskId: "FN-RUNTIME-COMPOSITION",
|
|
nodeId: "node-test",
|
|
agentId: "agent-test",
|
|
runId: "run-test",
|
|
renewedAt: new Date().toISOString(),
|
|
});
|
|
expect(claimed.ok).toBe(true);
|
|
|
|
await runtime.stop();
|
|
await runtime.stop();
|
|
expect(runtime.getStatus()).toBe("stopped");
|
|
expect(lifecycle.shutdownCalls).toBe(1);
|
|
|
|
/*
|
|
FNXC:SecretsEnvRuntimeWiring 2026-08-05-22:12:
|
|
Secrets-store resolution is an essential composition dependency, not a best-effort
|
|
secretsEnv convenience. A rejection must take the normal fail-closed startup cleanup
|
|
path so no partial executor or heartbeat runtime remains active.
|
|
*/
|
|
lifecycle.secretsStoreFailure = new Error("test secrets-store initialization failure");
|
|
failedRuntime = new InProcessRuntime({
|
|
projectId: "runtime-composition-secrets-store-failure",
|
|
workingDirectory: projectDir,
|
|
isolationMode: "in-process",
|
|
maxConcurrent: 1,
|
|
maxWorktrees: 1,
|
|
}, central);
|
|
failedRuntime.on("error", () => undefined);
|
|
await expect(failedRuntime.start()).rejects.toThrow("test secrets-store initialization failure");
|
|
expect(failedRuntime.getStatus()).toBe("errored");
|
|
expect(lifecycle.shutdownCalls).toBe(2);
|
|
} finally {
|
|
try {
|
|
await runtime?.stop();
|
|
} finally {
|
|
try {
|
|
await central?.close();
|
|
} finally {
|
|
try {
|
|
await harness.teardown();
|
|
} finally {
|
|
if (priorDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
else process.env.DATABASE_URL = priorDatabaseUrl;
|
|
await Promise.all([
|
|
projectDir ? rm(projectDir, { recursive: true, force: true }) : Promise.resolve(),
|
|
globalDir ? rm(globalDir, { recursive: true, force: true }) : Promise.resolve(),
|
|
]);
|
|
lifecycle.shutdownCalls = 0;
|
|
lifecycle.secretsStoreFailure = undefined;
|
|
lifecycle.secretsStoreGetter = undefined;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, 30_000);
|
|
});
|