@telorun/k8s-runner 0.10.2 → 0.12.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.
@@ -0,0 +1,694 @@
1
+ import { PassThrough, Writable } from "node:stream";
2
+
3
+ import type { V1Pod } from "@kubernetes/client-node";
4
+ import type {
5
+ BackendAppSpec,
6
+ BackendSession,
7
+ BackendStartSpec,
8
+ DebugFrame,
9
+ PortMapping,
10
+ RunnerEndpoint,
11
+ RunStatus,
12
+ WorkspaceAccess,
13
+ } from "@telorun/runner-core";
14
+ import {
15
+ portKey,
16
+ portsResolvedFrom,
17
+ relayDebugStream,
18
+ SessionStartError,
19
+ watchReachability,
20
+ workspaceMarkerWrite,
21
+ WorkspaceClient,
22
+ } from "@telorun/runner-core";
23
+
24
+ import type { K8sRunnerConfig } from "../config.js";
25
+ import { clampLimits } from "../limits.js";
26
+ import type { KubeClient } from "./client.js";
27
+ import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
28
+ import { buildWatchPod, inspectPortFor, WORKSPACE_PORT } from "./pod-spec.js";
29
+ import { deletePod, is404, msg, podPhase, podStatus, provisionMessage } from "./pod-status.js";
30
+ import { ensureWorkspaceConfigMap } from "./workspace-configmap.js";
31
+
32
+ /** How long the pod may take to reach Running before the start is abandoned.
33
+ * `activeDeadlineSeconds` only bounds an already-running pod. */
34
+ const START_DEADLINE_MS = 180_000;
35
+ const WATCH_REARM_DELAY_MS = 2_000;
36
+ /** How long to wait for the workspace container's HTTP surface to answer before
37
+ * giving up on the seed. It is a kernel boot plus a module resolve, so it is
38
+ * slower than the pod reaching Running. */
39
+ const WORKSPACE_READY_TIMEOUT_MS = 120_000;
40
+ const WORKSPACE_POLL_MS = 500;
41
+ const RESIZE_CHANNEL = 4;
42
+
43
+ interface ResizableSocket {
44
+ send(data: Buffer): void;
45
+ close(): void;
46
+ }
47
+
48
+ export interface WatchSessionDeps {
49
+ kube: KubeClient;
50
+ config: K8sRunnerConfig;
51
+ }
52
+
53
+ /**
54
+ * A watch session: one pod, one workspace volume, one container per running
55
+ * application, and the session outliving every run inside it.
56
+ *
57
+ * What it does NOT do is as load-bearing as what it does: it never builds an
58
+ * image. The build path exists to put a dependency closure on disk before boot;
59
+ * a watch session resolves its own into a per-app cache directory that lives as
60
+ * long as the pod, so the download happens once per app per session and every
61
+ * later reload resolves from local disk.
62
+ */
63
+ export async function startWatchSession(
64
+ deps: WatchSessionDeps,
65
+ spec: BackendStartSpec,
66
+ ): Promise<BackendSession> {
67
+ const { kube, config } = deps;
68
+ const ns = config.sessionNamespace;
69
+ const limits = clampLimits(config.appLimits, undefined);
70
+
71
+ let apps = spec.apps;
72
+ let podName = freshPodName(spec.sessionId);
73
+ let userStopped = false;
74
+
75
+ const workspaceAppConfigMap = await ensureWorkspaceConfigMap(kube, config);
76
+
77
+ /** Everything tied to ONE pod. A pod recreate (resume, or a change to the app
78
+ * set) replaces this wholesale rather than reconciling it in place: the
79
+ * container list is fixed at creation, so nothing about the old pod survives. */
80
+ interface PodRuntime {
81
+ name: string;
82
+ uid: string;
83
+ ip: string;
84
+ workspace: WorkspaceClient;
85
+ sockets: Map<string, ResizableSocket>;
86
+ stdins: Map<string, PassThrough>;
87
+ abort: AbortController;
88
+ stopWatch: () => void;
89
+ }
90
+ let runtime: PodRuntime | null = null;
91
+
92
+ let resolveDone!: () => void;
93
+ const done = new Promise<void>((r) => (resolveDone = r));
94
+ let settled = false;
95
+ const settle = (status: RunStatus): void => {
96
+ if (settled) return;
97
+ settled = true;
98
+ spec.onStatus(status);
99
+ resolveDone();
100
+ };
101
+
102
+ async function bringUp(seed: boolean): Promise<void> {
103
+ const pod = buildWatchPod({
104
+ config,
105
+ sessionId: spec.sessionId,
106
+ podName,
107
+ env: spec.env,
108
+ apps,
109
+ agent: spec.agent,
110
+ limits,
111
+ image: spec.config.image || config.defaultImage,
112
+ pullPolicy: spec.config.pullPolicy,
113
+ workspaceAppConfigMap,
114
+ });
115
+
116
+ let created: V1Pod;
117
+ try {
118
+ created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
119
+ } catch (err) {
120
+ throw new SessionStartError(
121
+ "start_failed",
122
+ "create",
123
+ `failed to create pod: ${msg(err)}`,
124
+ msg(err),
125
+ );
126
+ }
127
+
128
+ const abort = new AbortController();
129
+ const ip = await waitForRunning(podName, abort);
130
+
131
+ const workspace = new WorkspaceClient(`http://${ip}:${WORKSPACE_PORT}`);
132
+ await waitForWorkspace(workspace, abort.signal);
133
+
134
+ // The bundle reaches the volume through the SAME surface every later write
135
+ // takes — which is what lets the body-fetch init container and the tokenized
136
+ // tarball go away. App containers wait for their entry manifest to exist, so
137
+ // none of them has failed a load in the meantime.
138
+ if (seed) {
139
+ await workspace.apply({
140
+ write: [
141
+ ...spec.bundle.files.map((f) => ({
142
+ path: f.relativePath,
143
+ content: f.contents,
144
+ encoding: f.encoding ?? "utf8",
145
+ })),
146
+ // The workspace-root marker, so every app in this session anchors its
147
+ // module cache at ONE place instead of one per entry directory.
148
+ ...workspaceMarkerWrite(spec.bundle),
149
+ ],
150
+ });
151
+ }
152
+
153
+ endedApps.clear();
154
+ runtime = {
155
+ name: podName,
156
+ uid: created.metadata?.uid ?? "",
157
+ ip,
158
+ workspace,
159
+ sockets: new Map(),
160
+ stdins: new Map(),
161
+ abort,
162
+ stopWatch: () => {},
163
+ };
164
+
165
+ await attachApps(runtime);
166
+ await publishEndpoints(runtime, apps);
167
+ relayDebug(runtime);
168
+ watchPorts(runtime);
169
+ armPodWatch(runtime);
170
+
171
+ const agent = agentEndpoint();
172
+ spec.onStatus({
173
+ kind: "running",
174
+ endpoints: endpointsFor(config, spec.sessionId, allPorts(apps)),
175
+ ...(agent ? { agent } : {}),
176
+ });
177
+ }
178
+
179
+ /** Where this session's co-resident agent answers. The pod's containers share
180
+ * one network namespace, so the agent rides the session's own Service and
181
+ * Ingress — the port is simply in the routed set, and the host follows the
182
+ * same `<port>-<sessionId>` scheme every app port does. */
183
+ function agentEndpoint(): RunnerEndpoint | undefined {
184
+ const port = spec.agent?.port;
185
+ if (port === undefined) return undefined;
186
+ return endpointsFor(config, spec.sessionId, [{ port, protocol: "tcp" }])[0];
187
+ }
188
+
189
+ /** Every port the session's Service and Ingress must carry: the apps' declared
190
+ * ports plus the agent's. Kept apart from `allPorts`, which answers what the
191
+ * APPLICATIONS declare — that set is what the client is told about and what a
192
+ * reload re-reads, and the agent belongs in neither. */
193
+ function routedPorts(forApps: BackendAppSpec[]): PortMapping[] {
194
+ const port = spec.agent?.port;
195
+ const ports = allPorts(forApps);
196
+ return port === undefined ? ports : [...ports, { port, protocol: "tcp" as const }];
197
+ }
198
+
199
+ /** Resolve once the pod is Running, streaming provisioning messages meanwhile.
200
+ * Rejects with the pod's own failure detail rather than a timeout when the
201
+ * pod itself failed — the two send a reader to different places. */
202
+ async function waitForRunning(name: string, abort: AbortController): Promise<string> {
203
+ let lastProvision: string | undefined;
204
+ const deadline = Date.now() + START_DEADLINE_MS;
205
+ for (;;) {
206
+ if (abort.signal.aborted) throw new Error("session stopped while the pod was coming up");
207
+ let current: V1Pod;
208
+ try {
209
+ current = await kube.core.readNamespacedPod({ name, namespace: ns });
210
+ } catch (err) {
211
+ if (is404(err)) throw new Error("pod disappeared before reaching Running");
212
+ throw err;
213
+ }
214
+ const phase = podPhase(current);
215
+ if (phase === "Running") {
216
+ const ip = podStatus(current)?.podIP;
217
+ if (ip) return ip;
218
+ } else if (phase === "Failed" || phase === "Succeeded") {
219
+ throw new Error(`pod reached ${phase} before serving`);
220
+ } else {
221
+ const provision = provisionMessage(current);
222
+ if (provision && provision !== lastProvision) {
223
+ lastProvision = provision;
224
+ spec.onProgress("provision", provision);
225
+ }
226
+ }
227
+ if (Date.now() > deadline) {
228
+ throw new Error("pod did not reach Running within the start deadline");
229
+ }
230
+ await sleep(WORKSPACE_POLL_MS, abort.signal);
231
+ }
232
+ }
233
+
234
+ /** The workspace container is a kernel boot plus a module resolve, so being
235
+ * Running is not being ready. Every write — the seed above all — depends on
236
+ * it, so this waits rather than letting the first write fail. */
237
+ async function waitForWorkspace(client: WorkspaceClient, signal: AbortSignal): Promise<void> {
238
+ const deadline = Date.now() + WORKSPACE_READY_TIMEOUT_MS;
239
+ spec.onProgress("boot", "Starting workspace");
240
+ let lastError = "no response";
241
+ for (;;) {
242
+ if (signal.aborted) throw new Error("session stopped while the workspace was coming up");
243
+ try {
244
+ await client.tree();
245
+ return;
246
+ } catch (err) {
247
+ lastError = msg(err);
248
+ }
249
+ if (Date.now() > deadline) {
250
+ throw new SessionStartError(
251
+ "start_failed",
252
+ "start",
253
+ `workspace container did not become ready: ${lastError}`,
254
+ lastError,
255
+ );
256
+ }
257
+ await sleep(WORKSPACE_POLL_MS, signal);
258
+ }
259
+ }
260
+
261
+ /** One attach per app container: each has its own terminal, so the byte
262
+ * channel is keyed `(session, app)` rather than labelled on a merged stream. */
263
+ async function attachApps(rt: PodRuntime): Promise<void> {
264
+ for (const app of apps) {
265
+ const stdin = new PassThrough();
266
+ const stdout = new Writable({
267
+ write(chunk: Buffer, encoding, cb) {
268
+ if (chunk?.byteLength) spec.onOutput(app.name, Buffer.from(chunk), tagFor(app, "stdout"));
269
+ cb();
270
+ },
271
+ });
272
+ // Under `io: "streams"` the kubernetes attach subresource gives separate
273
+ // stdout and stderr channels — the demux is already there, and a TTY is
274
+ // what collapses it. So the split is real, not a label.
275
+ const stderr =
276
+ app.io === "streams"
277
+ ? new Writable({
278
+ write(chunk: Buffer, encoding, cb) {
279
+ if (chunk?.byteLength) spec.onOutput(app.name, Buffer.from(chunk), "stderr");
280
+ cb();
281
+ },
282
+ })
283
+ : null;
284
+ try {
285
+ const ws = await kube.attach.attach(
286
+ ns,
287
+ rt.name,
288
+ `app-${app.name}`,
289
+ stdout,
290
+ stderr,
291
+ stdin,
292
+ app.io === "tty",
293
+ );
294
+ rt.sockets.set(app.name, ws as unknown as ResizableSocket);
295
+ rt.stdins.set(app.name, stdin);
296
+ } catch (err) {
297
+ // A degraded terminal is not a failed session — status, run events and
298
+ // the workspace all still work — so it is reported on that app's own
299
+ // channel rather than aborting the start.
300
+ spec.onOutput(
301
+ app.name,
302
+ Buffer.from(`\r\n[runner] failed to attach: ${msg(err)}\r\n`),
303
+ tagFor(app, "stderr"),
304
+ );
305
+ }
306
+ }
307
+ }
308
+
309
+ function relayDebug(rt: PodRuntime): void {
310
+ apps.forEach((app, index) => {
311
+ void relayDebugStream({
312
+ url: `http://${rt.ip}:${inspectPortFor(index)}/events`,
313
+ onFrame: (frame) => {
314
+ void applyPortsResolved(app.name, frame);
315
+ spec.onDebug(app.name, frame);
316
+ },
317
+ signal: rt.abort.signal,
318
+ });
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Re-route an app whose declared port set changed on reload.
324
+ *
325
+ * The kernel re-resolves its `ports:` block on every load and says so on the
326
+ * stream the runner is already reading, so nothing here parses a manifest —
327
+ * which matters, because a manifest the runner could not parse would otherwise
328
+ * have to leave routing alone and report, on the hot path of every save.
329
+ *
330
+ * A pod's `containerPort` list is documentation; the Service and the Ingress
331
+ * are what make a port reachable, so this needs no pod recreate.
332
+ */
333
+ async function applyPortsResolved(appName: string, frame: DebugFrame): Promise<void> {
334
+ const declared = portsResolvedFrom(frame);
335
+ if (!declared) return;
336
+ const rt = runtime;
337
+ const app = apps.find((a) => a.name === appName);
338
+ if (!rt || !app) return;
339
+
340
+ const before = new Map(app.ports.map((p) => [portKey(p), p]));
341
+ const after = new Map(declared.map((p) => [portKey(p), p]));
342
+ const added = declared.filter((p) => !before.has(portKey(p)));
343
+ const removed = app.ports.filter((p) => !after.has(portKey(p)));
344
+ if (added.length === 0 && removed.length === 0) return;
345
+
346
+ // A port another app in this session already owns cannot be routed: session
347
+ // hosts are `<port>-<sessionId>`, a single label carrying no app name. It is
348
+ // reported against the app that asked for it, never dropped.
349
+ // The agent's port is taken too: it is on the same pod IP behind the same
350
+ // Service, so an app that reloads onto it would be routed the agent's
351
+ // traffic — or take the agent's, with nothing to tell the two apart.
352
+ const taken = new Set([
353
+ ...apps.filter((a) => a.name !== appName).flatMap((a) => a.ports.map(portKey)),
354
+ ...(spec.agent?.port !== undefined
355
+ ? [portKey({ port: spec.agent.port, protocol: "tcp" })]
356
+ : []),
357
+ ]);
358
+ const rejected = added.filter((p) => taken.has(portKey(p)));
359
+ const accepted = added.filter((p) => !taken.has(portKey(p)));
360
+
361
+ app.ports = [...app.ports.filter((p) => after.has(portKey(p))), ...accepted];
362
+
363
+ try {
364
+ await publishEndpoints(rt, apps);
365
+ } catch (err) {
366
+ spec.onEndpoints(appName, {
367
+ rejected: accepted.map((p) => ({ port: p.port, reason: msg(err) })),
368
+ });
369
+ return;
370
+ }
371
+ spec.onEndpoints(appName, {
372
+ ...(accepted.length > 0
373
+ ? { added: endpointsFor(config, spec.sessionId, accepted) }
374
+ : {}),
375
+ ...(removed.length > 0
376
+ ? { removed: endpointsFor(config, spec.sessionId, removed) }
377
+ : {}),
378
+ ...(rejected.length > 0
379
+ ? {
380
+ rejected: rejected.map((p) => ({
381
+ port: p.port,
382
+ reason:
383
+ spec.agent?.port === p.port && p.protocol === "tcp"
384
+ ? `this session's co-resident agent '${spec.agent.name}' listens on ${p.protocol} port ${p.port}`
385
+ : `another app in this session already declares ${p.protocol} port ${p.port}`,
386
+ })),
387
+ }
388
+ : {}),
389
+ });
390
+
391
+ const tcp = accepted.filter((p) => p.protocol === "tcp").map((p) => p.port);
392
+ if (tcp.length > 0) {
393
+ void watchReachability({
394
+ host: rt.ip,
395
+ ports: tcp,
396
+ onState: (port, state) => spec.onReachability(appName, port, state),
397
+ signal: rt.abort.signal,
398
+ });
399
+ }
400
+ }
401
+
402
+ function watchPorts(rt: PodRuntime): void {
403
+ for (const app of apps) {
404
+ const tcp = app.ports.filter((p) => p.protocol === "tcp").map((p) => p.port);
405
+ if (tcp.length === 0) continue;
406
+ void watchReachability({
407
+ host: rt.ip,
408
+ ports: tcp,
409
+ onState: (port, state) => spec.onReachability(app.name, port, state),
410
+ signal: rt.abort.signal,
411
+ });
412
+ }
413
+ }
414
+
415
+ /** Apps whose container has already been reported ended, so a repeated watch
416
+ * event does not re-report it. Cleared when a pod is replaced. */
417
+ const endedApps = new Set<string>();
418
+
419
+ /**
420
+ * Report an `app-<name>` container that has terminated.
421
+ *
422
+ * `restartPolicy: Never` plus a `workspace` container that runs forever means
423
+ * a pod whose application container died stays **Running** — so pod phase
424
+ * alone never notices, and the app is silently dead while the editor still
425
+ * shows it running. The container statuses are where that fact lives.
426
+ *
427
+ * A run ENDING is not the session ending: the rest of the session is up and
428
+ * the next edit starts the next generation, which is why this reports a run
429
+ * outcome rather than a status.
430
+ */
431
+ function noteEndedContainers(obj: unknown): void {
432
+ if (userStopped) return;
433
+ for (const status of podStatus(obj)?.containerStatuses ?? []) {
434
+ const name = status.name ?? "";
435
+ if (!name.startsWith("app-")) continue;
436
+ const terminated = status.state?.terminated;
437
+ if (!terminated) continue;
438
+ const appName = name.slice("app-".length);
439
+ if (endedApps.has(appName)) continue;
440
+ endedApps.add(appName);
441
+ spec.onRunEnded(appName, {
442
+ reason:
443
+ terminated.reason && terminated.reason !== "Completed"
444
+ ? `application container ${terminated.reason} (exit code ${terminated.exitCode ?? "unknown"})`
445
+ : `application container exited (code ${terminated.exitCode ?? "unknown"})`,
446
+ });
447
+ }
448
+ }
449
+
450
+ /** A watch on the pod, so a container that dies unrecoverably fails the
451
+ * session instead of leaving a stream that has simply gone quiet. */
452
+ function armPodWatch(rt: PodRuntime): void {
453
+ let stopped = false;
454
+ const arm = async (): Promise<void> => {
455
+ if (stopped) return;
456
+ try {
457
+ const req = await kube.watch.watch(
458
+ `/api/v1/namespaces/${ns}/pods`,
459
+ { fieldSelector: `metadata.name=${rt.name}` },
460
+ (type: string, obj: unknown) => {
461
+ if (stopped) return;
462
+ noteEndedContainers(obj);
463
+ const phase = podPhase(obj);
464
+ if (phase === "Failed" || phase === "Succeeded") {
465
+ // A watch session's pod reaching a terminal phase is never a
466
+ // normal run ending — a run ending leaves the pod up. So it is a
467
+ // session failure unless the user asked for it.
468
+ settle(
469
+ userStopped
470
+ ? { kind: "stopped" }
471
+ : { kind: "failed", message: `session pod reached ${phase}` },
472
+ );
473
+ }
474
+ },
475
+ () => {
476
+ if (stopped) return;
477
+ setTimeout(() => void arm(), WATCH_REARM_DELAY_MS).unref?.();
478
+ },
479
+ );
480
+ rt.stopWatch = () => {
481
+ stopped = true;
482
+ try {
483
+ (req as { abort?: () => void }).abort?.();
484
+ } catch {
485
+ /* already gone */
486
+ }
487
+ };
488
+ } catch {
489
+ if (!stopped) setTimeout(() => void arm(), WATCH_REARM_DELAY_MS).unref?.();
490
+ }
491
+ };
492
+ void arm();
493
+ }
494
+
495
+ /**
496
+ * Create or re-patch the Service and Ingress for the session's whole declared
497
+ * port set. Adding a `ports:` entry is as ordinary an edit as adding an
498
+ * import, and a container may bind any port regardless of what the pod spec
499
+ * declares — so without this the app listens and is simply unreachable: no
500
+ * ingress, no error, no event. The pod's `containerPort` list is
501
+ * documentation; the Service and Ingress are what make a port reachable, which
502
+ * is why this needs no pod recreate.
503
+ */
504
+ async function publishEndpoints(rt: PodRuntime, forApps: BackendAppSpec[]): Promise<void> {
505
+ if (!config.sessionIngressBaseDomain) return;
506
+ const ports = routedPorts(forApps);
507
+ if (ports.length === 0) return;
508
+ const service = buildSessionService(config, spec.sessionId, rt.name, rt.uid, ports);
509
+ const serviceName = service.metadata!.name!;
510
+ await upsert(
511
+ () => kube.core.createNamespacedService({ namespace: ns, body: service }),
512
+ async () => {
513
+ // A Service replace must carry the assigned `clusterIP` and the current
514
+ // `resourceVersion`: the first is immutable and an empty one is rejected
515
+ // outright, the second is what makes the write conflict rather than
516
+ // clobber. Reading first is not optional here.
517
+ const existing = await kube.core.readNamespacedService({ name: serviceName, namespace: ns });
518
+ await kube.core.replaceNamespacedService({
519
+ name: serviceName,
520
+ namespace: ns,
521
+ body: {
522
+ ...service,
523
+ metadata: {
524
+ ...service.metadata,
525
+ resourceVersion: existing.metadata?.resourceVersion,
526
+ },
527
+ spec: {
528
+ ...service.spec,
529
+ clusterIP: existing.spec?.clusterIP,
530
+ clusterIPs: existing.spec?.clusterIPs,
531
+ },
532
+ },
533
+ });
534
+ },
535
+ );
536
+ const { ingress } = buildSessionIngress(
537
+ config,
538
+ spec.sessionId,
539
+ serviceName,
540
+ rt.name,
541
+ rt.uid,
542
+ ports,
543
+ );
544
+ if (!ingress.spec?.rules?.length) return;
545
+ const ingressName = ingress.metadata!.name!;
546
+ await upsert(
547
+ () => kube.networking.createNamespacedIngress({ namespace: ns, body: ingress }),
548
+ async () => {
549
+ const existing = await kube.networking.readNamespacedIngress({
550
+ name: ingressName,
551
+ namespace: ns,
552
+ });
553
+ await kube.networking.replaceNamespacedIngress({
554
+ name: ingressName,
555
+ namespace: ns,
556
+ body: {
557
+ ...ingress,
558
+ metadata: {
559
+ ...ingress.metadata,
560
+ resourceVersion: existing.metadata?.resourceVersion,
561
+ },
562
+ },
563
+ });
564
+ },
565
+ );
566
+ }
567
+
568
+ async function teardownPod(): Promise<void> {
569
+ const rt = runtime;
570
+ runtime = null;
571
+ if (!rt) return;
572
+ rt.stopWatch();
573
+ rt.abort.abort();
574
+ for (const socket of rt.sockets.values()) {
575
+ try {
576
+ socket.close();
577
+ } catch {
578
+ /* already closed */
579
+ }
580
+ }
581
+ await deletePod(kube, ns, rt.name);
582
+ }
583
+
584
+ await bringUp(true);
585
+
586
+ return {
587
+ writeStdin(app, bytes) {
588
+ try {
589
+ runtime?.stdins.get(app)?.write(Buffer.from(bytes));
590
+ } catch {
591
+ /* stream ended */
592
+ }
593
+ },
594
+ resize(app, cols, rows) {
595
+ const socket = runtime?.sockets.get(app);
596
+ if (!socket) return;
597
+ try {
598
+ const payload = Buffer.from(JSON.stringify({ Width: cols, Height: rows }));
599
+ socket.send(Buffer.concat([Buffer.from([RESIZE_CHANNEL]), payload]));
600
+ } catch {
601
+ /* socket gone */
602
+ }
603
+ },
604
+ done,
605
+ get workspace(): WorkspaceAccess | undefined {
606
+ return runtime?.workspace;
607
+ },
608
+ async reload(app) {
609
+ const rt = runtime;
610
+ const spec_ = apps.find((a) => a.name === app);
611
+ if (!rt || !spec_) return;
612
+ await rt.workspace.touch(spec_.entryRelativePath);
613
+ },
614
+ async setApps(next) {
615
+ // A pod's container list is fixed at creation, so this is suspend+resume
616
+ // with a different set — the same path, reused because it has to be. The
617
+ // alternatives were a pod per app (needing ReadWriteMany storage for the
618
+ // shared workspace), pre-allocated slots, or a supervisor owning child
619
+ // kernels inside one container; all three cost more.
620
+ const files = await runtime?.workspace.snapshot();
621
+ await teardownPod();
622
+ apps = next;
623
+ podName = freshPodName(spec.sessionId);
624
+ await bringUp(false);
625
+ if (files && files.length > 0) {
626
+ await runtime!.workspace.apply({
627
+ write: files.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding })),
628
+ });
629
+ }
630
+ },
631
+ async suspend() {
632
+ // `teardownPod` stops the pod watch before deleting, so the delete is not
633
+ // reported as the session failing.
634
+ await teardownPod();
635
+ },
636
+ async stop() {
637
+ userStopped = true;
638
+ await teardownPod();
639
+ settle({ kind: "stopped" });
640
+ },
641
+ };
642
+
643
+ function tagFor(app: BackendAppSpec, real: "stdout" | "stderr"): "tty" | "stdout" | "stderr" {
644
+ return app.io === "tty" ? "tty" : real;
645
+ }
646
+
647
+ async function upsert(create: () => Promise<unknown>, replace: () => Promise<unknown>) {
648
+ try {
649
+ await create();
650
+ } catch (err) {
651
+ if (!isConflict(err)) throw err;
652
+ await replace();
653
+ }
654
+ }
655
+ }
656
+
657
+ function allPorts(apps: BackendAppSpec[]): PortMapping[] {
658
+ return apps.flatMap((a) => a.ports);
659
+ }
660
+
661
+ /** Every pod a session ever creates gets its own name. A resumed session reuses
662
+ * its ID but must not reuse the name: the pod it replaces may still be
663
+ * terminating, and creating over a terminating name races the API server's own
664
+ * delete. Reaping and session lookup key on the label, never on the name.
665
+ *
666
+ * The counter is what makes two recreates in the same millisecond distinct;
667
+ * the clock is what makes a name from a LATER closure (a resume builds a fresh
668
+ * one) distinct from an earlier closure's. Neither alone is enough. */
669
+ let podSequence = 0;
670
+
671
+ function freshPodName(sessionId: string): string {
672
+ podSequence += 1;
673
+ return `telo-watch-${sessionId}-${Date.now().toString(36)}${podSequence.toString(36)}`;
674
+ }
675
+
676
+ function isConflict(err: unknown): boolean {
677
+ const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
678
+ return (e?.statusCode ?? e?.code ?? e?.response?.statusCode) === 409;
679
+ }
680
+
681
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
682
+ return new Promise((resolve, reject) => {
683
+ const timer = setTimeout(resolve, ms);
684
+ timer.unref?.();
685
+ signal?.addEventListener(
686
+ "abort",
687
+ () => {
688
+ clearTimeout(timer);
689
+ reject(new Error("aborted"));
690
+ },
691
+ { once: true },
692
+ );
693
+ });
694
+ }