@covibes/zeroshot 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +94 -14
  2. package/cli/commands/providers.js +8 -9
  3. package/cli/index.js +3032 -2409
  4. package/cli/message-formatters-normal.js +28 -6
  5. package/cluster-templates/base-templates/debug-workflow.json +1 -1
  6. package/cluster-templates/base-templates/full-workflow.json +72 -188
  7. package/cluster-templates/base-templates/worker-validator.json +59 -3
  8. package/cluster-templates/conductor-bootstrap.json +4 -4
  9. package/lib/docker-config.js +8 -0
  10. package/lib/git-remote-utils.js +165 -0
  11. package/lib/id-detector.js +10 -7
  12. package/lib/provider-defaults.js +62 -0
  13. package/lib/provider-names.js +2 -1
  14. package/lib/settings/claude-auth.js +78 -0
  15. package/lib/settings.js +161 -63
  16. package/package.json +7 -2
  17. package/scripts/setup-merge-queue.sh +170 -0
  18. package/src/agent/agent-config.js +135 -82
  19. package/src/agent/agent-context-builder.js +297 -188
  20. package/src/agent/agent-hook-executor.js +310 -113
  21. package/src/agent/agent-lifecycle.js +385 -325
  22. package/src/agent/agent-stuck-detector.js +7 -7
  23. package/src/agent/agent-task-executor.js +824 -565
  24. package/src/agent/output-extraction.js +41 -24
  25. package/src/agent/output-reformatter.js +1 -1
  26. package/src/agent/schema-utils.js +108 -73
  27. package/src/agent-wrapper.js +10 -1
  28. package/src/agents/git-pusher-template.js +285 -0
  29. package/src/claude-task-runner.js +85 -34
  30. package/src/config-validator.js +922 -657
  31. package/src/input-helpers.js +65 -0
  32. package/src/isolation-manager.js +289 -199
  33. package/src/issue-providers/README.md +305 -0
  34. package/src/issue-providers/azure-devops-provider.js +273 -0
  35. package/src/issue-providers/base-provider.js +232 -0
  36. package/src/issue-providers/github-provider.js +179 -0
  37. package/src/issue-providers/gitlab-provider.js +241 -0
  38. package/src/issue-providers/index.js +196 -0
  39. package/src/issue-providers/jira-provider.js +239 -0
  40. package/src/ledger.js +22 -5
  41. package/src/lib/safe-exec.js +88 -0
  42. package/src/orchestrator.js +1107 -811
  43. package/src/preflight.js +313 -159
  44. package/src/process-metrics.js +98 -56
  45. package/src/providers/anthropic/cli-builder.js +53 -25
  46. package/src/providers/anthropic/index.js +72 -2
  47. package/src/providers/anthropic/output-parser.js +32 -14
  48. package/src/providers/base-provider.js +70 -0
  49. package/src/providers/capabilities.js +9 -0
  50. package/src/providers/google/output-parser.js +47 -38
  51. package/src/providers/index.js +19 -3
  52. package/src/providers/openai/cli-builder.js +14 -3
  53. package/src/providers/openai/index.js +1 -0
  54. package/src/providers/openai/output-parser.js +44 -30
  55. package/src/providers/opencode/cli-builder.js +42 -0
  56. package/src/providers/opencode/index.js +103 -0
  57. package/src/providers/opencode/models.js +55 -0
  58. package/src/providers/opencode/output-parser.js +122 -0
  59. package/src/schemas/sub-cluster.js +68 -39
  60. package/src/status-footer.js +94 -48
  61. package/src/sub-cluster-wrapper.js +76 -35
  62. package/src/template-resolver.js +12 -9
  63. package/src/tui/data-poller.js +123 -99
  64. package/src/tui/formatters.js +4 -3
  65. package/src/tui/keybindings.js +259 -318
  66. package/src/tui/renderer.js +11 -21
  67. package/task-lib/attachable-watcher.js +118 -81
  68. package/task-lib/claude-recovery.js +61 -24
  69. package/task-lib/commands/episodes.js +105 -0
  70. package/task-lib/commands/list.js +2 -2
  71. package/task-lib/commands/logs.js +231 -189
  72. package/task-lib/commands/schedules.js +111 -61
  73. package/task-lib/config.js +0 -2
  74. package/task-lib/runner.js +84 -27
  75. package/task-lib/scheduler.js +1 -1
  76. package/task-lib/store.js +467 -168
  77. package/task-lib/tui/formatters.js +94 -45
  78. package/task-lib/tui/renderer.js +13 -3
  79. package/task-lib/tui.js +73 -32
  80. package/task-lib/watcher.js +128 -90
  81. package/src/agents/git-pusher-agent.json +0 -20
  82. package/src/github.js +0 -139
