@covibes/zeroshot 5.2.1 → 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.
- package/CHANGELOG.md +174 -189
- package/README.md +226 -195
- package/cli/commands/providers.js +149 -0
- package/cli/index.js +3145 -2366
- package/cli/lib/first-run.js +40 -3
- package/cli/message-formatters-normal.js +28 -6
- package/cluster-templates/base-templates/debug-workflow.json +24 -78
- package/cluster-templates/base-templates/full-workflow.json +99 -316
- package/cluster-templates/base-templates/single-worker.json +23 -15
- package/cluster-templates/base-templates/worker-validator.json +105 -36
- package/cluster-templates/conductor-bootstrap.json +9 -7
- package/lib/docker-config.js +14 -1
- package/lib/git-remote-utils.js +165 -0
- package/lib/id-detector.js +10 -7
- package/lib/provider-defaults.js +62 -0
- package/lib/provider-detection.js +59 -0
- package/lib/provider-names.js +57 -0
- package/lib/settings/claude-auth.js +78 -0
- package/lib/settings.js +298 -15
- package/lib/stream-json-parser.js +4 -238
- package/package.json +27 -6
- package/scripts/setup-merge-queue.sh +170 -0
- package/scripts/validate-templates.js +100 -0
- package/src/agent/agent-config.js +140 -63
- package/src/agent/agent-context-builder.js +336 -165
- package/src/agent/agent-hook-executor.js +337 -67
- package/src/agent/agent-lifecycle.js +386 -287
- package/src/agent/agent-stuck-detector.js +7 -7
- package/src/agent/agent-task-executor.js +944 -683
- package/src/agent/output-extraction.js +217 -0
- package/src/agent/output-reformatter.js +175 -0
- package/src/agent/schema-utils.js +146 -0
- package/src/agent-wrapper.js +112 -31
- package/src/agents/git-pusher-template.js +285 -0
- package/src/claude-task-runner.js +145 -44
- package/src/config-router.js +13 -13
- package/src/config-validator.js +1049 -563
- package/src/input-helpers.js +65 -0
- package/src/isolation-manager.js +499 -320
- package/src/issue-providers/README.md +305 -0
- package/src/issue-providers/azure-devops-provider.js +273 -0
- package/src/issue-providers/base-provider.js +232 -0
- package/src/issue-providers/github-provider.js +179 -0
- package/src/issue-providers/gitlab-provider.js +241 -0
- package/src/issue-providers/index.js +196 -0
- package/src/issue-providers/jira-provider.js +239 -0
- package/src/ledger.js +50 -11
- package/src/lib/safe-exec.js +88 -0
- package/src/orchestrator.js +1348 -757
- package/src/preflight.js +306 -149
- package/src/process-metrics.js +98 -56
- package/src/providers/anthropic/cli-builder.js +73 -0
- package/src/providers/anthropic/index.js +204 -0
- package/src/providers/anthropic/models.js +23 -0
- package/src/providers/anthropic/output-parser.js +177 -0
- package/src/providers/base-provider.js +251 -0
- package/src/providers/capabilities.js +60 -0
- package/src/providers/google/cli-builder.js +55 -0
- package/src/providers/google/index.js +116 -0
- package/src/providers/google/models.js +24 -0
- package/src/providers/google/output-parser.js +101 -0
- package/src/providers/index.js +91 -0
- package/src/providers/openai/cli-builder.js +133 -0
- package/src/providers/openai/index.js +136 -0
- package/src/providers/openai/models.js +21 -0
- package/src/providers/openai/output-parser.js +143 -0
- package/src/providers/opencode/cli-builder.js +42 -0
- package/src/providers/opencode/index.js +103 -0
- package/src/providers/opencode/models.js +55 -0
- package/src/providers/opencode/output-parser.js +122 -0
- package/src/schemas/sub-cluster.js +68 -39
- package/src/status-footer.js +94 -48
- package/src/sub-cluster-wrapper.js +92 -36
- package/src/task-runner.js +8 -6
- package/src/template-resolver.js +12 -9
- package/src/tui/data-poller.js +123 -99
- package/src/tui/formatters.js +4 -3
- package/src/tui/keybindings.js +259 -318
- package/src/tui/layout.js +20 -3
- package/src/tui/renderer.js +11 -21
- package/task-lib/attachable-watcher.js +150 -111
- package/task-lib/claude-recovery.js +156 -0
- package/task-lib/commands/episodes.js +105 -0
- package/task-lib/commands/list.js +3 -3
- package/task-lib/commands/logs.js +231 -189
- package/task-lib/commands/resume.js +3 -2
- package/task-lib/commands/run.js +12 -3
- package/task-lib/commands/schedules.js +111 -61
- package/task-lib/config.js +0 -2
- package/task-lib/runner.js +128 -50
- package/task-lib/scheduler.js +3 -3
- package/task-lib/store.js +464 -152
- package/task-lib/tui/formatters.js +94 -45
- package/task-lib/tui/renderer.js +13 -3
- package/task-lib/tui.js +73 -32
- package/task-lib/watcher.js +157 -100
- package/src/agents/git-pusher-agent.json +0 -20
- package/src/github.js +0 -103
package/src/isolation-manager.js
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Handles:
|
|
5
5
|
* - Container creation with workspace mounts
|
|
6
|
-
* - Credential injection for
|
|
6
|
+
* - Credential injection for provider CLIs
|
|
7
7
|
* - Command execution inside containers
|
|
8
8
|
* - Container cleanup on stop/kill
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
const { spawn
|
|
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');
|
|
14
|
+
const crypto = require('crypto');
|
|
13
15
|
const path = require('path');
|
|
14
16
|
const os = require('os');
|
|
15
17
|
const fs = require('fs');
|
|
16
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');
|
|
17
21
|
const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
|
|
22
|
+
const { getProvider } = require('./providers');
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
25
|
* Escape a string for safe use in shell commands
|
|
@@ -28,6 +33,19 @@ function escapeShell(str) {
|
|
|
28
33
|
return `'${str.replace(/'/g, "'\\''")}'`;
|
|
29
34
|
}
|
|
30
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
|
+
|
|
31
49
|
const DEFAULT_IMAGE = 'zeroshot-cluster-base';
|
|
32
50
|
|
|
33
51
|
class IsolationManager {
|
|
@@ -68,6 +86,7 @@ class IsolationManager {
|
|
|
68
86
|
* @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
|
|
69
87
|
* @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
|
|
70
88
|
* @param {boolean} [config.noMounts=false] - Disable all credential mounts
|
|
89
|
+
* @param {string} [config.provider] - Provider name for credential warnings
|
|
71
90
|
* @returns {Promise<string>} Container ID
|
|
72
91
|
*/
|
|
73
92
|
async createContainer(clusterId, config) {
|
|
@@ -76,145 +95,207 @@ class IsolationManager {
|
|
|
76
95
|
const containerName = `zeroshot-cluster-${clusterId}`;
|
|
77
96
|
const reuseExisting = config.reuseExistingWorkspace || false;
|
|
78
97
|
|
|
79
|
-
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
if (this._isContainerRunning(existingId)) {
|
|
83
|
-
return existingId;
|
|
84
|
-
}
|
|
98
|
+
const runningContainerId = this._getRunningContainerId(clusterId);
|
|
99
|
+
if (runningContainerId) {
|
|
100
|
+
return runningContainerId;
|
|
85
101
|
}
|
|
86
102
|
|
|
87
|
-
// Clean up any existing container with same name
|
|
88
103
|
this._removeContainerByName(containerName);
|
|
89
104
|
|
|
90
|
-
|
|
91
|
-
// No worktrees - cleaner, no host path dependencies
|
|
92
|
-
// EXCEPTION: On resume (reuseExisting=true), skip copy and use existing workspace
|
|
93
|
-
if (this._isGitRepo(workDir)) {
|
|
94
|
-
const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
|
|
95
|
-
|
|
96
|
-
if (reuseExisting && fs.existsSync(isolatedPath)) {
|
|
97
|
-
// Resume mode: reuse existing isolated workspace (contains agent's work)
|
|
98
|
-
console.log(`[IsolationManager] Reusing existing isolated workspace at ${isolatedPath}`);
|
|
99
|
-
this.isolatedDirs = this.isolatedDirs || new Map();
|
|
100
|
-
this.isolatedDirs.set(clusterId, {
|
|
101
|
-
path: isolatedPath,
|
|
102
|
-
originalDir: workDir,
|
|
103
|
-
});
|
|
104
|
-
workDir = isolatedPath;
|
|
105
|
-
} else {
|
|
106
|
-
// Fresh start: create new isolated copy
|
|
107
|
-
const isolatedDir = await this._createIsolatedCopy(clusterId, workDir);
|
|
108
|
-
this.isolatedDirs = this.isolatedDirs || new Map();
|
|
109
|
-
this.isolatedDirs.set(clusterId, {
|
|
110
|
-
path: isolatedDir,
|
|
111
|
-
originalDir: workDir,
|
|
112
|
-
});
|
|
113
|
-
workDir = isolatedDir;
|
|
114
|
-
console.log(`[IsolationManager] Created isolated copy at ${workDir}`);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
105
|
+
workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
|
|
117
106
|
|
|
118
|
-
// Resolve container home directory EARLY - needed for Claude config mount and hooks
|
|
119
107
|
const settings = loadSettings();
|
|
108
|
+
const providerName = normalizeProviderName(
|
|
109
|
+
config.provider || settings.defaultProvider || 'claude'
|
|
110
|
+
);
|
|
120
111
|
const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
|
|
121
112
|
|
|
122
|
-
// Create fresh Claude config dir for this cluster (avoids permission issues from host)
|
|
123
113
|
const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
|
|
124
114
|
console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
|
|
125
115
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 [
|
|
129
168
|
'run',
|
|
130
|
-
'-d',
|
|
169
|
+
'-d',
|
|
131
170
|
'--name',
|
|
132
171
|
containerName,
|
|
133
|
-
// Mount workspace
|
|
134
172
|
'-v',
|
|
135
173
|
`${workDir}:/workspace`,
|
|
136
|
-
// Mount Docker socket for Docker-in-Docker (e2e tests need docker compose)
|
|
137
174
|
'-v',
|
|
138
175
|
'/var/run/docker.sock:/var/run/docker.sock',
|
|
139
|
-
// Add node user to host's docker group (fixes permission denied)
|
|
140
|
-
// CRITICAL: Without this, agent can't run docker commands inside container
|
|
141
176
|
'--group-add',
|
|
142
177
|
this._getDockerGid(),
|
|
143
|
-
// Mount fresh Claude config to container user's home (read-write - Claude CLI writes settings, todos, etc.)
|
|
144
178
|
'-v',
|
|
145
179
|
`${clusterConfigDir}:${containerHome}/.claude`,
|
|
146
180
|
];
|
|
181
|
+
}
|
|
147
182
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
183
|
+
_resolveMountConfig(config, settings) {
|
|
184
|
+
if (config.mounts) {
|
|
185
|
+
return config.mounts;
|
|
186
|
+
}
|
|
152
187
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
mountConfig = JSON.parse(process.env.ZEROSHOT_DOCKER_MOUNTS);
|
|
160
|
-
} catch {
|
|
161
|
-
console.warn('[IsolationManager] Invalid ZEROSHOT_DOCKER_MOUNTS JSON, using settings');
|
|
162
|
-
mountConfig = settings.dockerMounts;
|
|
163
|
-
}
|
|
164
|
-
} else {
|
|
165
|
-
// User settings
|
|
166
|
-
mountConfig = settings.dockerMounts;
|
|
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;
|
|
167
194
|
}
|
|
195
|
+
}
|
|
168
196
|
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
return settings.dockerMounts;
|
|
198
|
+
}
|
|
171
199
|
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
_applyCredentialMounts(args, config, settings, containerHome) {
|
|
201
|
+
const mountedHosts = [];
|
|
202
|
+
if (config.noMounts) {
|
|
203
|
+
return mountedHosts;
|
|
204
|
+
}
|
|
174
205
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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()) {
|
|
184
224
|
continue;
|
|
185
225
|
}
|
|
226
|
+
} catch {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
186
229
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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];
|
|
191
254
|
}
|
|
255
|
+
}
|
|
192
256
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
args.push('-e', `${spec.name}=${process.env[spec.name]}`);
|
|
204
|
-
}
|
|
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;
|
|
205
267
|
}
|
|
206
268
|
}
|
|
207
269
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
)
|
|
216
285
|
);
|
|
217
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) {
|
|
218
299
|
return new Promise((resolve, reject) => {
|
|
219
300
|
const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
220
301
|
|
|
@@ -229,138 +310,26 @@ class IsolationManager {
|
|
|
229
310
|
});
|
|
230
311
|
|
|
231
312
|
proc.on('close', async (code) => {
|
|
232
|
-
if (code
|
|
233
|
-
const containerId = stdout.trim().substring(0, 12);
|
|
234
|
-
this.containers.set(clusterId, containerId);
|
|
235
|
-
|
|
236
|
-
// Install dependencies if package.json exists
|
|
237
|
-
// This enables e2e tests and other npm-based tools to run
|
|
238
|
-
// OPTIMIZATION: Use pre-baked deps when possible (30-40% faster startup)
|
|
239
|
-
// See: GitHub issue #20
|
|
240
|
-
try {
|
|
241
|
-
console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
|
|
242
|
-
if (fs.existsSync(path.join(workDir, 'package.json'))) {
|
|
243
|
-
// Check if node_modules already exists in container (pre-baked or previous run)
|
|
244
|
-
const checkResult = await this.execInContainer(
|
|
245
|
-
clusterId,
|
|
246
|
-
['sh', '-c', 'test -d node_modules && test -f node_modules/.package-lock.json && echo "exists"'],
|
|
247
|
-
{}
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
if (checkResult.code === 0 && checkResult.stdout.trim() === 'exists') {
|
|
251
|
-
console.log(`[IsolationManager] ✓ Dependencies already installed (skipping npm install)`);
|
|
252
|
-
} else {
|
|
253
|
-
// Check if npm is available in container
|
|
254
|
-
const npmCheck = await this.execInContainer(clusterId, ['which', 'npm'], {});
|
|
255
|
-
if (npmCheck.code !== 0) {
|
|
256
|
-
console.log(`[IsolationManager] npm not available in container, skipping dependency install`);
|
|
257
|
-
} else {
|
|
258
|
-
// Issue #20: Try to use pre-baked dependencies first
|
|
259
|
-
// Check if pre-baked deps exist and can satisfy project requirements
|
|
260
|
-
const preBakeCheck = await this.execInContainer(
|
|
261
|
-
clusterId,
|
|
262
|
-
['sh', '-c', 'test -d /pre-baked-deps/node_modules && echo "exists"'],
|
|
263
|
-
{}
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
if (preBakeCheck.code === 0 && preBakeCheck.stdout.trim() === 'exists') {
|
|
267
|
-
console.log(`[IsolationManager] Checking if pre-baked deps satisfy requirements...`);
|
|
268
|
-
|
|
269
|
-
// Copy pre-baked deps, then run npm install to add any missing
|
|
270
|
-
// This is faster than full npm install: copy is ~2s, npm install adds ~5-10s for missing
|
|
271
|
-
const copyResult = await this.execInContainer(
|
|
272
|
-
clusterId,
|
|
273
|
-
['sh', '-c', 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true'],
|
|
274
|
-
{}
|
|
275
|
-
);
|
|
276
|
-
|
|
277
|
-
if (copyResult.code === 0) {
|
|
278
|
-
console.log(`[IsolationManager] ✓ Copied pre-baked dependencies`);
|
|
279
|
-
|
|
280
|
-
// Run npm install to add any missing deps (much faster with pre-baked base)
|
|
281
|
-
const installResult = await this.execInContainer(
|
|
282
|
-
clusterId,
|
|
283
|
-
['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline'],
|
|
284
|
-
{}
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
if (installResult.code === 0) {
|
|
288
|
-
console.log(`[IsolationManager] ✓ Dependencies installed (pre-baked + incremental)`);
|
|
289
|
-
} else {
|
|
290
|
-
// Fallback: full install (pre-baked copy may have caused issues)
|
|
291
|
-
console.warn(`[IsolationManager] Incremental install failed, falling back to full install`);
|
|
292
|
-
await this.execInContainer(
|
|
293
|
-
clusterId,
|
|
294
|
-
['sh', '-c', 'rm -rf node_modules && npm_config_engine_strict=false npm install --no-audit --no-fund'],
|
|
295
|
-
{}
|
|
296
|
-
);
|
|
297
|
-
console.log(`[IsolationManager] ✓ Dependencies installed (full fallback)`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
} else {
|
|
301
|
-
// No pre-baked deps, full npm install with retries
|
|
302
|
-
console.log(`[IsolationManager] Installing npm dependencies in container...`);
|
|
303
|
-
|
|
304
|
-
// Retry npm install with exponential backoff (network issues are common)
|
|
305
|
-
const maxRetries = 3;
|
|
306
|
-
const baseDelay = 2000; // 2 seconds
|
|
307
|
-
let installResult = null;
|
|
308
|
-
|
|
309
|
-
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
310
|
-
try {
|
|
311
|
-
installResult = await this.execInContainer(
|
|
312
|
-
clusterId,
|
|
313
|
-
['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund'],
|
|
314
|
-
{}
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
if (installResult.code === 0) {
|
|
318
|
-
console.log(`[IsolationManager] ✓ Dependencies installed`);
|
|
319
|
-
break; // Success - exit retry loop
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Failed - retry if not last attempt
|
|
323
|
-
// Use stderr if available, otherwise stdout (npm writes some errors to stdout)
|
|
324
|
-
const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
|
|
325
|
-
if (attempt < maxRetries) {
|
|
326
|
-
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
327
|
-
console.warn(
|
|
328
|
-
`[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
329
|
-
);
|
|
330
|
-
console.warn(`[IsolationManager] Error: ${errorOutput}`);
|
|
331
|
-
await new Promise((_resolve) => setTimeout(_resolve, delay));
|
|
332
|
-
} else {
|
|
333
|
-
console.warn(
|
|
334
|
-
`[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
|
|
335
|
-
);
|
|
336
|
-
}
|
|
337
|
-
} catch (execErr) {
|
|
338
|
-
if (attempt < maxRetries) {
|
|
339
|
-
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
340
|
-
console.warn(
|
|
341
|
-
`[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
|
|
342
|
-
);
|
|
343
|
-
console.warn(`[IsolationManager] Error: ${execErr.message}`);
|
|
344
|
-
await new Promise((_resolve) => setTimeout(_resolve, delay));
|
|
345
|
-
} else {
|
|
346
|
-
throw execErr; // Re-throw on last attempt
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
} catch (err) {
|
|
355
|
-
console.warn(
|
|
356
|
-
`[IsolationManager] ⚠️ Failed to install dependencies (non-fatal): ${err.message}`
|
|
357
|
-
);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
resolve(containerId);
|
|
361
|
-
} else {
|
|
313
|
+
if (code !== 0) {
|
|
362
314
|
reject(new Error(`Failed to create container: ${stderr}`));
|
|
315
|
+
return;
|
|
363
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);
|
|
364
333
|
});
|
|
365
334
|
|
|
366
335
|
proc.on('error', (err) => {
|
|
@@ -369,6 +338,81 @@ class IsolationManager {
|
|
|
369
338
|
});
|
|
370
339
|
}
|
|
371
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
|
+
|
|
372
416
|
/**
|
|
373
417
|
* Execute a command inside the container
|
|
374
418
|
* @param {string} clusterId - Cluster ID
|
|
@@ -376,6 +420,7 @@ class IsolationManager {
|
|
|
376
420
|
* @param {object} [options] - Exec options
|
|
377
421
|
* @param {boolean} [options.interactive] - Use -it flags
|
|
378
422
|
* @param {object} [options.env] - Environment variables
|
|
423
|
+
* @param {number} [options.timeout=30000] - Timeout in ms (0 = no timeout). Prevents infinite hangs.
|
|
379
424
|
* @returns {Promise<{stdout: string, stderr: string, code: number}>}
|
|
380
425
|
*/
|
|
381
426
|
execInContainer(clusterId, command, options = {}) {
|
|
@@ -399,6 +444,9 @@ class IsolationManager {
|
|
|
399
444
|
|
|
400
445
|
args.push(containerId, ...command);
|
|
401
446
|
|
|
447
|
+
// Default timeout: 30 seconds (prevents infinite hangs)
|
|
448
|
+
const timeout = options.timeout ?? 30000;
|
|
449
|
+
|
|
402
450
|
return new Promise((resolve, reject) => {
|
|
403
451
|
const proc = spawn('docker', args, {
|
|
404
452
|
stdio: options.interactive ? 'inherit' : ['pipe', 'pipe', 'pipe'],
|
|
@@ -406,6 +454,16 @@ class IsolationManager {
|
|
|
406
454
|
|
|
407
455
|
let stdout = '';
|
|
408
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
|
+
}
|
|
409
467
|
|
|
410
468
|
if (!options.interactive) {
|
|
411
469
|
proc.stdout.on('data', (data) => {
|
|
@@ -417,10 +475,16 @@ class IsolationManager {
|
|
|
417
475
|
}
|
|
418
476
|
|
|
419
477
|
proc.on('close', (code) => {
|
|
420
|
-
|
|
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
|
+
}
|
|
421
484
|
});
|
|
422
485
|
|
|
423
486
|
proc.on('error', (err) => {
|
|
487
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
424
488
|
reject(new Error(`Docker exec error: ${err.message}`));
|
|
425
489
|
});
|
|
426
490
|
});
|
|
@@ -542,7 +606,9 @@ class IsolationManager {
|
|
|
542
606
|
const isolatedInfo = this.isolatedDirs.get(clusterId);
|
|
543
607
|
|
|
544
608
|
if (preserveWorkspace) {
|
|
545
|
-
console.log(
|
|
609
|
+
console.log(
|
|
610
|
+
`[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
|
|
611
|
+
);
|
|
546
612
|
// Don't delete - but DON'T remove from Map either, resume() needs it
|
|
547
613
|
} else {
|
|
548
614
|
console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
|
|
@@ -677,58 +743,80 @@ class IsolationManager {
|
|
|
677
743
|
const files = [];
|
|
678
744
|
const directories = new Set();
|
|
679
745
|
|
|
680
|
-
const
|
|
681
|
-
|
|
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) => {
|
|
682
765
|
try {
|
|
683
|
-
|
|
766
|
+
return fs.readdirSync(currentSrc, { withFileTypes: true });
|
|
684
767
|
} catch (err) {
|
|
685
|
-
if (err
|
|
686
|
-
return;
|
|
768
|
+
if (shouldIgnoreFsError(err)) {
|
|
769
|
+
return [];
|
|
687
770
|
}
|
|
688
771
|
throw err;
|
|
689
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);
|
|
690
801
|
|
|
691
802
|
for (const entry of entries) {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
return entry.name.endsWith(pattern.slice(1));
|
|
696
|
-
}
|
|
697
|
-
return entry.name === pattern;
|
|
698
|
-
});
|
|
699
|
-
if (shouldExclude) continue;
|
|
803
|
+
if (shouldExcludeEntry(entry.name)) {
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
700
806
|
|
|
701
807
|
const srcPath = path.join(currentSrc, entry.name);
|
|
702
808
|
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
|
|
703
809
|
|
|
704
810
|
try {
|
|
705
|
-
|
|
706
|
-
if (entry.isSymbolicLink()) {
|
|
707
|
-
const targetStats = fs.statSync(srcPath);
|
|
708
|
-
if (targetStats.isDirectory()) {
|
|
709
|
-
directories.add(relPath);
|
|
710
|
-
collectFiles(srcPath, relPath);
|
|
711
|
-
} else {
|
|
712
|
-
files.push(relPath);
|
|
713
|
-
// Ensure parent directory is tracked
|
|
714
|
-
if (relativePath) directories.add(relativePath);
|
|
715
|
-
}
|
|
716
|
-
} else if (entry.isDirectory()) {
|
|
717
|
-
directories.add(relPath);
|
|
718
|
-
collectFiles(srcPath, relPath);
|
|
719
|
-
} else {
|
|
720
|
-
files.push(relPath);
|
|
721
|
-
// Ensure parent directory is tracked
|
|
722
|
-
if (relativePath) directories.add(relativePath);
|
|
723
|
-
}
|
|
811
|
+
handleEntry(entry, srcPath, relPath, relativePath);
|
|
724
812
|
} catch (err) {
|
|
725
|
-
if (err
|
|
813
|
+
if (shouldIgnoreFsError(err)) {
|
|
726
814
|
continue;
|
|
727
815
|
}
|
|
728
816
|
throw err;
|
|
729
817
|
}
|
|
730
818
|
}
|
|
731
|
-
}
|
|
819
|
+
}
|
|
732
820
|
|
|
733
821
|
collectFiles(src);
|
|
734
822
|
|
|
@@ -896,7 +984,10 @@ class IsolationManager {
|
|
|
896
984
|
],
|
|
897
985
|
},
|
|
898
986
|
};
|
|
899
|
-
fs.writeFileSync(
|
|
987
|
+
fs.writeFileSync(
|
|
988
|
+
path.join(configDir, 'settings.json'),
|
|
989
|
+
JSON.stringify(clusterSettings, null, 2)
|
|
990
|
+
);
|
|
900
991
|
|
|
901
992
|
// Track for cleanup
|
|
902
993
|
this.clusterConfigDirs = this.clusterConfigDirs || new Map();
|
|
@@ -932,33 +1023,48 @@ class IsolationManager {
|
|
|
932
1023
|
_preserveTerraformState(clusterId, isolatedPath) {
|
|
933
1024
|
const stateFiles = ['terraform.tfstate', 'terraform.tfstate.backup', 'tfplan'];
|
|
934
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
|
+
};
|
|
935
1057
|
|
|
936
1058
|
let foundState = false;
|
|
937
1059
|
|
|
938
1060
|
for (const checkDir of checkDirs) {
|
|
939
|
-
if (!
|
|
940
|
-
|
|
941
|
-
const hasStateFiles = stateFiles.some((file) => fs.existsSync(path.join(checkDir, file)));
|
|
942
|
-
|
|
943
|
-
if (hasStateFiles) {
|
|
944
|
-
const stateDir = path.join(os.homedir(), '.zeroshot', 'terraform-state', clusterId);
|
|
945
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
946
|
-
|
|
947
|
-
for (const file of stateFiles) {
|
|
948
|
-
const srcPath = path.join(checkDir, file);
|
|
949
|
-
if (fs.existsSync(srcPath)) {
|
|
950
|
-
const destPath = path.join(stateDir, file);
|
|
951
|
-
try {
|
|
952
|
-
fs.copyFileSync(srcPath, destPath);
|
|
953
|
-
console.log(`[IsolationManager] Preserved Terraform state: ${file} → ${stateDir}`);
|
|
954
|
-
foundState = true;
|
|
955
|
-
} catch (err) {
|
|
956
|
-
console.warn(`[IsolationManager] Failed to preserve ${file}: ${err.message}`);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
break; // Only backup from first dir with state files
|
|
1061
|
+
if (!hasStateFiles(checkDir)) {
|
|
1062
|
+
continue;
|
|
961
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
1066
|
+
foundState = copyStateFiles(checkDir);
|
|
1067
|
+
break;
|
|
962
1068
|
}
|
|
963
1069
|
|
|
964
1070
|
if (!foundState) {
|
|
@@ -990,9 +1096,12 @@ class IsolationManager {
|
|
|
990
1096
|
*/
|
|
991
1097
|
_isContainerRunning(containerId) {
|
|
992
1098
|
try {
|
|
993
|
-
const result = execSync(
|
|
994
|
-
|
|
995
|
-
|
|
1099
|
+
const result = execSync(
|
|
1100
|
+
`docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`,
|
|
1101
|
+
{
|
|
1102
|
+
encoding: 'utf8',
|
|
1103
|
+
}
|
|
1104
|
+
);
|
|
996
1105
|
return result.trim() === 'true';
|
|
997
1106
|
} catch {
|
|
998
1107
|
return false;
|
|
@@ -1017,7 +1126,8 @@ class IsolationManager {
|
|
|
1017
1126
|
*/
|
|
1018
1127
|
static isDockerAvailable() {
|
|
1019
1128
|
try {
|
|
1020
|
-
|
|
1129
|
+
// Require both CLI binary and a reachable daemon.
|
|
1130
|
+
execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
|
|
1021
1131
|
return true;
|
|
1022
1132
|
} catch {
|
|
1023
1133
|
return false;
|
|
@@ -1146,14 +1256,16 @@ class IsolationManager {
|
|
|
1146
1256
|
|
|
1147
1257
|
/**
|
|
1148
1258
|
* Create worktree-based isolation for a cluster (lightweight alternative to Docker)
|
|
1149
|
-
* Creates a git worktree at /
|
|
1259
|
+
* Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId}
|
|
1150
1260
|
* @param {string} clusterId - Cluster ID
|
|
1151
1261
|
* @param {string} workDir - Original working directory (must be a git repo)
|
|
1152
1262
|
* @returns {{ path: string, branch: string, repoRoot: string }}
|
|
1153
1263
|
*/
|
|
1154
1264
|
createWorktreeIsolation(clusterId, workDir) {
|
|
1155
1265
|
if (!this._isGitRepo(workDir)) {
|
|
1156
|
-
throw new Error(
|
|
1266
|
+
throw new Error(
|
|
1267
|
+
`Worktree isolation requires a git repository. ${workDir} is not a git repo.`
|
|
1268
|
+
);
|
|
1157
1269
|
}
|
|
1158
1270
|
|
|
1159
1271
|
const worktreeInfo = this.createWorktree(clusterId, workDir);
|
|
@@ -1196,7 +1308,8 @@ class IsolationManager {
|
|
|
1196
1308
|
}
|
|
1197
1309
|
|
|
1198
1310
|
// Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
|
|
1199
|
-
const
|
|
1311
|
+
const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
|
|
1312
|
+
let branchName = baseBranchName;
|
|
1200
1313
|
|
|
1201
1314
|
// Worktree path in tmp
|
|
1202
1315
|
const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId);
|
|
@@ -1207,34 +1320,73 @@ class IsolationManager {
|
|
|
1207
1320
|
fs.mkdirSync(parentDir, { recursive: true });
|
|
1208
1321
|
}
|
|
1209
1322
|
|
|
1210
|
-
//
|
|
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.
|
|
1211
1326
|
try {
|
|
1212
|
-
execSync(`git worktree remove --force
|
|
1327
|
+
execSync(`git worktree remove --force ${escapeShell(worktreePath)}`, {
|
|
1213
1328
|
cwd: repoRoot,
|
|
1214
1329
|
encoding: 'utf8',
|
|
1215
1330
|
stdio: 'pipe',
|
|
1216
1331
|
});
|
|
1217
1332
|
} catch {
|
|
1218
|
-
//
|
|
1333
|
+
// ignore
|
|
1219
1334
|
}
|
|
1220
|
-
|
|
1221
|
-
// Delete the branch if it exists (from previous run)
|
|
1222
1335
|
try {
|
|
1223
|
-
execSync(
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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 });
|
|
1228
1342
|
} catch {
|
|
1229
|
-
//
|
|
1343
|
+
// ignore
|
|
1230
1344
|
}
|
|
1231
1345
|
|
|
1232
|
-
// Create worktree with new branch based on HEAD
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
+
}
|
|
1238
1390
|
|
|
1239
1391
|
return {
|
|
1240
1392
|
path: worktreePath,
|
|
@@ -1250,19 +1402,46 @@ class IsolationManager {
|
|
|
1250
1402
|
* @param {boolean} [options.deleteBranch=false] - Also delete the branch
|
|
1251
1403
|
*/
|
|
1252
1404
|
removeWorktree(worktreeInfo, _options = {}) {
|
|
1405
|
+
// Remove the worktree (prefer git so metadata is cleaned up).
|
|
1253
1406
|
try {
|
|
1254
|
-
|
|
1255
|
-
execSync(`git worktree remove --force "${worktreeInfo.path}" 2>/dev/null`, {
|
|
1407
|
+
execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
|
|
1256
1408
|
cwd: worktreeInfo.repoRoot,
|
|
1257
1409
|
encoding: 'utf8',
|
|
1258
1410
|
stdio: 'pipe',
|
|
1259
1411
|
});
|
|
1260
1412
|
} catch {
|
|
1261
|
-
//
|
|
1413
|
+
// If git worktree metadata is stale, prune and retry once.
|
|
1262
1414
|
try {
|
|
1263
|
-
|
|
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
|
+
});
|
|
1264
1429
|
} catch {
|
|
1265
|
-
//
|
|
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
|
+
}
|
|
1266
1445
|
}
|
|
1267
1446
|
}
|
|
1268
1447
|
|