@telorun/runner-core 0.8.1 → 0.9.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 (97) hide show
  1. package/README.md +33 -2
  2. package/dist/backend.d.ts +107 -23
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/config.d.ts +27 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +12 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/contract.d.ts +199 -15
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js +9 -0
  11. package/dist/contract.js.map +1 -1
  12. package/dist/debug/ports-resolved.d.ts +26 -0
  13. package/dist/debug/ports-resolved.d.ts.map +1 -0
  14. package/dist/debug/ports-resolved.js +42 -0
  15. package/dist/debug/ports-resolved.js.map +1 -0
  16. package/dist/debug/relay.d.ts.map +1 -1
  17. package/dist/debug/relay.js +34 -6
  18. package/dist/debug/relay.js.map +1 -1
  19. package/dist/debug/run-projection.d.ts +53 -0
  20. package/dist/debug/run-projection.d.ts.map +1 -0
  21. package/dist/debug/run-projection.js +115 -0
  22. package/dist/debug/run-projection.js.map +1 -0
  23. package/dist/index.d.ts +8 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/routes/apps.d.ts +1 -1
  28. package/dist/routes/apps.d.ts.map +1 -1
  29. package/dist/routes/apps.js +14 -4
  30. package/dist/routes/apps.js.map +1 -1
  31. package/dist/routes/io.d.ts.map +1 -1
  32. package/dist/routes/io.js +42 -9
  33. package/dist/routes/io.js.map +1 -1
  34. package/dist/routes/session-start.d.ts +12 -12
  35. package/dist/routes/session-start.d.ts.map +1 -1
  36. package/dist/routes/session-start.js +86 -13
  37. package/dist/routes/session-start.js.map +1 -1
  38. package/dist/routes/sessions.d.ts +11 -1
  39. package/dist/routes/sessions.d.ts.map +1 -1
  40. package/dist/routes/sessions.js +429 -4
  41. package/dist/routes/sessions.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +21 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/session/byte-ring-buffer.d.ts +6 -1
  46. package/dist/session/byte-ring-buffer.d.ts.map +1 -1
  47. package/dist/session/byte-ring-buffer.js +2 -2
  48. package/dist/session/byte-ring-buffer.js.map +1 -1
  49. package/dist/session/registry.d.ts +144 -13
  50. package/dist/session/registry.d.ts.map +1 -1
  51. package/dist/session/registry.js +166 -25
  52. package/dist/session/registry.js.map +1 -1
  53. package/dist/session/watch-supervisor.d.ts +42 -0
  54. package/dist/session/watch-supervisor.d.ts.map +1 -0
  55. package/dist/session/watch-supervisor.js +99 -0
  56. package/dist/session/watch-supervisor.js.map +1 -0
  57. package/dist/session/workspace-app.d.ts +4 -0
  58. package/dist/session/workspace-app.d.ts.map +1 -0
  59. package/dist/session/workspace-app.js +26 -0
  60. package/dist/session/workspace-app.js.map +1 -0
  61. package/dist/session/workspace-client.d.ts +33 -0
  62. package/dist/session/workspace-client.d.ts.map +1 -0
  63. package/dist/session/workspace-client.js +62 -0
  64. package/dist/session/workspace-client.js.map +1 -0
  65. package/dist/session/workspace-marker.d.ts +25 -0
  66. package/dist/session/workspace-marker.d.ts.map +1 -0
  67. package/dist/session/workspace-marker.js +35 -0
  68. package/dist/session/workspace-marker.js.map +1 -0
  69. package/dist/sse/channel.d.ts.map +1 -1
  70. package/dist/sse/channel.js +4 -0
  71. package/dist/sse/channel.js.map +1 -1
  72. package/package.json +5 -4
  73. package/src/backend.ts +111 -22
  74. package/src/config.ts +59 -0
  75. package/src/contract.ts +197 -15
  76. package/src/debug/ports-resolved.test.ts +66 -0
  77. package/src/debug/ports-resolved.ts +43 -0
  78. package/src/debug/relay.ts +32 -4
  79. package/src/debug/run-projection.test.ts +155 -0
  80. package/src/debug/run-projection.ts +122 -0
  81. package/src/index.ts +20 -1
  82. package/src/routes/apps.ts +14 -5
  83. package/src/routes/io.ts +58 -12
  84. package/src/routes/session-start.ts +106 -27
  85. package/src/routes/sessions.ts +498 -7
  86. package/src/server.ts +22 -1
  87. package/src/session/byte-ring-buffer.ts +8 -2
  88. package/src/session/registry.ts +288 -28
  89. package/src/session/ring-buffer.test.ts +4 -3
  90. package/src/session/watch-lifetime.test.ts +203 -0
  91. package/src/session/watch-supervisor.ts +112 -0
  92. package/src/session/workspace-app.ts +27 -0
  93. package/src/session/workspace-client.ts +80 -0
  94. package/src/session/workspace-marker.test.ts +35 -0
  95. package/src/session/workspace-marker.ts +39 -0
  96. package/src/sse/channel.ts +5 -0
  97. package/workspace-app/telo.yaml +228 -0
