@covibes/zeroshot 5.3.0 → 5.4.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.
Files changed (119) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +8 -385
  3. package/cli/index.js +108 -4615
  4. package/package.json +16 -125
  5. package/CHANGELOG.md +0 -537
  6. package/cli/commands/providers.js +0 -150
  7. package/cli/lib/first-run.js +0 -211
  8. package/cli/lib/update-checker.js +0 -281
  9. package/cli/message-formatter-utils.js +0 -75
  10. package/cli/message-formatters-normal.js +0 -253
  11. package/cli/message-formatters-watch.js +0 -181
  12. package/cluster-templates/base-templates/debug-workflow.json +0 -375
  13. package/cluster-templates/base-templates/full-workflow.json +0 -697
  14. package/cluster-templates/base-templates/single-worker.json +0 -63
  15. package/cluster-templates/base-templates/worker-validator.json +0 -152
  16. package/cluster-templates/conductor-bootstrap.json +0 -124
  17. package/docker/zeroshot-cluster/Dockerfile +0 -152
  18. package/docker/zeroshot-cluster/pre-baked-deps.json +0 -28
  19. package/lib/completion.js +0 -174
  20. package/lib/docker-config.js +0 -212
  21. package/lib/id-detector.js +0 -53
  22. package/lib/provider-detection.js +0 -59
  23. package/lib/provider-names.js +0 -56
  24. package/lib/settings.js +0 -405
  25. package/lib/stream-json-parser.js +0 -10
  26. package/scripts/fix-node-pty-permissions.js +0 -75
  27. package/scripts/record-demo.sh +0 -279
  28. package/scripts/test-install.sh +0 -40
  29. package/scripts/validate-templates.js +0 -100
  30. package/src/agent/agent-config.js +0 -193
  31. package/src/agent/agent-context-builder.js +0 -335
  32. package/src/agent/agent-hook-executor.js +0 -402
  33. package/src/agent/agent-lifecycle.js +0 -683
  34. package/src/agent/agent-stuck-detector.js +0 -256
  35. package/src/agent/agent-task-executor.js +0 -1431
  36. package/src/agent/agent-trigger-evaluator.js +0 -67
  37. package/src/agent/output-extraction.js +0 -200
  38. package/src/agent/output-reformatter.js +0 -175
  39. package/src/agent/schema-utils.js +0 -111
  40. package/src/agent-wrapper.js +0 -570
  41. package/src/agents/git-pusher-agent.json +0 -20
  42. package/src/attach/attach-client.js +0 -438
  43. package/src/attach/attach-server.js +0 -543
  44. package/src/attach/index.js +0 -35
  45. package/src/attach/protocol.js +0 -220
  46. package/src/attach/ring-buffer.js +0 -121
  47. package/src/attach/socket-discovery.js +0 -267
  48. package/src/claude-task-runner.js +0 -523
  49. package/src/config-router.js +0 -80
  50. package/src/config-validator.js +0 -1660
  51. package/src/copy-worker.js +0 -43
  52. package/src/github.js +0 -139
  53. package/src/isolation-manager.js +0 -1381
  54. package/src/ledger.js +0 -614
  55. package/src/logic-engine.js +0 -223
  56. package/src/message-bus-bridge.js +0 -139
  57. package/src/message-bus.js +0 -249
  58. package/src/name-generator.js +0 -232
  59. package/src/orchestrator.js +0 -2519
  60. package/src/preflight.js +0 -485
  61. package/src/process-metrics.js +0 -566
  62. package/src/providers/anthropic/cli-builder.js +0 -45
  63. package/src/providers/anthropic/index.js +0 -134
  64. package/src/providers/anthropic/models.js +0 -23
  65. package/src/providers/anthropic/output-parser.js +0 -159
  66. package/src/providers/base-provider.js +0 -181
  67. package/src/providers/capabilities.js +0 -51
  68. package/src/providers/google/cli-builder.js +0 -55
  69. package/src/providers/google/index.js +0 -116
  70. package/src/providers/google/models.js +0 -24
  71. package/src/providers/google/output-parser.js +0 -92
  72. package/src/providers/index.js +0 -75
  73. package/src/providers/openai/cli-builder.js +0 -122
  74. package/src/providers/openai/index.js +0 -135
  75. package/src/providers/openai/models.js +0 -21
  76. package/src/providers/openai/output-parser.js +0 -129
  77. package/src/schemas/sub-cluster.js +0 -156
  78. package/src/status-footer.js +0 -980
  79. package/src/sub-cluster-wrapper.js +0 -560
  80. package/src/task-runner.js +0 -30
  81. package/src/template-resolver.js +0 -369
  82. package/src/tui/CHANGES.txt +0 -133
  83. package/src/tui/LAYOUT.md +0 -261
  84. package/src/tui/README.txt +0 -192
  85. package/src/tui/TWO-LEVEL-NAVIGATION.md +0 -186
  86. package/src/tui/data-poller.js +0 -325
  87. package/src/tui/demo.js +0 -208
  88. package/src/tui/formatters.js +0 -128
  89. package/src/tui/index.js +0 -193
  90. package/src/tui/keybindings.js +0 -383
  91. package/src/tui/layout.js +0 -334
  92. package/src/tui/renderer.js +0 -194
  93. package/task-lib/attachable-watcher.js +0 -272
  94. package/task-lib/claude-recovery.js +0 -119
  95. package/task-lib/commands/clean.js +0 -50
  96. package/task-lib/commands/get-log-path.js +0 -23
  97. package/task-lib/commands/kill.js +0 -32
  98. package/task-lib/commands/list.js +0 -105
  99. package/task-lib/commands/logs.js +0 -411
  100. package/task-lib/commands/resume.js +0 -42
  101. package/task-lib/commands/run.js +0 -57
  102. package/task-lib/commands/schedule.js +0 -105
  103. package/task-lib/commands/scheduler-cmd.js +0 -96
  104. package/task-lib/commands/schedules.js +0 -98
  105. package/task-lib/commands/status.js +0 -44
  106. package/task-lib/commands/unschedule.js +0 -16
  107. package/task-lib/completion.js +0 -9
  108. package/task-lib/config.js +0 -10
  109. package/task-lib/name-generator.js +0 -230
  110. package/task-lib/package.json +0 -3
  111. package/task-lib/runner.js +0 -144
  112. package/task-lib/scheduler.js +0 -252
  113. package/task-lib/store.js +0 -230
  114. package/task-lib/tui/formatters.js +0 -166
  115. package/task-lib/tui/index.js +0 -197
  116. package/task-lib/tui/layout.js +0 -111
  117. package/task-lib/tui/renderer.js +0 -119
  118. package/task-lib/tui.js +0 -384
  119. package/task-lib/watcher.js +0 -188
