@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
package/src/server.ts ADDED
@@ -0,0 +1,220 @@
1
+ // The deployed orchestrator's entrypoint (ADR-0019): the process the instance image runs — and, by
2
+ // the same token, the e2e tier's per-scenario fixture (ADR-0010 boots exactly this process on the
3
+ // host). Configuration is env, kube-style; the instance folder is the process cwd (the image bakes
4
+ // engine + `workflows/` there, ADR-0008). No dev.json, no hot-reload, no stub Harness: those were
5
+ // host-dev affordances, and there is no host dev mode.
6
+ //
7
+ // PORT listen port (default 4000 — what the Service targets)
8
+ // HOST listen hostname (default 0.0.0.0: pods must be reachable off-loopback)
9
+ // JR2_INSTANCE_TOKEN the Instance credential (ADR-0013), from the instance's Secret; minted
10
+ // per boot when absent (then only the announce line knows it — fixtures set it)
11
+ // JR2_SIGNING_KEY base64 key Sandbox tokens are signed with; from the Secret so live Sandboxes
12
+ // survive a pod restart. Absent → minted into `<dir>/.jr2/secret` (dev-grade).
13
+ // JR2_NAMESPACE the pod's own namespace (Deployment fieldRef) — presence = "deployed":
14
+ // Sandboxes are driven in it, and the Adapters' route home is Service DNS.
15
+ // <git.credentials[].token>
16
+ // each token env var the config names rides the instance Secret (ADR-0051):
17
+ // the Orchestrator materializes it into the Repo's credential Secret.
18
+ //
19
+ // The first stdout line is one JSON object `{ url, workflows }` — the discovery seam a fixture (or
20
+ // a human tailing pod logs) parses instead of racing the socket. Deployed with a data plane, the
21
+ // boot then creates one `Repo` resource per identity its Machines bind (ADR-0051) and announces
22
+ // each as `{ repo, url, bound: true }` — or `{ repo, error }` — one line apiece, after serving:
23
+ // a Repo the cluster refuses is a degraded Repo (ADR-0048), never a boot that did not happen.
24
+ // Deployed WITHOUT one, the same pass still runs and binds nothing, which unlabels every Repo a
25
+ // previous deploy bound — an Instance that drops its last `workspace()` leaves `jr2 gc` able to
26
+ // collect what it stopped using.
27
+
28
+ import { createHash } from "node:crypto";
29
+ import { join } from "node:path";
30
+ import { loadConfig } from "./config.ts";
31
+ import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
32
+ import {
33
+ HARNESS_CONFIGMAP,
34
+ HARNESS_CONFIG_KEY,
35
+ HARNESS_ENV_SECRET,
36
+ IMAGES_KEY,
37
+ IMAGES_MOUNT,
38
+ INSTANCE_HARNESS_PORT,
39
+ INSTANCE_HARNESS_SERVICE,
40
+ ORCHESTRATOR_SERVICE,
41
+ } from "./names.ts";
42
+ import { partsOf, type CarriedRepo } from "./parts.ts";
43
+ import { kubectlRepoFetches, type RepoFetches } from "./repo-fetch.ts";
44
+ import { kubectlRepos, type RepoResources } from "./repos.ts";
45
+ import { kubectlSandbox, type KubectlExec } from "./sandbox-kubectl.ts";
46
+ import { loadSigningKey, mintInstanceToken } from "./tokens.ts";
47
+ import type { SandboxPort } from "./workspace.ts";
48
+
49
+ export type ServerMainOptions = {
50
+ /** The instance folder (deployed: the image's WORKDIR; fixtures: a temp instance). */
51
+ dir: string;
52
+ /** The environment to read the contract above from (deployed: `process.env`). */
53
+ env: Record<string, string | undefined>;
54
+ /** Where the one-line JSON announcement goes (deployed: stdout). */
55
+ announce: (line: string) => void;
56
+ /** The kubectl process seam behind the data plane's two ports, injectable for tests.
57
+ * Deployed: the `kubectl` on PATH. */
58
+ exec?: KubectlExec;
59
+ };
60
+
61
+ /** Boot the instance the env describes; resolves once serving (the caller owns signals/exit). */
62
+ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstance> {
63
+ const { env } = opts;
64
+ const config = await loadConfig(opts.dir);
65
+ const port = env.PORT !== undefined ? Number(env.PORT) : 4000;
66
+ const signingKey =
67
+ env.JR2_SIGNING_KEY !== undefined ? Buffer.from(env.JR2_SIGNING_KEY, "base64") : await loadSigningKey(opts.dir);
68
+
69
+ // Deployed (JR2_NAMESPACE set): Adapters dial the orchestrator at its own Service DNS — stable
70
+ // by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013).
71
+ const namespace = env.JR2_NAMESPACE;
72
+
73
+ // Resolved HERE, not left for startInstance to mint: every Sandbox Harness gets the token's
74
+ // sha-256 as its echo gate (ADR-0023, below), so the token must exist before the first
75
+ // provision. From the instance Secret when deployed; per-boot for a host-booted fixture,
76
+ // exactly as before.
77
+ const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
78
+
79
+ // The data-plane switch (ADR-0012/0031/0051): a registered Machine COMPOSES a Sandbox — read off
80
+ // the same walk `jr2 up` makes — and this process is deployed in a cluster → wire the kubectl
81
+ // Sandbox backend. Otherwise an instance without a data plane (workspace() invocations fault
82
+ // pointedly). The walk loads the same modules `startInstance` registers below; Node's module
83
+ // cache makes them one import.
84
+ const carried = partsOf((await loadWorkflows(opts.dir)).map((w) => w.machine));
85
+ const dataPlane = carried.composesSandbox && namespace !== undefined;
86
+ let sandbox: SandboxPort | undefined;
87
+ let repos: RepoResources | undefined;
88
+ let fetches: RepoFetches | undefined;
89
+ // The Repo port hangs off DEPLOYED, not off the data plane: this boot's reconcile is the only
90
+ // writer that ever REMOVES `jr2.dev/bound` (repos.ts), and an Instance that drops its last
91
+ // `workspace()` still owns the Repos its earlier deploys bound. So the port is built whenever
92
+ // there is a cluster to drive, and the walk — now naming nothing — unlabels every one of them,
93
+ // which is what puts them on `jr2 gc`'s clock. Gate it on the data plane instead and they stay
94
+ // bound forever: uncollectable resources, with their node caches behind them.
95
+ if (namespace !== undefined) {
96
+ const credentials = config?.git?.credentials ?? [];
97
+ // The Repo resources (ADR-0051): created by this process, cloned by the operator's cache agent
98
+ // on every node that needs them. The port resolves `git.credentials` into each resource's
99
+ // `secretRef`, reading a token entry's env var off this process — the Instance Secret is
100
+ // `envFrom` on the Deployment, so `jr2 up` is what put it there.
101
+ repos = kubectlRepos({ namespace, credentials, env, ...(opts.exec ? { exec: opts.exec } : {}) });
102
+ if (dataPlane) {
103
+ // The ask a pod makes when something inside it fetches (ADR-0053): it marks the Sandbox CR
104
+ // and waits on the same status the provision waits on. Gated on the DATA PLANE, unlike the
105
+ // Repo port above — there is nothing to ask for where no Sandbox is ever composed, and the
106
+ // only caller is a pod that would have to exist to call it.
107
+ fetches = kubectlRepoFetches({ namespace, ...(opts.exec ? { exec: opts.exec } : {}) });
108
+ sandbox = kubectlSandbox({
109
+ // The fence (ADR-0051): a per-run url must match one of these, or the provision refuses it.
110
+ credentials,
111
+ // Where a provision records the Repos it names, before the CR names them.
112
+ repos,
113
+ // Named here the same way HARNESS_CONFIGMAP is: a jr2-owned mount path, deliberately NOT an
114
+ // env knob — there is no image escape hatch left to configure (ADR-0038). Note what this
115
+ // buys: the map is read per provision, so an instance whose `jr2-images` ConfigMap is not yet
116
+ // mounted still BOOTS and serves — only a provision fails, pointing at `jr2 up`. That is the
117
+ // correct blast pattern, and the stale-read window is one kubelet propagation.
118
+ imagesPath: join(IMAGES_MOUNT, IMAGES_KEY),
119
+ // The Harness containers' env (ADR-0018): what this instance can REACH (the custom provider
120
+ // — no Agents, they ride each Turn since ADR-0049), then the instance's own valueFrom
121
+ // entries (literal values already live in the jr2-harness-env Secret below).
122
+ env: [
123
+ {
124
+ name: "JR2_HARNESS_JSON",
125
+ valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
126
+ },
127
+ // The echo gate (ADR-0023): the Harness verifies echo bearers against this sha-256. The
128
+ // digest, never the token — the Agent executes code in the Harness container, and a
129
+ // digest inverts to nothing (the Instance token itself never enters a Sandbox, ADR-0013).
130
+ {
131
+ name: "JR2_ECHO_TOKEN_SHA256",
132
+ value: createHash("sha256").update(instanceToken).digest("base64url"),
133
+ },
134
+ ...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
135
+ ],
136
+ envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
137
+ // Presence only — the PEM itself was materialized into the jr2-ca ConfigMap by `jr2 up`
138
+ // (ADR-0020); the in-cluster config eval never reads the file.
139
+ caBundle: config?.harness?.caBundle !== undefined,
140
+ // Which nodes are Sandbox nodes (ADR-0052) — the CR carries it, the operator reads no config.
141
+ ...(config?.sandbox ? { placement: config.sandbox } : {}),
142
+ orchestratorUrl: `http://${ORCHESTRATOR_SERVICE}.${namespace}.svc:${port}`,
143
+ signingKey,
144
+ namespace,
145
+ ...(opts.exec ? { exec: opts.exec } : {}),
146
+ });
147
+ }
148
+ }
149
+
150
+ const inst = await startInstance({
151
+ dir: opts.dir,
152
+ port,
153
+ hostname: env.HOST ?? "0.0.0.0",
154
+ instanceToken,
155
+ signingKey,
156
+ sandbox,
157
+ dataPlane,
158
+ // Read per request, never snapshotted: the Repos as the cluster reports them right now. An
159
+ // instance without a data plane reports none — `GET /repos` answers `{ dataPlane: false,
160
+ // repos: [] }` (http.ts), and what its dropped Machines left behind is `jr2 gc`'s to name.
161
+ ...(dataPlane && repos ? { repos: () => repos.list() } : {}),
162
+ // The pod's route out (ADR-0053), same shape: read per request, off the port.
163
+ ...(fetches ? { fetchRepo: (name: string, identity: string) => fetches.fetch(name, identity) } : {}),
164
+ // Where a Menu-only Turn runs (ADR-0031): the Instance Harness's deterministic Service DNS.
165
+ // `jr2 up` converges the Deployment behind it whenever any definition declares
166
+ // `workspace: "none"`, so deployed, the address exists exactly when it is needed.
167
+ instanceHarness: namespace
168
+ ? `http://${INSTANCE_HARNESS_SERVICE}.${namespace}.svc:${INSTANCE_HARNESS_PORT}`
169
+ : undefined,
170
+ });
171
+ // Resumed runs are routine and stay quiet; runs this boot did NOT resume are not, so they ride
172
+ // the announce line (ADR-0030) — the one thing every boot prints, whatever is reading it. Without
173
+ // this, `drifted` is only reachable by asking after a run id nobody knows to ask about.
174
+ const { lost, drifted, failed } = inst.restored;
175
+ opts.announce(
176
+ JSON.stringify({
177
+ url: inst.url,
178
+ workflows: inst.workflows,
179
+ ...(lost.length ? { lost } : {}),
180
+ ...(drifted.length ? { drifted } : {}),
181
+ ...(failed.length ? { failed } : {}),
182
+ }),
183
+ );
184
+ // After serving, never awaited: the bound Repos' resources (ADR-0051). Serving does not wait on
185
+ // the cluster — a run whose Repo the boot could not record still finds its provision ensuring
186
+ // it again — and a refusal is one announced line per Repo, in ADR-0048's shape. A deployed
187
+ // instance that binds nothing still runs this: the walk names no Repo, so the reconcile is the
188
+ // whole of it, and every resource an earlier deploy bound becomes evictable.
189
+ if (repos) void ensureBound(repos, carried.repos, opts.announce);
190
+ return inst;
191
+ }
192
+
193
+ /**
194
+ * The boot's half of ADR-0051's "the Orchestrator creates Repo resources": one per identity the
195
+ * registered Machines bind, so statically known repositories are KNOWN before a run asks (the
196
+ * cache agent probes each; a node clones on first demand) — then
197
+ * the bound label reconciled, so a slot unbound since the last deploy is a Repo `jr2 gc` may
198
+ * evict. The boot is the ONE writer of a bound resource's spec: a redeploy that moved a url or a
199
+ * credential restates it here, and no provision does. Sequential, and each failure its own
200
+ * line: a wrong url on one Machine must not hide the others.
201
+ */
202
+ async function ensureBound(
203
+ repos: RepoResources,
204
+ bound: CarriedRepo[],
205
+ announce: (line: string) => void,
206
+ ): Promise<void> {
207
+ for (const repo of bound) {
208
+ try {
209
+ await repos.bind({ url: repo.url, identity: repo.identity, key: repo.key });
210
+ announce(JSON.stringify({ repo: repo.key, url: repo.url, bound: true }));
211
+ } catch (err) {
212
+ announce(JSON.stringify({ repo: repo.key, url: repo.url, error: (err as Error).message }));
213
+ }
214
+ }
215
+ try {
216
+ await repos.reconcileBound(bound.map((r) => r.key));
217
+ } catch (err) {
218
+ announce(JSON.stringify({ repos: bound.map((r) => r.key), error: (err as Error).message }));
219
+ }
220
+ }
package/src/setup.ts ADDED
@@ -0,0 +1,360 @@
1
+ // `jr2Setup` — the authoring surface (ADR-0015): an xstate `setup()` analog, not a DSL. It returns
2
+ // xstate's public `SetupReturn`, so `.createMachine()` yields a plain `StateMachine` — Stately-
3
+ // inspectable, `.provide()`-testable, constructible with no jr2 runtime. What the wrapper adds:
4
+ //
5
+ // - the MECHANISM events (`agent.fault`, `workspace.lost`, …) are injected into the event
6
+ // union, and the WORKFLOW event types derive from the zod defs — hand-written `EventFrom`
7
+ // unions retire;
8
+ // - the jr2 `gate` actor is pre-registered with typed input (a consumer actor under the same
9
+ // name wins — the unit-test seam), and every Agent SLOT the machine declares
10
+ // (`actors: { coder: agent(def) }` — ADR-0049) gets the same input finalization by brand;
11
+ // - because it takes the defs AS VALUES, `createMachine` validates that every event key
12
+ // appearing anywhere in the machine maps to a def — closing xstate's nested-`on` typo hole
13
+ // (unknown keys in nested states typecheck silently upstream) with a load-time failure;
14
+ // - the vocabulary is attached to the machine object (`vocabularyOf` — vocabulary.ts), which
15
+ // is what lets the `export const events` manifest die (ADR-0011 revised). It is scoped to
16
+ // THIS Machine: `gate` and the Agent slots resolve names against the Machine that invoked
17
+ // them, so a Machine nested by plain `invoke` keeps its own names and this one never sees
18
+ // them (ADR-0049);
19
+ // - an optional `input` on the createMachine config — a zod object — declares what a RUN of
20
+ // this machine is started with (ADR-0033). It rides the machine object beside the vocabulary
21
+ // (`inputSchemaOf`), never the xstate config: the host validates `POST /workflows/:name/runs`
22
+ // bodies against it and serves it as JSON Schema; absent, the door stays permissive.
23
+ //
24
+ // The typing follows the proven declared-signature pattern (report-xstate §1): the public
25
+ // signature is precise, the implementation is loosely typed with ONE jr2-internal cast at the
26
+ // return. The consumer surface has none.
27
+
28
+ import { randomUUID } from "node:crypto";
29
+ import {
30
+ setup,
31
+ type ActionFunction,
32
+ type AnyActorRef,
33
+ type AnyStateMachine,
34
+ type DelayConfig,
35
+ type EventObject,
36
+ type GuardPredicate,
37
+ type MachineContext,
38
+ type MetaObject,
39
+ type NonReducibleUnknown,
40
+ type ParameterizedObject,
41
+ type SetupReturn,
42
+ type UnknownActorLogic,
43
+ } from "xstate";
44
+ import type { z } from "zod";
45
+ import { eventMap, type EventDef, type EventFrom } from "@jr2/agent-protocol";
46
+ import type { AgentRunInput, AgentTurnInput, FaultTelemetry } from "./actor.ts";
47
+ import { isAgent } from "./agent.ts";
48
+ import { gate } from "./gate.ts";
49
+ import { actorPath, boundRunId } from "./registration.ts";
50
+ import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
51
+
52
+ /**
53
+ * The events jr2's own mechanism delivers into any workflow machine, injected into every jr2Setup
54
+ * union. Dotted names by construction (`NAME_RE` forbids dots in workflow event names), so they
55
+ * can never collide with a def.
56
+ */
57
+ export type MechanismEvent = FaultTelemetry | { type: "workspace.lost" };
58
+
59
+ /** The full event union a jr2Setup machine sees: the defs' derived types plus the mechanism's. */
60
+ export type WorkflowEvent<TDefs extends readonly EventDef[]> = EventFrom<TDefs[number]> | MechanismEvent;
61
+
62
+ /** The jr2 actor every workflow can invoke by name without listing it (ADR-0015). `gate` is the
63
+ * whole set: an Agent is not pre-registered, because it is not one logic — it is the slot the
64
+ * Machine declares, `actors: { coder: agent(def) }` (ADR-0049). */
65
+ const jr2Actors = { gate };
66
+ type JR2Actors = typeof jr2Actors;
67
+
68
+ /** Consumer actors merge OVER the pre-registered set: same name → the consumer's logic wins. */
69
+ type MergedActors<TActors extends Record<string, UnknownActorLogic>> = Omit<JR2Actors, keyof TActors> & TActors;
70
+
71
+ // Local equivalents of xstate's non-exported setup() mapped helpers (setup.d.ts) — same shapes,
72
+ // so the declared signature below composes with the public `ActionFunction`/`GuardPredicate`.
73
+ type ToParameterizedObject<T extends Record<string, ParameterizedObject["params"] | undefined>> = {
74
+ [K in keyof T & string]: { type: K; params: T[K] };
75
+ }[keyof T & string];
76
+ type ToProvidedActor<TActors extends Record<string, UnknownActorLogic>> = {
77
+ [K in keyof TActors & string]: { src: K; logic: TActors[K]; id: string | undefined };
78
+ }[keyof TActors & string];
79
+
80
+ /**
81
+ * Author a workflow machine (ADR-0015). Like `setup()`, but `types.events` is gone: the event
82
+ * union derives from `events` (defineEvent defs, taken as values) plus the mechanism events.
83
+ */
84
+ export function jr2Setup<
85
+ TContext extends MachineContext = MachineContext,
86
+ const TDefs extends readonly EventDef[] = readonly EventDef[],
87
+ TActors extends Record<string, UnknownActorLogic> = {},
88
+ TActions extends Record<string, ParameterizedObject["params"] | undefined> = {},
89
+ TGuards extends Record<string, ParameterizedObject["params"] | undefined> = {},
90
+ TDelay extends string = never,
91
+ TTag extends string = string,
92
+ TInput = NonReducibleUnknown,
93
+ TOutput extends NonReducibleUnknown = NonReducibleUnknown,
94
+ TEmitted extends EventObject = EventObject,
95
+ TMeta extends MetaObject = MetaObject,
96
+ >(def: {
97
+ /** context/input/output/tags/emitted/meta — never `events`; the union is derived. */
98
+ types?: {
99
+ context?: TContext;
100
+ input?: TInput;
101
+ output?: TOutput;
102
+ tags?: TTag;
103
+ emitted?: TEmitted;
104
+ meta?: TMeta;
105
+ };
106
+ /** This Machine's vocabulary, as values — the single source for types, validation, delivery. */
107
+ events: TDefs;
108
+ actors?: TActors;
109
+ actions?: {
110
+ [K in keyof TActions]: ActionFunction<
111
+ TContext,
112
+ WorkflowEvent<TDefs>,
113
+ WorkflowEvent<TDefs>,
114
+ TActions[K],
115
+ ToProvidedActor<MergedActors<TActors>>,
116
+ ToParameterizedObject<TActions>,
117
+ ToParameterizedObject<TGuards>,
118
+ TDelay,
119
+ TEmitted
120
+ >;
121
+ };
122
+ guards?: {
123
+ [K in keyof TGuards]: GuardPredicate<TContext, WorkflowEvent<TDefs>, TGuards[K], ToParameterizedObject<TGuards>>;
124
+ };
125
+ delays?: {
126
+ [K in TDelay]: DelayConfig<
127
+ TContext,
128
+ WorkflowEvent<TDefs>,
129
+ ToParameterizedObject<TActions>["params"],
130
+ WorkflowEvent<TDefs>
131
+ >;
132
+ };
133
+ }): SetupReturn<
134
+ TContext,
135
+ WorkflowEvent<TDefs>,
136
+ MergedActors<TActors>,
137
+ {},
138
+ TActions,
139
+ TGuards,
140
+ TDelay,
141
+ TTag,
142
+ TInput,
143
+ TOutput,
144
+ TEmitted,
145
+ TMeta
146
+ > {
147
+ const inner = setup({
148
+ types: def.types,
149
+ actors: { ...jr2Actors, ...(def.actors ?? {}) },
150
+ actions: def.actions,
151
+ guards: def.guards,
152
+ delays: def.delays,
153
+ } as never);
154
+
155
+ const createMachine = (config: never): AnyStateMachine => {
156
+ // The declared run input (ADR-0033) rides the config under xstate's own word for what a
157
+ // machine receives at creation — and is pulled OFF before xstate sees it: a zod schema is
158
+ // not machine structure, and it must never reach the fingerprint/serialization paths that
159
+ // read `machine.config`. It is attached beside the vocabulary below.
160
+ const { input: inputSchema, ...machineConfig } = config as { input?: z.ZodObject } & Record<string, unknown>;
161
+
162
+ // Resolve the defs first (duplicates and reserved semantics fail HERE, naming the machine —
163
+ // the same loud failure `eventMap` gave the manifest, moved to machine-build time)…
164
+ const defs = eventMap((machineConfig as { id?: string }).id ?? "(machine)", def.events);
165
+
166
+ // …then rewrite the config (ADR-0015): every Agent-slot/`gate` invoke's input is wrapped to
167
+ // append its DERIVED menu and finalize the mechanism fields. Static — the walk sees the same
168
+ // config the Console will — and the derived names still ride serializable input, so the
169
+ // ADR-0007 restore path and invoke-time `resolveAccepts` validation are unchanged.
170
+ const machine = (inner.createMachine as unknown as (c: never) => AnyStateMachine)(
171
+ deriveMenus(machineConfig, defs, def.actors ?? {}) as never,
172
+ );
173
+
174
+ // Close the nested-`on` typo hole (ADR-0015): with the manifest dead, a typo'd key would
175
+ // silently become vocabulary — so every non-dotted key the machine handles anywhere must map
176
+ // to a def. Walk every node's `transitions` map, not `machine.events` (which filters out
177
+ // targetless/actionless transitions — exactly where a typo'd key would hide). Dotted names
178
+ // (`agent.*`, `workspace.*`, `xstate.*`, delayed transitions) are mechanically jr2's/xstate's;
179
+ // `*` is the wildcard descriptor.
180
+ const walk = (node: AnyStateMachine["root"]): void => {
181
+ for (const descriptor of node.transitions.keys()) {
182
+ if (descriptor === "*" || descriptor.includes(".")) continue;
183
+ if (!defs.has(descriptor)) {
184
+ throw new Error(
185
+ `machine "${machine.id}" handles event "${descriptor}", which no def in jr2Setup({ events }) ` +
186
+ `declares (declared: ${[...defs.keys()].join(", ") || "none"}) — a typo, or a missing defineEvent`,
187
+ );
188
+ }
189
+ }
190
+ for (const child of Object.values(node.states)) walk(child);
191
+ };
192
+ walk(machine.root);
193
+
194
+ attachVocabulary(machine, defs);
195
+ if (inputSchema) attachInputSchema(machine, inputSchema);
196
+ return machine;
197
+ };
198
+
199
+ // The one jr2-internal cast (report-xstate §1): re-assert the merged SetupReturn over the
200
+ // loosely-built implementation. The consumer-facing types are the declared signature's.
201
+ return { ...inner, createMachine } as unknown as SetupReturn<
202
+ TContext,
203
+ WorkflowEvent<TDefs>,
204
+ MergedActors<TActors>,
205
+ {},
206
+ TActions,
207
+ TGuards,
208
+ TDelay,
209
+ TTag,
210
+ TInput,
211
+ TOutput,
212
+ TEmitted,
213
+ TMeta
214
+ >;
215
+ }
216
+
217
+ // --- Menu derivation (ADR-0015) ------------------------------------------------------------------
218
+ // A state that invokes an AGENT SLOT gets, as its Agent's tool menu, the workflow events its
219
+ // transitions handle — own + bubbled ancestors, per statechart semantics — filtered to audience
220
+ // ∈ {agent, any}; a `gate` gets the same set filtered to {external, any}. The invoking actor
221
+ // kind is the primary router; `audience` on the def exists to RESTRICT (tag the security-
222
+ // sensitive events). Explicit `tools:`/`accepts:` on the invoke input remain as escape hatches.
223
+ //
224
+ // An agent invoke is identified by its LOGIC, not by a reserved src name: the walk looks the
225
+ // invoke's `src` up in the setup's own `actors` map and asks `isAgent` (ADR-0049). That is also
226
+ // where the Agent's NAME comes from — the slot key, injected into the wrapped input, so nothing
227
+ // downstream (the iid, the Harness route, the markers) has to be authored twice.
228
+ //
229
+ // The walk also NAMES unnamed gate invokes with their state key path (ADR-0011):
230
+ // the gate actor derives its default id from its own actor path, so the invoke id is the leaf
231
+ // segment of a caller-facing name — `humanReview` beats xstate's `0.task-with-review.humanReview`.
232
+ // Naming here is id QUALITY only; uniqueness comes from the path mechanism in gate.ts.
233
+
234
+ type LooseInvoke = { src?: unknown; input?: unknown; [k: string]: unknown };
235
+ type LooseState = {
236
+ on?: Record<string, unknown>;
237
+ invoke?: LooseInvoke | LooseInvoke[];
238
+ states?: Record<string, LooseState>;
239
+ [k: string]: unknown;
240
+ };
241
+ type InputArgs = { context: unknown; event: unknown; self: AnyActorRef };
242
+
243
+ /** Rewrite a machine config, wrapping every Agent-slot/`gate` invoke input (immutably). */
244
+ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Record<string, UnknownActorLogic>): unknown {
245
+ const pick = (names: Set<string>, kind: "agent" | "external"): string[] =>
246
+ [...names].filter((name) => {
247
+ const d = defs.get(name);
248
+ return !!d && (d.audience === kind || d.audience === "any");
249
+ });
250
+
251
+ const walk = (node: LooseState, inherited: Set<string>, path: readonly string[]): LooseState => {
252
+ const names = new Set(inherited);
253
+ for (const key of Object.keys(node.on ?? {})) {
254
+ if (!key.includes(".") && key !== "*") names.add(key);
255
+ }
256
+
257
+ let out = node;
258
+ if (node.invoke) {
259
+ const invokes = Array.isArray(node.invoke) ? node.invoke : [node.invoke];
260
+ // >1 unnamed gate in one state would collide on the state-key id; suffix ONLY then, so
261
+ // the common case (one gate per state) keeps the clean name.
262
+ const unnamedGates = invokes.filter((inv) => inv?.src === "gate" && inv.id == null).length;
263
+ let ordinal = 0;
264
+ const wrapOne = (inv: LooseInvoke): LooseInvoke => {
265
+ // The slot key IS the Agent name (ADR-0049) — read off the declaration, never authored.
266
+ if (typeof inv?.src === "string" && isAgent(actors[inv.src])) {
267
+ return { ...inv, input: wrapAgentInput(inv.input, pick(names, "agent"), inv.src) };
268
+ }
269
+ if (inv?.src === "gate") {
270
+ const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, pick(names, "external")) };
271
+ // A machine-root gate (empty path) is left to xstate's default id: a `""` id would be
272
+ // worse than a noisy one, and the derived gate id still works.
273
+ if (inv.id == null && path.length) {
274
+ const key = path.join(".");
275
+ wrapped.id = unnamedGates > 1 ? `${key}.${ordinal++}` : key;
276
+ }
277
+ return wrapped;
278
+ }
279
+ return inv;
280
+ };
281
+ out = { ...node, invoke: Array.isArray(node.invoke) ? node.invoke.map(wrapOne) : wrapOne(node.invoke) };
282
+ }
283
+ if (node.states) {
284
+ const states: Record<string, LooseState> = {};
285
+ for (const [key, child] of Object.entries(node.states)) states[key] = walk(child, names, [...path, key]);
286
+ out = { ...(out === node ? node : out), states };
287
+ }
288
+ return out;
289
+ };
290
+
291
+ return walk(config as LooseState, new Set(), []);
292
+ }
293
+
294
+ const resolveInput = (orig: unknown, args: InputArgs): Record<string, unknown> =>
295
+ (typeof orig === "function" ? (orig as (a: InputArgs) => unknown)(args) : (orig ?? {})) as Record<string, unknown>;
296
+
297
+ /** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the derived menu, and
298
+ * finalize the mechanism fields. */
299
+ function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
300
+ return (args: InputArgs): AgentRunInput => {
301
+ const consumer = resolveInput(orig, args) as Partial<AgentTurnInput & AgentRunInput>;
302
+ return {
303
+ agentName,
304
+ instanceId: consumer.instanceId ?? mintIid(consumer, agentName, args.self),
305
+ endpoint: consumer.endpoint,
306
+ sandbox: consumer.sandbox,
307
+ prompt: consumer.prompt,
308
+ // This turn's dials (ADR-0018) — passed straight through; the Harness layers
309
+ // them over the definition when the Submission starts.
310
+ model: consumer.model,
311
+ thinkingLevel: consumer.thinkingLevel,
312
+ // ADR-0035's reroll gate: closed to `session: "continue"` and a `conversation` pin (the
313
+ // runaway recovery is a FRESH conversation — exactly what they opted out of), and to a
314
+ // caller-passed iid — fresh on first use, but not jr2-minted, so no reroll identity may
315
+ // derive from it (ADR-0016's minting doctrine).
316
+ ...(consumer.session === "continue" || consumer.conversation || consumer.instanceId
317
+ ? { continuation: true }
318
+ : {}),
319
+ tools: consumer.tools ?? derived,
320
+ };
321
+ };
322
+ }
323
+
324
+ /** Wrap a gate invoke input: append the derived accepted set. */
325
+ function wrapGateInput(orig: unknown, derived: string[]) {
326
+ return (args: InputArgs): Record<string, unknown> => {
327
+ const consumer = resolveInput(orig, args);
328
+ return { ...consumer, accepts: (consumer.accepts as readonly string[] | undefined) ?? derived };
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Mint the instance id (ADR-0016). It is minted in the INPUT MAPPER — not the actor — because
334
+ * the input is the persistence vehicle: restore re-spawns from the persisted input without
335
+ * re-running the mapper (same conversation), while a fresh transition re-runs it (new one).
336
+ *
337
+ * - Default (fresh session, jr's lossy handoff): a new conversation per invocation — a random
338
+ * suffix under a readable `<runId>/<actor-path>/<agent>` prefix.
339
+ * - `session: "continue"` (+ optional `scope`): the iid derives deterministically from
340
+ * `(run, actor path, agent, scope)`, so re-invocations continue ONE flue conversation. The
341
+ * actor path excludes the root actor (its id is generated per process — everything below it
342
+ * is author-named and stable across restore). Invoking a continue iid that is already live
343
+ * fails loudly at the registration table (one live surface per address).
344
+ * - `conversation` (the cross-machine continue): a workflow-chosen name REPLACES the actor path,
345
+ * so invocations in different machines — a pre-workspace triage state and a state inside the
346
+ * `workspace()` body — derive one iid and continue one conversation. Same determinism, same
347
+ * restore behavior, same already-live check as `session: "continue"`.
348
+ */
349
+ function mintIid(
350
+ consumer: { session?: "continue"; scope?: string; conversation?: string },
351
+ agentName: string,
352
+ self: AnyActorRef,
353
+ ): string {
354
+ const runId = boundRunId(self.system) ?? "local";
355
+ const scope = consumer.scope ? `/${consumer.scope}` : "";
356
+ if (consumer.conversation) return `${runId}/${consumer.conversation}/${agentName}${scope}`;
357
+ const path = actorPath(self).join(".") || "root";
358
+ if (consumer.session === "continue") return `${runId}/${path}/${agentName}${scope}`;
359
+ return `${runId}/${path}/${agentName}${scope}/${randomUUID().slice(0, 8)}`;
360
+ }