@@ -1,12 +1,30 @@
1
1
  import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
2
2
 
3
- import type { RunnerBackend } from "../backend.js";
4
- import type { RunnerTerms, SessionConfig, StartSessionRequest } from "../contract.js";
3
+ import type { BackendAppSpec, RunnerBackend } from "../backend.js";
4
+ import type { ResolvedRunnerApp, WatchSessionConfig } from "../config.js";
5
+ import {
6
+ DEFAULT_APP_NAME,
7
+ type IoMode,
8
+ type PortMapping,
9
+ type RunnerTerms,
10
+ type SessionAppSpec,
11
+ type SessionConfig,
12
+ type StartSessionRequest,
13
+ type WorkspaceChangeSet,
14
+ } from "../contract.js";
5
15
  import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
6
- import type { SessionRegistry } from "../session/registry.js";
7
- import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
16
+ import type { SessionEntry, SessionRegistry } from "../session/registry.js";
17
+ import { enforceTerms, launchWorkload, portsSchema, startWorkloadSession } from "./session-start.js";
8
18
  import { streamSessionEvents } from "../sse/channel.js";
9
19
 
20
+ /** The part of the operator's watch settings this route enforces. A projection
21
+ * of `WatchSessionConfig` rather than a second shape, so the two cannot
22
+ * disagree about what a field means. */
23
+ export type WatchConfig = Pick<
24
+ WatchSessionConfig,
25
+ "enabled" | "maxSessions" | "reloadLimitPerMinute"
26
+ >;
27
+
10
28
  export interface SessionsRouteDeps {
11
29
  backend: RunnerBackend;
12
30
  registry: SessionRegistry;
@@ -22,8 +40,28 @@ export interface SessionsRouteDeps {
22
40
  * the source of truth, so this re-checks what `/v1/capabilities` advertises
23
41
  * (e.g. an `image` allowlist) against a client that skipped the editor. */
24
42
  validateConfig?: (config: SessionConfig) => string | undefined;
43
+ /** The operator catalog a session's `agent` is resolved against — the same
44
+ * catalog `POST /v1/apps/:name/sessions` uses, because a co-resident agent IS
45
+ * an operator-predefined application, just one sharing a pod. */
46
+ apps?: Record<string, ResolvedRunnerApp>;
47
+ watch: WatchConfig;
25
48
  }
26
49
 
50
+ const appsSchema = {
51
+ type: "array",
52
+ minItems: 1,
53
+ items: {
54
+ type: "object",
55
+ required: ["name", "entryRelativePath"],
56
+ properties: {
57
+ name: { type: "string", minLength: 1, maxLength: 63 },
58
+ entryRelativePath: { type: "string", minLength: 1 },
59
+ ports: portsSchema,
60
+ io: { type: "string", enum: ["tty", "streams"] },
61
+ },
62
+ },
63
+ } as const;
64
+
27
65
  const startBodySchema = {
28
66
  type: "object",
29
67
  required: ["bundle", "env", "config"],
@@ -61,9 +99,42 @@ const startBodySchema = {
61
99
  },
62
100
  },
63
101
  inspect: { type: "boolean" },
102
+ mode: { type: "string", enum: ["run", "watch"] },
103
+ agent: { type: "string", minLength: 1 },
104
+ apps: appsSchema,
105
+ },
106
+ } as const;
107
+
108
+ const changeSetSchema = {
109
+ type: "object",
110
+ properties: {
111
+ write: {
112
+ type: "array",
113
+ items: {
114
+ type: "object",
115
+ required: ["path", "content"],
116
+ properties: {
117
+ path: { type: "string", minLength: 1 },
118
+ content: { type: "string" },
119
+ encoding: { type: "string", enum: ["utf8", "base64"] },
120
+ },
121
+ },
122
+ },
123
+ delete: { type: "array", items: { type: "string", minLength: 1 } },
64
124
  },
65
125
  } as const;
