@telorun/runner-core 0.8.2 → 0.10.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 (98) 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 +56 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +36 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/contract.d.ts +210 -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 +18 -13
  35. package/dist/routes/session-start.d.ts.map +1 -1
  36. package/dist/routes/session-start.js +95 -14
  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 +465 -5
  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 +4 -3
  73. package/src/app-catalog.test.ts +49 -1
  74. package/src/backend.ts +111 -22
  75. package/src/config.ts +98 -0
  76. package/src/contract.ts +213 -16
  77. package/src/debug/ports-resolved.test.ts +66 -0
  78. package/src/debug/ports-resolved.ts +43 -0
  79. package/src/debug/relay.ts +32 -4
  80. package/src/debug/run-projection.test.ts +155 -0
  81. package/src/debug/run-projection.ts +122 -0
  82. package/src/index.ts +20 -1
  83. package/src/routes/apps.ts +14 -5
  84. package/src/routes/io.ts +58 -12
  85. package/src/routes/session-start.ts +115 -27
  86. package/src/routes/sessions.ts +538 -7
  87. package/src/server.ts +22 -1
  88. package/src/session/byte-ring-buffer.ts +8 -2
  89. package/src/session/registry.ts +288 -28
  90. package/src/session/ring-buffer.test.ts +4 -3
  91. package/src/session/watch-lifetime.test.ts +203 -0
  92. package/src/session/watch-supervisor.ts +112 -0
  93. package/src/session/workspace-app.ts +27 -0
  94. package/src/session/workspace-client.ts +80 -0
  95. package/src/session/workspace-marker.test.ts +35 -0
  96. package/src/session/workspace-marker.ts +39 -0
  97. package/src/sse/channel.ts +5 -0
  98. package/workspace-app/telo.yaml +228 -0
package/src/backend.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  import type { DebugFrame } from "@telorun/debug-wire";
2
2
 
3
+ import type { ResolvedRunnerApp } from "./config.js";
3
4
  import type {
4
5
  AvailabilityReport,
6
+ ByteStreamTag,
7
+ IoMode,
5
8
  PortMapping,
6
9
  ProbeConfig,
7
10
  ReachabilityState,
8
11
  RunBundle,
12
+ RunnerEndpoint,
9
13
  RunPhase,
10
14
  RunStatus,
11
15
  SessionConfig,
16
+ SessionMode,
17
+ WorkspaceChangeSet,
18
+ WorkspaceCheckpointFile,
19
+ WorkspaceTree,
12
20
  } from "./contract.js";
13
21
 
