@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
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 {}
@@ -233,5 +260,37 @@ export function loadCoreConfig(
233
260
  "RUNNER_REPLAY_BUFFER_BYTES",
234
261
  ),
235
262
  corsOrigins: parseCorsOrigins(env.RUNNER_CORS_ORIGINS),
263
+ watch: loadWatchConfig(env),
264
+ };
265
+ }
266
+
267
+ export function loadWatchConfig(env: NodeJS.ProcessEnv): WatchSessionConfig {
268
+ return {
269
+ enabled: parseBool(env.RUNNER_WATCH_SESSIONS, false, "RUNNER_WATCH_SESSIONS"),
270
+ idleMs:
271
+ parsePositiveInt(env.RUNNER_WATCH_IDLE_SECONDS, 300, "RUNNER_WATCH_IDLE_SECONDS") * 1000,
272
+ maxTtlSeconds: parsePositiveInt(
273
+ env.RUNNER_WATCH_MAX_TTL_SECONDS,
274
+ 21600,
275
+ "RUNNER_WATCH_MAX_TTL_SECONDS",
276
+ ),
277
+ maxSessions: parsePositiveInt(env.RUNNER_WATCH_MAX_SESSIONS, 8, "RUNNER_WATCH_MAX_SESSIONS"),
278
+ reloadLimitPerMinute: parsePositiveInt(
279
+ env.RUNNER_WATCH_RELOAD_LIMIT,
280
+ 30,
281
+ "RUNNER_WATCH_RELOAD_LIMIT",
282
+ ),
283
+ suspendedTtlMs:
284
+ parsePositiveInt(
285
+ env.RUNNER_WATCH_SUSPENDED_TTL_SECONDS,
286
+ 86400,
287
+ "RUNNER_WATCH_SUSPENDED_TTL_SECONDS",
288
+ ) * 1000,
289
+ checkpointMs:
290
+ parsePositiveInt(
291
+ env.RUNNER_WORKSPACE_CHECKPOINT_SECONDS,
292
+ 30,
293
+ "RUNNER_WORKSPACE_CHECKPOINT_SECONDS",
294
+ ) * 1000,
236
295
  };
237
296
  }
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,45 @@ 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. */
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. */
138
222
  | { kind: "running"; endpoints?: RunnerEndpoint[]; inspectUrl?: string }
139
223
  | { kind: "exited"; code: number }
224
+ /** Reaped for idleness: the pod is gone, the workspace checkpoint is held, and
225
+ * `POST /v1/sessions/:id/resume` brings it back under the same session id.
226
+ * NOT terminal — nothing is evicted on it. Best-effort by design: the runner
227
+ * holds the checkpoint in memory, so a runner restart loses it and the editor
228
+ * (which holds the authoritative workspace) re-seeds from its own copy. */
229
+ | { kind: "suspended" }
140
230
  | { kind: "failed"; message: string }
141
231
  | { kind: "stopped" };
142
232
 
@@ -152,21 +242,113 @@ export type RunPhase = "build" | "provision" | "boot";
152
242
  * (spinner → ok / error) instead of an app-log line. */
153
243
  export type ReachabilityState = "checking" | "reachable" | "unreachable";
154
244
 
245
+ /** What started one generation of an application.
246
+ * - `initial` — the session came up
247
+ * - `watch` — a file changed under the app's watcher
248
+ * - `manual` — `POST /v1/sessions/:id/reload`
249
+ * - `resume` — a suspended session was brought back, or the app set changed */
250
+ export type RunTrigger = "initial" | "watch" | "manual" | "resume";
251
+
252
+ /**
253
+ * A RUN outcome — one per app per reload generation, distinct from the session
254
+ * status above. `generation` is monotonic per app and starts at 1; it is counted
255
+ * by the runner from the kernel lifecycle events already on the debug stream, so
256
+ * it asks the kernel for nothing.
257
+ */
258
+ export type RunOutcomeEvent =
259
+ | { type: "run"; app: string; generation: number; phase: "started"; trigger: RunTrigger }
260
+ | {
261
+ type: "run";
262
+ app: string;
263
+ generation: number;
264
+ phase: "completed";
265
+ code: number;
266
+ durationMs?: number;
267
+ }
268
+ | { type: "run"; app: string; generation: number; phase: "failed"; reason: string };
269
+
270
+ /**
271
+ * Workload output does NOT travel this channel, in either mode — it goes over the
272
+ * byte channel (`/io`), which exists precisely because per-chunk events are
273
+ * wasteful for high-volume output. The `stdout` / `stderr` variants that used to
274
+ * be declared here were emitted by nothing: a contract in shape only.
275
+ */
155
276
  export type RunEvent =
