@jr2/orchestrator 0.2.1 → 0.4.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/bin/server.ts +7 -4
- package/console/canvas.ts +27 -14
- package/console/components/app.ts +10 -1
- package/console/components/fleet.ts +39 -6
- package/console/components/machine-pane.ts +89 -2
- package/console/components/nav.ts +1 -1
- package/console/components/schema-form.ts +12 -4
- package/console/json-field.ts +23 -0
- package/console/main.ts +110 -39
- package/console/store.ts +221 -17
- package/console/style.css +90 -0
- package/package.json +4 -5
- package/src/actor.ts +507 -54
- package/src/agent.ts +2 -2
- package/src/ambient.ts +2 -0
- package/src/assembly-checks.ts +165 -0
- package/src/assembly-tar.ts +494 -0
- package/src/assembly.ts +721 -0
- package/src/chart.ts +2050 -0
- package/src/charts.ts +720 -0
- package/src/config.ts +233 -22
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +313 -0
- package/src/custodian.ts +951 -0
- package/src/customize.ts +216 -25
- package/src/effect-checks.ts +557 -0
- package/src/effect.ts +905 -0
- package/src/effector-wire.ts +525 -0
- package/src/fingerprint.ts +4 -3
- package/src/gate.ts +8 -0
- package/src/git-clone.ts +65 -0
- package/src/git-push.ts +336 -0
- package/src/harness-client.ts +247 -25
- package/src/held-secrets.ts +643 -0
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +514 -119
- package/src/images.ts +58 -31
- package/src/index.ts +49 -4
- package/src/instance.ts +445 -59
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/model-keys.ts +92 -0
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +89 -7
- package/src/notices.ts +88 -0
- package/src/open-body.ts +1444 -0
- package/src/open-effect.ts +144 -0
- package/src/open.ts +4 -4
- package/src/parts.ts +530 -33
- package/src/party.ts +468 -0
- package/src/pick.ts +309 -0
- package/src/pool.ts +1 -1
- package/src/registration.ts +301 -17
- package/src/repo-fetch.ts +61 -86
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1841 -105
- package/src/sandbox-effect.ts +891 -0
- package/src/sandbox-effector.ts +111 -0
- package/src/sandbox-kube.ts +1719 -0
- package/src/sandbox-watch.ts +260 -0
- package/src/secret-placement.ts +494 -0
- package/src/server.ts +285 -89
- package/src/setup.ts +589 -70
- package/src/size.ts +342 -0
- package/src/snapshot-store.ts +64 -13
- package/src/stub-harness.ts +5 -0
- package/src/sync-client.ts +54 -0
- package/src/timers.ts +178 -0
- package/src/tokens.ts +44 -12
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1144
package/src/timers.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// A run's pending timers across a restart (ADR-0007).
|
|
2
|
+
//
|
|
3
|
+
// xstate 5 keeps a delayed event (`after`, a named delay, a delayed `raise` or `sendTo`) on the
|
|
4
|
+
// actor SYSTEM's scheduler, not in the Machine snapshot. `getPersistedSnapshot()` carries no trace
|
|
5
|
+
// of it, and a restored actor runs no entry actions again, so nothing schedules it again: the
|
|
6
|
+
// timer is lost, not restarted. xstate 5 has a `snapshot.scheduler` seam in `createSystem`, but
|
|
7
|
+
// the Actor constructor never passes it.
|
|
8
|
+
//
|
|
9
|
+
// So the run host records each pending timer's absolute DEADLINE beside the snapshot, in the same
|
|
10
|
+
// blob, and schedules it again on the restored system after `start()`. The author writes plain
|
|
11
|
+
// `after`. The scheduled id is xstate's own (`xstate.after.<delay>.<stateId>` for an `after`), so
|
|
12
|
+
// the state's exit `cancel(id)` still cancels a re-armed timer.
|
|
13
|
+
//
|
|
14
|
+
// This reads an xstate INTERNAL: `system.getSnapshot()._scheduledEvents` (typed in xstate's
|
|
15
|
+
// `ActorSystem`, but underscored), `system.scheduler.schedule`, and `system.scheduler.cancel`
|
|
16
|
+
// (wrapped for the length of `start()`). An xstate bump can break it; the raw-xstate tests in
|
|
17
|
+
// test/timers.test.ts are the canary. xstate 6 persists timers in the snapshot and re-arms them
|
|
18
|
+
// itself, with the same deadline and clamp: on xstate 6, delete this module.
|
|
19
|
+
|
|
20
|
+
import type { AnyActorRef, EventObject } from "xstate";
|
|
21
|
+
|
|
22
|
+
/** One pending delayed event, JSON-safe. An actor is named by its path of child ids below the
|
|
23
|
+
* run's root (`[]` is the root). */
|
|
24
|
+
export type PendingTimer = {
|
|
25
|
+
source: string[];
|
|
26
|
+
target: string[];
|
|
27
|
+
/** xstate's scheduled id. */
|
|
28
|
+
id: string;
|
|
29
|
+
event: EventObject;
|
|
30
|
+
/** The delay as last scheduled, ms: the upper bound on what remains (see {@link startAndRearm}). */
|
|
31
|
+
delay: number;
|
|
32
|
+
/** Wall-clock ms at which it is due, on the host's clock (`now`). */
|
|
33
|
+
deadline: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function childrenOf(ref: AnyActorRef): Record<string, AnyActorRef> {
|
|
37
|
+
return (ref.getSnapshot() as { children?: Record<string, AnyActorRef> }).children ?? {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function pathOf(root: AnyActorRef, ref: AnyActorRef, path: string[] = []): string[] | undefined {
|
|
41
|
+
if (ref === root) return path;
|
|
42
|
+
for (const [id, child] of Object.entries(childrenOf(root))) {
|
|
43
|
+
const found = pathOf(child, ref, [...path, id]);
|
|
44
|
+
if (found) return found;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function refAt(root: AnyActorRef, path: string[]): AnyActorRef | undefined {
|
|
50
|
+
let ref: AnyActorRef | undefined = root;
|
|
51
|
+
for (const id of path) ref = ref && childrenOf(ref)[id];
|
|
52
|
+
return ref;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** xstate's key for a scheduled event: one per source actor and id. */
|
|
56
|
+
const keyOf = (source: AnyActorRef, id: string) => `${source.sessionId}.${id}`;
|
|
57
|
+
|
|
58
|
+
const clamp = (ms: number, delay: number) => Math.min(delay, Math.max(0, ms));
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The run's pending timers, read off its actor system. Call at persist time: the scheduler's table
|
|
62
|
+
* and the snapshot are read in the same tick, so they agree. A timer whose source or target is not
|
|
63
|
+
* in the run's tree (a delayed send to an actor outside it) is not recorded; jr2 has no such idiom.
|
|
64
|
+
*
|
|
65
|
+
* xstate stamps a timer's start with `Date.now()`, so the time that remains is read on that clock;
|
|
66
|
+
* the deadline is `now + remaining`, on the host's clock. With the default `now` the two are one
|
|
67
|
+
* clock and the deadline is xstate's own `startedAt + delay`.
|
|
68
|
+
*/
|
|
69
|
+
export function pendingTimers(root: AnyActorRef, now = Date.now()): PendingTimer[] {
|
|
70
|
+
const wall = Date.now();
|
|
71
|
+
const out: PendingTimer[] = [];
|
|
72
|
+
for (const s of Object.values(root.system.getSnapshot()._scheduledEvents)) {
|
|
73
|
+
const source = pathOf(root, s.source);
|
|
74
|
+
const target = pathOf(root, s.target);
|
|
75
|
+
if (!source || !target) continue;
|
|
76
|
+
const remaining = clamp(s.startedAt + s.delay - wall, s.delay);
|
|
77
|
+
out.push({ source, target, id: s.id, event: s.event, delay: s.delay, deadline: now + remaining });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The pending delayed events whose TARGET is `ref`, from any source: what Rest asks (rest.ts) —
|
|
84
|
+
* an `after`, a named delay, a delayed `raise` or `sendTo(self)`, a child's delayed `sendParent`.
|
|
85
|
+
* The same table {@link pendingTimers} persists and {@link startAndRearm} re-arms, so Rest reads
|
|
86
|
+
* the same timers after a restore as before it.
|
|
87
|
+
*/
|
|
88
|
+
export function scheduledFor(ref: AnyActorRef): Array<{ id: string; event: EventObject }> {
|
|
89
|
+
return Object.values(ref.system.getSnapshot()._scheduledEvents)
|
|
90
|
+
.filter((s) => s.target === ref)
|
|
91
|
+
.map((s) => ({ id: s.id, event: s.event }));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Watch a run's actor system for timers that FIRE: the returned function says whether one relayed
|
|
96
|
+
* event is a timer the system scheduled — an `after`, a named delay, a delayed `raise` or `sendTo` —
|
|
97
|
+
* and not an event an actor sent at once. Both reach the inspection stream from the same source with
|
|
98
|
+
* the same shape; only the scheduler knows the difference, and it forgets a timer as it fires it.
|
|
99
|
+
*
|
|
100
|
+
* So `scheduler.schedule` and `scheduler.cancel` are wrapped for the life of the system: each armed
|
|
101
|
+
* timer is kept by its key with the event object xstate relays when it fires (the same object, not
|
|
102
|
+
* a copy). A relayed event is a fired timer when its source and event object are an armed timer's
|
|
103
|
+
* and that timer is no longer in the scheduler's table; it is then forgotten. A re-armed timer
|
|
104
|
+
* (`startAndRearm`) goes through the same `schedule`, so a restored run recognises it too.
|
|
105
|
+
*
|
|
106
|
+
* Install it before `start()` (the root's creation event is early enough): a timer armed before the
|
|
107
|
+
* wrap is not seen. The same xstate internals as the rest of this module; the canary is the same.
|
|
108
|
+
*/
|
|
109
|
+
export function watchTimers(system: AnyActorRef["system"]): (source: unknown, event: unknown) => boolean {
|
|
110
|
+
const scheduler = system.scheduler;
|
|
111
|
+
const { schedule, cancel } = scheduler;
|
|
112
|
+
const armed = new Map<string, { source: AnyActorRef; event: EventObject }>();
|
|
113
|
+
scheduler.schedule = (source, target, event, delay, id = Math.random().toString(36).slice(2)) => {
|
|
114
|
+
schedule(source, target, event, delay, id);
|
|
115
|
+
armed.set(keyOf(source, id), { source, event });
|
|
116
|
+
};
|
|
117
|
+
scheduler.cancel = (source, id) => {
|
|
118
|
+
armed.delete(keyOf(source, id));
|
|
119
|
+
cancel(source, id);
|
|
120
|
+
};
|
|
121
|
+
return (source, event) => {
|
|
122
|
+
for (const [key, row] of armed) {
|
|
123
|
+
if (row.event !== event || row.source !== source) continue;
|
|
124
|
+
if (key in system.getSnapshot()._scheduledEvents) continue;
|
|
125
|
+
armed.delete(key);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start a restored run, then schedule each recorded timer again on it. One function, so the order
|
|
134
|
+
* cannot be got wrong: re-armed BEFORE `start()`, a timer is armed twice and fires twice, because
|
|
135
|
+
* the system's own `start()` schedules whatever is already in its table; and `start()` is where
|
|
136
|
+
* xstate builds the children, so a child's path resolves only after it.
|
|
137
|
+
*
|
|
138
|
+
* `start()` drains the mailbox: a child that sends on start (an author's `fromCallback`) can move
|
|
139
|
+
* the Machine out of a state before any timer is re-armed. That exit's `cancel(id)` finds nothing
|
|
140
|
+
* armed yet. So every cancel during `start()` is recorded, and a row it names is not re-armed: a
|
|
141
|
+
* state exit cancels the timer whether it happens during `start()` or after it.
|
|
142
|
+
*
|
|
143
|
+
* A child stopped during `start()` takes its timers with it, but xstate stops a child with
|
|
144
|
+
* `scheduler.cancelAll`, which walks the table, and the table is still empty: no cancel is seen.
|
|
145
|
+
* If its parent state is entered again, a NEW child sits at the same path. So each row's actors are
|
|
146
|
+
* resolved BEFORE `start()` (the restored tree already holds them), and a row is re-armed only on
|
|
147
|
+
* the same actors after it. A row of a stopped child then fires into nothing, not into its
|
|
148
|
+
* replacement.
|
|
149
|
+
*
|
|
150
|
+
* Remaining time is `deadline - now`, clamped to `[0, delay]`. A deadline that passed while the
|
|
151
|
+
* Orchestrator was down fires at once. A wall clock that went BACKWARDS cannot push a timer past its
|
|
152
|
+
* own delay. A wall clock that jumped forwards fires a timer early: only the wall clock survives a
|
|
153
|
+
* process, so the deadline is a wall-clock time. Rows are scheduled in deadline order, so timers
|
|
154
|
+
* that are all overdue (remaining 0) fire in the order they fell due, as in an uninterrupted run.
|
|
155
|
+
*/
|
|
156
|
+
export function startAndRearm(root: AnyActorRef, timers: readonly PendingTimer[], now = Date.now()): void {
|
|
157
|
+
const scheduler = root.system.scheduler;
|
|
158
|
+
const cancel = scheduler.cancel;
|
|
159
|
+
const cancelled = new Set<string>();
|
|
160
|
+
const rows = [...timers]
|
|
161
|
+
// `sort` is stable: equal deadlines keep the order xstate scheduled them in.
|
|
162
|
+
.sort((a, b) => a.deadline - b.deadline)
|
|
163
|
+
.map((t) => ({ t, source: refAt(root, t.source), target: refAt(root, t.target) }));
|
|
164
|
+
scheduler.cancel = (source, id) => {
|
|
165
|
+
cancelled.add(keyOf(source, id));
|
|
166
|
+
cancel(source, id);
|
|
167
|
+
};
|
|
168
|
+
try {
|
|
169
|
+
root.start();
|
|
170
|
+
} finally {
|
|
171
|
+
scheduler.cancel = cancel;
|
|
172
|
+
}
|
|
173
|
+
for (const { t, source, target } of rows) {
|
|
174
|
+
if (!source || !target || cancelled.has(keyOf(source, t.id))) continue;
|
|
175
|
+
if (refAt(root, t.source) !== source || refAt(root, t.target) !== target) continue;
|
|
176
|
+
scheduler.schedule(source, target, t.event, clamp(t.deadline - now, t.delay), t.id);
|
|
177
|
+
}
|
|
178
|
+
}
|
package/src/tokens.ts
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
// Bearer tokens (ADR-0013), because the Sandbox boundary is otherwise theater. The Harness
|
|
2
2
|
// container shares the pod's network namespace, so an Agent can reach the Orchestrator directly;
|
|
3
|
-
// a per-pod NetworkPolicy cannot distinguish its packets from the
|
|
3
|
+
// a per-pod NetworkPolicy cannot distinguish its packets from the Custodian's. Only authentication
|
|
4
4
|
// closes this, and only the token bounds what a caller may DO (as opposed to where it may talk).
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// Three principals. The asymmetry between the first two is the whole design; the third is a door
|
|
7
|
+
// key, not a seat:
|
|
7
8
|
//
|
|
8
9
|
// Instance token the human/CLI credential. Full trust: gates, run control, agent surfaces.
|
|
9
10
|
// From the instance Secret (`JR2_INSTANCE_TOKEN`); minted per boot when absent,
|
|
10
11
|
// announced once on stdout (fixtures capture it). An Agent never has it — it
|
|
11
12
|
// never enters a Sandbox.
|
|
12
13
|
//
|
|
13
|
-
//
|
|
14
|
+
// Publish token `jr2 up`'s credential for `PUT /assembly` alone (ADR-0069). A publish imports
|
|
15
|
+
// code into the process that holds the ServiceAccount, so it must cost what an
|
|
16
|
+
// image roll cost: cluster write access. From the instance Secret
|
|
17
|
+
// (`JR2_PUBLISH_TOKEN`), read by `jr2 up` through the kube API only. Every other
|
|
18
|
+
// route answers it 403: it is a known token, but not one that route accepts.
|
|
19
|
+
//
|
|
20
|
+
// Sandbox token the Custodian's credential. Authorizes exactly: deliver to `kind: "agent"`
|
|
14
21
|
// registrations whose Sandbox is THIS one. Never a Gate (a compromised Agent
|
|
15
22
|
// must not approve its own review), never another Sandbox (`coding.ts`'s iids
|
|
16
23
|
// are derivable and feature ids are readable from the Work Source, so a merely
|
|
17
24
|
// run-scoped token would let one feature's coder inject a verdict into another
|
|
18
|
-
// feature's reviewer). Delivered as a Secret
|
|
19
|
-
//
|
|
25
|
+
// feature's reviewer). Delivered as a Secret mounted into the Custodian
|
|
26
|
+
// container ALONE, which the Agent cannot read; the Harness holds its
|
|
27
|
+
// Stand-in (ADR-0059).
|
|
20
28
|
// The signed name is a POD hosting Turns, not a Sandbox CR per se: the Instance
|
|
21
|
-
// Harness's
|
|
29
|
+
// Harness's Custodian holds one signed for that placement's Service name
|
|
22
30
|
// (ADR-0031), scoping it to the Menu-only registrations placed there.
|
|
23
31
|
//
|
|
24
32
|
// And one bearer the other way (ADR-0058), which is no principal here because this server never
|
|
@@ -32,7 +40,7 @@
|
|
|
32
40
|
// verified by recomputing. Three things fall out that a token table would have to work for — the
|
|
33
41
|
// Orchestrator holds no per-Sandbox state, `provision` stays idempotent (re-minting yields the same
|
|
34
42
|
// token, so a re-applied Secret is a no-op), and a token minted before a restart still verifies
|
|
35
|
-
// after one, which is exactly what ADR-0012's re-attach promises the still-running
|
|
43
|
+
// after one, which is exactly what ADR-0012's re-attach promises the still-running Custodian.
|
|
36
44
|
|
|
37
45
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
38
46
|
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
@@ -42,7 +50,10 @@ import { join } from "node:path";
|
|
|
42
50
|
export type Principal =
|
|
43
51
|
/** The instance's own operator: the CLI, a human, a webhook translator holding the token. */
|
|
44
52
|
| { kind: "instance" }
|
|
45
|
-
/**
|
|
53
|
+
/** `jr2 up`, publishing an Assembly (ADR-0069). Only `PUT /assembly` accepts it: it is not an
|
|
54
|
+
* Instance principal, so it reads no run and starts none. */
|
|
55
|
+
| { kind: "publisher" }
|
|
56
|
+
/** One Sandbox's Custodian, speaking for the Agent in that pod — and for no one else. */
|
|
46
57
|
| { kind: "sandbox"; sandbox: string };
|
|
47
58
|
|
|
48
59
|
/** Resolve a bearer token to a principal; undefined = not a token we minted (→ 401). */
|
|
@@ -76,7 +87,20 @@ export function harnessTokenDigest(key: Buffer, placement: string): string {
|
|
|
76
87
|
return createHash("sha256").update(harnessToken(key, placement)).digest("base64url");
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* The signing key of one Effector placement (ADR-0068): `HMAC(signing key, "effector:" + placement)`,
|
|
92
|
+
* as the Harness bearer is derived (ADR-0058). The Orchestrator signs every request to that
|
|
93
|
+
* Effector with it and verifies every answer; the Effector reads it from a file in its own mount,
|
|
94
|
+
* which `jr2 up` writes (`instance` → the `jr2-instance-effector` Secret). Derived, not stored: a
|
|
95
|
+
* restarted Orchestrator derives the key a live Effector already holds. The `effector:` prefix
|
|
96
|
+
* keeps it apart from every Sandbox token and Harness bearer. The key's bytes are this string's.
|
|
97
|
+
*/
|
|
98
|
+
export function effectorKey(key: Buffer, placement: string): string {
|
|
99
|
+
return sign(key, `effector:${placement}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Mint the per-boot Instance token (opaque and random — it names nothing, it just IS the trust).
|
|
103
|
+
* The publish token is minted the same way when the Secret supplies none (ADR-0069). */
|
|
80
104
|
export function mintInstanceToken(): string {
|
|
81
105
|
return randomBytes(32).toString("base64url");
|
|
82
106
|
}
|
|
@@ -85,7 +109,7 @@ export function mintInstanceToken(): string {
|
|
|
85
109
|
* The instance's HMAC signing key, at `<dir>/.jr2/secret` (0600), created on first use.
|
|
86
110
|
*
|
|
87
111
|
* It MUST outlive the process: an Orchestrator restart leaves live Sandboxes running (ADR-0012 re-attach),
|
|
88
|
-
* and their
|
|
112
|
+
* and their Custodians still hold tokens minted by the process that died. A fresh key would reject
|
|
89
113
|
* every one of them — the Agent would silently lose its only route to its Machine.
|
|
90
114
|
*/
|
|
91
115
|
export async function loadSigningKey(dir: string): Promise<Buffer> {
|
|
@@ -108,10 +132,16 @@ export async function loadSigningKey(dir: string): Promise<Buffer> {
|
|
|
108
132
|
* A token we did not mint resolves to nothing, and the caller is refused — there is no anonymous
|
|
109
133
|
* principal, and no route decides for itself whether it needs one.
|
|
110
134
|
*/
|
|
111
|
-
export function createAuthenticator(opts: {
|
|
135
|
+
export function createAuthenticator(opts: {
|
|
136
|
+
instanceToken: string;
|
|
137
|
+
signingKey: Buffer;
|
|
138
|
+
/** The publish token (ADR-0069). Absent → no bearer resolves to the publisher. */
|
|
139
|
+
publishToken?: string;
|
|
140
|
+
}): Authenticator {
|
|
112
141
|
return (bearer) => {
|
|
113
142
|
if (!bearer) return undefined;
|
|
114
143
|
if (constantTimeEqual(bearer, opts.instanceToken)) return { kind: "instance" };
|
|
144
|
+
if (opts.publishToken !== undefined && constantTimeEqual(bearer, opts.publishToken)) return { kind: "publisher" };
|
|
115
145
|
const cut = bearer.lastIndexOf(".");
|
|
116
146
|
if (cut <= 0) return undefined;
|
|
117
147
|
const sandbox = bearer.slice(0, cut);
|
|
@@ -130,6 +160,7 @@ export function createAuthenticator(opts: { instanceToken: string; signingKey: B
|
|
|
130
160
|
*/
|
|
131
161
|
export function mayDeliverToAgent(principal: Principal, registrationSandbox: string | undefined): boolean {
|
|
132
162
|
if (principal.kind === "instance") return true;
|
|
163
|
+
if (principal.kind !== "sandbox") return false;
|
|
133
164
|
return registrationSandbox !== undefined && registrationSandbox === principal.sandbox;
|
|
134
165
|
}
|
|
135
166
|
|
|
@@ -137,10 +168,11 @@ export function mayDeliverToAgent(principal: Principal, registrationSandbox: str
|
|
|
137
168
|
* May this principal ask for a fetch on this Sandbox (ADR-0053)? A Sandbox token may ask for its
|
|
138
169
|
* OWN pod and no other — the scope is the caches that pod mounts, and the route refuses a Repo it
|
|
139
170
|
* does not. The Instance token may, on the same grounds it may deliver to any agent surface: it is
|
|
140
|
-
* the operator.
|
|
171
|
+
* the operator. The publish token may not: it opens the publish door and no other (ADR-0069).
|
|
141
172
|
*/
|
|
142
173
|
export function mayAskForSandbox(principal: Principal, sandbox: string): boolean {
|
|
143
174
|
if (principal.kind === "instance") return true;
|
|
175
|
+
if (principal.kind !== "sandbox") return false;
|
|
144
176
|
return principal.sandbox === sandbox;
|
|
145
177
|
}
|
|
146
178
|
|
package/src/vocabulary.ts
CHANGED
|
@@ -54,10 +54,32 @@ export function vocabularyOf(machine: AnyStateMachine): Map<string, EventDef> |
|
|
|
54
54
|
* silently accepting a name nobody declared.
|
|
55
55
|
*/
|
|
56
56
|
export function invokingMachine(self: AnyActorRef): AnyStateMachine | undefined {
|
|
57
|
-
|
|
57
|
+
// An Open body's actor (ADR-0070) delegates to the Machine compiled from the Chart it binds, and
|
|
58
|
+
// its own logic is no Machine: the bound Machine is the scope of the Agents and Gates its states
|
|
59
|
+
// invoke, so it is asked first.
|
|
60
|
+
const parent = self._parent;
|
|
61
|
+
const bound = parent ? boundMachines.get(parent) : undefined;
|
|
62
|
+
if (bound) return bound;
|
|
63
|
+
const logic = (parent as { logic?: unknown } | undefined)?.logic as AnyStateMachine | undefined;
|
|
58
64
|
return logic?.root ? logic : undefined;
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
// The Machine an Open body's actor runs (ADR-0070): compiled from the Chart the run bound, per
|
|
68
|
+
// actor, at start and again at restore. Keyed on the ACTOR, not on a config: two runs of one
|
|
69
|
+
// wrapper bind two Charts under the same slot. Kept here, beside the vocabulary, so this module
|
|
70
|
+
// stays a leaf: open-body.ts writes it, and `invokingMachine` reads it.
|
|
71
|
+
const boundMachines = new WeakMap<AnyActorRef, AnyStateMachine>();
|
|
72
|
+
|
|
73
|
+
/** Record the Machine an Open body's actor runs. jr2-internal: the Open body's logic calls it. */
|
|
74
|
+
export function attachBoundMachine(actor: AnyActorRef, machine: AnyStateMachine): void {
|
|
75
|
+
boundMachines.set(actor, machine);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The Machine an Open body's actor runs, or undefined for any other actor. */
|
|
79
|
+
export function boundMachineFor(actor: AnyActorRef): AnyStateMachine | undefined {
|
|
80
|
+
return boundMachines.get(actor);
|
|
81
|
+
}
|
|
82
|
+
|
|
61
83
|
// The declared run input (ADR-0033): the one piece of a machine's vocabulary the event defs
|
|
62
84
|
// missed — what a run of it is STARTED with. Same key choice as the vocabulary above, for the
|
|
63
85
|
// same reasons (per-machine attribution, no global registry, transparent across `.provide()`).
|
|
@@ -79,6 +101,49 @@ export function inputSchemaOf(machine: AnyStateMachine): z.ZodObject | undefined
|
|
|
79
101
|
return inputSchemas.get(machine.config);
|
|
80
102
|
}
|
|
81
103
|
|
|
104
|
+
// Who may send what, per state (ADR-0015): the seats the menu-derivation walk computed. Same key
|
|
105
|
+
// choice as the vocabulary, for the same reasons. The Machine's doc carries them; `jr2 up` and the
|
|
106
|
+
// Console show them, so an author reads the audience rule's answer for their own Machine.
|
|
107
|
+
|
|
108
|
+
/** One party's seat on one state: the state's id, the party, and the names it may send there —
|
|
109
|
+
* for an Agent the derived set its guards narrow; for a Gate its accepted set, unless the Gate's
|
|
110
|
+
* input names `accepts:` itself. */
|
|
111
|
+
export type MachineSeat = { state: string; party: "agent" | "gate"; agent?: string; names: string[] };
|
|
112
|
+
|
|
113
|
+
const seatTables = new WeakMap<MachineKey, MachineSeat[]>();
|
|
114
|
+
|
|
115
|
+
/** Attach a Machine's seats. jr2-internal: `jr2Setup.createMachine` calls it. */
|
|
116
|
+
export function attachSeats(machine: AnyStateMachine, seats: MachineSeat[]): void {
|
|
117
|
+
seatTables.set(machine.config, seats);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The seats of a Machine built by `jr2Setup`, in walk order; empty for any other Machine. */
|
|
121
|
+
export function seatsOf(machine: AnyStateMachine): MachineSeat[] {
|
|
122
|
+
return seatTables.get(machine.config) ?? [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One line per seat — `<state> agent:<slot>: [names]` or `<state> gate: [names]`. */
|
|
126
|
+
export function seatLine(seat: MachineSeat): string {
|
|
127
|
+
const party = seat.party === "agent" ? `agent:${seat.agent}` : "gate";
|
|
128
|
+
return `${seat.state} ${party}: [${seat.names.join(", ")}]`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const loadWarnings = new WeakMap<MachineKey, string[]>();
|
|
132
|
+
|
|
133
|
+
/** Attach the load warnings `jr2Setup.createMachine` found (ADR-0015). jr2-internal. */
|
|
134
|
+
export function attachLoadWarnings(machine: AnyStateMachine, warnings: string[]): void {
|
|
135
|
+
loadWarnings.set(machine.config, warnings);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What the build found legal and suspect — a mode nothing enters, a pick a Gate above takes from
|
|
139
|
+
* the Agent below, one name written for two parties on one path. `jr2 up` prints them; the
|
|
140
|
+
* Machine's doc carries them. A Machine `customize()` bound an Open argv in is rebuilt around a
|
|
141
|
+
* config of its own, which carries the walk's findings for the bound argv (ADR-0054). Empty for a
|
|
142
|
+
* Machine not built by `jr2Setup`. */
|
|
143
|
+
export function loadWarningsOf(machine: AnyStateMachine): string[] {
|
|
144
|
+
return loadWarnings.get(machine.config) ?? [];
|
|
145
|
+
}
|
|
146
|
+
|
|
82
147
|
/**
|
|
83
148
|
* What the HOST adds to the input of the machine a run STARTS with, beside the door
|
|
84
149
|
* (`RunHost.start`). One field today: the run's seed Instance ID, minted with the run and
|
package/src/wire.ts
CHANGED
|
@@ -28,6 +28,11 @@ export const SUBMISSION_ABORTED = "submission_aborted";
|
|
|
28
28
|
* on the class (one fresh-conversation reroll) without parsing prose. */
|
|
29
29
|
export const SUBMISSION_RUNAWAY = "runaway";
|
|
30
30
|
|
|
31
|
+
/** `SettlementError.type` for a Submission the Harness admitted but had not settled when it
|
|
32
|
+
* restarted (ADR-0031): the rebuilt stream settles it `failed`, so a re-attached `wait` reads a
|
|
33
|
+
* Settlement, not a 404. The Agent actor switches on this type, never on the message's prose. */
|
|
34
|
+
export const SUBMISSION_HARNESS_RESTARTED = "harness_restarted";
|
|
35
|
+
|
|
31
36
|
/** One settled Submission, in the shape `?view=history`'s `settlements` reports. */
|
|
32
37
|
export type Settlement = {
|
|
33
38
|
submissionId: string;
|
|
@@ -51,19 +56,97 @@ export type MessageAppendedEvent = StreamChunk<"message-appended"> & {
|
|
|
51
56
|
* a parked long-poll. */
|
|
52
57
|
export type SubmissionSettledEvent = StreamChunk<"submission-settled"> & Settlement;
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The Harness's memory guard killed the Agent's processes (ADR-0061, layer 4), on the stream of the
|
|
61
|
+
* conversation whose Turn was running. The Agent heard it in its `bash` answer; this is how the
|
|
62
|
+
* Orchestrator hears it, so the next Turn in the Workspace is told (ADR-0062). `peak` and `limit`
|
|
63
|
+
* are the guard's own spellings — carried to the notice as they are, never parsed.
|
|
64
|
+
*/
|
|
65
|
+
export type MemoryLimitEvent = StreamChunk<"memory-limit"> & { peak: string; limit: string };
|
|
66
|
+
|
|
54
67
|
/** One event on the durable stream (`GET ?offset=…&view=updates` → 200 JSON array). */
|
|
55
|
-
export type StreamEvent = MessageAppendedEvent | SubmissionSettledEvent;
|
|
68
|
+
export type StreamEvent = MessageAppendedEvent | SubmissionSettledEvent | MemoryLimitEvent;
|
|
69
|
+
|
|
70
|
+
// ---- Notices (ADR-0062) ------------------------------------------------------------------------
|
|
71
|
+
// What happened that the next Turn cannot find out for itself, as DATA: the Orchestrator keeps
|
|
72
|
+
// them and attaches the pending ones in scope to an admission (`AdmissionRequest.notices`); the
|
|
73
|
+
// Harness writes every word of the Briefing's Turn part from them.
|
|
74
|
+
|
|
75
|
+
/** The Sandbox's processes were killed at its memory limit during `agent`'s Turn — by the
|
|
76
|
+
* Harness's guard (`peak` known) or by the kernel (`peak` unknown). Workspace scope: the next Turn
|
|
77
|
+
* of any Agent in that Workspace. */
|
|
78
|
+
export type MemoryLimitNotice = {
|
|
79
|
+
kind: "memory-limit";
|
|
80
|
+
scope: "workspace";
|
|
81
|
+
agent: string;
|
|
82
|
+
peak?: string;
|
|
83
|
+
limit: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** This conversation is new; earlier context is gone. Conversation scope: that Agent's next
|
|
87
|
+
* continued Turn. `reason` says why — the fault that burned the last one, or that the Harness no
|
|
88
|
+
* longer holds it. */
|
|
89
|
+
export type ConversationNewNotice = { kind: "conversation-new"; scope: "conversation"; reason: string };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What an outstanding pick's answer is (ADR-0024): the LAST reply from below the Agent's state since
|
|
93
|
+
* the pick — a party's event (a Gate's answer, a nested Agent's pick) with its payload, or a child's
|
|
94
|
+
* output (a child Machine, an Effect stand-in) — named by who sent it. A child's error is no reply (a
|
|
95
|
+
* defect, ADR-0024). Absent where nothing replied: the answer carries no reply, and says nothing of
|
|
96
|
+
* whether work ran. Plain data; the Harness writes the words. `answer`: the value the Machine's
|
|
97
|
+
* `answer` action set (ADR-0067), which replaces the default.
|
|
98
|
+
*/
|
|
99
|
+
export type PickAnswer =
|
|
100
|
+
| { kind: "event"; event: string; sender?: { party: "agent" | "gate"; id: string }; payload: Record<string, unknown> }
|
|
101
|
+
| { kind: "output"; actor: string; output: unknown }
|
|
102
|
+
| { kind: "answer"; value: unknown };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A pick's answer that did not reach the pick's call (ADR-0024): `cut` — the Harness restarted while
|
|
106
|
+
* the workflow ran it, and jr2 re-admits the Agent on the same conversation with this notice; or the
|
|
107
|
+
* route left the Agent's state, and the next Turn of the conversation the picking Turn continued
|
|
108
|
+
* hears it. Conversation scope.
|
|
109
|
+
*/
|
|
110
|
+
export type PickAnswerNotice = {
|
|
111
|
+
kind: "pick-answer";
|
|
112
|
+
scope: "conversation";
|
|
113
|
+
pick: string;
|
|
114
|
+
cut: boolean;
|
|
115
|
+
/** The last reply since the pick — with `stopped`, an earlier link's, kept. */
|
|
116
|
+
answer?: PickAnswer;
|
|
117
|
+
/** Something other than the pick's route left the Agent's state while the pick's work ran, and
|
|
118
|
+
* that exit stopped the work before it finished (ADR-0024). */
|
|
119
|
+
stopped?: true;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export type Notice = MemoryLimitNotice | ConversationNewNotice | PickAnswerNotice;
|
|
56
123
|
|
|
57
124
|
/** Response header: the offset to resume the stream from (every stream read carries it). */
|
|
58
125
|
export const STREAM_NEXT_OFFSET_HEADER = "stream-next-offset";
|
|
59
126
|
|
|
60
|
-
/** `?view=`
|
|
127
|
+
/** `?view=` values the client reads. A read with neither view is `updates`; `history` is the
|
|
128
|
+
* conversation snapshot, read only to learn whether the Harness still holds it (ADR-0062). */
|
|
61
129
|
export const VIEW_UPDATES = "updates";
|
|
130
|
+
export const VIEW_HISTORY = "history";
|
|
62
131
|
|
|
63
132
|
/** `?live=` value: park until a new event or timeout (204 + the same headers). Long-poll is the
|
|
64
133
|
* ONLY wait transport (ADR-0027) — no SSE, no `?wait=result`. */
|
|
65
134
|
export const LIVE_LONG_POLL = "long-poll";
|
|
66
135
|
|
|
136
|
+
// ---- The live set (`PUT /agents` — ADR-0031) ----------------------------------------------------
|
|
137
|
+
// The Orchestrator states which conversations its live runs hold on the Instance Harness, and the
|
|
138
|
+
// Harness frees every other one that is not busy. The whole set every time (level-triggered), so a
|
|
139
|
+
// missed statement costs nothing.
|
|
140
|
+
|
|
141
|
+
/** One conversation a live run holds: the `:name` and `:id` of its `/agents/:name/:id` route. */
|
|
142
|
+
export type LiveConversation = { agent: string; instanceId: string };
|
|
143
|
+
|
|
144
|
+
/** What `PUT /agents` accepts: every conversation the Orchestrator's live runs hold. */
|
|
145
|
+
export type LiveSetRequest = { live: LiveConversation[] };
|
|
146
|
+
|
|
147
|
+
/** What `PUT /agents` answers (200): how many conversations this statement freed. */
|
|
148
|
+
export type LiveSetResponse = { freed: number };
|
|
149
|
+
|
|
67
150
|
// ---- The run-narrative echo (`POST /echo` — ADR-0023) ------------------------------------------
|
|
68
151
|
// "Print these events": the Orchestrator — the observation feed's one subscriber (ADR-0022) — tees
|
|
69
152
|
// the owning run's feed to the enclosing Workspace's Harness, which renders it into its own pod log.
|
|
@@ -101,3 +184,74 @@ export type EchoEvent = EchoStatusEvent | EchoEmitEvent | EchoAdmissionEvent | E
|
|
|
101
184
|
/** What `POST /echo` accepts: feed events in feed order. The answer is `{ printed }` — how many
|
|
102
185
|
* lines landed on stdout (an unrenderable event prints nothing; it never errors the batch). */
|
|
103
186
|
export type EchoRequest = { events: EchoEvent[] };
|
|
187
|
+
|
|
188
|
+
// --- the attach (ADR-0063) ----------------------------------------------------------------------
|
|
189
|
+
// The Workspace's Repos into `/work`, run by the Harness on `POST /attach` — the Orchestrator's
|
|
190
|
+
// call where it once ran a script over `kubectl exec`. Same bearer as every Harness route
|
|
191
|
+
// (ADR-0058); idempotent on the Harness's side.
|
|
192
|
+
|
|
193
|
+
/** One Repo Slot to attach. The Orchestrator resolves the Repo's `identity` and cache `key`
|
|
194
|
+
* (repo-identity.ts) — the Harness never re-derives them, so the cache it clones from and the node
|
|
195
|
+
* cache the operator mounted agree by construction (ADR-0051). `url` is the Binding's own spelling,
|
|
196
|
+
* the push url (ADR-0005); `ref` is the base the branch starts from, omitted for the Repo's default
|
|
197
|
+
* branch. */
|
|
198
|
+
export type AttachSlot = { slot: string; url: string; identity: string; key: string; ref?: string };
|
|
199
|
+
|
|
200
|
+
/** What `POST /attach` accepts: the slots in declaration order, the Workspace's branch, and — for a
|
|
201
|
+
* reviewer's seat — the sha the detached review worktree is forced to (ADR-0028). */
|
|
202
|
+
export type AttachRequest = { slots: AttachSlot[]; branch: string; reviewSha?: string };
|
|
203
|
+
|
|
204
|
+
/** What `POST /attach` answers (200): the branch worktree per slot, and the review worktree per
|
|
205
|
+
* slot when the request named a `reviewSha`. Freshness is the operator's verdict on the Sandbox,
|
|
206
|
+
* which the Orchestrator reads itself — never the Harness's to report. */
|
|
207
|
+
export type AttachResponse = { repos: Record<string, string>; review?: Record<string, string> };
|
|
208
|
+
|
|
209
|
+
/** What a failed attach answers: 400 for a body that is not an `AttachRequest`, 500 when a step
|
|
210
|
+
* failed — `slot` names the Repo Slot, and `error` carries the step and git's own stderr. */
|
|
211
|
+
export type AttachError = { error: string; slot?: string };
|
|
212
|
+
|
|
213
|
+
// --- the exec (a `command` Effect, ADR-0067) ----------------------------------------------------
|
|
214
|
+
// A `command` Effect runs in the Harness container of its Workspace's Sandbox, named by its
|
|
215
|
+
// Effect's id: one id runs once, whoever asks again. Same bearer as every Harness route (ADR-0058).
|
|
216
|
+
|
|
217
|
+
// --- the sync (ADR-0067) ------------------------------------------------------------------------
|
|
218
|
+
// After a push whose published commit is not the Agent's own — signed copies of its commits, or
|
|
219
|
+
// one squashed commit, signed or not — the sync moves the Agent's branch to it, here, in the
|
|
220
|
+
// Harness container, with one `update-ref` (files, index, HEAD and stash do not change). The
|
|
221
|
+
// bundle of the published commits travels through the Orchestrator. Same bearer as every Harness route (ADR-0058).
|
|
222
|
+
|
|
223
|
+
/** What `POST /sync` accepts: the branch Worktree (absolute, under `/work`), the branch, the pair
|
|
224
|
+
* the push published (`published`, the published tip; `source`, the Agent's commit it re-creates) and,
|
|
225
|
+
* when the push just ran, the bundle of the published commits (base64). With no bundle the published
|
|
226
|
+
* commits must already be in the Agent's store (a sync run again). */
|
|
227
|
+
export type SyncRequest = { worktree: string; branch: string; published: string; source: string; bundle?: string };
|
|
228
|
+
|
|
229
|
+
/** What the sync answers — a report, which the next push never trusts. `synced`: the branch moved
|
|
230
|
+
* (`replayed` commits the Agent made after the push were copied onto the published tip). `already`:
|
|
231
|
+
* the branch holds the published tip. `skipped`: the push published the Agent's own commit. `busy`: a rebase, merge,
|
|
232
|
+
* cherry-pick or revert of the branch is in progress; nothing changed. `not-synced`: the branch
|
|
233
|
+
* did not move (`reason` says why). */
|
|
234
|
+
export type SyncAnswer =
|
|
235
|
+
| { sync: "synced"; from: string; to: string; replayed: number; tries: number }
|
|
236
|
+
| { sync: "already" }
|
|
237
|
+
| { sync: "skipped" | "busy" | "not-synced"; reason: string };
|
|
238
|
+
|
|
239
|
+
/** What `POST /exec/:id` accepts: argv (never shell text), an absolute `cwd` or none, the timeout,
|
|
240
|
+
* and how many characters of the stream's end the output keeps. */
|
|
241
|
+
export type ExecRequest = { argv: string[]; cwd?: string; timeoutMs: number; tail: number };
|
|
242
|
+
|
|
243
|
+
/** How an exec ended. Every one is output; none is a fault. */
|
|
244
|
+
export type ExecOutcome =
|
|
245
|
+
| { outcome: "exited"; code: number; tail: string }
|
|
246
|
+
| { outcome: "timeout"; tail: string }
|
|
247
|
+
| { outcome: "killed"; signal: string; tail: string };
|
|
248
|
+
|
|
249
|
+
/** What `GET /exec/:id` answers: the stream from `offset` to `next`, and the output once settled. A
|
|
250
|
+
* read with no `offset` starts at the end. */
|
|
251
|
+
export type ExecRead = {
|
|
252
|
+
state: "running" | "settled";
|
|
253
|
+
stream: string;
|
|
254
|
+
next: number;
|
|
255
|
+
dropped?: number;
|
|
256
|
+
output?: ExecOutcome;
|
|
257
|
+
};
|