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