@ricsam/r5d-worker 0.0.37 → 0.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/cjs/main.cjs +11 -7
- package/dist/cjs/managed-paths.cjs +158 -0
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-sync.cjs +2 -1
- package/dist/mjs/main.mjs +11 -7
- package/dist/mjs/managed-paths.mjs +116 -0
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-sync.mjs +2 -1
- package/dist/types/managed-paths.d.ts +15 -0
- package/dist/types/workspace-sync.d.ts +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ r5d-worker --version
|
|
|
10
10
|
The worker uses the existing `r5dctl` config file and accepts `R5D_WORKER_TOKEN` or `R5D_API_KEY`. Project checkouts are managed under:
|
|
11
11
|
|
|
12
12
|
```text
|
|
13
|
-
~/.r5d/projects/<namespace
|
|
13
|
+
~/.r5d/projects/<namespace>/<project>/<branch-name>
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
Web shells and agent shell commands receive a server-issued `r5dctl` credential automatically. The credential is scoped to the signed-in user and revoked when the shell or command finishes, so commands such as `r5dctl auth status` do not require a separate login on the worker host.
|
|
@@ -19,6 +19,10 @@ When the user has signed in with GitHub, web shells and agent commands also rece
|
|
|
19
19
|
|
|
20
20
|
Visible branch checkouts are the user/agent workbench. Their `origin` remains the connected GitHub repository for manual Git work. r5d internal sync git metadata lives separately under `~/.r5d/sync`; when a worker connects, every idle registered branch is force-synchronized from that internal remote. The same destructive sync can be requested for a live worker from User Settings.
|
|
21
21
|
|
|
22
|
+
Branch names managed by r5d use lowercase letters, numbers, internal hyphens, and slash-separated segments, for example `ft/esm-support`. They may contain at most 45 characters. On the first start after upgrading to the nested checkout layout, the worker atomically moves each unambiguous legacy `<namespace>-<project>` directory to `<namespace>/<project>`. If both paths exist, it preserves both and stops with recovery instructions.
|
|
23
|
+
|
|
24
|
+
The nested-layout release is a breaking worker/server cutover. Run `bun run workspace:checkout-preflight` against the production database, stop and upgrade workers to the required version, deploy the matching server, and then reconnect workers. Older workers are rejected before WebSocket upgrade rather than receiving an incompatible manifest.
|
|
25
|
+
|
|
22
26
|
Worker labels are mandatory and unique per user. Choose labels that describe host capabilities, such as `macos`, `linux`, `ios`, or `ec2-build`.
|
|
23
27
|
|
|
24
28
|
`r5d-worker start` keeps a lightweight supervisor process attached to the launching terminal or service. When both r5d CLIs are updated from User Settings, the connected runtime verifies the installed versions, exits with a reload signal, and the supervisor reconnects using the new worker package. Updates only run while the worker has no active commands or shells.
|
package/dist/cjs/main.cjs
CHANGED
|
@@ -56,12 +56,12 @@ var import_git_identity = require("./git-identity.cjs");
|
|
|
56
56
|
var import_heartbeat = require("./heartbeat.cjs");
|
|
57
57
|
var import_process_tree = require("./process-tree.cjs");
|
|
58
58
|
var import_supervisor = require("./supervisor.cjs");
|
|
59
|
+
var import_managed_paths = require("./managed-paths.cjs");
|
|
59
60
|
var import_workspace_sync = require("./workspace-sync.cjs");
|
|
60
61
|
const import_meta = {};
|
|
61
62
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
62
63
|
const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
|
|
63
64
|
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
|
64
|
-
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
|
|
65
65
|
const DEFAULT_READ_LIMIT = 2e3;
|
|
66
66
|
const DEFAULT_READ_MAX_BYTES = 5e4;
|
|
67
67
|
const MAX_LINE_LENGTH = 2e3;
|
|
@@ -604,9 +604,7 @@ function validateProjectId(projectId) {
|
|
|
604
604
|
}
|
|
605
605
|
}
|
|
606
606
|
function validateBranchName(branchName) {
|
|
607
|
-
|
|
608
|
-
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
609
|
-
}
|
|
607
|
+
(0, import_managed_paths.validateManagedBranchName)(branchName);
|
|
610
608
|
}
|
|
611
609
|
function validatePlanId(planId) {
|
|
612
610
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
|
|
@@ -2088,10 +2086,13 @@ function websocketUrl(baseUrl, label) {
|
|
|
2088
2086
|
const url = new URL("/worker/ws", baseUrl);
|
|
2089
2087
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2090
2088
|
url.searchParams.set("label", label);
|
|
2089
|
+
url.searchParams.set("version", getWorkerVersion());
|
|
2091
2090
|
return url.toString();
|
|
2092
2091
|
}
|
|
2093
2092
|
function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
|
|
2094
|
-
|
|
2093
|
+
const manifest = manifestByProjectId.get(projectId);
|
|
2094
|
+
if (!manifest) throw new Error(`Project ${projectId} is missing from the worker workspace manifest`);
|
|
2095
|
+
return (0, import_managed_paths.managedProjectRoot)(projectsRoot, manifest.checkoutPathSegments);
|
|
2095
2096
|
}
|
|
2096
2097
|
async function startWorker(options) {
|
|
2097
2098
|
const config = readConfig(options.configPath);
|
|
@@ -2285,10 +2286,13 @@ async function startWorker(options) {
|
|
|
2285
2286
|
configureGitHubAuth(message.githubCredential);
|
|
2286
2287
|
visibleGitIdentity = message.gitIdentity;
|
|
2287
2288
|
workspaceRemoteUrl = message.workspaceRemoteUrl;
|
|
2289
|
+
const migratedProjectIds = (0, import_managed_paths.migrateLegacyProjectRoots)(projectsRoot, message.projects);
|
|
2288
2290
|
manifestByProjectId.clear();
|
|
2289
2291
|
for (const project of message.projects) manifestByProjectId.set(project.projectId, project);
|
|
2290
|
-
process.stdout.write(
|
|
2291
|
-
`)
|
|
2292
|
+
process.stdout.write(
|
|
2293
|
+
`[r5d-worker] workspace manifest: ${message.projects.length} projects${migratedProjectIds.length > 0 ? `; migrated ${migratedProjectIds.length} project checkout root(s)` : ""}
|
|
2294
|
+
`
|
|
2295
|
+
);
|
|
2292
2296
|
if (!periodicWorkspaceScan) {
|
|
2293
2297
|
const emptyFingerprint = (0, import_node_crypto.createHash)("sha256").update("").digest("hex");
|
|
2294
2298
|
periodicWorkspaceScan = setInterval(() => {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
var managed_paths_exports = {};
|
|
30
|
+
__export(managed_paths_exports, {
|
|
31
|
+
BRANCH_NAME_MAX_LENGTH: () => BRANCH_NAME_MAX_LENGTH,
|
|
32
|
+
BRANCH_NAME_PATTERN: () => BRANCH_NAME_PATTERN,
|
|
33
|
+
assertPathInside: () => assertPathInside,
|
|
34
|
+
legacyProjectSlug: () => legacyProjectSlug,
|
|
35
|
+
managedBranchPath: () => managedBranchPath,
|
|
36
|
+
managedProjectRoot: () => managedProjectRoot,
|
|
37
|
+
migrateLegacyProjectRoots: () => migrateLegacyProjectRoots,
|
|
38
|
+
validateCheckoutPathSegments: () => validateCheckoutPathSegments,
|
|
39
|
+
validateManagedBranchName: () => validateManagedBranchName
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(managed_paths_exports);
|
|
42
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
43
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
44
|
+
const BRANCH_NAME_MAX_LENGTH = 45;
|
|
45
|
+
const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
|
46
|
+
const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
|
|
47
|
+
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
48
|
+
function validateManagedBranchName(branchName) {
|
|
49
|
+
if (typeof branchName !== "string" || branchName.length === 0) {
|
|
50
|
+
throw new Error("Branch name is required");
|
|
51
|
+
}
|
|
52
|
+
if (branchName.length > BRANCH_NAME_MAX_LENGTH) {
|
|
53
|
+
throw new Error(`Branch name must be ${BRANCH_NAME_MAX_LENGTH} characters or fewer`);
|
|
54
|
+
}
|
|
55
|
+
if (!BRANCH_NAME_PATTERN.test(branchName)) {
|
|
56
|
+
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
57
|
+
}
|
|
58
|
+
const reserved = branchName.split("/").find((segment) => WINDOWS_RESERVED_PATH_SEGMENT.test(segment));
|
|
59
|
+
if (reserved) {
|
|
60
|
+
throw new Error(`Invalid branch name from server: reserved path segment '${reserved}'`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function validateCheckoutPathSegments(value) {
|
|
64
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
65
|
+
throw new Error("Invalid checkout path from server: expected namespace and project segments");
|
|
66
|
+
}
|
|
67
|
+
const segments = value;
|
|
68
|
+
for (const segment of segments) {
|
|
69
|
+
if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
|
|
70
|
+
throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return [segments[0], segments[1]];
|
|
74
|
+
}
|
|
75
|
+
function assertPathInside(root, candidate, label) {
|
|
76
|
+
const resolvedRoot = import_node_path.default.resolve(root);
|
|
77
|
+
const resolvedCandidate = import_node_path.default.resolve(candidate);
|
|
78
|
+
const relative = import_node_path.default.relative(resolvedRoot, resolvedCandidate);
|
|
79
|
+
if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
|
|
80
|
+
throw new Error(`${label} escapes its managed root: ${candidate}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function assertNoSymlinkComponents(root, candidate) {
|
|
84
|
+
assertPathInside(root, candidate, "Checkout path");
|
|
85
|
+
const relative = import_node_path.default.relative(import_node_path.default.resolve(root), import_node_path.default.resolve(candidate));
|
|
86
|
+
let current = import_node_path.default.resolve(root);
|
|
87
|
+
for (const component of relative.split(import_node_path.default.sep).filter(Boolean)) {
|
|
88
|
+
current = import_node_path.default.join(current, component);
|
|
89
|
+
if (!import_node_fs.default.existsSync(current)) break;
|
|
90
|
+
if (import_node_fs.default.lstatSync(current).isSymbolicLink()) {
|
|
91
|
+
throw new Error(`Checkout path contains a symbolic link: ${current}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function managedProjectRoot(projectsRoot, checkoutPathSegments) {
|
|
96
|
+
const segments = validateCheckoutPathSegments(checkoutPathSegments);
|
|
97
|
+
const result = import_node_path.default.join(projectsRoot, ...segments);
|
|
98
|
+
assertNoSymlinkComponents(projectsRoot, result);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
|
|
102
|
+
validateManagedBranchName(branchName);
|
|
103
|
+
const projectRoot = managedProjectRoot(projectsRoot, checkoutPathSegments);
|
|
104
|
+
const result = import_node_path.default.join(projectRoot, ...branchName.split("/"));
|
|
105
|
+
assertNoSymlinkComponents(projectRoot, result);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function legacyProjectSlug(projectPath, projectId) {
|
|
109
|
+
return (projectPath || projectId).trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || projectId;
|
|
110
|
+
}
|
|
111
|
+
function migrateLegacyProjectRoots(projectsRoot, projects) {
|
|
112
|
+
const resolvedRoot = import_node_path.default.resolve(projectsRoot);
|
|
113
|
+
const legacyOwners = /* @__PURE__ */ new Map();
|
|
114
|
+
const nestedOwners = /* @__PURE__ */ new Map();
|
|
115
|
+
const moves = [];
|
|
116
|
+
for (const project of projects) {
|
|
117
|
+
const nestedRoot = managedProjectRoot(resolvedRoot, project.checkoutPathSegments);
|
|
118
|
+
const legacySlug = legacyProjectSlug(project.projectPath, project.projectId);
|
|
119
|
+
const legacyRoot = import_node_path.default.join(resolvedRoot, legacySlug);
|
|
120
|
+
assertNoSymlinkComponents(resolvedRoot, legacyRoot);
|
|
121
|
+
const existingLegacyOwner = legacyOwners.get(legacyRoot);
|
|
122
|
+
if (existingLegacyOwner) {
|
|
123
|
+
throw new Error(`Projects ${existingLegacyOwner} and ${project.projectId} collide at legacy checkout path '${legacyRoot}'`);
|
|
124
|
+
}
|
|
125
|
+
legacyOwners.set(legacyRoot, project.projectId);
|
|
126
|
+
const existingNestedOwner = nestedOwners.get(nestedRoot);
|
|
127
|
+
if (existingNestedOwner) {
|
|
128
|
+
throw new Error(`Projects ${existingNestedOwner} and ${project.projectId} collide at checkout path '${nestedRoot}'`);
|
|
129
|
+
}
|
|
130
|
+
nestedOwners.set(nestedRoot, project.projectId);
|
|
131
|
+
const legacyExists = import_node_fs.default.existsSync(legacyRoot);
|
|
132
|
+
const nestedExists = import_node_fs.default.existsSync(nestedRoot);
|
|
133
|
+
if (legacyExists && nestedExists) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Checkout migration is ambiguous for ${project.projectPath}: both '${legacyRoot}' and '${nestedRoot}' exist. Preserve the desired checkout and remove or relocate the other path before reconnecting the worker.`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (legacyExists) moves.push({ projectId: project.projectId, legacyRoot, nestedRoot });
|
|
139
|
+
}
|
|
140
|
+
for (const move of moves) {
|
|
141
|
+
import_node_fs.default.mkdirSync(import_node_path.default.dirname(move.nestedRoot), { recursive: true });
|
|
142
|
+
assertNoSymlinkComponents(resolvedRoot, import_node_path.default.dirname(move.nestedRoot));
|
|
143
|
+
import_node_fs.default.renameSync(move.legacyRoot, move.nestedRoot);
|
|
144
|
+
}
|
|
145
|
+
return moves.map((move) => move.projectId);
|
|
146
|
+
}
|
|
147
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
148
|
+
0 && (module.exports = {
|
|
149
|
+
BRANCH_NAME_MAX_LENGTH,
|
|
150
|
+
BRANCH_NAME_PATTERN,
|
|
151
|
+
assertPathInside,
|
|
152
|
+
legacyProjectSlug,
|
|
153
|
+
managedBranchPath,
|
|
154
|
+
managedProjectRoot,
|
|
155
|
+
migrateLegacyProjectRoots,
|
|
156
|
+
validateCheckoutPathSegments,
|
|
157
|
+
validateManagedBranchName
|
|
158
|
+
});
|
package/dist/cjs/package.json
CHANGED
|
@@ -45,6 +45,7 @@ module.exports = __toCommonJS(workspace_sync_exports);
|
|
|
45
45
|
var import_node_crypto = require("node:crypto");
|
|
46
46
|
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
47
47
|
var import_node_path = __toESM(require("node:path"), 1);
|
|
48
|
+
var import_managed_paths = require("./managed-paths.cjs");
|
|
48
49
|
const WORKSPACE_BRANCH = "main";
|
|
49
50
|
const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
|
|
50
51
|
const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
|
|
@@ -92,7 +93,7 @@ function workspacePlansRelativePath(projectId, branchName) {
|
|
|
92
93
|
return import_node_path.default.posix.join("plans", projectId, encodeWorkspaceBranch(branchName));
|
|
93
94
|
}
|
|
94
95
|
function visibleProjectBranchPath(projectsRoot, manifest, branchName) {
|
|
95
|
-
return
|
|
96
|
+
return (0, import_managed_paths.managedBranchPath)(projectsRoot, manifest.checkoutPathSegments, branchName);
|
|
96
97
|
}
|
|
97
98
|
function localPlansBranchPath(plansRoot, projectId, branchName) {
|
|
98
99
|
return import_node_path.default.join(plansRoot, projectId, branchName);
|
package/dist/mjs/main.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import { configureVisibleGitIdentity } from "./git-identity.mjs";
|
|
|
9
9
|
import { hasWorkerHeartbeatTimedOut, WORKER_HEARTBEAT_INTERVAL_MS } from "./heartbeat.mjs";
|
|
10
10
|
import { terminateProcessTree } from "./process-tree.mjs";
|
|
11
11
|
import { superviseWorkerRuntime, WORKER_RELOAD_EXIT_CODE, WORKER_RUNTIME_ENV } from "./supervisor.mjs";
|
|
12
|
+
import { managedProjectRoot, migrateLegacyProjectRoots, validateManagedBranchName } from "./managed-paths.mjs";
|
|
12
13
|
import {
|
|
13
14
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
14
15
|
WorkspaceSyncSingleFlight,
|
|
@@ -18,7 +19,6 @@ import {
|
|
|
18
19
|
const DEFAULT_BASE_URL = "https://r5d.dev";
|
|
19
20
|
const WORKER_PACKAGE_NAME = "@ricsam/r5d-worker";
|
|
20
21
|
const LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
|
21
|
-
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/;
|
|
22
22
|
const DEFAULT_READ_LIMIT = 2e3;
|
|
23
23
|
const DEFAULT_READ_MAX_BYTES = 5e4;
|
|
24
24
|
const MAX_LINE_LENGTH = 2e3;
|
|
@@ -561,9 +561,7 @@ function validateProjectId(projectId) {
|
|
|
561
561
|
}
|
|
562
562
|
}
|
|
563
563
|
function validateBranchName(branchName) {
|
|
564
|
-
|
|
565
|
-
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
566
|
-
}
|
|
564
|
+
validateManagedBranchName(branchName);
|
|
567
565
|
}
|
|
568
566
|
function validatePlanId(planId) {
|
|
569
567
|
if (!/^[a-z0-9][a-z0-9-]*$/.test(planId)) {
|
|
@@ -2045,10 +2043,13 @@ function websocketUrl(baseUrl, label) {
|
|
|
2045
2043
|
const url = new URL("/worker/ws", baseUrl);
|
|
2046
2044
|
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2047
2045
|
url.searchParams.set("label", label);
|
|
2046
|
+
url.searchParams.set("version", getWorkerVersion());
|
|
2048
2047
|
return url.toString();
|
|
2049
2048
|
}
|
|
2050
2049
|
function projectRootFor(projectsRoot, projectId, manifestByProjectId) {
|
|
2051
|
-
|
|
2050
|
+
const manifest = manifestByProjectId.get(projectId);
|
|
2051
|
+
if (!manifest) throw new Error(`Project ${projectId} is missing from the worker workspace manifest`);
|
|
2052
|
+
return managedProjectRoot(projectsRoot, manifest.checkoutPathSegments);
|
|
2052
2053
|
}
|
|
2053
2054
|
async function startWorker(options) {
|
|
2054
2055
|
const config = readConfig(options.configPath);
|
|
@@ -2242,10 +2243,13 @@ async function startWorker(options) {
|
|
|
2242
2243
|
configureGitHubAuth(message.githubCredential);
|
|
2243
2244
|
visibleGitIdentity = message.gitIdentity;
|
|
2244
2245
|
workspaceRemoteUrl = message.workspaceRemoteUrl;
|
|
2246
|
+
const migratedProjectIds = migrateLegacyProjectRoots(projectsRoot, message.projects);
|
|
2245
2247
|
manifestByProjectId.clear();
|
|
2246
2248
|
for (const project of message.projects) manifestByProjectId.set(project.projectId, project);
|
|
2247
|
-
process.stdout.write(
|
|
2248
|
-
`)
|
|
2249
|
+
process.stdout.write(
|
|
2250
|
+
`[r5d-worker] workspace manifest: ${message.projects.length} projects${migratedProjectIds.length > 0 ? `; migrated ${migratedProjectIds.length} project checkout root(s)` : ""}
|
|
2251
|
+
`
|
|
2252
|
+
);
|
|
2249
2253
|
if (!periodicWorkspaceScan) {
|
|
2250
2254
|
const emptyFingerprint = createHash("sha256").update("").digest("hex");
|
|
2251
2255
|
periodicWorkspaceScan = setInterval(() => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const BRANCH_NAME_MAX_LENGTH = 45;
|
|
4
|
+
const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
|
5
|
+
const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
|
|
6
|
+
const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
|
7
|
+
function validateManagedBranchName(branchName) {
|
|
8
|
+
if (typeof branchName !== "string" || branchName.length === 0) {
|
|
9
|
+
throw new Error("Branch name is required");
|
|
10
|
+
}
|
|
11
|
+
if (branchName.length > BRANCH_NAME_MAX_LENGTH) {
|
|
12
|
+
throw new Error(`Branch name must be ${BRANCH_NAME_MAX_LENGTH} characters or fewer`);
|
|
13
|
+
}
|
|
14
|
+
if (!BRANCH_NAME_PATTERN.test(branchName)) {
|
|
15
|
+
throw new Error(`Invalid branch name from server: ${branchName}`);
|
|
16
|
+
}
|
|
17
|
+
const reserved = branchName.split("/").find((segment) => WINDOWS_RESERVED_PATH_SEGMENT.test(segment));
|
|
18
|
+
if (reserved) {
|
|
19
|
+
throw new Error(`Invalid branch name from server: reserved path segment '${reserved}'`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function validateCheckoutPathSegments(value) {
|
|
23
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
24
|
+
throw new Error("Invalid checkout path from server: expected namespace and project segments");
|
|
25
|
+
}
|
|
26
|
+
const segments = value;
|
|
27
|
+
for (const segment of segments) {
|
|
28
|
+
if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
|
|
29
|
+
throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return [segments[0], segments[1]];
|
|
33
|
+
}
|
|
34
|
+
function assertPathInside(root, candidate, label) {
|
|
35
|
+
const resolvedRoot = path.resolve(root);
|
|
36
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
37
|
+
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
|
38
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
39
|
+
throw new Error(`${label} escapes its managed root: ${candidate}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function assertNoSymlinkComponents(root, candidate) {
|
|
43
|
+
assertPathInside(root, candidate, "Checkout path");
|
|
44
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
45
|
+
let current = path.resolve(root);
|
|
46
|
+
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
47
|
+
current = path.join(current, component);
|
|
48
|
+
if (!fs.existsSync(current)) break;
|
|
49
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
50
|
+
throw new Error(`Checkout path contains a symbolic link: ${current}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function managedProjectRoot(projectsRoot, checkoutPathSegments) {
|
|
55
|
+
const segments = validateCheckoutPathSegments(checkoutPathSegments);
|
|
56
|
+
const result = path.join(projectsRoot, ...segments);
|
|
57
|
+
assertNoSymlinkComponents(projectsRoot, result);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
|
|
61
|
+
validateManagedBranchName(branchName);
|
|
62
|
+
const projectRoot = managedProjectRoot(projectsRoot, checkoutPathSegments);
|
|
63
|
+
const result = path.join(projectRoot, ...branchName.split("/"));
|
|
64
|
+
assertNoSymlinkComponents(projectRoot, result);
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
function legacyProjectSlug(projectPath, projectId) {
|
|
68
|
+
return (projectPath || projectId).trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || projectId;
|
|
69
|
+
}
|
|
70
|
+
function migrateLegacyProjectRoots(projectsRoot, projects) {
|
|
71
|
+
const resolvedRoot = path.resolve(projectsRoot);
|
|
72
|
+
const legacyOwners = /* @__PURE__ */ new Map();
|
|
73
|
+
const nestedOwners = /* @__PURE__ */ new Map();
|
|
74
|
+
const moves = [];
|
|
75
|
+
for (const project of projects) {
|
|
76
|
+
const nestedRoot = managedProjectRoot(resolvedRoot, project.checkoutPathSegments);
|
|
77
|
+
const legacySlug = legacyProjectSlug(project.projectPath, project.projectId);
|
|
78
|
+
const legacyRoot = path.join(resolvedRoot, legacySlug);
|
|
79
|
+
assertNoSymlinkComponents(resolvedRoot, legacyRoot);
|
|
80
|
+
const existingLegacyOwner = legacyOwners.get(legacyRoot);
|
|
81
|
+
if (existingLegacyOwner) {
|
|
82
|
+
throw new Error(`Projects ${existingLegacyOwner} and ${project.projectId} collide at legacy checkout path '${legacyRoot}'`);
|
|
83
|
+
}
|
|
84
|
+
legacyOwners.set(legacyRoot, project.projectId);
|
|
85
|
+
const existingNestedOwner = nestedOwners.get(nestedRoot);
|
|
86
|
+
if (existingNestedOwner) {
|
|
87
|
+
throw new Error(`Projects ${existingNestedOwner} and ${project.projectId} collide at checkout path '${nestedRoot}'`);
|
|
88
|
+
}
|
|
89
|
+
nestedOwners.set(nestedRoot, project.projectId);
|
|
90
|
+
const legacyExists = fs.existsSync(legacyRoot);
|
|
91
|
+
const nestedExists = fs.existsSync(nestedRoot);
|
|
92
|
+
if (legacyExists && nestedExists) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Checkout migration is ambiguous for ${project.projectPath}: both '${legacyRoot}' and '${nestedRoot}' exist. Preserve the desired checkout and remove or relocate the other path before reconnecting the worker.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (legacyExists) moves.push({ projectId: project.projectId, legacyRoot, nestedRoot });
|
|
98
|
+
}
|
|
99
|
+
for (const move of moves) {
|
|
100
|
+
fs.mkdirSync(path.dirname(move.nestedRoot), { recursive: true });
|
|
101
|
+
assertNoSymlinkComponents(resolvedRoot, path.dirname(move.nestedRoot));
|
|
102
|
+
fs.renameSync(move.legacyRoot, move.nestedRoot);
|
|
103
|
+
}
|
|
104
|
+
return moves.map((move) => move.projectId);
|
|
105
|
+
}
|
|
106
|
+
export {
|
|
107
|
+
BRANCH_NAME_MAX_LENGTH,
|
|
108
|
+
BRANCH_NAME_PATTERN,
|
|
109
|
+
assertPathInside,
|
|
110
|
+
legacyProjectSlug,
|
|
111
|
+
managedBranchPath,
|
|
112
|
+
managedProjectRoot,
|
|
113
|
+
migrateLegacyProjectRoots,
|
|
114
|
+
validateCheckoutPathSegments,
|
|
115
|
+
validateManagedBranchName
|
|
116
|
+
};
|
package/dist/mjs/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { managedBranchPath } from "./managed-paths.mjs";
|
|
4
5
|
const WORKSPACE_BRANCH = "main";
|
|
5
6
|
const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
|
|
6
7
|
const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
|
|
@@ -48,7 +49,7 @@ function workspacePlansRelativePath(projectId, branchName) {
|
|
|
48
49
|
return path.posix.join("plans", projectId, encodeWorkspaceBranch(branchName));
|
|
49
50
|
}
|
|
50
51
|
function visibleProjectBranchPath(projectsRoot, manifest, branchName) {
|
|
51
|
-
return
|
|
52
|
+
return managedBranchPath(projectsRoot, manifest.checkoutPathSegments, branchName);
|
|
52
53
|
}
|
|
53
54
|
function localPlansBranchPath(plansRoot, projectId, branchName) {
|
|
54
55
|
return path.join(plansRoot, projectId, branchName);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const BRANCH_NAME_MAX_LENGTH = 45;
|
|
2
|
+
export declare const BRANCH_NAME_PATTERN: RegExp;
|
|
3
|
+
export type CheckoutPathSegments = [namespace: string, project: string];
|
|
4
|
+
export type ManagedProjectManifestPath = {
|
|
5
|
+
projectId: string;
|
|
6
|
+
projectPath: string;
|
|
7
|
+
checkoutPathSegments: CheckoutPathSegments;
|
|
8
|
+
};
|
|
9
|
+
export declare function validateManagedBranchName(branchName: string): void;
|
|
10
|
+
export declare function validateCheckoutPathSegments(value: unknown): CheckoutPathSegments;
|
|
11
|
+
export declare function assertPathInside(root: string, candidate: string, label: string): void;
|
|
12
|
+
export declare function managedProjectRoot(projectsRoot: string, checkoutPathSegments: unknown): string;
|
|
13
|
+
export declare function managedBranchPath(projectsRoot: string, checkoutPathSegments: unknown, branchName: string): string;
|
|
14
|
+
export declare function legacyProjectSlug(projectPath: string, projectId: string): string;
|
|
15
|
+
export declare function migrateLegacyProjectRoots(projectsRoot: string, projects: ManagedProjectManifestPath[]): string[];
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { type CheckoutPathSegments } from "./managed-paths";
|
|
1
2
|
export declare const WORKSPACE_BRANCH = "main";
|
|
2
3
|
export declare const MAX_WORKSPACE_SYNC_DIFF_BYTES: number;
|
|
3
4
|
export declare const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5000;
|
|
4
5
|
export type WorkspaceProjectManifestEntry = {
|
|
5
6
|
projectId: string;
|
|
6
|
-
|
|
7
|
+
checkoutPathSegments: CheckoutPathSegments;
|
|
7
8
|
projectPath: string;
|
|
8
9
|
repoHttpUrl: string | null;
|
|
9
10
|
repoAuthHeader: string | null;
|