@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.
Files changed (98) hide show
  1. package/README.md +33 -2
  2. package/dist/backend.d.ts +107 -23
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/config.d.ts +56 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +36 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/contract.d.ts +210 -15
  9. package/dist/contract.d.ts.map +1 -1
  10. package/dist/contract.js +9 -0
  11. package/dist/contract.js.map +1 -1
  12. package/dist/debug/ports-resolved.d.ts +26 -0
  13. package/dist/debug/ports-resolved.d.ts.map +1 -0
  14. package/dist/debug/ports-resolved.js +42 -0
  15. package/dist/debug/ports-resolved.js.map +1 -0
  16. package/dist/debug/relay.d.ts.map +1 -1
  17. package/dist/debug/relay.js +34 -6
  18. package/dist/debug/relay.js.map +1 -1
  19. package/dist/debug/run-projection.d.ts +53 -0
  20. package/dist/debug/run-projection.d.ts.map +1 -0
  21. package/dist/debug/run-projection.js +115 -0
  22. package/dist/debug/run-projection.js.map +1 -0
  23. package/dist/index.d.ts +8 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +7 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/routes/apps.d.ts +1 -1
  28. package/dist/routes/apps.d.ts.map +1 -1
  29. package/dist/routes/apps.js +14 -4
  30. package/dist/routes/apps.js.map +1 -1
  31. package/dist/routes/io.d.ts.map +1 -1
  32. package/dist/routes/io.js +42 -9
  33. package/dist/routes/io.js.map +1 -1
  34. package/dist/routes/session-start.d.ts +18 -13
  35. package/dist/routes/session-start.d.ts.map +1 -1
  36. package/dist/routes/session-start.js +95 -14
  37. package/dist/routes/session-start.js.map +1 -1
  38. package/dist/routes/sessions.d.ts +11 -1
  39. package/dist/routes/sessions.d.ts.map +1 -1
  40. package/dist/routes/sessions.js +465 -5
  41. package/dist/routes/sessions.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +21 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/session/byte-ring-buffer.d.ts +6 -1
  46. package/dist/session/byte-ring-buffer.d.ts.map +1 -1
  47. package/dist/session/byte-ring-buffer.js +2 -2
  48. package/dist/session/byte-ring-buffer.js.map +1 -1
  49. package/dist/session/registry.d.ts +144 -13
  50. package/dist/session/registry.d.ts.map +1 -1
  51. package/dist/session/registry.js +166 -25
  52. package/dist/session/registry.js.map +1 -1
  53. package/dist/session/watch-supervisor.d.ts +42 -0
  54. package/dist/session/watch-supervisor.d.ts.map +1 -0
  55. package/dist/session/watch-supervisor.js +99 -0
  56. package/dist/session/watch-supervisor.js.map +1 -0
  57. package/dist/session/workspace-app.d.ts +4 -0
  58. package/dist/session/workspace-app.d.ts.map +1 -0
  59. package/dist/session/workspace-app.js +26 -0
  60. package/dist/session/workspace-app.js.map +1 -0
  61. package/dist/session/workspace-client.d.ts +33 -0
  62. package/dist/session/workspace-client.d.ts.map +1 -0
  63. package/dist/session/workspace-client.js +62 -0
  64. package/dist/session/workspace-client.js.map +1 -0
  65. package/dist/session/workspace-marker.d.ts +25 -0
  66. package/dist/session/workspace-marker.d.ts.map +1 -0
  67. package/dist/session/workspace-marker.js +35 -0
  68. package/dist/session/workspace-marker.js.map +1 -0
  69. package/dist/sse/channel.d.ts.map +1 -1
  70. package/dist/sse/channel.js +4 -0
  71. package/dist/sse/channel.js.map +1 -1
  72. package/package.json +4 -3
  73. package/src/app-catalog.test.ts +49 -1
  74. package/src/backend.ts +111 -22
  75. package/src/config.ts +98 -0
  76. package/src/contract.ts +213 -16
  77. package/src/debug/ports-resolved.test.ts +66 -0
  78. package/src/debug/ports-resolved.ts +43 -0
  79. package/src/debug/relay.ts +32 -4
  80. package/src/debug/run-projection.test.ts +155 -0
  81. package/src/debug/run-projection.ts +122 -0
  82. package/src/index.ts +20 -1
  83. package/src/routes/apps.ts +14 -5
  84. package/src/routes/io.ts +58 -12
  85. package/src/routes/session-start.ts +115 -27
  86. package/src/routes/sessions.ts +538 -7
  87. package/src/server.ts +22 -1
  88. package/src/session/byte-ring-buffer.ts +8 -2
  89. package/src/session/registry.ts +288 -28
  90. package/src/session/ring-buffer.test.ts +4 -3
  91. package/src/session/watch-lifetime.test.ts +203 -0
  92. package/src/session/watch-supervisor.ts +112 -0
  93. package/src/session/workspace-app.ts +27 -0
  94. package/src/session/workspace-client.ts +80 -0
  95. package/src/session/workspace-marker.test.ts +35 -0
  96. package/src/session/workspace-marker.ts +39 -0
  97. package/src/sse/channel.ts +5 -0
  98. package/workspace-app/telo.yaml +228 -0
