@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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
package/src/http.ts CHANGED
@@ -2,19 +2,32 @@
2
2
  // carries NO domain logic of its own: every handler delegates to a single `RunHost` method, so the
3
3
  // wire shape and the in-process API stay one behavior.
4
4
  //
5
- // TWO dialects, one primitive (ADR-0013). The Orchestrator does not speak MCP — that moved into the
6
- // Sandbox, where the Adapter serves it to the Agent over localhost. What is left here are two thin
7
- // adapters over the same registration table:
5
+ // TWO dialects, one primitive (ADR-0013). The Orchestrator does not speak MCP — nothing does: the
6
+ // Harness presents the Menu to its model itself, and reaches these routes through the Custodian in
7
+ // its pod, which holds the Sandbox token. What is left here are two thin adapters over the same
8
+ // registration table:
8
9
  //
9
10
  // # human / webhook / CI — the Gate resource of ADR-0011
10
11
  // GET /runs/:id open gates: accepts + schemas + meta [Instance token]
11
12
  // POST /runs/:id/gates/:gate/events validate + deliver [Instance token]
12
13
  //
13
- // # the Agent's Adapter, and nothing else
14
+ // # the Agent's Harness, through its Custodian, and nothing else
14
15
  // GET /agents/:iid/surface accepts + schemas + semantics [Sandbox token]
15
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]
16
18
  // POST /sandboxes/:name/fetch ask the node cache to fetch one Repo [Sandbox token]
17
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
+ //
18
31
  // The token is not decoration: an Agent has code execution in its Harness container and shares the
19
32
  // pod's network namespace, so it can reach these routes. A Sandbox token may deliver ONLY to an
20
33
  // agent surface recorded against its own Sandbox — never to a Gate. That is what stops an Agent
@@ -40,16 +53,21 @@ import { Hono } from "hono";
40
53
  import type { Context, MiddlewareHandler } from "hono";
41
54
  import { accepts } from "hono/accepts";
42
55
  import { streamSSE } from "hono/streaming";
43
- import { EventValidationError, UnknownAddressError } from "./registration.ts";
56
+ import { EventValidationError, NotTakenError, OutstandingError, UnknownAddressError } from "./registration.ts";
44
57
  import type { RepoStatus } from "./repos.ts";
45
58
  import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
46
59
  import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
47
- import { observe, type RunHost } from "./run-host.ts";
60
+ import { observe, type RunFeedEvent, type RunHost, type RunStatus, type WorkflowFeedEvent } from "./run-host.ts";
48
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";
49
65
 
50
66
  /** A `POST /runs/:id/events` body: the down-channel event. CANCEL is all that is left of it
51
- * (ADR-0013): APPROVE and STEER rode the deferred/poll machinery, which is reserved, not built. */
52
- type RunEventBody = { type?: string };
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 };
53
71
 
54
72
  /** `GET /runs/resolve` floor — a 1-char prefix is a table scan, not a question. The CLI enforces
55
73
  * the same floor on the argument; this one guards the scan regardless of who is calling. */
@@ -76,6 +94,15 @@ function errMessage(err: unknown): string {
76
94
  return err instanceof Error ? err.message : String(err);
77
95
  }
