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