66
126
 
127
+ /** A DNS label — the app name appears in a container name and in every run
128
+ * event, so it has to survive both. */
129
+ const APP_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
130
+
131
+ /** Names a session's own containers already use. The docker backend keys every
132
+ * container of a session in one map, so an app called `workspace` would
133
+ * overwrite the workspace handle — leaking the real container, since
134
+ * `AutoRemove` only fires on stop. Reserved in core so one rule covers both
135
+ * backends rather than k8s being safe only by its `app-` prefix. */
136
+ const RESERVED_APP_NAMES = new Set(["workspace", "agent"]);
137
+
67
138
  export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
68
139
  return async (app: FastifyInstance) => {
69
140
  app.post<{ Body: StartSessionRequest }>(
@@ -84,6 +155,14 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
84
155
  reply.send({
85
156
  sessionId: entry.sessionId,
86
157
  status: entry.status,
158
+ mode: entry.mode,
159
+ agent: entry.agent,
160
+ apps: [...entry.apps.values()].map((a) => ({
161
+ name: a.name,
162
+ io: a.io,
163
+ generation: a.generation,
164
+ ports: a.ports,
165
+ })),
87
166
  createdAt: entry.createdAt.toISOString(),
88
167
  exitedAt: entry.exitedAt?.toISOString(),
89
168
  });
@@ -119,9 +198,360 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
119
198
  corsOrigins: deps.corsOrigins,
120
199
  }),
121
200
  );