78
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
+
79
106
  /** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
80
107
  type JR2Env = { Variables: { principal: Principal } };
81
108
 
@@ -180,7 +207,7 @@ async function consoleTsAsset(file: string): Promise<Response> {
180
207
  // Content negotiation for the two page addresses (ADR-0032). JSON is the DEFAULT dialect: only a
181
208
  // request whose `Accept` prefers `text/html` — a browser's navigation — gets the Console shell.
182
209
  // No header, a bare wildcard, `application/json`, `text/event-stream` all fall through to JSON,
183
- // so the CLI, the Adapter and EventSource never see HTML they did not ask for.
210
+ // so the CLI, the Harness and EventSource never see HTML they did not ask for.
184
211
  function prefersHtml(c: Context): boolean {
185
212
  return (
186
213
  accepts(c, { header: "Accept", supports: ["application/json", "text/html"], default: "application/json" }) ===
@@ -230,36 +257,141 @@ const PING_MS = 15_000;
230
257
  * is the `error: terminated` an attached `jr2 run` hit). The frame is an SSE COMMENT (`:\n\n`):
231
258
  * every client ignores it, so it needs no place in the wire vocabulary.
232
259
  *
233
- * It is deliberately a **ping**, not a heartbeat or keepalive — CONTEXT.md puts both on the Lease's
260
+ * It is deliberately a **ping**, not a heartbeat or keepalive — GLOSSARY.md puts both on the Lease's
234
261
  * Avoid list, and this asserts nothing and expects no answer.
235
262
  *
236
263
  * It is NOT a liveness probe, and must not be mistaken for one: `stream.write` swallows its own
237
264
  * errors (hono's `StreamingApi`), so a failed write is indistinguishable from a good one. A peer
238
265
  * that goes away is detected by `stream.onAbort`, which is what every handler here wires to its
239
266
  * exit. The tick checks `aborted`/`closed` only so a ping that fires between the abort and the
240
- * teardown does not write into a dead stream. Returns its own clear fn.
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.
241
272
  */
242
- function pinger(stream: SseStream, done: () => void, everyMs = PING_MS): () => void {
273
+ function pinger(stream: SseStream, sub: Subscriber, done: () => void, everyMs = PING_MS): () => void {
243
274
  const timer = setInterval(() => {
244
275
  if (stream.aborted || stream.closed) return done();
245
- void stream.write(":\n\n");
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");
246
282
  }, everyMs);
247
283
  return () => clearInterval(timer);
248
284
  }
249
285
 
250
- /** The slice of hono's `StreamingApi` the ping needs: the raw write (`writeSSE` cannot express a
251
- * comment frame) plus the two flags that say the peer is gone. */
252
- type SseStream = { write: (s: string) => Promise<unknown>; aborted: boolean; closed: boolean };
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
+ }
253
372
 
254
373
  export type CreateAppOptions = {
255
374
  /** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
256
375
  pingMs?: number;
257
376
  /**
258
- * Whether this instance has a data plane at all (ADR-0051): a registered Machine composes a
259
- * Sandbox AND the process is deployed in a cluster. `false` is an answer — "this instance runs
260
- * no Workspace" — and `jr2 status` says so rather than listing nothing.
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.
261
381
  */
262
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>>;
263
395
  /**
264
396
  * The Repo resources as the cluster currently reports them (ADR-0051), read per request off the
265
397
  * port — a cache the agent cloned minutes after boot reads as present the next time anyone asks.
@@ -274,6 +406,17 @@ export type CreateAppOptions = {
274
406
  * ask, and the route says so rather than waiting on a cluster that is not there.
275
407
  */
276
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;
277
420
  };
278
421
 
