@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/pool.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// `pool(worker, spec)` (ADR-0017): the jr2-owned top-of-the-run Machine — one worker per Source
|
|
2
|
+
// item, at most `cap` at once. It absorbs what every jr-shaped workflow used to hand-roll:
|
|
3
|
+
// spawn-under-cap, stable child identity, `xstate.done.actor.*` completion collection,
|
|
4
|
+
// `stopChild` bookkeeping, the wake gate, and the re-query timer. The one top-level `spawnChild`
|
|
5
|
+
// lives HERE, once — which is what keeps the Console's static trace guaranteed by
|
|
6
|
+
// construction (the comment-enforced "keep spawnChild top-level" footgun deletes), and the
|
|
7
|
+
// worker is a registered string src, which is what makes spawned children persistable at all
|
|
8
|
+
// (xstate cannot persist inline-src children).
|
|
9
|
+
//
|
|
10
|
+
// The Source port is generalized — `next(active) → item | null`, plus an optional `wake` event
|
|
11
|
+
// def (a webhook / `jr2 send` push seam, served as a standing gate named "source") and
|
|
12
|
+
// `pollEvery` (the set mutates underneath us — re-query). A queue, a generator, or a re-queried
|
|
13
|
+
// set; tk's claim actor is one adapter (CONTEXT.md: Work Source is the ticket-flavored Source).
|
|
14
|
+
//
|
|
15
|
+
// Terminal triage is three-valued (ADR-0017, forced by parking-is-retention):
|
|
16
|
+
// - drained — source empty and all children settled → final (jr exit 0);
|
|
17
|
+
// - deadlocked — open-but-never-ready items and nothing running that could unblock them →
|
|
18
|
+
// final, distinct status (jr exit 2), never an indistinguishable idle poll;
|
|
19
|
+
// - waiting — children parked (gates, agents) → the run stays open; the gates list is the
|
|
20
|
+
// "what needs me" surface (jr exit 3).
|
|
21
|
+
// The pool can make this call because it owns both the source and the children. Distinguishing
|
|
22
|
+
// drained from deadlocked needs one more bit than `null` carries, so a Source's `next` MAY
|
|
23
|
+
// resolve the richer `{ item: null, open: n }` shape; a bare `null` means drained-when-idle.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
assign,
|
|
27
|
+
setup,
|
|
28
|
+
spawnChild,
|
|
29
|
+
stopChild,
|
|
30
|
+
type AnyStateMachine,
|
|
31
|
+
type PromiseActorLogic,
|
|
32
|
+
type StateMachine,
|
|
33
|
+
} from "xstate";
|
|
34
|
+
import type { z } from "zod";
|
|
35
|
+
import type { EventDef } from "@jr2/agent-protocol";
|
|
36
|
+
import { gate } from "./gate.ts";
|
|
37
|
+
import { attachWrapperBody, type JR2Wrapper, type WrapperActors } from "./parts.ts";
|
|
38
|
+
import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What `next` resolves. A plain item claims it; `null` says "nothing ready now" (drained, when
|
|
42
|
+
* nothing is running); the rich shape adds `open` — how many items exist but are not ready —
|
|
43
|
+
* which is the one bit that separates DRAINED from DEADLOCKED when the pool goes idle.
|
|
44
|
+
*/
|
|
45
|
+
export type SourceNextResult<T> = T | null | { item: T | null; open: number };
|
|
46
|
+
|
|
47
|
+
/** The Source port (ADR-0017): where a pool draws work from. Build one with {@link source}. */
|
|
48
|
+
export type SourceSpec<T> = {
|
|
49
|
+
/** Claim the next ready item, excluding the ones already being worked (`input.active`).
|
|
50
|
+
* A union of the accepted output shapes because xstate's actor-logic type is invariant in its
|
|
51
|
+
* output — a `fromPromise` inferred as `T | null` must still assign. */
|
|
52
|
+
next:
|
|
53
|
+
| PromiseActorLogic<SourceNextResult<T>, { active: string[] }>
|
|
54
|
+
| PromiseActorLogic<{ item: T | null; open: number }, { active: string[] }>
|
|
55
|
+
| PromiseActorLogic<T | null, { active: string[] }>
|
|
56
|
+
| PromiseActorLogic<null, { active: string[] }>;
|
|
57
|
+
/** An external event def that wakes discovery early (webhook / `jr2 send` push seam). The pool
|
|
58
|
+
* serves it as a standing gate named "source"; the def IS the pool machine's vocabulary. */
|
|
59
|
+
wake?: EventDef;
|
|
60
|
+
/** Re-query cadence while parked, in ms — for sets that mutate underneath us. */
|
|
61
|
+
pollEvery?: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Declare a Source. Identity by design — the value IS the spec; this is the naming/typing seam. */
|
|
65
|
+
export function source<T>(spec: SourceSpec<T>): SourceSpec<T> {
|
|
66
|
+
return spec;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** How a pool run ends (its machine output). `items` maps itemId → the worker's output. */
|
|
70
|
+
export type PoolOutput = { status: "drained" | "deadlocked"; items: Record<string, unknown> };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How a pool is configured (ADR-0017, ADR-0033). `TInput` is what came through the pool's door:
|
|
74
|
+
* the PARSED schema when `input` declares one, and `unknown` when it does not — absence is
|
|
75
|
+
* permissive, so jr2 knows nothing and says so, exactly as `workspace()`'s door-less path does.
|
|
76
|
+
* A pool that is fed by something other than a caller may state what it is fed by annotating the
|
|
77
|
+
* mapper's parameter; for anything a caller starts, the honest fix is to declare `input`.
|
|
78
|
+
*/
|
|
79
|
+
export type PoolSpec<T, TInput = unknown> = {
|
|
80
|
+
/** The machine id (the workflow's name in the Console). Default "pool". */
|
|
81
|
+
id?: string;
|
|
82
|
+
/** The pool's OWN declared run input (ADR-0033) — what `cap` and `itemInput` read off the door,
|
|
83
|
+
* and what types their `input`. The WORKER's schema is deliberately not the door: workers are
|
|
84
|
+
* fed per-ITEM (the source item, or `itemInput`'s result), never the run body, so it describes
|
|
85
|
+
* nothing a starter sends. */
|
|
86
|
+
input?: z.ZodObject;
|
|
87
|
+
source: SourceSpec<T>;
|
|
88
|
+
/** Stable child identity: the spawn id, the active-set entry, the outcome key. */
|
|
89
|
+
itemId: (item: T) => string;
|
|
90
|
+
/** Max concurrent workers — a number, or derived from run input. Default 1. */
|
|
91
|
+
cap?: number | ((args: { input: TInput }) => number);
|
|
92
|
+
/** Map an item (+ run input) to the worker's input. Default: the item itself. */
|
|
93
|
+
itemInput?: (item: T, args: { input: TInput }) => unknown;
|
|
94
|
+
/** Terminal policy when the source runs dry and children settle. Only "final" exists today
|
|
95
|
+
* (jr semantics: runs END); a parking policy can be added when a workflow needs one. */
|
|
96
|
+
onDrained?: "final";
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What `pool()` returns: the door a run of it starts with, the pool's own output, and the
|
|
101
|
+
* wrapper's actor slots — `worker` holding the worker's own type, because the pool is
|
|
102
|
+
* TRANSPARENT to its worker (ADR-0049), so `customize(machine, { agents })` on a pool-rooted
|
|
103
|
+
* workflow offers the WORKER's Agents and the composer never spells `worker`. {@link JR2Wrapper} is
|
|
104
|
+
* what marks it a jr2 wrapper: the transparency follows the marker, not the slot's spelling.
|
|
105
|
+
*/
|
|
106
|
+
export type PoolMachine<TWorker extends AnyStateMachine, TInput> = StateMachine<
|
|
107
|
+
any,
|
|
108
|
+
any,
|
|
109
|
+
any,
|
|
110
|
+
WrapperActors<"worker", TWorker, "next" | "gate">,
|
|
111
|
+
any,
|
|
112
|
+
any,
|
|
113
|
+
any,
|
|
114
|
+
any,
|
|
115
|
+
any,
|
|
116
|
+
TInput,
|
|
117
|
+
PoolOutput,
|
|
118
|
+
any,
|
|
119
|
+
any,
|
|
120
|
+
any
|
|
121
|
+
> &
|
|
122
|
+
JR2Wrapper<TWorker>;
|
|
123
|
+
|
|
124
|
+
type PoolCtx = {
|
|
125
|
+
runInput: Record<string, unknown>;
|
|
126
|
+
cap: number;
|
|
127
|
+
/** Item ids currently being worked — the spawn ids of live workers, and `next`'s exclusion. */
|
|
128
|
+
active: string[];
|
|
129
|
+
/** Settled workers' outputs, by item id — the pool's output collects them. */
|
|
130
|
+
items: Record<string, unknown>;
|
|
131
|
+
status?: "drained" | "deadlocked";
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** A claimed item, normalized (see {@link SourceNextResult}). */
|
|
135
|
+
type Claim = { item: unknown | null; open: number };
|
|
136
|
+
|
|
137
|
+
function normalizeClaim(output: unknown): Claim {
|
|
138
|
+
if (
|
|
139
|
+
output !== null &&
|
|
140
|
+
typeof output === "object" &&
|
|
141
|
+
"item" in output &&
|
|
142
|
+
typeof (output as { open?: unknown }).open === "number"
|
|
143
|
+
) {
|
|
144
|
+
return output as Claim;
|
|
145
|
+
}
|
|
146
|
+
return { item: output, open: 0 };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build the pool machine (ADR-0017). Returns a plain machine — the usual workflow ROOT
|
|
151
|
+
* (`export const machine = pool(...)`), but nestable as a child like any other. Its vocabulary is
|
|
152
|
+
* the wake def alone: the worker keeps its own (ADR-0011, ADR-0049).
|
|
153
|
+
*/
|
|
154
|
+
export function pool<TWorker extends AnyStateMachine, TSchema extends z.ZodObject>(
|
|
155
|
+
worker: TWorker,
|
|
156
|
+
spec: PoolSpec<any, z.infer<TSchema>> & { input: TSchema },
|
|
157
|
+
): PoolMachine<TWorker, z.infer<TSchema>>;
|
|
158
|
+
export function pool<TWorker extends AnyStateMachine, TInput = unknown>(
|
|
159
|
+
worker: TWorker,
|
|
160
|
+
spec: PoolSpec<any, TInput> & { input?: never },
|
|
161
|
+
): PoolMachine<TWorker, TInput>;
|
|
162
|
+
export function pool(worker: AnyStateMachine, spec: PoolSpec<any, any>): AnyStateMachine {
|
|
163
|
+
const wake = spec.source.wake;
|
|
164
|
+
const claimOf = (event: unknown): Claim => normalizeClaim((event as { output: unknown }).output);
|
|
165
|
+
const itemIdOf = (event: unknown): string => spec.itemId(claimOf(event).item as never);
|
|
166
|
+
/** `xstate.done.actor.<childId>` → the settled worker's item id. */
|
|
167
|
+
const doneChildOf = (event: unknown): string =>
|
|
168
|
+
((event as { type: string }).type.match(/^xstate\.done\.actor\.(.+)$/) as RegExpMatchArray)[1]!;
|
|
169
|
+
|
|
170
|
+
const machine = setup({
|
|
171
|
+
actors: { worker, next: spec.source.next, gate },
|
|
172
|
+
}).createMachine({
|
|
173
|
+
id: spec.id ?? "pool",
|
|
174
|
+
context: ({ input }): PoolCtx => ({
|
|
175
|
+
runInput: (input ?? {}) as Record<string, unknown>,
|
|
176
|
+
cap: typeof spec.cap === "function" ? spec.cap({ input }) : (spec.cap ?? 1),
|
|
177
|
+
active: [],
|
|
178
|
+
items: {},
|
|
179
|
+
}),
|
|
180
|
+
// The wake seam: a standing gate for the whole run's life (root invokes stop only at final).
|
|
181
|
+
// The invoke id IS the gate's name (the id derives from the actor path — ADR-0011):
|
|
182
|
+
// `source` when the pool is the root, `<path>.source` nested — so two nested
|
|
183
|
+
// pools' wake gates cannot collide.
|
|
184
|
+
invoke: wake ? [{ id: "source", src: "gate", input: { accepts: [wake.name] } }] : [],
|
|
185
|
+
initial: "discovering",
|
|
186
|
+
on: {
|
|
187
|
+
// A worker settled, at any moment: collect its output, free its slot, stop the ref, and
|
|
188
|
+
// go look for more work. Internal to jr2 once — the consumer casts this used to force
|
|
189
|
+
// (`DoneActorEvent` unions, `assertEvent`) ship as documented patterns instead.
|
|
190
|
+
"xstate.done.actor.*": {
|
|
191
|
+
target: ".discovering",
|
|
192
|
+
actions: [
|
|
193
|
+
assign({
|
|
194
|
+
items: ({ context, event }) => ({
|
|
195
|
+
...(context as unknown as PoolCtx).items,
|
|
196
|
+
[doneChildOf(event)]: (event as unknown as { output: unknown }).output,
|
|
197
|
+
}),
|
|
198
|
+
active: ({ context, event }) =>
|
|
199
|
+
(context as unknown as PoolCtx).active.filter((id) => id !== doneChildOf(event)),
|
|
200
|
+
}),
|
|
201
|
+
stopChild(({ event }) => doneChildOf(event)),
|
|
202
|
+
],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
states: {
|
|
206
|
+
discovering: {
|
|
207
|
+
// Saturated: don't even query — park until a worker settles (which re-enters here).
|
|
208
|
+
always: [{ guard: ({ context }) => context.active.length >= (context as PoolCtx).cap, target: "saturated" }],
|
|
209
|
+
invoke: {
|
|
210
|
+
id: "discover",
|
|
211
|
+
src: "next",
|
|
212
|
+
input: ({ context }) => ({ active: (context as PoolCtx).active }),
|
|
213
|
+
onDone: [
|
|
214
|
+
{
|
|
215
|
+
// Claimed: record it, spawn its worker (TOP-LEVEL spawnChild — the visualize
|
|
216
|
+
// trace), and loop for more. `reenter` re-invokes the query actor.
|
|
217
|
+
guard: ({ event }) => claimOf(event).item !== null,
|
|
218
|
+
target: "discovering",
|
|
219
|
+
reenter: true,
|
|
220
|
+
actions: [
|
|
221
|
+
assign({
|
|
222
|
+
active: ({ context, event }) => [...(context as unknown as PoolCtx).active, itemIdOf(event)],
|
|
223
|
+
}),
|
|
224
|
+
spawnChild("worker", {
|
|
225
|
+
id: ({ event }) => itemIdOf(event),
|
|
226
|
+
input: ({ context, event }) => {
|
|
227
|
+
const item = claimOf(event).item;
|
|
228
|
+
const runInput = (context as unknown as PoolCtx).runInput;
|
|
229
|
+
return (spec.itemInput ? spec.itemInput(item as never, { input: runInput }) : item) as never;
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
// Nothing ready, workers live → healthy parking: their settling re-queries, as do
|
|
235
|
+
// the wake gate and the poll timer. This is jr's exit-3 "waiting" as a state.
|
|
236
|
+
{ guard: ({ context }) => (context as PoolCtx).active.length > 0, target: "parked" },
|
|
237
|
+
// Idle + items exist that will never become ready by themselves → jr's exit 2.
|
|
238
|
+
{
|
|
239
|
+
guard: ({ event }) => claimOf(event).open > 0,
|
|
240
|
+
target: "deadlocked",
|
|
241
|
+
actions: assign({ status: () => "deadlocked" as const }),
|
|
242
|
+
},
|
|
243
|
+
// Idle + nothing anywhere → done (jr exit 0).
|
|
244
|
+
{ target: "drained", actions: assign({ status: () => "drained" as const }) },
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
saturated: {
|
|
249
|
+
// Every slot full: only a settling worker (the root `xstate.done.actor.*`) moves us.
|
|
250
|
+
},
|
|
251
|
+
parked: {
|
|
252
|
+
// Waiting on the world: wake early on push, or re-query on the poll cadence.
|
|
253
|
+
on: wake ? { [wake.name]: "discovering" } : {},
|
|
254
|
+
after: spec.source.pollEvery ? { [spec.source.pollEvery]: { target: "discovering" } } : {},
|
|
255
|
+
},
|
|
256
|
+
drained: { type: "final" },
|
|
257
|
+
deadlocked: { type: "final" },
|
|
258
|
+
},
|
|
259
|
+
// Outcome-in-context (the documented cast-free idiom): the two finals stamped `status`.
|
|
260
|
+
output: ({ context }): PoolOutput => ({
|
|
261
|
+
status: (context as unknown as PoolCtx).status ?? "drained",
|
|
262
|
+
items: (context as unknown as PoolCtx).items,
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// The pool's vocabulary is the pool's OWN — the wake def and nothing else (ADR-0011,
|
|
267
|
+
// ADR-0049). The worker's names stay the worker's: its gates and menus resolve against the
|
|
268
|
+
// Machine that invoked them, which is the worker, so merging them up here would only make a
|
|
269
|
+
// same-name/different-payload pair between two workers a collision that per-Machine scoping
|
|
270
|
+
// had already avoided.
|
|
271
|
+
if (wake) attachVocabulary(machine, new Map([[wake.name, wake]]));
|
|
272
|
+
// The pool is TRANSPARENT to its worker (ADR-0049), exactly as `workspace()` is to its body: a
|
|
273
|
+
// `customize(machine, { agents })` on a pool-rooted workflow means the worker's Agents, which
|
|
274
|
+
// is the only Machine under a pool that carries any.
|
|
275
|
+
attachWrapperBody(machine, "worker");
|
|
276
|
+
// The run input does NOT propagate from the worker (ADR-0033): a wrapper declares its own door,
|
|
277
|
+
// because what it feeds its child is not what a caller sends. Here it is sharpest — the pool
|
|
278
|
+
// never passes the run body to a worker at all, workers get items — so propagating the worker's
|
|
279
|
+
// schema would demand fields at the door the pool never uses, and zod's unknown-key stripping
|
|
280
|
+
// would silently DROP the pool-level fields `cap`/`itemInput` actually read. `workspace` is the
|
|
281
|
+
// milder case of the same species. A pool with a contract declares `spec.input`.
|
|
282
|
+
if (spec.input) attachInputSchema(machine, spec.input);
|
|
283
|
+
return machine;
|
|
284
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// The internal registration table (ADR-0011): one structure behind both delivery dialects.
|
|
2
|
+
// An Agent slot and `gate` register `address → { accepted event defs, deliver closure, meta }`;
|
|
3
|
+
// the two HTTP surfaces (`/agents/:iid/*` for the Adapter, `/runs/:id/gates/*` for humans and
|
|
4
|
+
// webhooks) are adapters over it — lookup, schema validation, delivery, discovery, and lifecycle
|
|
5
|
+
// are implemented ONCE. The table is implementation structure, not vocabulary: workflows speak
|
|
6
|
+
// only `defineEvent` / `agent` / `gate`.
|
|
7
|
+
//
|
|
8
|
+
// The dialects are also the AUTHORIZATION boundary (ADR-0013), which is why an agent registration
|
|
9
|
+
// records its `sandbox`: a Sandbox token may deliver to `kind: "agent"` registrations whose Sandbox
|
|
10
|
+
// is its own, and to nothing else. Never a Gate — a compromised Agent must not be able to approve
|
|
11
|
+
// its own review — and never another Sandbox: `coding.ts`'s iids are derivable, so a run-scoped
|
|
12
|
+
// credential would let one feature's coder inject a verdict into another feature's reviewer.
|
|
13
|
+
//
|
|
14
|
+
// Run identity reaches every registration mechanically through the xstate actor `system`: the
|
|
15
|
+
// host binds each run's root system to a RunBinding at createActor time, and callback actors
|
|
16
|
+
// (which receive `system`, shared by every actor in the tree at any nesting depth) resolve it
|
|
17
|
+
// here. That is what makes gate ids run-scoped — `gate: "F-12"` in two concurrent runs cannot
|
|
18
|
+
// collide — with zero workflow plumbing. The WeakMap is rendezvous keyed by per-run object
|
|
19
|
+
// identity, not a swappable adapter (the distinction ADR-0011 draws for the demux).
|
|
20
|
+
//
|
|
21
|
+
// The binding carries run identity and host infrastructure and NO vocabulary: event names are
|
|
22
|
+
// scoped to the Machine that declared them, not to the run (ADR-0011, ADR-0049), so
|
|
23
|
+
// `resolveAccepts` reads them off the invoking Machine below and a nested Machine's names are
|
|
24
|
+
// never the root's problem.
|
|
25
|
+
|
|
26
|
+
import type { ActorSystem, AnyActorRef, AnyEventObject } from "xstate";
|
|
27
|
+
import type { EventDef } from "@jr2/agent-protocol";
|
|
28
|
+
import type { AgentAdmission } from "./actor.ts";
|
|
29
|
+
import type { SandboxPort } from "./workspace.ts";
|
|
30
|
+
import { invokingMachine, vocabularyOf } from "./vocabulary.ts";
|
|
31
|
+
|
|
32
|
+
/** xstate doesn't export its internal AnyActorSystem; this matches what actors receive. */
|
|
33
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
34
|
+
export type AnyActorSystem = ActorSystem<any>;
|
|
35
|
+
|
|
36
|
+
/** A workflow event as delivered into a Machine: the def's name as `type`, plus its payload. */
|
|
37
|
+
export type DeliveredEvent = { type: string } & Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
/** One live registration: a state's declared surface, addressable by one external caller. */
|
|
40
|
+
export type Registration = {
|
|
41
|
+
/** Table-wide unique address (see {@link gateAddress} / {@link agentAddress}). */
|
|
42
|
+
address: string;
|
|
43
|
+
runId: string;
|
|
44
|
+
/** Which dialect registered it — what `GET /runs/:id` lists as gates vs agent surfaces. */
|
|
45
|
+
kind: "gate" | "agent";
|
|
46
|
+
/** The caller-facing id within its dialect (the gate id, or the agent's instance id). */
|
|
47
|
+
id: string;
|
|
48
|
+
/** Accepted events, name→def — the validation scope AND the discovery listing. */
|
|
49
|
+
defs: Map<string, EventDef>;
|
|
50
|
+
/** Serializable caller/integration context (PR URL, title …) — rides the discovery listing. */
|
|
51
|
+
meta?: Record<string, unknown>;
|
|
52
|
+
/**
|
|
53
|
+
* The actor path below the run root ({@link actorPath}) — WHERE in the Machine this surface
|
|
54
|
+
* lives. Carried beside `id` because an authored gate id ("F-12") erases the path a derived id
|
|
55
|
+
* happens to spell, and a caller locating the invoking state (the Console's gate pin) must
|
|
56
|
+
* never parse ids. Gates always carry it; agent registrations don't need it today.
|
|
57
|
+
*/
|
|
58
|
+
path?: string[];
|
|
59
|
+
/**
|
|
60
|
+
* The Sandbox this agent runs in — the scope of the Sandbox token that may deliver here
|
|
61
|
+
* (ADR-0013). Absent on gates, and on a workspace-less Agent turn (the mechanics tier's stub
|
|
62
|
+
* Harness runs on the host, in no Sandbox at all): those are the Instance token's business.
|
|
63
|
+
*/
|
|
64
|
+
sandbox?: string;
|
|
65
|
+
/** Close over the invoking state's `sendBack`; delivery lands where the actor was invoked. */
|
|
66
|
+
deliver: (event: DeliveredEvent) => void;
|
|
67
|
+
/**
|
|
68
|
+
* The Machine that invoked this actor — `self._parent`, captured at registration. The menu was
|
|
69
|
+
* derived from THIS machine's transitions (ADR-0015), so it is the only snapshot whose guards
|
|
70
|
+
* can answer "would this event move anything" (see {@link wouldMove}). Absent on registrations
|
|
71
|
+
* that have no parent to name, which is why every read of it fails open.
|
|
72
|
+
*/
|
|
73
|
+
invoker?: AnyActorRef;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Would this exact event move the invoking Machine? The guard question the derived menu cannot ask
|
|
78
|
+
* for itself: `deriveMenus` reads transition KEYS, so an event whose every transition is guarded
|
|
79
|
+
* false is on the menu regardless (ADR-0029).
|
|
80
|
+
*
|
|
81
|
+
* This is the AUTHORITATIVE form — delivery has the validated payload, so payload-reading guards
|
|
82
|
+
* answer on real data. {@link mayMove} is the menu's weaker form.
|
|
83
|
+
*
|
|
84
|
+
* **Fails open**: no invoker, or a guard that throws, reads as `true`. The failure modes are not
|
|
85
|
+
* symmetric. A wrong `true` offers a tool that does nothing — today's behavior, and recoverable,
|
|
86
|
+
* because the receipt now says so. A wrong `false` tells an Agent its work was rejected when the
|
|
87
|
+
* workflow would have accepted it, and nothing recovers from that.
|
|
88
|
+
*
|
|
89
|
+
* A transition that neither targets nor acts (`on: { X: {} }`) reads as false, matching xstate's
|
|
90
|
+
* own `can()`. Correct: a handler that does nothing is not a handler.
|
|
91
|
+
*/
|
|
92
|
+
export function wouldMove(invoker: AnyActorRef | undefined, event: AnyEventObject): boolean {
|
|
93
|
+
if (!invoker) return true;
|
|
94
|
+
try {
|
|
95
|
+
return invoker.getSnapshot().can(event);
|
|
96
|
+
} catch {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* COULD this event move the invoking Machine, judged before the Agent has picked its arguments?
|
|
103
|
+
* What the surface build can ask (ADR-0029) — there is no payload yet, so a guard reading one
|
|
104
|
+
* would answer on `undefined` and report a false "no".
|
|
105
|
+
*
|
|
106
|
+
* So the probe watches. The event goes in behind a Proxy that records any read outside `type`; a
|
|
107
|
+
* `false` is trusted only when the guard never looked at the payload, and a guard that did look is
|
|
108
|
+
* offered anyway and settled exactly at delivery by {@link wouldMove}. That keeps the two failure
|
|
109
|
+
* modes where they belong: a context-only guard (`rounds > 0`) filters precisely, and a
|
|
110
|
+
* payload-only guard (`event.verdict === "approved"`) is never silently hidden from the Agent.
|
|
111
|
+
*
|
|
112
|
+
* Verified against the pinned xstate: guards receive the object handed to `can()`, unspread and
|
|
113
|
+
* unwrapped, so the trap sees exactly the guard's own reads. If an xstate bump ever broke that, the
|
|
114
|
+
* trap would simply see nothing and this would degrade to filtering slightly more — hence the test
|
|
115
|
+
* that pins a payload-reading guard STAYING on the menu.
|
|
116
|
+
*/
|
|
117
|
+
export function mayMove(invoker: AnyActorRef | undefined, type: string): boolean {
|
|
118
|
+
if (!invoker) return true;
|
|
119
|
+
let readPayload = false;
|
|
120
|
+
const watch = (prop: string | symbol): void => {
|
|
121
|
+
if (typeof prop === "string" && prop !== "type") readPayload = true;
|
|
122
|
+
};
|
|
123
|
+
const probe = new Proxy({ type } as Record<string, unknown>, {
|
|
124
|
+
get(target, prop, receiver) {
|
|
125
|
+
watch(prop);
|
|
126
|
+
return Reflect.get(target, prop, receiver);
|
|
127
|
+
},
|
|
128
|
+
has(target, prop) {
|
|
129
|
+
watch(prop);
|
|
130
|
+
return Reflect.has(target, prop);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
if (invoker.getSnapshot().can(probe as AnyEventObject)) return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return readPayload;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Delivery target absent (unknown address, settled run, exited state) — the one catch point. */
|
|
142
|
+
export class UnknownAddressError extends Error {}
|
|
143
|
+
/** Delivery body rejected (unaccepted name, or payload failing the named schema). */
|
|
144
|
+
export class EventValidationError extends Error {}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The actor path below the run's root: every id from the root's children down to `ref` itself,
|
|
148
|
+
* root-most first. The root is excluded because its id is generated per process — everything
|
|
149
|
+
* below it is author-named and stable across restore (ADR-0016). One walk for both address
|
|
150
|
+
* kinds: `mintIid` builds iids from it, `gate` derives default gate ids from it, so the two
|
|
151
|
+
* cannot drift.
|
|
152
|
+
*/
|
|
153
|
+
export function actorPath(ref: AnyActorRef): string[] {
|
|
154
|
+
const segments: string[] = [];
|
|
155
|
+
for (let r: AnyActorRef | undefined = ref; r?._parent; r = r._parent) segments.unshift(r.id);
|
|
156
|
+
return segments;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The address of a run's gate: gate ids are run-scoped by construction (ADR-0011). */
|
|
160
|
+
export function gateAddress(runId: string, gate: string): string {
|
|
161
|
+
return `gate/${runId}/${gate}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The address of an agent instance's surface (`/agents/:iid/*`): iids are globally unique. */
|
|
165
|
+
export function agentAddress(instanceId: string): string {
|
|
166
|
+
return `agent/${instanceId}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class RegistrationTable {
|
|
170
|
+
private readonly byAddress = new Map<string, Registration>();
|
|
171
|
+
|
|
172
|
+
/** Register a live surface; returns its disposer (actors call it from their stop cleanup). */
|
|
173
|
+
register(reg: Registration): () => void {
|
|
174
|
+
if (this.byAddress.has(reg.address)) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`registration address "${reg.address}" is already live (two concurrent registrations share an id)`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
this.byAddress.set(reg.address, reg);
|
|
180
|
+
return () => {
|
|
181
|
+
// Dispose only our own entry — a re-registration under the same address must not be
|
|
182
|
+
// clobbered by a stale disposer running late (dev reload, restore races).
|
|
183
|
+
if (this.byAddress.get(reg.address) === reg) this.byAddress.delete(reg.address);
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
lookup(address: string): Registration | undefined {
|
|
188
|
+
return this.byAddress.get(address);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** All live registrations for one run — the `GET /runs/:id` discovery listing. */
|
|
192
|
+
byRun(runId: string): Registration[] {
|
|
193
|
+
return [...this.byAddress.values()].filter((r) => r.runId === runId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validate and deliver one event to an address: the shared behavior both dialect adapters
|
|
198
|
+
* call. Unknown address / unaccepted name / bad payload throw typed errors the adapters map
|
|
199
|
+
* to their wire (404 / 400, MCP tool errors). The payload is parsed by the named def's
|
|
200
|
+
* schema, so what lands in the Machine is exactly the validated shape.
|
|
201
|
+
*/
|
|
202
|
+
deliver(address: string, type: string, payload: unknown): void {
|
|
203
|
+
const reg = this.byAddress.get(address);
|
|
204
|
+
if (!reg) throw new UnknownAddressError(`no live registration at "${address}"`);
|
|
205
|
+
const def = reg.defs.get(type);
|
|
206
|
+
if (!def) {
|
|
207
|
+
throw new EventValidationError(
|
|
208
|
+
`"${reg.id}" does not accept "${type}" (accepts: ${[...reg.defs.keys()].join(", ") || "nothing"})`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const parsed = def.input.safeParse(payload ?? {});
|
|
212
|
+
if (!parsed.success) {
|
|
213
|
+
throw new EventValidationError(`invalid "${type}" payload: ${parsed.error.message}`);
|
|
214
|
+
}
|
|
215
|
+
reg.deliver({ type: def.name, ...parsed.data });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** What a callback actor needs from its host: run identity, the table, and the host's ports. */
|
|
220
|
+
export type RunBinding = {
|
|
221
|
+
runId: string;
|
|
222
|
+
workflow: string;
|
|
223
|
+
table: RegistrationTable;
|
|
224
|
+
/** The host's Sandbox backend, when it has a cluster (`workspace()` resolves it here —
|
|
225
|
+
* one cluster per orchestrator instance, so it is host infrastructure like the table). */
|
|
226
|
+
sandbox?: SandboxPort;
|
|
227
|
+
/**
|
|
228
|
+
* The Instance Harness base URL (ADR-0031) — the deterministic Service DNS a
|
|
229
|
+
* `workspace: "none"` Turn is admitted at. Deployed instances derive it from their namespace;
|
|
230
|
+
* absent, a `"none"` Agent without an explicit `endpoint` fails loudly.
|
|
231
|
+
*/
|
|
232
|
+
instanceHarness?: string;
|
|
233
|
+
/**
|
|
234
|
+
* Record an Agent invocation's durable admission in the host ledger (ADR-0016): persisted
|
|
235
|
+
* beside the snapshot in the same RunBlob save, keyed by iid (globally unique, so the map is
|
|
236
|
+
* flat). Optional so a bare unit-test binding can omit it — then admissions simply are not
|
|
237
|
+
* durable.
|
|
238
|
+
*/
|
|
239
|
+
recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
|
|
240
|
+
/**
|
|
241
|
+
* Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
|
|
242
|
+
* as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
|
|
243
|
+
* open feed).
|
|
244
|
+
*/
|
|
245
|
+
telemetry?: (event: RetryTelemetry) => void;
|
|
246
|
+
/**
|
|
247
|
+
* Put a Turn marker on the run's feed (ADR-0023): an Agent turn reports its admission and its
|
|
248
|
+
* settlement pick, so the run's narrative can name what a Turn hosted elsewhere decided.
|
|
249
|
+
* Host-supplied; absent (bare unit-test bindings), turns leave no markers.
|
|
250
|
+
*/
|
|
251
|
+
marker?: (event: TurnMarker) => void;
|
|
252
|
+
/**
|
|
253
|
+
* Attach the run-narrative echo to a Workspace's Harness (ADR-0023): the host replays the
|
|
254
|
+
* run's feed-so-far to `endpoint`, then tees live; returns the detach. Called by `workspace()`'s
|
|
255
|
+
* registrar — the same restore-safe seat the ambient handles ride — and scoped to the OWNING
|
|
256
|
+
* run by construction: the binding is per-run, so a sibling run's feed is unreachable.
|
|
257
|
+
* Host-supplied; absent = no echo.
|
|
258
|
+
*/
|
|
259
|
+
echo?: (endpoint: string) => () => void;
|
|
260
|
+
/**
|
|
261
|
+
* The HOST is ending this run for its own reasons (ADR-0024). An Agent invocation ending
|
|
262
|
+
* normally ends the Agent's turn — the state stopped waiting — but `RunHost.stop()` is the one
|
|
263
|
+
* ending that must leave the durable submission alive, because ADR-0007's restore re-attaches
|
|
264
|
+
* to it. The host cannot be INFERRED (process shutdown stops no actors, and restore is a fresh
|
|
265
|
+
* process), so it says so here, before it stops the actor.
|
|
266
|
+
*/
|
|
267
|
+
hostStopping?: boolean;
|
|
268
|
+
/**
|
|
269
|
+
* iid → the abort still in flight for it (ADR-0024). The Agent actor fills this on the way out and
|
|
270
|
+
* waits on it before admitting, so an abort can never overtake the next turn on the same
|
|
271
|
+
* instance — flue QUEUES per instance, and an abort that lost that race would settle the new
|
|
272
|
+
* submission before it ran. Created on demand: the ordering must hold for ANY binding, not only
|
|
273
|
+
* one the host remembered to equip.
|
|
274
|
+
*/
|
|
275
|
+
pendingAborts?: Map<string, Promise<void>>;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** One absorbed-retry attempt (a no-signal nudge), as the run feed carries it. */
|
|
279
|
+
export type RetryTelemetry = { kind: "retry"; child: string; attempt: number; reason: string };
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* ADR-0023's Turn markers, as the run feed carries them: a Turn admitted on a Harness (the Agent
|
|
283
|
+
* and its framing), and the settlement pick that ended it. `endpoint` names the HOSTING Harness —
|
|
284
|
+
* what lets the echo tee keep "markers, not mirrors": a marker whose Turn ran AT the echo's own
|
|
285
|
+
* target is dropped there, because that pod's log already carries the whole transcript. Feed
|
|
286
|
+
* data of the Instance-token class (the prompt is working data): markers ride the per-run feed
|
|
287
|
+
* and the echo, never ADR-0014's open band.
|
|
288
|
+
*/
|
|
289
|
+
export type TurnMarker =
|
|
290
|
+
| { kind: "admission"; agent: string; endpoint: string; prompt: string }
|
|
291
|
+
| { kind: "pick"; agent: string; endpoint: string; event: string; payload?: Record<string, unknown> };
|
|
292
|
+
|
|
293
|
+
const bindings = new WeakMap<AnyActorSystem, RunBinding>();
|
|
294
|
+
|
|
295
|
+
/** Host-side: bind a run's actor system to its identity, before the actor starts. */
|
|
296
|
+
export function bindRun(system: AnyActorSystem, binding: RunBinding): void {
|
|
297
|
+
bindings.set(system, binding);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The bound run's id, or undefined outside a jr2 host — the SOFT read `jr2Setup`'s iid minting
|
|
301
|
+
* uses, so a machine stays constructible and provide()-testable with no host at all. */
|
|
302
|
+
export function boundRunId(system: AnyActorSystem): string | undefined {
|
|
303
|
+
return bindings.get(system)?.runId;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
|
|
307
|
+
export function runBindingOf(system: AnyActorSystem): RunBinding {
|
|
308
|
+
const binding = bindings.get(system);
|
|
309
|
+
if (!binding) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"no run binding for this actor system — gate and Agent slots only run under a jr2 RunHost " +
|
|
312
|
+
"(unit tests: bindRun(actor.system, …) before start)",
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return binding;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Resolve `accepts` names against the vocabulary of the Machine that INVOKED this actor
|
|
320
|
+
* (ADR-0011, ADR-0049). Names are local to their Machine — `coding`'s `approve` and `release`'s
|
|
321
|
+
* `approve` may carry different payloads, and one run may hold both — so the scope is
|
|
322
|
+
* `self._parent.logic`, never a run-wide set. An unlisted name fails at invoke time, naming the
|
|
323
|
+
* Machine and its declared set.
|
|
324
|
+
*/
|
|
325
|
+
export function resolveAccepts(self: AnyActorRef, accepts: readonly string[]): Map<string, EventDef> {
|
|
326
|
+
const machine = invokingMachine(self);
|
|
327
|
+
const vocabulary = (machine && vocabularyOf(machine)) ?? new Map<string, EventDef>();
|
|
328
|
+
const defs = new Map<string, EventDef>();
|
|
329
|
+
for (const name of accepts) {
|
|
330
|
+
const def = vocabulary.get(name);
|
|
331
|
+
if (!def) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`machine "${machine?.id ?? "(no invoking machine)"}" does not declare event "${name}" ` +
|
|
334
|
+
`(declared: ${[...vocabulary.keys()].join(", ") || "none — pass its def to jr2Setup({ events })"})`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
defs.set(name, def);
|
|
338
|
+
}
|
|
339
|
+
return defs;
|
|
340
|
+
}
|