diff --git a/src/mission-view.test.ts b/src/mission-view.test.ts new file mode 100644 index 0000000..454d0b2 --- /dev/null +++ b/src/mission-view.test.ts @@ -0,0 +1,303 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { launchNext } from "./coordination.js"; +import type { CoordinationChild, CoordinationRecord } from "./coordination.js"; +import { CHANGE_EVENT } from "./plan.js"; +import { coordinationFacts, coordinationMissionView, missionViewEnabled, missionViewFor, NO_FACTS } from "./mission-view.js"; +import type { CoordinationFacts } from "./mission-view.js"; +import type { ShipRuntime } from "./runtime.js"; + +const A = "a".repeat(40); +const A2 = "b".repeat(40); +const C = "c".repeat(40); +const API = "https://forge.example/team/api.git"; +const CLIENT = "https://forge.example/team/client.git"; +const NOW = "2026-10-04T10:00:00.000Z"; + +function child(repo: string, patch: Partial = {}): CoordinationChild { + return { repo, state: "pending", attempts: 0, ...patch }; +} +function record(api: Partial, client: Partial, extra: Partial = {}): CoordinationRecord { + return { + id: "coord-fixture", + parentIntent: "add /v2/quotes and surface it in the app", + model: "m", + api: child(API, api), + client: child(CLIENT, client), + createdAt: NOW, + updatedAt: NOW, + ...extra, + }; +} +const facts = (api: CoordinationFacts["api"], client: CoordinationFacts["client"] = { acceptance: "not-recorded" }, plannedOn?: string): CoordinationFacts => ({ + api, + client, + ...(plannedOn !== undefined ? { plannedOn } : {}), +}); +const accepted = { acceptance: "accepted" as const }; + +// ---- recorded fixtures (shapes a real record takes at each stage) ---- + +const fresh = () => record({}, {}); +const apiRunning = () => record({ state: "running", attempts: 1, runId: "run-coord-api1" }, {}); +const apiMergedUnaccepted = () => record({ state: "merged", attempts: 1, runId: "run-coord-api1", anchorSha: A, mergedSha: A }, {}); +const apiFailedHold = () => + record( + { state: "failed", attempts: 1, runId: "run-coord-api1", failReason: "the run failed" }, + { state: "held", holdReason: "The API change failed before merging (the run failed)." }, + ); +const clientRunning = () => + record({ state: "delivered", attempts: 1, runId: "r-api", anchorSha: A, mergedSha: A }, { state: "running", attempts: 1, runId: "r-client", anchorSha: A }); +const bothMerged = (clientCheck?: CoordinationChild["clientCheck"], extra: Partial = {}) => + record( + { state: "delivered", attempts: 1, runId: "r-api", anchorSha: A, mergedSha: A }, + { state: "merged", attempts: 1, runId: "r-client", anchorSha: C, mergedSha: C, ...(clientCheck !== undefined ? { clientCheck } : {}), ...extra }, + ); + +test("default flag: off unless exactly SHIP_MISSION_VIEW=on", () => { + assert.equal(missionViewEnabled({}), false); + assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: "off" }), false); + assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: "1" }), false); + assert.equal(missionViewEnabled({ SHIP_MISSION_VIEW: " ON " }), true); +}); + +test("a fresh pair: only the api node is ready; the client waits on an unaccepted api", () => { + const v = coordinationMissionView(fresh()); + assert.deepEqual(v.ready, ["api"]); + assert.deepEqual(v.blocked, []); + assert.equal(v.verdict, "not-accepted"); + const client = v.nodes.find((n) => n.id === "client")!; + assert.match(client.reasons.join(";"), /"api" is pending, not accepted/); +}); + +test("api running: nothing is ready", () => { + assert.deepEqual(coordinationMissionView(apiRunning()).ready, []); +}); + +test("api accepted at the anchor: the client becomes ready (view only, nothing launched)", () => { + const v = coordinationMissionView(apiMergedUnaccepted(), facts(accepted)); + assert.deepEqual(v.ready, ["client"]); + assert.deepEqual(v.stale, []); + assert.equal(v.nodes.find((n) => n.id === "client")!.plannedOn, A); +}); + +test("NEGATIVE CONTROL: a finished (merged) but UNACCEPTED api child does not make the client ready or count toward acceptance", () => { + for (const acceptance of ["not-recorded", "pending", "superseded"] as const) { + const v = coordinationMissionView(apiMergedUnaccepted(), facts({ acceptance })); + assert.equal(v.nodes.find((n) => n.id === "api")!.state, "completed", acceptance); + assert.deepEqual(v.ready, [], acceptance); + assert.deepEqual(v.aggregate.covered, [], acceptance); + assert.ok(v.aggregate.unmet.includes("api-change"), acceptance); + assert.equal(v.verdict, "not-accepted", acceptance); + } +}); + +test("a rejected api child blocks the client, like a failure", () => { + const v = coordinationMissionView(apiMergedUnaccepted(), facts({ acceptance: "rejected" })); + assert.equal(v.nodes.find((n) => n.id === "api")!.state, "rejected"); + assert.deepEqual(v.blocked, [{ id: "client", by: ["api"] }]); +}); + +test("failed-API hold: client is blocked by api, nothing ready", () => { + const v = coordinationMissionView(apiFailedHold()); + assert.equal(v.nodes.find((n) => n.id === "api")!.state, "failed"); + assert.deepEqual(v.blocked, [{ id: "client", by: ["api"] }]); + assert.deepEqual(v.ready, []); + assert.equal(v.verdict, "not-accepted"); + assert.ok(v.notes.some((n) => /Client held: The API change failed/.test(n))); + assert.deepEqual(v.aggregate.openNodes.map((n) => n.state).sort(), ["blocked", "failed"]); +}); + +test("a retry that returns the api to pending clears the block", () => { + const retried = record({ state: "pending", attempts: 1 }, { state: "pending" }); + const v = coordinationMissionView(retried); + assert.deepEqual(v.blocked, []); + assert.deepEqual(v.ready, ["api"]); +}); + +test("full pair accepted with a compatible check: aggregate accepted", () => { + const v = coordinationMissionView(bothMerged("compatible"), facts(accepted, accepted, A)); + assert.equal(v.aggregate.accepted, true); + assert.equal(v.verdict, "accepted"); + assert.deepEqual(v.aggregate.unmet, []); + assert.deepEqual(v.stale, []); +}); + +test("NEGATIVE CONTROL: both children merged and compatible but the client has no recorded acceptance => not accepted", () => { + const v = coordinationMissionView(bothMerged("compatible"), facts(accepted, { acceptance: "not-recorded" }, A)); + assert.equal(v.aggregate.accepted, false); + assert.equal(v.verdict, "not-accepted"); + assert.ok(v.aggregate.unmet.includes("client-change")); +}); + +test("compatibility not yet compatible (running/incompatible without accept-risk) => requirement unmet", () => { + for (const check of ["running", "incompatible", "uncertain", "pending"] as const) { + const v = coordinationMissionView(bothMerged(check), facts(accepted, accepted, A)); + assert.ok(v.aggregate.unmet.includes("compatibility"), check); + assert.equal(v.verdict, "not-accepted", check); + } +}); + +test("waiver: accept-risk over an incompatible check is WAIVED, never accepted", () => { + const rec = bothMerged("incompatible", { checkAccepted: { by: "user-9", at: NOW } }); + const v = coordinationMissionView(rec, facts(accepted, accepted, A)); + assert.equal(v.verdict, "waived"); + assert.equal(v.aggregate.accepted, false); + assert.deepEqual(v.aggregate.waived, [{ requirement: "compatibility", actor: "user-9", reason: "accepted the risk of an incompatible compatibility check" }]); + assert.deepEqual(v.aggregate.unmet, []); +}); + +test("waiver without a named actor is invalid and does not waive", () => { + const rec = bothMerged("uncertain"); + const v = coordinationMissionView(rec, facts(accepted, accepted, A), { waivers: [{ requirementId: "compatibility", actor: " ", reason: "ship it" }] }); + assert.equal(v.verdict, "not-accepted"); + assert.deepEqual(v.aggregate.invalidWaivers, ["compatibility"]); +}); + +test("stale revision: the delivery record's merge sha differs from the anchor the client was built on", () => { + // The coordination anchored the client on A; the delivery record says the api merged B. + const v = coordinationMissionView(bothMerged("compatible"), facts({ acceptance: "accepted", deliverySha: A2 }, accepted)); + assert.deepEqual(v.stale, ["client"]); + assert.deepEqual(v.aggregate.staleNodes, ["client"]); + assert.equal(v.verdict, "not-accepted"); + assert.equal(v.nodes.find((n) => n.id === "api")!.revision, A2); + // Stale accepted work covers nothing. + assert.ok(v.aggregate.unmet.includes("client-change")); +}); + +test("stale revision on a pending client: not ready (stale, not satisfied, not missing)", () => { + // launchNext would launch the client on api.anchorSha (A); the delivery record says B. + const rec = record({ state: "delivered", attempts: 1, anchorSha: A, mergedSha: A, runId: "r-api" }, { state: "pending" }); + const v = coordinationMissionView(rec, facts({ acceptance: "accepted", deliverySha: A2 })); + assert.deepEqual(v.ready, []); + assert.deepEqual(v.stale, ["client"]); + assert.match(v.nodes.find((n) => n.id === "client")!.reasons.join(";"), /stale/); + // Control: the same record with a matching delivery sha is ready. + assert.deepEqual(coordinationMissionView(rec, facts({ acceptance: "accepted", deliverySha: A })).ready, ["client"]); + // A running client planned on A against a moved api is flagged stale too. + const running = coordinationMissionView(clientRunning(), facts({ acceptance: "accepted", deliverySha: A2 })); + assert.deepEqual(running.stale, ["client"]); +}); + +test("a merge recorded without a sha has no deliverable: it is not completed, accepted or ready-making", () => { + const rec = record({ state: "merged", attempts: 1, runId: "r-api" }, { state: "held", holdReason: "no anchor" }); + const v = coordinationMissionView(rec, facts(accepted)); + assert.equal(v.nodes.find((n) => n.id === "api")!.state, "running"); + assert.deepEqual(v.ready, []); + assert.ok(v.notes.some((n) => /without a recorded commit sha/.test(n))); +}); + +test("integration gate: declared+flag on => missing evidence blocks an otherwise accepted pair", () => { + const rec = { ...bothMerged("compatible"), integrationCheck: { command: "pnpm test:pair", evidence: [] } }; + const on = coordinationMissionView(rec, facts(accepted, accepted, A), { env: { SHIP_INTEGRATION_CHECK: "on" } }); + assert.equal(on.integration?.state, "missing"); + assert.equal(on.aggregate.accepted, true); // the node aggregate alone is satisfied... + assert.equal(on.verdict, "not-accepted"); // ...the integration gate holds the combined verdict. + const off = coordinationMissionView(rec, facts(accepted, accepted, A), { env: {} }); + assert.equal(off.integration, null); + assert.equal(off.verdict, "accepted"); +}); + +test("integration gate: passing evidence at the exact pair heads satisfies it", () => { + const rec = { + ...bothMerged("compatible"), + integrationCheck: { + command: "pnpm test:pair", + evidence: [ + { + class: "executed-pair" as const, + producer: { repo: API, sha: A }, + consumer: { repo: CLIENT, sha: C }, + command: "pnpm test:pair", + result: "passed" as const, + at: NOW, + }, + ], + }, + }; + const v = coordinationMissionView(rec, facts(accepted, accepted, A), { env: { SHIP_INTEGRATION_CHECK: "on" } }); + assert.equal(v.integration?.state, "satisfied"); + assert.equal(v.verdict, "accepted"); +}); + +test("the projection does not mutate the coordination record", () => { + const rec = bothMerged("compatible"); + const before = JSON.stringify(rec); + coordinationMissionView(rec, facts(accepted, accepted, A)); + assert.equal(JSON.stringify(rec), before); +}); + +// ---- real call path: stores -> facts -> view, and launchNext unaffected ---- + +function ev(seq: number, type: string, name?: string, data?: unknown) { + return { v: 1, seq, type, at: NOW, ...(name !== undefined ? { name } : {}), ...(data !== undefined ? { data } : {}) }; +} +function runtimeWith(events: Record, deliveries: Record): ShipRuntime { + return { + store: { load: async (id: string) => events[id] ?? [] }, + deliveryRecords: { get: async (id: string) => deliveries[id] ?? null }, + } as unknown as ShipRuntime; +} +const started = (extra: object = {}) => ev(1, "run-started", undefined, { input: { task: "t" }, ...extra }); +const approve = (approved: boolean) => [ev(2, "event-waiting", CHANGE_EVENT), ev(3, "event-received", CHANGE_EVENT, { payload: { approved, by: "user-7" } })]; + +test("facts read from the stores: recorded change decision => accepted; delivery sha is carried", async () => { + const rec = bothMerged("compatible"); + const rt = runtimeWith( + { "r-api": [started(), ...approve(true)], "r-client": [started(), ...approve(true)] }, + { "r-api": { state: "confirmed", mergedSha: A }, "r-client": { state: "confirmed", mergedSha: C } }, + ); + const f = await coordinationFacts(rt, rec); + assert.deepEqual(f.api, { acceptance: "accepted", deliverySha: A }); + assert.deepEqual(f.client, { acceptance: "accepted", deliverySha: C }); + const out = await missionViewFor(rt, rec, {}); + assert.ok("view" in out); + assert.equal(out.view.verdict, "accepted"); +}); + +test("NEGATIVE CONTROL (real path): a confirmed delivery without a recorded decision is NOT acceptance", async () => { + const rec = bothMerged("compatible"); + const rt = runtimeWith( + { "r-api": [started(), ev(2, "run-completed", undefined, { output: { status: "finished" } })], "r-client": [started(), ...approve(true)] }, + { "r-api": { state: "confirmed", mergedSha: A }, "r-client": { state: "confirmed", mergedSha: C } }, + ); + const out = await missionViewFor(rt, rec, {}); + assert.ok("view" in out); + assert.equal(out.view.nodes.find((n) => n.id === "api")!.state, "completed"); + assert.equal(out.view.verdict, "not-accepted"); +}); + +test("a denied decision is rejected and blocks the client; an unreadable store is not-recorded, never accepted", async () => { + const rec = bothMerged("compatible"); + const rt = runtimeWith({ "r-api": [started(), ...approve(false)], "r-client": [] }, {}); + const out = await missionViewFor(rt, rec, {}); + assert.ok("view" in out); + assert.equal(out.view.nodes.find((n) => n.id === "api")!.state, "rejected"); + const broken = { store: { load: async () => { throw new Error("boom"); } } } as unknown as ShipRuntime; + assert.deepEqual((await coordinationFacts(broken, rec)).api, { acceptance: "not-recorded" }); +}); + +test("launchNext on a failed-API coordination behaves as before with the view module loaded and used", async () => { + // Minimal in-memory runtime for the real launchNext path (api failed => client held, no enqueue). + const store = new Map(); + const rec = record({ state: "failed", attempts: 1, runId: "r-api", failReason: "the run failed" }, { state: "pending" }); + store.set("SHIP_COORDINATION_coord-fixture", JSON.stringify(rec)); + const runtime = { + config: { + get: async (k: string) => store.get(k), + set: async (k: string, v: string) => void store.set(k, v), + }, + store: { load: async () => [] }, + loadMeta: async () => null, + deliveryRecords: { get: async () => null }, + } as unknown as ShipRuntime; + const first = await launchNext(runtime, "coord-fixture"); + assert.equal(first.launched, null); + assert.equal(first.record.client.state, "held"); + const viewed = coordinationMissionView(first.record, NO_FACTS); + assert.deepEqual(viewed.blocked, [{ id: "client", by: ["api"] }]); + const second = await launchNext(runtime, "coord-fixture"); + assert.equal(second.launched, null); + assert.equal(second.record.client.state, "held"); +}); diff --git a/src/mission-view.ts b/src/mission-view.ts new file mode 100644 index 0000000..6458a95 --- /dev/null +++ b/src/mission-view.ts @@ -0,0 +1,307 @@ +/** + * S22 wiring (wave 4) — a READ-ONLY, derived "Mission view" of an existing + * coordination record, built with mission.ts. SHADOW ONLY: this module launches + * nothing, stores nothing and is never consulted by coordination.ts; the + * coordination's own states, launchNext idempotency and completion rule are + * untouched. The view answers "what would the general mission contract say + * about this pair?" so that contract can be compared with the fixed + * coordinator before anything is ever migrated onto it. + * + * THE PROJECTION (mission.ts header, "HOW THIS SUBSUMES coordination.ts"): + * nodes `api` and `client`, client.dependsOn = ["api"] + * inputRevisions client.inputRevisions.api = the API merge's sha, as the + * coordination recorded it (api.anchorSha) — what the client + * task was told to build against. The record stores only + * one anchor; a client that has merged has had its own + * anchorSha overwritten, so the planned-on revision for a + * launched client is client.anchorSha only while it is + * running/failed, and api.anchorSha otherwise. `plannedOn` + * in the facts overrides it when a caller has a better + * source. + * Deliverable type "patch"; revision = the DELIVERY RECORD's merge sha + * (an independent reading), else the child's own recorded + * merge sha. A delivery sha that differs from the anchor the + * client was planned on is a STALE input, as in rule 5. + * Outcome accepted ONLY from the task record's `acceptance === "accepted"` + * (task-record.ts: a recorded change decision on the latest + * attempt). A merged child with no recorded acceptance is + * `completed`, not `accepted`: "finished" never counts. + * failed-API hold api failed => client `blocked` (mission.ts settle()). + * waivers a recorded accept-risk over an incompatible/uncertain + * compatibility verdict is a WAIVER of the `compatibility` + * requirement (actor = who accepted), reported as waived, + * never as accepted. + * aggregate gate requirements api-change, client-change, compatibility, + * plus integration-evidence's status (via integrationGate, + * so SHIP_INTEGRATION_CHECK and the declaration decide + * whether it applies, exactly as coordinationComplete does). + * + * Flag: SHIP_MISSION_VIEW=on. Default off; the page renders nothing extra and + * this module is not called. + */ +import type { WorkflowEvent } from "@neutron-build/workflow"; +import { integrationGate } from "./coordination.js"; +import type { CoordinationChild, CoordinationRecord } from "./coordination.js"; +import type { IntegrationStatus } from "./integration-evidence.js"; +import { + aggregateAcceptance, + createMission, + nodeReadiness, + readyNodes, +} from "./mission.js"; +import type { AggregateResult, Mission, MissionNode, MissionSpec, NodeSpec, NodeState, Requirement, Waiver } from "./mission.js"; +import { taskRecord } from "./task-record.js"; +import type { AcceptanceState } from "./task-record.js"; +import type { ShipRuntime } from "./runtime.js"; + +export function missionViewEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return (env.SHIP_MISSION_VIEW ?? "").trim().toLowerCase() === "on"; +} + +/** What the task record and delivery record say about one child's latest run. */ +export interface ChildFacts { + /** task-record.ts acceptance of the child's latest attempt. */ + acceptance: AcceptanceState; + /** The delivery record's merged sha, when a record exists and carries one. */ + deliverySha?: string; +} + +export interface CoordinationFacts { + api: ChildFacts; + client: ChildFacts; + /** Overrides the planned-on API revision when the caller can state it. */ + plannedOn?: string; +} + +export const NO_FACTS: CoordinationFacts = { api: { acceptance: "not-recorded" }, client: { acceptance: "not-recorded" } }; + +const REQUIREMENTS: readonly Requirement[] = [ + { id: "api-change", deliverableType: "patch" }, + { id: "client-change", deliverableType: "patch" }, + { id: "compatibility" }, +]; + +const ZERO = { costCents: 0, steps: 0 }; + +interface Built { + mission: Mission; + waivers: Waiver[]; + notes: string[]; +} + +/** The child's own merge sha as the coordination recorded it. */ +function nonEmpty(sha: string | undefined): string | undefined { + return sha === undefined || sha === "" ? undefined : sha; +} + +/** mergedSha when recorded, else the starter's anchorSha (the same fallback coordination.ts uses for the client). */ +function ownSha(child: CoordinationChild): string | undefined { + return nonEmpty(child.mergedSha ?? child.anchorSha); +} + +function nodeStateOf(child: CoordinationChild, facts: ChildFacts, revision: string | undefined): NodeState { + switch (child.state) { + case "pending": + case "held": + return "pending"; + case "running": + return "running"; + case "failed": + return "failed"; + case "merged": + case "delivered": + // Merged without a provable sha has no deliverable to accept; it stays + // `running` (unfinished as far as the contract can tell) and is noted. + if (revision === undefined) return "running"; + if (facts.acceptance === "accepted") return "accepted"; + if (facts.acceptance === "rejected") return "rejected"; + return "completed"; + } +} + +export function missionSpecFromCoordination( + record: CoordinationRecord, + facts: CoordinationFacts = NO_FACTS, +): { spec: MissionSpec; waivers: Waiver[]; notes: string[]; deliverables: Map } { + const notes: string[] = []; + const deliverables = new Map(); + const merged = (c: CoordinationChild) => c.state === "merged" || c.state === "delivered"; + + const apiRevision = merged(record.api) ? (facts.api.deliverySha ?? ownSha(record.api)) : undefined; + const clientRevision = merged(record.client) ? (facts.client.deliverySha ?? ownSha(record.client)) : undefined; + if (merged(record.api) && apiRevision === undefined) notes.push("The API child merged without a recorded commit sha; its deliverable is unproven."); + if (merged(record.client) && clientRevision === undefined) notes.push("The client child merged without a recorded commit sha; its deliverable is unproven."); + if (apiRevision !== undefined) deliverables.set("api", { revision: apiRevision }); + if (clientRevision !== undefined) deliverables.set("client", { revision: clientRevision }); + + // The revision the client was planned on (see the header). + const launchedAnchor = record.client.state === "running" || record.client.state === "failed" ? record.client.anchorSha : undefined; + const plannedOn = nonEmpty(facts.plannedOn) ?? nonEmpty(launchedAnchor) ?? nonEmpty(record.api.anchorSha) ?? apiRevision ?? ""; + + const clientCompatible = record.client.clientCheck === "compatible"; + const nodes: NodeSpec[] = [ + { + id: "api", + goal: `API change in ${record.api.repo}`, + deliverableType: "patch", + dependsOn: [], + inputRevisions: {}, + authority: { grants: ["write:api"] }, + budget: ZERO, + acceptance: { contract: "task record: recorded change decision accepted", covers: ["api-change"] }, + state: nodeStateOf(record.api, facts.api, apiRevision), + attempts: record.api.attempts, + }, + { + id: "client", + goal: `Client change in ${record.client.repo}`, + deliverableType: "patch", + dependsOn: ["api"], + inputRevisions: { api: plannedOn }, + authority: { grants: ["write:client"] }, + budget: ZERO, + acceptance: { + contract: "task record: recorded change decision accepted; compatibility check compatible", + covers: ["client-change", ...(clientCompatible ? ["compatibility"] : [])], + }, + state: nodeStateOf(record.client, facts.client, clientRevision), + attempts: record.client.attempts, + }, + ]; + + const waivers: Waiver[] = []; + const check = record.client.clientCheck; + if ((check === "incompatible" || check === "uncertain") && record.client.checkAccepted !== undefined) { + waivers.push({ + requirementId: "compatibility", + actor: record.client.checkAccepted.by, + reason: `accepted the risk of an ${check} compatibility check`, + }); + } + if (record.client.state === "held" && record.client.holdReason !== undefined) notes.push(`Client held: ${record.client.holdReason}`); + + return { + spec: { + id: record.id, + goal: record.parentIntent, + authority: { grants: ["write:api", "write:client"] }, + budget: ZERO, + nodes, + }, + waivers, + notes, + deliverables, + }; +} + +/** Build the Mission: createMission settles blocked; deliverables/failures are attached afterwards (they never affect settle). */ +function build(record: CoordinationRecord, facts: CoordinationFacts): Built { + const { spec, waivers, notes, deliverables } = missionSpecFromCoordination(record, facts); + const created = createMission(spec); + const nodes: MissionNode[] = created.nodes.map((n) => { + const d = deliverables.get(n.id); + const child = n.id === "api" ? record.api : record.client; + const withDeliverable = d !== undefined && n.state !== "pending" && n.state !== "running" && n.state !== "failed" ? { ...n, deliverable: { type: "patch", revision: d.revision } } : n; + return n.state === "failed" ? { ...withDeliverable, failure: child.failReason ?? "failed" } : withDeliverable; + }); + return { mission: { ...created, nodes }, waivers, notes }; +} + +export interface MissionNodeView { + id: string; + repo: string; + state: NodeState; + revision?: string; + plannedOn?: string; + blockedBy: string[]; + /** nodeReadiness reasons while pending; empty otherwise. */ + reasons: string[]; +} + +export interface MissionView { + missionId: string; + nodes: MissionNodeView[]; + ready: string[]; + blocked: Array<{ id: string; by: string[] }>; + /** Nodes whose planned-on API revision no longer equals the API deliverable's. */ + stale: string[]; + aggregate: AggregateResult; + integration: IntegrationStatus | null; + /** The combined answer: aggregate acceptance AND the integration gate. */ + verdict: "accepted" | "waived" | "not-accepted"; + notes: string[]; +} + +export function coordinationMissionView( + record: CoordinationRecord, + facts: CoordinationFacts = NO_FACTS, + options: { env?: NodeJS.ProcessEnv; waivers?: readonly Waiver[] } = {}, +): MissionView { + const { mission, waivers, notes } = build(record, facts); + const aggregate = aggregateAcceptance(mission, REQUIREMENTS, [...waivers, ...(options.waivers ?? [])]); + const integration = integrationGate(record, options.env !== undefined ? { env: options.env } : {}); + const stale = mission.nodes + .filter((n) => n.dependsOn.some((d) => { + const dep = mission.nodes.find((x) => x.id === d); + return dep?.deliverable !== undefined && dep.deliverable.revision !== n.inputRevisions[d]; + })) + .map((n) => n.id); + const verdict = aggregate.verdict === "not-accepted" || integration?.blocking === true ? "not-accepted" : aggregate.verdict; + return { + missionId: mission.id, + nodes: mission.nodes.map((n) => ({ + id: n.id, + repo: n.id === "api" ? record.api.repo : record.client.repo, + state: n.state, + ...(n.deliverable !== undefined ? { revision: n.deliverable.revision } : {}), + ...(n.dependsOn.length > 0 ? { plannedOn: n.inputRevisions[n.dependsOn[0]!] } : {}), + blockedBy: [...(n.blockedBy ?? [])], + reasons: n.state === "pending" ? nodeReadiness(mission, n.id).reasons : [], + })), + ready: readyNodes(mission).map((n) => n.id), + blocked: mission.nodes.filter((n) => n.state === "blocked").map((n) => ({ id: n.id, by: [...(n.blockedBy ?? [])] })), + stale, + aggregate, + integration, + verdict, + notes, + }; +} + +/** + * Read the two facts the projection needs from the stores, READ-ONLY: the + * child's own latest run as a one-attempt task record (acceptance) and its + * delivery record (merge sha). A child with no run, or an unreadable one, gets + * "not-recorded" — unknown is never read as accepted. + */ +export async function coordinationFacts(runtime: ShipRuntime, record: CoordinationRecord): Promise { + const read = async (child: CoordinationChild): Promise => { + if (child.runId === undefined) return { acceptance: "not-recorded" }; + try { + const events = (await runtime.store.load(child.runId)) as WorkflowEvent[]; + const tr = taskRecord(child.runId, [{ meta: { runId: child.runId, task: "", status: "", model: "", createdAt: "", updatedAt: "" }, events }]); + const delivery = await runtime.deliveryRecords?.get(child.runId).catch(() => null); + return { + acceptance: tr.acceptance, + ...(delivery?.mergedSha !== undefined && delivery.mergedSha !== "" ? { deliverySha: delivery.mergedSha } : {}), + }; + } catch { + return { acceptance: "not-recorded" }; + } + }; + const [api, client] = await Promise.all([read(record.api), read(record.client)]); + return { api, client }; +} + +/** The page loader's one call. Never throws: a failure costs the panel only. */ +export async function missionViewFor( + runtime: ShipRuntime, + record: CoordinationRecord, + env: NodeJS.ProcessEnv = process.env, +): Promise<{ view: MissionView } | { error: string }> { + try { + return { view: coordinationMissionView(record, await coordinationFacts(runtime, record), { env }) }; + } catch (error) { + return { error: error instanceof Error ? error.message : "the mission view could not be built" }; + } +} diff --git a/web/src/routes/coordination.tsx b/web/src/routes/coordination.tsx index c503b6f..264816b 100644 --- a/web/src/routes/coordination.tsx +++ b/web/src/routes/coordination.tsx @@ -17,6 +17,8 @@ import { retryCoordinationChild, rollupCoordinationCost, } from "../../../dist/coordination.js"; +import { missionViewEnabled, missionViewFor } from "../../../dist/mission-view.js"; +import type { MissionView } from "../../../dist/mission-view.js"; import type { CoordinationChild, CoordinationCheckState, CoordinationCost, CoordinationRecord } from "../../../dist/coordination.js"; export const config = { mode: "app" }; @@ -34,7 +36,7 @@ interface ChildView { } interface CoordinationData { - coordinations: Array<{ record: CoordinationRecord; api: ChildView; client: ChildView; cost: CoordinationCost; complete: boolean }>; + coordinations: Array<{ record: CoordinationRecord; api: ChildView; client: ChildView; cost: CoordinationCost; complete: boolean; mission?: { view: MissionView } | { error: string } }>; canApprove: boolean; created: string | null; error: string | null; @@ -63,6 +65,8 @@ export async function loader({ request }: { request: Request }): PromiseMission view unavailable: {mission.error}

