@ricsam/r5d-worker 0.0.172 → 0.0.173

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.172",
3
+ "version": "0.0.173",
4
4
  "type": "commonjs"
5
5
  }
@@ -20605,7 +20605,7 @@ function resolveEntrypointPath(entrypoint) {
20605
20605
  }
20606
20606
  }
20607
20607
  function getR5dctlVersion() {
20608
- if (true) return "0.0.172";
20608
+ if (true) return "0.0.173";
20609
20609
  const entrypoint = process.argv[1] ? resolveEntrypointPath(process.argv[1]) : null;
20610
20610
  let current = entrypoint ? import_node_path2.default.dirname(entrypoint) : process.cwd();
20611
20611
  for (let index = 0; index < 12; index += 1) {
package/dist/mjs/main.mjs CHANGED
@@ -7,7 +7,7 @@ import { startManagerRpc } from "./runtime/releases/rpc-main.mjs";
7
7
  import { ManagerRpcConfig } from "./runtime/releases/rpc-protocol.mjs";
8
8
  const args = process.argv.slice(2);
9
9
  if (args.includes("--version")) {
10
- console.log(`r5d-worker ${true ? "0.0.172" : "development"}`);
10
+ console.log(`r5d-worker ${true ? "0.0.173" : "development"}`);
11
11
  } else if (!args.length || args.includes("--help")) {
12
12
  console.log(
13
13
  "Usage: r5d-worker start --label <label> [--root <dir>] [--base-url <url>] [--token <worker-token>]\n r5d-worker executor /absolute/private/config.json\n r5d-worker manager /absolute/private/config.json\n\nRun independently supervised durable runtime services. Provision configuration with r5dinfra."
@@ -15,7 +15,7 @@ if (args.includes("--version")) {
15
15
  } else if (args[0] === "start") {
16
16
  const runtime = await startPersonalWorker(
17
17
  parsePersonalWorkerOptions(args.slice(1)),
18
- true ? "0.0.172" : "development"
18
+ true ? "0.0.173" : "development"
19
19
  );
20
20
  console.log(`Worker connected: ${runtime.resourceId}`);
21
21
  let closing = false;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.172",
3
+ "version": "0.0.173",
4
4
  "type": "module"
5
5
  }
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { promises as fs, openSync, closeSync } from "node:fs";
5
5
  import { Database } from "bun:sqlite";
6
+ import { z } from "zod";
6
7
  import { canonicalJson } from "@ricsam/r5d-api/runtime-protocol";
7
8
  import { privateDirectory, readPrivateJson } from "../runtime/storage.mjs";
8
9
  import { openPersonalWorkerRuntime, PersonalWorkerGrant } from "./runtime.mjs";
@@ -10,12 +11,14 @@ import { installCliUpdate } from "../cli-update.mjs";
10
11
  import { WorkspaceError } from "../runtime/workspace/contracts.mjs";
11
12
  import { PersonalActionScheduler } from "./action-scheduler.mjs";
12
13
  import { resolvePersonalCliEntrypoint } from "./cli-entrypoint.mjs";
14
+ import { startTerminalStream } from "./terminal-stream.mjs";
15
+ import { TERMINAL_STREAM_CAPABILITY } from "./terminal-stream-protocol.mjs";
13
16
  class PersonalResponseError extends Error {
14
- constructor(code, status, rejectedBeforeAdmission) {
17
+ constructor(code, status, rejectedBeforeAdmission2) {
15
18
  super(`Personal worker request failed (${status})`);
16
19
  this.code = code;
17
20
  this.status = status;
18
- this.rejectedBeforeAdmission = rejectedBeforeAdmission;
21
+ this.rejectedBeforeAdmission = rejectedBeforeAdmission2;
19
22
  }
20
23
  code;
21
24
  status;
@@ -62,6 +65,8 @@ function safeError(error) {
62
65
  const value = error.code;
63
66
  return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : "personal_operation_unknown";
64
67
  }
68
+ const rejectedBeforeAdmission = (error) => error?.rejectedBeforeAdmission === true;
69
+ const CliUpdateRequest = z.object({ kind: z.literal("update-clis"), userId: z.string(), command: z.object({ version: z.string() }).passthrough() }).passthrough();
65
70
  async function startPersonalWorker(options, version) {
66
71
  const key = createHash("sha256").update(canonicalJson([options.baseUrl, options.label])).digest("hex").slice(0, 32), root = path.join(options.root, "personal", key);
67
72
  privateDirectory(root);
@@ -103,7 +108,7 @@ async function startPersonalWorker(options, version) {
103
108
  platform: process.platform,
104
109
  arch: process.arch,
105
110
  hostname: os.hostname(),
106
- capabilities: { updateClis: true, durableWorkspace: true, outerWorkspace: true, fileWalk: true, fileSearch: true }
111
+ capabilities: { updateClis: true, durableWorkspace: true, outerWorkspace: true, fileWalk: true, fileSearch: true, [TERMINAL_STREAM_CAPABILITY]: true }
107
112
  });
108
113
  const grant = PersonalWorkerGrant.parse(
109
114
  await request("/api/personal/resources/register", { kind: "worker", ...identity, label: options.label, metadata: metadata() })
@@ -152,10 +157,11 @@ async function startPersonalWorker(options, version) {
152
157
  if (!previous || state === "pending") {
153
158
  if (!previous) ledger.query("INSERT INTO actions VALUES(?,?,'pending',NULL)").run(input.id, input.requestHash);
154
159
  try {
155
- if (input.request.kind === "update-clis") {
156
- const command = input.request.command;
157
- if (input.request.userId !== grant.userId || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(command.version))
160
+ if (input.request?.kind === "update-clis") {
161
+ const update = CliUpdateRequest.safeParse(input.request);
162
+ if (!update.success || update.data.userId !== grant.userId || !/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(update.data.command.version))
158
163
  throw new Error("Invalid CLI update");
164
+ const command = update.data.command;
159
165
  if (previous) throw new Error("Inspect interrupted CLI installation before retrying");
160
166
  result = await installCliUpdate({
161
167
  workerPackageSpec: `@ricsam/r5d-worker@${command.version}`,
@@ -166,8 +172,8 @@ async function startPersonalWorker(options, version) {
166
172
  } else result = await runtime.dispatch(input.request);
167
173
  state = "completed";
168
174
  } catch (error) {
169
- state = error.rejectedBeforeAdmission === true ? "rejected" : "unknown";
170
- result = { error: safeError(error), ...error.rejectedBeforeAdmission === true ? { rejectedBeforeAdmission: true } : {} };
175
+ state = rejectedBeforeAdmission(error) ? "rejected" : "unknown";
176
+ result = { error: safeError(error), ...rejectedBeforeAdmission(error) ? { rejectedBeforeAdmission: true } : {} };
171
177
  }
172
178
  ledger.query("UPDATE actions SET state=?,result=? WHERE id=?").run(state, canonicalJson(result), input.id);
173
179
  }
@@ -177,6 +183,10 @@ async function startPersonalWorker(options, version) {
177
183
  process.stderr.write(`[r5d-worker] ${safeError(error)}; retaining original action for reconnect
178
184
  `);
179
185
  });
186
+ const streamUrl = new URL(`${endpoint}/stream`, options.baseUrl);
187
+ streamUrl.protocol = streamUrl.protocol === "http:" ? "ws:" : "wss:";
188
+ streamUrl.searchParams.set("instanceId", identity.instanceId);
189
+ const terminalStream = startTerminalStream({ url: streamUrl.toString(), credential: options.credential, version, runtime });
180
190
  const loop = (async () => {
181
191
  while (!stopped) {
182
192
  try {
@@ -200,6 +210,7 @@ async function startPersonalWorker(options, version) {
200
210
  root,
201
211
  async close() {
202
212
  stopped = true;
213
+ await terminalStream.close();
203
214
  await loop;
204
215
  await scheduler.drain();
205
216
  await runtime.close();
@@ -375,7 +375,7 @@ exec ${quote(executable)} ${quote(cli)} "$@"
375
375
  if (command.method === "search") return authority.searchFiles(identity, WorkspaceSearch.parse(command));
376
376
  }
377
377
  if (request.kind === "terminal") {
378
- await authority.ensureHydrated(identity);
378
+ if (command.method === "open" || command.method === "start") await authority.ensureHydrated(identity);
379
379
  if (command.method === "open" || command.method === "start") {
380
380
  const argv = command.method === "open" ? ["/bin/bash", "--noprofile", "--norc", ...command.command ? ["-c", command.command] : []] : ["/bin/sh", "-c", command.command];
381
381
  return {
@@ -472,12 +472,23 @@ exec ${quote(executable)} ${quote(cli)} "$@"
472
472
  }
473
473
  throw new WorkspaceError("invalid_request", "Unsupported personal workspace operation");
474
474
  }
475
+ async function subscribeTerminal(input, handlers) {
476
+ await approve(Workbench.parse(input.workbench));
477
+ const identity = { userId: grant.userId, sessionId: RuntimeId.parse(input.sessionId) };
478
+ return authority.subscribe(identity, { ...identity, operationId: RuntimeId.parse(input.operationId), fence: current.workerFence }, input.offset, handlers);
479
+ }
480
+ async function terminalInput(input) {
481
+ const identity = { userId: grant.userId, sessionId: RuntimeId.parse(input.sessionId) };
482
+ return authority.streamInput(identity, { ...identity, operationId: RuntimeId.parse(input.operationId), fence: current.workerFence }, input.data, input.resize);
483
+ }
475
484
  return {
476
485
  grant,
477
486
  renew,
478
487
  dispatch,
479
488
  authority,
480
489
  synchronize,
490
+ subscribeTerminal,
491
+ terminalInput,
481
492
  async close() {
482
493
  closing = true;
483
494
  clearInterval(publicationTimer);
@@ -0,0 +1,66 @@
1
+ import { z } from "zod";
2
+ import { RuntimeId } from "@ricsam/r5d-api/runtime-protocol";
3
+ const TERMINAL_STREAM_CAPABILITY = "terminal-stream-v1";
4
+ const STREAM_INPUT_MAX_CHARS = 1e5;
5
+ const STREAM_HEARTBEAT_MS = 25e3;
6
+ const Size = z.object({ cols: z.number().int().min(1).max(1e3), rows: z.number().int().min(1).max(1e3) }).strict();
7
+ const Input = z.string().max(STREAM_INPUT_MAX_CHARS).refine((s) => !s.includes("\0"), "NUL is not allowed");
8
+ const StreamWorkbench = z.object({
9
+ id: z.string().min(1).max(200),
10
+ repositoryId: z.string().min(1).max(200),
11
+ branch: z.string().min(1).max(400),
12
+ sessionId: RuntimeId,
13
+ rootProfile: z.enum(["account", "project"]).default("project"),
14
+ namespace: z.string().max(100).optional(),
15
+ projectName: z.string().max(100).optional(),
16
+ baseCommitHash: z.string().max(64).optional()
17
+ }).strict();
18
+ const ServerToWorkerFrame = z.discriminatedUnion("type", [
19
+ z.object({
20
+ type: z.literal("attach"),
21
+ streamId: RuntimeId,
22
+ sessionId: RuntimeId,
23
+ operationId: RuntimeId,
24
+ offset: z.number().int().nonnegative(),
25
+ workbench: StreamWorkbench
26
+ }).strict(),
27
+ z.object({ type: z.literal("input"), streamId: RuntimeId, data: Input.optional(), resize: Size.optional() }).strict(),
28
+ z.object({ type: z.literal("detach"), streamId: RuntimeId }).strict(),
29
+ z.object({ type: z.literal("ping") }).strict()
30
+ ]);
31
+ const WorkerToServerFrame = z.discriminatedUnion("type", [
32
+ z.object({ type: z.literal("hello"), version: z.string().max(100), capabilities: z.array(z.string().max(100)).max(32) }).strict(),
33
+ z.object({ type: z.literal("attached"), streamId: RuntimeId, offset: z.number().int().nonnegative() }).strict(),
34
+ z.object({ type: z.literal("output"), streamId: RuntimeId, offset: z.number().int().nonnegative(), base64: z.string().max(2e5) }).strict(),
35
+ z.object({ type: z.literal("exit"), streamId: RuntimeId, exitCode: z.number().int().nullable(), signal: z.string().max(40).nullable() }).strict(),
36
+ z.object({ type: z.literal("error"), streamId: RuntimeId, code: z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/) }).strict(),
37
+ z.object({ type: z.literal("detached"), streamId: RuntimeId, code: z.string().regex(/^[a-zA-Z0-9_-]{1,100}$/).optional() }).strict(),
38
+ z.object({ type: z.literal("pong") }).strict()
39
+ ]);
40
+ const BrowserToServerFrame = z.discriminatedUnion("type", [
41
+ z.object({ type: z.literal("input"), data: Input }).strict(),
42
+ z.object({ type: z.literal("resize"), cols: Size.shape.cols, rows: Size.shape.rows }).strict(),
43
+ z.object({ type: z.literal("ping") }).strict(),
44
+ z.object({ type: z.literal("pong") }).strict()
45
+ ]);
46
+ function completeUtf8Prefix(bytes) {
47
+ let count = bytes.length;
48
+ let start = count - 1;
49
+ while (start >= 0 && (bytes[start] & 192) === 128) start--;
50
+ if (start >= 0) {
51
+ const lead = bytes[start];
52
+ const width = lead >= 240 ? 4 : lead >= 224 ? 3 : lead >= 192 ? 2 : 1;
53
+ if (count - start < width) count = start;
54
+ }
55
+ return count;
56
+ }
57
+ export {
58
+ BrowserToServerFrame,
59
+ STREAM_HEARTBEAT_MS,
60
+ STREAM_INPUT_MAX_CHARS,
61
+ ServerToWorkerFrame,
62
+ StreamWorkbench,
63
+ TERMINAL_STREAM_CAPABILITY,
64
+ WorkerToServerFrame,
65
+ completeUtf8Prefix
66
+ };
@@ -0,0 +1,161 @@
1
+ import {
2
+ completeUtf8Prefix,
3
+ ServerToWorkerFrame,
4
+ TERMINAL_STREAM_CAPABILITY
5
+ } from "./terminal-stream-protocol.mjs";
6
+ const DEFAULT_BACKOFF_MS = [1e3, 2e3, 5e3, 1e4, 2e4];
7
+ const safeCode = (error) => {
8
+ const value = error?.code;
9
+ return typeof value === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : "terminal_stream_failed";
10
+ };
11
+ function startTerminalStream(options) {
12
+ const Socket = WebSocket;
13
+ const connect = options.connect ?? ((url, headers) => new Socket(url, { headers }));
14
+ const log = options.log ?? ((line) => process.stderr.write(`[r5d-worker] ${line}
15
+ `));
16
+ const backoff = options.backoffMs ?? DEFAULT_BACKOFF_MS;
17
+ let socket;
18
+ let stopped = false;
19
+ let attempt = 0;
20
+ let timer;
21
+ const streams = /* @__PURE__ */ new Map();
22
+ const send = (ws, frame) => {
23
+ try {
24
+ ws.send(JSON.stringify(frame));
25
+ } catch {
26
+ }
27
+ };
28
+ async function attach(ws, frame) {
29
+ const stream = { sessionId: frame.sessionId, operationId: frame.operationId, close: () => {
30
+ }, pending: Buffer.alloc(0), closed: false };
31
+ try {
32
+ const subscription = await options.runtime.subscribeTerminal(
33
+ { sessionId: frame.sessionId, workbench: frame.workbench, operationId: frame.operationId, offset: frame.offset },
34
+ {
35
+ frame: (event) => {
36
+ if (stream.closed) return;
37
+ if (event.event === "output") {
38
+ const bytes = Buffer.concat([stream.pending, Buffer.from(event.base64, "base64")]);
39
+ const count = completeUtf8Prefix(bytes);
40
+ stream.pending = bytes.subarray(count);
41
+ if (count) send(ws, { type: "output", streamId: frame.streamId, offset: event.offset - stream.pending.length, base64: bytes.subarray(0, count).toString("base64") });
42
+ return;
43
+ }
44
+ if (stream.pending.length) {
45
+ send(ws, { type: "output", streamId: frame.streamId, offset: event.receipt.stdoutBytes, base64: stream.pending.toString("base64") });
46
+ stream.pending = Buffer.alloc(0);
47
+ }
48
+ send(ws, { type: "exit", streamId: frame.streamId, exitCode: event.receipt.exitCode, signal: event.receipt.signal });
49
+ },
50
+ close: (error) => {
51
+ if (stream.closed) return;
52
+ stream.closed = true;
53
+ streams.delete(frame.streamId);
54
+ send(ws, { type: "detached", streamId: frame.streamId, ...error ? { code: error.code } : {} });
55
+ }
56
+ }
57
+ );
58
+ stream.close = () => {
59
+ if (stream.closed) return;
60
+ stream.closed = true;
61
+ streams.delete(frame.streamId);
62
+ subscription.close();
63
+ };
64
+ if (stream.closed) {
65
+ subscription.close();
66
+ return null;
67
+ }
68
+ send(ws, { type: "attached", streamId: frame.streamId, offset: subscription.offset });
69
+ return stream;
70
+ } catch (error) {
71
+ streams.delete(frame.streamId);
72
+ send(ws, { type: "error", streamId: frame.streamId, code: safeCode(error) });
73
+ return null;
74
+ }
75
+ }
76
+ async function handle(ws, text) {
77
+ const parsed = ServerToWorkerFrame.safeParse(JSON.parse(text));
78
+ if (!parsed.success) return;
79
+ const frame = parsed.data;
80
+ if (frame.type === "ping") return send(ws, { type: "pong" });
81
+ if (frame.type === "attach") {
82
+ if (streams.has(frame.streamId)) return;
83
+ streams.set(frame.streamId, attach(ws, frame));
84
+ return;
85
+ }
86
+ const pending = streams.get(frame.streamId);
87
+ if (frame.type === "detach") {
88
+ if (!pending) return;
89
+ const stream2 = await pending;
90
+ stream2?.close();
91
+ send(ws, { type: "detached", streamId: frame.streamId });
92
+ return;
93
+ }
94
+ if (!pending) return send(ws, { type: "error", streamId: frame.streamId, code: "stream_not_attached" });
95
+ const stream = await pending;
96
+ if (!stream || stream.closed) return send(ws, { type: "error", streamId: frame.streamId, code: "stream_not_attached" });
97
+ try {
98
+ await options.runtime.terminalInput({
99
+ sessionId: stream.sessionId,
100
+ operationId: stream.operationId,
101
+ ...frame.data !== void 0 ? { data: frame.data } : {},
102
+ ...frame.resize ? { resize: frame.resize } : {}
103
+ });
104
+ } catch (error) {
105
+ send(ws, { type: "error", streamId: frame.streamId, code: safeCode(error) });
106
+ }
107
+ }
108
+ function open() {
109
+ if (stopped) return;
110
+ let ws;
111
+ try {
112
+ ws = connect(options.url, { authorization: `Bearer ${options.credential}` });
113
+ } catch (error) {
114
+ log(`terminal stream unavailable: ${safeCode(error)}`);
115
+ schedule();
116
+ return;
117
+ }
118
+ socket = ws;
119
+ ws.onopen = () => {
120
+ attempt = 0;
121
+ send(ws, { type: "hello", version: options.version, capabilities: [TERMINAL_STREAM_CAPABILITY] });
122
+ };
123
+ ws.onmessage = (event) => {
124
+ void handle(ws, String(event.data)).catch((error) => log(`terminal stream frame failed: ${safeCode(error)}`));
125
+ };
126
+ ws.onerror = () => {
127
+ };
128
+ ws.onclose = () => {
129
+ if (socket !== ws) return;
130
+ socket = void 0;
131
+ for (const pending of streams.values()) void pending.then((stream) => stream?.close());
132
+ streams.clear();
133
+ schedule();
134
+ };
135
+ }
136
+ function schedule() {
137
+ if (stopped) return;
138
+ const delay = backoff[Math.min(attempt, backoff.length - 1)];
139
+ attempt++;
140
+ timer = setTimeout(open, delay);
141
+ timer.unref?.();
142
+ }
143
+ open();
144
+ return {
145
+ async close() {
146
+ stopped = true;
147
+ clearTimeout(timer);
148
+ const current = socket;
149
+ socket = void 0;
150
+ for (const pending of streams.values()) void pending.then((stream) => stream?.close());
151
+ streams.clear();
152
+ try {
153
+ current?.close(1e3, "worker stopping");
154
+ } catch {
155
+ }
156
+ }
157
+ };
158
+ }
159
+ export {
160
+ startTerminalStream
161
+ };
@@ -12,7 +12,7 @@ class HostExecutorClient {
12
12
  constructor(config) {
13
13
  this.config = AdapterConfig.parse(config);
14
14
  }
15
- request(command) {
15
+ frame(command) {
16
16
  const stat = lstatSync(this.config.socketPath);
17
17
  if (!stat.isSocket() || stat.uid !== process.getuid?.() || stat.mode & 63) throw new ExecutorError("insecure_socket");
18
18
  const request = JSON.stringify({
@@ -23,6 +23,59 @@ class HostExecutorClient {
23
23
  command
24
24
  }) + "\n";
25
25
  if (Buffer.byteLength(request) > MAX_FRAME_BYTES) throw new ExecutorError("request_too_large");
26
+ return request;
27
+ }
28
+ /** Keeps its socket open: the admission reply resolves the promise, then every
29
+ * newline-delimited frame reaches `handlers.frame` until either side closes. */
30
+ subscribe(run, offset, handlers) {
31
+ const request = this.frame({ method: "subscribe", run, offset });
32
+ return new Promise((resolve, reject) => {
33
+ const socket = createConnection(this.config.socketPath);
34
+ let buffer = Buffer.alloc(0);
35
+ let admitted = false;
36
+ let closed = false;
37
+ const finish = (error) => {
38
+ if (closed) return;
39
+ closed = true;
40
+ socket.destroy();
41
+ if (!admitted) reject(error ?? new ExecutorError("transport_outcome_unknown"));
42
+ else handlers.close(error);
43
+ };
44
+ socket.setTimeout(this.config.timeoutMs, () => {
45
+ if (!admitted) finish(new ExecutorError("transport_outcome_unknown"));
46
+ });
47
+ socket.once("connect", () => socket.write(request));
48
+ socket.on("error", () => finish(new ExecutorError("transport_outcome_unknown")));
49
+ socket.once("close", () => finish(admitted ? void 0 : new ExecutorError("transport_outcome_unknown")));
50
+ socket.on("data", (chunk) => {
51
+ buffer = Buffer.concat([buffer, chunk]);
52
+ if (buffer.length > MAX_FRAME_BYTES) return finish(new ExecutorError("invalid_response"));
53
+ for (; ; ) {
54
+ const newline = buffer.indexOf(10);
55
+ if (newline < 0) return;
56
+ const line = buffer.subarray(0, newline).toString("utf8");
57
+ buffer = buffer.subarray(newline + 1);
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(line);
61
+ } catch {
62
+ return finish(new ExecutorError("invalid_response"));
63
+ }
64
+ if (!admitted) {
65
+ if (parsed.ok !== true || !parsed.result) return finish(new ExecutorError(parsed.error?.code ?? "invalid_response"));
66
+ admitted = true;
67
+ socket.setTimeout(0);
68
+ resolve({ ...parsed.result, close: () => finish() });
69
+ continue;
70
+ }
71
+ if (parsed.event !== "output" && parsed.event !== "exit") return finish(new ExecutorError("invalid_response"));
72
+ handlers.frame(parsed);
73
+ }
74
+ });
75
+ });
76
+ }
77
+ request(command) {
78
+ const request = this.frame(command);
26
79
  return new Promise((resolve, reject) => {
27
80
  const socket = createConnection(this.config.socketPath);
28
81
  let buffer = Buffer.alloc(0);
@@ -89,6 +142,10 @@ class HostExecutorClient {
89
142
  cancel(run, actionId) {
90
143
  return this.request({ method: "cancel", run, actionId });
91
144
  }
145
+ /** Live PTY input without a durable action: nothing to replay, nothing to poison. */
146
+ input(run, data, resize) {
147
+ return this.request({ method: "input", run, ...data !== void 0 ? { data } : {}, ...resize ? { resize } : {} });
148
+ }
92
149
  }
93
150
  export {
94
151
  HostExecutorClient
@@ -55,14 +55,37 @@ async function startHostExecutor(input) {
55
55
  socket.end(JSON.stringify({ ok: false, error: { code: "invalid_request" } }) + "\n");
56
56
  return;
57
57
  }
58
- const authorityHint = request?.command?.method === "authority";
58
+ const method = request?.command?.method;
59
+ const authorityHint = method === "authority";
59
60
  if (pending >= 72 || pending >= 64 && !authorityHint) {
60
61
  socket.end(JSON.stringify({ ok: false, error: { code: "ipc_busy" } }) + "\n");
61
62
  return;
62
63
  }
63
64
  pending++;
64
- void executor.handle(request).then(
65
- (result) => socket.end(JSON.stringify({ ok: true, result }) + "\n"),
65
+ let sink;
66
+ let ready;
67
+ if (method === "subscribe") {
68
+ socket.setTimeout(0);
69
+ sink = {
70
+ ready: new Promise((resolve) => {
71
+ ready = resolve;
72
+ }),
73
+ send: (frame) => new Promise((resolve, reject) => {
74
+ if (socket.destroyed || socket.writableEnded) return reject(new ExecutorError("stream_closed"));
75
+ if (socket.write(JSON.stringify(frame) + "\n")) resolve();
76
+ else socket.once("drain", resolve);
77
+ }),
78
+ end: () => socket.end()
79
+ };
80
+ const bound = sink;
81
+ socket.once("close", () => executor.unsubscribe(bound));
82
+ }
83
+ void executor.handle(request, sink).then(
84
+ (result) => {
85
+ if (!sink) return socket.end(JSON.stringify({ ok: true, result }) + "\n");
86
+ socket.write(JSON.stringify({ ok: true, result }) + "\n");
87
+ ready();
88
+ },
66
89
  (error) => socket.end(
67
90
  JSON.stringify({
68
91
  ok: false,
@@ -16,6 +16,8 @@ import {
16
16
  ExecutorError,
17
17
  ExecutorRequest,
18
18
  EXECUTOR_CAPABILITY,
19
+ EXECUTOR_STREAM_CAPABILITY,
20
+ STREAM_CHUNK_BYTES,
19
21
  ShellPayload
20
22
  } from "./protocol.mjs";
21
23
  import { ExecutorStore, privateDirectory } from "./storage.mjs";
@@ -39,7 +41,7 @@ class HostExecutor {
39
41
  releaseId: this.config.releaseId,
40
42
  role: "executor",
41
43
  protocol: { min: 1, max: 1 },
42
- capabilities: [EXECUTOR_CAPABILITY, ...nativePty ? ["host-pty-v1"] : []]
44
+ capabilities: [EXECUTOR_CAPABILITY, EXECUTOR_STREAM_CAPABILITY, ...nativePty ? ["host-pty-v1"] : []]
43
45
  };
44
46
  this.leaseTimer = setInterval(() => {
45
47
  const authority = this.authority();
@@ -58,6 +60,7 @@ class HostExecutor {
58
60
  adapters;
59
61
  hello;
60
62
  active = /* @__PURE__ */ new Map();
63
+ subscribers = /* @__PURE__ */ new Map();
61
64
  leaseTimer;
62
65
  closing = false;
63
66
  poisoned = false;
@@ -65,11 +68,13 @@ class HostExecutor {
65
68
  /** Authority is synchronous and authenticated; never queue lease renewal behind a blocked OS write.
66
69
  * Resource commands retain one ordered admission/action boundary. Commands already admitted may
67
70
  * complete after promotion; queued commands and yielded admission hashes must recheck the fence.
71
+ * The live terminal channel (subscribe, input) carries no durable action and
72
+ * never waits behind that boundary: a keystroke is not queued behind a spawn.
68
73
  */
69
- handle(input) {
74
+ handle(input, sink) {
70
75
  try {
71
76
  const request = ExecutorRequest.parse(input);
72
- if (request.command.method === "authority") return this.dispatch(request);
77
+ if (["authority", "subscribe", "input"].includes(request.command.method)) return this.dispatch(request, sink);
73
78
  } catch (error) {
74
79
  return Promise.reject(error);
75
80
  }
@@ -78,6 +83,49 @@ class HostExecutor {
78
83
  });
79
84
  return task;
80
85
  }
86
+ /** Drops a subscription whose transport closed. Idempotent. */
87
+ unsubscribe(sink) {
88
+ const subscriber = this.subscribers.get(sink);
89
+ if (!subscriber) return;
90
+ subscriber.closed = true;
91
+ this.subscribers.delete(sink);
92
+ }
93
+ notify(operationId) {
94
+ for (const subscriber of this.subscribers.values()) if (subscriber.operationId === operationId) void this.pump(subscriber);
95
+ }
96
+ async pump(subscriber) {
97
+ subscriber.dirty = true;
98
+ if (subscriber.pumping) return;
99
+ subscriber.pumping = true;
100
+ try {
101
+ await subscriber.sink.ready;
102
+ while (subscriber.dirty && !subscriber.closed) {
103
+ subscriber.dirty = false;
104
+ for (; ; ) {
105
+ const receipt = this.active.get(subscriber.operationId)?.receipt ?? this.store.get(subscriber.operationId);
106
+ if (!receipt) throw new ExecutorError("run_not_found");
107
+ if (subscriber.offset < receipt.stdoutBytes) {
108
+ const chunk = this.store.read(subscriber.operationId, "stdout", subscriber.offset, receipt.stdoutBytes, STREAM_CHUNK_BYTES);
109
+ subscriber.offset = chunk.nextOffset;
110
+ await subscriber.sink.send({ event: "output", offset: subscriber.offset, base64: chunk.base64 });
111
+ if (subscriber.closed) return;
112
+ continue;
113
+ }
114
+ if (!this.active.has(subscriber.operationId) || ["completed", "cancelled", "unknown"].includes(receipt.state)) {
115
+ await subscriber.sink.send({ event: "exit", receipt: { ...receipt } });
116
+ this.unsubscribe(subscriber.sink);
117
+ subscriber.sink.end();
118
+ }
119
+ break;
120
+ }
121
+ }
122
+ } catch {
123
+ this.unsubscribe(subscriber.sink);
124
+ subscriber.sink.end();
125
+ } finally {
126
+ subscriber.pumping = false;
127
+ }
128
+ }
81
129
  authenticate(request) {
82
130
  const grant = this.adapters.find(request.auth.principalId);
83
131
  const principal = this.config.principals.find((p) => p.id === request.auth.principalId) ?? grant?.principal;
@@ -120,7 +168,7 @@ class HostExecutor {
120
168
  if (!run || run.userId !== identity.userId || run.sessionId !== (identity.sessionId ?? null)) throw new ExecutorError("run_not_found");
121
169
  return run;
122
170
  }
123
- async dispatch(input) {
171
+ async dispatch(input, sink) {
124
172
  if (this.closing) throw new ExecutorError("executor_unavailable");
125
173
  const request = ExecutorRequest.parse(input);
126
174
  const principal = this.authenticate(request);
@@ -315,6 +363,24 @@ class HostExecutor {
315
363
  stdout: this.store.read(receipt.operationId, "stdout", command.stdoutOffset, receipt.stdoutBytes, command.maxBytes),
316
364
  stderr: this.store.read(receipt.operationId, "stderr", command.stderrOffset, receipt.stderrBytes, command.maxBytes)
317
365
  };
366
+ if (command.method === "subscribe") {
367
+ if (!sink) throw new ExecutorError("invalid_request");
368
+ if (!receipt.pty) throw new ExecutorError("not_a_pty");
369
+ if (command.offset > receipt.stdoutBytes) throw new ExecutorError("invalid_offset");
370
+ const subscriber = { operationId: receipt.operationId, offset: command.offset, sink, pumping: false, dirty: false, closed: false };
371
+ this.subscribers.set(sink, subscriber);
372
+ void this.pump(subscriber);
373
+ return { receipt: { ...receipt }, offset: command.offset };
374
+ }
375
+ if (command.method === "input") {
376
+ const live = this.active.get(receipt.operationId);
377
+ if (!live || live.finishing) throw new ExecutorError(receipt.state === "unknown" ? "outcome_unknown" : "run_not_running");
378
+ if (!live.pty) throw new ExecutorError("not_a_pty");
379
+ if (receipt.stdinClosed) throw new ExecutorError("stdin_closed");
380
+ if (command.data !== void 0) await live.pty.write(command.data);
381
+ if (command.resize) await live.pty.resize(command.resize.cols, command.resize.rows);
382
+ return { ok: true };
383
+ }
318
384
  const actionHash = tokenHash(
319
385
  canonicalJson({
320
386
  method: command.method,
@@ -388,6 +454,7 @@ class HostExecutor {
388
454
  this.store.append(run.receipt.operationId, stream, data);
389
455
  run.receipt[stream === "stdout" ? "stdoutBytes" : "stderrBytes"] += data.length;
390
456
  this.store.put(run.receipt);
457
+ if (stream === "stdout") this.notify(run.receipt.operationId);
391
458
  } catch {
392
459
  this.poisoned = true;
393
460
  void this.finish(run, true, "output_persistence_failed").catch(() => {
@@ -419,6 +486,7 @@ class HostExecutor {
419
486
  if (cleanupFailed) run.receipt.reason = "process_cleanup_uncertain";
420
487
  this.store.put(run.receipt);
421
488
  this.active.delete(run.receipt.operationId);
489
+ this.notify(run.receipt.operationId);
422
490
  if (cleanupFailed) throw new ExecutorError("process_cleanup_uncertain");
423
491
  })();
424
492
  return run.finishing;
@@ -426,6 +494,10 @@ class HostExecutor {
426
494
  async close() {
427
495
  this.closing = true;
428
496
  clearInterval(this.leaseTimer);
497
+ for (const [sink] of this.subscribers) {
498
+ this.unsubscribe(sink);
499
+ sink.end();
500
+ }
429
501
  await this.serial;
430
502
  await Promise.allSettled([...this.active.values()].map((run) => this.finish(run, true, "executor_shutdown")));
431
503
  this.store.close();