@ricsam/r5d-worker 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mjs/main.mjs CHANGED
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { createHash } from "node:crypto";
6
6
  import { hostname } from "node:os";
7
- import * as nodePty from "node-pty";
7
+ import { spawn as spawnChildProcess } from "node:child_process";
8
8
  const DEFAULT_BASE_URL = "https://r5d.dev";
9
9
  const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
10
10
  const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
@@ -14,23 +14,28 @@ const MAX_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
14
14
  const activeProcesses = /* @__PURE__ */ new Map();
15
15
  const activePtys = /* @__PURE__ */ new Map();
16
16
  const branchLocks = /* @__PURE__ */ new Map();
17
+ let containerRegistryCredential = null;
17
18
  function defaultConfigPath() {
18
19
  return path.join(os.homedir(), ".config", "r5d", "r5dctl", "config.json");
19
20
  }
20
- function defaultProjectRoot(projectId) {
21
- return path.join(os.homedir(), ".r5d", "projects", projectId);
21
+ function defaultProjectsRoot() {
22
+ return path.join(os.homedir(), ".r5d", "projects");
23
+ }
24
+ function defaultSyncRoot() {
25
+ return path.join(os.homedir(), ".r5d", "sync");
22
26
  }
23
27
  function printHelp() {
24
28
  process.stdout.write(`Usage:
25
- r5d-worker start <project-id> --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
29
+ r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
26
30
 
27
31
  Options:
28
- --label <label> Required unique worker label for this project, e.g. macos or ec2-build
32
+ --label <label> Required unique worker label for this user, e.g. macos or ec2-build
29
33
  --base-url <url> r5d.dev base URL (default from r5d config, R5D_BASE_URL, or ${DEFAULT_BASE_URL})
30
34
  --token <token> r5d worker token
31
35
  --api-key <key> r5d API key
32
36
  --config <path> Config path (default ~/.config/r5d/r5dctl/config.json)
33
- --project-root <dir> Override checkout root (default ~/.r5d/projects/<project-id>)
37
+ --project-root <dir> Override projects checkout root (default ~/.r5d/projects)
38
+ --sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
34
39
  -h, --help Show this help
35
40
  `);
36
41
  }
@@ -93,6 +98,11 @@ function parseArgs(argv) {
93
98
  options.projectRoot = projectRoot;
94
99
  continue;
95
100
  }
101
+ const syncRoot = readOption("--sync-root");
102
+ if (syncRoot !== void 0) {
103
+ options.syncRoot = syncRoot;
104
+ continue;
105
+ }
96
106
  rest.push(arg);
97
107
  }
98
108
  if (options.help || rest.length === 0) {
@@ -104,7 +114,6 @@ function parseArgs(argv) {
104
114
  }
105
115
  return {
106
116
  command: "start",
107
- projectId: requireValue(rest[1], "Missing project id"),
108
117
  options
109
118
  };
110
119
  }
@@ -182,17 +191,14 @@ function validateBranchName(branchName) {
182
191
  throw new Error(`Invalid branch name from server: ${branchName}`);
183
192
  }
184
193
  }
