@jr2/orchestrator 0.1.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
@@ -0,0 +1,348 @@
1
+ // `customize(machine, parts)` (ADR-0049, ADR-0051): RETUNE what a Machine carries, without
2
+ // editing the module that exports it. A package exports a Machine and nothing beside it — the
3
+ // door, the vocabulary, the Agents, the Sandbox Image and the Repo Slots all ride the exported
4
+ // object — so the only thing a consumer can be handed is the Machine, and the only honest way to
5
+ // say "same workflow, my model, my repository" is a function over it.
6
+ //
7
+ // It is a plain function in the family of `workspace()` and `pool()`, returning a plain
8
+ // `StateMachine`: no jr2-owned machine type over xstate's, nothing to survive a later
9
+ // `.provide()`, nothing to learn (ADR-0015's rule, restated by ADR-0049 — a `.with()` method and
10
+ // a callable-machine hybrid were both rejected there).
11
+ //
12
+ // Three moves, and each is the smallest one that works:
13
+ //
14
+ // - An AGENT override is xstate's own `provide`: `machine.provide({ actors: { coder:
15
+ // agent({ ...stock, ...override }) } })`. The slot already IS the Agent (ADR-0049), so
16
+ // retuning one is substituting a slot's logic, which is what `provide` is for.
17
+ // - A CHILD override is the same call one level down, recursing. Each level is exactly the
18
+ // one-level reach ADR-0015 found `provide` has — held by the composer who owns the child
19
+ // object, never host-side injection into somebody else's Machine.
20
+ // - The IMAGE and the REPOS are the parts `provide` cannot carry: xstate copies implementations
21
+ // and passes the CONFIG through by reference, and every part a Machine carries is keyed on
22
+ // that config (parts.ts, vocabulary.ts). A new image or a bound slot therefore needs a new key
23
+ // — so those fields clone the wrapper's config and rebuild it with the same implementations,
24
+ // re-attaching what the original carried.
25
+ //
26
+ // jr2's wrappers are TRANSPARENT: `agents`/`actors` route through `workspace()`'s `body` and
27
+ // `pool()`'s `worker`, so a consumer customizing a Workspace-rooted workflow never writes `body`
28
+ // and never has to know that jr2 wrapped anything. `image`/`user`/`repos` travel the same chain in
29
+ // the other direction — down to the `workspace()` that owns the seats and the slots.
30
+ //
31
+ // Both halves route on the wrapper's own RECORD of being one, never on a slot's spelling: the
32
+ // runtime reads `wrapperBodyOf` and the types read `JR2Wrapper` (parts.ts), which the wrappers
33
+ // stamp and state together. That is what keeps the compiler's answer and the runtime's the same
34
+ // answer for a Machine whose author happened to name a slot `body`.
35
+ //
36
+ // Only DECLARED parts can be customized; none can be added. A consumer who needs a third Agent
37
+ // composes a new Machine — which is the same act, spelled honestly. A `workspace()` that declared
38
+ // its slot MAP Open (`repos: open`, ADR-0051) declared exactly this: every slot is the composer's
39
+ // to name, so the map they write is the declaration, not an addition to one.
40
+
41
+ import { StateMachine, type AnyStateMachine, type InputFrom, type ProvidedActor } from "xstate";
42
+ import type { AgentLogic } from "./actor.ts";
43
+ import { isAgent, type AgentDefinition } from "./agent.ts";
44
+ import { agent } from "./harness-client.ts";
45
+ import {
46
+ asMachine,
47
+ assertRepoSlot,
48
+ attachSandboxParts,
49
+ attachWrapperBody,
50
+ composesSandbox,
51
+ open,
52
+ sandboxPartsOf,
53
+ wrapperBodyOf,
54
+ type JR2Repos,
55
+ type JR2Wrapper,
56
+ type RepoSlot,
57
+ type SandboxParts,
58
+ } from "./parts.ts";
59
+ import { attachInputSchema, attachVocabulary, inputSchemaOf, vocabularyOf } from "./vocabulary.ts";
60
+
61
+ // --- What a Machine declares, read at the TYPE level ---------------------------------------------
62
+ // Everything below reads xstate's own `TActor` parameter — the `{ src, logic, id }` union
63
+ // `setup({ actors })` derives and `provide()` is checked against. Nothing is registered, declared
64
+ // twice, or inferred from a name: the slots a Machine carries ARE its type, so a `customize()` of
65
+ // an Agent the Machine does not carry is a compile error (ADR-0050) and `jr2 up`'s typecheck gate
66
+ // is where it stops.
67
+
68
+ /** The actor slots a Machine declares. */
69
+ type SlotsOf<M extends AnyStateMachine> =
70
+ M extends StateMachine<
71
+ any,
72
+ any,
73
+ any,
74
+ infer TActor extends ProvidedActor,
75
+ any,
76
+ any,
77
+ any,
78
+ any,
79
+ any,
80
+ any,
81
+ any,
82
+ any,
83
+ any,
84
+ any
85
+ >
86
+ ? TActor
87
+ : never;
88
+
89
+ /** The logic a Machine declares under one slot key. */
90
+ type LogicAt<M extends AnyStateMachine, K extends string> = Extract<SlotsOf<M>, { src: K }>["logic"];
91
+
92
+ /** The Agent slots — told from every other slot by their logic, which no other actor kind has. */
93
+ type AgentSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: AgentLogic }>["src"];
94
+
95
+ /** The child-Machine slots: the slots composition reaches (ADR-0049 — a nested Machine is an
96
+ * ordinary `invoke` of an ordinary slot). */
97
+ type ChildSlots<M extends AnyStateMachine> = Extract<SlotsOf<M>, { logic: AnyStateMachine }>["src"];
98
+
99
+ /** The child Machine under one slot key, constrained so `Customize` may recurse into it. */
100
+ type ChildAt<M extends AnyStateMachine, K extends string> = Extract<LogicAt<M, K>, AnyStateMachine>;
101
+
102
+ /**
103
+ * The Machine a `customize()` actually reaches: jr2's wrappers are transparent to their body, so
104
+ * this is the first Machine an AUTHOR wrote. It mirrors `bodyOf`'s runtime walk, and reads the
105
+ * SAME record — `JR2Wrapper` is the type half of the stamp `attachWrapperBody` writes — so both
106
+ * stop at the same Machine and what the compiler offers is what the call retunes. Recursive in
107
+ * tail position, as the runtime walk is unbounded: however many wrappers a composer stacks, the
108
+ * compiler and the call answer alike.
109
+ */
110
+ // Each step is taken on the wrapper's MARKER (`JR2Wrapper`, parts.ts), never on a slot's spelling —
111
+ // an author is free to name a slot `body` or `worker`, and routing through it because of the name
112
+ // would offer the Agents of a Machine the composer never named.
113
+ type Reached<M extends AnyStateMachine> = M extends JR2Wrapper<infer TBody> ? Reached<TBody> : M;
114
+
115
+ /**
116
+ * The `workspace()` a `customize()` reaches for its SEATS and SLOTS — the other direction from
117
+ * {@link Reached}: down through the wrappers to the first Machine that composes a Sandbox, which
118
+ * is the one whose `JR2Repos` marker says which slots exist (ADR-0051). `never` when no wrapper on
119
+ * the chain composes one, which is what makes `repos` unavailable there rather than `{}`.
120
+ */
121
+ type WorkspaceOf<M extends AnyStateMachine> =
122
+ M extends JR2Repos<any> ? M : M extends JR2Wrapper<infer TBody> ? WorkspaceOf<TBody> : never;
123
+
124
+ /** The Repo Slot keys the reached `workspace()` declared. The `never` case is tested by itself,
125
+ * because `never extends JR2Repos<infer TSlots>` is true with nothing to infer from — and an
126
+ * uninferred `TSlots` widens to `string`, which would offer every key exactly where there are none. */
127
+ type RepoSlotsOf<M extends AnyStateMachine> = [WorkspaceOf<M>] extends [never]
128
+ ? never
129
+ : WorkspaceOf<M> extends JR2Repos<infer TSlots>
130
+ ? TSlots
131
+ : never;
132
+
133
+ /**
134
+ * What may be retuned on one Machine, mirroring the DECLARATION's own shape and recursively
135
+ * partial (ADR-0049): every key optional, every Agent override a partial definition, every child
136
+ * a `Customize` of that child.
137
+ *
138
+ * `image`/`user` are the `workspace()` options (ADR-0037, ADR-0005) and apply to the wrapper the
139
+ * chain reaches; a Machine that composes no Sandbox refuses them at build time, because there is
140
+ * nothing for them to name.
141
+ */
142
+ export type Customize<M extends AnyStateMachine> = {
143
+ /** Retune an Agent this Machine carries: the override is layered over the stock definition, so
144
+ * `{ model }` alone keeps the instructions the author wrote — and BINDS the model when the
145
+ * author left it Open (ADR-0054), which is the common consumer line for a packaged Machine. The
146
+ * override is a `Partial<AgentDefinition>`, never a declaration: a composer binds, and binding
147
+ * to `open` again is not a thing anyone means. A Machine carrying no Agent takes `never` rather
148
+ * than `{}`: an empty object type accepts any literal, which would make the one case with
149
+ * nothing to name the one case with nothing checked. */
150
+ agents?: [AgentSlots<Reached<M>>] extends [never]
151
+ ? never
152
+ : { [K in AgentSlots<Reached<M>>]?: Partial<AgentDefinition> };
153
+ /** Retune a Machine this one composes — the same shape, one level down. */
154
+ actors?: [ChildSlots<Reached<M>>] extends [never]
155
+ ? never
156
+ : { [K in ChildSlots<Reached<M>>]?: Customize<ChildAt<Reached<M>, K>> };
157
+ /** The Sandbox Image (ADR-0037): a `file:` URL to a docker context this module ships, or a
158
+ * registry ref. */
159
+ image?: string;
160
+ /** The User Container's image (ADR-0005), in the same two shapes. */
161
+ user?: string;
162
+ /** Bind the Repo Slots the reached `workspace()` declared (ADR-0051) — an open slot to a url,
163
+ * or a bound or per-run one to a different Binding; any of the three forms, so a consumer can
164
+ * also bind a mapper over the wrapper's door. A slot the Machine does not declare is a compile
165
+ * error, and a Machine that composes no Sandbox takes `never`, as `agents` does. A `workspace()`
166
+ * that declared its map Open takes any keys — they are the composer's words, in the order the
167
+ * Machine documents — and at least one, checked at the call. */
168
+ repos?: [RepoSlotsOf<M>] extends [never] ? never : { [K in RepoSlotsOf<M>]?: RepoSlot<InputFrom<WorkspaceOf<M>>> };
169
+ };
170
+
171
+ /** The same shape with the types erased — what the implementation walks. */
172
+ type LooseParts = {
173
+ agents?: Record<string, Partial<AgentDefinition> | undefined>;
174
+ actors?: Record<string, LooseParts | undefined>;
175
+ image?: string;
176
+ user?: string;
177
+ repos?: Record<string, RepoSlot | undefined>;
178
+ };
179
+
180
+ /**
181
+ * Retune the parts a Machine carries, returning a NEW Machine of the same type — the original is
182
+ * untouched, so two customizations of one import are two independent Machines (ADR-0049's
183
+ * `deep`/`quick`), and a run of either carries what it was given.
184
+ */
185
+ export function customize<M extends AnyStateMachine>(machine: M, parts: Customize<M>): M {
186
+ const { agents, actors, image, user, repos } = parts as LooseParts;
187
+ let out: AnyStateMachine = machine;
188
+ // Order matters in one direction only: the Sandbox seats and slots REBUILD the wrapper, so they
189
+ // go last and carry the retuned implementations with them.
190
+ if (agents || actors) out = retune(out, { agents, actors });
191
+ const seats: Seats = {
192
+ ...(image !== undefined ? { image } : {}),
193
+ ...(user !== undefined ? { user } : {}),
194
+ ...(repos !== undefined ? { repos } : {}),
195
+ };
196
+ if (seats.image !== undefined || seats.user !== undefined || seats.repos !== undefined) out = reseat(out, seats);
197
+ return out as M;
198
+ }
199
+
200
+ /** What `reseat` carries down the chain: the two image seats and the slot overrides, each present
201
+ * only when the composer named it. */
202
+ type Seats = { image?: string; user?: string; repos?: Record<string, RepoSlot | undefined> };
203
+
204
+ /** The slot keys whose logic answers `pred` — what an error names, so the message is the
205
+ * Machine's own declaration rather than advice. */
206
+ function slotsWhere(machine: AnyStateMachine, pred: (logic: unknown) => boolean): string[] {
207
+ return Object.entries(machine.implementations.actors as Record<string, unknown>)
208
+ .filter(([, logic]) => pred(logic))
209
+ .map(([name]) => name);
210
+ }
211
+
212
+ const listed = (names: string[]): string => names.join(", ") || "none";
213
+
214
+ /** The Machine a wrapper is transparent to, or undefined for a Machine an author wrote. */
215
+ function bodyOf(machine: AnyStateMachine): { slot: string; body: AnyStateMachine } | undefined {
216
+ const slot = wrapperBodyOf(machine);
217
+ if (!slot) return undefined;
218
+ const body = asMachine((machine.implementations.actors as Record<string, unknown>)[slot]);
219
+ return body ? { slot, body } : undefined;
220
+ }
221
+
222
+ /** Substitute one slot's logic — the one move every level of this module makes. */
223
+ function substitute(machine: AnyStateMachine, actors: Record<string, unknown>): AnyStateMachine {
224
+ return machine.provide({ actors } as Parameters<AnyStateMachine["provide"]>[0]);
225
+ }
226
+
227
+ /** Agents and children, resolved against the first Machine an author wrote. */
228
+ function retune(machine: AnyStateMachine, parts: LooseParts): AnyStateMachine {
229
+ const inner = bodyOf(machine);
230
+ if (inner) return substitute(machine, { [inner.slot]: retune(inner.body, parts) });
231
+
232
+ const actors: Record<string, unknown> = {};
233
+ for (const [name, override] of Object.entries(parts.agents ?? {})) {
234
+ if (!override) continue;
235
+ const logic = (machine.implementations.actors as Record<string, unknown>)[name];
236
+ if (!isAgent(logic)) {
237
+ throw new Error(
238
+ `customize(): machine "${machine.id}" carries no Agent slot "${name}" — its Agents are: ` +
239
+ `${listed(slotsWhere(machine, isAgent))} (ADR-0049). Only declared parts can be retuned; ` +
240
+ "a Machine that needs another Agent is a new Machine.",
241
+ );
242
+ }
243
+ // The override is layered OVER the stock declaration, never merged into it: `{ model }` alone
244
+ // keeps the author's instructions, and the result is one whole declaration the slot carries —
245
+ // bound, if the model was Open and this override named one; still Open if it did not, and the
246
+ // walk reports it again (ADR-0054).
247
+ actors[name] = agent({ ...logic.definition, ...override });
248
+ }
249
+ for (const [name, childParts] of Object.entries(parts.actors ?? {})) {
250
+ if (!childParts) continue;
251
+ const child = asMachine((machine.implementations.actors as Record<string, unknown>)[name]);
252
+ if (!child) {
253
+ throw new Error(
254
+ `customize(): machine "${machine.id}" composes no Machine under "${name}" — the Machines ` +
255
+ `it composes are: ${listed(slotsWhere(machine, (logic) => !!asMachine(logic)))} (ADR-0049).`,
256
+ );
257
+ }
258
+ actors[name] = customize(child, childParts as Customize<AnyStateMachine>);
259
+ }
260
+ return substitute(machine, actors);
261
+ }
262
+
263
+ /** The Sandbox seats and slots, applied to the `workspace()` the chain reaches. */
264
+ function reseat(machine: AnyStateMachine, seats: Seats): AnyStateMachine {
265
+ if (composesSandbox(machine)) {
266
+ const { repos: override, ...images } = seats;
267
+ const parts = sandboxPartsOf(machine);
268
+ if (parts.repos === open) return rebuild(machine, { ...parts, ...images, repos: nameSlots(machine, override) });
269
+ const repos = { ...parts.repos };
270
+ // Only DECLARED slots can be bound (ADR-0051): the Machine's own word for each Repo is the
271
+ // key, and a key it never declared would attach a repository the body has no handle for.
272
+ // The check is on the declaration's OWN keys — `in` would also answer yes for `constructor`
273
+ // and every other prototype key, and this runtime twin of the compile-time check must give
274
+ // the compiler's answer for the calls the compiler never sees (an untyped call, a widened
275
+ // key). Binding a per-run slot with a static url is legal — it becomes bound; which slots a
276
+ // consumer may fix is the package author's call, expressed by the slot's state.
277
+ for (const [slot, value] of Object.entries(override ?? {})) {
278
+ if (value === undefined) continue;
279
+ if (!Object.hasOwn(parts.repos, slot)) {
280
+ throw new Error(
281
+ `customize(): machine "${machine.id}" declares no Repo Slot "${slot}" — its slots are: ` +
282
+ `${listed(Object.keys(parts.repos))} (ADR-0051)`,
283
+ );
284
+ }
285
+ assertRepoSlot("customize()", slot, value);
286
+ repos[slot] = value;
287
+ }
288
+ return rebuild(machine, { ...parts, ...images, repos });
289
+ }
290
+ const inner = bodyOf(machine);
291
+ if (!inner) {
292
+ const named = seats.repos !== undefined ? "`repos`" : "`image`/`user`";
293
+ throw new Error(
294
+ `customize(): ${named} say what a SANDBOX is made of and which Repos it attaches, and machine ` +
295
+ `"${machine.id}" composes none — only a workspace() wrapper carries those seats and slots ` +
296
+ "(ADR-0049, ADR-0037, ADR-0051).",
297
+ );
298
+ }
299
+ return substitute(machine, { [inner.slot]: reseat(inner.body, seats) });
300
+ }
301
+
302
+ /**
303
+ * The composer's half of an Open slot MAP (ADR-0051): the Machine said `repos: open`, so the map
304
+ * written here IS the declaration — every key is the composer's word for a Repo, the order is
305
+ * kept for the body, and each value is any of the three slot forms (a composer building a further
306
+ * package may leave one `open` for the next composer). Held to the same checks `workspace()` runs
307
+ * on a declared map, because it is the same map one call later: a directory-shaped key, a valid
308
+ * binding, and at least one slot. A `customize` that names no `repos` leaves the map Open, and
309
+ * the walk reports it again.
310
+ */
311
+ function nameSlots(machine: AnyStateMachine, override: Seats["repos"]): SandboxParts["repos"] {
312
+ if (override === undefined) return open;
313
+ const repos: Record<string, RepoSlot> = {};
314
+ for (const [slot, value] of Object.entries(override)) {
315
+ if (value === undefined) continue;
316
+ assertRepoSlot("customize()", slot, value);
317
+ repos[slot] = value;
318
+ }
319
+ if (Object.keys(repos).length === 0) {
320
+ throw new Error(
321
+ `customize(): machine "${machine.id}" declares its Repo Slots open as a map — name at least one: ` +
322
+ '`repos: { app: "https://…" }` (ADR-0051).',
323
+ );
324
+ }
325
+ return repos;
326
+ }
327
+
328
+ /**
329
+ * Rebuild a wrapper around a NEW config object, carrying everything the original carried.
330
+ *
331
+ * `provide()` cannot do this: it passes `this.config` through by reference, and the parts a
332
+ * Machine carries are keyed on exactly that object — so writing new Sandbox seats under it would
333
+ * retune the ORIGINAL too, and `deep`/`quick` would be one Machine wearing the last image
334
+ * assigned. The clone is shallow because only the object's IDENTITY changes: the states, the
335
+ * transitions and the invokes are the same values, so the Machine shape — and therefore the
336
+ * fingerprint a restore compares (ADR-0030) — is bit-identical to the original's.
337
+ */
338
+ function rebuild(machine: AnyStateMachine, seats: SandboxParts): AnyStateMachine {
339
+ const clone = new StateMachine({ ...machine.config }, machine.implementations) as AnyStateMachine;
340
+ const vocabulary = vocabularyOf(machine);
341
+ if (vocabulary) attachVocabulary(clone, vocabulary);
342
+ const door = inputSchemaOf(machine);
343
+ if (door) attachInputSchema(clone, door);
344
+ const slot = wrapperBodyOf(machine);
345
+ if (slot) attachWrapperBody(clone, slot);
346
+ attachSandboxParts(clone, seats);
347
+ return clone;
348
+ }
@@ -0,0 +1,135 @@
1
+ // Durable Machine state codec (ADR-0007): strip the non-serializable, environment-bound parts
2
+ // of an xstate snapshot on the way to the store, and re-inject them on the way back. Context
3
+ // holds live ports (a FlueClient, the ControlPlane) and child actor inputs reference them; the
4
+ // codec replaces those with placeholders the host re-hydrates against the current process.
5
+ //
6
+ // A live handle hides in TWO places: the parent `context`, and each invoked child's persisted
7
+ // input at `snapshot.children.<id>.snapshot.input`. We strip both on save and re-inject both on
8
+ // restore. The child re-attach lever is the persisted input, not the parent `invoke` (xstate v5
9
+ // re-spawns from the child's persisted input on restore), so `rewriteChildInput` is what makes a
10
+ // restored run re-attach its stream instead of re-POSTing.
11
+ //
12
+ // We rebuild the touched paths immutably rather than `structuredClone`-ing the whole snapshot:
13
+ // a live handle in `context` (e.g. a function) makes `structuredClone` throw DataCloneError
14
+ // *before* a stripper could run, so the strip must replace those references, not clone them.
15
+
16
+ export type StoredSnapshot = {
17
+ runId: string;
18
+ status: string;
19
+ snapshot: unknown;
20
+ reason?: string;
21
+ };
22
+
23
+ type ChildEntry = { snapshot?: { input?: unknown; [k: string]: unknown }; [k: string]: unknown };
24
+
25
+ type AnySnapshot = {
26
+ context?: unknown;
27
+ children?: Record<string, ChildEntry | undefined>;
28
+ [k: string]: unknown;
29
+ };
30
+
31
+ /**
32
+ * Rebuild a snapshot, applying `onContext` to `context` and `onChildInput` to every invoked
33
+ * child's persisted input. Returns a new top-level structure (the source is never mutated); the
34
+ * untouched JSON-shaped siblings are carried by reference. Used in both directions — on serialize
35
+ * the callbacks strip live handles (yielding a JSON-safe value), on hydrate they re-inject them.
36
+ */
37
+ function mapSnapshot(value: unknown, onContext: (ctx: any) => any, onChildInput: (input: any) => any): unknown {
38
+ if (!value || typeof value !== "object") return value;
39
+ const src = value as AnySnapshot;
40
+ const out: AnySnapshot = { ...src };
41
+
42
+ if ("context" in src) {
43
+ out.context = onContext(src.context);
44
+ }
45
+
46
+ const children = src.children;
47
+ if (children && typeof children === "object") {
48
+ const nextChildren: Record<string, ChildEntry | undefined> = {};
49
+ for (const id of Object.keys(children)) {
50
+ const child = children[id];
51
+ const childSnapshot = child?.snapshot;
52
+ if (child && childSnapshot && typeof childSnapshot === "object" && "input" in childSnapshot) {
53
+ nextChildren[id] = {
54
+ ...child,
55
+ snapshot: { ...childSnapshot, input: onChildInput(childSnapshot.input) },
56
+ };
57
+ } else {
58
+ nextChildren[id] = child;
59
+ }
60
+ }
61
+ out.children = nextChildren;
62
+ }
63
+
64
+ return out;
65
+ }
66
+
67
+ /** Serialize a snapshot for persistence, stripping live context and child inputs. */
68
+ export function serializeSnapshot(
69
+ snapshot: unknown,
70
+ opts: { stripContext: (ctx: any) => any; stripChildInput: (input: any) => any },
71
+ ): unknown {
72
+ return mapSnapshot(snapshot, opts.stripContext, opts.stripChildInput);
73
+ }
74
+
75
+ /** Rebuild a runnable snapshot from stored form, re-injecting live context and child inputs. */
76
+ export function hydrateSnapshot(
77
+ stored: unknown,
78
+ opts: { injectContext: (ctx: any) => any; rewriteChildInput: (input: any) => any },
79
+ ): unknown {
80
+ return mapSnapshot(stored, opts.injectContext, opts.rewriteChildInput);
81
+ }
82
+
83
+ /** The durable admission record restore rewrites into a child input (see actor.ts). Structural
84
+ * twin of `AgentAdmission` — kept import-free so this codec module stays a pure leaf.
85
+ * `instanceId` is the actor's live-conversation stamp (a runaway reroll advances it past the
86
+ * ledger key — ADR-0035); it rides the rewrite verbatim so the restored actor re-addresses the
87
+ * live conversation. */
88
+ type Admission = { streamUrl: string; offset: string; submissionId: string; instanceId?: string };
89
+
90
+ /** A persisted Agent child input: `instanceId` (the durable handle) beside `agentName` —
91
+ * both strings by construction (machine children carry neither at top level; `endpoint` can no
92
+ * longer identify it, being ambient-optional since ADR-0016). */
93
+ function isAgentRunInput(input: unknown): input is { instanceId: string; agentName: string } {
94
+ return (
95
+ !!input &&
96
+ typeof input === "object" &&
97
+ typeof (input as { instanceId?: unknown }).instanceId === "string" &&
98
+ typeof (input as { agentName?: unknown }).agentName === "string"
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Rewrite every persisted Agent child input in a snapshot TREE so restore re-attaches
104
+ * (drop `prompt`, set `attach`) instead of re-prompting — at any nesting depth. The admissions
105
+ * come from the run's HOST LEDGER (ADR-0016), keyed by iid: iids are globally unique, so one
106
+ * flat map covers the whole tree (the old per-level `context.offsets` scoping died with the
107
+ * context leg). An instanceId with no recorded admission re-admits its prompt — a run that
108
+ * crashed before its admission was ledgered is the same first-turn at-most-once edge as before
109
+ * (ADR-0007), narrowed to the send→record window.
110
+ */
111
+ export function reattachAgentRuns(snapshot: unknown, admissions: Record<string, Admission>): unknown {
112
+ const walk = (node: unknown): unknown => {
113
+ if (!node || typeof node !== "object") return node;
114
+ const src = node as AnySnapshot & { input?: unknown };
115
+ const out: typeof src = { ...src };
116
+
117
+ const admission = isAgentRunInput(src.input) ? admissions[src.input.instanceId] : undefined;
118
+ if (isAgentRunInput(src.input) && admission !== undefined) {
119
+ out.input = { ...src.input, prompt: undefined, attach: admission };
120
+ }
121
+
122
+ const children = src.children;
123
+ if (children && typeof children === "object") {
124
+ const nextChildren: Record<string, ChildEntry | undefined> = {};
125
+ for (const id of Object.keys(children)) {
126
+ const child = children[id];
127
+ nextChildren[id] =
128
+ child && child.snapshot ? { ...child, snapshot: walk(child.snapshot) as ChildEntry["snapshot"] } : child;
129
+ }
130
+ out.children = nextChildren;
131
+ }
132
+ return out;
133
+ };
134
+ return walk(snapshot);
135
+ }
@@ -0,0 +1,92 @@
1
+ // The content address of a Machine's SHAPE (ADR-0030). A persisted snapshot names the Machine it
2
+ // was written under; restore compares and refuses a mismatch, because a workflow is matched to a
3
+ // run by filename alone and the state volume outlives the image that wrote it — so a run parked at
4
+ // a Gate meets whatever `jr2 up` baked in next, and nothing used to notice.
5
+ //
6
+ // What it hashes is RESTORABILITY, not behavior: the facts that decide whether an old snapshot can
7
+ // still be interpreted — state ids and nesting, which state is initial, what each state invokes and
8
+ // under which id (the invoke id is the key in the snapshot's `children` map), and where every
9
+ // transition goes. Not guard bodies, not assigns, not prompts. Editing a guard changes what a run
10
+ // DOES next; it does not make the snapshot unreadable, and restore-after-redeploy would be unusable
11
+ // if every such edit stranded the runs in flight. That line is the whole design, and ADR-0030 argues
12
+ // it: this is a restorability check, and drift means "I can no longer read what I saved."
13
+ //
14
+ // The traversal is `machine-doc.ts`'s, deliberately — it is already deterministic (states sorted by
15
+ // `order`), JSON-pure, provider-independent, and descends into invoked AND spawned child machines.
16
+ // This module only projects it down and hashes the result.
17
+
18
+ import { createHash } from "node:crypto";
19
+ import type { AnyStateMachine } from "xstate";
20
+ import { serializeMachine, type MachineBodyDoc, type MachineStateDoc } from "./machine-doc.ts";
21
+
22
+ /** The projection that gets hashed — a Machine reduced to what restore has to agree about. */
23
+ type ShapeState = {
24
+ id: string;
25
+ key: string;
26
+ type: string;
27
+ initial?: string;
28
+ /** `id` as well as `src`: the id keys this child in the persisted `children` map. */
29
+ invoke: Array<{ id: string; src: string }>;
30
+ states: ShapeState[];
31
+ children: Array<{ src: string; via: string; machine?: ShapeBody }>;
32
+ /** The walk could not see this state's spawns — recorded so the blind spot is IN the hash. */
33
+ opaque?: true;
34
+ };
35
+
36
+ type ShapeBody = {
37
+ id: string;
38
+ root: ShapeState;
39
+ /** `source|event|targets`, sorted — reordering `on:` keys is not a restorability change. */
40
+ transitions: string[];
41
+ };
42
+
43
+ function shapeState(state: MachineStateDoc): ShapeState {
44
+ const out: ShapeState = {
45
+ id: state.id,
46
+ key: state.key,
47
+ type: state.type,
48
+ invoke: state.invoke.map((inv) => ({ id: inv.id, src: inv.src })),
49
+ states: state.states.map(shapeState),
50
+ children: state.children.map((child) => ({
51
+ src: child.src,
52
+ via: child.via,
53
+ ...(child.machine ? { machine: shapeBody(child.machine) } : {}),
54
+ })),
55
+ };
56
+ if (state.initial !== undefined) out.initial = state.initial;
57
+ if (state.opaqueActions) out.opaque = true;
58
+ return out;
59
+ }
60
+
61
+ function shapeBody(body: MachineBodyDoc): ShapeBody {
62
+ return {
63
+ id: body.id,
64
+ root: shapeState(body.root),
65
+ transitions: body.transitions.map((t) => `${t.source}|${t.event}|${t.targets.join(",")}`).sort(),
66
+ };
67
+ }
68
+
69
+ /** Memoized per machine OBJECT: `persist` runs on every snapshot microtask, and a dev reload's
70
+ * fresh machine object correctly gets a fresh entry. Deliberately NOT `vocabularyOf`'s key —
71
+ * that one is `machine.config`, because a `.provide()` clone must keep the defs it was built
72
+ * with (ADR-0011). A shape has nothing to keep: it is derived from the config alone, so a clone
73
+ * recomputes the identical digest and only pays for the walk once. */
74
+ const cache = new WeakMap<AnyStateMachine, string>();
75
+
76
+ /**
77
+ * This Machine's shape, as 12 hex chars — the same width `jr2 up`'s content hash uses, and for the
78
+ * same reason: it is read by humans in error messages far more often than by machines.
79
+ *
80
+ * The workflow NAME is deliberately excluded. A run already carries its workflow, and restore looks
81
+ * the def up by that name before it gets here; folding the name in would only make a rename look
82
+ * like a shape change on a run that never resolves to the renamed def anyway.
83
+ */
84
+ export function fingerprintOf(machine: AnyStateMachine): string {
85
+ const hit = cache.get(machine);
86
+ if (hit !== undefined) return hit;
87
+ // `serializeMachine` wants a workflow name for the DTO; the projection drops it.
88
+ const shape = shapeBody(serializeMachine("", machine));
89
+ const digest = createHash("sha256").update(JSON.stringify(shape)).digest("hex").slice(0, 12);
90
+ cache.set(machine, digest);
91
+ return digest;
92
+ }
package/src/gate.ts ADDED
@@ -0,0 +1,76 @@
1
+ // The `gate` actor (ADR-0011): a pending external input on a run, as an addressable resource.
2
+ // The symmetric twin of the Agent actor for every NON-agent caller — humans (`jr2 send`, a UI),
3
+ // webhook translators, CI. "Human" is policy, not mechanism, so the actor is not named for one
4
+ // caller.
5
+ //
6
+ // A state that needs outside input invokes `gate` with `{ gate?, accepts?, meta? }`. On start
7
+ // the actor resolves the accepted names against the vocabulary of the MACHINE THAT INVOKED IT —
8
+ // `self._parent.logic`, per-Machine scoping (ADR-0011, ADR-0049), which is what lets a nested
9
+ // Machine keep its own `approve` — and registers into the host's table; `GET /runs/:id` then
10
+ // lists the gate (accepts +
11
+ // schemas + meta — what a CLI, an inbox UI, or a webhook translator discovers), and
12
+ // `POST /runs/:id/gates/:gate/events` validates against the named schema and delivers through
13
+ // the closure below — the event lands on the state that invoked the gate, at any nesting depth.
14
+ // Leaving the state stops the actor, which destroys the gate: parking IS retention (ADR-0012),
15
+ // and a gone state is a gone surface.
16
+
17
+ import { fromCallback } from "xstate";
18
+ import { actorPath, gateAddress, resolveAccepts, runBindingOf, type DeliveredEvent } from "./registration.ts";
19
+
20
+ export type GateInput = {
21
+ /**
22
+ * The gate's caller-facing id. LEAVE IT UNSET for the derived default: the actor path below
23
+ * the run root (in a jr2Setup machine the leaf is the state key path — `deriveMenus` names the
24
+ * invoke), which is unique wherever concurrently live siblings have distinct actor ids — i.e.
25
+ * wherever fan-out is correct at all. Author an id only to give external callers a meaningful
26
+ * flat name (e.g. the feature id); then run-wide uniqueness is the author's problem, and two
27
+ * LIVE gates with one id fail loudly at invoke. Run-scoped by the host either way — two runs'
28
+ * `"F-12"` gates cannot collide.
29
+ */
30
+ gate?: string;
31
+ /**
32
+ * Accepted event names. In a jr2Setup machine LEAVE IT UNSET: the accepted set derives from
33
+ * the invoking state's transitions, audience ∈ {external, any} (ADR-0015) — this field is the
34
+ * escape hatch. An unlisted name is an invoke-time error either way.
35
+ */
36
+ accepts?: readonly string[];
37
+ /** Serializable context for callers: what a UI renders, what a webhook matches on (prUrl…). */
38
+ meta?: Record<string, unknown>;
39
+ };
40
+
41
+ /** The gate actor. Sends nothing of its own; every event it delivers is a caller's, validated.
42
+ * Input admits `undefined` so `invoke: { src: "gate" }` — the fully-derived common case —
43
+ * typechecks without an input mapper. */
44
+ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input: raw, system, sendBack, self }) => {
45
+ const input = raw ?? {};
46
+ const binding = runBindingOf(system);
47
+ // The derived id is deterministic from machine structure, so recomputing it on every (re)start
48
+ // is restore-stable and needs no input-mapper persistence — the mapper is only load-bearing
49
+ // where minting has a random component (mintIid's fresh-session suffix, ADR-0016).
50
+ const path = actorPath(self);
51
+ const id = input.gate ?? path.join(".");
52
+ if (!id) {
53
+ // Only a rootless invocation (createActor(gate) directly) has an empty path.
54
+ throw new Error("gate has no derivable id (no parent actor) — pass `gate` explicitly");
55
+ }
56
+ if (!input.accepts?.length) {
57
+ // Derivation found nothing (or a plain-setup machine passed nothing): a gate accepting no
58
+ // events can never be moved — refuse loudly at invoke rather than park a dead surface.
59
+ throw new Error(
60
+ `gate "${id}" accepts no events — handle at least one external/any event on the ` +
61
+ `invoking state (the accepted set derives from its transitions), or pass \`accepts\` explicitly`,
62
+ );
63
+ }
64
+ const defs = resolveAccepts(self, input.accepts);
65
+ const dispose = binding.table.register({
66
+ address: gateAddress(binding.runId, id),
67
+ runId: binding.runId,
68
+ kind: "gate",
69
+ id,
70
+ path,
71
+ defs,
72
+ meta: input.meta,
73
+ deliver: (event) => sendBack(event),
74
+ });
75
+ return dispose;
76
+ });