@telorun/runner-core 0.5.2
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/LICENSE +17 -0
- package/README.md +38 -0
- package/dist/backend.d.ts +71 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +2 -0
- package/dist/backend.js.map +1 -0
- package/dist/base-image-catalog.d.ts +110 -0
- package/dist/base-image-catalog.d.ts.map +1 -0
- package/dist/base-image-catalog.js +245 -0
- package/dist/base-image-catalog.js.map +1 -0
- package/dist/capabilities-schema.d.ts +33 -0
- package/dist/capabilities-schema.d.ts.map +1 -0
- package/dist/capabilities-schema.js +44 -0
- package/dist/capabilities-schema.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +170 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +24 -0
- package/dist/contract.js.map +1 -0
- package/dist/debug/relay.d.ts +25 -0
- package/dist/debug/relay.d.ts.map +1 -0
- package/dist/debug/relay.js +89 -0
- package/dist/debug/relay.js.map +1 -0
- package/dist/dependency-key.d.ts +38 -0
- package/dist/dependency-key.d.ts.map +1 -0
- package/dist/dependency-key.js +68 -0
- package/dist/dependency-key.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/capabilities.d.ts +12 -0
- package/dist/routes/capabilities.d.ts.map +1 -0
- package/dist/routes/capabilities.js +15 -0
- package/dist/routes/capabilities.js.map +1 -0
- package/dist/routes/health.d.ts +5 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +8 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/io.d.ts +8 -0
- package/dist/routes/io.d.ts.map +1 -0
- package/dist/routes/io.js +239 -0
- package/dist/routes/io.js.map +1 -0
- package/dist/routes/probe.d.ts +7 -0
- package/dist/routes/probe.d.ts.map +1 -0
- package/dist/routes/probe.js +22 -0
- package/dist/routes/probe.js.map +1 -0
- package/dist/routes/sessions.d.ts +22 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +223 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/session/bundle-path.d.ts +12 -0
- package/dist/session/bundle-path.d.ts.map +1 -0
- package/dist/session/bundle-path.js +27 -0
- package/dist/session/bundle-path.js.map +1 -0
- package/dist/session/byte-ring-buffer.d.ts +29 -0
- package/dist/session/byte-ring-buffer.d.ts.map +1 -0
- package/dist/session/byte-ring-buffer.js +54 -0
- package/dist/session/byte-ring-buffer.js.map +1 -0
- package/dist/session/registry.d.ts +62 -0
- package/dist/session/registry.d.ts.map +1 -0
- package/dist/session/registry.js +156 -0
- package/dist/session/registry.js.map +1 -0
- package/dist/session/ring-buffer.d.ts +37 -0
- package/dist/session/ring-buffer.d.ts.map +1 -0
- package/dist/session/ring-buffer.js +64 -0
- package/dist/session/ring-buffer.js.map +1 -0
- package/dist/session/session-id.d.ts +3 -0
- package/dist/session/session-id.d.ts.map +1 -0
- package/dist/session/session-id.js +19 -0
- package/dist/session/session-id.js.map +1 -0
- package/dist/sse/channel.d.ts +11 -0
- package/dist/sse/channel.d.ts.map +1 -0
- package/dist/sse/channel.js +129 -0
- package/dist/sse/channel.js.map +1 -0
- package/package.json +48 -0
- package/src/backend.ts +88 -0
- package/src/base-image-catalog.test.ts +209 -0
- package/src/base-image-catalog.ts +320 -0
- package/src/capabilities-schema.test.ts +54 -0
- package/src/capabilities-schema.ts +71 -0
- package/src/config.ts +122 -0
- package/src/contract.ts +170 -0
- package/src/debug/relay.ts +104 -0
- package/src/dependency-key.test.ts +64 -0
- package/src/dependency-key.ts +105 -0
- package/src/index.ts +33 -0
- package/src/routes/capabilities.ts +20 -0
- package/src/routes/health.ts +9 -0
- package/src/routes/io.ts +265 -0
- package/src/routes/probe.ts +35 -0
- package/src/routes/sessions.ts +270 -0
- package/src/server.ts +108 -0
- package/src/session/bundle-path.ts +27 -0
- package/src/session/byte-ring-buffer.ts +62 -0
- package/src/session/registry.test.ts +34 -0
- package/src/session/registry.ts +185 -0
- package/src/session/ring-buffer.test.ts +54 -0
- package/src/session/ring-buffer.ts +75 -0
- package/src/session/session-id.test.ts +17 -0
- package/src/session/session-id.ts +20 -0
- package/src/sse/channel.ts +154 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The backend-neutral `/v1` session contract. Every field here is shared by
|
|
3
|
+
* the docker and kubernetes backends and travels over the HTTP+SSE wire to the
|
|
4
|
+
* editor adapter. Backend-specific concerns (how a container/pod is spawned,
|
|
5
|
+
* how the bundle is delivered) live behind the `RunnerBackend` interface.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DebugFrame } from "@telorun/debug-wire";
|
|
9
|
+
|
|
10
|
+
export type PullPolicy = "missing" | "always" | "never";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What a runner advertises about itself on `GET /v1/capabilities`. The editor
|
|
14
|
+
* fetches this to render a generic runner config form — instead of hardcoding
|
|
15
|
+
* per-backend fields — so the runner is the authority on its own config surface.
|
|
16
|
+
* `config.schema` is a JSON Schema describing the editable `SessionConfig` fields
|
|
17
|
+
* (each property carries its own `default`); server-enforced fields are marked
|
|
18
|
+
* `readOnly` with the enforced value as their `default`. `baseUrl` is never in
|
|
19
|
+
* this schema — the client owns it (you need it to reach the runner).
|
|
20
|
+
*/
|
|
21
|
+
export interface RunnerCapabilities {
|
|
22
|
+
displayName: string;
|
|
23
|
+
description: string;
|
|
24
|
+
config: { schema: JsonSchema };
|
|
25
|
+
features: RunnerFeatures;
|
|
26
|
+
/** A usage agreement the operator requires before a session may start.
|
|
27
|
+
* Omitted (or undefined) when this runner has no terms — e.g. a local
|
|
28
|
+
* development runner. The runner ENFORCES it: `POST /v1/sessions` is rejected
|
|
29
|
+
* with `428 terms_required` unless the client sends `x-telo-accepted-terms`
|
|
30
|
+
* matching `terms.version`. The editor surfaces it and records acceptance. */
|
|
31
|
+
terms?: RunnerTerms;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An operator-defined agreement. `version` is opaque and operator-controlled;
|
|
35
|
+
* bumping it re-prompts every client. `body` is plain text / markdown. */
|
|
36
|
+
export interface RunnerTerms {
|
|
37
|
+
version: string;
|
|
38
|
+
title: string;
|
|
39
|
+
body: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** HTTP header carrying the accepted terms version on `POST /v1/sessions`. */
|
|
43
|
+
export const ACCEPTED_TERMS_HEADER = "x-telo-accepted-terms";
|
|
44
|
+
|
|
45
|
+
export interface RunnerFeatures {
|
|
46
|
+
/** Runner exposes a live PTY byte channel (`/v1/sessions/:id/io`). */
|
|
47
|
+
io: boolean;
|
|
48
|
+
/** Runner can publish workload ports back to the client. */
|
|
49
|
+
ports: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A JSON Schema document, kept structurally open so runner-core need not depend
|
|
53
|
+
* on a JSON-Schema type package. The editor treats it as `JSONSchema7`. */
|
|
54
|
+
export type JsonSchema = Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
export interface ProbeConfig {
|
|
57
|
+
image: string;
|
|
58
|
+
pullPolicy: PullPolicy;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SessionConfig {
|
|
62
|
+
image: string;
|
|
63
|
+
pullPolicy: PullPolicy;
|
|
64
|
+
registryUrl?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConfigIssue {
|
|
68
|
+
path: string;
|
|
69
|
+
message: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type AvailabilityReport =
|
|
73
|
+
| { status: "ready" }
|
|
74
|
+
| { status: "needs-setup"; issues: ConfigIssue[] }
|
|
75
|
+
| { status: "unavailable"; message: string; remediation?: string };
|
|
76
|
+
|
|
77
|
+
export interface RunBundle {
|
|
78
|
+
entryRelativePath: string;
|
|
79
|
+
files: Array<{ relativePath: string; contents: string }>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PortProtocol = "tcp" | "udp";
|
|
83
|
+
|
|
84
|
+
export interface PortMapping {
|
|
85
|
+
port: number;
|
|
86
|
+
protocol: PortProtocol;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Announced on `RunStatus.running`. `host`/`port` describe a directly-dialable
|
|
91
|
+
* endpoint (docker host-port publish); `url` carries a fully-qualified address
|
|
92
|
+
* for backends that front the workload with a proxy/ingress (k8s per-session
|
|
93
|
+
* ingress), where a bare host:port is not reachable. The client adapter fills an
|
|
94
|
+
* empty `host` from its own baseUrl — the runner does not know the hostname the
|
|
95
|
+
* client used to reach it.
|
|
96
|
+
*/
|
|
97
|
+
export interface RunnerEndpoint {
|
|
98
|
+
host: string;
|
|
99
|
+
port: number;
|
|
100
|
+
protocol: PortProtocol;
|
|
101
|
+
/** Fully-qualified URL when the endpoint is fronted by a proxy/ingress. */
|
|
102
|
+
url?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface StartSessionRequest {
|
|
106
|
+
bundle: RunBundle;
|
|
107
|
+
env: Record<string, string>;
|
|
108
|
+
ports?: PortMapping[];
|
|
109
|
+
config: SessionConfig;
|
|
110
|
+
/** Request the kernel debug stream. When true the runner launches the
|
|
111
|
+
* workload with `--inspect`, subscribes to the in-workload inspect endpoint
|
|
112
|
+
* (reachable only by the runner — never published outward), and relays each
|
|
113
|
+
* frame to the client as a `debug` {@link RunEvent}. */
|
|
114
|
+
inspect?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type RunStatus =
|
|
118
|
+
| { kind: "starting" }
|
|
119
|
+
/** `inspectUrl` is the kernel debug/inspection UI fronted by a proxy (set only
|
|
120
|
+
* when the session ran with `inspect` and the runner has a public base URL);
|
|
121
|
+
* absent when the inspect endpoint isn't externally reachable. */
|
|
122
|
+
| { kind: "running"; endpoints?: RunnerEndpoint[]; inspectUrl?: string }
|
|
123
|
+
| { kind: "exited"; code: number }
|
|
124
|
+
| { kind: "failed"; message: string }
|
|
125
|
+
| { kind: "stopped" };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Coarse phase a session passes through while coming up, carried on `progress`
|
|
129
|
+
* events. Additive to the status enum: `RunStatus` stays `starting` until the
|
|
130
|
+
* workload is actually up; these messages drive the editor's spinner + step feed.
|
|
131
|
+
*/
|
|
132
|
+
export type RunPhase = "build" | "provision" | "boot";
|
|
133
|
+
|
|
134
|
+
export type RunEvent =
|
|
135
|
+
| { type: "stdout"; chunk: string }
|
|
136
|
+
| { type: "stderr"; chunk: string }
|
|
137
|
+
| { type: "status"; status: RunStatus }
|
|
138
|
+
| { type: "progress"; phase: RunPhase; message: string; done?: boolean }
|
|
139
|
+
/** A frame relayed from the workload's kernel debug stream (kernel event or
|
|
140
|
+
* log line). Only emitted when the session was started with `inspect`. */
|
|
141
|
+
| { type: "debug"; frame: DebugFrame };
|
|
142
|
+
|
|
143
|
+
export function isTerminal(status: RunStatus): boolean {
|
|
144
|
+
return status.kind === "exited" || status.kind === "failed" || status.kind === "stopped";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stages a session start can fail at. The docker/k8s backends share the
|
|
149
|
+
* vocabulary; not every stage applies to every backend (`pull`/`inspect` are
|
|
150
|
+
* image-availability stages, `create`/`attach`/`start` are workload stages).
|
|
151
|
+
*/
|
|
152
|
+
export type StartFailureStage =
|
|
153
|
+
| "pull"
|
|
154
|
+
| "inspect"
|
|
155
|
+
| "daemon"
|
|
156
|
+
| "create"
|
|
157
|
+
| "attach"
|
|
158
|
+
| "start";
|
|
159
|
+
|
|
160
|
+
export class SessionStartError extends Error {
|
|
161
|
+
constructor(
|
|
162
|
+
public readonly kind: "pull_failed" | "start_failed",
|
|
163
|
+
public readonly stage: StartFailureStage,
|
|
164
|
+
message: string,
|
|
165
|
+
public readonly daemonMessage?: string,
|
|
166
|
+
) {
|
|
167
|
+
super(message);
|
|
168
|
+
this.name = "SessionStartError";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { DebugFrame } from "@telorun/debug-wire";
|
|
2
|
+
|
|
3
|
+
export interface DebugRelayOptions {
|
|
4
|
+
/** The workload's inspect SSE endpoint, e.g. `http://telo-run-<id>:9230/events`.
|
|
5
|
+
* Reachable only by the runner over the backend's private network. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Delivered each parsed frame off the stream. */
|
|
8
|
+
onFrame: (frame: DebugFrame) => void;
|
|
9
|
+
/** Aborts the relay (session ended). */
|
|
10
|
+
signal: AbortSignal;
|
|
11
|
+
/** Optional connect-retry budget. The workload's inspect server isn't up the
|
|
12
|
+
* instant the container starts; we retry until it answers or `signal` aborts.
|
|
13
|
+
* Default: retry indefinitely (until abort). */
|
|
14
|
+
onError?: (err: Error) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RECONNECT_DELAY_MS = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe to a workload's inspect SSE endpoint and deliver each frame to
|
|
21
|
+
* `onFrame`. Retries the connection until the endpoint answers or `signal`
|
|
22
|
+
* aborts, and reconnects if the stream drops while the session is still live.
|
|
23
|
+
* The runner relays these frames onward; the workload's inspect port is never
|
|
24
|
+
* exposed outside the runner.
|
|
25
|
+
*
|
|
26
|
+
* Backend-neutral (uses global `fetch`); the docker and k8s backends share it.
|
|
27
|
+
*/
|
|
28
|
+
export async function relayDebugStream(opts: DebugRelayOptions): Promise<void> {
|
|
29
|
+
const { url, onFrame, signal } = opts;
|
|
30
|
+
while (!signal.aborted) {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(url, {
|
|
33
|
+
signal,
|
|
34
|
+
headers: { accept: "text/event-stream" },
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok || !res.body) {
|
|
37
|
+
await delay(RECONNECT_DELAY_MS, signal);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
await pump(res.body, onFrame, signal);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (signal.aborted) return;
|
|
43
|
+
opts.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
44
|
+
}
|
|
45
|
+
await delay(RECONNECT_DELAY_MS, signal);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function pump(
|
|
50
|
+
body: ReadableStream<Uint8Array>,
|
|
51
|
+
onFrame: (frame: DebugFrame) => void,
|
|
52
|
+
signal: AbortSignal,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const reader = body.getReader();
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
let buf = "";
|
|
57
|
+
try {
|
|
58
|
+
while (!signal.aborted) {
|
|
59
|
+
const { value, done } = await reader.read();
|
|
60
|
+
if (done) return;
|
|
61
|
+
buf += decoder.decode(value, { stream: true });
|
|
62
|
+
let sep: number;
|
|
63
|
+
// SSE frames are separated by a blank line.
|
|
64
|
+
while ((sep = buf.indexOf("\n\n")) >= 0) {
|
|
65
|
+
const frame = parseSseData(buf.slice(0, sep));
|
|
66
|
+
buf = buf.slice(sep + 2);
|
|
67
|
+
if (frame) onFrame(frame);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
reader.cancel().catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract and parse the `data:` payload of one SSE frame. Ignores comment
|
|
76
|
+
* lines (`: heartbeat`) and unparseable frames. */
|
|
77
|
+
function parseSseData(block: string): DebugFrame | null {
|
|
78
|
+
const data = block
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((line) => line.startsWith("data:"))
|
|
81
|
+
.map((line) => line.slice(5).trimStart())
|
|
82
|
+
.join("\n");
|
|
83
|
+
if (!data) return null;
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(data) as DebugFrame;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
if (signal.aborted) return resolve();
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
signal.removeEventListener("abort", onAbort);
|
|
96
|
+
resolve();
|
|
97
|
+
}, ms);
|
|
98
|
+
const onAbort = (): void => {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
resolve();
|
|
101
|
+
};
|
|
102
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { RunBundle } from "./contract.js";
|
|
4
|
+
import { extractDependencyKey } from "./dependency-key.js";
|
|
5
|
+
|
|
6
|
+
function bundle(manifest: string, extra: RunBundle["files"] = []): RunBundle {
|
|
7
|
+
return {
|
|
8
|
+
entryRelativePath: "manifest.yaml",
|
|
9
|
+
files: [{ relativePath: "manifest.yaml", contents: manifest }, ...extra],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const APP = [
|
|
14
|
+
"kind: Telo.Application",
|
|
15
|
+
"metadata:",
|
|
16
|
+
" name: app",
|
|
17
|
+
"imports:",
|
|
18
|
+
" Console: std/console@0.9.0",
|
|
19
|
+
" Http: std/http-server@1.2.0",
|
|
20
|
+
"---",
|
|
21
|
+
"kind: Telo.Definition",
|
|
22
|
+
"metadata:",
|
|
23
|
+
" name: Thing",
|
|
24
|
+
"controllers:",
|
|
25
|
+
" - pkg:npm/@acme/thing@1.0.0",
|
|
26
|
+
"",
|
|
27
|
+
].join("\n");
|
|
28
|
+
|
|
29
|
+
describe("extractDependencyKey", () => {
|
|
30
|
+
it("collects imports and body-declared controllers, sorted", () => {
|
|
31
|
+
const key = extractDependencyKey(bundle(APP));
|
|
32
|
+
expect(key.importSources).toEqual(["std/console@0.9.0", "std/http-server@1.2.0"]);
|
|
33
|
+
expect(key.controllerLocators).toEqual(["pkg:npm/@acme/thing@1.0.0"]);
|
|
34
|
+
expect(key.fullContentFallback).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("reads the object form of an import entry", () => {
|
|
38
|
+
const m = APP.replace(
|
|
39
|
+
" Console: std/console@0.9.0",
|
|
40
|
+
" Console:\n source: std/console@0.9.0\n variables: { level: info }",
|
|
41
|
+
);
|
|
42
|
+
expect(extractDependencyKey(bundle(m)).importSources).toContain("std/console@0.9.0");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("flags local_path controllers for full-content fallback", () => {
|
|
46
|
+
const m = APP.replace(
|
|
47
|
+
" - pkg:npm/@acme/thing@1.0.0",
|
|
48
|
+
" - pkg:npm/@acme/thing@1.0.0\nlocal_path: ./thing",
|
|
49
|
+
);
|
|
50
|
+
expect(extractDependencyKey(bundle(m)).fullContentFallback).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("flags an unparseable file for full-content fallback", () => {
|
|
54
|
+
const key = extractDependencyKey(bundle(APP, [{ relativePath: "broken.yaml", contents: "a: b: c: :\n - [" }]));
|
|
55
|
+
expect(key.fullContentFallback).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns empty sets for a manifest with no imports or controllers", () => {
|
|
59
|
+
const key = extractDependencyKey(bundle("kind: Telo.Application\nmetadata:\n name: bare\n"));
|
|
60
|
+
expect(key.importSources).toEqual([]);
|
|
61
|
+
expect(key.controllerLocators).toEqual([]);
|
|
62
|
+
expect(key.fullContentFallback).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parseAllDocuments } from "yaml";
|
|
2
|
+
|
|
3
|
+
import type { RunBundle } from "./contract.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The inputs that determine a session's baked dependency layer — everything
|
|
7
|
+
* `telo install` would resolve into `.telo/{manifests,npm}`, and nothing about
|
|
8
|
+
* resource bodies or CEL. A backend that prebuilds a per-app image keys the
|
|
9
|
+
* image tag on this so a body-only edit reuses the image while an import or
|
|
10
|
+
* controller change rebuilds it.
|
|
11
|
+
*
|
|
12
|
+
* Derived by a SHALLOW static parse of the in-memory bundle — no graph load, no
|
|
13
|
+
* network, no archive. Exact import versions pin the transitive closure, so the
|
|
14
|
+
* authored `imports:` + body-declared controllers are sufficient.
|
|
15
|
+
*
|
|
16
|
+
* Assumes every file in the bundle is reachable from the entry (true for
|
|
17
|
+
* editor / control-plane-produced bundles, which carry exactly the app's
|
|
18
|
+
* files). It scans ALL `Telo.Definition` docs rather than following the entry's
|
|
19
|
+
* include/import graph — cheaper, and erring toward over-inclusion only ever
|
|
20
|
+
* busts the cache more eagerly. The one unsound case is a bundle carrying an
|
|
21
|
+
* orphan controller-bearing Definition that `telo install` would NOT bake (not
|
|
22
|
+
* reachable from this entry) yet another entry with the same closure WOULD: they
|
|
23
|
+
* could share a tag whose image lacks that controller. Not reachable via the
|
|
24
|
+
* editor; revisit (anchor to the include graph) if bundles ever carry unreachable
|
|
25
|
+
* Definition files.
|
|
26
|
+
*/
|
|
27
|
+
export interface DependencyKey {
|
|
28
|
+
/** Sorted `imports:` source strings from every module doc. */
|
|
29
|
+
importSources: string[];
|
|
30
|
+
/** Sorted controller PURLs declared by inline `Telo.Definition` docs. */
|
|
31
|
+
controllerLocators: string[];
|
|
32
|
+
/**
|
|
33
|
+
* True when a `Telo.Definition` resolves its controller from bundle-local
|
|
34
|
+
* source (`local_path`), or a file failed to parse. In either case the dep
|
|
35
|
+
* key alone can't capture a controller change, so the caller MUST also fold
|
|
36
|
+
* the full bundle contents into the tag (safe fallback to content-keying).
|
|
37
|
+
*/
|
|
38
|
+
fullContentFallback: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function asModuleKind(kind: unknown): boolean {
|
|
42
|
+
return kind === "Telo.Application" || kind === "Telo.Library";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pull `{ source }` out of an `imports:` map value (bare string or object). */
|
|
46
|
+
function importSourceOf(value: unknown): string | undefined {
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
if (value && typeof value === "object") {
|
|
49
|
+
const source = (value as { source?: unknown }).source;
|
|
50
|
+
if (typeof source === "string") return source;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function extractDependencyKey(bundle: RunBundle): DependencyKey {
|
|
56
|
+
const importSources = new Set<string>();
|
|
57
|
+
const controllerLocators = new Set<string>();
|
|
58
|
+
let fullContentFallback = false;
|
|
59
|
+
|
|
60
|
+
for (const file of bundle.files) {
|
|
61
|
+
let docs;
|
|
62
|
+
try {
|
|
63
|
+
docs = parseAllDocuments(file.contents);
|
|
64
|
+
} catch {
|
|
65
|
+
// Unparseable file — we can't know what it declares, so fall back to
|
|
66
|
+
// hashing the whole bundle rather than risk an under-specified key.
|
|
67
|
+
fullContentFallback = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const doc of docs) {
|
|
71
|
+
if (doc.errors.length > 0) {
|
|
72
|
+
fullContentFallback = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const value = doc.toJS() as Record<string, unknown> | null;
|
|
76
|
+
if (!value || typeof value !== "object") continue;
|
|
77
|
+
const kind = value.kind;
|
|
78
|
+
|
|
79
|
+
if (asModuleKind(kind) && value.imports && typeof value.imports === "object") {
|
|
80
|
+
for (const entry of Object.values(value.imports as Record<string, unknown>)) {
|
|
81
|
+
const source = importSourceOf(entry);
|
|
82
|
+
if (source) importSources.add(source);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (kind === "Telo.Definition") {
|
|
87
|
+
const controllers = value.controllers;
|
|
88
|
+
if (Array.isArray(controllers)) {
|
|
89
|
+
for (const c of controllers) {
|
|
90
|
+
if (typeof c === "string") controllerLocators.add(c);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A bundle-local controller's source lives in the body; the PURL/path
|
|
94
|
+
// can't capture an edit to it, so force full-content keying.
|
|
95
|
+
if (typeof value.local_path === "string") fullContentFallback = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
importSources: [...importSources].sort(),
|
|
102
|
+
controllerLocators: [...controllerLocators].sort(),
|
|
103
|
+
fullContentFallback,
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export * from "./contract.js";
|
|
2
|
+
export * from "./backend.js";
|
|
3
|
+
export * from "./config.js";
|
|
4
|
+
export * from "./server.js";
|
|
5
|
+
export { sessionConfigSchema, type SessionConfigSchemaOptions } from "./capabilities-schema.js";
|
|
6
|
+
export {
|
|
7
|
+
BaseImageCatalog,
|
|
8
|
+
filterTags,
|
|
9
|
+
parseDockerHubRef,
|
|
10
|
+
resolveTagDigest,
|
|
11
|
+
type TagFilter,
|
|
12
|
+
type BaseImageCatalogOptions,
|
|
13
|
+
} from "./base-image-catalog.js";
|
|
14
|
+
export { extractDependencyKey, type DependencyKey } from "./dependency-key.js";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
SessionRegistry,
|
|
18
|
+
SessionLimitError,
|
|
19
|
+
SessionEvictedError,
|
|
20
|
+
type SessionEntry,
|
|
21
|
+
type RegistryDeps,
|
|
22
|
+
} from "./session/registry.js";
|
|
23
|
+
export { EventRingBuffer, type BufferedEvent } from "./session/ring-buffer.js";
|
|
24
|
+
export { ByteRingBuffer, type BufferedBytes } from "./session/byte-ring-buffer.js";
|
|
25
|
+
export { normalizeBundlePath, validateSessionId, BundlePathError } from "./session/bundle-path.js";
|
|
26
|
+
export { streamSessionEvents, type SseStreamArgs } from "./sse/channel.js";
|
|
27
|
+
export { healthRoute } from "./routes/health.js";
|
|
28
|
+
export { capabilitiesRoute } from "./routes/capabilities.js";
|
|
29
|
+
export { probeRoute, type ProbeRouteDeps } from "./routes/probe.js";
|
|
30
|
+
export { sessionsRoute, type SessionsRouteDeps } from "./routes/sessions.js";
|
|
31
|
+
export { ioRoute, type IoRouteDeps } from "./routes/io.js";
|
|
32
|
+
export { relayDebugStream, type DebugRelayOptions } from "./debug/relay.js";
|
|
33
|
+
export type { DebugFrame, DebugEvent, DebugLog } from "@telorun/debug-wire";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
import type { RunnerCapabilities } from "../contract.js";
|
|
4
|
+
|
|
5
|
+
/** Advertises the runner's self-description + editable config schema, so the
|
|
6
|
+
* editor can render a generic runner config form without hardcoding per-backend
|
|
7
|
+
* fields. The document is backend-authored — each concrete runner supplies its
|
|
8
|
+
* own `RunnerCapabilities`; core only serves it.
|
|
9
|
+
*
|
|
10
|
+
* Accepts a getter as well as a static document so a runner whose config
|
|
11
|
+
* surface changes at runtime (e.g. a base-image catalog refreshed from a
|
|
12
|
+
* registry) is re-resolved on each request rather than frozen at boot. */
|
|
13
|
+
export function capabilitiesRoute(
|
|
14
|
+
capabilities: RunnerCapabilities | (() => RunnerCapabilities),
|
|
15
|
+
): FastifyPluginAsync {
|
|
16
|
+
const resolve = typeof capabilities === "function" ? capabilities : () => capabilities;
|
|
17
|
+
return async (app: FastifyInstance) => {
|
|
18
|
+
app.get("/v1/capabilities", async () => resolve());
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
/** Liveness. Returns the runner's own version, supplied by the concrete runner
|
|
4
|
+
* (each runner package has its own version). */
|
|
5
|
+
export function healthRoute(version: string): FastifyPluginAsync {
|
|
6
|
+
return async (app: FastifyInstance) => {
|
|
7
|
+
app.get("/v1/health", async () => ({ ok: true, version }));
|
|
8
|
+
};
|
|
9
|
+
}
|