156
- | { type: "stdout"; chunk: string }
157
- | { type: "stderr"; chunk: string }
158
277
  | { 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 };
278
+ /** `app` is absent for session-scoped provisioning (scheduling, image pull) and
279
+ * present once the message belongs to one application. */
280
+ | { type: "progress"; app?: string; phase: RunPhase; message: string; done?: boolean }
281
+ /** A frame relayed from one app's kernel debug stream (kernel event or log
282
+ * line). Only emitted when the session was started with `inspect`. */
283
+ | { type: "debug"; app: string; frame: DebugFrame }
284
+ /** A reachability transition for one declared port of one app. */
285
+ | { type: "reachability"; app: string; port: number; state: ReachabilityState }
286
+ | RunOutcomeEvent
287
+ /** An app's declared port set changed on reload and the runner re-patched the
288
+ * Service and Ingress. Without this the app binds the new port inside the pod
289
+ * and is simply unreachable: no ingress, no error, no event. */
290
+ | {
291
+ type: "endpoints";
292
+ app: string;
293
+ added?: RunnerEndpoint[];
294
+ removed?: RunnerEndpoint[];
295
+ /** A declared port that could not be routed (it collides with another app
296
+ * in this session). Reported, never dropped. */
297
+ rejected?: Array<{ port: number; reason: string }>;
298
+ };
165
299
 
300
+ /**
301
+ * Terminal = the session is over and the registry may schedule eviction.
302
+ * `suspended` is deliberately NOT terminal: the session record and its workspace
303
+ * checkpoint outlive the pod, and `resume` brings it back under the same id.
304
+ */
166
305
  export function isTerminal(status: RunStatus): boolean {
167
306
  return status.kind === "exited" || status.kind === "failed" || status.kind === "stopped";
168
307
  }
169
308
 
