mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
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:
1 parent
b606c15806
commit
82ff95db72
6 files changed
+95
-1
No files matched your search
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user