@ricsam/r5d-worker 0.0.6 → 0.0.8

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.
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var git_identity_exports = {};
20
+ __export(git_identity_exports, {
21
+ configureVisibleGitIdentity: () => configureVisibleGitIdentity
22
+ });
23
+ module.exports = __toCommonJS(git_identity_exports);
24
+ function configureVisibleGitIdentity(branchPath, identity, runGit) {
25
+ if (!identity) {
26
+ return;
27
+ }
28
+ const name = identity.name.trim();
29
+ const email = identity.email.trim();
30
+ if (!name || !email) {
31
+ throw new Error("Git identity must include name and email");
32
+ }
33
+ runGit(["config", "user.name", name], { cwd: branchPath });
34
+ runGit(["config", "user.email", email], { cwd: branchPath });
35
+ }
36
+ // Annotate the CommonJS export names for ESM import in node:
37
+ 0 && (module.exports = {
38
+ configureVisibleGitIdentity
39
+ });
package/dist/cjs/main.cjs CHANGED
@@ -39,6 +39,7 @@ const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
39
39
  const R5D_REMOTE_NAME = "r5d";
40
40
  const LEGACY_BUILD_IT_NOW_REMOTE_NAME = "build-it-now";
41
41
  const activeProcesses = /* @__PURE__ */ new Map();
42
+ const cancelledProcessRuns = /* @__PURE__ */ new Set();
42
43
  const activePtys = /* @__PURE__ */ new Map();
43
44
  const branchLocks = /* @__PURE__ */ new Map();
44
45
  let containerRegistryCredential = null;
@@ -97,7 +98,7 @@ function getWorkerVersion() {
97
98
  return packageVersion;
98
99
  }
99
100
  const sourceVersion = readVersionFile(import_node_path.default.join(current, "VERSION.txt"));
100
- if (sourceVersion && import_node_path.default.basename(current) === "binctl-packages") {
101
+ if (sourceVersion && import_node_path.default.basename(current) === "packages") {
101
102
  return sourceVersion;
102
103
  }
103
104
  const parent = import_node_path.default.dirname(current);
@@ -223,8 +224,8 @@ async function readResponseText(response) {
223
224
  return "";
224
225
  }
225
226
  }