@@ -8,13 +8,15 @@
8
8
  * - Container cleanup on stop/kill
9
9
  */
10
10
 
11
- const { spawn, execSync } = require('child_process');
11
+ const { spawn } = require('child_process');
12
+ const { execSync } = require('./lib/safe-exec'); // Enforces timeouts - prevents infinite hangs
12
13
  const { Worker } = require('worker_threads');
13
14
  const crypto = require('crypto');
14
15
  const path = require('path');
15
16
  const os = require('os');
16
17
  const fs = require('fs');
17
18
  const { loadSettings } = require('../lib/settings');
19
+ const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/claude-auth');
18
20
  const { normalizeProviderName } = require('../lib/provider-names');
19
21
  const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
20
22
  const { getProvider } = require('./providers');
@@ -93,173 +95,207 @@ class IsolationManager {
93
95
  const containerName = `zeroshot-cluster-${clusterId}`;
94
96
  const reuseExisting = config.reuseExistingWorkspace || false;
95
97
 
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
- }
98
+ const runningContainerId = this._getRunningContainerId(clusterId);
99
+ if (runningContainerId) {
100
+ return runningContainerId;
102
101
  }
103
102
 
104
- // Clean up any existing container with same name
105
103
  this._removeContainerByName(containerName);
106
104
 
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
- }
105
+ workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
134
106
 
135
- // Resolve container home directory EARLY - needed for Claude config mount and hooks
136
107
  const settings = loadSettings();
137
108
  const providerName = normalizeProviderName(
138
109
  config.provider || settings.defaultProvider || 'claude'
139
110
  );
140
111
  const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
141
112
 
142
- // Create fresh Claude config dir for this cluster (avoids permission issues from host)
143
113
  const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
144
114
  console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
145
115
 
146
- // Build docker run command
147
- // NOTE: Container runs as 'node' user (uid 1000) for --dangerously-skip-permissions
148
- const args = [
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 [
149
168
  'run',
150
- '-d', // detached
169
+ '-d',
151
170
  '--name',
152
171
  containerName,
153
- // Mount workspace
154
172
  '-v',
155
173
  `${workDir}:/workspace`,
156
- // Mount Docker socket for Docker-in-Docker (e2e tests need docker compose)
157
174
  '-v',
158
175
  '/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
176
  '--group-add',
162
177
  this._getDockerGid(),
163
- // Mount fresh Claude config to container user's home (read-write - Claude CLI writes settings, todos, etc.)
164
178
  '-v',
165
179
  `${clusterConfigDir}:${containerHome}/.claude`,
166
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
+ }
167
199
 
200
+ _applyCredentialMounts(args, config, settings, containerHome) {
168
201
  const mountedHosts = [];
202
+ if (config.noMounts) {
203
+ return mountedHosts;
204
+ }
169
205
 
170
- // Add configurable credential mounts
171
- // Priority: CLI config > env var > settings > defaults
172
- if (!config.noMounts) {
173
- let mountConfig;
206
+ const mountConfig = this._resolveMountConfig(config, settings);
207
+ const mounts = resolveMounts(mountConfig, { containerHome });
208
+ const claudeContainerPath = path.posix.join(containerHome, '.claude');
174
209
 
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;
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;
189
217
  }
190
218
 
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');
219
+ const hostPath = expandHomePath(mount.host);
194
220
 
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
- );
221
+ try {
222
+ const stat = fs.statSync(hostPath);
223
+ if (hostPath.endsWith('config') && !stat.isFile()) {
201
224
  continue;
202
225
  }
226
+ } catch {
227
+ continue;
228
+ }
203
229
 
204
- const hostPath = expandHomePath(mount.host);
230
+ const mountSpec = mount.readonly
231
+ ? `${hostPath}:${mount.container}:ro`
232
+ : `${hostPath}:${mount.container}`;
233
+ args.push('-v', mountSpec);
234
+ mountedHosts.push(hostPath);
235
+ }
205
236
 
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
- }
237
+ const envToPass = this._collectDockerEnvVars(mountConfig, settings);
238
+ for (const [key, value] of Object.entries(envToPass)) {
239
+ args.push('-e', `${key}=${value}`);
240
+ }
217
241
 
218
- const mountSpec = mount.readonly
219
- ? `${hostPath}:${mount.container}:ro`
220
- : `${hostPath}:${mount.container}`;
221
- args.push('-v', mountSpec);
222
- mountedHosts.push(hostPath);
223
- }
242
+ return mountedHosts;
243
+ }
224
244
 
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
- }
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];
235
254
  }
236
255
  }
