@jr2/orchestrator 0.2.1 → 0.4.1

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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -1,7 +1,8 @@
1
1
  // The internal registration table (ADR-0011): one structure behind both delivery dialects.
2
- // An Agent slot and `gate` register `address → { accepted event defs, deliver closure, meta }`;
3
- // the two HTTP surfaces (`/agents/:iid/*` for the Adapter, `/runs/:id/gates/*` for humans and
4
- // webhooks) are adapters over it — lookup, schema validation, delivery, discovery, and lifecycle
2
+ // An Agent slot and `gate` register `address → { accepted event defs, deliver closure, meta,
3
+ // sender }` — the sender written on every event it delivers (ADR-0015); the two HTTP surfaces
4
+ // (`/agents/:iid/*` for the Agent's Harness, `/runs/:id/gates/*` for humans and webhooks) are
5
+ // adapters over it — lookup, schema validation, delivery, discovery, and lifecycle
5
6
  // are implemented ONCE. The table is implementation structure, not vocabulary: workflows speak
6
7
  // only `defineEvent` / `agent` / `gate`.
7
8
  //
@@ -23,11 +24,20 @@
23
24
  // `resolveAccepts` reads them off the invoking Machine below and a nested Machine's names are
24
25
  // never the root's problem.
25
26
 
26
- import type { ActorSystem, AnyActorRef, AnyEventObject } from "xstate";
27
- import type { EventDef } from "@jr2/agent-protocol";
27
+ import { getStateNodes } from "xstate";
28
+ import type { ActorSystem, AnyActorRef, AnyEventObject, AnyMachineSnapshot, AnyStateMachine } from "xstate";
29
+ import { SENDER_KEY, type EventDef, type Sender } from "@jr2/agent-protocol";
28
30
  import type { AgentAdmission } from "./actor.ts";
31
+ import type { EffectMarker, EffectRow } from "./effect.ts";
32
+ import type { EffectorPlacement, EffectorTarget } from "./http-effect.ts";
33
+ import type { NoticeScope, TakenNotices } from "./notices.ts";
34
+ import type { AnswerSeat } from "./pick.ts";
35
+ import type { Notice, PickAnswer } from "./wire.ts";
29
36
  import type { SandboxPort } from "./workspace.ts";
30
37
  import { invokingMachine, vocabularyOf } from "./vocabulary.ts";
38
+ import type { ChartText } from "./chart.ts";
39
+ import { notTaken, senderText, takesNow, type NotTakenWriting } from "./party.ts";
40
+ import { restOf } from "./rest.ts";
31
41
 
32
42
  /** xstate doesn't export its internal AnyActorSystem; this matches what actors receive. */