309
+ /**
310
+ * Which stream a byte-channel chunk came from. Under `io: "tty"` every chunk is
311
+ * `tty` — the tag never asserts a split that does not exist, which is exactly the
312
+ * failure the deleted `stdout` / `stderr` run events represented.
313
+ */
314
+ export type ByteStreamTag = "tty" | "stdout" | "stderr";
315
+
316
+ /** One file in a workspace snapshot: its path and the sha256 of its bytes.
317
+ * Hashing content — rather than comparing sizes or timestamps — is what makes
318
+ * two snapshots diff into an exact change set. */
319
+ export interface WorkspaceFileEntry {
320
+ path: string;
321
+ hash: string;
322
+ }
323
+
324
+ export interface WorkspaceTree {
325
+ files: WorkspaceFileEntry[];
326
+ }
327
+
328
+ /**
329
+ * An explicit write/delete list rather than a whole-tree PUT: a deletion has to
330
+ * be expressible, and a whole-tree PUT can only express it by treating absence as
331
+ * intent. A one-file save is a change set of one — there is deliberately no
332
+ * single-file write route, which would be a second set of concurrency rules.
333
+ */
334
+ export interface WorkspaceChangeSet {
335
+ write?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>;
336
+ delete?: string[];
337
+ }
338
+
339
+ export interface WorkspaceApplyResult {
340
+ written: number;
341
+ deleted: number;
342
+ }
343
+
344
+ /** One file of a checkpoint — a tree entry plus the bytes needed to re-seed a
345
+ * fresh pod. Text stays `utf8`; anything that is not valid text is carried
346
+ * `base64`, so a binary asset survives a suspend/resume round trip. */
347
+ export interface WorkspaceCheckpointFile extends WorkspaceFileEntry {
348
+ content: string;
349
+ encoding: "utf8" | "base64";
350
+ }
351
+
170
352
  /**
171
353
  * Stages a session start can fail at. The docker/k8s backends share the
172
354
  * vocabulary; not every stage applies to every backend (`pull`/`inspect` are
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { DebugFrame } from "@telorun/debug-wire";
4
+ import { portKey, portsResolvedFrom, PORTS_RESOLVED_EVENT } from "./ports-resolved.js";
5
+
6
+ const frame = (event: string, payload?: unknown): DebugFrame => ({
7
+ kind: "event",
8
+ timestamp: "2026-01-01T00:00:00.000Z",
9
+ event,
10
+ payload,
11
+ });
12
+
13
+ describe("portsResolvedFrom", () => {
14
+ it("reads the declared port set the kernel resolved", () => {
15
+ expect(
16
+ portsResolvedFrom(
17
+ frame(PORTS_RESOLVED_EVENT, {
18
+ ports: [
19
+ { name: "http", port: 3000, protocol: "tcp" },
20
+ { name: "metrics", port: 9100, protocol: "udp" },
21
+ ],
22
+ }),
23
+ ),
24
+ ).toEqual([
25
+ { port: 3000, protocol: "tcp" },
26
+ { port: 9100, protocol: "udp" },
27
+ ]);
28
+ });
29
+
30
+ it("reads an empty set as an empty set, not as 'no information'", () => {
31
+ // An app that dropped its last `ports:` entry has to un-route it, so the
32
+ // difference between [] and undefined is load-bearing.
33
+ expect(portsResolvedFrom(frame(PORTS_RESOLVED_EVENT, { ports: [] }))).toEqual([]);
34
+ });
35
+
36
+ it("ignores every other frame", () => {
37
+ expect(portsResolvedFrom(frame("Kernel.Started"))).toBeUndefined();
38
+ expect(
39
+ portsResolvedFrom({ kind: "log", timestamp: "", stream: "stdout", line: "x" }),
40
+ ).toBeUndefined();
41
+ });
42
+
43
+ it("ignores a payload it cannot make sense of", () => {
44
+ // The runner reads a stream from a kernel it is not versioned with, so an
45
+ // unrecognizable frame is one to skip, never one to fail the session on.
46
+ expect(portsResolvedFrom(frame(PORTS_RESOLVED_EVENT))).toBeUndefined();
47
+ expect(portsResolvedFrom(frame(PORTS_RESOLVED_EVENT, { ports: "3000" }))).toBeUndefined();
48
+ expect(
49
+ portsResolvedFrom(frame(PORTS_RESOLVED_EVENT, { ports: [{ port: "3000" }, { port: 80 }] })),
50
+ ).toEqual([{ port: 80, protocol: "tcp" }]);
51
+ });
52
+
53
+ it("defaults an unstated protocol to tcp", () => {
54
+ expect(portsResolvedFrom(frame(PORTS_RESOLVED_EVENT, { ports: [{ port: 80 }] }))).toEqual([
55
+ { port: 80, protocol: "tcp" },
56
+ ]);
57
+ });
58
+ });
59
+
60
+ describe("portKey", () => {
61
+ it("separates the two protocols on one number", () => {
62
+ expect(portKey({ port: 3000, protocol: "tcp" })).not.toBe(
63
+ portKey({ port: 3000, protocol: "udp" }),
64
+ );
65
+ });
66
+ });
@@ -0,0 +1,43 @@
1
+ import { isEventFrame, type DebugFrame } from "@telorun/debug-wire";
2
+
3
+ import type { PortMapping, PortProtocol } from "../contract.js";
4
+
5
+ /** The kernel says this once per load: the Application's `ports:` block has
6
+ * resolved to these integers. It re-fires on every watch reload, which is what
7
+ * lets a host re-route without parsing a manifest. */
8
+ export const PORTS_RESOLVED_EVENT = "Kernel.PortsResolved";
9
+
10
+ /**
11
+ * The declared port set carried by a `Kernel.PortsResolved` frame, or undefined
12
+ * for any other frame.
13
+ *
14
+ * DECLARED, not bound. A runner routes what the manifest asked for; whether
15
+ * anything actually bound it is the reachability watcher's question, and it
16
+ * already answers it per port. Routing what happened to be bound instead would
17
+ * expose a port the manifest never declared, and would rest on a per-module
18
+ * convention — a transport whose kind does not emit a listening event would
19
+ * silently get no routing at all.
20
+ *
21
+ * Tolerant of a payload that is not the expected shape: a runner reads this off
22
+ * a stream produced by a kernel it does not version together with, so a frame it
23
+ * cannot make sense of is one to ignore, not to fail on.
24
+ */
25
+ export function portsResolvedFrom(frame: DebugFrame): PortMapping[] | undefined {
26
+ if (!isEventFrame(frame) || frame.event !== PORTS_RESOLVED_EVENT) return undefined;
27
+ const raw = (frame.payload as { ports?: unknown } | undefined)?.ports;
28
+ if (!Array.isArray(raw)) return undefined;
29
+ const ports: PortMapping[] = [];
30
+ for (const entry of raw) {
31
+ const port = (entry as { port?: unknown })?.port;
32
+ const protocol = (entry as { protocol?: unknown })?.protocol;
33
+ if (typeof port !== "number" || !Number.isInteger(port)) continue;
34
+ ports.push({ port, protocol: protocol === "udp" ? "udp" : ("tcp" as PortProtocol) });
35
+ }
36
+ return ports;
37
+ }
38
+
39
+ /** Identity of a port within a session: the pair, since tcp/3000 and udp/3000
40
+ * are different endpoints. */
41
+ export function portKey(mapping: PortMapping): string {
42
+ return `${mapping.protocol}/${mapping.port}`;
43
+ }
@@ -29,17 +29,27 @@ const RECONNECT_DELAY_MS = 500;
29
29
  */