14
22
  /**
@@ -40,59 +48,140 @@ export interface RunnerBackend {
40
48
  reapOrphans?(): Promise<void>;
41
49
  }
42
50
 
43
- export interface BackendStartSpec {
44
- sessionId: string;
45
- bundle: RunBundle;
46
- /** Bundle-relative entry path, already traversal-normalized by core. */
51
+ /** One application container the backend must stand up. */
52
+ export interface BackendAppSpec {
53
+ name: string;
54
+ /** Bundle-relative entry path, already traversal-normalized by core. Empty for
55
+ * a self-contained app session, where the image's own entrypoint runs. */
47
56
  entryRelativePath: string;
48
- env: Record<string, string>;
49
57
  ports: PortMapping[];
58
+ io: IoMode;
59
+ }
60
+
61
+ /**
62
+ * The DATA half of a workload launch — everything a start needs that is not a
63
+ * callback. Retained on the session entry so `resume` can build a fresh pod
64
+ * under the same session id from the same description, rather than a second,
65
+ * drifting copy of it.
66
+ */
67
+ export interface WorkloadLaunch {
68
+ bundle: RunBundle;
69
+ env: Record<string, string>;
50
70
  config: SessionConfig;
51
71
  /** True for an operator-predefined app session (`POST /v1/apps/:name/sessions`):
52
72
  * `config.image` is self-contained (app + controllers baked in), so the
53
73
  * backend runs the image's own entrypoint and stages no bundle — `bundle`
54
74
  * is an empty placeholder. */
55
75
  selfContained: boolean;
56
- /** When true, launch the workload with `--inspect` and relay its kernel debug
76
+ /** When true, launch each app with `--inspect` and relay its kernel debug
57
77
  * stream via `onDebug`. The inspect endpoint stays reachable only by the
58
- * runner — never published outward. */
78
+ * runner — never published outward. Always true for a watch session: that
79
+ * stream is where `run` events are projected from. */
59
80
  inspect: boolean;
81
+ /** `run` (one pod, terminal on exit, today's shape) or `watch` (a workspace
82
+ * volume, `telo run --watch` per app, the pod outliving its runs). */
83
+ mode: SessionMode;
84
+ /** The applications to run, one container each. Never empty — core defaults a
85
+ * request with no `apps` to a single entry. */
86
+ apps: BackendAppSpec[];
87
+ /** The resolved catalog entry for a co-resident agent container, when one was
88
+ * requested. Its operator env goes on THAT container and nowhere else — the
89
+ * credential boundary used to be structural (two pods) and is now a code
90
+ * invariant (containers in one pod). */
91
+ agent?: ResolvedRunnerApp;
92
+ }
93
+
94
+ export interface BackendStartSpec extends WorkloadLaunch {
95
+ sessionId: string;
60
96
 
61
97
  /** Emit a lifecycle status. The backend drives `starting` → `running` →
62
- * terminal (`exited`/`failed`/`stopped`). */
98
+ * terminal (`exited`/`failed`/`stopped`), or `suspended` on an idle reap. */
63
99
  onStatus(status: RunStatus): void;
64
100
  /** Emit a progress message for a coming-up phase (build / provision / boot).
65
101
  * Additive to status — surfaces what's happening while the session is still
66
- * `starting`, driving the editor's spinner + step feed. */
67
- onProgress(phase: RunPhase, message: string, done?: boolean): void;
68
- /** Raw merged stdout/stderr (PTY) bytes from the workload. */
69
- onOutput(chunk: Buffer): void;
70
- /** A frame relayed from the workload's kernel debug stream. Only called when
71
- * `inspect` is true and the backend has connected to the inspect endpoint. */
72
- onDebug(frame: DebugFrame): void;
102
+ * `starting`. `app` is omitted for session-scoped provisioning. */
103
+ onProgress(phase: RunPhase, message: string, done?: boolean, app?: string): void;
104
+ /** Bytes from one app's terminal. `stream` is `tty` under a terminal attach
105
+ * and `stdout`/`stderr` only where the transport genuinely separated them. */
106
+ onOutput(app: string, chunk: Buffer, stream: ByteStreamTag): void;
107
+ /** A frame relayed from one app's kernel debug stream. Only called when
108
+ * `inspect` is true and the backend has connected to that app's endpoint. */
109
+ onDebug(app: string, frame: DebugFrame): void;
73
110
  /** Report a declared port's reachability from the runner network — `checking`
74
111
  * while the workload comes up, then `reachable`, or `unreachable` after a
75
112
  * timeout. Surfaced on the editor's endpoint badge, not the log stream. */
76
- onReachability(port: number, state: ReachabilityState): void;
113
+ onReachability(app: string, port: number, state: ReachabilityState): void;
114
+ /** One app's workload ended on its own. A run session's workload ending IS the
115
+ * session ending, so this is a watch-session concern: under `--watch` a
116
+ * finished run leaves the container up, and a container that goes away has
117
+ * died. Reported through the contract rather than by synthesizing a kernel
118
+ * frame — that stream's contract is "frames relayed from the workload", and a
119
+ * backend writing into it puts an event on the wire no kernel emitted. */
120
+ onRunEnded(app: string, outcome: { code?: number; reason?: string }): void;
121
+ /** An app's routable endpoint set changed after a reload re-read its declared
122
+ * ports. */
123
+ onEndpoints(
124
+ app: string,
125
+ change: {
126
+ added?: RunnerEndpoint[];
127
+ removed?: RunnerEndpoint[];
128
+ rejected?: Array<{ port: number; reason: string }>;
129
+ },
130
+ ): void;
77
131
  /** True once a user stop / shutdown has been requested — lets the backend
78
132
  * classify a kill as `stopped` rather than `failed`. */
79
133
  isUserStopped(): boolean;
80
134
  }
81
135
 
