@jr2/orchestrator 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
package/src/http.ts ADDED
@@ -0,0 +1,753 @@
1
+ // The orchestrator HTTP surface (ADR-0009/0013): a thin REST + SSE facade over a `RunHost`. It
2
+ // carries NO domain logic of its own: every handler delegates to a single `RunHost` method, so the
3
+ // wire shape and the in-process API stay one behavior.
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:
8
+ //
9
+ // # human / webhook / CI — the Gate resource of ADR-0011
10
+ // GET /runs/:id open gates: accepts + schemas + meta [Instance token]
11
+ // POST /runs/:id/gates/:gate/events validate + deliver [Instance token]
12
+ //
13
+ // # the Agent's Adapter, and nothing else
14
+ // GET /agents/:iid/surface accepts + schemas + semantics [Sandbox token]
15
+ // POST /agents/:iid/events validate + deliver → the turn receipt [Sandbox token]
16
+ // POST /sandboxes/:name/fetch ask the node cache to fetch one Repo [Sandbox token]
17
+ //
18
+ // The token is not decoration: an Agent has code execution in its Harness container and shares the
19
+ // pod's network namespace, so it can reach these routes. A Sandbox token may deliver ONLY to an
20
+ // agent surface recorded against its own Sandbox — never to a Gate. That is what stops an Agent
21
+ // from approving its own review.
22
+ //
23
+ // THREE bands of access, not two (ADR-0014) — the middleware is what says which:
24
+ //
25
+ // open structure + observation. `/workflows`, a template's Machine, the Console shell,
26
+ // and the run PROJECTIONS below (`GET /workflows/:name/runs*`). No context, no
27
+ // control.
28
+ // authenticated any principal we minted a token for. The Agent's surface lives here, scoped
29
+ // further per-registration by `mayDeliverToAgent`.
30
+ // instanceOnly the Instance token ALONE. Run control and full run state (`/runs*`): a Sandbox
31
+ // token authenticates but is refused, because reading another feature's context or
32
+ // cancelling a run is not on the Agent's surface any more than a Gate is.
33
+
34
+ import { readFile, stat } from "node:fs/promises";
35
+ import { createRequire } from "node:module";
36
+ import { pathToFileURL } from "node:url";
37
+ import tsBlankSpace from "ts-blank-space";
38
+ import { Hono } from "hono";
39
+ import type { Context, MiddlewareHandler } from "hono";
40
+ import { accepts } from "hono/accepts";
41
+ import { streamSSE } from "hono/streaming";
42
+ import { EventValidationError, UnknownAddressError } from "./registration.ts";
43
+ import type { RepoStatus } from "./repos.ts";
44
+ import { UnmountedRepoError, type FetchAnswer } from "./repo-fetch.ts";
45
+ import { mayAskForSandbox, mayDeliverToAgent, type Authenticator, type Principal } from "./tokens.ts";
46
+ import { observe, type RunHost } from "./run-host.ts";
47
+ import { KIT_VERSION } from "./config.ts";
48
+
49
+ /** A `POST /runs/:id/events` body: the down-channel event. CANCEL is all that is left of it
50
+ * (ADR-0013): APPROVE and STEER rode the deferred/poll machinery, which is reserved, not built. */
51
+ type RunEventBody = { type?: string };
52
+
53
+ /** `GET /runs/resolve` floor — a 1-char prefix is a table scan, not a question. The CLI enforces
54
+ * the same floor on the argument; this one guards the scan regardless of who is calling. */
55
+ const MIN_RUN_ID_PREFIX = 4;
56
+
57
+ /** How many ambiguous candidates are worth showing. Proving ambiguity takes 2; letting the caller
58
+ * PICK is the point, so the listing goes deeper before it truncates. */
59
+ const RESOLVE_LIMIT = 10;
60
+
61
+ /** Read a request body as JSON, tolerating an empty body (→ {}) and malformed JSON (→ {}). */
62
+ async function readJson(text: Promise<string>): Promise<Record<string, unknown>> {
63
+ try {
64
+ const raw = await text;
65
+ if (!raw) return {};
66
+ const parsed = JSON.parse(raw);
67
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ /** The message of a thrown error, for the `{ error }` 404 body. */
74
+ function errMessage(err: unknown): string {
75
+ return err instanceof Error ? err.message : String(err);
76
+ }
77
+
78
+ /** The app's context: `authenticated` resolves the bearer to a `principal` the routes read back. */
79
+ type JR2Env = { Variables: { principal: Principal } };
80
+
81
+ /** The bearer token on a request, if it carries one. */
82
+ function bearerOf(c: Context<JR2Env>): string | undefined {
83
+ const header = c.req.header("authorization") ?? "";
84
+ const match = /^Bearer\s+(.+)$/i.exec(header);
85
+ return match?.[1]?.trim() || undefined;
86
+ }
87
+
88
+ // ---- Console assets (`/assets/*`) -----------------------------------------------------------
89
+ // The Console: the browser page that renders a workflow's Machine and its runs. Shipped inside
90
+ // this package and read from disk — no CDN, no build step (ADR-0032: the page serves only its own
91
+ // assets). `serveStatic` is deliberately avoided: its root is cwd-relative, and this package is a
92
+ // library that must serve its own files wherever the process starts. Assets are root-owned
93
+ // (`/assets/*`, not under a page path) so no workflow name can ever shadow them. Three kinds:
94
+ //
95
+ // .html/.css the shipped bytes, verbatim.
96
+ // *.ts Console source (ADR-0034) — the flat files plus the one `components/` level
97
+ // the view modules live in. Browsers don't type-strip, so the server does what
98
+ // Node does for itself: types replaced by whitespace (`ts-blank-space`), erased
99
+ // once per (file, mtime) and cached. Erasure, not compilation — positions are
100
+ // preserved, so stack traces point at the real line with no sourcemaps.
101
+ // /assets/vendor/* Preact's browser ESM, resolved through THIS package's dep edge like the elkjs
102
+ // bundle. The page's import map binds the bare specifiers to these URLs.
103
+
104
+ const CONSOLE_DIR = new URL("../console/", import.meta.url);
105
+
106
+ /** The vendored elkjs bundle, resolved through THIS package's dep edge (pnpm-safe), read once. */
107
+ let elkBundle: Promise<Buffer> | undefined;
108
+ function readElkBundle(): Promise<Buffer> {
109
+ // elkjs ships no `exports` map today, so the subpath resolves; if a future version adds one,
110
+ // switch to resolving "elkjs/package.json" and joining "lib/elk.bundled.js".
111
+ elkBundle ??= readFile(createRequire(import.meta.url).resolve("elkjs/lib/elk.bundled.js"));
112
+ return elkBundle;
113
+ }
114
+
115
+ /**
116
+ * Preact's browser ESM at `/assets/vendor/*` (ADR-0034): the served name → the file under the
117
+ * `preact` package. Resolved via "preact/package.json" — preact HAS an `exports` map (unlike
118
+ * elkjs), so its dist subpaths don't resolve directly. `hooks.module.js` imports the bare
119
+ * specifier `"preact"`; the page's import map covers vendored modules too, so that import lands
120
+ * back on `/assets/vendor/preact.module.js` and the files can sit flat.
121
+ */
122
+ const VENDOR_FILES: Record<string, string> = {
123
+ "preact.module.js": "dist/preact.module.js",
124
+ "hooks.module.js": "hooks/dist/hooks.module.js",
125
+ };
126
+
127
+ /** Vendor files, read once each — package contents only change with the package. */
128
+ const vendorCache = new Map<string, Promise<Buffer>>();
129
+ function readVendor(name: string): Promise<Buffer> | undefined {
130
+ const rel = VENDOR_FILES[name];
131
+ if (!rel) return undefined;
132
+ let body = vendorCache.get(name);
133
+ if (!body) {
134
+ const pkg = createRequire(import.meta.url).resolve("preact/package.json");
135
+ body = readFile(new URL(rel, pathToFileURL(pkg)));
136
+ vendorCache.set(name, body);
137
+ }
138
+ return body;
139
+ }
140
+
141
+ /** Serve one file of the Console with its content type. */
142
+ async function consoleAsset(rel: string, contentType: string): Promise<Response> {
143
+ const body = await readFile(new URL(rel, CONSOLE_DIR));
144
+ return new Response(new Uint8Array(body), { headers: { "content-type": contentType } });
145
+ }
146
+
147
+ /** Erased Console source, cached per file: an edit moves the mtime, which drops the entry. */
148
+ const erasedCache = new Map<string, { mtimeMs: number; js: string }>();
149
+
150
+ /**
151
+ * Serve one Console `.ts` file with its types erased (ADR-0034). The failure class is gated where
152
+ * it belongs — what `ts-blank-space` cannot erase (an enum, a namespace), `tsc --noEmit` already
153
+ * rejected in CI — so the 500 here is a should-never bar, not a compiler diagnostic surface.
154
+ */
155
+ async function consoleTsAsset(file: string): Promise<Response> {
156
+ const url = new URL(file, CONSOLE_DIR);
157
+ let mtimeMs: number;
158
+ try {
159
+ mtimeMs = (await stat(url)).mtimeMs;
160
+ } catch {
161
+ return Response.json({ error: `no console asset "${file}"` }, { status: 404 });
162
+ }
163
+ let hit = erasedCache.get(file);
164
+ if (!hit || hit.mtimeMs !== mtimeMs) {
165
+ const source = await readFile(url, "utf8");
166
+ const unerasable: string[] = [];
167
+ // The node's own text, sliced by position — `getText()` needs a parent chain the parse here
168
+ // does not build.
169
+ const js = tsBlankSpace(source, (node) => unerasable.push(source.slice(node.pos, node.end).trim()));
170
+ if (unerasable.length) {
171
+ return new Response(`cannot erase types from ${file}: ${unerasable.join(", ")}`, { status: 500 });
172
+ }
173
+ hit = { mtimeMs, js };
174
+ erasedCache.set(file, hit);
175
+ }
176
+ return new Response(hit.js, { headers: { "content-type": "text/javascript; charset=utf-8" } });
177
+ }
178
+
179
+ // Content negotiation for the two page addresses (ADR-0032). JSON is the DEFAULT dialect: only a
180
+ // request whose `Accept` prefers `text/html` — a browser's navigation — gets the Console shell.
181
+ // No header, a bare wildcard, `application/json`, `text/event-stream` all fall through to JSON,
182
+ // so the CLI, the Adapter and EventSource never see HTML they did not ask for.
183
+ function prefersHtml(c: Context): boolean {
184
+ return (
185
+ accepts(c, { header: "Accept", supports: ["application/json", "text/html"], default: "application/json" }) ===
186
+ "text/html"
187
+ );
188
+ }
189
+
190
+ /**
191
+ * One SSE handler's single exit. Every feed can end four ways — terminal frame, race guard, client
192
+ * abort, host shutdown — and each must unsubscribe, clear the ping timer, and resolve EXACTLY once.
193
+ * Spelling that out per exit is how a timer gets leaked, so each handler builds one of these and
194
+ * every exit becomes `exit.done`.
195
+ *
196
+ * `onExit` is called AFTER subscribing, because `host.subscribe` replays synchronously and can
197
+ * therefore finish the feed before it returns — hence the already-finished check inside it.
198
+ */
199
+ function closer(resolve: () => void) {
200
+ let cleanups: Array<() => void> = [];
201
+ let finished = false;
202
+ const release = () => {
203
+ for (const fn of cleanups) fn();
204
+ cleanups = [];
205
+ };
206
+ return {
207
+ done: () => {
208
+ if (finished) return;
209
+ finished = true;
210
+ release();
211
+ resolve();
212
+ },
213
+ /** Register cleanup (an unsubscribe, a timer clear) to run on whichever exit happens first. */
214
+ onExit: (fn: () => void) => (finished ? fn() : cleanups.push(fn)),
215
+ /** Run the cleanups WITHOUT ending the feed — the read-through race guard, which detaches from
216
+ * a run that is already gone and then still has a final frame to write. */
217
+ release,
218
+ };
219
+ }
220
+
221
+ /** How often an otherwise-silent feed writes a ping. */
222
+ const PING_MS = 15_000;
223
+
224
+ /**
225
+ * Keep a quiet feed alive.
226
+ *
227
+ * A run parked on a gate transitions for hours, so its feed writes zero bytes and any idle
228
+ * intermediary drops the connection — the client sees a dead socket that still looks healthy (this
229
+ * is the `error: terminated` an attached `jr2 run` hit). The frame is an SSE COMMENT (`:\n\n`):
230
+ * every client ignores it, so it needs no place in the wire vocabulary.
231
+ *
232
+ * It is deliberately a **ping**, not a heartbeat or keepalive — CONTEXT.md puts both on the Lease's
233
+ * Avoid list, and this asserts nothing and expects no answer.
234
+ *
235
+ * It is NOT a liveness probe, and must not be mistaken for one: `stream.write` swallows its own
236
+ * errors (hono's `StreamingApi`), so a failed write is indistinguishable from a good one. A peer
237
+ * that goes away is detected by `stream.onAbort`, which is what every handler here wires to its
238
+ * exit. The tick checks `aborted`/`closed` only so a ping that fires between the abort and the
239
+ * teardown does not write into a dead stream. Returns its own clear fn.
240
+ */
241
+ function pinger(stream: SseStream, done: () => void, everyMs = PING_MS): () => void {
242
+ const timer = setInterval(() => {
243
+ if (stream.aborted || stream.closed) return done();
244
+ void stream.write(":\n\n");
245
+ }, everyMs);
246
+ return () => clearInterval(timer);
247
+ }
248
+
249
+ /** The slice of hono's `StreamingApi` the ping needs: the raw write (`writeSSE` cannot express a
250
+ * comment frame) plus the two flags that say the peer is gone. */
251
+ type SseStream = { write: (s: string) => Promise<unknown>; aborted: boolean; closed: boolean };
252
+
253
+ export type CreateAppOptions = {
254
+ /** Ping interval for the SSE feeds. Tests shorten it; nothing in production sets it. */
255
+ pingMs?: number;
256
+ /**
257
+ * Whether this instance has a data plane at all (ADR-0051): a registered Machine composes a
258
+ * Sandbox AND the process is deployed in a cluster. `false` is an answer — "this instance runs
259
+ * no Workspace" — and `jr2 status` says so rather than listing nothing.
260
+ */
261
+ dataPlane?: boolean;
262
+ /**
263
+ * The Repo resources as the cluster currently reports them (ADR-0051), read per request off the
264
+ * port — a cache the agent cloned minutes after boot reads as present the next time anyone asks.
265
+ * Absent for an instance without a data plane and for the in-process tests that build an app
266
+ * straight over a host: both answer no Repos.
267
+ */
268
+ repos?: () => Promise<RepoStatus[]>;
269
+ /**
270
+ * The ask a pod makes when something inside it fetches (ADR-0053), off the port the caller
271
+ * built: mark the Sandbox CR for one Repo key, wait for the landing on its status, answer.
272
+ * Absent for an instance without a data plane and for the in-process tests: no Sandbox, so no
273
+ * ask, and the route says so rather than waiting on a cluster that is not there.
274
+ */
275
+ fetchRepo?: (sandbox: string, identity: string) => Promise<FetchAnswer>;
276
+ };
277
+
278
+ /**
279
+ * Build the orchestrator HTTP app over a `RunHost` (ADR-0009/0013 route table).
280
+ *
281
+ * `auth` is how a bearer token becomes a principal. Omitting it leaves the surface OPEN, which is
282
+ * only ever right for an in-process test that reaches `app.request` directly — `startInstance`
283
+ * (every real boot, deployed or fixture) always supplies one.
284
+ */
285
+ export function createApp(host: RunHost, auth?: Authenticator, opts: CreateAppOptions = {}): Hono<JR2Env> {
286
+ const pingMs = opts.pingMs ?? PING_MS;
287
+ const app = new Hono<JR2Env>();
288
+
289
+ /** Authenticate, or refuse. There is no anonymous principal (ADR-0013) — an open surface would
290
+ * hand every Agent in the cluster a delivery API, which is the hole this ADR exists to close. */
291
+ const authenticated: MiddlewareHandler<JR2Env> = async (c, next) => {
292
+ if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
293
+ const principal = auth(bearerOf(c));
294
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
295
+ c.set("principal", principal);
296
+ return next();
297
+ };
298
+ /** The principal `authenticated` resolved. An unconfigured `auth` means full trust. */
299
+ const principalOf = (c: Context<JR2Env>): Principal => c.get("principal") ?? { kind: "instance" };
300
+
301
+ /**
302
+ * Authenticate, AND require the INSTANCE token (ADR-0014). A Sandbox token is a token we minted,
303
+ * so `authenticated` alone lets it through — which on the run surface is too much: it would let an
304
+ * Adapter's credential read every run's context (other features' branches, tickets, verdicts) and
305
+ * CANCEL any run. Neither is on the Agent's surface. Its scope is delivering to agent
306
+ * registrations recorded against its OWN Sandbox (ADR-0013), and the gate route already says so in
307
+ * the other direction.
308
+ */
309
+ const instanceOnly: MiddlewareHandler<JR2Env> = async (c, next) => {
310
+ if (!auth) return next(); // no authenticator configured: tests only (see the doc comment)
311
+ const principal = auth(bearerOf(c));
312
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
313
+ if (principal.kind !== "instance") {
314
+ return c.json({ error: "run state and control need the Instance token — a Sandbox token has neither" }, 403);
315
+ }
316
+ c.set("principal", principal);
317
+ return next();
318
+ };
319
+
320
+ // Liveness, and the one place an instance says WHAT IT IS. Unauthenticated because it is the
321
+ // readiness probe's target and because identity is not run state — it is the same class of thing
322
+ // as the route table, which is public by being served. The CLI probes it to explain a failure it
323
+ // could otherwise only report as a bare status code (version skew reads as a nonsense 404).
324
+ // `hash` is the image's content address (ADR-0019), absent for a host-booted fixture process,
325
+ // which has no image to be addressed.
326
+ app.get("/healthz", (c) => c.json({ ok: true, version: KIT_VERSION, hash: process.env.JR2_CONTENT_HASH }));
327
+ app.get("/readyz", (c) => c.json({ ready: true }));
328
+
329
+ // Structure, not state: the workflow listing, a template's Machine, and the Console shell are
330
+ // unauthenticated. They expose no run, drive nothing, and the Console is a BROWSER page — it
331
+ // loads before any token is entered (ADR-0032). Everything that reads or moves a run is guarded
332
+ // below.
333
+ app.get("/workflows", (c) => c.json(host.workflows()));
334
+
335
+ // The two PAGE addresses (ADR-0032): `/` and `/workflows/:name` negotiate. A browser navigation
336
+ // (`Accept` prefers text/html) gets the one Console shell — the path carries the selection, the
337
+ // shell is the same bytes for any of them. Every other caller gets JSON, the default dialect.
338
+ //
339
+ // JSON `/` is a 404: nothing lived there before the Console, and inventing an index now would be
340
+ // surface no client asked for. JSON `/workflows/:name` is the workflow DETAIL: identity plus the
341
+ // machine's declared input as JSON Schema (`null` when it declares none — ADR-0033). Open band:
342
+ // a schema is structure, exactly like the Machine document below.
343
+ app.get("/", (c) => {
344
+ if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
345
+ return c.json({ error: "no JSON resource at / — the Console is the HTML dialect" }, 404);
346
+ });
347
+ app.get("/workflows/:name", (c) => {
348
+ // The shell for ANY name — an unknown workflow surfaces in-page via its 404'd /machine fetch.
349
+ if (prefersHtml(c)) return consoleAsset("page.html", "text/html; charset=utf-8");
350
+ const name = c.req.param("name");
351
+ const doc = host.machine(name);
352
+ if (!doc) return c.json({ error: `no workflow "${name}"` }, 404);
353
+ return c.json({ name, machineId: doc.id, input: host.inputSchema(name) ?? null });
354
+ });
355
+
356
+ // Push work: start a run of a registered workflow. Unknown workflow → host.start throws → 404.
357
+ // A body failing the machine's declared input schema (ADR-0033) → 400 naming the accepted
358
+ // shape — the same error class, and the same wire mapping, as a gate delivery failing its
359
+ // schema (`POST /runs/:id/gates/:gate/events` below).
360
+ app.post("/workflows/:name/runs", authenticated, async (c) => {
361
+ const name = c.req.param("name");
362
+ const input = await readJson(c.req.text());
363
+ try {
364
+ const { runId, instanceId } = await host.start(name, input);
365
+ return c.json({ runId, instanceId }, 201);
366
+ } catch (err) {
367
+ if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
368
+ return c.json({ error: errMessage(err) }, 404);
369
+ }
370
+ });
371
+
372
+ // The registered template Machine's structure — what the Console's diagram renders (structure
373
+ // is provider-independent, so the un-`provide()`d template is exactly right).
374
+ app.get("/workflows/:name/machine", (c) => {
375
+ const name = c.req.param("name");
376
+ const doc = host.machine(name);
377
+ return doc ? c.json(doc) : c.json({ error: `no workflow "${name}"` }, 404);
378
+ });
379
+
380
+ // ---- Observation (`GET /workflows/:name/runs*`) ---------------------------------------------
381
+ // What the tokenless Console needs, and the most it may have. The page is a BROWSER: it holds no
382
+ // token until one is ENTERED (ADR-0032), and baking one in would mean giving the INSTANCE token
383
+ // — gates, run control, every run's context — to whatever can load a URL (and the orchestrator
384
+ // binds 0.0.0.0 — pods must reach it — so that URL is not only yours). So the page gets a
385
+ // projection instead of a credential: `observe()` keeps identity + the state VALUE and drops
386
+ // context, and the guarded `/runs*` routes above stay exactly as guarded as they were. A
387
+ // projection, not a bypass.
388
+ //
389
+ // Scoped to one workflow because that is what an observer already knows (it is in the page's
390
+ // path): no listing of everything this orchestrator is running.
391
+
392
+ app.get("/workflows/:name/runs", (c) => c.json(host.observations(c.req.param("name"))));
393
+
394
+ /**
395
+ * SSE: a whole WORKFLOW's activity (ADR-0022) — every run of it appearing, moving, emitting and
396
+ * leaving, on one connection that outlives all of them.
397
+ *
398
+ * The feed is LEVEL-TRIGGERED: `status` always carries a whole observation, never a patch, and the
399
+ * opening `runs` frame carries the entire current set. A reconnecting client therefore converges
400
+ * with no replay buffer, no Last-Event-ID and no per-client state on this side — re-delivery is
401
+ * idempotent by construction. Same reconciliation idiom as the Lease (ADR-0021) and ADR-0019.
402
+ *
403
+ * Unknown workflow is NOT a 404: it attaches and reports an empty set, matching
404
+ * `/workflows/:name/runs`. The page is opened by path, and a later registration may supply the
405
+ * name a moment later — the already-open feed then just starts working.
406
+ *
407
+ * Same open band as the routes above (ADR-0014): `observe()` projects away context, instanceId and
408
+ * fault at every depth, and an emit contributes its TYPE alone.
409
+ */
410
+ app.get("/workflows/:name/events", async (c) => {
411
+ const name = c.req.param("name");
412
+ return streamSSE(c, async (stream) => {
413
+ await new Promise<void>((resolve) => {
414
+ const exit = closer(resolve);
415
+ // Subscribe and snapshot in one call, then write the snapshot in the same tick: nothing can
416
+ // start, move or finish in between, so the client's first frame is a complete picture.
417
+ const { runs, unsubscribe } = host.observeWorkflow(name, (ev) => {
418
+ if (ev.kind === "closed") return exit.done();
419
+ if (ev.kind === "gone") {
420
+ void stream.writeSSE({ event: "gone", data: JSON.stringify({ runId: ev.runId }) });
421
+ return;
422
+ }
423
+ if (ev.kind === "emit") {
424
+ // The TYPE alone: an emit's payload is author data, the same class of thing as context.
425
+ void stream.writeSSE({ event: "emit", data: JSON.stringify({ runId: ev.runId, type: ev.event.type }) });
426
+ return;
427
+ }
428
+ if (ev.kind === "retry") {
429
+ // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind the
430
+ // Instance token like `fault` (ADR-0014/0016).
431
+ void stream.writeSSE({
432
+ event: "retry",
433
+ data: JSON.stringify({ runId: ev.runId, child: ev.child, attempt: ev.attempt }),
434
+ });
435
+ return;
436
+ }
437
+ void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) });
438
+ });
439
+ exit.onExit(unsubscribe);
440
+ // `retry` steers the browser's own EventSource backoff. This feed never ends on its own, so
441
+ // every close is a fault worth reconnecting from — the client does not decide that.
442
+ void stream.writeSSE({ event: "runs", data: JSON.stringify(runs.map(observe)), retry: 2000 });
443
+ exit.onExit(pinger(stream, exit.done, pingMs));
444
+ stream.onAbort(exit.done);
445
+ });
446
+ });
447
+ });
448
+
449
+ app.get("/workflows/:name/runs/:runId/events", async (c) => {
450
+ const name = c.req.param("name");
451
+ const runId = c.req.param("runId");
452
+ const live = host.status(runId);
453
+ // LIVE runs only, and only through the workflow that owns them. A settled run's terminal status
454
+ // is a read-through into the store (`host.read`) — that is the Instance's feed, not this one.
455
+ if (!live || live.workflow !== name) return c.json({ error: `no live run "${runId}" of "${name}"` }, 404);
456
+ return streamSSE(c, async (stream) => {
457
+ await new Promise<void>((resolve) => {
458
+ const exit = closer(resolve);
459
+ exit.onExit(
460
+ host.subscribe(runId, (ev) => {
461
+ // The host is shutting down under a feed that has no end of its own.
462
+ if (ev.kind === "closed") return exit.done();
463
+ if (ev.kind === "emit") {
464
+ // The TYPE alone: an emit's payload is author data, the same class of thing as context.
465
+ void stream.writeSSE({ event: "emit", data: JSON.stringify({ type: ev.event.type }) });
466
+ return;
467
+ }
468
+ if (ev.kind === "retry") {
469
+ // `{ child, attempt }` only — `reason` is mechanism/error text, which stays behind
470
+ // the Instance token like `fault` (ADR-0014/0016).
471
+ void stream.writeSSE({ event: "retry", data: JSON.stringify({ child: ev.child, attempt: ev.attempt }) });
472
+ return;
473
+ }
474
+ // Turn markers (ADR-0023) carry an Agent's framing and pick payload — Instance-token
475
+ // class, so the OPEN band never sees them (not even their types).
476
+ if (ev.kind === "admission" || ev.kind === "pick") return;
477
+ // Terminal frame must flush before the handler returns and closes the stream (see the
478
+ // guarded feed below for why the exit is chained off the write).
479
+ const terminal = ev.status.status !== "active";
480
+ void stream.writeSSE({ event: "status", data: JSON.stringify(observe(ev.status)) }).then(() => {
481
+ if (terminal) exit.done();
482
+ });
483
+ }),
484
+ );
485
+ // Race guard: settled between the liveness check and the subscribe, which then attached to
486
+ // nothing. No read-through on this route, so there is nothing to fall back to — just close.
487
+ if (host.status(runId) === undefined) return exit.done();
488
+ exit.onExit(pinger(stream, exit.done, pingMs));
489
+ stream.onAbort(exit.done);
490
+ });
491
+ });
492
+ });
493
+
494
+ // The Console's assets, root-owned (`/assets/*`): they share no prefix with a page address, so
495
+ // no workflow name can capture them — the guard the old `/viz/assets` ordering provided, now by
496
+ // construction. The shell itself is served by the negotiated page addresses above.
497
+ app.get("/assets/style.css", () => consoleAsset("style.css", "text/css; charset=utf-8"));
498
+ app.get("/assets/elk.js", async () => {
499
+ const body = await readElkBundle();
500
+ return new Response(new Uint8Array(body), {
501
+ headers: { "content-type": "text/javascript; charset=utf-8" },
502
+ });
503
+ });
504
+
505
+ // Console SOURCE (ADR-0034): any `.ts` under console/ is served with its types erased — the
506
+ // flat files (main.ts, store.ts, canvas.ts) and the `components/` level the view modules live
507
+ // in. Each param pattern admits one flat path segment of name characters — no separators, so no
508
+ // traversal, and nothing outside console/ is reachable by construction.
509
+ app.get("/assets/:file{[\\w.-]+\\.ts}", (c) => consoleTsAsset(c.req.param("file")));
510
+ app.get("/assets/components/:file{[\\w.-]+\\.ts}", (c) => consoleTsAsset(`components/${c.req.param("file")}`));
511
+
512
+ // Preact's browser ESM (ADR-0034), off this package's own dep edge — the elkjs precedent.
513
+ app.get("/assets/vendor/:file", async (c) => {
514
+ const file = c.req.param("file");
515
+ const body = await readVendor(file);
516
+ if (!body) return c.json({ error: `no vendor asset "${file}"` }, 404);
517
+ return new Response(new Uint8Array(await body), {
518
+ headers: { "content-type": "text/javascript; charset=utf-8" },
519
+ });
520
+ });
521
+
522
+ app.get("/runs", instanceOnly, (c) => c.json(host.list()));
523
+
524
+ // The Repos as the cluster currently reports them (ADR-0048/0051) — what `jr2 status` renders,
525
+ // and the place ADR-0047's "register the key, the cache agent retries" points at. Instance band,
526
+ // like every other state route: a row names a Repo and carries git's own error text, which is
527
+ // exactly the class of thing the open observation projections strip (ADR-0014). An instance
528
+ // without a data plane answers `{ dataPlane: false, repos: [] }` rather than 404 — "this
529
+ // instance runs no Workspace" is an answer, and it is the same answer next boot.
530
+ app.get("/repos", instanceOnly, async (c) =>
531
+ c.json({ dataPlane: opts.dataPlane ?? false, repos: (await opts.repos?.()) ?? [] }),
532
+ );
533
+
534
+ // Abbreviated run ids (ADR-0009). Registered before `/runs/:runId` so "resolve" is never captured
535
+ // as a run id. The prefix rides in the query string
536
+ // because this is a search, not an address: `/runs/resolve/abc` would read like a run named
537
+ // "resolve". Resolution lives HERE and not on the addressed routes below, which stay full-id —
538
+ // a prefix that resolves today goes ambiguous tomorrow, and a write must never be prefix-sensitive.
539
+ //
540
+ // Ids ONLY, never RunStatus: it keeps the scan index-only, it matches what git prints for an
541
+ // ambiguous hash, and it holds down what this newly reveals — settled run ids are now visible to
542
+ // an Instance-token holder, which `GET /runs`'s deferred `?all` had withheld. Deliberately not on
543
+ // the open observation routes: prefix probing there would be a run-id enumeration oracle.
544
+ app.get("/runs/resolve", instanceOnly, async (c) => {
545
+ const prefix = c.req.query("prefix") ?? "";
546
+ if (prefix.length < MIN_RUN_ID_PREFIX) {
547
+ return c.json({ error: `prefix must be at least ${MIN_RUN_ID_PREFIX} characters` }, 400);
548
+ }
549
+ // Scan one past the cap so `truncated` is knowable without a second COUNT.
550
+ const found = await host.candidates(prefix, RESOLVE_LIMIT + 1);
551
+ return c.json({
552
+ prefix,
553
+ runIds: found.slice(0, RESOLVE_LIMIT),
554
+ truncated: found.length > RESOLVE_LIMIT,
555
+ });
556
+ });
557
+
558
+ // Read-through (ADR-0009): a completed run's final status lives in the store after the registry
559
+ // drops it, so this serves terminal runs too — only a genuinely unknown run is a 404. The status
560
+ // carries the run's OPEN GATES (ADR-0011) — the discovery listing external callers act on
561
+ // (`jr2 send` menus, UI inbox cards, webhook translators matching on meta). Settled run → [].
562
+ app.get("/runs/:runId", instanceOnly, async (c) => {
563
+ const runId = c.req.param("runId");
564
+ const status = await host.read(runId);
565
+ return status ? c.json({ ...status, gates: host.gates(runId) }) : c.json({ error: `no run "${runId}"` }, 404);
566
+ });
567
+
568
+ // Gates delivery (ADR-0011): validate the body against the gate's named schema and deliver into
569
+ // the gated state. Unknown gate (never opened, state exited, run settled) → 404; a name the gate
570
+ // doesn't accept, or a payload failing its schema → 400 naming what IS accepted.
571
+ //
572
+ // A Gate is a HUMAN's decision (or a webhook's, or CI's). An Agent holding a Sandbox token is
573
+ // refused here unconditionally — this is the exact line between "the Agent reports an outcome"
574
+ // and "the Agent approves its own PR" (ADR-0013).
575
+ app.post("/runs/:runId/gates/:gate/events", authenticated, async (c) => {
576
+ if (principalOf(c).kind === "sandbox") {
577
+ return c.json({ error: "a Sandbox token cannot deliver to a gate — gates are not on the Agent's surface" }, 403);
578
+ }
579
+ const body = await readJson(c.req.text());
580
+ try {
581
+ host.sendToGate(c.req.param("runId"), c.req.param("gate"), body);
582
+ return c.json({ ok: true });
583
+ } catch (err) {
584
+ if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
585
+ if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
586
+ throw err;
587
+ }
588
+ });
589
+
590
+ // SSE: a live run streams its status deltas + author `emit`s (current status replayed on attach,
591
+ // then live until the terminal transition or client abort). A run that has already settled streams
592
+ // its final status once and closes (so `jr2 logs -f` works on a finished run). Unknown run → 404.
593
+ app.get("/runs/:runId/events", instanceOnly, async (c) => {
594
+ const runId = c.req.param("runId");
595
+ if (host.status(runId) === undefined) {
596
+ const finalStatus = await host.read(runId);
597
+ if (!finalStatus) return c.json({ error: `no run "${runId}"` }, 404);
598
+ return streamSSE(c, async (stream) => {
599
+ await stream.writeSSE({ event: "status", data: JSON.stringify(finalStatus) });
600
+ });
601
+ }
602
+ return streamSSE(c, async (stream) => {
603
+ await new Promise<void>((resolve) => {
604
+ const exit = closer(resolve);
605
+ exit.onExit(
606
+ host.subscribe(runId, (ev) => {
607
+ if (ev.kind === "closed") return exit.done();
608
+ if (ev.kind === "emit") {
609
+ void stream.writeSSE({ event: "emit", data: JSON.stringify(ev.event) });
610
+ return;
611
+ }
612
+ if (ev.kind === "retry") {
613
+ // The Instance's own feed: the full telemetry, reason included (same trust class as
614
+ // `fault`).
615
+ void stream.writeSSE({ event: "retry", data: JSON.stringify(ev) });
616
+ return;
617
+ }
618
+ if (ev.kind === "admission" || ev.kind === "pick") {
619
+ // Turn markers (ADR-0023) — Instance-token band, so the framing/payload ride whole.
620
+ // These stay OFF the open workflow feed entirely (run-host.ts feeds them per-run).
621
+ void stream.writeSSE({ event: ev.kind, data: JSON.stringify(ev) });
622
+ return;
623
+ }
624
+ // Exiting lets the handler return, which CLOSES the stream — so on the terminal frame we
625
+ // must wait for the write to flush first, or a fire-and-forget write races the close and the
626
+ // final status is dropped (the very frame `jr2 run` blocks on). Chain the exit off the write.
627
+ const terminal = ev.status.status !== "active";
628
+ void stream.writeSSE({ event: "status", data: JSON.stringify(ev.status) }).then(() => {
629
+ if (terminal) exit.done();
630
+ });
631
+ }),
632
+ );
633
+ // Race guard: the run may have settled between the liveness check above and this subscribe,
634
+ // which then attaches to nothing and never fires. Fall back to the terminal read-through.
635
+ if (host.status(runId) === undefined) {
636
+ exit.release();
637
+ void host.read(runId).then((s) => {
638
+ if (s) void stream.writeSSE({ event: "status", data: JSON.stringify(s) });
639
+ exit.done();
640
+ });
641
+ return;
642
+ }
643
+ exit.onExit(pinger(stream, exit.done, pingMs));
644
+ stream.onAbort(exit.done);
645
+ });
646
+ });
647
+ });
648
+
649
+ // Run control (ADR-0002). CANCEL is the only event left on this seam: APPROVE and STEER answered
650
+ // held `deferred` calls and drained `poll` inboxes, and ADR-0013 reserves both semantics without
651
+ // building them. Workflow-defined events reach a run through its GATES, not through here.
652
+ //
653
+ // It ENDS the run (ADR-0025): the Agents' turns end with it and the run does not come back on
654
+ // the next restore. `RunHost.stop()` — park it, keep it restorable — is a different verb, and
655
+ // deliberately not on the wire.
656
+ app.post("/runs/:runId/events", instanceOnly, async (c) => {
657
+ const runId = c.req.param("runId");
658
+ const body = (await readJson(c.req.text())) as RunEventBody;
659
+ if (body.type !== "CANCEL") {
660
+ return c.json({ error: `unknown event type "${body.type ?? ""}" (accepts: CANCEL)` }, 400);
661
+ }
662
+ try {
663
+ await host.cancel(runId);
664
+ return c.json({ ok: true });
665
+ } catch (err) {
666
+ return c.json({ error: errMessage(err) }, 404);
667
+ }
668
+ });
669
+
670
+ // ---- The Agent's surface (`/agents/:iid/*` — ADR-0013) --------------------------------------
671
+ // Served to ONE caller: the Adapter in the Agent's Sandbox. It renders `surface` as `tools/list`
672
+ // and turns a `tools/call` into an `events` POST. The Orchestrator therefore keeps no MCP
673
+ // dependency, no transport, no session handling — and the Agent keeps no route to this API
674
+ // except through a process whose credential it cannot read.
675
+ //
676
+ // Both routes are adapters over the SAME registration table the gates ride: lookup, validation
677
+ // and delivery are implemented once, in `registration.ts`.
678
+
679
+ /** Guard: the surface must exist, and this principal must be allowed to speak for it. */
680
+ const agentRegistration = (c: Context<JR2Env, "/agents/:instanceId/surface" | "/agents/:instanceId/events">) => {
681
+ const instanceId = c.req.param("instanceId");
682
+ const surface = host.agentSurface(instanceId);
683
+ // The one catch point (ADR-0011): no live registration (settled run, exited state, unknown
684
+ // iid) → there is no surface to serve. 404 BEFORE the scope check — a caller with a valid
685
+ // token learns nothing from it that it did not already know.
686
+ if (!surface) return { error: c.json({ error: `no live agent surface for instance "${instanceId}"` }, 404) };
687
+ if (!mayDeliverToAgent(principalOf(c), surface.sandbox)) {
688
+ // A Sandbox token for a DIFFERENT Sandbox (or for a workspace-less run, which no Sandbox
689
+ // owns). This is the check that keeps one feature's coder out of another's reviewer.
690
+ return { error: c.json({ error: `this token cannot speak for instance "${instanceId}"` }, 403) };
691
+ }
692
+ return { surface };
693
+ };
694
+
695
+ // This turn's menu: the events the invoking state accepts, their input schemas, their semantics.
696
+ // A transition swaps the registration, which swaps this — so the Adapter gets a state-scoped
697
+ // toolset for free, and needs no `list_changed` to know it (flue re-lists on every submission).
698
+ app.get("/agents/:instanceId/surface", authenticated, (c) => {
699
+ const { surface, error } = agentRegistration(c);
700
+ return error ?? c.json(surface);
701
+ });
702
+
703
+ // The Agent's pick, delivered into the state that invoked it. The receipt describes itself
704
+ // (ADR-0024): what was delivered, and whether that ended the turn — which the Adapter renders as
705
+ // prose. Its `deliveryId` still makes an outcome addressable after the fact, the room a deferred
706
+ // result will need when it lands.
707
+ app.post("/agents/:instanceId/events", authenticated, async (c) => {
708
+ const { error } = agentRegistration(c);
709
+ if (error) return error;
710
+ const body = await readJson(c.req.text());
711
+ try {
712
+ return c.json(host.sendToAgent(c.req.param("instanceId"), body));
713
+ } catch (err) {
714
+ if (err instanceof UnknownAddressError) return c.json({ error: errMessage(err) }, 404);
715
+ if (err instanceof EventValidationError) return c.json({ error: errMessage(err) }, 400);
716
+ throw err;
717
+ }
718
+ });
719
+
720
+ // The pod's one route out (ADR-0053). A `git fetch` inside a Sandbox runs a program on the
721
+ // runtime volume, which asks the Adapter on localhost, which forwards to this. What lands here
722
+ // is one identity; what goes back is the landing, or the cache as it stands with git's own words
723
+ // — freshness degrades, absence does not, so this answers 200 either way and the program prints
724
+ // the warning. The caller waits on the ask the way an attach waits on Ready: same status, same
725
+ // per-key entry, one wait.
726
+ //
727
+ // Scoped by the token to ITS OWN pod, and then by the CR to the Repos that pod mounts. Neither
728
+ // is decoration: without the first, one feature's Sandbox could spend the cluster's credential
729
+ // on another's; without the second, on any Repo in the namespace.
730
+ app.post("/sandboxes/:name/fetch", authenticated, async (c) => {
731
+ const name = c.req.param("name");
732
+ if (!mayAskForSandbox(principalOf(c), name)) {
733
+ return c.json({ error: `this token cannot ask for Sandbox "${name}"` }, 403);
734
+ }
735
+ if (!opts.fetchRepo) {
736
+ return c.json({ error: "this instance runs no Workspace, so it holds no Repo cache to ask" }, 404);
737
+ }
738
+ const body = await readJson(c.req.text());
739
+ const identity = body.identity;
740
+ if (typeof identity !== "string" || identity === "") {
741
+ return c.json({ error: "a fetch names the Repo's identity: { identity }" }, 400);
742
+ }
743
+ try {
744
+ return c.json(await opts.fetchRepo(name, identity));
745
+ } catch (err) {
746
+ // The scope refusal, and the only one: a Repo this Sandbox does not mount.
747
+ if (err instanceof UnmountedRepoError) return c.json({ error: errMessage(err) }, 404);
748
+ throw err;
749
+ }
750
+ });
751
+
752
+ return app;
753
+ }