@telorun/runner-core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
+
3
+ import { isEventFrame } from "@telorun/debug-wire";
4
+ import type { RunnerBackend } from "../backend.js";
5
+ import {
6
+ ACCEPTED_TERMS_HEADER,
7
+ SessionStartError,
8
+ type PortMapping,
9
+ type RunBundle,
10
+ type RunnerTerms,
11
+ type SessionConfig,
12
+ } from "../contract.js";
13
+ import { generateSessionId } from "../session/session-id.js";
14
+ import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
15
+
16
+ /** JSON Schema for the `ports` body field, shared by every session-creating
17
+ * route so bundle and app sessions validate port mappings identically. */
18
+ export const portsSchema = {
19
+ type: "array",
20
+ items: {
21
+ type: "object",
22
+ required: ["port", "protocol"],
23
+ properties: {
24
+ port: { type: "integer", minimum: 1, maximum: 65535 },
25
+ protocol: { type: "string", enum: ["tcp", "udp"] },
26
+ },
27
+ },
28
+ } as const;
29
+
30
+ /**
31
+ * Terms enforcement shared by every session-creating route — the server is the
32
+ * source of truth, so a client that skips the editor gate still can't start a
33
+ * workload without acknowledging the current terms version. Sends the 428 and
34
+ * returns false when the gate is closed.
35
+ */
36
+ export function enforceTerms(
37
+ req: FastifyRequest,
38
+ reply: FastifyReply,
39
+ terms: RunnerTerms | undefined,
40
+ ): boolean {
41
+ if (!terms) return true;
42
+ const raw = req.headers[ACCEPTED_TERMS_HEADER];
43
+ const accepted = Array.isArray(raw) ? raw[0] : raw;
44
+ if (accepted !== terms.version) {
45
+ reply.code(428).send({ error: "terms_required", terms });
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** Dependencies the shared session-creation leaf needs, independent of which
52
+ * route drives it. Both `SessionsRouteDeps` and `AppsRouteDeps` satisfy it. */
53
+ export interface WorkloadStartDeps {
54
+ backend: RunnerBackend;
55
+ registry: SessionRegistry;
56
+ /** The runner's own default registry URL, surfaced to the workload as
57
+ * TELO_REGISTRY_URL when the request doesn't override it. */
58
+ defaultRegistryUrl?: string;
59
+ }
60
+
61
+ export interface WorkloadStartArgs {
62
+ bundle: RunBundle;
63
+ entryRelativePath: string;
64
+ env: Record<string, string>;
65
+ ports: PortMapping[];
66
+ config: SessionConfig;
67
+ selfContained: boolean;
68
+ inspect: boolean;
69
+ }
70
+
71
+ /**
72
+ * The session-creation leaf shared by `POST /v1/sessions` (bundle sessions) and
73
+ * `POST /v1/apps/:name/sessions` (operator-predefined apps): registers the
74
+ * session, responds 201 with the shared `/v1/sessions/:id/events` stream URL,
75
+ * and starts the workload in the background. Whatever door a session was
76
+ * created through, everything after creation lives in the one session
77
+ * collection (status, DELETE, events, io).
78
+ */
79
+ export async function startWorkloadSession(
80
+ app: FastifyInstance,
81
+ deps: WorkloadStartDeps,
82
+ args: WorkloadStartArgs,
83
+ reply: FastifyReply,
84
+ ): Promise<void> {
85
+ const sessionId = generateSessionId();
86
+
87
+ let entry: ReturnType<SessionRegistry["register"]>;
88
+ try {
89
+ entry = deps.registry.register({ sessionId });
90
+ } catch (err) {
91
+ if (err instanceof SessionLimitError) {
92
+ reply.code(409).send({ error: "too_many_sessions", message: err.message });
93
+ return;
94
+ }
95
+ throw err;
96
+ }
97
+
98
+ // Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
99
+ // it up. Precedence: explicit env value > config.registryUrl (per-request
100
+ // override) > runner's own default. Trim client-supplied URLs so stray
101
+ // whitespace from an editor input doesn't flow into the workload.
102
+ const configRegistryUrl = args.config.registryUrl?.trim() || undefined;
103
+ const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
104
+ const sessionEnv =
105
+ registryUrl && !("TELO_REGISTRY_URL" in args.env)
106
+ ? { ...args.env, TELO_REGISTRY_URL: registryUrl }
107
+ : args.env;
108
+
109
+ // Respond as soon as the session is registered — BEFORE the backend starts.
110
+ // `backend.start()` now spans the on-cluster image build and pod bring-up,
111
+ // which can take seconds-to-minutes; awaiting it here would hide the event
112
+ // stream until the workload is already up, so the client never sees build /
113
+ // provision / boot progress live. Returning the streamUrl first lets the
114
+ // client connect immediately; start runs in the background and its progress,
115
+ // output, and terminal status flow over the stream.
116
+ reply.code(201).send({
117
+ sessionId,
118
+ streamUrl: `/v1/sessions/${sessionId}/events`,
119
+ createdAt: entry.createdAt.toISOString(),
120
+ });
121
+
122
+ deps.backend
123
+ .start({
124
+ sessionId,
125
+ bundle: args.bundle,
126
+ entryRelativePath: args.entryRelativePath,
127
+ env: sessionEnv,
128
+ ports: args.ports,
129
+ config: args.config,
130
+ selfContained: args.selfContained,
131
+ inspect: args.inspect,
132
+ onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
133
+ onProgress: (phase, message, done) =>
134
+ deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
135
+ onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
136
+ // Relay only kernel *event* frames to the client. stdout/stderr already
137
+ // arrive over the byte channel (onOutput), so forwarding log frames would
138
+ // double the traffic and let log spam evict lifecycle events from the
139
+ // byte-capped replay buffer. The editor discards relayed logs anyway.
140
+ onDebug: (frame) => {
141
+ if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
142
+ },
143
+ onReachability: (port, state) =>
144
+ deps.registry.emit(sessionId, { type: "reachability", port, state }),
145
+ isUserStopped: () => entry.userStopped,
146
+ })
147
+ .then(async (session) => {
148
+ entry.session = session;
149
+ // Pre-start DELETE race: a DELETE received during backend.start (e.g.
150
+ // while an image build was running) can't stop a workload that didn't
151
+ // exist yet — it set userStopped and returned 204. Now that the workload
152
+ // is live, honor the earlier DELETE.
153
+ if (entry.userStopped) {
154
+ try {
155
+ await session.stop();
156
+ } catch (err) {
157
+ app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
158
+ }
159
+ }
160
+ })
161
+ .catch((err) => {
162
+ // The 201 is already sent, so a start failure surfaces as a terminal
163
+ // `failed` status on the stream (the registry schedules eviction on a
164
+ // terminal status; the SSE channel delivers it, then closes).
165
+ const message =
166
+ err instanceof SessionStartError
167
+ ? `${err.stage}: ${err.message}`
168
+ : err instanceof Error
169
+ ? err.message
170
+ : String(err);
171
+ app.log.error({ err, sessionId }, "session start failed");
172
+ deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
173
+ });
174
+ }
@@ -1,17 +1,10 @@
1
1
  import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
2
2
 
3
- import { isEventFrame } from "@telorun/debug-wire";
4
3
  import type { RunnerBackend } from "../backend.js";
5
- import {
6
- ACCEPTED_TERMS_HEADER,
7
- SessionStartError,
8
- type RunnerTerms,
9
- type SessionConfig,
10
- type StartSessionRequest,
11
- } from "../contract.js";
4
+ import type { RunnerTerms, SessionConfig, StartSessionRequest } from "../contract.js";
12
5
  import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
13
- import { generateSessionId } from "../session/session-id.js";
14
- import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
6
+ import type { SessionRegistry } from "../session/registry.js";
7
+ import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
15
8
  import { streamSessionEvents } from "../sse/channel.js";
16
9
 
17
10
  export interface SessionsRouteDeps {
@@ -57,17 +50,7 @@ const startBodySchema = {
57
50
  type: "object",
58
51
  additionalProperties: { type: "string" },
59
52
  },
60
- ports: {
61
- type: "array",
62
- items: {
63
- type: "object",
64
- required: ["port", "protocol"],
65
- properties: {
66
- port: { type: "integer", minimum: 1, maximum: 65535 },
67
- protocol: { type: "string", enum: ["tcp", "udp"] },
68
- },
69
- },
70
- },
53
+ ports: portsSchema,
71
54
  config: {
72
55
  type: "object",
73
56
  required: ["image", "pullPolicy"],
@@ -87,17 +70,7 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
87
70
  "/v1/sessions",
88
71
  { schema: { body: startBodySchema } },
89
72
  async (req, reply) => {
90
- // Terms enforcement — the server is the source of truth, so a client that
91
- // skips the editor gate still can't start a session without acknowledging
92
- // the current terms version.
93
- if (deps.terms) {
94
- const raw = req.headers[ACCEPTED_TERMS_HEADER];
95
- const accepted = Array.isArray(raw) ? raw[0] : raw;
96
- if (accepted !== deps.terms.version) {
97
- reply.code(428).send({ error: "terms_required", terms: deps.terms });
98
- return;
99
- }
100
- }
73
+ if (!enforceTerms(req, reply, deps.terms)) return;
101
74
  return startSession(app, deps, req.body, reply);
102
75
  },
103
76
  );
@@ -155,8 +128,6 @@ async function startSession(
155
128
  body: StartSessionRequest,
156
129
  reply: FastifyReply,
157
130
  ): Promise<void> {
158
- const sessionId = generateSessionId();
159
-
160
131
  let entryRelative: string;
161
132
  try {
162
133
  // Traversal guard for the entry path and every bundle file — a `../foo`
@@ -183,90 +154,18 @@ async function startSession(
183
154
  }
184
155
  }
185
156
 
186
- let entry: ReturnType<SessionRegistry["register"]>;
187
- try {
188
- entry = deps.registry.register({ sessionId });
189
- } catch (err) {
190
- if (err instanceof SessionLimitError) {
191
- reply.code(409).send({ error: "too_many_sessions", message: err.message });
192
- return;
193
- }
194
- throw err;
195
- }
196
-
197
- // 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;
202
- const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
203
- const sessionEnv =
204
- registryUrl && !("TELO_REGISTRY_URL" in body.env)
205
- ? { ...body.env, TELO_REGISTRY_URL: registryUrl }
206
- : body.env;
207
-
208
- // Respond as soon as the session is registered — BEFORE the backend starts.
209
- // `backend.start()` now spans the on-cluster image build and pod bring-up,
210
- // which can take seconds-to-minutes; awaiting it here would hide the event
211
- // stream until the workload is already up, so the client never sees build /
212
- // provision / boot progress live. Returning the streamUrl first lets the
213
- // client connect immediately; start runs in the background and its progress,
214
- // output, and terminal status flow over the stream.
215
- reply.code(201).send({
216
- sessionId,
217
- streamUrl: `/v1/sessions/${sessionId}/events`,
218
- createdAt: entry.createdAt.toISOString(),
219
- });
220
-
221
- deps.backend
222
- .start({
223
- sessionId,
157
+ return startWorkloadSession(
158
+ app,
159
+ deps,
160
+ {
224
161
  bundle: body.bundle,
225
162
  entryRelativePath: entryRelative,
226
- env: sessionEnv,
163
+ env: body.env,
227
164
  ports: body.ports ?? [],
228
165
  config: body.config,
166
+ selfContained: false,
229
167
  inspect: body.inspect ?? false,
230
- onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
231
- onProgress: (phase, message, done) =>
232
- deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
233
- onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
234
- // Relay only kernel *event* frames to the client. stdout/stderr already
235
- // arrive over the byte channel (onOutput), so forwarding log frames would
236
- // double the traffic and let log spam evict lifecycle events from the
237
- // byte-capped replay buffer. The editor discards relayed logs anyway.
238
- onDebug: (frame) => {
239
- if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
240
- },
241
- onReachability: (port, state) =>
242
- deps.registry.emit(sessionId, { type: "reachability", port, state }),
243
- isUserStopped: () => entry.userStopped,
244
- })
245
- .then(async (session) => {
246
- entry.session = session;
247
- // Pre-start DELETE race: a DELETE received during backend.start (e.g.
248
- // while an image build was running) can't stop a workload that didn't
249
- // exist yet — it set userStopped and returned 204. Now that the workload
250
- // is live, honor the earlier DELETE.
251
- if (entry.userStopped) {
252
- try {
253
- await session.stop();
254
- } catch (err) {
255
- app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
256
- }
257
- }
258
- })
259
- .catch((err) => {
260
- // The 201 is already sent, so a start failure surfaces as a terminal
261
- // `failed` status on the stream (the registry schedules eviction on a
262
- // terminal status; the SSE channel delivers it, then closes).
263
- const message =
264
- err instanceof SessionStartError
265
- ? `${err.stage}: ${err.message}`
266
- : err instanceof Error
267
- ? err.message
268
- : String(err);
269
- app.log.error({ err, sessionId }, "session start failed");
270
- deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
271
- });
168
+ },
169
+ reply,
170
+ );
272
171
  }
package/src/server.ts CHANGED
@@ -3,8 +3,9 @@ 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
+ import { appsRoute } from "./routes/apps.js";
8
9
  import { capabilitiesRoute } from "./routes/capabilities.js";
9
10
  import { healthRoute } from "./routes/health.js";
10
11
  import { ioRoute } from "./routes/io.js";
@@ -25,8 +26,14 @@ export interface ServerDeps {
25
26
  /** Runner's default registry URL, passed to workloads as TELO_REGISTRY_URL. */
26
27
  defaultRegistryUrl?: string;
27
28
  /** Backend config gate, enforced on `POST /v1/sessions` before the workload
28
- * starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`. */
29
+ * starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`.
30
+ * Not consulted for app sessions — their image comes from `apps`. */
29
31
  validateConfig?: (config: SessionConfig) => string | undefined;
32
+ /** Operator-predefined applications launchable by name (usually
33
+ * `loadResolvedApps(process.env)`). Advertised on /v1/capabilities as
34
+ * `apps` descriptors; sessions of them are created via
35
+ * `POST /v1/apps/:name/sessions`. */
36
+ apps?: Record<string, ResolvedRunnerApp>;
30
37
  registry?: SessionRegistry;
31
38
  }
32
39
 
@@ -58,13 +65,26 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
58
65
  replayBufferBytes: deps.config.replayBufferBytes,
59
66
  });
60
67
 
68
+ // The app catalog is injected into the served capabilities document here, so
69
+ // what /v1/capabilities advertises and what the session route accepts can
70
+ // never drift — both come from `deps.apps`.
71
+ const appDescriptors: RunnerAppDescriptor[] = Object.values(deps.apps ?? {}).map(
72
+ ({ name, title, description }) => ({ name, title, description }),
73
+ );
74
+ const withApps = (caps: RunnerCapabilities): RunnerCapabilities =>
75
+ appDescriptors.length > 0 ? { ...caps, apps: appDescriptors } : caps;
76
+ const capabilitiesGetter =
77
+ typeof deps.capabilities === "function"
78
+ ? () => withApps((deps.capabilities as () => RunnerCapabilities)())
79
+ : withApps(deps.capabilities);
80
+
61
81
  // Terms are stable across the process — resolve the capabilities once for them
62
82
  // even when `capabilities` is a getter (the route still re-resolves per request).
63
83
  const capabilitiesValue =
64
- typeof deps.capabilities === "function" ? deps.capabilities() : deps.capabilities;
84
+ typeof capabilitiesGetter === "function" ? capabilitiesGetter() : capabilitiesGetter;
65
85
 
66
86
  await app.register(healthRoute(deps.version));
67
- await app.register(capabilitiesRoute(deps.capabilities));
87
+ await app.register(capabilitiesRoute(capabilitiesGetter));
68
88
  await app.register(probeRoute({ backend: deps.backend }));
69
89
  await app.register(
70
90
  sessionsRoute({
@@ -74,8 +94,17 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
74
94
  defaultRegistryUrl: deps.defaultRegistryUrl,
75
95
  validateConfig: deps.validateConfig,
76
96
  // The capabilities document is the single source of the runner's terms;
77
- // the session route enforces what /v1/capabilities advertises.
97
+ // every session-creating route enforces what /v1/capabilities advertises.
98
+ terms: capabilitiesValue.terms,
99
+ }),
100
+ );
101
+ await app.register(
102
+ appsRoute({
103
+ backend: deps.backend,
104
+ registry,
105
+ defaultRegistryUrl: deps.defaultRegistryUrl,
78
106
  terms: capabilitiesValue.terms,
107
+ apps: deps.apps,
79
108
  }),
80
109
  );
81
110
  await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));