82
136
  export interface BackendSession {
83
- /** Write bytes to the workload's stdin (PTY). A no-op once the workload has
84
- * terminated, so callers need not track liveness. */
85
- writeStdin(bytes: Uint8Array): void;
137
+ /** Write bytes to one app's stdin. A no-op once that workload has terminated,
138
+ * so callers need not track liveness. */
139
+ writeStdin(app: string, bytes: Uint8Array): void;
86
140
 
87
- /** Resize the workload's PTY. */
88
- resize(cols: number, rows: number): void;
141
+ /** Resize one app's PTY. A no-op for an app running under `io: "streams"` —
142
+ * the route rejects such a resize before it reaches here. */
143
+ resize(app: string, cols: number, rows: number): void;
89
144
 
90
145
  /** Resolves after the workload terminates and its terminal status has been
91
146
  * emitted via `onStatus`. Never rejects — terminal failures surface as a
92
- * `failed` status. */
147
+ * `failed` status. A watch session's workload outlives its runs, so this
148
+ * settles only on stop, suspend, or the pod deadline. */
93
149
  readonly done: Promise<void>;
94
150
 
95
151
  /** Force-stop the workload. Idempotent and safe to call after natural exit
96
152
  * (a backend should treat an already-gone workload as a no-op). */
97
153
  stop(): Promise<void>;
154
+
155
+ /** The workspace surface, present only on a watch session. Everything outside
156
+ * the pod writes through here; the agent (inside it) writes the volume with
157
+ * its own filesystem tools. */
158
+ readonly workspace?: WorkspaceAccess;
159
+
160
+ /** Re-run one app with no file change (`POST /v1/sessions/:id/reload`), by
161
+ * touching its entry manifest through the same path every other write takes.
162
+ * No signalling into the container, no shared PID namespace, no `exec` — RBAC
163
+ * is unchanged. Watch sessions only. */
164
+ reload?(app: string): Promise<void>;
165
+
166
+ /** Replace the running app set. A pod's container list is fixed at creation,
167
+ * so this checkpoints, deletes the pod and creates one with the new set —
168
+ * the suspend/resume path, reused because it has to be. */
169
+ setApps?(apps: BackendAppSpec[]): Promise<void>;
170
+
171
+ /** Delete the workload, keeping nothing but what the caller already
172
+ * checkpointed. `resume` builds a fresh one from that checkpoint. */
173
+ suspend?(): Promise<void>;
174
+ }
175
+
176
+ /** The `workspace` container's routes, proxied by the runner. Reading and
177
+ * applying a change set are the only two shapes: a single-file write route
178
+ * would be a second set of concurrency rules over the same directory. */
179
+ export interface WorkspaceAccess {
180
+ tree(): Promise<WorkspaceTree>;
181
+ readFile(path: string): Promise<{ content: string; size: number }>;
182
+ apply(changes: WorkspaceChangeSet): Promise<{ written: number; deleted: number }>;
183
+ /** Whole-tree pull for the checkpoint timer and for suspend. Whole tree rather
184
+ * than a delta log: a manifest workspace is small, and one shape is easier to
185
+ * reason about than a replay that has to be correct. */
186
+ snapshot(): Promise<WorkspaceCheckpointFile[]>;
98
187
  }
package/src/config.ts CHANGED
@@ -15,6 +15,33 @@ export interface RunnerCoreConfig {
15
15
  exitTtlMs: number;
16
16
  replayBufferBytes: number;
17
17
  corsOrigins: string[] | "*";
18
+ watch: WatchSessionConfig;
19
+ }
20
+
21
+ /**
22
+ * Watch-session policy. Off by default and separately ceilinged, because a watch
23
+ * session is a materially different resource shape from a run: a pod that
24
+ * outlives its runs, whose concurrency is bounded by simultaneous EDITORS rather
25
+ * than simultaneous runs. Inheriting a run session's ceilings would size it for
26
+ * the opposite assumption.
27
+ */
28
+ export interface WatchSessionConfig {
29
+ /** Server-side gate. Watch sessions are never client-requestable when off. */
30
+ enabled: boolean;
31
+ /** No SSE/WS subscriber for this long → suspend (pod deleted, checkpoint held). */
32
+ idleMs: number;
33
+ /** Pod deadline for a watch session. The agent and app containers share one
34
+ * pod, so one deadline covers both: take the longer (agent) ceiling and let
35
+ * idleness do the real work, or the conversation dies at an hour mid-turn. */
36
+ maxTtlSeconds: number;
37
+ maxSessions: number;
38
+ reloadLimitPerMinute: number;
39
+ /** How long a SUSPENDED record is retained before eviction. Deliberately not
40
+ * the pod deadline: that bounds a pod, so on its own nothing would ever evict
41
+ * a suspended record. */
42
+ suspendedTtlMs: number;
43
+ /** How often the runner pulls a whole-tree workspace snapshot. */
44
+ checkpointMs: number;
18
45
  }
