@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
@@ -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
+ });
@@ -0,0 +1,122 @@
1
+ import { isEventFrame, type DebugFrame } from "@telorun/debug-wire";
2
+
3
+ import type { RunTrigger } from "../contract.js";
4
+ import type { SessionRegistry } from "../session/registry.js";
5
+
6
+ /**
7
+ * Projects per-app `run` events out of the kernel lifecycle events the debug
8
+ * stream already carries.
9
+ *
10
+ * The runner cannot see inside the container, and parsing the merged PTY stream
11
+ * is not a contract — so a watch session always runs with the kernel debug
12
+ * stream on and this reads it. A watch reload is a stop/start pair on ONE debug
13
+ * connection, so `generation` is counted here and the kernel is asked for
14
+ * nothing.
15
+ *
16
+ * Three event names are read, and only three:
17
+ * - `Kernel.Starting` → the generation began
18
+ * - `Kernel.Stopped` → it ended, carrying `exitCode`
19
+ * - `Kernel.RunFailed` → it never reached a running state, carrying why
20
+ *
21
+ * `Kernel.Started` is deliberately ignored: it and `Kernel.Starting` bracket the
22
+ * same transition, and one `started` per generation is the contract.
23
+ */
24
+ export class RunProjection {
25
+ /** What to attribute the NEXT generation of each app to. A reload is a watch
26
+ * reload unless something told us otherwise, so `watch` is the resting value
27
+ * and `initial` / `manual` / `resume` are set by whoever caused them. */
28
+ private readonly triggers = new Map<string, RunTrigger>();
29
+
30
+ constructor(
31
+ private readonly registry: SessionRegistry,
32
+ private readonly sessionId: string,
33
+ ) {}
34
+
35
+ /** Attribute an app's next generation. Called with `initial` when the session
36
+ * comes up, `manual` from the reload route, `resume` after a pod recreate. */
37
+ expect(app: string, trigger: RunTrigger): void {
38
+ this.triggers.set(app, trigger);
39
+ }
40
+
41
+ /** Attribute every app's next generation — the session-wide cases (initial,
42
+ * resume) always move all of them together. */
43
+ expectAll(trigger: RunTrigger): void {
44
+ const entry = this.registry.get(this.sessionId);
45
+ for (const app of entry?.apps.keys() ?? []) this.triggers.set(app, trigger);
46
+ }
47
+
48
+ /** Feed one frame from `app`'s debug stream. Frames that are not one of the
49
+ * three lifecycle events pass through untouched. */
50
+ frame(app: string, frame: DebugFrame): void {
51
+ if (!isEventFrame(frame)) return;
52
+ switch (frame.event) {
53
+ case "Kernel.Starting":
54
+ this.begin(app);
55
+ return;
56
+ case "Kernel.Stopped":
57
+ this.registry.finishGeneration(this.sessionId, app, {
58
+ phase: "completed",
59
+ code: exitCodeOf(frame.payload),
60
+ });
61
+ return;
62
+ case "Kernel.RunFailed":
63
+ // A LOAD failure emits no `Kernel.Starting` — the kernel never got that
64
+ // far — so open the generation here rather than reporting a failure of a
65
+ // generation the client never saw start. The pair is what a consumer
66
+ // reads as "this attempt happened and did not survive".
67
+ this.begin(app);
68
+ this.registry.finishGeneration(this.sessionId, app, {
69
+ phase: "failed",
70
+ reason: failureReason(frame.payload),
71
+ });
72
+ return;
73
+ default:
74
+ return;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Close the open generation because its WORKLOAD ended, not because the kernel
80
+ * said so. A container that goes away under `--watch` emits no `Kernel.Stopped`
81
+ * — there is nothing left to emit it — so the backend reports the ending and
82
+ * this turns it into the same run outcome the kernel path produces.
83
+ */
84
+ endGeneration(app: string, outcome: { code?: number; reason?: string }): void {
85
+ const channel = this.registry.get(this.sessionId)?.apps.get(app);
86
+ if (!channel || channel.startedAt === null) return;
87
+ this.registry.finishGeneration(
88
+ this.sessionId,
89
+ app,
90
+ outcome.reason === undefined && outcome.code !== undefined
91
+ ? { phase: "completed", code: outcome.code }
92
+ : { phase: "failed", reason: outcome.reason ?? "workload ended" },
93
+ );
94
+ }
95
+
96
+ /** Open a generation unless one is already open — `Kernel.Starting` followed
97
+ * by a `Kernel.RunFailed` from the boot phase must not count twice. */
98
+ private begin(app: string): void {
99
+ const channel = this.registry.get(this.sessionId)?.apps.get(app);
100
+ if (!channel || channel.startedAt !== null) return;
101
+ const trigger = this.triggers.get(app) ?? "watch";
102
+ // Consumed: the next generation is a plain watch reload unless something
103
+ // says otherwise again.
104
+ this.triggers.delete(app);
105
+ this.registry.startGeneration(this.sessionId, app, trigger);
106
+ }
107
+ }
108
+
109
+ function exitCodeOf(payload: unknown): number {
110
+ const code = (payload as { exitCode?: unknown } | undefined)?.exitCode;
111
+ return typeof code === "number" ? code : 0;
112
+ }
113
+
114
+ /** The most actionable thing the failure carries: a diagnostic code when the
115
+ * kernel had one (`CEL_UNKNOWN_FIELD`, `ERR_MANIFEST_VALIDATION_FAILED`), else
116
+ * its message. A code is what a reader — an agent above all — branches on. */
117
+ function failureReason(payload: unknown): string {
118
+ const p = payload as { code?: unknown; message?: unknown } | undefined;
119
+ if (typeof p?.code === "string" && p.code !== "") return p.code;
120
+ if (typeof p?.message === "string" && p.message !== "") return p.message;
121
+ return "run failed";
122
+ }
package/src/index.ts CHANGED
@@ -17,17 +17,36 @@ export {
17
17
  SessionRegistry,
18
18
  SessionLimitError,
19
19
  SessionEvictedError,
20
+ UnknownAppError,
21
+ type AppChannel,
22
+ type RunAttribution,
20
23
  type SessionEntry,
24
+ type RegisterArgs,
21
25
  type RegistryDeps,
26
+ type WorkspaceCheckpoint,
22
27
  } from "./session/registry.js";
28
+ export { WatchSupervisor, type WatchSupervisorDeps } from "./session/watch-supervisor.js";
29
+ export { RunProjection } from "./debug/run-projection.js";
30
+ export {
31
+ portKey,
32
+ portsResolvedFrom,
33
+ PORTS_RESOLVED_EVENT,
34
+ } from "./debug/ports-resolved.js";
23
35
  export { EventRingBuffer, type BufferedEvent } from "./session/ring-buffer.js";
24
36
  export { ByteRingBuffer, type BufferedBytes } from "./session/byte-ring-buffer.js";
25
37
  export { normalizeBundlePath, validateSessionId, BundlePathError } from "./session/bundle-path.js";
38
+ export { WorkspaceClient } from "./session/workspace-client.js";
39
+ export { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "./session/workspace-app.js";
40
+ export {
41
+ workspaceMarkerWrite,
42
+ WORKSPACE_MARKER_CONTENTS,
43
+ WORKSPACE_MARKER_FILENAME,
44
+ } from "./session/workspace-marker.js";
26
45
  export { streamSessionEvents, type SseStreamArgs } from "./sse/channel.js";
27
46
  export { healthRoute } from "./routes/health.js";
28
47
  export { capabilitiesRoute } from "./routes/capabilities.js";
29
48
  export { probeRoute, type ProbeRouteDeps } from "./routes/probe.js";
30
- export { sessionsRoute, type SessionsRouteDeps } from "./routes/sessions.js";
49
+ export { sessionsRoute, type SessionsRouteDeps, type WatchConfig } from "./routes/sessions.js";
31
50
  export { appsRoute, type AppsRouteDeps } from "./routes/apps.js";
32
51
  export { ioRoute, type IoRouteDeps } from "./routes/io.js";
33
52
  export { relayDebugStream, type DebugRelayOptions } from "./debug/relay.js";
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyPluginAsync } from "fastify";
2
2
 
3
3
  import type { RunnerBackend } from "../backend.js";
4
4
  import type { ResolvedRunnerApp } from "../config.js";
5
- import type { PortMapping, RunnerTerms } from "../contract.js";
5
+ import { DEFAULT_APP_NAME, type PortMapping, type RunnerTerms } from "../contract.js";
6
6
  import type { SessionRegistry } from "../session/registry.js";
7
7
  import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
8
8
 
@@ -88,15 +88,24 @@ export function appsRoute(deps: AppsRouteDeps): FastifyPluginAsync {
88
88
  app,
89
89
  deps,
90
90
  {
91
- // Self-contained image — no bundle to deliver; the entry path is an
92
- // unused placeholder so the backend spec stays total.
91
+ // Self-contained image — no bundle to deliver, and no entry path:
92
+ // the image's own entrypoint runs.
93
93
  bundle: { entryRelativePath: "telo.yaml", files: [] },
94
- entryRelativePath: "telo.yaml",
95
94
  env,
96
- ports: req.body?.ports ?? [],
97
95
  config: { image: appEntry.image, pullPolicy: appEntry.pullPolicy },
98
96
  selfContained: true,
99
97
  inspect: req.body?.inspect ?? false,
98
+ // An operator-predefined app session is one run of one application.
99
+ // Watch is a workspace shape, and this door carries no workspace.
100
+ mode: "run",
101
+ apps: [
102
+ {
103
+ name: DEFAULT_APP_NAME,
104
+ entryRelativePath: "",
105
+ ports: req.body?.ports ?? [],
106
+ io: "tty",
107
+ },
108
+ ],
100
109
  },
101
110
  reply,
102
111
  );