; + const v = mission.view; + const list = (ids: string[]) => (ids.length > 0 ? ids.join(", ") : "none"); + return ( +
+
+ Mission view + read-only, derived + + {v.verdict} +
+

Ready to start: {list(v.ready)}

+

Blocked: {v.blocked.length > 0 ? v.blocked.map((b) => `${b.id} (by ${b.by.join(", ")})`).join("; ") : "none"}

+

Stale (planned on a moved revision): {list(v.stale)}

+

+ Aggregate acceptance: {v.aggregate.accepted ? "accepted" : v.aggregate.verdict} + {v.aggregate.unmet.length > 0 ? ` — unmet: ${v.aggregate.unmet.join(", ")}` : ""} + {v.aggregate.waived.length > 0 ? ` — waived (not accepted): ${v.aggregate.waived.map((w) => `${w.requirement} by ${w.actor}`).join(", ")}` : ""} +

+ {v.integration !== null &&

Integration evidence: {v.integration.state} — {v.integration.reason}

} + {v.notes.map((n) =>

{n}

)} +

A finished child counts only once its change is accepted in the task record. Nothing here launches or changes the coordination.

+
+ ); +} + export default function Coordination({ data }: { data: CoordinationData }) { return ( <> @@ -277,7 +308,7 @@ export default function Coordination({ data }: { data: CoordinationData }) {

Coordinated changes ({data.coordinations.length})

- {data.coordinations.map(({ record, api, client, cost, complete }) => ( + {data.coordinations.map(({ record, api, client, cost, complete, mission }) => (
{record.parentIntent.length > 120 ? `${record.parentIntent.slice(0, 120)}…` : record.parentIntent} @@ -367,6 +398,7 @@ export default function Coordination({ data }: { data: CoordinationData }) { )}
)} + {mission !== undefined && }

Total spend: {costLine(cost.total)}

))}