@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
package/src/timers.ts ADDED
@@ -0,0 +1,178 @@
1
+ // A run's pending timers across a restart (ADR-0007).
2
+ //
3
+ // xstate 5 keeps a delayed event (`after`, a named delay, a delayed `raise` or `sendTo`) on the
4
+ // actor SYSTEM's scheduler, not in the Machine snapshot. `getPersistedSnapshot()` carries no trace
5
+ // of it, and a restored actor runs no entry actions again, so nothing schedules it again: the
6
+ // timer is lost, not restarted. xstate 5 has a `snapshot.scheduler` seam in `createSystem`, but
7
+ // the Actor constructor never passes it.
8
+ //
9
+ // So the run host records each pending timer's absolute DEADLINE beside the snapshot, in the same
10
+ // blob, and schedules it again on the restored system after `start()`. The author writes plain
11
+ // `after`. The scheduled id is xstate's own (`xstate.after.<delay>.<stateId>` for an `after`), so
12
+ // the state's exit `cancel(id)` still cancels a re-armed timer.
13
+ //
14
+ // This reads an xstate INTERNAL: `system.getSnapshot()._scheduledEvents` (typed in xstate's
15
+ // `ActorSystem`, but underscored), `system.scheduler.schedule`, and `system.scheduler.cancel`
16
+ // (wrapped for the length of `start()`). An xstate bump can break it; the raw-xstate tests in
17
+ // test/timers.test.ts are the canary. xstate 6 persists timers in the snapshot and re-arms them
18
+ // itself, with the same deadline and clamp: on xstate 6, delete this module.
19
+
20
+ import type { AnyActorRef, EventObject } from "xstate";
21
+
22
+ /** One pending delayed event, JSON-safe. An actor is named by its path of child ids below the
23
+ * run's root (`[]` is the root). */
24
+ export type PendingTimer = {
25
+ source: string[];
26
+ target: string[];
27
+ /** xstate's scheduled id. */
28
+ id: string;
29
+ event: EventObject;
30
+ /** The delay as last scheduled, ms: the upper bound on what remains (see {@link startAndRearm}). */
31
+ delay: number;
32
+ /** Wall-clock ms at which it is due, on the host's clock (`now`). */
33
+ deadline: number;
34
+ };
35
+
36
+ function childrenOf(ref: AnyActorRef): Record<string, AnyActorRef> {
37
+ return (ref.getSnapshot() as { children?: Record<string, AnyActorRef> }).children ?? {};
38
+ }
39
+
40
+ function pathOf(root: AnyActorRef, ref: AnyActorRef, path: string[] = []): string[] | undefined {
41
+ if (ref === root) return path;
42
+ for (const [id, child] of Object.entries(childrenOf(root))) {
43
+ const found = pathOf(child, ref, [...path, id]);
44
+ if (found) return found;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function refAt(root: AnyActorRef, path: string[]): AnyActorRef | undefined {
50
+ let ref: AnyActorRef | undefined = root;
51
+ for (const id of path) ref = ref && childrenOf(ref)[id];
52
+ return ref;
53
+ }
54
+
55
+ /** xstate's key for a scheduled event: one per source actor and id. */
56
+ const keyOf = (source: AnyActorRef, id: string) => `${source.sessionId}.${id}`;
57
+
58
+ const clamp = (ms: number, delay: number) => Math.min(delay, Math.max(0, ms));
59
+
60
+ /**
61
+ * The run's pending timers, read off its actor system. Call at persist time: the scheduler's table
62
+ * and the snapshot are read in the same tick, so they agree. A timer whose source or target is not
63
+ * in the run's tree (a delayed send to an actor outside it) is not recorded; jr2 has no such idiom.
64
+ *
65
+ * xstate stamps a timer's start with `Date.now()`, so the time that remains is read on that clock;
66
+ * the deadline is `now + remaining`, on the host's clock. With the default `now` the two are one
67
+ * clock and the deadline is xstate's own `startedAt + delay`.
68
+ */
69
+ export function pendingTimers(root: AnyActorRef, now = Date.now()): PendingTimer[] {
70
+ const wall = Date.now();
71
+ const out: PendingTimer[] = [];
72
+ for (const s of Object.values(root.system.getSnapshot()._scheduledEvents)) {
73
+ const source = pathOf(root, s.source);
74
+ const target = pathOf(root, s.target);
75
+ if (!source || !target) continue;
76
+ const remaining = clamp(s.startedAt + s.delay - wall, s.delay);
77
+ out.push({ source, target, id: s.id, event: s.event, delay: s.delay, deadline: now + remaining });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * The pending delayed events whose TARGET is `ref`, from any source: what Rest asks (rest.ts) —
84
+ * an `after`, a named delay, a delayed `raise` or `sendTo(self)`, a child's delayed `sendParent`.
85
+ * The same table {@link pendingTimers} persists and {@link startAndRearm} re-arms, so Rest reads
86
+ * the same timers after a restore as before it.
87
+ */
88
+ export function scheduledFor(ref: AnyActorRef): Array<{ id: string; event: EventObject }> {
89
+ return Object.values(ref.system.getSnapshot()._scheduledEvents)
90
+ .filter((s) => s.target === ref)
91
+ .map((s) => ({ id: s.id, event: s.event }));
92
+ }
93
+
94
+ /**
95
+ * Watch a run's actor system for timers that FIRE: the returned function says whether one relayed
96
+ * event is a timer the system scheduled — an `after`, a named delay, a delayed `raise` or `sendTo` —
97
+ * and not an event an actor sent at once. Both reach the inspection stream from the same source with
98
+ * the same shape; only the scheduler knows the difference, and it forgets a timer as it fires it.
99
+ *
100
+ * So `scheduler.schedule` and `scheduler.cancel` are wrapped for the life of the system: each armed
101
+ * timer is kept by its key with the event object xstate relays when it fires (the same object, not
102
+ * a copy). A relayed event is a fired timer when its source and event object are an armed timer's
103
+ * and that timer is no longer in the scheduler's table; it is then forgotten. A re-armed timer
104
+ * (`startAndRearm`) goes through the same `schedule`, so a restored run recognises it too.
105
+ *
106
+ * Install it before `start()` (the root's creation event is early enough): a timer armed before the
107
+ * wrap is not seen. The same xstate internals as the rest of this module; the canary is the same.
108
+ */
109
+ export function watchTimers(system: AnyActorRef["system"]): (source: unknown, event: unknown) => boolean {
110
+ const scheduler = system.scheduler;
111
+ const { schedule, cancel } = scheduler;
112
+ const armed = new Map<string, { source: AnyActorRef; event: EventObject }>();
113
+ scheduler.schedule = (source, target, event, delay, id = Math.random().toString(36).slice(2)) => {
114
+ schedule(source, target, event, delay, id);
115
+ armed.set(keyOf(source, id), { source, event });
116
+ };
117
+ scheduler.cancel = (source, id) => {
118
+ armed.delete(keyOf(source, id));
119
+ cancel(source, id);
120
+ };
121
+ return (source, event) => {
122
+ for (const [key, row] of armed) {
123
+ if (row.event !== event || row.source !== source) continue;
124
+ if (key in system.getSnapshot()._scheduledEvents) continue;
125
+ armed.delete(key);
126
+ return true;
127
+ }
128
+ return false;
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Start a restored run, then schedule each recorded timer again on it. One function, so the order
134
+ * cannot be got wrong: re-armed BEFORE `start()`, a timer is armed twice and fires twice, because
135
+ * the system's own `start()` schedules whatever is already in its table; and `start()` is where
136
+ * xstate builds the children, so a child's path resolves only after it.
137
+ *
138
+ * `start()` drains the mailbox: a child that sends on start (an author's `fromCallback`) can move
139
+ * the Machine out of a state before any timer is re-armed. That exit's `cancel(id)` finds nothing
140
+ * armed yet. So every cancel during `start()` is recorded, and a row it names is not re-armed: a
141
+ * state exit cancels the timer whether it happens during `start()` or after it.
142
+ *
143
+ * A child stopped during `start()` takes its timers with it, but xstate stops a child with
144
+ * `scheduler.cancelAll`, which walks the table, and the table is still empty: no cancel is seen.
145
+ * If its parent state is entered again, a NEW child sits at the same path. So each row's actors are
146
+ * resolved BEFORE `start()` (the restored tree already holds them), and a row is re-armed only on
147
+ * the same actors after it. A row of a stopped child then fires into nothing, not into its
148
+ * replacement.
149
+ *
150
+ * Remaining time is `deadline - now`, clamped to `[0, delay]`. A deadline that passed while the
151
+ * Orchestrator was down fires at once. A wall clock that went BACKWARDS cannot push a timer past its
152
+ * own delay. A wall clock that jumped forwards fires a timer early: only the wall clock survives a
153
+ * process, so the deadline is a wall-clock time. Rows are scheduled in deadline order, so timers
154
+ * that are all overdue (remaining 0) fire in the order they fell due, as in an uninterrupted run.
155
+ */
156
+ export function startAndRearm(root: AnyActorRef, timers: readonly PendingTimer[], now = Date.now()): void {
157
+ const scheduler = root.system.scheduler;
158
+ const cancel = scheduler.cancel;
159
+ const cancelled = new Set<string>();
160
+ const rows = [...timers]
161
+ // `sort` is stable: equal deadlines keep the order xstate scheduled them in.
162
+ .sort((a, b) => a.deadline - b.deadline)
163
+ .map((t) => ({ t, source: refAt(root, t.source), target: refAt(root, t.target) }));
164
+ scheduler.cancel = (source, id) => {
165
+ cancelled.add(keyOf(source, id));
166
+ cancel(source, id);
167
+ };
168
+ try {
169
+ root.start();
170
+ } finally {
171
+ scheduler.cancel = cancel;
172
+ }
173
+ for (const { t, source, target } of rows) {
174
+ if (!source || !target || cancelled.has(keyOf(source, t.id))) continue;
175
+ if (refAt(root, t.source) !== source || refAt(root, t.target) !== target) continue;
176
+ scheduler.schedule(source, target, t.event, clamp(t.deadline - now, t.delay), t.id);
177
+ }
178
+ }
package/src/tokens.ts CHANGED
@@ -1,24 +1,32 @@
1
1
  // Bearer tokens (ADR-0013), because the Sandbox boundary is otherwise theater. The Harness
2
2
  // container shares the pod's network namespace, so an Agent can reach the Orchestrator directly;
3
- // a per-pod NetworkPolicy cannot distinguish its packets from the Adapter's. Only authentication
3
+ // a per-pod NetworkPolicy cannot distinguish its packets from the Custodian's. Only authentication
4
4
  // closes this, and only the token bounds what a caller may DO (as opposed to where it may talk).
5
5
  //
6
- // Two principals, and the asymmetry between them is the whole design:
6
+ // Three principals. The asymmetry between the first two is the whole design; the third is a door
7
+ // key, not a seat:
7
8
  //
8
9
  // Instance token the human/CLI credential. Full trust: gates, run control, agent surfaces.
9
10
  // From the instance Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent,
10
11
  // announced once on stdout (fixtures capture it). An Agent never has it — it
11
12
  // never enters a Sandbox.
12
13
  //
13
- // Sandbox token the Adapter's credential. Authorizes exactly: deliver to `kind: "agent"`
14
+ // Publish token `jr2 up`'s credential for `PUT /assembly` alone (ADR-0069). A publish imports
15
+ // code into the process that holds the ServiceAccount, so it must cost what an
16
+ // image roll cost: cluster write access. From the instance Secret
17
+ // (`JR2_PUBLISH_TOKEN`), read by `jr2 up` through the kube API only. Every other
18
+ // route answers it 403: it is a known token, but not one that route accepts.
19
+ //
20
+ // Sandbox token the Custodian's credential. Authorizes exactly: deliver to `kind: "agent"`
14
21
  // registrations whose Sandbox is THIS one. Never a Gate (a compromised Agent
15
22
  // must not approve its own review), never another Sandbox (`coding.ts`'s iids
16
23
  // are derivable and feature ids are readable from the Work Source, so a merely
17
24
  // run-scoped token would let one feature's coder inject a verdict into another
18
- // feature's reviewer). Delivered as a Secret via the CR's `envFrom` into the
19
- // Adapter container ALONE, which is the one place the Agent cannot read.
25
+ // feature's reviewer). Delivered as a Secret mounted into the Custodian
26
+ // container ALONE, which the Agent cannot read; the Harness holds its
27
+ // Stand-in (ADR-0059).
20
28
  // The signed name is a POD hosting Turns, not a Sandbox CR per se: the Instance
21
- // Harness's Adapter bears one signed for that placement's Service name
29
+ // Harness's Custodian holds one signed for that placement's Service name
22
30
  // (ADR-0031), scoping it to the Menu-only registrations placed there.
23
31
  //
24
32
  // And one bearer the other way (ADR-0058), which is no principal here because this server never
@@ -32,7 +40,7 @@
32
40
  // verified by recomputing. Three things fall out that a token table would have to work for — the
33
41
  // Orchestrator holds no per-Sandbox state, `provision` stays idempotent (re-minting yields the same
34
42
  // token, so a re-applied Secret is a no-op), and a token minted before a restart still verifies
35
- // after one, which is exactly what ADR-0012's re-attach promises the still-running Adapter.
43
+ // after one, which is exactly what ADR-0012's re-attach promises the still-running Custodian.
36
44
 
37
45
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
38
46
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
@@ -42,7 +50,10 @@ import { join } from "node:path";
42
50
  export type Principal =
43
51
  /** The instance's own operator: the CLI, a human, a webhook translator holding the token. */
44
52
  | { kind: "instance" }
45
- /** One Sandbox's Adapter, speaking for the Agent in that pod — and for no one else. */
53
+ /** `jr2 up`, publishing an Assembly (ADR-0069). Only `PUT /assembly` accepts it: it is not an
54
+ * Instance principal, so it reads no run and starts none. */
55
+ | { kind: "publisher" }
56
+ /** One Sandbox's Custodian, speaking for the Agent in that pod — and for no one else. */
46
57
  | { kind: "sandbox"; sandbox: string };
47
58
 
48
59
  /** Resolve a bearer token to a principal; undefined = not a token we minted (→ 401). */
@@ -76,7 +87,20 @@ export function harnessTokenDigest(key: Buffer, placement: string): string {
76
87
  return createHash("sha256").update(harnessToken(key, placement)).digest("base64url");
77
88
  }
78
89
 
79
- /** Mint the per-boot Instance token (opaque and random — it names nothing, it just IS the trust). */
90
+ /**
91
+ * The signing key of one Effector placement (ADR-0068): `HMAC(signing key, "effector:" + placement)`,
92
+ * as the Harness bearer is derived (ADR-0058). The Orchestrator signs every request to that
93
+ * Effector with it and verifies every answer; the Effector reads it from a file in its own mount,
94
+ * which `jr2 up` writes (`instance` → the `jr2-instance-effector` Secret). Derived, not stored: a
95
+ * restarted Orchestrator derives the key a live Effector already holds. The `effector:` prefix
96
+ * keeps it apart from every Sandbox token and Harness bearer. The key's bytes are this string's.
97
+ */
98
+ export function effectorKey(key: Buffer, placement: string): string {
99
+ return sign(key, `effector:${placement}`);
100
+ }
101
+
102
+ /** Mint the per-boot Instance token (opaque and random — it names nothing, it just IS the trust).
103
+ * The publish token is minted the same way when the Secret supplies none (ADR-0069). */
80
104
  export function mintInstanceToken(): string {
81
105
  return randomBytes(32).toString("base64url");
82
106
  }
@@ -85,7 +109,7 @@ export function mintInstanceToken(): string {
85
109
  * The instance's HMAC signing key, at `<dir>/.jr2/secret` (0600), created on first use.
86
110
  *
87
111
  * It MUST outlive the process: an Orchestrator restart leaves live Sandboxes running (ADR-0012 re-attach),
88
- * and their Adapters still hold tokens minted by the process that died. A fresh key would reject
112
+ * and their Custodians still hold tokens minted by the process that died. A fresh key would reject
89
113
  * every one of them — the Agent would silently lose its only route to its Machine.
90
114
  */
91
115
  export async function loadSigningKey(dir: string): Promise<Buffer> {
@@ -108,10 +132,16 @@ export async function loadSigningKey(dir: string): Promise<Buffer> {
108
132
  * A token we did not mint resolves to nothing, and the caller is refused — there is no anonymous
109
133
  * principal, and no route decides for itself whether it needs one.
110
134
  */
111
- export function createAuthenticator(opts: { instanceToken: string; signingKey: Buffer }): Authenticator {
135
+ export function createAuthenticator(opts: {
136
+ instanceToken: string;
137
+ signingKey: Buffer;
138
+ /** The publish token (ADR-0069). Absent → no bearer resolves to the publisher. */
139
+ publishToken?: string;
140
+ }): Authenticator {
112
141
  return (bearer) => {
113
142
  if (!bearer) return undefined;
114
143
  if (constantTimeEqual(bearer, opts.instanceToken)) return { kind: "instance" };
144
+ if (opts.publishToken !== undefined && constantTimeEqual(bearer, opts.publishToken)) return { kind: "publisher" };
115
145
  const cut = bearer.lastIndexOf(".");
116
146
  if (cut <= 0) return undefined;
117
147
  const sandbox = bearer.slice(0, cut);
@@ -130,6 +160,7 @@ export function createAuthenticator(opts: { instanceToken: string; signingKey: B
130
160
  */
131
161
  export function mayDeliverToAgent(principal: Principal, registrationSandbox: string | undefined): boolean {
132
162
  if (principal.kind === "instance") return true;
163
+ if (principal.kind !== "sandbox") return false;
133
164
  return registrationSandbox !== undefined && registrationSandbox === principal.sandbox;
134
165
  }
135
166
 
@@ -137,10 +168,11 @@ export function mayDeliverToAgent(principal: Principal, registrationSandbox: str
137
168
  * May this principal ask for a fetch on this Sandbox (ADR-0053)? A Sandbox token may ask for its
138
169
  * OWN pod and no other — the scope is the caches that pod mounts, and the route refuses a Repo it
139
170
  * does not. The Instance token may, on the same grounds it may deliver to any agent surface: it is
140
- * the operator. Nothing else can hold either, so there is no third case.
171
+ * the operator. The publish token may not: it opens the publish door and no other (ADR-0069).
141
172
  */
142
173
  export function mayAskForSandbox(principal: Principal, sandbox: string): boolean {
143
174
  if (principal.kind === "instance") return true;
175
+ if (principal.kind !== "sandbox") return false;
144
176
  return principal.sandbox === sandbox;
145
177
  }
146
178
 
package/src/vocabulary.ts CHANGED
@@ -54,10 +54,32 @@ export function vocabularyOf(machine: AnyStateMachine): Map<string, EventDef> |
54
54
  * silently accepting a name nobody declared.
55
55
  */
56
56
  export function invokingMachine(self: AnyActorRef): AnyStateMachine | undefined {
57
- const logic = (self._parent as { logic?: unknown } | undefined)?.logic as AnyStateMachine | undefined;
57
+ // An Open body's actor (ADR-0070) delegates to the Machine compiled from the Chart it binds, and
58
+ // its own logic is no Machine: the bound Machine is the scope of the Agents and Gates its states
59
+ // invoke, so it is asked first.
60
+ const parent = self._parent;
61
+ const bound = parent ? boundMachines.get(parent) : undefined;
62
+ if (bound) return bound;
63
+ const logic = (parent as { logic?: unknown } | undefined)?.logic as AnyStateMachine | undefined;
58
64
  return logic?.root ? logic : undefined;
59
65
  }
60
66
 
67
+ // The Machine an Open body's actor runs (ADR-0070): compiled from the Chart the run bound, per
68
+ // actor, at start and again at restore. Keyed on the ACTOR, not on a config: two runs of one
69
+ // wrapper bind two Charts under the same slot. Kept here, beside the vocabulary, so this module
70
+ // stays a leaf: open-body.ts writes it, and `invokingMachine` reads it.
71
+ const boundMachines = new WeakMap<AnyActorRef, AnyStateMachine>();
72
+
73
+ /** Record the Machine an Open body's actor runs. jr2-internal: the Open body's logic calls it. */
74
+ export function attachBoundMachine(actor: AnyActorRef, machine: AnyStateMachine): void {
75
+ boundMachines.set(actor, machine);
76
+ }
77
+
78
+ /** The Machine an Open body's actor runs, or undefined for any other actor. */
79
+ export function boundMachineFor(actor: AnyActorRef): AnyStateMachine | undefined {
80
+ return boundMachines.get(actor);
81
+ }
82
+
61
83
  // The declared run input (ADR-0033): the one piece of a machine's vocabulary the event defs
62
84
  // missed — what a run of it is STARTED with. Same key choice as the vocabulary above, for the
63
85
  // same reasons (per-machine attribution, no global registry, transparent across `.provide()`).
@@ -79,6 +101,49 @@ export function inputSchemaOf(machine: AnyStateMachine): z.ZodObject | undefined
79
101
  return inputSchemas.get(machine.config);
80
102
  }
81
103
 
104
+ // Who may send what, per state (ADR-0015): the seats the menu-derivation walk computed. Same key
105
+ // choice as the vocabulary, for the same reasons. The Machine's doc carries them; `jr2 up` and the
106
+ // Console show them, so an author reads the audience rule's answer for their own Machine.
107
+
108
+ /** One party's seat on one state: the state's id, the party, and the names it may send there —
109
+ * for an Agent the derived set its guards narrow; for a Gate its accepted set, unless the Gate's
110
+ * input names `accepts:` itself. */
111
+ export type MachineSeat = { state: string; party: "agent" | "gate"; agent?: string; names: string[] };
112
+
113
+ const seatTables = new WeakMap<MachineKey, MachineSeat[]>();
114
+
115
+ /** Attach a Machine's seats. jr2-internal: `jr2Setup.createMachine` calls it. */
116
+ export function attachSeats(machine: AnyStateMachine, seats: MachineSeat[]): void {
117
+ seatTables.set(machine.config, seats);
118
+ }
119
+
120
+ /** The seats of a Machine built by `jr2Setup`, in walk order; empty for any other Machine. */
121
+ export function seatsOf(machine: AnyStateMachine): MachineSeat[] {
122
+ return seatTables.get(machine.config) ?? [];
123
+ }
124
+
125
+ /** One line per seat — `<state> agent:<slot>: [names]` or `<state> gate: [names]`. */
126
+ export function seatLine(seat: MachineSeat): string {
127
+ const party = seat.party === "agent" ? `agent:${seat.agent}` : "gate";
128
+ return `${seat.state} ${party}: [${seat.names.join(", ")}]`;
129
+ }
130
+
131
+ const loadWarnings = new WeakMap<MachineKey, string[]>();
132
+
133
+ /** Attach the load warnings `jr2Setup.createMachine` found (ADR-0015). jr2-internal. */
134
+ export function attachLoadWarnings(machine: AnyStateMachine, warnings: string[]): void {
135
+ loadWarnings.set(machine.config, warnings);
136
+ }
137
+
138
+ /** What the build found legal and suspect — a mode nothing enters, a pick a Gate above takes from
139
+ * the Agent below, one name written for two parties on one path. `jr2 up` prints them; the
140
+ * Machine's doc carries them. A Machine `customize()` bound an Open argv in is rebuilt around a
141
+ * config of its own, which carries the walk's findings for the bound argv (ADR-0054). Empty for a
142
+ * Machine not built by `jr2Setup`. */
143
+ export function loadWarningsOf(machine: AnyStateMachine): string[] {
144
+ return loadWarnings.get(machine.config) ?? [];
145
+ }
146
+
82
147
  /**
83
148
  * What the HOST adds to the input of the machine a run STARTS with, beside the door
84
149
  * (`RunHost.start`). One field today: the run's seed Instance ID, minted with the run and
package/src/wire.ts CHANGED
@@ -28,6 +28,11 @@ export const SUBMISSION_ABORTED = "submission_aborted";
28
28
  * on the class (one fresh-conversation reroll) without parsing prose. */
29
29
  export const SUBMISSION_RUNAWAY = "runaway";
30
30
 
31
+ /** `SettlementError.type` for a Submission the Harness admitted but had not settled when it
32
+ * restarted (ADR-0031): the rebuilt stream settles it `failed`, so a re-attached `wait` reads a
33
+ * Settlement, not a 404. The Agent actor switches on this type, never on the message's prose. */
34
+ export const SUBMISSION_HARNESS_RESTARTED = "harness_restarted";
35
+
31
36
  /** One settled Submission, in the shape `?view=history`'s `settlements` reports. */
32
37
  export type Settlement = {
33
38
  submissionId: string;
@@ -51,19 +56,97 @@ export type MessageAppendedEvent = StreamChunk<"message-appended"> & {
51
56
  * a parked long-poll. */
52
57
  export type SubmissionSettledEvent = StreamChunk<"submission-settled"> & Settlement;
53
58
 
59
+ /**
60
+ * The Harness's memory guard killed the Agent's processes (ADR-0061, layer 4), on the stream of the
61
+ * conversation whose Turn was running. The Agent heard it in its `bash` answer; this is how the
62
+ * Orchestrator hears it, so the next Turn in the Workspace is told (ADR-0062). `peak` and `limit`
63
+ * are the guard's own spellings — carried to the notice as they are, never parsed.
64
+ */
65
+ export type MemoryLimitEvent = StreamChunk<"memory-limit"> & { peak: string; limit: string };
66
+
54
67
  /** One event on the durable stream (`GET ?offset=…&view=updates` → 200 JSON array). */
55
- export type StreamEvent = MessageAppendedEvent | SubmissionSettledEvent;
68
+ export type StreamEvent = MessageAppendedEvent | SubmissionSettledEvent | MemoryLimitEvent;
69
+
70
+ // ---- Notices (ADR-0062) ------------------------------------------------------------------------
71
+ // What happened that the next Turn cannot find out for itself, as DATA: the Orchestrator keeps
72
+ // them and attaches the pending ones in scope to an admission (`AdmissionRequest.notices`); the
73
+ // Harness writes every word of the Briefing's Turn part from them.
74
+
75
+ /** The Sandbox's processes were killed at its memory limit during `agent`'s Turn — by the
76
+ * Harness's guard (`peak` known) or by the kernel (`peak` unknown). Workspace scope: the next Turn
77
+ * of any Agent in that Workspace. */
78
+ export type MemoryLimitNotice = {
79
+ kind: "memory-limit";
80
+ scope: "workspace";
81
+ agent: string;
82
+ peak?: string;
83
+ limit: string;
84
+ };
85
+
86
+ /** This conversation is new; earlier context is gone. Conversation scope: that Agent's next
87
+ * continued Turn. `reason` says why — the fault that burned the last one, or that the Harness no
88
+ * longer holds it. */
89
+ export type ConversationNewNotice = { kind: "conversation-new"; scope: "conversation"; reason: string };
90
+
91
+ /**
92
+ * What an outstanding pick's answer is (ADR-0024): the LAST reply from below the Agent's state since
93
+ * the pick — a party's event (a Gate's answer, a nested Agent's pick) with its payload, or a child's
94
+ * output (a child Machine, an Effect stand-in) — named by who sent it. A child's error is no reply (a
95
+ * defect, ADR-0024). Absent where nothing replied: the answer carries no reply, and says nothing of
96
+ * whether work ran. Plain data; the Harness writes the words. `answer`: the value the Machine's
97
+ * `answer` action set (ADR-0067), which replaces the default.
98
+ */
99
+ export type PickAnswer =
100
+ | { kind: "event"; event: string; sender?: { party: "agent" | "gate"; id: string }; payload: Record<string, unknown> }
101
+ | { kind: "output"; actor: string; output: unknown }
102
+ | { kind: "answer"; value: unknown };
103
+
104
+ /**
105
+ * A pick's answer that did not reach the pick's call (ADR-0024): `cut` — the Harness restarted while
106
+ * the workflow ran it, and jr2 re-admits the Agent on the same conversation with this notice; or the
107
+ * route left the Agent's state, and the next Turn of the conversation the picking Turn continued
108
+ * hears it. Conversation scope.
109
+ */
110
+ export type PickAnswerNotice = {
111
+ kind: "pick-answer";
112
+ scope: "conversation";
113
+ pick: string;
114
+ cut: boolean;
115
+ /** The last reply since the pick — with `stopped`, an earlier link's, kept. */
116
+ answer?: PickAnswer;
117
+ /** Something other than the pick's route left the Agent's state while the pick's work ran, and
118
+ * that exit stopped the work before it finished (ADR-0024). */
119
+ stopped?: true;
120
+ };
121
+
122
+ export type Notice = MemoryLimitNotice | ConversationNewNotice | PickAnswerNotice;
56
123
 
57
124
  /** Response header: the offset to resume the stream from (every stream read carries it). */
58
125
  export const STREAM_NEXT_OFFSET_HEADER = "stream-next-offset";
59
126
 
60
- /** `?view=` value the client reads. A read with neither view is `updates`. */
127
+ /** `?view=` values the client reads. A read with neither view is `updates`; `history` is the
128
+ * conversation snapshot, read only to learn whether the Harness still holds it (ADR-0062). */
61
129
  export const VIEW_UPDATES = "updates";
130
+ export const VIEW_HISTORY = "history";
62
131
 
63
132
  /** `?live=` value: park until a new event or timeout (204 + the same headers). Long-poll is the
64
133
  * ONLY wait transport (ADR-0027) — no SSE, no `?wait=result`. */
65
134
  export const LIVE_LONG_POLL = "long-poll";
66
135
 
136
+ // ---- The live set (`PUT /agents` — ADR-0031) ----------------------------------------------------
137
+ // The Orchestrator states which conversations its live runs hold on the Instance Harness, and the
138
+ // Harness frees every other one that is not busy. The whole set every time (level-triggered), so a
139
+ // missed statement costs nothing.
140
+
141
+ /** One conversation a live run holds: the `:name` and `:id` of its `/agents/:name/:id` route. */
142
+ export type LiveConversation = { agent: string; instanceId: string };
143
+
144
+ /** What `PUT /agents` accepts: every conversation the Orchestrator's live runs hold. */
145
+ export type LiveSetRequest = { live: LiveConversation[] };
146
+
147
+ /** What `PUT /agents` answers (200): how many conversations this statement freed. */
148
+ export type LiveSetResponse = { freed: number };
149
+
67
150
  // ---- The run-narrative echo (`POST /echo` — ADR-0023) ------------------------------------------
68
151
  // "Print these events": the Orchestrator — the observation feed's one subscriber (ADR-0022) — tees
69
152
  // the owning run's feed to the enclosing Workspace's Harness, which renders it into its own pod log.
@@ -101,3 +184,74 @@ export type EchoEvent = EchoStatusEvent | EchoEmitEvent | EchoAdmissionEvent | E
101
184
  /** What `POST /echo` accepts: feed events in feed order. The answer is `{ printed }` — how many
102
185
  * lines landed on stdout (an unrenderable event prints nothing; it never errors the batch). */
103
186
  export type EchoRequest = { events: EchoEvent[] };
187
+
188
+ // --- the attach (ADR-0063) ----------------------------------------------------------------------
189
+ // The Workspace's Repos into `/work`, run by the Harness on `POST /attach` — the Orchestrator's
190
+ // call where it once ran a script over `kubectl exec`. Same bearer as every Harness route
191
+ // (ADR-0058); idempotent on the Harness's side.
192
+
193
+ /** One Repo Slot to attach. The Orchestrator resolves the Repo's `identity` and cache `key`
194
+ * (repo-identity.ts) — the Harness never re-derives them, so the cache it clones from and the node
195
+ * cache the operator mounted agree by construction (ADR-0051). `url` is the Binding's own spelling,
196
+ * the push url (ADR-0005); `ref` is the base the branch starts from, omitted for the Repo's default
197
+ * branch. */
198
+ export type AttachSlot = { slot: string; url: string; identity: string; key: string; ref?: string };
199
+
200
+ /** What `POST /attach` accepts: the slots in declaration order, the Workspace's branch, and — for a
201
+ * reviewer's seat — the sha the detached review worktree is forced to (ADR-0028). */
202
+ export type AttachRequest = { slots: AttachSlot[]; branch: string; reviewSha?: string };
203
+
204
+ /** What `POST /attach` answers (200): the branch worktree per slot, and the review worktree per
205
+ * slot when the request named a `reviewSha`. Freshness is the operator's verdict on the Sandbox,
206
+ * which the Orchestrator reads itself — never the Harness's to report. */
207
+ export type AttachResponse = { repos: Record<string, string>; review?: Record<string, string> };
208
+
209
+ /** What a failed attach answers: 400 for a body that is not an `AttachRequest`, 500 when a step
210
+ * failed — `slot` names the Repo Slot, and `error` carries the step and git's own stderr. */
211
+ export type AttachError = { error: string; slot?: string };
212
+
213
+ // --- the exec (a `command` Effect, ADR-0067) ----------------------------------------------------
214
+ // A `command` Effect runs in the Harness container of its Workspace's Sandbox, named by its
215
+ // Effect's id: one id runs once, whoever asks again. Same bearer as every Harness route (ADR-0058).
216
+
217
+ // --- the sync (ADR-0067) ------------------------------------------------------------------------
218
+ // After a push whose published commit is not the Agent's own — signed copies of its commits, or
219
+ // one squashed commit, signed or not — the sync moves the Agent's branch to it, here, in the
220
+ // Harness container, with one `update-ref` (files, index, HEAD and stash do not change). The
221
+ // bundle of the published commits travels through the Orchestrator. Same bearer as every Harness route (ADR-0058).
222
+
223
+ /** What `POST /sync` accepts: the branch Worktree (absolute, under `/work`), the branch, the pair
224
+ * the push published (`published`, the published tip; `source`, the Agent's commit it re-creates) and,
225
+ * when the push just ran, the bundle of the published commits (base64). With no bundle the published
226
+ * commits must already be in the Agent's store (a sync run again). */
227
+ export type SyncRequest = { worktree: string; branch: string; published: string; source: string; bundle?: string };
228
+
229
+ /** What the sync answers — a report, which the next push never trusts. `synced`: the branch moved
230
+ * (`replayed` commits the Agent made after the push were copied onto the published tip). `already`:
231
+ * the branch holds the published tip. `skipped`: the push published the Agent's own commit. `busy`: a rebase, merge,
232
+ * cherry-pick or revert of the branch is in progress; nothing changed. `not-synced`: the branch
233
+ * did not move (`reason` says why). */
234
+ export type SyncAnswer =
235
+ | { sync: "synced"; from: string; to: string; replayed: number; tries: number }
236
+ | { sync: "already" }
237
+ | { sync: "skipped" | "busy" | "not-synced"; reason: string };
238
+
239
+ /** What `POST /exec/:id` accepts: argv (never shell text), an absolute `cwd` or none, the timeout,
240
+ * and how many characters of the stream's end the output keeps. */
241
+ export type ExecRequest = { argv: string[]; cwd?: string; timeoutMs: number; tail: number };
242
+
243
+ /** How an exec ended. Every one is output; none is a fault. */
244
+ export type ExecOutcome =
245
+ | { outcome: "exited"; code: number; tail: string }
246
+ | { outcome: "timeout"; tail: string }
247
+ | { outcome: "killed"; signal: string; tail: string };
248
+
249
+ /** What `GET /exec/:id` answers: the stream from `offset` to `next`, and the output once settled. A
250
+ * read with no `offset` starts at the end. */
251
+ export type ExecRead = {
252
+ state: "running" | "settled";
253
+ stream: string;
254
+ next: number;
255
+ dropped?: number;
256
+ output?: ExecOutcome;
257
+ };