226
- async function verifyBinctlAuth(baseUrl, token) {
227
- const statusUrl = new URL("/api/binctl/auth/status", baseUrl);
227
+ async function verifyR5dctlAuth(baseUrl, token) {
228
+ const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
228
229
  let response;
229
230
  try {
230
231
  response = await fetch(statusUrl, {
@@ -253,12 +254,12 @@ async function verifyBinctlAuth(baseUrl, token) {
253
254
  );
254
255
  }
255
256
  function resolveCredentials(options, config) {
256
- const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.BINCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.BINCTL_API_KEY ?? config.apiKey;
257
+ const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
257
258
  if (!token) {
258
259
  throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
259
260
  }
260
261
  return {
261
- baseUrl: normalizeBaseUrl(options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.BINCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL),
262
+ baseUrl: normalizeBaseUrl(options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL),
262
263
  token
263
264
  };
264
265
  }
@@ -685,6 +686,31 @@ async function collectStream(stream) {
685
686
  }
686
687
  return await new Response(stream).text();
687
688
  }
689
+ async function streamCommandOutput(stream, onData) {
690
+ if (!stream) {
691
+ return;
692
+ }
693
+ const reader = stream.getReader();
694
+ const decoder = new TextDecoder();
695
+ try {
696
+ while (true) {
697
+ const chunk = await reader.read();
698
+ if (chunk.done) {
699
+ break;
700
+ }
701
+ const text = decoder.decode(chunk.value, { stream: true });
702
+ if (text.length > 0) {
703
+ onData(text);
704
+ }
705
+ }
706
+ const trailing = decoder.decode();
707
+ if (trailing.length > 0) {
708
+ onData(trailing);
709
+ }
710
+ } finally {
711
+ reader.releaseLock();
712
+ }
713
+ }
688
714
  function ensureOperationBranch(input) {
689
715
  return ensureBranchWorkspace(input);
690
716
  }
@@ -831,6 +857,171 @@ async function executeEditFileOperation(input) {
831
857
  file: resolved.virtualPath
832
858
  };
833
859
  }
860
+ function decodePatchPath(rawPath) {
861
+ const trimmed = rawPath.trim();
862
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
863
+ try {
864
+ return JSON.parse(trimmed);
865
+ } catch {
866
+ return trimmed.slice(1, -1);
867
+ }
868
+ }
869
+ return trimmed;
870
+ }
871
+ function normalizePatchPath(rawPath) {
872
+ const withoutMetadata = rawPath.split(" ")[0] ?? rawPath;
873
+ let patchPath = decodePatchPath(withoutMetadata);
874
+ if (patchPath === "/dev/null") {
875
+ return null;
876
+ }
877
+ if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
878
+ patchPath = patchPath.slice(2);
879
+ }
880
+ if (!patchPath || import_node_path.default.isAbsolute(patchPath)) {
881
+ throw new Error(`Invalid patch path: ${rawPath}`);
882
+ }
883
+ const normalized = import_node_path.default.posix.normalize(patchPath);
884
+ if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
885
+ throw new Error(`Invalid patch path: ${rawPath}`);
886
+ }
887
+ return normalized;
888
+ }
889
+ function readDiffPathToken(value, startIndex) {
890
+ let index = startIndex;
891
+ while (index < value.length && /\s/.test(value[index] ?? "")) {
892
+ index += 1;
893
+ }
894
+ if (index >= value.length) {
895
+ throw new Error("Missing path in patch header");
896
+ }
897
+ if (value[index] !== '"') {
898
+ const tokenStart2 = index;
899
+ while (index < value.length && !/\s/.test(value[index] ?? "")) {
900
+ index += 1;
901
+ }
902
+ return { token: value.slice(tokenStart2, index), nextIndex: index };
903
+ }
904
+ const tokenStart = index;
905
+ index += 1;
906
+ let escaped = false;
907
+ while (index < value.length) {
908
+ const character = value[index];
909
+ if (escaped) {
910
+ escaped = false;
911
+ } else if (character === "\\") {
912
+ escaped = true;
913
+ } else if (character === '"') {
914
+ index += 1;
915
+ return { token: value.slice(tokenStart, index), nextIndex: index };
916
+ }
917
+ index += 1;
918
+ }
919
+ throw new Error("Unterminated quoted path in patch header");
920
+ }
921
+ function parseDiffGitPaths(line) {
922
+ if (!line.startsWith("diff --git ")) {
923
+ throw new Error(`Invalid patch header: ${line}`);
924
+ }
925
+ const rest = line.slice("diff --git ".length);
926
+ const first = readDiffPathToken(rest, 0);
927
+ const second = readDiffPathToken(rest, first.nextIndex);
928
+ if (rest.slice(second.nextIndex).trim()) {
929
+ throw new Error(`Invalid patch header: ${line}`);
930
+ }
931
+ return {
932
+ oldPath: normalizePatchPath(first.token),
933
+ newPath: normalizePatchPath(second.token)
934
+ };
935
+ }
936
+ function summarizePatch(patch) {
937
+ if (!patch.trim()) {
938
+ throw new Error("Patch cannot be empty");
939
+ }
940
+ const files = [];
941
+ let current;
942
+ for (const line of patch.split(/\r?\n/)) {
943
+ if (line.startsWith("diff --git ")) {
944
+ const { oldPath, newPath } = parseDiffGitPaths(line);
945
+ if (!oldPath && !newPath) {
946
+ throw new Error(`Invalid patch header: ${line}`);
947
+ }
948
+ current = {
949
+ path: newPath ?? oldPath,
950
+ ...oldPath && newPath && oldPath !== newPath ? { oldPath } : {},
951
+ operation: oldPath && newPath && oldPath !== newPath ? "move" : "update",
952
+ additions: 0,
953
+ deletions: 0,
954
+ hunks: 0
955
+ };
956
+ files.push(current);
957
+ continue;
958
+ }
959
+ if (!current) {
960
+ continue;
961
+ }
962
+ if (line.startsWith("new file mode ")) {
963
+ current.operation = "add";
964
+ continue;
965
+ }
966
+ if (line.startsWith("deleted file mode ")) {
967
+ current.operation = "delete";
968
+ if (current.oldPath) {
969
+ current.path = current.oldPath;
970
+ delete current.oldPath;
971
+ }
972
+ continue;
973
+ }
974
+ if (line.startsWith("rename from ")) {
975
+ current.operation = "move";
976
+ current.oldPath = normalizePatchPath(line.slice("rename from ".length)) ?? void 0;
977
+ continue;
978
+ }
979
+ if (line.startsWith("rename to ")) {
980
+ current.operation = "move";
981
+ current.path = normalizePatchPath(line.slice("rename to ".length)) ?? current.path;
982
+ continue;
983
+ }
984
+ if (line.startsWith("@@")) {
985
+ current.hunks += 1;
986
+ continue;
987
+ }
988
+ if (line.startsWith("+") && !line.startsWith("+++")) {
989
+ current.additions += 1;
990
+ continue;
991
+ }
992
+ if (line.startsWith("-") && !line.startsWith("---")) {
993
+ current.deletions += 1;
994
+ }
995
+ }
996
+ if (files.length === 0) {
997
+ throw new Error("Patch must contain at least one diff --git file header");
998
+ }
999
+ return files;
1000
+ }
1001
+ async function executeApplyPatchOperation(input) {
1002
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
1003
+ const files = summarizePatch(input.message.patch);
1004
+ const patchPath = import_node_path.default.join(import_node_os.default.tmpdir(), `r5d-apply-patch-${(0, import_node_crypto.randomUUID)()}.patch`);
1005
+ try {
1006
+ import_node_fs.default.writeFileSync(patchPath, input.message.patch, "utf8");
1007
+ runInternalGit(workspace.internalGit, ["apply", "--check", "--whitespace=nowarn", patchPath]);
1008
+ runInternalGit(workspace.internalGit, ["apply", "--whitespace=nowarn", patchPath]);
1009
+ } catch (error) {
1010
+ throw new Error(`Patch did not apply: ${error instanceof Error ? error.message : String(error)}`);
1011
+ } finally {
1012
+ import_node_fs.default.rmSync(patchPath, { force: true });
1013
+ }
1014
+ return {
1015
+ type: "apply_patch",
1016
+ files,
1017
+ summary: {
1018
+ filesChanged: files.length,
1019
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
1020
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
1021
+ hunks: files.reduce((sum, file) => sum + file.hunks, 0)
1022
+ }
1023
+ };
1024
+ }
834
1025
  async function executeViewFileBytesOperation(input) {
835
1026
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
836
1027
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
@@ -952,6 +1143,8 @@ async function executeOperation(input) {
952
1143
  return executeCreateFileOperation({ ...input, message: input.message });
953
1144
  case "edit_file":
954
1145
  return executeEditFileOperation({ ...input, message: input.message });
1146
+ case "apply_patch":
1147
+ return executeApplyPatchOperation({ ...input, message: input.message });
955
1148
  case "view_file_bytes":
956
1149
  return executeViewFileBytesOperation({ ...input, message: input.message });
957
1150
  case "sync":
@@ -1053,6 +1246,105 @@ async function executeCommand(input) {
1053
1246
  activeProcesses.delete(input.message.runId);
1054
1247
  }
1055
1248
  }
1249
+ async function executeStreamingCommand(input) {
1250
+ const startedAt = Date.now();
1251
+ let timeout;
1252
+ let started = false;
1253
+ let timedOut = false;
1254
+ try {
1255
+ if (cancelledProcessRuns.delete(input.message.runId)) {
1256
+ sendWorkerMessage(input.ws, {
1257
+ type: "exec_start_error",
1258
+ requestId: input.message.requestId,
1259
+ runId: input.message.runId,
1260
+ error: "Command cancelled before it started"
1261
+ });
1262
+ return;
1263
+ }
1264
+ const branchPath = ensureBranchWorkspace({
1265
+ projectId: input.projectId,
1266
+ baseUrl: input.baseUrl,
1267
+ token: input.token,
1268
+ projectRoot: input.projectRoot,
1269
+ syncRoot: input.syncRoot,
1270
+ branchName: input.message.branchName,
1271
+ manifest: input.manifest
1272
+ });
1273
+ const cwd = resolveCommandCwd(branchPath.branchPath, input.message.cwd);
1274
+ const subprocess = Bun.spawn(input.message.argv, {
1275
+ cwd,
1276
+ stdout: "pipe",
1277
+ stderr: "pipe",
1278
+ env: {
1279
+ ...process.env,
1280
+ ...containerRegistryEnv(),
1281
+ ...input.message.env ?? {}
1282
+ }
1283
+ });
1284
+ activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1285
+ started = true;
1286
+ sendWorkerMessage(input.ws, {
1287
+ type: "exec_started",
1288
+ requestId: input.message.requestId,
1289
+ runId: input.message.runId
1290
+ });
1291
+ if (input.message.timeoutMs) {
1292
+ timeout = setTimeout(() => {
1293
+ timedOut = true;
1294
+ subprocess.kill("SIGTERM");
1295
+ }, input.message.timeoutMs);
1296
+ }
1297
+ const [exitCode] = await Promise.all([
1298
+ subprocess.exited,
1299
+ streamCommandOutput(subprocess.stdout, (data) => {
1300
+ sendWorkerMessage(input.ws, {
1301
+ type: "exec_output",
1302
+ runId: input.message.runId,
1303
+ stream: "stdout",
1304
+ data
1305
+ });
1306
+ }),
1307
+ streamCommandOutput(subprocess.stderr, (data) => {
1308
+ sendWorkerMessage(input.ws, {
1309
+ type: "exec_output",
1310
+ runId: input.message.runId,
1311
+ stream: "stderr",
1312
+ data
1313
+ });
1314
+ })
1315
+ ]);
1316
+ sendWorkerMessage(input.ws, {
1317
+ type: "exec_exit",
1318
+ runId: input.message.runId,
1319
+ exitCode,
1320
+ durationMs: Date.now() - startedAt,
1321
+ ...timedOut ? { timedOut: true } : {}
1322
+ });
1323
+ } catch (error) {
1324
+ const message = error instanceof Error ? error.message : String(error);
1325
+ if (started) {
1326
+ sendWorkerMessage(input.ws, {
1327
+ type: "exec_error",
1328
+ runId: input.message.runId,
1329
+ error: message,
1330
+ durationMs: Date.now() - startedAt
1331
+ });
1332
+ } else {
1333
+ sendWorkerMessage(input.ws, {
1334
+ type: "exec_start_error",
1335
+ requestId: input.message.requestId,
1336
+ runId: input.message.runId,
1337
+ error: message
1338
+ });
1339
+ }
1340
+ } finally {
1341
+ if (timeout) {
1342
+ clearTimeout(timeout);
1343
+ }
1344
+ activeProcesses.delete(input.message.runId);
1345
+ cancelledProcessRuns.delete(input.message.runId);
1346
+ }
1347
+ }
1056
1348
  function sendWorkerMessage(ws, message) {
1057
1349
  ws.send(JSON.stringify(message));
1058
1350
  }
@@ -1370,7 +1662,7 @@ async function startWorker(options) {
1370
1662
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1371
1663
  `);
1372
1664
  runGit(["--version"]);
1373
- await verifyBinctlAuth(baseUrl, token);
1665
+ await verifyR5dctlAuth(baseUrl, token);
1374
1666
  import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
1375
1667
  import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
1376
1668
  const manifestByProjectId = /* @__PURE__ */ new Map();
@@ -1443,14 +1735,16 @@ async function startWorker(options) {
1443
1735
  const active = activeProcesses.get(message.runId);
1444
1736
  if (active) {
1445
1737
  active.process.kill("SIGTERM");
1738
+ } else {
1739
+ cancelledProcessRuns.add(message.runId);
1446
1740
  }
1447
1741
  ws.send(
1448
1742
  JSON.stringify({
1449
1743
  type: "cancel_result",
1450
1744
  requestId: message.requestId,
1451
1745
  runId: message.runId,
1452
- cancelled: !!active,
1453
- message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "No active command with that run id"
1746
+ cancelled: true,
1747
+ message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "Cancellation queued before command start"
1454
1748
  })
1455
1749
  );
1456
1750
  return;
@@ -1488,7 +1782,40 @@ async function startWorker(options) {
1488
1782
  ws.send(JSON.stringify(result));
1489
1783
  return;
1490
1784
  }
1491
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
1785
+ if (message.type === "exec_start") {
1786
+ sendWorkerMessage(ws, {
1787
+ type: "exec_accepted",
1788
+ requestId: message.requestId,
1789
+ runId: message.runId
1790
+ });
1791
+ try {
1792
+ const manifest = manifestByProjectId.get(message.projectId);
1793
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
1794
+ process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
1795
+ `);
1796
+ void withBranchLock(
1797
+ message.projectId,
1798
+ message.branchName,
1799
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
1800
+ ).catch((error) => {
1801
+ sendWorkerMessage(ws, {
1802
+ type: "exec_start_error",
1803
+ requestId: message.requestId,
1804
+ runId: message.runId,
1805
+ error: error instanceof Error ? error.message : String(error)
1806
+ });
1807
+ });
1808
+ } catch (error) {
1809
+ sendWorkerMessage(ws, {
1810
+ type: "exec_start_error",
1811
+ requestId: message.requestId,
1812
+ runId: message.runId,
1813
+ error: error instanceof Error ? error.message : String(error)
1814
+ });
1815
+ }
1816
+ return;
1817
+ }
1818
+ if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "apply_patch" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
1492
1819
  try {
1493
1820
  const manifest = manifestByProjectId.get(message.projectId);
1494
1821
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -1513,6 +1840,17 @@ async function startWorker(options) {
1513
1840
  })
