@jr2/orchestrator 0.3.0 → 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 -4
- package/src/actor.ts +502 -49
- 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 +152 -46
- package/src/content-hash.ts +105 -0
- package/src/custodian.lua +6 -5
- package/src/custodian.ts +71 -19
- 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 +245 -23
- package/src/held-secrets.ts +82 -26
- package/src/holds.ts +156 -0
- package/src/http-effect.ts +981 -0
- package/src/http.ts +496 -104
- package/src/images.ts +47 -21
- package/src/index.ts +43 -2
- package/src/instance.ts +444 -58
- package/src/kube-client.ts +333 -0
- package/src/machine-doc.ts +88 -6
- package/src/mount-digest.ts +53 -0
- package/src/names.ts +66 -8
- 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 +60 -85
- package/src/repos.ts +63 -74
- package/src/rest.ts +133 -0
- package/src/run-host.ts +1832 -98
- 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 +281 -84
- 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 +35 -4
- package/src/vocabulary.ts +66 -1
- package/src/wire.ts +156 -2
- package/src/workspace.ts +529 -194
- package/src/sandbox-kubectl.ts +0 -1108
package/src/http.ts
CHANGED
|
@@ -14,8 +14,20 @@
|
|
|
14
14
|
// # the Agent's Harness, through its Custodian, and nothing else
|
|
15
15
|
// GET /agents/:iid/surface accepts + schemas + semantics [Sandbox token]
|
|
16
16
|
// POST /agents/:iid/events validate + deliver → the turn receipt [Sandbox token]
|
|
17
|
+
// GET /agents/:iid/deliveries/:id long-poll an outstanding pick's answer [Sandbox token]
|
|
17
18
|
// POST /sandboxes/:name/fetch ask the node cache to fetch one Repo [Sandbox token]
|
|
18
19
|
//
|
|
20
|
+
// # named Charts (ADR-0070): a Chart under a name is a Workflow `<wrapper>/<name>`
|
|
21
|
+
// GET /workflows/:wrapper/chart-schema the grammar, narrowed to the Open body [open]
|
|
22
|
+
// PUT /workflows/:wrapper/charts/:name admit + keep a runtime Chart [Instance token]
|
|
23
|
+
// DELETE /workflows/:wrapper/charts/:name remove a runtime Chart [Instance token]
|
|
24
|
+
// POST /workflows/:wrapper/charts/:name/runs start the named Chart [Instance token]
|
|
25
|
+
// GET /workflows/:name/runs/:runId/machine the run's bound Machine, Charts compiled [open]
|
|
26
|
+
//
|
|
27
|
+
// # `jr2 up`, and nothing else (ADR-0069)
|
|
28
|
+
// PUT /assembly publish the next Assembly [publish token]
|
|
29
|
+
// GET /assembly the current Assembly and the held ones [open]
|
|
30
|
+
//
|
|
19
31
|
// The token is not decoration: an Agent has code execution in its Harness container and shares the
|
|
20
32
|
// pod's network namespace, so it can reach these routes. A Sandbox token may deliver ONLY to an
|
|
21
33
|
// agent surface recorded against its own Sandbox — never to a Gate. That is what stops an Agent
|
|
@@ -41,16 +53,21 @@ import { Hono } from "hono";
|
|
|
41
53
|
import type { Context, MiddlewareHandler } from "hono";
|
|
42
54
|
import { accepts } from "hono/accepts";
|
|
43
55
|
import { streamSSE } from "hono/streaming";
|
|
44
|
-
import { EventValidationError, UnknownAddressError } from "./registration.ts";
|
|
56
|
+
import { EventValidationError, NotTakenError, OutstandingError, UnknownAddressError } from "./registration.ts";
|
|
45
57
|
import type { RepoStatus } from "./repos.ts";
|
|
46
58
|
import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
|
|
47
59
|
import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
|
|
48
|
-
import { observe, type RunHost } from "./run-host.ts";
|
|
60
|
+
import { observe, type RunFeedEvent, type RunHost, type RunStatus, type WorkflowFeedEvent } from "./run-host.ts";
|
|
49
61
|
import { KIT_VERSION } from "./config.ts";
|
|
62
|
+
import type { AssemblyDoor } from "./assembly.ts";
|
|
63
|
+
import { ChartRefusal } from "./chart.ts";
|
|
64
|
+
import { chartName, NamedChartError, splitChartName, type NamedCharts } from "./charts.ts";
|
|
50
65
|
|
|
51
66
|
/** A `POST /runs/:id/events` body: the down-channel event. CANCEL is all that is left of it
|
|
52
|
-
* (ADR-0013): APPROVE and STEER rode the deferred/poll machinery
|
|
53
|
-
|
|
67
|
+
* (ADR-0013): APPROVE and STEER rode the deferred/poll machinery: `deferred`
|
|
68
|
+
* is retired (ADR-0006), `poll` reserved but not built.
|
|
69
|
+
* `keep` leaves the cancelled run's Workspaces for inspection instead of destroying them (ADR-0025). */
|
|
70
|
+
type RunEventBody = { type?: string; keep?: unknown };
|
|
54
71
|
|
|
55
72
|
/** `GET /runs/resolve` floor — a 1-char prefix is a table scan, not a question. The CLI enforces
|
|
56
73
|
* the same floor on the argument; this one guards the scan regardless of who is calling. */
|
|
@@ -77,6 +94,15 @@ function errMessage(err: unknown): string {
|
|
|
77
94
|
return err instanceof Error ? err.message : String(err);
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/** The 503 body while a deferred restore is pending (ADR-0026, ADR-0069): ask again. */
|
|
98
|
+
function restorePendingMessage(what: string): string {
|
|
99
|
+
return `the Orchestrator has not restored its runs yet (no current Assembly) — ask again for ${what}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Why the publish token is refused everywhere but `PUT /assembly` (ADR-0069): it is not an
|
|
103
|
+
* Instance principal, so it reads no run, starts none, and speaks for no Agent. */
|
|
104
|
+
const PUBLISHER_ONLY = "the publish token publishes an Assembly (PUT /assembly) and does nothing else";
|
|
105
|
+
|
|
80
106
|
/** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
|
|
81
107
|
type JR2Env = { Variables: { principal: Principal } };
|
|
82
108
|
|
|
@@ -231,36 +257,141 @@ const PING_MS = 15_000;
|
|
|
231
257
|
* is the `error: terminated` an attached `jr2 run` hit). The frame is an SSE COMMENT (`:\n\n`):
|
|
232
258
|
* every client ignores it, so it needs no place in the wire vocabulary.
|
|
233
259
|
*
|
|
234
|
-
* It is deliberately a **ping**, not a heartbeat or keepalive —
|
|
260
|
+
* It is deliberately a **ping**, not a heartbeat or keepalive — GLOSSARY.md puts both on the Lease's
|
|
235
261
|
* Avoid list, and this asserts nothing and expects no answer.
|
|
236
262
|
*
|
|
237
263
|
* It is NOT a liveness probe, and must not be mistaken for one: `stream.write` swallows its own
|
|
238
264
|
* errors (hono's `StreamingApi`), so a failed write is indistinguishable from a good one. A peer
|
|
239
265
|
* that goes away is detected by `stream.onAbort`, which is what every handler here wires to its
|
|
240
266
|
* exit. The tick checks `aborted`/`closed` only so a ping that fires between the abort and the
|
|
241
|
-
* teardown does not write into a dead stream.
|
|
267
|
+
* teardown does not write into a dead stream.
|
|
268
|
+
*
|
|
269
|
+
* The tick is also where a subscriber is judged (ADR-0022): a reader {@link Subscriber.behind} is
|
|
270
|
+
* closed here, and the ping itself goes out through the subscriber, so a reader stalled on a quiet
|
|
271
|
+
* feed falls behind too. Returns its own clear fn.
|
|
242
272
|
*/
|
|
243
|
-
function pinger(stream: SseStream, done: () => void, everyMs = PING_MS): () => void {
|
|
273
|
+
function pinger(stream: SseStream, sub: Subscriber, done: () => void, everyMs = PING_MS): () => void {
|
|
244
274
|
const timer = setInterval(() => {
|
|
245
275
|
if (stream.aborted || stream.closed) return done();
|
|
246
|
-
|
|
276
|
+
if (sub.behind()) {
|
|
277
|
+
// Aborted, not drained: the frames it would never read are dropped with it.
|
|
278
|
+
stream.abort();
|
|
279
|
+
return done();
|
|
280
|
+
}
|
|
281
|
+
void sub.send(":\n\n");
|
|
247
282
|
}, everyMs);
|
|
248
283
|
return () => clearInterval(timer);
|
|
249
284
|
}
|
|
250
285
|
|
|
251
|
-
/** The slice of hono's `StreamingApi` the
|
|
252
|
-
* comment frame)
|
|
253
|
-
|
|
286
|
+
/** The slice of hono's `StreamingApi` the feeds need: the raw write (a frame is rendered here, and
|
|
287
|
+
* `writeSSE` cannot express a comment frame), the two flags that say the peer is gone, and the
|
|
288
|
+
* abort that closes a reader that fell behind. */
|
|
289
|
+
type SseStream = { write: (s: string) => Promise<unknown>; aborted: boolean; closed: boolean; abort: () => void };
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* How many writes one subscriber may leave unread across a whole ping interval before it is closed
|
|
293
|
+
* (ADR-0022). A reader this far behind is not catching up, and holding frames for it is a per-client
|
|
294
|
+
* queue the server does not keep: the feeds are level-triggered, so closing it loses no state — the
|
|
295
|
+
* reader reconnects (EventSource on its own retry, the CLI on the stream's end) and the opening
|
|
296
|
+
* frame is the whole current truth.
|
|
297
|
+
*/
|
|
298
|
+
const BACKLOG_FRAMES = 64;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* One SSE frame, spelled as hono's `writeSSE` spells it. JSON has no raw newline, so the payload is
|
|
302
|
+
* always one `data:` line.
|
|
303
|
+
*/
|
|
304
|
+
function sseFrame(event: string, data: unknown, retry?: number): string {
|
|
305
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n${retry ? `retry: ${retry}\n` : ""}\n`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* A run that left the live set WITHOUT settling — stopped, so parked for the next boot's restore
|
|
310
|
+
* (ADR-0025) — reads through as `active`, which is the truth about the Machine and says nothing
|
|
311
|
+
* about the feed. `gone` is a fact, not an inference (ADR-0022): stated here, so a reader that
|
|
312
|
+
* re-attaches whenever a feed ends before a terminal status (the CLI) knows this end is the run's
|
|
313
|
+
* own and does not re-attach in a loop.
|
|
314
|
+
*/
|
|
315
|
+
async function sayGoneIfParked(
|
|
316
|
+
stream: { writeSSE: (m: { event: string; data: string }) => Promise<unknown> },
|
|
317
|
+
status: RunStatus,
|
|
318
|
+
): Promise<void> {
|
|
319
|
+
if (status.status !== "active") return;
|
|
320
|
+
await stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: status.runId }) });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Render each feed event ONCE, however many subscribers share it (ADR-0022). The host hands every
|
|
325
|
+
* listener of a feed the same event object, so the object is the key, and the frame is released
|
|
326
|
+
* with it. `undefined` is a rendering too — "this band does not carry it" — and is cached alike.
|
|
327
|
+
*/
|
|
328
|
+
function renderOnce<E extends object, F>(render: (event: E) => F): (event: E) => F {
|
|
329
|
+
const frames = new WeakMap<E, F>();
|
|
330
|
+
return (event) => {
|
|
331
|
+
if (frames.has(event)) return frames.get(event) as F;
|
|
332
|
+
const frame = render(event);
|
|
333
|
+
frames.set(event, frame);
|
|
334
|
+
return frame;
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One subscriber's writes, and the one question asked of them at each ping. */
|
|
339
|
+
type Subscriber = {
|
|
340
|
+
/** Write a shared frame as-is. */
|
|
341
|
+
send: (frame: string) => Promise<void>;
|
|
342
|
+
/** Whether {@link BACKLOG_FRAMES} writes sent before the previous ping are still unread. */
|
|
343
|
+
behind: () => boolean;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* One subscriber (ADR-0022): the only state kept per client is a count of its writes. A reader is
|
|
348
|
+
* judged on LAG, never on burst size — one transition's Emits, or a Pool's statuses, reach every
|
|
349
|
+
* subscriber in one tick, and a reader that keeps up drains them long before the next ping. What is
|
|
350
|
+
* still unread a whole interval after it was sent is a reader that fell behind.
|
|
351
|
+
*/
|
|
352
|
+
function subscriber(stream: SseStream): Subscriber {
|
|
353
|
+
let sent = 0;
|
|
354
|
+
let read = 0;
|
|
355
|
+
let sentByLastPing = 0;
|
|
356
|
+
return {
|
|
357
|
+
async send(frame) {
|
|
358
|
+
sent++;
|
|
359
|
+
try {
|
|
360
|
+
await stream.write(frame);
|
|
361
|
+
} finally {
|
|
362
|
+
read++; // a stream's writes settle in order, so this counts a prefix
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
behind() {
|
|
366
|
+
const unread = sentByLastPing - read;
|
|
367
|
+
sentByLastPing = sent;
|
|
368
|
+
return unread >= BACKLOG_FRAMES;
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
254
372
|
|
|
255
373
|
export type CreateAppOptions = {
|
|
256
374
|
/** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
|
|
257
375
|
pingMs?: number;
|
|
258
376
|
/**
|
|
259
|
-
* Whether this instance has a data plane at all (ADR-0051):
|
|
260
|
-
*
|
|
261
|
-
* no Workspace" — and `jr2 status` says so rather than listing
|
|
377
|
+
* Whether this instance has a data plane at all (ADR-0051, ADR-0069): the process is deployed in
|
|
378
|
+
* a cluster, so it wires its kube client whatever the current Assembly composes. `false` is an
|
|
379
|
+
* answer — "this instance runs no Workspace" — and `jr2 status` says so rather than listing
|
|
380
|
+
* nothing.
|
|
262
381
|
*/
|
|
263
382
|
dataPlane?: boolean;
|
|
383
|
+
/**
|
|
384
|
+
* The publish door and the current Assembly (ADR-0069). Absent for the in-process tests that
|
|
385
|
+
* build an app straight over a host: `GET /assembly` then answers no current Assembly, and
|
|
386
|
+
* `PUT /assembly` answers 404.
|
|
387
|
+
*/
|
|
388
|
+
assembly?: AssemblyDoor;
|
|
389
|
+
/**
|
|
390
|
+
* The digests of the ConfigMaps this process reads through a mount (ADR-0069), as
|
|
391
|
+
* `readMountDigests` reads them. `GET /assembly` reports them, so `jr2 up` can wait for the
|
|
392
|
+
* kubelet before it publishes. Absent when not deployed: nothing is mounted.
|
|
393
|
+
*/
|
|
394
|
+
mounts?: () => Promise<Record<string, string>>;
|
|
264
395
|
/**
|
|
265
396
|
* The Repo resources as the cluster currently reports them (ADR-0051), read per request off the
|
|
266
397
|
* port — a cache the agent cloned minutes after boot reads as present the next time anyone asks.
|
|
@@ -275,6 +406,17 @@ export type CreateAppOptions = {
|
|
|
275
406
|
* ask, and the route says so rather than waiting on a cluster that is not there.
|
|
276
407
|
*/
|
|
277
408
|
fetchRepo?: (sandbox: string, identity: string) => Promise<FetchAnswer>;
|
|
409
|
+
/** How long one poll of an outstanding pick waits for its answer before it answers `outstanding`
|
|
410
|
+
* (ADR-0013, ADR-0024). Default 25 s: every request stays far below undici's 300 s and the
|
|
411
|
+
* Custodian's idle limit. Tests shorten it. */
|
|
412
|
+
deliveryWaitMs?: number;
|
|
413
|
+
/**
|
|
414
|
+
* The named Charts of the Instance (ADR-0070): what `PUT`/`DELETE /workflows/:wrapper/charts/:name`
|
|
415
|
+
* change, what `GET /workflows/:wrapper/chart-schema` narrows the grammar from, and the
|
|
416
|
+
* unrunnable ones `GET /workflows` adds to the host's listing. Absent for the in-process tests
|
|
417
|
+
* that build an app straight over a host: those routes then answer 404.
|
|
418
|
+
*/
|
|
419
|
+
charts?: NamedCharts;
|
|
278
420
|
};
|
|
279
421
|
|
|
280
422
|
/**
|
|
@@ -294,6 +436,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
294
436
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
295
437
|
const principal = auth(bearerOf(c));
|
|
296
438
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
439
|
+
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
297
440
|
c.set("principal", principal);
|
|
298
441
|
return next();
|
|
299
442
|
};
|
|
@@ -312,6 +455,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
312
455
|
if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
|
|
313
456
|
const principal = auth(bearerOf(c));
|
|
314
457
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
458
|
+
if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
|
|
315
459
|
if (principal.kind !== "instance") {
|
|
316
460
|
return c.json({ error: "run state and control need the Instance token — a Sandbox token has neither" }, 403);
|
|
317
461
|
}
|
|
@@ -323,16 +467,166 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
323
467
|
// readiness probe's target and because identity is not run state — it is the same class of thing
|
|
324
468
|
// as the route table, which is public by being served. The CLI probes it to explain a failure it
|
|
325
469
|
// could otherwise only report as a bare status code (version skew reads as a nonsense 404).
|
|
326
|
-
// `hash` is the
|
|
327
|
-
|
|
328
|
-
app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: process.env.JR2_CONTENT_HASH }));
|
|
470
|
+
// `hash` is the current Assembly's (ADR-0069), absent while the process holds none.
|
|
471
|
+
app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: opts.assembly?.current()?.hash }));
|
|
329
472
|
app.get("/readyz", (c) => c.json({ ready: true }));
|
|
330
473
|
|
|
331
474
|
// Structure, not state: the workflow listing, a template's Machine, and the Console shell are
|
|
332
475
|
// unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
|
|
333
476
|
// loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
|
|
334
477
|
// below.
|
|
335
|
-
|
|
478
|
+
// Each Workflow with its origin, and each Chart wrapper's Charts inside its entry (ADR-0070); a
|
|
479
|
+
// named Chart that cannot run now is listed with why.
|
|
480
|
+
app.get("/workflows", (c) => {
|
|
481
|
+
const listing = host.workflowList();
|
|
482
|
+
return c.json(opts.charts ? opts.charts.listing(listing) : listing);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// The current Assembly and the ones live runs hold (ADR-0069). Open like `/workflows`: a hash
|
|
486
|
+
// and a Kit version are identity, the class of thing `/healthz` already serves.
|
|
487
|
+
// `mounts` is what this process reads from its mounted ConfigMaps, so a publish can wait until
|
|
488
|
+
// the kubelet has delivered the data the converge wrote.
|
|
489
|
+
app.get("/assembly", async (c) => {
|
|
490
|
+
const view = opts.assembly ? await opts.assembly.view() : { kitVersion: KIT_VERSION, current: null, held: [] };
|
|
491
|
+
return c.json(opts.mounts ? { ...view, mounts: await opts.mounts() } : view);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// The publish door (ADR-0069): the publish token ALONE. Publishing imports code into the process
|
|
495
|
+
// that holds the ServiceAccount, so the Instance token, which starts runs and answers Gates, is
|
|
496
|
+
// refused here by name. The checks after authentication are the door's (assembly.ts), each with
|
|
497
|
+
// its own status, and the old Assembly stays current through every refusal.
|
|
498
|
+
app.put("/assembly", async (c) => {
|
|
499
|
+
if (auth) {
|
|
500
|
+
const principal = auth(bearerOf(c));
|
|
501
|
+
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
502
|
+
if (principal.kind !== "publisher") {
|
|
503
|
+
return c.json(
|
|
504
|
+
{
|
|
505
|
+
error:
|
|
506
|
+
"publishing an Assembly needs the publish token (JR2_PUBLISH_TOKEN in the instance Secret), which " +
|
|
507
|
+
"`jr2 up` reads through the kube API — the Instance token does not publish (ADR-0069)",
|
|
508
|
+
},
|
|
509
|
+
403,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!opts.assembly) return c.json({ error: "this instance has no publish door" }, 404);
|
|
514
|
+
const answer = await opts.assembly.publish({
|
|
515
|
+
kitVersion: c.req.header("jr2-kit-version"),
|
|
516
|
+
hash: c.req.header("jr2-assembly-hash"),
|
|
517
|
+
body: c.req.raw.body,
|
|
518
|
+
});
|
|
519
|
+
return c.json(answer.body, answer.status);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// ---- Named Charts (ADR-0070) ----------------------------------------------------------------
|
|
523
|
+
// Before every `/workflows/:name/*` route. A Chart is data and cannot reach the process, so a
|
|
524
|
+
// runtime Chart is kept behind the Instance token, not the publish token; the door that admits it
|
|
525
|
+
// is the one a run's Chart passes. A Chart file is the Assembly's own: no route changes one.
|
|
526
|
+
|
|
527
|
+
/** A `NamedChartError` as its answer. */
|
|
528
|
+
const chartError = (c: Context<JR2Env>, err: unknown): Response => {
|
|
529
|
+
if (err instanceof NamedChartError) return c.json(err.body, err.status);
|
|
530
|
+
throw err;
|
|
531
|
+
};
|
|
532
|
+
const noCharts = (c: Context<JR2Env>) => c.json({ error: "this instance keeps no named Charts" }, 404);
|
|
533
|
+
|
|
534
|
+
// The grammar narrowed to the wrapper's Open body: its lent names, guard, action and delay names,
|
|
535
|
+
// allowed models and Workspace accesses as `enum`s. Structure, like the Machine doc: open band.
|
|
536
|
+
app.get("/workflows/:wrapper/chart-schema", (c) => {
|
|
537
|
+
const wrapper = c.req.param("wrapper");
|
|
538
|
+
const schema = opts.charts?.schema(wrapper);
|
|
539
|
+
return schema ? c.json(schema) : c.json({ error: `no Chart wrapper "${wrapper}"` }, 404);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// The body is the Chart text alone. 201 when new, 200 when it replaced one: a live run of the
|
|
543
|
+
// old text holds its own.
|
|
544
|
+
app.put("/workflows/:wrapper/charts/:name", instanceOnly, async (c) => {
|
|
545
|
+
if (!opts.charts) return noCharts(c);
|
|
546
|
+
let text: unknown;
|
|
547
|
+
try {
|
|
548
|
+
text = JSON.parse(await c.req.text());
|
|
549
|
+
} catch (err) {
|
|
550
|
+
return c.json({ error: "refused", report: [`(chart): the body is not JSON: ${errMessage(err)}`] }, 422);
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
const put = await opts.charts.put(c.req.param("wrapper"), c.req.param("name"), text);
|
|
554
|
+
// `unrunnable`: a publish bound a wrapper that refuses the text while it was written. It is
|
|
555
|
+
// kept on the volume and listed with that reason, as each publish's re-admission does.
|
|
556
|
+
return c.json(
|
|
557
|
+
{
|
|
558
|
+
name: put.name,
|
|
559
|
+
origin: "runtime",
|
|
560
|
+
hash: put.hash,
|
|
561
|
+
...(put.unrunnable !== undefined ? { unrunnable: put.unrunnable } : {}),
|
|
562
|
+
},
|
|
563
|
+
put.created ? 201 : 200,
|
|
564
|
+
);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
return chartError(c, err);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
app.delete("/workflows/:wrapper/charts/:name", instanceOnly, async (c) => {
|
|
571
|
+
if (!opts.charts) return noCharts(c);
|
|
572
|
+
try {
|
|
573
|
+
await opts.charts.remove(c.req.param("wrapper"), c.req.param("name"));
|
|
574
|
+
return c.body(null, 204);
|
|
575
|
+
} catch (err) {
|
|
576
|
+
return chartError(c, err);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Start a run of a Workflow (ADR-0009). A named Chart that cannot run now is 409 with why
|
|
582
|
+
* (ADR-0070); a Chart in the input that the door refuses is 422 with the report; input failing
|
|
583
|
+
* the door schema is 400 (ADR-0033); an unknown Workflow is 404.
|
|
584
|
+
*/
|
|
585
|
+
const startRun = async (c: Context<JR2Env>, name: string): Promise<Response> => {
|
|
586
|
+
const unrunnable = opts.charts?.get(name)?.unrunnable;
|
|
587
|
+
if (unrunnable !== undefined) return c.json({ error: `named Chart "${name}" cannot run: ${unrunnable}` }, 409);
|
|
588
|
+
const input = await readJson(c.req.text());
|
|
589
|
+
try {
|
|
590
|
+
const { runId, instanceId } = await host.start(name, input);
|
|
591
|
+
return c.json({ runId, instanceId }, 201);
|
|
592
|
+
} catch (err) {
|
|
593
|
+
if (err instanceof ChartRefusal) return c.json({ error: "refused", report: err.report }, 422);
|
|
594
|
+
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
595
|
+
return c.json({ error: errMessage(err) }, 404);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// The documented start of a named Chart; `POST /workflows/<wrapper>%2F<name>/runs` is the same.
|
|
600
|
+
app.post("/workflows/:wrapper/charts/:name/runs", instanceOnly, (c) =>
|
|
601
|
+
startRun(c, chartName(c.req.param("wrapper"), c.req.param("name"))),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
// The run's bound Machine (ADR-0070): the wrapper's doc with each bound body's Chart compiled in,
|
|
605
|
+
// so the Console draws the states a Chart run moves through. Structure: open band, and only
|
|
606
|
+
// through the Workflow the run is of, as the per-run feed is: an observer names the Workflow
|
|
607
|
+
// it already knows. A settled run's doc is served too, so the Console draws where it ended.
|
|
608
|
+
app.get("/workflows/:name/runs/:runId/machine", async (c) => {
|
|
609
|
+
const runId = c.req.param("runId");
|
|
610
|
+
const doc = await host.runMachine(runId);
|
|
611
|
+
return doc && doc.workflow === c.req.param("name")
|
|
612
|
+
? c.json(doc)
|
|
613
|
+
: c.json({ error: `no run "${runId}" of workflow "${c.req.param("name")}"` }, 404);
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
/** A named Chart that is listed but cannot run now (ADR-0070): 409 with why, as its start is,
|
|
617
|
+
* never a 404 for a name `GET /workflows` lists. */
|
|
618
|
+
const unrunnableChart = (c: Context<JR2Env>, name: string): Response | undefined => {
|
|
619
|
+
const named = opts.charts?.get(name);
|
|
620
|
+
if (named?.unrunnable === undefined) return undefined;
|
|
621
|
+
return c.json(
|
|
622
|
+
{
|
|
623
|
+
error: `named Chart "${name}" cannot run: ${named.unrunnable}`,
|
|
624
|
+
unrunnable: named.unrunnable,
|
|
625
|
+
chart: { origin: named.origin, hash: named.hash },
|
|
626
|
+
},
|
|
627
|
+
409,
|
|
628
|
+
);
|
|
629
|
+
};
|
|
336
630
|
|
|
337
631
|
// The two PAGE addresses (ADR-0032): `/` and `/workflows/:name` negotiate. A browser navigation
|
|
338
632
|
// (`Accept` prefers text/html) gets the one Console shell — the path carries the selection, the
|
|
@@ -351,8 +645,19 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
351
645
|
if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
|
|
352
646
|
const name = c.req.param("name");
|
|
353
647
|
const doc = host.machine(name);
|
|
354
|
-
if (!doc) return c.json({ error: `no workflow "${name}"` }, 404);
|
|
355
|
-
|
|
648
|
+
if (!doc) return unrunnableChart(c, name) ?? c.json({ error: `no workflow "${name}"` }, 404);
|
|
649
|
+
// A named Chart answers its door minus the Chart fields, and its origin and hash; a Chart
|
|
650
|
+
// wrapper answers its Open bodies' declarations, for a client that writes Charts (ADR-0070).
|
|
651
|
+
const split = splitChartName(name);
|
|
652
|
+
const named = split && (opts.charts?.get(name) ?? host.namedChart(split.wrapper, split.name));
|
|
653
|
+
const wrapper = split ? undefined : opts.charts?.wrapperOf(name);
|
|
654
|
+
return c.json({
|
|
655
|
+
name,
|
|
656
|
+
machineId: doc.id,
|
|
657
|
+
input: host.inputSchema(name) ?? null,
|
|
658
|
+
...(named ? { chart: { origin: named.origin, hash: named.hash } } : {}),
|
|
659
|
+
...(wrapper ? { openBodies: wrapper.bodies.map((b) => ({ slot: b.slot, ...b.declaration })) } : {}),
|
|
660
|
+
});
|
|
356
661
|
});
|
|
357
662
|
|
|
358
663
|
// Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
|
|
@@ -361,24 +666,16 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
361
666
|
// schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
|
|
362
667
|
// run control, and a Sandbox token's scope is its own agent surface — the Custodian's route
|
|
363
668
|
// allowlist not forwarding this route is a second layer, not the control (ADR-0059).
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
const { runId, instanceId } = await host.start(name, input);
|
|
369
|
-
return c.json({ runId, instanceId }, 201);
|
|
370
|
-
} catch (err) {
|
|
371
|
-
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
372
|
-
return c.json({ error: errMessage(err) }, 404);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
669
|
+
// A Chart wrapper's one-shot door (ADR-0070): a Chart field in the body is admitted before the
|
|
670
|
+
// run starts, and a refusal is 422 with the report. A named Chart rides the path encoded.
|
|
671
|
+
app.post("/workflows/:name/runs", instanceOnly, (c) => startRun(c, c.req.param("name")));
|
|
375
672
|
|
|
376
673
|
// The registered template Machine's structure — what the Console's diagram renders (structure
|
|
377
674
|
// is provider-independent, so the un-`provide()`d template is exactly right).
|
|
378
675
|
app.get("/workflows/:name/machine", (c) => {
|
|
379
676
|
const name = c.req.param("name");
|
|
380
677
|
const doc = host.machine(name);
|
|
381
|
-
return doc ? c.json(doc) : c.json({ error: `no workflow "${name}"` }, 404);
|
|
678
|
+
return doc ? c.json(doc) : (unrunnableChart(c, name) ?? c.json({ error: `no workflow "${name}"` }, 404));
|
|
382
679
|
});
|
|
383
680
|
|
|
384
681
|
// ---- Observation (`GET /workflows/:name/runs*`) ---------------------------------------------
|
|
@@ -395,6 +692,18 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
395
692
|
|
|
396
693
|
app.get("/workflows/:name/runs", (c) => c.json(host.observations(c.req.param("name"))));
|
|
397
694
|
|
|
695
|
+
// The workflow feed, rendered once per event for every page attached to it (ADR-0022): the
|
|
696
|
+
// `observe()` projection and its JSON happen once, not once per open page.
|
|
697
|
+
const workflowFrame = renderOnce((ev: Exclude<WorkflowFeedEvent, { kind: "closed" }>) => {
|
|
698
|
+
if (ev.kind === "gone") return sseFrame("gone", { runId: ev.runId });
|
|
699
|
+
// The TYPE alone: an emit's payload is author data, the same class of thing as context.
|
|
700
|
+
if (ev.kind === "emit") return sseFrame("emit", { runId: ev.runId, type: ev.event.type });
|
|
701
|
+
// `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
|
|
702
|
+
// Instance token like `fault` (ADR-0014/0016).
|
|
703
|
+
if (ev.kind === "retry") return sseFrame("retry", { runId: ev.runId, child: ev.child, attempt: ev.attempt });
|
|
704
|
+
return sseFrame("status", observe(ev.status));
|
|
705
|
+
});
|
|
706
|
+
|
|
398
707
|
/**
|
|
399
708
|
* SSE: a whole WORKFLOW's activity (ADR-0022) — every run of it appearing, moving, emitting and
|
|
400
709
|
* leaving, on one connection that outlives all of them.
|
|
@@ -416,40 +725,46 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
416
725
|
return streamSSE(c, async (stream) => {
|
|
417
726
|
await new Promise<void>((resolve) => {
|
|
418
727
|
const exit = closer(resolve);
|
|
728
|
+
const sub = subscriber(stream);
|
|
419
729
|
// Subscribe and snapshot in one call, then write the snapshot in the same tick: nothing can
|
|
420
730
|
// start, move or finish in between, so the client's first frame is a complete picture.
|
|
421
731
|
const { runs, unsubscribe } = host.observeWorkflow(name, (ev) => {
|
|
422
732
|
if (ev.kind === "closed") return exit.done();
|
|
423
|
-
|
|
424
|
-
void stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: ev.runId }) });
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
if (ev.kind === "emit") {
|
|
428
|
-
// The TYPE alone: an emit's payload is author data, the same class of thing as context.
|
|
429
|
-
void stream.writeSSE({ event: "emit", data: JSON.stringify({ runId: ev.runId, type: ev.event.type }) });
|
|
430
|
-
return;
|
|
431
|
-
}
|
|
432
|
-
if (ev.kind === "retry") {
|
|
433
|
-
// `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
|
|
434
|
-
// Instance token like `fault` (ADR-0014/0016).
|
|
435
|
-
void stream.writeSSE({
|
|
436
|
-
event: "retry",
|
|
437
|
-
data: JSON.stringify({ runId: ev.runId, child: ev.child, attempt: ev.attempt }),
|
|
438
|
-
});
|
|
439
|
-
return;
|
|
440
|
-
}
|
|
441
|
-
void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) });
|
|
733
|
+
void sub.send(workflowFrame(ev));
|
|
442
734
|
});
|
|
443
735
|
exit.onExit(unsubscribe);
|
|
444
736
|
// `retry` steers the browser's own EventSource backoff. This feed never ends on its own, so
|
|
445
|
-
// every close is a fault worth reconnecting from — the client does not decide that.
|
|
446
|
-
|
|
447
|
-
|
|
737
|
+
// every close is a fault worth reconnecting from — the client does not decide that. The
|
|
738
|
+
// snapshot is this subscriber's own, so it is the one frame rendered per client.
|
|
739
|
+
void sub.send(sseFrame("runs", runs.map(observe), 2000));
|
|
740
|
+
exit.onExit(pinger(stream, sub, exit.done, pingMs));
|
|
448
741
|
stream.onAbort(exit.done);
|
|
449
742
|
});
|
|
450
743
|
});
|
|
451
744
|
});
|
|
452
745
|
|
|
746
|
+
// The per-run feed in the OPEN band, rendered once per event for all its subscribers (ADR-0022).
|
|
747
|
+
// `undefined` = this band does not carry it.
|
|
748
|
+
const openRunFrame = renderOnce((ev: Exclude<RunFeedEvent, { kind: "closed" }>) => {
|
|
749
|
+
// The TYPE alone: an emit's payload is author data, the same class of thing as context.
|
|
750
|
+
if (ev.kind === "emit") return sseFrame("emit", { type: ev.event.type });
|
|
751
|
+
// `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
|
|
752
|
+
// Instance token like `fault` (ADR-0014/0016).
|
|
753
|
+
if (ev.kind === "retry") return sseFrame("retry", { child: ev.child, attempt: ev.attempt });
|
|
754
|
+
// Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token class, so
|
|
755
|
+
// the OPEN band never sees them (not even their types).
|
|
756
|
+
if (ev.kind === "admission" || ev.kind === "pick") return undefined;
|
|
757
|
+
// A wait for capacity's lines carry the scheduler's words (ADR-0064): behind the token. The
|
|
758
|
+
// open band sees `placing` lit in the status's children, and nothing more.
|
|
759
|
+
if (ev.kind === "placing" || ev.kind === "placed") return undefined;
|
|
760
|
+
// A child's error below an Agent's state (ADR-0024) is mechanism text, like `fault`: behind the
|
|
761
|
+
// token.
|
|
762
|
+
if (ev.kind === "child-error") return undefined;
|
|
763
|
+
// An Effect's lines (ADR-0067) carry argv and output — working data: behind the token.
|
|
764
|
+
if (ev.kind === "effect") return undefined;
|
|
765
|
+
return sseFrame("status", observe(ev.status));
|
|
766
|
+
});
|
|
767
|
+
|
|
453
768
|
app.get("/workflows/:name/runs/:runId/events", async (c) => {
|
|
454
769
|
const name = c.req.param("name");
|
|
455
770
|
const runId = c.req.param("runId");
|
|
@@ -460,28 +775,17 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
460
775
|
return streamSSE(c, async (stream) => {
|
|
461
776
|
await new Promise<void>((resolve) => {
|
|
462
777
|
const exit = closer(resolve);
|
|
778
|
+
const sub = subscriber(stream);
|
|
463
779
|
exit.onExit(
|
|
464
780
|
host.subscribe(runId, (ev) => {
|
|
465
781
|
// The host is shutting down under a feed that has no end of its own.
|
|
466
782
|
if (ev.kind === "closed") return exit.done();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
void stream.writeSSE({ event: "emit", data: JSON.stringify({ type: ev.event.type }) });
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
if (ev.kind === "retry") {
|
|
473
|
-
// `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind
|
|
474
|
-
// the Instance token like `fault` (ADR-0014/0016).
|
|
475
|
-
void stream.writeSSE({ event: "retry", data: JSON.stringify({ child: ev.child, attempt: ev.attempt }) });
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
// Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token
|
|
479
|
-
// class, so the OPEN band never sees them (not even their types).
|
|
480
|
-
if (ev.kind === "admission" || ev.kind === "pick") return;
|
|
783
|
+
const frame = openRunFrame(ev);
|
|
784
|
+
if (frame === undefined) return;
|
|
481
785
|
// Terminal frame must flush before the handler returns and closes the stream (see the
|
|
482
786
|
// guarded feed below for why the exit is chained off the write).
|
|
483
|
-
const terminal = ev.status.status !== "active";
|
|
484
|
-
void
|
|
787
|
+
const terminal = ev.kind === "status" && ev.status.status !== "active";
|
|
788
|
+
void sub.send(frame).then(() => {
|
|
485
789
|
if (terminal) exit.done();
|
|
486
790
|
});
|
|
487
791
|
}),
|
|
@@ -489,7 +793,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
489
793
|
// Race guard: settled between the liveness check and the subscribe, which then attached to
|
|
490
794
|
// nothing. No read-through on this route, so there is nothing to fall back to — just close.
|
|
491
795
|
if (host.status(runId) === undefined) return exit.done();
|
|
492
|
-
exit.onExit(pinger(stream, exit.done, pingMs));
|
|
796
|
+
exit.onExit(pinger(stream, sub, exit.done, pingMs));
|
|
493
797
|
stream.onAbort(exit.done);
|
|
494
798
|
});
|
|
495
799
|
});
|
|
@@ -587,10 +891,38 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
587
891
|
} catch (err) {
|
|
588
892
|
if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
|
|
589
893
|
if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
|
|
894
|
+
// The run would not take it from this Gate now (ADR-0013): never `{ ok: true }`. The body
|
|
895
|
+
// names what the Gate takes now, as `GET /runs/:id` lists it.
|
|
896
|
+
if (err instanceof NotTakenError) {
|
|
897
|
+
const runId = c.req.param("runId");
|
|
898
|
+
const gate = c.req.param("gate");
|
|
899
|
+
const acceptsNow = host.gates(runId).find((g) => g.gate === gate)?.acceptsNow ?? [];
|
|
900
|
+
return c.json({ error: errMessage(err), acceptsNow }, 409);
|
|
901
|
+
}
|
|
590
902
|
throw err;
|
|
591
903
|
}
|
|
592
904
|
});
|
|
593
905
|
|
|
906
|
+
// The per-run feed in the Instance band, rendered once per event for all its subscribers
|
|
907
|
+
// (ADR-0022).
|
|
908
|
+
const runFrame = renderOnce((ev: Exclude<RunFeedEvent, { kind: "closed" }>) => {
|
|
909
|
+
if (ev.kind === "emit") return sseFrame("emit", ev.event);
|
|
910
|
+
// The Instance's own feed: the full telemetry, reason included (same trust class as `fault`).
|
|
911
|
+
if (ev.kind === "retry") return sseFrame("retry", ev);
|
|
912
|
+
// Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole. These stay
|
|
913
|
+
// OFF the open workflow feed entirely (run-host.ts feeds them per-run).
|
|
914
|
+
if (ev.kind === "admission" || ev.kind === "pick") return sseFrame(ev.kind, ev);
|
|
915
|
+
// A wait for capacity starting and ending (ADR-0064) — Instance-token band too: the message is
|
|
916
|
+
// the scheduler's or the quota's own words.
|
|
917
|
+
if (ev.kind === "placing" || ev.kind === "placed") return sseFrame(ev.kind, ev);
|
|
918
|
+
// A child's error below an Agent's state, which the Agent's answer does not carry (ADR-0024):
|
|
919
|
+
// the operator reads it here, with its message.
|
|
920
|
+
if (ev.kind === "child-error") return sseFrame(ev.kind, ev);
|
|
921
|
+
// An Effect's lines (ADR-0067): its start, its stream, its output, an abort — Instance-token band.
|
|
922
|
+
if (ev.kind === "effect") return sseFrame("effect", ev);
|
|
923
|
+
return sseFrame("status", ev.status);
|
|
924
|
+
});
|
|
925
|
+
|
|
594
926
|
// SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
|
|
595
927
|
// then live until the terminal transition or client abort). A run that has already settled streams
|
|
596
928
|
// its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
|
|
@@ -601,35 +933,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
601
933
|
if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
|
|
602
934
|
return streamSSE(c, async (stream) => {
|
|
603
935
|
await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
|
|
936
|
+
await sayGoneIfParked(stream, finalStatus);
|
|
604
937
|
});
|
|
605
938
|
}
|
|
606
939
|
return streamSSE(c, async (stream) => {
|
|
607
940
|
await new Promise<void>((resolve) => {
|
|
608
941
|
const exit = closer(resolve);
|
|
942
|
+
const sub = subscriber(stream);
|
|
609
943
|
exit.onExit(
|
|
610
944
|
host.subscribe(runId, (ev) => {
|
|
611
945
|
if (ev.kind === "closed") return exit.done();
|
|
612
|
-
if (ev.kind === "emit") {
|
|
613
|
-
void stream.writeSSE({ event: "emit", data: JSON.stringify(ev.event) });
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
if (ev.kind === "retry") {
|
|
617
|
-
// The Instance's own feed: the full telemetry, reason included (same trust class as
|
|
618
|
-
// `fault`).
|
|
619
|
-
void stream.writeSSE({ event: "retry", data: JSON.stringify(ev) });
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
if (ev.kind === "admission" || ev.kind === "pick") {
|
|
623
|
-
// Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole.
|
|
624
|
-
// These stay OFF the open workflow feed entirely (run-host.ts feeds them per-run).
|
|
625
|
-
void stream.writeSSE({ event: ev.kind, data: JSON.stringify(ev) });
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
946
|
// Exiting lets the handler return, which CLOSES the stream — so on the terminal frame we
|
|
629
947
|
// must wait for the write to flush first, or a fire-and-forget write races the close and the
|
|
630
948
|
// final status is dropped (the very frame `jr2 run` blocks on). Chain the exit off the write.
|
|
631
|
-
const terminal = ev.status.status !== "active";
|
|
632
|
-
void
|
|
949
|
+
const terminal = ev.kind === "status" && ev.status.status !== "active";
|
|
950
|
+
void sub.send(runFrame(ev)).then(() => {
|
|
633
951
|
if (terminal) exit.done();
|
|
634
952
|
});
|
|
635
953
|
}),
|
|
@@ -638,24 +956,27 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
638
956
|
// which then attaches to nothing and never fires. Fall back to the terminal read-through.
|
|
639
957
|
if (host.status(runId) === undefined) {
|
|
640
958
|
exit.release();
|
|
641
|
-
void host.read(runId).then((s) => {
|
|
642
|
-
if (s)
|
|
959
|
+
void host.read(runId).then(async (s) => {
|
|
960
|
+
if (s) {
|
|
961
|
+
await stream.writeSSE({ event: "status", data: JSON.stringify(s) });
|
|
962
|
+
await sayGoneIfParked(stream, s);
|
|
963
|
+
}
|
|
643
964
|
exit.done();
|
|
644
965
|
});
|
|
645
966
|
return;
|
|
646
967
|
}
|
|
647
|
-
exit.onExit(pinger(stream, exit.done, pingMs));
|
|
968
|
+
exit.onExit(pinger(stream, sub, exit.done, pingMs));
|
|
648
969
|
stream.onAbort(exit.done);
|
|
649
970
|
});
|
|
650
971
|
});
|
|
651
972
|
});
|
|
652
973
|
|
|
653
974
|
// Run control (ADR-0002). CANCEL is the only event left on this seam: APPROVE and STEER answered
|
|
654
|
-
// held
|
|
655
|
-
//
|
|
975
|
+
// held calls and drained `poll` inboxes; a held call is now every pick answered at Rest (ADR-0024),
|
|
976
|
+
// and `poll` stays reserved (ADR-0013). Workflow-defined events reach a run through its GATES.
|
|
656
977
|
//
|
|
657
|
-
// It ENDS the run (ADR-0025): the Agents' turns end with it
|
|
658
|
-
//
|
|
978
|
+
// It ENDS the run (ADR-0025): the Agents' turns end with it, the run does not come back on the
|
|
979
|
+
// next restore, and its Workspaces are destroyed unless the body says `keep: true`. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
|
|
659
980
|
// deliberately not on the wire.
|
|
660
981
|
app.post("/runs/:runId/events", instanceOnly, async (c) => {
|
|
661
982
|
const runId = c.req.param("runId");
|
|
@@ -663,8 +984,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
663
984
|
if (body.type !== "CANCEL") {
|
|
664
985
|
return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
|
|
665
986
|
}
|
|
987
|
+
// Whether to keep is a fact of the moment of the cancel, never a config key (ADR-0025).
|
|
988
|
+
if (body.keep !== undefined && typeof body.keep !== "boolean") {
|
|
989
|
+
return c.json({ error: `"keep" must be a boolean` }, 400);
|
|
990
|
+
}
|
|
666
991
|
try {
|
|
667
|
-
await host.cancel(runId);
|
|
992
|
+
await host.cancel(runId, { keep: body.keep === true });
|
|
668
993
|
return c.json({ ok: true });
|
|
669
994
|
} catch (err) {
|
|
670
995
|
return c.json({ error: errMessage(err) }, 404);
|
|
@@ -685,7 +1010,23 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
685
1010
|
/** Guard: the surface must exist, and this principal must be allowed to speak for it. */
|
|
686
1011
|
const agentRegistration = (c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events">) => {
|
|
687
1012
|
const instanceId = c.req.param("instanceId");
|
|
1013
|
+
// Only a Turn that is over answers 404 (ADR-0026). While the host is stopping its runs are
|
|
1014
|
+
// parked for restore, not over, so every `/agents/*` call answers 503: the Harness re-asks a
|
|
1015
|
+
// surface read on ADR-0042's ladder and tells a pick to try again. BEFORE the lookup, because
|
|
1016
|
+
// a stopping host may already have lost the registration the question is about.
|
|
1017
|
+
if (host.stopping) {
|
|
1018
|
+
return {
|
|
1019
|
+
error: c.json({ error: `the Orchestrator is stopping — ask again for instance "${instanceId}"` }, 503),
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
688
1022
|
const surface = host.agentSurface(instanceId);
|
|
1023
|
+
// A boot with no current Assembly listens before it restores (ADR-0069): until the first
|
|
1024
|
+
// publish restores the waiting runs, a registration this host does not have may be one of
|
|
1025
|
+
// theirs, and that Turn is not over either. AFTER the lookup, so a run that is hosted already
|
|
1026
|
+
// (re-attached, or started under the published Assembly) is served.
|
|
1027
|
+
if (!surface && host.restorePending) {
|
|
1028
|
+
return { error: c.json({ error: restorePendingMessage(`instance "${instanceId}"`) }, 503) };
|
|
1029
|
+
}
|
|
689
1030
|
// The one catch point (ADR-0011): no live registration (settled run, exited state, unknown
|
|
690
1031
|
// iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
|
|
691
1032
|
// token learns nothing from it that it did not already know.
|
|
@@ -698,18 +1039,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
698
1039
|
return { surface };
|
|
699
1040
|
};
|
|
700
1041
|
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
1042
|
+
// The Agent's Menu — every pick it can be offered in this Machine, with input schemas and
|
|
1043
|
+
// semantics, fixed for the conversation — and this Turn's Allowed picks (ADR-0029). A transition
|
|
1044
|
+
// swaps the registration, which swaps the Allowed picks, never the Menu; the Harness reads both
|
|
1045
|
+
// per Submission and needs no `list_changed`.
|
|
704
1046
|
app.get("/agents/:instanceId/surface", authenticated, (c) => {
|
|
705
1047
|
const { surface, error } = agentRegistration(c);
|
|
706
|
-
|
|
1048
|
+
if (error) return error;
|
|
1049
|
+
host.agentSurfaceServed(c.req.param("instanceId"));
|
|
1050
|
+
return c.json(surface);
|
|
707
1051
|
});
|
|
708
1052
|
|
|
709
1053
|
// The Agent's pick, delivered into the state that invoked it. The receipt describes itself
|
|
710
1054
|
// (ADR-0024): what was delivered, and whether that ended the turn — which the Harness renders as
|
|
711
|
-
// prose. Its `deliveryId`
|
|
712
|
-
//
|
|
1055
|
+
// prose. Its `deliveryId` makes an outstanding pick addressable after the fact: its answer is
|
|
1056
|
+
// polled at `GET /agents/:iid/deliveries/:id`.
|
|
713
1057
|
app.post("/agents/:instanceId/events", authenticated, async (c) => {
|
|
714
1058
|
const { error } = agentRegistration(c);
|
|
715
1059
|
if (error) return error;
|
|
@@ -718,11 +1062,59 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
|
|
|
718
1062
|
return c.json(host.sendToAgent(c.req.param("instanceId"), body));
|
|
719
1063
|
} catch (err) {
|
|
720
1064
|
if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
|
|
721
|
-
|
|
1065
|
+
// One pick at a time (ADR-0024): refused, nothing delivered, and nothing is allowed until the
|
|
1066
|
+
// workflow is back at Rest. `outstanding` names the pick still running, when there is one; the
|
|
1067
|
+
// Harness writes the words.
|
|
1068
|
+
if (err instanceof OutstandingError) {
|
|
1069
|
+
return c.json(
|
|
1070
|
+
{
|
|
1071
|
+
error: errMessage(err),
|
|
1072
|
+
refused: "outstanding",
|
|
1073
|
+
...(err.outstanding !== undefined ? { outstanding: err.outstanding } : {}),
|
|
1074
|
+
allowed: [],
|
|
1075
|
+
},
|
|
1076
|
+
409,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
if (err instanceof EventValidationError) {
|
|
1080
|
+
// The Allowed picks ride the refusal (ADR-0029), so "Allowed now: …" can follow it.
|
|
1081
|
+
return c.json({ error: errMessage(err), ...(err.allowed ? { allowed: err.allowed } : {}) }, 400);
|
|
1082
|
+
}
|
|
722
1083
|
throw err;
|
|
723
1084
|
}
|
|
724
1085
|
});
|
|
725
1086
|
|
|
1087
|
+
// An outstanding pick's answer (ADR-0013, ADR-0024), long-polled: the POST answered at once with a
|
|
1088
|
+
// delivery id, and the Harness asks here inside the pick's one tool call, up to `deliveryWaitMs`
|
|
1089
|
+
// per request, until the workflow is back at Rest in the Agent's state or has left it. A GET is
|
|
1090
|
+
// safe to repeat, so a reset or a restarting Orchestrator costs a retry, never the pick: a stopping
|
|
1091
|
+
// or parked host answers 503 — ask again — and the restored host answers from the saved hold.
|
|
1092
|
+
// Scoped like the pick: the Sandbox the registration had when the pick was made, because the
|
|
1093
|
+
// answer outlives a registration that a route out of the Agent's state ends.
|
|
1094
|
+
app.get("/agents/:instanceId/deliveries/:deliveryId", authenticated, async (c) => {
|
|
1095
|
+
const instanceId = c.req.param("instanceId");
|
|
1096
|
+
const deliveryId = c.req.param("deliveryId");
|
|
1097
|
+
if (host.stopping || host.parkedDelivery(instanceId, deliveryId)) {
|
|
1098
|
+
return c.json({ error: `the Orchestrator is stopping — ask again for delivery "${deliveryId}"` }, 503);
|
|
1099
|
+
}
|
|
1100
|
+
const scope = host.deliveryScope(instanceId, deliveryId);
|
|
1101
|
+
// Not restored yet is not over (ADR-0026, ADR-0069): the restored host answers from the hold.
|
|
1102
|
+
if (!scope && host.restorePending) {
|
|
1103
|
+
return c.json({ error: restorePendingMessage(`delivery "${deliveryId}"`) }, 503);
|
|
1104
|
+
}
|
|
1105
|
+
if (!scope) return c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
|
|
1106
|
+
if (!mayDeliverToAgent(principalOf(c), scope.sandbox)) {
|
|
1107
|
+
return c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403);
|
|
1108
|
+
}
|
|
1109
|
+
const receipt = await host.awaitDelivery(instanceId, deliveryId, opts.deliveryWaitMs, c.req.raw.signal);
|
|
1110
|
+
if (host.stopping) {
|
|
1111
|
+
return c.json({ error: `the Orchestrator is stopping — ask again for delivery "${deliveryId}"` }, 503);
|
|
1112
|
+
}
|
|
1113
|
+
return receipt
|
|
1114
|
+
? c.json(receipt)
|
|
1115
|
+
: c.json({ error: `no delivery "${deliveryId}" for instance "${instanceId}"` }, 404);
|
|
1116
|
+
});
|
|
1117
|
+
|
|
726
1118
|
// The pod's one route out (ADR-0053). A `git fetch` inside a Sandbox runs a program on the
|
|
727
1119
|
// runtime volume, which asks the Custodian on localhost, which forwards to this. What lands here
|
|
728
1120
|
// is one identity; what goes back is the landing, or the cache as it stands with git's own words
|