Compare commits

...
12 changed files with 363 additions and 108 deletions

No files matched your search

+2 -2
View File
@@ -2,7 +2,7 @@ import { physicalSessionName } from "./session-registry.js";
import { resolveNodeFile } from "./scope/node-file.js";
import { belongsToProject } from "./workspace/project-catalog.js";
import { inspectGraph } from "./workflow-reconciliation.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } from "./proof/judgments.js";
import { createProofPolicyRead, readMissionReadiness, readProjectReadinessAsync } from "./proof/judgments.js";
import { lifecycleObligations, requiredLifecycleSteps } from "./lifecycle-obligations.js";
import { QueueWakeRepository } from "./queue-wake-repository.js";
import { isTypedGateBlocker } from "./queue-repository.js";
@@ -1361,7 +1361,7 @@ export async function buildExecutionView(deps: ExecutionViewDeps, opts?: { missi
...(opts?.project ? { project: opts.project, membership: "exact project:<id> queue tags and lifecycle identity; unscoped rows excluded" } : {}),
view: "execution",
readiness,
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
project_readiness: missionsRoot ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
mission,
@@ -389,6 +389,22 @@ function declaredNodeId(dir: string): string | null {
* from them); it defaults to the ordinary filesystem reader and changes nothing about the evaluation. */
export function readProjectReadiness(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const missions = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory()).map(name => ({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) }));
return projectReadiness(missions);
}
/** The same semantic read, yielding between missions for daemon requests and source-watch refreshes.
* A single mission (including its evidence) is still synchronous; this is not a hard time bound or atomic snapshot. */
export async function readProjectReadinessAsync(missionsRoot: string, readPolicy: ProofPolicyRead = defaultPolicyRead, root?: string, io: ScopeFsDeps = proofFs, observe?: (file: string) => void) {
const names = fs.readdirSync(missionsRoot).filter(n => fs.statSync(path.join(missionsRoot, n)).isDirectory());
const missions: Array<MissionReadiness & { name: string }> = [];
for (const name of names) {
await new Promise<void>(resolve => setImmediate(resolve));
missions.push({ name, ...readMissionReadiness(contained(missionsRoot, path.join(missionsRoot, name)), readPolicy, root, io, observe) });
}
return projectReadiness(missions);
}
function projectReadiness(missions: Array<MissionReadiness & { name: string }>) {
const active = missions.filter(m => ["active", "release-candidate"].includes(m.historicalStatus ?? ""));
return { revision: hash(active), state: active.length && active.every(m => m.state === "ready") ? "ready" : active.some(m => m.state === "unknown") ? "unknown" : "not-ready", missions, basis: "Active mission readiness; distinct outcome judgment and publication remain separate" };
}
@@ -3,7 +3,7 @@ import * as path from "node:path";
import { createHash } from "node:crypto";
import type { EventBus } from "../event-bus.js";
import type { ScopeFsDeps } from "../scope/scope-view-projection.js";
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
import { createProofPolicyRead, proofFs, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
export interface ProofSourceWatch {
close(): void;
@@ -76,16 +76,15 @@ function shallowWatches(missionsRoot: string, realRoot: string, top: string, pro
* including absent nearer ones, and accepted evidence elsewhere in the project) is watched at its parent directory,
* not recursively, so sibling checkouts and build trees are never subscribed to.
*/
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): ProofSourceWatch {
export async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
missionsRoot = path.resolve(missionsRoot);
let projectRoot = path.dirname(missionsRoot);
try { projectRoot = resolveProjectRoot(missionsRoot) ?? projectRoot; } catch { /* Legacy roots keep the missions root's parent. */ }
const top = path.dirname(path.resolve(projectRoot));
// The authority boundary in real terms: what the readers' containment check accepts.
const project = canonical(projectRoot) ?? path.resolve(projectRoot);
// ponytail: bounded local workspaces use a full semantic read after a file burst.
// Keep this workload measured; subtree indexing is warranted only when that bound is exceeded.
const basis = () => {
// Keep the full semantic read, but let queue/send requests run between missions.
const basis = async () => {
const read = new Set<string>();
const note = (p: string) => { read.add(p); return p; };
const io: ScopeFsDeps = {
@@ -95,7 +94,8 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
listDir: (p) => proofFs.listDir(note(p)),
isDirectory: (p) => proofFs.isDirectory(note(p)),
};
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).missions.map(m => [m.name, m.revision]))).digest("hex");
const result = await readProjectReadinessAsync(missionsRoot, createProofPolicyRead(), undefined, io, note);
const hash = createHash("sha256").update(JSON.stringify(result.missions.map(m => [m.name, m.revision]))).digest("hex");
// A file read through a link inside the project depends on its real location too: observe both.
for (const file of [...read]) {
const real = canonical(file);
@@ -107,6 +107,7 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
let closed = false, failed = false, covered = false, readable = false;
let revision = "unavailable", timer: NodeJS.Timeout | undefined;
let scopeDirty = false;
let reading = true, dirty = false;
const notifyScope = () => { try { scopeChanged?.(); } catch { /* A later event or explicit refresh retries the view read. */ } };
let wanted: Shallow = new Map();
const shallow = new Map<string, fs.FSWatcher>();
@@ -163,25 +164,32 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
/** Read the sources and keep the subscriptions in step with what was read, even when the result is unchanged.
* Coverage is certified only by a read whose every dependency was already watched before it; if the dependencies
* keep moving past a small budget, the sources are reported unobserved rather than read forever. */
const evaluate = (): string => {
let read = basis();
const evaluate = async (): Promise<{ hash: string; covered: boolean }> => {
let read = await basis();
for (let i = 0; ; i++) {
if (closed) return { hash: revision, covered: false };
// A root whose real location is unknown or outside the project has no anchors there: never certified.
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
if (i === 2) { covered = false; return read.hash; }
read = basis();
// Coverage belongs to this candidate, just like the hash; neither escapes a discarded read.
if (!resubscribe(read.watches)) return { hash: read.hash, covered: recursiveReal !== null && within(recursiveReal, project) };
if (i === 2) return { hash: read.hash, covered: false };
read = await basis();
}
};
const observed = () => !closed && !failed && covered && readable;
const schedule = () => {
if (timer || closed) return;
timer = setTimeout(() => {
dirty = true;
if (timer || reading || closed) return;
timer = setTimeout(async () => {
timer = undefined;
if (closed) return;
reading = true; dirty = false;
try {
if (scopeDirty) { scopeDirty = false; notifyScope(); }
const before = observed();
const next = evaluate();
const candidate = await evaluate();
if (closed || dirty) return;
const next = candidate.hash;
covered = candidate.covered && !failed;
readable = true;
// Deduplicate on what clients see: the revision and whether the sources are observed. Losing or regaining
// coverage is news even when the proof result itself did not change.
@@ -190,12 +198,16 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
invalidate();
notify(observed() ? revision : "unavailable");
} catch (e) {
if (closed) return;
// Never report the sources as watched after a failed read or a watch that could not be attached.
readable = false;
if (e instanceof WatchFailure) covered = false;
revision = "unavailable";
invalidate();
notify("unavailable");
} finally {
reading = false;
if (dirty && !closed) schedule();
}
}, 40);
timer.unref();
@@ -203,7 +215,10 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
try {
recursiveReal = canonical(missionsRoot);
recursive = watchDir(missionsRoot, true);
try { revision = evaluate(); readable = true; } catch (e) {
try {
const candidate = await evaluate();
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
} catch (e) {
if (e instanceof WatchFailure) throw e;
// Direct reads name unavailable inputs; watching may recover them.
covered = true;
@@ -214,6 +229,9 @@ export function watchProofSources(missionsRoot: string, invalidate: () => void,
for (const watcher of shallow.values()) watcher.close();
invalidate(); notify("unavailable"); notifyScope();
return { close() {}, observation: () => ({ state: "unavailable", revision }) };
} finally {
reading = false;
if (dirty && !closed) schedule();
}
return {
observation: () => ({ state: observed() ? "watching" : "unavailable", revision }),
+6 -3
View File
@@ -3,7 +3,7 @@ import { proofSourceObservation } from "../domain/proof/source-watch.js";
import * as path from "node:path";
import type { SliceIndexer } from "../domain/slices/slice-indexer.js";
import type { EventBus } from "../domain/event-bus.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadiness, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadinessAsync, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
import { requireSenderIdentity, resolveRecordedProvenance } from "./require-sender-identity.js";
import { projectById, sameProjectRoot } from "../domain/workspace/project-read.js";
import { ProjectReadError } from "../domain/workspace/project-catalog.js";
@@ -78,12 +78,15 @@ export function proofRoutes(): Hono {
// freshness is reported as unverified rather than borrowing the selected workspace's state.
const observation = (c: Parameters<typeof indexer>[0], project: { id: string; root: string } | null) =>
project ? { state: "unavailable" as const, revision: "unverified" } : proofSourceObservation(c);
app.get("/", c => {
app.get("/", async c => {
const { root, evidenceRoot, scope, project } = target(c, c.req.query("scope"), c.req.query("project"), c.req.query("projectRoot"));
const common = { sourceObservation: observation(c, project), ...(project ? { project } : {}) };
// A catalog project's own root bounds policy, scope identity and evidence (never an ancestor's project.yaml).
const bound = project?.root;
if (!scope) return c.json({ ...readProjectReadiness(root, undefined, bound), ...common });
if (!scope) {
const result = await readProjectReadinessAsync(root, undefined, bound);
return c.json({ ...result, ...common, sourceObservation: observation(c, project) });
}
const dir = resolveProofScope(root, scope);
if (path.basename(path.dirname(dir)) !== "slices") return c.json({ ...readMissionReadiness(dir, undefined, bound), ...common });
const refs = c.req.queries("evidence") ?? [];
+14 -10
View File
@@ -18,7 +18,7 @@ import { inferCatalogProject, ProjectReadError } from "../domain/workspace/proje
// avoid `/proof-asset` being parsed as a slice-name.
import { Hono, type Context } from "hono";
import { readSliceReadiness, readProjectReadiness } from "../domain/proof/judgments.js";
import { readSliceReadiness, readProjectReadinessAsync } from "../domain/proof/judgments.js";
import * as fs from "node:fs";
import * as path from "node:path";
import { SliceIndexer, SliceListEntry, SliceStatus } from "../domain/slices/slice-indexer.js";
@@ -37,7 +37,7 @@ export function slicesRoutes(): Hono {
// 1) Static literal `/` BEFORE dynamic `/:name` so the list isn't
// shadowed by a slice named "list" or similar.
app.get("/", (c) => {
app.get("/", async (c) => {
const deps = getDeps(c);
if (!deps) return c.json({ error: "slices_indexer_unavailable" }, 503);
if (!deps.indexer.isReady()) {
@@ -61,7 +61,7 @@ export function slicesRoutes(): Hono {
// operation: the list rebuild, the boundToWorkflow per-slice get loop,
// and the mission sidecar share ONE membership batch (pre-scope, each
// uncached get built its own 2-scan batch: 2+2N total queue scans).
return deps.indexer.withMembershipBatch(() => {
const listing = deps.indexer.withMembershipBatch(() => {
const all = deps.indexer.list();
let filtered = filter === "all" ? all : all.filter((s) => s.status === filter);
// Workflows in Spec Library v0: optional lens filter — narrow to
@@ -102,7 +102,7 @@ export function slicesRoutes(): Hono {
complete: projectLookup.complete,
});
const authored = deps.indexer.missionAuthoredStatuses();
return c.json({
return {
slices: filtered.map((s) => ({
...s,
queueCounts: queueCountsByPath.get(s.slicePath) ?? null,
@@ -114,13 +114,17 @@ export function slicesRoutes(): Hono {
// VM-005 (release-0.4.7): additive authored mission-status sidecar so
// chip surfaces can honor authored-wins precedence without a second
// round-trip. The `slices` array itself is byte-untouched.
missions: { ...authored, ...Object.fromEntries(readProjectReadiness(deps.indexer.slicesRoot).missions.map(m => [m.name, {
...authored[m.name],
authoredStatus: m.historicalStatus ?? authored[m.name]?.authoredStatus ?? null,
readiness: m,
}])) },
});
missions: authored,
};
});
if (listing instanceof Response) return listing;
// The membership batch is synchronous and request-local; finish it before yielding to other requests.
const readiness = await readProjectReadinessAsync(deps.indexer.slicesRoot);
return c.json({ ...listing, missions: { ...listing.missions, ...Object.fromEntries(readiness.missions.map(m => [m.name, {
...listing.missions[m.name],
authoredStatus: m.historicalStatus ?? listing.missions[m.name]?.authoredStatus ?? null,
readiness: m,
}])) } });
});
// V0.3.1 slice 17 founder-walk-workspace-state-correctness (walk item 8 — Explorer auto-show): explicit cache invalidation surface.
+1 -1
View File
@@ -2118,7 +2118,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
deps.sliceIndexer = sliceIndexer;
if (sliceIndexer.isReady()) {
const { watchProofSources } = await import("./domain/proof/source-watch.js");
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
deps.proofSourceWatch = await watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
sliceIndexer.invalidate();
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
});
@@ -24,6 +24,7 @@ import { queueItemsSchema } from "../src/db/migrations/024_queue_items.js";
import { queueTransitionsSchema } from "../src/db/migrations/025_queue_transitions.js";
import { queueItemSummarySchema } from "../src/db/migrations/044_queue_item_summary.js";
import { buildExecutionView, type ExecutionViewDeps } from "../src/domain/execution-view.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const SHA = "a".repeat(40);
const REGISTRY_FILES = 450;
@@ -120,4 +121,25 @@ describe("execution view review registry", () => {
expect(turnsAtRead[REGISTRY_FILES - 1]! - turnsAtRead[0]!).toBeGreaterThan(0);
expect(turnsAtRead).toHaveLength(REGISTRY_FILES);
});
it("also yields inside the later whole-project readiness read, preserving its projection", async () => {
const deps = fixture("turns", 2), missions = deps.slicesRoot()!;
for (const name of ["beta", "gamma"]) {
fs.mkdirSync(path.join(missions, name));
fs.writeFileSync(path.join(missions, name, "mission.yaml"), "kind: mission\ncomposition: { slices: [] }\n");
}
const first = fs.readdirSync(missions).slice(0, -1).find(name => name !== "turns")!;
let started = false, yielded = false;
hooks.onRead = file => {
if (!started && file === path.join(missions, first, "mission.yaml")) {
started = true;
setImmediate(() => { yielded = true; });
}
};
const doc = await buildExecutionView(deps, { mission: "turns" });
expect(started).toBe(true);
expect(yielded, "earlier registry yields must not hide a later synchronous project walk").toBe(true);
hooks.onRead = null;
expect(doc.project_readiness).toEqual(readProjectReadiness(missions));
});
});
+6 -3
View File
@@ -265,7 +265,7 @@ describe("retained authority negative controls", () => {
const sliceRevision = readSliceReadiness(f.alpha).revision;
const events: Array<{ type: string; revision: string }> = [];
let invalidated = 0;
const watch = watchProofSources(f.missions, () => { invalidated++; }, {
const watch = await watchProofSources(f.missions, () => { invalidated++; }, {
emit: (event: { type: string; revision: string }) => events.push(event),
} as unknown as EventBus);
try {
@@ -296,8 +296,11 @@ describe("retained authority negative controls", () => {
it("pushes changed source truth, keeps sibling basis, and ignores unchanged source bytes", async () => {
const f = fixture(); f.judge(); const sibling = readSliceReadiness(f.beta).revision;
const events: Array<{ type: string; revision: string }> = []; let invalidated = 0;
const watch = watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
const watch = await watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
try {
// Creation events may interrupt asynchronous setup; count changes only after the initial basis settles.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
const file = join(f.alpha, "proof/evidence.md"); fs.writeFileSync(file, fs.readFileSync(file));
await new Promise(r => setTimeout(r, 100)); expect(events).toEqual([]);
fs.writeFileSync(file, "Measured outcome changed.\n");
@@ -450,7 +453,7 @@ it("notifies scope followers on a node-header change even when proof readiness i
const f = fixture(), file = join(f.alpha, "SPEC.md");
const original = fs.readFileSync(file, "utf8"), before = readMissionReadiness(f.mission).revision;
let scopeChanges = 0;
const watch = watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
const watch = await watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
try {
const updated = original.replace("id: 01-alpha", "id: renamed-alias");
fs.writeFileSync(file, updated);
+54 -47
View File
@@ -153,8 +153,15 @@ describe("request-local proof-policy reuse", () => {
});
});
async function startWatch(...args: Parameters<typeof watchProofSources>) {
const opening = watchProofSources(...args);
// Startup itself now yields; settle its cooperative turns under the fixture clock.
await vi.runAllTimersAsync();
return opening;
}
describe("watch-local proof-policy reuse", () => {
function observe(missions: string) {
async function observe(missions: string) {
vi.useFakeTimers();
// Each watched directory's listener, by path: "a change" is delivered by the missions root's own watch.
const listeners = new Map<string, () => void>();
@@ -163,13 +170,13 @@ describe("watch-local proof-policy reuse", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn();
const watch = watchProofSources(missions, invalidate, { emit } as unknown as EventBus);
const watch = await startWatch(missions, invalidate, { emit } as unknown as EventBus);
const changed = () => listeners.get(missions)!();
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(missions).missions.map(m => [m.name, m.revision]))).digest("hex");
const event = () => {
const event = async () => {
const previous = watch.observation().revision, calls = invalidate.mock.calls.length;
const expected = oracle();
changed(); vi.advanceTimersByTime(50);
changed(); await vi.advanceTimersByTimeAsync(50);
expect(watch.observation()).toEqual({ state: "watching", revision: expected });
expect(invalidate).toHaveBeenCalledTimes(calls + Number(previous !== expected));
expect(emit).toHaveBeenCalledTimes(invalidate.mock.calls.length);
@@ -177,17 +184,17 @@ describe("watch-local proof-policy reuse", () => {
return { watch, changed, event, oracle, invalidate };
}
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", () => {
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", async () => {
const f = fixture();
fs.cpSync(f.mission, path.join(f.missions, "second"), { recursive: true });
const o = observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const o = await observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
const policy = fs.readFileSync(f.projectFile, "utf8");
try {
for (const text of ["changed outcome", "second outcome"]) {
f.write(path.join(f.dirs[0]!, "SPEC.md"), `# Outcome\n\n## Proof contract\n- [ ] ${text}.\n`);
const expected = o.oracle();
read.mockClear(); parse.mockClear();
o.changed(); vi.advanceTimersByTime(50);
o.changed(); await vi.advanceTimersByTimeAsync(50);
expect(o.watch.observation()).toEqual({ state: "watching", revision: expected });
expect.soft(read.mock.calls.filter(([file]) => file === f.projectFile)).toHaveLength(1);
expect.soft(parse.mock.calls.filter(([text]) => text === policy)).toHaveLength(1);
@@ -196,37 +203,37 @@ describe("watch-local proof-policy reuse", () => {
expect.soft(read.mock.calls.filter(([file]) => file === f.missionFile)).toHaveLength(2);
}
expect(o.invalidate).toHaveBeenCalledTimes(2);
o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
await o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", () => {
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", async () => {
const f = fixture(), sliceFile = path.join(f.dirs[0]!, "slice.yaml");
const project = fs.readFileSync(f.projectFile, "utf8"), mission = fs.readFileSync(f.missionFile, "utf8"), slice = fs.readFileSync(sliceFile, "utf8");
const o = observe(f.missions);
const o = await observe(f.missions);
try {
for (const [file, base, actor] of [[f.projectFile, project, "new-project"], [f.missionFile, mission, "new-mission"], [sliceFile, slice, "new-slice"]]) {
f.write(file!, { ...YAML.parse(base!), proofPolicy: { judges: [actor] } });
o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
}
f.write(sliceFile, slice); f.write(f.missionFile, mission); o.event();
fs.unlinkSync(f.projectFile); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); o.event();
f.write(sliceFile, slice); f.write(f.missionFile, mission); await o.event();
fs.unlinkSync(f.projectFile); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("legacy");
f.write(f.projectFile, project); await o.event();
for (const bad of ["proofPolicy: [unclosed", { proofPolicy: { judges: [] } }]) {
f.write(f.projectFile, bad); o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
f.write(f.projectFile, bad); await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
f.write(f.projectFile, project); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
}
const original = fs.readFileSync, read = vi.spyOn(fs, "readFileSync");
read.mockImplementation(((file: fs.PathOrFileDescriptor, ...args: any[]) => {
if (file === f.projectFile) throw Object.assign(new Error("policy denied"), { code: "EACCES" });
return (original as any)(file, ...args);
}) as typeof fs.readFileSync);
o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
await o.event(); expect(readSliceReadiness(f.dirs[0]!).state).toBe("unknown");
read.mockImplementation(original); await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual(["judge@trial"]);
} finally { o.watch.close(); }
});
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", async () => {
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
@@ -238,7 +245,7 @@ describe("watch-local proof-policy reuse", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn();
const watch = watchProofSources(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
const watch = await startWatch(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
@@ -246,34 +253,34 @@ describe("watch-local proof-policy reuse", () => {
expect(recursive.get(outside)).toBe(false);
expect([...recursive].filter(([root, deep]) => deep && root !== f.missions)).toEqual([]);
f.write(evidence, "changed observation");
listeners.get(outside)!("change", "report.md"); vi.advanceTimersByTime(50);
listeners.get(outside)!("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(dir).state).toBe("unknown");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.writeFileSync(`${evidence}.tmp`, "accepted observation"); fs.renameSync(`${evidence}.tmp`, evidence);
listeners.get(outside)!("rename", "report.md"); vi.advanceTimersByTime(50);
listeners.get(outside)!("rename", "report.md"); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(dir).state).toBe("ready");
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
expect(invalidate).toHaveBeenCalledTimes(2);
} finally { watch.close(); }
});
it("rechecks accepted evidence between events without reusing judgment inputs", () => {
it("rechecks accepted evidence between events without reusing judgment inputs", async () => {
const f = fixture(), dir = f.dirs[0]!, evidence = path.join(dir, "proof/evidence.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
recordJudgment(f.missions, { scope: "trial/slices/slice-0", item: item.id, verdict: "accept", reason: "observed", evidence: ["proof/evidence.md"], expectedRevision: item.revision, expectedPrevious: null }, "judge@trial", "fixture");
const o = observe(f.missions);
const o = await observe(f.missions);
try {
expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
f.write(evidence, "changed observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("unknown");
f.write(evidence, "accepted observation"); await o.event(); expect(readSliceReadiness(dir).state).toBe("ready");
expect(o.invalidate).toHaveBeenCalledTimes(2);
} finally { o.watch.close(); }
});
});
describe("QA bounded adverse coverage at 8b236e06", () => {
function adverseFixture() {
async function adverseFixture() {
const f = fixture(), dir = f.dirs[0]!, outside = path.join(f.root, "build", "out"), evidence = path.join(outside, "report.md");
f.write(evidence, "accepted observation");
const item = readSliceReadiness(dir).items[0]!;
@@ -284,46 +291,46 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
const h = { event: listener, error: undefined as (() => void) | undefined }; handles.set(root, h);
return { on: (_kind: string, cb: () => void) => { h.error = cb; }, close: vi.fn() };
}) as unknown as typeof fs.watch);
const invalidate = vi.fn(), emit = vi.fn(), watch = watchProofSources(f.missions, invalidate, { emit } as unknown as EventBus);
const invalidate = vi.fn(), emit = vi.fn(), watch = await startWatch(f.missions, invalidate, { emit } as unknown as EventBus);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(f.missions).missions.map(m => [m.name, m.revision]))).digest("hex");
return { f, dir, outside, evidence, handles, watch, oracle, invalidate, emit };
}
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", () => {
const t = adverseFixture();
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", async () => {
const t = await adverseFixture();
try {
expect(t.watch.observation()).toEqual({ state: "watching", revision: t.oracle() });
const failed = t.handles.get(t.outside)!; failed.error!();
expect(t.watch.observation().state).toBe("unavailable");
t.f.write(t.evidence, "changed observation");
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); vi.advanceTimersByTime(50);
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// A fresh semantic read is not repaired notification coverage for the errored, unreplaced handle.
expect(t.watch.observation().state).toBe("unavailable");
} finally { t.watch.close(); }
});
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", () => {
const t = adverseFixture();
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", async () => {
const t = await adverseFixture();
try {
const opened = () => vi.mocked(fs.watch).mock.calls.filter(([root]) => root === t.outside).length;
expect(opened()).toBe(1);
fs.renameSync(t.outside, `${t.outside}.old`); t.f.write(t.evidence, "changed observation");
// The platform names nothing: only the structural parent of the replaced directory reports a change.
t.handles.get(path.dirname(t.outside))!.event("rename", null); vi.advanceTimersByTime(50);
t.handles.get(path.dirname(t.outside))!.event("rename", null); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.watch.observation().revision).toBe(t.oracle());
// The old same-path handle cannot certify coverage: it is replaced, or the sources are reported unobserved.
expect(opened() === 2 || t.watch.observation().state === "unavailable").toBe(true);
} finally { t.watch.close(); }
});
it("watches the real location of evidence reached through a link inside the project", () => {
const t = adverseFixture();
it("watches the real location of evidence reached through a link inside the project", async () => {
const t = await adverseFixture();
try {
// The evidence directory becomes a link to another directory inside the project, with the same accepted bytes.
const real = path.join(t.f.root, "real"), live = path.join(real, "live");
t.f.write(path.join(live, "report.md"), "accepted observation");
fs.renameSync(t.outside, `${t.outside}.old`); fs.symlinkSync(live, t.outside);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(50);
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("ready");
// Replacing the real directory is seen at its own parent, or the sources are reported unobserved.
const watched = vi.mocked(fs.watch).mock.calls.map(([root]) => root);
@@ -331,7 +338,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
expect(watched.some((root) => !String(root).startsWith(t.f.root) && root !== path.dirname(t.f.root))).toBe(false);
} finally { t.watch.close(); }
});
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, async () => {
const f = fixture(), real = path.join(f.root, "real"), linked = path.join(f.root, "linked-missions");
fs.mkdirSync(real); fs.renameSync(f.missions, path.join(real, "missions")); fs.symlinkSync(path.join(real, "missions"), linked);
vi.useFakeTimers();
@@ -342,22 +349,22 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const oracle = () => createHash("sha256").update(JSON.stringify(readProjectReadiness(linked).missions.map(m => [m.name, m.revision]))).digest("hex");
const watch = watchProofSources(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const watch = await startWatch(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
try {
expect(watch.observation()).toEqual({ state: "watching", revision: oracle() });
fs.renameSync(path.join(real, "missions"), path.join(real, "missions.old"));
fs.cpSync(path.join(real, "missions.old"), path.join(real, "missions"), { recursive: true });
const before = opened.length;
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(50);
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); await vi.advanceTimersByTimeAsync(50);
// A new mission in the replacement tree reaches clients through the live watches only.
fs.cpSync(path.join(real, "missions", "trial"), path.join(real, "missions", "new-mission"), { recursive: true });
for (const h of opened.slice(before)) h.event("rename", h.recursive ? "new-mission/mission.yaml" : "new-mission");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(watch.observation().state === "unavailable" || watch.observation().revision === oracle()).toBe(true);
expect(opened.some((h) => !h.root.startsWith(f.root) && h.root !== path.dirname(f.root))).toBe(false);
} finally { watch.close(); }
});
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, () => {
for (const start of ["outside", "inside"] as const) it(`reports unavailable while a linked missions root's real location is outside the project (starts ${start})`, async () => {
const f = fixture(), linked = path.join(f.root, "linked-missions"), inside = path.join(f.root, "real", "missions");
const other = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "policy-reuse-other-"))); roots.push(other);
const outside = path.join(other, "missions");
@@ -370,21 +377,21 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
opened.push({ root, event: listener });
return { on: vi.fn(), close: vi.fn() };
}) as unknown as typeof fs.watch);
const emit = vi.fn(), watch = watchProofSources(linked, vi.fn(), { emit } as unknown as EventBus);
const emit = vi.fn(), watch = await startWatch(linked, vi.fn(), { emit } as unknown as EventBus);
try {
if (start === "inside") {
expect(watch.observation().state).toBe("watching");
// The supported alias is re-pointed outside the project; the link's own parent sees it.
fs.unlinkSync(linked); fs.symlinkSync(outside, linked);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); vi.advanceTimersByTime(50);
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); await vi.advanceTimersByTimeAsync(50);
expect(emit.mock.calls.map(([e]) => e.revision)).toContain("unavailable");
}
expect(watch.observation().state).toBe("unavailable");
expect(opened.some((h) => h.root.startsWith(other))).toBe(false);
} finally { watch.close(); }
});
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
const t = adverseFixture();
it("QA observes replacement of an external evidence parent or honestly reports unavailable", async () => {
const t = await adverseFixture();
try {
const previous = t.watch.observation().revision;
expect(readSliceReadiness(t.dir).state).toBe("ready");
@@ -394,7 +401,7 @@ describe("QA bounded adverse coverage at 8b236e06", () => {
if (root === path.dirname(t.outside)) h.event("rename", path.basename(t.outside));
if (root === t.f.root) h.event("rename", "build");
}
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readSliceReadiness(t.dir).state).toBe("unknown");
expect(t.oracle()).not.toBe(previous);
expect(t.watch.observation().state === "unavailable" || t.watch.observation().revision === t.oracle()).toBe(true);
@@ -0,0 +1,52 @@
import { afterEach, expect, it } from "vitest";
import * as fs from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { Hono } from "hono";
import { proofRoutes } from "../src/routes/proof.js";
import { slicesRoutes } from "../src/routes/slices.js";
import { readProjectReadiness } from "../src/domain/proof/judgments.js";
const roots: string[] = [];
afterEach(() => { for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); });
it.each(["proof", "slices"])("serves another event-loop turn during the %s project read without changing the result", async target => {
const root = fs.mkdtempSync(join(tmpdir(), "proof-project-yield-")); roots.push(root);
const missions = join(root, "missions");
fs.mkdirSync(missions);
fs.writeFileSync(join(root, "project.yaml"), "kind: project\nmetadata: { id: trial }\n");
for (const name of ["alpha", "beta", "gamma"]) {
fs.mkdirSync(join(missions, name));
fs.writeFileSync(join(missions, name, "mission.yaml"), "kind: mission\nmetadata: { status: active }\ncomposition: { slices: [] }\n");
}
const readiness = readProjectReadiness(missions);
const expected = target === "proof" ? readiness : {
slices: [], totalCount: 0, filter: "all", boundToWorkflow: null,
missions: Object.fromEntries(readiness.missions.map(m => [m.name, { authoredStatus: m.historicalStatus, readiness: m }])),
};
let sourceReady = true;
const app = new Hono();
app.use("*", async (c, next) => {
c.set("sliceIndexer" as never, { isReady: () => true, slicesRoot: missions,
withMembershipBatch: (fn: () => unknown) => fn(), list: () => [],
observedQueueCounts: () => new Map(), missionAuthoredStatuses: () => ({}),
} as never);
c.set("sliceDetailProjector" as never, {} as never);
c.set("proofSourceWatch" as never, { observation: () => ({ state: sourceReady ? "watching" : "unavailable", revision: "fixture" }) } as never);
await next();
});
app.route("/api/proof", proofRoutes());
app.route("/api/slices", slicesRoutes());
let completed = false;
const read = app.request(`/api/${target}`).then(response => { completed = true; return response; });
await new Promise<void>(resolve => setImmediate(resolve));
if (completed) {
const early = await read; expect(early.status).toBe(200);
expect(await early.clone().json()).toMatchObject(expected);
}
expect(completed, "the proof read must yield before finishing the whole tree").toBe(false);
sourceReady = false;
const response = await read;
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject(target === "proof" ? { ...expected, sourceObservation: { state: "unavailable" } } : expected);
});
@@ -6,7 +6,7 @@ const { watch, createProofPolicyRead, readProjectReadiness, resolveProjectRoot,
}));
// Real-location checks see every fixture path as itself (no links in these mocked trees).
vi.mock("node:fs", () => ({ watch, existsSync: () => true, realpathSync: (p: string) => p }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, resolveProjectRoot, proofFs }));
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, readProjectReadinessAsync: async (...args: unknown[]) => readProjectReadiness(...args), resolveProjectRoot, proofFs }));
import { watchProofSources } from "../src/domain/proof/source-watch.js";
import type { EventBus } from "../src/domain/event-bus.js";
afterEach(() => { vi.useRealTimers(); vi.resetAllMocks(); proofFs.exists.mockImplementation(() => true); });
@@ -21,14 +21,14 @@ function reads(paths: (missions: string) => Array<[keyof Io, string]>) {
}
const handle = () => { const close = vi.fn(); watch.mockImplementation(() => ({ on: vi.fn(), close })); return close; };
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", () => {
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")], ["readBytes", path.join(project, "build", "out", "report.md")]]);
const close = handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Inputs at their parents, plus shallow structural anchors up to the project's parent (for replaced directories).
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [project, false], [path.join(project, "build", "out"), false],
@@ -43,23 +43,23 @@ it("watches only the missions root recursively; the project and evidence outside
const notifyProject = watch.mock.calls[1]![2], notifyEvidence = watch.mock.calls[2]![2];
notifyProject("change", "unrelated.txt");
notifyEvidence("change", Buffer.from("other.md"));
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notifyProject("rename", Buffer.from("project.yaml"));
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
notifyEvidence("rename", "report.md");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(4);
// No filename: that directory's watch is rebound (closed, reattached by path) and the read verified again.
notifyEvidence("change", null);
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(6);
instance.close();
expect(close).toHaveBeenCalledTimes(6);
});
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -67,26 +67,26 @@ it("watches the nearest existing ancestor of a missing input, filtered to the ne
proofFs.exists.mockImplementation((p: string) => !p.startsWith(path.join(project, "build")));
reads(() => [["readBytes", path.join(project, "build", "out", "report.md")]]);
handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([[missions, true], [project, false], [path.dirname(project), false]]);
const notify = watch.mock.calls[1]![2];
notify("rename", "unrelated");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
notify("rename", "build");
vi.advanceTimersByTime(50);
await vi.advanceTimersByTimeAsync(50);
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
instance.close();
});
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", async () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const close = vi.fn();
watch.mockReturnValueOnce({ on: vi.fn(), close }).mockImplementationOnce(() => { throw new Error("ENOSPC"); });
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
expect(instance.observation().state).toBe("unavailable");
expect(close).toHaveBeenCalledOnce();
expect(watch).toHaveBeenCalledTimes(2);
@@ -94,12 +94,12 @@ it("reports unavailable and closes what it opened when a required watch fails, w
expect(close).toHaveBeenCalledOnce();
});
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", () => {
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", async () => {
const missions = path.resolve("fixture/missions");
resolveProjectRoot.mockReturnValue(path.dirname(missions));
reads((root) => [["exists", path.join(root, "m1", "mission.yaml")]]);
const close = handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
// Only the missions root's structural anchors are added, shallow.
expect(watch.mock.calls.map(([root, options]) => [root, options.recursive])).toEqual([
[missions, true], [path.dirname(missions), false], [path.dirname(path.dirname(missions)), false],
@@ -108,7 +108,7 @@ it("watches nothing else recursively when the evaluation reads nothing outside t
expect(close).toHaveBeenCalledTimes(3);
});
it("does not report watching when the inputs keep changing past the reconciliation budget", () => {
it("does not report watching when the inputs keep changing past the reconciliation budget", async () => {
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
@@ -120,7 +120,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
return { missions: [] };
});
handle();
const instance = watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
const attached = new Set(watch.mock.calls.map(([root]) => root));
// Either the final dependency set is actually watched and verified, or the monitor says it is not watching.
const finalDir = path.dirname(sets[2]!);
@@ -129,7 +129,7 @@ it("does not report watching when the inputs keep changing past the reconciliati
instance.close();
});
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
it("tells clients when coverage is lost or regained even though the proof result is unchanged", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -146,19 +146,19 @@ it("tells clients when coverage is lost or regained even though the proof result
});
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
expect(instance.observation().state).toBe("watching");
const notifyProject = watch.mock.calls[1]![2];
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable", instance.observation().revision]);
instance.close();
});
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
it("after close, a queued callback neither re-reads, reopens nor emits", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project");
const missions = path.join(project, "missions");
@@ -166,14 +166,141 @@ it("after close, a queued callback neither re-reads, reopens nor emits", () => {
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const notifyMissions = watch.mock.calls[0]![2];
notifyMissions("change", "x");
instance.close();
notifyMissions("change", "y");
vi.advanceTimersByTime(100);
await vi.advanceTimersByTimeAsync(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
expect(watch).toHaveBeenCalledTimes(3);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
it.each(["changed", "unchanged"])("coalesces mid-read events and publishes only a current %s basis", async mode => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: Array<{ name: string; revision: string }> }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
const notify = watch.mock.calls[0]![2];
notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const settledRevision = instance.observation().revision;
const calls = readProjectReadiness.mock.calls.length;
notify("change", "SPEC.md"); notify("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(100);
expect(readProjectReadiness).toHaveBeenCalledTimes(calls);
finish({ missions: [{ name: "trial", revision: "superseded" }] });
await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().revision).toBe(settledRevision);
expect(emit).not.toHaveBeenCalled();
readProjectReadiness.mockReturnValue({ missions: mode === "changed" ? [{ name: "trial", revision: "current" }] : [] });
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(mode === "changed" ? 1 : 0);
instance.close();
});
it("publishes unchanged-revision coverage recovery only after an interrupted read settles cleanly", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
let call = 0;
readProjectReadiness.mockImplementation((_m: string, _r: unknown, _root: unknown, io?: Io) => {
io?.exists(path.join(project, "project.yaml"));
io?.readBytes(path.join(project, `input-${Math.min(call++, 2)}`, "report.md"));
return { missions: [] };
});
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
const settled = instance.observation();
expect(settled.state).toBe("unavailable"); // all three setup passes attached a previously unwatched dependency
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
const notify = watch.mock.calls[0]![2];
notify("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
notify("change", "report.md");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect.soft(instance.observation()).toEqual(settled);
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation()).toEqual({ state: "watching", revision: settled.revision });
expect.soft(emit.mock.calls.map(([event]) => event.revision)).toEqual([settled.revision]);
instance.close();
});
it("reports watch errors immediately while a refresh is still awaiting its read", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
let error!: () => void;
watch.mockImplementation(() => ({ on: (_event: string, callback: () => void) => { error = callback; }, close: vi.fn() }));
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "report.md"); await vi.advanceTimersByTimeAsync(50);
error();
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit.mock.calls.at(-1)![0].revision).toBe("unavailable");
finish({ missions: [] }); await vi.advanceTimersByTimeAsync(0);
expect(instance.observation().state).toBe("unavailable");
expect(emit.mock.calls.every(([event]) => event.revision === "unavailable")).toBe(true);
instance.close();
});
it("does not publish an interrupted setup candidate before a clean refresh", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
const read = readProjectReadiness.getMockImplementation()!;
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockImplementationOnce((...args: unknown[]) => {
read(...args);
return new Promise(resolve => { finish = resolve; });
});
handle();
const emit = vi.fn(), opening = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
watch.mock.calls[0]![2]("change", "report.md");
finish({ missions: [] });
const instance = await opening;
expect(instance.observation()).toEqual({ state: "unavailable", revision: "unavailable" });
expect(emit).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(50);
expect(instance.observation().state).toBe("watching");
expect(emit).toHaveBeenCalledTimes(1);
instance.close();
});
it("does not reattach watches or publish when an in-flight read finishes after close", async () => {
vi.useFakeTimers();
const project = path.resolve("fixture/project"), missions = path.join(project, "missions");
resolveProjectRoot.mockReturnValue(project);
reads(() => [["exists", path.join(project, "project.yaml")]]);
handle();
const emit = vi.fn();
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
let finish!: (value: { missions: [] }) => void;
readProjectReadiness.mockReturnValueOnce(new Promise(resolve => { finish = resolve; }));
watch.mock.calls[0]![2]("change", "SPEC.md");
await vi.advanceTimersByTimeAsync(50);
const watches = watch.mock.calls.length;
instance.close(); finish({ missions: [] });
await vi.advanceTimersByTimeAsync(100);
expect(watch).toHaveBeenCalledTimes(watches);
expect(emit).not.toHaveBeenCalled();
expect(instance.observation().state).toBe("unavailable");
});
@@ -25,10 +25,13 @@ it("watches the owning project policy above a nested missions root", async () =>
expect(readSliceReadiness(slice).state).toBe("ready");
const events: unknown[] = [];
let invalidated = 0;
const watch = watchProofSources(missions, () => invalidated++, {
const watch = await watchProofSources(missions, () => invalidated++, {
emit: (event: unknown) => events.push(event),
} as unknown as EventBus);
try {
// A creation event can dirty setup before it returns; start assertions at the settled observed basis.
await expect.poll(() => watch.observation().state, { timeout: 5000 }).toBe("watching");
events.length = 0; invalidated = 0;
await new Promise(resolve => setTimeout(resolve, 250));
expect(events).toHaveLength(0);
const before = watch.observation().revision;
@@ -43,11 +46,11 @@ it("watches the owning project policy above a nested missions root", async () =>
} finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});
it("retains the existing watch boundary for roots without project.yaml", () => {
it("retains the existing watch boundary for roots without project.yaml", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "proof-legacy-root-"));
const missions = path.join(root, "missions");
fs.mkdirSync(missions);
const watch = watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
const watch = await watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
try { expect(watch.observation().state).toBe("watching"); }
finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
});