@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.
- package/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- 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
|
+
}
|