237
256
 
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
- );
257
+ for (const envVar of CLAUDE_AUTH_ENV_VARS) {
258
+ if (process.env[envVar]) {
259
+ envToPass[envVar] = process.env[envVar];
260
+ }
261
+ }
248
262
 
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
- );
263
+ const authEnv = resolveClaudeAuth(settings);
264
+ for (const [key, value] of Object.entries(authEnv)) {
265
+ if (!(key in envToPass)) {
266
+ envToPass[key] = value;
257
267
  }
258
268
  }
259
269
 
260
- // Finish docker args
261
- args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
270
+ return envToPass;
271
+ }
272
+
273
+ _warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
274
+ if (providerName === 'claude') {
275
+ return;
276
+ }
262
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) {
263
299
  return new Promise((resolve, reject) => {
264
300
  const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
265
301
 
@@ -274,29 +310,26 @@ class IsolationManager {
274
310
  });
275
311
 
276
312
  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 {
313
+ if (code !== 0) {
298
314
  reject(new Error(`Failed to create container: ${stderr}`));
315
+ return;
299
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);
300
333
  });
301
334
 
302
335
  proc.on('error', (err) => {
@@ -387,6 +420,7 @@ class IsolationManager {
387
420
  * @param {object} [options] - Exec options
388
421
  * @param {boolean} [options.interactive] - Use -it flags
389
422
  * @param {object} [options.env] - Environment variables
423
+ * @param {number} [options.timeout=30000] - Timeout in ms (0 = no timeout). Prevents infinite hangs.
390
424
  * @returns {Promise<{stdout: string, stderr: string, code: number}>}
391
425
  */
392
426
  execInContainer(clusterId, command, options = {}) {
@@ -410,6 +444,9 @@ class IsolationManager {
410
444
 
411
445
  args.push(containerId, ...command);
412
446
 
447
+ // Default timeout: 30 seconds (prevents infinite hangs)
448
+ const timeout = options.timeout ?? 30000;
449
+
413
450
  return new Promise((resolve, reject) => {
414
451
  const proc = spawn('docker', args, {
415
452
  stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
@@ -417,6 +454,16 @@ class IsolationManager {
417
454
 
418
455
  let stdout = '';
419
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
+ }
420
467
 
421
468
  if (!options.interactive) {
422
469
  proc.stdout.on('data', (data) => {
@@ -428,10 +475,16 @@ class IsolationManager {
428
475
  }
429
476
 
430
477
  proc.on('close', (code) => {
431
- resolve({ stdout, stderr, 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
+ }
432
484
  });
433
485
 
434
486
  proc.on('error', (err) => {
487
+ if (timeoutId) clearTimeout(timeoutId);
435
488
  reject(new Error(`Docker exec error: ${err.message}`));
436
489
  });
437
490
  });
@@ -690,58 +743,80 @@ class IsolationManager {
690
743
  const files = [];
691
744
  const directories = new Set();
692
745
 
693
- const collectFiles = (currentSrc, relativePath = '') => {
694
- let entries;
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) => {
695
765
  try {
696
- entries = fs.readdirSync(currentSrc, { withFileTypes: true });
766
+ return fs.readdirSync(currentSrc, { withFileTypes: true });
697
767
  } catch (err) {
698
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
699
- return;
768
+ if (shouldIgnoreFsError(err)) {
769
+ return [];
700
770
  }
701
771
  throw err;
702
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);
703
801
 
704
802
  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;
803
+ if (shouldExcludeEntry(entry.name)) {
804
+ continue;
805
+ }
713
806
 
714
807
  const srcPath = path.join(currentSrc, entry.name);
715
808
  const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
716
809
 
717
810
  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
- }
811
+ handleEntry(entry, srcPath, relPath, relativePath);
737
812
  } catch (err) {
738
- if (err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT') {
813
+ if (shouldIgnoreFsError(err)) {
739
814
  continue;
740
815
  }
741
816
  throw err;
742
817
  }
743
818
  }
744
- };
819
+ }
745
820
 
746
821
  collectFiles(src);
747
822
 
@@ -948,33 +1023,48 @@ class IsolationManager {
948
1023
  _preserveTerraformState(clusterId, isolatedPath) {
949
1024
  const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
950
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
+ };
951
1057
 
952
1058
  let foundState = false;
953
1059
 
954
1060
  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
1061
+ if (!hasStateFiles(checkDir)) {
1062
+ continue;
977
1063
  }
1064
+
1065
+ fs.mkdirSync(stateDir, { recursive: true });
1066
+ foundState = copyStateFiles(checkDir);
1067
+ break;
978
1068
  }
979
1069
 
980
1070
  if (!foundState) {