@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- 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
|
+
}
|