201
+
202
+ // ---- Workspace surface -------------------------------------------------
203
+ // Every write from OUTSIDE the pod goes through these; the co-resident agent
204
+ // writes the same volume directly with its own filesystem tools. Two writers,
205
+ // N watchers, one directory — which is what replaces three delivery paths.
206
+
207
+ app.get<{ Params: { id: string } }>("/v1/sessions/:id/workspace", async (req, reply) => {
208
+ const ws = resolveWorkspace(deps, req.params.id, reply);
209
+ if (!ws) return;
210
+ reply.send(await ws.tree());
211
+ });
212
+
213
+ app.post<{ Params: { id: string }; Body: WorkspaceChangeSet }>(
214
+ "/v1/sessions/:id/workspace",
215
+ { schema: { body: changeSetSchema } },
216
+ async (req, reply) => {
217
+ const entry = requireWatchSession(deps, req.params.id, reply);
218
+ if (!entry) return;
219
+ // The reload budget belongs on the route that CAUSES reloads. Guarding
220
+ // `POST /reload` alone left the editor's save path — one write per save,
221
+ // each triggering a kernel reload — unbounded, which is the shape the
222
+ // budget exists to prevent.
223
+ if (!admitReload(entry, deps.watch.reloadLimitPerMinute)) {
224
+ reply.code(429).send({
225
+ error: "reload_rate_limited",
226
+ message: `more than ${deps.watch.reloadLimitPerMinute} workspace writes in the last minute`,
227
+ });
228
+ return;
229
+ }
230
+ const ws = entry.session?.workspace;
231
+ if (!ws) {
232
+ reply.code(409).send({ error: "not_running", message: "session has no live workspace" });
233
+ return;
234
+ }
235
+ try {
236
+ for (const w of req.body.write ?? []) normalizeBundlePath(w.path);
237
+ for (const p of req.body.delete ?? []) normalizeBundlePath(p);
238
+ } catch (err) {
239
+ if (err instanceof BundlePathError) {
240
+ reply.code(400).send({ error: "invalid_path", message: err.message });
241
+ return;
242
+ }
243
+ throw err;
244
+ }
245
+ reply.send(await ws.apply(req.body));
246
+ },
247
+ );
248
+
249
+ app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
250
+ "/v1/sessions/:id/workspace/file",
251
+ async (req, reply) => {
252
+ const ws = resolveWorkspace(deps, req.params.id, reply);
253
+ if (!ws) return;
254
+ const path = req.query.path?.trim();
255
+ if (!path) {
256
+ reply.code(400).send({ error: "invalid_path", message: "?path= is required" });
257
+ return;
258
+ }
259
+ try {
260
+ normalizeBundlePath(path);
261
+ } catch (err) {
262
+ if (err instanceof BundlePathError) {
263
+ reply.code(400).send({ error: "invalid_path", message: err.message });
264
+ return;
265
+ }
266
+ throw err;
267
+ }
268
+ reply.send(await ws.readFile(path));
269
+ },
270
+ );
271
+
272
+ // `--watch` reloads on change, and pressing Run again after a one-shot app
273
+ // completed is not a change. This touches the named app's entry manifest
274
+ // through the same write path everything else uses — no signalling into the
275
+ // container, no shared PID namespace, no `exec`.
276
+ app.post<{ Params: { id: string }; Querystring: { app?: string } }>(
277
+ "/v1/sessions/:id/reload",
278
+ async (req, reply) => {
279
+ const entry = requireWatchSession(deps, req.params.id, reply);
280
+ if (!entry) return;
281
+ const session = entry.session;
282
+ if (!session?.reload) {
283
+ reply.code(409).send({ error: "not_running", message: "session has no live workload" });
284
+ return;
285
+ }
286
+ if (!admitReload(entry, deps.watch.reloadLimitPerMinute)) {
287
+ reply.code(429).send({
288
+ error: "reload_rate_limited",
289
+ message: `more than ${deps.watch.reloadLimitPerMinute} reloads in the last minute`,
290
+ });
291
+ return;
292
+ }
293
+ // Omitting `app` reloads every app in the session.
294
+ const requested = req.query.app?.trim();
295
+ if (requested && !entry.apps.has(requested)) {
296
+ reply.code(404).send({
297
+ error: "unknown_app",
298
+ message: `session runs no app named '${requested}'`,
299
+ });
300
+ return;
301
+ }
302
+ const targets = requested ? [requested] : [...entry.apps.keys()];
303
+ for (const name of targets) entry.attribution?.expect(name, "manual");
304
+ for (const name of targets) await session.reload(name);
305
+ reply.code(202).send({ reloaded: targets });
306
+ },
307
+ );
308
+
309
+ // A pod's container list is fixed at creation, so changing WHICH apps run
310
+ // costs a checkpoint and a pod recreate — the suspend/resume path, reused.
311
+ // Editing a file stays free; this is the rare action that pays.
312
+ app.put<{ Params: { id: string }; Body: { apps: SessionAppSpec[] } }>(
313
+ "/v1/sessions/:id/apps",
314
+ { schema: { body: { type: "object", required: ["apps"], properties: { apps: appsSchema } } } },
315
+ async (req, reply) => {
316
+ const entry = requireWatchSession(deps, req.params.id, reply);
317
+ if (!entry) return;
318
+ const session = entry.session;
319
+ if (!session?.setApps) {
320
+ reply.code(409).send({ error: "not_running", message: "session has no live workload" });
321
+ return;
322
+ }
323
+ const resolved = resolveApps(req.body.apps, undefined, []);
324
+ if ("error" in resolved) {
325
+ reply.code(400).send(resolved.error);
326
+ return;
327
+ }
328
+ // Channels for the NEW apps go in first — the backend starts emitting
329
+ // output as soon as a container is up, and a push to an unknown app is
330
+ // silently dropped. Removals wait until the backend has succeeded, so a
331
+ // failed recreate leaves the session describing what is still running.
332
+ for (const spec of resolved.apps) {
333
+ if (!entry.apps.has(spec.name)) {
334
+ deps.registry.addApp(entry, { name: spec.name, io: spec.io, ports: spec.ports });
335
+ }
336
+ }
337
+ entry.attribution?.expectAll("resume");
338
+ try {
339
+ await session.setApps(resolved.apps);
340
+ } catch (err) {
341
+ // `setApps` recreates the workload, so rejecting is a normal outcome
342
+ // (pod create, start deadline, workspace never ready). Undo the
343
+ // channels we added and fail the session rather than leaving it
344
+ // `running` with nothing behind it.
345
+ for (const spec of resolved.apps) {
346
+ if (!entry.launch?.apps.some((a) => a.name === spec.name)) {
347
+ entry.apps.delete(spec.name);
348
+ }
349
+ }
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ deps.registry.emit(entry.sessionId, {
352
+ type: "status",
353
+ status: { kind: "failed", message: `changing the app set failed: ${message}` },
354
+ });
355
+ reply.code(500).send({ error: "set_apps_failed", message });
356
+ return;
357
+ }
358
+ for (const name of [...entry.apps.keys()]) {
359
+ if (!resolved.apps.some((a) => a.name === name)) entry.apps.delete(name);
360
+ }
361
+ // The retained launch is what `resume` rebuilds from. Leaving it stale
362
+ // would rebuild the ORIGINAL app set under a registry holding the new
363
+ // one: output for the running containers dropped, and a session document
364
+ // listing apps that are not running.
365
+ if (entry.launch) entry.launch = { ...entry.launch, apps: resolved.apps };
366
+ reply.code(202).send({ apps: resolved.apps.map((a) => a.name) });
367
+ },
368
+ );
369
+
370
+ // A suspended session is best-effort by design: the checkpoint lives in the
371
+ // runner's memory, so a restart loses it and this answers 404. The editor
372
+ // holds the authoritative workspace and re-seeds from its own copy — which
373
+ // is a requirement ON the editor, not an observation about it.
374
+ app.post<{ Params: { id: string } }>("/v1/sessions/:id/resume", async (req, reply) => {
375
+ const entry = deps.registry.get(req.params.id);
376
+ if (!entry || entry.status.kind !== "suspended") {
377
+ reply.code(404).send({
378
+ error: "not_suspended",
379
+ message: `session '${req.params.id}' is not suspended`,
380
+ });
381
+ return;
382
+ }
383
+ const checkpoint = entry.checkpoint;
384
+ const launch = entry.launch;
385
+ if (!checkpoint || !launch) {
386
+ reply.code(409).send({
387
+ error: "no_checkpoint",
388
+ message: "session was suspended before a workspace checkpoint was taken",
389
+ });
390
+ return;
391
+ }
392
+ entry.attribution?.expectAll("resume");
393
+ entry.session = null;
394
+ deps.registry.emit(entry.sessionId, { type: "status", status: { kind: "starting" } });
395
+ // The checkpoint IS the bundle for the new pod — a cold pod re-downloads
396
+ // its module closure, and only the workspace is checkpointed, never the
397
+ // cache.
398
+ launchWorkload(
399
+ app,
400
+ deps,
401
+ {
402
+ ...launch,
403
+ bundle: {
404
+ entryRelativePath: launch.apps[0]?.entryRelativePath ?? "telo.yaml",
405
+ files: checkpoint.files.map((f) => ({
406
+ relativePath: f.path,
407
+ contents: f.content,
408
+ encoding: f.encoding,
409
+ })),
410
+ },
411
+ },
412
+ entry,
413
+ );
414
+ reply.code(202).send({ sessionId: entry.sessionId });
415
+ });
122
416
  };