33
43
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -45,8 +55,17 @@ export type Registration = {
45
55
  kind: "gate" | "agent";
46
56
  /** The caller-facing id within its dialect (the gate id, or the agent's instance id). */
47
57
  id: string;
48
- /** Accepted events, name→def — the validation scope AND the discovery listing. */
58
+ /** Accepted events, name→def — the validation scope AND the discovery listing. For an Agent,
59
+ * the invoking state's derived set: what its Allowed picks are narrowed from (ADR-0029). */
49
60
  defs: Map<string, EventDef>;
61
+ /**
62
+ * An Agent's MENU (ADR-0029), in its fixed order: the union of the derived sets of every state
63
+ * that invokes an Agent of this name, and the tools block the Harness presents — byte-stable for
64
+ * the conversation, so it never breaks the provider's prompt cache. A superset of {@link defs};
65
+ * a pick on it that {@link defs} lacks is refused at delivery, naming the Allowed picks. Absent
66
+ * on gates, and on an Agent registered with no Menu, which serves its `defs` as the Menu.
67
+ */
68
+ menu?: Map<string, EventDef>;
50
69
  /** Serializable caller/integration context (PR URL, title …) — rides the discovery listing. */
51
70
  meta?: Record<string, unknown>;
52
71
  /**
@@ -64,6 +83,9 @@ export type Registration = {
64
83
  sandbox?: string;
65
84
  /** Close over the invoking state's `sendBack`; delivery lands where the actor was invoked. */
66
85
  deliver: (event: DeliveredEvent) => void;
86
+ /** An Agent's: the Harness read this surface (`GET /agents/:iid/surface`), so it has the Turn in
87
+ * hand and prompts it. Until then the Turn may end with no model ever asked (ADR-0026). */
88
+ served?: () => void;
67
89
  /**
68
90
  * The Machine that invoked this actor — `self._parent`, captured at registration. The menu was
69
91
  * derived from THIS machine's transitions (ADR-0015), so it is the only snapshot whose guards
@@ -71,6 +93,22 @@ export type Registration = {
71
93
  * that have no parent to name, which is why every read of it fails open.
72
94
  */
73
95
  invoker?: AnyActorRef;
96
+ /**
97
+ * Who this registration delivers as (ADR-0015): an Agent by its slot, a Gate by its invoke id.
98
+ * The table writes it on every event it delivers (`event.sender`), never taken from the caller.
99
+ * Absent on a registration no party made (a bare unit-test entry): its events carry none.
100
+ */
101
+ sender?: Sender;
102
+ /** The invoke id of the actor that registered: what finds its state — its seat — in the
103
+ * Machine, for the party questions (`party.ts`). */
104
+ invokeId?: string;
105
+ /**
106
+ * An Agent's: the continued conversation ({@link continuedIid}) this Turn is part of — set only
107
+ * when the Turn continues one (`continue: true`). A pick whose route leaves the Agent's state is
108
+ * answered by a notice to it (ADR-0024); a fresh Turn's conversation is never heard again, so its
109
+ * answer has nowhere to go.
110
+ */
111
+ conversation?: string;
74
112
  };
75
113
 
