@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/parts.ts
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// The PARTS a Machine carries (ADR-0049, ADR-0051): its Agents, and — on a `workspace()` wrapper
|
|
2
|
+
// — the Sandbox Image the pod runs and the Repo Slots it attaches. A Machine carries everything it
|
|
3
|
+
// depends on and composes by invoke, so what a deployment must know about a workflow is neither a
|
|
4
|
+
// folder nor a config block: it is an attachment on the machine object plus a walk over it.
|
|
5
|
+
//
|
|
6
|
+
// Two halves, one file, because they are one idea:
|
|
7
|
+
//
|
|
8
|
+
// - The ATTACHMENT. `workspace()` stamps its static options here, keyed on `machine.config` —
|
|
9
|
+
// the raw object xstate's `.provide()` passes through unchanged, exactly as the vocabulary is
|
|
10
|
+
// keyed (vocabulary.ts). That is ADR-0049's one rule: parts resolve at invoke time through the
|
|
11
|
+
// LIVE actor's logic, never a build-time closure, so a `provide()` clone and a `customize()`
|
|
12
|
+
// retune both find the part the actor was actually invoked as. The image is therefore never
|
|
13
|
+
// persisted — the provisioning state re-reads it off the Machine on restore.
|
|
14
|
+
// - The WALK. Its callers are the converge and the boot (ADR-0018/0019/0031/0037/0051): the
|
|
15
|
+
// custom-provider preflight probes the models the registered Machines actually name, the
|
|
16
|
+
// Instance Harness converges when any of them declares `workspace: "none"`, every `file:`
|
|
17
|
+
// image context a Machine ships is built and content-tagged, every BOUND Repo is known before
|
|
18
|
+
// a run can ask for it, an OPEN part — a Repo Slot with no url, an Agent with no model
|
|
19
|
+
// (ADR-0054) — is refused before anything is built, and whether any registered Machine
|
|
20
|
+
// composes a Sandbox at all is the data-plane switch. None of these can read
|
|
21
|
+
// an invoke's `input` (it is a function — dials are not statically recoverable), and none
|
|
22
|
+
// needs to: identity lives in the definition, the image and the slots are options, and all are
|
|
23
|
+
// values ON the Machine. A per-run slot is a function too, and the walk reports nothing for
|
|
24
|
+
// it: which Repo it binds is the run's business, and the fence at attach is its check.
|
|
25
|
+
//
|
|
26
|
+
// The walk is STRUCTURAL, and it descends by the same two mechanisms composition uses:
|
|
27
|
+
//
|
|
28
|
+
// - `implementations.actors` — every slot a `setup()`/`jr2Setup()` machine declares, whether or
|
|
29
|
+
// not a state invokes it. An Agent slot is recognized by its brand (`isAgent`), a child
|
|
30
|
+
// Machine by having a state tree. That covers an imported Machine invoked as a child,
|
|
31
|
+
// `workspace()`'s `body`, and `pool()`'s `worker` — all named slots since ADR-0049.
|
|
32
|
+
// - inline invoked machines — a machine object written straight onto an `invoke.src`, which
|
|
33
|
+
// xstate rewrites to a generated key and keeps only on the raw config node. Nothing jr2 owns
|
|
34
|
+
// arrives this way any more; the walk keeps it because an AUTHOR may still write one.
|
|
35
|
+
//
|
|
36
|
+
// Deliberately NOT a `provide()`-aware read: the walk runs on the registered Machine, which is the
|
|
37
|
+
// object the run will start, so a `customize()` clone is walked as itself and a test seam's fake
|
|
38
|
+
// (unbranded) contributes nothing.
|
|
39
|
+
|
|
40
|
+
import { basename } from "node:path";
|
|
41
|
+
import { fileURLToPath } from "node:url";
|
|
42
|
+
import type { AnyActorRef, AnyStateMachine, StateNode, UnknownActorLogic } from "xstate";
|
|
43
|
+
import { isAgent, isOpenAgent, type AgentDeclaration } from "./agent.ts";
|
|
44
|
+
import { isImageContext } from "./images.ts";
|
|
45
|
+
import { open } from "./open.ts";
|
|
46
|
+
import { repoIdentity } from "./repo-identity.ts";
|
|
47
|
+
|
|
48
|
+
// --- Repo Slots (ADR-0051) ---------------------------------------------------------------------
|
|
49
|
+
// A `workspace()` names each Repo it attaches under a SLOT — the Machine's own word for it, the
|
|
50
|
+
// key of the body's `workspace.repos` handles, and the directory under `/work`. The slot's VALUE
|
|
51
|
+
// is one of three states, and the walk tells them apart without evaluating anything. A Machine
|
|
52
|
+
// whose body names no slot — it enumerates whatever is attached — declares the whole MAP Open
|
|
53
|
+
// instead (`repos: open`): the composer names every slot, in an order the Machine may give a
|
|
54
|
+
// meaning to. Open at the map is the same word as Open at a slot, one level up (CONTEXT.md).
|
|
55
|
+
|
|
56
|
+
// The sentinel itself lives in open.ts, a module with nothing else in it: since ADR-0054 it marks
|
|
57
|
+
// an Agent's model too, and agent.ts must read it without importing this file (the walk imports
|
|
58
|
+
// agent.ts, not the other way round). Re-exported here because a Repo Slot is where a composer
|
|
59
|
+
// meets it first, and `import { open } from "@jr2/orchestrator"` is the only spelling anyone writes.
|
|
60
|
+
export { open } from "./open.ts";
|
|
61
|
+
|
|
62
|
+
/** What a Repo Slot resolves to (CONTEXT.md "Binding"): the url, and the base the branch
|
|
63
|
+
* Worktree is cut from — absent, the Repo's own default branch. */
|
|
64
|
+
export type Binding = { url: string; ref?: string };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One Repo Slot's value: BOUND (a url, or `{ url, ref? }`), OPEN (the {@link open} sentinel —
|
|
68
|
+
* someone downstream binds it), or PER-RUN (a mapper over the wrapper's door, so a run input — a
|
|
69
|
+
* ticket field — decides). Bound and open are what `jr2 up` can see; per-run is the run's business,
|
|
70
|
+
* fenced at attach by `git.credentials` (ADR-0051).
|
|
71
|
+
*/
|
|
72
|
+
export type RepoSlot<TInput = unknown> =
|
|
73
|
+
| typeof open
|
|
74
|
+
| string
|
|
75
|
+
| Binding
|
|
76
|
+
| ((args: { input: TInput }) => string | Binding);
|
|
77
|
+
|
|
78
|
+
/** The three states, read off a slot's value. */
|
|
79
|
+
export type RepoSlotState =
|
|
80
|
+
| { kind: "open" }
|
|
81
|
+
| { kind: "bound"; binding: Binding }
|
|
82
|
+
| { kind: "per-run"; mapper: (args: { input: unknown }) => string | Binding };
|
|
83
|
+
|
|
84
|
+
/** Classify one slot's value. Assumes the value passed {@link assertRepoSlot}. */
|
|
85
|
+
export function repoSlotState(value: RepoSlot<any>): RepoSlotState {
|
|
86
|
+
if (value === open) return { kind: "open" };
|
|
87
|
+
if (typeof value === "function") return { kind: "per-run", mapper: value };
|
|
88
|
+
return { kind: "bound", binding: typeof value === "string" ? { url: value } : value };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A slot key becomes a directory name under `/work`, so it is held to what a path segment can
|
|
92
|
+
* carry — and to what a prompt can name without quoting. It starts with a LETTER: the handles
|
|
93
|
+
* keep declaration order, which a Machine may give a meaning to (`task`: the first slot is the
|
|
94
|
+
* one the coder edits), and JS puts an integer-like key such as `"1"` ahead of every other key in
|
|
95
|
+
* `Object.keys`, wherever the author wrote it. */
|
|
96
|
+
export const REPO_SLOT_KEY = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refuse a malformed slot value BY NAME, at build time — the same derives-from-a-typo class the
|
|
100
|
+
* spec guard catches for the branch, one build earlier. A bound url is also parsed here: an
|
|
101
|
+
* identity the walk cannot derive (a relative path, an unknown scheme) is refused where the
|
|
102
|
+
* author wrote it rather than at the converge that walks it. `where` names the caller
|
|
103
|
+
* (`workspace()`, `customize()`).
|
|
104
|
+
*/
|
|
105
|
+
export function assertRepoSlot(where: string, slot: string, value: unknown): asserts value is RepoSlot<any> {
|
|
106
|
+
if (!REPO_SLOT_KEY.test(slot)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`${where}: Repo Slot key ${JSON.stringify(slot)} is not a directory name — a slot becomes ` +
|
|
109
|
+
"`/work/<slot>` and its order matters, so it starts with a letter and matches " +
|
|
110
|
+
"/^[A-Za-z][A-Za-z0-9._-]*$/ (ADR-0051).",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (value === open || typeof value === "function") return;
|
|
114
|
+
const binding = typeof value === "string" ? { url: value } : (value as Partial<Binding> | null | undefined);
|
|
115
|
+
const bad = (what: string) =>
|
|
116
|
+
new Error(
|
|
117
|
+
`${where}: Repo Slot "${slot}" is ${what} — a slot is \`open\`, a url, \`{ url, ref? }\`, or a mapper ` +
|
|
118
|
+
"`({ input }) => url | { url, ref? }` over the door (ADR-0051).",
|
|
119
|
+
);
|
|
120
|
+
if (typeof binding !== "object" || binding === null) throw bad(`not a binding (got ${JSON.stringify(value)})`);
|
|
121
|
+
if (typeof binding.url !== "string" || !binding.url)
|
|
122
|
+
throw bad(`bound to an empty url (got ${JSON.stringify(value)})`);
|
|
123
|
+
if (binding.ref !== undefined && (typeof binding.ref !== "string" || !binding.ref))
|
|
124
|
+
throw bad(`bound with an empty ref (got ${JSON.stringify(value)})`);
|
|
125
|
+
try {
|
|
126
|
+
repoIdentity(binding.url);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${where}: Repo Slot "${slot}" binds ${JSON.stringify(binding.url)}, which names no Repo — ` +
|
|
130
|
+
`${err instanceof Error ? err.message : err} (ADR-0051).`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The static parts a `workspace()` wrapper carries (ADR-0049, ADR-0037, ADR-0051): what the
|
|
137
|
+
* Sandbox is MADE of and which Repos it attaches. The images are NAMES in ADR-0037's two shapes — a
|
|
138
|
+
* `file:` URL to a docker context the Machine's module ships, or a registry ref — and both resolve
|
|
139
|
+
* to a concrete ref on the port's side, which is what keeps the Machine cluster-agnostic. The
|
|
140
|
+
* Repos are Slots, each bound, open, or per-run.
|
|
141
|
+
*
|
|
142
|
+
* They are options rather than `WorkspaceSpec` fields because they are STATIC: `jr2 up` must find
|
|
143
|
+
* them by walking the Machine, and a per-run spec is a function of run input that no walk can
|
|
144
|
+
* evaluate.
|
|
145
|
+
*/
|
|
146
|
+
export type SandboxParts = {
|
|
147
|
+
/** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
|
|
148
|
+
image?: string;
|
|
149
|
+
/** The User Container's image (ADR-0005). Absent → the pod has no third container. */
|
|
150
|
+
user?: string;
|
|
151
|
+
/** The Repo Slots, in declaration order — which the handles keep (ADR-0051) — or
|
|
152
|
+
* {@link open} for a map the composer fills whole. */
|
|
153
|
+
repos: Record<string, RepoSlot> | typeof open;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
// The stamps below are written ON `machine.config` under `Symbol.for` keys, never held in a
|
|
157
|
+
// module-local WeakMap, for the reason `open` and `asMachine` give: an Instance resolves its OWN
|
|
158
|
+
// `@jr2/orchestrator`, and the installed CLI walks with ITS copy (ADR-0043) — a WeakMap the
|
|
159
|
+
// Instance's `workspace()` filled is one the CLI's `partsOf` never sees, and the walk then reports
|
|
160
|
+
// a Machine that composes no Sandbox: no Sandbox Image built, no cache agent converged, and a
|
|
161
|
+
// provision that parks on a mount nothing serves. `machine.config` is the key either way, because
|
|
162
|
+
// a `.provide()` clone shares it (ADR-0011) and a `customize()` clone re-stamps. A symbol property
|
|
163
|
+
// is invisible to `JSON.stringify` and `Object.keys`, so xstate and the fingerprint read past it.
|
|
164
|
+
const SANDBOX_PARTS: unique symbol = Symbol.for("jr2.sandbox.parts");
|
|
165
|
+
const WRAPPER_BODY: unique symbol = Symbol.for("jr2.wrapper.body");
|
|
166
|
+
type Stamped = { [SANDBOX_PARTS]?: SandboxParts; [WRAPPER_BODY]?: string };
|
|
167
|
+
|
|
168
|
+
/** Attach a wrapper's static Sandbox parts. jr2-internal: `workspace()` calls it. */
|
|
169
|
+
export function attachSandboxParts(machine: AnyStateMachine, parts: SandboxParts): void {
|
|
170
|
+
(machine.config as Stamped)[SANDBOX_PARTS] = parts;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The Sandbox parts a Machine carries — no images and no slots for any Machine that is not a
|
|
174
|
+
* `workspace()` wrapper, which is the honest answer: it composes no Sandbox. */
|
|
175
|
+
export function sandboxPartsOf(machine: AnyStateMachine | undefined): SandboxParts {
|
|
176
|
+
return (machine?.config as Stamped | undefined)?.[SANDBOX_PARTS] ?? { repos: {} };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Does this Machine COMPOSE a Sandbox — i.e. is it a `workspace()` wrapper? Distinct from
|
|
180
|
+
* `sandboxPartsOf(m)` naming no image: a wrapper that names neither image still owns the two
|
|
181
|
+
* seats, which is what `customize({ image })` retunes (ADR-0049) — and it always carries at least
|
|
182
|
+
* one Repo Slot (ADR-0051). Also the data-plane switch, read off the walk: an Instance needs
|
|
183
|
+
* Sandboxes exactly when a registered Machine composes one. */
|
|
184
|
+
export function composesSandbox(machine: AnyStateMachine): boolean {
|
|
185
|
+
return (machine.config as Stamped)[SANDBOX_PARTS] !== undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The slot a jr2 WRAPPER is transparent to (ADR-0049): `workspace()`'s `body`, `pool()`'s
|
|
189
|
+
// `worker`. A wrapper owns Sandbox lifecycle or Source scheduling and carries no Agents of its
|
|
190
|
+
// own, so a composer who writes `customize(research, { agents: { researcher } })` means the
|
|
191
|
+
// Machine inside — and never has to know that jr2 wrapped it, or spell `body`.
|
|
192
|
+
//
|
|
193
|
+
// Recorded, not inferred: a slot named `body` is a name any author may choose, and routing a
|
|
194
|
+
// customize through it because of its spelling would retune a different Machine than the
|
|
195
|
+
// composer named. The wrappers stamp this the same way they stamp everything else a Machine
|
|
196
|
+
// carries — on `machine.config`, so a `provide()` clone and a `customize()` retune keep it, and
|
|
197
|
+
// under a `Symbol.for` key, so a second module copy reads it (see `SANDBOX_PARTS`).
|
|
198
|
+
|
|
199
|
+
/** Mark this Machine a jr2 wrapper over `slot`. jr2-internal: `workspace()`/`pool()` call it. */
|
|
200
|
+
export function attachWrapperBody(machine: AnyStateMachine, slot: string): void {
|
|
201
|
+
(machine.config as Stamped)[WRAPPER_BODY] = slot;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The slot a jr2 wrapper is transparent to — undefined for every Machine an author wrote, which
|
|
205
|
+
* is where a `customize()` stops descending and starts resolving. */
|
|
206
|
+
export function wrapperBodyOf(machine: AnyStateMachine): string | undefined {
|
|
207
|
+
return (machine.config as Stamped)[WRAPPER_BODY];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
declare const wrapperBody: unique symbol;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The TYPE half of {@link attachWrapperBody}'s stamp: a jr2 wrapper's machine type SAYS which
|
|
214
|
+
* Machine it is transparent to, so `customize()`'s types read the same record its runtime walk
|
|
215
|
+
* reads (`wrapperBodyOf`) and the compiler's answer and the runtime's are one answer.
|
|
216
|
+
*
|
|
217
|
+
* Recorded here too, for the reason the runtime records it: a slot named `body` or `worker` is a
|
|
218
|
+
* name any author may choose, so a type that routed a `customize()` through a slot's SPELLING
|
|
219
|
+
* would offer the composer the Agents of a Machine they never named — and would deny the Agents
|
|
220
|
+
* of the one they did, since the reach stops at a Machine that is not a wrapper. Only a wrapper
|
|
221
|
+
* carries this marker, and only `workspace()` and `pool()` write it.
|
|
222
|
+
*
|
|
223
|
+
* Phantom: the property exists in the type alone. A wrapper's implementation returns a plain
|
|
224
|
+
* machine and its overloads state this, exactly as the runtime stamp lives beside the object
|
|
225
|
+
* rather than on it.
|
|
226
|
+
*/
|
|
227
|
+
export type JR2Wrapper<TBody extends AnyStateMachine> = { readonly [wrapperBody]: TBody };
|
|
228
|
+
|
|
229
|
+
declare const repoSlots: unique symbol;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The TYPE half of a wrapper's Repo Slots (ADR-0051): a `workspace()`'s machine type SAYS which
|
|
233
|
+
* slots it declared, so `customize()`'s `repos` offers exactly those keys and a slot the Machine
|
|
234
|
+
* never declared is a compile error — read through `pool()`'s `worker` and `workspace()`'s
|
|
235
|
+
* `body` the way the image seats are. Phantom, like {@link JR2Wrapper}: the property exists in the
|
|
236
|
+
* type alone, and the runtime reads the same record off `sandboxPartsOf`.
|
|
237
|
+
*/
|
|
238
|
+
export type JR2Repos<TSlots extends string> = { readonly [repoSlots]: TSlots };
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The actor-slot union a jr2 wrapper declares, in xstate's own `ProvidedActor` shape. `workspace()`
|
|
242
|
+
* and `pool()` name it in their return types beside {@link JR2Wrapper}, so the body's own slots are
|
|
243
|
+
* readable THROUGH the wrapper and `customize()` can offer the composer the Agents of the Machine
|
|
244
|
+
* inside (customize.ts) — the compile-time twin of the walk above.
|
|
245
|
+
*
|
|
246
|
+
* The mechanism actors ride along as `UnknownActorLogic`: they are named slots (Stately shows
|
|
247
|
+
* `provision`, not `inline`) but nothing outside the wrapper substitutes them, so their logic
|
|
248
|
+
* types buy nothing and would drag the port contracts into every consumer's inference.
|
|
249
|
+
*/
|
|
250
|
+
export type WrapperActors<TSlot extends string, TBody extends AnyStateMachine, TMechanism extends string> =
|
|
251
|
+
| { src: TSlot; logic: TBody; id: string | undefined }
|
|
252
|
+
| { src: TMechanism; logic: UnknownActorLogic; id: string | undefined };
|
|
253
|
+
|
|
254
|
+
/** One Agent a Machine carries: the SLOT KEY it is declared under (its name everywhere — the
|
|
255
|
+
* Harness route, the minted iid, the markers) and the declaration that slot runs — Open model and
|
|
256
|
+
* all (ADR-0054), because a converge that could not see an unbound Agent could not refuse it. */
|
|
257
|
+
export type CarriedAgent = { name: string; definition: AgentDeclaration };
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One docker context a Machine ships (ADR-0037's built origin) — a `file:` URL a module named with
|
|
261
|
+
* `import.meta.resolve`, which is the only way an ES module can name a folder it owns.
|
|
262
|
+
*
|
|
263
|
+
* `dir` is that URL as a path, which is what `docker build` takes and what the content digest
|
|
264
|
+
* covers. `name` is the directory's basename and is DECORATION — it rides the image tag so a human
|
|
265
|
+
* reading `docker images` sees something better than a hash. The identity is the digest.
|
|
266
|
+
*/
|
|
267
|
+
export type CarriedImage = { url: string; dir: string; name: string };
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* One Repo a Machine BINDS (ADR-0051) — the Binding as written, plus the identity every spelling
|
|
271
|
+
* of one repository normalizes to and the key the cluster addresses its cache by (repo-identity.ts).
|
|
272
|
+
* Deduped by identity: two Machines spelling one repository two ways are one Repo, and the first
|
|
273
|
+
* spelling in walk order is the one the CR is created with.
|
|
274
|
+
*/
|
|
275
|
+
export type CarriedRepo = { url: string; ref?: string; identity: string; key: string };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A part left OPEN on a registered Machine — a Repo Slot with no url (ADR-0051), an Agent with no
|
|
279
|
+
* model (ADR-0054) — and what `jr2 up` refuses, before anything is built, naming the Machine, the
|
|
280
|
+
* slot, and the `customize` line that binds it. One shape for both, because a composer fixes both
|
|
281
|
+
* the same way and the walk locates both the same way.
|
|
282
|
+
*
|
|
283
|
+
* The Machine is named by WHERE it sits, not by its xstate id: `path` is the chain of actor-slot
|
|
284
|
+
* keys a `customize()` of the registered root walks to reach the `workspace()` that declares the
|
|
285
|
+
* slot — `[]` when the root is that wrapper, `["review"]` for a Machine composed under `actors:
|
|
286
|
+
* { review }`. It is exactly the `actors:` nesting of the fix line ({@link customizeLine}), so
|
|
287
|
+
* jr2's transparent wrappers (`workspace()`'s `body`, `pool()`'s `worker`) are omitted from it as
|
|
288
|
+
* `customize()` omits them. `undefined` when the wrapper was reached through a Machine invoked
|
|
289
|
+
* INLINE (a machine object written straight onto `invoke.src`): an actor with no slot key is one
|
|
290
|
+
* no `customize()` can name, so no line binds that slot — declaring it under `setup({ actors })`
|
|
291
|
+
* does.
|
|
292
|
+
*
|
|
293
|
+
* `slot` is `undefined` for a `workspace()` that declared its whole map Open (`repos: open`,
|
|
294
|
+
* ADR-0051): there is no slot to name, because naming the slots is what the composer does.
|
|
295
|
+
*/
|
|
296
|
+
export type OpenSlot = { slot: string | undefined; path: readonly string[] | undefined };
|
|
297
|
+
|
|
298
|
+
/** Which kind of Open part a fix line binds — the two `customize()` keys (ADR-0051, ADR-0054). */
|
|
299
|
+
export type OpenPart = "repo" | "agent";
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The `customize` line that binds an Open part, given the identifier the composer holds the
|
|
303
|
+
* registered Machine by: `customize(codeReview, { repos: { target: "<url>" } })` for a part on the
|
|
304
|
+
* root, nested through `actors` for one on a composed Machine —
|
|
305
|
+
* `customize(top, { actors: { review: { repos: { target: "<url>" } } } })`. The same nesting
|
|
306
|
+
* `customize()` accepts, so the line pastes.
|
|
307
|
+
*
|
|
308
|
+
* An Agent binds through `agents` instead, and the placeholder is the model spelling ADR-0018
|
|
309
|
+
* demands — `<provider>/<model>`, not a bare model id, since the prefix is what picks the
|
|
310
|
+
* endpoint. `repo` is the default because a Repo Slot was the first Open part and reads as the
|
|
311
|
+
* unmarked case. An Open MAP has no slot to print (`slot` undefined): the placeholder `<slot>`
|
|
312
|
+
* stands where the composer's own word goes, since choosing it is the composer's half of the line.
|
|
313
|
+
*/
|
|
314
|
+
export function customizeLine(
|
|
315
|
+
machine: string,
|
|
316
|
+
path: readonly string[],
|
|
317
|
+
slot: string | undefined,
|
|
318
|
+
part: OpenPart = "repo",
|
|
319
|
+
): string {
|
|
320
|
+
const binds =
|
|
321
|
+
part === "agent"
|
|
322
|
+
? `{ agents: { ${slot}: { model: "<provider>/<model>" } } }`
|
|
323
|
+
: `{ repos: { ${slot ?? "<slot>"}: "<url>" } }`;
|
|
324
|
+
const inner = path.reduceRight((parts, key) => `{ actors: { ${key}: ${parts} } }`, binds);
|
|
325
|
+
return `customize(${machine}, ${inner})`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The runtime twin of the walk's `path`: the actor-slot chain from a run's root actor down to
|
|
330
|
+
* `actor` (its own `src` included), read off the live actor tree — each `src` is the slot key it
|
|
331
|
+
* was invoked or spawned as, and a wrapper's transparent body is skipped by the same record
|
|
332
|
+
* ({@link wrapperBodyOf}) the walk skips it by. `undefined` past an inline-invoked actor, whose
|
|
333
|
+
* `src` is the logic itself and names no slot.
|
|
334
|
+
*/
|
|
335
|
+
export function actorSlotPath(actor: AnyActorRef | undefined): string[] | undefined {
|
|
336
|
+
const path: string[] = [];
|
|
337
|
+
for (let node = actor; node?._parent; node = node._parent) {
|
|
338
|
+
const src = (node as { src?: unknown }).src;
|
|
339
|
+
if (typeof src !== "string") return undefined;
|
|
340
|
+
const parent = asMachine((node._parent as { logic?: unknown }).logic);
|
|
341
|
+
if (!parent || wrapperBodyOf(parent) !== src) path.unshift(src);
|
|
342
|
+
}
|
|
343
|
+
return path;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Everything the registered Machines carry that a converge or a boot must act on. */
|
|
347
|
+
export type CarriedParts = {
|
|
348
|
+
agents: CarriedAgent[];
|
|
349
|
+
images: CarriedImage[];
|
|
350
|
+
/** Every bound Repo, deduped by identity, in walk order. */
|
|
351
|
+
repos: CarriedRepo[];
|
|
352
|
+
/** Every open Repo Slot, in walk order — non-empty is a converge refusal. An Open MAP
|
|
353
|
+
* (`repos: open`) is one entry with no `slot`. */
|
|
354
|
+
openSlots: OpenSlot[];
|
|
355
|
+
/** Every Agent whose model is still Open (ADR-0054), in walk order — the same refusal, by the
|
|
356
|
+
* same route, and the reason these are two lists rather than one: the fix lines differ. */
|
|
357
|
+
openAgents: OpenSlot[];
|
|
358
|
+
/** Whether any Machine reached, at any depth, composes a Sandbox — the data-plane switch. */
|
|
359
|
+
composesSandbox: boolean;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/** A machine actor, told apart from a promise/callback/observable one by having a state tree.
|
|
363
|
+
* Structural on purpose: an Instance resolves its OWN `@jr2/orchestrator`, so the CLI's walk and a
|
|
364
|
+
* workflow's machines may come from two module instances and no `instanceof` can hold. */
|
|
365
|
+
export function asMachine(logic: unknown): AnyStateMachine | undefined {
|
|
366
|
+
return (logic as AnyStateMachine | undefined)?.root ? (logic as AnyStateMachine) : undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Every machine object written INLINE on one state's invokes. A named `src` resolves through
|
|
370
|
+
* `implementations.actors` instead and is walked there. */
|
|
371
|
+
function inlineMachines(node: StateNode<any, any>): AnyStateMachine[] {
|
|
372
|
+
const config = [node.config.invoke ?? []].flat() as Array<{ src?: unknown }>;
|
|
373
|
+
return config.flatMap((inv) => {
|
|
374
|
+
const machine = asMachine(inv?.src);
|
|
375
|
+
return machine ? [machine] : [];
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** `JSON.stringify` with object keys sorted at every depth — a value's identity, not its spelling.
|
|
380
|
+
* Symbols are spelled out rather than dropped: `JSON.stringify` silently omits a symbol-valued
|
|
381
|
+
* key, which would make an Open Agent (ADR-0054) collapse into a bound one that differs in nothing
|
|
382
|
+
* but its model. */
|
|
383
|
+
function canonical(value: unknown): string {
|
|
384
|
+
return JSON.stringify(value, (_key, v) =>
|
|
385
|
+
typeof v === "symbol"
|
|
386
|
+
? v.toString()
|
|
387
|
+
: v !== null && typeof v === "object" && !Array.isArray(v)
|
|
388
|
+
? Object.fromEntries(
|
|
389
|
+
Object.keys(v as Record<string, unknown>)
|
|
390
|
+
.sort()
|
|
391
|
+
.map((k) => [k, (v as Record<string, unknown>)[k]]),
|
|
392
|
+
)
|
|
393
|
+
: v,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Everything the given Machines carry, themselves and through the Machines they compose — deduped,
|
|
399
|
+
* in walk order.
|
|
400
|
+
*
|
|
401
|
+
* Two Machines in one instance may each carry a `coder`, and if their definitions differ BOTH are
|
|
402
|
+
* reported: they are two Agents that share a slot key, which is exactly what ADR-0049 made legal
|
|
403
|
+
* and what a flat roster could not hold. Identical ones collapse, so a definition value shared by
|
|
404
|
+
* three Machines is preflighted once — and so is one docker context two Workspaces name.
|
|
405
|
+
*/
|
|
406
|
+
export function partsOf(machines: Iterable<AnyStateMachine>): CarriedParts {
|
|
407
|
+
const agents: CarriedAgent[] = [];
|
|
408
|
+
const images: CarriedImage[] = [];
|
|
409
|
+
const repos: CarriedRepo[] = [];
|
|
410
|
+
const openSlots: OpenSlot[] = [];
|
|
411
|
+
const openAgents: OpenSlot[] = [];
|
|
412
|
+
let sandboxed = false;
|
|
413
|
+
const seen = new Set<string>();
|
|
414
|
+
// Cycle guard AND work saver: a Machine reached twice carries the same parts both times, and a
|
|
415
|
+
// Machine that composes itself is legal (a recursive pool worker) but not walkable twice.
|
|
416
|
+
const walked = new Set<AnyStateMachine>();
|
|
417
|
+
|
|
418
|
+
const collectAgent = (path: OpenSlot["path"], name: string, definition: AgentDeclaration): void => {
|
|
419
|
+
// Keyed on the PAIR, serialized whole: a separator character inside a template literal is
|
|
420
|
+
// either ambiguous (a slot key may contain it) or, if chosen for being impossible, a control
|
|
421
|
+
// byte that makes this module binary to git — invisible to diff, blame and grep, forever.
|
|
422
|
+
// Serialized CANONICALLY (keys sorted at every depth): two structurally equal definitions
|
|
423
|
+
// written in a different key order are one Agent, and must collapse to one preflight.
|
|
424
|
+
const key = canonical(["agent", name, definition]);
|
|
425
|
+
if (seen.has(key)) return;
|
|
426
|
+
seen.add(key);
|
|
427
|
+
agents.push({ name, definition });
|
|
428
|
+
// Reported, not withheld: the Agent is still carried (a `workspace: "none"` one still
|
|
429
|
+
// converges the Instance Harness), and it is the converge's job to refuse it by name —
|
|
430
|
+
// `jr2 up`'s preflight simply has no model to probe for it (ADR-0054).
|
|
431
|
+
if (isOpenAgent(definition)) openAgents.push({ slot: name, path });
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Only the BUILT origin is collected: a registry ref is deployed-never-built, so there is
|
|
435
|
+
// nothing for a converge to do with it (ADR-0037/0039 — what jr2 did not stamp, jr2 does not
|
|
436
|
+
// touch). Both image seats ride the same rule, because ADR-0005 gives the User Container the
|
|
437
|
+
// same two origins and the same resolution.
|
|
438
|
+
const collectImage = (url: string | undefined): void => {
|
|
439
|
+
if (url === undefined || !isImageContext(url)) return;
|
|
440
|
+
const key = canonical(["image", url]);
|
|
441
|
+
if (seen.has(key)) return;
|
|
442
|
+
seen.add(key);
|
|
443
|
+
const dir = fileURLToPath(url);
|
|
444
|
+
images.push({ url, dir, name: basename(dir) });
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Keyed on the IDENTITY, not the spelling (ADR-0051): `git@github.com:acme/app.git` and
|
|
448
|
+
// `https://github.com/acme/app` are one Repo and one cache, so they collapse to one entry — the
|
|
449
|
+
// first spelling wins, and it is the url the CR is created with. A per-run slot contributes
|
|
450
|
+
// nothing, and an open one is reported for the converge to refuse — as is an Open map, which
|
|
451
|
+
// has no slot to report and is refused by the same route.
|
|
452
|
+
const collectRepos = (path: OpenSlot["path"], slots: SandboxParts["repos"]): void => {
|
|
453
|
+
if (slots === open) {
|
|
454
|
+
openSlots.push({ slot: undefined, path });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
for (const [slot, value] of Object.entries(slots)) {
|
|
458
|
+
const state = repoSlotState(value);
|
|
459
|
+
if (state.kind === "open") openSlots.push({ slot, path });
|
|
460
|
+
if (state.kind !== "bound") continue;
|
|
461
|
+
const { identity, key } = repoIdentity(state.binding.url);
|
|
462
|
+
const dedupe = canonical(["repo", identity]);
|
|
463
|
+
if (seen.has(dedupe)) continue;
|
|
464
|
+
seen.add(dedupe);
|
|
465
|
+
repos.push({ ...state.binding, identity, key });
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const walkStates = (node: StateNode<any, any>, visit: (machine: AnyStateMachine) => void): void => {
|
|
470
|
+
for (const machine of inlineMachines(node)) visit(machine);
|
|
471
|
+
for (const child of Object.values(node.states as Record<string, StateNode<any, any>>)) walkStates(child, visit);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
// `path` is the `customize()` route from the registered root to `machine` (see `OpenSlot`):
|
|
475
|
+
// a named child slot extends it, a wrapper's transparent body does not, and an inline invoke
|
|
476
|
+
// ends it — nothing downstream of an actor without a slot key can be named by a composer.
|
|
477
|
+
const walk = (machine: AnyStateMachine, path: OpenSlot["path"]): void => {
|
|
478
|
+
if (walked.has(machine)) return;
|
|
479
|
+
walked.add(machine);
|
|
480
|
+
const parts = sandboxPartsOf(machine);
|
|
481
|
+
collectImage(parts.image);
|
|
482
|
+
collectImage(parts.user);
|
|
483
|
+
if (composesSandbox(machine)) {
|
|
484
|
+
sandboxed = true;
|
|
485
|
+
collectRepos(path, parts.repos);
|
|
486
|
+
}
|
|
487
|
+
const body = wrapperBodyOf(machine);
|
|
488
|
+
for (const [name, logic] of Object.entries(machine.implementations.actors as Record<string, unknown>)) {
|
|
489
|
+
if (isAgent(logic)) collectAgent(path, name, logic.definition);
|
|
490
|
+
else {
|
|
491
|
+
const child = asMachine(logic);
|
|
492
|
+
if (child) walk(child, path === undefined ? undefined : name === body ? path : [...path, name]);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
walkStates(machine.root, (inline) => walk(inline, undefined));
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
for (const machine of machines) walk(machine, []);
|
|
499
|
+
return { agents, images, repos, openSlots, openAgents, composesSandbox: sandboxed };
|
|
500
|
+
}
|