@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
@@ -1,17 +1,53 @@
1
1
  import { EventEmitter } from "node:events";
2
2
 
3
- import type { BackendSession } from "../backend.js";
4
- import { isTerminal, type RunEvent, type RunStatus } from "../contract.js";
3
+ import type { DebugFrame } from "@telorun/debug-wire";
4
+
5
+ import type { BackendSession, WorkloadLaunch } from "../backend.js";
6
+ import {
7
+ DEFAULT_APP_NAME,
8
+ isTerminal,
9
+ type ByteStreamTag,
10
+ type IoMode,
11
+ type PortMapping,
12
+ type RunEvent,
13
+ type RunStatus,
14
+ type RunTrigger,
15
+ type SessionMode,
16
+ type WorkspaceCheckpointFile,
17
+ } from "../contract.js";
5
18
  import { ByteRingBuffer, type BufferedBytes } from "./byte-ring-buffer.js";
6
19
  import { EventRingBuffer, type BufferedEvent } from "./ring-buffer.js";
7
20
 
21
+ /**
22
+ * One application's terminal and run bookkeeping. The byte channel is keyed
23
+ * `(session, app)` rather than labelled on a merged stream: each app container
24
+ * has its OWN terminal, and merging them would make `/io` unattachable to one.
25
+ */
26
+ export interface AppChannel {
27
+ readonly name: string;
28
+ readonly io: IoMode;
29
+ readonly byteBuffer: ByteRingBuffer;
30
+ readonly byteEmitter: EventEmitter;
31
+ /** Monotonic per app, starting at 1. Counted here from the kernel lifecycle
32
+ * events the debug stream already carries — the kernel is asked for nothing. */
33
+ generation: number;
34
+ /** Epoch ms the current generation started, for `durationMs` on completion.
35
+ * Null between generations. */
36
+ startedAt: number | null;
37
+ /** The port set this app currently declares. Re-read on every reload, so a
38
+ * `ports:` edit patches the Service and Ingress instead of silently binding
39
+ * a port nothing routes. */
40
+ ports: PortMapping[];
41
+ }
42
+
8
43
  export interface SessionEntry {
9
44
  readonly sessionId: string;
10
45
  readonly createdAt: Date;
46
+ readonly mode: SessionMode;
11
47
  readonly buffer: EventRingBuffer;
12
- readonly byteBuffer: ByteRingBuffer;
13
48
  readonly emitter: EventEmitter;
14
- readonly byteEmitter: EventEmitter;
49
+ /** One channel per application container, insertion-ordered. */
50
+ readonly apps: Map<string, AppChannel>;
15
51
 
16
52
  /** The live backend workload. Null until `start` resolves; the route writes
17
53
  * stdin / resize / stop through it. A backend's `writeStdin` is a no-op once
@@ -21,12 +57,70 @@ export interface SessionEntry {
21
57
  exitedAt: Date | null;
22
58
  userStopped: boolean;
23
59
  evictionTimer: NodeJS.Timeout | null;
60
+
61
+ /** Catalog name of the co-resident agent, when one was requested. */
62
+ agent?: string;
63
+ /** Last whole-tree workspace snapshot, pulled on the checkpoint timer and
64
+ * again on suspend. A CACHE, never the only copy: the editor holds the
65
+ * authoritative workspace, so a runner restart losing this costs an upload,
66
+ * not user work. */
67
+ checkpoint: WorkspaceCheckpoint | null;
68
+ /** How the NEXT generation of an app should be attributed. Structural rather
69
+ * than the projection type itself, so the registry stays free of the debug
70
+ * stream it knows nothing about. */
71
+ attribution: RunAttribution | null;
72
+ /** The description this session was launched from, retained so `resume` can
73
+ * build a fresh pod under the same id without a second copy of it. */
74
+ launch: WorkloadLaunch | null;
75
+ /** Live SSE/WS subscriber count. Zero for longer than the idle window is what
76
+ * suspends a watch session. */
77
+ subscribers: number;
78
+ /** Epoch ms the subscriber count last fell to zero; null while someone is
79
+ * attached. */
80
+ idleSince: number | null;
81
+ /** Epoch ms of recent reloads, for the per-session reload rate limit. A watch
82
+ * session reloads on every save, so an unbounded one is a way to keep a pod
83
+ * permanently rebuilding. */
84
+ reloads: number[];
85
+ /** Epoch ms the last workspace checkpoint was pulled. */
86
+ checkpointedAt: number | null;
87
+ }
88
+
89
+ /**
90
+ * The run projection as the registry sees it — structural, so the registry stays
91
+ * free of the debug stream it knows nothing about. `expect` is how a route tells
92
+ * it about a generation it is ABOUT to cause: without that, a manual reload
93
+ * would be reported as a watch reload, and the runner would be guessing at the
94
+ * one fact it actually knows.
95
+ */
96
+ export interface RunAttribution {
97
+ expect(app: string, trigger: RunTrigger): void;
98
+ expectAll(trigger: RunTrigger): void;
99
+ frame(app: string, frame: DebugFrame): void;
100
+ /** Close the open generation because its workload ended — a container that
101
+ * goes away emits no `Kernel.Stopped`, so the backend reports the ending. */
102
+ endGeneration(app: string, outcome: { code?: number; reason?: string }): void;
103
+ }
104
+
105
+ /** A whole-tree snapshot plus the contents needed to re-seed a fresh pod. Whole
106
+ * tree rather than a delta log from the write path: a manifest workspace is
107
+ * small, and one shape is easier to reason about than a replay that has to be
108
+ * correct. */
109
+ export interface WorkspaceCheckpoint {
110
+ takenAt: Date;
111
+ files: WorkspaceCheckpointFile[];
24
112
  }
