@telorun/runner-core 0.5.2 → 0.7.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 (44) hide show
  1. package/dist/abortable-delay.d.ts +10 -0
  2. package/dist/abortable-delay.d.ts.map +1 -0
  3. package/dist/abortable-delay.js +26 -0
  4. package/dist/abortable-delay.js.map +1 -0
  5. package/dist/backend.d.ts +10 -1
  6. package/dist/backend.d.ts.map +1 -1
  7. package/dist/config.d.ts +43 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +77 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/contract.d.ts +34 -2
  12. package/dist/contract.d.ts.map +1 -1
  13. package/dist/contract.js.map +1 -1
  14. package/dist/debug/relay.d.ts.map +1 -1
  15. package/dist/debug/relay.js +3 -17
  16. package/dist/debug/relay.js.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/reachability.d.ts +37 -0
  22. package/dist/reachability.d.ts.map +1 -0
  23. package/dist/reachability.js +71 -0
  24. package/dist/reachability.js.map +1 -0
  25. package/dist/routes/sessions.d.ts +5 -0
  26. package/dist/routes/sessions.d.ts.map +1 -1
  27. package/dist/routes/sessions.js +83 -31
  28. package/dist/routes/sessions.js.map +1 -1
  29. package/dist/server.d.ts +7 -2
  30. package/dist/server.d.ts.map +1 -1
  31. package/dist/server.js +11 -2
  32. package/dist/server.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/abortable-delay.ts +24 -0
  35. package/src/app-catalog.test.ts +54 -0
  36. package/src/backend.ts +10 -0
  37. package/src/config.ts +116 -1
  38. package/src/contract.ts +33 -3
  39. package/src/debug/relay.ts +4 -17
  40. package/src/index.ts +4 -0
  41. package/src/reachability.test.ts +83 -0
  42. package/src/reachability.ts +106 -0
  43. package/src/routes/sessions.ts +93 -30
  44. package/src/server.ts +24 -5
package/src/index.ts CHANGED
@@ -30,4 +30,8 @@ export { probeRoute, type ProbeRouteDeps } from "./routes/probe.js";
30
30
  export { sessionsRoute, type SessionsRouteDeps } from "./routes/sessions.js";
31
31
  export { ioRoute, type IoRouteDeps } from "./routes/io.js";
32
32
  export { relayDebugStream, type DebugRelayOptions } from "./debug/relay.js";
33
+ export {
34
+ watchReachability,
35
+ type WatchReachabilityOptions,
36
+ } from "./reachability.js";
33
37
  export type { DebugFrame, DebugEvent, DebugLog } from "@telorun/debug-wire";