123
417
  }
124
418
 
419
+ /** Reject anything but a live watch session, with the reason. */
420
+ function requireWatchSession(
421
+ deps: SessionsRouteDeps,
422
+ sessionId: string,
423
+ reply: FastifyReply,
424
+ ): SessionEntry | undefined {
425
+ const entry = deps.registry.get(sessionId);
426
+ if (!entry) {
427
+ reply.code(404).send({ error: "not_found", message: `session '${sessionId}' not in registry` });
428
+ return undefined;
429
+ }
430
+ if (entry.mode !== "watch") {
431
+ reply.code(409).send({
432
+ error: "not_a_watch_session",
433
+ message: "this surface exists only on a watch session",
434
+ });
435
+ return undefined;
436
+ }
437
+ return entry;
438
+ }
439
+
440
+ function resolveWorkspace(
441
+ deps: SessionsRouteDeps,
442
+ sessionId: string,
443
+ reply: FastifyReply,
444
+ ): NonNullable<SessionEntry["session"]>["workspace"] | undefined {
445
+ const entry = requireWatchSession(deps, sessionId, reply);
446
+ if (!entry) return undefined;
447
+ const workspace = entry.session?.workspace;
448
+ if (!workspace) {
449
+ reply.code(409).send({ error: "not_running", message: "session has no live workspace" });
450
+ return undefined;
451
+ }
452
+ return workspace;
453
+ }
454
+
455
+ /** Sliding one-minute window. Recorded only on an ADMITTED reload, so a client
456
+ * hammering a rate-limited endpoint cannot extend its own penalty. */
457
+ function admitReload(entry: SessionEntry, limitPerMinute: number): boolean {
458
+ const now = Date.now();
459
+ entry.reloads = entry.reloads.filter((t) => now - t < 60_000);
460
+ if (entry.reloads.length >= limitPerMinute) return false;
461
+ entry.reloads.push(now);
462
+ return true;
463
+ }
464
+
465
+ type ResolvedApps = { apps: BackendAppSpec[] } | { error: { error: string; message: string } };
466
+
467
+ /**
468
+ * Normalize the declared app set. Omitted, it defaults to a single app named
469
+ * `app` taking the bundle's own entry and the request's `ports` — so a
470
+ * single-app session is written exactly as it is today.
471
+ *
472
+ * Ports are unique across the WHOLE session, not per app: session hosts are
473
+ * `<port>-<sessionId>.<base-domain>`, a single label, so two apps both listening
474
+ * on 3000 collide with nothing to distinguish them. Rejecting at create is a
475
+ * better outcome than a URL that silently reaches the wrong app — the user
476
+ * controls both manifests.
477
+ */
478
+ function resolveApps(
479
+ declared: SessionAppSpec[] | undefined,
480
+ bundleEntry: string | undefined,
481
+ fallbackPorts: PortMapping[],
482
+ ): ResolvedApps {
483
+ const specs: SessionAppSpec[] = declared ?? [
484
+ {
485
+ name: DEFAULT_APP_NAME,
486
+ entryRelativePath: bundleEntry ?? "",
487
+ ports: fallbackPorts,
488
+ },
489
+ ];
490
+
491
+ const seenNames = new Set<string>();
492
+ const portOwner = new Map<string, string>();
493
+ const apps: BackendAppSpec[] = [];
494
+
495
+ for (const spec of specs) {
496
+ if (!APP_NAME_PATTERN.test(spec.name)) {
497
+ return {
498
+ error: {
499
+ error: "invalid_app_name",
500
+ message: `app name '${spec.name}' must be a DNS label (lowercase alphanumeric and '-')`,
501
+ },
502
+ };
503
+ }
504
+ if (RESERVED_APP_NAMES.has(spec.name)) {
505
+ return {
506
+ error: {
507
+ error: "reserved_app_name",
508
+ message: `app name '${spec.name}' is reserved — a session's own containers use it`,
509
+ },
510
+ };
511
+ }
512
+ if (seenNames.has(spec.name)) {
513
+ return {
514
+ error: { error: "duplicate_app_name", message: `app '${spec.name}' is declared twice` },
515
+ };
516
+ }
517
+ seenNames.add(spec.name);
518
+
519
+ let entryRelativePath: string;
520
+ try {
521
+ entryRelativePath = normalizeBundlePath(spec.entryRelativePath);
522
+ } catch (err) {
523
+ if (err instanceof BundlePathError) {
524
+ return { error: { error: "invalid_bundle", message: err.message } };
525
+ }
526
+ throw err;
527
+ }
528
+
529
+ const ports = spec.ports ?? [];
530
+ for (const port of ports) {
531
+ const key = `${port.protocol}/${port.port}`;
532
+ const owner = portOwner.get(key);
533
+ if (owner) {
534
+ return {
535
+ error: {
536
+ error: "port_conflict",
537
+ message: `apps '${owner}' and '${spec.name}' both declare ${port.protocol} port ${port.port}; session hosts carry no app name, so the two would be indistinguishable`,
538
+ },
539
+ };
540
+ }
541
+ portOwner.set(key, spec.name);
542
+ }
543
+
544
+ apps.push({
545
+ name: spec.name,
546
+ entryRelativePath,
547
+ ports,
548
+ io: (spec.io ?? "tty") as IoMode,
549
+ });
550
+ }
551
+
552
+ return { apps };
553
+ }
554
+
125
555
  async function startSession(
126
556
  app: FastifyInstance,
127
557
  deps: SessionsRouteDeps,
@@ -154,17 +584,78 @@ async function startSession(
154
584
  }
155
585
  }
