@ricsam/r5d-worker 0.0.171 → 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.
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/internal-r5dctl.cjs +1 -1
- package/dist/mjs/main.mjs +2 -2
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/personal/client.mjs +19 -8
- package/dist/mjs/personal/runtime.mjs +12 -1
- package/dist/mjs/personal/terminal-stream-protocol.mjs +66 -0
- package/dist/mjs/personal/terminal-stream.mjs +161 -0
- package/dist/mjs/runtime/client.mjs +58 -1
- package/dist/mjs/runtime/daemon.mjs +26 -3
- package/dist/mjs/runtime/executor.mjs +76 -4
- package/dist/mjs/runtime/protocol.mjs +15 -1
- package/dist/mjs/runtime/workspace/authority.mjs +53 -11
- package/dist/types/personal/runtime.d.ts +29 -0
- package/dist/types/personal/terminal-stream-protocol.d.ts +132 -0
- package/dist/types/personal/terminal-stream.d.ts +31 -0
- package/dist/types/runtime/client.d.ts +18 -1
- package/dist/types/runtime/executor.d.ts +9 -2
- package/dist/types/runtime/protocol.d.ts +98 -0
- package/dist/types/runtime/releases/rpc-protocol.d.ts +136 -0
- package/dist/types/runtime/workspace/authority.d.ts +28 -4
- package/dist/types/runtime/workspace/contracts.d.ts +1 -1
- package/package.json +2 -2
package/dist/cjs/package.json
CHANGED
|
@@ -20605,7 +20605,7 @@ function resolveEntrypointPath(entrypoint) {
|
|
|
20605
20605
|
}
|
|
20606
20606
|
}
|
|
20607
20607
|
function getR5dctlVersion() {
|
|
20608
|
-
if (true) return "0.0.
|
|
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.
|
|
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.
|
|
18
|
+
true ? "0.0.173" : "development"
|
|
19
19
|
);
|
|
20
20
|
console.log(`Worker connected: ${runtime.resourceId}`);
|
|
21
21
|
let closing = false;
|
package/dist/mjs/package.json
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
|
156
|
-
const
|
|
157
|
-
if (
|
|
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
|
|
170
|
-
result = { error: safeError(error), ...error
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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();
|