@@ -0,0 +1,83 @@
1
+ import net from "node:net";
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+
5
+ import type { ReachabilityState } from "./contract.js";
6
+ import { watchReachability } from "./reachability.js";
7
+
8
+ const servers: net.Server[] = [];
9
+
10
+ function listen(port = 0): Promise<{ port: number; server: net.Server }> {
11
+ return new Promise((resolve) => {
12
+ const server = net.createServer();
13
+ servers.push(server);
14
+ server.listen(port, "127.0.0.1", () => {
15
+ resolve({ port: (server.address() as net.AddressInfo).port, server });
16
+ });
17
+ });
18
+ }
19
+
20
+ function sleep(ms: number): Promise<void> {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ afterEach(() => {
25
+ for (const s of servers) s.close();
26
+ servers.length = 0;
27
+ });
28
+
29
+ // Short windows so the suite is fast; real defaults are seconds.
30
+ const fast = { timeoutMs: 120, intervalMs: 20, recheckIntervalMs: 20, connectTimeoutMs: 50 };
31
+
32
+ describe("watchReachability", () => {
33
+ it("reports checking then reachable for a live port", async () => {
34
+ const { port } = await listen();
35
+ const states: ReachabilityState[] = [];
36
+ await watchReachability({
37
+ host: "127.0.0.1",
38
+ ports: [port],
39
+ onState: (_p, s) => states.push(s),
40
+ signal: new AbortController().signal,
41
+ ...fast,
42
+ });
43
+ expect(states[0]).toBe("checking");
44
+ expect(states.at(-1)).toBe("reachable");
45
+ });
46
+
47
+ it("reports unreachable after the timeout when nothing listens", async () => {
48
+ const { port, server } = await listen();
49
+ server.close(); // free the port → connections are refused
50
+ const states: ReachabilityState[] = [];
51
+ const controller = new AbortController();
52
+ const run = watchReachability({
53
+ host: "127.0.0.1",
54
+ ports: [port],
55
+ onState: (_p, s) => states.push(s),
56
+ signal: controller.signal,
57
+ ...fast,
58
+ });
59
+ await sleep(300);
60
+ controller.abort();
61
+ await run;
62
+ expect(states[0]).toBe("checking");
63
+ expect(states).toContain("unreachable");
64
+ });
65
+
66
+ it("flips back to reachable when the port recovers", async () => {
67
+ const { port, server } = await listen();
68
+ server.close();
69
+ const states: ReachabilityState[] = [];
70
+ const run = watchReachability({
71
+ host: "127.0.0.1",
72
+ ports: [port],
73
+ onState: (_p, s) => states.push(s),
74
+ signal: new AbortController().signal,
75
+ ...fast,
76
+ });
77
+ await sleep(200); // past the timeout → unreachable
78
+ await listen(port); // bind the same port → recovers
79
+ await run; // resolves once reachable
80
+ expect(states).toContain("unreachable");
81
+ expect(states.at(-1)).toBe("reachable");
82
+ });
83
+ });
@@ -0,0 +1,106 @@
1
+ import net from "node:net";
2
+
3
+ import { abortableDelay } from "./abortable-delay.js";
4
+ import type { ReachabilityState } from "./contract.js";
5
+
6
+ export interface WatchReachabilityOptions {
7
+ /** Address the runner dials — pod IP (k8s) or published host / container name (docker). */
8
+ host: string;
9
+ /** TCP ports the workload declared; each is watched and reported by port. */
10
+ ports: number[];
11
+ /** Receives every state transition for a port: `checking` on start, then
12
+ * `reachable` once it accepts a connection, or `unreachable` after the timeout. */
13
+ onState: (port: number, state: ReachabilityState) => void;
14
+ /** Aborts the watch — wire to the session's teardown. */
15
+ signal: AbortSignal;
16
+ /** How long a port may stay unreachable before it's reported `unreachable`.
17
+ * Default 30s. */
18
+ timeoutMs?: number;
19
+ /** Poll interval while waiting for a port to come up. Default 1s. */
20
+ intervalMs?: number;
21
+ /** Poll interval after a port was reported `unreachable`, to flip it back to
22
+ * `reachable` if it recovers. Default 5s. */
23
+ recheckIntervalMs?: number;
24
+ /** Per-attempt TCP connect timeout. Default 1s. */
25
+ connectTimeoutMs?: number;
26
+ }
27
+
28
+ const DEFAULT_TIMEOUT_MS = 30_000;
29
+ const DEFAULT_INTERVAL_MS = 1_000;
30
+ const DEFAULT_RECHECK_INTERVAL_MS = 5_000;
31
+ const DEFAULT_CONNECT_TIMEOUT_MS = 1_000;
32
+
33
+ interface WatchConfig {
34
+ host: string;
35
+ onState: (port: number, state: ReachabilityState) => void;
36
+ signal: AbortSignal;
37
+ timeoutMs: number;
38
+ intervalMs: number;
39
+ recheckIntervalMs: number;
40
+ connectTimeoutMs: number;
41
+ }
42
+
43
+ /** One TCP connect attempt. Resolves true on a completed connection, false on
44
+ * refusal / timeout / error. Never throws; `unref`'d so a pending attempt can't
45
+ * keep the event loop alive, and always torn down. */
46
+ function tryConnect(host: string, port: number, timeoutMs: number): Promise<boolean> {
47
+ return new Promise((resolve) => {
48
+ const socket = net.connect({ host, port });
49
+ socket.unref();
50
+ let settled = false;
51
+ const done = (ok: boolean): void => {
52
+ if (settled) return;
53
+ settled = true;
54
+ socket.destroy();
55
+ resolve(ok);
56
+ };
57
+ socket.setTimeout(timeoutMs);
58
+ socket.once("connect", () => done(true));
59
+ socket.once("timeout", () => done(false));
60
+ socket.once("error", () => done(false));
61
+ });
62
+ }
63
+
64
+ async function watchPort(port: number, cfg: WatchConfig): Promise<void> {
65
+ cfg.onState(port, "checking");
66
+ const start = Date.now();
67
+ let reportedUnreachable = false;
68
+ while (!cfg.signal.aborted) {
69
+ if (await tryConnect(cfg.host, port, cfg.connectTimeoutMs)) {
70
+ cfg.onState(port, "reachable");
71
+ return;
72
+ }
73
+ if (!reportedUnreachable && Date.now() - start >= cfg.timeoutMs) {
74
+ reportedUnreachable = true;
75
+ cfg.onState(port, "unreachable");
76
+ }
77
+ await abortableDelay(reportedUnreachable ? cfg.recheckIntervalMs : cfg.intervalMs, cfg.signal);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Watches each declared TCP port for reachability from the runner network and
83
+ * reports per-port transitions via `onState`: `checking` immediately, then
84
+ * `reachable` the moment a connection succeeds, or `unreachable` after
85
+ * `timeoutMs` of refusal. After `unreachable` it keeps probing (slower) and
86
+ * flips back to `reachable` on recovery — so a slow-but-correct start
87
+ * self-corrects.
88
+ *
89
+ * This catches the loopback-bind / wrong-port / crash-loop failure that
90
+ * otherwise surfaces only as an opaque downstream 502; emitting state (not a log
91
+ * line) lets the editor render it on the endpoint badge. Backend-neutral: the
92
+ * runner supplies how its workload is dialed and where state goes.
93
+ */
94
+ export async function watchReachability(options: WatchReachabilityOptions): Promise<void> {
95
+ if (options.ports.length === 0) return;
96
+ const cfg: WatchConfig = {
97
+ host: options.host,
98
+ onState: options.onState,
99
+ signal: options.signal,
100
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
101
+ intervalMs: options.intervalMs ?? DEFAULT_INTERVAL_MS,
102
+ recheckIntervalMs: options.recheckIntervalMs ?? DEFAULT_RECHECK_INTERVAL_MS,
103
+ connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
104
+ };
105
+ await Promise.all(options.ports.map((port) => watchPort(port, cfg)));
106
+ }
@@ -5,10 +5,12 @@ import type { RunnerBackend } from "../backend.js";
5
5
  import {
6
6
  ACCEPTED_TERMS_HEADER,
7
7
  SessionStartError,
8
+ type RunBundle,
8
9
  type RunnerTerms,
9
10
  type SessionConfig,
10
11
  type StartSessionRequest,
11
12
  } from "../contract.js";
13
+ import type { ResolvedRunnerApp } from "../config.js";
12
14
  import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
13
15
  import { generateSessionId } from "../session/session-id.js";
14
16
  import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
@@ -24,6 +26,10 @@ export interface SessionsRouteDeps {
24
26
  /** When set, a session may only start if the client acknowledges this exact
25
27
  * terms version via the `x-telo-accepted-terms` header. */
26
28
  terms?: RunnerTerms;
29
+ /** Operator-predefined applications launchable by name
30
+ * (`StartSessionRequest.app`), with their operator env already resolved.
31
+ * The catalog is the whole gate — an unknown name is rejected. */
32
+ apps?: Record<string, ResolvedRunnerApp>;
27
33
  /** Backend-supplied config gate. Returns an error message to reject the
28
34
  * request with `400 invalid_config`, or `undefined` to accept. The runner is
29
35
  * the source of truth, so this re-checks what `/v1/capabilities` advertises
@@ -31,10 +37,13 @@ export interface SessionsRouteDeps {
31
37
  validateConfig?: (config: SessionConfig) => string | undefined;
32
38
  }
33
39
 
40
+ // `bundle`/`config` are schema-optional because an `app` session needs neither;
41
+ // the route enforces their presence for regular bundle sessions.
34
42
  const startBodySchema = {
35
43
  type: "object",
36
- required: ["bundle", "env", "config"],
44
+ required: ["env"],
37
45
  properties: {
46
+ app: { type: "string", minLength: 1 },
38
47
  bundle: {
39
48
  type: "object",
40
49
  required: ["entryRelativePath", "files"],
@@ -157,29 +166,68 @@ async function startSession(
157
166
  ): Promise<void> {
158
167
  const sessionId = generateSessionId();
159
168
 
169
+ // App sessions launch an operator-predefined image by name: the catalog
170
+ // resolves the image and operator env server-side, so the client can neither
171
+ // pick the image nor reach the secrets — the catalog IS the gate, and an
172
+ // unknown name is rejected here.
173
+ const appEntry = body.app === undefined ? undefined : deps.apps?.[body.app];
174
+ if (body.app !== undefined && !appEntry) {
175
+ const offered = Object.keys(deps.apps ?? {});
176
+ reply.code(400).send({
177
+ error: "unknown_app",
178
+ message:
179
+ `app '${body.app}' is not offered by this runner` +
180
+ (offered.length > 0
181
+ ? ` — offered apps: ${offered.join(", ")}`
182
+ : " — it offers no predefined applications") +
183
+ " (see /v1/capabilities).",
184
+ });
185
+ return;
186
+ }
187
+ if (!appEntry && (!body.bundle || !body.config)) {
188
+ reply.code(400).send({
189
+ error: "invalid_request",
190
+ message: "'bundle' and 'config' are required unless launching a predefined app via 'app'.",
191
+ });
192
+ return;
193
+ }
194
+
195
+ let bundle: RunBundle;
160
196
  let entryRelative: string;
161
- try {
162
- // Traversal guard for the entry path and every bundle file — a `../foo`
163
- // would let the workload read or execute paths outside its session dir.
164
- // Validated here (backend-neutral) so a bad path is a 400, not a backend
165
- // 500, regardless of how the backend ultimately delivers the bundle.
166
- entryRelative = normalizeBundlePath(body.bundle.entryRelativePath);
167
- for (const file of body.bundle.files) normalizeBundlePath(file.relativePath);
168
- } catch (err) {
169
- if (err instanceof BundlePathError) {
170
- reply.code(400).send({ error: "invalid_bundle", message: err.message });
171
- return;
197
+ let config: SessionConfig;
198
+ if (appEntry) {
199
+ // Self-contained image no bundle to deliver; the entry path is an unused
200
+ // placeholder so the backend spec stays total.
201
+ bundle = { entryRelativePath: "telo.yaml", files: [] };
202
+ entryRelative = bundle.entryRelativePath;
203
+ config = { image: appEntry.image, pullPolicy: appEntry.pullPolicy };
204
+ } else {
205
+ bundle = body.bundle!;
206
+ config = body.config!;
207
+ try {
208
+ // Traversal guard for the entry path and every bundle file — a `../foo`
209
+ // would let the workload read or execute paths outside its session dir.
210
+ // Validated here (backend-neutral) so a bad path is a 400, not a backend
211
+ // 500, regardless of how the backend ultimately delivers the bundle.
212
+ entryRelative = normalizeBundlePath(bundle.entryRelativePath);
213
+ for (const file of bundle.files) normalizeBundlePath(file.relativePath);
214
+ } catch (err) {
215
+ if (err instanceof BundlePathError) {
216
+ reply.code(400).send({ error: "invalid_bundle", message: err.message });
217
+ return;
218
+ }
219
+ throw err;
172
220
  }
173
- throw err;
174
- }
175
221
 
176
- // Backend config gate (e.g. an image allowlist). The advertised capabilities
177
- // constrain the editor; this enforces the same against any client.
178
- if (deps.validateConfig) {
179
- const message = deps.validateConfig(body.config);
180
- if (message) {
181
- reply.code(400).send({ error: "invalid_config", message });
182
- return;
222
+ // Backend config gate (e.g. an image allowlist). The advertised capabilities
223
+ // constrain the editor; this enforces the same against any client. App
224
+ // sessions skip it — their image comes from the catalog, not the client.
225
+ if (deps.validateConfig) {
226
+ const message = deps.validateConfig(config);
227
+ if (message) {
228
+ reply.code(400).send({ error: "invalid_config", message });
229
+ return;
230
+ }
183
231
  }
184
232
  }
185
233
 
@@ -194,16 +242,28 @@ async function startSession(
194
242
  throw err;
195
243
  }
196
244
 
245
+ // For an app session, drop client-supplied values for any env key the
246
+ // catalog defines (a client must never override operator-held values, which
247
+ // include secrets), then inject the operator's values.
248
+ const clientEnv = appEntry
249
+ ? {
250
+ ...Object.fromEntries(
251
+ Object.entries(body.env).filter(([key]) => !(key in appEntry.env)),
252
+ ),
253
+ ...appEntry.env,
254
+ }
255
+ : body.env;
256
+
197
257
  // Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
198
- // it up. Precedence: body.env explicit value > body.config.registryUrl
199
- // (per-request override) > runner's own default. Trim client-supplied URLs
200
- // so stray whitespace from an editor input doesn't flow into the workload.
201
- const configRegistryUrl = body.config.registryUrl?.trim() || undefined;
258
+ // it up. Precedence: explicit env value > config.registryUrl (per-request
259
+ // override) > runner's own default. Trim client-supplied URLs so stray
260
+ // whitespace from an editor input doesn't flow into the workload.
261
+ const configRegistryUrl = config.registryUrl?.trim() || undefined;
202
262
  const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
203
263
  const sessionEnv =
204
- registryUrl && !("TELO_REGISTRY_URL" in body.env)
205
- ? { ...body.env, TELO_REGISTRY_URL: registryUrl }
206
- : body.env;
264
+ registryUrl && !("TELO_REGISTRY_URL" in clientEnv)
265
+ ? { ...clientEnv, TELO_REGISTRY_URL: registryUrl }
266
+ : clientEnv;
207
267
 
208
268
  // Respond as soon as the session is registered — BEFORE the backend starts.
209
269
  // `backend.start()` now spans the on-cluster image build and pod bring-up,
@@ -221,11 +281,12 @@ async function startSession(
221
281
  deps.backend
222
282
  .start({
223
283
  sessionId,
224
- bundle: body.bundle,
284
+ bundle,
225
285
  entryRelativePath: entryRelative,
226
286
  env: sessionEnv,
227
287
  ports: body.ports ?? [],
228
- config: body.config,
288
+ config,
289
+ selfContained: appEntry !== undefined,
229
290
  inspect: body.inspect ?? false,
230
291
  onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
231
292
  onProgress: (phase, message, done) =>
@@ -238,6 +299,8 @@ async function startSession(
238
299
  onDebug: (frame) => {
239
300
  if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
240
301
  },
302
+ onReachability: (port, state) =>
303
+ deps.registry.emit(sessionId, { type: "reachability", port, state }),
241
304
  isUserStopped: () => entry.userStopped,
242
305
  })
243
306
  .then(async (session) => {
package/src/server.ts CHANGED
@@ -3,8 +3,8 @@ import cors from "@fastify/cors";
3
3
  import websocket from "@fastify/websocket";
4
4
 
5
5
  import type { RunnerBackend } from "./backend.js";
6
- import type { RunnerCoreConfig } from "./config.js";
7
- import type { RunnerCapabilities, SessionConfig } from "./contract.js";
6
+ import type { ResolvedRunnerApp, RunnerCoreConfig } from "./config.js";
7
+ import type { RunnerAppDescriptor, RunnerCapabilities, SessionConfig } from "./contract.js";
8
8
  import { capabilitiesRoute } from "./routes/capabilities.js";
9
9
  import { healthRoute } from "./routes/health.js";
10
10
  import { ioRoute } from "./routes/io.js";
@@ -25,8 +25,13 @@ export interface ServerDeps {
25
25
  /** Runner's default registry URL, passed to workloads as TELO_REGISTRY_URL. */
26
26
  defaultRegistryUrl?: string;
27
27
  /** Backend config gate, enforced on `POST /v1/sessions` before the workload
28
- * starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`. */
28
+ * starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`.
29
+ * Not consulted for app sessions — their image comes from `apps`. */
29
30
  validateConfig?: (config: SessionConfig) => string | undefined;
31
+ /** Operator-predefined applications launchable by name (usually
32
+ * `loadResolvedApps(process.env)`). Advertised on /v1/capabilities as
33
+ * `apps` descriptors; the session route resolves and gates against it. */
34
+ apps?: Record<string, ResolvedRunnerApp>;
30
35
  registry?: SessionRegistry;
31
36
  }
32
37
 
@@ -58,13 +63,26 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
58
63
  replayBufferBytes: deps.config.replayBufferBytes,
59
64
  });
60
65
 
66
+ // The app catalog is injected into the served capabilities document here, so
67
+ // what /v1/capabilities advertises and what the session route accepts can
68
+ // never drift — both come from `deps.apps`.
69
+ const appDescriptors: RunnerAppDescriptor[] = Object.values(deps.apps ?? {}).map(
70
+ ({ name, title, description }) => ({ name, title, description }),
71
+ );
72
+ const withApps = (caps: RunnerCapabilities): RunnerCapabilities =>
73
+ appDescriptors.length > 0 ? { ...caps, apps: appDescriptors } : caps;
74
+ const capabilitiesGetter =
75
+ typeof deps.capabilities === "function"
76
+ ? () => withApps((deps.capabilities as () => RunnerCapabilities)())
77
+ : withApps(deps.capabilities);
78
+
61
79
  // Terms are stable across the process — resolve the capabilities once for them
62
80
  // even when `capabilities` is a getter (the route still re-resolves per request).
63
81
  const capabilitiesValue =
64
- typeof deps.capabilities === "function" ? deps.capabilities() : deps.capabilities;
82
+ typeof capabilitiesGetter === "function" ? capabilitiesGetter() : capabilitiesGetter;
65
83
 
66
84
  await app.register(healthRoute(deps.version));
67
- await app.register(capabilitiesRoute(deps.capabilities));
85
+ await app.register(capabilitiesRoute(capabilitiesGetter));
68
86
  await app.register(probeRoute({ backend: deps.backend }));
69
87
  await app.register(
70
88
  sessionsRoute({
@@ -76,6 +94,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
76
94
  // The capabilities document is the single source of the runner's terms;
77
95
  // the session route enforces what /v1/capabilities advertises.
78
96
  terms: capabilitiesValue.terms,
97
+ apps: deps.apps,
79
98
  }),
80
99
  );
81
100
  await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));