156
586
 
587
+ const mode = body.mode ?? "run";
588
+ if (mode === "watch" && !deps.watch.enabled) {
589
+ reply.code(400).send({
590
+ error: "watch_disabled",
591
+ message: "this runner does not offer watch sessions (see /v1/capabilities)",
592
+ });
593
+ return;
594
+ }
595
+
596
+ // An agent with nothing watching its writes is a silent no-op, so the pairing
597
+ // is enforced rather than tolerated.
598
+ let agent: ResolvedRunnerApp | undefined;
599
+ if (body.agent) {
600
+ if (mode !== "watch") {
601
+ reply.code(400).send({
602
+ error: "agent_requires_watch",
603
+ message: "`agent` requires `mode: \"watch\"` — nothing would observe the agent's writes",
604
+ });
605
+ return;
606
+ }
607
+ agent = deps.apps?.[body.agent];
608
+ if (!agent) {
609
+ const offered = Object.keys(deps.apps ?? {});
610
+ reply.code(400).send({
611
+ error: "unknown_agent",
612
+ message:
613
+ `agent '${body.agent}' is not offered by this runner` +
614
+ (offered.length > 0 ? ` — offered: ${offered.join(", ")}` : ""),
615
+ });
616
+ return;
617
+ }
618
+ }
619
+
620
+ if (mode === "watch") {
621
+ // A suspended session holds no pod, so it does not consume the resource the
622
+ // ceiling exists to bound — and it is reclaimable, so counting it would let
623
+ // a few visitors who left hold every slot for the whole suspended TTL.
624
+ const live = deps.registry
625
+ .list()
626
+ .filter(
627
+ (e) => e.mode === "watch" && e.exitedAt === null && e.status.kind !== "suspended",
628
+ ).length;
629
+ if (live >= deps.watch.maxSessions) {
630
+ reply.code(409).send({
631
+ error: "too_many_watch_sessions",
632
+ message: `runner is at its configured max of ${deps.watch.maxSessions} concurrent watch sessions`,
633
+ });
634
+ return;
635
+ }
636
+ }
637
+
638
+ const resolved = resolveApps(body.apps, entryRelative, body.ports ?? []);
639
+ if ("error" in resolved) {
640
+ reply.code(400).send(resolved.error);
641
+ return;
642
+ }
643
+
157
644
  return startWorkloadSession(
158
645
  app,
159
646
  deps,
160
647
  {
161
648
  bundle: body.bundle,
162
- entryRelativePath: entryRelative,
163
649
  env: body.env,
164
- ports: body.ports ?? [],
165
650
  config: body.config,
166
651
  selfContained: false,
167
- inspect: body.inspect ?? false,
652
+ // A watch session always runs with the kernel debug stream on: that stream
653
+ // is the only place run outcomes exist, and parsing the merged PTY output
654
+ // would not be a contract.
655
+ inspect: mode === "watch" ? true : (body.inspect ?? false),
656
+ mode,
657
+ apps: resolved.apps,
658
+ agent,
168
659
  },
169
660
  reply,
170
661
  );
