@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 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: () => this.cancel(request.id),
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 workerCommandLauncher?.cancel(runId);
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
- if (active) {
3217
+ let outcome;
3218
+ if (spawned) {
3219
+ outcome = "stopped";
3205
3220
  try {
3206
- closeProcessStdin(active);
3207
- await (0, import_process_tree.terminateProcessTree)(active.process);
3208
- cancelMessage = `Stopped ${active.command}`;
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 ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
3226
+ cancelMessage = `Failed to stop ${spawned.command}: ${error instanceof Error ? error.message : String(error)}`;
3212
3227
  }
3213
3228
  } else {
3214
- cancelledProcessRuns.add(runId);
3215
- abortPendingReservation(`run:${runId}`, "Cancellation queued before command start");
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
  })
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.124",
3
+ "version": "0.0.126",
4
4
  "type": "commonjs"
5
5
  }
@@ -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: () => this.cancel(request.id),
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 workerCommandLauncher?.cancel(runId);
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
- if (active) {
3238
+ let outcome;
3239
+ if (spawned) {
3240
+ outcome = "stopped";
3226
3241
  try {
3227
- closeProcessStdin(active);
3228
- await terminateProcessTree(active.process);
3229
- cancelMessage = `Stopped ${active.command}`;
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 ${active.command}: ${error instanceof Error ? error.message : String(error)}`;
3247
+ cancelMessage = `Failed to stop ${spawned.command}: ${error instanceof Error ? error.message : String(error)}`;
3233
3248
  }
3234
3249
  } else {
3235
- cancelledProcessRuns.add(runId);
3236
- abortPendingReservation(`run:${runId}`, "Cancellation queued before command start");
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
  })
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.124",
3
+ "version": "0.0.126",
4
4
  "type": "module"
5
5
  }
@@ -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
- cancel(id: string): Promise<void>;
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>;
@@ -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.124",
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
  },