@ricsam/r5d-worker 0.0.124 → 0.0.126
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 +5 -1
- package/dist/cjs/browser-upload.cjs +73 -0
- package/dist/cjs/command-launcher.cjs +6 -2
- package/dist/cjs/main.cjs +41 -20
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/browser-upload.mjs +42 -0
- package/dist/mjs/command-launcher.mjs +6 -2
- package/dist/mjs/main.mjs +41 -20
- package/dist/mjs/package.json +1 -1
- package/dist/types/browser-upload.d.ts +7 -0
- package/dist/types/command-launcher.d.ts +2 -1
- package/dist/types/main.d.ts +20 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -89,8 +89,12 @@ The launcher owns admission, OS isolation, budgets, and diagnostic evidence. Its
|
|
|
89
89
|
|
|
90
90
|
Requests must be processed concurrently so cancellation and cleanup cannot wait behind queued admission. Controller RPCs are bounded (10 seconds, or 90 seconds for acquisition); external policy must reject excessive queue waits within that bound. Controller loss or protocol failure stops command admission, preserves uncertain outcomes for recovery, reaps ordinary children, and restarts the runtime through its supervisor. It never falls back to direct spawning. Shutdown closes controller stdin; the controller must reap its jobs before exiting.
|
|
91
91
|
|
|
92
|
-
Cancellation is checked again immediately before spawn. Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. The worker reports its own view of every lane (holders and queue, by run id) to the server with `capacity_report` whenever an admission changes; the platform exposes it through `r5dctl ps list` and `r5dctl workspace status`. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
|
|
92
|
+
Cancellation is checked again immediately before spawn. A `cancel` carrying `scope: "unstarted"` is the server giving up on a start whose budget expired: it revokes only a launch that has not spawned (capacity queue, mount hold, preparation) and leaves a running process alone; `cancel_result.outcome` reports `unstarted`, `started`, `stopped`, or `unknown` (no record of the run; the cancellation is remembered so a later arrival is refused). Transient disconnects preserve commands and terminals; expiry of the five-minute execution lease cancels agent work and closes terminals. File operations, communication recovery, and cancellation do not enter launcher admission. Internal worker maintenance subprocesses are also outside this hook. The worker reports its own view of every lane (holders and queue, by run id) to the server with `capacity_report` whenever an admission changes; the platform exposes it through `r5dctl ps list` and `r5dctl workspace status`. Deployment-specific limits and service protection are documented in [Worker deployment resources](../../docs/worker-deployment-resources.md).
|
|
93
93
|
|
|
94
94
|
When the connected `r5d-browser` requests a port forward, the worker opens each relayed connection only to `127.0.0.1` on the requested worker port. Browser-side and worker-side ports may differ. The worker never opens a public listener, and a disconnected worker leaves the browser's long-lived mapping unavailable until the same worker label reconnects.
|
|
95
95
|
|
|
96
96
|
Control execution requires an initialized launcher that explicitly declares its control capacity (or direct execution without an external launcher), an updated worker, and r5dctl installed on the worker's own PATH. Missing control support is an update error; a control request never falls back to a general shell. Deploy the app and worker support assets, update the CLI/worker, and reconnect before resuming paused work.
|
|
97
|
+
|
|
98
|
+
## Uploading files to the signed-in browser
|
|
99
|
+
|
|
100
|
+
`browser_upload_files` transfers regular files from this worker to the connected r5d-browser and attaches them to the selected file input in one agent operation. Source paths may be anywhere this worker can read, including outside the project; relative paths resolve from the session working directory. `$R5D_ROOT`, `$R5D_SESSION_ID`, and `~/` are expanded without executing a shell. File reads are bounded to 1 MiB per RPC, and source metadata is checked throughout the transfer. These transient reads and binary responses bypass recovery journals. Uploads interrupted by a disconnect must be retried as a new operation.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var browser_upload_exports = {};
|
|
30
|
+
__export(browser_upload_exports, {
|
|
31
|
+
readBrowserUploadChunk: () => readBrowserUploadChunk
|
|
32
|
+
});
|
|
33
|
+
module.exports = __toCommonJS(browser_upload_exports);
|
|
34
|
+
var import_promises = __toESM(require("node:fs/promises"), 1);
|
|
35
|
+
var import_node_fs = require("node:fs");
|
|
36
|
+
var import_node_os = __toESM(require("node:os"), 1);
|
|
37
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
38
|
+
var import_browser_upload = require("@ricsam/r5d-api/browser-upload");
|
|
39
|
+
async function readBrowserUploadChunk(input, context) {
|
|
40
|
+
if (typeof input.filePath !== "string" || !input.filePath.trim() || input.filePath.includes("\0"))
|
|
41
|
+
throw new Error("Upload requires a file path.");
|
|
42
|
+
if (!Number.isSafeInteger(input.offset) || input.offset < 0) throw new Error("Invalid upload read offset.");
|
|
43
|
+
if (input.offset > 0 && !input.version) throw new Error("Upload continuation requires a file version.");
|
|
44
|
+
const expanded = input.filePath.replace(/\$\{R5D_ROOT\}|\$R5D_ROOT\b/g, () => context.rootDir).replace(/\$\{R5D_SESSION_ID\}|\$R5D_SESSION_ID\b/g, () => context.sessionId).replace(/^~\//, () => `${import_node_os.default.homedir()}/`);
|
|
45
|
+
const filePath = import_node_path.default.resolve(context.cwd, expanded);
|
|
46
|
+
const handle = await import_promises.default.open(filePath, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_NONBLOCK);
|
|
47
|
+
try {
|
|
48
|
+
const stat = await handle.stat({ bigint: true });
|
|
49
|
+
if (!stat.isFile()) throw new Error(`Upload source is not a regular file: ${input.filePath}`);
|
|
50
|
+
const metadata = (0, import_browser_upload.validateUploadFile)({ filename: import_node_path.default.basename(filePath), size: Number(stat.size) });
|
|
51
|
+
const versionOf = (s) => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
|
|
52
|
+
const version = versionOf(stat);
|
|
53
|
+
if (input.version !== void 0 && input.version !== version)
|
|
54
|
+
throw new Error(`Upload source changed while being transferred: ${input.filePath}`);
|
|
55
|
+
if (input.offset > metadata.size) throw new Error("Upload offset is past the end of the file.");
|
|
56
|
+
const bytes = Buffer.alloc(input.metadataOnly === true ? 0 : Math.min(import_browser_upload.BROWSER_UPLOAD_CHUNK_BYTES, metadata.size - input.offset));
|
|
57
|
+
let read = 0;
|
|
58
|
+
while (read < bytes.length) {
|
|
59
|
+
const { bytesRead } = await handle.read(bytes, read, bytes.length - read, input.offset + read);
|
|
60
|
+
if (!bytesRead) throw new Error(`Upload source was truncated: ${input.filePath}`);
|
|
61
|
+
read += bytesRead;
|
|
62
|
+
}
|
|
63
|
+
if (versionOf(await handle.stat({ bigint: true })) !== version)
|
|
64
|
+
throw new Error(`Upload source changed while being read: ${input.filePath}`);
|
|
65
|
+
return { type: "browser_upload_read", ...metadata, version, offset: input.offset, base64: bytes.toString("base64") };
|
|
66
|
+
} finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
71
|
+
0 && (module.exports = {
|
|
72
|
+
readBrowserUploadChunk
|
|
73
|
+
});
|
|
@@ -245,7 +245,9 @@ class WorkerCommandLauncher {
|
|
|
245
245
|
if (!argv.length) throw new Error("Command argv must not be empty");
|
|
246
246
|
return [...prefix, ...argv];
|
|
247
247
|
},
|
|
248
|
-
cancel: () =>
|
|
248
|
+
cancel: async () => {
|
|
249
|
+
await this.cancel(request.id);
|
|
250
|
+
},
|
|
249
251
|
diagnose: async () => {
|
|
250
252
|
this.assertHealthy();
|
|
251
253
|
if (!this.child) return void 0;
|
|
@@ -261,9 +263,10 @@ class WorkerCommandLauncher {
|
|
|
261
263
|
release: () => this.releaseRecord(record)
|
|
262
264
|
};
|
|
263
265
|
}
|
|
266
|
+
/** Revoke a launch. Returns whether this launcher still held it (queued or acquired, not yet released). */
|
|
264
267
|
async cancel(id) {
|
|
265
268
|
const record = this.launches.get(id);
|
|
266
|
-
if (!record || record.released) return;
|
|
269
|
+
if (!record || record.released) return false;
|
|
267
270
|
record.revoked = true;
|
|
268
271
|
record.rejectAdmission(new CommandLaunchCancelledError());
|
|
269
272
|
if (this.child) {
|
|
@@ -273,6 +276,7 @@ class WorkerCommandLauncher {
|
|
|
273
276
|
throw this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
274
277
|
}
|
|
275
278
|
}
|
|
279
|
+
return true;
|
|
276
280
|
}
|
|
277
281
|
async cancelSession(sessionId) {
|
|
278
282
|
await this.cancelMatching((_id, ownerSessionId) => ownerSessionId === sessionId);
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -56,6 +56,7 @@ __export(main_exports, {
|
|
|
56
56
|
writeWorkerTextFile: () => writeWorkerTextFile
|
|
57
57
|
});
|
|
58
58
|
module.exports = __toCommonJS(main_exports);
|
|
59
|
+
var import_browser_upload = require("./browser-upload.cjs");
|
|
59
60
|
var import_runtime_version = require("./runtime-version.cjs");
|
|
60
61
|
var import_plan_paths = require("./plan-paths.cjs");
|
|
61
62
|
var import_plan_parser = require("./plan-parser.cjs");
|
|
@@ -325,9 +326,10 @@ function unregisterPendingReservation(key, controller) {
|
|
|
325
326
|
}
|
|
326
327
|
function abortPendingReservation(key, reason) {
|
|
327
328
|
const pending = pendingReservationAborts.get(key);
|
|
328
|
-
if (!pending) return;
|
|
329
|
+
if (!pending) return false;
|
|
329
330
|
pendingReservationAborts.delete(key);
|
|
330
331
|
pending.controller.abort(new import_workspace_mount_hold_fence.WorkspaceMountHoldAbortedError(reason));
|
|
332
|
+
return true;
|
|
331
333
|
}
|
|
332
334
|
function abortPendingReservations(reason, sessionId) {
|
|
333
335
|
for (const [key, pending] of [...pendingReservationAborts]) {
|
|
@@ -2818,6 +2820,13 @@ async function executeOperation(input) {
|
|
|
2818
2820
|
return executeViewFileBytesOperation({ ...input, message: input.message });
|
|
2819
2821
|
case "code_list":
|
|
2820
2822
|
return executeCodeListOperation({ ...input, message: input.message });
|
|
2823
|
+
case "browser_upload_read":
|
|
2824
|
+
input.assertAdmission();
|
|
2825
|
+
return (0, import_browser_upload.readBrowserUploadChunk)(input.message.input, {
|
|
2826
|
+
cwd: input.resolvedTarget.rootPath,
|
|
2827
|
+
rootDir: input.rootDir,
|
|
2828
|
+
sessionId: input.message.sessionId
|
|
2829
|
+
});
|
|
2821
2830
|
case "code_read":
|
|
2822
2831
|
return executeCodeReadOperation({ ...input, message: input.message });
|
|
2823
2832
|
}
|
|
@@ -3191,32 +3200,38 @@ async function reapCompletedCredentialBearingProcessGroup(runId, subprocess) {
|
|
|
3191
3200
|
);
|
|
3192
3201
|
}
|
|
3193
3202
|
}
|
|
3194
|
-
async function cancelProcessRun(runId) {
|
|
3203
|
+
async function cancelProcessRun(runId, scope) {
|
|
3204
|
+
const spawned = activeProcesses.get(runId);
|
|
3205
|
+
if (scope === "unstarted" && spawned) return { cancelled: false, message: `${spawned.command} already started`, outcome: "started" };
|
|
3206
|
+
if (!spawned) cancelledProcessRuns.add(runId);
|
|
3207
|
+
const revocation = workerCommandLauncher?.cancel(runId);
|
|
3195
3208
|
let resourceCancellationError;
|
|
3209
|
+
let launchRevoked = false;
|
|
3196
3210
|
try {
|
|
3197
|
-
await
|
|
3211
|
+
launchRevoked = await revocation ?? false;
|
|
3198
3212
|
} catch (error) {
|
|
3199
3213
|
resourceCancellationError = error instanceof Error ? error.message : String(error);
|
|
3200
3214
|
}
|
|
3201
|
-
const active = activeProcesses.get(runId);
|
|
3202
3215
|
let cancelled = resourceCancellationError === void 0;
|
|
3203
3216
|
let cancelMessage;
|
|
3204
|
-
|
|
3217
|
+
let outcome;
|
|
3218
|
+
if (spawned) {
|
|
3219
|
+
outcome = "stopped";
|
|
3205
3220
|
try {
|
|
3206
|
-
closeProcessStdin(
|
|
3207
|
-
await (0, import_process_tree.terminateProcessTree)(
|
|
3208
|
-
cancelMessage = `Stopped ${
|
|
3221
|
+
closeProcessStdin(spawned);
|
|
3222
|
+
await (0, import_process_tree.terminateProcessTree)(spawned.process);
|
|
3223
|
+
cancelMessage = `Stopped ${spawned.command}`;
|
|
3209
3224
|
} catch (error) {
|
|
3210
3225
|
cancelled = false;
|
|
3211
|
-
cancelMessage = `Failed to stop ${
|
|
3226
|
+
cancelMessage = `Failed to stop ${spawned.command}: ${error instanceof Error ? error.message : String(error)}`;
|
|
3212
3227
|
}
|
|
3213
3228
|
} else {
|
|
3214
|
-
|
|
3215
|
-
|
|
3229
|
+
const reservationAborted = abortPendingReservation(`run:${runId}`, "Cancellation queued before command start");
|
|
3230
|
+
outcome = launchRevoked || reservationAborted ? "unstarted" : "unknown";
|
|
3216
3231
|
cancelMessage = "Cancellation queued before command start";
|
|
3217
3232
|
}
|
|
3218
3233
|
if (resourceCancellationError) cancelMessage += `; job resource termination failed: ${resourceCancellationError}`;
|
|
3219
|
-
return { cancelled, message: cancelMessage };
|
|
3234
|
+
return { cancelled, message: cancelMessage, outcome };
|
|
3220
3235
|
}
|
|
3221
3236
|
function closeProcessStdin(active) {
|
|
3222
3237
|
if (!active?.stdin) {
|
|
@@ -3243,6 +3258,10 @@ function sendReplayWorkerMessage(ws, message) {
|
|
|
3243
3258
|
}
|
|
3244
3259
|
function sendWorkerMessage(ws, message) {
|
|
3245
3260
|
if (launcherFailureInProgress) return;
|
|
3261
|
+
if (message.type === "browser_upload_read_result") {
|
|
3262
|
+
if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
|
3263
|
+
return;
|
|
3264
|
+
}
|
|
3246
3265
|
const transportError = "error" in message && typeof message.error === "string" ? message.error : message.type === "workspace_sync_result" && message.result.outcome === "failed" ? message.result.error : void 0;
|
|
3247
3266
|
if (recoveryJournal && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
|
|
3248
3267
|
recoveryJournal.unknown(message.requestId);
|
|
@@ -6029,6 +6048,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
6029
6048
|
capabilities: {
|
|
6030
6049
|
updateClis: true,
|
|
6031
6050
|
browserPortForwarding: true,
|
|
6051
|
+
browserFileUploads: true,
|
|
6032
6052
|
execStdinV1: true,
|
|
6033
6053
|
ptyEnvFilesV1: true,
|
|
6034
6054
|
workspaceRemediationAncestorGuardV1: true,
|
|
@@ -6165,10 +6185,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
6165
6185
|
if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
|
|
6166
6186
|
if (!startupRecoveryComplete && message.type !== "ping") return;
|
|
6167
6187
|
if (currentWorkerSocket !== ws) return;
|
|
6168
|
-
const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" ? message.requestId : void 0;
|
|
6188
|
+
const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" && message.type !== "browser_upload_read" ? message.requestId : void 0;
|
|
6169
6189
|
if (message.type === "cancel") {
|
|
6170
6190
|
const admission = recoveryJournal.admit({ ...message, requestId: message.requestId });
|
|
6171
|
-
const cancelled = await cancelProcessRun(message.runId);
|
|
6191
|
+
const cancelled = await cancelProcessRun(message.runId, message.scope);
|
|
6172
6192
|
let admitted;
|
|
6173
6193
|
try {
|
|
6174
6194
|
admitted = await admission;
|
|
@@ -6194,7 +6214,8 @@ async function startWorker(options, projectRuntime = {
|
|
|
6194
6214
|
requestId: message.requestId,
|
|
6195
6215
|
runId: message.runId,
|
|
6196
6216
|
cancelled: cancelled.cancelled,
|
|
6197
|
-
message: cancelled.message
|
|
6217
|
+
message: cancelled.message,
|
|
6218
|
+
outcome: cancelled.outcome
|
|
6198
6219
|
})
|
|
6199
6220
|
);
|
|
6200
6221
|
return;
|
|
@@ -7070,10 +7091,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
7070
7091
|
});
|
|
7071
7092
|
return;
|
|
7072
7093
|
}
|
|
7073
|
-
if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
|
|
7094
|
+
if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read" || message.type === "browser_upload_read") {
|
|
7074
7095
|
if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
|
|
7075
7096
|
sendWorkerMessage(ws, {
|
|
7076
|
-
type: "operation_result",
|
|
7097
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7077
7098
|
requestId: message.requestId,
|
|
7078
7099
|
error: `Workspace operations are deferred for incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}; use a canonical remediation shell`
|
|
7079
7100
|
});
|
|
@@ -7101,7 +7122,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7101
7122
|
sendSerializedWorkerMessage(
|
|
7102
7123
|
ws,
|
|
7103
7124
|
JSON.stringify({
|
|
7104
|
-
type: "operation_result",
|
|
7125
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7105
7126
|
requestId: message.requestId,
|
|
7106
7127
|
error: error instanceof Error ? error.message : String(error)
|
|
7107
7128
|
})
|
|
@@ -7129,7 +7150,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7129
7150
|
sendSerializedWorkerMessage(
|
|
7130
7151
|
ws,
|
|
7131
7152
|
JSON.stringify({
|
|
7132
|
-
type: "operation_result",
|
|
7153
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7133
7154
|
requestId: message.requestId,
|
|
7134
7155
|
result
|
|
7135
7156
|
})
|
|
@@ -7147,7 +7168,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7147
7168
|
sendSerializedWorkerMessage(
|
|
7148
7169
|
ws,
|
|
7149
7170
|
JSON.stringify({
|
|
7150
|
-
type: "operation_result",
|
|
7171
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7151
7172
|
requestId: message.requestId,
|
|
7152
7173
|
error: error instanceof Error ? error.message : String(error)
|
|
7153
7174
|
})
|
package/dist/cjs/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
BROWSER_UPLOAD_CHUNK_BYTES,
|
|
7
|
+
validateUploadFile
|
|
8
|
+
} from "@ricsam/r5d-api/browser-upload";
|
|
9
|
+
async function readBrowserUploadChunk(input, context) {
|
|
10
|
+
if (typeof input.filePath !== "string" || !input.filePath.trim() || input.filePath.includes("\0"))
|
|
11
|
+
throw new Error("Upload requires a file path.");
|
|
12
|
+
if (!Number.isSafeInteger(input.offset) || input.offset < 0) throw new Error("Invalid upload read offset.");
|
|
13
|
+
if (input.offset > 0 && !input.version) throw new Error("Upload continuation requires a file version.");
|
|
14
|
+
const expanded = input.filePath.replace(/\$\{R5D_ROOT\}|\$R5D_ROOT\b/g, () => context.rootDir).replace(/\$\{R5D_SESSION_ID\}|\$R5D_SESSION_ID\b/g, () => context.sessionId).replace(/^~\//, () => `${os.homedir()}/`);
|
|
15
|
+
const filePath = path.resolve(context.cwd, expanded);
|
|
16
|
+
const handle = await fs.open(filePath, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
17
|
+
try {
|
|
18
|
+
const stat = await handle.stat({ bigint: true });
|
|
19
|
+
if (!stat.isFile()) throw new Error(`Upload source is not a regular file: ${input.filePath}`);
|
|
20
|
+
const metadata = validateUploadFile({ filename: path.basename(filePath), size: Number(stat.size) });
|
|
21
|
+
const versionOf = (s) => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
|
|
22
|
+
const version = versionOf(stat);
|
|
23
|
+
if (input.version !== void 0 && input.version !== version)
|
|
24
|
+
throw new Error(`Upload source changed while being transferred: ${input.filePath}`);
|
|
25
|
+
if (input.offset > metadata.size) throw new Error("Upload offset is past the end of the file.");
|
|
26
|
+
const bytes = Buffer.alloc(input.metadataOnly === true ? 0 : Math.min(BROWSER_UPLOAD_CHUNK_BYTES, metadata.size - input.offset));
|
|
27
|
+
let read = 0;
|
|
28
|
+
while (read < bytes.length) {
|
|
29
|
+
const { bytesRead } = await handle.read(bytes, read, bytes.length - read, input.offset + read);
|
|
30
|
+
if (!bytesRead) throw new Error(`Upload source was truncated: ${input.filePath}`);
|
|
31
|
+
read += bytesRead;
|
|
32
|
+
}
|
|
33
|
+
if (versionOf(await handle.stat({ bigint: true })) !== version)
|
|
34
|
+
throw new Error(`Upload source changed while being read: ${input.filePath}`);
|
|
35
|
+
return { type: "browser_upload_read", ...metadata, version, offset: input.offset, base64: bytes.toString("base64") };
|
|
36
|
+
} finally {
|
|
37
|
+
await handle.close();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export {
|
|
41
|
+
readBrowserUploadChunk
|
|
42
|
+
};
|
|
@@ -216,7 +216,9 @@ class WorkerCommandLauncher {
|
|
|
216
216
|
if (!argv.length) throw new Error("Command argv must not be empty");
|
|
217
217
|
return [...prefix, ...argv];
|
|
218
218
|
},
|
|
219
|
-
cancel: () =>
|
|
219
|
+
cancel: async () => {
|
|
220
|
+
await this.cancel(request.id);
|
|
221
|
+
},
|
|
220
222
|
diagnose: async () => {
|
|
221
223
|
this.assertHealthy();
|
|
222
224
|
if (!this.child) return void 0;
|
|
@@ -232,9 +234,10 @@ class WorkerCommandLauncher {
|
|
|
232
234
|
release: () => this.releaseRecord(record)
|
|
233
235
|
};
|
|
234
236
|
}
|
|
237
|
+
/** Revoke a launch. Returns whether this launcher still held it (queued or acquired, not yet released). */
|
|
235
238
|
async cancel(id) {
|
|
236
239
|
const record = this.launches.get(id);
|
|
237
|
-
if (!record || record.released) return;
|
|
240
|
+
if (!record || record.released) return false;
|
|
238
241
|
record.revoked = true;
|
|
239
242
|
record.rejectAdmission(new CommandLaunchCancelledError());
|
|
240
243
|
if (this.child) {
|
|
@@ -244,6 +247,7 @@ class WorkerCommandLauncher {
|
|
|
244
247
|
throw this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
245
248
|
}
|
|
246
249
|
}
|
|
250
|
+
return true;
|
|
247
251
|
}
|
|
248
252
|
async cancelSession(sessionId) {
|
|
249
253
|
await this.cancelMatching((_id, ownerSessionId) => ownerSessionId === sessionId);
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import { readBrowserUploadChunk } from "./browser-upload.mjs";
|
|
2
3
|
import { WORKER_RUNTIME_VERSION } from "./runtime-version.mjs";
|
|
3
4
|
import { getWorkerPlanPath, getWorkerPlansPath, validatePlanId } from "./plan-paths.mjs";
|
|
4
5
|
import { createPlanMarkdown, updateTaskCheckbox } from "./plan-parser.mjs";
|
|
@@ -346,9 +347,10 @@ function unregisterPendingReservation(key, controller) {
|
|
|
346
347
|
}
|
|
347
348
|
function abortPendingReservation(key, reason) {
|
|
348
349
|
const pending = pendingReservationAborts.get(key);
|
|
349
|
-
if (!pending) return;
|
|
350
|
+
if (!pending) return false;
|
|
350
351
|
pendingReservationAborts.delete(key);
|
|
351
352
|
pending.controller.abort(new WorkspaceMountHoldAbortedError(reason));
|
|
353
|
+
return true;
|
|
352
354
|
}
|
|
353
355
|
function abortPendingReservations(reason, sessionId) {
|
|
354
356
|
for (const [key, pending] of [...pendingReservationAborts]) {
|
|
@@ -2839,6 +2841,13 @@ async function executeOperation(input) {
|
|
|
2839
2841
|
return executeViewFileBytesOperation({ ...input, message: input.message });
|
|
2840
2842
|
case "code_list":
|
|
2841
2843
|
return executeCodeListOperation({ ...input, message: input.message });
|
|
2844
|
+
case "browser_upload_read":
|
|
2845
|
+
input.assertAdmission();
|
|
2846
|
+
return readBrowserUploadChunk(input.message.input, {
|
|
2847
|
+
cwd: input.resolvedTarget.rootPath,
|
|
2848
|
+
rootDir: input.rootDir,
|
|
2849
|
+
sessionId: input.message.sessionId
|
|
2850
|
+
});
|
|
2842
2851
|
case "code_read":
|
|
2843
2852
|
return executeCodeReadOperation({ ...input, message: input.message });
|
|
2844
2853
|
}
|
|
@@ -3212,32 +3221,38 @@ async function reapCompletedCredentialBearingProcessGroup(runId, subprocess) {
|
|
|
3212
3221
|
);
|
|
3213
3222
|
}
|
|
3214
3223
|
}
|
|
3215
|
-
async function cancelProcessRun(runId) {
|
|
3224
|
+
async function cancelProcessRun(runId, scope) {
|
|
3225
|
+
const spawned = activeProcesses.get(runId);
|
|
3226
|
+
if (scope === "unstarted" && spawned) return { cancelled: false, message: `${spawned.command} already started`, outcome: "started" };
|
|
3227
|
+
if (!spawned) cancelledProcessRuns.add(runId);
|
|
3228
|
+
const revocation = workerCommandLauncher?.cancel(runId);
|
|
3216
3229
|
let resourceCancellationError;
|
|
3230
|
+
let launchRevoked = false;
|
|
3217
3231
|
try {
|
|
3218
|
-
await
|
|
3232
|
+
launchRevoked = await revocation ?? false;
|
|
3219
3233
|
} catch (error) {
|
|
3220
3234
|
resourceCancellationError = error instanceof Error ? error.message : String(error);
|
|
3221
3235
|
}
|
|
3222
|
-
const active = activeProcesses.get(runId);
|
|
3223
3236
|
let cancelled = resourceCancellationError === void 0;
|
|
3224
3237
|
let cancelMessage;
|
|
3225
|
-
|
|
3238
|
+
let outcome;
|
|
3239
|
+
if (spawned) {
|
|
3240
|
+
outcome = "stopped";
|
|
3226
3241
|
try {
|
|
3227
|
-
closeProcessStdin(
|
|
3228
|
-
await terminateProcessTree(
|
|
3229
|
-
cancelMessage = `Stopped ${
|
|
3242
|
+
closeProcessStdin(spawned);
|
|
3243
|
+
await terminateProcessTree(spawned.process);
|
|
3244
|
+
cancelMessage = `Stopped ${spawned.command}`;
|
|
3230
3245
|
} catch (error) {
|
|
3231
3246
|
cancelled = false;
|
|
3232
|
-
cancelMessage = `Failed to stop ${
|
|
3247
|
+
cancelMessage = `Failed to stop ${spawned.command}: ${error instanceof Error ? error.message : String(error)}`;
|
|
3233
3248
|
}
|
|
3234
3249
|
} else {
|
|
3235
|
-
|
|
3236
|
-
|
|
3250
|
+
const reservationAborted = abortPendingReservation(`run:${runId}`, "Cancellation queued before command start");
|
|
3251
|
+
outcome = launchRevoked || reservationAborted ? "unstarted" : "unknown";
|
|
3237
3252
|
cancelMessage = "Cancellation queued before command start";
|
|
3238
3253
|
}
|
|
3239
3254
|
if (resourceCancellationError) cancelMessage += `; job resource termination failed: ${resourceCancellationError}`;
|
|
3240
|
-
return { cancelled, message: cancelMessage };
|
|
3255
|
+
return { cancelled, message: cancelMessage, outcome };
|
|
3241
3256
|
}
|
|
3242
3257
|
function closeProcessStdin(active) {
|
|
3243
3258
|
if (!active?.stdin) {
|
|
@@ -3264,6 +3279,10 @@ function sendReplayWorkerMessage(ws, message) {
|
|
|
3264
3279
|
}
|
|
3265
3280
|
function sendWorkerMessage(ws, message) {
|
|
3266
3281
|
if (launcherFailureInProgress) return;
|
|
3282
|
+
if (message.type === "browser_upload_read_result") {
|
|
3283
|
+
if (currentWorkerSocket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
|
3284
|
+
return;
|
|
3285
|
+
}
|
|
3267
3286
|
const transportError = "error" in message && typeof message.error === "string" ? message.error : message.type === "workspace_sync_result" && message.result.outcome === "failed" ? message.result.error : void 0;
|
|
3268
3287
|
if (recoveryJournal && transportError && isWorkerCommunicationFailure(transportError) && "requestId" in message && typeof message.requestId === "string") {
|
|
3269
3288
|
recoveryJournal.unknown(message.requestId);
|
|
@@ -6050,6 +6069,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
6050
6069
|
capabilities: {
|
|
6051
6070
|
updateClis: true,
|
|
6052
6071
|
browserPortForwarding: true,
|
|
6072
|
+
browserFileUploads: true,
|
|
6053
6073
|
execStdinV1: true,
|
|
6054
6074
|
ptyEnvFilesV1: true,
|
|
6055
6075
|
workspaceRemediationAncestorGuardV1: true,
|
|
@@ -6186,10 +6206,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
6186
6206
|
if (!startupRecoveryComplete && message.type !== "ping") await startupRecoveryPromise;
|
|
6187
6207
|
if (!startupRecoveryComplete && message.type !== "ping") return;
|
|
6188
6208
|
if (currentWorkerSocket !== ws) return;
|
|
6189
|
-
const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" ? message.requestId : void 0;
|
|
6209
|
+
const operationRequestId = "requestId" in message && typeof message.requestId === "string" && message.type !== "workspace_config" && message.type !== "browser_upload_read" ? message.requestId : void 0;
|
|
6190
6210
|
if (message.type === "cancel") {
|
|
6191
6211
|
const admission = recoveryJournal.admit({ ...message, requestId: message.requestId });
|
|
6192
|
-
const cancelled = await cancelProcessRun(message.runId);
|
|
6212
|
+
const cancelled = await cancelProcessRun(message.runId, message.scope);
|
|
6193
6213
|
let admitted;
|
|
6194
6214
|
try {
|
|
6195
6215
|
admitted = await admission;
|
|
@@ -6215,7 +6235,8 @@ async function startWorker(options, projectRuntime = {
|
|
|
6215
6235
|
requestId: message.requestId,
|
|
6216
6236
|
runId: message.runId,
|
|
6217
6237
|
cancelled: cancelled.cancelled,
|
|
6218
|
-
message: cancelled.message
|
|
6238
|
+
message: cancelled.message,
|
|
6239
|
+
outcome: cancelled.outcome
|
|
6219
6240
|
})
|
|
6220
6241
|
);
|
|
6221
6242
|
return;
|
|
@@ -7091,10 +7112,10 @@ async function startWorker(options, projectRuntime = {
|
|
|
7091
7112
|
});
|
|
7092
7113
|
return;
|
|
7093
7114
|
}
|
|
7094
|
-
if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read") {
|
|
7115
|
+
if (message.type === "read_plan" || message.type === "lint_plans" || message.type === "update_task_status" || message.type === "read" || message.type === "write" || message.type === "edit" || message.type === "grep" || message.type === "find" || message.type === "ls" || message.type === "view_file_bytes" || message.type === "code_list" || message.type === "code_read" || message.type === "browser_upload_read") {
|
|
7095
7116
|
if (workspaceOperationsAreFenced(deferredWorkspaceConfiguration, activeWorkspaceIncidentId)) {
|
|
7096
7117
|
sendWorkerMessage(ws, {
|
|
7097
|
-
type: "operation_result",
|
|
7118
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7098
7119
|
requestId: message.requestId,
|
|
7099
7120
|
error: `Workspace operations are deferred for incident ${deferredWorkspaceConfiguration?.incidentId ?? activeWorkspaceIncidentId}; use a canonical remediation shell`
|
|
7100
7121
|
});
|
|
@@ -7122,7 +7143,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7122
7143
|
sendSerializedWorkerMessage(
|
|
7123
7144
|
ws,
|
|
7124
7145
|
JSON.stringify({
|
|
7125
|
-
type: "operation_result",
|
|
7146
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7126
7147
|
requestId: message.requestId,
|
|
7127
7148
|
error: error instanceof Error ? error.message : String(error)
|
|
7128
7149
|
})
|
|
@@ -7150,7 +7171,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7150
7171
|
sendSerializedWorkerMessage(
|
|
7151
7172
|
ws,
|
|
7152
7173
|
JSON.stringify({
|
|
7153
|
-
type: "operation_result",
|
|
7174
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7154
7175
|
requestId: message.requestId,
|
|
7155
7176
|
result
|
|
7156
7177
|
})
|
|
@@ -7168,7 +7189,7 @@ async function startWorker(options, projectRuntime = {
|
|
|
7168
7189
|
sendSerializedWorkerMessage(
|
|
7169
7190
|
ws,
|
|
7170
7191
|
JSON.stringify({
|
|
7171
|
-
type: "operation_result",
|
|
7192
|
+
type: message.type === "browser_upload_read" ? "browser_upload_read_result" : "operation_result",
|
|
7172
7193
|
requestId: message.requestId,
|
|
7173
7194
|
error: error instanceof Error ? error.message : String(error)
|
|
7174
7195
|
})
|
package/dist/mjs/package.json
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type BrowserUploadChunk, type BrowserUploadReadInput } from "@ricsam/r5d-api/browser-upload";
|
|
2
|
+
/** Unlike code-view paths, uploads may read any regular file accessible to this worker. */
|
|
3
|
+
export declare function readBrowserUploadChunk(input: BrowserUploadReadInput, context: {
|
|
4
|
+
cwd: string;
|
|
5
|
+
rootDir: string;
|
|
6
|
+
sessionId: string;
|
|
7
|
+
}): Promise<BrowserUploadChunk>;
|
|
@@ -86,7 +86,8 @@ export declare class WorkerCommandLauncher {
|
|
|
86
86
|
capacityReport(): CommandLaunchCapacityReport;
|
|
87
87
|
private notifyCapacityChange;
|
|
88
88
|
acquire(request: CommandLaunchRequest): Promise<CommandLaunchLease>;
|
|
89
|
-
|
|
89
|
+
/** Revoke a launch. Returns whether this launcher still held it (queued or acquired, not yet released). */
|
|
90
|
+
cancel(id: string): Promise<boolean>;
|
|
90
91
|
cancelSession(sessionId: string): Promise<void>;
|
|
91
92
|
cancelMatching(matches: (id: string, sessionId?: string) => boolean): Promise<void>;
|
|
92
93
|
close(): Promise<void>;
|
package/dist/types/main.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
import type { BrowserUploadChunk, BrowserUploadReadInput } from "@ricsam/r5d-api/browser-upload";
|
|
2
3
|
import type { WorkerLintPlansResult, WorkerUpdatePlanTaskResult } from "./plan-paths";
|
|
3
4
|
import { WORKER_RESUMABLE_PROTOCOL, type WorkerRecoveryClientMessage, type WorkerRecoveryServerMessage } from "./recovery-protocol";
|
|
4
5
|
import { Database } from "bun:sqlite";
|
|
@@ -141,6 +142,8 @@ export type WorkerSessionTarget = {
|
|
|
141
142
|
ownerUserId: string;
|
|
142
143
|
rootProfile: "visible_projects" | "canonical_sync";
|
|
143
144
|
};
|
|
145
|
+
type WorkerCancelScope = "unstarted";
|
|
146
|
+
type WorkerCancelOutcome = "unstarted" | "started" | "stopped" | "unknown";
|
|
144
147
|
type WorkerClientMessage = WorkerRecoveryClientMessage | {
|
|
145
148
|
type: "capacity_report";
|
|
146
149
|
capacity: import("./command-launcher").CommandLaunchCapacityReport & {
|
|
@@ -202,6 +205,11 @@ type WorkerClientMessage = WorkerRecoveryClientMessage | {
|
|
|
202
205
|
ptyId: string;
|
|
203
206
|
requestId?: string;
|
|
204
207
|
error: string;
|
|
208
|
+
} | {
|
|
209
|
+
type: "browser_upload_read_result";
|
|
210
|
+
requestId: string;
|
|
211
|
+
result?: WorkerOperationResult;
|
|
212
|
+
error?: string;
|
|
205
213
|
} | {
|
|
206
214
|
type: "operation_result";
|
|
207
215
|
requestId: string;
|
|
@@ -262,6 +270,8 @@ type WorkerClientMessage = WorkerRecoveryClientMessage | {
|
|
|
262
270
|
runId: string;
|
|
263
271
|
cancelled: boolean;
|
|
264
272
|
message?: string;
|
|
273
|
+
/** `unstarted`: a launch that had not spawned was revoked; `started`: a spawned process was left alone; `stopped`; `unknown`: no record, cancellation recorded. */
|
|
274
|
+
outcome?: WorkerCancelOutcome;
|
|
265
275
|
} | {
|
|
266
276
|
type: "pong";
|
|
267
277
|
} | {
|
|
@@ -351,6 +361,12 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
|
|
|
351
361
|
type: "project";
|
|
352
362
|
}>;
|
|
353
363
|
path: string;
|
|
364
|
+
} | {
|
|
365
|
+
type: "browser_upload_read";
|
|
366
|
+
requestId: string;
|
|
367
|
+
target: WorkerSessionTarget;
|
|
368
|
+
sessionId: string;
|
|
369
|
+
input: BrowserUploadReadInput;
|
|
354
370
|
} | {
|
|
355
371
|
type: "code_read";
|
|
356
372
|
requestId: string;
|
|
@@ -533,6 +549,8 @@ type WorkerServerMessage = WorkerRecoveryServerMessage | {
|
|
|
533
549
|
type: "cancel";
|
|
534
550
|
requestId: string;
|
|
535
551
|
runId: string;
|
|
552
|
+
/** `unstarted`: the server's start-deadline abandonment; never touch a spawned process. */
|
|
553
|
+
scope?: WorkerCancelScope;
|
|
536
554
|
} | {
|
|
537
555
|
type: "ping";
|
|
538
556
|
};
|
|
@@ -633,9 +651,9 @@ type WorkerCodeReadResult = {
|
|
|
633
651
|
mtime: string;
|
|
634
652
|
base64: string;
|
|
635
653
|
};
|
|
636
|
-
type WorkerOperationResult = WorkerReadFileResult | WorkerReadPlanResult | WorkerLintPlansResult | WorkerUpdatePlanTaskResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult;
|
|
654
|
+
type WorkerOperationResult = WorkerReadFileResult | WorkerReadPlanResult | WorkerLintPlansResult | WorkerUpdatePlanTaskResult | WorkerWriteFileResult | WorkerEditFileResult | WorkerGrepResult | WorkerFindResult | WorkerLsResult | WorkerViewFileBytesResult | WorkerExecStdinAck | WorkerCreateProjectBranchResult | WorkerDeleteProjectBranchResult | WorkerCodeListResult | WorkerCodeReadResult | BrowserUploadChunk;
|
|
637
655
|
type WorkerOperationServerMessage = Extract<WorkerServerMessage, {
|
|
638
|
-
type: "read_plan" | "lint_plans" | "update_task_status" | "read" | "write" | "edit" | "grep" | "find" | "ls" | "view_file_bytes" | "code_list" | "code_read";
|
|
656
|
+
type: "read_plan" | "lint_plans" | "update_task_status" | "read" | "write" | "edit" | "grep" | "find" | "ls" | "view_file_bytes" | "code_list" | "code_read" | "browser_upload_read";
|
|
639
657
|
}>;
|
|
640
658
|
declare class WorkspaceSyncAdmissionChangedError extends Error {
|
|
641
659
|
readonly name = "WorkspaceSyncAdmissionChangedError";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ricsam/r5d-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.126",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/cjs/main.cjs",
|
|
6
6
|
"module": "./dist/mjs/main.mjs",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"r5d-worker": "dist/cjs/main.cjs"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
+
"@ricsam/r5d-api": "^0.0.126",
|
|
24
25
|
"node-pty": "^1.1.0",
|
|
25
26
|
"picomatch": "^4.0.3"
|
|
26
27
|
},
|