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