package/src/routes/io.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from "fastify";
2
2
  import type { WebSocket } from "@fastify/websocket";
3
3
 
4
- import { isTerminal } from "../contract.js";
4
+ import { isTerminal, type ByteStreamTag } from "../contract.js";
5
5
  import type { BufferedBytes } from "../session/byte-ring-buffer.js";
6
6
  import type { SessionRegistry } from "../session/registry.js";
7
7
 
@@ -12,6 +12,11 @@ export interface IoRouteDeps {
12
12
 
13
13
  const RESIZE_THROTTLE_MS = 50;
14
14
  const SEQ_PREFIX_BYTES = 4;
15
+ const STREAM_TAG_BYTES = 1;
16
+ /** Wire codes for the per-frame stream tag. A tag rather than a labelled merged
17
+ * stream: under `io: "streams"` the transport really did separate stdout from
18
+ * stderr, and under `tty` it really did not. */
19
+ const STREAM_CODES: Record<ByteStreamTag, number> = { tty: 0, stdout: 1, stderr: 2 };
15
20
  /** Upper bound on cols/rows accepted from the client. xterm + a fit addon
16
21
  * produce values in the low thousands at extreme zoom; anything past this
17
22
  * is either nonsense or a malicious client trying to feed
@@ -28,7 +33,7 @@ interface ControlFrame {
28
33
 
29
34
  export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
30
35
  return async (app: FastifyInstance) => {
31
- app.get<{ Params: { id: string }; Querystring: { lastSeq?: string } }>(
36
+ app.get<{ Params: { id: string }; Querystring: IoQuery }>(
32
37
  "/v1/sessions/:id/io",
33
38
  { websocket: true },
34
39
  (socket, req) => handleIo(socket, req, deps),
@@ -36,9 +41,16 @@ export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
36
41
  };
37
42
  }
38
43
 
44
+ interface IoQuery {
45
+ lastSeq?: string;
46
+ /** Which application's terminal to attach to. Required whenever the session
47
+ * runs more than one app — there is no defensible default among several. */
48
+ app?: string;
49
+ }
50
+
39
51
  function handleIo(
40
52
  socket: WebSocket,
41
- req: FastifyRequest<{ Params: { id: string }; Querystring: { lastSeq?: string } }>,
53
+ req: FastifyRequest<{ Params: { id: string }; Querystring: IoQuery }>,
42
54
  deps: IoRouteDeps,
43
55
  ): void {
44
56
  // Origin allowlist runs INSIDE the handler (post-handshake) rather than
@@ -61,6 +73,24 @@ function handleIo(
61
73
  return;
62
74
  }
63
75
 
76
+ // Resolve which app's terminal this attachment is for. An explicit name must
77
+ // exist; an omitted one is only defensible on a single-app session.
78
+ const requestedApp = req.query.app?.trim() || undefined;
79
+ const channel = requestedApp
80
+ ? entry.apps.get(requestedApp)
81
+ : deps.registry.soleApp(entry);
82
+ if (!channel) {
83
+ closeWith(
84
+ socket,
85
+ 4404,
86
+ requestedApp
87
+ ? `session runs no app named '${requestedApp}'`
88
+ : `session runs ${entry.apps.size} apps — ?app= is required`,
89
+ );
90
+ return;
91
+ }
92
+ const appName = channel.name;
93
+
64
94
  const lastSeq = parseLastSeq(req.query.lastSeq);
65
95
 
66
96
  // Subscribe BEFORE snapshotting the replay buffer. This is load-bearing:
@@ -71,7 +101,7 @@ function handleIo(
71
101
  // the handler switches to direct-send mode.
72
102
  let mode: "deferred" | "direct" = "deferred";
73
103
  const liveQueue: BufferedBytes[] = [];
74
- const unsubscribe = deps.registry.subscribeBytes(sessionId, (buffered) => {
104
+ const unsubscribe = deps.registry.subscribeBytes(sessionId, appName, (buffered) => {
75
105
  if (socket.readyState !== socket.OPEN) return;
76
106
  if (mode === "deferred") {
77
107
  liveQueue.push(buffered);
@@ -80,7 +110,7 @@ function handleIo(
80
110
  }
81
111
  });
82
112
 
83
- const { entries, hasGap } = entry.byteBuffer.replay(lastSeq);
113
+ const { entries, hasGap } = channel.byteBuffer.replay(lastSeq);
84
114
 
85
115
  // If the session is already terminal AND the byte buffer holds nothing
86
116
  // newer than what the client already saw, there's no live channel to
@@ -91,8 +121,9 @@ function handleIo(
91
121
  return;
92
122
  }
93
123
 
94
- // Confirm the resume point so the client can validate its own bookkeeping.
95
- sendJson(socket, { type: "seq", seq: lastSeq });
124
+ // Confirm the resume point and which attachment this is, so a client knows
125
+ // whether to offer resize before it sends one.
126
+ sendJson(socket, { type: "seq", seq: lastSeq, app: appName, io: channel.io });
96
127
  if (hasGap) {
97
128
  sendJson(socket, { type: "gap", reason: "buffer_evicted" });
98
129
  }
@@ -125,7 +156,7 @@ function handleIo(
125
156
  if (!next) return;
126
157
  // Session may have exited between schedule and flush; backends treat
127
158
  // resize on a gone workload as a no-op.
128
- entry.session?.resize(next.cols, next.rows);
159
+ entry.session?.resize(appName, next.cols, next.rows);
129
160
  };
130
161
 
131
162
  socket.on("message", (raw, isBinary) => {
@@ -133,7 +164,7 @@ function handleIo(
133
164
  const session = entry.session;
134
165
  if (!session) return;
135
166
  const buf = raw instanceof Buffer ? raw : Buffer.from(raw as ArrayBuffer);
136
- session.writeStdin(buf);
167
+ session.writeStdin(appName, buf);
137
168
  return;
138
169
  }
139
170
  const text = raw.toString();
@@ -144,6 +175,13 @@ function handleIo(
144
175
  return;
145
176
  }
146
177
  if (parsed.type === "resize") {
178
+ // A resize is meaningless with no PTY — rejected rather than silently
179
+ // dropped, so a client attached to a `streams` app learns it must not
180
+ // wire its terminal's resize through.
181
+ if (channel.io !== "tty") {
182
+ sendJson(socket, { type: "error", error: "resize_unsupported", io: channel.io });
183
+ return;
184
+ }
147
185
  const cols = clampDimension(parsed.cols);
148
186
  const rows = clampDimension(parsed.rows);
149
187
  if (cols === null || rows === null) return;
@@ -190,7 +228,12 @@ function handleIo(
190
228
  drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
191
229
  }
192
230
 
231
+ // A byte-channel attachment counts as a live client: a session whose terminal
232
+ // someone is watching is not idle, even with no SSE stream open.
233
+ const releaseSubscriber = deps.registry.addSubscriber(sessionId);
234
+
193
235
  const cleanup = (): void => {
236
+ releaseSubscriber();
194
237
  unsubscribe();
195
238
  unsubscribeStatus();
196
239
  if (resizeTimer) clearTimeout(resizeTimer);
@@ -200,14 +243,17 @@ function handleIo(
200
243
  socket.on("error", cleanup);
201
244
  }
202
245
 
203
- /** Wire format for a binary frame: `[seq:4 BE][payload:N]`. The 4-byte
246
+ /** Wire format for a binary frame: `[seq:4 BE][stream:1][payload:N]`. The 4-byte
204
247
  * prefix lets the client de-sync detect — it knows the authoritative seq
205
248
  * for every byte received, instead of inferring from frame count. Replay
206
- * duplicates and reconnect resumes both lean on this. */
249
+ * duplicates and reconnect resumes both lean on this. The stream byte says
250
+ * which source produced the payload; it is `tty` under a terminal attach and
251
+ * never asserts a split the transport did not make. */
207
252
  function sendBytesFrame(socket: WebSocket, buffered: BufferedBytes): void {
208
253
  if (socket.readyState !== socket.OPEN) return;
209
- const prefix = Buffer.alloc(SEQ_PREFIX_BYTES);
254
+ const prefix = Buffer.alloc(SEQ_PREFIX_BYTES + STREAM_TAG_BYTES);
210
255
  prefix.writeUInt32BE(buffered.seq, 0);
256
+ prefix.writeUInt8(STREAM_CODES[buffered.stream] ?? 0, SEQ_PREFIX_BYTES);
211
257
  try {
212
258
  socket.send(Buffer.concat([prefix, buffered.bytes]));
213
259
  } catch {
@@ -1,17 +1,19 @@
1
1
  import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
2
2
 
3
3
  import { isEventFrame } from "@telorun/debug-wire";
4
- import type { RunnerBackend } from "../backend.js";
4
+ import type { RunnerBackend, WorkloadLaunch } from "../backend.js";
5
+ import { RunProjection } from "../debug/run-projection.js";
5
6
  import {
6
7
  ACCEPTED_TERMS_HEADER,
7
8
  SessionStartError,
8
- type PortMapping,
9
- type RunBundle,
10
9
  type RunnerTerms,
11
- type SessionConfig,
12
10
  } from "../contract.js";
13
11
  import { generateSessionId } from "../session/session-id.js";
14
- import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
12
+ import {
13
+ SessionLimitError,
14
+ type SessionEntry,
15
+ type SessionRegistry,
16
+ } from "../session/registry.js";
15
17
 
16
18
  /** JSON Schema for the `ports` body field, shared by every session-creating
17
19
  * route so bundle and app sessions validate port mappings identically. */
@@ -58,15 +60,7 @@ export interface WorkloadStartDeps {
58
60
  defaultRegistryUrl?: string;
59
61
  }
60
62
 
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
- }
63
+ export type WorkloadStartArgs = WorkloadLaunch;
70
64
 
71
65
  /**
72
66
  * The session-creation leaf shared by `POST /v1/sessions` (bundle sessions) and
@@ -81,12 +75,22 @@ export async function startWorkloadSession(
81
75
  deps: WorkloadStartDeps,
82
76
  args: WorkloadStartArgs,
83
77
  reply: FastifyReply,
78
+ /** Things the route decided about this session that the client would
79
+ * otherwise have to infer from an absence — an agent dropped because its
80
+ * port collided with an application's. Emitted on the stream, because the
81
+ * 201 is sent before the workload starts and has nowhere to carry them. */
82
+ notices?: string[],
84
83
  ): Promise<void> {
85
84
  const sessionId = generateSessionId();
86
85
 
87
86
  let entry: ReturnType<SessionRegistry["register"]>;
88
87
  try {
89
- entry = deps.registry.register({ sessionId });
88
+ entry = deps.registry.register({
89
+ sessionId,
90
+ mode: args.mode,
91
+ agent: args.agent?.name,
92
+ apps: args.apps.map((a) => ({ name: a.name, io: a.io, ports: a.ports })),
93
+ });
90
94
  } catch (err) {
91
95
  if (err instanceof SessionLimitError) {
92
96
  reply.code(409).send({ error: "too_many_sessions", message: err.message });
@@ -101,10 +105,11 @@ export async function startWorkloadSession(
101
105
  // whitespace from an editor input doesn't flow into the workload.
102
106
  const configRegistryUrl = args.config.registryUrl?.trim() || undefined;
103
107
  const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
104
- const sessionEnv =
108
+ const sessionEnv = withoutCacheOverride(
105
109
  registryUrl && !("TELO_REGISTRY_URL" in args.env)
106
110
  ? { ...args.env, TELO_REGISTRY_URL: registryUrl }
107
- : args.env;
111
+ : args.env,
112
+ );
108
113
 
109
114
  // Respond as soon as the session is registered — BEFORE the backend starts.
110
115
  // `backend.start()` now spans the on-cluster image build and pod bring-up,
@@ -119,29 +124,96 @@ export async function startWorkloadSession(
119
124
  createdAt: entry.createdAt.toISOString(),
120
125
  });
121
126
 
127
+ for (const message of notices ?? []) {
128
+ deps.registry.emit(sessionId, { type: "progress", phase: "provision", message });
129
+ }
130
+
131
+ // Every app's first generation is attributed to the session coming up; a
132
+ // reload after that is a watch reload unless a route says otherwise.
133
+ const projection = new RunProjection(deps.registry, sessionId);
134
+ projection.expectAll("initial");
135
+ // Parked on the entry so the reload / resume routes can attribute the
136
+ // generation they are about to cause.
137
+ entry.attribution = projection;
138
+
139
+ const launch: WorkloadLaunch = { ...args, env: sessionEnv };
140
+ entry.launch = launch;
141
+ launchWorkload(app, deps, launch, entry);
142
+ }
143
+
144
+ /**
145
+ * Wire a backend workload to an EXISTING registry entry and start it in the
146
+ * background. Shared by session creation and by `resume`, which builds a fresh
147
+ * pod from the checkpoint under the same session id — the callback wiring is the
148
+ * contract's, so a second copy of it would be a second place run outcomes,
149
+ * output routing and the credential boundary could drift.
150
+ */
151
+ export function launchWorkload(
152
+ app: FastifyInstance,
153
+ deps: WorkloadStartDeps,
154
+ args: WorkloadStartArgs,
155
+ entry: SessionEntry,
156
+ ): void {
157
+ const sessionId = entry.sessionId;
158
+ const projection = entry.attribution;
159
+
122
160
  deps.backend
123
161
  .start({
124
162
  sessionId,
125
163
  bundle: args.bundle,
126
- entryRelativePath: args.entryRelativePath,
127
- env: sessionEnv,
128
- ports: args.ports,
164
+ env: args.env,
129
165
  config: args.config,
130
166
  selfContained: args.selfContained,
131
167
  inspect: args.inspect,
168
+ mode: args.mode,
169
+ apps: args.apps,
170
+ agent: args.agent,
132
171
  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),
172
+ onProgress: (phase, message, done, app) =>
173
+ deps.registry.emit(sessionId, { type: "progress", app, phase, message, done }),
174
+ onOutput: (app, chunk, stream) => deps.registry.pushBytes(sessionId, app, chunk, stream),
136
175
  // Relay only kernel *event* frames to the client. stdout/stderr already
137
176
  // arrive over the byte channel (onOutput), so forwarding log frames would
138
177
  // double the traffic and let log spam evict lifecycle events from the
139
178
  // byte-capped replay buffer. The editor discards relayed logs anyway.
140
- onDebug: (frame) => {
141
- if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
179
+ //
180
+ // The projection sees EVERY frame, including the ones not relayed — run
181
+ // outcomes are derived from lifecycle events, not from what a client
182
+ // happens to be shown.
183
+ onDebug: (appName, frame) => {
184
+ projection?.frame(appName, frame);
185
+ if (isEventFrame(frame)) {
186
+ deps.registry.emit(sessionId, { type: "debug", app: appName, frame });
187
+ }
188
+ },
189
+ // A watch session's app dying is a RUN outcome, not a session status: the
190
+ // rest of the session is still up, and the next edit starts the next
191
+ // generation. The projection owns generation identity, so the ending goes
192
+ // through it rather than being emitted directly.
193
+ onRunEnded: (appName, outcome) =>
194
+ projection?.endGeneration(appName, outcome) ??
195
+ deps.registry.finishGeneration(sessionId, appName, {
196
+ phase: "failed",
197
+ reason: outcome.reason ?? "workload ended",
198
+ }),
199
+ onReachability: (app, port, state) =>
200
+ deps.registry.emit(sessionId, { type: "reachability", app, port, state }),
201
+ onEndpoints: (appName, change) => {
202
+ // The registry's own channel is what `GET /v1/sessions/:id` reports, so
203
+ // the delta has to land there too — otherwise the session document keeps
204
+ // describing the port set the session STARTED with, forever.
205
+ const channel = entry.apps.get(appName);
206
+ if (channel) {
207
+ const removed = new Set(
208
+ (change.removed ?? []).map((e) => `${e.protocol}/${e.port}`),
209
+ );
210
+ channel.ports = [
211
+ ...channel.ports.filter((p) => !removed.has(`${p.protocol}/${p.port}`)),
212
+ ...(change.added ?? []).map((e) => ({ port: e.port, protocol: e.protocol })),
213
+ ];
214
+ }
215
+ deps.registry.emit(sessionId, { type: "endpoints", app: appName, ...change });
142
216
  },
143
- onReachability: (port, state) =>
144
- deps.registry.emit(sessionId, { type: "reachability", port, state }),
145
217
  isUserStopped: () => entry.userStopped,
146
218
  })
147
219
  .then(async (session) => {
@@ -172,3 +244,19 @@ export async function startWorkloadSession(
172
244
  deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
173
245
  });
174
246
  }
247
+
248
+ /**
249
+ * Drop a client-supplied `TELO_CACHE_DIR`.
250
+ *
251
+ * It OUTRANKS the `telo-workspace.yaml` marker the session seeds at its
252
+ * workspace root, so a client that sets it silently gives every app its own
253
+ * module cache — the exact thing the marker exists to prevent. Where a cache
254
+ * root is genuinely needed (the workspace container, whose manifest lives
255
+ * outside the workspace) the backend sets it itself.
256
+ */
257
+ function withoutCacheOverride(env: Record<string, string>): Record<string, string> {
258
+ if (!("TELO_CACHE_DIR" in env)) return env;
259
+ const rest = { ...env };
260
+ delete rest.TELO_CACHE_DIR;
261
+ return rest;
262
+ }