19
46
 
20
47
  export class RunnerConfigError extends Error {}
@@ -110,6 +137,15 @@ export interface RunnerAppConfig {
110
137
  /** Workload image pull policy (default `missing`); `always` keeps a moving
111
138
  * tag like `latest-slim` fresh. */
112
139
  pullPolicy?: PullPolicy;
140
+ /** The tcp port this image listens on, published by the runner when the entry
141
+ * is used as a session's co-resident `agent` — a client cannot supply it,
142
+ * because what gets published is the operator's decision, not the caller's.
143
+ * There is deliberately NO default: the catalog is pure operator
144
+ * configuration and the runner has no built-in knowledge of any specific
145
+ * app, so guessing 8080 would be exactly that knowledge. An entry without it
146
+ * still works as a standalone app session (where the client declares the
147
+ * ports it wants published); only `agent` use requires it. */
148
+ port?: number;
113
149
  title?: string;
114
150
  description?: string;
115
151
  }
@@ -120,6 +156,9 @@ export interface ResolvedRunnerApp {
120
156
  image: string;
121
157
  env: Record<string, string>;
122
158
  pullPolicy: PullPolicy;
159
+ /** See {@link RunnerAppConfig.port} — undefined when the operator declared
160
+ * none, which is what makes the entry unusable as an `agent`. */
161
+ port?: number;
123
162
  title?: string;
124
163
  description?: string;
125
164
  }
