@abelxiaoxing/cadence 1.3.0 → 1.4.0
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 +115 -10
- package/package.json +5 -3
- package/prompts/abel-design.md +71 -17
- package/prompts/abel-diagnose.md +10 -1
- package/prompts/abel-implement.md +118 -20
- package/prompts/abel-init.md +9 -2
- package/src/apply-transaction.ts +16 -14
- package/src/artifact-store.ts +17 -0
- package/src/candidate-patch.ts +2 -1
- package/src/canonical.ts +16 -0
- package/src/change-contract.ts +268 -0
- package/src/child-session.ts +4 -3
- package/src/contracts.ts +141 -33
- package/src/control-contracts.ts +51 -1
- package/src/delivery-compiler.ts +148 -31
- package/src/design-control.ts +69 -11
- package/src/design-journal.ts +216 -72
- package/src/durable-workflow.ts +4385 -0
- package/src/implement-graph.ts +11 -16
- package/src/index.ts +380 -981
- package/src/isolation-backend.ts +84 -23
- package/src/package-delivery.ts +486 -0
- package/src/package-verification.ts +509 -0
- package/src/route-policy.ts +1 -11
- package/src/run-state.ts +4 -16
- package/src/run-store.ts +5 -144
- package/src/scoped-grep-worker.mjs +23 -0
- package/src/scoped-tools.ts +312 -116
- package/src/sqlite-schema.ts +171 -0
- package/src/storage-schema.ts +352 -0
- package/src/subagent-activity.ts +14 -1
- package/src/submit-tool.ts +20 -2
- package/src/task-ledger.ts +14 -55
- package/src/verification-capability.ts +298 -102
- package/src/verification-environment.ts +213 -0
- package/src/verification-identity.ts +77 -0
- package/src/worker-broker.ts +97 -34
- package/src/workflow-engine.ts +28 -8285
- package/src/workflow-policy.ts +1427 -0
- package/src/workflow-state-machine.ts +4478 -0
- package/src/workspace-io-entry.mjs +58 -0
- package/src/workspace-io-worker.mjs +984 -0
- package/src/workspace-io.ts +84 -0
- package/src/workspace-store.ts +125 -19
- package/skills/abel-workflow/SKILL.md +0 -205
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
// Generated by scripts/build-workspace-io.mjs; edit workspace-io-entry.mjs and its source modules.
|
|
2
|
+
// src/workspace-io-entry.mjs
|
|
3
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
4
|
+
|
|
5
|
+
// src/artifact-store.ts
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
chmodSync,
|
|
9
|
+
closeSync,
|
|
10
|
+
constants,
|
|
11
|
+
copyFileSync,
|
|
12
|
+
fsyncSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
openSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeFileSync
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
var SHA256 = /^[a-f0-9]{64}$/u;
|
|
23
|
+
function digest(bytes) {
|
|
24
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
function requireHash(hash) {
|
|
27
|
+
if (!SHA256.test(hash))
|
|
28
|
+
throw new Error("artifact-hash-invalid");
|
|
29
|
+
}
|
|
30
|
+
function ensureDirectory(directory) {
|
|
31
|
+
const stat = lstatSync(directory, { throwIfNoEntry: false });
|
|
32
|
+
if (stat) {
|
|
33
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
34
|
+
throw new Error("artifact-store-path-unsafe");
|
|
35
|
+
}
|
|
36
|
+
chmodSync(directory, 448);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let cursor = path.dirname(directory);
|
|
40
|
+
while (cursor !== path.dirname(cursor)) {
|
|
41
|
+
const ancestor = lstatSync(cursor, { throwIfNoEntry: false });
|
|
42
|
+
if (ancestor) {
|
|
43
|
+
if (ancestor.isSymbolicLink() || !ancestor.isDirectory()) {
|
|
44
|
+
throw new Error("artifact-store-path-unsafe");
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
cursor = path.dirname(cursor);
|
|
49
|
+
}
|
|
50
|
+
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
51
|
+
const created = lstatSync(directory);
|
|
52
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
53
|
+
throw new Error("artifact-store-path-unsafe");
|
|
54
|
+
}
|
|
55
|
+
chmodSync(directory, 448);
|
|
56
|
+
}
|
|
57
|
+
function syncDirectory(directory) {
|
|
58
|
+
const descriptor = openSync(directory, constants.O_RDONLY);
|
|
59
|
+
try {
|
|
60
|
+
fsyncSync(descriptor);
|
|
61
|
+
} finally {
|
|
62
|
+
closeSync(descriptor);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function writeAtomic(target, bytes) {
|
|
66
|
+
const directory = path.dirname(target);
|
|
67
|
+
ensureDirectory(directory);
|
|
68
|
+
const temporary = path.join(directory, `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`);
|
|
69
|
+
const descriptor = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 384);
|
|
70
|
+
try {
|
|
71
|
+
writeFileSync(descriptor, bytes);
|
|
72
|
+
fsyncSync(descriptor);
|
|
73
|
+
} finally {
|
|
74
|
+
closeSync(descriptor);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
renameSync(temporary, target);
|
|
78
|
+
chmodSync(target, 384);
|
|
79
|
+
syncDirectory(directory);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(temporary);
|
|
83
|
+
} catch {}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class ArtifactStore {
|
|
89
|
+
root;
|
|
90
|
+
#blobs;
|
|
91
|
+
#references;
|
|
92
|
+
constructor(root) {
|
|
93
|
+
if (!path.isAbsolute(root))
|
|
94
|
+
throw new Error("artifact-store-root-invalid");
|
|
95
|
+
this.root = path.resolve(root);
|
|
96
|
+
this.#blobs = path.join(this.root, "blobs");
|
|
97
|
+
this.#references = path.join(this.root, "references");
|
|
98
|
+
ensureDirectory(this.root);
|
|
99
|
+
ensureDirectory(this.#blobs);
|
|
100
|
+
ensureDirectory(this.#references);
|
|
101
|
+
}
|
|
102
|
+
#blobPath(hash) {
|
|
103
|
+
requireHash(hash);
|
|
104
|
+
return path.join(this.#blobs, hash.slice(0, 2), hash);
|
|
105
|
+
}
|
|
106
|
+
#referencePath(hash) {
|
|
107
|
+
requireHash(hash);
|
|
108
|
+
return path.join(this.#references, hash.slice(0, 2), `${hash}.ref`);
|
|
109
|
+
}
|
|
110
|
+
put(input) {
|
|
111
|
+
if (!(input instanceof Uint8Array))
|
|
112
|
+
throw new Error("artifact-bytes-invalid");
|
|
113
|
+
const bytes = Buffer.from(input);
|
|
114
|
+
const hash = digest(bytes);
|
|
115
|
+
const target = this.#blobPath(hash);
|
|
116
|
+
const existing = lstatSync(target, { throwIfNoEntry: false });
|
|
117
|
+
if (existing) {
|
|
118
|
+
if (existing.isSymbolicLink() || !existing.isFile()) {
|
|
119
|
+
throw new Error("artifact-store-path-unsafe");
|
|
120
|
+
}
|
|
121
|
+
const current = readFileSync(target);
|
|
122
|
+
if (current.length !== bytes.length || digest(current) !== hash) {
|
|
123
|
+
throw new Error("artifact-integrity-invalid");
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
writeAtomic(target, bytes);
|
|
127
|
+
const stored = readFileSync(target);
|
|
128
|
+
if (stored.length !== bytes.length || digest(stored) !== hash) {
|
|
129
|
+
throw new Error("artifact-integrity-invalid");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return Object.freeze({ hash, bytes: bytes.length });
|
|
133
|
+
}
|
|
134
|
+
read(hash) {
|
|
135
|
+
const target = this.#blobPath(hash);
|
|
136
|
+
const stat = lstatSync(target, { throwIfNoEntry: false });
|
|
137
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) {
|
|
138
|
+
throw new Error("artifact-unavailable");
|
|
139
|
+
}
|
|
140
|
+
const bytes = readFileSync(target);
|
|
141
|
+
if (digest(bytes) !== hash)
|
|
142
|
+
throw new Error("artifact-integrity-invalid");
|
|
143
|
+
return Buffer.from(bytes);
|
|
144
|
+
}
|
|
145
|
+
copyVerified(hash, destination) {
|
|
146
|
+
const source = this.#blobPath(hash);
|
|
147
|
+
const stat = lstatSync(source, { throwIfNoEntry: false });
|
|
148
|
+
if (!stat?.isFile() || stat.isSymbolicLink())
|
|
149
|
+
throw new Error("artifact-unavailable");
|
|
150
|
+
copyFileSync(source, destination, constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE);
|
|
151
|
+
const bytes = readFileSync(destination);
|
|
152
|
+
if (digest(bytes) !== hash)
|
|
153
|
+
throw new Error("artifact-integrity-invalid");
|
|
154
|
+
return bytes.byteLength;
|
|
155
|
+
}
|
|
156
|
+
referenceCount(hash) {
|
|
157
|
+
requireHash(hash);
|
|
158
|
+
const referencePath = this.#referencePath(hash);
|
|
159
|
+
const stat = lstatSync(referencePath, { throwIfNoEntry: false });
|
|
160
|
+
if (!stat)
|
|
161
|
+
return 0;
|
|
162
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
163
|
+
throw new Error("artifact-reference-invalid");
|
|
164
|
+
}
|
|
165
|
+
const raw = readFileSync(referencePath, "utf8");
|
|
166
|
+
if (!/^(?:0|[1-9][0-9]*)\n$/u.test(raw)) {
|
|
167
|
+
throw new Error("artifact-reference-invalid");
|
|
168
|
+
}
|
|
169
|
+
const count = Number(raw.trim());
|
|
170
|
+
if (!Number.isSafeInteger(count))
|
|
171
|
+
throw new Error("artifact-reference-invalid");
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
#setReferenceCount(hash, count) {
|
|
175
|
+
if (!Number.isSafeInteger(count) || count < 0) {
|
|
176
|
+
throw new Error("artifact-reference-invalid");
|
|
177
|
+
}
|
|
178
|
+
writeAtomic(this.#referencePath(hash), Buffer.from(`${count}
|
|
179
|
+
`));
|
|
180
|
+
return count;
|
|
181
|
+
}
|
|
182
|
+
retain(hash) {
|
|
183
|
+
this.read(hash);
|
|
184
|
+
const current = this.referenceCount(hash);
|
|
185
|
+
if (current === Number.MAX_SAFE_INTEGER) {
|
|
186
|
+
throw new Error("artifact-reference-overflow");
|
|
187
|
+
}
|
|
188
|
+
return this.#setReferenceCount(hash, current + 1);
|
|
189
|
+
}
|
|
190
|
+
release(hash) {
|
|
191
|
+
this.read(hash);
|
|
192
|
+
const current = this.referenceCount(hash);
|
|
193
|
+
if (current === 0)
|
|
194
|
+
throw new Error("artifact-reference-underflow");
|
|
195
|
+
return this.#setReferenceCount(hash, current - 1);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/verification-identity.ts
|
|
200
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
201
|
+
import {
|
|
202
|
+
closeSync as closeSync2,
|
|
203
|
+
constants as constants2,
|
|
204
|
+
fstatSync,
|
|
205
|
+
lstatSync as lstatSync2,
|
|
206
|
+
openSync as openSync2,
|
|
207
|
+
readdirSync,
|
|
208
|
+
readlinkSync,
|
|
209
|
+
readSync
|
|
210
|
+
} from "node:fs";
|
|
211
|
+
import path2 from "node:path";
|
|
212
|
+
function verificationEnvironmentDigest(roots, checkCancelled = () => {}) {
|
|
213
|
+
const digest2 = createHash2("sha256");
|
|
214
|
+
let entries = 0;
|
|
215
|
+
let bytes = 0;
|
|
216
|
+
const started = Date.now();
|
|
217
|
+
const buffer = Buffer.alloc(256 * 1024);
|
|
218
|
+
const visit = (file, relative, dependencyRoot) => {
|
|
219
|
+
checkCancelled();
|
|
220
|
+
if (++entries > 500000 || Date.now() - started > 60000)
|
|
221
|
+
throw new Error("verification-environment-limit");
|
|
222
|
+
const stat = lstatSync2(file, { throwIfNoEntry: false });
|
|
223
|
+
digest2.update(JSON.stringify([relative, stat ? stat.mode & 511 : null]));
|
|
224
|
+
if (!stat) {
|
|
225
|
+
digest2.update("absent");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (stat.isSymbolicLink()) {
|
|
229
|
+
digest2.update(JSON.stringify(["link", readlinkSync(file)]));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (stat.isDirectory()) {
|
|
233
|
+
digest2.update("directory");
|
|
234
|
+
for (const name of readdirSync(file).sort()) {
|
|
235
|
+
if (dependencyRoot && [".vite", ".vite-temp"].includes(name))
|
|
236
|
+
continue;
|
|
237
|
+
visit(path2.join(file, name), `${relative}/${name}`, false);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (!stat.isFile())
|
|
242
|
+
throw new Error("verification-environment-unsafe");
|
|
243
|
+
const fd = openSync2(file, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
244
|
+
try {
|
|
245
|
+
const before = fstatSync(fd);
|
|
246
|
+
if (before.ino !== stat.ino || before.dev !== stat.dev)
|
|
247
|
+
throw new Error("verification-environment-changed");
|
|
248
|
+
const content = createHash2("sha256");
|
|
249
|
+
for (;; ) {
|
|
250
|
+
const count = readSync(fd, buffer, 0, buffer.length, null);
|
|
251
|
+
if (count === 0)
|
|
252
|
+
break;
|
|
253
|
+
checkCancelled();
|
|
254
|
+
bytes += count;
|
|
255
|
+
if (bytes > 16 * 1024 ** 3 || Date.now() - started > 60000)
|
|
256
|
+
throw new Error("verification-environment-limit");
|
|
257
|
+
content.update(buffer.subarray(0, count));
|
|
258
|
+
}
|
|
259
|
+
const after = fstatSync(fd);
|
|
260
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs)
|
|
261
|
+
throw new Error("verification-environment-changed");
|
|
262
|
+
digest2.update(JSON.stringify(["file", content.digest("hex")]));
|
|
263
|
+
} finally {
|
|
264
|
+
closeSync2(fd);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
for (const root of [...new Set(roots)].sort())
|
|
268
|
+
visit(root, root, path2.basename(root) === "node_modules");
|
|
269
|
+
return digest2.digest("hex");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/workspace-store.ts
|
|
273
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
274
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
275
|
+
import {
|
|
276
|
+
chmodSync as chmodSync2,
|
|
277
|
+
closeSync as closeSync3,
|
|
278
|
+
constants as constants3,
|
|
279
|
+
fsyncSync as fsyncSync2,
|
|
280
|
+
lstatSync as lstatSync4,
|
|
281
|
+
mkdirSync as mkdirSync2,
|
|
282
|
+
openSync as openSync3,
|
|
283
|
+
readFileSync as readFileSync2,
|
|
284
|
+
renameSync as renameSync2,
|
|
285
|
+
unlinkSync as unlinkSync2,
|
|
286
|
+
writeFileSync as writeFileSync2
|
|
287
|
+
} from "node:fs";
|
|
288
|
+
import path4 from "node:path";
|
|
289
|
+
import { promisify } from "node:util";
|
|
290
|
+
|
|
291
|
+
// src/canonical.ts
|
|
292
|
+
function compareCanonicalStrings(left, right) {
|
|
293
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/control-contracts.ts
|
|
297
|
+
var CONTROL_SCHEMA_PROPERTIES = {
|
|
298
|
+
command: {
|
|
299
|
+
type: "string",
|
|
300
|
+
enum: ["start", "status", "resume", "rebind", "cancel", "discard"]
|
|
301
|
+
},
|
|
302
|
+
stage: { type: "string", enum: ["abel-implement"] },
|
|
303
|
+
change: { type: "string" },
|
|
304
|
+
operationId: { type: "string" },
|
|
305
|
+
deliveryRevision: { type: "integer", minimum: 1 },
|
|
306
|
+
receiptHash: { type: "string" },
|
|
307
|
+
routeId: { type: "string" },
|
|
308
|
+
recovery: {
|
|
309
|
+
type: "object",
|
|
310
|
+
additionalProperties: false,
|
|
311
|
+
properties: {
|
|
312
|
+
incidentKey: { type: "string", pattern: "^[a-f0-9]{64}$" },
|
|
313
|
+
failureSequence: { type: "integer", minimum: 1 },
|
|
314
|
+
reason: {
|
|
315
|
+
type: "string",
|
|
316
|
+
enum: ["parent-directed-retry", "route-changed", "context-extended"]
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
required: ["incidentKey", "failureSequence", "reason"]
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
var commandSchema = (command, fields) => ({
|
|
323
|
+
type: "object",
|
|
324
|
+
properties: {
|
|
325
|
+
command: { type: "string", enum: [command] },
|
|
326
|
+
...Object.fromEntries(fields.map((field) => [field, CONTROL_SCHEMA_PROPERTIES[field]]))
|
|
327
|
+
},
|
|
328
|
+
required: ["command", ...fields],
|
|
329
|
+
additionalProperties: false
|
|
330
|
+
});
|
|
331
|
+
var CONTROL_COMMAND_PARAMETERS = {
|
|
332
|
+
type: "object",
|
|
333
|
+
properties: CONTROL_SCHEMA_PROPERTIES,
|
|
334
|
+
required: ["command", "stage", "change"],
|
|
335
|
+
anyOf: [
|
|
336
|
+
commandSchema("start", ["stage", "change", "operationId"]),
|
|
337
|
+
commandSchema("status", ["stage", "change"]),
|
|
338
|
+
commandSchema("resume", ["stage", "change", "operationId"]),
|
|
339
|
+
commandSchema("resume", ["stage", "change", "operationId", "recovery"]),
|
|
340
|
+
commandSchema("resume", [
|
|
341
|
+
"stage",
|
|
342
|
+
"change",
|
|
343
|
+
"operationId",
|
|
344
|
+
"deliveryRevision",
|
|
345
|
+
"receiptHash",
|
|
346
|
+
"recovery"
|
|
347
|
+
]),
|
|
348
|
+
commandSchema("resume", [
|
|
349
|
+
"stage",
|
|
350
|
+
"change",
|
|
351
|
+
"operationId",
|
|
352
|
+
"deliveryRevision",
|
|
353
|
+
"receiptHash"
|
|
354
|
+
]),
|
|
355
|
+
commandSchema("rebind", ["stage", "change", "operationId", "routeId"]),
|
|
356
|
+
commandSchema("cancel", ["stage", "change", "operationId"]),
|
|
357
|
+
commandSchema("discard", ["stage", "change", "operationId"])
|
|
358
|
+
],
|
|
359
|
+
additionalProperties: false
|
|
360
|
+
};
|
|
361
|
+
var CONTROL_TOOL_FIELDS = new Set([
|
|
362
|
+
"command",
|
|
363
|
+
"stage",
|
|
364
|
+
"change",
|
|
365
|
+
"operationId",
|
|
366
|
+
"deliveryRevision",
|
|
367
|
+
"receiptHash",
|
|
368
|
+
"routeId",
|
|
369
|
+
"recovery"
|
|
370
|
+
]);
|
|
371
|
+
var COMMAND_FIELDS = Object.freeze({
|
|
372
|
+
start: new Set(["command", "stage", "change", "operationId"]),
|
|
373
|
+
status: new Set(["command", "stage", "change"]),
|
|
374
|
+
resume: new Set([
|
|
375
|
+
"command",
|
|
376
|
+
"stage",
|
|
377
|
+
"change",
|
|
378
|
+
"operationId",
|
|
379
|
+
"deliveryRevision",
|
|
380
|
+
"receiptHash",
|
|
381
|
+
"recovery"
|
|
382
|
+
]),
|
|
383
|
+
rebind: new Set(["command", "stage", "change", "operationId", "routeId"]),
|
|
384
|
+
cancel: new Set(["command", "stage", "change", "operationId"]),
|
|
385
|
+
discard: new Set(["command", "stage", "change", "operationId"])
|
|
386
|
+
});
|
|
387
|
+
// src/contracts.ts
|
|
388
|
+
var LIMITS = {
|
|
389
|
+
maxActiveChildSessions: 4,
|
|
390
|
+
maxEnvelopeBytes: 64 * 1024,
|
|
391
|
+
phaseTimeoutMs: 20 * 60 * 1000,
|
|
392
|
+
maxCompleteResultBytes: 64 * 1024
|
|
393
|
+
};
|
|
394
|
+
var NONCANONICAL = /(^|\/)\.\.(\/|$)|(^|\/)\/|^\//;
|
|
395
|
+
function isValidRelativePath(p) {
|
|
396
|
+
return typeof p === "string" && p.length > 0 && p.length <= 512 && !NONCANONICAL.test(p) && !p.startsWith("/") && !/^[a-z]:\//iu.test(p) && (p === "." || !p.startsWith("./") && !p.endsWith("/") && !p.split("/").includes(".")) && !p.includes("..") && !p.includes("\\") && !p.includes("\x00");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/safe-path.ts
|
|
400
|
+
import { lstatSync as lstatSync3 } from "node:fs";
|
|
401
|
+
import path3 from "node:path";
|
|
402
|
+
function observeSafePath(root, relative) {
|
|
403
|
+
if (!isValidRelativePath(relative)) {
|
|
404
|
+
return { kind: "unsafe", reason: "invalid-path" };
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
const resolvedRoot = path3.resolve(root);
|
|
408
|
+
const rootStat = lstatSync3(resolvedRoot, { throwIfNoEntry: false });
|
|
409
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
410
|
+
return { kind: "unsafe", reason: "root-unavailable" };
|
|
411
|
+
}
|
|
412
|
+
if (relative === ".")
|
|
413
|
+
return { kind: "directory" };
|
|
414
|
+
let current = resolvedRoot;
|
|
415
|
+
const parts = relative.split("/");
|
|
416
|
+
for (const [index, part] of parts.entries()) {
|
|
417
|
+
current = path3.join(current, part);
|
|
418
|
+
if (!current.startsWith(`${resolvedRoot}${path3.sep}`)) {
|
|
419
|
+
return { kind: "unsafe", reason: "invalid-path" };
|
|
420
|
+
}
|
|
421
|
+
const stat = lstatSync3(current, { throwIfNoEntry: false });
|
|
422
|
+
if (!stat)
|
|
423
|
+
return { kind: "absent" };
|
|
424
|
+
if (stat.isSymbolicLink()) {
|
|
425
|
+
return { kind: "unsafe", reason: "symlink" };
|
|
426
|
+
}
|
|
427
|
+
if (index < parts.length - 1) {
|
|
428
|
+
if (!stat.isDirectory()) {
|
|
429
|
+
return { kind: "unsafe", reason: "parent-not-directory" };
|
|
430
|
+
}
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (stat.isFile())
|
|
434
|
+
return { kind: "file" };
|
|
435
|
+
if (stat.isDirectory())
|
|
436
|
+
return { kind: "directory" };
|
|
437
|
+
return { kind: "unsafe", reason: "special-file" };
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
return { kind: "unsafe", reason: "path-unavailable" };
|
|
441
|
+
}
|
|
442
|
+
return { kind: "unsafe", reason: "invalid-path" };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// src/workspace-io.ts
|
|
446
|
+
import { Worker } from "node:worker_threads";
|
|
447
|
+
async function runWorkspaceIo(input) {
|
|
448
|
+
input.signal?.throwIfAborted();
|
|
449
|
+
const cancelled = new SharedArrayBuffer(4);
|
|
450
|
+
const flag = new Int32Array(cancelled);
|
|
451
|
+
const started = performance.now();
|
|
452
|
+
const worker = new Worker(new URL("./workspace-io-worker.mjs", import.meta.url), {
|
|
453
|
+
workerData: {
|
|
454
|
+
root: input.root,
|
|
455
|
+
artifactRoot: input.artifactRoot,
|
|
456
|
+
operation: input.operation,
|
|
457
|
+
args: input.args,
|
|
458
|
+
cancelled
|
|
459
|
+
},
|
|
460
|
+
execArgv: []
|
|
461
|
+
});
|
|
462
|
+
const abort = () => {
|
|
463
|
+
Atomics.store(flag, 0, 1);
|
|
464
|
+
worker.postMessage("cancel");
|
|
465
|
+
};
|
|
466
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
467
|
+
if (input.signal?.aborted)
|
|
468
|
+
abort();
|
|
469
|
+
try {
|
|
470
|
+
return await new Promise((resolve, reject) => {
|
|
471
|
+
let message;
|
|
472
|
+
let failure;
|
|
473
|
+
worker.once("message", (value) => {
|
|
474
|
+
message = value;
|
|
475
|
+
});
|
|
476
|
+
worker.once("error", (error) => {
|
|
477
|
+
failure = error;
|
|
478
|
+
});
|
|
479
|
+
worker.once("exit", (code) => {
|
|
480
|
+
try {
|
|
481
|
+
input.onMetrics?.({
|
|
482
|
+
operation: input.operation,
|
|
483
|
+
elapsedMs: performance.now() - started,
|
|
484
|
+
files: message?.files ?? 0,
|
|
485
|
+
bytes: message?.bytes ?? 0
|
|
486
|
+
});
|
|
487
|
+
} catch {}
|
|
488
|
+
if (input.signal?.aborted)
|
|
489
|
+
reject(input.signal.reason);
|
|
490
|
+
else if (failure || code !== 0 || !message)
|
|
491
|
+
reject(new Error("workspace-io-unavailable"));
|
|
492
|
+
else if (!message.ok)
|
|
493
|
+
reject(new Error(message.error ?? "workspace-io-failed"));
|
|
494
|
+
else
|
|
495
|
+
resolve(message.result);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
} finally {
|
|
499
|
+
input.signal?.removeEventListener("abort", abort);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// src/workspace-store.ts
|
|
504
|
+
var SHA2562 = /^[a-f0-9]{64}$/u;
|
|
505
|
+
function hash(...values) {
|
|
506
|
+
const digest2 = createHash3("sha256");
|
|
507
|
+
for (const value of values) {
|
|
508
|
+
const bytes = typeof value === "string" ? Buffer.from(value) : value;
|
|
509
|
+
digest2.update(`${bytes.byteLength}:`);
|
|
510
|
+
digest2.update(bytes);
|
|
511
|
+
}
|
|
512
|
+
return digest2.digest("hex");
|
|
513
|
+
}
|
|
514
|
+
function stableEntries(entries) {
|
|
515
|
+
return Object.fromEntries(Object.entries(entries).sort(([left], [right]) => compareCanonicalStrings(left, right)).map(([relative, entry]) => [relative, { ...entry }]));
|
|
516
|
+
}
|
|
517
|
+
function manifestHash(entries) {
|
|
518
|
+
return hash("cadence-workspace-manifest", JSON.stringify(stableEntries(entries)));
|
|
519
|
+
}
|
|
520
|
+
function revisionId(parentRevisionId, manifest) {
|
|
521
|
+
return hash("cadence-workspace-revision", parentRevisionId ?? "", manifest);
|
|
522
|
+
}
|
|
523
|
+
function ensureDirectory2(directory) {
|
|
524
|
+
const stat = lstatSync4(directory, { throwIfNoEntry: false });
|
|
525
|
+
if (stat) {
|
|
526
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
527
|
+
throw new Error("unsafe-workspace-path");
|
|
528
|
+
}
|
|
529
|
+
chmodSync2(directory, 448);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
let cursor = path4.dirname(directory);
|
|
533
|
+
while (cursor !== path4.dirname(cursor)) {
|
|
534
|
+
const ancestor = lstatSync4(cursor, { throwIfNoEntry: false });
|
|
535
|
+
if (ancestor) {
|
|
536
|
+
if (ancestor.isSymbolicLink() || !ancestor.isDirectory()) {
|
|
537
|
+
throw new Error("unsafe-workspace-path");
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
cursor = path4.dirname(cursor);
|
|
542
|
+
}
|
|
543
|
+
mkdirSync2(directory, { recursive: true, mode: 448 });
|
|
544
|
+
const created = lstatSync4(directory);
|
|
545
|
+
if (created.isSymbolicLink() || !created.isDirectory()) {
|
|
546
|
+
throw new Error("unsafe-workspace-path");
|
|
547
|
+
}
|
|
548
|
+
chmodSync2(directory, 448);
|
|
549
|
+
}
|
|
550
|
+
function syncDirectory2(directory) {
|
|
551
|
+
const descriptor = openSync3(directory, constants3.O_RDONLY);
|
|
552
|
+
try {
|
|
553
|
+
fsyncSync2(descriptor);
|
|
554
|
+
} finally {
|
|
555
|
+
closeSync3(descriptor);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function writeAtomic2(target, bytes) {
|
|
559
|
+
const directory = path4.dirname(target);
|
|
560
|
+
ensureDirectory2(directory);
|
|
561
|
+
const temporary = path4.join(directory, `.${path4.basename(target)}.${process.pid}.${randomUUID2()}.tmp`);
|
|
562
|
+
const descriptor = openSync3(temporary, constants3.O_CREAT | constants3.O_EXCL | constants3.O_WRONLY, 384);
|
|
563
|
+
try {
|
|
564
|
+
writeFileSync2(descriptor, bytes);
|
|
565
|
+
fsyncSync2(descriptor);
|
|
566
|
+
} finally {
|
|
567
|
+
closeSync3(descriptor);
|
|
568
|
+
}
|
|
569
|
+
try {
|
|
570
|
+
renameSync2(temporary, target);
|
|
571
|
+
chmodSync2(target, 384);
|
|
572
|
+
syncDirectory2(directory);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
try {
|
|
575
|
+
unlinkSync2(temporary);
|
|
576
|
+
} catch {}
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
function requireSafeRelative(relative) {
|
|
581
|
+
if (!isValidRelativePath(relative) || relative === ".") {
|
|
582
|
+
throw new Error("unsafe-workspace-path");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function isExcluded(relative, excluded) {
|
|
586
|
+
return excluded.some((entry) => relative === entry || relative.startsWith(`${entry}/`));
|
|
587
|
+
}
|
|
588
|
+
function parseNullSeparated(bytes) {
|
|
589
|
+
const decoded = bytes.toString("utf8");
|
|
590
|
+
if (!Buffer.from(decoded, "utf8").equals(bytes)) {
|
|
591
|
+
throw new Error("unsafe-workspace-path");
|
|
592
|
+
}
|
|
593
|
+
return decoded.split("\x00").filter((entry) => entry.length > 0);
|
|
594
|
+
}
|
|
595
|
+
function sameEntry(left, right) {
|
|
596
|
+
if (!left || !right)
|
|
597
|
+
return left === right;
|
|
598
|
+
if (left.kind !== right.kind)
|
|
599
|
+
return false;
|
|
600
|
+
return left.kind === "absent" || right.kind === "file" && left.hash === right.hash && left.bytes === right.bytes && left.mode === right.mode;
|
|
601
|
+
}
|
|
602
|
+
function requireMode(mode) {
|
|
603
|
+
if (!Number.isInteger(mode) || mode < 0 || mode > 511) {
|
|
604
|
+
throw new Error("workspace-file-mode-invalid");
|
|
605
|
+
}
|
|
606
|
+
return mode;
|
|
607
|
+
}
|
|
608
|
+
function requireRevisionShape(value) {
|
|
609
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
610
|
+
throw new Error("workspace-revision-invalid");
|
|
611
|
+
}
|
|
612
|
+
const candidate = value;
|
|
613
|
+
if (typeof candidate.revisionId !== "string" || !SHA2562.test(candidate.revisionId) || candidate.parentRevisionId !== null && (typeof candidate.parentRevisionId !== "string" || !SHA2562.test(candidate.parentRevisionId)) || typeof candidate.manifestHash !== "string" || !SHA2562.test(candidate.manifestHash) || !candidate.entries || typeof candidate.entries !== "object" || Array.isArray(candidate.entries)) {
|
|
614
|
+
throw new Error("workspace-revision-invalid");
|
|
615
|
+
}
|
|
616
|
+
for (const [relative, entry] of Object.entries(candidate.entries)) {
|
|
617
|
+
requireSafeRelative(relative);
|
|
618
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
619
|
+
throw new Error("workspace-revision-invalid");
|
|
620
|
+
}
|
|
621
|
+
if (entry.kind === "absent") {
|
|
622
|
+
if (Object.keys(entry).length !== 1) {
|
|
623
|
+
throw new Error("workspace-revision-invalid");
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const file = entry;
|
|
628
|
+
if (file.kind !== "file" || !SHA2562.test(file.hash) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || Object.keys(file).length !== 4) {
|
|
629
|
+
throw new Error("workspace-revision-invalid");
|
|
630
|
+
}
|
|
631
|
+
requireMode(file.mode);
|
|
632
|
+
}
|
|
633
|
+
return candidate;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
class WorkspaceStore {
|
|
637
|
+
root;
|
|
638
|
+
#revisions;
|
|
639
|
+
#artifacts;
|
|
640
|
+
#checkCancelled;
|
|
641
|
+
#onMetrics;
|
|
642
|
+
constructor(root, artifacts, options = {}) {
|
|
643
|
+
this.#checkCancelled = options.checkCancelled ?? (() => {});
|
|
644
|
+
this.#onMetrics = options.onMetrics;
|
|
645
|
+
if (!path4.isAbsolute(root))
|
|
646
|
+
throw new Error("workspace-store-root-invalid");
|
|
647
|
+
this.root = path4.resolve(root);
|
|
648
|
+
this.#revisions = path4.join(this.root, "revisions");
|
|
649
|
+
this.#artifacts = artifacts;
|
|
650
|
+
ensureDirectory2(this.root);
|
|
651
|
+
ensureDirectory2(this.#revisions);
|
|
652
|
+
}
|
|
653
|
+
#revisionPath(id) {
|
|
654
|
+
if (!SHA2562.test(id))
|
|
655
|
+
throw new Error("workspace-revision-id-invalid");
|
|
656
|
+
return path4.join(this.#revisions, id.slice(0, 2), `${id}.json`);
|
|
657
|
+
}
|
|
658
|
+
#storeRevision(parentRevisionId, rawEntries) {
|
|
659
|
+
const entries = stableEntries(rawEntries);
|
|
660
|
+
const manifest = manifestHash(entries);
|
|
661
|
+
const id = revisionId(parentRevisionId, manifest);
|
|
662
|
+
const revision = {
|
|
663
|
+
revisionId: id,
|
|
664
|
+
parentRevisionId,
|
|
665
|
+
manifestHash: manifest,
|
|
666
|
+
entries
|
|
667
|
+
};
|
|
668
|
+
const target = this.#revisionPath(id);
|
|
669
|
+
const existing = lstatSync4(target, { throwIfNoEntry: false });
|
|
670
|
+
if (existing) {
|
|
671
|
+
const stored = this.getRevision(id);
|
|
672
|
+
if (JSON.stringify(stored) !== JSON.stringify(revision)) {
|
|
673
|
+
throw new Error("workspace-revision-conflict");
|
|
674
|
+
}
|
|
675
|
+
return stored;
|
|
676
|
+
}
|
|
677
|
+
writeAtomic2(target, Buffer.from(`${JSON.stringify(revision)}
|
|
678
|
+
`));
|
|
679
|
+
const inherited = parentRevisionId ? this.getRevision(parentRevisionId).entries : {};
|
|
680
|
+
for (const [relative, entry] of Object.entries(entries)) {
|
|
681
|
+
if (entry.kind === "file" && !sameEntry(entry, inherited[relative]))
|
|
682
|
+
this.#artifacts.retain(entry.hash);
|
|
683
|
+
}
|
|
684
|
+
return structuredClone(revision);
|
|
685
|
+
}
|
|
686
|
+
#isAncestor(ancestorId, revision) {
|
|
687
|
+
let cursor = revision;
|
|
688
|
+
const visited = new Set;
|
|
689
|
+
while (cursor) {
|
|
690
|
+
if (cursor.revisionId === ancestorId)
|
|
691
|
+
return true;
|
|
692
|
+
if (visited.has(cursor.revisionId)) {
|
|
693
|
+
throw new Error("workspace-revision-cycle");
|
|
694
|
+
}
|
|
695
|
+
visited.add(cursor.revisionId);
|
|
696
|
+
cursor = cursor.parentRevisionId ? this.getRevision(cursor.parentRevisionId) : null;
|
|
697
|
+
}
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
captureBaseline(input) {
|
|
701
|
+
const consumerRoot = path4.resolve(input.consumerRoot);
|
|
702
|
+
const rootStat = lstatSync4(consumerRoot, { throwIfNoEntry: false });
|
|
703
|
+
if (!rootStat?.isDirectory() || rootStat.isSymbolicLink()) {
|
|
704
|
+
throw new Error("unsafe-workspace-path");
|
|
705
|
+
}
|
|
706
|
+
const excluded = [".git", "node_modules", ...input.excludedPaths ?? []];
|
|
707
|
+
for (const relative of excluded)
|
|
708
|
+
requireSafeRelative(relative);
|
|
709
|
+
return this.#captureBaselineFiles(input, this.#trackedFiles(input), consumerRoot, excluded);
|
|
710
|
+
}
|
|
711
|
+
#trackedFiles(input) {
|
|
712
|
+
const timeout = input.gitTimeoutMs ?? 30000;
|
|
713
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30000)
|
|
714
|
+
throw new Error("workspace-git-timeout-invalid");
|
|
715
|
+
const consumerRoot = path4.resolve(input.consumerRoot);
|
|
716
|
+
let tracked;
|
|
717
|
+
try {
|
|
718
|
+
tracked = parseNullSeparated(execFileSync("git", ["ls-files", "-z", "--cached"], {
|
|
719
|
+
cwd: consumerRoot,
|
|
720
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
721
|
+
timeout,
|
|
722
|
+
killSignal: "SIGKILL"
|
|
723
|
+
}));
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (error instanceof Error && error.message === "unsafe-workspace-path") {
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
throw new Error("workspace-git-baseline-unavailable");
|
|
729
|
+
}
|
|
730
|
+
return tracked;
|
|
731
|
+
}
|
|
732
|
+
#captureBaselineFiles(input, tracked, consumerRoot, excluded) {
|
|
733
|
+
const approved = input.approvedUntracked ?? [];
|
|
734
|
+
const absent = input.absent ?? [];
|
|
735
|
+
const selected = [...new Set([...tracked, ...approved])].sort();
|
|
736
|
+
const entries = {};
|
|
737
|
+
for (const relative of selected) {
|
|
738
|
+
this.#checkCancelled();
|
|
739
|
+
requireSafeRelative(relative);
|
|
740
|
+
if (isExcluded(relative, excluded))
|
|
741
|
+
continue;
|
|
742
|
+
const observation = observeSafePath(consumerRoot, relative);
|
|
743
|
+
if (observation.kind === "absent") {
|
|
744
|
+
entries[relative] = { kind: "absent" };
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (observation.kind !== "file")
|
|
748
|
+
throw new Error("unsafe-workspace-path");
|
|
749
|
+
const absolute = path4.join(consumerRoot, ...relative.split("/"));
|
|
750
|
+
const bytes = readFileSync2(absolute);
|
|
751
|
+
const artifact = this.#artifacts.put(bytes);
|
|
752
|
+
const stat = lstatSync4(absolute);
|
|
753
|
+
entries[relative] = {
|
|
754
|
+
kind: "file",
|
|
755
|
+
hash: artifact.hash,
|
|
756
|
+
bytes: artifact.bytes,
|
|
757
|
+
mode: requireMode(stat.mode & 511)
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
for (const relative of [...new Set(absent)].sort()) {
|
|
761
|
+
requireSafeRelative(relative);
|
|
762
|
+
if (isExcluded(relative, excluded))
|
|
763
|
+
throw new Error("unsafe-workspace-path");
|
|
764
|
+
if (observeSafePath(consumerRoot, relative).kind !== "absent") {
|
|
765
|
+
throw new Error("workspace-path-not-absent");
|
|
766
|
+
}
|
|
767
|
+
entries[relative] = { kind: "absent" };
|
|
768
|
+
}
|
|
769
|
+
return this.#storeRevision(null, entries);
|
|
770
|
+
}
|
|
771
|
+
getRevision(id) {
|
|
772
|
+
const target = this.#revisionPath(id);
|
|
773
|
+
const stat = lstatSync4(target, { throwIfNoEntry: false });
|
|
774
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) {
|
|
775
|
+
throw new Error("workspace-revision-unavailable");
|
|
776
|
+
}
|
|
777
|
+
let parsed;
|
|
778
|
+
try {
|
|
779
|
+
parsed = JSON.parse(readFileSync2(target, "utf8"));
|
|
780
|
+
} catch {
|
|
781
|
+
throw new Error("workspace-revision-invalid");
|
|
782
|
+
}
|
|
783
|
+
const revision = requireRevisionShape(parsed);
|
|
784
|
+
if (revision.revisionId !== id || manifestHash(revision.entries) !== revision.manifestHash && hash("cadence-workspace-manifest", JSON.stringify(revision.entries)) !== revision.manifestHash || revisionId(revision.parentRevisionId, revision.manifestHash) !== id) {
|
|
785
|
+
throw new Error("workspace-revision-integrity-invalid");
|
|
786
|
+
}
|
|
787
|
+
return structuredClone(revision);
|
|
788
|
+
}
|
|
789
|
+
createRevision(input) {
|
|
790
|
+
const parent = this.getRevision(input.parentRevisionId);
|
|
791
|
+
const entries = stableEntries(parent.entries);
|
|
792
|
+
for (const [relative, change] of Object.entries(input.changes).sort(([left], [right]) => compareCanonicalStrings(left, right))) {
|
|
793
|
+
requireSafeRelative(relative);
|
|
794
|
+
if (change === null || typeof change === "object" && "kind" in change && change.kind === "absent") {
|
|
795
|
+
entries[relative] = { kind: "absent" };
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
const descriptor = change instanceof Uint8Array ? {
|
|
799
|
+
kind: "file",
|
|
800
|
+
bytes: change,
|
|
801
|
+
mode: entries[relative]?.kind === "file" ? entries[relative].mode : 420
|
|
802
|
+
} : change;
|
|
803
|
+
if (descriptor?.kind !== "file" || !(descriptor.bytes instanceof Uint8Array)) {
|
|
804
|
+
throw new Error("workspace-change-invalid");
|
|
805
|
+
}
|
|
806
|
+
const artifact = this.#artifacts.put(descriptor.bytes);
|
|
807
|
+
entries[relative] = {
|
|
808
|
+
kind: "file",
|
|
809
|
+
hash: artifact.hash,
|
|
810
|
+
bytes: artifact.bytes,
|
|
811
|
+
mode: requireMode(descriptor.mode ?? 420)
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
return this.#storeRevision(parent.revisionId, entries);
|
|
815
|
+
}
|
|
816
|
+
mergeRevision(input) {
|
|
817
|
+
const base = this.getRevision(input.baseRevisionId);
|
|
818
|
+
const current = this.getRevision(input.currentRevisionId);
|
|
819
|
+
const candidate = this.getRevision(input.candidateRevisionId);
|
|
820
|
+
if (candidate.parentRevisionId !== base.revisionId || !this.#isAncestor(base.revisionId, current)) {
|
|
821
|
+
throw new Error("workspace-revision-base-invalid");
|
|
822
|
+
}
|
|
823
|
+
const boundPaths = [...new Set(input.boundPaths)];
|
|
824
|
+
for (const relative of boundPaths) {
|
|
825
|
+
requireSafeRelative(relative);
|
|
826
|
+
if (!sameEntry(base.entries[relative], current.entries[relative])) {
|
|
827
|
+
throw new Error("workspace-revision-stale");
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
const allPaths = new Set([
|
|
831
|
+
...Object.keys(base.entries),
|
|
832
|
+
...Object.keys(candidate.entries)
|
|
833
|
+
]);
|
|
834
|
+
const delta = [...allPaths].filter((relative) => !sameEntry(base.entries[relative], candidate.entries[relative]));
|
|
835
|
+
for (const relative of delta) {
|
|
836
|
+
if (!sameEntry(base.entries[relative], current.entries[relative])) {
|
|
837
|
+
throw new Error("workspace-revision-stale");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
const merged = stableEntries(current.entries);
|
|
841
|
+
for (const relative of delta.sort()) {
|
|
842
|
+
const entry = candidate.entries[relative];
|
|
843
|
+
if (entry)
|
|
844
|
+
merged[relative] = { ...entry };
|
|
845
|
+
else
|
|
846
|
+
delete merged[relative];
|
|
847
|
+
}
|
|
848
|
+
return this.#storeRevision(current.revisionId, merged);
|
|
849
|
+
}
|
|
850
|
+
materialize(revisionIdValue, destination) {
|
|
851
|
+
const revision = this.getRevision(revisionIdValue);
|
|
852
|
+
const root = path4.resolve(destination);
|
|
853
|
+
ensureDirectory2(root);
|
|
854
|
+
for (const [relative, entry] of Object.entries(revision.entries)) {
|
|
855
|
+
this.#checkCancelled();
|
|
856
|
+
requireSafeRelative(relative);
|
|
857
|
+
const observation = observeSafePath(root, relative);
|
|
858
|
+
if (entry.kind === "absent") {
|
|
859
|
+
const segments2 = relative.split("/");
|
|
860
|
+
const supersededByFileAncestor = segments2.slice(0, -1).some((_segment, index) => revision.entries[segments2.slice(0, index + 1).join("/")]?.kind === "file");
|
|
861
|
+
if (supersededByFileAncestor)
|
|
862
|
+
continue;
|
|
863
|
+
if (observation.kind !== "absent") {
|
|
864
|
+
throw new Error("workspace-materialize-conflict");
|
|
865
|
+
}
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const segments = relative.split("/");
|
|
869
|
+
let parent = root;
|
|
870
|
+
for (const segment of segments.slice(0, -1)) {
|
|
871
|
+
parent = path4.join(parent, segment);
|
|
872
|
+
ensureDirectory2(parent);
|
|
873
|
+
}
|
|
874
|
+
const target = path4.join(root, ...segments);
|
|
875
|
+
if (observation.kind !== "absent") {
|
|
876
|
+
throw new Error("workspace-materialize-conflict");
|
|
877
|
+
}
|
|
878
|
+
const bytes = this.#artifacts.copyVerified(entry.hash, target);
|
|
879
|
+
if (bytes !== entry.bytes)
|
|
880
|
+
throw new Error("workspace-revision-integrity-invalid");
|
|
881
|
+
chmodSync2(target, entry.mode);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
async captureBaselineWithGit(input, signal) {
|
|
885
|
+
signal?.throwIfAborted();
|
|
886
|
+
const consumerRoot = path4.resolve(input.consumerRoot);
|
|
887
|
+
const stat = lstatSync4(consumerRoot, { throwIfNoEntry: false });
|
|
888
|
+
if (!stat?.isDirectory() || stat.isSymbolicLink())
|
|
889
|
+
throw new Error("unsafe-workspace-path");
|
|
890
|
+
const excluded = [".git", "node_modules", ...input.excludedPaths ?? []];
|
|
891
|
+
for (const relative of excluded)
|
|
892
|
+
requireSafeRelative(relative);
|
|
893
|
+
const timeout = input.gitTimeoutMs ?? 30000;
|
|
894
|
+
if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30000)
|
|
895
|
+
throw new Error("workspace-git-timeout-invalid");
|
|
896
|
+
let tracked;
|
|
897
|
+
try {
|
|
898
|
+
const { stdout } = await promisify(execFile)("git", ["ls-files", "-z", "--cached"], {
|
|
899
|
+
cwd: consumerRoot,
|
|
900
|
+
encoding: "buffer",
|
|
901
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
902
|
+
timeout,
|
|
903
|
+
killSignal: "SIGKILL",
|
|
904
|
+
signal
|
|
905
|
+
});
|
|
906
|
+
tracked = parseNullSeparated(stdout);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
signal?.throwIfAborted();
|
|
909
|
+
if (error instanceof Error && error.message === "unsafe-workspace-path")
|
|
910
|
+
throw error;
|
|
911
|
+
throw new Error("workspace-git-baseline-unavailable");
|
|
912
|
+
}
|
|
913
|
+
this.#checkCancelled();
|
|
914
|
+
return this.#captureBaselineFiles(input, tracked, consumerRoot, excluded);
|
|
915
|
+
}
|
|
916
|
+
captureBaselineAsync(input, signal) {
|
|
917
|
+
return runWorkspaceIo({
|
|
918
|
+
root: this.root,
|
|
919
|
+
artifactRoot: this.#artifacts.root,
|
|
920
|
+
operation: "captureBaseline",
|
|
921
|
+
args: [input],
|
|
922
|
+
signal,
|
|
923
|
+
onMetrics: this.#onMetrics
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
materializeAsync(revisionId2, destination, signal) {
|
|
927
|
+
return runWorkspaceIo({
|
|
928
|
+
root: this.root,
|
|
929
|
+
artifactRoot: this.#artifacts.root,
|
|
930
|
+
operation: "materialize",
|
|
931
|
+
args: [revisionId2, destination],
|
|
932
|
+
signal,
|
|
933
|
+
onMetrics: this.#onMetrics
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/workspace-io-entry.mjs
|
|
939
|
+
var controller = new AbortController;
|
|
940
|
+
var flag = new Int32Array(workerData.cancelled);
|
|
941
|
+
parentPort.on("message", () => controller.abort(new Error("cancelled")));
|
|
942
|
+
var checkCancelled = () => {
|
|
943
|
+
if (Atomics.load(flag, 0))
|
|
944
|
+
throw new Error("cancelled");
|
|
945
|
+
};
|
|
946
|
+
try {
|
|
947
|
+
if (workerData.operation === "verificationIdentity") {
|
|
948
|
+
const result = verificationEnvironmentDigest(workerData.args[0], checkCancelled);
|
|
949
|
+
parentPort.postMessage({ ok: true, result, files: 0, bytes: 0 });
|
|
950
|
+
} else {
|
|
951
|
+
const artifacts = new ArtifactStore(workerData.artifactRoot);
|
|
952
|
+
const store = new WorkspaceStore(workerData.root, artifacts, {
|
|
953
|
+
checkCancelled
|
|
954
|
+
});
|
|
955
|
+
let result;
|
|
956
|
+
let revision;
|
|
957
|
+
checkCancelled();
|
|
958
|
+
if (workerData.operation === "captureBaseline") {
|
|
959
|
+
revision = await store.captureBaselineWithGit(workerData.args[0], controller.signal);
|
|
960
|
+
result = revision;
|
|
961
|
+
} else if (workerData.operation === "materialize") {
|
|
962
|
+
revision = store.getRevision(workerData.args[0]);
|
|
963
|
+
store.materialize(workerData.args[0], workerData.args[1]);
|
|
964
|
+
} else {
|
|
965
|
+
throw new Error("workspace-io-operation-invalid");
|
|
966
|
+
}
|
|
967
|
+
const files = Object.values(revision.entries).filter((entry) => entry.kind === "file");
|
|
968
|
+
parentPort.postMessage({
|
|
969
|
+
ok: true,
|
|
970
|
+
result,
|
|
971
|
+
files: files.length,
|
|
972
|
+
bytes: files.reduce((sum, entry) => sum + entry.bytes, 0)
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
} catch (error) {
|
|
976
|
+
parentPort.postMessage({
|
|
977
|
+
ok: false,
|
|
978
|
+
error: error instanceof Error ? error.message : "workspace-io-failed",
|
|
979
|
+
files: 0,
|
|
980
|
+
bytes: 0
|
|
981
|
+
});
|
|
982
|
+
} finally {
|
|
983
|
+
parentPort.close();
|
|
984
|
+
}
|