@@ -1,1381 +0,0 @@
1
- /**
2
- * IsolationManager - Docker container lifecycle for isolated cluster execution
3
- *
4
- * Handles:
5
- * - Container creation with workspace mounts
6
- * - Credential injection for provider CLIs
7
- * - Command execution inside containers
8
- * - Container cleanup on stop/kill
9
- */
10
-
11
- const { spawn, execSync } = require('child_process');
12
- const { Worker } = require('worker_threads');
13
- const crypto = require('crypto');
14
- const path = require('path');
15
- const os = require('os');
16
- const fs = require('fs');
17
- const { loadSettings } = require('../lib/settings');
18
- const { normalizeProviderName } = require('../lib/provider-names');
19
- const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
20
- const { getProvider } = require('./providers');
21
-
22
- /**
23
- * Escape a string for safe use in shell commands
24
- * Prevents shell injection when passing dynamic values to execSync with shell: true
25
- * @param {string} str - String to escape
26
- * @returns {string} Shell-escaped string
27
- */
28
- function escapeShell(str) {
29
- // Replace single quotes with escaped version and wrap in single quotes
30
- // This is the safest approach for shell escaping
31
- return `'${str.replace(/'/g, "'\\''")}'`;
32
- }
33
-
34
- function expandHomePath(value) {
35
- if (!value) return value;
36
- if (value === '~') return os.homedir();
37
- return value.replace(/^~(?=\/|$)/, os.homedir());
38
- }
39
-
40
- function pathContains(base, target) {
41
- const resolvedBase = path.resolve(base);
42
- const resolvedTarget = path.resolve(target);
43
- if (resolvedBase === resolvedTarget) return true;
44
- return resolvedTarget.startsWith(resolvedBase + path.sep);
45
- }
46
-
47
- const DEFAULT_IMAGE = 'zeroshot-cluster-base';
48
-
49
- class IsolationManager {
50
- constructor(options = {}) {
51
- this.image = options.image || DEFAULT_IMAGE;
52
- this.containers = new Map(); // clusterId -> containerId
53
- this.isolatedDirs = new Map(); // clusterId -> { path, originalDir }
54
- this.clusterConfigDirs = new Map(); // clusterId -> configDirPath
55
- this.worktrees = new Map(); // clusterId -> { path, branch, repoRoot }
56
- }
57
-
58
- /**
59
- * Get GitHub token from gh CLI config (hosts.yml)
60
- * Works with older gh CLI versions that don't have `gh auth token` command
61
- * @returns {string|null}
62
- * @private
63
- */
64
- _getGhToken() {
65
- try {
66
- const hostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
67
- if (!fs.existsSync(hostsPath)) return null;
68
-
69
- const content = fs.readFileSync(hostsPath, 'utf8');
70
- // Match oauth_token: <token> in YAML
71
- const match = content.match(/oauth_token:\s*(\S+)/);
72
- return match ? match[1] : null;
73
- } catch {
74
- return null;
75
- }
76
- }
77
-
78
- /**
79
- * Create and start a container for a cluster
80
- * @param {string} clusterId - Cluster ID
81
- * @param {object} config - Container config
82
- * @param {string} config.workDir - Working directory to mount
83
- * @param {string} [config.image] - Docker image (default: zeroshot-cluster-base)
84
- * @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
85
- * @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
86
- * @param {boolean} [config.noMounts=false] - Disable all credential mounts
87
- * @param {string} [config.provider] - Provider name for credential warnings
88
- * @returns {Promise<string>} Container ID
89
- */
90
- async createContainer(clusterId, config) {
91
- const image = config.image || this.image;
92
- let workDir = config.workDir || process.cwd();
93
- const containerName = `zeroshot-cluster-${clusterId}`;
94
- const reuseExisting = config.reuseExistingWorkspace || false;
95
-
96
- // Check if container already exists
97
- if (this.containers.has(clusterId)) {
98
- const existingId = this.containers.get(clusterId);
99
- if (this._isContainerRunning(existingId)) {
100
- return existingId;
101
- }
102
- }
103
-
104
- // Clean up any existing container with same name
105
- this._removeContainerByName(containerName);
106
-
107
- // For isolation mode: copy files to temp dir with fresh git repo (100% isolated)
108
- // No worktrees - cleaner, no host path dependencies
109
- // EXCEPTION: On resume (reuseExisting=true), skip copy and use existing workspace
110
- if (this._isGitRepo(workDir)) {
111
- const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
112
-
113
- if (reuseExisting && fs.existsSync(isolatedPath)) {
114
- // Resume mode: reuse existing isolated workspace (contains agent's work)
115
- console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
116
- this.isolatedDirs = this.isolatedDirs || new Map();
117
- this.isolatedDirs.set(clusterId, {
118
- path: isolatedPath,
119
- originalDir: workDir,
120
- });
121
- workDir = isolatedPath;
122
- } else {
123
- // Fresh start: create new isolated copy
124
- const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
125
- this.isolatedDirs = this.isolatedDirs || new Map();
126
- this.isolatedDirs.set(clusterId, {
127
- path: isolatedDir,
128
- originalDir: workDir,
129
- });
130
- workDir = isolatedDir;
131
- console.log(`[IsolationManager] Created isolated copy at ${workDir}`);
132
- }
133
- }
134
-
135
- // Resolve container home directory EARLY - needed for Claude config mount and hooks
136
- const settings = loadSettings();
137
- const providerName = normalizeProviderName(
138
- config.provider || settings.defaultProvider || 'claude'
139
- );
140
- const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
141
-
142
- // Create fresh Claude config dir for this cluster (avoids permission issues from host)
143
- const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
144
- console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
145
-
146
- // Build docker run command
147
- // NOTE: Container runs as 'node' user (uid 1000) for --dangerously-skip-permissions
148
- const args = [
149
- 'run',
150
- '-d', // detached
151
- '--name',
152
- containerName,
153
- // Mount workspace
154
- '-v',
155
- `${workDir}:/workspace`,
156
- // Mount Docker socket for Docker-in-Docker (e2e tests need docker compose)
157
- '-v',
158
- '/var/run/docker.sock:/var/run/docker.sock',
159
- // Add node user to host's docker group (fixes permission denied)
160
- // CRITICAL: Without this, agent can't run docker commands inside container
161
- '--group-add',
162
- this._getDockerGid(),
163
- // Mount fresh Claude config to container user's home (read-write - Claude CLI writes settings, todos, etc.)
164
- '-v',
165
- `${clusterConfigDir}:${containerHome}/.claude`,
166
- ];
167
-
168
- const mountedHosts = [];
169
-
170
- // Add configurable credential mounts
171
- // Priority: CLI config > env var > settings > defaults
172
- if (!config.noMounts) {
173
- let mountConfig;
174
-
175
- if (config.mounts) {
176
- // CLI override
177
- mountConfig = config.mounts;
178
- } else if (process.env.ZEROSHOT_DOCKER_MOUNTS) {
179
- // Environment override
180
- try {
181
- mountConfig = JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
182
- } catch {
183
- console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
184
- mountConfig = settings.dockerMounts;
185
- }
186
- } else {
187
- // User settings
188
- mountConfig = settings.dockerMounts;
189
- }
190
-
191
- // Resolve presets to actual mount specs (containerHome already resolved above)
192
- const mounts = resolveMounts(mountConfig, { containerHome });
193
- const claudeContainerPath = path.posix.join(containerHome, '.claude');
194
-
195
- for (const mount of mounts) {
196
- if (mount.container === claudeContainerPath) {
197
- console.warn(
198
- `[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
199
- '(Claude config is managed by zeroshot).'
200
- );
201
- continue;
202
- }
203
-
204
- const hostPath = expandHomePath(mount.host);
205
-
206
- // Check path exists and is mountable
207
- try {
208
- const stat = fs.statSync(hostPath);
209
- // Files ending in 'config' must be files (some systems have dirs)
210
- if (hostPath.endsWith('config') && !stat.isFile()) {
211
- continue;
212
- }
213
- } catch {
214
- // Path doesn't exist - skip silently
215
- continue;
216
- }
217
-
218
- const mountSpec = mount.readonly
219
- ? `${hostPath}:${mount.container}:ro`
220
- : `${hostPath}:${mount.container}`;
221
- args.push('-v', mountSpec);
222
- mountedHosts.push(hostPath);
223
- }
224
-
225
- // Pass env vars based on enabled presets
226
- const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
227
- for (const spec of envSpecs) {
228
- if (spec.forced) {
229
- // Forced value - always pass with specified value
230
- args.push('-e', `${spec.name}=${spec.value}`);
231
- } else if (process.env[spec.name]) {
232
- // Dynamic value - only pass if set in environment
233
- args.push('-e', `${spec.name}=${process.env[spec.name]}`);
234
- }
235
- }
236
- }
237
-
238
- // Warn when provider credentials are likely missing
239
- if (providerName !== 'claude') {
240
- const provider = getProvider(providerName);
241
- const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
242
- const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
243
- const hasCredentialMount = mountedHosts.some((hostPath) =>
244
- expandedCreds.some(
245
- (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
246
- )
247
- );
248
-
249
- if (!hasCredentialMount && expandedCreds.length > 0) {
250
- const exampleHost = credentialPaths[0];
251
- const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
252
- const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
253
- console.warn(
254
- `[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
255
- `Add one with --mount ${exampleHost}:${exampleContainer}:ro`
256
- );
257
- }
258
- }
259
-
260
- // Finish docker args
261
- args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
262
-
263
- return new Promise((resolve, reject) => {
264
- const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
265
-
266
- let stdout = '';
267
- let stderr = '';
268
-
269
- proc.stdout.on('data', (data) => {
270
- stdout += data;
271
- });
272
- proc.stderr.on('data', (data) => {
273
- stderr += data;
274
- });
275
-
276
- proc.on('close', async (code) => {
277
- if (code === 0) {
278
- const containerId = stdout.trim().substring(0, 12);
279
- this.containers.set(clusterId, containerId);
280
-
281
- // Install dependencies if package.json exists
282
- // This enables e2e tests and other npm-based tools to run
283
- // OPTIMIZATION: Use pre-baked deps when possible (30-40% faster startup)
284
- // See: GitHub issue #20
285
- try {
286
- console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
287
- if (fs.existsSync(path.join(workDir, 'package.json'))) {
288
- await this._installDependenciesWithRetry(clusterId);
289
- }
290
- } catch (err) {
291
- console.warn(
292
- `[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
293
- );
294
- }
295
-
296
- resolve(containerId);
297
- } else {
298
- reject(new Error(`Failed to create container: ${stderr}`));
299
- }
300
- });
301
-
302
- proc.on('error', (err) => {
303
- reject(new Error(`Docker spawn error: ${err.message}`));
304
- });
305
- });
306
- }
307
-
308
- async _installDependenciesWithRetry(clusterId) {
309
- console.log(`[IsolationManager] Installing npm dependencies in container...`);
310
-
311
- const maxRetries = 3;
312
- const baseDelay = 2000; // 2 seconds
313
- const installCommand = [
314
- 'sh',
315
- '-c',
316
- [
317
- 'if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then',
318
- 'echo "__deps_present__";',
319
- 'exit 0;',
320
- 'fi;',
321
- 'if ! command -v npm >/dev/null 2>&1; then',
322
- 'echo "__npm_missing__";',
323
- 'exit 127;',
324
- 'fi;',
325
- 'if [ -d /pre-baked-deps/node_modules ]; then',
326
- 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true;',
327
- 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline;',
328
- 'install_code=$?;',
329
- 'if [ $install_code -ne 0 ]; then',
330
- 'rm -rf node_modules;',
331
- 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
332
- 'fi;',
333
- 'else',
334
- 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
335
- 'fi',
336
- ].join(' '),
337
- ];
338
-
339
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
340
- try {
341
- const installResult = await this.execInContainer(clusterId, installCommand, {});
342
- const stdout = installResult.stdout || '';
343
-
344
- if (installResult.code === 0) {
345
- if (stdout.includes('__deps_present__')) {
346
- console.log(
347
- `[IsolationManager] ✓ Dependencies already installed (skipping npm install)`
348
- );
349
- } else {
350
- console.log(`[IsolationManager] ✓ Dependencies installed`);
351
- }
352
- return;
353
- }
354
-
355
- const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
356
- if (attempt < maxRetries) {
357
- const delay = baseDelay * Math.pow(2, attempt - 1);
358
- console.warn(
359
- `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
360
- );
361
- console.warn(`[IsolationManager] Error: ${errorOutput}`);
362
- await new Promise((resolve) => setTimeout(resolve, delay));
363
- } else {
364
- console.warn(
365
- `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
366
- );
367
- }
368
- } catch (execErr) {
369
- if (attempt < maxRetries) {
370
- const delay = baseDelay * Math.pow(2, attempt - 1);
371
- console.warn(
372
- `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
373
- );
374
- console.warn(`[IsolationManager] Error: ${execErr.message}`);
375
- await new Promise((resolve) => setTimeout(resolve, delay));
376
- } else {
377
- throw execErr;
378
- }
379
- }
380
- }
381
- }
382
-
383
- /**
384
- * Execute a command inside the container
385
- * @param {string} clusterId - Cluster ID
386
- * @param {string[]} command - Command and arguments
387
- * @param {object} [options] - Exec options
388
- * @param {boolean} [options.interactive] - Use -it flags
389
- * @param {object} [options.env] - Environment variables
390
- * @returns {Promise<{stdout: string, stderr: string, code: number}>}
391
- */
392
- execInContainer(clusterId, command, options = {}) {
393
- const containerId = this.containers.get(clusterId);
394
- if (!containerId) {
395
- throw new Error(`No container found for cluster ${clusterId}`);
396
- }
397
-
398
- const args = ['exec'];
399
-
400
- if (options.interactive) {
401
- args.push('-it');
402
- }
403
-
404
- // Add environment variables
405
- if (options.env) {
406
- for (const [key, value] of Object.entries(options.env)) {
407
- args.push('-e', `${key}=${value}`);
408
- }
409
- }
410
-
411
- args.push(containerId, ...command);
412
-
413
- return new Promise((resolve, reject) => {
414
- const proc = spawn('docker', args, {
415
- stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
416
- });
417
-
418
- let stdout = '';
419
- let stderr = '';
420
-
421
- if (!options.interactive) {
422
- proc.stdout.on('data', (data) => {
423
- stdout += data;
424
- });
425
- proc.stderr.on('data', (data) => {
426
- stderr += data;
427
- });
428
- }
429
-
430
- proc.on('close', (code) => {
431
- resolve({ stdout, stderr, code });
432
- });
433
-
434
- proc.on('error', (err) => {
435
- reject(new Error(`Docker exec error: ${err.message}`));
436
- });
437
- });
438
- }
439
-
440
- /**
441
- * Spawn a PTY-like process inside the container
442
- * Returns a child process that can be used like a PTY
443
- * @param {string} clusterId - Cluster ID
444
- * @param {string[]} command - Command and arguments
445
- * @param {object} [options] - Spawn options
446
- * @returns {ChildProcess}
447
- */
448
- spawnInContainer(clusterId, command, options = {}) {
449
- const containerId = this.containers.get(clusterId);
450
- if (!containerId) {
451
- throw new Error(`No container found for cluster ${clusterId}`);
452
- }
453
-
454
- // IMPORTANT: Must use -i flag for interactive stdin/stdout communication with commands like 'cat'
455
- // If omitted, docker exec will not properly connect stdin, causing piped input to be ignored
456
- // This is required for PTY-like behavior where child process stdin/stdout are used
457
- const args = ['exec', '-i'];
458
-
459
- // Add environment variables
460
- if (options.env) {
461
- for (const [key, value] of Object.entries(options.env)) {
462
- args.push('-e', `${key}=${value}`);
463
- }
464
- }
465
-
466
- args.push(containerId, ...command);
467
-
468
- return spawn('docker', args, {
469
- stdio: ['pipe', 'pipe', 'pipe'],
470
- ...options.spawnOptions,
471
- });
472
- }
473
-
474
- /**
475
- * Stop a container
476
- * @param {string} clusterId - Cluster ID
477
- * @param {number} [timeout=10] - Timeout in seconds before SIGKILL
478
- * @returns {Promise<void>}
479
- */
480
- stopContainer(clusterId, timeout = 10, explicitContainerId = null) {
481
- // Use explicit containerId (from restored state) or in-memory Map
482
- const containerId = explicitContainerId || this.containers.get(clusterId);
483
- if (!containerId) {
484
- return; // Already stopped or never started
485
- }
486
-
487
- return new Promise((resolve) => {
488
- const proc = spawn('docker', ['stop', '-t', String(timeout), containerId], {
489
- stdio: ['pipe', 'pipe', 'pipe'],
490
- });
491
-
492
- proc.on('close', () => {
493
- resolve();
494
- });
495
-
496
- proc.on('error', () => {
497
- resolve(); // Ignore errors on stop
498
- });
499
- });
500
- }
501
-
502
- /**
503
- * Remove a container
504
- * @param {string} clusterId - Cluster ID
505
- * @param {boolean} [force=false] - Force remove running container
506
- * @returns {Promise<void>}
507
- */
508
- removeContainer(clusterId, force = false, explicitContainerId = null) {
509
- // Use explicit containerId (from restored state) or in-memory Map
510
- const containerId = explicitContainerId || this.containers.get(clusterId);
511
- if (!containerId) {
512
- return;
513
- }
514
-
515
- const args = ['rm'];
516
- if (force) {
517
- args.push('-f');
518
- }
519
- args.push(containerId);
520
-
521
- return new Promise((resolve) => {
522
- const proc = spawn('docker', args, {
523
- stdio: ['pipe', 'pipe', 'pipe'],
524
- });
525
-
526
- proc.on('close', () => {
527
- this.containers.delete(clusterId);
528
- resolve();
529
- });
530
-
531
- proc.on('error', () => {
532
- this.containers.delete(clusterId);
533
- resolve();
534
- });
535
- });
536
- }
537
-
538
- /**
539
- * Stop and remove a container, and optionally clean up isolated dir/config
540
- * @param {string} clusterId - Cluster ID
541
- * @param {object} [options] - Cleanup options
542
- * @param {boolean} [options.preserveWorkspace=false] - If true, keep the isolated workspace (for resume capability)
543
- * @returns {Promise<void>}
544
- */
545
- async cleanup(clusterId, options = {}) {
546
- const preserveWorkspace = options.preserveWorkspace || false;
547
-
548
- await this.stopContainer(clusterId);
549
- await this.removeContainer(clusterId);
550
-
551
- // Clean up isolated directory if one was created (unless preserveWorkspace is set)
552
- if (this.isolatedDirs?.has(clusterId)) {
553
- const isolatedInfo = this.isolatedDirs.get(clusterId);
554
-
555
- if (preserveWorkspace) {
556
- console.log(
557
- `[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
558
- );
559
- // Don't delete - but DON'T remove from Map either, resume() needs it
560
- } else {
561
- console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
562
-
563
- // Preserve Terraform state before deleting isolated directory
564
- this._preserveTerraformState(clusterId, isolatedInfo.path);
565
-
566
- // Remove the isolated directory
567
- try {
568
- fs.rmSync(isolatedInfo.path, { recursive: true, force: true });
569
- } catch {
570
- // Ignore
571
- }
572
- this.isolatedDirs.delete(clusterId);
573
- }
574
- }
575
-
576
- // Clean up cluster config dir (always - it's recreated on resume)
577
- this._cleanupClusterConfigDir(clusterId);
578
- }
579
-
580
- /**
581
- * Create an isolated copy of a directory with fresh git repo
582
- * @private
583
- * @param {string} clusterId - Cluster ID
584
- * @param {string} sourceDir - Source directory to copy
585
- * @returns {Promise<string>} Path to isolated directory
586
- */
587
- async _createIsolatedCopy(clusterId, sourceDir) {
588
- const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
589
-
590
- // Clean up existing dir
591
- if (fs.existsSync(isolatedPath)) {
592
- fs.rmSync(isolatedPath, { recursive: true, force: true });
593
- }
594
-
595
- // Create directory
596
- fs.mkdirSync(isolatedPath, { recursive: true });
597
-
598
- // Copy files (excluding .git and common build artifacts)
599
- await this._copyDirExcluding(sourceDir, isolatedPath, [
600
- '.git',
601
- 'node_modules',
602
- '.next',
603
- 'dist',
604
- 'build',
605
- '__pycache__',
606
- '.pytest_cache',
607
- '.mypy_cache',
608
- '.ruff_cache',
609
- '.venv',
610
- 'venv',
611
- '.tox',
612
- '.eggs',
613
- '*.egg-info',
614
- 'coverage',
615
- '.coverage',
616
- '.nyc_output',
617
- '.DS_Store',
618
- 'Thumbs.db',
619
- ]);
620
-
621
- // Get remote URL from original repo (for PR creation)
622
- let remoteUrl = null;
623
- try {
624
- remoteUrl = execSync('git remote get-url origin', {
625
- cwd: sourceDir,
626
- encoding: 'utf8',
627
- stdio: 'pipe',
628
- }).trim();
629
- } catch {
630
- // No remote configured in source
631
- }
632
-
633
- // Initialize fresh git repo with all setup in a single batched command
634
- // This reduces ~500ms overhead (5 execSync calls @ ~100ms each) to ~100ms (1 call)
635
- // Issue #22: Batch git operations for 5-10% startup reduction
636
- const branchName = `zeroshot/${clusterId}`;
637
-
638
- // Build authenticated remote URL if source had one (needed for git push / PR creation)
639
- let authRemoteUrl = null;
640
- if (remoteUrl) {
641
- authRemoteUrl = remoteUrl;
642
- const token = this._getGhToken();
643
- if (token && remoteUrl.startsWith('https://github.com/')) {
644
- // Convert https://github.com/org/repo.git to https://x-access-token:TOKEN@github.com/org/repo.git
645
- authRemoteUrl = remoteUrl.replace(
646
- 'https://github.com/',
647
- `https://x-access-token:${token}@github.com/`
648
- );
649
- }
650
- }
651
-
652
- // Batch all git operations into a single shell command
653
- // Using --allow-empty on commit to handle edge case of empty directories
654
- const gitCommands = [
655
- 'git init',
656
- authRemoteUrl ? `git remote add origin ${escapeShell(authRemoteUrl)}` : null,
657
- 'git add -A',
658
- 'git commit -m "Initial commit (isolated copy)" --allow-empty',
659
- `git checkout -b ${escapeShell(branchName)}`,
660
- ]
661
- .filter(Boolean)
662
- .join(' && ');
663
-
664
- execSync(gitCommands, {
665
- cwd: isolatedPath,
666
- stdio: 'pipe',
667
- shell: '/bin/bash',
668
- });
669
-
670
- return isolatedPath;
671
- }
672
-
673
- /**
674
- * Copy directory excluding certain paths using parallel worker threads
675
- * Supports exact matches and glob patterns (*.ext)
676
- *
677
- * Performance optimization for large repos (10k+ files):
678
- * - Phase 1: Collect all files async (non-blocking traversal)
679
- * - Phase 2: Create directory structure (must be sequential)
680
- * - Phase 3: Copy files in parallel using worker threads
681
- *
682
- * @private
683
- * @param {string} src - Source directory
684
- * @param {string} dest - Destination directory
685
- * @param {string[]} exclude - Patterns to exclude
686
- * @returns {Promise<void>}
687
- */
688
- async _copyDirExcluding(src, dest, exclude) {
689
- // Phase 1: Collect all files and directories
690
- const files = [];
691
- const directories = new Set();
692
-
693
- const collectFiles = (currentSrc, relativePath = '') => {
694
- let entries;
695
- try {
696
- entries = fs.readdirSync(currentSrc, { withFileTypes: true });
697
- } catch (err) {
698
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
699
- return;
700
- }
701
- throw err;
702
- }
703
-
704
- for (const entry of entries) {
705
- // Check exclusions (exact match or glob pattern)
706
- const shouldExclude = exclude.some((pattern) => {
707
- if (pattern.startsWith('*.')) {
708
- return entry.name.endsWith(pattern.slice(1));
709
- }
710
- return entry.name === pattern;
711
- });
712
- if (shouldExclude) continue;
713
-
714
- const srcPath = path.join(currentSrc, entry.name);
715
- const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
716
-
717
- try {
718
- // Handle symlinks: resolve to actual target
719
- if (entry.isSymbolicLink()) {
720
- const targetStats = fs.statSync(srcPath);
721
- if (targetStats.isDirectory()) {
722
- directories.add(relPath);
723
- collectFiles(srcPath, relPath);
724
- } else {
725
- files.push(relPath);
726
- // Ensure parent directory is tracked
727
- if (relativePath) directories.add(relativePath);
728
- }
729
- } else if (entry.isDirectory()) {
730
- directories.add(relPath);
731
- collectFiles(srcPath, relPath);
732
- } else {
733
- files.push(relPath);
734
- // Ensure parent directory is tracked
735
- if (relativePath) directories.add(relativePath);
736
- }
737
- } catch (err) {
738
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
739
- continue;
740
- }
741
- throw err;
742
- }
743
- }
744
- };
745
-
746
- collectFiles(src);
747
-
748
- // Phase 2: Create directory structure (sequential - must exist before file copy)
749
- // Sort directories by depth to ensure parents are created before children
750
- const sortedDirs = Array.from(directories).sort((a, b) => {
751
- const depthA = a.split(path.sep).length;
752
- const depthB = b.split(path.sep).length;
753
- return depthA - depthB;
754
- });
755
-
756
- for (const dir of sortedDirs) {
757
- const destDir = path.join(dest, dir);
758
- try {
759
- fs.mkdirSync(destDir, { recursive: true });
760
- } catch (err) {
761
- if (err.code !== 'EEXIST') {
762
- throw err;
763
- }
764
- }
765
- }
766
-
767
- // Phase 3: Copy files in parallel using worker threads
768
- // For small file counts (<100), use synchronous copy (worker overhead not worth it)
769
- if (files.length < 100) {
770
- for (const relPath of files) {
771
- const srcPath = path.join(src, relPath);
772
- const destPath = path.join(dest, relPath);
773
- try {
774
- fs.copyFileSync(srcPath, destPath);
775
- } catch (err) {
776
- if (err.code !== 'EACCES' && err.code !== 'EPERM' && err.code !== 'ENOENT') {
777
- throw err;
778
- }
779
- }
780
- }
781
- return;
782
- }
783
-
784
- // Use worker threads for larger file counts
785
- const numWorkers = Math.min(4, os.cpus().length);
786
- const chunkSize = Math.ceil(files.length / numWorkers);
787
- const workerPath = path.join(__dirname, 'copy-worker.js');
788
-
789
- // Split files into chunks for workers
790
- const chunks = [];
791
- for (let i = 0; i < files.length; i += chunkSize) {
792
- chunks.push(files.slice(i, i + chunkSize));
793
- }
794
-
795
- // Spawn workers and wait for completion
796
- const workerPromises = chunks.map((chunk) => {
797
- return new Promise((resolve, reject) => {
798
- const worker = new Worker(workerPath, {
799
- workerData: {
800
- files: chunk,
801
- sourceBase: src,
802
- destBase: dest,
803
- },
804
- });
805
-
806
- worker.on('message', (result) => {
807
- resolve(result);
808
- });
809
-
810
- worker.on('error', (err) => {
811
- reject(err);
812
- });
813
-
814
- worker.on('exit', (code) => {
815
- if (code !== 0) {
816
- reject(new Error(`Worker exited with code ${code}`));
817
- }
818
- });
819
- });
820
- });
821
-
822
- // Wait for all workers to complete (proper async/await - no busy-wait!)
823
- // FIX: Previous version used busy-wait which blocked the event loop,
824
- // preventing worker thread messages from being processed (timeout bug)
825
- await Promise.all(workerPromises);
826
- }
827
-
828
- /**
829
- * Get container ID for a cluster
830
- * @param {string} clusterId - Cluster ID
831
- * @returns {string|undefined}
832
- */
833
- getContainerId(clusterId) {
834
- return this.containers.get(clusterId);
835
- }
836
-
837
- /**
838
- * Check if a cluster has an active container
839
- * @param {string} clusterId - Cluster ID
840
- * @returns {boolean}
841
- */
842
- hasContainer(clusterId) {
843
- const containerId = this.containers.get(clusterId);
844
- if (!containerId) return false;
845
- return this._isContainerRunning(containerId);
846
- }
847
-
848
- /**
849
- * Get Claude config directory
850
- * @private
851
- */
852
- _getClaudeConfigDir() {
853
- return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
854
- }
855
-
856
- /**
857
- * Create a fresh Claude config directory for a cluster (avoids permission issues from host)
858
- * Copies only essential files: .credentials.json
859
- * @private
860
- * @param {string} clusterId - Cluster ID
861
- * @param {string} containerHome - Container home directory path (e.g., '/root' or '/home/node')
862
- * @returns {string} Path to cluster-specific config directory
863
- */
864
- _createClusterConfigDir(clusterId, containerHome = '/root') {
865
- const sourceDir = this._getClaudeConfigDir();
866
- const configDir = path.join(os.tmpdir(), 'zeroshot-cluster-configs', clusterId);
867
-
868
- // Clean up existing dir
869
- if (fs.existsSync(configDir)) {
870
- fs.rmSync(configDir, { recursive: true, force: true });
871
- }
872
-
873
- // Create fresh directory and required subdirectories
874
- fs.mkdirSync(configDir, { recursive: true });
875
- const hooksDir = path.join(configDir, 'hooks');
876
- fs.mkdirSync(hooksDir, { recursive: true });
877
- // CRITICAL: Claude CLI writes session files to projects/ subdirectory
878
- const projectsDir = path.join(configDir, 'projects');
879
- fs.mkdirSync(projectsDir, { recursive: true });
880
-
881
- // Copy only credentials file (essential for auth)
882
- const credentialsFile = path.join(sourceDir, '.credentials.json');
883
- if (fs.existsSync(credentialsFile)) {
884
- fs.copyFileSync(credentialsFile, path.join(configDir, '.credentials.json'));
885
- }
886
-
887
- // Copy hook script to block AskUserQuestion (CRITICAL for autonomous execution)
888
- const hookScriptSrc = path.join(__dirname, '..', 'hooks', 'block-ask-user-question.py');
889
- const hookScriptDst = path.join(hooksDir, 'block-ask-user-question.py');
890
- if (fs.existsSync(hookScriptSrc)) {
891
- fs.copyFileSync(hookScriptSrc, hookScriptDst);
892
- fs.chmodSync(hookScriptDst, 0o755);
893
- }
894
-
895
- // Create settings.json with PreToolUse hook to block AskUserQuestion
896
- // This PREVENTS agents from asking questions in non-interactive mode
897
- const clusterSettings = {
898
- hooks: {
899
- PreToolUse: [
900
- {
901
- matcher: 'AskUserQuestion',
902
- hooks: [
903
- {
904
- type: 'command',
905
- command: `${containerHome}/.claude/hooks/block-ask-user-question.py`,
906
- },
907
- ],
908
- },
909
- ],
910
- },
911
- };
912
- fs.writeFileSync(
913
- path.join(configDir, 'settings.json'),
914
- JSON.stringify(clusterSettings, null, 2)
915
- );
916
-
917
- // Track for cleanup
918
- this.clusterConfigDirs = this.clusterConfigDirs || new Map();
919
- this.clusterConfigDirs.set(clusterId, configDir);
920
-
921
- return configDir;
922
- }
923
-
924
- /**
925
- * Clean up cluster config directory
926
- * @private
927
- * @param {string} clusterId - Cluster ID
928
- */
929
- _cleanupClusterConfigDir(clusterId) {
930
- if (!this.clusterConfigDirs?.has(clusterId)) return;
931
-
932
- const configDir = this.clusterConfigDirs.get(clusterId);
933
- try {
934
- fs.rmSync(configDir, { recursive: true, force: true });
935
- } catch {
936
- // Ignore
937
- }
938
- this.clusterConfigDirs.delete(clusterId);
939
- }
940
-
941
- /**
942
- * Preserve Terraform state files before cleanup
943
- * Checks both terraform/ subdirectory and root directory
944
- * @private
945
- * @param {string} clusterId - Cluster ID
946
- * @param {string} isolatedPath - Path to isolated directory
947
- */
948
- _preserveTerraformState(clusterId, isolatedPath) {
949
- const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
950
- const checkDirs = [isolatedPath, path.join(isolatedPath, 'terraform')];
951
-
952
- let foundState = false;
953
-
954
- for (const checkDir of checkDirs) {
955
- if (!fs.existsSync(checkDir)) continue;
956
-
957
- const hasStateFiles = stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
958
-
959
- if (hasStateFiles) {
960
- const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
961
- fs.mkdirSync(stateDir, { recursive: true });
962
-
963
- for (const file of stateFiles) {
964
- const srcPath = path.join(checkDir, file);
965
- if (fs.existsSync(srcPath)) {
966
- const destPath = path.join(stateDir, file);
967
- try {
968
- fs.copyFileSync(srcPath, destPath);
969
- console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
970
- foundState = true;
971
- } catch (err) {
972
- console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
973
- }
974
- }
975
- }
976
- break; // Only backup from first dir with state files
977
- }
978
- }
979
-
980
- if (!foundState) {
981
- console.log(`[IsolationManager] No Terraform state found to preserve`);
982
- }
983
- }
984
-
985
- /**
986
- * Get host's docker group GID (for Docker socket access inside container)
987
- * @private
988
- * @returns {string} Docker group GID
989
- */
990
- _getDockerGid() {
991
- try {
992
- // Get docker group info: "docker:x:999:user1,user2"
993
- const result = execSync('getent group docker', { encoding: 'utf8' });
994
- const gid = result.split(':')[2];
995
- return gid.trim();
996
- } catch {
997
- // Fallback: common docker GID is 999
998
- console.warn('[IsolationManager] Could not detect docker GID, using default 999');
999
- return '999';
1000
- }
1001
- }
1002
-
1003
- /**
1004
- * Check if a container is running
1005
- * @private
1006
- */
1007
- _isContainerRunning(containerId) {
1008
- try {
1009
- const result = execSync(
1010
- `docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`,
1011
- {
1012
- encoding: 'utf8',
1013
- }
1014
- );
1015
- return result.trim() === 'true';
1016
- } catch {
1017
- return false;
1018
- }
1019
- }
1020
-
1021
- /**
1022
- * Remove container by name (cleanup before create)
1023
- * @private
1024
- */
1025
- _removeContainerByName(name) {
1026
- try {
1027
- execSync(`docker rm -f ${escapeShell(name)} 2>/dev/null`, { encoding: 'utf8' });
1028
- } catch {
1029
- // Ignore - container doesn't exist
1030
- }
1031
- }
1032
-
1033
- /**
1034
- * Check if Docker is available
1035
- * @returns {boolean}
1036
- */
1037
- static isDockerAvailable() {
1038
- try {
1039
- // Require both CLI binary and a reachable daemon.
1040
- execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
1041
- return true;
1042
- } catch {
1043
- return false;
1044
- }
1045
- }
1046
-
1047
- /**
1048
- * Check if the base image exists
1049
- * @param {string} [image] - Image name to check
1050
- * @returns {boolean}
1051
- */
1052
- static imageExists(image = DEFAULT_IMAGE) {
1053
- try {
1054
- execSync(`docker image inspect ${escapeShell(image)} 2>/dev/null`, {
1055
- encoding: 'utf8',
1056
- stdio: 'pipe',
1057
- });
1058
- return true;
1059
- } catch {
1060
- return false;
1061
- }
1062
- }
1063
-
1064
- /**
1065
- * Build the Docker image with retry logic
1066
- * @param {string} [image] - Image name to build
1067
- * @param {number} [maxRetries=3] - Maximum retry attempts
1068
- * @returns {Promise<void>}
1069
- */
1070
- static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3) {
1071
- // Repository root is one level up from src/
1072
- const repoRoot = path.join(__dirname, '..');
1073
- const dockerfilePath = path.join(repoRoot, 'docker', 'zeroshot-cluster', 'Dockerfile');
1074
-
1075
- if (!fs.existsSync(dockerfilePath)) {
1076
- throw new Error(`Dockerfile not found at ${dockerfilePath}`);
1077
- }
1078
-
1079
- console.log(`[IsolationManager] Building Docker image '${image}'...`);
1080
-
1081
- const baseDelay = 3000; // 3 seconds
1082
-
1083
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
1084
- try {
1085
- // CRITICAL: Run from repo root so build context includes package.json and src/
1086
- // Use -f flag to specify Dockerfile location
1087
- execSync(`docker build -f docker/zeroshot-cluster/Dockerfile -t ${escapeShell(image)} .`, {
1088
- cwd: repoRoot,
1089
- encoding: 'utf8',
1090
- stdio: 'inherit',
1091
- });
1092
-
1093
- console.log(`[IsolationManager] ✓ Image '${image}' built successfully`);
1094
- return;
1095
- } catch (err) {
1096
- if (attempt < maxRetries) {
1097
- const delay = baseDelay * Math.pow(2, attempt - 1);
1098
- console.warn(
1099
- `[IsolationManager] ⚠️ Docker build failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
1100
- );
1101
- console.warn(`[IsolationManager] Error: ${err.message}`);
1102
- await new Promise((resolve) => setTimeout(resolve, delay));
1103
- } else {
1104
- throw new Error(
1105
- `Failed to build Docker image '${image}' after ${maxRetries} attempts: ${err.message}`
1106
- );
1107
- }
1108
- }
1109
- }
1110
- }
1111
-
1112
- /**
1113
- * Ensure Docker image exists, building it if necessary
1114
- * @param {string} [image] - Image name to ensure
1115
- * @param {boolean} [autoBuild=true] - Auto-build if missing
1116
- * @returns {Promise<void>}
1117
- */
1118
- static async ensureImage(image = DEFAULT_IMAGE, autoBuild = true) {
1119
- if (this.imageExists(image)) {
1120
- return;
1121
- }
1122
-
1123
- if (!autoBuild) {
1124
- throw new Error(
1125
- `Docker image '${image}' not found. Build it with:\n` +
1126
- ` docker build -t ${image} zeroshot/cluster/docker/zeroshot-cluster/`
1127
- );
1128
- }
1129
-
1130
- console.log(`[IsolationManager] Image '${image}' not found, building automatically...`);
1131
- await this.buildImage(image);
1132
- }
1133
-
1134
- /**
1135
- * Check if directory is a git repository
1136
- * @private
1137
- */
1138
- _isGitRepo(dir) {
1139
- try {
1140
- execSync('git rev-parse --git-dir', {
1141
- cwd: dir,
1142
- encoding: 'utf8',
1143
- stdio: 'pipe',
1144
- });
1145
- return true;
1146
- } catch {
1147
- return false;
1148
- }
1149
- }
1150
-
1151
- /**
1152
- * Get the git repository root for a directory
1153
- * @private
1154
- */
1155
- _getGitRoot(dir) {
1156
- try {
1157
- return execSync('git rev-parse --show-toplevel', {
1158
- cwd: dir,
1159
- encoding: 'utf8',
1160
- stdio: 'pipe',
1161
- }).trim();
1162
- } catch {
1163
- return null;
1164
- }
1165
- }
1166
-
1167
- /**
1168
- * Create worktree-based isolation for a cluster (lightweight alternative to Docker)
1169
- * Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId}
1170
- * @param {string} clusterId - Cluster ID
1171
- * @param {string} workDir - Original working directory (must be a git repo)
1172
- * @returns {{ path: string, branch: string, repoRoot: string }}
1173
- */
1174
- createWorktreeIsolation(clusterId, workDir) {
1175
- if (!this._isGitRepo(workDir)) {
1176
- throw new Error(
1177
- `Worktree isolation requires a git repository. ${workDir} is not a git repo.`
1178
- );
1179
- }
1180
-
1181
- const worktreeInfo = this.createWorktree(clusterId, workDir);
1182
- this.worktrees.set(clusterId, worktreeInfo);
1183
-
1184
- console.log(`[IsolationManager] Created worktree isolation at ${worktreeInfo.path}`);
1185
- console.log(`[IsolationManager] Branch: ${worktreeInfo.branch}`);
1186
-
1187
- return worktreeInfo;
1188
- }
1189
-
1190
- /**
1191
- * Clean up worktree isolation for a cluster
1192
- * @param {string} clusterId - Cluster ID
1193
- * @param {object} [options] - Cleanup options
1194
- * @param {boolean} [options.preserveBranch=true] - Keep the branch after removing worktree
1195
- */
1196
- cleanupWorktreeIsolation(clusterId, options = {}) {
1197
- const worktreeInfo = this.worktrees.get(clusterId);
1198
- if (!worktreeInfo) {
1199
- return; // No worktree to clean up
1200
- }
1201
-
1202
- this.removeWorktree(worktreeInfo, options);
1203
- this.worktrees.delete(clusterId);
1204
-
1205
- console.log(`[IsolationManager] Cleaned up worktree isolation for ${clusterId}`);
1206
- }
1207
-
1208
- /**
1209
- * Create a git worktree for isolated work
1210
- * @param {string} clusterId - Cluster ID (used as branch name)
1211
- * @param {string} workDir - Original working directory
1212
- * @returns {{ path: string, branch: string, repoRoot: string }}
1213
- */
1214
- createWorktree(clusterId, workDir) {
1215
- const repoRoot = this._getGitRoot(workDir);
1216
- if (!repoRoot) {
1217
- throw new Error(`Cannot find git root for ${workDir}`);
1218
- }
1219
-
1220
- // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
1221
- const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1222
- let branchName = baseBranchName;
1223
-
1224
- // Worktree path in tmp
1225
- const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId);
1226
-
1227
- // Ensure parent directory exists
1228
- const parentDir = path.dirname(worktreePath);
1229
- if (!fs.existsSync(parentDir)) {
1230
- fs.mkdirSync(parentDir, { recursive: true });
1231
- }
1232
-
1233
- // Best-effort cleanup of stale worktree metadata and directory.
1234
- // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1235
- // git may keep the branch "checked out" and block deletion/reuse.
1236
- try {
1237
- execSync(`git worktree remove --force ${escapeShell(worktreePath)}`, {
1238
- cwd: repoRoot,
1239
- encoding: 'utf8',
1240
- stdio: 'pipe',
1241
- });
1242
- } catch {
1243
- // ignore
1244
- }
1245
- try {
1246
- execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1247
- } catch {
1248
- // ignore
1249
- }
1250
- try {
1251
- fs.rmSync(worktreePath, { recursive: true, force: true });
1252
- } catch {
1253
- // ignore
1254
- }
1255
-
1256
- // Create worktree with new branch based on HEAD (retry on branch collision/in-use)
1257
- for (let attempt = 0; attempt < 10; attempt++) {
1258
- // Best-effort delete if branch exists and is not in use by another worktree.
1259
- try {
1260
- execSync(`git branch -D ${escapeShell(branchName)}`, {
1261
- cwd: repoRoot,
1262
- encoding: 'utf8',
1263
- stdio: 'pipe',
1264
- });
1265
- } catch {
1266
- // ignore
1267
- }
1268
-
1269
- try {
1270
- execSync(
1271
- `git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} HEAD`,
1272
- {
1273
- cwd: repoRoot,
1274
- encoding: 'utf8',
1275
- stdio: 'pipe',
1276
- }
1277
- );
1278
- break;
1279
- } catch (err) {
1280
- const stderr = (
1281
- err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1282
- ).toLowerCase();
1283
- const isBranchCollision =
1284
- stderr.includes('already exists') ||
1285
- stderr.includes('cannot delete branch') ||
1286
- stderr.includes('checked out');
1287
-
1288
- if (attempt < 9 && isBranchCollision) {
1289
- branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1290
- try {
1291
- execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1292
- } catch {
1293
- // ignore
1294
- }
1295
- continue;
1296
- }
1297
- throw err;
1298
- }
1299
- }
1300
-
1301
- return {
1302
- path: worktreePath,
1303
- branch: branchName,
1304
- repoRoot,
1305
- };
1306
- }
1307
-
1308
- /**
1309
- * Remove a git worktree
1310
- * @param {{ path: string, branch: string, repoRoot: string }} worktreeInfo
1311
- * @param {object} [options] - Removal options
1312
- * @param {boolean} [options.deleteBranch=false] - Also delete the branch
1313
- */
1314
- removeWorktree(worktreeInfo, _options = {}) {
1315
- // Remove the worktree (prefer git so metadata is cleaned up).
1316
- try {
1317
- execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1318
- cwd: worktreeInfo.repoRoot,
1319
- encoding: 'utf8',
1320
- stdio: 'pipe',
1321
- });
1322
- } catch {
1323
- // If git worktree metadata is stale, prune and retry once.
1324
- try {
1325
- execSync('git worktree prune', {
1326
- cwd: worktreeInfo.repoRoot,
1327
- encoding: 'utf8',
1328
- stdio: 'pipe',
1329
- });
1330
- } catch {
1331
- // ignore
1332
- }
1333
- try {
1334
- execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1335
- cwd: worktreeInfo.repoRoot,
1336
- encoding: 'utf8',
1337
- stdio: 'pipe',
1338
- });
1339
- } catch {
1340
- // Last resort: delete directory, then prune stale worktree entries.
1341
- try {
1342
- fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1343
- } catch {
1344
- // ignore
1345
- }
1346
- try {
1347
- execSync('git worktree prune', {
1348
- cwd: worktreeInfo.repoRoot,
1349
- encoding: 'utf8',
1350
- stdio: 'pipe',
1351
- });
1352
- } catch {
1353
- // ignore
1354
- }
1355
- }
1356
- }
1357
-
1358
- // Optionally delete the branch (only if not merged)
1359
- // We leave this commented out - let the user decide to keep/delete branches
1360
- // try {
1361
- // execSync(`git branch -D "${worktreeInfo.branch}" 2>/dev/null`, {
1362
- // cwd: worktreeInfo.repoRoot,
1363
- // encoding: 'utf8',
1364
- // stdio: 'pipe'
1365
- // });
1366
- // } catch {
1367
- // // Ignore - branch may have been merged or deleted
1368
- // }
1369
- }
1370
-
1371
- /**
1372
- * Get worktree info for a cluster
1373
- * @param {string} clusterId - Cluster ID
1374
- * @returns {{ path: string, branch: string, repoRoot: string }|undefined}
1375
- */
1376
- getWorktreeInfo(clusterId) {
1377
- return this.worktrees.get(clusterId);
1378
- }
1379
- }
1380
-
1381
- module.exports = IsolationManager;