25
113
 
26
114
  export interface RegistryDeps {
27
115
  maxSessions: number;
28
116
  exitTtlMs: number;
117
+ /** Per APP, not per session — each application container has its own terminal
118
+ * and its own replay buffer. */
29
119
  replayBufferBytes: number;
120
+ /** How long a suspended session record is retained before eviction. Bounds
121
+ * accumulation, and is deliberately not the pod deadline: that bounds a POD,
122
+ * so on its own nothing would ever evict a suspended record. */
123
+ suspendedTtlMs?: number;
30
124
  }
31
125
 
32
126
  const EVENT_EMITTED = "event";
@@ -42,6 +136,16 @@ const MAX_PUSH_CHUNK = 64 * 1024;
42
136
 
43
137
  export class SessionLimitError extends Error {}
44
138
  export class SessionEvictedError extends Error {}
139
+ export class UnknownAppError extends Error {}
140
+
141
+ export interface RegisterArgs {
142
+ sessionId: string;
143
+ mode?: SessionMode;
144
+ agent?: string;
145
+ /** The applications this session runs. Defaults to one app named `app` with a
146
+ * terminal, so a single-app session behaves exactly as before. */
147
+ apps?: Array<{ name: string; io?: IoMode; ports?: PortMapping[] }>;
148
+ }
45
149
 
