mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-11 03:31:38 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da1dfb409 |
No files matched your search
+2
-2
@@ -6,9 +6,9 @@ This isn't a schedule or a promise: we release often, and an item ships when a g
|
||||
To see what's being worked on, by release and theme, open the [OpenRig roadmap board](https://github.com/users/mvschwarz/projects/1).
|
||||
Issues labelled `good first issue` are also listed on the [contribute page](https://github.com/mvschwarz/openrig/contribute).
|
||||
|
||||
## Released in 0.7.10
|
||||
## Released in 0.7.11
|
||||
|
||||
[0.7.10 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.10): it is a patch for people running their own agent teams. A step held after a managed compaction now goes in instead of waiting forever. See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
[0.7.11 is published](https://github.com/mvschwarz/openrig/releases/tag/v0.7.11): OpenRig 0.7.11 is a patch for people running their own agent teams. An ordinary send or work notice now goes into a person's typed draft by default instead of being refused (see Behaviour that changed). See its release notes for what shipped, known issues, upgrade instructions and contributor credits.
|
||||
|
||||
## How to help
|
||||
|
||||
|
||||
@@ -64,8 +64,6 @@ interface WhoamiResult {
|
||||
resolvedBy: string;
|
||||
identity: WhoamiIdentity & Record<string, unknown>;
|
||||
peers: WhoamiPeer[];
|
||||
/** #2871: present only while `rig up` is still creating the team. */
|
||||
rosterState?: "building";
|
||||
edges: { outgoing: WhoamiEdge[]; incoming: WhoamiEdge[] };
|
||||
transcript: { enabled: boolean; path: string | null; tailCommand: string | null };
|
||||
contextUsage?: {
|
||||
@@ -105,8 +103,6 @@ function projectCompactWhoami(data: Record<string, unknown>, bounded?: BoundedPe
|
||||
}),
|
||||
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
|
||||
peersNote: bounded ? bounded.note : data["peersNote"],
|
||||
// #2871: while `rig up` is still creating the team, say so, as the daemon does.
|
||||
...(data["rosterState"] !== undefined ? { rosterState: data["rosterState"] } : {}),
|
||||
...(bounded ? { peersOmitted: bounded.omitted } : {}),
|
||||
// edges already carry only kind + to/from {logicalId, sessionName}.
|
||||
edges: data["edges"],
|
||||
@@ -147,14 +143,9 @@ export function boundPeers(data: Record<string, unknown>, bound: number, allPeer
|
||||
const ordered = [0, 1, 2].flatMap((t) => peers.filter((peer) => tier(peer) === t));
|
||||
const shown = ordered.slice(0, bound);
|
||||
const omitted = peers.length - shown.length;
|
||||
// #2871: while `rig up` is still building the team, the daemon's note opens with that; keep it first, and don't call
|
||||
// the edges complete.
|
||||
const daemonNote = typeof data["peersNote"] === "string" ? data["peersNote"] : "";
|
||||
const building = data["rosterState"] === "building";
|
||||
const opening = building && daemonNote.includes("peers = ") ? daemonNote.slice(0, daemonNote.indexOf("peers = ")) : "";
|
||||
const note = `${opening}peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
const note = `peers = ${shown.length} of this rig's ${peers.length} (roster excluding self; edge peers first, then your pod, then roster order; `
|
||||
+ `bound output.whoami_peers=${bound}); ${omitted} not shown; all: ${allPeersCommand}; `
|
||||
+ `edges = directional relationships (${building ? "the ones written so far" : "always complete"}); use \`rig ps --nodes\` for node inventory including self + live state`;
|
||||
+ "edges = directional relationships (always complete); use `rig ps --nodes` for node inventory including self + live state";
|
||||
return { peers: shown, omitted, note };
|
||||
}
|
||||
|
||||
@@ -424,10 +415,6 @@ left out. --all-peers lists every one (today's compact output).`)
|
||||
console.log(`Transport: ${id.attachmentType === "external_cli" ? "external_cli (outbound only)" : id.attachmentType}`);
|
||||
console.log(`Resolved: via ${data.resolvedBy.replace(/_/g, " ")}`);
|
||||
|
||||
if (data.rosterState === "building") {
|
||||
console.log("");
|
||||
console.log("Team: still being built by `rig up`: the peers and edges below are the ones created so far, and more may come. Run `rig whoami` again once it finishes.");
|
||||
}
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
|
||||
@@ -31,7 +31,7 @@ export async function checkSeatAttention(client: DaemonClient, seats: readonly s
|
||||
const check: AttentionCheck = { attention: [], unknown: [] };
|
||||
const byRig = new Map<string, Set<string>>();
|
||||
for (const seat of seats) {
|
||||
const at = seat.indexOf("@");
|
||||
const at = seat.lastIndexOf("@");
|
||||
if (at <= 0 || at === seat.length - 1) { check.unknown.push({ seat, why: "not a seat@rig name" }); continue; }
|
||||
const rig = seat.slice(at + 1);
|
||||
if (!byRig.has(rig)) byRig.set(rig, new Set());
|
||||
|
||||
@@ -98,18 +98,6 @@ it("a seat that isn't in this daemon's inventory is unknown, never 'fine'", asyn
|
||||
{ seat: "x@elsewhere", why: "no rig named elsewhere on this daemon" }]);
|
||||
});
|
||||
|
||||
it("finds attention for a seat whose rig name contains @", async () => {
|
||||
const { client: c } = client({
|
||||
"/api/ps": { status: 200, data: [{ rigId: "r1", rigName: "team@east" }] },
|
||||
"/api/rigs/r1/nodes": { status: 200, data: [
|
||||
{ canonicalSessionName: "lead@team@east", needsAttention: true, startupStatus: "attention_required", latestError: LOGIN },
|
||||
] },
|
||||
});
|
||||
expect(await checkSeatAttention(c, ["lead@team@east"])).toEqual({
|
||||
attention: [{ seat: "lead@team@east", reason: LOGIN, waitingOnPerson: false }], unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("an unreadable kernel status is reported, not read as no held seat", async () => {
|
||||
const { client: c } = client({ "/api/kernel/status": { status: 503, data: { error: "kernel_boot_tracker_unavailable" } } });
|
||||
expect(await checkKernelAttention(c)).toEqual({ attention: [],
|
||||
|
||||
@@ -214,30 +214,6 @@ describe("rig whoami --json: bounded peers (output.compact, output.whoami_peers)
|
||||
for (const p of BIG_PEERS) expect(out).toContain(p.logicalId);
|
||||
});
|
||||
|
||||
// #2871: while `rig up` is still building the team, every form of the answer keeps saying so.
|
||||
it("a team still being built: rosterState and the daemon's opening survive bounding, and edges aren't called complete", async () => {
|
||||
const BUILDING = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
served = { ...response(BIG_PEERS), rosterState: "building", peersNote: BUILDING + DAEMON_NOTE };
|
||||
// Switch off: the daemon's own note and state.
|
||||
let out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersNote).toBe(BUILDING + DAEMON_NOTE);
|
||||
// Switch on: the bounded note opens the same way, and names the edges as the ones written so far.
|
||||
vi.stubEnv("OPENRIG_OUTPUT_COMPACT", "1");
|
||||
out = JSON.parse(await run("--json"));
|
||||
expect(out.rosterState).toBe("building");
|
||||
expect(out.peersOmitted).toBe(15);
|
||||
expect(out.peersNote).toBe(
|
||||
BUILDING
|
||||
+ "peers = 8 of this rig's 23 (roster excluding self; edge peers first, then your pod, then roster order; "
|
||||
+ "bound output.whoami_peers=8); 15 not shown; all: rig whoami --json --all-peers --node-id 'node-1'; "
|
||||
+ "edges = directional relationships (the ones written so far); use `rig ps --nodes` for node inventory including self + live state",
|
||||
);
|
||||
// Plain text says it too.
|
||||
expect(await run()).toContain("Team: still being built by `rig up`");
|
||||
});
|
||||
|
||||
it("the full-list command names the identity the call used: --session, quoted", () => {
|
||||
const bounded = boundPeers(response(BIG_PEERS), 2, "rig whoami --json --all-peers --session 'dev-impl@big'");
|
||||
expect(bounded.note).toContain("all: rig whoami --json --all-peers --session 'dev-impl@big';");
|
||||
|
||||
@@ -89,6 +89,5 @@ export function permissionDeciderFor(
|
||||
const kernel = seatState(db, KERNEL_OPERATOR_SESSION);
|
||||
if (kernel.state === "missing" || (kernel.state === "unknown" && !kernel.recorded)) return null;
|
||||
return { session: KERNEL_OPERATOR_SESSION, source: "kernel-operator", ...kernel,
|
||||
// A setting that's set but ignored keeps saying so, rather than reading as unset.
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : receiver.note };
|
||||
note: receiver.session ? `${receiver.session}, the receiving agent, ${receiver.session === requester ? "is the seat asking" : "isn't on this host"}` : undefined };
|
||||
}
|
||||
@@ -110,26 +110,6 @@ export class EventBus {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a nested transactional writer (a savepoint inside the active envelope) whose failure the caller handles. If it
|
||||
* throws, its savepoint has rolled back the event rows it persisted, so their tokens leave the envelope too, and the
|
||||
* caller's transaction can still commit. Outside an envelope it just runs.
|
||||
*/
|
||||
discardOnThrow<T>(writer: () => T): T {
|
||||
const envelope = this.activeEnvelope;
|
||||
const persisted = envelope ? new Set(envelope.persisted) : null;
|
||||
const registered = envelope ? new Set(envelope.registered) : null;
|
||||
try {
|
||||
return writer();
|
||||
} catch (err) {
|
||||
if (envelope && persisted && registered) {
|
||||
for (const token of [...envelope.persisted]) if (!persisted.has(token)) envelope.persisted.delete(token);
|
||||
for (const token of [...envelope.registered]) if (!registered.has(token)) envelope.registered.delete(token);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one caller-owned synchronous transaction under the W2b notification
|
||||
* envelope. Every token persisted through EventBus during the callback must
|
||||
|
||||
@@ -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, readProjectReadinessAsync } from "./proof/judgments.js";
|
||||
import { createProofPolicyRead, readMissionReadiness, readProjectReadiness } 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 ? await readProjectReadinessAsync(missionsRoot, readPolicy) : null,
|
||||
project_readiness: missionsRoot ? readProjectReadiness(missionsRoot, readPolicy) : null,
|
||||
// Authored guidance is carried verbatim in meaning, never parsed into edges or acceptance.
|
||||
planning_guidance: arrangement?.state === "valid" ? arrangement.guidance : [],
|
||||
mission,
|
||||
|
||||
@@ -818,9 +818,6 @@ export class MissionControlWriteContract {
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// A runner's permission request isn't closed by an answer: it's handed to the receiving agent, or kept. Say which.
|
||||
const final = after as QueueItem | null;
|
||||
const runnerNotice = !answer.message && final && runnerPermissionOrigin(final) ? runnerAnswerNotice(final) : undefined;
|
||||
return {
|
||||
actionId: actionEntry!.actionId,
|
||||
verb: actionVerb,
|
||||
@@ -830,7 +827,7 @@ export class MissionControlWriteContract {
|
||||
notifyAttempted: false,
|
||||
notifyResult: null,
|
||||
auditedAt: evaluatedAt,
|
||||
answer: runnerNotice ? { ...answer, message: runnerNotice } : answer,
|
||||
answer,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -963,18 +960,6 @@ function verbToClosure(input: MissionControlActionInput): ClosureMapping {
|
||||
}
|
||||
}
|
||||
|
||||
/** What became of a person's answer on a runner's permission request: handed to the receiving agent, or kept (the
|
||||
* person's own receipt says why). Undefined for a request the answer closed (no agent wired here). */
|
||||
function runnerAnswerNotice(ask: QueueItem): string | undefined {
|
||||
if (ask.state === "handed-off" && ask.handedOffTo) {
|
||||
return `Your answer on ${ask.qitemId} went to ${ask.handedOffTo}, the receiving agent, which decides. The runner waits until then; nothing has run.`;
|
||||
}
|
||||
if (ask.state === "pending" || ask.state === "in-progress" || ask.state === "blocked") {
|
||||
return `Your answer is kept on ${ask.qitemId}, and the request is still waiting: the tool hasn't run, and nothing was permitted or refused. A note to you says why it wasn't passed to a receiving agent. To decide directly: rig seat decide-permission ${ask.qitemId} --option <id>.`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function snapshotQitem(q: QueueItem): Record<string, unknown> {
|
||||
return {
|
||||
qitemId: q.qitemId,
|
||||
|
||||
@@ -389,22 +389,6 @@ 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, readProjectReadinessAsync, resolveProjectRoot } from "./judgments.js";
|
||||
import { createProofPolicyRead, proofFs, readProjectReadiness, resolveProjectRoot } from "./judgments.js";
|
||||
|
||||
export interface ProofSourceWatch {
|
||||
close(): void;
|
||||
@@ -76,15 +76,16 @@ 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 async function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): Promise<ProofSourceWatch> {
|
||||
export function watchProofSources(missionsRoot: string, invalidate: () => void, bus: EventBus, scopeChanged?: () => void): 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);
|
||||
// Keep the full semantic read, but let queue/send requests run between missions.
|
||||
const basis = async () => {
|
||||
// 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 = () => {
|
||||
const read = new Set<string>();
|
||||
const note = (p: string) => { read.add(p); return p; };
|
||||
const io: ScopeFsDeps = {
|
||||
@@ -94,8 +95,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
listDir: (p) => proofFs.listDir(note(p)),
|
||||
isDirectory: (p) => proofFs.isDirectory(note(p)),
|
||||
};
|
||||
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");
|
||||
const hash = createHash("sha256").update(JSON.stringify(readProjectReadiness(missionsRoot, createProofPolicyRead(), undefined, io, note).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,7 +107,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
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>();
|
||||
@@ -164,32 +163,25 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
/** 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 = async (): Promise<{ hash: string; covered: boolean }> => {
|
||||
let read = await basis();
|
||||
const evaluate = (): string => {
|
||||
let read = 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.
|
||||
// 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();
|
||||
if (!resubscribe(read.watches)) { covered = recursiveReal !== null && within(recursiveReal, project); return read.hash; }
|
||||
if (i === 2) { covered = false; return read.hash; }
|
||||
read = basis();
|
||||
}
|
||||
};
|
||||
const observed = () => !closed && !failed && covered && readable;
|
||||
const schedule = () => {
|
||||
dirty = true;
|
||||
if (timer || reading || closed) return;
|
||||
timer = setTimeout(async () => {
|
||||
if (timer || closed) return;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
if (closed) return;
|
||||
reading = true; dirty = false;
|
||||
try {
|
||||
if (scopeDirty) { scopeDirty = false; notifyScope(); }
|
||||
const before = observed();
|
||||
const candidate = await evaluate();
|
||||
if (closed || dirty) return;
|
||||
const next = candidate.hash;
|
||||
covered = candidate.covered && !failed;
|
||||
const next = evaluate();
|
||||
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.
|
||||
@@ -198,16 +190,12 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
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();
|
||||
@@ -215,10 +203,7 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
try {
|
||||
recursiveReal = canonical(missionsRoot);
|
||||
recursive = watchDir(missionsRoot, true);
|
||||
try {
|
||||
const candidate = await evaluate();
|
||||
if (!dirty) { revision = candidate.hash; covered = candidate.covered && !failed; readable = true; }
|
||||
} catch (e) {
|
||||
try { revision = evaluate(); readable = true; } catch (e) {
|
||||
if (e instanceof WatchFailure) throw e;
|
||||
// Direct reads name unavailable inputs; watching may recover them.
|
||||
covered = true;
|
||||
@@ -229,9 +214,6 @@ export async function watchProofSources(missionsRoot: string, invalidate: () =>
|
||||
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 }),
|
||||
|
||||
@@ -4257,9 +4257,7 @@ export class QueueRepository {
|
||||
: answer && typeof answer === "object" && "text" in answer ? `wrote "${String(answer.text).trim()}"`
|
||||
: "answered without a choice or words";
|
||||
const person = input.actorSession;
|
||||
// A handoff that fails after persisting its events rolls them back with its savepoint: discardOnThrow takes their
|
||||
// tokens out of the caller's notify envelope too, so the kept answer below still commits.
|
||||
try { this.eventBus.discardOnThrow(() => this.handoffNow({
|
||||
try { this.handoffNow({
|
||||
qitemId: item.qitemId, fromSession: person, toSession: decider,
|
||||
summary: `Decide: ${item.summary ?? "a runner's permission request"} (${person} answered)`,
|
||||
transitionNote: `runner-permission-answer-passed: ${person}'s answer goes to ${decider}, the receiving agent, to decide while the runner waits`,
|
||||
@@ -4275,7 +4273,7 @@ export class QueueRepository {
|
||||
"Act on a clear answer at once: a clear yes or an allow click is the allow option, a clear no or a reject click the reject option. Don't ask them to confirm a clear answer. If their words are unclear or conditional, ask them back, naming this request, then decide.",
|
||||
"Until it's decided (by you, or by anyone with that command), the runner keeps waiting, with no timeout that refuses it; if its turn ends first, nothing is sent.",
|
||||
].join("\n"),
|
||||
})); } catch (error) {
|
||||
}); } catch (error) {
|
||||
// Kept, and committed with the answer's own write (a throw here would roll it back): the request stays open, and
|
||||
// the person is told it couldn't be passed on. Never a default permit.
|
||||
console.error(`[queue] a runner permission answer couldn't be handed to ${decider}; it's kept and the request stays open`, error);
|
||||
@@ -4307,11 +4305,7 @@ export class QueueRepository {
|
||||
if (item.state === "done" || item.state === "failed") {
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
this.transitionLog.append({ qitemId: item.qitemId, state: item.state, actorSession: input.actorSession, transitionNote: input.transitionNote });
|
||||
// The row's state doesn't change, but its decision does: a runner waiting on this request reads it now, and
|
||||
// Steering refreshes.
|
||||
return { persistedEvents: [this.eventBus.persistWithinTransaction({ type: "queue.updated", qitemId: item.qitemId,
|
||||
fromState: item.state, toState: item.state, closureReason: null, closureTarget: null,
|
||||
actorSession: input.actorSession, summary: item.summary ?? null })] };
|
||||
return { persistedEvents: [] as PersistedEvent[] };
|
||||
}
|
||||
if (item.state !== "pending" && item.state !== "in-progress" && item.state !== "blocked") return null;
|
||||
this.db.prepare("UPDATE queue_items SET human_answers = ? WHERE qitem_id = ?").run(JSON.stringify(answers), item.qitemId);
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
// The rigs `rig up` is still building on this daemon: created, with their seats being created and launched one by one
|
||||
// and their edges still to be written. An agent that asks who's on its team in that window is told so, rather than
|
||||
// reading a partial roster as the whole team (#2871). In memory only, like the import guard beside it: after a daemon
|
||||
// restart nothing is marked, so the answer is today's, never a stuck "still being built".
|
||||
const building = new WeakMap<Database.Database, Set<string>>();
|
||||
|
||||
export function markRigBuilding(db: Database.Database, rigId: string): void {
|
||||
let rigs = building.get(db);
|
||||
if (!rigs) building.set(db, (rigs = new Set()));
|
||||
rigs.add(rigId);
|
||||
}
|
||||
|
||||
export function unmarkRigBuilding(db: Database.Database, rigId: string): void {
|
||||
building.get(db)?.delete(rigId);
|
||||
}
|
||||
|
||||
export function rigStillBeingBuilt(db: Database.Database, rigId: string): boolean {
|
||||
return building.get(db)?.has(rigId) ?? false;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { LegacyRigSpecCodec as RigSpecCodec } from "./rigspec-codec.js"; // TODO
|
||||
import type { LegacyRigSpec as RigSpec, LegacyRigSpecEdge as RigSpecEdge, InstantiateOutcome, InstantiateResult } from "./types.js"; // TODO: AS-T08b — migrate to pod-aware RigSpec
|
||||
import { resolveLaunchCwd } from "./cwd-resolution.js";
|
||||
import type { RecordCreated } from "./operation-correlation.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "./rigs-being-built.js";
|
||||
|
||||
// Only these edge kinds constrain launch order
|
||||
const LAUNCH_DEPENDENCY_KINDS = new Set(["delegates_to", "spawned_by"]);
|
||||
@@ -1413,7 +1412,7 @@ export class PodRigInstantiator {
|
||||
name = typeof parsed?.name === "string" ? parsed.name : null;
|
||||
} catch { /* the import below reports the parse error */ }
|
||||
}
|
||||
if (!name) return this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
if (!name) return this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
let inFlight = importsInFlight.get(this.db);
|
||||
if (!inFlight) importsInFlight.set(this.db, (inFlight = new Set()));
|
||||
if (inFlight.has(name)) {
|
||||
@@ -1421,27 +1420,12 @@ export class PodRigInstantiator {
|
||||
}
|
||||
inFlight.add(name);
|
||||
try {
|
||||
return await this.instantiateBuilding(rigSpecYaml, rigRoot, opts);
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, opts);
|
||||
} finally {
|
||||
inFlight.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
/** {@link instantiateOnce}, with the new rig marked as still being built from its creation until every seat and edge
|
||||
* has been written (#2871), so a seat that asks who's on its team in between is told the team isn't complete yet. */
|
||||
private async instantiateBuilding(rigSpecYaml: string, rigRoot: string, opts?: Parameters<PodRigInstantiator["instantiate"]>[2]) {
|
||||
const marked: string[] = [];
|
||||
const recordCreated: RecordCreated = (rigId, nodeId) => {
|
||||
if (nodeId === undefined) { markRigBuilding(this.db, rigId); marked.push(rigId); }
|
||||
opts?.recordCreated?.(rigId, nodeId);
|
||||
};
|
||||
try {
|
||||
return await this.instantiateOnce(rigSpecYaml, rigRoot, { ...opts, recordCreated });
|
||||
} finally {
|
||||
for (const rigId of marked) unmarkRigBuilding(this.db, rigId);
|
||||
}
|
||||
}
|
||||
|
||||
private async instantiateOnce(rigSpecYaml: string, rigRoot: string, opts?: { nonInterruptive?: boolean; cwdOverride?: string; env?: SeatEnv; installRoot?: string; force?: boolean; recordCreated?: RecordCreated; prelaunchHook?: (rigId: string, replacedRigIds: readonly string[]) => Promise<{ ok: true; rollback?: () => Promise<void> } | { ok: false; code: string; message: string; retainRig?: boolean }> }): Promise<InstantiateOutcome> {
|
||||
// 1. Parse + validate
|
||||
let rigSpec: PodRigSpec;
|
||||
|
||||
@@ -366,7 +366,7 @@ export class RunnerPermissions {
|
||||
const words = typed && typeof typed === "object" && typed.kind === "typed-reply" ? String(typed.text ?? "").trim() || null : null;
|
||||
const recorded = this.deps.queueRepo.recordPermissionDecision({ qitemId: live.qitemId, actorSession: decider, questionId: QUESTION_ID, optionId,
|
||||
transitionNote: `runner-permission-decided by ${decider}, an agent, not the person: ${optionId}${reason ? `, because ${clip(reason, MAX_AGENT_TEXT)}` : ""}${words ? `; the person wrote "${words}"` : typeof typed === "string" ? `; the person chose ${typed}` : ""}` });
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided. Read the request again.` };
|
||||
if (!recorded) return { ok: false, code: "not_recorded", message: `The decision couldn't be recorded on ${live.qitemId} (its row is ${live.state}). Nothing was decided; the request is still waiting.` };
|
||||
return this.read(null, askId);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { RigRepository } from "./rig-repository.js";
|
||||
import type { SessionRegistry } from "./session-registry.js";
|
||||
import type { TranscriptStore } from "./transcript-store.js";
|
||||
import { resolveWorkspaceContext, type WhoamiWorkspaceBlock } from "./workspace/workspace-resolver.js";
|
||||
import { rigStillBeingBuilt } from "./rigs-being-built.js";
|
||||
|
||||
export interface WhoamiResult {
|
||||
resolvedBy: "node_id" | "session_name";
|
||||
@@ -44,9 +43,6 @@ export interface WhoamiResult {
|
||||
* Additive note only — `peers[]` name and shape are unchanged.
|
||||
*/
|
||||
peersNote: string;
|
||||
/** Present only while `rig up` is still creating this rig's seats and edges (#2871): the peers and edges are the ones
|
||||
* created so far, and more may come. Absent once the rig is built, and for every rig not being built now. */
|
||||
rosterState?: "building";
|
||||
edges: {
|
||||
outgoing: Array<{ kind: string; to: { logicalId: string; sessionName: string | null } }>;
|
||||
incoming: Array<{ kind: string; from: { logicalId: string; sessionName: string | null } }>;
|
||||
@@ -145,11 +141,6 @@ interface WhoamiDeps {
|
||||
contextUsageStore?: import("./context-usage-store.js").ContextUsageStore;
|
||||
}
|
||||
|
||||
/** Said first in `peersNote` while the rig is still being built, so a seat doesn't plan around a teammate that hasn't been
|
||||
* created yet. Exported for the CLI's bounded note. */
|
||||
export const ROSTER_BUILDING_NOTE = "rosterState = building: `rig up` is still creating this team's seats and edges, so the peers and edges "
|
||||
+ "below are only the ones created so far and more may come; run `rig whoami` again once `rig up` finishes. ";
|
||||
|
||||
export class WhoamiService {
|
||||
private db: Database.Database;
|
||||
private rigRepo: RigRepository;
|
||||
@@ -365,14 +356,11 @@ export class WhoamiService {
|
||||
envOverride: query.targetRepoOverride ?? process.env["OPENRIG_TARGET_REPO"] ?? null,
|
||||
});
|
||||
|
||||
const building = rigStillBeingBuilt(this.db, nodeRow.rig_id);
|
||||
return {
|
||||
resolvedBy,
|
||||
identity,
|
||||
peers,
|
||||
...(building ? { rosterState: "building" as const } : {}),
|
||||
peersNote: (building ? ROSTER_BUILDING_NOTE : "")
|
||||
+ "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
peersNote: "peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state",
|
||||
edges: { outgoing, incoming },
|
||||
transcript,
|
||||
commands: { sendExamples, captureExamples },
|
||||
|
||||
@@ -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, readProjectReadinessAsync, recordJudgment, resolveProofScope, resolveProjectRoot, type JudgeInput } from "../domain/proof/judgments.js";
|
||||
import { JudgmentError, evidenceAt, readSliceReadiness, readMissionReadiness, readProjectReadiness, 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,15 +78,12 @@ 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("/", async c => {
|
||||
app.get("/", 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) {
|
||||
const result = await readProjectReadinessAsync(root, undefined, bound);
|
||||
return c.json({ ...result, ...common, sourceObservation: observation(c, project) });
|
||||
}
|
||||
if (!scope) return c.json({ ...readProjectReadiness(root, undefined, bound), ...common });
|
||||
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") ?? [];
|
||||
|
||||
@@ -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, readProjectReadinessAsync } from "../domain/proof/judgments.js";
|
||||
import { readSliceReadiness, readProjectReadiness } 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("/", async (c) => {
|
||||
app.get("/", (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).
|
||||
const listing = deps.indexer.withMembershipBatch(() => {
|
||||
return 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 {
|
||||
return c.json({
|
||||
slices: filtered.map((s) => ({
|
||||
...s,
|
||||
queueCounts: queueCountsByPath.get(s.slicePath) ?? null,
|
||||
@@ -114,17 +114,13 @@ 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,
|
||||
};
|
||||
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,
|
||||
}])) },
|
||||
});
|
||||
});
|
||||
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.
|
||||
|
||||
@@ -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 = await watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
|
||||
deps.proofSourceWatch = watchProofSources(sliceIndexer.slicesRoot, () => sliceIndexer.invalidate(), eventBus, () => {
|
||||
sliceIndexer.invalidate();
|
||||
eventBus.emit({ type: "scope.sources_changed", scope: sliceIndexer.slicesRoot });
|
||||
});
|
||||
|
||||
@@ -183,10 +183,5 @@ describe("who decides a runner's answered permission request", () => {
|
||||
// A chosen seat this host doesn't have can't decide: the kernel operator, when here, else nobody.
|
||||
expect(permissionDeciderFor(db([]), "runner@team", () => "ghost@team")).toBeNull();
|
||||
expect(permissionDeciderFor(both, "runner@team", () => "ghost@team")).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: "ghost@team, the receiving agent, isn't on this host" });
|
||||
// A setting that's set but ignored keeps its note when the kernel operator decides: it never reads as unset.
|
||||
const ignored = { session: null, problem: 'agents.operator_session is "alex@external", which isn\'t an agent\'s session, so it\'s ignored' };
|
||||
const kernelDecides = permissionDeciderFor(both, "runner@team", () => ignored)!;
|
||||
expect(kernelDecides).toMatchObject({ session: KERNEL_OPERATOR_SESSION, note: ignored.problem });
|
||||
expect(describeDefaultReceiver(kernelDecides)).not.toContain("unset");
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ 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;
|
||||
@@ -121,25 +120,4 @@ 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));
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,6 @@ import { NodeLauncher } from "../src/domain/node-launcher.js";
|
||||
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
|
||||
import { reconcileSkillLoadout } from "../src/domain/skill-catalog.js";
|
||||
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { BootstrapOrchestrator } from "../src/domain/bootstrap-orchestrator.js";
|
||||
import { ServiceOrchestrator } from "../src/domain/service-orchestrator.js";
|
||||
import { ComposeServicesAdapter } from "../src/adapters/compose-services-adapter.js";
|
||||
@@ -110,34 +108,6 @@ describe("PodRigInstantiator", () => {
|
||||
return { db, rigRepo, podRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux };
|
||||
}
|
||||
|
||||
// #2871: a seat that asks who's on its team while `rig up` is still building it is told so; once built, the answer is today's.
|
||||
it("whoami says the team is still being built during instantiate, and answers as before once it's built", async () => {
|
||||
const files = { [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl"), [`${RIG_ROOT}/agents/qa/agent.yaml`]: agentYaml("qa") };
|
||||
const { db, rigRepo, sessionRegistry, inst } = setup(files);
|
||||
const whoami = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore: new TranscriptStore({ transcriptsRoot: "/tmp/transcripts", enabled: true }) });
|
||||
const spec = makeRigSpec({ pods: [{ id: "dev", label: "Dev", members: [
|
||||
{ id: "impl", agentRef: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
{ id: "qa", agentRef: "local:agents/qa", profile: "default", runtime: "claude-code", cwd: "." },
|
||||
], edges: [{ from: "impl", to: "qa", kind: "delegates_to" }] }] });
|
||||
const early: Array<ReturnType<WhoamiService["resolve"]>> = [];
|
||||
let firstNode: string | undefined;
|
||||
const result = await inst.instantiate(RigSpecCodec.serialize(spec), RIG_ROOT, { recordCreated: (_rigId, nodeId) => {
|
||||
if (!nodeId) return;
|
||||
firstNode ??= nodeId;
|
||||
early.push(whoami.resolve({ nodeId: firstNode }));
|
||||
} });
|
||||
expect(result.ok).toBe(true);
|
||||
// Mid-launch: the first seat sees only what exists so far, and is told the team isn't complete.
|
||||
expect(early[0]).toMatchObject({ rosterState: "building", peers: [], edges: { outgoing: [], incoming: [] } });
|
||||
expect(early[0]!.peersNote).toMatch(/^rosterState = building: `rig up` is still creating this team's seats and edges/);
|
||||
// Built: the full roster and edges, with no building state and today's note.
|
||||
const done = whoami.resolve({ nodeId: firstNode! });
|
||||
expect(done).not.toHaveProperty("rosterState");
|
||||
expect(done.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
|
||||
expect(done.peers.map((peer) => peer.logicalId)).toEqual(["dev.qa"]);
|
||||
expect(done.edges.outgoing).toMatchObject([{ kind: "delegates_to", to: { logicalId: "dev.qa" } }]);
|
||||
});
|
||||
|
||||
// T1: valid rig instantiates pods + nodes + edges
|
||||
it("instantiates pods + nodes correctly", async () => {
|
||||
const { db, inst } = setup();
|
||||
|
||||
@@ -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 = await watchProofSources(f.missions, () => { invalidated++; }, {
|
||||
const watch = watchProofSources(f.missions, () => { invalidated++; }, {
|
||||
emit: (event: { type: string; revision: string }) => events.push(event),
|
||||
} as unknown as EventBus);
|
||||
try {
|
||||
@@ -296,11 +296,8 @@ 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 = await watchProofSources(f.missions, () => { invalidated++; }, { emit: (e: { type: string; revision: string }) => events.push(e) } as unknown as EventBus);
|
||||
const watch = 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");
|
||||
@@ -453,7 +450,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 = await watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
|
||||
const watch = watchProofSources(f.missions, () => {}, { emit: () => {} } as unknown as EventBus, () => { scopeChanges++; });
|
||||
try {
|
||||
const updated = original.replace("id: 01-alpha", "id: renamed-alias");
|
||||
fs.writeFileSync(file, updated);
|
||||
|
||||
@@ -153,15 +153,8 @@ 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", () => {
|
||||
async function observe(missions: string) {
|
||||
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>();
|
||||
@@ -170,13 +163,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 = await startWatch(missions, invalidate, { emit } as unknown as EventBus);
|
||||
const watch = watchProofSources(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 = async () => {
|
||||
const event = () => {
|
||||
const previous = watch.observation().revision, calls = invalidate.mock.calls.length;
|
||||
const expected = oracle();
|
||||
changed(); await vi.advanceTimersByTimeAsync(50);
|
||||
changed(); vi.advanceTimersByTime(50);
|
||||
expect(watch.observation()).toEqual({ state: "watching", revision: expected });
|
||||
expect(invalidate).toHaveBeenCalledTimes(calls + Number(previous !== expected));
|
||||
expect(emit).toHaveBeenCalledTimes(invalidate.mock.calls.length);
|
||||
@@ -184,17 +177,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", async () => {
|
||||
it("reads/parses shared policy once in each real watch callback, preserving the uncached revision", () => {
|
||||
const f = fixture();
|
||||
fs.cpSync(f.mission, path.join(f.missions, "second"), { recursive: true });
|
||||
const o = await observe(f.missions), read = vi.spyOn(fs, "readFileSync"), parse = vi.spyOn(YAML, "parse");
|
||||
const o = 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(); await vi.advanceTimersByTimeAsync(50);
|
||||
o.changed(); vi.advanceTimersByTime(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);
|
||||
@@ -203,37 +196,37 @@ describe("watch-local proof-policy reuse", () => {
|
||||
expect.soft(read.mock.calls.filter(([file]) => file === f.missionFile)).toHaveLength(2);
|
||||
}
|
||||
expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
await o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
o.event(); expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
|
||||
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", async () => {
|
||||
it("observes changed, missing, overridden, malformed and unreadable policy on the next event", () => {
|
||||
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 = await observe(f.missions);
|
||||
const o = 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] } });
|
||||
await o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
|
||||
o.event(); expect(readSliceReadiness(f.dirs[0]!).policy!.judges).toEqual([actor]);
|
||||
}
|
||||
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();
|
||||
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();
|
||||
for (const bad of ["proofPolicy: [unclosed", { proofPolicy: { judges: [] } }]) {
|
||||
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"]);
|
||||
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"]);
|
||||
}
|
||||
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);
|
||||
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"]);
|
||||
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"]);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
|
||||
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", async () => {
|
||||
it("observes accepted evidence outside the missions root at its parent, never recursively, through edit and atomic replacement", () => {
|
||||
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]!;
|
||||
@@ -245,7 +238,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 = await startWatch(f.missions, invalidate, { emit: vi.fn() } as unknown as EventBus);
|
||||
const watch = watchProofSources(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() });
|
||||
@@ -253,34 +246,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"); await vi.advanceTimersByTimeAsync(50);
|
||||
listeners.get(outside)!("change", "report.md"); vi.advanceTimersByTime(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"); await vi.advanceTimersByTimeAsync(50);
|
||||
listeners.get(outside)!("rename", "report.md"); vi.advanceTimersByTime(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", async () => {
|
||||
it("rechecks accepted evidence between events without reusing judgment inputs", () => {
|
||||
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 = await observe(f.missions);
|
||||
const o = observe(f.missions);
|
||||
try {
|
||||
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");
|
||||
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");
|
||||
expect(o.invalidate).toHaveBeenCalledTimes(2);
|
||||
} finally { o.watch.close(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("QA bounded adverse coverage at 8b236e06", () => {
|
||||
async function adverseFixture() {
|
||||
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]!;
|
||||
@@ -291,46 +284,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 = await startWatch(f.missions, invalidate, { emit } as unknown as EventBus);
|
||||
const invalidate = vi.fn(), emit = vi.fn(), watch = watchProofSources(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", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("QA retains unavailable after an async required-handle error and another valid event unless coverage is replaced", () => {
|
||||
const t = 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"); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(t.f.missions)!.event("change", "trial/slices/slice-0/SPEC.md"); vi.advanceTimersByTime(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", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("rebinds a replaced evidence parent when its structural parent reports a change without a filename", () => {
|
||||
const t = 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); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", null); vi.advanceTimersByTime(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", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("watches the real location of evidence reached through a link inside the project", () => {
|
||||
const t = 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)); await vi.advanceTimersByTimeAsync(50);
|
||||
t.handles.get(path.dirname(t.outside))!.event("rename", path.basename(t.outside)); vi.advanceTimersByTime(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);
|
||||
@@ -338,7 +331,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"})`, async () => {
|
||||
for (const name of ["missions", null]) it(`rebinds a linked missions root when its real directory is replaced (${name ?? "no filename"})`, () => {
|
||||
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();
|
||||
@@ -349,22 +342,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 = await startWatch(linked, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const watch = watchProofSources(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); await vi.advanceTimersByTimeAsync(50);
|
||||
opened.filter((h) => h.root === real).at(-1)!.event("rename", name); vi.advanceTimersByTime(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");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(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})`, async () => {
|
||||
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})`, () => {
|
||||
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");
|
||||
@@ -377,21 +370,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 = await startWatch(linked, vi.fn(), { emit } as unknown as EventBus);
|
||||
const emit = vi.fn(), watch = watchProofSources(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"); await vi.advanceTimersByTimeAsync(50);
|
||||
opened.filter((h) => h.root === f.root).at(-1)!.event("rename", "linked-missions"); vi.advanceTimersByTime(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", async () => {
|
||||
const t = await adverseFixture();
|
||||
it("QA observes replacement of an external evidence parent or honestly reports unavailable", () => {
|
||||
const t = adverseFixture();
|
||||
try {
|
||||
const previous = t.watch.observation().revision;
|
||||
expect(readSliceReadiness(t.dir).state).toBe("ready");
|
||||
@@ -401,7 +394,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");
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(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);
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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, readProjectReadinessAsync: async (...args: unknown[]) => readProjectReadiness(...args), resolveProjectRoot, proofFs }));
|
||||
vi.mock("../src/domain/proof/judgments.js", () => ({ createProofPolicyRead, readProjectReadiness, 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", async () => {
|
||||
it("watches only the missions root recursively; the project and evidence outside it get shallow, filtered watches", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = 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"));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
|
||||
notifyProject("rename", Buffer.from("project.yaml"));
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
|
||||
notifyEvidence("rename", "report.md");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(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);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(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", async () => {
|
||||
it("watches the nearest existing ancestor of a missing input, filtered to the next path segment", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = 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");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(2);
|
||||
notify("rename", "build");
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
vi.advanceTimersByTime(50);
|
||||
expect(readProjectReadiness).toHaveBeenCalledTimes(3);
|
||||
instance.close();
|
||||
});
|
||||
|
||||
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", async () => {
|
||||
it("reports unavailable and closes what it opened when a required watch fails, without a wider fallback", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = 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", async () => {
|
||||
it("watches nothing else recursively when the evaluation reads nothing outside the missions root", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = 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", async () => {
|
||||
it("does not report watching when the inputs keep changing past the reconciliation budget", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit: vi.fn() } as unknown as EventBus);
|
||||
const instance = 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", async () => {
|
||||
it("tells clients when coverage is lost or regained even though the proof result is unchanged", () => {
|
||||
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 = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const instance = 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"); await vi.advanceTimersByTimeAsync(50);
|
||||
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(50);
|
||||
expect(instance.observation().state).toBe("unavailable");
|
||||
expect(emit.mock.calls.map(([e]) => e.revision)).toEqual(["unavailable"]);
|
||||
notifyProject("change", "project.yaml"); await vi.advanceTimersByTimeAsync(50);
|
||||
notifyProject("change", "project.yaml"); vi.advanceTimersByTime(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", async () => {
|
||||
it("after close, a queued callback neither re-reads, reopens nor emits", () => {
|
||||
vi.useFakeTimers();
|
||||
const project = path.resolve("fixture/project");
|
||||
const missions = path.join(project, "missions");
|
||||
@@ -166,141 +166,14 @@ it("after close, a queued callback neither re-reads, reopens nor emits", async (
|
||||
reads(() => [["exists", path.join(project, "project.yaml")]]);
|
||||
handle();
|
||||
const emit = vi.fn();
|
||||
const instance = await watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const instance = watchProofSources(missions, vi.fn(), { emit } as unknown as EventBus);
|
||||
const notifyMissions = watch.mock.calls[0]![2];
|
||||
notifyMissions("change", "x");
|
||||
instance.close();
|
||||
notifyMissions("change", "y");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
vi.advanceTimersByTime(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]; 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];
|
||||
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];
|
||||
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,13 +25,10 @@ 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 = await watchProofSources(missions, () => invalidated++, {
|
||||
const watch = 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;
|
||||
@@ -46,11 +43,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", async () => {
|
||||
it("retains the existing watch boundary for roots without project.yaml", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "proof-legacy-root-"));
|
||||
const missions = path.join(root, "missions");
|
||||
fs.mkdirSync(missions);
|
||||
const watch = await watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
|
||||
const watch = watchProofSources(missions, () => {}, { emit() {} } as unknown as EventBus);
|
||||
try { expect(watch.observation().state).toBe("watching"); }
|
||||
finally { watch.close(); fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -717,9 +717,7 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
|
||||
it("Steering's Approve or Deny on the request is the person's yes or no: it goes to the agent, or, with none, to the request as before", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The result says what became of the answer (the TUI prints it), never that the request closed.
|
||||
expect(await f.act(askId, "approve")).toMatchObject({ status: 200,
|
||||
body: { answer: { message: `Your answer on ${askId} went to ${DECIDER}, the receiving agent, which decides. The runner waits until then; nothing has run.` } } });
|
||||
expect(await f.act(askId, "approve")).toMatchObject({ status: 200 });
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "handed-off", handedOffTo: DECIDER });
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(handedFrom(f, askId)!.body).toContain(`they wrote "yes"`);
|
||||
@@ -727,8 +725,7 @@ describe("an answered request goes to the receiving agent: the pair's round-0 co
|
||||
for (const verb of ["approve", "deny"] as const) {
|
||||
const g = fixture({ decider: null });
|
||||
const id = String((await g.submit()).body.askId);
|
||||
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb,
|
||||
answer: { message: expect.stringContaining(`Your answer is kept on ${id}, and the request is still waiting: the tool hasn't run`) } } });
|
||||
expect(await g.act(id, verb, { reason: `${verb}: only the test file` })).toMatchObject({ status: 200, body: { verb } });
|
||||
expect(await g.read(id)).toMatchObject({ body: { state: "pending" } });
|
||||
// The audit keeps what the person did: their verb, and their reason.
|
||||
expect(g.db.prepare("SELECT action_verb, reason FROM mission_control_actions WHERE qitem_id = ?").all(id))
|
||||
@@ -929,37 +926,8 @@ describe("an answered request: review-r2's round-1 questions", () => {
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
const spy = vi.spyOn(f.queueRepo, "recordPermissionDecision").mockReturnValue(false);
|
||||
try {
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded",
|
||||
error: expect.stringContaining("Nothing was decided. Read the request again.") } });
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ status: 409, body: { ok: false, code: "not_recorded" } });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an answered request: #2934's follow-ups", () => {
|
||||
it("a decision on a row closed without one persists queue.updated, so a waiting runner and Steering hear it", async () => {
|
||||
const f = fixture({ decider: null });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
await f.answer(askId, { decision: "only the test file" });
|
||||
await f.queueRepo.update({ qitemId: askId, actorSession: PERSON, state: "done", closureReason: "no-follow-on" });
|
||||
const updates = () => (f.db.prepare("SELECT COUNT(*) AS n FROM events WHERE type = 'queue.updated' AND json_extract(payload, '$.qitemId') = ?").get(askId) as { n: number }).n;
|
||||
const before = updates();
|
||||
expect(await f.decide(askId, "allow")).toMatchObject({ body: { state: "answered", optionId: "allow" } });
|
||||
expect(updates()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("a handoff that fails after persisting its events still keeps the person's words (the envelope drops the rolled-back tokens)", async () => {
|
||||
const f = fixture({ decider: DECIDER });
|
||||
const askId = String((await f.submit()).body.askId);
|
||||
// The real handoff persists its two events, then fails at its last check: its savepoint rolls them back.
|
||||
const spy = vi.spyOn(f.queueRepo as unknown as { assertTerminalClosureHasIntent: () => void }, "assertTerminalClosureHasIntent")
|
||||
.mockImplementationOnce(() => { throw new Error("closure check failed"); });
|
||||
try {
|
||||
expect(await f.answer(askId, { decision: "only the test file" })).toMatchObject({ status: 200 });
|
||||
} finally { spy.mockRestore(); }
|
||||
expect(f.queueRepo.getById(askId)).toMatchObject({ state: "pending" });
|
||||
expect((f.queueRepo.getById(askId)!.humanAnswers?.permission as { text?: string })?.text).toBe("only the test file");
|
||||
expect(await f.read(askId)).toMatchObject({ body: { state: "pending" } });
|
||||
expect(f.permits()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,7 @@ import { externalCliAttachmentSchema } from "../src/db/migrations/019_external_c
|
||||
import { RigRepository } from "../src/domain/rig-repository.js";
|
||||
import { SessionRegistry } from "../src/domain/session-registry.js";
|
||||
import { TranscriptStore } from "../src/domain/transcript-store.js";
|
||||
import { WhoamiService, ROSTER_BUILDING_NOTE } from "../src/domain/whoami-service.js";
|
||||
import { markRigBuilding, unmarkRigBuilding } from "../src/domain/rigs-being-built.js";
|
||||
import { WhoamiService } from "../src/domain/whoami-service.js";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
|
||||
function setupDb(): Database.Database {
|
||||
@@ -144,25 +143,6 @@ describe("WhoamiService", () => {
|
||||
expect(result["podRoster"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// #2871: the building state belongs to the rig being built, by id, never to another rig with the same name.
|
||||
it("only the rig being built says so; a running rig with the same name answers as before", () => {
|
||||
const { nodeA } = seedRig();
|
||||
const newRig = rigRepo.createRig("my-rig");
|
||||
const newNode = rigRepo.addNode(newRig.id, "dev.impl", { role: "worker", runtime: "claude-code" });
|
||||
markRigBuilding(db, newRig.id);
|
||||
|
||||
const running = svc.resolve({ nodeId: nodeA.id })!;
|
||||
expect(running).not.toHaveProperty("rosterState");
|
||||
expect(running.peersNote).toBe("peers = this rig's roster excluding self (no edge filter); edges = directional relationships; use `rig ps --nodes` for node inventory including self + live state");
|
||||
|
||||
const building = svc.resolve({ nodeId: newNode.id })!;
|
||||
expect(building.rosterState).toBe("building");
|
||||
expect(building.peersNote.startsWith(ROSTER_BUILDING_NOTE)).toBe(true);
|
||||
|
||||
unmarkRigBuilding(db, newRig.id);
|
||||
expect(svc.resolve({ nodeId: newNode.id })).not.toHaveProperty("rosterState");
|
||||
});
|
||||
|
||||
it("unknown nodeId returns null", () => {
|
||||
seedRig();
|
||||
const result = svc.resolve({ nodeId: "nonexistent" });
|
||||
|
||||
@@ -471,8 +471,7 @@ async function run(): Promise<void> {
|
||||
const actor = actingAs(snapshot.attentionRead, action.actorSession);
|
||||
view.dispatch({ type: "notice", message: action.verb === "resolve" ? `Resolved ${action.qitemId} as ${actor}: it's unparked for its owner, with your decision.`
|
||||
: action.verb === "approve"
|
||||
// A runner's permission request says what became of the answer: handed to its receiving agent, or kept.
|
||||
? result.answer?.message ?? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
? `Approved ${action.qitemId} as ${actor}: the request is closed on its own row.`
|
||||
: result.answer?.message ?? (result.answer?.outcome === "recorded" ? `Recorded on ${action.qitemId}. Still to answer: ${result.answer.unanswered.join("; ")}`
|
||||
: `Answered ${action.qitemId} as ${actor}: the answer went back to the asker, and the request is closed.`) });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user