@@ -179,6 +218,12 @@ function validateAppEntry(name: string, value: unknown): RunnerAppConfig {
179
218
  if (entry.pullPolicy !== undefined && !PULL_POLICIES.includes(entry.pullPolicy as string)) {
180
219
  fail(`has an invalid 'pullPolicy' — expected one of ${PULL_POLICIES.join(", ")}.`);
181
220
  }
221
+ if (
222
+ entry.port !== undefined &&
223
+ (typeof entry.port !== "number" || !Number.isInteger(entry.port) || entry.port < 1 || entry.port > 65535)
224
+ ) {
225
+ fail("has an invalid 'port' — expected an integer in 1..65535.");
226
+ }
182
227
  for (const key of ["title", "description"] as const) {
183
228
  if (entry[key] !== undefined && typeof entry[key] !== "string") {
184
229
  fail(`has an invalid '${key}' — expected a string.`);
@@ -187,6 +232,26 @@ function validateAppEntry(name: string, value: unknown): RunnerAppConfig {
187
232
  return entry as unknown as RunnerAppConfig;
188
233
  }
189
234
 
235
+ /**
236
+ * Which catalog entries may be a session's co-resident `agent` — the ones that
237
+ * declare a `port`, because the session route refuses the rest and a runner
238
+ * must not advertise what it will reject.
239
+ *
240
+ * Derived here rather than in each runner's capabilities call because this IS
241
+ * the acceptance condition: the two used to be one list by construction
242
+ * (`Object.keys`), and adding a second condition to the route without adding it
243
+ * here is exactly how they drift. An operator upgrading with an existing
244
+ * catalog has no `port` anywhere — the field is new — so an unfiltered
245
+ * advertisement makes the editor attach an agent to every run and every run is
246
+ * refused.
247
+ *
248
+ * Per entry, not per catalog: declaring a port opts an app into co-resident
249
+ * use, omitting one leaves it launchable on its own.
250
+ */
251
+ export function coResidentAgentNames(apps: Record<string, ResolvedRunnerApp>): string[] {
252
+ return Object.keys(apps).filter((name) => apps[name]?.port !== undefined);
253
+ }
254
+
190
255
  /** The catalog runners pass to `buildServer`: `RUNNER_APPS` validated with
191
256
  * defaults applied; empty when unset. The catalog is pure operator
192
257
  * configuration — runner-core knows nothing about any specific app. */
@@ -199,6 +264,7 @@ export function loadResolvedApps(env: NodeJS.ProcessEnv): Record<string, Resolve
199
264
  image: entry.image,
200
265
  env: entry.env ?? {},
201
266
  pullPolicy: entry.pullPolicy ?? "missing",
267
+ port: entry.port,
202
268
  title: entry.title,
203
269
  description: entry.description,
204
270
  };
@@ -233,5 +299,37 @@ export function loadCoreConfig(
233
299
  "RUNNER_REPLAY_BUFFER_BYTES",
234
300
  ),
235
301
  corsOrigins: parseCorsOrigins(env.RUNNER_CORS_ORIGINS),
302
+ watch: loadWatchConfig(env),
303
+ };
304
+ }
305
+
306
+ export function loadWatchConfig(env: NodeJS.ProcessEnv): WatchSessionConfig {
307
+ return {
308
+ enabled: parseBool(env.RUNNER_WATCH_SESSIONS, false, "RUNNER_WATCH_SESSIONS"),
309
+ idleMs:
310
+ parsePositiveInt(env.RUNNER_WATCH_IDLE_SECONDS, 300, "RUNNER_WATCH_IDLE_SECONDS") * 1000,
311
+ maxTtlSeconds: parsePositiveInt(
312
+ env.RUNNER_WATCH_MAX_TTL_SECONDS,
313
+ 21600,
314
+ "RUNNER_WATCH_MAX_TTL_SECONDS",
315
+ ),
316
+ maxSessions: parsePositiveInt(env.RUNNER_WATCH_MAX_SESSIONS, 8, "RUNNER_WATCH_MAX_SESSIONS"),
317
+ reloadLimitPerMinute: parsePositiveInt(
318
+ env.RUNNER_WATCH_RELOAD_LIMIT,
319
+ 30,
320
+ "RUNNER_WATCH_RELOAD_LIMIT",
321
+ ),
322
+ suspendedTtlMs:
323
+ parsePositiveInt(
324
+ env.RUNNER_WATCH_SUSPENDED_TTL_SECONDS,
325
+ 86400,
326
+ "RUNNER_WATCH_SUSPENDED_TTL_SECONDS",
327
+ ) * 1000,
328
+ checkpointMs:
329
+ parsePositiveInt(
330
+ env.RUNNER_WORKSPACE_CHECKPOINT_SECONDS,
331
+ 30,
332
+ "RUNNER_WORKSPACE_CHECKPOINT_SECONDS",
333
+ ) * 1000,
236
334
  };
237
335
  }
package/src/contract.ts CHANGED
@@ -46,11 +46,30 @@ export interface RunnerTerms {
46
46
  /** HTTP header carrying the accepted terms version on `POST /v1/sessions`. */
47
47
  export const ACCEPTED_TERMS_HEADER = "x-telo-accepted-terms";
48
48
 
49
+ /**
50
+ * How an application container's byte channel is attached.
51
+ * - `tty` — one merged stream, as a terminal produces. Resize works;
52
+ * `CLICOLOR_FORCE` is injected.
53
+ * - `streams` — stdout and stderr separated at the source. Resize is rejected
54
+ * (meaningless with no PTY) and no colour is forced, so the app
55
+ * sees the environment it would see in production.
56
+ * Nothing is invented at the transport layer: docker's non-TTY attach is already
57
+ * a multiplexed stream carrying a per-frame stream id, and the kubernetes attach
58
+ * subresource without a TTY already gives separate stdout/stderr channels.
59
+ */
60
+ export type IoMode = "tty" | "streams";
61
+
49
62
  export interface RunnerFeatures {
50
- /** Runner exposes a live PTY byte channel (`/v1/sessions/:id/io`). */
51
- io: boolean;
63
+ /** Byte-channel attach modes this runner offers (`/v1/sessions/:id/io`). */
64
+ io: IoMode[];
52
65
  /** Runner can publish workload ports back to the client. */
53
66
  ports: boolean;
67
+ /** Watch sessions (`mode: "watch"`) are requestable. Server-gated: a runner
68
+ * with watch disabled advertises `false` and rejects the field. */
69
+ watch: boolean;
70
+ /** Catalog names admissible as a session's co-resident `agent`. Empty/absent
71
+ * when the operator configured none. */
72
+ agents?: string[];
54
73
  }
55
74
 
56
75
  /** An operator-predefined application the runner can launch by name. Only the
@@ -92,7 +111,14 @@ export type AvailabilityReport =
92
111
 
93
112
  export interface RunBundle {
94
113
  entryRelativePath: string;
95
- files: Array<{ relativePath: string; contents: string }>;
114
+ files: Array<{
115
+ relativePath: string;
116
+ contents: string;
117
+ /** `utf8` when omitted. A checkpoint re-seeds a resumed session through this
118
+ * same shape, and a workspace may hold a binary asset, so dropping anything
119
+ * that is not text would silently lose a file across a suspend. */
120
+ encoding?: "utf8" | "base64";
121
+ }>;
96
122
  }