76
114
  /**
@@ -98,6 +136,41 @@ export function wouldMove(invoker: AnyActorRef | undefined, event: AnyEventObjec
98
136
  }
99
137
  }
100
138
 
139
+ /**
140
+ * The states this exact event's transitions name as targets, by id — empty for a pick with no
141
+ * target. Read before the send and checked after it with `leftAgain`: what tells a pick that entered
142
+ * a state and left it again inside its own send (an `always` back to where it was) from one that
143
+ * stayed where it went or went nowhere (ADR-0024). Fails CLOSED: a read that cannot be made reads
144
+ * as no target, the plain receipt.
145
+ */
146
+ export function targetsOf(invoker: AnyActorRef | undefined, event: AnyEventObject): string[] {
147
+ if (!invoker) return [];
148
+ try {
149
+ const snapshot = invoker.getSnapshot() as AnyMachineSnapshot;
150
+ return (snapshot.machine as AnyStateMachine)
151
+ .getTransitionData(snapshot, event)
152
+ .flatMap((t) => (t.target ?? []).map((node) => node.id));
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Is some state of `targets` no longer active — entered by the send and left again inside it? A
160
+ * target to the state the pick was taken in (`drafting: { on: { note: "drafting" } }`) is still
161
+ * active, so it is not. Fails CLOSED: false, the plain receipt.
162
+ */
163
+ export function leftAgain(invoker: AnyActorRef | undefined, targets: readonly string[]): boolean {
164
+ if (!invoker || targets.length === 0) return false;
165
+ try {
166
+ const snapshot = invoker.getSnapshot() as AnyMachineSnapshot;
167
+ const active = new Set(getStateNodes(snapshot.machine.root, snapshot.value).map((node) => node.id));
168
+ return targets.some((id) => !active.has(id));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
101
174
  /**
102
175
  * COULD this event move the invoking Machine, judged before the Agent has picked its arguments?
103
176
  * What the surface build can ask (ADR-0029) — there is no payload yet, so a guard reading one
@@ -138,10 +211,82 @@ export function mayMove(invoker: AnyActorRef | undefined, type: string): boolean
138
211
  return readPayload;
139
212
  }
140
213
 
214
+ /** Would the run take `type` from this registration now, payload-blind? Asked AS its sender
215
+ * ({@link takesNow}); a registration with no seat to ask from (a plain `setup()` actor tree, a bare
216
+ * test entry) falls back to {@link mayMove}. */
217
+ function takenNow(reg: Pick<Registration, "invoker" | "sender" | "invokeId">, type: string): boolean {
218
+ return takesNow(reg, type) ?? mayMove(reg.invoker, type);
219
+ }
220
+
221
+ /**
222
+ * An Agent's Allowed picks now (ADR-0029): the invoking state's derived set, narrowed to what the
223
+ * invoking Machine would take from this Agent ({@link takenNow}), in Menu order — so the list reads
224
+ * in the order the tools block does. The ONE reading of the list: the surface, every receipt, the
225
+ * nudge and the wake all ask here, so no two of them can disagree about what the Turn may pick
226
+ * (ADR-0016).
227
+ *
228
+ * EMPTY while the Agent's state is not at Rest (rest.ts, ADR-0024): something below it runs, or a
229
+ * timer holds, and every pick is refused until it is back at Rest — so the Rest rule and the
230
+ * payload-blind selection give one list, and a Turn that starts busy is not asked into a refusal.
231
+ */
232
+ export function allowedPicks(reg: Pick<Registration, "defs" | "menu" | "invoker" | "sender" | "invokeId">): string[] {
233
+ if (restOf(reg) === "busy") return [];
234
+ const ordered = new Set([...(reg.menu ?? reg.defs).keys()].filter((name) => reg.defs.has(name)));
235
+ for (const name of reg.defs.keys()) ordered.add(name);
236
+ return [...ordered].filter((name) => takenNow(reg, name));
237
+ }
238
+
239
+ /** A Gate's `acceptsNow` (ADR-0011): its accepted set, narrowed the same way as an Agent's Allowed
240
+ * picks — the names the run would take from this Gate now. */
241
+ export function acceptsNow(reg: Pick<Registration, "defs" | "invoker" | "sender" | "invokeId">): string[] {
242
+ return [...reg.defs.keys()].filter((name) => takenNow(reg, name));
243
+ }
244
+
141
245
  /** Delivery target absent (unknown address, settled run, exited state) — the one catch point. */
142
246
  export class UnknownAddressError extends Error {}
143
- /** Delivery body rejected (unaccepted name, or payload failing the named schema). */
144
- export class EventValidationError extends Error {}
247
+ /** Delivery body rejected (unaccepted name, or payload failing the named schema). An Agent's
248
+ * refusal carries its Allowed picks, read after the refusal (ADR-0029), so the Harness can say
249
+ * what the Turn may pick instead. */
250
+ export class EventValidationError extends Error {
251
+ allowed?: string[];
252
+ }
253
+
254
+ /**
255
+ * A pick that arrived while the Agent's state is not at Rest (ADR-0024): refused, `finish` included,
256
+ * and nothing is delivered — xstate would take it, and a pick its state handles would exit the child
257
+ * state and stop the work running there. `outstanding` names this Turn's pick still out, when there
258
+ * is one; without it, the state is busy with work the Turn did not pick (it started busy).
259
+ */
260
+ export class OutstandingError extends Error {
261
+ readonly outstanding?: string;
262
+ constructor(message: string, outstanding?: string) {
263
+ super(message);
264
+ this.outstanding = outstanding;
265
+ }
266
+ }
267
+
268
+ /** Refuse a caller's body that carries the sender (ADR-0013, ADR-0015): only the Orchestrator writes
269
+ * it, from the registration, so neither the Agent's wire nor the gates route takes it. */
270
+ export function refuseSenderField(payload: unknown): void {
271
+ if (payload && typeof payload === "object" && SENDER_KEY in payload) {
272
+ throw new EventValidationError(
273
+ `\`${SENDER_KEY}\` is written by the Orchestrator on every delivered event; a caller cannot send it`,
274
+ );
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The run would not take this event from this sender (ADR-0015, ADR-0029): xstate's selection holds
280
+ * a writing that is another party's, or — at a Gate — nothing would move. Refused BEFORE the Machine
281
+ * sees it. `writings` names the other party's writings, empty when nothing would move.
282
+ */
283
+ export class NotTakenError extends Error {
284
+ readonly writings: readonly NotTakenWriting[];
285
+ constructor(message: string, writings: readonly NotTakenWriting[] = []) {
286
+ super(message);
287
+ this.writings = writings;
288
+ }
289
+ }
145
290
 
146
291
  /**
147
292
  * The actor path below the run's root: every id from the root's children down to `ref` itself,
@@ -164,7 +309,7 @@ export function actorPath(ref: AnyActorRef): string[] {
164
309
  * instances (two Pool children, whose ids are the items') can never meet.
165
310
  *
166
311
  * One derivation, two readers, so they cannot drift: `mintIid` builds every iid on it (a fresh
167
- * Turn adds a random suffix, a burned conversation's successor its epoch), and the Agent actor
312
+ * Turn adds a random suffix, a burned conversation's successor its epoch), and the Agent's Actor
168
313
  * names the conversation it burns with it.
169
314
  */
170
315
  export function continuedIid(machine: AnyActorRef | undefined, runId: string, agentName: string): string {
@@ -213,11 +358,14 @@ export class RegistrationTable {
213
358
  * Validate and deliver one event to an address: the shared behavior both dialect adapters
214
359
  * call. Unknown address / unaccepted name / bad payload throw typed errors the adapters map
215
360
  * to their wire (404 / 400, MCP tool errors). The payload is parsed by the named def's
216
- * schema, so what lands in the Machine is exactly the validated shape.
361
+ * schema, so what lands in the Machine is exactly the validated shape — plus the registration's
362
+ * sender, written here and nowhere else (ADR-0015). A delivery that would run another party's
363
+ * writing throws {@link NotTakenError}.
217
364
  */
218
365
  deliver(address: string, type: string, payload: unknown): void {
219
366
  const reg = this.byAddress.get(address);
220
367
  if (!reg) throw new UnknownAddressError(`no live registration at "${address}"`);
368
+ refuseSenderField(payload);
221
369
  const def = reg.defs.get(type);
222
370
  if (!def) {
223
371
  throw new EventValidationError(
@@ -228,7 +376,25 @@ export class RegistrationTable {
228
376
  if (!parsed.success) {
229
377
  throw new EventValidationError(`invalid "${type}" payload: ${parsed.error.message}`);
230
378
  }
231
- reg.deliver({ type: def.name, ...parsed.data });
379
+ // `type` after the payload: a payload never names the event the Machine receives (ADR-0011).
380
+ const event: DeliveredEvent = {
381
+ ...parsed.data,
382
+ type: def.name,
383
+ ...(reg.sender ? { [SENDER_KEY]: reg.sender } : {}),
384
+ };
385
+ // Exact, with the real payload (ADR-0015): a delivery whose selection holds a writing that is
386
+ // not this sender's never reaches the Machine.
387
+ const others = reg.sender ? notTaken(reg, event) : [];
388
+ if (others.length) {
389
+ const owned = (w: NotTakenWriting): string =>
390
+ w.owners.length ? `, which is ${w.owners.map(senderText).join(" and ")}'s` : "";
391
+ throw new NotTakenError(
392
+ `"${type}" from ${senderText(reg.sender!)} would run the writing on ` +
393
+ `${others.map((w) => `"${w.state}"${owned(w)}`).join(" and ")}`,
394
+ others,
395
+ );
396
+ }
397
+ reg.deliver(event);
232
398
  }
233
399
  }
234
400
 
@@ -236,6 +402,9 @@ export class RegistrationTable {
236
402
  export type RunBinding = {
237
403
  runId: string;
238
404
  workflow: string;
405
+ /** The hash of the Assembly the run holds (ADR-0069): the one it started under, through
406
+ * restarts. Absent for a run no Assembly started. */
407
+ assembly?: string;
239
408
  table: RegistrationTable;
240
409
  /** The host's Sandbox backend, when it has a cluster (`workspace()` resolves it here —
241
410
  * one cluster per orchestrator instance, so it is host infrastructure like the table). */
@@ -248,18 +417,30 @@ export type RunBinding = {
248
417
  instanceHarness?: string;
249
418
  /**
250
419
  * The Harness bearer for a placement (ADR-0058): `harnessToken(signingKey, placement)`, closed
251
- * over the key by the host. The Agent actor asks it for the pod its Turn resolved to; the echo
420
+ * over the key by the host. The Agent's Actor asks it for the pod its Turn resolved to; the echo
252
421
  * attach asks it for the Workspace's. Absent (bare unit-test bindings, the stub path), no bearer
253
422
  * is sent — which only a Harness that checks nothing accepts.
254
423
  */
255
424
  harnessBearer?: (placement: string) => string;
425
+ /**
426
+ * Where an Effector placement is, and the key its wire is signed with (ADR-0068): the Instance
427
+ * Effector's Service and `effectorKey(signingKey, "instance")`, closed over by the host. An http
428
+ * Effect asks it; absent (bare unit-test bindings, an instance with no Effector), it faults.
429
+ */
430
+ effector?: (placement: EffectorPlacement) => EffectorTarget | undefined;
256
431
  /**
257
432
  * Record an Agent invocation's durable admission in the host ledger (ADR-0016): persisted
258
433
  * beside the snapshot in the same RunBlob save, keyed by iid (globally unique, so the map is
259
434
  * flat). Optional so a bare unit-test binding can omit it — then admissions simply are not
260
435
  * durable.
261
436
  */
262
- recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
437
+ recordAdmission?: (instanceId: string, admission: AgentAdmission, delivered?: readonly string[]) => void;
438
+ /**
439
+ * The admission ledger's READ half (ADR-0016): the last admission ledgered under this iid. A
440
+ * continued Turn asks it whether its conversation was ever admitted — only then can the Harness
441
+ * have lost it (ADR-0062).
442
+ */
443
+ admissionOf?: (instanceId: string) => AgentAdmission | undefined;
263
444
  /**
264
445
  * The epoch ledger's READ half (ADR-0057): the current epoch of a continued conversation,
265
446
  * keyed by its base Instance ID ({@link continuedIid}). The input mapper reads it AT MINT TIME,
@@ -273,9 +454,32 @@ export type RunBinding = {
273
454
  * it ended, and the next `continue` on that Agent mints `<conversation>/<epoch>` — a virgin
274
455
  * conversation. No fault leaves a conversation worth continuing (ADR-0035): a runaway either
275
456
  * had its reroll or holds a poisoned context, and infra and no-signal mean the Harness that
276
- * held it is unreachable. Persisted beside the snapshot with the admission ledger.
457
+ * held it is unreachable. Persisted beside the snapshot with the admission ledger. `reason` is
458
+ * the fault's: the host raises it as the next `continue`'s `conversation-new` notice (ADR-0062).
277
459
  */
278
- bumpEpoch?: (conversation: string) => void;
460
+ bumpEpoch?: (conversation: string, reason: string) => void;
461
+ /**
462
+ * The notices' write half (ADR-0062): keep a notice for the next admission in scope — `to` is a
463
+ * continued conversation's base id or a Sandbox name, as the notice's `scope` says. Persisted at
464
+ * the write, like the ledgers. `source` names the event, so one event raised twice is one notice.
465
+ * A burned conversation's notice is raised by `bumpEpoch` itself, in the same save.
466
+ */
467
+ raiseNotice?: (notice: Notice, to: string, source?: string) => void;
468
+ /** Reserve the pending notices in one admission's scope. They are delivered by the
469
+ * `recordAdmission` that ledgers it (its `delivered` ids), or released if the admission fails. */
470
+ takeNotices?: (scope: NoticeScope) => TakenNotices;
471
+ releaseNotices?: (ids: readonly string[]) => void;
472
+ /**
473
+ * The pick the Turn now at this address has outstanding, or answered and not yet read (ADR-0024):
474
+ * what the Agent's Actor asks when its Harness restarted under a Turn — a Turn whose pick is still
475
+ * out, or whose answer never reached the model, is cut, not faulted (ADR-0031). Read after the host
476
+ * settles its holds, so `answered` is the Rest rule's verdict now. An earlier Turn's pick on the
477
+ * same continued address is not this Turn's: its answer went as a notice. Absent outside a RunHost.
478
+ */
479
+ outstandingOf?: (instanceId: string) => OutstandingPick | undefined;
480
+ /** The held answer reached the model by another road — a re-admission's notice — so the poll's
481
+ * copy is spent (ADR-0031). */
482
+ heldAnswerDelivered?: (instanceId: string) => void;
279
483
  /**
280
484
  * Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
281
485
  * as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
@@ -287,7 +491,7 @@ export type RunBinding = {
287
491
  * settlement pick, so the run's narrative can name what a Turn hosted elsewhere decided.
288
492
  * Host-supplied; absent (bare unit-test bindings), turns leave no markers.
289
493
  */
290
- marker?: (event: TurnMarker) => void;
494
+ marker?: (event: RunMarker) => void;
291
495
  /**
292
496
  * Attach the run-narrative echo to a Workspace's Harness (ADR-0023): the host replays the
293
497
  * run's feed-so-far to `endpoint` — the Harness in `placement`'s pod, pushed with that
@@ -306,13 +510,63 @@ export type RunBinding = {
306
510
  */
307
511
  hostStopping?: boolean;
308
512
  /**
309
- * iid → the abort still in flight for it (ADR-0024). The Agent actor fills this on the way out and
513
+ * Every reply since the pick that `machine`'s Agent has outstanding, or is delivering now, oldest
514
+ * first (ADR-0024) — what an `answer` action reads as its `chain` (ADR-0067). `seat` is the place
515
+ * the action runs from: an Agent's state, and then only that Agent's pick counts; or `null`, a
516
+ * transition below no Agent's state, and then `event` — the event whose transition runs the action
517
+ * — names the Agent: the sender of a pick, or the Agent whose pick started the child whose done
518
+ * or error event it is. Undefined when no such pick is being answered, when the event names an
519
+ * Agent with none, or when the event names no Agent outside a pick's own send while two Agents'
520
+ * states are active or more than one pick is outstanding: then the action cannot tell whose
521
+ * answer it shapes, and sets none.
522
+ */
523
+ chainOf?: (machine: AnyActorRef, seat: AnswerSeat, event?: AnyEventObject) => PickAnswer[] | undefined;
524
+ /** Set what that pick's answer is (ADR-0067): it replaces the default, and the last call wins.
525
+ * Kept with the hold, so a restore answers the same. */
526
+ setAnswer?: (machine: AnyActorRef, seat: AnswerSeat, value: unknown, event?: AnyEventObject) => void;
527
+ /**
528
+ * The Effect ledger's READ half (ADR-0067): one Effect's row, by its id — what a restored Effect
529
+ * reads to deliver its output, re-attach, or settle `unknown`, and never to run twice.
530
+ */
531
+ effectRow?: (effectId: string) => EffectRow | undefined;
532
+ /** Every row of the run's Effect ledger, by Effect id — what a push reads its pairs from (each
533
+ * push of its Workspace and Repo Slot that published, ADR-0067). */
534
+ effectRows?: () => Readonly<Record<string, EffectRow>>;
535
+ /**
536
+ * The Effect ledger's WRITE half (ADR-0067), in the same RunBlob as the snapshot. `"now"` (the
537
+ * default) saves AT THE WRITE — so an intent row is on disk, with the snapshot that holds its
538
+ * Effect, before the call leaves. `"with-transition"` is for a row written INSIDE a transition (an
539
+ * abort, D1): it is kept in memory for the save that carries that transition — a save now would
540
+ * read the Machine before its route — and saved after the transition if nothing else saves it.
541
+ * Optional: a bare binding keeps no ledger, and nothing it runs is durable.
542
+ */
543
+ recordEffect?: (effectId: string, row: EffectRow, save?: "now" | "with-transition") => void;
544
+ /**
545
+ * iid → the abort still in flight for it (ADR-0024). The Agent's Actor fills this on the way out and
310
546
  * waits on it before admitting, so an abort can never overtake the next turn on the same
311
547
  * instance — flue QUEUES per instance, and an abort that lost that race would settle the new
312
548
  * submission before it ran. Created on demand: the ordering must hold for ANY binding, not only
313
549
  * one the host remembered to equip.
314
550
  */
315
551
  pendingAborts?: Map<string, Promise<void>>;
552
+ /**
553
+ * A named Chart of the run's own Workflow, by its short name (ADR-0070, D-C): what an Open body
554
+ * whose input is `{ chart: { name } }` binds. The host resolves it among the Charts of the
555
+ * wrapper that is the run's Workflow (`<wrapper>` of `<wrapper>/<name>`, or the Workflow itself),
556
+ * and only for a body that wrapper invokes itself. Absent outside a RunHost: no name resolves.
557
+ */
558
+ chartNamed?: (name: string) => ChartText | undefined;
559
+ };
560
+
561
+ /** A conversation's latest outstanding pick, as the Agent's Actor reads it (ADR-0024). `answered`: the Machine
562
+ * came back to Rest, or left the Agent's state (`turnComplete`); `fetched`: the answer reached the
563
+ * model. */
564
+ export type OutstandingPick = {
565
+ pick: string;
566
+ answered: boolean;
567
+ turnComplete: boolean;
568
+ answer?: PickAnswer;
569
+ fetched: boolean;
316
570
  };
317
571
 
318
572
  /** One absorbed-retry attempt (a no-signal nudge), as the run feed carries it. */
@@ -330,6 +584,30 @@ export type TurnMarker =
330
584
  | { kind: "admission"; agent: string; endpoint: string; prompt: string }
331
585
  | { kind: "pick"; agent: string; endpoint: string; event: string; payload?: Record<string, unknown> };
332
586
 
587
+ /**
588
+ * ADR-0064's two feed lines per wait for capacity: a Workspace's Sandbox has no node — `placing`,
589
+ * with the reason (`on`) and the scheduler's or the quota's own words — and then it has one —
590
+ * `placed`, `after` milliseconds since `since`. A changed reason adds no line: `RunStatus.waiting`
591
+ * carries the latest. `child` is the Workspace's actor path below the run root, its ids joined by
592
+ * `/` (`""` for a run whose root IS the Workspace). Instance-token class like the Turn markers: the
593
+ * message can name nodes and taints, so these ride the per-run feed alone.
594
+ */
595
+ export type PlacingMarker =
596
+ | { kind: "placing"; child: string; on: "node" | "quota"; message: string; since: string }
597
+ | { kind: "placed"; child: string; since: string; after: number };
598
+
599
+ /**
600
+ * A child below an Agent's state failed while a pick of that Agent was outstanding (ADR-0024): the
601
+ * pick's answer carries no reply for it — the Agent is not told — so the operator reads it here: the
602
+ * Agent, its pick, the child (its slot, or its invoke id) and the error's message. Instance-token
603
+ * class like the Turn markers: an error message is mechanism text, as `fault` is.
604
+ */
605
+ export type ChildErrorMarker = { kind: "child-error"; agent: string; pick: string; child: string; error: string };
606
+
607
+ /** Every marker a run's mechanism puts on its per-run feed — an Effect's lines among them
608
+ * (ADR-0067): its start, its stream, its output, its abort. */
609
+ export type RunMarker = TurnMarker | PlacingMarker | ChildErrorMarker | EffectMarker;
610
+
333
611
  const bindings = new WeakMap<AnyActorSystem, RunBinding>();
334
612
 
335
613
  /** Host-side: bind a run's actor system to its identity, before the actor starts. */
@@ -349,6 +627,12 @@ export function boundEpoch(system: AnyActorSystem, conversation: string): number
349
627
  return bindings.get(system)?.epochOf?.(conversation) ?? 0;
350
628
  }
351
629
 
630
+ /** The bound run, or undefined outside a jr2 host — the SOFT read an Effect uses: with no host it
631
+ * keeps no ledger and writes no feed, and still runs. */
632
+ export function boundRun(system: AnyActorSystem): RunBinding | undefined {
633
+ return bindings.get(system);
634
+ }
635
+
352
636
  /** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
353
637
  export function runBindingOf(system: AnyActorSystem): RunBinding {
354
638
  const binding = bindings.get(system);