1514
1841
  );
1515
1842
  }
1843
+ return;
1844
+ }
1845
+ const unhandledMessage = message;
1846
+ if (typeof unhandledMessage.requestId === "string") {
1847
+ ws.send(
1848
+ JSON.stringify({
1849
+ type: "operation_result",
1850
+ requestId: unhandledMessage.requestId,
1851
+ error: `Unsupported worker message type: ${String(unhandledMessage.type)}`
1852
+ })
1853
+ );
1516
1854
  }
1517
1855
  })().catch((error) => {
1518
1856
  process.stderr.write(`[r5d-worker] message handling failed: ${error instanceof Error ? error.message : String(error)}
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "commonjs"
5
5
  }
@@ -0,0 +1,15 @@
1
+ function configureVisibleGitIdentity(branchPath, identity, runGit) {
2
+ if (!identity) {
3
+ return;
4
+ }
5
+ const name = identity.name.trim();
6
+ const email = identity.email.trim();
7
+ if (!name || !email) {
8
+ throw new Error("Git identity must include name and email");
9
+ }
10
+ runGit(["config", "user.name", name], { cwd: branchPath });
11
+ runGit(["config", "user.email", email], { cwd: branchPath });
12
+ }
13
+ export {
14
+ configureVisibleGitIdentity
15
+ };
package/dist/mjs/main.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { createHash } from "node:crypto";
5
+ import { createHash, randomUUID } from "node:crypto";
6
6
  import { hostname } from "node:os";
7
7
  import { spawn as spawnChildProcess } from "node:child_process";
8
8
  import { configureVisibleGitIdentity } from "./git-identity.mjs";
@@ -16,6 +16,7 @@ const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
16
16
  const R5D_REMOTE_NAME = "r5d";
17
17
  const LEGACY_BUILD_IT_NOW_REMOTE_NAME = "build-it-now";
18
18
  const activeProcesses = /* @__PURE__ */ new Map();
19
+ const cancelledProcessRuns = /* @__PURE__ */ new Set();
19
20
  const activePtys = /* @__PURE__ */ new Map();
20
21
  const branchLocks = /* @__PURE__ */ new Map();
21
22
  let containerRegistryCredential = null;
@@ -74,7 +75,7 @@ function getWorkerVersion() {
74
75
  return packageVersion;
75
76
  }
76
77
  const sourceVersion = readVersionFile(path.join(current, "VERSION.txt"));
77
- if (sourceVersion && path.basename(current) === "binctl-packages") {
78
+ if (sourceVersion && path.basename(current) === "packages") {
78
79
  return sourceVersion;
79
80
  }
80
81
  const parent = path.dirname(current);
@@ -200,8 +201,8 @@ async function readResponseText(response) {
200
201
  return "";
201
202
  }
202
203
  }
203
- async function verifyBinctlAuth(baseUrl, token) {
204
- const statusUrl = new URL("/api/binctl/auth/status", baseUrl);
204
+ async function verifyR5dctlAuth(baseUrl, token) {
205
+ const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
205
206
  let response;
206
207
  try {
207
208
  response = await fetch(statusUrl, {
@@ -230,12 +231,12 @@ async function verifyBinctlAuth(baseUrl, token) {
230
231
  );
231
232
  }
232
233
  function resolveCredentials(options, config) {
233
- const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.BINCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.BINCTL_API_KEY ?? config.apiKey;
234
+ const token = options.token ?? process.env.R5D_WORKER_TOKEN ?? process.env.R5D_TOKEN ?? process.env.R5DCTL_TOKEN ?? config.token ?? options.apiKey ?? process.env.R5D_API_KEY ?? process.env.R5DCTL_API_KEY ?? config.apiKey;
234
235
  if (!token) {
235
236
  throw new Error("Authentication required. Run `r5dctl auth login` or set R5D_WORKER_TOKEN/R5D_API_KEY.");
236
237
  }
237
238
  return {
238
- baseUrl: normalizeBaseUrl(options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.BINCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL),
239
+ baseUrl: normalizeBaseUrl(options.baseUrl ?? process.env.R5D_BASE_URL ?? process.env.R5DCTL_BASE_URL ?? config.baseUrl ?? DEFAULT_BASE_URL),
239
240
  token
240
241
  };
241
242
  }
@@ -662,6 +663,31 @@ async function collectStream(stream) {
662
663
  }
663
664
  return await new Response(stream).text();
664
665
  }
666
+ async function streamCommandOutput(stream, onData) {
667
+ if (!stream) {
668
+ return;
669
+ }
670
+ const reader = stream.getReader();
671
+ const decoder = new TextDecoder();
672
+ try {
673
+ while (true) {
674
+ const chunk = await reader.read();
675
+ if (chunk.done) {
676
+ break;
677
+ }
678
+ const text = decoder.decode(chunk.value, { stream: true });
679
+ if (text.length > 0) {
680
+ onData(text);
681
+ }
682
+ }
683
+ const trailing = decoder.decode();
684
+ if (trailing.length > 0) {
685
+ onData(trailing);
686
+ }
687
+ } finally {
688
+ reader.releaseLock();
689
+ }
690
+ }
665
691
  function ensureOperationBranch(input) {
666
692
  return ensureBranchWorkspace(input);
667
693
  }
@@ -808,6 +834,171 @@ async function executeEditFileOperation(input) {
808
834
  file: resolved.virtualPath
809
835
  };
810
836
  }
837
+ function decodePatchPath(rawPath) {
838
+ const trimmed = rawPath.trim();
839
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
840
+ try {
841
+ return JSON.parse(trimmed);
842
+ } catch {
843
+ return trimmed.slice(1, -1);
844
+ }
845
+ }
846
+ return trimmed;
847
+ }
848
+ function normalizePatchPath(rawPath) {
849
+ const withoutMetadata = rawPath.split(" ")[0] ?? rawPath;
850
+ let patchPath = decodePatchPath(withoutMetadata);
851
+ if (patchPath === "/dev/null") {
852
+ return null;
853
+ }
854
+ if (patchPath.startsWith("a/") || patchPath.startsWith("b/")) {
855
+ patchPath = patchPath.slice(2);
856
+ }
857
+ if (!patchPath || path.isAbsolute(patchPath)) {
858
+ throw new Error(`Invalid patch path: ${rawPath}`);
859
+ }
860
+ const normalized = path.posix.normalize(patchPath);
861
+ if (normalized === "." || normalized.startsWith("../") || normalized === "..") {
862
+ throw new Error(`Invalid patch path: ${rawPath}`);
863
+ }
864
+ return normalized;
865
+ }
866
+ function readDiffPathToken(value, startIndex) {
867
+ let index = startIndex;
868
+ while (index < value.length && /\s/.test(value[index] ?? "")) {
869
+ index += 1;
870
+ }
871
+ if (index >= value.length) {
872
+ throw new Error("Missing path in patch header");
873
+ }
874
+ if (value[index] !== '"') {
875
+ const tokenStart2 = index;
876
+ while (index < value.length && !/\s/.test(value[index] ?? "")) {
877
+ index += 1;
878
+ }
879
+ return { token: value.slice(tokenStart2, index), nextIndex: index };
880
+ }
881
+ const tokenStart = index;
882
+ index += 1;
883
+ let escaped = false;
884
+ while (index < value.length) {
885
+ const character = value[index];
886
+ if (escaped) {
887
+ escaped = false;
888
+ } else if (character === "\\") {
889
+ escaped = true;
890
+ } else if (character === '"') {
891
+ index += 1;
892
+ return { token: value.slice(tokenStart, index), nextIndex: index };
893
+ }
894
+ index += 1;
895
+ }
896
+ throw new Error("Unterminated quoted path in patch header");
897
+ }
898
+ function parseDiffGitPaths(line) {
899
+ if (!line.startsWith("diff --git ")) {
900
+ throw new Error(`Invalid patch header: ${line}`);
901
+ }
902
+ const rest = line.slice("diff --git ".length);
903
+ const first = readDiffPathToken(rest, 0);
904
+ const second = readDiffPathToken(rest, first.nextIndex);
905
+ if (rest.slice(second.nextIndex).trim()) {
906
+ throw new Error(`Invalid patch header: ${line}`);
907
+ }
908
+ return {
909
+ oldPath: normalizePatchPath(first.token),
910
+ newPath: normalizePatchPath(second.token)
911
+ };
912
+ }
913
+ function summarizePatch(patch) {
914
+ if (!patch.trim()) {
915
+ throw new Error("Patch cannot be empty");
916
+ }
917
+ const files = [];
918
+ let current;
919
+ for (const line of patch.split(/\r?\n/)) {
920
+ if (line.startsWith("diff --git ")) {
921
+ const { oldPath, newPath } = parseDiffGitPaths(line);
922
+ if (!oldPath && !newPath) {
923
+ throw new Error(`Invalid patch header: ${line}`);
924
+ }
925
+ current = {
926
+ path: newPath ?? oldPath,
927
+ ...oldPath && newPath && oldPath !== newPath ? { oldPath } : {},
928
+ operation: oldPath && newPath && oldPath !== newPath ? "move" : "update",
929
+ additions: 0,
930
+ deletions: 0,
931
+ hunks: 0
932
+ };
933
+ files.push(current);
934
+ continue;
935
+ }
936
+ if (!current) {
937
+ continue;
938
+ }
939
+ if (line.startsWith("new file mode ")) {
940
+ current.operation = "add";
941
+ continue;
942
+ }
943
+ if (line.startsWith("deleted file mode ")) {
944
+ current.operation = "delete";
945
+ if (current.oldPath) {
946
+ current.path = current.oldPath;
947
+ delete current.oldPath;
948
+ }
949
+ continue;
950
+ }
951
+ if (line.startsWith("rename from ")) {
952
+ current.operation = "move";
953
+ current.oldPath = normalizePatchPath(line.slice("rename from ".length)) ?? void 0;
954
+ continue;
955
+ }
956
+ if (line.startsWith("rename to ")) {
957
+ current.operation = "move";
958
+ current.path = normalizePatchPath(line.slice("rename to ".length)) ?? current.path;
959
+ continue;
960
+ }
961
+ if (line.startsWith("@@")) {
962
+ current.hunks += 1;
963
+ continue;
964
+ }
965
+ if (line.startsWith("+") && !line.startsWith("+++")) {
966
+ current.additions += 1;
967
+ continue;
968
+ }
969
+ if (line.startsWith("-") && !line.startsWith("---")) {
970
+ current.deletions += 1;
971
+ }
972
+ }
973
+ if (files.length === 0) {
974
+ throw new Error("Patch must contain at least one diff --git file header");
975
+ }
976
+ return files;
977
+ }
978
+ async function executeApplyPatchOperation(input) {
979
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
980
+ const files = summarizePatch(input.message.patch);
981
+ const patchPath = path.join(os.tmpdir(), `r5d-apply-patch-${randomUUID()}.patch`);
982
+ try {
983
+ fs.writeFileSync(patchPath, input.message.patch, "utf8");
984
+ runInternalGit(workspace.internalGit, ["apply", "--check", "--whitespace=nowarn", patchPath]);
985
+ runInternalGit(workspace.internalGit, ["apply", "--whitespace=nowarn", patchPath]);
986
+ } catch (error) {
987
+ throw new Error(`Patch did not apply: ${error instanceof Error ? error.message : String(error)}`);
988
+ } finally {
989
+ fs.rmSync(patchPath, { force: true });
990
+ }
991
+ return {
992
+ type: "apply_patch",
993
+ files,
994
+ summary: {
995
+ filesChanged: files.length,
996
+ additions: files.reduce((sum, file) => sum + file.additions, 0),
997
+ deletions: files.reduce((sum, file) => sum + file.deletions, 0),
998
+ hunks: files.reduce((sum, file) => sum + file.hunks, 0)
999
+ }
1000
+ };
1001
+ }
811
1002
  async function executeViewFileBytesOperation(input) {
812
1003
  const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
813
1004
  const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
@@ -929,6 +1120,8 @@ async function executeOperation(input) {
929
1120
  return executeCreateFileOperation({ ...input, message: input.message });
930
1121
  case "edit_file":
931
1122
  return executeEditFileOperation({ ...input, message: input.message });
1123
+ case "apply_patch":
1124
+ return executeApplyPatchOperation({ ...input, message: input.message });
932
1125
  case "view_file_bytes":
933
1126
  return executeViewFileBytesOperation({ ...input, message: input.message });
934
1127
  case "sync":
@@ -1030,6 +1223,105 @@ async function executeCommand(input) {
1030
1223
  activeProcesses.delete(input.message.runId);
1031
1224
  }
1032
1225
  }
1226
+ async function executeStreamingCommand(input) {
1227
+ const startedAt = Date.now();
1228
+ let timeout;
1229
+ let started = false;
1230
+ let timedOut = false;
1231
+ try {
1232
+ if (cancelledProcessRuns.delete(input.message.runId)) {
1233
+ sendWorkerMessage(input.ws, {
1234
+ type: "exec_start_error",
1235
+ requestId: input.message.requestId,
1236
+ runId: input.message.runId,
1237
+ error: "Command cancelled before it started"
1238
+ });
1239
+ return;
1240
+ }
1241
+ const branchPath = ensureBranchWorkspace({
1242
+ projectId: input.projectId,
1243
+ baseUrl: input.baseUrl,
1244
+ token: input.token,
1245
+ projectRoot: input.projectRoot,
1246
+ syncRoot: input.syncRoot,
1247
+ branchName: input.message.branchName,
1248
+ manifest: input.manifest
1249
+ });
1250
+ const cwd = resolveCommandCwd(branchPath.branchPath, input.message.cwd);
1251
+ const subprocess = Bun.spawn(input.message.argv, {
1252
+ cwd,
1253
+ stdout: "pipe",
1254
+ stderr: "pipe",
1255
+ env: {
1256
+ ...process.env,
1257
+ ...containerRegistryEnv(),
1258
+ ...input.message.env ?? {}
1259
+ }
1260
+ });
1261
+ activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
1262
+ started = true;
1263
+ sendWorkerMessage(input.ws, {
1264
+ type: "exec_started",
1265
+ requestId: input.message.requestId,
1266
+ runId: input.message.runId
1267
+ });
1268
+ if (input.message.timeoutMs) {
1269
+ timeout = setTimeout(() => {
1270
+ timedOut = true;
1271
+ subprocess.kill("SIGTERM");
1272
+ }, input.message.timeoutMs);
1273
+ }
1274
+ const [exitCode] = await Promise.all([
1275
+ subprocess.exited,
1276
+ streamCommandOutput(subprocess.stdout, (data) => {
1277
+ sendWorkerMessage(input.ws, {
1278
+ type: "exec_output",
1279
+ runId: input.message.runId,
1280
+ stream: "stdout",
1281
+ data
1282
+ });
1283
+ }),
1284
+ streamCommandOutput(subprocess.stderr, (data) => {
1285
+ sendWorkerMessage(input.ws, {
1286
+ type: "exec_output",
1287
+ runId: input.message.runId,
1288
+ stream: "stderr",
1289
+ data
1290
+ });
1291
+ })
1292
+ ]);
1293
+ sendWorkerMessage(input.ws, {
1294
+ type: "exec_exit",
1295
+ runId: input.message.runId,
1296
+ exitCode,
1297
+ durationMs: Date.now() - startedAt,
1298
+ ...timedOut ? { timedOut: true } : {}
1299
+ });
1300
+ } catch (error) {
1301
+ const message = error instanceof Error ? error.message : String(error);
1302
+ if (started) {
1303
+ sendWorkerMessage(input.ws, {
1304
+ type: "exec_error",
1305
+ runId: input.message.runId,
1306
+ error: message,
1307
+ durationMs: Date.now() - startedAt
1308
+ });
1309
+ } else {
1310
+ sendWorkerMessage(input.ws, {
1311
+ type: "exec_start_error",
1312
+ requestId: input.message.requestId,
1313
+ runId: input.message.runId,
1314
+ error: message
1315
+ });
1316
+ }
1317
+ } finally {
1318
+ if (timeout) {
1319
+ clearTimeout(timeout);
1320
+ }
1321
+ activeProcesses.delete(input.message.runId);
1322
+ cancelledProcessRuns.delete(input.message.runId);
1323
+ }
1324
+ }
1033
1325
  function sendWorkerMessage(ws, message) {
1034
1326
  ws.send(JSON.stringify(message));
1035
1327
  }
@@ -1347,7 +1639,7 @@ async function startWorker(options) {
1347
1639
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
1348
1640
  `);
1349
1641
  runGit(["--version"]);
1350
- await verifyBinctlAuth(baseUrl, token);
1642
+ await verifyR5dctlAuth(baseUrl, token);
1351
1643
  fs.mkdirSync(projectsRoot, { recursive: true });
1352
1644
  fs.mkdirSync(syncRoot, { recursive: true });
1353
1645
  const manifestByProjectId = /* @__PURE__ */ new Map();
@@ -1420,14 +1712,16 @@ async function startWorker(options) {
1420
1712
  const active = activeProcesses.get(message.runId);
1421
1713
  if (active) {
1422
1714
  active.process.kill("SIGTERM");
1715
+ } else {
1716
+ cancelledProcessRuns.add(message.runId);
1423
1717
  }
1424
1718
  ws.send(
1425
1719
  JSON.stringify({
1426
1720
  type: "cancel_result",
1427
1721
  requestId: message.requestId,
1428
1722
  runId: message.runId,
1429
- cancelled: !!active,
1430
- message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "No active command with that run id"
1723
+ cancelled: true,
1724
+ message: active ? `Sent SIGTERM to ${active.command.join(" ")}` : "Cancellation queued before command start"
1431
1725
  })
1432
1726
  );
1433
1727
  return;
@@ -1465,7 +1759,40 @@ async function startWorker(options) {
1465
1759
  ws.send(JSON.stringify(result));
1466
1760
  return;
1467
1761
  }
1468
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
1762
+ if (message.type === "exec_start") {
1763
+ sendWorkerMessage(ws, {
1764
+ type: "exec_accepted",
1765
+ requestId: message.requestId,
1766
+ runId: message.runId
1767
+ });
1768
+ try {
1769
+ const manifest = manifestByProjectId.get(message.projectId);
1770
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
1771
+ process.stdout.write(`[r5d-worker] exec_start ${message.runId}: ${message.argv.join(" ")}
1772
+ `);
1773
+ void withBranchLock(
1774
+ message.projectId,
1775
+ message.branchName,
1776
+ () => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
1777
+ ).catch((error) => {
1778
+ sendWorkerMessage(ws, {
1779
+ type: "exec_start_error",
1780
+ requestId: message.requestId,
1781
+ runId: message.runId,
1782
+ error: error instanceof Error ? error.message : String(error)
1783
+ });
1784
+ });
1785
+ } catch (error) {
1786
+ sendWorkerMessage(ws, {
1787
+ type: "exec_start_error",
1788
+ requestId: message.requestId,
1789
+ runId: message.runId,
1790
+ error: error instanceof Error ? error.message : String(error)
1791
+ });
1792
+ }
1793
+ return;
1794
+ }
1795
+ if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "apply_patch" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "confirm_large_diff" || message.type === "pull_branch") {
1469
1796
  try {
1470
1797
  const manifest = manifestByProjectId.get(message.projectId);
1471
1798
  const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
@@ -1490,6 +1817,17 @@ async function startWorker(options) {
1490
1817
  })
1491
1818
  );
1492
1819
  }
1820
+ return;
1821
+ }
1822
+ const unhandledMessage = message;
1823
+ if (typeof unhandledMessage.requestId === "string") {
1824
+ ws.send(
1825
+ JSON.stringify({
1826
+ type: "operation_result",
1827
+ requestId: unhandledMessage.requestId,
1828
+ error: `Unsupported worker message type: ${String(unhandledMessage.type)}`
1829
+ })
1830
+ );
1493
1831
  }
1494
1832
  })().catch((error) => {
1495
1833
  process.stderr.write(`[r5d-worker] message handling failed: ${error instanceof Error ? error.message : String(error)}
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",
@@ -15,7 +15,7 @@
15
15
  "repository": {
16
16
  "type": "git",
17
17
  "url": "git+https://github.com/ricsam/r5d-dev.git",
18
- "directory": "binctl-packages/r5d-worker"
18
+ "directory": "packages/r5d-worker"
19
19
  },
20
20
  "bin": {
21
21
  "r5d-worker": "dist/cjs/main.cjs"