46
150
  export class SessionRegistry {
47
151
  private readonly sessions = new Map<string, SessionEntry>();
@@ -68,7 +172,7 @@ export class SessionRegistry {
68
172
  * Creates a fresh registry entry. Callers are responsible for guarding against
69
173
  * duplicate insertion. Throws SessionLimitError if we're at capacity.
70
174
  */
71
- register(args: { sessionId: string }): SessionEntry {
175
+ register(args: RegisterArgs): SessionEntry {
72
176
  if (this.sessions.size >= this.deps.maxSessions && !this.evictOldestTerminal()) {
73
177
  throw new SessionLimitError(
74
178
  `runner is at its configured max of ${this.deps.maxSessions} concurrent sessions`,
@@ -77,32 +181,74 @@ export class SessionRegistry {
77
181
  const entry: SessionEntry = {
78
182
  sessionId: args.sessionId,
79
183
  createdAt: new Date(),
184
+ mode: args.mode ?? "run",
80
185
  buffer: new EventRingBuffer(this.deps.replayBufferBytes),
81
- byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
82
186
  emitter: new EventEmitter(),
83
- byteEmitter: new EventEmitter(),
187
+ apps: new Map(),
84
188
  session: null,
85
189
  status: { kind: "starting" },
86
190
  exitedAt: null,
87
191
  userStopped: false,
88
192
  evictionTimer: null,
193
+ agent: args.agent,
194
+ checkpoint: null,
195
+ attribution: null,
196
+ launch: null,
197
+ subscribers: 0,
198
+ idleSince: Date.now(),
199
+ reloads: [],
200
+ checkpointedAt: null,
89
201
  };
90
202
  // Many transient SSE / WS subscribers per session is normal — bump the
91
203
  // default 10-listener warning to a high cap so the alarm still fires
92
204
  // for a real listener leak. 256 is well above the realistic concurrent-
93
205
  // tab count and well below "obviously a bug".
94
206
  entry.emitter.setMaxListeners(256);
95
- entry.byteEmitter.setMaxListeners(256);
207
+ for (const app of args.apps ?? [{ name: DEFAULT_APP_NAME }]) {
208
+ this.addApp(entry, app);
209
+ }
96
210
  this.sessions.set(args.sessionId, entry);
97
211
  return entry;
98
212
  }
99
213
 
100
- pushBytes(sessionId: string, bytes: Buffer): BufferedBytes | undefined {
101
- const entry = this.sessions.get(sessionId);
102
- if (!entry) return undefined;
214
+ /** Add an application channel to an existing entry. Used at register time and
215
+ * when `PUT /v1/sessions/:id/apps` changes the running set. */
216
+ addApp(
217
+ entry: SessionEntry,
218
+ app: { name: string; io?: IoMode; ports?: PortMapping[] },
219
+ ): AppChannel {
220
+ const channel: AppChannel = {
221
+ name: app.name,
222
+ io: app.io ?? "tty",
223
+ byteBuffer: new ByteRingBuffer(this.deps.replayBufferBytes),
224
+ byteEmitter: new EventEmitter(),
225
+ generation: 0,
226
+ startedAt: null,
227
+ ports: app.ports ?? [],
228
+ };
229
+ channel.byteEmitter.setMaxListeners(256);
230
+ entry.apps.set(app.name, channel);
231
+ return channel;
232
+ }
233
+
234
+ /** The single app of a single-app session — what `/io` and `reload` fall back
235
+ * to when no `app` is given. Undefined when the session runs several, where
236
+ * there is no defensible default among many terminals. */
237
+ soleApp(entry: SessionEntry): AppChannel | undefined {
238
+ return entry.apps.size === 1 ? entry.apps.values().next().value : undefined;
239
+ }
240
+
241
+ pushBytes(
242
+ sessionId: string,
243
+ appName: string,
244
+ bytes: Buffer,
245
+ stream: ByteStreamTag = "tty",
246
+ ): BufferedBytes | undefined {
247
+ const channel = this.sessions.get(sessionId)?.apps.get(appName);
248
+ if (!channel) return undefined;
103
249
  if (bytes.byteLength <= MAX_PUSH_CHUNK) {
104
- const buffered = entry.byteBuffer.push(bytes);
105
- entry.byteEmitter.emit(BYTES_EMITTED, buffered);
250
+ const buffered = channel.byteBuffer.push(bytes, stream);
251
+ channel.byteEmitter.emit(BYTES_EMITTED, buffered);
106
252
  return buffered;
107
253
  }
108
254
  // Split the chunk into MAX_PUSH_CHUNK-sized slices, each getting its
@@ -112,17 +258,25 @@ export class SessionRegistry {
112
258
  const slice = bytes.subarray(off, Math.min(off + MAX_PUSH_CHUNK, bytes.byteLength));
113
259
  // subarray shares memory with the parent buffer; copy so the ring's
114
260
  // entry doesn't pin the original allocation past eviction.
115
- last = entry.byteBuffer.push(Buffer.from(slice));
116
- entry.byteEmitter.emit(BYTES_EMITTED, last);
261
+ last = channel.byteBuffer.push(Buffer.from(slice), stream);
262
+ channel.byteEmitter.emit(BYTES_EMITTED, last);
117
263
  }
118
264
  return last;
119
265
  }
120
266
 
121
- subscribeBytes(sessionId: string, listener: (b: BufferedBytes) => void): () => void {
267
+ subscribeBytes(
268
+ sessionId: string,
269
+ appName: string,
270
+ listener: (b: BufferedBytes) => void,
271
+ ): () => void {
122
272
  const entry = this.sessions.get(sessionId);
123
273
  if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
124
- entry.byteEmitter.on(BYTES_EMITTED, listener);
125
- return () => entry.byteEmitter.off(BYTES_EMITTED, listener);
274
+ const channel = entry.apps.get(appName);
275
+ if (!channel) {
276
+ throw new UnknownAppError(`session '${sessionId}' runs no app named '${appName}'`);
277
+ }
278
+ channel.byteEmitter.on(BYTES_EMITTED, listener);
279
+ return () => channel.byteEmitter.off(BYTES_EMITTED, listener);
126
280
  }
127
281
 
128
282
  emit(sessionId: string, event: RunEvent): BufferedEvent | undefined {
@@ -133,13 +287,71 @@ export class SessionRegistry {
133
287
  entry.status = event.status;
134
288
  if (isTerminal(event.status)) {
135
289
  entry.exitedAt = new Date();
136
- this.scheduleEviction(entry);
290
+ this.scheduleEviction(entry, this.deps.exitTtlMs);
291
+ } else if (event.status.kind === "suspended") {
292
+ // Not terminal — the record and its checkpoint outlive the pod — but it
293
+ // still needs its own ceiling, or suspended records accumulate forever.
294
+ this.scheduleEviction(entry, this.deps.suspendedTtlMs);
295
+ } else {
296
+ this.cancelEviction(entry);
137
297
  }
138
298
  }
139
299
  entry.emitter.emit(EVENT_EMITTED, buffered);
140
300
  return buffered;
141
301
  }
142
302
 
303
+ /** Begin a generation for one app and emit its `run.started`. Returns the new
304
+ * generation number, or undefined when the app is unknown. */
305
+ startGeneration(
306
+ sessionId: string,
307
+ appName: string,
308
+ trigger: "initial" | "watch" | "manual" | "resume",
309
+ ): number | undefined {
310
+ const channel = this.sessions.get(sessionId)?.apps.get(appName);
311
+ if (!channel) return undefined;
312
+ channel.generation += 1;
313
+ channel.startedAt = Date.now();
314
+ this.emit(sessionId, {
315
+ type: "run",
316
+ app: appName,
317
+ generation: channel.generation,
318
+ phase: "started",
319
+ trigger,
320
+ });
321
+ return channel.generation;
322
+ }
323
+
324
+ /** Close the current generation of one app. A completion leaves the SESSION
325
+ * status untouched — that is the whole point of the split. */
326
+ finishGeneration(
327
+ sessionId: string,
328
+ appName: string,
329
+ outcome: { phase: "completed"; code: number } | { phase: "failed"; reason: string },
330
+ ): void {
331
+ const channel = this.sessions.get(sessionId)?.apps.get(appName);
332
+ if (!channel || channel.generation === 0) return;
333
+ const durationMs = channel.startedAt === null ? undefined : Date.now() - channel.startedAt;
334
+ channel.startedAt = null;
335
+ if (outcome.phase === "completed") {
336
+ this.emit(sessionId, {
337
+ type: "run",
338
+ app: appName,
339
+ generation: channel.generation,
340
+ phase: "completed",
341
+ code: outcome.code,
342
+ durationMs,
343
+ });
344
+ } else {
345
+ this.emit(sessionId, {
346
+ type: "run",
347
+ app: appName,
348
+ generation: channel.generation,
349
+ phase: "failed",
350
+ reason: outcome.reason,
351
+ });
352
+ }
353
+ }
354
+
143
355
  subscribe(sessionId: string, listener: (e: BufferedEvent) => void): () => void {
144
356
  const entry = this.sessions.get(sessionId);
145
357
  if (!entry) throw new SessionEvictedError(`session '${sessionId}' not in registry`);
@@ -147,25 +359,64 @@ export class SessionRegistry {
147
359
  return () => entry.emitter.off(EVENT_EMITTED, listener);
148
360
  }
149
361
 
150
- /** Free a slot at capacity by removing the oldest already-terminated session
151
- * (by exit time). Retained exited sessions are history kept for re-attach, so
152
- * they yield to a new run rather than blocking it; live sessions are never
153
- * evicted. Returns false when every session is still live. */
362
+ /** Count a live client. Idleness no SSE/WS subscriber for the configured
363
+ * window is what suspends a watch session, and aggressive reaping is what
364
+ * makes per-visitor watch sessions affordable; they only work as a pair. */
365
+ addSubscriber(sessionId: string): () => void {
366
+ const entry = this.sessions.get(sessionId);
367
+ if (!entry) return () => {};
368
+ entry.subscribers += 1;
369
+ entry.idleSince = null;
370
+ let released = false;
371
+ return () => {
372
+ if (released) return;
373
+ released = true;
374
+ entry.subscribers = Math.max(0, entry.subscribers - 1);
375
+ if (entry.subscribers === 0) entry.idleSince = Date.now();
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Free a slot at capacity by removing the oldest RECLAIMABLE session: a
381
+ * terminated one (history kept for re-attach, which yields to a new run) or a
382
+ * SUSPENDED one.
383
+ *
384
+ * A suspended session is reclaimable because losing it costs exactly what the
385
+ * design already says a runner restart costs — a checkpoint the editor
386
+ * re-seeds from its own copy. Excluding it would let a handful of visitors who
387
+ * each left after five minutes hold every slot for the whole suspended TTL,
388
+ * which with the shipped defaults is a day.
389
+ *
390
+ * Ordered by when the session stopped being live, so the least recently
391
+ * abandoned goes first. Returns false when every session is still live.
392
+ */
154
393
  private evictOldestTerminal(): boolean {
155
394
  let oldest: SessionEntry | undefined;
395
+ let oldestAt = Number.POSITIVE_INFINITY;
156
396
  for (const entry of this.sessions.values()) {
157
- if (entry.exitedAt === null) continue;
158
- if (!oldest || entry.exitedAt < oldest.exitedAt!) oldest = entry;
397
+ const since = reclaimableSince(entry);
398
+ if (since === null || since >= oldestAt) continue;
399
+ oldest = entry;
400
+ oldestAt = since;
159
401
  }
160
402
  if (!oldest) return false;
161
403
  return this.remove(oldest.sessionId);
162
404
  }
163
405
 
164
- private scheduleEviction(entry: SessionEntry): void {
165
- if (entry.evictionTimer) return;
406
+ private cancelEviction(entry: SessionEntry): void {
407
+ if (!entry.evictionTimer) return;
408
+ clearTimeout(entry.evictionTimer);
409
+ entry.evictionTimer = null;
410
+ }
411
+
412
+ private scheduleEviction(entry: SessionEntry, ttlMs: number | undefined): void {
413
+ if (ttlMs === undefined) return;
414
+ // A resume re-arms from `running`, so an already-armed timer is replaced
415
+ // rather than kept — the surviving one would evict a live session.
416
+ this.cancelEviction(entry);
166
417
  entry.evictionTimer = setTimeout(() => {
167
418
  this.sessions.delete(entry.sessionId);
168
- }, this.deps.exitTtlMs);
419
+ }, ttlMs);
169
420
  // Allow process exit even if evictions are pending — they are pure state,
170
421
  // not work.
171
422
  entry.evictionTimer.unref?.();
@@ -183,3 +434,12 @@ export class SessionRegistry {
183
434
  return true;
184
435
  }
185
436
  }
437
+
438
+ /** When a session stopped being live, or null while it still is. A terminated
439
+ * session is reclaimable from its exit; a suspended one from when it went idle
440
+ * (it has no pod and its workspace is a checkpoint the editor can re-seed). */
441
+ function reclaimableSince(entry: SessionEntry): number | null {
442
+ if (entry.exitedAt !== null) return entry.exitedAt.getTime();
443
+ if (entry.status.kind === "suspended") return entry.idleSince ?? 0;
444
+ return null;
445
+ }
@@ -7,8 +7,8 @@ import { normalizeBundlePath, validateSessionId, BundlePathError } from "./bundl
7
7
  describe("EventRingBuffer", () => {
8
8
  it("assigns monotonic ids starting at 1 and replays after an id", () => {
9
9
  const buf = new EventRingBuffer(1_000_000);
10
- buf.push({ type: "stdout", chunk: "a" });
11
- buf.push({ type: "stdout", chunk: "b" });
10
+ buf.push({ type: "progress", phase: "boot", message: "a" });
11
+ buf.push({ type: "progress", phase: "boot", message: "b" });
12
12
  const { entries, hasGap } = buf.replay(1);
13
13
  expect(entries.map((e) => e.id)).toEqual([2]);
14
14
  expect(hasGap).toBe(false);
@@ -16,7 +16,8 @@ describe("EventRingBuffer", () => {
16
16
 
17
17
  it("evicts oldest entries past the byte cap but always retains the last", () => {
18
18
  const buf = new EventRingBuffer(50);
19
- for (let i = 0; i < 20; i++) buf.push({ type: "stdout", chunk: "x".repeat(20) });
19
+ for (let i = 0; i < 20; i++)
20
+ buf.push({ type: "progress", phase: "boot", message: "x".repeat(20) });
20
21
  expect(buf.size).toBeGreaterThanOrEqual(1);
21
22
  expect(buf.bytes).toBeLessThanOrEqual(50 + 40);
22
23
  const { hasGap } = buf.replay(0);
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { SessionRegistry, type SessionEntry } from "./registry.js";
4
+ import { WatchSupervisor } from "./watch-supervisor.js";
5
+
6
+ function makeRegistry() {
7
+ return new SessionRegistry({
8
+ maxSessions: 4,
9
+ exitTtlMs: 60_000,
10
+ replayBufferBytes: 100_000,
11
+ suspendedTtlMs: 3_600_000,
12
+ });
13
+ }
14
+
15
+ describe("suspended is not terminal", () => {
16
+ it("keeps the session and schedules its own eviction, not the exit one", () => {
17
+ const registry = makeRegistry();
18
+ const entry = registry.register({ sessionId: "s1", mode: "watch" });
19
+ registry.emit("s1", { type: "status", status: { kind: "suspended" } });
20
+
21
+ expect(registry.has("s1")).toBe(true);
22
+ // `exitedAt` is what marks a session terminal for capacity eviction — a
23
+ // suspended session must not be a candidate.
24
+ expect(entry.exitedAt).toBeNull();
25
+ expect(entry.evictionTimer).not.toBeNull();
26
+ });
27
+
28
+ it("cancels the suspended eviction when the session resumes", () => {
29
+ const registry = makeRegistry();
30
+ const entry = registry.register({ sessionId: "s1", mode: "watch" });
31
+ registry.emit("s1", { type: "status", status: { kind: "suspended" } });
32
+ registry.emit("s1", { type: "status", status: { kind: "running" } });
33
+
34
+ expect(entry.evictionTimer).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("a suspended session does not hold a slot", () => {
39
+ it("is evicted to make room rather than blocking a new session", () => {
40
+ // Losing it costs exactly what a runner restart already costs — a checkpoint
41
+ // the editor re-seeds. Excluding it would let a handful of visitors who each
42
+ // left after five minutes hold every slot for the whole suspended TTL.
43
+ const registry = new SessionRegistry({
44
+ maxSessions: 1,
45
+ exitTtlMs: 60_000,
46
+ replayBufferBytes: 100_000,
47
+ suspendedTtlMs: 86_400_000,
48
+ });
49
+ registry.register({ sessionId: "old", mode: "watch" });
50
+ registry.emit("old", { type: "status", status: { kind: "suspended" } });
51
+
52
+ expect(() => registry.register({ sessionId: "new", mode: "watch" })).not.toThrow();
53
+ expect(registry.has("old")).toBe(false);
54
+ expect(registry.has("new")).toBe(true);
55
+ });
56
+
57
+ it("never evicts a live session to make room", () => {
58
+ const registry = new SessionRegistry({
59
+ maxSessions: 1,
60
+ exitTtlMs: 60_000,
61
+ replayBufferBytes: 100_000,
62
+ });
63
+ registry.register({ sessionId: "live", mode: "watch" });
64
+ registry.emit("live", { type: "status", status: { kind: "running" } });
65
+
66
+ expect(() => registry.register({ sessionId: "new", mode: "watch" })).toThrow();
67
+ expect(registry.has("live")).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("byte channels are per app", () => {
72
+ it("routes a push to the named app and drops one for an unknown name", () => {
73
+ const registry = makeRegistry();
74
+ const entry = registry.register({
75
+ sessionId: "s1",
76
+ mode: "watch",
77
+ apps: [{ name: "web" }, { name: "worker" }],
78
+ });
79
+
80
+ registry.pushBytes("s1", "web", Buffer.from("hello"));
81
+ expect(entry.apps.get("web")!.byteBuffer.size).toBe(1);
82
+ expect(entry.apps.get("worker")!.byteBuffer.size).toBe(0);
83
+
84
+ expect(registry.pushBytes("s1", "nope", Buffer.from("x"))).toBeUndefined();
85
+ });
86
+
87
+ it("carries the stream tag through the buffer", () => {
88
+ const registry = makeRegistry();
89
+ registry.register({ sessionId: "s1", apps: [{ name: "app", io: "streams" }] });
90
+ registry.pushBytes("s1", "app", Buffer.from("out"), "stdout");
91
+ registry.pushBytes("s1", "app", Buffer.from("err"), "stderr");
92
+
93
+ const entries = registry.get("s1")!.apps.get("app")!.byteBuffer.replay(0).entries;
94
+ expect(entries.map((e) => e.stream)).toEqual(["stdout", "stderr"]);
95
+ });
96
+
97
+ it("has a sole app only when the session runs exactly one", () => {
98
+ const registry = makeRegistry();
99
+ const one = registry.register({ sessionId: "s1" });
100
+ const two = registry.register({
101
+ sessionId: "s2",
102
+ apps: [{ name: "web" }, { name: "worker" }],
103
+ });
104
+
105
+ expect(registry.soleApp(one)?.name).toBe("app");
106
+ expect(registry.soleApp(two)).toBeUndefined();
107
+ });
108
+ });
109
+
110
+ describe("WatchSupervisor", () => {
111
+ function watchEntry(registry: SessionRegistry, snapshot = vi.fn(async () => [])) {
112
+ const entry = registry.register({ sessionId: "s1", mode: "watch" });
113
+ const suspend = vi.fn(async () => {});
114
+ entry.session = {
115
+ writeStdin: () => {},
116
+ resize: () => {},
117
+ done: Promise.resolve(),
118
+ stop: async () => {},
119
+ suspend,
120
+ workspace: {
121
+ tree: async () => ({ files: [] }),
122
+ readFile: async () => ({ content: "", size: 0 }),
123
+ apply: async () => ({ written: 0, deleted: 0 }),
124
+ snapshot,
125
+ },
126
+ } as unknown as SessionEntry["session"];
127
+ registry.emit("s1", { type: "status", status: { kind: "running" } });
128
+ return { entry, suspend, snapshot };
129
+ }
130
+
131
+ it("checkpoints on the timer while a client is attached", async () => {
132
+ const registry = makeRegistry();
133
+ const { entry, suspend, snapshot } = watchEntry(registry);
134
+ registry.addSubscriber("s1");
135
+ const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 0 });
136
+
137
+ await supervisor.tick();
138
+ await vi.waitFor(() => expect(snapshot).toHaveBeenCalled());
139
+
140
+ expect(suspend).not.toHaveBeenCalled();
141
+ expect(entry.checkpoint).not.toBeNull();
142
+ });
143
+
144
+ it("snapshots before suspending — the volume dies with the pod", async () => {
145
+ const registry = makeRegistry();
146
+ const order: string[] = [];
147
+ const snapshot = vi.fn(async () => {
148
+ order.push("snapshot");
149
+ return [];
150
+ });
151
+ const { entry, suspend } = watchEntry(registry, snapshot);
152
+ suspend.mockImplementation(async () => {
153
+ order.push("suspend");
154
+ });
155
+ // No subscriber was ever added, so the entry is idle from registration.
156
+ const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
157
+
158
+ await supervisor.tick();
159
+ await vi.waitFor(() => expect(entry.status.kind).toBe("suspended"));
160
+
161
+ expect(order).toEqual(["snapshot", "suspend"]);
162
+ expect(entry.session).toBeNull();
163
+ });
164
+
165
+ it("does not suspend while a client is attached", async () => {
166
+ const registry = makeRegistry();
167
+ const { suspend } = watchEntry(registry);
168
+ registry.addSubscriber("s1");
169
+ const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
170
+
171
+ await supervisor.tick();
172
+ await new Promise((r) => setTimeout(r, 20));
173
+
174
+ expect(suspend).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("becomes idle again once the last subscriber detaches", async () => {
178
+ const registry = makeRegistry();
179
+ const { entry, suspend } = watchEntry(registry);
180
+ const release = registry.addSubscriber("s1");
181
+ expect(entry.idleSince).toBeNull();
182
+ release();
183
+ expect(entry.idleSince).not.toBeNull();
184
+
185
+ const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 60_000 });
186
+ await supervisor.tick();
187
+ await vi.waitFor(() => expect(suspend).toHaveBeenCalled());
188
+ });
189
+
190
+ it("leaves run sessions alone", async () => {
191
+ const registry = makeRegistry();
192
+ const entry = registry.register({ sessionId: "s2", mode: "run" });
193
+ const suspend = vi.fn(async () => {});
194
+ entry.session = { suspend } as unknown as SessionEntry["session"];
195
+ registry.emit("s2", { type: "status", status: { kind: "running" } });
196
+
197
+ const supervisor = new WatchSupervisor({ registry, idleMs: 0, checkpointMs: 0 });
198
+ await supervisor.tick();
199
+ await new Promise((r) => setTimeout(r, 20));
200
+
201
+ expect(suspend).not.toHaveBeenCalled();
202
+ });
203
+ });