@ricsam/r5d-worker 0.0.121 → 0.0.123
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 +6 -3
- package/dist/cjs/command-launcher.cjs +76 -2
- package/dist/cjs/control-command-policy.cjs +177 -0
- package/dist/cjs/main.cjs +75 -10
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-checkout-garbage.cjs +190 -0
- package/dist/cjs/project-worktrees.cjs +9 -2
- package/dist/cjs/recovery-store.cjs +13 -0
- package/dist/mjs/command-launcher.mjs +75 -2
- package/dist/mjs/control-command-policy.mjs +146 -0
- package/dist/mjs/main.mjs +80 -10
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-checkout-garbage.mjs +150 -0
- package/dist/mjs/project-worktrees.mjs +9 -2
- package/dist/mjs/recovery-store.mjs +13 -0
- package/dist/types/command-launcher.d.ts +42 -0
- package/dist/types/control-command-policy.d.ts +53 -0
- package/dist/types/main.d.ts +7 -0
- package/dist/types/project-checkout-garbage.d.ts +69 -0
- package/dist/types/project-worktrees.d.ts +6 -0
- package/dist/types/recovery-store.d.ts +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,190 @@
|
|
|
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 project_checkout_garbage_exports = {};
|
|
30
|
+
__export(project_checkout_garbage_exports, {
|
|
31
|
+
PROJECT_DELETED_CHECKOUTS_DIRECTORY: () => PROJECT_DELETED_CHECKOUTS_DIRECTORY,
|
|
32
|
+
ProjectCheckoutGarbageCollector: () => ProjectCheckoutGarbageCollector,
|
|
33
|
+
discoverStagedProjectCheckouts: () => discoverStagedProjectCheckouts,
|
|
34
|
+
isStagedProjectCheckoutPath: () => isStagedProjectCheckoutPath,
|
|
35
|
+
ownedStagedPath: () => ownedStagedPath,
|
|
36
|
+
removeStagedProjectCheckout: () => removeStagedProjectCheckout,
|
|
37
|
+
stageProjectCheckoutForDeletion: () => stageProjectCheckoutForDeletion
|
|
38
|
+
});
|
|
39
|
+
module.exports = __toCommonJS(project_checkout_garbage_exports);
|
|
40
|
+
var import_node_crypto = require("node:crypto");
|
|
41
|
+
var import_node_fs = __toESM(require("node:fs"), 1);
|
|
42
|
+
var import_node_path = __toESM(require("node:path"), 1);
|
|
43
|
+
const PROJECT_DELETED_CHECKOUTS_DIRECTORY = ".r5d-deleted";
|
|
44
|
+
function isRealDirectory(candidate) {
|
|
45
|
+
try {
|
|
46
|
+
return import_node_fs.default.lstatSync(candidate).isDirectory();
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function stageProjectCheckoutForDeletion(projectRoot, branchName, checkoutPath) {
|
|
52
|
+
const garbageRoot = import_node_path.default.join(projectRoot, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
53
|
+
if (!isRealDirectory(checkoutPath)) throw new Error(`Cannot stage ${checkoutPath} for deletion: not a directory`);
|
|
54
|
+
import_node_fs.default.mkdirSync(garbageRoot, { recursive: true });
|
|
55
|
+
if (!isRealDirectory(garbageRoot)) throw new Error(`Cannot stage ${checkoutPath} for deletion: ${garbageRoot} is not a directory`);
|
|
56
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
57
|
+
const target = import_node_path.default.join(garbageRoot, `${branchName.replace(/\//g, "__")}-${timestamp}-${(0, import_node_crypto.randomUUID)().slice(0, 8)}`);
|
|
58
|
+
import_node_fs.default.renameSync(checkoutPath, target);
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
function isStagedProjectCheckoutPath(projectsRoot, candidate) {
|
|
62
|
+
const relative = import_node_path.default.relative(import_node_path.default.resolve(projectsRoot), import_node_path.default.resolve(candidate));
|
|
63
|
+
if (!relative || relative.startsWith("..") || import_node_path.default.isAbsolute(relative)) return false;
|
|
64
|
+
const segments = relative.split(import_node_path.default.sep);
|
|
65
|
+
return segments.length === 4 && segments[2] === PROJECT_DELETED_CHECKOUTS_DIRECTORY && segments[3] !== "" && !segments[3].startsWith(".");
|
|
66
|
+
}
|
|
67
|
+
function ownedStagedPath(projectsRoot, candidate) {
|
|
68
|
+
const root = import_node_path.default.resolve(projectsRoot);
|
|
69
|
+
const resolved = import_node_path.default.resolve(candidate);
|
|
70
|
+
if (!isStagedProjectCheckoutPath(root, resolved)) return null;
|
|
71
|
+
let current = root;
|
|
72
|
+
for (const segment of import_node_path.default.relative(root, import_node_path.default.dirname(resolved)).split(import_node_path.default.sep)) {
|
|
73
|
+
current = import_node_path.default.join(current, segment);
|
|
74
|
+
if (!isRealDirectory(current)) return null;
|
|
75
|
+
}
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = import_node_fs.default.lstatSync(resolved);
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
if (stat.isSymbolicLink()) return { kind: "link" };
|
|
83
|
+
return stat.isDirectory() ? { kind: "directory" } : null;
|
|
84
|
+
}
|
|
85
|
+
function realDirectoryEntries(directory) {
|
|
86
|
+
if (!isRealDirectory(directory)) return [];
|
|
87
|
+
try {
|
|
88
|
+
return import_node_fs.default.readdirSync(directory, { withFileTypes: true }).map((entry) => entry.name).sort();
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function discoverStagedProjectCheckouts(projectsRoot) {
|
|
94
|
+
const root = import_node_path.default.resolve(projectsRoot);
|
|
95
|
+
const staged = [];
|
|
96
|
+
for (const namespace of realDirectoryEntries(root)) {
|
|
97
|
+
if (namespace.startsWith(".")) continue;
|
|
98
|
+
for (const project of realDirectoryEntries(import_node_path.default.join(root, namespace))) {
|
|
99
|
+
if (project.startsWith(".")) continue;
|
|
100
|
+
const garbageRoot = import_node_path.default.join(root, namespace, project, PROJECT_DELETED_CHECKOUTS_DIRECTORY);
|
|
101
|
+
for (const entry of realDirectoryEntries(garbageRoot)) {
|
|
102
|
+
const candidate = import_node_path.default.join(garbageRoot, entry);
|
|
103
|
+
if (ownedStagedPath(root, candidate)) staged.push(candidate);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return staged;
|
|
108
|
+
}
|
|
109
|
+
async function removeStagedProjectCheckout(stagedPath) {
|
|
110
|
+
if (import_node_fs.default.lstatSync(stagedPath).isSymbolicLink()) {
|
|
111
|
+
import_node_fs.default.unlinkSync(stagedPath);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (process.platform === "win32") {
|
|
115
|
+
await import_node_fs.default.promises.rm(stagedPath, { recursive: true, force: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const command = ["rm", "-rf", "--", stagedPath];
|
|
119
|
+
if (Bun.which("nice")) command.unshift("nice", "-n", "19");
|
|
120
|
+
if (process.platform === "linux" && Bun.which("ionice")) command.unshift("ionice", "-c", "3");
|
|
121
|
+
const child = Bun.spawn(command, { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
122
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
123
|
+
if (exitCode !== 0) throw new Error(`rm exited ${exitCode}: ${stderr.trim()}`);
|
|
124
|
+
if (import_node_fs.default.existsSync(stagedPath)) throw new Error("staged checkout still exists after removal");
|
|
125
|
+
}
|
|
126
|
+
class ProjectCheckoutGarbageCollector {
|
|
127
|
+
projectsRoot;
|
|
128
|
+
remove;
|
|
129
|
+
log;
|
|
130
|
+
queued = /* @__PURE__ */ new Set();
|
|
131
|
+
tail = Promise.resolve();
|
|
132
|
+
collectedCount = 0;
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.projectsRoot = import_node_path.default.resolve(options.projectsRoot);
|
|
135
|
+
this.remove = options.remove ?? removeStagedProjectCheckout;
|
|
136
|
+
this.log = options.log ?? ((message) => process.stderr.write(`${message}
|
|
137
|
+
`));
|
|
138
|
+
}
|
|
139
|
+
/** Number of removals that have completed successfully. */
|
|
140
|
+
get collected() {
|
|
141
|
+
return this.collectedCount;
|
|
142
|
+
}
|
|
143
|
+
/** Resolves once everything queued so far has been attempted. */
|
|
144
|
+
get idle() {
|
|
145
|
+
return this.tail;
|
|
146
|
+
}
|
|
147
|
+
enqueue(stagedPath) {
|
|
148
|
+
const resolved = import_node_path.default.resolve(stagedPath);
|
|
149
|
+
if (!ownedStagedPath(this.projectsRoot, resolved)) {
|
|
150
|
+
throw new Error(`Refusing to collect ${stagedPath}: not a staged checkout under ${this.projectsRoot}`);
|
|
151
|
+
}
|
|
152
|
+
if (this.queued.has(resolved)) return;
|
|
153
|
+
this.queued.add(resolved);
|
|
154
|
+
this.tail = this.tail.then(async () => {
|
|
155
|
+
try {
|
|
156
|
+
const owned = ownedStagedPath(this.projectsRoot, resolved);
|
|
157
|
+
if (owned) {
|
|
158
|
+
const startedAt = performance.now();
|
|
159
|
+
await this.remove(resolved);
|
|
160
|
+
this.collectedCount += 1;
|
|
161
|
+
this.log(`[r5d-worker] collected deleted checkout ${resolved} in ${Math.round(performance.now() - startedAt)}ms`);
|
|
162
|
+
} else if (import_node_fs.default.existsSync(resolved) || isRealDirectory(import_node_path.default.dirname(resolved))) {
|
|
163
|
+
this.log(`[r5d-worker] skipped deleted checkout ${resolved}: no longer an owned staged checkout`);
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
this.log(
|
|
167
|
+
`[r5d-worker] deleted checkout ${resolved} could not be collected yet: ${error instanceof Error ? error.message : String(error)}`
|
|
168
|
+
);
|
|
169
|
+
} finally {
|
|
170
|
+
this.queued.delete(resolved);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Enqueue every staged checkout left behind by an earlier process. */
|
|
175
|
+
sweep() {
|
|
176
|
+
const staged = discoverStagedProjectCheckouts(this.projectsRoot);
|
|
177
|
+
for (const stagedPath of staged) this.enqueue(stagedPath);
|
|
178
|
+
return staged.length;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
182
|
+
0 && (module.exports = {
|
|
183
|
+
PROJECT_DELETED_CHECKOUTS_DIRECTORY,
|
|
184
|
+
ProjectCheckoutGarbageCollector,
|
|
185
|
+
discoverStagedProjectCheckouts,
|
|
186
|
+
isStagedProjectCheckoutPath,
|
|
187
|
+
ownedStagedPath,
|
|
188
|
+
removeStagedProjectCheckout,
|
|
189
|
+
stageProjectCheckoutForDeletion
|
|
190
|
+
});
|
|
@@ -61,6 +61,7 @@ var import_node_os = __toESM(require("node:os"), 1);
|
|
|
61
61
|
var import_node_path = __toESM(require("node:path"), 1);
|
|
62
62
|
var import_git_process_environment = require("./git-process-environment.cjs");
|
|
63
63
|
var import_managed_paths = require("./managed-paths.cjs");
|
|
64
|
+
var import_project_checkout_garbage = require("./project-checkout-garbage.cjs");
|
|
64
65
|
var import_project_mirror_refs_token = require("./project-mirror-refs-token.cjs");
|
|
65
66
|
var import_working_tree_mirror = require("./working-tree-mirror.cjs");
|
|
66
67
|
const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
|
|
@@ -1340,6 +1341,7 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1340
1341
|
const primaryCommonDir = commonGitDirectory(primaryPath);
|
|
1341
1342
|
if (!primaryCommonDir) throw new Error("Primary project checkout is unavailable");
|
|
1342
1343
|
let movedAsidePath;
|
|
1344
|
+
let stagedForCollectionPath;
|
|
1343
1345
|
if (import_node_fs.default.existsSync(checkoutPath)) {
|
|
1344
1346
|
const checkoutCommonDir = import_node_fs.default.lstatSync(checkoutPath).isDirectory() ? commonGitDirectory(checkoutPath) : null;
|
|
1345
1347
|
if (checkoutCommonDir !== primaryCommonDir) {
|
|
@@ -1349,7 +1351,8 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1349
1351
|
if (projectWorktreeOperationInProgress(checkoutPath)) {
|
|
1350
1352
|
throw new Error(`Project branch ${input.branchName} has an in-progress Git operation`);
|
|
1351
1353
|
}
|
|
1352
|
-
|
|
1354
|
+
stagedForCollectionPath = (0, import_project_checkout_garbage.stageProjectCheckoutForDeletion)(input.projectRoot, input.branchName, checkoutPath);
|
|
1355
|
+
git(primaryPath, ["worktree", "prune"], `prune linked worktree ${input.branchName}`);
|
|
1353
1356
|
}
|
|
1354
1357
|
} else {
|
|
1355
1358
|
tryGit(primaryPath, ["worktree", "prune"]);
|
|
@@ -1358,7 +1361,11 @@ function deleteLinkedProjectBranch(input) {
|
|
|
1358
1361
|
if (tryGit(primaryPath, ["show-ref", "--verify", "--quiet", branchRef])) {
|
|
1359
1362
|
git(primaryPath, ["branch", "-D", input.branchName], `delete project branch ${input.branchName}`);
|
|
1360
1363
|
}
|
|
1361
|
-
return {
|
|
1364
|
+
return {
|
|
1365
|
+
branchName: input.branchName,
|
|
1366
|
+
...movedAsidePath ? { movedAsidePath } : {},
|
|
1367
|
+
...stagedForCollectionPath ? { stagedForCollectionPath } : {}
|
|
1368
|
+
};
|
|
1362
1369
|
}
|
|
1363
1370
|
function removeProjectWorktrees(input) {
|
|
1364
1371
|
const projectRoot = import_node_path.default.resolve(input.projectRoot);
|
|
@@ -98,6 +98,19 @@ class WorkerRecoveryStore {
|
|
|
98
98
|
isUnknown(requestId) {
|
|
99
99
|
return this.row(requestId)?.state === "unknown";
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Re-admit an operation whose crash outcome is unknown so it can run again
|
|
103
|
+
* and record a result. Only a branch deletion qualifies: it is idempotent
|
|
104
|
+
* for its exact incarnation (durable tombstone plus incarnation preflight),
|
|
105
|
+
* so a rerun converges on the same outcome. Every other unknown operation
|
|
106
|
+
* keeps the no-replay invariant.
|
|
107
|
+
*/
|
|
108
|
+
readmitUnknownBranchDeletion(requestId) {
|
|
109
|
+
const row = this.row(requestId);
|
|
110
|
+
if (!row || row.state !== "unknown" || row.request_type !== "delete_project_branch") return false;
|
|
111
|
+
this.db.run("UPDATE operations SET state='accepted', response=NULL WHERE request_id=?", [row.request_id]);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
101
114
|
unknown(requestId) {
|
|
102
115
|
const row = this.row(requestId);
|
|
103
116
|
if (row) this.db.run("UPDATE operations SET state='unknown' WHERE request_id=?", [row.request_id]);
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
function commandLaunchLane(request) {
|
|
3
|
+
if (request.commandClass === "control") {
|
|
4
|
+
if (request.kind !== "exec_start") throw new Error("Control commands must be exec_start launches");
|
|
5
|
+
return "control";
|
|
6
|
+
}
|
|
7
|
+
return request.kind === "exec" && request.workspaceEffect === "none" ? "utility" : "general";
|
|
8
|
+
}
|
|
2
9
|
class CommandLauncherUnavailableError extends Error {
|
|
3
10
|
constructor(message, cause) {
|
|
4
11
|
super(message, { cause });
|
|
@@ -86,12 +93,14 @@ class WorkerCommandLauncher {
|
|
|
86
93
|
initialized = false;
|
|
87
94
|
closePromise;
|
|
88
95
|
sequence = 0;
|
|
96
|
+
capacity;
|
|
89
97
|
async initialize() {
|
|
90
98
|
this.assertHealthy();
|
|
91
99
|
if (this.initialized) return;
|
|
92
100
|
if (this.child) {
|
|
93
101
|
try {
|
|
94
|
-
await this.rpc("initialize");
|
|
102
|
+
const result = await this.rpc("initialize");
|
|
103
|
+
this.capacity = parseCapacity(result.capacity);
|
|
95
104
|
} catch (error) {
|
|
96
105
|
this.fail(error instanceof Error ? error : new Error(String(error)));
|
|
97
106
|
await this.close();
|
|
@@ -103,19 +112,63 @@ class WorkerCommandLauncher {
|
|
|
103
112
|
get activeIds() {
|
|
104
113
|
return [...this.launches.keys()];
|
|
105
114
|
}
|
|
115
|
+
/** The lane totals the launcher declared at initialization; undefined for a direct worker or an older launcher. */
|
|
116
|
+
get declaredCapacity() {
|
|
117
|
+
return this.capacity;
|
|
118
|
+
}
|
|
119
|
+
get supportsControlLane() {
|
|
120
|
+
return this.initialized && (this.mode === "direct" || (this.capacity?.controlSlots ?? 0) > 0);
|
|
121
|
+
}
|
|
122
|
+
/** Current admissions per lane, for the worker's capacity report. */
|
|
123
|
+
capacityReport() {
|
|
124
|
+
const bounded = this.child !== void 0;
|
|
125
|
+
const lanes = {
|
|
126
|
+
general: { slots: bounded ? this.capacity?.generalSlots ?? null : null, active: [], queued: [] },
|
|
127
|
+
utility: { slots: bounded ? this.capacity?.utilitySlots ?? null : null, active: [], queued: [] },
|
|
128
|
+
control: { slots: bounded ? this.capacity?.controlSlots ?? null : null, active: [], queued: [] }
|
|
129
|
+
};
|
|
130
|
+
for (const record of this.launches.values()) {
|
|
131
|
+
if (record.released) continue;
|
|
132
|
+
const entry = {
|
|
133
|
+
id: record.request.id,
|
|
134
|
+
...record.request.sessionId ? { sessionId: record.request.sessionId } : {},
|
|
135
|
+
since: record.since
|
|
136
|
+
};
|
|
137
|
+
(record.phase === "active" ? lanes[record.lane].active : lanes[record.lane].queued).push(entry);
|
|
138
|
+
}
|
|
139
|
+
return { bounded, controlRuntimeMs: bounded ? this.capacity?.controlRuntimeMs ?? null : null, lanes };
|
|
140
|
+
}
|
|
141
|
+
notifyCapacityChange() {
|
|
142
|
+
try {
|
|
143
|
+
this.options.onCapacityChange?.();
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
}
|
|
106
147
|
async acquire(request) {
|
|
107
148
|
this.assertHealthy();
|
|
108
149
|
if (!this.initialized) throw new Error("Command launcher is not initialized");
|
|
109
150
|
request.assertAdmission();
|
|
110
151
|
if (this.launches.has(request.id)) throw new Error(`Duplicate command launch identity: ${request.id}`);
|
|
152
|
+
const lane = commandLaunchLane(request);
|
|
153
|
+
if (lane === "control" && !this.supportsControlLane)
|
|
154
|
+
throw new Error("Update the worker command launcher before running control commands");
|
|
111
155
|
let rejectAdmission;
|
|
112
156
|
const cancellation = new Promise((_resolve, reject) => {
|
|
113
157
|
rejectAdmission = reject;
|
|
114
158
|
});
|
|
115
159
|
void cancellation.catch(() => {
|
|
116
160
|
});
|
|
117
|
-
const record = {
|
|
161
|
+
const record = {
|
|
162
|
+
request,
|
|
163
|
+
lane,
|
|
164
|
+
phase: this.child ? "queued" : "active",
|
|
165
|
+
since: (/* @__PURE__ */ new Date()).toISOString(),
|
|
166
|
+
revoked: false,
|
|
167
|
+
released: false,
|
|
168
|
+
rejectAdmission
|
|
169
|
+
};
|
|
118
170
|
this.launches.set(request.id, record);
|
|
171
|
+
this.notifyCapacityChange();
|
|
119
172
|
let prefix = [];
|
|
120
173
|
let admissionTimer;
|
|
121
174
|
try {
|
|
@@ -141,6 +194,9 @@ class WorkerCommandLauncher {
|
|
|
141
194
|
}
|
|
142
195
|
}, 100);
|
|
143
196
|
prefix = await Promise.race([acquisition, cancellation]);
|
|
197
|
+
record.phase = "active";
|
|
198
|
+
record.since = (/* @__PURE__ */ new Date()).toISOString();
|
|
199
|
+
this.notifyCapacityChange();
|
|
144
200
|
}
|
|
145
201
|
request.assertAdmission();
|
|
146
202
|
this.assertHealthy();
|
|
@@ -240,6 +296,7 @@ class WorkerCommandLauncher {
|
|
|
240
296
|
}
|
|
241
297
|
record.released = true;
|
|
242
298
|
this.launches.delete(record.request.id);
|
|
299
|
+
this.notifyCapacityChange();
|
|
243
300
|
})();
|
|
244
301
|
}
|
|
245
302
|
assertHealthy() {
|
|
@@ -301,6 +358,21 @@ class WorkerCommandLauncher {
|
|
|
301
358
|
return this.failure;
|
|
302
359
|
}
|
|
303
360
|
}
|
|
361
|
+
function parseCapacity(value) {
|
|
362
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
363
|
+
const candidate = value;
|
|
364
|
+
const numbers = ["generalSlots", "utilitySlots", "controlSlots", "controlRuntimeMs", "queueTimeoutMs"];
|
|
365
|
+
if (!numbers.every((key) => typeof candidate[key] === "number" && Number.isSafeInteger(candidate[key]) && candidate[key] >= 0)) {
|
|
366
|
+
throw new Error("Command launcher initialize returned an invalid capacity");
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
generalSlots: candidate.generalSlots,
|
|
370
|
+
utilitySlots: candidate.utilitySlots,
|
|
371
|
+
controlSlots: candidate.controlSlots,
|
|
372
|
+
controlRuntimeMs: candidate.controlRuntimeMs,
|
|
373
|
+
queueTimeoutMs: candidate.queueTimeoutMs
|
|
374
|
+
};
|
|
375
|
+
}
|
|
304
376
|
async function createWorkerCommandLauncher(options = {}) {
|
|
305
377
|
const launcher = new WorkerCommandLauncher({ ...options, argv: parseCommandLauncher(options.configuration) });
|
|
306
378
|
await launcher.initialize();
|
|
@@ -310,6 +382,7 @@ export {
|
|
|
310
382
|
CommandLaunchCancelledError,
|
|
311
383
|
CommandLauncherUnavailableError,
|
|
312
384
|
WorkerCommandLauncher,
|
|
385
|
+
commandLaunchLane,
|
|
313
386
|
commandLauncherEnvironment,
|
|
314
387
|
createWorkerCommandLauncher,
|
|
315
388
|
parseCommandLauncher
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
const CONTROL_COMMAND_PROGRAM = "r5dctl";
|
|
2
|
+
const CONTROL_COMMAND_RUNTIME_MS = 12e4;
|
|
3
|
+
const CONTROL_COMMAND_MAX_ARGV = 64;
|
|
4
|
+
const CONTROL_COMMAND_MAX_LENGTH = 4096;
|
|
5
|
+
const CONTROL_GLOBAL_FLAGS_WITH_VALUE = /* @__PURE__ */ new Set(["-p", "--project", "-s", "--session", "-b", "--branch"]);
|
|
6
|
+
const CONTROL_GLOBAL_FLAGS = /* @__PURE__ */ new Set(["--json", "-h", "--help", "-v", "--version"]);
|
|
7
|
+
const REFUSED_ANYWHERE = /* @__PURE__ */ new Set(["-f", "--follow", "--watch", "--base-url", "--config", "--token", "--api-key"]);
|
|
8
|
+
const CONTROL_VERBS = {
|
|
9
|
+
session: /* @__PURE__ */ new Set(["status", "stop", "prompt", "claim", "claims", "process-log"]),
|
|
10
|
+
sessions: /* @__PURE__ */ new Set(["recent"]),
|
|
11
|
+
describe: /* @__PURE__ */ new Set(["project", "branch", "session", "branch-operation", "branch-deletion"]),
|
|
12
|
+
get: /* @__PURE__ */ new Set(["projects", "branches", "sessions", "envs", "env"]),
|
|
13
|
+
conversation: /* @__PURE__ */ new Set(["overview", "inspect-node", "inspect-work"]),
|
|
14
|
+
ps: /* @__PURE__ */ new Set(["list", "history", "inspect", "stop"]),
|
|
15
|
+
workspace: /* @__PURE__ */ new Set(["status"]),
|
|
16
|
+
auth: /* @__PURE__ */ new Set(["status"]),
|
|
17
|
+
create: /* @__PURE__ */ new Set(["session"]),
|
|
18
|
+
update: /* @__PURE__ */ new Set(["session"]),
|
|
19
|
+
delete: /* @__PURE__ */ new Set(["session"]),
|
|
20
|
+
"answer-questions": null,
|
|
21
|
+
"answer-env-request": null
|
|
22
|
+
};
|
|
23
|
+
const CODE_LOADING_ENV_PATTERN = /^(NODE_OPTIONS|NODE_PATH|NODE_REPL_EXTERNAL_MODULE|BUN_OPTIONS|BUN_CONFIG_[A-Z_]+|LD_PRELOAD|LD_LIBRARY_PATH|LD_AUDIT|DYLD_[A-Z_]+|PATH|PATHEXT|PYTHONPATH|PERL5OPT|RUBYOPT|SHELL|ENV|BASH_ENV)$/i;
|
|
24
|
+
const BARE_WORD = /^[A-Za-z0-9_@%+=:,./-]+$/;
|
|
25
|
+
const DOUBLE_QUOTED_FORBIDDEN = /[$`\\!]/;
|
|
26
|
+
function tokenizePlainInvocation(command) {
|
|
27
|
+
if (command.length > CONTROL_COMMAND_MAX_LENGTH) return { reason: "command is longer than the control lane allows" };
|
|
28
|
+
if (/[\r\n]/.test(command)) return { reason: "multi-line commands are shell scripts" };
|
|
29
|
+
const argv = [];
|
|
30
|
+
let index = 0;
|
|
31
|
+
while (index < command.length) {
|
|
32
|
+
const char = command[index];
|
|
33
|
+
if (char === " " || char === " ") {
|
|
34
|
+
index += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
let word = "";
|
|
38
|
+
let quotedParts = 0;
|
|
39
|
+
let bareParts = 0;
|
|
40
|
+
while (index < command.length && command[index] !== " " && command[index] !== " ") {
|
|
41
|
+
const current = command[index];
|
|
42
|
+
if (current === "'") {
|
|
43
|
+
const end = command.indexOf("'", index + 1);
|
|
44
|
+
if (end < 0) return { reason: "unterminated single quote" };
|
|
45
|
+
word += command.slice(index + 1, end);
|
|
46
|
+
quotedParts += 1;
|
|
47
|
+
index = end + 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (current === '"') {
|
|
51
|
+
const end = command.indexOf('"', index + 1);
|
|
52
|
+
if (end < 0) return { reason: "unterminated double quote" };
|
|
53
|
+
const inner = command.slice(index + 1, end);
|
|
54
|
+
if (DOUBLE_QUOTED_FORBIDDEN.test(inner)) return { reason: "double-quoted text would be expanded by the shell" };
|
|
55
|
+
word += inner;
|
|
56
|
+
quotedParts += 1;
|
|
57
|
+
index = end + 1;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let bare = "";
|
|
61
|
+
while (index < command.length && !/[\s'"]/.test(command[index])) {
|
|
62
|
+
bare += command[index];
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
if (!BARE_WORD.test(bare)) return { reason: `shell syntax in "${bare}" is not part of a plain invocation` };
|
|
66
|
+
word += bare;
|
|
67
|
+
bareParts += 1;
|
|
68
|
+
}
|
|
69
|
+
if (quotedParts + bareParts > 1) return { reason: "adjacent quoted and bare text is shell concatenation" };
|
|
70
|
+
argv.push(word);
|
|
71
|
+
if (argv.length > CONTROL_COMMAND_MAX_ARGV) return { reason: "too many arguments for a control command" };
|
|
72
|
+
}
|
|
73
|
+
if (argv.length === 0) return { reason: "empty command" };
|
|
74
|
+
return { argv };
|
|
75
|
+
}
|
|
76
|
+
function assertControlCommandArgv(argv) {
|
|
77
|
+
if (argv.length === 0 || argv.length > CONTROL_COMMAND_MAX_ARGV) throw new Error("Control commands are a single r5dctl invocation");
|
|
78
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) throw new Error(`Control commands must start with ${CONTROL_COMMAND_PROGRAM}`);
|
|
79
|
+
if (argv.reduce((length, argument) => length + (typeof argument === "string" ? argument.length : 0) + 1, 0) > CONTROL_COMMAND_MAX_LENGTH)
|
|
80
|
+
throw new Error("Control command arguments exceed the length limit");
|
|
81
|
+
for (const argument of argv) {
|
|
82
|
+
if (typeof argument !== "string" || argument.includes("\0") || /[\r\n]/.test(argument)) throw new Error("Control command arguments must be plain text");
|
|
83
|
+
if (REFUSED_ANYWHERE.has(argument.split("=", 1)[0])) throw new Error(`Control command option is not allowed (${argument.split("=", 1)[0]})`);
|
|
84
|
+
if (argument === "--") throw new Error("Control commands cannot use `--`");
|
|
85
|
+
}
|
|
86
|
+
let position = 1;
|
|
87
|
+
while (position < argv.length) {
|
|
88
|
+
const argument = argv[position];
|
|
89
|
+
if (CONTROL_GLOBAL_FLAGS.has(argument)) {
|
|
90
|
+
position += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (CONTROL_GLOBAL_FLAGS_WITH_VALUE.has(argument)) {
|
|
94
|
+
if (position + 1 >= argv.length) throw new Error(`Missing value for ${argument}`);
|
|
95
|
+
position += 2;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (argument.startsWith("-")) throw new Error(`Global flag ${argument} is not allowed in a control command`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
const verb = argv[position];
|
|
102
|
+
if (verb === void 0) throw new Error("Control commands need a coordination verb (for example `r5dctl ps list`)");
|
|
103
|
+
const allowed = Object.hasOwn(CONTROL_VERBS, verb) ? CONTROL_VERBS[verb] : void 0;
|
|
104
|
+
if (allowed === void 0) throw new Error(`r5dctl ${verb} is not a coordination command`);
|
|
105
|
+
if (allowed === null) return;
|
|
106
|
+
const subject = argv[position + 1];
|
|
107
|
+
if (subject === void 0 || !allowed.has(subject)) {
|
|
108
|
+
throw new Error(`r5dctl ${verb}${subject ? ` ${subject}` : ""} is not a coordination command`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function resolveControlCommandExecutable(trustedPath, which, program = CONTROL_COMMAND_PROGRAM) {
|
|
112
|
+
const resolved = trustedPath ? which(program, { PATH: trustedPath }) : null;
|
|
113
|
+
if (!resolved) throw new Error(`${program} is not installed on the worker's trusted PATH; control commands are unavailable until it is`);
|
|
114
|
+
return resolved;
|
|
115
|
+
}
|
|
116
|
+
function controlCommandEnvironment(environment, trustedPath) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const [key, value] of Object.entries(environment)) {
|
|
119
|
+
if (value === void 0 || CODE_LOADING_ENV_PATTERN.test(key)) continue;
|
|
120
|
+
result[key] = value;
|
|
121
|
+
}
|
|
122
|
+
if (trustedPath !== void 0) result.PATH = trustedPath;
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
function classifyControlCommand(command) {
|
|
126
|
+
const tokenized = tokenizePlainInvocation(command.trim());
|
|
127
|
+
if ("reason" in tokenized) return { control: false, reason: tokenized.reason };
|
|
128
|
+
const { argv } = tokenized;
|
|
129
|
+
if (argv[0] !== CONTROL_COMMAND_PROGRAM) return { control: false, reason: "not an r5dctl invocation" };
|
|
130
|
+
try {
|
|
131
|
+
assertControlCommandArgv(argv);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return { control: false, reason: error instanceof Error ? error.message : String(error) };
|
|
134
|
+
}
|
|
135
|
+
return { control: true, argv };
|
|
136
|
+
}
|
|
137
|
+
export {
|
|
138
|
+
CONTROL_COMMAND_MAX_ARGV,
|
|
139
|
+
CONTROL_COMMAND_MAX_LENGTH,
|
|
140
|
+
CONTROL_COMMAND_PROGRAM,
|
|
141
|
+
CONTROL_COMMAND_RUNTIME_MS,
|
|
142
|
+
assertControlCommandArgv,
|
|
143
|
+
classifyControlCommand,
|
|
144
|
+
controlCommandEnvironment,
|
|
145
|
+
resolveControlCommandExecutable
|
|
146
|
+
};
|