package/src/server.ts CHANGED
@@ -12,6 +12,7 @@ import { ioRoute } from "./routes/io.js";
12
12
  import { probeRoute } from "./routes/probe.js";
13
13
  import { sessionsRoute } from "./routes/sessions.js";
14
14
  import { SessionRegistry } from "./session/registry.js";
15
+ import { WatchSupervisor } from "./session/watch-supervisor.js";
15
16
 
16
17
  export interface ServerDeps {
17
18
  backend: RunnerBackend;
@@ -52,7 +53,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
52
53
  // default to `*` and let operators narrow via RUNNER_CORS_ORIGINS.
53
54
  await app.register(cors, {
54
55
  origin: deps.config.corsOrigins,
55
- methods: ["GET", "POST", "DELETE", "OPTIONS"],
56
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
56
57
  });
57
58
 
58
59
  await app.register(websocket);
@@ -63,8 +64,20 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
63
64
  maxSessions: deps.config.maxSessions,
64
65
  exitTtlMs: deps.config.exitTtlMs,
65
66
  replayBufferBytes: deps.config.replayBufferBytes,
67
+ suspendedTtlMs: deps.config.watch.suspendedTtlMs,
66
68
  });
67
69
 
70
+ // The checkpoint timer and the idle reaper. Started unconditionally: it walks
71
+ // watch sessions only, and with watch disabled there are none.
72
+ const supervisor = new WatchSupervisor({
73
+ registry,
74
+ idleMs: deps.config.watch.idleMs,
75
+ checkpointMs: deps.config.watch.checkpointMs,
76
+ log: app.log,
77
+ });
78
+ supervisor.start();
79
+ app.addHook("onClose", async () => supervisor.stop());
80
+
68
81
  // The app catalog is injected into the served capabilities document here, so
