@the-open-engine/zeroshot 6.39.0 → 6.39.1
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 -0
- package/cli/index.js +60 -17
- package/lib/detached-startup.d.ts +1 -0
- package/lib/detached-startup.js +2 -1
- package/lib/start-cluster-environment.d.ts +1 -0
- package/lib/start-cluster-run-options.d.ts +1 -0
- package/lib/start-cluster-run-options.js +1 -0
- package/npm-shrinkwrap.json +6 -8
- package/package.json +3 -3
- package/scripts/rust-distribution.js +13 -31
- package/src/agents/git-pusher-template.js +17 -34
- package/src/copy-containment.js +191 -0
- package/src/copy-containment.ts +259 -0
- package/src/copy-worker.js +29 -30
- package/src/copy-worker.ts +43 -30
- package/src/isolation-manager.js +59 -20
- package/src/legacy-lib/detached-startup.ts +3 -1
- package/src/legacy-lib/start-cluster-environment.ts +1 -0
- package/src/legacy-lib/start-cluster-run-options.ts +2 -0
- package/src/orchestrator.js +13 -3
- package/src/pr-body-template.js +71 -0
package/src/isolation-manager.js
CHANGED
|
@@ -34,6 +34,14 @@ const { resolveProviderCredentialPaths } = require('../lib/provider-credential-p
|
|
|
34
34
|
const { getProvider } = require('./providers');
|
|
35
35
|
const { readRepoSettings } = require('../lib/repo-settings');
|
|
36
36
|
const { provisionClaudeCredentials } = require('./claude-credentials');
|
|
37
|
+
const {
|
|
38
|
+
CopyContainmentError,
|
|
39
|
+
copyErrorFromPayload,
|
|
40
|
+
createCopyBoundary,
|
|
41
|
+
resolveCopyPath,
|
|
42
|
+
resolveSourcePath,
|
|
43
|
+
validateRelativePath,
|
|
44
|
+
} = require('./copy-containment');
|
|
37
45
|
|
|
38
46
|
const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
|
|
39
47
|
const FRESH_BASE_REF_PREFIX = 'refs/zeroshot/base-fetch';
|
|
@@ -1298,6 +1306,7 @@ class IsolationManager {
|
|
|
1298
1306
|
// Phase 1: Collect all files and directories
|
|
1299
1307
|
const files = [];
|
|
1300
1308
|
const directories = new Set();
|
|
1309
|
+
const copyBoundary = createCopyBoundary(src, dest);
|
|
1301
1310
|
|
|
1302
1311
|
const shouldIgnoreFsError = (err) =>
|
|
1303
1312
|
err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
|
|
@@ -1328,12 +1337,13 @@ class IsolationManager {
|
|
|
1328
1337
|
}
|
|
1329
1338
|
};
|
|
1330
1339
|
|
|
1331
|
-
function handleEntry(entry,
|
|
1340
|
+
function handleEntry(entry, relPath, relativePath, ancestorDirectories) {
|
|
1332
1341
|
if (entry.isSymbolicLink()) {
|
|
1333
|
-
const
|
|
1342
|
+
const resolvedSourcePath = resolveSourcePath(copyBoundary, relPath);
|
|
1343
|
+
const targetStats = fs.statSync(resolvedSourcePath);
|
|
1334
1344
|
if (targetStats.isDirectory()) {
|
|
1335
1345
|
directories.add(relPath);
|
|
1336
|
-
collectFiles(
|
|
1346
|
+
collectFiles(relPath, ancestorDirectories);
|
|
1337
1347
|
return;
|
|
1338
1348
|
}
|
|
1339
1349
|
|
|
@@ -1344,7 +1354,7 @@ class IsolationManager {
|
|
|
1344
1354
|
|
|
1345
1355
|
if (entry.isDirectory()) {
|
|
1346
1356
|
directories.add(relPath);
|
|
1347
|
-
collectFiles(
|
|
1357
|
+
collectFiles(relPath, ancestorDirectories);
|
|
1348
1358
|
return;
|
|
1349
1359
|
}
|
|
1350
1360
|
|
|
@@ -1352,7 +1362,15 @@ class IsolationManager {
|
|
|
1352
1362
|
ensureParentDirTracked(relativePath);
|
|
1353
1363
|
}
|
|
1354
1364
|
|
|
1355
|
-
function collectFiles(
|
|
1365
|
+
function collectFiles(relativePath = '', ancestorDirectories = new Set()) {
|
|
1366
|
+
const currentSrc = relativePath
|
|
1367
|
+
? resolveSourcePath(copyBoundary, relativePath)
|
|
1368
|
+
: copyBoundary.sourceRoot.canonicalPath;
|
|
1369
|
+
if (ancestorDirectories.has(currentSrc)) {
|
|
1370
|
+
throw new CopyContainmentError(relativePath, 'source directory symlink creates a cycle');
|
|
1371
|
+
}
|
|
1372
|
+
const childAncestors = new Set(ancestorDirectories);
|
|
1373
|
+
childAncestors.add(currentSrc);
|
|
1356
1374
|
const entries = readEntries(currentSrc);
|
|
1357
1375
|
|
|
1358
1376
|
for (const entry of entries) {
|
|
@@ -1360,11 +1378,11 @@ class IsolationManager {
|
|
|
1360
1378
|
continue;
|
|
1361
1379
|
}
|
|
1362
1380
|
|
|
1363
|
-
const
|
|
1364
|
-
const relPath = relativePath ? path.join(relativePath,
|
|
1381
|
+
const entryName = validateRelativePath(entry.name);
|
|
1382
|
+
const relPath = relativePath ? path.join(relativePath, entryName) : entryName;
|
|
1365
1383
|
|
|
1366
1384
|
try {
|
|
1367
|
-
handleEntry(entry,
|
|
1385
|
+
handleEntry(entry, relPath, relativePath, childAncestors);
|
|
1368
1386
|
} catch (err) {
|
|
1369
1387
|
if (shouldIgnoreFsError(err)) {
|
|
1370
1388
|
continue;
|
|
@@ -1374,7 +1392,7 @@ class IsolationManager {
|
|
|
1374
1392
|
}
|
|
1375
1393
|
}
|
|
1376
1394
|
|
|
1377
|
-
collectFiles(
|
|
1395
|
+
collectFiles();
|
|
1378
1396
|
|
|
1379
1397
|
// Phase 2: Create directory structure (sequential - must exist before file copy)
|
|
1380
1398
|
// Sort directories by depth to ensure parents are created before children
|
|
@@ -1385,7 +1403,7 @@ class IsolationManager {
|
|
|
1385
1403
|
});
|
|
1386
1404
|
|
|
1387
1405
|
for (const dir of sortedDirs) {
|
|
1388
|
-
const destDir =
|
|
1406
|
+
const { destinationPath: destDir } = resolveCopyPath(copyBoundary, dir);
|
|
1389
1407
|
try {
|
|
1390
1408
|
fs.mkdirSync(destDir, { recursive: true });
|
|
1391
1409
|
} catch (err) {
|
|
@@ -1399,10 +1417,9 @@ class IsolationManager {
|
|
|
1399
1417
|
// For small file counts (<100), use synchronous copy (worker overhead not worth it)
|
|
1400
1418
|
if (files.length < 100) {
|
|
1401
1419
|
for (const relPath of files) {
|
|
1402
|
-
const srcPath = path.join(src, relPath);
|
|
1403
|
-
const destPath = path.join(dest, relPath);
|
|
1404
1420
|
try {
|
|
1405
|
-
|
|
1421
|
+
const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relPath);
|
|
1422
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
1406
1423
|
} catch (err) {
|
|
1407
1424
|
if (err.code !== 'EACCES' && err.code !== 'EPERM' && err.code !== 'ENOENT') {
|
|
1408
1425
|
throw err;
|
|
@@ -1424,18 +1441,22 @@ class IsolationManager {
|
|
|
1424
1441
|
}
|
|
1425
1442
|
|
|
1426
1443
|
// Spawn workers and wait for completion
|
|
1444
|
+
const workers = [];
|
|
1427
1445
|
const workerPromises = chunks.map((chunk) => {
|
|
1428
1446
|
return new Promise((resolve, reject) => {
|
|
1429
1447
|
const worker = new Worker(workerPath, {
|
|
1430
1448
|
workerData: {
|
|
1431
1449
|
files: chunk,
|
|
1432
|
-
sourceBase:
|
|
1433
|
-
destBase:
|
|
1450
|
+
sourceBase: copyBoundary.sourceRoot.canonicalPath,
|
|
1451
|
+
destBase: copyBoundary.destinationRoot.canonicalPath,
|
|
1452
|
+
expectedBoundary: copyBoundary,
|
|
1434
1453
|
},
|
|
1435
1454
|
});
|
|
1455
|
+
workers.push(worker);
|
|
1456
|
+
let result = null;
|
|
1436
1457
|
|
|
1437
|
-
worker.on('message', (
|
|
1438
|
-
|
|
1458
|
+
worker.on('message', (workerResult) => {
|
|
1459
|
+
result = workerResult;
|
|
1439
1460
|
});
|
|
1440
1461
|
|
|
1441
1462
|
worker.on('error', (err) => {
|
|
@@ -1445,6 +1466,12 @@ class IsolationManager {
|
|
|
1445
1466
|
worker.on('exit', (code) => {
|
|
1446
1467
|
if (code !== 0) {
|
|
1447
1468
|
reject(new Error(`Worker exited with code ${code}`));
|
|
1469
|
+
} else if (!result) {
|
|
1470
|
+
reject(new Error('Copy worker exited without reporting a result'));
|
|
1471
|
+
} else if (result.error) {
|
|
1472
|
+
reject(copyErrorFromPayload(result.error));
|
|
1473
|
+
} else {
|
|
1474
|
+
resolve(result);
|
|
1448
1475
|
}
|
|
1449
1476
|
});
|
|
1450
1477
|
});
|
|
@@ -1453,7 +1480,12 @@ class IsolationManager {
|
|
|
1453
1480
|
// Wait for all workers to complete (proper async/await - no busy-wait!)
|
|
1454
1481
|
// FIX: Previous version used busy-wait which blocked the event loop,
|
|
1455
1482
|
// preventing worker thread messages from being processed (timeout bug)
|
|
1456
|
-
|
|
1483
|
+
try {
|
|
1484
|
+
await Promise.all(workerPromises);
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
|
|
1487
|
+
throw err;
|
|
1488
|
+
}
|
|
1457
1489
|
}
|
|
1458
1490
|
|
|
1459
1491
|
/**
|
|
@@ -2383,8 +2415,15 @@ class IsolationManager {
|
|
|
2383
2415
|
// NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
|
|
2384
2416
|
// Compose project — only a project scoped to the worktree directory basename, which is
|
|
2385
2417
|
// the only kind zeroshot could itself have created, is touched.
|
|
2386
|
-
|
|
2387
|
-
|
|
2418
|
+
let teardown = { shouldTeardown: false };
|
|
2419
|
+
try {
|
|
2420
|
+
const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
|
|
2421
|
+
teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
|
|
2422
|
+
} catch (error) {
|
|
2423
|
+
console.warn(
|
|
2424
|
+
`[IsolationManager] Skipping Docker Compose teardown in ${worktreeInfo.path}: ${error.message}`
|
|
2425
|
+
);
|
|
2426
|
+
}
|
|
2388
2427
|
if (teardown.shouldTeardown) {
|
|
2389
2428
|
try {
|
|
2390
2429
|
runSync('docker', teardown.args, {
|
|
@@ -26,6 +26,7 @@ interface RunOptions extends Record<string, unknown> {
|
|
|
26
26
|
mergeQueue?: unknown;
|
|
27
27
|
pr?: unknown;
|
|
28
28
|
prBase?: unknown;
|
|
29
|
+
prBody?: unknown;
|
|
29
30
|
ship?: unknown;
|
|
30
31
|
worktree?: unknown;
|
|
31
32
|
}
|
|
@@ -236,9 +237,10 @@ async function registerDetachedSetupCluster({
|
|
|
236
237
|
setupStartedAt: Date.now(),
|
|
237
238
|
setupStage: 'starting',
|
|
238
239
|
autoPr: plan.delivery !== 'none',
|
|
239
|
-
prOptions:
|
|
240
|
+
prOptions: plan.delivery !== 'none'
|
|
240
241
|
? {
|
|
241
242
|
prBase: runOptions.prBase,
|
|
243
|
+
prBody: typeof runOptions.prBody === 'string' ? runOptions.prBody : null,
|
|
242
244
|
mergeQueue: runOptions.mergeQueue || false,
|
|
243
245
|
closeIssue: runOptions.closeIssue || null,
|
|
244
246
|
autoMerge: plan.autoMerge,
|
|
@@ -28,6 +28,7 @@ interface RunOptions extends Record<string, unknown> {
|
|
|
28
28
|
noMounts?: unknown;
|
|
29
29
|
pr?: unknown;
|
|
30
30
|
prBase?: unknown;
|
|
31
|
+
prBody?: unknown;
|
|
31
32
|
preparedWorktree?: unknown;
|
|
32
33
|
requiredQualityGates?: unknown;
|
|
33
34
|
ship?: unknown;
|
|
@@ -201,6 +202,7 @@ function buildStartOptionsFromPlan({
|
|
|
201
202
|
containerHome: optionalValue(options.containerHome),
|
|
202
203
|
forceProvider: optionalValue(forceProvider),
|
|
203
204
|
prBase: environment ? resolvePrBase(options) : optionalValue(options.prBase),
|
|
205
|
+
prBody: typeof options.prBody === 'string' ? options.prBody : undefined,
|
|
204
206
|
mergeQueue: environment ? resolveMergeQueue(options) : optionalValue(options.mergeQueue),
|
|
205
207
|
closeIssue: environment ? resolveCloseIssue(options) : optionalValue(options.closeIssue),
|
|
206
208
|
ship: plan.delivery === 'ship',
|
package/src/orchestrator.js
CHANGED
|
@@ -262,6 +262,7 @@ function buildPrOptions(options, requiredQualityGates) {
|
|
|
262
262
|
prBase: options.prBase || null,
|
|
263
263
|
mergeQueue: options.mergeQueue || false,
|
|
264
264
|
closeIssue: options.closeIssue || null,
|
|
265
|
+
prBody: typeof options.prBody === 'string' ? options.prBody : null,
|
|
265
266
|
gitRemote: options.gitRemote || null,
|
|
266
267
|
autoMerge,
|
|
267
268
|
...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
|
|
@@ -2038,6 +2039,7 @@ class Orchestrator {
|
|
|
2038
2039
|
prBase: options.prBase,
|
|
2039
2040
|
mergeQueue: options.mergeQueue,
|
|
2040
2041
|
closeIssue: options.closeIssue,
|
|
2042
|
+
prBody: options.prBody,
|
|
2041
2043
|
requiredQualityGates: options.requiredQualityGates,
|
|
2042
2044
|
autoMerge: resolveRunPlan(options).autoMerge,
|
|
2043
2045
|
gitRemote: gitContext?.remote || options.gitRemote,
|
|
@@ -2155,8 +2157,16 @@ class Orchestrator {
|
|
|
2155
2157
|
// NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
|
|
2156
2158
|
// Compose project — only a project scoped to the worktree directory basename, which is
|
|
2157
2159
|
// the only kind zeroshot could itself have created, is touched.
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
+
let teardown;
|
|
2161
|
+
try {
|
|
2162
|
+
const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
|
|
2163
|
+
teardown = resolveWorktreeComposeTeardown(worktreePath);
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
this._log(
|
|
2166
|
+
`[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${error.message}`
|
|
2167
|
+
);
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2160
2170
|
if (!teardown.shouldTeardown) {
|
|
2161
2171
|
if (teardown.composePath) {
|
|
2162
2172
|
this._log(
|
|
@@ -4217,7 +4227,7 @@ Continue from where you left off. Review your previous output to understand what
|
|
|
4217
4227
|
|
|
4218
4228
|
// Get issue context from ledger
|
|
4219
4229
|
const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' });
|
|
4220
|
-
const issueNumber = issueMsg?.content?.data?.
|
|
4230
|
+
const issueNumber = issueMsg?.content?.data?.issue_number || 'unknown';
|
|
4221
4231
|
const issueTitle = issueMsg?.content?.data?.title || 'Implementation';
|
|
4222
4232
|
|
|
4223
4233
|
// Generate the final prompt in one typed assembly pass. Issue values are
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const MAX_PR_BODY_LENGTH = 65536;
|
|
2
|
+
|
|
3
|
+
function normalizeIssueNumber(value) {
|
|
4
|
+
const candidate = typeof value === 'number' ? String(value) : value;
|
|
5
|
+
if (typeof candidate !== 'string') return 'unknown';
|
|
6
|
+
const trimmed = candidate.trim();
|
|
7
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) ? trimmed : 'unknown';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeIssueTitle(value) {
|
|
11
|
+
if (typeof value !== 'string' || value.trim() === '') return 'Implementation';
|
|
12
|
+
const normalized = [...value]
|
|
13
|
+
.map((character) => {
|
|
14
|
+
const codePoint = character.codePointAt(0);
|
|
15
|
+
return codePoint < 0x20 || codePoint === 0x7f ? ' ' : character;
|
|
16
|
+
})
|
|
17
|
+
.join('')
|
|
18
|
+
.trim();
|
|
19
|
+
return normalized || 'Implementation';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveIssueContext(options = {}) {
|
|
23
|
+
const issueNumber = normalizeIssueNumber(options.issueNumber);
|
|
24
|
+
const issueTitle = normalizeIssueTitle(options.issueTitle);
|
|
25
|
+
const issueReference =
|
|
26
|
+
options.includeIssueReference === false || issueNumber === 'unknown'
|
|
27
|
+
? ''
|
|
28
|
+
: `Closes #${issueNumber}`;
|
|
29
|
+
return { issueNumber, issueTitle, issueReference };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizePrBodyTemplate(value) {
|
|
33
|
+
if (value === undefined || value === null) return null;
|
|
34
|
+
if (typeof value !== 'string') throw new TypeError('PR body template must be a string');
|
|
35
|
+
if (value.includes('\0')) throw new TypeError('PR body template must not contain NUL bytes');
|
|
36
|
+
if (value.length > MAX_PR_BODY_LENGTH) {
|
|
37
|
+
throw new TypeError(`PR body template must not exceed ${MAX_PR_BODY_LENGTH} characters`);
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Render a bounded PR body. Missing issue metadata expands to empty text so
|
|
44
|
+
* manual tasks never leak internal sentinel values into pull requests.
|
|
45
|
+
*/
|
|
46
|
+
function renderPullRequestBody(template, options = {}) {
|
|
47
|
+
const issueContext = resolveIssueContext(options);
|
|
48
|
+
const normalizedTemplate = normalizePrBodyTemplate(template);
|
|
49
|
+
if (normalizedTemplate === null) return issueContext.issueReference;
|
|
50
|
+
|
|
51
|
+
const hasIssue = issueContext.issueNumber !== 'unknown';
|
|
52
|
+
const substitutions = {
|
|
53
|
+
'{{issue_number}}': hasIssue ? issueContext.issueNumber : '',
|
|
54
|
+
'{{issue_title}}': hasIssue ? issueContext.issueTitle : '',
|
|
55
|
+
'{{issue_reference}}': hasIssue ? issueContext.issueReference : '',
|
|
56
|
+
};
|
|
57
|
+
let rendered = normalizedTemplate;
|
|
58
|
+
for (const [token, replacement] of Object.entries(substitutions)) {
|
|
59
|
+
rendered = rendered.replaceAll(token, replacement);
|
|
60
|
+
}
|
|
61
|
+
if (rendered.length > MAX_PR_BODY_LENGTH) {
|
|
62
|
+
throw new TypeError(`Rendered PR body must not exceed ${MAX_PR_BODY_LENGTH} characters`);
|
|
63
|
+
}
|
|
64
|
+
return rendered;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
MAX_PR_BODY_LENGTH,
|
|
69
|
+
renderPullRequestBody,
|
|
70
|
+
resolveIssueContext,
|
|
71
|
+
};
|