@eir-labs/coltrane 0.5.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/agents/bill.json +59 -0
- package/agents/deploy-agent.json +68 -0
- package/agents/deploy-scout.json +40 -0
- package/agents/john.json +42 -0
- package/agents/miles.json +44 -0
- package/charts/software-delivery-v1.json +9 -0
- package/charts/software-delivery-v2.json +39 -0
- package/dist/src/bifrost_invoker.js +5 -3
- package/dist/src/bifrost_invoker.js.map +1 -1
- package/dist/src/canonical_form.d.ts +23 -0
- package/dist/src/canonical_form.js +53 -0
- package/dist/src/canonical_form.js.map +1 -1
- package/dist/src/chart.d.ts +254 -0
- package/dist/src/chart.js +897 -0
- package/dist/src/chart.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +20 -3
- package/dist/src/claude_invoker.js +30 -5
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +19 -4
- package/dist/src/cli.js +132 -9
- package/dist/src/cli.js.map +1 -1
- package/dist/src/composition.d.ts +24 -0
- package/dist/src/composition.js +50 -5
- package/dist/src/composition.js.map +1 -1
- package/dist/src/conduct_daemon.d.ts +59 -0
- package/dist/src/conduct_daemon.js +178 -0
- package/dist/src/conduct_daemon.js.map +1 -0
- package/dist/src/genome_schema.d.ts +1476 -146
- package/dist/src/genome_schema.js +438 -32
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_store.d.ts +79 -0
- package/dist/src/genome_store.js +503 -0
- package/dist/src/genome_store.js.map +1 -0
- package/dist/src/gig_tracker.d.ts +11 -1
- package/dist/src/gig_tracker.js +5 -0
- package/dist/src/gig_tracker.js.map +1 -1
- package/dist/src/hosted_tools.d.ts +23 -0
- package/dist/src/hosted_tools.js +170 -0
- package/dist/src/hosted_tools.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/ledger.d.ts +22 -0
- package/dist/src/ledger.js +4 -0
- package/dist/src/ledger.js.map +1 -1
- package/dist/src/loader.d.ts +9 -1
- package/dist/src/loader.js +105 -5
- package/dist/src/loader.js.map +1 -1
- package/dist/src/mcp.js +52 -5
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/output_mirror.d.ts +70 -0
- package/dist/src/output_mirror.js +381 -0
- package/dist/src/output_mirror.js.map +1 -0
- package/dist/src/outputs.d.ts +77 -1
- package/dist/src/outputs.js +179 -37
- package/dist/src/outputs.js.map +1 -1
- package/dist/src/play_worker.d.ts +24 -0
- package/dist/src/play_worker.js +49 -0
- package/dist/src/play_worker.js.map +1 -0
- package/dist/src/registry.d.ts +2 -0
- package/dist/src/registry.js +63 -33
- package/dist/src/registry.js.map +1 -1
- package/dist/src/reuse.d.ts +56 -0
- package/dist/src/reuse.js +0 -0
- package/dist/src/reuse.js.map +1 -1
- package/dist/src/runtime.d.ts +91 -2
- package/dist/src/runtime.js +236 -18
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/seal_drill.d.ts +32 -0
- package/dist/src/seal_drill.js +138 -0
- package/dist/src/seal_drill.js.map +1 -0
- package/dist/src/server.d.ts +39 -1
- package/dist/src/server.js +760 -68
- package/dist/src/server.js.map +1 -1
- package/dist/src/skill_subprocess.js +12 -4
- package/dist/src/skill_subprocess.js.map +1 -1
- package/dist/src/supabase_genome.d.ts +28 -0
- package/dist/src/supabase_genome.js +49 -0
- package/dist/src/supabase_genome.js.map +1 -0
- package/dist/src/tool_surface.d.ts +5 -0
- package/dist/src/tool_surface.js +15 -0
- package/dist/src/tool_surface.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/worker.d.ts +182 -0
- package/dist/src/worker.js +609 -0
- package/dist/src/worker.js.map +1 -0
- package/domain_types/branch-state.json +21 -0
- package/domain_types/change-context.json +39 -0
- package/domain_types/change-decision.json +36 -0
- package/domain_types/change-plan.json +47 -0
- package/domain_types/change-request.json +24 -0
- package/domain_types/change-set.json +46 -0
- package/domain_types/change-verdict.json +26 -0
- package/domain_types/deploy-verdict.json +23 -0
- package/domain_types/design-brief.json +37 -0
- package/domain_types/design-concept.json +36 -0
- package/domain_types/design-definition.json +37 -0
- package/domain_types/design-question.json +23 -0
- package/domain_types/design-verdict.json +27 -0
- package/domain_types/preview-deployment.json +32 -0
- package/institutions/quartet.json +344 -0
- package/package.json +21 -3
- package/skills/vercel-api/fixtures/error.json +10 -0
- package/skills/vercel-api/fixtures/ready.json +10 -0
- package/skills/vercel-api/fixtures/unsettled.json +10 -0
- package/skills/vercel-api/meta.json +10 -0
- package/skills/vercel-api/skill.mjs +63 -0
- package/standards/preview-deploy-v1.json +89 -0
- package/standards/product-design-v1.json +122 -0
- package/standards/promote-v1.json +41 -0
- package/standards/software-change-v1.json +147 -0
- package/venues/ci-deploy-room-v1.json +28 -0
- package/venues/empty-room-v1.json +19 -0
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
// THE CHART — one gig as a performance of many standards.
|
|
2
|
+
//
|
|
3
|
+
// A standard is a phase graph over chairs. A chart is the same idea one level up: a typed DAG over
|
|
4
|
+
// STANDARDS, where each MOVEMENT names one and typed EDGES carry a movement's SEALED OUTPUTS into
|
|
5
|
+
// the next movement's entry chairs. Everything a standard already refuses at compose time, a chart
|
|
6
|
+
// refuses at compose time too — a cycle, an edge naming a type nothing seals, an unclassified
|
|
7
|
+
// conditional flow, an entry slot with no provider, a gate key colliding with a human chair — so a
|
|
8
|
+
// misarranged performance dies where it is authored, never at minute nine.
|
|
9
|
+
//
|
|
10
|
+
// THREE SEAMS THIS MODULE OWNS, AND WHY THEY ARE HERE AND NOT IN runtime.ts:
|
|
11
|
+
//
|
|
12
|
+
// 1. COMPOSE (`composeChart`). Eleven rules in a fixed firing order, each independently testable
|
|
13
|
+
// against a minimal violating input, returning a STRUCTURED violation list. A rule that fires
|
|
14
|
+
// stops the walk: a chart is refused for one named reason, not for a heap of cascading ones.
|
|
15
|
+
// 2. IDENTITY (`chartHash`). The arrangement's own hash, folded into `run_fingerprint` in the slot
|
|
16
|
+
// that held `genome_hash`. For the degenerate one-movement chart it SHORT-CIRCUITS to
|
|
17
|
+
// `genomeHash(standard)` verbatim, which is what makes a single-standard gig's fingerprint
|
|
18
|
+
// byte-identical to what it was before charts existed.
|
|
19
|
+
// 3. PERFORM (`runChart`). Walk the movements in topological order, calling `runGig` per movement.
|
|
20
|
+
// The runtime stays the thing that runs ONE standard; the chart is the thing that arranges
|
|
21
|
+
// several. Between movements — the only place a stop is free — the chart checks the approval
|
|
22
|
+
// gates and the budget envelope, and records what completed so a resume never re-derives it.
|
|
23
|
+
//
|
|
24
|
+
// Spec: sealed design gig 51fda6b1 (product-design-v1), design-concept `chart-schema-spec-001`,
|
|
25
|
+
// verified by a deliver-phase verdict (7/7) and approved by the governor. The ChartSchema itself
|
|
26
|
+
// lives with every other genome class in src/genome_schema.ts — one Zod source, no exceptions.
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import { ChartSchema } from "./genome_schema.js";
|
|
29
|
+
import { toolBaseName } from "./tool_providers.js";
|
|
30
|
+
import { composeMovementGigId } from "./outputs.js";
|
|
31
|
+
import { canonJson, sha256Hex, CANONICAL_FORM_VERSION } from "./canonical_form.js";
|
|
32
|
+
import { producersSha, CHECKPOINT_SCHEMA_VERSION } from "./reuse.js";
|
|
33
|
+
import { runGig, genomeHash, outputSatisfiesType, CORE_TO_PRIMITIVE, ResumeRefused } from "./runtime.js";
|
|
34
|
+
import { drainGigHeader } from "./output_mirror.js";
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
// Identity
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
38
|
+
/** One movement, no edges, no gates: the single-standard gig, wearing the chart's clothes. */
|
|
39
|
+
export function isDegenerateChart(chart) {
|
|
40
|
+
return chart.movements.length === 1 && (chart.edges?.length ?? 0) === 0 && (chart.approval_gates?.length ?? 0) === 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The arrangement's identity.
|
|
44
|
+
*
|
|
45
|
+
* MOVEMENT lines (sorted by movement_id) fold `movement_id` with the movement's standard
|
|
46
|
+
* projection; EDGE lines (sorted) fold the endpoints, the type and the declared `optional` flag;
|
|
47
|
+
* GATE lines fold the arrangement's human seats. Declaration order therefore cannot move the hash,
|
|
48
|
+
* and the same standards arranged differently cannot share it.
|
|
49
|
+
*
|
|
50
|
+
* The standard projection is `genomeHash(standard)` ITSELF, deliberately: the spec asked for "the
|
|
51
|
+
* same field set genomeHash folds today", and the only way to guarantee those are the same bytes —
|
|
52
|
+
* now and after the next change to that field set — is to call the same function. It is also what
|
|
53
|
+
* makes the degenerate short-circuit below an exact identity rather than a coincidence.
|
|
54
|
+
*/
|
|
55
|
+
export function chartHash(plan) {
|
|
56
|
+
const edges = plan.chart.edges ?? [];
|
|
57
|
+
const gates = plan.chart.approval_gates ?? [];
|
|
58
|
+
// THE SHORT-CIRCUIT. A one-movement, no-edge, no-gate chart hashes to exactly what its standard
|
|
59
|
+
// hashed to before charts existed — no movement_id, no wrapper bytes. Falsifiable, and falsified
|
|
60
|
+
// by tests/chart.test.ts: chartHash(oneMovement) === genomeHash(sameStandard).
|
|
61
|
+
if (plan.movements.length === 1 && edges.length === 0 && gates.length === 0) {
|
|
62
|
+
return genomeHash(plan.movements[0].standard);
|
|
63
|
+
}
|
|
64
|
+
const movementLines = [...plan.movements]
|
|
65
|
+
.map((m) => `M\t${m.movement_id}\t${genomeHash(m.standard)}`)
|
|
66
|
+
.sort();
|
|
67
|
+
// The DECLARED edge, `optional` flag included — the arrangement as authored, per the spec's
|
|
68
|
+
// formula. (The R6 classification is derivable from it plus the source movement's projection,
|
|
69
|
+
// which is already folded above, so nothing is lost by folding the declaration instead.)
|
|
70
|
+
const edgeLines = [...edges]
|
|
71
|
+
.map((e) => `E\t${e.from_movement}\t${e.to_movement}\t${e.output_type}\t${e.optional}`)
|
|
72
|
+
.sort();
|
|
73
|
+
// GATE lines are an addition to the spec's two-line formula, deliberately: an approval gate
|
|
74
|
+
// changes what the performance DOES (it requires a person), so a chart that gained one is not the
|
|
75
|
+
// same arrangement, and a resume across the change must be refused rather than silently accepted.
|
|
76
|
+
const gateLines = [...gates]
|
|
77
|
+
.map((g) => `G\t${g.gate_id}\t${g.after_movement}\t${g.before_movement}\t${g.chair}`)
|
|
78
|
+
.sort();
|
|
79
|
+
return sha256Hex([...movementLines, ...edgeLines, ...gateLines].join("\n") + "\n");
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Desugar a bare `standard_slug` into the chart it always was.
|
|
83
|
+
*
|
|
84
|
+
* `chart_slug == movement_id == standard_slug`, no edges, no gates, and the dispatch payload
|
|
85
|
+
* becomes the movement's `runtime_fills`. Every existing caller path is preserved because a
|
|
86
|
+
* single-standard gig IS this chart.
|
|
87
|
+
*/
|
|
88
|
+
export function degenerateChart(standard_slug, gig_input) {
|
|
89
|
+
return ChartSchema.parse({
|
|
90
|
+
slug: standard_slug,
|
|
91
|
+
movements: [{ movement_id: standard_slug, standard_slug, runtime_fills: gig_input ?? {} }],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The dispatch target refine: EXACTLY ONE of `standard_slug` / `chart_slug`.
|
|
96
|
+
*
|
|
97
|
+
* Both optional at the outer level (so no existing caller's shape breaks) and refined to
|
|
98
|
+
* exactly-one, because "a standard AND a chart" names two performances and "neither" names none.
|
|
99
|
+
*/
|
|
100
|
+
export function dispatchTarget(args) {
|
|
101
|
+
const std = (args.standard_slug ?? "").trim();
|
|
102
|
+
const chart = (args.chart_slug ?? "").trim();
|
|
103
|
+
if (std !== "" && chart !== "") {
|
|
104
|
+
return { ok: false, error: `dispatch names exactly one target: standard_slug "${std}" and chart_slug "${chart}" are both set. A single-standard dispatch IS the one-movement chart — pass one.` };
|
|
105
|
+
}
|
|
106
|
+
if (std === "" && chart === "") {
|
|
107
|
+
return { ok: false, error: `dispatch names exactly one target: neither standard_slug nor chart_slug was supplied.` };
|
|
108
|
+
}
|
|
109
|
+
return std !== "" ? { ok: true, kind: "standard", slug: std } : { ok: true, kind: "chart", slug: chart };
|
|
110
|
+
}
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
112
|
+
// Compose — R0..R10, in firing order
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
114
|
+
/** The charset the checkpoint store's path guard admits. An id outside it cannot be resumed. */
|
|
115
|
+
const MOVEMENT_ID_SAFE = /^[A-Za-z0-9._-]+$/;
|
|
116
|
+
/** Roles nothing else depends on: the seats whose outputs leave the standard. */
|
|
117
|
+
function terminalRoles(standard) {
|
|
118
|
+
const depended = new Set();
|
|
119
|
+
for (const ph of standard.phases)
|
|
120
|
+
for (const ch of ph.chairs)
|
|
121
|
+
for (const d of ch.depends_on)
|
|
122
|
+
depended.add(d);
|
|
123
|
+
const out = new Set();
|
|
124
|
+
for (const ph of standard.phases)
|
|
125
|
+
for (const ch of ph.chairs)
|
|
126
|
+
if (!depended.has(ch.role))
|
|
127
|
+
out.add(ch.role);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
function chairsOf(standard) {
|
|
131
|
+
return standard.phases.flatMap((p) => [...p.chairs]);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What a standard SEALS, split by whether it is promised or merely possible.
|
|
135
|
+
*
|
|
136
|
+
* `required` is the union of the standard's declared `output_types` and every terminal chair's
|
|
137
|
+
* promised-and-not-optional contract. The union, not just the declaration, because
|
|
138
|
+
* `Standard.output_types` is optional in the schema and a type a terminal chair genuinely seals is
|
|
139
|
+
* sealed whether or not the standard restates it — a rule that called that a dead name would refuse
|
|
140
|
+
* most real standards.
|
|
141
|
+
*/
|
|
142
|
+
function sealedTypes(standard) {
|
|
143
|
+
const terminal = terminalRoles(standard);
|
|
144
|
+
const required = new Set(standard.output_types ?? []);
|
|
145
|
+
const optional = new Set();
|
|
146
|
+
for (const ch of chairsOf(standard)) {
|
|
147
|
+
if (!terminal.has(ch.role))
|
|
148
|
+
continue;
|
|
149
|
+
const opt = new Set(ch.optional_outputs ?? []);
|
|
150
|
+
for (const t of ch.output_contract)
|
|
151
|
+
(opt.has(t) ? optional : required).add(t);
|
|
152
|
+
}
|
|
153
|
+
for (const t of required)
|
|
154
|
+
optional.delete(t);
|
|
155
|
+
return { required, optional };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The types a movement must receive FROM OUTSIDE its standard.
|
|
159
|
+
*
|
|
160
|
+
* A type is an outside need when a chair consumes it, the standard declares it as a gig input
|
|
161
|
+
* (#177's contract — the standard's own statement that it comes from elsewhere), and no chair in
|
|
162
|
+
* the standard produces it. Anything else is the standard's internal dataflow, which
|
|
163
|
+
* `composeStandard` has already validated and which is none of the chart's business.
|
|
164
|
+
*/
|
|
165
|
+
function outsideNeeds(standard) {
|
|
166
|
+
const declared = new Set(standard.input_types ?? []);
|
|
167
|
+
const producedInside = new Set();
|
|
168
|
+
for (const ch of chairsOf(standard))
|
|
169
|
+
for (const t of ch.output_contract)
|
|
170
|
+
producedInside.add(t);
|
|
171
|
+
const needs = new Set();
|
|
172
|
+
for (const ch of chairsOf(standard)) {
|
|
173
|
+
for (const t of ch.input_contract) {
|
|
174
|
+
if (!declared.has(t) || producedInside.has(t))
|
|
175
|
+
continue;
|
|
176
|
+
needs.add(t);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...needs];
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The effective tool set of one agent in one room: its OWN grants, intersected with the room's
|
|
183
|
+
* equipment. Grants are returned as the agent declared them (scoping intact) and matched on their
|
|
184
|
+
* BASE name, because that is how every other grant resolution in the engine matches — a room that
|
|
185
|
+
* holds `Bash` holds `Bash(npx vitest run:*)`.
|
|
186
|
+
*
|
|
187
|
+
* The direction is the whole point: this can only ever return a subset of `agent.allowed_tools`. A
|
|
188
|
+
* tool present in the room and absent from the charter does not appear, so a venue cannot hand a
|
|
189
|
+
* player authority its charter never claimed.
|
|
190
|
+
*/
|
|
191
|
+
export function venueEffectiveTools(agent, venue) {
|
|
192
|
+
const room = new Set(venue.equipment.tools.map(toolBaseName));
|
|
193
|
+
return (agent.allowed_tools ?? []).filter((g) => room.has(toolBaseName(g)));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The types a chart's SOURCE movements are seeded with — the honest answer to R7 at load time.
|
|
197
|
+
*
|
|
198
|
+
* R7 refuses a movement whose required outside input has no provider: no incoming hard edge, no
|
|
199
|
+
* `runtime_fills` entry, and not declared on the dispatch payload. The payload is a dispatch-time
|
|
200
|
+
* fact, so a LOADER cannot know it — and a loader that passed nothing would make every chart whose
|
|
201
|
+
* first movement declares a gig contract unloadable, which is not a defect in the chart.
|
|
202
|
+
*
|
|
203
|
+
* So the load-time answer is this: a movement with NO incoming edge is a boundary movement, seeded
|
|
204
|
+
* from outside the arrangement, and its standard's own `input_types` — the standard's declaration
|
|
205
|
+
* that these types enter from elsewhere (#177) — are what the payload is expected to carry. An
|
|
206
|
+
* INTERIOR movement gets nothing from here, so the dead slot R7 exists for (movement B needs a type
|
|
207
|
+
* and nothing upstream produces it) still fires at load.
|
|
208
|
+
*
|
|
209
|
+
* The strict check happens where the payload is a fact: `gig_dispatch` re-composes the chart with
|
|
210
|
+
* the real payload's keys, so a dispatch that does not actually carry the seed is refused there.
|
|
211
|
+
*/
|
|
212
|
+
export function chartEntrySeedTypes(chart, standards) {
|
|
213
|
+
const hasIncoming = new Set(chart.edges.map((e) => e.to_movement));
|
|
214
|
+
const seeds = new Set();
|
|
215
|
+
for (const m of chart.movements) {
|
|
216
|
+
if (hasIncoming.has(m.movement_id))
|
|
217
|
+
continue;
|
|
218
|
+
for (const t of standards.get(m.standard_slug)?.input_types ?? [])
|
|
219
|
+
seeds.add(t);
|
|
220
|
+
}
|
|
221
|
+
return [...seeds];
|
|
222
|
+
}
|
|
223
|
+
export function composeChart(input) {
|
|
224
|
+
const V = (v) => ({ ok: false, violations: v });
|
|
225
|
+
// ── R0 schema ────────────────────────────────────────────────────────────────────────────────
|
|
226
|
+
// A malformed chart never reaches the relational rules.
|
|
227
|
+
let chart;
|
|
228
|
+
try {
|
|
229
|
+
chart = ChartSchema.parse(input.chart);
|
|
230
|
+
}
|
|
231
|
+
catch (e) {
|
|
232
|
+
return V([{ rule: "R0", detail: `chart does not parse: ${e instanceof Error ? e.message : String(e)}` }]);
|
|
233
|
+
}
|
|
234
|
+
// ── R1 movement_id uniqueness (and addressability) ───────────────────────────────────────────
|
|
235
|
+
// Must fire before every rule that keys on movement_id — which, after R1, is all of them.
|
|
236
|
+
{
|
|
237
|
+
const violations = [];
|
|
238
|
+
const seen = new Set();
|
|
239
|
+
for (const m of chart.movements) {
|
|
240
|
+
if (seen.has(m.movement_id)) {
|
|
241
|
+
violations.push({ rule: "R1", movement_id: m.movement_id, detail: `duplicate movement_id "${m.movement_id}" — movement_id is the checkpoint and reuse namespace, so two movements sharing one would share each other's cached work.` });
|
|
242
|
+
}
|
|
243
|
+
seen.add(m.movement_id);
|
|
244
|
+
if (!MOVEMENT_ID_SAFE.test(m.movement_id)) {
|
|
245
|
+
violations.push({ rule: "R1", movement_id: m.movement_id, detail: `movement_id "${m.movement_id}" is not addressable: a checkpoint id must match ${String(MOVEMENT_ID_SAFE)}. An id outside that charset reads back as "no checkpoint", which would make this movement silently un-resumable.` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (violations.length > 0)
|
|
249
|
+
return V(violations);
|
|
250
|
+
}
|
|
251
|
+
// ── R2 standard resolution ───────────────────────────────────────────────────────────────────
|
|
252
|
+
const resolved = [];
|
|
253
|
+
{
|
|
254
|
+
const violations = [];
|
|
255
|
+
for (const m of chart.movements) {
|
|
256
|
+
const standard = input.standards.get(m.standard_slug);
|
|
257
|
+
if (!standard) {
|
|
258
|
+
violations.push({ rule: "R2", movement_id: m.movement_id, detail: `unknown standard "${m.standard_slug}" in movement "${m.movement_id}" — it resolves to nothing in this genome.` });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
resolved.push({ movement_id: m.movement_id, standard, runtime_fills: m.runtime_fills, seatings: m.seatings });
|
|
262
|
+
}
|
|
263
|
+
if (violations.length > 0)
|
|
264
|
+
return V(violations);
|
|
265
|
+
}
|
|
266
|
+
const byId = new Map(resolved.map((m) => [m.movement_id, m]));
|
|
267
|
+
// ── R3 seating resolution (dead seat) ────────────────────────────────────────────────────────
|
|
268
|
+
// The same defect class as a granted tool with no provider: a seating that names a chair the
|
|
269
|
+
// standard does not declare, or an agent the genome does not hold, backs nothing.
|
|
270
|
+
{
|
|
271
|
+
const violations = [];
|
|
272
|
+
for (const m of resolved) {
|
|
273
|
+
const roles = new Set(chairsOf(m.standard).map((c) => c.role));
|
|
274
|
+
for (const s of m.seatings) {
|
|
275
|
+
if (!roles.has(s.chair)) {
|
|
276
|
+
violations.push({ rule: "R3", movement_id: m.movement_id, detail: `movement "${m.movement_id}" seats chair "${s.chair}", which standard "${m.standard.slug}" does not declare. Declared chairs: [${[...roles].join(", ")}].` });
|
|
277
|
+
}
|
|
278
|
+
if (input.agents && !input.agents.has(s.agent_slug)) {
|
|
279
|
+
violations.push({ rule: "R3", movement_id: m.movement_id, detail: `movement "${m.movement_id}" seats agent "${s.agent_slug}" in chair "${s.chair}", and no such agent resolves in this genome — a dead seat.` });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (violations.length > 0)
|
|
284
|
+
return V(violations);
|
|
285
|
+
}
|
|
286
|
+
// ── R4 edge + gate endpoint resolution ───────────────────────────────────────────────────────
|
|
287
|
+
// Gate endpoints are resolved HERE, not with the gate keys in R8, because R5's graph includes
|
|
288
|
+
// gate ordering edges and cannot be built over unknown nodes.
|
|
289
|
+
{
|
|
290
|
+
const violations = [];
|
|
291
|
+
for (const e of chart.edges) {
|
|
292
|
+
for (const [side, id] of [["from_movement", e.from_movement], ["to_movement", e.to_movement]]) {
|
|
293
|
+
if (!byId.has(id)) {
|
|
294
|
+
violations.push({ rule: "R4", edge: { from_movement: e.from_movement, to_movement: e.to_movement, output_type: e.output_type }, detail: `edge references unknown movement "${id}" (${side}).` });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const g of chart.approval_gates) {
|
|
299
|
+
for (const [side, id] of [["after_movement", g.after_movement], ["before_movement", g.before_movement]]) {
|
|
300
|
+
if (!byId.has(id)) {
|
|
301
|
+
violations.push({ rule: "R4", gate_id: g.gate_id, detail: `approval gate "${g.gate_id}" references unknown movement "${id}" (${side}).` });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (violations.length > 0)
|
|
306
|
+
return V(violations);
|
|
307
|
+
}
|
|
308
|
+
// ── R5 acyclicity, over edges ∪ gate ordering ────────────────────────────────────────────────
|
|
309
|
+
const order = [];
|
|
310
|
+
{
|
|
311
|
+
const adj = new Map(resolved.map((m) => [m.movement_id, new Set()]));
|
|
312
|
+
for (const e of chart.edges)
|
|
313
|
+
adj.get(e.from_movement).add(e.to_movement);
|
|
314
|
+
// A gate is an ORDERING as much as an approval: nothing before it may wait on anything after it.
|
|
315
|
+
for (const g of chart.approval_gates)
|
|
316
|
+
if (g.after_movement !== g.before_movement)
|
|
317
|
+
adj.get(g.after_movement).add(g.before_movement);
|
|
318
|
+
const WHITE = 0, GREY = 1, BLACK = 2;
|
|
319
|
+
const color = new Map([...adj.keys()].map((k) => [k, WHITE]));
|
|
320
|
+
const stack = [];
|
|
321
|
+
let cycle;
|
|
322
|
+
const visit = (id) => {
|
|
323
|
+
if (cycle)
|
|
324
|
+
return;
|
|
325
|
+
color.set(id, GREY);
|
|
326
|
+
stack.push(id);
|
|
327
|
+
for (const next of adj.get(id) ?? []) {
|
|
328
|
+
if (cycle)
|
|
329
|
+
break;
|
|
330
|
+
const c = color.get(next);
|
|
331
|
+
if (c === GREY) {
|
|
332
|
+
cycle = [...stack.slice(stack.indexOf(next)), next];
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
if (c === WHITE)
|
|
336
|
+
visit(next);
|
|
337
|
+
}
|
|
338
|
+
stack.pop();
|
|
339
|
+
color.set(id, BLACK);
|
|
340
|
+
// Post-order push, reversed below: the topological order the performance walks.
|
|
341
|
+
if (!cycle)
|
|
342
|
+
order.push(id);
|
|
343
|
+
};
|
|
344
|
+
// Declaration order seeds the DFS, so a chart with no edges walks as written.
|
|
345
|
+
for (const m of resolved)
|
|
346
|
+
if (color.get(m.movement_id) === WHITE)
|
|
347
|
+
visit(m.movement_id);
|
|
348
|
+
if (cycle) {
|
|
349
|
+
return V([{ rule: "R5", detail: `chart is cyclic: ${cycle.join(" → ")}. A performance has a first movement.` }]);
|
|
350
|
+
}
|
|
351
|
+
order.reverse();
|
|
352
|
+
}
|
|
353
|
+
// ── R6 dead-name / optional-flow classification ──────────────────────────────────────────────
|
|
354
|
+
// The entry-chair-seed rule, promoted to the edge level: an edge asserts that a type SEALED by
|
|
355
|
+
// the source movement seeds the sink's entry. An edge naming a type nothing seals is a dead name.
|
|
356
|
+
const edges_classified = [];
|
|
357
|
+
{
|
|
358
|
+
const violations = [];
|
|
359
|
+
for (const e of chart.edges) {
|
|
360
|
+
const src = byId.get(e.from_movement);
|
|
361
|
+
const { required, optional } = sealedTypes(src.standard);
|
|
362
|
+
const where = { from_movement: e.from_movement, to_movement: e.to_movement, output_type: e.output_type };
|
|
363
|
+
if (required.has(e.output_type)) {
|
|
364
|
+
// Promised: a hard edge, whatever `optional` says. `optional` narrows nothing here.
|
|
365
|
+
edges_classified.push({ ...where, kind: "hard" });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (optional.has(e.output_type)) {
|
|
369
|
+
if (e.optional !== true) {
|
|
370
|
+
violations.push({ rule: "R6", edge: where, detail: `movement "${e.from_movement}" seals "${e.output_type}" only through a terminal chair's optional_outputs, so this flow may carry nothing: conditional flow must set optional:true on the edge. Declare it, or make the type a promised output of standard "${src.standard.slug}".` });
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
edges_classified.push({ ...where, kind: "conditional" });
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
violations.push({ rule: "R6", edge: where, detail: `dead name: "${e.output_type}" is never sealed by movement "${e.from_movement}" (standard "${src.standard.slug}" seals [${[...required, ...optional].join(", ") || "nothing"}]). An edge that names an unsealed type would carry nothing and could not say so.` });
|
|
377
|
+
}
|
|
378
|
+
if (violations.length > 0)
|
|
379
|
+
return V(violations);
|
|
380
|
+
}
|
|
381
|
+
// ── R7 entry-slot satisfaction (dead slot) ───────────────────────────────────────────────────
|
|
382
|
+
// A required outside-need with no provider is refused where the chart is authored. A CONDITIONAL
|
|
383
|
+
// edge does not count: it is classified, not guaranteed, and the sink requires the type.
|
|
384
|
+
{
|
|
385
|
+
const violations = [];
|
|
386
|
+
const payload = new Set(input.payload_types ?? []);
|
|
387
|
+
for (const m of resolved) {
|
|
388
|
+
const hard = new Set();
|
|
389
|
+
for (const e of edges_classified)
|
|
390
|
+
if (e.to_movement === m.movement_id && e.kind === "hard")
|
|
391
|
+
hard.add(e.output_type);
|
|
392
|
+
const conditional = new Set();
|
|
393
|
+
for (const e of edges_classified)
|
|
394
|
+
if (e.to_movement === m.movement_id && e.kind === "conditional")
|
|
395
|
+
conditional.add(e.output_type);
|
|
396
|
+
const fills = new Set(Object.keys(m.runtime_fills));
|
|
397
|
+
for (const need of outsideNeeds(m.standard)) {
|
|
398
|
+
if (hard.has(need) || fills.has(need) || payload.has(need))
|
|
399
|
+
continue;
|
|
400
|
+
const onlyConditional = conditional.has(need) ? ` A conditional edge carries "${need}" but may carry nothing, so it cannot be its only provider.` : "";
|
|
401
|
+
violations.push({ rule: "R7", movement_id: m.movement_id, detail: `dead slot: movement "${m.movement_id}" input "${need}" has no provider — no incoming hard edge carries it, runtime_fills does not fill it, and the chart payload does not declare it.${onlyConditional}` });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (violations.length > 0)
|
|
405
|
+
return V(violations);
|
|
406
|
+
}
|
|
407
|
+
// ── R8 gate key discipline ───────────────────────────────────────────────────────────────────
|
|
408
|
+
// A gate is keyed by `gate_id` precisely so an ARRANGEMENT-level approval cannot be answered by
|
|
409
|
+
// an approval meant for a within-movement human chair that shares a role name.
|
|
410
|
+
{
|
|
411
|
+
const violations = [];
|
|
412
|
+
const seen = new Set();
|
|
413
|
+
for (const g of chart.approval_gates) {
|
|
414
|
+
if (seen.has(g.gate_id)) {
|
|
415
|
+
violations.push({ rule: "R8", gate_id: g.gate_id, detail: `duplicate gate_id "${g.gate_id}" — approvals are keyed by it, so two gates sharing one would be answered by a single yes.` });
|
|
416
|
+
}
|
|
417
|
+
seen.add(g.gate_id);
|
|
418
|
+
if (g.after_movement === g.before_movement) {
|
|
419
|
+
violations.push({ rule: "R8", gate_id: g.gate_id, detail: `gate "${g.gate_id}" gates movement "${g.before_movement}" on itself. A gate orders two movements.` });
|
|
420
|
+
}
|
|
421
|
+
for (const id of [g.after_movement, g.before_movement]) {
|
|
422
|
+
const m = byId.get(id);
|
|
423
|
+
if (!m)
|
|
424
|
+
continue; // R4 already reported it
|
|
425
|
+
for (const ch of chairsOf(m.standard)) {
|
|
426
|
+
if (ch.human === true && ch.role === g.gate_id) {
|
|
427
|
+
violations.push({ rule: "R8", gate_id: g.gate_id, detail: `gate_id "${g.gate_id}" collides with a human chair role in movement "${id}" (standard "${m.standard.slug}"). Both park on approvals["${g.gate_id}"], so one person's yes would answer the other's question.` });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (violations.length > 0)
|
|
433
|
+
return V(violations);
|
|
434
|
+
}
|
|
435
|
+
// ── R9 budget envelope, on the compose path ──────────────────────────────────────────────────
|
|
436
|
+
// Restated here rather than left to Zod: `z.number().positive()` accepts Infinity, and an
|
|
437
|
+
// unbounded envelope is not a bound.
|
|
438
|
+
if (chart.budget_envelope) {
|
|
439
|
+
const total = chart.budget_envelope.total_usd;
|
|
440
|
+
if (!Number.isFinite(total) || total <= 0) {
|
|
441
|
+
return V([{ rule: "R9", detail: `budget_envelope.total_usd must be a finite positive number of dollars; got ${String(total)}. An envelope that cannot be exceeded is not an envelope.` }]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ── R10 the venue ceiling — a room NARROWS a player, never widens one ────────────────────────
|
|
445
|
+
// The venue is one enforcement layer among several, and the only one that bounds what EXISTS in
|
|
446
|
+
// the room rather than what a principal may reach for. So it composes with the others by
|
|
447
|
+
// INTERSECTION, and the failure it can prove statically is the one worth refusing here: a seated
|
|
448
|
+
// agent whose entire grant set lies outside the room. That chair's work is dead before the
|
|
449
|
+
// downbeat — the spawn would advertise nothing it was chartered to hold — so the arrangement is
|
|
450
|
+
// refused where it is authored, with the agent, the room and the emptiness named.
|
|
451
|
+
//
|
|
452
|
+
// What this does NOT do: decide whether a NON-empty intersection is sufficient. Which of an
|
|
453
|
+
// agent's tools a given chair actually needs is not stated anywhere in the genome, so claiming to
|
|
454
|
+
// check it would be a check in name only. Emptiness is the part that is provable.
|
|
455
|
+
if (chart.venue !== undefined) {
|
|
456
|
+
const venue = input.venues?.get(chart.venue);
|
|
457
|
+
if (!venue) {
|
|
458
|
+
return V([{
|
|
459
|
+
rule: "R10",
|
|
460
|
+
detail: `unknown venue "${chart.venue}" — it resolves to nothing this caller can see, so the ceiling it is ` +
|
|
461
|
+
`supposed to impose cannot be computed. An unresolvable ceiling is not an absent ceiling: define the ` +
|
|
462
|
+
`venue under venues/<slug>.json (venue_define), or drop the field.`,
|
|
463
|
+
}]);
|
|
464
|
+
}
|
|
465
|
+
const violations = [];
|
|
466
|
+
for (const m of resolved) {
|
|
467
|
+
// Everyone who plays this movement: the standard's own composed roster, plus any agent the
|
|
468
|
+
// chart seats over it. One pass per agent, whichever way it got into the room.
|
|
469
|
+
const seated = new Map();
|
|
470
|
+
for (const a of m.standard.agents)
|
|
471
|
+
seated.set(a.slug, a);
|
|
472
|
+
for (const s of m.seatings) {
|
|
473
|
+
const a = input.agents?.get(s.agent_slug);
|
|
474
|
+
if (a)
|
|
475
|
+
seated.set(a.slug, a);
|
|
476
|
+
}
|
|
477
|
+
for (const a of seated.values()) {
|
|
478
|
+
const grants = a.allowed_tools ?? [];
|
|
479
|
+
// An agent that grants nothing needs nothing from the room. Deny-by-default cuts both ways.
|
|
480
|
+
if (grants.length === 0)
|
|
481
|
+
continue;
|
|
482
|
+
if (venueEffectiveTools(a, venue).length > 0)
|
|
483
|
+
continue;
|
|
484
|
+
violations.push({
|
|
485
|
+
rule: "R10",
|
|
486
|
+
movement_id: m.movement_id,
|
|
487
|
+
detail: `venue "${venue.slug}" starves agent "${a.slug}" in movement "${m.movement_id}": its grants ` +
|
|
488
|
+
`[${grants.join(", ")}] intersect the room's equipment [${venue.equipment.tools.join(", ") || "nothing"}] in ` +
|
|
489
|
+
`NOTHING, so every tool the agent is chartered to hold is absent here. A venue is a ceiling, not a grant — ` +
|
|
490
|
+
`it cannot supply what the charter does not claim. Widen the room's equipment, seat an agent this room ` +
|
|
491
|
+
`equips, or hold this movement somewhere else.`,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (violations.length > 0)
|
|
496
|
+
return V(violations);
|
|
497
|
+
}
|
|
498
|
+
const plan = {
|
|
499
|
+
chart,
|
|
500
|
+
movements: resolved,
|
|
501
|
+
order,
|
|
502
|
+
edges_classified,
|
|
503
|
+
// The venue is NOT folded in: a room is environment, not structure (see ChartSchema.venue).
|
|
504
|
+
chart_hash: chartHash({ movements: resolved, chart }),
|
|
505
|
+
};
|
|
506
|
+
return { ok: true, violations: [], ...plan };
|
|
507
|
+
}
|
|
508
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
509
|
+
// Perform
|
|
510
|
+
// ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
511
|
+
/** How a GATE's sealed approval is filed among the chart checkpoint's movement rows. A movement_id
|
|
512
|
+
* cannot collide with it: R1 admits only `[A-Za-z0-9._-]+`, and this prefix carries a colon. */
|
|
513
|
+
const GATE_ROW_PREFIX = "gate:";
|
|
514
|
+
/** The chart's own checkpoint: which movements completed, and what the performance has spent. */
|
|
515
|
+
export function chartCheckpointId(gig_id) {
|
|
516
|
+
return `${gig_id}.chart`;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* A movement's gig id.
|
|
520
|
+
*
|
|
521
|
+
* The degenerate chart's movement runs under the chart's own id, unchanged — same outputs file,
|
|
522
|
+
* same checkpoint, same ledger row id a single-standard dispatch always had. A real arrangement
|
|
523
|
+
* gives each movement its own id, so one movement's checkpoint, header and row cannot be another's.
|
|
524
|
+
*
|
|
525
|
+
* The spec left open whether the movements of one chart should instead SHARE a gig id, so that
|
|
526
|
+
* `OutputStore.trace` — scoped to a single gig — would walk the whole performance. That question
|
|
527
|
+
* is now ANSWERED THE OTHER WAY: the ids stay separate (a movement keeps its own checkpoint,
|
|
528
|
+
* header and row) and the STORE learned the id scheme instead. `performanceRoot` reads a
|
|
529
|
+
* performance off a movement's gig id, so `trace` walks the whole arrangement and LABELS each
|
|
530
|
+
* node with the movement it lived in. The composition is the one owner of how the id is built;
|
|
531
|
+
* the store is the one owner of how it is read back apart.
|
|
532
|
+
*/
|
|
533
|
+
export function movementGigId(plan, gig_id, movement_id) {
|
|
534
|
+
return isDegenerateChart(plan.chart) ? gig_id : composeMovementGigId(gig_id, movement_id);
|
|
535
|
+
}
|
|
536
|
+
/** Where a movement's own checkpoint lives. Equal to the gig id, as `runGig` expects. */
|
|
537
|
+
export function movementCheckpointId(plan, gig_id, movement_id) {
|
|
538
|
+
return movementGigId(plan, gig_id, movement_id);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* The chart's resume identity. Same discipline as a run's (src/reuse.ts RunIdentity): a resume into
|
|
542
|
+
* a MOVED arrangement would put movements from chart B onto sealed outputs from chart A with
|
|
543
|
+
* nothing recording it, so the gate is stated, not assumed.
|
|
544
|
+
*/
|
|
545
|
+
function chartIdentity(plan, chartInput, deps) {
|
|
546
|
+
return {
|
|
547
|
+
standard_slug: plan.chart.slug,
|
|
548
|
+
genome_hash: plan.chart_hash,
|
|
549
|
+
producers_sha: producersSha({ agents: plan.movements.flatMap((m) => [...m.standard.agents]) }),
|
|
550
|
+
gig_input_sha: sha256Hex(canonJson(chartInput)),
|
|
551
|
+
model_version: deps.model_version ?? "unknown",
|
|
552
|
+
depth: deps.depth ?? "",
|
|
553
|
+
canonical_form_version: CANONICAL_FORM_VERSION,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Perform the chart: walk the movements in topological order, one `runGig` each.
|
|
558
|
+
*
|
|
559
|
+
* The boundaries are where everything interesting happens, because a boundary is the last place a
|
|
560
|
+
* stop is free:
|
|
561
|
+
* - the incoming edges' carriers are gathered and handed to the sink as SEALED RECORDS (so its
|
|
562
|
+
* provenance reaches back across the boundary, rather than a payload copy that says it came
|
|
563
|
+
* from nowhere),
|
|
564
|
+
* - an approval gate parks the performance — checkpointed, drained as `awaiting_approval`,
|
|
565
|
+
* nothing hollow sealed — exactly as an in-standard human chair does,
|
|
566
|
+
* - the budget envelope is compared against real settled spend BEFORE the next movement starts,
|
|
567
|
+
* - and the movement that just finished is recorded, so a resume never re-derives it.
|
|
568
|
+
*/
|
|
569
|
+
export async function runChart(plan, chartInput, deps) {
|
|
570
|
+
const degenerate = isDegenerateChart(plan.chart);
|
|
571
|
+
if (deps.resume_from !== undefined && deps.gig_id !== undefined && deps.gig_id !== deps.resume_from) {
|
|
572
|
+
throw new ResumeRefused(deps.resume_from, `the caller supplied a different gig_id ("${deps.gig_id}") — a resumed performance continues the one it resumes, it does not fork one`);
|
|
573
|
+
}
|
|
574
|
+
const gig_id = deps.resume_from ?? deps.gig_id ?? randomUUID();
|
|
575
|
+
const started_at = new Date().toISOString();
|
|
576
|
+
const chart_slug = plan.chart.slug;
|
|
577
|
+
const identity = chartIdentity(plan, chartInput, deps);
|
|
578
|
+
const cpId = chartCheckpointId(gig_id);
|
|
579
|
+
/** Movements a previous attempt completed: movement_id → its handed-on records. */
|
|
580
|
+
const restored = new Map();
|
|
581
|
+
/** Gates an earlier attempt already sealed: gate_id → the approval that stands. */
|
|
582
|
+
const restoredGates = new Map();
|
|
583
|
+
let priorSpentUsd = 0;
|
|
584
|
+
let chartStartedAt = started_at;
|
|
585
|
+
// ── resume ────────────────────────────────────────────────────────────────────────────────────
|
|
586
|
+
if (deps.resume_from !== undefined) {
|
|
587
|
+
if (!deps.checkpoints)
|
|
588
|
+
throw new ResumeRefused(gig_id, "no checkpoint store is wired, so there is nothing to resume from");
|
|
589
|
+
let cp;
|
|
590
|
+
try {
|
|
591
|
+
cp = deps.checkpoints.read(cpId);
|
|
592
|
+
}
|
|
593
|
+
catch (e) {
|
|
594
|
+
throw new ResumeRefused(gig_id, `its chart checkpoint could not be read — ${e instanceof Error ? e.message : String(e)}`);
|
|
595
|
+
}
|
|
596
|
+
if (cp) {
|
|
597
|
+
if (cp.schema_version !== CHECKPOINT_SCHEMA_VERSION) {
|
|
598
|
+
throw new ResumeRefused(gig_id, `its chart checkpoint is schema v${cp.schema_version} and this engine reads v${CHECKPOINT_SCHEMA_VERSION}`);
|
|
599
|
+
}
|
|
600
|
+
const drift = Object.keys(identity)
|
|
601
|
+
.filter((k) => cp.identity[k] !== identity[k])
|
|
602
|
+
.map((k) => `${k}: checkpoint="${String(cp.identity[k])}" current="${String(identity[k])}"`);
|
|
603
|
+
if (drift.length > 0)
|
|
604
|
+
throw new ResumeRefused(gig_id, "it was checkpointed under a different arrangement", drift);
|
|
605
|
+
const known = new Set(plan.movements.map((m) => m.movement_id));
|
|
606
|
+
for (const row of cp.roles) {
|
|
607
|
+
const gateId = row.role.startsWith(GATE_ROW_PREFIX) ? row.role.slice(GATE_ROW_PREFIX.length) : undefined;
|
|
608
|
+
if (gateId === undefined && !known.has(row.role)) {
|
|
609
|
+
throw new ResumeRefused(gig_id, `its chart checkpoint names movement "${row.role}", which this chart does not declare`);
|
|
610
|
+
}
|
|
611
|
+
const gate = gateId === undefined ? undefined : plan.chart.approval_gates.find((g) => g.gate_id === gateId);
|
|
612
|
+
if (gateId !== undefined && !gate) {
|
|
613
|
+
throw new ResumeRefused(gig_id, `its chart checkpoint names gate "${gateId}", which this chart does not declare`);
|
|
614
|
+
}
|
|
615
|
+
const records = [];
|
|
616
|
+
for (let i = 0; i < row.output_ids.length; i++) {
|
|
617
|
+
const id = row.output_ids[i];
|
|
618
|
+
const rec = deps.outputs.get(id);
|
|
619
|
+
if (!rec)
|
|
620
|
+
throw new ResumeRefused(gig_id, `its chart checkpoint names output "${id}" for movement "${row.role}", which the output store no longer holds`);
|
|
621
|
+
if (rec.content_sha !== row.content_shas[i])
|
|
622
|
+
throw new ResumeRefused(gig_id, `output "${id}" (movement "${row.role}") has a different content_sha than the chart checkpoint recorded — the store moved under it`);
|
|
623
|
+
const fp = deps.outputs.typeFingerprint(rec.domain_type);
|
|
624
|
+
if (fp === "")
|
|
625
|
+
throw new ResumeRefused(gig_id, `the registry can no longer describe type "${rec.domain_type}" (movement "${row.role}"), so its sealed output cannot be checked`);
|
|
626
|
+
if (fp !== row.type_fingerprints[i])
|
|
627
|
+
throw new ResumeRefused(gig_id, `type "${rec.domain_type}" (movement "${row.role}") has changed shape since that output was sealed`);
|
|
628
|
+
records.push(rec);
|
|
629
|
+
}
|
|
630
|
+
if (gate) {
|
|
631
|
+
// The gate was answered in an earlier attempt and its yes is sealed. It is NOT asked
|
|
632
|
+
// again: two records for one decision would put a second yes in the chain and make the
|
|
633
|
+
// approval look like a repeated act.
|
|
634
|
+
const rec = records[0];
|
|
635
|
+
restoredGates.set(gate.gate_id, { gate_id: gate.gate_id, chair: gate.chair, approved_by: rec.agent_slug, output_id: rec.id });
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
restored.set(row.role, { records, row });
|
|
639
|
+
}
|
|
640
|
+
priorSpentUsd = cp.prior_budget_state?.spent_usd ?? 0;
|
|
641
|
+
chartStartedAt = cp.started_at;
|
|
642
|
+
}
|
|
643
|
+
else if (!degenerate) {
|
|
644
|
+
// FAILURE POSTURE, inherited from `runGig`: a resume is a CLAIM about a specific prior
|
|
645
|
+
// performance. If nothing was ever recorded as complete the claim is false, and quietly
|
|
646
|
+
// running cold would charge the caller for a full performance they believe they are
|
|
647
|
+
// continuing. A degenerate chart is exempt — it writes no chart checkpoint at all, and its
|
|
648
|
+
// resume is the single-standard resume it always was, read from the movement's own.
|
|
649
|
+
const anyMovementCheckpoint = plan.movements.some((m) => {
|
|
650
|
+
try {
|
|
651
|
+
return deps.checkpoints.read(movementCheckpointId(plan, gig_id, m.movement_id)) !== undefined;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
});
|
|
657
|
+
if (!anyMovementCheckpoint) {
|
|
658
|
+
throw new ResumeRefused(gig_id, "no chart checkpoint exists for it (nothing was ever recorded as complete)");
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const runs = new Map();
|
|
663
|
+
const gatesApproved = [];
|
|
664
|
+
let spentUsd = priorSpentUsd;
|
|
665
|
+
const envelope = plan.chart.budget_envelope?.total_usd;
|
|
666
|
+
/** Write the chart's checkpoint: which movements are done, and what the performance has spent. */
|
|
667
|
+
const saveChartCheckpoint = () => {
|
|
668
|
+
if (!deps.checkpoints || degenerate)
|
|
669
|
+
return;
|
|
670
|
+
const roles = [];
|
|
671
|
+
for (const id of plan.order) {
|
|
672
|
+
const run = runs.get(id);
|
|
673
|
+
if (!run || (run.status !== "complete" && run.status !== "skipped"))
|
|
674
|
+
continue;
|
|
675
|
+
const prior = restored.get(id);
|
|
676
|
+
roles.push(prior && run.status === "skipped"
|
|
677
|
+
? prior.row
|
|
678
|
+
: {
|
|
679
|
+
role: id, phase: run.standard_slug, movement_id: id,
|
|
680
|
+
output_ids: run.outputs.map((r) => r.id),
|
|
681
|
+
content_shas: run.outputs.map((r) => r.content_sha),
|
|
682
|
+
domain_types: run.outputs.map((r) => r.domain_type),
|
|
683
|
+
type_fingerprints: run.outputs.map((r) => deps.outputs.typeFingerprint(r.domain_type)),
|
|
684
|
+
sealed_at: new Date().toISOString(),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
// A gate's sealed yes rides on the checkpoint too, so a resumed performance restores the
|
|
688
|
+
// approval instead of asking for it again.
|
|
689
|
+
for (const g of gatesApproved) {
|
|
690
|
+
const rec = deps.outputs.get(g.output_id);
|
|
691
|
+
if (!rec)
|
|
692
|
+
continue;
|
|
693
|
+
const gate = plan.chart.approval_gates.find((x) => x.gate_id === g.gate_id);
|
|
694
|
+
roles.push({
|
|
695
|
+
role: `${GATE_ROW_PREFIX}${g.gate_id}`, phase: `gate`,
|
|
696
|
+
...(gate ? { movement_id: gate.before_movement } : {}),
|
|
697
|
+
output_ids: [rec.id], content_shas: [rec.content_sha], domain_types: [rec.domain_type],
|
|
698
|
+
type_fingerprints: [deps.outputs.typeFingerprint(rec.domain_type)],
|
|
699
|
+
sealed_at: rec.created_at,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (roles.length === 0)
|
|
703
|
+
return;
|
|
704
|
+
// What the performance has spent SO FAR, in real dollars — the number the envelope is
|
|
705
|
+
// denominated in, and the one a resumed performance reads before spawning anything.
|
|
706
|
+
const prior_budget_state = { spent_usd: spentUsd };
|
|
707
|
+
try {
|
|
708
|
+
deps.checkpoints.write({
|
|
709
|
+
schema_version: CHECKPOINT_SCHEMA_VERSION, gig_id: cpId, identity,
|
|
710
|
+
started_at: chartStartedAt, updated_at: new Date().toISOString(),
|
|
711
|
+
roles,
|
|
712
|
+
prior_budget_state,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
catch { /* a checkpoint that will not persist must not kill a performance that is otherwise fine */ }
|
|
716
|
+
};
|
|
717
|
+
/** The records an edge into `movement_id` carries: the source movement's sealed outputs, by type. */
|
|
718
|
+
const carriersFor = (movement_id) => {
|
|
719
|
+
const out = [];
|
|
720
|
+
for (const e of plan.edges_classified) {
|
|
721
|
+
if (e.to_movement !== movement_id)
|
|
722
|
+
continue;
|
|
723
|
+
const src = runs.get(e.from_movement);
|
|
724
|
+
if (!src)
|
|
725
|
+
continue;
|
|
726
|
+
// The SAME type-match the runtime uses at the seal boundary (subtype-aware for a core-type
|
|
727
|
+
// declaration, exact for a domain type) — asked through its one owner, not re-derived here.
|
|
728
|
+
for (const rec of src.outputs) {
|
|
729
|
+
if (!outputSatisfiesType(rec, e.output_type))
|
|
730
|
+
continue;
|
|
731
|
+
if (!out.includes(rec))
|
|
732
|
+
out.push(rec);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return out;
|
|
736
|
+
};
|
|
737
|
+
const park = async (gate, movement) => {
|
|
738
|
+
saveChartCheckpoint();
|
|
739
|
+
// AWAITED, like the in-standard park: parking is the performance's last act before the caller
|
|
740
|
+
// exits, and an in-flight fetch dies with the process — which would leave the sink's row saying
|
|
741
|
+
// "running" about a performance that is waiting on a person.
|
|
742
|
+
await drainGigHeader({
|
|
743
|
+
gig_id, standard_slug: chart_slug, status: "awaiting_approval",
|
|
744
|
+
genome_hash: plan.chart_hash, started_at: chartStartedAt, finished_at: new Date().toISOString(),
|
|
745
|
+
outputs_count: [...runs.values()].reduce((n, r) => n + r.outputs.length, 0),
|
|
746
|
+
error: `awaiting approval at chart gate "${gate.gate_id}" (chair "${gate.chair}") before movement "${movement.movement_id}"`,
|
|
747
|
+
}).catch((e) => {
|
|
748
|
+
if (process.env["COLTRANE_DRAIN_DEBUG"])
|
|
749
|
+
console.error(`[drain] awaiting chart header ${gig_id}: ${String(e)}`);
|
|
750
|
+
});
|
|
751
|
+
runs.set(movement.movement_id, {
|
|
752
|
+
movement_id: movement.movement_id, standard_slug: movement.standard.slug,
|
|
753
|
+
gig_id: movementGigId(plan, gig_id, movement.movement_id),
|
|
754
|
+
status: "awaiting_approval", outputs: [], spent_usd: 0,
|
|
755
|
+
});
|
|
756
|
+
return result("awaiting_approval", { gate_id: gate.gate_id, movement_id: movement.movement_id, chair: gate.chair });
|
|
757
|
+
};
|
|
758
|
+
const result = (status, awaiting, exhausted_at) => ({
|
|
759
|
+
chart_slug, chart_hash: plan.chart_hash, gig_id, status,
|
|
760
|
+
movements: plan.order.map((id) => runs.get(id) ?? {
|
|
761
|
+
movement_id: id, standard_slug: byIdOf(id).standard.slug,
|
|
762
|
+
gig_id: movementGigId(plan, gig_id, id),
|
|
763
|
+
status: status === "budget_exhausted" ? "budget_exhausted" : "awaiting_approval",
|
|
764
|
+
outputs: [], spent_usd: 0,
|
|
765
|
+
}),
|
|
766
|
+
...(awaiting ? { awaiting } : {}),
|
|
767
|
+
...(gatesApproved.length > 0 ? { gates_approved: gatesApproved } : {}),
|
|
768
|
+
spent_usd: spentUsd,
|
|
769
|
+
...(envelope !== undefined ? { budget: { total_usd: envelope, spent_usd: spentUsd, ...(exhausted_at ? { exhausted_at_movement: exhausted_at } : {}) } } : {}),
|
|
770
|
+
...(restored.size > 0 ? { resumed: { movements: [...restored.keys()], outputs: [...restored.values()].reduce((n, r) => n + r.records.length, 0) } } : {}),
|
|
771
|
+
});
|
|
772
|
+
const byIdOf = (movement_id) => plan.movements.find((m) => m.movement_id === movement_id);
|
|
773
|
+
// ── the walk ──────────────────────────────────────────────────────────────────────────────────
|
|
774
|
+
for (const movement_id of plan.order) {
|
|
775
|
+
const movement = byIdOf(movement_id);
|
|
776
|
+
// A movement an earlier attempt completed never runs again: its sealed outputs are the edge's
|
|
777
|
+
// carriers, re-read from the store and re-checked above.
|
|
778
|
+
const already = restored.get(movement_id);
|
|
779
|
+
if (already) {
|
|
780
|
+
runs.set(movement_id, {
|
|
781
|
+
movement_id, standard_slug: movement.standard.slug,
|
|
782
|
+
gig_id: movementGigId(plan, gig_id, movement_id),
|
|
783
|
+
status: "skipped", outputs: already.records, spent_usd: 0,
|
|
784
|
+
});
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// ── the gates in front of this movement ────────────────────────────────────────────────────
|
|
788
|
+
for (const gate of plan.chart.approval_gates) {
|
|
789
|
+
if (gate.before_movement !== movement_id)
|
|
790
|
+
continue;
|
|
791
|
+
const standing = restoredGates.get(gate.gate_id);
|
|
792
|
+
if (standing) {
|
|
793
|
+
gatesApproved.push(standing);
|
|
794
|
+
continue;
|
|
795
|
+
} // answered already, and sealed
|
|
796
|
+
const approval = deps.approvals?.[gate.gate_id];
|
|
797
|
+
if (!approval)
|
|
798
|
+
return park(gate, movement);
|
|
799
|
+
// A yes SEALS, through the same output gate as every record, under the approving principal's
|
|
800
|
+
// name, carrying the content_shas of exactly what was approved.
|
|
801
|
+
const approvedInputs = runs.get(gate.after_movement)?.outputs ?? [];
|
|
802
|
+
const core = deps.outputs.coreTypeOf("Judgment") ?? "Judgment";
|
|
803
|
+
const rec = deps.outputs.write({
|
|
804
|
+
core_type: core, domain_type: "Judgment", domain: movement.standard.domain,
|
|
805
|
+
gig_id, agent_slug: deps.approved_by ?? "human", from_role: gate.chair,
|
|
806
|
+
phase: `gate:${gate.gate_id}`, primitive: CORE_TO_PRIMITIVE[core] ?? "JUDGE",
|
|
807
|
+
data: approval,
|
|
808
|
+
input_refs: approvedInputs.map((i) => i.id),
|
|
809
|
+
input_shas: approvedInputs.map((i) => i.content_sha),
|
|
810
|
+
});
|
|
811
|
+
for (const i of approvedInputs)
|
|
812
|
+
deps.outputs.addRef(rec.id, i.id, "derived_from", rec.primitive);
|
|
813
|
+
gatesApproved.push({ gate_id: gate.gate_id, chair: gate.chair, approved_by: rec.agent_slug, output_id: rec.id });
|
|
814
|
+
}
|
|
815
|
+
// ── the envelope ──────────────────────────────────────────────────────────────────────────
|
|
816
|
+
// Deterministic, at the boundary, before any inference: a spend that already happened must not
|
|
817
|
+
// cost another invocation to discover.
|
|
818
|
+
if (envelope !== undefined && spentUsd >= envelope) {
|
|
819
|
+
saveChartCheckpoint();
|
|
820
|
+
runs.set(movement_id, {
|
|
821
|
+
movement_id, standard_slug: movement.standard.slug,
|
|
822
|
+
gig_id: movementGigId(plan, gig_id, movement_id),
|
|
823
|
+
status: "budget_exhausted", outputs: [], spent_usd: 0,
|
|
824
|
+
});
|
|
825
|
+
return result("budget_exhausted", undefined, movement_id);
|
|
826
|
+
}
|
|
827
|
+
// ── the movement ──────────────────────────────────────────────────────────────────────────
|
|
828
|
+
const movementGig = movementGigId(plan, gig_id, movement_id);
|
|
829
|
+
const seeds = [
|
|
830
|
+
...carriersFor(movement_id),
|
|
831
|
+
// A gate's sealed verdict is offered to the movement it gates, on the same terms as an edge's
|
|
832
|
+
// carrier: if the sink consumes a Judgment, the approval is part of what it reasoned from.
|
|
833
|
+
...gatesApproved
|
|
834
|
+
.filter((g) => plan.chart.approval_gates.some((x) => x.gate_id === g.gate_id && x.before_movement === movement_id))
|
|
835
|
+
.map((g) => deps.outputs.get(g.output_id))
|
|
836
|
+
.filter((r) => r !== undefined),
|
|
837
|
+
];
|
|
838
|
+
// A movement whose own checkpoint exists resumes from it (its completed chairs are already
|
|
839
|
+
// sealed); one with no checkpoint runs cold. The DEGENERATE chart passes the caller's resume
|
|
840
|
+
// claim straight through instead, so a resume of a single-standard gig that never recorded
|
|
841
|
+
// anything is refused by `runGig` exactly as it always was — a chart must not quietly turn a
|
|
842
|
+
// false claim into a cold run.
|
|
843
|
+
//
|
|
844
|
+
// `deps.budget` (append units) rides along unchanged and is therefore PER MOVEMENT: it is a rate
|
|
845
|
+
// limiter on one run's consumed context. The cross-movement bound is the chart's envelope, in
|
|
846
|
+
// real dollars, checked at the boundary above. Two different limits, kept apart deliberately.
|
|
847
|
+
const priorMovementCp = deps.checkpoints?.read(movementGig);
|
|
848
|
+
const resumeMovement = deps.resume_from !== undefined && (degenerate || priorMovementCp !== undefined);
|
|
849
|
+
const movementDeps = {
|
|
850
|
+
...deps,
|
|
851
|
+
gig_id: movementGig,
|
|
852
|
+
resume_from: resumeMovement ? movementGig : undefined,
|
|
853
|
+
seed_outputs: seeds.length > 0 ? seeds : undefined,
|
|
854
|
+
chart: {
|
|
855
|
+
chart_slug, movement_id, chart_hash: plan.chart_hash, degenerate,
|
|
856
|
+
prior_budget_state: { spent_usd: spentUsd },
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
const gigInput = { ...chartInput, ...movement.runtime_fills };
|
|
860
|
+
let res;
|
|
861
|
+
try {
|
|
862
|
+
res = await runGig(movement.standard, gigInput, movementDeps);
|
|
863
|
+
}
|
|
864
|
+
catch (e) {
|
|
865
|
+
// The performance keeps what it earned: the movements that completed are recorded, so a
|
|
866
|
+
// resume starts at the one that died rather than at the top.
|
|
867
|
+
saveChartCheckpoint();
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
const movementSpend = res.usage?.total_cost_usd ?? 0;
|
|
871
|
+
spentUsd += movementSpend;
|
|
872
|
+
runs.set(movement_id, {
|
|
873
|
+
movement_id, standard_slug: movement.standard.slug, gig_id: movementGig,
|
|
874
|
+
status: res.status === "awaiting_approval" ? "awaiting_approval" : "complete",
|
|
875
|
+
result: res, outputs: res.outputs, spent_usd: movementSpend,
|
|
876
|
+
});
|
|
877
|
+
// A movement that parked at its OWN human chair parks the performance: the chart has no more
|
|
878
|
+
// right to run past a person than the standard does.
|
|
879
|
+
if (res.status === "awaiting_approval") {
|
|
880
|
+
saveChartCheckpoint();
|
|
881
|
+
// A within-movement human chair, not a gate: no gate_id, and the chair is named as the
|
|
882
|
+
// role it is. The approving resume answers it through `approvals[role]`, exactly as a
|
|
883
|
+
// single-standard dispatch does.
|
|
884
|
+
return result("awaiting_approval", res.awaiting ? { movement_id, chair: res.awaiting.role, phase: res.awaiting.phase } : undefined);
|
|
885
|
+
}
|
|
886
|
+
saveChartCheckpoint();
|
|
887
|
+
}
|
|
888
|
+
// The performance finished, so there is nothing left to resume.
|
|
889
|
+
if (deps.checkpoints && !degenerate) {
|
|
890
|
+
try {
|
|
891
|
+
deps.checkpoints.remove(cpId);
|
|
892
|
+
}
|
|
893
|
+
catch { /* reclaiming disk must not fail a run that succeeded */ }
|
|
894
|
+
}
|
|
895
|
+
return result("complete");
|
|
896
|
+
}
|
|
897
|
+
//# sourceMappingURL=chart.js.map
|