279
422
  /**
@@ -293,6 +436,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
293
436
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
294
437
  const principal = auth(bearerOf(c));
295
438
  if (!principal) return c.json({ error: "unauthorized" }, 401);
439
+ if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
296
440
  c.set("principal", principal);
297
441
  return next();
298
442
  };
@@ -301,8 +445,8 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
301
445
 
302
446
  /**
303
447
  * Authenticate, AND require the INSTANCE token (ADR-0014). A Sandbox token is a token we minted,
304
- * so `authenticated` alone lets it through — which on the run surface is too much: it would let an
305
- * Adapter's credential read every run's context (other features' branches, tickets, verdicts) and
448
+ * so `authenticated` alone lets it through — which on the run surface is too much: it would let a
449
+ * Custodian's credential read every run's context (other features' branches, tickets, verdicts) and
306
450
  * CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
307
451
  * registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
308
452
  * the other direction.
@@ -311,6 +455,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
311
455
  if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
312
456
  const principal = auth(bearerOf(c));
313
457
  if (!principal) return c.json({ error: "unauthorized" }, 401);
458
+ if (principal.kind === "publisher") return c.json({ error: PUBLISHER_ONLY }, 403);
314
459
  if (principal.kind !== "instance") {
315
460
  return c.json({ error: "run state and control need the Instance token — a Sandbox token has neither" }, 403);
316
461
  }
@@ -322,16 +467,166 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
322
467
  // readiness probe's target and because identity is not run state — it is the same class of thing
323
468
  // as the route table, which is public by being served. The CLI probes it to explain a failure it
324
469
  // could otherwise only report as a bare status code (version skew reads as a nonsense 404).
325
- // `hash` is the image's content address (ADR-0019), absent for a host-booted fixture process,
326
- // which has no image to be addressed.
327
- 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 }));
328
472
  app.get("/readyz", (c) => c.json({ ready: true }));
329
473
 
330
474
  // Structure, not state: the workflow listing, a template's Machine, and the Console shell are
331
475
  // unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
332
476
  // loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
333
477
  // below.
334
- app.get("/workflows", (c) => c.json(host.workflows()));
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
+ };
335
630
 
336
631
  // The two PAGE addresses (ADR-0032): `/` and `/workflows/:name` negotiate. A browser navigation
337
632
  // (`Accept` prefers text/html) gets the one Console shell — the path carries the selection, the
@@ -350,34 +645,37 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
350
645
  if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
351
646
  const name = c.req.param("name");
352
647
  const doc = host.machine(name);
353
- if (!doc) return c.json({ error: `no workflow "${name}"` }, 404);
354
- return c.json({ name, machineId: doc.id, input: host.inputSchema(name) ?? null });
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
+ });
355
661
  });
356
662
 
357
663
  // Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
358
664
  // A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
359
665
  // shape — the same error class, and the same wire mapping, as a gate delivery failing its
360
666
  // schema (`POST /runs/:id/gates/:gate/events` below). Instance-band (ADR-0058): starting work is
361
- // run control, and a Sandbox token's scope is its own agent surface — no Adapter forwarding this
362
- // route is what keeps an Agent from starting runs today, and that is not a control.
363
- app.post("/workflows/:name/runs", instanceOnly, async (c) => {
364
- const name = c.req.param("name");
365
- const input = await readJson(c.req.text());
366
- try {
367
- const { runId, instanceId } = await host.start(name, input);
368
- return c.json({ runId, instanceId }, 201);
369
- } catch (err) {
370
- if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
371
- return c.json({ error: errMessage(err) }, 404);
372
- }
373
- });
667
+ // run control, and a Sandbox token's scope is its own agent surface — the Custodian's route
668
+ // allowlist not forwarding this route is a second layer, not the control (ADR-0059).
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")));
374
672
 
375
673
  // The registered template Machine's structure — what the Console's diagram renders (structure
376
674
  // is provider-independent, so the un-`provide()`d template is exactly right).
377
675
  app.get("/workflows/:name/machine", (c) => {
378
676
  const name = c.req.param("name");
379
677
  const doc = host.machine(name);
380
- 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));
381
679
  });
382
680
 
383
681
  // ---- Observation (`GET /workflows/:name/runs*`) ---------------------------------------------
@@ -394,6 +692,18 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
394
692
 
395
693
  app.get("/workflows/:name/runs", (c) => c.json(host.observations(c.req.param("name"))));
396
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
+
397
707
  /**
398
708
  * SSE: a whole WORKFLOW's activity (ADR-0022) — every run of it appearing, moving, emitting and
399
709
  * leaving, on one connection that outlives all of them.
@@ -415,40 +725,46 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
415
725
  return streamSSE(c, async (stream) => {
416
726
  await new Promise<void>((resolve) => {
417
727
  const exit = closer(resolve);
728
+ const sub = subscriber(stream);
418
729
  // Subscribe and snapshot in one call, then write the snapshot in the same tick: nothing can
419
730
  // start, move or finish in between, so the client's first frame is a complete picture.
420
731
  const { runs, unsubscribe } = host.observeWorkflow(name, (ev) => {
421
732
  if (ev.kind === "closed") return exit.done();
422
- if (ev.kind === "gone") {
423
- void stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: ev.runId }) });
424
- return;
425
- }
426
- if (ev.kind === "emit") {
427
- // The TYPE alone: an emit's payload is author data, the same class of thing as context.
428
- void stream.writeSSE({ event: "emit", data: JSON.stringify({ runId: ev.runId, type: ev.event.type }) });
429
- return;
430
- }
431
- if (ev.kind === "retry") {
432
- // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
433
- // Instance token like `fault` (ADR-0014/0016).
434
- void stream.writeSSE({
435
- event: "retry",
436
- data: JSON.stringify({ runId: ev.runId, child: ev.child, attempt: ev.attempt }),
437
- });
438
- return;
439
- }
440
- void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) });
733
+ void sub.send(workflowFrame(ev));
441
734
  });
442
735
  exit.onExit(unsubscribe);
443
736
  // `retry` steers the browser's own EventSource backoff. This feed never ends on its own, so
444
- // every close is a fault worth reconnecting from — the client does not decide that.
445
- void stream.writeSSE({ event: "runs", data: JSON.stringify(runs.map(observe)), retry: 2000 });
446
- exit.onExit(pinger(stream, exit.done, pingMs));
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));
447
741
  stream.onAbort(exit.done);
448
742
  });
449
743
  });
450
744
  });
451
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
+
452
768
  app.get("/workflows/:name/runs/:runId/events", async (c) => {
453
769
  const name = c.req.param("name");
454
770
  const runId = c.req.param("runId");
@@ -459,28 +775,17 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
459
775
  return streamSSE(c, async (stream) => {
460
776
  await new Promise<void>((resolve) => {
461
777
  const exit = closer(resolve);
778
+ const sub = subscriber(stream);
462
779
  exit.onExit(
463
780
  host.subscribe(runId, (ev) => {
464
781
  // The host is shutting down under a feed that has no end of its own.
465
782
  if (ev.kind === "closed") return exit.done();
466
- if (ev.kind === "emit") {
467
- // The TYPE alone: an emit's payload is author data, the same class of thing as context.
468
- void stream.writeSSE({ event: "emit", data: JSON.stringify({ type: ev.event.type }) });
469
- return;
470
- }
471
- if (ev.kind === "retry") {
472
- // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind
473
- // the Instance token like `fault` (ADR-0014/0016).
474
- void stream.writeSSE({ event: "retry", data: JSON.stringify({ child: ev.child, attempt: ev.attempt }) });
475
- return;
476
- }
477
- // Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token
478
- // class, so the OPEN band never sees them (not even their types).
479
- if (ev.kind === "admission" || ev.kind === "pick") return;
783
+ const frame = openRunFrame(ev);
784
+ if (frame === undefined) return;
480
785
  // Terminal frame must flush before the handler returns and closes the stream (see the
481
786
  // guarded feed below for why the exit is chained off the write).
482
- const terminal = ev.status.status !== "active";
483
- void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) }).then(() => {
787
+ const terminal = ev.kind === "status" && ev.status.status !== "active";
788
+ void sub.send(frame).then(() => {
484
789
  if (terminal) exit.done();
485
790
  });
486
791
  }),
@@ -488,7 +793,7 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
488
793
  // Race guard: settled between the liveness check and the subscribe, which then attached to
489
794
  // nothing. No read-through on this route, so there is nothing to fall back to — just close.
490
795
  if (host.status(runId) === undefined) return exit.done();
491
- exit.onExit(pinger(stream, exit.done, pingMs));
796
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
492
797
  stream.onAbort(exit.done);
493
798
  });
494
799
  });
@@ -586,10 +891,38 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
586
891
  } catch (err) {
587
892
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
588
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
+ }
589
902
  throw err;
590
903
  }
591
904
  });
592
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
+
593
926
  // SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
594
927
  // then live until the terminal transition or client abort). A run that has already settled streams
595
928
  // its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
@@ -600,35 +933,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
600
933
  if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
601
934
  return streamSSE(c, async (stream) => {
602
935
  await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
936
+ await sayGoneIfParked(stream, finalStatus);
603
937
  });
604
938
  }
605
939
  return streamSSE(c, async (stream) => {
606
940
  await new Promise<void>((resolve) => {
607
941
  const exit = closer(resolve);
942
+ const sub = subscriber(stream);
608
943
  exit.onExit(
609
944
  host.subscribe(runId, (ev) => {
610
945
  if (ev.kind === "closed") return exit.done();
611
- if (ev.kind === "emit") {
612
- void stream.writeSSE({ event: "emit", data: JSON.stringify(ev.event) });
613
- return;
614
- }
615
- if (ev.kind === "retry") {
616
- // The Instance's own feed: the full telemetry, reason included (same trust class as
617
- // `fault`).
618
- void stream.writeSSE({ event: "retry", data: JSON.stringify(ev) });
619
- return;
620
- }
621
- if (ev.kind === "admission" || ev.kind === "pick") {
622
- // Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole.
623
- // These stay OFF the open workflow feed entirely (run-host.ts feeds them per-run).
624
- void stream.writeSSE({ event: ev.kind, data: JSON.stringify(ev) });
625
- return;
626
- }
627
946
  // Exiting lets the handler return, which CLOSES the stream — so on the terminal frame we
628
947
  // must wait for the write to flush first, or a fire-and-forget write races the close and the
629
948
  // final status is dropped (the very frame `jr2 run` blocks on). Chain the exit off the write.
630
- const terminal = ev.status.status !== "active";
631
- void stream.writeSSE({ event: "status", data: JSON.stringify(ev.status) }).then(() => {
949
+ const terminal = ev.kind === "status" && ev.status.status !== "active";
950
+ void sub.send(runFrame(ev)).then(() => {
632
951
  if (terminal) exit.done();
633
952
  });
634
953
  }),
@@ -637,24 +956,27 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
637
956
  // which then attaches to nothing and never fires. Fall back to the terminal read-through.
638
957
  if (host.status(runId) === undefined) {
639
958
  exit.release();
640
- void host.read(runId).then((s) => {
641
- if (s) void stream.writeSSE({ event: "status", data: JSON.stringify(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
+ }
642
964
  exit.done();
643
965
  });
644
966
  return;
645
967
  }
646
- exit.onExit(pinger(stream, exit.done, pingMs));
968
+ exit.onExit(pinger(stream, sub, exit.done, pingMs));
647
969
  stream.onAbort(exit.done);
648
970
  });
649
971
  });
650
972
  });
651
973
 
652
974
  // Run control (ADR-0002). CANCEL is the only event left on this seam: APPROVE and STEER answered
653
- // held `deferred` calls and drained `poll` inboxes, and ADR-0013 reserves both semantics without
654
- // building them. Workflow-defined events reach a run through its GATES, not through here.
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.
655
977
  //
656
- // It ENDS the run (ADR-0025): the Agents' turns end with it and the run does not come back on
657
- // the next restore. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
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
658
980
  // deliberately not on the wire.
659
981
  app.post("/runs/:runId/events", instanceOnly, async (c) => {
660
982
  const runId = c.req.param("runId");
@@ -662,8 +984,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
662
984
  if (body.type !== "CANCEL") {
663
985
  return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
664
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
+ }
665
991
  try {
666
- await host.cancel(runId);
992
+ await host.cancel(runId, { keep: body.keep === true });
667
993
  return c.json({ ok: true });
668
994
  } catch (err) {
669
995
  return c.json({ error: errMessage(err) }, 404);
@@ -671,10 +997,12 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
671
997
  });
672
998
 
673
999
  // ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
674
- // Served to ONE caller: the Adapter in the Agent's Sandbox. It renders `surface` as `tools/list`
675
- // and turns a `tools/call` into an `events` POST. The Orchestrator therefore keeps no MCP
676
- // dependency, no transport, no session handling — and the Agent keeps no route to this API
677
- // except through a process whose credential it cannot read.
1000
+ // Served to ONE caller: the Agent's Harness, through the Custodian in its pod. The Harness renders
1001
+ // `surface` as the Menu and turns a pick into an `events` POST. The Orchestrator therefore keeps
1002
+ // no MCP dependency, no transport, no session handling — and the Agent keeps no route to this
1003
+ // API except through a container whose credential it cannot read. Everything a pick is judged by
1004
+ // is judged HERE: the token's scope, the live registration, the event's name and payload — the
1005
+ // Custodian forwards and never decides (ADR-0059).
678
1006
  //
679
1007
  // Both routes are adapters over the SAME registration table the gates ride: lookup, validation
680
1008
  // and delivery are implemented once, in `registration.ts`.
@@ -682,7 +1010,23 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
682
1010
  /** Guard: the surface must exist, and this principal must be allowed to speak for it. */
