@jr2/orchestrator 0.1.4 → 0.2.1
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/package.json +3 -3
- package/src/actor.ts +157 -62
- package/src/agent.ts +8 -7
- package/src/gate.ts +1 -1
- package/src/harness-client.ts +61 -14
- package/src/http.ts +8 -5
- package/src/instance.ts +13 -10
- package/src/registration.ts +48 -2
- package/src/run-host.ts +54 -11
- package/src/sandbox-kubectl.ts +19 -11
- package/src/server.ts +1 -12
- package/src/setup.ts +110 -35
- package/src/stub-harness.ts +21 -7
- package/src/tokens.ts +31 -1
- package/src/vocabulary.ts +2 -1
- package/src/workspace.ts +1 -1
package/src/instance.ts
CHANGED
|
@@ -29,7 +29,7 @@ import { RunHost } from "./run-host.ts";
|
|
|
29
29
|
import type { RunRecord } from "./run-host.ts";
|
|
30
30
|
import { SqliteSnapshotStore } from "./snapshot-store.ts";
|
|
31
31
|
import type { SnapshotStore } from "./snapshot-store.ts";
|
|
32
|
-
import { createAuthenticator, loadSigningKey, mintInstanceToken } from "./tokens.ts";
|
|
32
|
+
import { createAuthenticator, harnessToken, loadSigningKey, mintInstanceToken } from "./tokens.ts";
|
|
33
33
|
import type { SandboxPort } from "./workspace.ts";
|
|
34
34
|
|
|
35
35
|
export type InstanceOptions = {
|
|
@@ -113,19 +113,25 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
|
|
|
113
113
|
}
|
|
114
114
|
await store.init();
|
|
115
115
|
|
|
116
|
-
// Resolved BEFORE the host
|
|
117
|
-
//
|
|
116
|
+
// Resolved BEFORE the host, and the signing key with it: the host derives every Harness bearer
|
|
117
|
+
// from the key (ADR-0058) — admission, stream, abort, echo. The key is loaded from (or minted
|
|
118
|
+
// into) the instance folder, NOT generated per process: live Sandboxes outlive a restart, their
|
|
119
|
+
// Adapters still bear tokens this key signed, and their Harnesses still check bearers it derived.
|
|
120
|
+
// The Instance token is per-boot; the key is not. Both are served under in step 4 below.
|
|
118
121
|
const instanceToken = opts.instanceToken ?? mintInstanceToken();
|
|
122
|
+
const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
|
|
119
123
|
|
|
120
124
|
const host = new RunHost({
|
|
121
125
|
store,
|
|
122
126
|
reconcile: opts.reconcile,
|
|
123
127
|
sandbox: opts.sandbox,
|
|
124
128
|
instanceHarness: opts.instanceHarness,
|
|
129
|
+
harnessBearer: (placement) => harnessToken(signingKey, placement),
|
|
125
130
|
// The run-narrative echo (ADR-0023): tee a run's feed to its enclosing Workspace's Harness,
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
131
|
+
// bearing that Workspace's Harness bearer (ADR-0058) — never the Instance token, which would
|
|
132
|
+
// otherwise pass through a process the Agent executes code beside. The wire push is here and
|
|
133
|
+
// the fire-and-forget is the host's, so a Harness that refuses (or is gone) costs a log line.
|
|
134
|
+
echo: (endpoint, bearer) => createEchoPush({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
|
|
129
135
|
// A run left `live` to be retried is otherwise unexplained — the announce line names it, this
|
|
130
136
|
// says why (ADR-0030). stderr, because it is a fault, not the boot's structured result.
|
|
131
137
|
onRestoreError: (runId, err) =>
|
|
@@ -153,10 +159,7 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
|
|
|
153
159
|
// discoverable by asking after a specific run id nobody knows to ask about.
|
|
154
160
|
const restored = await host.restore();
|
|
155
161
|
|
|
156
|
-
// 4. Serve, authenticated (ADR-0013)
|
|
157
|
-
// instance folder, NOT generated per process: live Sandboxes outlive a restart, and their
|
|
158
|
-
// Adapters still bear tokens this key signed. The Instance token is per-boot; the key is not.
|
|
159
|
-
const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
|
|
162
|
+
// 4. Serve, authenticated (ADR-0013) — the token and key resolved above.
|
|
160
163
|
const auth = createAuthenticator({ instanceToken, signingKey });
|
|
161
164
|
// The Repos are read PER REQUEST, never snapshotted here: a cache the agent cloned minutes
|
|
162
165
|
// after boot must show as present the next time anyone asks (ADR-0048/0051).
|
package/src/registration.ts
CHANGED
|
@@ -156,6 +156,22 @@ export function actorPath(ref: AnyActorRef): string[] {
|
|
|
156
156
|
return segments;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The Instance ID of an Agent's ONE continued conversation in one Machine instance (ADR-0057):
|
|
161
|
+
* `<runId>/<machine actor path>/<agent>`, which is also its epoch-0 spelling. `machine` is the
|
|
162
|
+
* actor of the INVOKING Machine — the invoke's own leaf id is deliberately absent, because every
|
|
163
|
+
* state of one Machine that says `continue` means the same conversation, and two Machine
|
|
164
|
+
* instances (two Pool children, whose ids are the items') can never meet.
|
|
165
|
+
*
|
|
166
|
+
* One derivation, two readers, so they cannot drift: `mintIid` builds every iid on it (a fresh
|
|
167
|
+
* Turn adds a random suffix, a burned conversation's successor its epoch), and the Agent actor
|
|
168
|
+
* names the conversation it burns with it.
|
|
169
|
+
*/
|
|
170
|
+
export function continuedIid(machine: AnyActorRef | undefined, runId: string, agentName: string): string {
|
|
171
|
+
const path = (machine ? actorPath(machine).join(".") : "") || "root";
|
|
172
|
+
return `${runId}/${path}/${agentName}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
/** The address of a run's gate: gate ids are run-scoped by construction (ADR-0011). */
|
|
160
176
|
export function gateAddress(runId: string, gate: string): string {
|
|
161
177
|
return `gate/${runId}/${gate}`;
|
|
@@ -230,6 +246,13 @@ export type RunBinding = {
|
|
|
230
246
|
* absent, a `"none"` Agent without an explicit `endpoint` fails loudly.
|
|
231
247
|
*/
|
|
232
248
|
instanceHarness?: string;
|
|
249
|
+
/**
|
|
250
|
+
* The Harness bearer for a placement (ADR-0058): `harnessToken(signingKey, placement)`, closed
|
|
251
|
+
* over the key by the host. The Agent actor asks it for the pod its Turn resolved to; the echo
|
|
252
|
+
* attach asks it for the Workspace's. Absent (bare unit-test bindings, the stub path), no bearer
|
|
253
|
+
* is sent — which only a Harness that checks nothing accepts.
|
|
254
|
+
*/
|
|
255
|
+
harnessBearer?: (placement: string) => string;
|
|
233
256
|
/**
|
|
234
257
|
* Record an Agent invocation's durable admission in the host ledger (ADR-0016): persisted
|
|
235
258
|
* beside the snapshot in the same RunBlob save, keyed by iid (globally unique, so the map is
|
|
@@ -237,6 +260,22 @@ export type RunBinding = {
|
|
|
237
260
|
* durable.
|
|
238
261
|
*/
|
|
239
262
|
recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
|
|
263
|
+
/**
|
|
264
|
+
* The epoch ledger's READ half (ADR-0057): the current epoch of a continued conversation,
|
|
265
|
+
* keyed by its base Instance ID ({@link continuedIid}). The input mapper reads it AT MINT TIME,
|
|
266
|
+
* so the minted id persists with the input — a restore re-attaches the same conversation, and
|
|
267
|
+
* only a fresh transition (which re-runs the mapper) sees a bump. Optional: a bare unit-test
|
|
268
|
+
* binding has no burned conversations, so every conversation reads epoch 0.
|
|
269
|
+
*/
|
|
270
|
+
epochOf?: (conversation: string) => number;
|
|
271
|
+
/**
|
|
272
|
+
* The epoch ledger's WRITE half (ADR-0057): the terminal `agent.fault` burns the conversation
|
|
273
|
+
* it ended, and the next `continue` on that Agent mints `<conversation>/<epoch>` — a virgin
|
|
274
|
+
* conversation. No fault leaves a conversation worth continuing (ADR-0035): a runaway either
|
|
275
|
+
* had its reroll or holds a poisoned context, and infra and no-signal mean the Harness that
|
|
276
|
+
* held it is unreachable. Persisted beside the snapshot with the admission ledger.
|
|
277
|
+
*/
|
|
278
|
+
bumpEpoch?: (conversation: string) => void;
|
|
240
279
|
/**
|
|
241
280
|
* Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
|
|
242
281
|
* as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
|
|
@@ -251,12 +290,13 @@ export type RunBinding = {
|
|
|
251
290
|
marker?: (event: TurnMarker) => void;
|
|
252
291
|
/**
|
|
253
292
|
* Attach the run-narrative echo to a Workspace's Harness (ADR-0023): the host replays the
|
|
254
|
-
* run's feed-so-far to `endpoint
|
|
293
|
+
* run's feed-so-far to `endpoint` — the Harness in `placement`'s pod, pushed with that
|
|
294
|
+
* placement's bearer (ADR-0058) — then tees live; returns the detach. Called by `workspace()`'s
|
|
255
295
|
* registrar — the same restore-safe seat the ambient handles ride — and scoped to the OWNING
|
|
256
296
|
* run by construction: the binding is per-run, so a sibling run's feed is unreachable.
|
|
257
297
|
* Host-supplied; absent = no echo.
|
|
258
298
|
*/
|
|
259
|
-
echo?: (endpoint: string) => () => void;
|
|
299
|
+
echo?: (endpoint: string, placement: string) => () => void;
|
|
260
300
|
/**
|
|
261
301
|
* The HOST is ending this run for its own reasons (ADR-0024). An Agent invocation ending
|
|
262
302
|
* normally ends the Agent's turn — the state stopped waiting — but `RunHost.stop()` is the one
|
|
@@ -303,6 +343,12 @@ export function boundRunId(system: AnyActorSystem): string | undefined {
|
|
|
303
343
|
return bindings.get(system)?.runId;
|
|
304
344
|
}
|
|
305
345
|
|
|
346
|
+
/** The epoch of a continued conversation, or 0 outside a jr2 host (and for one nothing has
|
|
347
|
+
* burned) — the SOFT read `jr2Setup`'s iid minting uses, beside {@link boundRunId}. */
|
|
348
|
+
export function boundEpoch(system: AnyActorSystem, conversation: string): number {
|
|
349
|
+
return bindings.get(system)?.epochOf?.(conversation) ?? 0;
|
|
350
|
+
}
|
|
351
|
+
|
|
306
352
|
/** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
|
|
307
353
|
export function runBindingOf(system: AnyActorSystem): RunBinding {
|
|
308
354
|
const binding = bindings.get(system);
|
package/src/run-host.ts
CHANGED
|
@@ -312,13 +312,18 @@ export type RunHostOptions = {
|
|
|
312
312
|
sandbox?: SandboxPort;
|
|
313
313
|
/** The Instance Harness base URL (ADR-0031) — where `workspace: "none"` Turns are admitted. */
|
|
314
314
|
instanceHarness?: string;
|
|
315
|
+
/** The Harness bearer for a placement (ADR-0058) — `startInstance` closes `harnessToken` over the
|
|
316
|
+
* signing key. Handed to every run's binding, and to the echo pusher below. Absent, no Harness
|
|
317
|
+
* call bears one: only the stub Harness answers such a host. */
|
|
318
|
+
harnessBearer?: (placement: string) => string;
|
|
315
319
|
/**
|
|
316
320
|
* Build the run-narrative echo pusher for one Workspace's Harness (ADR-0023) — the seam a fake
|
|
317
|
-
* echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts)
|
|
318
|
-
*
|
|
319
|
-
* the echo is a courtesy view of the feed,
|
|
321
|
+
* echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts).
|
|
322
|
+
* `bearer` is that Workspace's Harness bearer (ADR-0058), when the host derives one. Absent = no
|
|
323
|
+
* echo: a host without it runs identically, because the echo is a courtesy view of the feed,
|
|
324
|
+
* never a dependency of the run.
|
|
320
325
|
*/
|
|
321
|
-
echo?: (endpoint: string) => (events: EchoEvent[]) => Promise<void>;
|
|
326
|
+
echo?: (endpoint: string, bearer: string | undefined) => (events: EchoEvent[]) => Promise<void>;
|
|
322
327
|
};
|
|
323
328
|
|
|
324
329
|
/** What we persist per run: the machine snapshot wrapped with the run metadata restore needs.
|
|
@@ -334,6 +339,19 @@ type RunBlob = {
|
|
|
334
339
|
machine?: string;
|
|
335
340
|
snapshot: unknown;
|
|
336
341
|
agents?: Record<string, AgentAdmission>;
|
|
342
|
+
/**
|
|
343
|
+
* The EPOCH ledger (ADR-0057): a continued conversation's base Instance ID
|
|
344
|
+
* (`<runId>/<machine actor path>/<agent>`) → the epoch the next `continue` on it lands on. An
|
|
345
|
+
* entry appears only once a terminal `agent.fault` has burned that conversation; everything
|
|
346
|
+
* else reads 0, which is the unsuffixed id itself.
|
|
347
|
+
*
|
|
348
|
+
* It rides THIS blob rather than a store of its own for the same reasons `agents` does: it is
|
|
349
|
+
* per-run state with the run's exact lifetime, and saving it in the same write gives it the
|
|
350
|
+
* snapshot's atomicity — a restore that re-attached a Turn but forgot which conversations were
|
|
351
|
+
* burned would hand the next `continue` a dead one. Keyed by the BASE id (never the minted
|
|
352
|
+
* `<id>/<epoch>`), so one conversation has exactly one counter however often it burns.
|
|
353
|
+
*/
|
|
354
|
+
epochs?: Record<string, number>;
|
|
337
355
|
fault?: string;
|
|
338
356
|
};
|
|
339
357
|
|
|
@@ -346,6 +364,8 @@ type LiveRun = {
|
|
|
346
364
|
binding: RunBinding;
|
|
347
365
|
/** The live admission ledger (ADR-0016): persisted as `RunBlob.agents`, seeded on restore. */
|
|
348
366
|
agents: Record<string, AgentAdmission>;
|
|
367
|
+
/** The live epoch ledger (ADR-0057): persisted as `RunBlob.epochs`, seeded on restore. */
|
|
368
|
+
epochs: Record<string, number>;
|
|
349
369
|
/** The error that killed the run, if it errored (xstate serializes Error to `{}`, so the
|
|
350
370
|
* message is captured here at the observer and persisted onto the blob for `read`). */
|
|
351
371
|
fault?: string;
|
|
@@ -368,7 +388,11 @@ export class RunHost {
|
|
|
368
388
|
private readonly onRestoreError?: (runId: string, err: unknown) => void;
|
|
369
389
|
private readonly sandbox?: SandboxPort;
|
|
370
390
|
private readonly instanceHarness?: string;
|
|
371
|
-
private readonly
|
|
391
|
+
private readonly harnessBearer?: (placement: string) => string;
|
|
392
|
+
private readonly echoFactory?: (
|
|
393
|
+
endpoint: string,
|
|
394
|
+
bearer: string | undefined,
|
|
395
|
+
) => (events: EchoEvent[]) => Promise<void>;
|
|
372
396
|
private readonly workflowDefs = new Map<string, WorkflowDef>();
|
|
373
397
|
private readonly runs = new Map<string, LiveRun>();
|
|
374
398
|
/**
|
|
@@ -386,6 +410,7 @@ export class RunHost {
|
|
|
386
410
|
this.onRestoreError = opts.onRestoreError;
|
|
387
411
|
this.sandbox = opts.sandbox;
|
|
388
412
|
this.instanceHarness = opts.instanceHarness;
|
|
413
|
+
this.harnessBearer = opts.harnessBearer;
|
|
389
414
|
this.echoFactory = opts.echo;
|
|
390
415
|
}
|
|
391
416
|
|
|
@@ -534,6 +559,9 @@ export class RunHost {
|
|
|
534
559
|
record,
|
|
535
560
|
def,
|
|
536
561
|
agents,
|
|
562
|
+
// The burned conversations come back with the admissions (ADR-0057): a restored run's
|
|
563
|
+
// next `continue` must land where this run's last one did, not on a dead conversation.
|
|
564
|
+
blob.epochs ?? {},
|
|
537
565
|
);
|
|
538
566
|
actor.start();
|
|
539
567
|
reattached.push(stored.runId);
|
|
@@ -608,7 +636,7 @@ export class RunHost {
|
|
|
608
636
|
// `sendBack` synchronously, so a pick that moved the Machine out of that state has already
|
|
609
637
|
// destroyed this registration by now.
|
|
610
638
|
//
|
|
611
|
-
// IDENTITY, not existence: under `
|
|
639
|
+
// IDENTITY, not existence: under `continue: true` the next state re-registers the SAME
|
|
612
640
|
// address for its own turn, and that is a new turn — this one still ended.
|
|
613
641
|
return {
|
|
614
642
|
delivered: true,
|
|
@@ -865,11 +893,11 @@ export class RunHost {
|
|
|
865
893
|
* Scoped to the OWNING run's lineage by construction: it reads one run's buffer and listeners
|
|
866
894
|
* and nothing else — a sibling run's events cannot reach this endpoint through here.
|
|
867
895
|
*/
|
|
868
|
-
private attachEcho(runId: string, endpoint: string): () => void {
|
|
896
|
+
private attachEcho(runId: string, endpoint: string, placement: string): () => void {
|
|
869
897
|
const run = this.runs.get(runId);
|
|
870
898
|
const factory = this.echoFactory;
|
|
871
899
|
if (!run || !factory) return () => {};
|
|
872
|
-
const push = factory(endpoint);
|
|
900
|
+
const push = factory(endpoint, this.harnessBearer?.(placement));
|
|
873
901
|
let chain = Promise.resolve();
|
|
874
902
|
let reported = false;
|
|
875
903
|
const enqueue = (events: EchoEvent[]): void => {
|
|
@@ -929,6 +957,7 @@ export class RunHost {
|
|
|
929
957
|
record: RunRecord,
|
|
930
958
|
def: WorkflowDef,
|
|
931
959
|
agents: Record<string, AgentAdmission> = {},
|
|
960
|
+
epochs: Record<string, number> = {},
|
|
932
961
|
): AnyActor {
|
|
933
962
|
let live: LiveRun | undefined;
|
|
934
963
|
let scheduled = false;
|
|
@@ -946,6 +975,7 @@ export class RunHost {
|
|
|
946
975
|
table: this.table,
|
|
947
976
|
sandbox: this.sandbox,
|
|
948
977
|
instanceHarness: this.instanceHarness,
|
|
978
|
+
...(this.harnessBearer ? { harnessBearer: this.harnessBearer } : {}),
|
|
949
979
|
// The admission ledger's write half (ADR-0016): the Agent actor reports the durable handle the
|
|
950
980
|
// moment the Harness admits it, and the ledger hits the store in the same RunBlob save. An
|
|
951
981
|
// admission arriving around stop/untrack still lands in `agents` but skips the save,
|
|
@@ -955,6 +985,17 @@ export class RunHost {
|
|
|
955
985
|
const run = this.runs.get(record.runId);
|
|
956
986
|
if (run && run.agents === agents) this.persist(run);
|
|
957
987
|
},
|
|
988
|
+
// The epoch ledger's two halves (ADR-0057). The read is what the input mapper mints from;
|
|
989
|
+
// the write is the terminal `agent.fault` burning the conversation it ended, saved in the
|
|
990
|
+
// same RunBlob as the admission ledger — and, like it, saved AT THE WRITE rather than
|
|
991
|
+
// waiting for the next transition, because a crash between the fault and the next state's
|
|
992
|
+
// invoke would otherwise restore into the burned conversation.
|
|
993
|
+
epochOf: (conversation) => epochs[conversation] ?? 0,
|
|
994
|
+
bumpEpoch: (conversation) => {
|
|
995
|
+
epochs[conversation] = (epochs[conversation] ?? 0) + 1;
|
|
996
|
+
const run = this.runs.get(record.runId);
|
|
997
|
+
if (run && run.epochs === epochs) this.persist(run);
|
|
998
|
+
},
|
|
958
999
|
// Absorbed-retry attempts go straight to the run's observers (SSE/CLI watch) — they are
|
|
959
1000
|
// feed events, not machine events (ADR-0016: the workflow sees only the terminal fault).
|
|
960
1001
|
telemetry: (event) => {
|
|
@@ -969,7 +1010,7 @@ export class RunHost {
|
|
|
969
1010
|
if (run && run.binding === binding) this.feed(run, event);
|
|
970
1011
|
},
|
|
971
1012
|
// The run-narrative echo attach (ADR-0023), called from `workspace()`'s registrar.
|
|
972
|
-
echo: (endpoint) => this.attachEcho(record.runId, endpoint),
|
|
1013
|
+
echo: (endpoint, placement) => this.attachEcho(record.runId, endpoint, placement),
|
|
973
1014
|
};
|
|
974
1015
|
let bound = false;
|
|
975
1016
|
const actor = createActor(machine, {
|
|
@@ -1002,7 +1043,7 @@ export class RunHost {
|
|
|
1002
1043
|
if (ev.type === "@xstate.snapshot") schedule();
|
|
1003
1044
|
},
|
|
1004
1045
|
});
|
|
1005
|
-
live = this.track(record, actor, def, agents, binding);
|
|
1046
|
+
live = this.track(record, actor, def, agents, epochs, binding);
|
|
1006
1047
|
return actor;
|
|
1007
1048
|
}
|
|
1008
1049
|
|
|
@@ -1011,9 +1052,10 @@ export class RunHost {
|
|
|
1011
1052
|
actor: AnyActor,
|
|
1012
1053
|
def: WorkflowDef,
|
|
1013
1054
|
agents: Record<string, AgentAdmission>,
|
|
1055
|
+
epochs: Record<string, number>,
|
|
1014
1056
|
binding: RunBinding,
|
|
1015
1057
|
): LiveRun {
|
|
1016
|
-
const run: LiveRun = { record, actor, def, agents, binding, listeners: new Set(), feedSoFar: [] };
|
|
1058
|
+
const run: LiveRun = { record, actor, def, agents, epochs, binding, listeners: new Set(), feedSoFar: [] };
|
|
1017
1059
|
this.runs.set(record.runId, run);
|
|
1018
1060
|
// Ordinary persistence rides the inspection stream (see `spawn`); the subscription exists
|
|
1019
1061
|
// for the ERROR channel: an errored actor (an invoke threw — e.g. ADR-0011's invoke-time
|
|
@@ -1068,6 +1110,7 @@ export class RunHost {
|
|
|
1068
1110
|
machine: fingerprintOf(run.def.machine),
|
|
1069
1111
|
snapshot: serialized,
|
|
1070
1112
|
agents: run.agents,
|
|
1113
|
+
epochs: run.epochs,
|
|
1071
1114
|
fault: run.fault,
|
|
1072
1115
|
};
|
|
1073
1116
|
const status = terminal ?? (machineStatus === "active" ? "live" : machineStatus);
|
package/src/sandbox-kubectl.ts
CHANGED
|
@@ -69,7 +69,7 @@ import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs }
|
|
|
69
69
|
import { CA_CONFIGMAP, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
|
|
70
70
|
import { repoIdentity } from "./repo-identity.ts";
|
|
71
71
|
import type { RepoResources } from "./repos.ts";
|
|
72
|
-
import { sandboxToken } from "./tokens.ts";
|
|
72
|
+
import { harnessTokenDigest, sandboxToken } from "./tokens.ts";
|
|
73
73
|
import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
|
|
74
74
|
|
|
75
75
|
/** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
|
|
@@ -423,15 +423,23 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
423
423
|
];
|
|
424
424
|
|
|
425
425
|
// The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
|
|
426
|
-
// config) first, then the mechanism-owned vars (the Adapter address, the CA trust path
|
|
427
|
-
// win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom
|
|
428
|
-
// container only — never on the Adapter, whose env is minted here and
|
|
429
|
-
// credential.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
]
|
|
426
|
+
// config) first, then the mechanism-owned vars (the Adapter address, the CA trust path, the
|
|
427
|
+
// wire's gate), which win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom
|
|
428
|
+
// land on the HARNESS container only — never on the Adapter, whose env is minted here and
|
|
429
|
+
// carries the pod's only credential.
|
|
430
|
+
//
|
|
431
|
+
// The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
|
|
432
|
+
// Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
|
|
433
|
+
// same name must not be able to choose the Harness's credential.
|
|
434
|
+
const harnessEnv = (name: string): HarnessEnvVar[] => {
|
|
435
|
+
if (!opts.signingKey) throw new Error("kubectlSandbox: a Harness needs a signingKey to check its bearer");
|
|
436
|
+
return [
|
|
437
|
+
...(opts.env ?? []),
|
|
438
|
+
{ name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
|
|
439
|
+
...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
|
|
440
|
+
{ name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
|
|
441
|
+
];
|
|
442
|
+
};
|
|
435
443
|
|
|
436
444
|
/**
|
|
437
445
|
* The two init steps, in order (ADR-0037). They are plain container fragments the operator
|
|
@@ -573,7 +581,7 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
|
|
|
573
581
|
// this used to carry was unreachable. The seat's own vars (the fallback `HOME`) come
|
|
574
582
|
// FIRST, so the instance's `harness.env` can still override them the way it overrides
|
|
575
583
|
// anything the image set.
|
|
576
|
-
env: [...seat.env, ...harnessEnv()],
|
|
584
|
+
env: [...seat.env, ...harnessEnv(req.name)],
|
|
577
585
|
...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
|
|
578
586
|
// What the AGENT gets: an address on its own loopback, and no credential anywhere. This is
|
|
579
587
|
// the only thing in the pod that tells it how to reach its Machine (ADR-0013).
|
package/src/server.ts
CHANGED
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
// previous deploy bound — an Instance that drops its last `workspace()` leaves `jr2 gc` able to
|
|
26
26
|
// collect what it stopped using.
|
|
27
27
|
|
|
28
|
-
import { createHash } from "node:crypto";
|
|
29
28
|
import { join } from "node:path";
|
|
30
29
|
import { loadConfig } from "./config.ts";
|
|
31
30
|
import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
|
|
@@ -70,10 +69,7 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
70
69
|
// by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013).
|
|
71
70
|
const namespace = env.JR2_NAMESPACE;
|
|
72
71
|
|
|
73
|
-
//
|
|
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.
|
|
72
|
+
// From the instance Secret when deployed; per-boot for a host-booted fixture.
|
|
77
73
|
const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
|
|
78
74
|
|
|
79
75
|
// The data-plane switch (ADR-0012/0031/0051): a registered Machine COMPOSES a Sandbox — read off
|
|
@@ -124,13 +120,6 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
|
|
|
124
120
|
name: "JR2_HARNESS_JSON",
|
|
125
121
|
valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
|
|
126
122
|
},
|
|
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
123
|
...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
|
|
135
124
|
],
|
|
136
125
|
envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
|
package/src/setup.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { eventMap, type EventDef, type EventFrom } from "@jr2/agent-protocol";
|
|
|
46
46
|
import type { AgentRunInput, AgentTurnInput, FaultTelemetry } from "./actor.ts";
|
|
47
47
|
import { isAgent } from "./agent.ts";
|
|
48
48
|
import { gate } from "./gate.ts";
|
|
49
|
-
import {
|
|
49
|
+
import { boundEpoch, boundRunId, continuedIid } from "./registration.ts";
|
|
50
50
|
import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -264,7 +264,9 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
|
|
|
264
264
|
const wrapOne = (inv: LooseInvoke): LooseInvoke => {
|
|
265
265
|
// The slot key IS the Agent name (ADR-0049) — read off the declaration, never authored.
|
|
266
266
|
if (typeof inv?.src === "string" && isAgent(actors[inv.src])) {
|
|
267
|
-
|
|
267
|
+
const derived = pick(names, "agent");
|
|
268
|
+
if (derived.length === 0) refuseBuriedPicks(node, inv.src, path, pick);
|
|
269
|
+
return { ...inv, input: wrapAgentInput(inv.input, derived, inv.src) };
|
|
268
270
|
}
|
|
269
271
|
if (inv?.src === "gate") {
|
|
270
272
|
const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, pick(names, "external")) };
|
|
@@ -291,32 +293,106 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
|
|
|
291
293
|
return walk(config as LooseState, new Set(), []);
|
|
292
294
|
}
|
|
293
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Refuse the one shape that LOOKS like a Menu and is not one (ADR-0015, ADR-0057): an Agent
|
|
298
|
+
* invoked on a state whose picks are written in its SUBSTATES.
|
|
299
|
+
*
|
|
300
|
+
* A Menu derives from the invoking state's own + ANCESTOR transitions, per statechart semantics,
|
|
301
|
+
* so a pick written below that state is one the Turn is never offered — the model is handed a
|
|
302
|
+
* Menu it cannot end its turn with, and the actor reads an empty Menu as a Turn that ended as
|
|
303
|
+
* intended, so nothing nudges and nothing faults: the run parks. Until ADR-0057 the `tools:`
|
|
304
|
+
* override papered over it; with the override gone the kit says it here, at build, where `jr2 up`
|
|
305
|
+
* walks every registered Machine.
|
|
306
|
+
*
|
|
307
|
+
* An EMPTY Menu is not itself wrong — a state moved by a Gate or a timer asks its Agent for text
|
|
308
|
+
* and nothing else — so only the contradiction is refused: no Menu here, and picks below.
|
|
309
|
+
*/
|
|
310
|
+
function refuseBuriedPicks(
|
|
311
|
+
node: LooseState,
|
|
312
|
+
agentName: string,
|
|
313
|
+
path: readonly string[],
|
|
314
|
+
pick: (names: Set<string>, kind: "agent" | "external") => string[],
|
|
315
|
+
): void {
|
|
316
|
+
const below = new Set<string>();
|
|
317
|
+
const descend = (n: LooseState): void => {
|
|
318
|
+
for (const key of Object.keys(n.on ?? {})) if (!key.includes(".") && key !== "*") below.add(key);
|
|
319
|
+
for (const child of Object.values(n.states ?? {})) descend(child);
|
|
320
|
+
};
|
|
321
|
+
for (const child of Object.values(node.states ?? {})) descend(child);
|
|
322
|
+
|
|
323
|
+
const buried = pick(below, "agent");
|
|
324
|
+
if (buried.length === 0) return;
|
|
325
|
+
const state = path.length ? path.join(".") : "(the machine root)";
|
|
326
|
+
throw new Error(
|
|
327
|
+
`agent "${agentName}" is invoked on state "${state}", which derives an EMPTY Menu while the ` +
|
|
328
|
+
`states BELOW it handle ${buried.join(", ")} — a Menu is the invoking state's own plus its ` +
|
|
329
|
+
`ancestors' transitions (ADR-0015), so a pick written below is one the Turn can never be ` +
|
|
330
|
+
`offered, and a Turn with no Menu ends with no pick at all. Move it onto "${state}" (guard ` +
|
|
331
|
+
`it if it must not always be offered — ADR-0029), or, if this Turn is deliberately ` +
|
|
332
|
+
`menu-less, the pick belongs to another invoke.`,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
294
336
|
const resolveInput = (orig: unknown, args: InputArgs): Record<string, unknown> =>
|
|
295
337
|
(typeof orig === "function" ? (orig as (a: InputArgs) => unknown)(args) : (orig ?? {})) as Record<string, unknown>;
|
|
296
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Every key a Turn's input may carry (ADR-0057): the Frame (`prompt`, `cwd`), the Dials (`model`,
|
|
341
|
+
* `thinkingLevel`), `continue`, and the Placement seat the stub tier states (`endpoint`,
|
|
342
|
+
* `sandbox`). Anything else is refused at RUNTIME, because no type can catch it: an invoke's input
|
|
343
|
+
* is a function returning a union, and the conditional spreads authors write inside it defeat
|
|
344
|
+
* excess-property checking — so a misspelled `continue` or a key from some other surface would
|
|
345
|
+
* compile and then silently take a fresh conversation every Turn, the class of quiet wrong
|
|
346
|
+
* behavior ADR-0057 was written to end.
|
|
347
|
+
*/
|
|
348
|
+
const TURN_INPUT_KEYS: ReadonlySet<string> = new Set([
|
|
349
|
+
"prompt",
|
|
350
|
+
"cwd",
|
|
351
|
+
"continue",
|
|
352
|
+
"model",
|
|
353
|
+
"thinkingLevel",
|
|
354
|
+
"endpoint",
|
|
355
|
+
"sandbox",
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
/** Refuse a Turn whose input carries a key a Turn has no use for, before anything is minted or
|
|
359
|
+
* admitted. The message names the slot, the keys found, and the whole surface (ADR-0057). */
|
|
360
|
+
function refuseUnknownKeys(consumer: object, agentName: string): void {
|
|
361
|
+
const unknown = Object.keys(consumer).filter((key) => !TURN_INPUT_KEYS.has(key));
|
|
362
|
+
if (unknown.length === 0) return;
|
|
363
|
+
throw new Error(
|
|
364
|
+
`agent "${agentName}": the Turn's input carries ${unknown.map((k) => `\`${k}\``).join(", ")}, which a ` +
|
|
365
|
+
`Turn has no use for — its input is its Frame (\`prompt\`, \`cwd\`), its Dials (\`model\`, ` +
|
|
366
|
+
`\`thinkingLevel\`), and \`continue\` (ADR-0057)`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
297
370
|
/** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the derived menu, and
|
|
298
371
|
* finalize the mechanism fields. */
|
|
299
372
|
function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
|
|
300
373
|
return (args: InputArgs): AgentRunInput => {
|
|
301
374
|
const consumer = resolveInput(orig, args) as Partial<AgentTurnInput & AgentRunInput>;
|
|
375
|
+
refuseUnknownKeys(consumer, agentName);
|
|
302
376
|
return {
|
|
303
377
|
agentName,
|
|
304
|
-
|
|
378
|
+
// jr2 mints every id (ADR-0016, ADR-0057): the Turn's input carries no id to honor, so
|
|
379
|
+
// nothing a Machine writes can address a conversation jr2 did not derive.
|
|
380
|
+
instanceId: mintIid(consumer, agentName, args.self),
|
|
305
381
|
endpoint: consumer.endpoint,
|
|
306
382
|
sandbox: consumer.sandbox,
|
|
307
383
|
prompt: consumer.prompt,
|
|
384
|
+
// The Frame's other half (ADR-0057) — WHERE this Turn works. Passed through as written; the
|
|
385
|
+
// actor resolves an ABSENT one against the enclosing Workspace, which only it can see.
|
|
386
|
+
cwd: consumer.cwd,
|
|
308
387
|
// This turn's dials (ADR-0018) — passed straight through; the Harness layers
|
|
309
388
|
// them over the definition when the Submission starts.
|
|
310
389
|
model: consumer.model,
|
|
311
390
|
thinkingLevel: consumer.thinkingLevel,
|
|
312
|
-
// ADR-0035's reroll gate: closed to `
|
|
313
|
-
// runaway recovery is a
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
? { continuation: true }
|
|
318
|
-
: {}),
|
|
319
|
-
tools: consumer.tools ?? derived,
|
|
391
|
+
// ADR-0035's reroll gate: closed to `continue: true`, which names an EXISTING conversation
|
|
392
|
+
// while the runaway's one recovery is a fresh one — exactly what it opted out of.
|
|
393
|
+
...(consumer.continue === true ? { continuation: true } : {}),
|
|
394
|
+
// The Menu (ADR-0015): always the derived one, since ADR-0057 retired the override.
|
|
395
|
+
tools: derived,
|
|
320
396
|
};
|
|
321
397
|
};
|
|
322
398
|
}
|
|
@@ -330,31 +406,30 @@ function wrapGateInput(orig: unknown, derived: string[]) {
|
|
|
330
406
|
}
|
|
331
407
|
|
|
332
408
|
/**
|
|
333
|
-
* Mint the instance id (ADR-0016). It is minted in the INPUT MAPPER — not the actor —
|
|
334
|
-
* the input is the persistence vehicle: restore re-spawns from the persisted input
|
|
335
|
-
* re-running the mapper (same conversation), while a fresh transition re-runs it
|
|
409
|
+
* Mint the instance id (ADR-0016, ADR-0057). It is minted in the INPUT MAPPER — not the actor —
|
|
410
|
+
* because the input is the persistence vehicle: restore re-spawns from the persisted input
|
|
411
|
+
* without re-running the mapper (same conversation), while a fresh transition re-runs it.
|
|
336
412
|
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
413
|
+
* Both spellings are built on {@link continuedIid}, `<runId>/<machine actor path>/<agent>` — the
|
|
414
|
+
* Agent's ONE conversation in this Machine instance. The machine actor path excludes the root
|
|
415
|
+
* actor (its id is generated per process) and the invoke's own leaf id (states of one Machine
|
|
416
|
+
* share the conversation, ADR-0057), so two Pool children — whose ids are their items' — can
|
|
417
|
+
* never meet.
|
|
418
|
+
*
|
|
419
|
+
* - Default (FRESH, jr's lossy handoff): a new conversation per invocation — that id plus a
|
|
420
|
+
* random suffix.
|
|
421
|
+
* - `continue: true`: that id exactly, at epoch 0 — the author writes a boolean, because in a
|
|
422
|
+
* state machine the identity (the run, the Machine instance, the Agent slot) is already on the
|
|
423
|
+
* page. Once jr2 has faulted the conversation, the ledger's epoch suffixes it (`<id>/1`,
|
|
424
|
+
* `<id>/2`, …): a virgin conversation, since no fault leaves one worth continuing (ADR-0035).
|
|
425
|
+
* **Epoch 0 is never spelled** — the unsuffixed id IS the first conversation, which keeps the
|
|
426
|
+
* common case readable on the Harness route, in the ledger and in the logs. Invoking a live
|
|
427
|
+
* continued id fails loudly at the registration table (one live surface per address).
|
|
348
428
|
*/
|
|
349
|
-
function mintIid(
|
|
350
|
-
consumer: { session?: "continue"; scope?: string; conversation?: string },
|
|
351
|
-
agentName: string,
|
|
352
|
-
self: AnyActorRef,
|
|
353
|
-
): string {
|
|
429
|
+
function mintIid(consumer: { continue?: true }, agentName: string, self: AnyActorRef): string {
|
|
354
430
|
const runId = boundRunId(self.system) ?? "local";
|
|
355
|
-
const
|
|
356
|
-
if (consumer.
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
return `${runId}/${path}/${agentName}${scope}/${randomUUID().slice(0, 8)}`;
|
|
431
|
+
const conversation = continuedIid(self, runId, agentName);
|
|
432
|
+
if (!consumer.continue) return `${conversation}/${randomUUID().slice(0, 8)}`;
|
|
433
|
+
const epoch = boundEpoch(self.system, conversation);
|
|
434
|
+
return epoch === 0 ? conversation : `${conversation}/${epoch}`;
|
|
360
435
|
}
|