185
- function gitRemoteUrl(baseUrl, projectId) {
186
- return `${baseUrl}/git/${projectId}.git`;
187
- }
188
- function gitExtraHeaderConfigKey(baseUrl) {
189
- return `http.${normalizeBaseUrl(baseUrl)}/.extraHeader`;
194
+ function gitExtraHeaderConfigKey(extraHeaderUrl) {
195
+ return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
190
196
  }
191
197
  function gitAuthArgs(auth) {
192
198
  if (!auth) {
193
199
  return [];
194
200
  }
195
- return ["-c", `${gitExtraHeaderConfigKey(auth.baseUrl)}=Authorization: Bearer ${auth.token}`];
201
+ return ["-c", `${gitExtraHeaderConfigKey(auth.extraHeaderUrl)}=${auth.header}`];
196
202
  }
197
203
  function runGit(args, options = {}) {
198
204
  const command = ["git", ...gitAuthArgs(options.auth), ...args];
@@ -220,8 +226,22 @@ function tryGit(args, options = {}) {
220
226
  return false;
221
227
  }
222
228
  }
223
- function getCurrentCommitHash(cwd) {
224
- return runGit(["rev-parse", "HEAD"], { cwd });
229
+ function runInternalGit(context, args) {
230
+ return runGit(["--git-dir", context.gitDir, "--work-tree", context.workTree, ...args], { auth: context.auth });
231
+ }
232
+ function runInternalGitDir(context, args) {
233
+ return runGit(["--git-dir", context.gitDir, ...args], { auth: context.auth });
234
+ }
235
+ function tryInternalGit(context, args) {
236
+ try {
237
+ runInternalGit(context, args);
238
+ return true;
239
+ } catch {
240
+ return false;
241
+ }
242
+ }
243
+ function getInternalCommitHash(context) {
244
+ return runInternalGit(context, ["rev-parse", "HEAD"]);
225
245
  }
226
246
  function getGitBlobHashForContent(content) {
227
247
  const buffer = typeof content === "string" ? Buffer.from(content, "utf8") : content;
@@ -230,6 +250,12 @@ function getGitBlobHashForContent(content) {
230
250
  function hasWorktreeChanges(cwd) {
231
251
  return runGit(["status", "--porcelain"], { cwd }).trim().length > 0;
232
252
  }
253
+ function getInternalStatus(context) {
254
+ return runInternalGit(context, ["status", "--porcelain", "--", ".", ":(exclude).git"]);
255
+ }
256
+ function hasInternalWorktreeChanges(context) {
257
+ return getInternalStatus(context).trim().length > 0;
258
+ }
233
259
  function assertInsideBranch(branchPath, filePath) {
234
260
  const relative = path.relative(branchPath, filePath);
235
261
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
@@ -252,11 +278,11 @@ function resolveProjectFilePath(branchPath, virtualPath) {
252
278
  assertInsideBranch(branchPath, absolutePath);
253
279
  return { absolutePath, virtualPath: `/${repoRelativePath}`, repoRelativePath };
254
280
  }
255
- function branchLockKey(projectRoot, branchName) {
256
- return `${projectRoot}:${branchName}`;
281
+ function branchLockKey(projectId, branchName) {
282
+ return `${projectId}:${branchName}`;
257
283
  }
258
- async function withBranchLock(projectRoot, branchName, fn) {
259
- const key = branchLockKey(projectRoot, branchName);
284
+ async function withBranchLock(projectId, branchName, fn) {
285
+ const key = branchLockKey(projectId, branchName);
260
286
  const previous = branchLocks.get(key) ?? Promise.resolve();
261
287
  let release = () => {
262
288
  };
@@ -277,60 +303,283 @@ async function withBranchLock(projectRoot, branchName, fn) {
277
303
  }
278
304
  }
279
305
  }
280
- function configureRepo(pathName, auth) {
281
- const authHeaderKey = gitExtraHeaderConfigKey(auth.baseUrl);
282
- tryGit(["config", "--unset-all", "http.extraHeader"], { cwd: pathName });
283
- tryGit(["config", "--unset-all", authHeaderKey], { cwd: pathName });
284
- runGit(["config", "--add", authHeaderKey, `Authorization: Bearer ${auth.token}`], { cwd: pathName });
285
- runGit(["config", "user.name", "r5d.dev Worker"], { cwd: pathName });
286
- runGit(["config", "user.email", "worker@r5d.dev"], { cwd: pathName });
306
+ function resolveRemoteUrl(baseUrl, remoteUrl) {
307
+ if (/^https?:\/\//i.test(remoteUrl)) {
308
+ return remoteUrl;
309
+ }
310
+ return new URL(remoteUrl, baseUrl).toString();
311
+ }
312
+ function fallbackInternalRemoteUrl(baseUrl, projectId) {
313
+ return new URL(`/git/${projectId}.git`, baseUrl).toString();
287
314
  }
288
- function ensureMainClone(input) {
289
- const mainPath = path.join(input.projectRoot, "main");
290
- const remoteUrl = gitRemoteUrl(input.baseUrl, input.projectId);
291
- fs.mkdirSync(input.projectRoot, { recursive: true });
292
- if (!fs.existsSync(path.join(mainPath, ".git"))) {
293
- fs.rmSync(mainPath, { recursive: true, force: true });
294
- runGit(["clone", "--origin", "build-it-now", remoteUrl, mainPath], { auth: input });
315
+ function internalGitAuth(baseUrl, token) {
316
+ return {
317
+ extraHeaderUrl: baseUrl,
318
+ header: `Authorization: Bearer ${token}`
319
+ };
320
+ }
321
+ function internalRemoteUrlFor(baseUrl, projectId, manifest) {
322
+ return resolveRemoteUrl(baseUrl, manifest?.internalRemoteUrl ?? fallbackInternalRemoteUrl(baseUrl, projectId));
323
+ }
324
+ function githubRemoteUrlFor(baseUrl, projectId, manifest) {
325
+ return manifest?.repoHttpUrl ?? internalRemoteUrlFor(baseUrl, projectId, manifest);
326
+ }
327
+ function gitExtraHeaderUrlForRemote(remoteUrl) {
328
+ try {
329
+ return new URL(remoteUrl).origin;
330
+ } catch {
331
+ return remoteUrl;
295
332
  }
296
- configureRepo(mainPath, input);
297
- runGit(["remote", "set-url", "build-it-now", remoteUrl], { cwd: mainPath });
298
- runGit(["fetch", "build-it-now", "--prune"], { cwd: mainPath });
299
- runGit(["checkout", "main"], { cwd: mainPath });
300
- if (!hasWorktreeChanges(mainPath)) {
301
- tryGit(["pull", "--ff-only", "build-it-now", "main"], { cwd: mainPath });
333
+ }
334
+ function gitAuthForRemote(remoteUrl, authHeader) {
335
+ if (!authHeader) {
336
+ return void 0;
302
337
  }
303
- return mainPath;
338
+ return {
339
+ extraHeaderUrl: gitExtraHeaderUrlForRemote(remoteUrl),
340
+ header: authHeader
341
+ };
304
342
  }
305
- function ensureBranchWorkspace(input) {
306
- validateBranchName(input.branchName);
307
- const mainPath = ensureMainClone(input);
308
- if (input.branchName === "main") {
309
- return mainPath;
343
+ function branchGitDirName(branchName) {
344
+ return `${encodeURIComponent(branchName)}.git`;
345
+ }
346
+ function internalGitDirFor(syncRoot, projectId, branchName) {
347
+ return path.join(syncRoot, projectId, branchGitDirName(branchName));
348
+ }
349
+ function hasNormalVisibleGitDir(branchPath) {
350
+ const gitPath = path.join(branchPath, ".git");
351
+ return fs.existsSync(gitPath) && fs.statSync(gitPath).isDirectory();
352
+ }
353
+ function ensureOriginRemote(branchPath, remoteUrl) {
354
+ if (tryGit(["remote", "get-url", "origin"], { cwd: branchPath })) {
355
+ runGit(["remote", "set-url", "origin", remoteUrl], { cwd: branchPath });
356
+ } else {
357
+ runGit(["remote", "add", "origin", remoteUrl], { cwd: branchPath });
310
358
  }
311
- const branchPath = path.join(input.projectRoot, input.branchName);
312
- runGit(["fetch", "build-it-now", "--prune"], { cwd: mainPath });
313
- const hasRemoteBranch = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/build-it-now/${input.branchName}`], {
314
- cwd: mainPath
359
+ }
360
+ function shellQuote(value) {
361
+ return `'${value.replace(/'/g, "'\\''")}'`;
362
+ }
363
+ function githubCredentialsPath() {
364
+ return path.join(os.homedir(), ".r5d", "github-credentials");
365
+ }
366
+ function decodeBasicAuthHeader(authHeader) {
367
+ const match = /^Authorization:\s*Basic\s+(.+)$/i.exec(authHeader.trim());
368
+ if (!match) {
369
+ return null;
370
+ }
371
+ const decoded = Buffer.from(match[1], "base64").toString("utf8");
372
+ const separatorIndex = decoded.indexOf(":");
373
+ if (separatorIndex <= 0) {
374
+ return null;
375
+ }
376
+ return {
377
+ username: decoded.slice(0, separatorIndex),
378
+ password: decoded.slice(separatorIndex + 1)
379
+ };
380
+ }
381
+ function writeCredentialStoreEntry(remoteUrl, authHeader) {
382
+ const credentials = decodeBasicAuthHeader(authHeader);
383
+ if (!credentials) {
384
+ return null;
385
+ }
386
+ let remote;
387
+ try {
388
+ remote = new URL(remoteUrl);
389
+ } catch {
390
+ return null;
391
+ }
392
+ if (remote.protocol !== "https:") {
393
+ return null;
394
+ }
395
+ const storePath = githubCredentialsPath();
396
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
397
+ const hostKey = `${remote.protocol}//${remote.host}`;
398
+ const existing = fs.existsSync(storePath) ? fs.readFileSync(storePath, "utf8").split(/\r?\n/).filter(Boolean) : [];
399
+ const next = existing.filter((line) => {
400
+ try {
401
+ const parsed = new URL(line);
402
+ return `${parsed.protocol}//${parsed.host}` !== hostKey;
403
+ } catch {
404
+ return true;
405
+ }
315
406
  });
316
- if (!fs.existsSync(path.join(branchPath, ".git"))) {
407
+ const credentialUrl = new URL(hostKey);
408
+ credentialUrl.username = credentials.username;
409
+ credentialUrl.password = credentials.password;
410
+ next.push(credentialUrl.toString());
411
+ fs.writeFileSync(storePath, `${next.join("\n")}
412
+ `, { mode: 384 });
413
+ fs.chmodSync(storePath, 384);
414
+ return storePath;
415
+ }
416
+ function configureVisibleGitHubAuth(branchPath, remoteUrl, authHeader) {
417
+ if (!authHeader) {
418
+ return;
419
+ }
420
+ const credentialStore = writeCredentialStoreEntry(remoteUrl, authHeader);
421
+ if (credentialStore) {
422
+ runGit(["config", "credential.helper", `store --file=${shellQuote(credentialStore)}`], { cwd: branchPath });
423
+ runGit(["config", "credential.useHttpPath", "false"], { cwd: branchPath });
424
+ return;
425
+ }
426
+ runGit(["config", `${gitExtraHeaderConfigKey(gitExtraHeaderUrlForRemote(remoteUrl))}`, authHeader], { cwd: branchPath });
427
+ }
428
+ function dockerConfigPath() {
429
+ return path.join(os.homedir(), ".docker", "config.json");
430
+ }
431
+ function containersAuthPath() {
432
+ return path.join(os.homedir(), ".config", "containers", "auth.json");
433
+ }
434
+ function readJsonFile(filePath) {
435
+ if (!fs.existsSync(filePath)) {
436
+ return {};
437
+ }
438
+ try {
439
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
440
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
441
+ } catch {
442
+ return {};
443
+ }
444
+ }
445
+ function writeRegistryAuthFile(filePath, credential) {
446
+ const config = readJsonFile(filePath);
447
+ const existingAuths = config.auths && typeof config.auths === "object" && !Array.isArray(config.auths) ? config.auths : {};
448
+ const auths = existingAuths;
449
+ auths[credential.registry] = {
450
+ username: credential.username,
451
+ password: credential.password,
452
+ auth: Buffer.from(`${credential.username}:${credential.password}`).toString("base64")
453
+ };
454
+ config.auths = auths;
455
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
456
+ fs.writeFileSync(filePath, `${JSON.stringify(config, null, 2)}
457
+ `, { mode: 384 });
458
+ fs.chmodSync(filePath, 384);
459
+ }
460
+ function configureContainerRegistryAuth(credential) {
461
+ containerRegistryCredential = credential;
462
+ if (!credential) {
463
+ return;
464
+ }
465
+ writeRegistryAuthFile(dockerConfigPath(), credential);
466
+ writeRegistryAuthFile(containersAuthPath(), credential);
467
+ if (credential.missingScopes.length > 0) {
468
+ process.stderr.write(
469
+ `[r5d-worker] GitHub token is missing GHCR scope(s): ${credential.missingScopes.join(", ")}. Sign in with GitHub again if GHCR push/pull fails.
470
+ `
471
+ );
472
+ } else {
473
+ process.stdout.write(`[r5d-worker] configured GHCR auth for ${credential.username}
474
+ `);
475
+ }
476
+ }
477
+ function containerRegistryEnv() {
478
+ if (!containerRegistryCredential) {
479
+ return {};
480
+ }
481
+ return {
482
+ R5D_GHCR_REGISTRY: containerRegistryCredential.registry,
483
+ R5D_GHCR_NAMESPACE: containerRegistryCredential.username,
484
+ R5D_GHCR_AUTH_FILE: containersAuthPath(),
485
+ REGISTRY_AUTH_FILE: containersAuthPath(),
486
+ GHCR_REGISTRY: containerRegistryCredential.registry,
487
+ GHCR_NAMESPACE: containerRegistryCredential.username
488
+ };
489
+ }
490
+ function checkoutVisibleBranch(input) {
491
+ const branchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/heads/${input.branchName}`], { cwd: input.branchPath });
492
+ const remoteBranchExists = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${input.branchName}`], {
493
+ cwd: input.branchPath
494
+ });
495
+ const defaultRemoteExists = tryGit(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${input.defaultBranch}`], {
496
+ cwd: input.branchPath
497
+ });
498
+ const clean = !hasWorktreeChanges(input.branchPath);
499
+ if (remoteBranchExists && clean) {
500
+ runGit(["checkout", "-B", input.branchName, `origin/${input.branchName}`], { cwd: input.branchPath });
501
+ return;
502
+ }
503
+ if (branchExists) {
504
+ runGit(["checkout", input.branchName], { cwd: input.branchPath });
505
+ return;
506
+ }
507
+ if (defaultRemoteExists) {
508
+ runGit(["checkout", "-B", input.branchName, `origin/${input.defaultBranch}`], { cwd: input.branchPath });
509
+ return;
510
+ }
511
+ runGit(["checkout", "-B", input.branchName], { cwd: input.branchPath });
512
+ }
513
+ function ensureVisibleGitCheckout(input) {
514
+ validateBranchName(input.branchName);
515
+ fs.mkdirSync(input.projectRoot, { recursive: true });
516
+ const branchPath = path.join(input.projectRoot, input.branchName);
517
+ if (!hasNormalVisibleGitDir(branchPath)) {
317
518
  fs.rmSync(branchPath, { recursive: true, force: true });
318
- if (hasRemoteBranch) {
319
- runGit(["worktree", "add", "-B", input.branchName, branchPath, `build-it-now/${input.branchName}`], {
320
- cwd: mainPath
321
- });
322
- } else {
323
- runGit(["worktree", "add", "-b", input.branchName, branchPath, "build-it-now/main"], {
324
- cwd: mainPath
325
- });
519
+ fs.mkdirSync(path.dirname(branchPath), { recursive: true });
520
+ if (!tryGit(["clone", "--origin", "origin", input.githubRemoteUrl, branchPath], { auth: input.githubAuth })) {
521
+ fs.mkdirSync(branchPath, { recursive: true });
522
+ runGit(["init"], { cwd: branchPath });
326
523
  }
327
524
  }
328
- configureRepo(branchPath, input);
329
- if (hasRemoteBranch && !hasWorktreeChanges(branchPath)) {
330
- tryGit(["pull", "--ff-only", "build-it-now", input.branchName], { cwd: branchPath });
331
- }
525
+ ensureOriginRemote(branchPath, input.githubRemoteUrl);
526
+ configureVisibleGitHubAuth(branchPath, input.githubRemoteUrl, input.githubAuthHeader);
527
+ tryGit(["fetch", "origin", "--prune"], { cwd: branchPath, auth: input.githubAuth });
528
+ checkoutVisibleBranch({ branchPath, branchName: input.branchName, defaultBranch: input.defaultBranch });
332
529
  return branchPath;
333
530
  }
531
+ function configureInternalRepo(context, remoteUrl) {
532
+ if (tryInternalGit(context, ["remote", "get-url", "build-it-now"])) {
533
+ runInternalGit(context, ["remote", "set-url", "build-it-now", remoteUrl]);
534
+ } else {
535
+ runInternalGit(context, ["remote", "add", "build-it-now", remoteUrl]);
536
+ }
537
+ runInternalGitDir(context, ["config", "user.name", "r5d.dev Worker"]);
538
+ runInternalGitDir(context, ["config", "user.email", "worker@r5d.dev"]);
539
+ }
540
+ function ensureInternalSyncGit(input) {
541
+ validateBranchName(input.branchName);
542
+ const gitDir = internalGitDirFor(input.syncRoot, input.projectId, input.branchName);
543
+ fs.mkdirSync(path.dirname(gitDir), { recursive: true });
544
+ if (!fs.existsSync(gitDir)) {
545
+ runGit(["init", "--bare", gitDir]);
546
+ }
547
+ const auth = internalGitAuth(input.baseUrl, input.token);
548
+ const context = { gitDir, workTree: input.branchPath, auth };
549
+ const remoteUrl = internalRemoteUrlFor(input.baseUrl, input.projectId, input.manifest);
550
+ configureInternalRepo(context, remoteUrl);
551
+ runInternalGit(context, ["fetch", "build-it-now", "--prune", "+refs/heads/*:refs/remotes/build-it-now/*"]);
552
+ const hasRemoteBranch = tryInternalGit(context, ["show-ref", "--verify", "--quiet", `refs/remotes/build-it-now/${input.branchName}`]);
553
+ const target = hasRemoteBranch ? `refs/remotes/build-it-now/${input.branchName}` : "refs/remotes/build-it-now/main";
554
+ const hasHead = tryInternalGit(context, ["rev-parse", "--verify", "HEAD"]);
555
+ if (!hasHead || !hasInternalWorktreeChanges(context)) {
556
+ runInternalGit(context, ["checkout", "-f", "-B", input.branchName, target]);
557
+ }
558
+ return context;
559
+ }
560
+ function ensureBranchWorkspace(input) {
561
+ const defaultBranch = input.manifest?.defaultBranch || "main";
562
+ const githubRemoteUrl = githubRemoteUrlFor(input.baseUrl, input.projectId, input.manifest);
563
+ const githubAuth = gitAuthForRemote(githubRemoteUrl, input.manifest?.repoAuthHeader);
564
+ const branchPath = ensureVisibleGitCheckout({
565
+ projectRoot: input.projectRoot,
566
+ branchName: input.branchName,
567
+ githubRemoteUrl,
568
+ githubAuth,
569
+ githubAuthHeader: input.manifest?.repoAuthHeader ?? null,
570
+ defaultBranch
571
+ });
572
+ const internalGit = ensureInternalSyncGit({
573
+ projectId: input.projectId,
574
+ baseUrl: input.baseUrl,
575
+ token: input.token,
576
+ syncRoot: input.syncRoot,
577
+ branchPath,
578
+ branchName: input.branchName,
579
+ manifest: input.manifest
580
+ });
581
+ return { branchPath, internalGit };
582
+ }
334
583
  function resolveCommandCwd(branchPath, cwd) {
335
584
  if (!cwd) {
336
585
  return branchPath;
@@ -439,8 +688,8 @@ function readImageDimensions(bytes, mediaType) {
439
688
  return dimensions;
440
689
  }
441
690
  async function executeReadFileOperation(input) {
442
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
443
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
691
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
692
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
444
693
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
445
694
  throw new Error(`File not found: ${resolved.virtualPath}`);
446
695
  }
@@ -454,8 +703,8 @@ async function executeReadFileOperation(input) {
454
703
  };
455
704
  }
456
705
  async function executeCreateFileOperation(input) {
457
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
458
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
706
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
707
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
459
708
  if (fs.existsSync(resolved.absolutePath)) {
460
709
  throw new Error(`File "${resolved.virtualPath}" already exists. Use edit_file to modify existing files.`);
461
710
  }
@@ -468,8 +717,8 @@ async function executeCreateFileOperation(input) {
468
717
  };
469
718
  }
470
719
  async function executeEditFileOperation(input) {
471
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
472
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
720
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
721
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
473
722
  if (input.message.oldString === input.message.newString) {
474
723
  throw new Error("old_string and new_string must be different");
475
724
  }
@@ -498,8 +747,8 @@ async function executeEditFileOperation(input) {
498
747
  };
499
748
  }
500
749
  async function executeViewFileBytesOperation(input) {
501
- const branchPath = ensureOperationBranch({ ...input, branchName: input.message.branchName });
502
- const resolved = resolveProjectFilePath(branchPath, input.message.filePath);
750
+ const workspace = ensureOperationBranch({ ...input, branchName: input.message.branchName });
751
+ const resolved = resolveProjectFilePath(workspace.branchPath, input.message.filePath);
503
752
  if (!fs.existsSync(resolved.absolutePath) || !fs.statSync(resolved.absolutePath).isFile()) {
504
753
  throw new Error(`File not found: ${resolved.virtualPath}`);
505
754
  }
@@ -520,12 +769,12 @@ async function executeViewFileBytesOperation(input) {
520
769
  height: dimensions.height
521
770
  };
522
771
  }
523
- function stagedBlobSizeBytes(cwd) {
524
- const names = runGit(["diff", "--cached", "--name-only", "-z"], { cwd }).split("\0").filter(Boolean);
772
+ function stagedBlobSizeBytes(context) {
773
+ const names = runInternalGit(context, ["diff", "--cached", "--name-only", "-z", "--", ".", ":(exclude).git"]).split("\0").filter(Boolean);
525
774
  let total = 0;
526
775
  for (const name of names) {
527
776
  try {
528
- const sizeText = runGit(["cat-file", "-s", `:${name}`], { cwd });
777
+ const sizeText = runInternalGit(context, ["cat-file", "-s", `:${name}`]);
529
778
  total += Number.parseInt(sizeText, 10) || 0;
530
779
  } catch {
531
780
  }
@@ -536,11 +785,11 @@ function stagedBlobSizeBytes(cwd) {
536
785
  return total;
537
786
  }
538
787
  function syncBranch(input) {
539
- const branchPath = ensureOperationBranch(input);
540
- runGit(["add", "-A"], { cwd: branchPath });
541
- const hasStagedChanges = !tryGit(["diff", "--cached", "--quiet"], { cwd: branchPath });
542
- const status = runGit(["status", "--porcelain"], { cwd: branchPath });
543
- const currentCommit = getCurrentCommitHash(branchPath);
788
+ const workspace = ensureOperationBranch(input);
789
+ runInternalGit(workspace.internalGit, ["add", "-A", "--", ".", ":(exclude).git"]);
790
+ const hasStagedChanges = !tryInternalGit(workspace.internalGit, ["diff", "--cached", "--quiet", "--", ".", ":(exclude).git"]);
791
+ const status = getInternalStatus(workspace.internalGit);
792
+ const currentCommit = getInternalCommitHash(workspace.internalGit);
544
793
  if (!hasStagedChanges) {
545
794
  return {
546
795
  type: "sync",
@@ -550,7 +799,7 @@ function syncBranch(input) {
550
799
  status
551
800
  };
552
801
  }
553
- const diffSizeBytes = stagedBlobSizeBytes(branchPath);
802
+ const diffSizeBytes = stagedBlobSizeBytes(workspace.internalGit);
554
803
  if (diffSizeBytes > MAX_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
555
804
  return {
556
805
  type: "sync",
@@ -568,15 +817,15 @@ function syncBranch(input) {
568
817
  parentNodeId: input.parentNodeId,
569
818
  ...input.confirmedLargeDiff ? { confirmedLargeDiff: true, confirmationReason: input.confirmationReason } : {}
570
819
  });
571
- runGit(["commit", "-m", message], { cwd: branchPath });
572
- const commitHash = getCurrentCommitHash(branchPath);
573
- runGit(["push", "build-it-now", `HEAD:refs/heads/${input.branchName}`], { cwd: branchPath });
820
+ runInternalGit(workspace.internalGit, ["commit", "-m", message]);
821
+ const commitHash = getInternalCommitHash(workspace.internalGit);
822
+ runInternalGit(workspace.internalGit, ["push", "build-it-now", `HEAD:refs/heads/${input.branchName}`]);
574
823
  return {
575
824
  type: "sync",
576
825
  changed: true,
577
826
  commitHash,
578
827
  diffSizeBytes,
579
- status: runGit(["status", "--porcelain"], { cwd: branchPath }),
828
+ status: getInternalStatus(workspace.internalGit),
580
829
  trigger: input.trigger
581
830
  };
582
831
  }
@@ -600,14 +849,14 @@ function confirmLargeDiff(input) {
600
849
  };
601
850
  }
602
851
  function pullBranch(input) {
603
- const branchPath = ensureOperationBranch(input);
604
- runGit(["fetch", "build-it-now", "--prune"], { cwd: branchPath });
852
+ const workspace = ensureOperationBranch(input);
853
+ runInternalGit(workspace.internalGit, ["fetch", "build-it-now", "--prune", "+refs/heads/*:refs/remotes/build-it-now/*"]);
605
854
  const target = input.commitHash ?? `build-it-now/${input.branchName}`;
606
- runGit(["reset", "--hard", target], { cwd: branchPath });
607
- runGit(["clean", "-fd"], { cwd: branchPath });
855
+ runInternalGit(workspace.internalGit, ["reset", "--hard", target]);
856
+ runInternalGit(workspace.internalGit, ["clean", "-fd", "--", ".", ":(exclude).git"]);
608
857
  return {
609
858
  type: "pull_branch",
610
- commitHash: getCurrentCommitHash(branchPath)
859
+ commitHash: getInternalCommitHash(workspace.internalGit)
611
860
  };
612
861
  }
613
862
  async function executeOperation(input) {
@@ -626,10 +875,12 @@ async function executeOperation(input) {
626
875
  baseUrl: input.baseUrl,
627
876
  token: input.token,
628
877
  projectRoot: input.projectRoot,
878
+ syncRoot: input.syncRoot,
629
879
  branchName: input.message.branchName,
630
880
  sessionId: input.message.sessionId,
631
881
  parentNodeId: input.message.parentNodeId,
632
- trigger: input.message.trigger
882
+ trigger: input.message.trigger,
883
+ manifest: input.manifest
633
884
  });
634
885
  case "confirm_large_diff":
635
886
  return confirmLargeDiff({
@@ -637,10 +888,12 @@ async function executeOperation(input) {
637
888
  baseUrl: input.baseUrl,
638
889
  token: input.token,
639
890
  projectRoot: input.projectRoot,
891
+ syncRoot: input.syncRoot,
640
892
  branchName: input.message.branchName,
641
893
  sessionId: input.message.sessionId,
642
894
  parentNodeId: input.message.parentNodeId,
643
- reason: input.message.reason
895
+ reason: input.message.reason,
896
+ manifest: input.manifest
644
897
  });
645
898
  case "pull_branch":
646
899
  return pullBranch({
@@ -648,8 +901,10 @@ async function executeOperation(input) {
648
901
  baseUrl: input.baseUrl,
649
902
  token: input.token,
650
903
  projectRoot: input.projectRoot,
904
+ syncRoot: input.syncRoot,
651
905
  branchName: input.message.branchName,
652
- commitHash: input.message.commitHash
906
+ commitHash: input.message.commitHash,
907
+ manifest: input.manifest
653
908
  });
654
909
  }
655
910
  }
@@ -662,15 +917,18 @@ async function executeCommand(input) {
662
917
  baseUrl: input.baseUrl,
663
918
  token: input.token,
664
919
  projectRoot: input.projectRoot,
665
- branchName: input.message.branchName
920
+ syncRoot: input.syncRoot,
921
+ branchName: input.message.branchName,
922
+ manifest: input.manifest
666
923
  });
667
- const cwd = resolveCommandCwd(branchPath, input.message.cwd);
924
+ const cwd = resolveCommandCwd(branchPath.branchPath, input.message.cwd);
668
925
  const subprocess = Bun.spawn(input.message.argv, {
669
926
  cwd,
670
927
  stdout: "pipe",
671
928
  stderr: "pipe",
672
929
  env: {
673
930
  ...process.env,
931
+ ...containerRegistryEnv(),
674
932
  ...input.message.env ?? {}
675
933
  }
676
934
  });
@@ -713,6 +971,182 @@ async function executeCommand(input) {
713
971
  function sendWorkerMessage(ws, message) {
714
972
  ws.send(JSON.stringify(message));
715
973
  }
974
+ const PTY_BRIDGE_SCRIPT = String.raw`
975
+ const readline = require("node:readline");
976
+ const nodePty = require("node-pty");
977
+
978
+ let ptyProcess = null;
979
+
980
+ function send(message, callback) {
981
+ process.stdout.write(JSON.stringify(message) + "\n", callback);
982
+ }
983
+
984
+ function decode(data) {
985
+ return Buffer.from(data, "base64").toString("utf8");
986
+ }
987
+
988
+ const rl = readline.createInterface({ input: process.stdin });
989
+
990
+ rl.on("line", (line) => {
991
+ let message;
992
+ try {
993
+ message = JSON.parse(line);
994
+ } catch (error) {
995
+ send({ type: "error", error: error instanceof Error ? error.message : String(error) });
996
+ return;
997
+ }
998
+
999
+ try {
1000
+ if (message.type === "open") {
1001
+ ptyProcess = nodePty.spawn(message.file, message.args, message.options);
1002
+ ptyProcess.onData((data) => {
1003
+ send({ type: "output", data: Buffer.from(data, "utf8").toString("base64") });
1004
+ });
1005
+ ptyProcess.onExit((event) => {
1006
+ send({ type: "exit", exitCode: event.exitCode, signal: event.signal }, () => process.exit(0));
1007
+ });
1008
+ send({ type: "opened" });
1009
+ return;
1010
+ }
1011
+
1012
+ if (!ptyProcess) {
1013
+ send({ type: "error", error: "PTY has not been opened" });
1014
+ return;
1015
+ }
1016
+
1017
+ if (message.type === "input") {
1018
+ ptyProcess.write(decode(message.data));
1019
+ return;
1020
+ }
1021
+
1022
+ if (message.type === "resize") {
1023
+ ptyProcess.resize(message.cols, message.rows);
1024
+ return;
1025
+ }
1026
+
1027
+ if (message.type === "close") {
1028
+ ptyProcess.kill();
1029
+ }
1030
+ } catch (error) {
1031
+ send({ type: "error", error: error instanceof Error ? error.message : String(error) });
1032
+ }
1033
+ });
1034
+ `;
1035
+ function nodePtyModulePaths() {
1036
+ const candidates = [];
1037
+ const entrypoint = process.argv[1];
1038
+ if (entrypoint) {
1039
+ try {
1040
+ let current = path.dirname(fs.realpathSync(entrypoint));
1041
+ for (let index = 0; index < 8; index += 1) {
1042
+ candidates.push(path.join(current, "node_modules"));
1043
+ const parent = path.dirname(current);
1044
+ if (parent === current) {
1045
+ break;
1046
+ }
1047
+ current = parent;
1048
+ }
1049
+ } catch {
1050
+ }
1051
+ }
1052
+ candidates.push(path.join(process.cwd(), "node_modules"), "/usr/local/lib/node_modules", "/usr/lib/node_modules");
1053
+ if (process.env.NODE_PATH) {
1054
+ candidates.push(...process.env.NODE_PATH.split(path.delimiter));
1055
+ }
1056
+ return [...new Set(candidates.filter(Boolean))].join(path.delimiter);
1057
+ }
1058
+ function createNodePtyBridge(options) {
1059
+ const child = spawnChildProcess("node", ["-e", PTY_BRIDGE_SCRIPT], {
1060
+ env: {
1061
+ ...process.env,
1062
+ NODE_PATH: nodePtyModulePaths()
1063
+ }
1064
+ });
1065
+ let lineBuffer = "";
1066
+ let emittedTerminalEvent = false;
1067
+ let stderr = "";
1068
+ const sendToChild = (message) => {
1069
+ if (child.stdin.writable) {
1070
+ child.stdin.write(`${JSON.stringify(message)}
1071
+ `);
1072
+ }
1073
+ };
1074
+ const handleEvent = (event) => {
1075
+ if (event.type === "opened") {
1076
+ options.onOpened();
1077
+ return;
1078
+ }
1079
+ if (event.type === "output") {
1080
+ options.onOutput(Buffer.from(event.data, "base64").toString("utf8"));
1081
+ return;
1082
+ }
1083
+ if (event.type === "exit") {
1084
+ emittedTerminalEvent = true;
1085
+ options.onExit({ exitCode: event.exitCode, signal: event.signal });
1086
+ return;
1087
+ }
1088
+ if (event.type === "error") {
1089
+ emittedTerminalEvent = true;
1090
+ options.onError(new Error(event.error));
1091
+ }
1092
+ };
1093
+ child.stdout.setEncoding("utf8");
1094
+ child.stdout.on("data", (chunk) => {
1095
+ lineBuffer += chunk;
1096
+ let newlineIndex = lineBuffer.indexOf("\n");
1097
+ while (newlineIndex !== -1) {
1098
+ const line = lineBuffer.slice(0, newlineIndex).trim();
1099
+ lineBuffer = lineBuffer.slice(newlineIndex + 1);
1100
+ if (line) {
1101
+ try {
1102
+ handleEvent(JSON.parse(line));
1103
+ } catch (error) {
1104
+ emittedTerminalEvent = true;
1105
+ options.onError(new Error(error instanceof Error ? error.message : String(error)));
1106
+ }
1107
+ }
1108
+ newlineIndex = lineBuffer.indexOf("\n");
1109
+ }
1110
+ });
1111
+ child.stderr.setEncoding("utf8");
1112
+ child.stderr.on("data", (chunk) => {
1113
+ stderr += chunk;
1114
+ process.stderr.write(`[r5d-worker] pty helper: ${chunk}`);
1115
+ });
1116
+ child.on("error", (error) => {
1117
+ emittedTerminalEvent = true;
1118
+ options.onError(error);
1119
+ });
1120
+ child.on("exit", (code, signal) => {
1121
+ if (emittedTerminalEvent) {
1122
+ return;
1123
+ }
1124
+ const detail = stderr.trim() || `node pty helper exited with code ${code ?? "unknown"}${signal ? ` signal ${signal}` : ""}`;
1125
+ options.onError(new Error(detail));
1126
+ });
1127
+ sendToChild({
1128
+ type: "open",
1129
+ file: options.file,
1130
+ args: options.args,
1131
+ options: options.ptyOptions
1132
+ });
1133
+ return {
1134
+ write(data) {
1135
+ sendToChild({ type: "input", data: Buffer.from(data, "utf8").toString("base64") });
1136
+ },
1137
+ resize(cols, rows) {
1138
+ sendToChild({ type: "resize", cols, rows });
1139
+ },
1140
+ kill() {
1141
+ sendToChild({ type: "close" });
1142
+ setTimeout(() => {
1143
+ if (!child.killed) {
1144
+ child.kill();
1145
+ }
1146
+ }, 500);
1147
+ }
1148
+ };
1149
+ }
716
1150
  function resolveHostShell() {
717
1151
  if (process.platform === "win32") {
718
1152
  return { file: process.env.COMSPEC || "powershell.exe", args: [] };
@@ -726,43 +1160,61 @@ function openPty(input) {
726
1160
  baseUrl: input.baseUrl,
727
1161
  token: input.token,
728
1162
  projectRoot: input.projectRoot,
729
- branchName: input.message.branchName
1163
+ syncRoot: input.syncRoot,
1164
+ branchName: input.message.branchName,
1165
+ manifest: input.manifest
730
1166
  });
731
1167
  const shell = resolveHostShell();
732
- const ptyProcess = nodePty.spawn(shell.file, shell.args, {
733
- name: "xterm-256color",
734
- cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
735
- rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
736
- cwd: branchPath,
737
- env: {
738
- ...process.env,
739
- ...input.message.env ?? {},
740
- BUILD_IT_NOW_PROJECT_ID: input.projectId,
741
- BUILD_IT_NOW_BRANCH_NAME: input.message.branchName
1168
+ const ptyProcess = createNodePtyBridge({
1169
+ file: shell.file,
1170
+ args: shell.args,
1171
+ ptyOptions: {
1172
+ name: "xterm-256color",
1173
+ cols: Math.max(1, Math.min(Math.floor(input.message.cols || 80), 500)),
1174
+ rows: Math.max(1, Math.min(Math.floor(input.message.rows || 24), 500)),
1175
+ cwd: branchPath.branchPath,
1176
+ env: {
1177
+ ...process.env,
1178
+ ...containerRegistryEnv(),
1179
+ ...input.message.env ?? {},
1180
+ BUILD_IT_NOW_PROJECT_ID: input.projectId,
1181
+ BUILD_IT_NOW_BRANCH_NAME: input.message.branchName
1182
+ }
1183
+ },
1184
+ onOpened: () => {
1185
+ sendWorkerMessage(input.ws, {
1186
+ type: "pty_opened",
1187
+ requestId: input.message.requestId,
1188
+ ptyId: input.message.ptyId
1189
+ });
1190
+ },
1191
+ onOutput: (data) => {
1192
+ sendWorkerMessage(input.ws, {
1193
+ type: "pty_output",
1194
+ ptyId: input.message.ptyId,
1195
+ data
1196
+ });
1197
+ },
1198
+ onExit: (event) => {
1199
+ activePtys.delete(input.message.ptyId);
1200
+ sendWorkerMessage(input.ws, {
1201
+ type: "pty_exit",
1202
+ ptyId: input.message.ptyId,
1203
+ exitCode: event.exitCode,
1204
+ signal: event.signal
1205
+ });
1206
+ },
1207
+ onError: (error) => {
1208
+ activePtys.delete(input.message.ptyId);
1209
+ sendWorkerMessage(input.ws, {
1210
+ type: "pty_error",
1211
+ requestId: input.message.requestId,
1212
+ ptyId: input.message.ptyId,
1213
+ error: error.message
1214
+ });
742
1215
  }
743
1216
  });
744
1217
  activePtys.set(input.message.ptyId, ptyProcess);
745
- ptyProcess.onData((data) => {
746
- sendWorkerMessage(input.ws, {
747
- type: "pty_output",
748
- ptyId: input.message.ptyId,
749
- data
750
- });
751
- });
752
- ptyProcess.onExit((event) => {
753
- activePtys.delete(input.message.ptyId);
754
- sendWorkerMessage(input.ws, {
755
- type: "pty_exit",
756
- ptyId: input.message.ptyId,
757
- exitCode: event.exitCode,
758
- signal: event.signal
759
- });
760
- });
761
- sendWorkerMessage(input.ws, {
762
- type: "pty_opened",
763
- requestId: input.message.requestId,
764
- ptyId: input.message.ptyId
765
- });
766
1218
  } catch (error) {
767
1219
  sendWorkerMessage(input.ws, {
768
1220
  type: "pty_error",
@@ -808,32 +1260,36 @@ function closeAllPtys() {
808
1260
  }
809
1261
  activePtys.clear();
810
1262
  }
811
- function websocketUrl(baseUrl, projectId, label) {
1263
+ function websocketUrl(baseUrl, label) {
812
1264
  const url = new URL("/worker/ws", baseUrl);
813
1265
  url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
814
- url.searchParams.set("projectId", projectId);
815
1266
  url.searchParams.set("label", label);
816
1267
  return url.toString();
817
1268
  }
818
- async function startWorker(projectId, options) {
1269
+ function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
1270
+ return path.join(projectsRoot, manifestByProjectId.get(projectId)?.repoSlug ?? projectId);
1271
+ }
1272
+ async function startWorker(options) {
819
1273
  const config = readConfig(options.configPath);
820
1274
  const { baseUrl, token } = resolveCredentials(options, config);
821
1275
  const label = requireValue(options.label, "Missing required --label <label>");
822
1276
  validateLabel(label);
823
- const projectRoot = path.resolve(options.projectRoot ?? defaultProjectRoot(projectId));
824
- process.stdout.write(`[r5d-worker] project: ${projectId}
825
- `);
1277
+ const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
1278
+ const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
826
1279
  process.stdout.write(`[r5d-worker] label: ${label}
827
1280
  `);
828
- process.stdout.write(`[r5d-worker] root: ${projectRoot}
1281
+ process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
1282
+ `);
1283
+ process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
829
1284
  `);
830
1285
  process.stdout.write(`[r5d-worker] server: ${baseUrl}
831
1286
  `);
832
1287
  runGit(["--version"]);
833
1288
  await verifyBinctlAuth(baseUrl, token);
834
- process.stdout.write("[r5d-worker] preparing local checkout...\n");
835
- ensureMainClone({ projectId, baseUrl, token, projectRoot });
836
- const ws = new WebSocket(websocketUrl(baseUrl, projectId, label), {
1289
+ fs.mkdirSync(projectsRoot, { recursive: true });
1290
+ fs.mkdirSync(syncRoot, { recursive: true });
1291
+ const manifestByProjectId = /* @__PURE__ */ new Map();
1292
+ const ws = new WebSocket(websocketUrl(baseUrl, label), {
837
1293
  headers: {
838
1294
  Authorization: `Bearer ${token}`
839
1295
  }
@@ -847,7 +1303,7 @@ async function startWorker(projectId, options) {
847
1303
  arch: process.arch,
848
1304
  pid: process.pid,
849
1305
  version: "0.1.0",
850
- projectRoot
1306
+ projectRoot: projectsRoot
851
1307
  }
852
1308
  };
853
1309
  ws.send(JSON.stringify(hello));
@@ -859,6 +1315,40 @@ async function startWorker(projectId, options) {
859
1315
  if (message.type === "connected") {
860
1316
  return;
861
1317
  }
1318
+ if (message.type === "project_manifest") {
1319
+ configureContainerRegistryAuth(message.githubContainerRegistry);
1320
+ manifestByProjectId.clear();
1321
+ for (const project of message.projects) {
1322
+ manifestByProjectId.set(project.projectId, project);
1323
+ }
1324
+ process.stdout.write(`[r5d-worker] project manifest: ${message.projects.length} projects
1325
+ `);
1326
+ for (const project of message.projects) {
1327
+ const projectRoot = projectRootFor(projectsRoot, project.projectId, manifestByProjectId);
1328
+ const branches = project.branches.length > 0 ? project.branches : [project.defaultBranch || "main"];
1329
+ for (const branchName of branches) {
1330
+ try {
1331
+ await withBranchLock(project.projectId, branchName, async () => {
1332
+ ensureBranchWorkspace({
1333
+ projectId: project.projectId,
1334
+ baseUrl,
1335
+ token,
1336
+ projectRoot,
1337
+ syncRoot,
1338
+ branchName,
1339
+ manifest: project
1340
+ });
1341
+ });
1342
+ } catch (error) {
1343
+ process.stderr.write(
1344
+ `[r5d-worker] failed to sync ${project.repoSlug}/${branchName}: ${error instanceof Error ? error.message : String(error)}
1345
+ `
1346
+ );
1347
+ }
1348
+ }
1349
+ }
1350
+ return;
1351
+ }
862
1352
  if (message.type === "ping") {
863
1353
  ws.send(JSON.stringify({ type: "pong" }));
864
1354
  return;
@@ -880,9 +1370,11 @@ async function startWorker(projectId, options) {
880
1370
  return;
881
1371
  }
882
1372
  if (message.type === "pty_open") {
883
- process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.branchName}
1373
+ const manifest = manifestByProjectId.get(message.projectId);
1374
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
1375
+ process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
884
1376
  `);
885
- openPty({ ws, message, projectId, baseUrl, token, projectRoot });
1377
+ openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
886
1378
  return;
887
1379
  }
888
1380
  if (message.type === "pty_input") {
@@ -898,22 +1390,26 @@ async function startWorker(projectId, options) {
898
1390
  return;
899
1391
  }
900
1392
  if (message.type === "exec") {
1393
+ const manifest = manifestByProjectId.get(message.projectId);
1394
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
901
1395
  process.stdout.write(`[r5d-worker] exec ${message.runId}: ${message.argv.join(" ")}
902
1396
  `);
903
1397
  const result = await withBranchLock(
904
- projectRoot,
1398
+ message.projectId,
905
1399
  message.branchName,
906
- () => executeCommand({ message, projectId, baseUrl, token, projectRoot })
1400
+ () => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
907
1401
  );
908
1402
  ws.send(JSON.stringify(result));
909
1403
  return;
910
1404
  }
911
- if (message.type === "read_file" || message.type === "create_file" || message.type === "edit_file" || message.type === "view_file_bytes" || message.type === "sync" || message.type === "pull_branch") {
1405
+ 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") {
912
1406
  try {
1407
+ const manifest = manifestByProjectId.get(message.projectId);
1408
+ const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
913
1409
  const result = await withBranchLock(
914
- projectRoot,
1410
+ message.projectId,
915
1411
  message.branchName,
916
- () => executeOperation({ message, projectId, baseUrl, token, projectRoot })
1412
+ () => executeOperation({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest })
917
1413
  );
918
1414
  ws.send(
919
1415
  JSON.stringify({
@@ -958,7 +1454,7 @@ async function main() {
958
1454
  printHelp();
959
1455
  return;
960
1456
  }
961
- await startWorker(parsed.projectId, parsed.options);
1457
+ await startWorker(parsed.options);
962
1458
  }
963
1459
  main().catch((error) => {
964
1460
  process.stderr.write(`[r5d-worker] ${error instanceof Error ? error.message : String(error)}