@ricsam/r5d-worker 0.0.13 → 0.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/main.cjs +232 -63
- package/dist/cjs/package.json +1 -1
- package/dist/mjs/main.mjs +229 -63
- package/dist/mjs/package.json +1 -1
- package/dist/types/main.d.ts +20 -0
- package/package.json +1 -1
package/dist/cjs/main.cjs
CHANGED
|
@@ -32,6 +32,9 @@ __export(main_exports, {
|
|
|
32
32
|
ensureVisibleGitCheckout: () => ensureVisibleGitCheckout,
|
|
33
33
|
isArtifactEnvPath: () => isArtifactEnvPath,
|
|
34
34
|
prepareArtifactEnvForShell: () => prepareArtifactEnvForShell,
|
|
35
|
+
preparePlanEnvForShell: () => preparePlanEnvForShell,
|
|
36
|
+
resolveProjectFilePath: () => resolveProjectFilePath,
|
|
37
|
+
syncProjectPlans: () => syncProjectPlans,
|
|
35
38
|
syncSessionArtifacts: () => syncSessionArtifacts
|
|
36
39
|
});
|
|
37
40
|
module.exports = __toCommonJS(main_exports);
|
|
@@ -70,6 +73,9 @@ function defaultSyncRoot() {
|
|
|
70
73
|
function defaultArtifactRoot() {
|
|
71
74
|
return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "artifacts");
|
|
72
75
|
}
|
|
76
|
+
function defaultPlanRoot() {
|
|
77
|
+
return import_node_path.default.join(import_node_os.default.homedir(), ".r5d", "plans");
|
|
78
|
+
}
|
|
73
79
|
function printHelp() {
|
|
74
80
|
process.stdout.write(`Usage:
|
|
75
81
|
r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
|
|
@@ -84,6 +90,7 @@ Options:
|
|
|
84
90
|
--project-root <dir> Override projects checkout root (default ~/.r5d/projects)
|
|
85
91
|
--sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
|
|
86
92
|
--artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
|
|
93
|
+
--plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
|
|
87
94
|
-v, --version Show r5d-worker version
|
|
88
95
|
-h, --help Show this help
|
|
89
96
|
`);
|
|
@@ -205,6 +212,11 @@ function parseArgs(argv) {
|
|
|
205
212
|
options.artifactRoot = artifactRoot;
|
|
206
213
|
continue;
|
|
207
214
|
}
|
|
215
|
+
const planRoot = readOption("--plan-root");
|
|
216
|
+
if (planRoot !== void 0) {
|
|
217
|
+
options.planRoot = planRoot;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
208
220
|
rest.push(arg);
|
|
209
221
|
}
|
|
210
222
|
if (options.version) {
|
|
@@ -248,6 +260,7 @@ async function readResponseText(response) {
|
|
|
248
260
|
}
|
|
249
261
|
}
|
|
250
262
|
const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
|
|
263
|
+
const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
|
|
251
264
|
function validateArtifactSessionId(sessionId) {
|
|
252
265
|
if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
|
|
253
266
|
throw new Error(`Invalid artifact session id: ${sessionId}`);
|
|
@@ -256,7 +269,7 @@ function validateArtifactSessionId(sessionId) {
|
|
|
256
269
|
function assertInsideRoot(rootPath, candidatePath, label) {
|
|
257
270
|
const relative = import_node_path.default.relative(rootPath, candidatePath);
|
|
258
271
|
if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
|
|
259
|
-
throw new Error(`${label} escapes
|
|
272
|
+
throw new Error(`${label} escapes sync directory`);
|
|
260
273
|
}
|
|
261
274
|
}
|
|
262
275
|
function sessionArtifactDir(artifactRoot, sessionId) {
|
|
@@ -271,6 +284,23 @@ function artifactEnv(artifactRoot, sessionId) {
|
|
|
271
284
|
R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
|
|
272
285
|
};
|
|
273
286
|
}
|
|
287
|
+
function projectPlanDir(planRoot, projectId, branchName) {
|
|
288
|
+
validateProjectId(projectId);
|
|
289
|
+
validateBranchName(branchName);
|
|
290
|
+
return import_node_path.default.join(planRoot, projectId, branchName);
|
|
291
|
+
}
|
|
292
|
+
function planEnv(planRoot, projectId, branchName, activePlanId) {
|
|
293
|
+
const plansDir = projectPlanDir(planRoot, projectId, branchName);
|
|
294
|
+
const env = {
|
|
295
|
+
[R5D_PLANS_DIR_ENV]: plansDir
|
|
296
|
+
};
|
|
297
|
+
if (activePlanId) {
|
|
298
|
+
validatePlanId(activePlanId);
|
|
299
|
+
env.R5D_ACTIVE_PLAN_ID = activePlanId;
|
|
300
|
+
env.R5D_ACTIVE_PLAN_FILE = import_node_path.default.join(plansDir, `${activePlanId}.plan.md`);
|
|
301
|
+
}
|
|
302
|
+
return env;
|
|
303
|
+
}
|
|
274
304
|
function isArtifactEnvPath(filePath) {
|
|
275
305
|
return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
|
|
276
306
|
}
|
|
@@ -283,99 +313,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
|
|
|
283
313
|
function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
|
|
284
314
|
return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
|
|
285
315
|
}
|
|
316
|
+
function planApiBasePath(projectId, branchName) {
|
|
317
|
+
return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
|
|
318
|
+
}
|
|
319
|
+
function planManifestUrl(baseUrl, projectId, branchName) {
|
|
320
|
+
return new URL(planApiBasePath(projectId, branchName), baseUrl);
|
|
321
|
+
}
|
|
322
|
+
function planDownloadUrl(baseUrl, projectId, branchName, filename) {
|
|
323
|
+
return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
|
|
324
|
+
}
|
|
286
325
|
function sha256Path(filePath) {
|
|
287
326
|
return (0, import_node_crypto.createHash)("sha256").update(import_node_fs.default.readFileSync(filePath)).digest("hex");
|
|
288
327
|
}
|
|
289
|
-
function
|
|
290
|
-
if (!value || typeof value !== "object" || !Array.isArray(value
|
|
291
|
-
throw new Error(
|
|
328
|
+
function parseRemoteFileManifest(value, collectionKey, label) {
|
|
329
|
+
if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
|
|
330
|
+
throw new Error(`${label} manifest response is invalid`);
|
|
292
331
|
}
|
|
293
|
-
return value.
|
|
332
|
+
return value[collectionKey].map((entry) => {
|
|
294
333
|
if (!entry || typeof entry !== "object") {
|
|
295
|
-
throw new Error(
|
|
334
|
+
throw new Error(`${label} manifest entry is invalid`);
|
|
296
335
|
}
|
|
297
|
-
const
|
|
298
|
-
if (typeof
|
|
299
|
-
throw new Error(
|
|
336
|
+
const file = entry;
|
|
337
|
+
if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
|
|
338
|
+
throw new Error(`${label} manifest entry has an invalid filename`);
|
|
300
339
|
}
|
|
301
|
-
if (typeof
|
|
302
|
-
throw new Error(
|
|
340
|
+
if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
|
|
341
|
+
throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
|
|
303
342
|
}
|
|
304
|
-
if (typeof
|
|
305
|
-
throw new Error(
|
|
343
|
+
if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
|
|
344
|
+
throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
|
|
306
345
|
}
|
|
307
346
|
return {
|
|
308
|
-
filename:
|
|
309
|
-
size:
|
|
310
|
-
sha256:
|
|
347
|
+
filename: file.filename,
|
|
348
|
+
size: file.size,
|
|
349
|
+
sha256: file.sha256.toLowerCase()
|
|
311
350
|
};
|
|
312
351
|
});
|
|
313
352
|
}
|
|
314
|
-
async function
|
|
315
|
-
const response = await fetch(
|
|
353
|
+
async function fetchRemoteFileManifest(input) {
|
|
354
|
+
const response = await fetch(input.url, {
|
|
316
355
|
headers: {
|
|
317
356
|
Authorization: `Bearer ${input.token}`
|
|
318
357
|
}
|
|
319
358
|
});
|
|
320
359
|
if (!response.ok) {
|
|
321
360
|
const detail = (await readResponseText(response)).trim();
|
|
322
|
-
throw new Error(`Failed to fetch
|
|
361
|
+
throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
323
362
|
}
|
|
324
|
-
return
|
|
363
|
+
return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
|
|
325
364
|
}
|
|
326
|
-
async function
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
{
|
|
333
|
-
headers: {
|
|
334
|
-
Authorization: `Bearer ${input.token}`
|
|
335
|
-
}
|
|
365
|
+
async function downloadRemoteSyncedFile(input) {
|
|
366
|
+
const targetPath = import_node_path.default.join(input.targetDir, input.file.filename);
|
|
367
|
+
assertInsideRoot(input.targetDir, targetPath, input.label);
|
|
368
|
+
const response = await fetch(input.url, {
|
|
369
|
+
headers: {
|
|
370
|
+
Authorization: `Bearer ${input.token}`
|
|
336
371
|
}
|
|
337
|
-
);
|
|
372
|
+
});
|
|
338
373
|
if (!response.ok) {
|
|
339
374
|
const detail = (await readResponseText(response)).trim();
|
|
340
|
-
throw new Error(`Failed to download
|
|
375
|
+
throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
341
376
|
}
|
|
342
377
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
343
378
|
const receivedHash = (0, import_node_crypto.createHash)("sha256").update(buffer).digest("hex");
|
|
344
|
-
if (receivedHash !== input.
|
|
345
|
-
throw new Error(`Downloaded
|
|
379
|
+
if (receivedHash !== input.file.sha256) {
|
|
380
|
+
throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
|
|
346
381
|
}
|
|
347
|
-
if (buffer.length !== input.
|
|
348
|
-
throw new Error(`Downloaded
|
|
382
|
+
if (buffer.length !== input.file.size) {
|
|
383
|
+
throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
|
|
349
384
|
}
|
|
350
385
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
351
386
|
import_node_fs.default.writeFileSync(tempPath, buffer);
|
|
352
387
|
import_node_fs.default.renameSync(tempPath, targetPath);
|
|
353
388
|
}
|
|
354
|
-
async function
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const localPath = import_node_path.default.join(targetDir, artifact.filename);
|
|
361
|
-
assertInsideRoot(targetDir, localPath, "Artifact path");
|
|
389
|
+
async function syncRemoteFileSet(input) {
|
|
390
|
+
import_node_fs.default.mkdirSync(input.targetDir, { recursive: true });
|
|
391
|
+
const manifestNames = new Set(input.files.map((file) => file.filename));
|
|
392
|
+
for (const file of input.files) {
|
|
393
|
+
const localPath = import_node_path.default.join(input.targetDir, file.filename);
|
|
394
|
+
assertInsideRoot(input.targetDir, localPath, input.label);
|
|
362
395
|
if (import_node_fs.default.existsSync(localPath)) {
|
|
363
396
|
const stats = import_node_fs.default.statSync(localPath);
|
|
364
|
-
if (stats.isFile() && stats.size ===
|
|
397
|
+
if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
|
|
365
398
|
continue;
|
|
366
399
|
}
|
|
367
400
|
import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
|
|
368
401
|
}
|
|
369
|
-
await
|
|
402
|
+
await input.download(file);
|
|
370
403
|
}
|
|
371
|
-
for (const localName of import_node_fs.default.readdirSync(targetDir)) {
|
|
404
|
+
for (const localName of import_node_fs.default.readdirSync(input.targetDir)) {
|
|
372
405
|
if (!manifestNames.has(localName)) {
|
|
373
|
-
const localPath = import_node_path.default.join(targetDir, localName);
|
|
374
|
-
assertInsideRoot(targetDir, localPath,
|
|
406
|
+
const localPath = import_node_path.default.join(input.targetDir, localName);
|
|
407
|
+
assertInsideRoot(input.targetDir, localPath, input.label);
|
|
375
408
|
import_node_fs.default.rmSync(localPath, { recursive: true, force: true });
|
|
376
409
|
}
|
|
377
410
|
}
|
|
378
|
-
return targetDir;
|
|
411
|
+
return input.targetDir;
|
|
412
|
+
}
|
|
413
|
+
async function fetchSessionArtifactManifest(input) {
|
|
414
|
+
return fetchRemoteFileManifest({
|
|
415
|
+
url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
|
|
416
|
+
token: input.token,
|
|
417
|
+
collectionKey: "artifacts",
|
|
418
|
+
label: "artifact"
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async function syncSessionArtifacts(input) {
|
|
422
|
+
const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
|
|
423
|
+
const artifacts = await fetchSessionArtifactManifest(input);
|
|
424
|
+
return syncRemoteFileSet({
|
|
425
|
+
targetDir,
|
|
426
|
+
files: artifacts,
|
|
427
|
+
label: "Artifact path",
|
|
428
|
+
download: (artifact) => downloadRemoteSyncedFile({
|
|
429
|
+
url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
|
|
430
|
+
token: input.token,
|
|
431
|
+
targetDir,
|
|
432
|
+
file: artifact,
|
|
433
|
+
label: "artifact"
|
|
434
|
+
})
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async function fetchProjectPlanManifest(input) {
|
|
438
|
+
return fetchRemoteFileManifest({
|
|
439
|
+
url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
|
|
440
|
+
token: input.token,
|
|
441
|
+
collectionKey: "plans",
|
|
442
|
+
label: "plan"
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
async function syncProjectPlans(input) {
|
|
446
|
+
const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
|
|
447
|
+
const plans = await fetchProjectPlanManifest(input);
|
|
448
|
+
return syncRemoteFileSet({
|
|
449
|
+
targetDir,
|
|
450
|
+
files: plans,
|
|
451
|
+
label: "Plan path",
|
|
452
|
+
download: (plan) => downloadRemoteSyncedFile({
|
|
453
|
+
url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
|
|
454
|
+
token: input.token,
|
|
455
|
+
targetDir,
|
|
456
|
+
file: plan,
|
|
457
|
+
label: "plan"
|
|
458
|
+
})
|
|
459
|
+
});
|
|
379
460
|
}
|
|
380
461
|
async function resolveArtifactEnvFilePath(input) {
|
|
381
462
|
if (!isArtifactEnvPath(input.filePath)) {
|
|
@@ -434,6 +515,19 @@ async function prepareArtifactEnvForShell(input) {
|
|
|
434
515
|
R5D_ARTIFACTS_DIR: artifactsDir
|
|
435
516
|
};
|
|
436
517
|
}
|
|
518
|
+
async function preparePlanEnvForShell(input) {
|
|
519
|
+
try {
|
|
520
|
+
await syncProjectPlans(input);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
process.stderr.write(
|
|
523
|
+
`[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
|
|
524
|
+
`
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
|
|
528
|
+
import_node_fs.default.mkdirSync(plansDir, { recursive: true });
|
|
529
|
+
return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
|
|
530
|
+
}
|
|
437
531
|
async function verifyR5dctlAuth(baseUrl, token) {
|
|
438
532
|
const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
|
|
439
533
|
let response;
|
|
@@ -478,11 +572,24 @@ function validateLabel(label) {
|
|
|
478
572
|
throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
|
|
479
573
|
}
|
|
480
574
|
}
|
|
575
|
+
function validateProjectId(projectId) {
|
|
576
|
+
if (projectId === "local" || projectId === "local-code") {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
|
|
580
|
+
throw new Error(`Invalid project id from server: ${projectId}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
481
583
|
function validateBranchName(branchName) {
|
|
482
584
|
if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
|
|
483
585
|
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
484
586
|
}
|
|
485
587
|
}
|
|
588
|
+
function validatePlanId(planId) {
|
|
589
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
|
|
590
|
+
throw new Error(`Invalid plan id from server: ${planId}`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
486
593
|
function gitExtraHeaderConfigKey(extraHeaderUrl) {
|
|
487
594
|
return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
|
|
488
595
|
}
|
|
@@ -548,9 +655,12 @@ function getInternalStatus(context) {
|
|
|
548
655
|
function hasInternalWorktreeChanges(context) {
|
|
549
656
|
return getInternalStatus(context).trim().length > 0;
|
|
550
657
|
}
|
|
658
|
+
function isInsideBranchPath(branchPath, filePath) {
|
|
659
|
+
const relative = import_node_path.default.relative(import_node_path.default.resolve(branchPath), import_node_path.default.resolve(filePath));
|
|
660
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${import_node_path.default.sep}`) && !import_node_path.default.isAbsolute(relative);
|
|
661
|
+
}
|
|
551
662
|
function assertInsideBranch(branchPath, filePath) {
|
|
552
|
-
|
|
553
|
-
if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
|
|
663
|
+
if (!isInsideBranchPath(branchPath, filePath)) {
|
|
554
664
|
throw new Error("File path escapes the project branch");
|
|
555
665
|
}
|
|
556
666
|
}
|
|
@@ -558,17 +668,27 @@ function resolveProjectFilePath(branchPath, virtualPath) {
|
|
|
558
668
|
if (typeof virtualPath !== "string" || !virtualPath.startsWith("/") || virtualPath.includes("\0")) {
|
|
559
669
|
throw new Error(`File path must be an absolute project path starting with /. Got: ${JSON.stringify(virtualPath)}`);
|
|
560
670
|
}
|
|
671
|
+
const checkoutAbsolutePath = import_node_path.default.resolve(virtualPath);
|
|
672
|
+
if (isInsideBranchPath(branchPath, checkoutAbsolutePath)) {
|
|
673
|
+
const checkoutVirtualPath = toVirtualPath(import_node_path.default.resolve(branchPath), checkoutAbsolutePath);
|
|
674
|
+
if (checkoutVirtualPath !== virtualPath) {
|
|
675
|
+
return resolveProjectFilePath(branchPath, checkoutVirtualPath);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (virtualPath.split("/").includes("..")) {
|
|
679
|
+
throw new Error(`Invalid project file path: ${virtualPath}`);
|
|
680
|
+
}
|
|
561
681
|
const normalized = import_node_path.default.posix.normalize(virtualPath);
|
|
562
|
-
if (normalized
|
|
682
|
+
if (normalized.startsWith("/../")) {
|
|
563
683
|
throw new Error(`Invalid project file path: ${virtualPath}`);
|
|
564
684
|
}
|
|
565
|
-
const repoRelativePath = normalized.slice(1);
|
|
685
|
+
const repoRelativePath = normalized === "/" ? "" : normalized.slice(1);
|
|
566
686
|
if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
|
|
567
687
|
throw new Error("Refusing to access .git through worker file tools");
|
|
568
688
|
}
|
|
569
|
-
const absolutePath = import_node_path.default.resolve(branchPath, ...repoRelativePath.split("/"));
|
|
689
|
+
const absolutePath = repoRelativePath ? import_node_path.default.resolve(branchPath, ...repoRelativePath.split("/")) : import_node_path.default.resolve(branchPath);
|
|
570
690
|
assertInsideBranch(branchPath, absolutePath);
|
|
571
|
-
return { absolutePath, virtualPath: `/${repoRelativePath}
|
|
691
|
+
return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
|
|
572
692
|
}
|
|
573
693
|
function branchLockKey(projectId, branchName) {
|
|
574
694
|
return `${projectId}:${branchName}`;
|
|
@@ -1518,6 +1638,14 @@ async function executeCommand(input) {
|
|
|
1518
1638
|
sessionId: input.message.sessionId,
|
|
1519
1639
|
artifactRoot: input.artifactRoot
|
|
1520
1640
|
});
|
|
1641
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
1642
|
+
baseUrl: input.baseUrl,
|
|
1643
|
+
token: input.token,
|
|
1644
|
+
projectId: input.projectId,
|
|
1645
|
+
branchName: input.message.branchName,
|
|
1646
|
+
planRoot: input.planRoot,
|
|
1647
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
1648
|
+
});
|
|
1521
1649
|
const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
|
|
1522
1650
|
const subprocess = Bun.spawn(input.message.argv, {
|
|
1523
1651
|
cwd,
|
|
@@ -1528,7 +1656,8 @@ async function executeCommand(input) {
|
|
|
1528
1656
|
...containerRegistryEnv(),
|
|
1529
1657
|
...input.message.env ?? {},
|
|
1530
1658
|
...internalGitProcessEnv(workspace, input.message.env),
|
|
1531
|
-
...artifactProcessEnv
|
|
1659
|
+
...artifactProcessEnv,
|
|
1660
|
+
...planProcessEnv
|
|
1532
1661
|
}
|
|
1533
1662
|
});
|
|
1534
1663
|
activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
|
|
@@ -1599,6 +1728,14 @@ async function executeStreamingCommand(input) {
|
|
|
1599
1728
|
sessionId: input.message.sessionId,
|
|
1600
1729
|
artifactRoot: input.artifactRoot
|
|
1601
1730
|
});
|
|
1731
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
1732
|
+
baseUrl: input.baseUrl,
|
|
1733
|
+
token: input.token,
|
|
1734
|
+
projectId: input.projectId,
|
|
1735
|
+
branchName: input.message.branchName,
|
|
1736
|
+
planRoot: input.planRoot,
|
|
1737
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
1738
|
+
});
|
|
1602
1739
|
const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
|
|
1603
1740
|
const subprocess = Bun.spawn(input.message.argv, {
|
|
1604
1741
|
cwd,
|
|
@@ -1609,7 +1746,8 @@ async function executeStreamingCommand(input) {
|
|
|
1609
1746
|
...containerRegistryEnv(),
|
|
1610
1747
|
...input.message.env ?? {},
|
|
1611
1748
|
...internalGitProcessEnv(workspace, input.message.env),
|
|
1612
|
-
...artifactProcessEnv
|
|
1749
|
+
...artifactProcessEnv,
|
|
1750
|
+
...planProcessEnv
|
|
1613
1751
|
}
|
|
1614
1752
|
});
|
|
1615
1753
|
activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
|
|
@@ -1861,7 +1999,7 @@ function resolveHostShell() {
|
|
|
1861
1999
|
}
|
|
1862
2000
|
return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
|
|
1863
2001
|
}
|
|
1864
|
-
function openPty(input) {
|
|
2002
|
+
async function openPty(input) {
|
|
1865
2003
|
try {
|
|
1866
2004
|
const branchPath = ensureBranchWorkspace({
|
|
1867
2005
|
projectId: input.projectId,
|
|
@@ -1872,6 +2010,14 @@ function openPty(input) {
|
|
|
1872
2010
|
branchName: input.message.branchName,
|
|
1873
2011
|
manifest: input.manifest
|
|
1874
2012
|
});
|
|
2013
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
2014
|
+
baseUrl: input.baseUrl,
|
|
2015
|
+
token: input.token,
|
|
2016
|
+
projectId: input.projectId,
|
|
2017
|
+
branchName: input.message.branchName,
|
|
2018
|
+
planRoot: input.planRoot,
|
|
2019
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
2020
|
+
});
|
|
1875
2021
|
const shell = resolveHostShell();
|
|
1876
2022
|
const ptyProcess = createNodePtyBridge({
|
|
1877
2023
|
file: shell.file,
|
|
@@ -1885,6 +2031,7 @@ function openPty(input) {
|
|
|
1885
2031
|
...process.env,
|
|
1886
2032
|
...containerRegistryEnv(),
|
|
1887
2033
|
...input.message.env ?? {},
|
|
2034
|
+
...planProcessEnv,
|
|
1888
2035
|
R5D_PROJECT_ID: input.projectId,
|
|
1889
2036
|
R5D_BRANCH_NAME: input.message.branchName
|
|
1890
2037
|
}
|
|
@@ -1985,6 +2132,7 @@ async function startWorker(options) {
|
|
|
1985
2132
|
const projectsRoot = import_node_path.default.resolve(options.projectRoot ?? defaultProjectsRoot());
|
|
1986
2133
|
const syncRoot = import_node_path.default.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
|
|
1987
2134
|
const artifactRoot = import_node_path.default.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
|
|
2135
|
+
const planRoot = import_node_path.default.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
|
|
1988
2136
|
process.stdout.write(`[r5d-worker] label: ${label}
|
|
1989
2137
|
`);
|
|
1990
2138
|
process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
|
|
@@ -1992,6 +2140,8 @@ async function startWorker(options) {
|
|
|
1992
2140
|
process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
|
|
1993
2141
|
`);
|
|
1994
2142
|
process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
|
|
2143
|
+
`);
|
|
2144
|
+
process.stdout.write(`[r5d-worker] plan root: ${planRoot}
|
|
1995
2145
|
`);
|
|
1996
2146
|
process.stdout.write(`[r5d-worker] server: ${baseUrl}
|
|
1997
2147
|
`);
|
|
@@ -2000,6 +2150,7 @@ async function startWorker(options) {
|
|
|
2000
2150
|
import_node_fs.default.mkdirSync(projectsRoot, { recursive: true });
|
|
2001
2151
|
import_node_fs.default.mkdirSync(syncRoot, { recursive: true });
|
|
2002
2152
|
import_node_fs.default.mkdirSync(artifactRoot, { recursive: true });
|
|
2153
|
+
import_node_fs.default.mkdirSync(planRoot, { recursive: true });
|
|
2003
2154
|
const manifestByProjectId = /* @__PURE__ */ new Map();
|
|
2004
2155
|
const ws = new WebSocket(websocketUrl(baseUrl, label), {
|
|
2005
2156
|
headers: {
|
|
@@ -2016,7 +2167,8 @@ async function startWorker(options) {
|
|
|
2016
2167
|
pid: process.pid,
|
|
2017
2168
|
version: getWorkerVersion(),
|
|
2018
2169
|
projectRoot: projectsRoot,
|
|
2019
|
-
artifactRoot
|
|
2170
|
+
artifactRoot,
|
|
2171
|
+
planRoot
|
|
2020
2172
|
}
|
|
2021
2173
|
};
|
|
2022
2174
|
ws.send(JSON.stringify(hello));
|
|
@@ -2052,6 +2204,20 @@ async function startWorker(options) {
|
|
|
2052
2204
|
branchName,
|
|
2053
2205
|
manifest: project
|
|
2054
2206
|
});
|
|
2207
|
+
try {
|
|
2208
|
+
await syncProjectPlans({
|
|
2209
|
+
baseUrl,
|
|
2210
|
+
token,
|
|
2211
|
+
projectId: project.projectId,
|
|
2212
|
+
branchName,
|
|
2213
|
+
planRoot
|
|
2214
|
+
});
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
process.stderr.write(
|
|
2217
|
+
`[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
|
|
2218
|
+
`
|
|
2219
|
+
);
|
|
2220
|
+
}
|
|
2055
2221
|
});
|
|
2056
2222
|
} catch (error) {
|
|
2057
2223
|
process.stderr.write(
|
|
@@ -2090,7 +2256,7 @@ async function startWorker(options) {
|
|
|
2090
2256
|
const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
|
|
2091
2257
|
process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
|
|
2092
2258
|
`);
|
|
2093
|
-
openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
|
|
2259
|
+
await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
|
|
2094
2260
|
return;
|
|
2095
2261
|
}
|
|
2096
2262
|
if (message.type === "pty_input") {
|
|
@@ -2113,7 +2279,7 @@ async function startWorker(options) {
|
|
|
2113
2279
|
const result = await withBranchLock(
|
|
2114
2280
|
message.projectId,
|
|
2115
2281
|
message.branchName,
|
|
2116
|
-
() => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
|
|
2282
|
+
() => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
|
|
2117
2283
|
);
|
|
2118
2284
|
ws.send(JSON.stringify(result));
|
|
2119
2285
|
return;
|
|
@@ -2132,7 +2298,7 @@ async function startWorker(options) {
|
|
|
2132
2298
|
void withBranchLock(
|
|
2133
2299
|
message.projectId,
|
|
2134
2300
|
message.branchName,
|
|
2135
|
-
() => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
|
|
2301
|
+
() => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
|
|
2136
2302
|
).catch((error) => {
|
|
2137
2303
|
sendWorkerMessage(ws, {
|
|
2138
2304
|
type: "exec_start_error",
|
|
@@ -2238,5 +2404,8 @@ if (isCliEntrypoint()) {
|
|
|
2238
2404
|
ensureVisibleGitCheckout,
|
|
2239
2405
|
isArtifactEnvPath,
|
|
2240
2406
|
prepareArtifactEnvForShell,
|
|
2407
|
+
preparePlanEnvForShell,
|
|
2408
|
+
resolveProjectFilePath,
|
|
2409
|
+
syncProjectPlans,
|
|
2241
2410
|
syncSessionArtifacts
|
|
2242
2411
|
});
|
package/dist/cjs/package.json
CHANGED
package/dist/mjs/main.mjs
CHANGED
|
@@ -33,6 +33,9 @@ function defaultSyncRoot() {
|
|
|
33
33
|
function defaultArtifactRoot() {
|
|
34
34
|
return path.join(os.homedir(), ".r5d", "artifacts");
|
|
35
35
|
}
|
|
36
|
+
function defaultPlanRoot() {
|
|
37
|
+
return path.join(os.homedir(), ".r5d", "plans");
|
|
38
|
+
}
|
|
36
39
|
function printHelp() {
|
|
37
40
|
process.stdout.write(`Usage:
|
|
38
41
|
r5d-worker start --label <label> [--base-url <url>] [--token <token>] [--api-key <key>]
|
|
@@ -47,6 +50,7 @@ Options:
|
|
|
47
50
|
--project-root <dir> Override projects checkout root (default ~/.r5d/projects)
|
|
48
51
|
--sync-root <dir> Override r5d internal sync git root (default ~/.r5d/sync)
|
|
49
52
|
--artifact-root <dir> Override session artifact root (default from R5D_ARTIFACTS_ROOT or ~/.r5d/artifacts)
|
|
53
|
+
--plan-root <dir> Override synced plan root (default from R5D_PLANS_ROOT or ~/.r5d/plans)
|
|
50
54
|
-v, --version Show r5d-worker version
|
|
51
55
|
-h, --help Show this help
|
|
52
56
|
`);
|
|
@@ -168,6 +172,11 @@ function parseArgs(argv) {
|
|
|
168
172
|
options.artifactRoot = artifactRoot;
|
|
169
173
|
continue;
|
|
170
174
|
}
|
|
175
|
+
const planRoot = readOption("--plan-root");
|
|
176
|
+
if (planRoot !== void 0) {
|
|
177
|
+
options.planRoot = planRoot;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
171
180
|
rest.push(arg);
|
|
172
181
|
}
|
|
173
182
|
if (options.version) {
|
|
@@ -211,6 +220,7 @@ async function readResponseText(response) {
|
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
const R5D_ARTIFACTS_DIR_REF = "$R5D_ARTIFACTS_DIR";
|
|
223
|
+
const R5D_PLANS_DIR_ENV = "R5D_PLANS_DIR";
|
|
214
224
|
function validateArtifactSessionId(sessionId) {
|
|
215
225
|
if (!/^(migration-)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
|
|
216
226
|
throw new Error(`Invalid artifact session id: ${sessionId}`);
|
|
@@ -219,7 +229,7 @@ function validateArtifactSessionId(sessionId) {
|
|
|
219
229
|
function assertInsideRoot(rootPath, candidatePath, label) {
|
|
220
230
|
const relative = path.relative(rootPath, candidatePath);
|
|
221
231
|
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
222
|
-
throw new Error(`${label} escapes
|
|
232
|
+
throw new Error(`${label} escapes sync directory`);
|
|
223
233
|
}
|
|
224
234
|
}
|
|
225
235
|
function sessionArtifactDir(artifactRoot, sessionId) {
|
|
@@ -234,6 +244,23 @@ function artifactEnv(artifactRoot, sessionId) {
|
|
|
234
244
|
R5D_ARTIFACTS_DIR: sessionArtifactDir(artifactRoot, sessionId)
|
|
235
245
|
};
|
|
236
246
|
}
|
|
247
|
+
function projectPlanDir(planRoot, projectId, branchName) {
|
|
248
|
+
validateProjectId(projectId);
|
|
249
|
+
validateBranchName(branchName);
|
|
250
|
+
return path.join(planRoot, projectId, branchName);
|
|
251
|
+
}
|
|
252
|
+
function planEnv(planRoot, projectId, branchName, activePlanId) {
|
|
253
|
+
const plansDir = projectPlanDir(planRoot, projectId, branchName);
|
|
254
|
+
const env = {
|
|
255
|
+
[R5D_PLANS_DIR_ENV]: plansDir
|
|
256
|
+
};
|
|
257
|
+
if (activePlanId) {
|
|
258
|
+
validatePlanId(activePlanId);
|
|
259
|
+
env.R5D_ACTIVE_PLAN_ID = activePlanId;
|
|
260
|
+
env.R5D_ACTIVE_PLAN_FILE = path.join(plansDir, `${activePlanId}.plan.md`);
|
|
261
|
+
}
|
|
262
|
+
return env;
|
|
263
|
+
}
|
|
237
264
|
function isArtifactEnvPath(filePath) {
|
|
238
265
|
return filePath === R5D_ARTIFACTS_DIR_REF || filePath.startsWith(`${R5D_ARTIFACTS_DIR_REF}/`);
|
|
239
266
|
}
|
|
@@ -246,99 +273,150 @@ function artifactManifestUrl(baseUrl, projectId, branchName, sessionId) {
|
|
|
246
273
|
function artifactDownloadUrl(baseUrl, projectId, branchName, sessionId, filename) {
|
|
247
274
|
return new URL(`${artifactApiBasePath(projectId, branchName, sessionId)}/${encodeURIComponent(filename)}`, baseUrl);
|
|
248
275
|
}
|
|
276
|
+
function planApiBasePath(projectId, branchName) {
|
|
277
|
+
return `/preview-api/${encodeURIComponent(projectId)}/${encodeURIComponent(branchName)}/plans`;
|
|
278
|
+
}
|
|
279
|
+
function planManifestUrl(baseUrl, projectId, branchName) {
|
|
280
|
+
return new URL(planApiBasePath(projectId, branchName), baseUrl);
|
|
281
|
+
}
|
|
282
|
+
function planDownloadUrl(baseUrl, projectId, branchName, filename) {
|
|
283
|
+
return new URL(`${planApiBasePath(projectId, branchName)}/${encodeURIComponent(filename)}`, baseUrl);
|
|
284
|
+
}
|
|
249
285
|
function sha256Path(filePath) {
|
|
250
286
|
return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
251
287
|
}
|
|
252
|
-
function
|
|
253
|
-
if (!value || typeof value !== "object" || !Array.isArray(value
|
|
254
|
-
throw new Error(
|
|
288
|
+
function parseRemoteFileManifest(value, collectionKey, label) {
|
|
289
|
+
if (!value || typeof value !== "object" || !Array.isArray(value[collectionKey])) {
|
|
290
|
+
throw new Error(`${label} manifest response is invalid`);
|
|
255
291
|
}
|
|
256
|
-
return value.
|
|
292
|
+
return value[collectionKey].map((entry) => {
|
|
257
293
|
if (!entry || typeof entry !== "object") {
|
|
258
|
-
throw new Error(
|
|
294
|
+
throw new Error(`${label} manifest entry is invalid`);
|
|
259
295
|
}
|
|
260
|
-
const
|
|
261
|
-
if (typeof
|
|
262
|
-
throw new Error(
|
|
296
|
+
const file = entry;
|
|
297
|
+
if (typeof file.filename !== "string" || file.filename.includes("/") || file.filename.includes("\\")) {
|
|
298
|
+
throw new Error(`${label} manifest entry has an invalid filename`);
|
|
263
299
|
}
|
|
264
|
-
if (typeof
|
|
265
|
-
throw new Error(
|
|
300
|
+
if (typeof file.size !== "number" || !Number.isFinite(file.size) || file.size < 0) {
|
|
301
|
+
throw new Error(`${label} manifest entry has an invalid size: ${file.filename}`);
|
|
266
302
|
}
|
|
267
|
-
if (typeof
|
|
268
|
-
throw new Error(
|
|
303
|
+
if (typeof file.sha256 !== "string" || !/^[a-f0-9]{64}$/i.test(file.sha256)) {
|
|
304
|
+
throw new Error(`${label} manifest entry has an invalid sha256: ${file.filename}`);
|
|
269
305
|
}
|
|
270
306
|
return {
|
|
271
|
-
filename:
|
|
272
|
-
size:
|
|
273
|
-
sha256:
|
|
307
|
+
filename: file.filename,
|
|
308
|
+
size: file.size,
|
|
309
|
+
sha256: file.sha256.toLowerCase()
|
|
274
310
|
};
|
|
275
311
|
});
|
|
276
312
|
}
|
|
277
|
-
async function
|
|
278
|
-
const response = await fetch(
|
|
313
|
+
async function fetchRemoteFileManifest(input) {
|
|
314
|
+
const response = await fetch(input.url, {
|
|
279
315
|
headers: {
|
|
280
316
|
Authorization: `Bearer ${input.token}`
|
|
281
317
|
}
|
|
282
318
|
});
|
|
283
319
|
if (!response.ok) {
|
|
284
320
|
const detail = (await readResponseText(response)).trim();
|
|
285
|
-
throw new Error(`Failed to fetch
|
|
321
|
+
throw new Error(`Failed to fetch ${input.label} manifest (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
286
322
|
}
|
|
287
|
-
return
|
|
323
|
+
return parseRemoteFileManifest(await response.json(), input.collectionKey, input.label);
|
|
288
324
|
}
|
|
289
|
-
async function
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
headers: {
|
|
297
|
-
Authorization: `Bearer ${input.token}`
|
|
298
|
-
}
|
|
325
|
+
async function downloadRemoteSyncedFile(input) {
|
|
326
|
+
const targetPath = path.join(input.targetDir, input.file.filename);
|
|
327
|
+
assertInsideRoot(input.targetDir, targetPath, input.label);
|
|
328
|
+
const response = await fetch(input.url, {
|
|
329
|
+
headers: {
|
|
330
|
+
Authorization: `Bearer ${input.token}`
|
|
299
331
|
}
|
|
300
|
-
);
|
|
332
|
+
});
|
|
301
333
|
if (!response.ok) {
|
|
302
334
|
const detail = (await readResponseText(response)).trim();
|
|
303
|
-
throw new Error(`Failed to download
|
|
335
|
+
throw new Error(`Failed to download ${input.label} ${input.file.filename} (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
304
336
|
}
|
|
305
337
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
306
338
|
const receivedHash = createHash("sha256").update(buffer).digest("hex");
|
|
307
|
-
if (receivedHash !== input.
|
|
308
|
-
throw new Error(`Downloaded
|
|
339
|
+
if (receivedHash !== input.file.sha256) {
|
|
340
|
+
throw new Error(`Downloaded ${input.label} ${input.file.filename} failed sha256 verification`);
|
|
309
341
|
}
|
|
310
|
-
if (buffer.length !== input.
|
|
311
|
-
throw new Error(`Downloaded
|
|
342
|
+
if (buffer.length !== input.file.size) {
|
|
343
|
+
throw new Error(`Downloaded ${input.label} ${input.file.filename} has unexpected size`);
|
|
312
344
|
}
|
|
313
345
|
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
|
|
314
346
|
fs.writeFileSync(tempPath, buffer);
|
|
315
347
|
fs.renameSync(tempPath, targetPath);
|
|
316
348
|
}
|
|
317
|
-
async function
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const localPath = path.join(targetDir, artifact.filename);
|
|
324
|
-
assertInsideRoot(targetDir, localPath, "Artifact path");
|
|
349
|
+
async function syncRemoteFileSet(input) {
|
|
350
|
+
fs.mkdirSync(input.targetDir, { recursive: true });
|
|
351
|
+
const manifestNames = new Set(input.files.map((file) => file.filename));
|
|
352
|
+
for (const file of input.files) {
|
|
353
|
+
const localPath = path.join(input.targetDir, file.filename);
|
|
354
|
+
assertInsideRoot(input.targetDir, localPath, input.label);
|
|
325
355
|
if (fs.existsSync(localPath)) {
|
|
326
356
|
const stats = fs.statSync(localPath);
|
|
327
|
-
if (stats.isFile() && stats.size ===
|
|
357
|
+
if (stats.isFile() && stats.size === file.size && sha256Path(localPath) === file.sha256) {
|
|
328
358
|
continue;
|
|
329
359
|
}
|
|
330
360
|
fs.rmSync(localPath, { recursive: true, force: true });
|
|
331
361
|
}
|
|
332
|
-
await
|
|
362
|
+
await input.download(file);
|
|
333
363
|
}
|
|
334
|
-
for (const localName of fs.readdirSync(targetDir)) {
|
|
364
|
+
for (const localName of fs.readdirSync(input.targetDir)) {
|
|
335
365
|
if (!manifestNames.has(localName)) {
|
|
336
|
-
const localPath = path.join(targetDir, localName);
|
|
337
|
-
assertInsideRoot(targetDir, localPath,
|
|
366
|
+
const localPath = path.join(input.targetDir, localName);
|
|
367
|
+
assertInsideRoot(input.targetDir, localPath, input.label);
|
|
338
368
|
fs.rmSync(localPath, { recursive: true, force: true });
|
|
339
369
|
}
|
|
340
370
|
}
|
|
341
|
-
return targetDir;
|
|
371
|
+
return input.targetDir;
|
|
372
|
+
}
|
|
373
|
+
async function fetchSessionArtifactManifest(input) {
|
|
374
|
+
return fetchRemoteFileManifest({
|
|
375
|
+
url: artifactManifestUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId),
|
|
376
|
+
token: input.token,
|
|
377
|
+
collectionKey: "artifacts",
|
|
378
|
+
label: "artifact"
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
async function syncSessionArtifacts(input) {
|
|
382
|
+
const targetDir = sessionArtifactDir(input.artifactRoot, input.sessionId);
|
|
383
|
+
const artifacts = await fetchSessionArtifactManifest(input);
|
|
384
|
+
return syncRemoteFileSet({
|
|
385
|
+
targetDir,
|
|
386
|
+
files: artifacts,
|
|
387
|
+
label: "Artifact path",
|
|
388
|
+
download: (artifact) => downloadRemoteSyncedFile({
|
|
389
|
+
url: artifactDownloadUrl(input.baseUrl, input.projectId, input.branchName, input.sessionId, artifact.filename),
|
|
390
|
+
token: input.token,
|
|
391
|
+
targetDir,
|
|
392
|
+
file: artifact,
|
|
393
|
+
label: "artifact"
|
|
394
|
+
})
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function fetchProjectPlanManifest(input) {
|
|
398
|
+
return fetchRemoteFileManifest({
|
|
399
|
+
url: planManifestUrl(input.baseUrl, input.projectId, input.branchName),
|
|
400
|
+
token: input.token,
|
|
401
|
+
collectionKey: "plans",
|
|
402
|
+
label: "plan"
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
async function syncProjectPlans(input) {
|
|
406
|
+
const targetDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
|
|
407
|
+
const plans = await fetchProjectPlanManifest(input);
|
|
408
|
+
return syncRemoteFileSet({
|
|
409
|
+
targetDir,
|
|
410
|
+
files: plans,
|
|
411
|
+
label: "Plan path",
|
|
412
|
+
download: (plan) => downloadRemoteSyncedFile({
|
|
413
|
+
url: planDownloadUrl(input.baseUrl, input.projectId, input.branchName, plan.filename),
|
|
414
|
+
token: input.token,
|
|
415
|
+
targetDir,
|
|
416
|
+
file: plan,
|
|
417
|
+
label: "plan"
|
|
418
|
+
})
|
|
419
|
+
});
|
|
342
420
|
}
|
|
343
421
|
async function resolveArtifactEnvFilePath(input) {
|
|
344
422
|
if (!isArtifactEnvPath(input.filePath)) {
|
|
@@ -397,6 +475,19 @@ async function prepareArtifactEnvForShell(input) {
|
|
|
397
475
|
R5D_ARTIFACTS_DIR: artifactsDir
|
|
398
476
|
};
|
|
399
477
|
}
|
|
478
|
+
async function preparePlanEnvForShell(input) {
|
|
479
|
+
try {
|
|
480
|
+
await syncProjectPlans(input);
|
|
481
|
+
} catch (error) {
|
|
482
|
+
process.stderr.write(
|
|
483
|
+
`[r5d-worker] plan sync skipped for ${input.projectId}/${input.branchName}: ${error instanceof Error ? error.message : String(error)}
|
|
484
|
+
`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const plansDir = projectPlanDir(input.planRoot, input.projectId, input.branchName);
|
|
488
|
+
fs.mkdirSync(plansDir, { recursive: true });
|
|
489
|
+
return planEnv(input.planRoot, input.projectId, input.branchName, input.activePlanId);
|
|
490
|
+
}
|
|
400
491
|
async function verifyR5dctlAuth(baseUrl, token) {
|
|
401
492
|
const statusUrl = new URL("/api/r5dctl/auth/status", baseUrl);
|
|
402
493
|
let response;
|
|
@@ -441,11 +532,24 @@ function validateLabel(label) {
|
|
|
441
532
|
throw new Error("Worker label must start with a letter or number and may contain letters, numbers, dots, underscores, and hyphens");
|
|
442
533
|
}
|
|
443
534
|
}
|
|
535
|
+
function validateProjectId(projectId) {
|
|
536
|
+
if (projectId === "local" || projectId === "local-code") {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(projectId)) {
|
|
540
|
+
throw new Error(`Invalid project id from server: ${projectId}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
444
543
|
function validateBranchName(branchName) {
|
|
445
544
|
if (branchName !== "main" && !BRANCH_RE.test(branchName)) {
|
|
446
545
|
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
447
546
|
}
|
|
448
547
|
}
|
|
548
|
+
function validatePlanId(planId) {
|
|
549
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
|
|
550
|
+
throw new Error(`Invalid plan id from server: ${planId}`);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
449
553
|
function gitExtraHeaderConfigKey(extraHeaderUrl) {
|
|
450
554
|
return `http.${normalizeBaseUrl(extraHeaderUrl)}/.extraHeader`;
|
|
451
555
|
}
|
|
@@ -511,9 +615,12 @@ function getInternalStatus(context) {
|
|
|
511
615
|
function hasInternalWorktreeChanges(context) {
|
|
512
616
|
return getInternalStatus(context).trim().length > 0;
|
|
513
617
|
}
|
|
618
|
+
function isInsideBranchPath(branchPath, filePath) {
|
|
619
|
+
const relative = path.relative(path.resolve(branchPath), path.resolve(filePath));
|
|
620
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
621
|
+
}
|
|
514
622
|
function assertInsideBranch(branchPath, filePath) {
|
|
515
|
-
|
|
516
|
-
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
623
|
+
if (!isInsideBranchPath(branchPath, filePath)) {
|
|
517
624
|
throw new Error("File path escapes the project branch");
|
|
518
625
|
}
|
|
519
626
|
}
|
|
@@ -521,17 +628,27 @@ function resolveProjectFilePath(branchPath, virtualPath) {
|
|
|
521
628
|
if (typeof virtualPath !== "string" || !virtualPath.startsWith("/") || virtualPath.includes("\0")) {
|
|
522
629
|
throw new Error(`File path must be an absolute project path starting with /. Got: ${JSON.stringify(virtualPath)}`);
|
|
523
630
|
}
|
|
631
|
+
const checkoutAbsolutePath = path.resolve(virtualPath);
|
|
632
|
+
if (isInsideBranchPath(branchPath, checkoutAbsolutePath)) {
|
|
633
|
+
const checkoutVirtualPath = toVirtualPath(path.resolve(branchPath), checkoutAbsolutePath);
|
|
634
|
+
if (checkoutVirtualPath !== virtualPath) {
|
|
635
|
+
return resolveProjectFilePath(branchPath, checkoutVirtualPath);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (virtualPath.split("/").includes("..")) {
|
|
639
|
+
throw new Error(`Invalid project file path: ${virtualPath}`);
|
|
640
|
+
}
|
|
524
641
|
const normalized = path.posix.normalize(virtualPath);
|
|
525
|
-
if (normalized
|
|
642
|
+
if (normalized.startsWith("/../")) {
|
|
526
643
|
throw new Error(`Invalid project file path: ${virtualPath}`);
|
|
527
644
|
}
|
|
528
|
-
const repoRelativePath = normalized.slice(1);
|
|
645
|
+
const repoRelativePath = normalized === "/" ? "" : normalized.slice(1);
|
|
529
646
|
if (repoRelativePath === ".git" || repoRelativePath.startsWith(".git/")) {
|
|
530
647
|
throw new Error("Refusing to access .git through worker file tools");
|
|
531
648
|
}
|
|
532
|
-
const absolutePath = path.resolve(branchPath, ...repoRelativePath.split("/"));
|
|
649
|
+
const absolutePath = repoRelativePath ? path.resolve(branchPath, ...repoRelativePath.split("/")) : path.resolve(branchPath);
|
|
533
650
|
assertInsideBranch(branchPath, absolutePath);
|
|
534
|
-
return { absolutePath, virtualPath: `/${repoRelativePath}
|
|
651
|
+
return { absolutePath, virtualPath: repoRelativePath ? `/${repoRelativePath}` : "/", repoRelativePath };
|
|
535
652
|
}
|
|
536
653
|
function branchLockKey(projectId, branchName) {
|
|
537
654
|
return `${projectId}:${branchName}`;
|
|
@@ -1481,6 +1598,14 @@ async function executeCommand(input) {
|
|
|
1481
1598
|
sessionId: input.message.sessionId,
|
|
1482
1599
|
artifactRoot: input.artifactRoot
|
|
1483
1600
|
});
|
|
1601
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
1602
|
+
baseUrl: input.baseUrl,
|
|
1603
|
+
token: input.token,
|
|
1604
|
+
projectId: input.projectId,
|
|
1605
|
+
branchName: input.message.branchName,
|
|
1606
|
+
planRoot: input.planRoot,
|
|
1607
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
1608
|
+
});
|
|
1484
1609
|
const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
|
|
1485
1610
|
const subprocess = Bun.spawn(input.message.argv, {
|
|
1486
1611
|
cwd,
|
|
@@ -1491,7 +1616,8 @@ async function executeCommand(input) {
|
|
|
1491
1616
|
...containerRegistryEnv(),
|
|
1492
1617
|
...input.message.env ?? {},
|
|
1493
1618
|
...internalGitProcessEnv(workspace, input.message.env),
|
|
1494
|
-
...artifactProcessEnv
|
|
1619
|
+
...artifactProcessEnv,
|
|
1620
|
+
...planProcessEnv
|
|
1495
1621
|
}
|
|
1496
1622
|
});
|
|
1497
1623
|
activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
|
|
@@ -1562,6 +1688,14 @@ async function executeStreamingCommand(input) {
|
|
|
1562
1688
|
sessionId: input.message.sessionId,
|
|
1563
1689
|
artifactRoot: input.artifactRoot
|
|
1564
1690
|
});
|
|
1691
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
1692
|
+
baseUrl: input.baseUrl,
|
|
1693
|
+
token: input.token,
|
|
1694
|
+
projectId: input.projectId,
|
|
1695
|
+
branchName: input.message.branchName,
|
|
1696
|
+
planRoot: input.planRoot,
|
|
1697
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
1698
|
+
});
|
|
1565
1699
|
const cwd = resolveCommandCwd(workspace.branchPath, input.message.cwd);
|
|
1566
1700
|
const subprocess = Bun.spawn(input.message.argv, {
|
|
1567
1701
|
cwd,
|
|
@@ -1572,7 +1706,8 @@ async function executeStreamingCommand(input) {
|
|
|
1572
1706
|
...containerRegistryEnv(),
|
|
1573
1707
|
...input.message.env ?? {},
|
|
1574
1708
|
...internalGitProcessEnv(workspace, input.message.env),
|
|
1575
|
-
...artifactProcessEnv
|
|
1709
|
+
...artifactProcessEnv,
|
|
1710
|
+
...planProcessEnv
|
|
1576
1711
|
}
|
|
1577
1712
|
});
|
|
1578
1713
|
activeProcesses.set(input.message.runId, { process: subprocess, command: input.message.argv });
|
|
@@ -1824,7 +1959,7 @@ function resolveHostShell() {
|
|
|
1824
1959
|
}
|
|
1825
1960
|
return { file: process.env.SHELL || "/bin/bash", args: ["-l"] };
|
|
1826
1961
|
}
|
|
1827
|
-
function openPty(input) {
|
|
1962
|
+
async function openPty(input) {
|
|
1828
1963
|
try {
|
|
1829
1964
|
const branchPath = ensureBranchWorkspace({
|
|
1830
1965
|
projectId: input.projectId,
|
|
@@ -1835,6 +1970,14 @@ function openPty(input) {
|
|
|
1835
1970
|
branchName: input.message.branchName,
|
|
1836
1971
|
manifest: input.manifest
|
|
1837
1972
|
});
|
|
1973
|
+
const planProcessEnv = await preparePlanEnvForShell({
|
|
1974
|
+
baseUrl: input.baseUrl,
|
|
1975
|
+
token: input.token,
|
|
1976
|
+
projectId: input.projectId,
|
|
1977
|
+
branchName: input.message.branchName,
|
|
1978
|
+
planRoot: input.planRoot,
|
|
1979
|
+
activePlanId: input.message.env?.R5D_ACTIVE_PLAN_ID
|
|
1980
|
+
});
|
|
1838
1981
|
const shell = resolveHostShell();
|
|
1839
1982
|
const ptyProcess = createNodePtyBridge({
|
|
1840
1983
|
file: shell.file,
|
|
@@ -1848,6 +1991,7 @@ function openPty(input) {
|
|
|
1848
1991
|
...process.env,
|
|
1849
1992
|
...containerRegistryEnv(),
|
|
1850
1993
|
...input.message.env ?? {},
|
|
1994
|
+
...planProcessEnv,
|
|
1851
1995
|
R5D_PROJECT_ID: input.projectId,
|
|
1852
1996
|
R5D_BRANCH_NAME: input.message.branchName
|
|
1853
1997
|
}
|
|
@@ -1948,6 +2092,7 @@ async function startWorker(options) {
|
|
|
1948
2092
|
const projectsRoot = path.resolve(options.projectRoot ?? defaultProjectsRoot());
|
|
1949
2093
|
const syncRoot = path.resolve(options.syncRoot ?? process.env.R5D_SYNC_ROOT ?? defaultSyncRoot());
|
|
1950
2094
|
const artifactRoot = path.resolve(options.artifactRoot ?? process.env.R5D_ARTIFACTS_ROOT ?? defaultArtifactRoot());
|
|
2095
|
+
const planRoot = path.resolve(options.planRoot ?? process.env.R5D_PLANS_ROOT ?? defaultPlanRoot());
|
|
1951
2096
|
process.stdout.write(`[r5d-worker] label: ${label}
|
|
1952
2097
|
`);
|
|
1953
2098
|
process.stdout.write(`[r5d-worker] projects root: ${projectsRoot}
|
|
@@ -1955,6 +2100,8 @@ async function startWorker(options) {
|
|
|
1955
2100
|
process.stdout.write(`[r5d-worker] sync root: ${syncRoot}
|
|
1956
2101
|
`);
|
|
1957
2102
|
process.stdout.write(`[r5d-worker] artifact root: ${artifactRoot}
|
|
2103
|
+
`);
|
|
2104
|
+
process.stdout.write(`[r5d-worker] plan root: ${planRoot}
|
|
1958
2105
|
`);
|
|
1959
2106
|
process.stdout.write(`[r5d-worker] server: ${baseUrl}
|
|
1960
2107
|
`);
|
|
@@ -1963,6 +2110,7 @@ async function startWorker(options) {
|
|
|
1963
2110
|
fs.mkdirSync(projectsRoot, { recursive: true });
|
|
1964
2111
|
fs.mkdirSync(syncRoot, { recursive: true });
|
|
1965
2112
|
fs.mkdirSync(artifactRoot, { recursive: true });
|
|
2113
|
+
fs.mkdirSync(planRoot, { recursive: true });
|
|
1966
2114
|
const manifestByProjectId = /* @__PURE__ */ new Map();
|
|
1967
2115
|
const ws = new WebSocket(websocketUrl(baseUrl, label), {
|
|
1968
2116
|
headers: {
|
|
@@ -1979,7 +2127,8 @@ async function startWorker(options) {
|
|
|
1979
2127
|
pid: process.pid,
|
|
1980
2128
|
version: getWorkerVersion(),
|
|
1981
2129
|
projectRoot: projectsRoot,
|
|
1982
|
-
artifactRoot
|
|
2130
|
+
artifactRoot,
|
|
2131
|
+
planRoot
|
|
1983
2132
|
}
|
|
1984
2133
|
};
|
|
1985
2134
|
ws.send(JSON.stringify(hello));
|
|
@@ -2015,6 +2164,20 @@ async function startWorker(options) {
|
|
|
2015
2164
|
branchName,
|
|
2016
2165
|
manifest: project
|
|
2017
2166
|
});
|
|
2167
|
+
try {
|
|
2168
|
+
await syncProjectPlans({
|
|
2169
|
+
baseUrl,
|
|
2170
|
+
token,
|
|
2171
|
+
projectId: project.projectId,
|
|
2172
|
+
branchName,
|
|
2173
|
+
planRoot
|
|
2174
|
+
});
|
|
2175
|
+
} catch (error) {
|
|
2176
|
+
process.stderr.write(
|
|
2177
|
+
`[r5d-worker] plan sync skipped for ${project.projectId}/${branchName}: ${error instanceof Error ? error.message : String(error)}
|
|
2178
|
+
`
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2018
2181
|
});
|
|
2019
2182
|
} catch (error) {
|
|
2020
2183
|
process.stderr.write(
|
|
@@ -2053,7 +2216,7 @@ async function startWorker(options) {
|
|
|
2053
2216
|
const projectRoot = projectRootFor(projectsRoot, message.projectId, manifestByProjectId);
|
|
2054
2217
|
process.stdout.write(`[r5d-worker] pty ${message.ptyId}: ${message.projectId}/${message.branchName}
|
|
2055
2218
|
`);
|
|
2056
|
-
openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, manifest });
|
|
2219
|
+
await openPty({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, planRoot, manifest });
|
|
2057
2220
|
return;
|
|
2058
2221
|
}
|
|
2059
2222
|
if (message.type === "pty_input") {
|
|
@@ -2076,7 +2239,7 @@ async function startWorker(options) {
|
|
|
2076
2239
|
const result = await withBranchLock(
|
|
2077
2240
|
message.projectId,
|
|
2078
2241
|
message.branchName,
|
|
2079
|
-
() => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
|
|
2242
|
+
() => executeCommand({ message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
|
|
2080
2243
|
);
|
|
2081
2244
|
ws.send(JSON.stringify(result));
|
|
2082
2245
|
return;
|
|
@@ -2095,7 +2258,7 @@ async function startWorker(options) {
|
|
|
2095
2258
|
void withBranchLock(
|
|
2096
2259
|
message.projectId,
|
|
2097
2260
|
message.branchName,
|
|
2098
|
-
() => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, manifest })
|
|
2261
|
+
() => executeStreamingCommand({ ws, message, projectId: message.projectId, baseUrl, token, projectRoot, syncRoot, artifactRoot, planRoot, manifest })
|
|
2099
2262
|
).catch((error) => {
|
|
2100
2263
|
sendWorkerMessage(ws, {
|
|
2101
2264
|
type: "exec_start_error",
|
|
@@ -2200,5 +2363,8 @@ export {
|
|
|
2200
2363
|
ensureVisibleGitCheckout,
|
|
2201
2364
|
isArtifactEnvPath,
|
|
2202
2365
|
prepareArtifactEnvForShell,
|
|
2366
|
+
preparePlanEnvForShell,
|
|
2367
|
+
resolveProjectFilePath,
|
|
2368
|
+
syncProjectPlans,
|
|
2203
2369
|
syncSessionArtifacts
|
|
2204
2370
|
};
|
package/dist/mjs/package.json
CHANGED
package/dist/types/main.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export declare function syncSessionArtifacts(input: {
|
|
|
8
8
|
sessionId: string;
|
|
9
9
|
artifactRoot: string;
|
|
10
10
|
}): Promise<string>;
|
|
11
|
+
export declare function syncProjectPlans(input: {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
token: string;
|
|
14
|
+
projectId: string;
|
|
15
|
+
branchName: string;
|
|
16
|
+
planRoot: string;
|
|
17
|
+
}): Promise<string>;
|
|
11
18
|
export declare function prepareArtifactEnvForShell(input: {
|
|
12
19
|
baseUrl: string;
|
|
13
20
|
token: string;
|
|
@@ -16,10 +23,23 @@ export declare function prepareArtifactEnvForShell(input: {
|
|
|
16
23
|
sessionId?: string;
|
|
17
24
|
artifactRoot: string;
|
|
18
25
|
}): Promise<Record<string, string>>;
|
|
26
|
+
export declare function preparePlanEnvForShell(input: {
|
|
27
|
+
baseUrl: string;
|
|
28
|
+
token: string;
|
|
29
|
+
projectId: string;
|
|
30
|
+
branchName: string;
|
|
31
|
+
planRoot: string;
|
|
32
|
+
activePlanId?: string;
|
|
33
|
+
}): Promise<Record<string, string>>;
|
|
19
34
|
type GitAuth = {
|
|
20
35
|
extraHeaderUrl: string;
|
|
21
36
|
header: string;
|
|
22
37
|
};
|
|
38
|
+
export declare function resolveProjectFilePath(branchPath: string, virtualPath: string): {
|
|
39
|
+
absolutePath: string;
|
|
40
|
+
virtualPath: string;
|
|
41
|
+
repoRelativePath: string;
|
|
42
|
+
};
|
|
23
43
|
export declare function ensureVisibleGitCheckout(input: {
|
|
24
44
|
projectRoot: string;
|
|
25
45
|
branchName: string;
|