@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.
- package/README.md +33 -2
- package/dist/backend.d.ts +107 -23
- package/dist/backend.d.ts.map +1 -1
- package/dist/config.d.ts +56 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +36 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +210 -15
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +9 -0
- package/dist/contract.js.map +1 -1
- package/dist/debug/ports-resolved.d.ts +26 -0
- package/dist/debug/ports-resolved.d.ts.map +1 -0
- package/dist/debug/ports-resolved.js +42 -0
- package/dist/debug/ports-resolved.js.map +1 -0
- package/dist/debug/relay.d.ts.map +1 -1
- package/dist/debug/relay.js +34 -6
- package/dist/debug/relay.js.map +1 -1
- package/dist/debug/run-projection.d.ts +53 -0
- package/dist/debug/run-projection.d.ts.map +1 -0
- package/dist/debug/run-projection.js +115 -0
- package/dist/debug/run-projection.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/routes/apps.d.ts +1 -1
- package/dist/routes/apps.d.ts.map +1 -1
- package/dist/routes/apps.js +14 -4
- package/dist/routes/apps.js.map +1 -1
- package/dist/routes/io.d.ts.map +1 -1
- package/dist/routes/io.js +42 -9
- package/dist/routes/io.js.map +1 -1
- package/dist/routes/session-start.d.ts +18 -13
- package/dist/routes/session-start.d.ts.map +1 -1
- package/dist/routes/session-start.js +95 -14
- package/dist/routes/session-start.js.map +1 -1
- package/dist/routes/sessions.d.ts +11 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +465 -5
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +21 -1
- package/dist/server.js.map +1 -1
- package/dist/session/byte-ring-buffer.d.ts +6 -1
- package/dist/session/byte-ring-buffer.d.ts.map +1 -1
- package/dist/session/byte-ring-buffer.js +2 -2
- package/dist/session/byte-ring-buffer.js.map +1 -1
- package/dist/session/registry.d.ts +144 -13
- package/dist/session/registry.d.ts.map +1 -1
- package/dist/session/registry.js +166 -25
- package/dist/session/registry.js.map +1 -1
- package/dist/session/watch-supervisor.d.ts +42 -0
- package/dist/session/watch-supervisor.d.ts.map +1 -0
- package/dist/session/watch-supervisor.js +99 -0
- package/dist/session/watch-supervisor.js.map +1 -0
- package/dist/session/workspace-app.d.ts +4 -0
- package/dist/session/workspace-app.d.ts.map +1 -0
- package/dist/session/workspace-app.js +26 -0
- package/dist/session/workspace-app.js.map +1 -0
- package/dist/session/workspace-client.d.ts +33 -0
- package/dist/session/workspace-client.d.ts.map +1 -0
- package/dist/session/workspace-client.js +62 -0
- package/dist/session/workspace-client.js.map +1 -0
- package/dist/session/workspace-marker.d.ts +25 -0
- package/dist/session/workspace-marker.d.ts.map +1 -0
- package/dist/session/workspace-marker.js +35 -0
- package/dist/session/workspace-marker.js.map +1 -0
- package/dist/sse/channel.d.ts.map +1 -1
- package/dist/sse/channel.js +4 -0
- package/dist/sse/channel.js.map +1 -1
- package/package.json +4 -3
- package/src/app-catalog.test.ts +49 -1
- package/src/backend.ts +111 -22
- package/src/config.ts +98 -0
- package/src/contract.ts +213 -16
- package/src/debug/ports-resolved.test.ts +66 -0
- package/src/debug/ports-resolved.ts +43 -0
- package/src/debug/relay.ts +32 -4
- package/src/debug/run-projection.test.ts +155 -0
- package/src/debug/run-projection.ts +122 -0
- package/src/index.ts +20 -1
- package/src/routes/apps.ts +14 -5
- package/src/routes/io.ts +58 -12
- package/src/routes/session-start.ts +115 -27
- package/src/routes/sessions.ts +538 -7
- package/src/server.ts +22 -1
- package/src/session/byte-ring-buffer.ts +8 -2
- package/src/session/registry.ts +288 -28
- package/src/session/ring-buffer.test.ts +4 -3
- package/src/session/watch-lifetime.test.ts +203 -0
- package/src/session/watch-supervisor.ts +112 -0
- package/src/session/workspace-app.ts +27 -0
- package/src/session/workspace-client.ts +80 -0
- package/src/session/workspace-marker.test.ts +35 -0
- package/src/session/workspace-marker.ts +39 -0
- package/src/sse/channel.ts +5 -0
- 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
|
+
}
|
package/src/debug/relay.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
78
|
+
const block = buf.slice(0, sep);
|
|
68
79
|
buf = buf.slice(sep + 2);
|
|
69
|
-
|
|
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";
|
package/src/routes/apps.ts
CHANGED
|
@@ -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
|
|
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
|
|
92
|
-
//
|
|
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
|
);
|