@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/backend.ts
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import type { DebugFrame } from "@telorun/debug-wire";
|
|
2
2
|
|
|
3
|
+
import type { ResolvedRunnerApp } from "./config.js";
|
|
3
4
|
import type {
|
|
4
5
|
AvailabilityReport,
|
|
6
|
+
ByteStreamTag,
|
|
7
|
+
IoMode,
|
|
5
8
|
PortMapping,
|
|
6
9
|
ProbeConfig,
|
|
7
10
|
ReachabilityState,
|
|
8
11
|
RunBundle,
|
|
12
|
+
RunnerEndpoint,
|
|
9
13
|
RunPhase,
|
|
10
14
|
RunStatus,
|
|
11
15
|
SessionConfig,
|
|
16
|
+
SessionMode,
|
|
17
|
+
WorkspaceChangeSet,
|
|
18
|
+
WorkspaceCheckpointFile,
|
|
19
|
+
WorkspaceTree,
|
|
12
20
|
} from "./contract.js";
|
|
13
21
|
|
|
14
22
|
/**
|
|
@@ -40,59 +48,140 @@ export interface RunnerBackend {
|
|
|
40
48
|
reapOrphans?(): Promise<void>;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/** Bundle-relative entry path, already traversal-normalized by core.
|
|
51
|
+
/** One application container the backend must stand up. */
|
|
52
|
+
export interface BackendAppSpec {
|
|
53
|
+
name: string;
|
|
54
|
+
/** Bundle-relative entry path, already traversal-normalized by core. Empty for
|
|
55
|
+
* a self-contained app session, where the image's own entrypoint runs. */
|
|
47
56
|
entryRelativePath: string;
|
|
48
|
-
env: Record<string, string>;
|
|
49
57
|
ports: PortMapping[];
|
|
58
|
+
io: IoMode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The DATA half of a workload launch — everything a start needs that is not a
|
|
63
|
+
* callback. Retained on the session entry so `resume` can build a fresh pod
|
|
64
|
+
* under the same session id from the same description, rather than a second,
|
|
65
|
+
* drifting copy of it.
|
|
66
|
+
*/
|
|
67
|
+
export interface WorkloadLaunch {
|
|
68
|
+
bundle: RunBundle;
|
|
69
|
+
env: Record<string, string>;
|
|
50
70
|
config: SessionConfig;
|
|
51
71
|
/** True for an operator-predefined app session (`POST /v1/apps/:name/sessions`):
|
|
52
72
|
* `config.image` is self-contained (app + controllers baked in), so the
|
|
53
73
|
* backend runs the image's own entrypoint and stages no bundle — `bundle`
|
|
54
74
|
* is an empty placeholder. */
|
|
55
75
|
selfContained: boolean;
|
|
56
|
-
/** When true, launch
|
|
76
|
+
/** When true, launch each app with `--inspect` and relay its kernel debug
|
|
57
77
|
* stream via `onDebug`. The inspect endpoint stays reachable only by the
|
|
58
|
-
* runner — never published outward.
|
|
78
|
+
* runner — never published outward. Always true for a watch session: that
|
|
79
|
+
* stream is where `run` events are projected from. */
|
|
59
80
|
inspect: boolean;
|
|
81
|
+
/** `run` (one pod, terminal on exit, today's shape) or `watch` (a workspace
|
|
82
|
+
* volume, `telo run --watch` per app, the pod outliving its runs). */
|
|
83
|
+
mode: SessionMode;
|
|
84
|
+
/** The applications to run, one container each. Never empty — core defaults a
|
|
85
|
+
* request with no `apps` to a single entry. */
|
|
86
|
+
apps: BackendAppSpec[];
|
|
87
|
+
/** The resolved catalog entry for a co-resident agent container, when one was
|
|
88
|
+
* requested. Its operator env goes on THAT container and nowhere else — the
|
|
89
|
+
* credential boundary used to be structural (two pods) and is now a code
|
|
90
|
+
* invariant (containers in one pod). */
|
|
91
|
+
agent?: ResolvedRunnerApp;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface BackendStartSpec extends WorkloadLaunch {
|
|
95
|
+
sessionId: string;
|
|
60
96
|
|
|
61
97
|
/** Emit a lifecycle status. The backend drives `starting` → `running` →
|
|
62
|
-
* terminal (`exited`/`failed`/`stopped`). */
|
|
98
|
+
* terminal (`exited`/`failed`/`stopped`), or `suspended` on an idle reap. */
|
|
63
99
|
onStatus(status: RunStatus): void;
|
|
64
100
|
/** Emit a progress message for a coming-up phase (build / provision / boot).
|
|
65
101
|
* Additive to status — surfaces what's happening while the session is still
|
|
66
|
-
* `starting
|
|
67
|
-
onProgress(phase: RunPhase, message: string, done?: boolean): void;
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
* `starting`. `app` is omitted for session-scoped provisioning. */
|
|
103
|
+
onProgress(phase: RunPhase, message: string, done?: boolean, app?: string): void;
|
|
104
|
+
/** Bytes from one app's terminal. `stream` is `tty` under a terminal attach
|
|
105
|
+
* and `stdout`/`stderr` only where the transport genuinely separated them. */
|
|
106
|
+
onOutput(app: string, chunk: Buffer, stream: ByteStreamTag): void;
|
|
107
|
+
/** A frame relayed from one app's kernel debug stream. Only called when
|
|
108
|
+
* `inspect` is true and the backend has connected to that app's endpoint. */
|
|
109
|
+
onDebug(app: string, frame: DebugFrame): void;
|
|
73
110
|
/** Report a declared port's reachability from the runner network — `checking`
|
|
74
111
|
* while the workload comes up, then `reachable`, or `unreachable` after a
|
|
75
112
|
* timeout. Surfaced on the editor's endpoint badge, not the log stream. */
|
|
76
|
-
onReachability(port: number, state: ReachabilityState): void;
|
|
113
|
+
onReachability(app: string, port: number, state: ReachabilityState): void;
|
|
114
|
+
/** One app's workload ended on its own. A run session's workload ending IS the
|
|
115
|
+
* session ending, so this is a watch-session concern: under `--watch` a
|
|
116
|
+
* finished run leaves the container up, and a container that goes away has
|
|
117
|
+
* died. Reported through the contract rather than by synthesizing a kernel
|
|
118
|
+
* frame — that stream's contract is "frames relayed from the workload", and a
|
|
119
|
+
* backend writing into it puts an event on the wire no kernel emitted. */
|
|
120
|
+
onRunEnded(app: string, outcome: { code?: number; reason?: string }): void;
|
|
121
|
+
/** An app's routable endpoint set changed after a reload re-read its declared
|
|
122
|
+
* ports. */
|
|
123
|
+
onEndpoints(
|
|
124
|
+
app: string,
|
|
125
|
+
change: {
|
|
126
|
+
added?: RunnerEndpoint[];
|
|
127
|
+
removed?: RunnerEndpoint[];
|
|
128
|
+
rejected?: Array<{ port: number; reason: string }>;
|
|
129
|
+
},
|
|
130
|
+
): void;
|
|
77
131
|
/** True once a user stop / shutdown has been requested — lets the backend
|
|
78
132
|
* classify a kill as `stopped` rather than `failed`. */
|
|
79
133
|
isUserStopped(): boolean;
|
|
80
134
|
}
|
|
81
135
|
|
|
82
136
|
export interface BackendSession {
|
|
83
|
-
/** Write bytes to
|
|
84
|
-
*
|
|
85
|
-
writeStdin(bytes: Uint8Array): void;
|
|
137
|
+
/** Write bytes to one app's stdin. A no-op once that workload has terminated,
|
|
138
|
+
* so callers need not track liveness. */
|
|
139
|
+
writeStdin(app: string, bytes: Uint8Array): void;
|
|
86
140
|
|
|
87
|
-
/** Resize
|
|
88
|
-
resize
|
|
141
|
+
/** Resize one app's PTY. A no-op for an app running under `io: "streams"` —
|
|
142
|
+
* the route rejects such a resize before it reaches here. */
|
|
143
|
+
resize(app: string, cols: number, rows: number): void;
|
|
89
144
|
|
|
90
145
|
/** Resolves after the workload terminates and its terminal status has been
|
|
91
146
|
* emitted via `onStatus`. Never rejects — terminal failures surface as a
|
|
92
|
-
* `failed` status.
|
|
147
|
+
* `failed` status. A watch session's workload outlives its runs, so this
|
|
148
|
+
* settles only on stop, suspend, or the pod deadline. */
|
|
93
149
|
readonly done: Promise<void>;
|
|
94
150
|
|
|
95
151
|
/** Force-stop the workload. Idempotent and safe to call after natural exit
|
|
96
152
|
* (a backend should treat an already-gone workload as a no-op). */
|
|
97
153
|
stop(): Promise<void>;
|
|
154
|
+
|
|
155
|
+
/** The workspace surface, present only on a watch session. Everything outside
|
|
156
|
+
* the pod writes through here; the agent (inside it) writes the volume with
|
|
157
|
+
* its own filesystem tools. */
|
|
158
|
+
readonly workspace?: WorkspaceAccess;
|
|
159
|
+
|
|
160
|
+
/** Re-run one app with no file change (`POST /v1/sessions/:id/reload`), by
|
|
161
|
+
* touching its entry manifest through the same path every other write takes.
|
|
162
|
+
* No signalling into the container, no shared PID namespace, no `exec` — RBAC
|
|
163
|
+
* is unchanged. Watch sessions only. */
|
|
164
|
+
reload?(app: string): Promise<void>;
|
|
165
|
+
|
|
166
|
+
/** Replace the running app set. A pod's container list is fixed at creation,
|
|
167
|
+
* so this checkpoints, deletes the pod and creates one with the new set —
|
|
168
|
+
* the suspend/resume path, reused because it has to be. */
|
|
169
|
+
setApps?(apps: BackendAppSpec[]): Promise<void>;
|
|
170
|
+
|
|
171
|
+
/** Delete the workload, keeping nothing but what the caller already
|
|
172
|
+
* checkpointed. `resume` builds a fresh one from that checkpoint. */
|
|
173
|
+
suspend?(): Promise<void>;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The `workspace` container's routes, proxied by the runner. Reading and
|
|
177
|
+
* applying a change set are the only two shapes: a single-file write route
|
|
178
|
+
* would be a second set of concurrency rules over the same directory. */
|
|
179
|
+
export interface WorkspaceAccess {
|
|
180
|
+
tree(): Promise<WorkspaceTree>;
|
|
181
|
+
readFile(path: string): Promise<{ content: string; size: number }>;
|
|
182
|
+
apply(changes: WorkspaceChangeSet): Promise<{ written: number; deleted: number }>;
|
|
183
|
+
/** Whole-tree pull for the checkpoint timer and for suspend. Whole tree rather
|
|
184
|
+
* than a delta log: a manifest workspace is small, and one shape is easier to
|
|
185
|
+
* reason about than a replay that has to be correct. */
|
|
186
|
+
snapshot(): Promise<WorkspaceCheckpointFile[]>;
|
|
98
187
|
}
|
package/src/config.ts
CHANGED
|
@@ -15,6 +15,33 @@ export interface RunnerCoreConfig {
|
|
|
15
15
|
exitTtlMs: number;
|
|
16
16
|
replayBufferBytes: number;
|
|
17
17
|
corsOrigins: string[] | "*";
|
|
18
|
+
watch: WatchSessionConfig;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Watch-session policy. Off by default and separately ceilinged, because a watch
|
|
23
|
+
* session is a materially different resource shape from a run: a pod that
|
|
24
|
+
* outlives its runs, whose concurrency is bounded by simultaneous EDITORS rather
|
|
25
|
+
* than simultaneous runs. Inheriting a run session's ceilings would size it for
|
|
26
|
+
* the opposite assumption.
|
|
27
|
+
*/
|
|
28
|
+
export interface WatchSessionConfig {
|
|
29
|
+
/** Server-side gate. Watch sessions are never client-requestable when off. */
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
/** No SSE/WS subscriber for this long → suspend (pod deleted, checkpoint held). */
|
|
32
|
+
idleMs: number;
|
|
33
|
+
/** Pod deadline for a watch session. The agent and app containers share one
|
|
34
|
+
* pod, so one deadline covers both: take the longer (agent) ceiling and let
|
|
35
|
+
* idleness do the real work, or the conversation dies at an hour mid-turn. */
|
|
36
|
+
maxTtlSeconds: number;
|
|
37
|
+
maxSessions: number;
|
|
38
|
+
reloadLimitPerMinute: number;
|
|
39
|
+
/** How long a SUSPENDED record is retained before eviction. Deliberately not
|
|
40
|
+
* the pod deadline: that bounds a pod, so on its own nothing would ever evict
|
|
41
|
+
* a suspended record. */
|
|
42
|
+
suspendedTtlMs: number;
|
|
43
|
+
/** How often the runner pulls a whole-tree workspace snapshot. */
|
|
44
|
+
checkpointMs: number;
|
|
18
45
|
}
|
|
19
46
|
|
|
20
47
|
export class RunnerConfigError extends Error {}
|
|
@@ -110,6 +137,15 @@ export interface RunnerAppConfig {
|
|
|
110
137
|
/** Workload image pull policy (default `missing`); `always` keeps a moving
|
|
111
138
|
* tag like `latest-slim` fresh. */
|
|
112
139
|
pullPolicy?: PullPolicy;
|
|
140
|
+
/** The tcp port this image listens on, published by the runner when the entry
|
|
141
|
+
* is used as a session's co-resident `agent` — a client cannot supply it,
|
|
142
|
+
* because what gets published is the operator's decision, not the caller's.
|
|
143
|
+
* There is deliberately NO default: the catalog is pure operator
|
|
144
|
+
* configuration and the runner has no built-in knowledge of any specific
|
|
145
|
+
* app, so guessing 8080 would be exactly that knowledge. An entry without it
|
|
146
|
+
* still works as a standalone app session (where the client declares the
|
|
147
|
+
* ports it wants published); only `agent` use requires it. */
|
|
148
|
+
port?: number;
|
|
113
149
|
title?: string;
|
|
114
150
|
description?: string;
|
|
115
151
|
}
|
|
@@ -120,6 +156,9 @@ export interface ResolvedRunnerApp {
|
|
|
120
156
|
image: string;
|
|
121
157
|
env: Record<string, string>;
|
|
122
158
|
pullPolicy: PullPolicy;
|
|
159
|
+
/** See {@link RunnerAppConfig.port} — undefined when the operator declared
|
|
160
|
+
* none, which is what makes the entry unusable as an `agent`. */
|
|
161
|
+
port?: number;
|
|
123
162
|
title?: string;
|
|
124
163
|
description?: string;
|
|
125
164
|
}
|
|
@@ -179,6 +218,12 @@ function validateAppEntry(name: string, value: unknown): RunnerAppConfig {
|
|
|
179
218
|
if (entry.pullPolicy !== undefined && !PULL_POLICIES.includes(entry.pullPolicy as string)) {
|
|
180
219
|
fail(`has an invalid 'pullPolicy' — expected one of ${PULL_POLICIES.join(", ")}.`);
|
|
181
220
|
}
|
|
221
|
+
if (
|
|
222
|
+
entry.port !== undefined &&
|
|
223
|
+
(typeof entry.port !== "number" || !Number.isInteger(entry.port) || entry.port < 1 || entry.port > 65535)
|
|
224
|
+
) {
|
|
225
|
+
fail("has an invalid 'port' — expected an integer in 1..65535.");
|
|
226
|
+
}
|
|
182
227
|
for (const key of ["title", "description"] as const) {
|
|
183
228
|
if (entry[key] !== undefined && typeof entry[key] !== "string") {
|
|
184
229
|
fail(`has an invalid '${key}' — expected a string.`);
|
|
@@ -187,6 +232,26 @@ function validateAppEntry(name: string, value: unknown): RunnerAppConfig {
|
|
|
187
232
|
return entry as unknown as RunnerAppConfig;
|
|
188
233
|
}
|
|
189
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Which catalog entries may be a session's co-resident `agent` — the ones that
|
|
237
|
+
* declare a `port`, because the session route refuses the rest and a runner
|
|
238
|
+
* must not advertise what it will reject.
|
|
239
|
+
*
|
|
240
|
+
* Derived here rather than in each runner's capabilities call because this IS
|
|
241
|
+
* the acceptance condition: the two used to be one list by construction
|
|
242
|
+
* (`Object.keys`), and adding a second condition to the route without adding it
|
|
243
|
+
* here is exactly how they drift. An operator upgrading with an existing
|
|
244
|
+
* catalog has no `port` anywhere — the field is new — so an unfiltered
|
|
245
|
+
* advertisement makes the editor attach an agent to every run and every run is
|
|
246
|
+
* refused.
|
|
247
|
+
*
|
|
248
|
+
* Per entry, not per catalog: declaring a port opts an app into co-resident
|
|
249
|
+
* use, omitting one leaves it launchable on its own.
|
|
250
|
+
*/
|
|
251
|
+
export function coResidentAgentNames(apps: Record<string, ResolvedRunnerApp>): string[] {
|
|
252
|
+
return Object.keys(apps).filter((name) => apps[name]?.port !== undefined);
|
|
253
|
+
}
|
|
254
|
+
|
|
190
255
|
/** The catalog runners pass to `buildServer`: `RUNNER_APPS` validated with
|
|
191
256
|
* defaults applied; empty when unset. The catalog is pure operator
|
|
192
257
|
* configuration — runner-core knows nothing about any specific app. */
|
|
@@ -199,6 +264,7 @@ export function loadResolvedApps(env: NodeJS.ProcessEnv): Record<string, Resolve
|
|
|
199
264
|
image: entry.image,
|
|
200
265
|
env: entry.env ?? {},
|
|
201
266
|
pullPolicy: entry.pullPolicy ?? "missing",
|
|
267
|
+
port: entry.port,
|
|
202
268
|
title: entry.title,
|
|
203
269
|
description: entry.description,
|
|
204
270
|
};
|
|
@@ -233,5 +299,37 @@ export function loadCoreConfig(
|
|
|
233
299
|
"RUNNER_REPLAY_BUFFER_BYTES",
|
|
234
300
|
),
|
|
235
301
|
corsOrigins: parseCorsOrigins(env.RUNNER_CORS_ORIGINS),
|
|
302
|
+
watch: loadWatchConfig(env),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function loadWatchConfig(env: NodeJS.ProcessEnv): WatchSessionConfig {
|
|
307
|
+
return {
|
|
308
|
+
enabled: parseBool(env.RUNNER_WATCH_SESSIONS, false, "RUNNER_WATCH_SESSIONS"),
|
|
309
|
+
idleMs:
|
|
310
|
+
parsePositiveInt(env.RUNNER_WATCH_IDLE_SECONDS, 300, "RUNNER_WATCH_IDLE_SECONDS") * 1000,
|
|
311
|
+
maxTtlSeconds: parsePositiveInt(
|
|
312
|
+
env.RUNNER_WATCH_MAX_TTL_SECONDS,
|
|
313
|
+
21600,
|
|
314
|
+
"RUNNER_WATCH_MAX_TTL_SECONDS",
|
|
315
|
+
),
|
|
316
|
+
maxSessions: parsePositiveInt(env.RUNNER_WATCH_MAX_SESSIONS, 8, "RUNNER_WATCH_MAX_SESSIONS"),
|
|
317
|
+
reloadLimitPerMinute: parsePositiveInt(
|
|
318
|
+
env.RUNNER_WATCH_RELOAD_LIMIT,
|
|
319
|
+
30,
|
|
320
|
+
"RUNNER_WATCH_RELOAD_LIMIT",
|
|
321
|
+
),
|
|
322
|
+
suspendedTtlMs:
|
|
323
|
+
parsePositiveInt(
|
|
324
|
+
env.RUNNER_WATCH_SUSPENDED_TTL_SECONDS,
|
|
325
|
+
86400,
|
|
326
|
+
"RUNNER_WATCH_SUSPENDED_TTL_SECONDS",
|
|
327
|
+
) * 1000,
|
|
328
|
+
checkpointMs:
|
|
329
|
+
parsePositiveInt(
|
|
330
|
+
env.RUNNER_WORKSPACE_CHECKPOINT_SECONDS,
|
|
331
|
+
30,
|
|
332
|
+
"RUNNER_WORKSPACE_CHECKPOINT_SECONDS",
|
|
333
|
+
) * 1000,
|
|
236
334
|
};
|
|
237
335
|
}
|
package/src/contract.ts
CHANGED
|
@@ -46,11 +46,30 @@ export interface RunnerTerms {
|
|
|
46
46
|
/** HTTP header carrying the accepted terms version on `POST /v1/sessions`. */
|
|
47
47
|
export const ACCEPTED_TERMS_HEADER = "x-telo-accepted-terms";
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* How an application container's byte channel is attached.
|
|
51
|
+
* - `tty` — one merged stream, as a terminal produces. Resize works;
|
|
52
|
+
* `CLICOLOR_FORCE` is injected.
|
|
53
|
+
* - `streams` — stdout and stderr separated at the source. Resize is rejected
|
|
54
|
+
* (meaningless with no PTY) and no colour is forced, so the app
|
|
55
|
+
* sees the environment it would see in production.
|
|
56
|
+
* Nothing is invented at the transport layer: docker's non-TTY attach is already
|
|
57
|
+
* a multiplexed stream carrying a per-frame stream id, and the kubernetes attach
|
|
58
|
+
* subresource without a TTY already gives separate stdout/stderr channels.
|
|
59
|
+
*/
|
|
60
|
+
export type IoMode = "tty" | "streams";
|
|
61
|
+
|
|
49
62
|
export interface RunnerFeatures {
|
|
50
|
-
/**
|
|
51
|
-
io:
|
|
63
|
+
/** Byte-channel attach modes this runner offers (`/v1/sessions/:id/io`). */
|
|
64
|
+
io: IoMode[];
|
|
52
65
|
/** Runner can publish workload ports back to the client. */
|
|
53
66
|
ports: boolean;
|
|
67
|
+
/** Watch sessions (`mode: "watch"`) are requestable. Server-gated: a runner
|
|
68
|
+
* with watch disabled advertises `false` and rejects the field. */
|
|
69
|
+
watch: boolean;
|
|
70
|
+
/** Catalog names admissible as a session's co-resident `agent`. Empty/absent
|
|
71
|
+
* when the operator configured none. */
|
|
72
|
+
agents?: string[];
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
/** An operator-predefined application the runner can launch by name. Only the
|
|
@@ -92,7 +111,14 @@ export type AvailabilityReport =
|
|
|
92
111
|
|
|
93
112
|
export interface RunBundle {
|
|
94
113
|
entryRelativePath: string;
|
|
95
|
-
files: Array<{
|
|
114
|
+
files: Array<{
|
|
115
|
+
relativePath: string;
|
|
116
|
+
contents: string;
|
|
117
|
+
/** `utf8` when omitted. A checkpoint re-seeds a resumed session through this
|
|
118
|
+
* same shape, and a workspace may hold a binary asset, so dropping anything
|
|
119
|
+
* that is not text would silently lose a file across a suspend. */
|
|
120
|
+
encoding?: "utf8" | "base64";
|
|
121
|
+
}>;
|
|
96
122
|
}
|
|
97
123
|
|
|
98
124
|
export type PortProtocol = "tcp" | "udp";
|
|
@@ -118,6 +144,42 @@ export interface RunnerEndpoint {
|
|
|
118
144
|
url?: string;
|
|
119
145
|
}
|
|
120
146
|
|
|
147
|
+
/**
|
|
148
|
+
* How long a session lives relative to its runs.
|
|
149
|
+
* - `run` — one run. The workload exits and the session is terminal. Today's
|
|
150
|
+
* behaviour, and the default.
|
|
151
|
+
* - `watch` — a workspace that runs continuously. Each application container
|
|
152
|
+
* runs `telo run --watch`, an edit costs a kernel reload rather
|
|
153
|
+
* than a pod, and a completed one-shot run leaves the session up.
|
|
154
|
+
*/
|
|
155
|
+
export type SessionMode = "run" | "watch";
|
|
156
|
+
|
|
157
|
+
/** The name a single-app session's one application takes when the request
|
|
158
|
+
* declares no `apps` — so every `run` / `debug` / `endpoints` event names an
|
|
159
|
+
* app, and no client needs two readings of the same stream. */
|
|
160
|
+
export const DEFAULT_APP_NAME = "app";
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* One application in a session — one container, one kernel, one watcher. Most
|
|
164
|
+
* workspaces have exactly one: an Application with several Services in its
|
|
165
|
+
* `targets:` is still one kernel. The multi-app case is two independent
|
|
166
|
+
* Applications, which genuinely cannot be merged (importing a `Telo.Application`
|
|
167
|
+
* is a hard error, so no manifest composes them).
|
|
168
|
+
*/
|
|
169
|
+
export interface SessionAppSpec {
|
|
170
|
+
/** Unique within the session and usable as a DNS label — it appears in the
|
|
171
|
+
* container name and in every run event. */
|
|
172
|
+
name: string;
|
|
173
|
+
/** Bundle-relative entry manifest this app runs. */
|
|
174
|
+
entryRelativePath: string;
|
|
175
|
+
/** Ports this app declares. Unique across the WHOLE session: session hosts are
|
|
176
|
+
* `<port>-<sessionId>.<base-domain>`, a single label, so two apps on one port
|
|
177
|
+
* would collide with nothing to tell them apart. */
|
|
178
|
+
ports?: PortMapping[];
|
|
179
|
+
/** Terminal or separated streams; defaults to `tty`. */
|
|
180
|
+
io?: IoMode;
|
|
181
|
+
}
|
|
182
|
+
|
|
121
183
|
export interface StartSessionRequest {
|
|
122
184
|
bundle: RunBundle;
|
|
123
185
|
env: Record<string, string>;
|
|
@@ -126,17 +188,60 @@ export interface StartSessionRequest {
|
|
|
126
188
|
/** Request the kernel debug stream. When true the runner launches the
|
|
127
189
|
* workload with `--inspect`, subscribes to the in-workload inspect endpoint
|
|
128
190
|
* (reachable only by the runner — never published outward), and relays each
|
|
129
|
-
* frame to the client as a `debug` {@link RunEvent}.
|
|
191
|
+
* frame to the client as a `debug` {@link RunEvent}. A watch session always
|
|
192
|
+
* runs with it on — that stream is where `run` events are projected from. */
|
|
130
193
|
inspect?: boolean;
|
|
194
|
+
/** Defaults to `run`. */
|
|
195
|
+
mode?: SessionMode;
|
|
196
|
+
/** Catalog name of a co-resident agent container. At most one per session,
|
|
197
|
+
* never per app: the agent's unit is the workspace, and two agents over one
|
|
198
|
+
* workspace would contend on the same files and split one conversation in
|
|
199
|
+
* half. Requires `mode: "watch"` — an agent with nothing watching its writes
|
|
200
|
+
* is a silent no-op. */
|
|
201
|
+
agent?: string;
|
|
202
|
+
/** The applications this session runs, one container each. Omitted, it
|
|
203
|
+
* defaults to a single app named `app` on the bundle's own entry with the
|
|
204
|
+
* request's `ports` — so a single-app session is written exactly as before. */
|
|
205
|
+
apps?: SessionAppSpec[];
|
|
131
206
|
}
|
|
132
207
|
|
|
208
|
+
/**
|
|
209
|
+
* The SESSION's status — how the session itself is doing, not how any one run
|
|
210
|
+
* ended. The two are separate nouns on the same stream: a one-shot app finishing
|
|
211
|
+
* in a watch session emits `run.completed` and leaves the session `running`, so
|
|
212
|
+
* the next edit starts that app's next generation.
|
|
213
|
+
*
|
|
214
|
+
* `exited` belongs to a `run` session only, where the session IS the run.
|
|
215
|
+
*/
|
|
133
216
|
export type RunStatus =
|
|
134
217
|
| { kind: "starting" }
|
|
135
|
-
/** `inspectUrl` is the kernel
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
218
|
+
/** Every application container is up. `inspectUrl` is the kernel
|
|
219
|
+
* debug/inspection UI fronted by a proxy (set only when the session ran with
|
|
220
|
+
* `inspect` and the runner has a public base URL); absent when the inspect
|
|
221
|
+
* endpoint isn't externally reachable.
|
|
222
|
+
*
|
|
223
|
+
* `agent` is where this session's co-resident agent answers, present only on
|
|
224
|
+
* a session that requested one AND that this runner could route. An ENDPOINT
|
|
225
|
+
* rather than a URL string because the no-proxy case is real — a docker
|
|
226
|
+
* runner publishing to the host knows the port and not the hostname the
|
|
227
|
+
* client reached it by, exactly as for an app's endpoint, so the client
|
|
228
|
+
* fills an empty `host` from its own base URL. Carried here and not in
|
|
229
|
+
* `endpoints` because the agent is session infrastructure: `endpoints` are
|
|
230
|
+
* the ports the APPLICATIONS declared, and merging the two would make an
|
|
231
|
+
* operator-run container look like part of the user's manifest. */
|
|
232
|
+
| {
|
|
233
|
+
kind: "running";
|
|
234
|
+
endpoints?: RunnerEndpoint[];
|
|
235
|
+
inspectUrl?: string;
|
|
236
|
+
agent?: RunnerEndpoint;
|
|
237
|
+
}
|
|
139
238
|
| { kind: "exited"; code: number }
|
|
239
|
+
/** Reaped for idleness: the pod is gone, the workspace checkpoint is held, and
|
|
240
|
+
* `POST /v1/sessions/:id/resume` brings it back under the same session id.
|
|
241
|
+
* NOT terminal — nothing is evicted on it. Best-effort by design: the runner
|
|
242
|
+
* holds the checkpoint in memory, so a runner restart loses it and the editor
|
|
243
|
+
* (which holds the authoritative workspace) re-seeds from its own copy. */
|
|
244
|
+
| { kind: "suspended" }
|
|
140
245
|
| { kind: "failed"; message: string }
|
|
141
246
|
| { kind: "stopped" };
|
|
142
247
|
|
|
@@ -152,21 +257,113 @@ export type RunPhase = "build" | "provision" | "boot";
|
|
|
152
257
|
* (spinner → ok / error) instead of an app-log line. */
|
|
153
258
|
export type ReachabilityState = "checking" | "reachable" | "unreachable";
|
|
154
259
|
|
|
260
|
+
/** What started one generation of an application.
|
|
261
|
+
* - `initial` — the session came up
|
|
262
|
+
* - `watch` — a file changed under the app's watcher
|
|
263
|
+
* - `manual` — `POST /v1/sessions/:id/reload`
|
|
264
|
+
* - `resume` — a suspended session was brought back, or the app set changed */
|
|
265
|
+
export type RunTrigger = "initial" | "watch" | "manual" | "resume";
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A RUN outcome — one per app per reload generation, distinct from the session
|
|
269
|
+
* status above. `generation` is monotonic per app and starts at 1; it is counted
|
|
270
|
+
* by the runner from the kernel lifecycle events already on the debug stream, so
|
|
271
|
+
* it asks the kernel for nothing.
|
|
272
|
+
*/
|
|
273
|
+
export type RunOutcomeEvent =
|
|
274
|
+
| { type: "run"; app: string; generation: number; phase: "started"; trigger: RunTrigger }
|
|
275
|
+
| {
|
|
276
|
+
type: "run";
|
|
277
|
+
app: string;
|
|
278
|
+
generation: number;
|
|
279
|
+
phase: "completed";
|
|
280
|
+
code: number;
|
|
281
|
+
durationMs?: number;
|
|
282
|
+
}
|
|
283
|
+
| { type: "run"; app: string; generation: number; phase: "failed"; reason: string };
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Workload output does NOT travel this channel, in either mode — it goes over the
|
|
287
|
+
* byte channel (`/io`), which exists precisely because per-chunk events are
|
|
288
|
+
* wasteful for high-volume output. The `stdout` / `stderr` variants that used to
|
|
289
|
+
* be declared here were emitted by nothing: a contract in shape only.
|
|
290
|
+
*/
|
|
155
291
|
export type RunEvent =
|
|
156
|
-
| { type: "stdout"; chunk: string }
|
|
157
|
-
| { type: "stderr"; chunk: string }
|
|
158
292
|
| { type: "status"; status: RunStatus }
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
| { type: "
|
|
293
|
+
/** `app` is absent for session-scoped provisioning (scheduling, image pull) and
|
|
294
|
+
* present once the message belongs to one application. */
|
|
295
|
+
| { type: "progress"; app?: string; phase: RunPhase; message: string; done?: boolean }
|
|
296
|
+
/** A frame relayed from one app's kernel debug stream (kernel event or log
|
|
297
|
+
* line). Only emitted when the session was started with `inspect`. */
|
|
298
|
+
| { type: "debug"; app: string; frame: DebugFrame }
|
|
299
|
+
/** A reachability transition for one declared port of one app. */
|
|
300
|
+
| { type: "reachability"; app: string; port: number; state: ReachabilityState }
|
|
301
|
+
| RunOutcomeEvent
|
|
302
|
+
/** An app's declared port set changed on reload and the runner re-patched the
|
|
303
|
+
* Service and Ingress. Without this the app binds the new port inside the pod
|
|
304
|
+
* and is simply unreachable: no ingress, no error, no event. */
|
|
305
|
+
| {
|
|
306
|
+
type: "endpoints";
|
|
307
|
+
app: string;
|
|
308
|
+
added?: RunnerEndpoint[];
|
|
309
|
+
removed?: RunnerEndpoint[];
|
|
310
|
+
/** A declared port that could not be routed (it collides with another app
|
|
311
|
+
* in this session). Reported, never dropped. */
|
|
312
|
+
rejected?: Array<{ port: number; reason: string }>;
|
|
313
|
+
};
|
|
165
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Terminal = the session is over and the registry may schedule eviction.
|
|
317
|
+
* `suspended` is deliberately NOT terminal: the session record and its workspace
|
|
318
|
+
* checkpoint outlive the pod, and `resume` brings it back under the same id.
|
|
319
|
+
*/
|
|
166
320
|
export function isTerminal(status: RunStatus): boolean {
|
|
167
321
|
return status.kind === "exited" || status.kind === "failed" || status.kind === "stopped";
|
|
168
322
|
}
|
|
169
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Which stream a byte-channel chunk came from. Under `io: "tty"` every chunk is
|
|
326
|
+
* `tty` — the tag never asserts a split that does not exist, which is exactly the
|
|
327
|
+
* failure the deleted `stdout` / `stderr` run events represented.
|
|
328
|
+
*/
|
|
329
|
+
export type ByteStreamTag = "tty" | "stdout" | "stderr";
|
|
330
|
+
|
|
331
|
+
/** One file in a workspace snapshot: its path and the sha256 of its bytes.
|
|
332
|
+
* Hashing content — rather than comparing sizes or timestamps — is what makes
|
|
333
|
+
* two snapshots diff into an exact change set. */
|
|
334
|
+
export interface WorkspaceFileEntry {
|
|
335
|
+
path: string;
|
|
336
|
+
hash: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export interface WorkspaceTree {
|
|
340
|
+
files: WorkspaceFileEntry[];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* An explicit write/delete list rather than a whole-tree PUT: a deletion has to
|
|
345
|
+
* be expressible, and a whole-tree PUT can only express it by treating absence as
|
|
346
|
+
* intent. A one-file save is a change set of one — there is deliberately no
|
|
347
|
+
* single-file write route, which would be a second set of concurrency rules.
|
|
348
|
+
*/
|
|
349
|
+
export interface WorkspaceChangeSet {
|
|
350
|
+
write?: Array<{ path: string; content: string; encoding?: "utf8" | "base64" }>;
|
|
351
|
+
delete?: string[];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export interface WorkspaceApplyResult {
|
|
355
|
+
written: number;
|
|
356
|
+
deleted: number;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** One file of a checkpoint — a tree entry plus the bytes needed to re-seed a
|
|
360
|
+
* fresh pod. Text stays `utf8`; anything that is not valid text is carried
|
|
361
|
+
* `base64`, so a binary asset survives a suspend/resume round trip. */
|
|
362
|
+
export interface WorkspaceCheckpointFile extends WorkspaceFileEntry {
|
|
363
|
+
content: string;
|
|
364
|
+
encoding: "utf8" | "base64";
|
|
365
|
+
}
|
|
366
|
+
|
|
170
367
|
/**
|
|
171
368
|
* Stages a session start can fail at. The docker/k8s backends share the
|
|
172
369
|
* vocabulary; not every stage applies to every backend (`pull`/`inspect` are
|