@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.
- package/dist/backend.d.ts +5 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/config.d.ts +43 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +77 -0
- package/dist/config.js.map +1 -1
- package/dist/contract.d.ts +15 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/apps.d.ts +29 -0
- package/dist/routes/apps.d.ts.map +1 -0
- package/dist/routes/apps.js +64 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/session-start.d.ts +59 -0
- package/dist/routes/session-start.d.ts.map +1 -0
- package/dist/routes/session-start.js +129 -0
- package/dist/routes/session-start.js.map +1 -0
- package/dist/routes/sessions.d.ts +2 -2
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +8 -104
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts +8 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -3
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
- package/src/app-catalog.test.ts +54 -0
- package/src/backend.ts +5 -0
- package/src/config.ts +116 -1
- package/src/contract.ts +16 -0
- package/src/index.ts +1 -0
- package/src/routes/apps.ts +106 -0
- package/src/routes/session-start.ts +174 -0
- package/src/routes/sessions.ts +14 -115
- package/src/server.ts +35 -6
|
@@ -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
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -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 {
|
|
14
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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:
|
|
163
|
+
env: body.env,
|
|
227
164
|
ports: body.ports ?? [],
|
|
228
165
|
config: body.config,
|
|
166
|
+
selfContained: false,
|
|
229
167
|
inspect: body.inspect ?? false,
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
84
|
+
typeof capabilitiesGetter === "function" ? capabilitiesGetter() : capabilitiesGetter;
|
|
65
85
|
|
|
66
86
|
await app.register(healthRoute(deps.version));
|
|
67
|
-
await app.register(capabilitiesRoute(
|
|
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
|
-
//
|
|
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 }));
|