30
30
  export async function relayDebugStream(opts: DebugRelayOptions): Promise<void> {
31
31
  const { url, onFrame, signal } = opts;
32
+ // The last id delivered, carried across reconnects. Without it a drop replays
33
+ // the producer's whole buffer, and a consumer that DERIVES state from the
34
+ // stream — the run projection counting generations — re-counts every reload
35
+ // still in that buffer. In a watch session that is one pair per save.
36
+ let lastEventId = 0;
32
37
  while (!signal.aborted) {
33
38
  try {
34
39
  const res = await fetch(url, {
35
40
  signal,
36
- headers: { accept: "text/event-stream" },
41
+ headers: {
42
+ accept: "text/event-stream",
43
+ ...(lastEventId > 0 ? { "last-event-id": String(lastEventId) } : {}),
44
+ },
37
45
  });
38
46
  if (!res.ok || !res.body) {
39
47
  await abortableDelay(RECONNECT_DELAY_MS, signal);
40
48
  continue;
41
49
  }
42
- await pump(res.body, onFrame, signal);
50
+ await pump(res.body, onFrame, signal, (id) => {
51
+ lastEventId = id;
52
+ });
43
53
  } catch (err) {
44
54
  if (signal.aborted) return;
45
55
  opts.onError?.(err instanceof Error ? err : new Error(String(err)));
@@ -52,6 +62,7 @@ async function pump(
52
62
  body: ReadableStream<Uint8Array>,
53
63
  onFrame: (frame: DebugFrame) => void,
54
64
  signal: AbortSignal,
65
+ onId: (id: number) => void,
55
66
  ): Promise<void> {
56
67
  const reader = body.getReader();
57
68
  const decoder = new TextDecoder();
@@ -64,9 +75,15 @@ async function pump(
64
75
  let sep: number;
65
76
  // SSE frames are separated by a blank line.
66
77
  while ((sep = buf.indexOf("\n\n")) >= 0) {
67
- const frame = parseSseData(buf.slice(0, sep));
78
+ const block = buf.slice(0, sep);
68
79
  buf = buf.slice(sep + 2);
69
- if (frame) onFrame(frame);
80
+ const frame = parseSseData(block);
81
+ if (!frame) continue;
82
+ onFrame(frame);
83
+ // Checkpoint AFTER delivery, so a frame the consumer never saw is not
84
+ // skipped on the next reconnect.
85
+ const id = parseSseId(block);
86
+ if (id !== null) onId(id);
70
87
  }
71
88
  }
72
89
  } finally {
@@ -74,6 +91,17 @@ async function pump(
74
91
  }
75
92
  }
76
93
 
94
+ /** The `id:` of one SSE frame, or null when the producer sent none — an older
95
+ * kernel, whose stream simply has no resume point. */
96
+ function parseSseId(block: string): number | null {
97
+ for (const line of block.split("\n")) {
98
+ if (!line.startsWith("id:")) continue;
99
+ const parsed = Number.parseInt(line.slice(3).trim(), 10);
100
+ return Number.isFinite(parsed) ? parsed : null;
101
+ }
102
+ return null;
103
+ }
104
+
77
105
  /** Extract and parse the `data:` payload of one SSE frame. Ignores comment
78
106
  * lines (`: heartbeat`) and unparseable frames. */
79
107
  function parseSseData(block: string): DebugFrame | null {
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { DebugFrame } from "@telorun/debug-wire";
4
+ import type { RunEvent } from "../contract.js";
5
+ import { SessionRegistry } from "../session/registry.js";
6
+ import { RunProjection } from "./run-projection.js";
7
+
8
+ function harness(apps = [{ name: "app" }]) {
9
+ const registry = new SessionRegistry({
10
+ maxSessions: 4,
11
+ exitTtlMs: 1000,
12
+ replayBufferBytes: 100_000,
13
+ });
14
+ const entry = registry.register({ sessionId: "s1", mode: "watch", apps });
15
+ const projection = new RunProjection(registry, "s1");
16
+ entry.attribution = projection;
17
+ const runs = (): RunEvent[] =>
18
+ entry.buffer.replay(0).entries.map((e) => e.event).filter((e) => e.type === "run");
19
+ return { registry, entry, projection, runs };
20
+ }
21
+
22
+ const event = (name: string, payload?: unknown): DebugFrame => ({
23
+ kind: "event",
24
+ timestamp: "2026-01-01T00:00:00.000Z",
25
+ event: name,
26
+ payload,
27
+ });
28
+
29
+ describe("RunProjection", () => {
30
+ it("brackets a generation from Kernel.Starting and Kernel.Stopped", () => {
31
+ const h = harness();
32
+ h.projection.expectAll("initial");
33
+ h.projection.frame("app", event("Kernel.Starting"));
34
+ h.projection.frame("app", event("Kernel.Started"));
35
+ h.projection.frame("app", event("Kernel.Stopped", { exitCode: 0 }));
36
+
37
+ expect(h.runs()).toMatchObject([
38
+ { type: "run", app: "app", generation: 1, phase: "started", trigger: "initial" },
39
+ { type: "run", app: "app", generation: 1, phase: "completed", code: 0 },
40
+ ]);
41
+ });
42
+
43
+ it("leaves the session running when a generation completes", () => {
44
+ const h = harness();
45
+ h.projection.expectAll("initial");
46
+ h.registry.emit("s1", { type: "status", status: { kind: "running" } });
47
+ h.projection.frame("app", event("Kernel.Starting"));
48
+ h.projection.frame("app", event("Kernel.Stopped", { exitCode: 0 }));
49
+
50
+ // The whole point of the split: a one-shot app finishing is not the session
51
+ // ending, so nothing is scheduled for eviction.
52
+ expect(h.entry.status).toEqual({ kind: "running" });
53
+ expect(h.entry.exitedAt).toBeNull();
54
+ expect(h.entry.evictionTimer).toBeNull();
55
+ });
56
+
57
+ it("counts a reload as the next generation and attributes it to the watcher", () => {
58
+ const h = harness();
59
+ h.projection.expectAll("initial");
60
+ h.projection.frame("app", event("Kernel.Starting"));
61
+ h.projection.frame("app", event("Kernel.Stopped", { exitCode: 0 }));
62
+ h.projection.frame("app", event("Kernel.Starting"));
63
+
64
+ expect(h.runs().at(-1)).toMatchObject({
65
+ generation: 2,
66
+ phase: "started",
67
+ trigger: "watch",
68
+ });
69
+ });
70
+
71
+ it("attributes a generation a route caused, then falls back to watch", () => {
72
+ const h = harness();
73
+ h.projection.expectAll("initial");
74
+ h.projection.frame("app", event("Kernel.Starting"));
75
+ h.projection.frame("app", event("Kernel.Stopped", { exitCode: 0 }));
76
+
77
+ h.projection.expect("app", "manual");
78
+ h.projection.frame("app", event("Kernel.Starting"));
79
+ h.projection.frame("app", event("Kernel.Stopped", { exitCode: 0 }));
80
+ h.projection.frame("app", event("Kernel.Starting"));
81
+
82
+ const started = h.runs().filter((e) => e.type === "run" && e.phase === "started");
83
+ expect(started.map((e) => (e as { trigger: string }).trigger)).toEqual([
84
+ "initial",
85
+ "manual",
86
+ "watch",
87
+ ]);
88
+ });
89
+
90
+ it("opens a generation for a load failure, which emits no Kernel.Starting", () => {
91
+ const h = harness();
92
+ h.projection.expectAll("initial");
93
+ // A manifest that fails to LOAD never reaches Kernel.Starting; reporting the
94
+ // failure of a generation the client never saw start would be unreadable.
95
+ h.projection.frame(
96
+ "app",
97
+ event("Kernel.RunFailed", {
98
+ phase: "load",
99
+ code: "ERR_MANIFEST_VALIDATION_FAILED",
100
+ message: "bad manifest",
101
+ }),
102
+ );
103
+
104
+ expect(h.runs()).toMatchObject([
105
+ { generation: 1, phase: "started", trigger: "initial" },
106
+ { generation: 1, phase: "failed", reason: "ERR_MANIFEST_VALIDATION_FAILED" },
107
+ ]);
108
+ });
109
+
110
+ it("does not double-count a boot failure that follows Kernel.Starting", () => {
111
+ const h = harness();
112
+ h.projection.expectAll("initial");
113
+ h.projection.frame("app", event("Kernel.Starting"));
114
+ h.projection.frame("app", event("Kernel.RunFailed", { phase: "start", message: "boom" }));
115
+
116
+ expect(h.runs()).toMatchObject([
117
+ { generation: 1, phase: "started" },
118
+ { generation: 1, phase: "failed", reason: "boom" },
119
+ ]);
120
+ });
121
+
122
+ it("closes a generation whose WORKLOAD ended, with no Kernel.Stopped", () => {
123
+ const h = harness();
124
+ h.projection.expectAll("initial");
125
+ h.projection.frame("app", event("Kernel.Starting"));
126
+ // A container that goes away emits nothing — there is nothing left to emit
127
+ // it — so the backend reports the ending through the contract instead.
128
+ h.projection.endGeneration("app", { reason: "application container exited" });
129
+
130
+ expect(h.runs()).toMatchObject([
131
+ { generation: 1, phase: "started" },
132
+ { generation: 1, phase: "failed", reason: "application container exited" },
133
+ ]);
134
+ });
135
+
136
+ it("ignores a workload ending when no generation is open", () => {
137
+ const h = harness();
138
+ h.projection.endGeneration("app", { reason: "gone" });
139
+ expect(h.runs()).toEqual([]);
140
+ });
141
+
142
+ it("counts generations per app, not per session", () => {
143
+ const h = harness([{ name: "web" }, { name: "worker" }]);
144
+ h.projection.expectAll("initial");
145
+ h.projection.frame("web", event("Kernel.Starting"));
146
+ h.projection.frame("worker", event("Kernel.Starting"));
147
+ h.projection.frame("worker", event("Kernel.Stopped", { exitCode: 0 }));
148
+ h.projection.frame("worker", event("Kernel.Starting"));
149
+
150
+ const byApp = (name: string) =>
151
+ h.runs().filter((e) => (e as { app: string }).app === name);
152
+ expect(byApp("web").at(-1)).toMatchObject({ generation: 1, phase: "started" });
153
+ expect(byApp("worker").at(-1)).toMatchObject({ generation: 2, phase: "started" });
154
+ });
155
+ });