@covibes/zeroshot 1.0.2 → 1.1.4

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.
@@ -164,12 +164,19 @@ class Orchestrator {
164
164
  isolationManager = new IsolationManager({ image: isolation.image });
165
165
  // Restore the container mapping so cleanup works
166
166
  isolationManager.containers.set(clusterId, isolation.containerId);
167
+ // Restore isolated dir mapping for workspace preservation during cleanup
168
+ if (isolation.workDir) {
169
+ isolationManager.isolatedDirs.set(clusterId, {
170
+ path: path.join(os.tmpdir(), 'zeroshot-isolated', clusterId),
171
+ originalDir: isolation.workDir,
172
+ });
173
+ }
167
174
  isolation = {
168
175
  ...isolation,
169
176
  manager: isolationManager,
170
177
  };
171
178
  this._log(
172
- `[Orchestrator] Restored isolation manager for ${clusterId} (container: ${isolation.containerId})`
179
+ `[Orchestrator] Restored isolation manager for ${clusterId} (container: ${isolation.containerId}, workDir: ${isolation.workDir || 'unknown'})`
173
180
  );
174
181
  }
175
182
 
@@ -289,6 +296,13 @@ class Orchestrator {
289
296
  continue;
290
297
  }
291
298
 
299
+ // CRITICAL: Killed clusters are DELETED from disk, not persisted
300
+ // This ensures they can't be accidentally resumed
301
+ if (cluster.state === 'killed') {
302
+ delete existingClusters[clusterId];
303
+ continue;
304
+ }
305
+
292
306
  existingClusters[clusterId] = {
293
307
  id: cluster.id,
294
308
  config: cluster.config,
@@ -299,11 +313,13 @@ class Orchestrator {
299
313
  // Persist failure info for resume capability
300
314
  failureInfo: cluster.failureInfo || null,
301
315
  // Persist isolation info (excluding manager instance which can't be serialized)
316
+ // CRITICAL: workDir is required for resume() to recreate container with same workspace
302
317
  isolation: cluster.isolation
303
318
  ? {
304
319
  enabled: cluster.isolation.enabled,
305
320
  containerId: cluster.isolation.containerId,
306
321
  image: cluster.isolation.image,
322
+ workDir: cluster.isolation.workDir, // Required for resume
307
323
  }
308
324
  : null,
309
325
  };
@@ -489,12 +505,14 @@ class Orchestrator {
489
505
  // Track PID for zombie detection (this process owns the cluster)
490
506
  pid: process.pid,
491
507
  // Isolation state (only if enabled)
508
+ // CRITICAL: Store workDir for resume capability - without this, resume() can't recreate container
492
509
  isolation: options.isolation
493
510
  ? {
494
511
  enabled: true,
495
512
  containerId,
496
513
  image: options.isolationImage || 'zeroshot-cluster-base',
497
514
  manager: isolationManager,
515
+ workDir: options.cwd || process.cwd(), // Persisted for resume
498
516
  }
499
517
  : null,
500
518
  };
@@ -823,10 +841,11 @@ class Orchestrator {
823
841
  }
824
842
 
825
843
  // Clean up isolation container if enabled
844
+ // CRITICAL: Preserve workspace for resume capability - only delete on kill()
826
845
  if (cluster.isolation?.manager) {
827
- this._log(`[Orchestrator] Cleaning up isolation container for ${clusterId}...`);
828
- await cluster.isolation.manager.cleanup(clusterId);
829
- this._log(`[Orchestrator] Container removed`);
846
+ this._log(`[Orchestrator] Stopping isolation container for ${clusterId} (preserving workspace for resume)...`);
847
+ await cluster.isolation.manager.cleanup(clusterId, { preserveWorkspace: true });
848
+ this._log(`[Orchestrator] Container stopped, workspace preserved`);
830
849
  }
831
850
 
832
851
  cluster.state = 'stopped';
@@ -854,11 +873,11 @@ class Orchestrator {
854
873
  await agent.stop();
855
874
  }
856
875
 
857
- // Force remove isolation container if enabled
876
+ // Force remove isolation container AND workspace (full cleanup, no resume)
858
877
  if (cluster.isolation?.manager) {
859
- this._log(`[Orchestrator] Force removing isolation container for ${clusterId}...`);
860
- await cluster.isolation.manager.removeContainer(clusterId, true); // force=true
861
- this._log(`[Orchestrator] Container removed`);
878
+ this._log(`[Orchestrator] Force removing isolation container and workspace for ${clusterId}...`);
879
+ await cluster.isolation.manager.cleanup(clusterId, { preserveWorkspace: false });
880
+ this._log(`[Orchestrator] Container and workspace removed`);
862
881
  }
863
882
 
864
883
  // Close message bus and ledger
@@ -976,10 +995,26 @@ class Orchestrator {
976
995
  if (!containerExists) {
977
996
  this._log(`[Orchestrator] Container ${oldContainerId} not found, recreating...`);
978
997
 
979
- // Create new container
998
+ // Create new container using saved workDir (CRITICAL for isolation mode resume)
999
+ // The isolated workspace at /tmp/zeroshot-isolated/{clusterId} was preserved by stop()
1000
+ const workDir = cluster.isolation.workDir;
1001
+ if (!workDir) {
1002
+ throw new Error(`Cannot resume cluster ${clusterId}: workDir not saved in isolation state`);
1003
+ }
1004
+
1005
+ // Check if isolated workspace still exists (it should, if stop() was used)
1006
+ const isolatedPath = path.join(os.tmpdir(), 'zeroshot-isolated', clusterId);
1007
+ if (!fs.existsSync(isolatedPath)) {
1008
+ throw new Error(
1009
+ `Cannot resume cluster ${clusterId}: isolated workspace deleted. ` +
1010
+ `Was the cluster killed (not stopped)? Use 'zeroshot run' to start fresh.`
1011
+ );
1012
+ }
1013
+
980
1014
  const newContainerId = await cluster.isolation.manager.createContainer(clusterId, {
981
- workDir: process.cwd(),
1015
+ workDir, // Use saved workDir, NOT process.cwd()
982
1016
  image: cluster.isolation.image,
1017
+ reuseExistingWorkspace: true, // CRITICAL: Don't wipe existing work
983
1018
  });
984
1019
 
985
1020
  this._log(`[Orchestrator] New container created: ${newContainerId}`);
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Preflight Validation - Check all dependencies before starting
3
+ *
4
+ * Validates:
5
+ * - Claude CLI installed and authenticated
6
+ * - gh CLI installed and authenticated (if using issue numbers)
7
+ * - Docker available (if using --isolation)
8
+ *
9
+ * Provides CLEAR, ACTIONABLE error messages with recovery instructions.
10
+ */
11
+
12
+ const { execSync } = require('child_process');
13
+ const path = require('path');
14
+ const fs = require('fs');
15
+ const os = require('os');
16
+
17
+ /**
18
+ * Validation result
19
+ * @typedef {Object} ValidationResult
20
+ * @property {boolean} valid - Whether validation passed
21
+ * @property {string[]} errors - Fatal errors that block execution
22
+ * @property {string[]} warnings - Non-fatal warnings
23
+ */
24
+
25
+ /**
26
+ * Format error with recovery instructions
27
+ * @param {string} title - Error title
28
+ * @param {string} detail - Error details
29
+ * @param {string[]} recovery - Recovery steps
30
+ * @returns {string}
31
+ */
32
+ function formatError(title, detail, recovery) {
33
+ let msg = `\n❌ ${title}\n`;
34
+ msg += ` ${detail}\n`;
35
+ if (recovery.length > 0) {
36
+ msg += `\n To fix:\n`;
37
+ recovery.forEach((step, i) => {
38
+ msg += ` ${i + 1}. ${step}\n`;
39
+ });
40
+ }
41
+ return msg;
42
+ }
43
+
44
+ /**
45
+ * Check if a command exists
46
+ * @param {string} cmd - Command to check
47
+ * @returns {boolean}
48
+ */
49
+ function commandExists(cmd) {
50
+ try {
51
+ execSync(`which ${cmd}`, { encoding: 'utf8', stdio: 'pipe' });
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Get Claude CLI version
60
+ * @returns {{ installed: boolean, version: string | null, error: string | null }}
61
+ */
62
+ function getClaudeVersion() {
63
+ try {
64
+ const output = execSync('claude --version', { encoding: 'utf8', stdio: 'pipe' });
65
+ const match = output.match(/(\d+\.\d+\.\d+)/);
66
+ return {
67
+ installed: true,
68
+ version: match ? match[1] : 'unknown',
69
+ error: null,
70
+ };
71
+ } catch (err) {
72
+ if (err.message.includes('command not found') || err.message.includes('not found')) {
73
+ return {
74
+ installed: false,
75
+ version: null,
76
+ error: 'Claude CLI not installed',
77
+ };
78
+ }
79
+ return {
80
+ installed: false,
81
+ version: null,
82
+ error: err.message,
83
+ };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Check Claude CLI authentication status
89
+ * @returns {{ authenticated: boolean, error: string | null, configDir: string }}
90
+ */
91
+ function checkClaudeAuth() {
92
+ const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
93
+ const credentialsPath = path.join(configDir, '.credentials.json');
94
+
95
+ // Check if credentials file exists
96
+ if (!fs.existsSync(credentialsPath)) {
97
+ return {
98
+ authenticated: false,
99
+ error: 'No credentials file found',
100
+ configDir,
101
+ };
102
+ }
103
+
104
+ // Check if credentials file has content
105
+ try {
106
+ const content = fs.readFileSync(credentialsPath, 'utf8');
107
+ const creds = JSON.parse(content);
108
+
109
+ // Check for OAuth token (primary auth method)
110
+ if (creds.claudeAiOauth?.accessToken) {
111
+ // Check if token is expired
112
+ const expiresAt = creds.claudeAiOauth.expiresAt;
113
+ if (expiresAt && new Date(expiresAt) < new Date()) {
114
+ return {
115
+ authenticated: false,
116
+ error: 'OAuth token expired',
117
+ configDir,
118
+ };
119
+ }
120
+ return {
121
+ authenticated: true,
122
+ error: null,
123
+ configDir,
124
+ };
125
+ }
126
+
127
+ // Check for API key auth
128
+ if (creds.apiKey) {
129
+ return {
130
+ authenticated: true,
131
+ error: null,
132
+ configDir,
133
+ };
134
+ }
135
+
136
+ return {
137
+ authenticated: false,
138
+ error: 'No valid authentication found in credentials',
139
+ configDir,
140
+ };
141
+ } catch (err) {
142
+ return {
143
+ authenticated: false,
144
+ error: `Failed to parse credentials: ${err.message}`,
145
+ configDir,
146
+ };
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Check gh CLI authentication status
152
+ * @returns {{ installed: boolean, authenticated: boolean, error: string | null }}
153
+ */
154
+ function checkGhAuth() {
155
+ // Check if gh is installed
156
+ if (!commandExists('gh')) {
157
+ return {
158
+ installed: false,
159
+ authenticated: false,
160
+ error: 'gh CLI not installed',
161
+ };
162
+ }
163
+
164
+ // Check auth status
165
+ try {
166
+ execSync('gh auth status', { encoding: 'utf8', stdio: 'pipe' });
167
+ return {
168
+ installed: true,
169
+ authenticated: true,
170
+ error: null,
171
+ };
172
+ } catch (err) {
173
+ // gh auth status returns non-zero if not authenticated
174
+ const stderr = err.stderr || err.message || '';
175
+
176
+ if (stderr.includes('not logged in')) {
177
+ return {
178
+ installed: true,
179
+ authenticated: false,
180
+ error: 'gh CLI not authenticated',
181
+ };
182
+ }
183
+
184
+ return {
185
+ installed: true,
186
+ authenticated: false,
187
+ error: stderr.trim() || 'Unknown gh auth error',
188
+ };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Check Docker availability
194
+ * @returns {{ available: boolean, error: string | null }}
195
+ */
196
+ function checkDocker() {
197
+ try {
198
+ execSync('docker --version', { encoding: 'utf8', stdio: 'pipe' });
199
+
200
+ // Also check if Docker daemon is running
201
+ execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
202
+
203
+ return {
204
+ available: true,
205
+ error: null,
206
+ };
207
+ } catch (err) {
208
+ const stderr = err.stderr || err.message || '';
209
+
210
+ if (stderr.includes('command not found') || stderr.includes('not found')) {
211
+ return {
212
+ available: false,
213
+ error: 'Docker not installed',
214
+ };
215
+ }
216
+
217
+ if (stderr.includes('Cannot connect') || stderr.includes('Is the docker daemon running')) {
218
+ return {
219
+ available: false,
220
+ error: 'Docker daemon not running',
221
+ };
222
+ }
223
+
224
+ return {
225
+ available: false,
226
+ error: stderr.trim() || 'Unknown Docker error',
227
+ };
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Run all preflight checks
233
+ * @param {Object} options - Preflight options
234
+ * @param {boolean} options.requireGh - Whether gh CLI is required (true if using issue number)
235
+ * @param {boolean} options.requireDocker - Whether Docker is required (true if using --isolation)
236
+ * @param {boolean} options.quiet - Suppress success messages
237
+ * @returns {ValidationResult}
238
+ */
239
+ function runPreflight(options = {}) {
240
+ const errors = [];
241
+ const warnings = [];
242
+
243
+ // 1. Check Claude CLI installation
244
+ const claude = getClaudeVersion();
245
+ if (!claude.installed) {
246
+ errors.push(
247
+ formatError(
248
+ 'Claude CLI not installed',
249
+ claude.error,
250
+ [
251
+ 'Install Claude CLI: npm install -g @anthropic-ai/claude-code',
252
+ 'Or: brew install claude (macOS)',
253
+ 'Then run: claude --version',
254
+ ]
255
+ )
256
+ );
257
+ } else {
258
+ // 2. Check Claude CLI authentication
259
+ const auth = checkClaudeAuth();
260
+ if (!auth.authenticated) {
261
+ errors.push(
262
+ formatError(
263
+ 'Claude CLI not authenticated',
264
+ auth.error,
265
+ [
266
+ 'Run: claude login',
267
+ 'Follow the browser prompts to authenticate',
268
+ `Config directory: ${auth.configDir}`,
269
+ ]
270
+ )
271
+ );
272
+ }
273
+
274
+ // Check version (warn if old)
275
+ if (claude.version) {
276
+ const [major, minor] = claude.version.split('.').map(Number);
277
+ if (major < 1 || (major === 1 && minor < 0)) {
278
+ warnings.push(
279
+ `⚠️ Claude CLI version ${claude.version} may be outdated. Consider upgrading.`
280
+ );
281
+ }
282
+ }
283
+ }
284
+
285
+ // 3. Check gh CLI (if required)
286
+ if (options.requireGh) {
287
+ const gh = checkGhAuth();
288
+ if (!gh.installed) {
289
+ errors.push(
290
+ formatError(
291
+ 'GitHub CLI (gh) not installed',
292
+ 'Required for fetching issues by number',
293
+ [
294
+ 'Install: brew install gh (macOS) or apt install gh (Linux)',
295
+ 'Or download from: https://cli.github.com/',
296
+ ]
297
+ )
298
+ );
299
+ } else if (!gh.authenticated) {
300
+ errors.push(
301
+ formatError(
302
+ 'GitHub CLI (gh) not authenticated',
303
+ gh.error,
304
+ [
305
+ 'Run: gh auth login',
306
+ 'Select GitHub.com, HTTPS, and authenticate via browser',
307
+ 'Then verify: gh auth status',
308
+ ]
309
+ )
310
+ );
311
+ }
312
+ }
313
+
314
+ // 4. Check Docker (if required)
315
+ if (options.requireDocker) {
316
+ const docker = checkDocker();
317
+ if (!docker.available) {
318
+ errors.push(
319
+ formatError(
320
+ 'Docker not available',
321
+ docker.error,
322
+ docker.error.includes('daemon')
323
+ ? ['Start Docker Desktop', 'Or run: sudo systemctl start docker (Linux)']
324
+ : [
325
+ 'Install Docker Desktop from: https://docker.com/products/docker-desktop',
326
+ 'Then start Docker and verify: docker info',
327
+ ]
328
+ )
329
+ );
330
+ }
331
+ }
332
+
333
+ return {
334
+ valid: errors.length === 0,
335
+ errors,
336
+ warnings,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Run preflight checks and exit if failed
342
+ * @param {Object} options - Preflight options
343
+ * @param {boolean} options.requireGh - Whether gh CLI is required
344
+ * @param {boolean} options.requireDocker - Whether Docker is required
345
+ * @param {boolean} options.quiet - Suppress success messages
346
+ */
347
+ function requirePreflight(options = {}) {
348
+ const result = runPreflight(options);
349
+
350
+ // Print warnings regardless of success
351
+ if (result.warnings.length > 0) {
352
+ for (const warning of result.warnings) {
353
+ console.warn(warning);
354
+ }
355
+ }
356
+
357
+ if (!result.valid) {
358
+ console.error('\n' + '='.repeat(60));
359
+ console.error('PREFLIGHT CHECK FAILED');
360
+ console.error('='.repeat(60));
361
+
362
+ for (const error of result.errors) {
363
+ console.error(error);
364
+ }
365
+
366
+ console.error('='.repeat(60) + '\n');
367
+ process.exit(1);
368
+ }
369
+
370
+ if (!options.quiet) {
371
+ console.log('✓ Preflight checks passed');
372
+ }
373
+ }
374
+
375
+ module.exports = {
376
+ runPreflight,
377
+ requirePreflight,
378
+ getClaudeVersion,
379
+ checkClaudeAuth,
380
+ checkGhAuth,
381
+ checkDocker,
382
+ formatError,
383
+ };