fix(daemon): wire scheduler lifecycle + status surface + route-level Option Y tests (OPR.0.3.4.9 guard BLOCKING)

1. Lifecycle: PeriodicSnapshotScheduler instantiated in startup.ts,
started in index.ts after listen (reads enabled/interval/retention
from settings-store), stopped on SIGINT/SIGTERM. Only starts when
snapshots.periodic.enabled is true.

2. Status surface: PsEntry extended with periodicSnapshotActive,
periodicSnapshotIntervalSeconds, autoPeriodicSnapshotCount. The
projection queries auto-periodic count per rig and reads the
scheduler active/interval state via setPeriodicSnapshotState.

3. Route-level Option Y consumer proof: /api/up stale-APD + newer
periodic -> restores from auto-periodic (snapshotKind assertion);
fresher-APD wins; Explorer /:id/up same stale-APD + newer periodic
discriminator.
This commit is contained in:
mvschwarz committed 2026-06-15 00:47:39 -07:00
1 parent b606c15806
commit 82ff95db72
6 files changed
+95 -1

No files matched your search

+19 -1
View File
@@ -43,6 +43,12 @@ export interface PsEntry {
archivedAt: string | null;
/** OPR.0.3.3.19 - convenience flag; true iff `archivedAt !== null`. */
isArchived: boolean;
/** OPR.0.3.4.9 — periodic snapshot floor: whether the scheduler is active. */
periodicSnapshotActive: boolean;
/** OPR.0.3.4.9 — periodic snapshot interval in seconds (0 if inactive). */
periodicSnapshotIntervalSeconds: number;
/** OPR.0.3.4.9 — count of auto-periodic snapshots for this rig. */
autoPeriodicSnapshotCount: number;
}
/**
@@ -83,12 +89,19 @@ export function deriveRigLifecycleState(nodeStates: NodeLifecycleState[]): RigLi
export class PsProjectionService {
readonly db: Database.Database;
private readonly seatActivity: SeatActivityService | null;
private periodicSnapshotActive = false;
private periodicSnapshotIntervalSeconds = 0;
constructor(deps: { db: Database.Database; seatActivity?: SeatActivityService }) {
this.db = deps.db;
this.seatActivity = deps.seatActivity ?? null;
}
setPeriodicSnapshotState(active: boolean, intervalSeconds: number): void {
this.periodicSnapshotActive = active;
this.periodicSnapshotIntervalSeconds = intervalSeconds;
}
getEntries(filter?: RigArchiveFilter): PsEntry[] {
// OPR.0.3.3.19 - default excludes archived rigs at the projection layer
// (NOT client-side). Explicit includeArchived/archivedOnly opt in.
@@ -108,7 +121,8 @@ export class PsProjectionService {
WHERE n.rig_id = r.id AND s.status = 'running'
AND s.id = (SELECT s2.id FROM sessions s2 WHERE s2.node_id = s.node_id ORDER BY s2.created_at DESC, s2.id DESC LIMIT 1)
) as earliest_running_at,
(SELECT snap.created_at FROM snapshots snap WHERE snap.rig_id = r.id ORDER BY snap.created_at DESC, snap.id DESC LIMIT 1) as latest_snapshot_at
(SELECT snap.created_at FROM snapshots snap WHERE snap.rig_id = r.id ORDER BY snap.created_at DESC, snap.id DESC LIMIT 1) as latest_snapshot_at,
(SELECT COUNT(*) FROM snapshots snap WHERE snap.rig_id = r.id AND snap.kind = 'auto-periodic') as auto_periodic_count
FROM rigs r
${where}
ORDER BY r.name
@@ -120,6 +134,7 @@ export class PsProjectionService {
running_count: number;
earliest_running_at: string | null;
latest_snapshot_at: string | null;
auto_periodic_count: number;
}>;
const now = Date.now();
@@ -170,6 +185,9 @@ export class PsProjectionService {
latestSnapshot: r.latest_snapshot_at ? formatAge(now - new Date(r.latest_snapshot_at + "Z").getTime()) : null,
archivedAt: r.archived_at,
isArchived: r.archived_at !== null,
periodicSnapshotActive: this.periodicSnapshotActive,
periodicSnapshotIntervalSeconds: this.periodicSnapshotIntervalSeconds,
autoPeriodicSnapshotCount: r.auto_periodic_count,
};
});
}
+15
View File
@@ -56,6 +56,16 @@ export async function startServer(port?: number) {
// PsProjectionService + node-inventory enrichment serve
// fresh data on each request.
deps.seatActivityService?.start(deps.rigRepo.db);
// OPR.0.3.4.9 — start the periodic snapshot scheduler (crash-insurance).
if (deps.periodicSnapshotScheduler) {
const settingsStore = deps.settingsStore;
const enabled = settingsStore ? settingsStore.resolveOne("snapshots.periodic.enabled").value === true : true;
if (enabled) {
const intervalS = settingsStore ? (settingsStore.resolveOne("snapshots.periodic.interval_seconds").value as number) : 300;
const retentionKeep = settingsStore ? (settingsStore.resolveOne("snapshots.periodic.retention_keep").value as number) : 10;
deps.periodicSnapshotScheduler.start(intervalS * 1000, retentionKeep);
}
}
}
});
servers.push(srv);
@@ -76,6 +86,11 @@ export async function startServer(port?: number) {
} catch (err) {
console.error("[seat-activity] shutdown error", err);
}
try {
deps.periodicSnapshotScheduler?.stop();
} catch (err) {
console.error("[periodic-snapshot] shutdown error", err);
}
await Promise.all(
servers.map(
(srv) =>
+1
View File
@@ -160,6 +160,7 @@ export interface AppDeps {
watchdogHistoryLog?: WatchdogHistoryLog;
watchdogPolicyEngine?: WatchdogPolicyEngine;
watchdogScheduler?: WatchdogScheduler;
periodicSnapshotScheduler?: import("./domain/periodic-snapshot-scheduler.js").PeriodicSnapshotScheduler;
workflowRuntime?: WorkflowRuntime;
/**
* Absolute path to the daemon's bundled built-in workflow-specs
+5
View File
@@ -1245,6 +1245,11 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
});
deps.contextMonitor = contextMonitor;
// OPR.0.3.4.9 — periodic snapshot scheduler (crash-insurance floor).
const { PeriodicSnapshotScheduler } = await import("./domain/periodic-snapshot-scheduler.js");
const periodicSnapshotScheduler = new PeriodicSnapshotScheduler({ db, snapshotCapture, snapshotRepo });
deps.periodicSnapshotScheduler = periodicSnapshotScheduler;
const app = createApp(deps);
return { app, db, deps, contextMonitor };
+14
View File
@@ -632,6 +632,20 @@ describe("Rig CRUD routes", () => {
expect(body.error).not.toContain("auto-pre-down snapshot");
});
// OPR.0.3.4.9 — Option Y: auto-periodic co-equal with auto-pre-down via Explorer /:id/up.
it("OPR.0.3.4.9 Option Y: stale auto-pre-down + newer auto-periodic -> restores from auto-periodic via /:id/up", async () => {
const rig = repo.createRig("option-y-explorer");
repo.addNode(rig.id, "worker", { role: "worker" });
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
await new Promise((r) => setTimeout(r, 10));
snapshotCapture.captureSnapshot(rig.id, "auto-periodic");
const res = await app.request(`/api/rigs/${rig.id}/up`, { method: "POST" });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.snapshotKind).toBe("auto-periodic");
});
// OPR.0.3.4.4 — the Explorer restore route is INDEPENDENT of /api/up (it
// parsed no body at all), so plan:true was silently ignored and the route
// always mutated. These tests pin the daemon-level read-only gate here too.
+41
View File
@@ -223,6 +223,47 @@ describe("Up API route", () => {
});
});
// OPR.0.3.4.9 — Option Y: auto-periodic co-equal with auto-pre-down in restore selection.
describe("OPR.0.3.4.9 Option Y restore selection via /api/up", () => {
it("stale auto-pre-down + newer auto-periodic -> restores from auto-periodic", async () => {
const rig = rigRepo.createRig("option-y-rig");
rigRepo.addNode(rig.id, "worker", { role: "worker" });
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
// Wait a tick so created_at differs
await new Promise((r) => setTimeout(r, 10));
snapshotCapture.captureSnapshot(rig.id, "auto-periodic");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "option-y-rig" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.status).toBe("restored");
expect(body.snapshotKind).toBe("auto-periodic");
});
it("fresher auto-pre-down + older auto-periodic -> restores from auto-pre-down", async () => {
const rig = rigRepo.createRig("option-y-rig2");
rigRepo.addNode(rig.id, "worker", { role: "worker" });
snapshotCapture.captureSnapshot(rig.id, "auto-periodic");
await new Promise((r) => setTimeout(r, 10));
snapshotCapture.captureSnapshot(rig.id, "auto-pre-down");
const res = await app.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: "option-y-rig2" }),
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.snapshotKind).toBe("auto-pre-down");
});
});
// OPR.0.3.4.4 — `--plan` must be READ-ONLY on the rig_name restore path.
// The rig_name branch early-returned BEFORE the bootstrap plan gate, so
// `rig up --existing <rig> --plan` mutated (recreated sessions, flipped