@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
package/src/routes/sessions.ts
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
|
|
2
2
|
|
|
3
|
-
import type { RunnerBackend } from "../backend.js";
|
|
4
|
-
import type {
|
|
3
|
+
import type { BackendAppSpec, RunnerBackend } from "../backend.js";
|
|
4
|
+
import type { ResolvedRunnerApp, WatchSessionConfig } from "../config.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_APP_NAME,
|
|
7
|
+
type IoMode,
|
|
8
|
+
type PortMapping,
|
|
9
|
+
type RunnerTerms,
|
|
10
|
+
type SessionAppSpec,
|
|
11
|
+
type SessionConfig,
|
|
12
|
+
type StartSessionRequest,
|
|
13
|
+
type WorkspaceChangeSet,
|
|
14
|
+
} from "../contract.js";
|
|
5
15
|
import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
|
|
6
|
-
import type { SessionRegistry } from "../session/registry.js";
|
|
7
|
-
import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
|
|
16
|
+
import type { SessionEntry, SessionRegistry } from "../session/registry.js";
|
|
17
|
+
import { enforceTerms, launchWorkload, portsSchema, startWorkloadSession } from "./session-start.js";
|
|
8
18
|
import { streamSessionEvents } from "../sse/channel.js";
|
|
9
19
|
|
|
20
|
+
/** The part of the operator's watch settings this route enforces. A projection
|
|
21
|
+
* of `WatchSessionConfig` rather than a second shape, so the two cannot
|
|
22
|
+
* disagree about what a field means. */
|
|
23
|
+
export type WatchConfig = Pick<
|
|
24
|
+
WatchSessionConfig,
|
|
25
|
+
"enabled" | "maxSessions" | "reloadLimitPerMinute"
|
|
26
|
+
>;
|
|
27
|
+
|
|
10
28
|
export interface SessionsRouteDeps {
|
|
11
29
|
backend: RunnerBackend;
|
|
12
30
|
registry: SessionRegistry;
|
|
@@ -22,8 +40,28 @@ export interface SessionsRouteDeps {
|
|
|
22
40
|
* the source of truth, so this re-checks what `/v1/capabilities` advertises
|
|
23
41
|
* (e.g. an `image` allowlist) against a client that skipped the editor. */
|
|
24
42
|
validateConfig?: (config: SessionConfig) => string | undefined;
|
|
43
|
+
/** The operator catalog a session's `agent` is resolved against — the same
|
|
44
|
+
* catalog `POST /v1/apps/:name/sessions` uses, because a co-resident agent IS
|
|
45
|
+
* an operator-predefined application, just one sharing a pod. */
|
|
46
|
+
apps?: Record<string, ResolvedRunnerApp>;
|
|
47
|
+
watch: WatchConfig;
|
|
25
48
|
}
|
|
26
49
|
|
|
50
|
+
const appsSchema = {
|
|
51
|
+
type: "array",
|
|
52
|
+
minItems: 1,
|
|
53
|
+
items: {
|
|
54
|
+
type: "object",
|
|
55
|
+
required: ["name", "entryRelativePath"],
|
|
56
|
+
properties: {
|
|
57
|
+
name: { type: "string", minLength: 1, maxLength: 63 },
|
|
58
|
+
entryRelativePath: { type: "string", minLength: 1 },
|
|
59
|
+
ports: portsSchema,
|
|
60
|
+
io: { type: "string", enum: ["tty", "streams"] },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
27
65
|
const startBodySchema = {
|
|
28
66
|
type: "object",
|
|
29
67
|
required: ["bundle", "env", "config"],
|
|
@@ -61,9 +99,42 @@ const startBodySchema = {
|
|
|
61
99
|
},
|
|
62
100
|
},
|
|
63
101
|
inspect: { type: "boolean" },
|
|
102
|
+
mode: { type: "string", enum: ["run", "watch"] },
|
|
103
|
+
agent: { type: "string", minLength: 1 },
|
|
104
|
+
apps: appsSchema,
|
|
64
105
|
},
|
|
65
106
|
} as const;
|
|
66
107
|
|
|
108
|
+
const changeSetSchema = {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
write: {
|
|
112
|
+
type: "array",
|
|
113
|
+
items: {
|
|
114
|
+
type: "object",
|
|
115
|
+
required: ["path", "content"],
|
|
116
|
+
properties: {
|
|
117
|
+
path: { type: "string", minLength: 1 },
|
|
118
|
+
content: { type: "string" },
|
|
119
|
+
encoding: { type: "string", enum: ["utf8", "base64"] },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
delete: { type: "array", items: { type: "string", minLength: 1 } },
|
|
124
|
+
},
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
/** A DNS label — the app name appears in a container name and in every run
|
|
128
|
+
* event, so it has to survive both. */
|
|
129
|
+
const APP_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
130
|
+
|
|
131
|
+
/** Names a session's own containers already use. The docker backend keys every
|
|
132
|
+
* container of a session in one map, so an app called `workspace` would
|
|
133
|
+
* overwrite the workspace handle — leaking the real container, since
|
|
134
|
+
* `AutoRemove` only fires on stop. Reserved in core so one rule covers both
|
|
135
|
+
* backends rather than k8s being safe only by its `app-` prefix. */
|
|
136
|
+
const RESERVED_APP_NAMES = new Set(["workspace", "agent"]);
|
|
137
|
+
|
|
67
138
|
export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
|
|
68
139
|
return async (app: FastifyInstance) => {
|
|
69
140
|
app.post<{ Body: StartSessionRequest }>(
|
|
@@ -84,6 +155,14 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
|
|
|
84
155
|
reply.send({
|
|
85
156
|
sessionId: entry.sessionId,
|
|
86
157
|
status: entry.status,
|
|
158
|
+
mode: entry.mode,
|
|
159
|
+
agent: entry.agent,
|
|
160
|
+
apps: [...entry.apps.values()].map((a) => ({
|
|
161
|
+
name: a.name,
|
|
162
|
+
io: a.io,
|
|
163
|
+
generation: a.generation,
|
|
164
|
+
ports: a.ports,
|
|
165
|
+
})),
|
|
87
166
|
createdAt: entry.createdAt.toISOString(),
|
|
88
167
|
exitedAt: entry.exitedAt?.toISOString(),
|
|
89
168
|
});
|
|
@@ -119,9 +198,360 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
|
|
|
119
198
|
corsOrigins: deps.corsOrigins,
|
|
120
199
|
}),
|
|
121
200
|
);
|
|
201
|
+
|
|
202
|
+
// ---- Workspace surface -------------------------------------------------
|
|
203
|
+
// Every write from OUTSIDE the pod goes through these; the co-resident agent
|
|
204
|
+
// writes the same volume directly with its own filesystem tools. Two writers,
|
|
205
|
+
// N watchers, one directory — which is what replaces three delivery paths.
|
|
206
|
+
|
|
207
|
+
app.get<{ Params: { id: string } }>("/v1/sessions/:id/workspace", async (req, reply) => {
|
|
208
|
+
const ws = resolveWorkspace(deps, req.params.id, reply);
|
|
209
|
+
if (!ws) return;
|
|
210
|
+
reply.send(await ws.tree());
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
app.post<{ Params: { id: string }; Body: WorkspaceChangeSet }>(
|
|
214
|
+
"/v1/sessions/:id/workspace",
|
|
215
|
+
{ schema: { body: changeSetSchema } },
|
|
216
|
+
async (req, reply) => {
|
|
217
|
+
const entry = requireWatchSession(deps, req.params.id, reply);
|
|
218
|
+
if (!entry) return;
|
|
219
|
+
// The reload budget belongs on the route that CAUSES reloads. Guarding
|
|
220
|
+
// `POST /reload` alone left the editor's save path — one write per save,
|
|
221
|
+
// each triggering a kernel reload — unbounded, which is the shape the
|
|
222
|
+
// budget exists to prevent.
|
|
223
|
+
if (!admitReload(entry, deps.watch.reloadLimitPerMinute)) {
|
|
224
|
+
reply.code(429).send({
|
|
225
|
+
error: "reload_rate_limited",
|
|
226
|
+
message: `more than ${deps.watch.reloadLimitPerMinute} workspace writes in the last minute`,
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const ws = entry.session?.workspace;
|
|
231
|
+
if (!ws) {
|
|
232
|
+
reply.code(409).send({ error: "not_running", message: "session has no live workspace" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
for (const w of req.body.write ?? []) normalizeBundlePath(w.path);
|
|
237
|
+
for (const p of req.body.delete ?? []) normalizeBundlePath(p);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err instanceof BundlePathError) {
|
|
240
|
+
reply.code(400).send({ error: "invalid_path", message: err.message });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
reply.send(await ws.apply(req.body));
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
|
|
250
|
+
"/v1/sessions/:id/workspace/file",
|
|
251
|
+
async (req, reply) => {
|
|
252
|
+
const ws = resolveWorkspace(deps, req.params.id, reply);
|
|
253
|
+
if (!ws) return;
|
|
254
|
+
const path = req.query.path?.trim();
|
|
255
|
+
if (!path) {
|
|
256
|
+
reply.code(400).send({ error: "invalid_path", message: "?path= is required" });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
normalizeBundlePath(path);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (err instanceof BundlePathError) {
|
|
263
|
+
reply.code(400).send({ error: "invalid_path", message: err.message });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
reply.send(await ws.readFile(path));
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// `--watch` reloads on change, and pressing Run again after a one-shot app
|
|
273
|
+
// completed is not a change. This touches the named app's entry manifest
|
|
274
|
+
// through the same write path everything else uses — no signalling into the
|
|
275
|
+
// container, no shared PID namespace, no `exec`.
|
|
276
|
+
app.post<{ Params: { id: string }; Querystring: { app?: string } }>(
|
|
277
|
+
"/v1/sessions/:id/reload",
|
|
278
|
+
async (req, reply) => {
|
|
279
|
+
const entry = requireWatchSession(deps, req.params.id, reply);
|
|
280
|
+
if (!entry) return;
|
|
281
|
+
const session = entry.session;
|
|
282
|
+
if (!session?.reload) {
|
|
283
|
+
reply.code(409).send({ error: "not_running", message: "session has no live workload" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (!admitReload(entry, deps.watch.reloadLimitPerMinute)) {
|
|
287
|
+
reply.code(429).send({
|
|
288
|
+
error: "reload_rate_limited",
|
|
289
|
+
message: `more than ${deps.watch.reloadLimitPerMinute} reloads in the last minute`,
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// Omitting `app` reloads every app in the session.
|
|
294
|
+
const requested = req.query.app?.trim();
|
|
295
|
+
if (requested && !entry.apps.has(requested)) {
|
|
296
|
+
reply.code(404).send({
|
|
297
|
+
error: "unknown_app",
|
|
298
|
+
message: `session runs no app named '${requested}'`,
|
|
299
|
+
});
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const targets = requested ? [requested] : [...entry.apps.keys()];
|
|
303
|
+
for (const name of targets) entry.attribution?.expect(name, "manual");
|
|
304
|
+
for (const name of targets) await session.reload(name);
|
|
305
|
+
reply.code(202).send({ reloaded: targets });
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// A pod's container list is fixed at creation, so changing WHICH apps run
|
|
310
|
+
// costs a checkpoint and a pod recreate — the suspend/resume path, reused.
|
|
311
|
+
// Editing a file stays free; this is the rare action that pays.
|
|
312
|
+
app.put<{ Params: { id: string }; Body: { apps: SessionAppSpec[] } }>(
|
|
313
|
+
"/v1/sessions/:id/apps",
|
|
314
|
+
{ schema: { body: { type: "object", required: ["apps"], properties: { apps: appsSchema } } } },
|
|
315
|
+
async (req, reply) => {
|
|
316
|
+
const entry = requireWatchSession(deps, req.params.id, reply);
|
|
317
|
+
if (!entry) return;
|
|
318
|
+
const session = entry.session;
|
|
319
|
+
if (!session?.setApps) {
|
|
320
|
+
reply.code(409).send({ error: "not_running", message: "session has no live workload" });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const resolved = resolveApps(req.body.apps, undefined, []);
|
|
324
|
+
if ("error" in resolved) {
|
|
325
|
+
reply.code(400).send(resolved.error);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// Channels for the NEW apps go in first — the backend starts emitting
|
|
329
|
+
// output as soon as a container is up, and a push to an unknown app is
|
|
330
|
+
// silently dropped. Removals wait until the backend has succeeded, so a
|
|
331
|
+
// failed recreate leaves the session describing what is still running.
|
|
332
|
+
for (const spec of resolved.apps) {
|
|
333
|
+
if (!entry.apps.has(spec.name)) {
|
|
334
|
+
deps.registry.addApp(entry, { name: spec.name, io: spec.io, ports: spec.ports });
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
entry.attribution?.expectAll("resume");
|
|
338
|
+
try {
|
|
339
|
+
await session.setApps(resolved.apps);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// `setApps` recreates the workload, so rejecting is a normal outcome
|
|
342
|
+
// (pod create, start deadline, workspace never ready). Undo the
|
|
343
|
+
// channels we added and fail the session rather than leaving it
|
|
344
|
+
// `running` with nothing behind it.
|
|
345
|
+
for (const spec of resolved.apps) {
|
|
346
|
+
if (!entry.launch?.apps.some((a) => a.name === spec.name)) {
|
|
347
|
+
entry.apps.delete(spec.name);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
351
|
+
deps.registry.emit(entry.sessionId, {
|
|
352
|
+
type: "status",
|
|
353
|
+
status: { kind: "failed", message: `changing the app set failed: ${message}` },
|
|
354
|
+
});
|
|
355
|
+
reply.code(500).send({ error: "set_apps_failed", message });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
for (const name of [...entry.apps.keys()]) {
|
|
359
|
+
if (!resolved.apps.some((a) => a.name === name)) entry.apps.delete(name);
|
|
360
|
+
}
|
|
361
|
+
// The retained launch is what `resume` rebuilds from. Leaving it stale
|
|
362
|
+
// would rebuild the ORIGINAL app set under a registry holding the new
|
|
363
|
+
// one: output for the running containers dropped, and a session document
|
|
364
|
+
// listing apps that are not running.
|
|
365
|
+
if (entry.launch) entry.launch = { ...entry.launch, apps: resolved.apps };
|
|
366
|
+
reply.code(202).send({ apps: resolved.apps.map((a) => a.name) });
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
// A suspended session is best-effort by design: the checkpoint lives in the
|
|
371
|
+
// runner's memory, so a restart loses it and this answers 404. The editor
|
|
372
|
+
// holds the authoritative workspace and re-seeds from its own copy — which
|
|
373
|
+
// is a requirement ON the editor, not an observation about it.
|
|
374
|
+
app.post<{ Params: { id: string } }>("/v1/sessions/:id/resume", async (req, reply) => {
|
|
375
|
+
const entry = deps.registry.get(req.params.id);
|
|
376
|
+
if (!entry || entry.status.kind !== "suspended") {
|
|
377
|
+
reply.code(404).send({
|
|
378
|
+
error: "not_suspended",
|
|
379
|
+
message: `session '${req.params.id}' is not suspended`,
|
|
380
|
+
});
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const checkpoint = entry.checkpoint;
|
|
384
|
+
const launch = entry.launch;
|
|
385
|
+
if (!checkpoint || !launch) {
|
|
386
|
+
reply.code(409).send({
|
|
387
|
+
error: "no_checkpoint",
|
|
388
|
+
message: "session was suspended before a workspace checkpoint was taken",
|
|
389
|
+
});
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
entry.attribution?.expectAll("resume");
|
|
393
|
+
entry.session = null;
|
|
394
|
+
deps.registry.emit(entry.sessionId, { type: "status", status: { kind: "starting" } });
|
|
395
|
+
// The checkpoint IS the bundle for the new pod — a cold pod re-downloads
|
|
396
|
+
// its module closure, and only the workspace is checkpointed, never the
|
|
397
|
+
// cache.
|
|
398
|
+
launchWorkload(
|
|
399
|
+
app,
|
|
400
|
+
deps,
|
|
401
|
+
{
|
|
402
|
+
...launch,
|
|
403
|
+
bundle: {
|
|
404
|
+
entryRelativePath: launch.apps[0]?.entryRelativePath ?? "telo.yaml",
|
|
405
|
+
files: checkpoint.files.map((f) => ({
|
|
406
|
+
relativePath: f.path,
|
|
407
|
+
contents: f.content,
|
|
408
|
+
encoding: f.encoding,
|
|
409
|
+
})),
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
entry,
|
|
413
|
+
);
|
|
414
|
+
reply.code(202).send({ sessionId: entry.sessionId });
|
|
415
|
+
});
|
|
122
416
|
};
|
|
123
417
|
}
|
|
124
418
|
|
|
419
|
+
/** Reject anything but a live watch session, with the reason. */
|
|
420
|
+
function requireWatchSession(
|
|
421
|
+
deps: SessionsRouteDeps,
|
|
422
|
+
sessionId: string,
|
|
423
|
+
reply: FastifyReply,
|
|
424
|
+
): SessionEntry | undefined {
|
|
425
|
+
const entry = deps.registry.get(sessionId);
|
|
426
|
+
if (!entry) {
|
|
427
|
+
reply.code(404).send({ error: "not_found", message: `session '${sessionId}' not in registry` });
|
|
428
|
+
return undefined;
|
|
429
|
+
}
|
|
430
|
+
if (entry.mode !== "watch") {
|
|
431
|
+
reply.code(409).send({
|
|
432
|
+
error: "not_a_watch_session",
|
|
433
|
+
message: "this surface exists only on a watch session",
|
|
434
|
+
});
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
return entry;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function resolveWorkspace(
|
|
441
|
+
deps: SessionsRouteDeps,
|
|
442
|
+
sessionId: string,
|
|
443
|
+
reply: FastifyReply,
|
|
444
|
+
): NonNullable<SessionEntry["session"]>["workspace"] | undefined {
|
|
445
|
+
const entry = requireWatchSession(deps, sessionId, reply);
|
|
446
|
+
if (!entry) return undefined;
|
|
447
|
+
const workspace = entry.session?.workspace;
|
|
448
|
+
if (!workspace) {
|
|
449
|
+
reply.code(409).send({ error: "not_running", message: "session has no live workspace" });
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
return workspace;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Sliding one-minute window. Recorded only on an ADMITTED reload, so a client
|
|
456
|
+
* hammering a rate-limited endpoint cannot extend its own penalty. */
|
|
457
|
+
function admitReload(entry: SessionEntry, limitPerMinute: number): boolean {
|
|
458
|
+
const now = Date.now();
|
|
459
|
+
entry.reloads = entry.reloads.filter((t) => now - t < 60_000);
|
|
460
|
+
if (entry.reloads.length >= limitPerMinute) return false;
|
|
461
|
+
entry.reloads.push(now);
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
type ResolvedApps = { apps: BackendAppSpec[] } | { error: { error: string; message: string } };
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Normalize the declared app set. Omitted, it defaults to a single app named
|
|
469
|
+
* `app` taking the bundle's own entry and the request's `ports` — so a
|
|
470
|
+
* single-app session is written exactly as it is today.
|
|
471
|
+
*
|
|
472
|
+
* Ports are unique across the WHOLE session, not per app: session hosts are
|
|
473
|
+
* `<port>-<sessionId>.<base-domain>`, a single label, so two apps both listening
|
|
474
|
+
* on 3000 collide with nothing to distinguish them. Rejecting at create is a
|
|
475
|
+
* better outcome than a URL that silently reaches the wrong app — the user
|
|
476
|
+
* controls both manifests.
|
|
477
|
+
*/
|
|
478
|
+
function resolveApps(
|
|
479
|
+
declared: SessionAppSpec[] | undefined,
|
|
480
|
+
bundleEntry: string | undefined,
|
|
481
|
+
fallbackPorts: PortMapping[],
|
|
482
|
+
): ResolvedApps {
|
|
483
|
+
const specs: SessionAppSpec[] = declared ?? [
|
|
484
|
+
{
|
|
485
|
+
name: DEFAULT_APP_NAME,
|
|
486
|
+
entryRelativePath: bundleEntry ?? "",
|
|
487
|
+
ports: fallbackPorts,
|
|
488
|
+
},
|
|
489
|
+
];
|
|
490
|
+
|
|
491
|
+
const seenNames = new Set<string>();
|
|
492
|
+
const portOwner = new Map<string, string>();
|
|
493
|
+
const apps: BackendAppSpec[] = [];
|
|
494
|
+
|
|
495
|
+
for (const spec of specs) {
|
|
496
|
+
if (!APP_NAME_PATTERN.test(spec.name)) {
|
|
497
|
+
return {
|
|
498
|
+
error: {
|
|
499
|
+
error: "invalid_app_name",
|
|
500
|
+
message: `app name '${spec.name}' must be a DNS label (lowercase alphanumeric and '-')`,
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (RESERVED_APP_NAMES.has(spec.name)) {
|
|
505
|
+
return {
|
|
506
|
+
error: {
|
|
507
|
+
error: "reserved_app_name",
|
|
508
|
+
message: `app name '${spec.name}' is reserved — a session's own containers use it`,
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (seenNames.has(spec.name)) {
|
|
513
|
+
return {
|
|
514
|
+
error: { error: "duplicate_app_name", message: `app '${spec.name}' is declared twice` },
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
seenNames.add(spec.name);
|
|
518
|
+
|
|
519
|
+
let entryRelativePath: string;
|
|
520
|
+
try {
|
|
521
|
+
entryRelativePath = normalizeBundlePath(spec.entryRelativePath);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
if (err instanceof BundlePathError) {
|
|
524
|
+
return { error: { error: "invalid_bundle", message: err.message } };
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const ports = spec.ports ?? [];
|
|
530
|
+
for (const port of ports) {
|
|
531
|
+
const key = `${port.protocol}/${port.port}`;
|
|
532
|
+
const owner = portOwner.get(key);
|
|
533
|
+
if (owner) {
|
|
534
|
+
return {
|
|
535
|
+
error: {
|
|
536
|
+
error: "port_conflict",
|
|
537
|
+
message: `apps '${owner}' and '${spec.name}' both declare ${port.protocol} port ${port.port}; session hosts carry no app name, so the two would be indistinguishable`,
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
portOwner.set(key, spec.name);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
apps.push({
|
|
545
|
+
name: spec.name,
|
|
546
|
+
entryRelativePath,
|
|
547
|
+
ports,
|
|
548
|
+
io: (spec.io ?? "tty") as IoMode,
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { apps };
|
|
553
|
+
}
|
|
554
|
+
|
|
125
555
|
async function startSession(
|
|
126
556
|
app: FastifyInstance,
|
|
127
557
|
deps: SessionsRouteDeps,
|
|
@@ -154,18 +584,119 @@ async function startSession(
|
|
|
154
584
|
}
|
|
155
585
|
}
|
|
156
586
|
|
|
587
|
+
const mode = body.mode ?? "run";
|
|
588
|
+
if (mode === "watch" && !deps.watch.enabled) {
|
|
589
|
+
reply.code(400).send({
|
|
590
|
+
error: "watch_disabled",
|
|
591
|
+
message: "this runner does not offer watch sessions (see /v1/capabilities)",
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// An agent with nothing watching its writes is a silent no-op, so the pairing
|
|
597
|
+
// is enforced rather than tolerated.
|
|
598
|
+
let agent: ResolvedRunnerApp | undefined;
|
|
599
|
+
if (body.agent) {
|
|
600
|
+
if (mode !== "watch") {
|
|
601
|
+
reply.code(400).send({
|
|
602
|
+
error: "agent_requires_watch",
|
|
603
|
+
message: "`agent` requires `mode: \"watch\"` — nothing would observe the agent's writes",
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
agent = deps.apps?.[body.agent];
|
|
608
|
+
if (!agent) {
|
|
609
|
+
const offered = Object.keys(deps.apps ?? {});
|
|
610
|
+
reply.code(400).send({
|
|
611
|
+
error: "unknown_agent",
|
|
612
|
+
message:
|
|
613
|
+
`agent '${body.agent}' is not offered by this runner` +
|
|
614
|
+
(offered.length > 0 ? ` — offered: ${offered.join(", ")}` : ""),
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
// An agent nothing can reach is the same silent no-op as an agent nothing
|
|
619
|
+
// watches, one layer down: it would write the volume and never be asked to.
|
|
620
|
+
// The port is the operator's to declare, so the failure names their config
|
|
621
|
+
// rather than anything the caller can change.
|
|
622
|
+
if (agent.port === undefined) {
|
|
623
|
+
reply.code(400).send({
|
|
624
|
+
error: "agent_port_undeclared",
|
|
625
|
+
message:
|
|
626
|
+
`agent '${body.agent}' declares no port, so this runner cannot route it — ` +
|
|
627
|
+
`set RUNNER_APPS.${body.agent}.port to the port its image listens on`,
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (mode === "watch") {
|
|
634
|
+
// A suspended session holds no pod, so it does not consume the resource the
|
|
635
|
+
// ceiling exists to bound — and it is reclaimable, so counting it would let
|
|
636
|
+
// a few visitors who left hold every slot for the whole suspended TTL.
|
|
637
|
+
const live = deps.registry
|
|
638
|
+
.list()
|
|
639
|
+
.filter(
|
|
640
|
+
(e) => e.mode === "watch" && e.exitedAt === null && e.status.kind !== "suspended",
|
|
641
|
+
).length;
|
|
642
|
+
if (live >= deps.watch.maxSessions) {
|
|
643
|
+
reply.code(409).send({
|
|
644
|
+
error: "too_many_watch_sessions",
|
|
645
|
+
message: `runner is at its configured max of ${deps.watch.maxSessions} concurrent watch sessions`,
|
|
646
|
+
});
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const resolved = resolveApps(body.apps, entryRelative, body.ports ?? []);
|
|
652
|
+
if ("error" in resolved) {
|
|
653
|
+
reply.code(400).send(resolved.error);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// The agent's port shares the session's port space with the apps' — on
|
|
658
|
+
// kubernetes literally (the pod's containers share one network namespace, so
|
|
659
|
+
// two of them cannot bind the same port at all), and on docker because a
|
|
660
|
+
// no-proxy runner publishes both to the same host.
|
|
661
|
+
//
|
|
662
|
+
// The MANIFEST WINS. A client asks for an agent as a convenience, and on the
|
|
663
|
+
// editor's path does so on every run without the user choosing; refusing the
|
|
664
|
+
// whole session would mean an application that declares the operator's agent
|
|
665
|
+
// port — 8080, which applications routinely declare — could not be run at all,
|
|
666
|
+
// for a reason naming a container the user never requested and cannot decline.
|
|
667
|
+
// So the agent is dropped and the session starts without one, reported rather
|
|
668
|
+
// than silent: a client sees no `agent` endpoint on `running`, and the notice
|
|
669
|
+
// below says why.
|
|
670
|
+
let agentNotice: string | undefined;
|
|
671
|
+
if (agent?.port !== undefined) {
|
|
672
|
+
const clash = resolved.apps.find((a) =>
|
|
673
|
+
a.ports.some((p) => p.protocol === "tcp" && p.port === agent!.port),
|
|
674
|
+
);
|
|
675
|
+
if (clash) {
|
|
676
|
+
agentNotice =
|
|
677
|
+
`co-resident agent '${agent.name}' was not started: it listens on tcp port ${agent.port}, ` +
|
|
678
|
+
`which app '${clash.name}' declares`;
|
|
679
|
+
agent = undefined;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
157
683
|
return startWorkloadSession(
|
|
158
684
|
app,
|
|
159
685
|
deps,
|
|
160
686
|
{
|
|
161
687
|
bundle: body.bundle,
|
|
162
|
-
entryRelativePath: entryRelative,
|
|
163
688
|
env: body.env,
|
|
164
|
-
ports: body.ports ?? [],
|
|
165
689
|
config: body.config,
|
|
166
690
|
selfContained: false,
|
|
167
|
-
|
|
691
|
+
// A watch session always runs with the kernel debug stream on: that stream
|
|
692
|
+
// is the only place run outcomes exist, and parsing the merged PTY output
|
|
693
|
+
// would not be a contract.
|
|
694
|
+
inspect: mode === "watch" ? true : (body.inspect ?? false),
|
|
695
|
+
mode,
|
|
696
|
+
apps: resolved.apps,
|
|
697
|
+
agent,
|
|
168
698
|
},
|
|
169
699
|
reply,
|
|
700
|
+
agentNotice ? [agentNotice] : undefined,
|
|
170
701
|
);
|
|
171
702
|
}
|
package/src/server.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ioRoute } from "./routes/io.js";
|
|
|
12
12
|
import { probeRoute } from "./routes/probe.js";
|
|
13
13
|
import { sessionsRoute } from "./routes/sessions.js";
|
|
14
14
|
import { SessionRegistry } from "./session/registry.js";
|
|
15
|
+
import { WatchSupervisor } from "./session/watch-supervisor.js";
|
|
15
16
|
|
|
16
17
|
export interface ServerDeps {
|
|
17
18
|
backend: RunnerBackend;
|
|
@@ -52,7 +53,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
52
53
|
// default to `*` and let operators narrow via RUNNER_CORS_ORIGINS.
|
|
53
54
|
await app.register(cors, {
|
|
54
55
|
origin: deps.config.corsOrigins,
|
|
55
|
-
methods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
56
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
56
57
|
});
|
|
57
58
|
|
|
58
59
|
await app.register(websocket);
|
|
@@ -63,8 +64,20 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
63
64
|
maxSessions: deps.config.maxSessions,
|
|
64
65
|
exitTtlMs: deps.config.exitTtlMs,
|
|
65
66
|
replayBufferBytes: deps.config.replayBufferBytes,
|
|
67
|
+
suspendedTtlMs: deps.config.watch.suspendedTtlMs,
|
|
66
68
|
});
|
|
67
69
|
|
|
70
|
+
// The checkpoint timer and the idle reaper. Started unconditionally: it walks
|
|
71
|
+
// watch sessions only, and with watch disabled there are none.
|
|
72
|
+
const supervisor = new WatchSupervisor({
|
|
73
|
+
registry,
|
|
74
|
+
idleMs: deps.config.watch.idleMs,
|
|
75
|
+
checkpointMs: deps.config.watch.checkpointMs,
|
|
76
|
+
log: app.log,
|
|
77
|
+
});
|
|
78
|
+
supervisor.start();
|
|
79
|
+
app.addHook("onClose", async () => supervisor.stop());
|
|
80
|
+
|
|
68
81
|
// The app catalog is injected into the served capabilities document here, so
|
|
69
82
|
// what /v1/capabilities advertises and what the session route accepts can
|
|
70
83
|
// never drift — both come from `deps.apps`.
|
|
@@ -96,6 +109,14 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
96
109
|
// The capabilities document is the single source of the runner's terms;
|
|
97
110
|
// every session-creating route enforces what /v1/capabilities advertises.
|
|
98
111
|
terms: capabilitiesValue.terms,
|
|
112
|
+
// A co-resident agent IS an operator-predefined application — one that
|
|
113
|
+
// happens to share a pod — so it resolves against the same catalog.
|
|
114
|
+
apps: deps.apps,
|
|
115
|
+
watch: {
|
|
116
|
+
enabled: deps.config.watch.enabled,
|
|
117
|
+
maxSessions: deps.config.watch.maxSessions,
|
|
118
|
+
reloadLimitPerMinute: deps.config.watch.reloadLimitPerMinute,
|
|
119
|
+
},
|
|
99
120
|
}),
|
|
100
121
|
);
|
|
101
122
|
await app.register(
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
+
import type { ByteStreamTag } from "../contract.js";
|
|
2
|
+
|
|
1
3
|
export interface BufferedBytes {
|
|
2
4
|
seq: number;
|
|
3
5
|
bytes: Buffer;
|
|
6
|
+
/** Which stream produced these bytes. `tty` under a terminal attach, where
|
|
7
|
+
* there is genuinely one merged stream; `stdout` / `stderr` only where the
|
|
8
|
+
* transport really did separate them. */
|
|
9
|
+
stream: ByteStreamTag;
|
|
4
10
|
}
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -22,9 +28,9 @@ export class ByteRingBuffer {
|
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
push(bytes: Buffer): BufferedBytes {
|
|
31
|
+
push(bytes: Buffer, stream: ByteStreamTag = "tty"): BufferedBytes {
|
|
26
32
|
const seq = this.nextSeq++;
|
|
27
|
-
const entry: BufferedBytes = { seq, bytes };
|
|
33
|
+
const entry: BufferedBytes = { seq, bytes, stream };
|
|
28
34
|
this.entries.push(entry);
|
|
29
35
|
this.totalBytes += bytes.byteLength;
|
|
30
36
|
this.evict();
|