683
1011
  const agentRegistration = (c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events">) => {
684
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
+ }
685
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
+ }
686
1030
  // The one catch point (ADR-0011): no live registration (settled run, exited state, unknown
687
1031
  // iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
688
1032
  // token learns nothing from it that it did not already know.
@@ -695,18 +1039,21 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
695
1039
  return { surface };
696
1040
  };
697
1041
 
698
- // This turn's menu: the events the invoking state accepts, their input schemas, their semantics.
699
- // A transition swaps the registration, which swaps this — so the Adapter gets a state-scoped
700
- // toolset for free, and needs no `list_changed` to know it (flue re-lists on every submission).
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`.
701
1046
  app.get("/agents/:instanceId/surface", authenticated, (c) => {
702
1047
  const { surface, error } = agentRegistration(c);
703
- return error ?? c.json(surface);
1048
+ if (error) return error;
1049
+ host.agentSurfaceServed(c.req.param("instanceId"));
1050
+ return c.json(surface);
704
1051
  });
705
1052
 
706
1053
  // The Agent's pick, delivered into the state that invoked it. The receipt describes itself
707
- // (ADR-0024): what was delivered, and whether that ended the turn — which the Adapter renders as
708
- // prose. Its `deliveryId` still makes an outcome addressable after the fact, the room a deferred
709
- // result will need when it lands.
1054
+ // (ADR-0024): what was delivered, and whether that ended the turn — which the Harness renders as
1055
+ // prose. Its `deliveryId` makes an outstanding pick addressable after the fact: its answer is
1056
+ // polled at `GET /agents/:iid/deliveries/:id`.
710
1057
  app.post("/agents/:instanceId/events", authenticated, async (c) => {
711
1058
  const { error } = agentRegistration(c);
712
1059
  if (error) return error;
@@ -715,13 +1062,61 @@ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOp
715
1062
  return c.json(host.sendToAgent(c.req.param("instanceId"), body));
716
1063
  } catch (err) {
717
1064
  if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
718
- if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
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
+ }
719
1083
  throw err;
720
1084
  }
721
1085
  });
722
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
+
723
1118
  // The pod's one route out (ADR-0053). A `git fetch` inside a Sandbox runs a program on the
724
- // runtime volume, which asks the Adapter on localhost, which forwards to this. What lands here
1119
+ // runtime volume, which asks the Custodian on localhost, which forwards to this. What lands here
725
1120
  // is one identity; what goes back is the landing, or the cache as it stands with git's own words
726
1121
  // — freshness degrades, absence does not, so this answers 200 either way and the program prints
727
1122
  // the warning. The caller waits on the ask the way an attach waits on Ready: same status, same