mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 22:58:42 -10:00
VM-005 B1: write-side mission-status cache coherence (narrow C-vii exception)
POST /:missionId/complete writes the mission README status but left the authored-status sidecar TTL-hot, so /api/slices served the pre-write word for up to 60s while /api/missions/:id read the new word from disk — an FR-1 coherence violation through the daemon's own API (guard final product review). Per the arch ruling (b8d91aee…, drop-the-blob) and plan v1.6.1 §J-1: one narrow SliceIndexer.invalidateMissionStatusCache() nulling ONLY the missionStatusCache blob, exactly one additive post-success call in the /complete route (writeMissionStatusComplete and readMissionStatus stay byte-unchanged — the C-vii fence holds around the one-line exception), and the §J-1d regression: hot-primed sidecar → POST complete → immediate detail+slices coherence, idempotent re-POST, and the negative that listing/detail caches survive. Out-of-band writes remain the TTL regime by design. Zero UI edits. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
c746883cba
commit
704ddb58d9
3 files changed
+88
No files matched your search
@@ -199,6 +199,20 @@ export class SliceIndexer {
|
||||
this.missionStatusCache = null;
|
||||
}
|
||||
|
||||
/** VM-005 B1 (the narrow C-vii exception; arch ruling b8d91aee…) — the
|
||||
* READ-AFTER-WRITE seam: POST /:missionId/complete writes the mission
|
||||
* README status, and the sidecar's 60s TTL would otherwise keep serving
|
||||
* the pre-write word to /api/slices while /api/missions/:id reads the
|
||||
* new word from disk. This drops the WHOLE missionStatusCache blob and
|
||||
* nothing else (listing/detail caches untouched — drop-the-blob, never
|
||||
* full-flush: zero coherence gain and a 60s registry churn were the
|
||||
* ruling's reasons to reject the alternatives). The sole write path
|
||||
* calls it post-success; the next read rebuilds from disk (README =
|
||||
* SSOT). Out-of-band file writes remain the TTL regime by design. */
|
||||
invalidateMissionStatusCache(): void {
|
||||
this.missionStatusCache = null;
|
||||
}
|
||||
|
||||
/** VM-005 (release-0.4.7) — the authored mission-status sidecar for the
|
||||
* slices list payload: one mission-README frontmatter read per indexed
|
||||
* mission, inside the same 60s cache discipline as the listing. Keys are
|
||||
|
||||
@@ -111,6 +111,11 @@ export function missionsRoutes(): Hono {
|
||||
500,
|
||||
);
|
||||
}
|
||||
// VM-005 B1 (narrow C-vii exception): the write succeeded — drop the
|
||||
// authored-status sidecar cache so the very next /api/slices read serves
|
||||
// the new word (read-after-write coherence). Any future daemon
|
||||
// mission-status mutation route calls this same method.
|
||||
indexer.invalidateMissionStatusCache();
|
||||
return c.json({ missionId, status: "complete" });
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { SliceIndexer } from "../src/domain/slices/slice-indexer.js";
|
||||
import { missionsRoutes } from "../src/routes/missions.js";
|
||||
import { slicesRoutes } from "../src/routes/slices.js";
|
||||
|
||||
function buildApp(indexer: SliceIndexer): Hono {
|
||||
const app = new Hono();
|
||||
@@ -225,3 +226,71 @@ describe("POST /api/missions/:missionId/complete", () => {
|
||||
expect(readme).toContain("id: preserves");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VM-005 B1 — write-side cache coherence (the narrow C-vii exception;
|
||||
// arch ruling b8d91aee…, plan v1.6.1 §J-1d, guard's repro shape verbatim).
|
||||
// Out-of-band file writes remain the 60s TTL regime by design — this seam
|
||||
// covers the daemon's OWN write path only (no watchers, no write-through).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SidecarBody = { missions: Record<string, { authoredStatus: string | null }> };
|
||||
|
||||
function buildAppWithSlices(ix: SliceIndexer): Hono {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("sliceIndexer" as never, ix);
|
||||
// the list path touches only the indexer; a stub satisfies getDeps
|
||||
c.set("sliceDetailProjector" as never, {} as never);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/missions", missionsRoutes());
|
||||
app.route("/api/slices", slicesRoutes());
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("VM-005 B1 — read-after-write coherence at the daemon's own API", () => {
|
||||
it("hot GET /api/slices → POST complete → IMMEDIATE detail + slices BOTH read complete; second POST idempotent", async () => {
|
||||
writeMissionReadme(missionsRoot, "relx", "---\nid: relx\nstatus: active\n---\n# Relx\n");
|
||||
writeSliceInMission(missionsRoot, "relx", "target");
|
||||
const app = buildAppWithSlices(indexer);
|
||||
|
||||
// Prime the sidecar HOT — deliberately NO ?refresh=1 (refresh would
|
||||
// full-invalidate and mask the seam; the defect lives on the hot path).
|
||||
const primed = await app.request("/api/slices?filter=all");
|
||||
expect(primed.status).toBe(200);
|
||||
expect(((await primed.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "active" });
|
||||
|
||||
const post = await app.request("/api/missions/relx/complete", { method: "POST" });
|
||||
expect(post.status).toBe(200);
|
||||
|
||||
// IMMEDIATELY (no refresh, no TTL wait): both payloads carry the new word.
|
||||
const detail = await app.request("/api/missions/relx");
|
||||
expect(detail.status).toBe(200);
|
||||
expect(((await detail.json()) as { status?: string | null }).status).toBe("complete");
|
||||
const hot = await app.request("/api/slices?filter=all");
|
||||
expect(((await hot.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "complete" });
|
||||
|
||||
// Idempotent second POST: still 200, sidecar still coherent.
|
||||
const again = await app.request("/api/missions/relx/complete", { method: "POST" });
|
||||
expect(again.status).toBe(200);
|
||||
const hot2 = await app.request("/api/slices?filter=all");
|
||||
expect(((await hot2.json()) as SidecarBody).missions["relx"]).toEqual({ authoredStatus: "complete" });
|
||||
});
|
||||
|
||||
it("NEGATIVE: listing + detail caches SURVIVE the complete-write invalidation (drop-the-blob, never full-flush)", async () => {
|
||||
writeMissionReadme(missionsRoot, "relx", "---\nstatus: active\n---\n# Relx\n");
|
||||
writeSliceInMission(missionsRoot, "relx", "target");
|
||||
const app = buildAppWithSlices(indexer);
|
||||
const listBefore = indexer.list(); // primes the listing cache
|
||||
const recordBefore = indexer.get("target"); // primes the detail cache
|
||||
indexer.missionAuthoredStatuses(); // primes the sidecar
|
||||
const post = await app.request("/api/missions/relx/complete", { method: "POST" });
|
||||
expect(post.status).toBe(200);
|
||||
// Reference equality: the SAME cached instances = those caches survived.
|
||||
expect(indexer.list()).toBe(listBefore);
|
||||
expect(indexer.get("target")).toBe(recordBefore);
|
||||
// ...while the sidecar rebuilt from disk with the new word.
|
||||
expect(indexer.missionAuthoredStatuses()["relx"]).toEqual({ authoredStatus: "complete" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user