97
123
 
98
124
  export type PortProtocol = "tcp" | "udp";
@@ -118,6 +144,42 @@ export interface RunnerEndpoint {
118
144
  url?: string;
119
145
  }
120
146
 
147
+ /**
148
+ * How long a session lives relative to its runs.
149
+ * - `run` — one run. The workload exits and the session is terminal. Today's
150
+ * behaviour, and the default.
151
+ * - `watch` — a workspace that runs continuously. Each application container
152
+ * runs `telo run --watch`, an edit costs a kernel reload rather
153
+ * than a pod, and a completed one-shot run leaves the session up.
154
+ */
155
+ export type SessionMode = "run" | "watch";
156
+
157
+ /** The name a single-app session's one application takes when the request
158
+ * declares no `apps` — so every `run` / `debug` / `endpoints` event names an
159
+ * app, and no client needs two readings of the same stream. */
160
+ export const DEFAULT_APP_NAME = "app";
161
+
162
+ /**
163
+ * One application in a session — one container, one kernel, one watcher. Most
164
+ * workspaces have exactly one: an Application with several Services in its
165
+ * `targets:` is still one kernel. The multi-app case is two independent
166
+ * Applications, which genuinely cannot be merged (importing a `Telo.Application`
167
+ * is a hard error, so no manifest composes them).
168
+ */
169
+ export interface SessionAppSpec {
170
+ /** Unique within the session and usable as a DNS label — it appears in the
171
+ * container name and in every run event. */
172
+ name: string;
173
+ /** Bundle-relative entry manifest this app runs. */
174
+ entryRelativePath: string;
175
+ /** Ports this app declares. Unique across the WHOLE session: session hosts are
176
+ * `<port>-<sessionId>.<base-domain>`, a single label, so two apps on one port
177
+ * would collide with nothing to tell them apart. */
178
+ ports?: PortMapping[];
179
+ /** Terminal or separated streams; defaults to `tty`. */
180
+ io?: IoMode;
181
+ }
182
+
121
183
  export interface StartSessionRequest {
122
184
  bundle: RunBundle;
123
185
  env: Record<string, string>;
@@ -126,17 +188,60 @@ export interface StartSessionRequest {
126
188
  /** Request the kernel debug stream. When true the runner launches the
127
189
  * workload with `--inspect`, subscribes to the in-workload inspect endpoint
128
190
  * (reachable only by the runner — never published outward), and relays each
129
- * frame to the client as a `debug` {@link RunEvent}. */
191
+ * frame to the client as a `debug` {@link RunEvent}. A watch session always
192
+ * runs with it on — that stream is where `run` events are projected from. */
130
193
  inspect?: boolean;
194
+ /** Defaults to `run`. */
195
+ mode?: SessionMode;
196
+ /** Catalog name of a co-resident agent container. At most one per session,
197
+ * never per app: the agent's unit is the workspace, and two agents over one
198
+ * workspace would contend on the same files and split one conversation in
199
+ * half. Requires `mode: "watch"` — an agent with nothing watching its writes
200
+ * is a silent no-op. */
201
+ agent?: string;
202
+ /** The applications this session runs, one container each. Omitted, it
203
+ * defaults to a single app named `app` on the bundle's own entry with the
204
+ * request's `ports` — so a single-app session is written exactly as before. */
205
+ apps?: SessionAppSpec[];
131
206
  }
132
207
 
208
+ /**
209
+ * The SESSION's status — how the session itself is doing, not how any one run
210
+ * ended. The two are separate nouns on the same stream: a one-shot app finishing
211
+ * in a watch session emits `run.completed` and leaves the session `running`, so
212
+ * the next edit starts that app's next generation.
213
+ *
214
+ * `exited` belongs to a `run` session only, where the session IS the run.
215
+ */
133
216
  export type RunStatus =
134
217
  | { kind: "starting" }
135
- /** `inspectUrl` is the kernel debug/inspection UI fronted by a proxy (set only
136
- * when the session ran with `inspect` and the runner has a public base URL);
137
- * absent when the inspect endpoint isn't externally reachable. */
138
- | { kind: "running"; endpoints?: RunnerEndpoint[]; inspectUrl?: string }
218
+ /** Every application container is up. `inspectUrl` is the kernel
219
+ * debug/inspection UI fronted by a proxy (set only when the session ran with
220
+ * `inspect` and the runner has a public base URL); absent when the inspect
221
+ * endpoint isn't externally reachable.
222
+ *
223
+ * `agent` is where this session's co-resident agent answers, present only on
224
+ * a session that requested one AND that this runner could route. An ENDPOINT
225
+ * rather than a URL string because the no-proxy case is real — a docker
226
+ * runner publishing to the host knows the port and not the hostname the
227
+ * client reached it by, exactly as for an app's endpoint, so the client
228
+ * fills an empty `host` from its own base URL. Carried here and not in
229
+ * `endpoints` because the agent is session infrastructure: `endpoints` are
230
+ * the ports the APPLICATIONS declared, and merging the two would make an
231
+ * operator-run container look like part of the user's manifest. */
232
+ | {
233
+ kind: "running";
234
+ endpoints?: RunnerEndpoint[];
235
+ inspectUrl?: string;
236
+ agent?: RunnerEndpoint;
237
+ }
139
238
  | { kind: "exited"; code: number }
239
+ /** Reaped for idleness: the pod is gone, the workspace checkpoint is held, and
240
+ * `POST /v1/sessions/:id/resume` brings it back under the same session id.
241
+ * NOT terminal — nothing is evicted on it. Best-effort by design: the runner
242
+ * holds the checkpoint in memory, so a runner restart loses it and the editor
243
+ * (which holds the authoritative workspace) re-seeds from its own copy. */
244
+ | { kind: "suspended" }
140
245
  | { kind: "failed"; message: string }
141
246
  | { kind: "stopped" };
142
247
 
@@ -152,21 +257,113 @@ export type RunPhase = "build" | "provision" | "boot";
152
257
  * (spinner → ok / error) instead of an app-log line. */
153
258
  export type ReachabilityState = "checking" | "reachable" | "unreachable";
154
259
 
260
+ /** What started one generation of an application.
261
+ * - `initial` — the session came up
262
+ * - `watch` — a file changed under the app's watcher
263
+ * - `manual` — `POST /v1/sessions/:id/reload`
264
+ * - `resume` — a suspended session was brought back, or the app set changed */
265
+ export type RunTrigger = "initial" | "watch" | "manual" | "resume";
266
+
267
+ /**
268
+ * A RUN outcome — one per app per reload generation, distinct from the session
269
+ * status above. `generation` is monotonic per app and starts at 1; it is counted
270
+ * by the runner from the kernel lifecycle events already on the debug stream, so
271
+ * it asks the kernel for nothing.
272
+ */
273
+ export type RunOutcomeEvent =
274
+ | { type: "run"; app: string; generation: number; phase: "started"; trigger: RunTrigger }
275
+ | {
276
+ type: "run";
277
+ app: string;
278
+ generation: number;
279
+ phase: "completed";
280
+ code: number;
281
+ durationMs?: number;
282
+ }
283
+ | { type: "run"; app: string; generation: number; phase: "failed"; reason: string };
284
+
285
+ /**
286
+ * Workload output does NOT travel this channel, in either mode — it goes over the
287
+ * byte channel (`/io`), which exists precisely because per-chunk events are
288
+ * wasteful for high-volume output. The `stdout` / `stderr` variants that used to
289
+ * be declared here were emitted by nothing: a contract in shape only.
290
+ */
155
291
  export type RunEvent =
156
- | { type: "stdout"; chunk: string }
157
- | { type: "stderr"; chunk: string }
158
292
  | { type: "status"; status: RunStatus }
159
- | { type: "progress"; phase: RunPhase; message: string; done?: boolean }
160
- /** A frame relayed from the workload's kernel debug stream (kernel event or
161
- * log line). Only emitted when the session was started with `inspect`. */
162
- | { type: "debug"; frame: DebugFrame }
163
- /** A reachability transition for one declared port, keyed by port. */
164
- | { type: "reachability"; port: number; state: ReachabilityState };
293
+ /** `app` is absent for session-scoped provisioning (scheduling, image pull) and
294
+ * present once the message belongs to one application. */
295
+ | { type: "progress"; app?: string; phase: RunPhase; message: string; done?: boolean }
296
+ /** A frame relayed from one app's kernel debug stream (kernel event or log
297
+ * line). Only emitted when the session was started with `inspect`. */
298
+ | { type: "debug"; app: string; frame: DebugFrame }
299
+ /** A reachability transition for one declared port of one app. */
300
+ | { type: "reachability"; app: string; port: number; state: ReachabilityState }
301
+ | RunOutcomeEvent
302
+ /** An app's declared port set changed on reload and the runner re-patched the
303
+ * Service and Ingress. Without this the app binds the new port inside the pod
304
+ * and is simply unreachable: no ingress, no error, no event. */
305
+ | {
306
+ type: "endpoints";
307
+ app: string;
308
+ added?: RunnerEndpoint[];
309
+ removed?: RunnerEndpoint[];
310
+ /** A declared port that could not be routed (it collides with another app
311
+ * in this session). Reported, never dropped. */
312
+ rejected?: Array<{ port: number; reason: string }>;
313
+ };
165
314
 
315
+ /**
316
+ * Terminal = the session is over and the registry may schedule eviction.
317
+ * `suspended` is deliberately NOT terminal: the session record and its workspace
318
+ * checkpoint outlive the pod, and `resume` brings it back under the same id.
319
+ */
166
320
  export function isTerminal(status: RunStatus): boolean {
167
321
  return status.kind === "exited" || status.kind === "failed" || status.kind === "stopped";
168
322
  }
169
323
 
324
+ /**
325
+ * Which stream a byte-channel chunk came from. Under `io: "tty"` every chunk is
326
+ * `tty` — the tag never asserts a split that does not exist, which is exactly the
327
+ * failure the deleted `stdout` / `stderr` run events represented.
328
+ */
329
+ export type ByteStreamTag = "tty" | "stdout" | "stderr";
330
+
331
+ /** One file in a workspace snapshot: its path and the sha256 of its bytes.
332
+ * Hashing content — rather than comparing sizes or timestamps — is what makes
333
+ * two snapshots diff into an exact change set. */
334
+ export interface WorkspaceFileEntry {
335
+ path: string;
336
+ hash: string;
337
+ }
338
+
339
+ export interface WorkspaceTree {
340
+ files: WorkspaceFileEntry[];
341
+ }
342
+
343
+ /**
344
+ * An explicit write/delete list rather than a whole-tree PUT: a deletion has to
345
+ * be expressible, and a whole-tree PUT can only express it by treating absence as
346
+ * intent. A one-file save is a change set of one — there is deliberately no
347
+ * single-file write route, which would be a second set of concurrency rules.
348
+ */
349
+ export interface WorkspaceChangeSet {
350
+ write?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>;
351
+ delete?: string[];
352
+ }
353
+
354
+ export interface WorkspaceApplyResult {
355
+ written: number;
356
+ deleted: number;
357
+ }
358
+
359
+ /** One file of a checkpoint — a tree entry plus the bytes needed to re-seed a
360
+ * fresh pod. Text stays `utf8`; anything that is not valid text is carried
361
+ * `base64`, so a binary asset survives a suspend/resume round trip. */
362
+ export interface WorkspaceCheckpointFile extends WorkspaceFileEntry {
363
+ content: string;
364
+ encoding: "utf8" | "base64";
365
+ }
366
+
170
367
  /**
171
368
  * Stages a session start can fail at. The docker/k8s backends share the
172
369
  * vocabulary; not every stage applies to every backend (`pull`/`inspect` are