69
82
  // what /v1/capabilities advertises and what the session route accepts can
70
83
  // never drift — both come from `deps.apps`.
@@ -96,6 +109,14 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
96
109
  // The capabilities document is the single source of the runner's terms;
97
110
  // every session-creating route enforces what /v1/capabilities advertises.
98
111
  terms: capabilitiesValue.terms,
112
+ // A co-resident agent IS an operator-predefined application — one that
113
+ // happens to share a pod — so it resolves against the same catalog.
114
+ apps: deps.apps,
115
+ watch: {
116
+ enabled: deps.config.watch.enabled,
117
+ maxSessions: deps.config.watch.maxSessions,
118
+ reloadLimitPerMinute: deps.config.watch.reloadLimitPerMinute,
119
+ },
99
120
  }),
100
121
  );
101
122
  await app.register(
@@ -1,6 +1,12 @@
1
+ import type { ByteStreamTag } from "../contract.js";
2
+
1
3
  export interface BufferedBytes {
2
4
  seq: number;
3
5
  bytes: Buffer;
6
+ /** Which stream produced these bytes. `tty` under a terminal attach, where
7
+ * there is genuinely one merged stream; `stdout` / `stderr` only where the
8
+ * transport really did separate them. */
9
+ stream: ByteStreamTag;
4
10
  }
5
11
 
6
12
  /**
@@ -22,9 +28,9 @@ export class ByteRingBuffer {
22
28
  }
23
29
  }
24
30
 
25
- push(bytes: Buffer): BufferedBytes {
31
+ push(bytes: Buffer, stream: ByteStreamTag = "tty"): BufferedBytes {
26
32
  const seq = this.nextSeq++;
27
- const entry: BufferedBytes = { seq, bytes };
33
+ const entry: BufferedBytes = { seq, bytes, stream };
28
34
  this.entries.push(entry);
29
35
  this.totalBytes += bytes.byteLength;
30
36
  this.evict();