opencode-pilot 0.9.0 → 0.9.2
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/package.json +1 -1
- package/service/actions.js +38 -2
- package/test/unit/actions.test.js +84 -10
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -18,7 +18,9 @@ import os from "os";
|
|
|
18
18
|
*/
|
|
19
19
|
async function getOpencodePorts() {
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// Use full path to lsof since /usr/sbin may not be in PATH in all contexts
|
|
22
|
+
// (e.g., when running as a service or from certain shell environments)
|
|
23
|
+
const output = execSync('/usr/sbin/lsof -i -P 2>/dev/null | grep -E "opencode.*LISTEN" || true', {
|
|
22
24
|
encoding: 'utf-8',
|
|
23
25
|
timeout: 30000
|
|
24
26
|
});
|
|
@@ -75,6 +77,24 @@ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
|
|
|
75
77
|
return 0; // No match
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Verify a server is healthy by checking it returns valid project data
|
|
82
|
+
* @param {string} url - Server URL
|
|
83
|
+
* @param {object} project - Project data already fetched from /project/current
|
|
84
|
+
* @returns {boolean} True if server appears healthy
|
|
85
|
+
*/
|
|
86
|
+
function isServerHealthy(project) {
|
|
87
|
+
// A healthy server should return a project with an id and time.created
|
|
88
|
+
// Stale/broken servers may return HTML or incomplete JSON
|
|
89
|
+
return !!(
|
|
90
|
+
project &&
|
|
91
|
+
typeof project === 'object' &&
|
|
92
|
+
project.id &&
|
|
93
|
+
project.time &&
|
|
94
|
+
typeof project.time.created === 'number'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
/**
|
|
79
99
|
* Discover a running opencode server that matches the target directory
|
|
80
100
|
*
|
|
@@ -82,7 +102,10 @@ function getPathMatchScore(targetPath, worktree, sandboxes = []) {
|
|
|
82
102
|
* 1. Exact sandbox match (highest priority)
|
|
83
103
|
* 2. Exact worktree match
|
|
84
104
|
* 3. Target is subdirectory of worktree
|
|
85
|
-
*
|
|
105
|
+
*
|
|
106
|
+
* NOTE: Global servers (worktree="/") are NOT used - sessions spawned via
|
|
107
|
+
* pilot should run in isolated mode rather than attach to the global project,
|
|
108
|
+
* since the global project doesn't have the right working directory context.
|
|
86
109
|
*
|
|
87
110
|
* @param {string} targetDir - The directory we want to work in
|
|
88
111
|
* @param {object} [options] - Options for testing/mocking
|
|
@@ -115,9 +138,22 @@ export async function discoverOpencodeServer(targetDir, options = {}) {
|
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
const project = await response.json();
|
|
141
|
+
|
|
142
|
+
// Health check: verify response has expected structure
|
|
143
|
+
if (!isServerHealthy(project)) {
|
|
144
|
+
debug(`discoverOpencodeServer: ${url} failed health check (invalid project data)`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
118
148
|
const worktree = project.worktree || '/';
|
|
119
149
|
const sandboxes = project.sandboxes || [];
|
|
120
150
|
|
|
151
|
+
// Skip global servers - pilot sessions should run isolated
|
|
152
|
+
if (worktree === '/') {
|
|
153
|
+
debug(`discoverOpencodeServer: ${url} is global project, skipping`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
121
157
|
const score = getPathMatchScore(targetDir, worktree, sandboxes);
|
|
122
158
|
debug(`discoverOpencodeServer: ${url} worktree=${worktree} score=${score}`);
|
|
123
159
|
|
|
@@ -310,10 +310,10 @@ describe('actions.js', () => {
|
|
|
310
310
|
const mockPorts = async () => [3000, 4000];
|
|
311
311
|
const mockFetch = async (url) => {
|
|
312
312
|
if (url === 'http://localhost:3000/project/current') {
|
|
313
|
-
return { ok: true, json: async () => ({ worktree: '/Users/test/project-a', sandboxes: [] }) };
|
|
313
|
+
return { ok: true, json: async () => ({ id: 'proj-a', worktree: '/Users/test/project-a', sandboxes: [], time: { created: 1 } }) };
|
|
314
314
|
}
|
|
315
315
|
if (url === 'http://localhost:4000/project/current') {
|
|
316
|
-
return { ok: true, json: async () => ({ worktree: '/Users/test/project-b', sandboxes: [] }) };
|
|
316
|
+
return { ok: true, json: async () => ({ id: 'proj-b', worktree: '/Users/test/project-b', sandboxes: [], time: { created: 1 } }) };
|
|
317
317
|
}
|
|
318
318
|
return { ok: false };
|
|
319
319
|
};
|
|
@@ -332,7 +332,7 @@ describe('actions.js', () => {
|
|
|
332
332
|
const mockPorts = async () => [3000];
|
|
333
333
|
const mockFetch = async (url) => {
|
|
334
334
|
if (url === 'http://localhost:3000/project/current') {
|
|
335
|
-
return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
|
|
335
|
+
return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
|
|
336
336
|
}
|
|
337
337
|
return { ok: false };
|
|
338
338
|
};
|
|
@@ -352,8 +352,10 @@ describe('actions.js', () => {
|
|
|
352
352
|
const mockFetch = async (url) => {
|
|
353
353
|
if (url === 'http://localhost:3000/project/current') {
|
|
354
354
|
return { ok: true, json: async () => ({
|
|
355
|
+
id: 'proj',
|
|
355
356
|
worktree: '/Users/test/project',
|
|
356
|
-
sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1']
|
|
357
|
+
sandboxes: ['/Users/test/.opencode/worktree/abc/sandbox-1'],
|
|
358
|
+
time: { created: 1 }
|
|
357
359
|
}) };
|
|
358
360
|
}
|
|
359
361
|
return { ok: false };
|
|
@@ -374,11 +376,11 @@ describe('actions.js', () => {
|
|
|
374
376
|
const mockFetch = async (url) => {
|
|
375
377
|
if (url === 'http://localhost:3000/project/current') {
|
|
376
378
|
// Global project
|
|
377
|
-
return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
|
|
379
|
+
return { ok: true, json: async () => ({ id: 'global', worktree: '/', sandboxes: [], time: { created: 1 } }) };
|
|
378
380
|
}
|
|
379
381
|
if (url === 'http://localhost:4000/project/current') {
|
|
380
382
|
// Specific project
|
|
381
|
-
return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
|
|
383
|
+
return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
|
|
382
384
|
}
|
|
383
385
|
return { ok: false };
|
|
384
386
|
};
|
|
@@ -392,23 +394,25 @@ describe('actions.js', () => {
|
|
|
392
394
|
assert.strictEqual(result, 'http://localhost:4000');
|
|
393
395
|
});
|
|
394
396
|
|
|
395
|
-
test('
|
|
397
|
+
test('skips global project servers', async () => {
|
|
396
398
|
const { discoverOpencodeServer } = await import('../../service/actions.js');
|
|
397
399
|
|
|
398
400
|
const mockPorts = async () => [3000];
|
|
399
401
|
const mockFetch = async (url) => {
|
|
400
402
|
if (url === 'http://localhost:3000/project/current') {
|
|
401
|
-
|
|
403
|
+
// Global project with worktree="/"
|
|
404
|
+
return { ok: true, json: async () => ({ id: 'global', worktree: '/', sandboxes: [], time: { created: 1 } }) };
|
|
402
405
|
}
|
|
403
406
|
return { ok: false };
|
|
404
407
|
};
|
|
405
408
|
|
|
409
|
+
// Global servers should be skipped - pilot sessions should run isolated
|
|
406
410
|
const result = await discoverOpencodeServer('/Users/test/random/path', {
|
|
407
411
|
getPorts: mockPorts,
|
|
408
412
|
fetch: mockFetch
|
|
409
413
|
});
|
|
410
414
|
|
|
411
|
-
assert.strictEqual(result,
|
|
415
|
+
assert.strictEqual(result, null);
|
|
412
416
|
});
|
|
413
417
|
|
|
414
418
|
test('returns null when fetch fails for all servers', async () => {
|
|
@@ -436,7 +440,56 @@ describe('actions.js', () => {
|
|
|
436
440
|
return { ok: false };
|
|
437
441
|
}
|
|
438
442
|
if (url === 'http://localhost:4000/project/current') {
|
|
439
|
-
return { ok: true, json: async () => ({ worktree: '/Users/test/project', sandboxes: [] }) };
|
|
443
|
+
return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
|
|
444
|
+
}
|
|
445
|
+
return { ok: false };
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const result = await discoverOpencodeServer('/Users/test/project', {
|
|
449
|
+
getPorts: mockPorts,
|
|
450
|
+
fetch: mockFetch
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
assert.strictEqual(result, 'http://localhost:4000');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test('skips servers that return invalid JSON', async () => {
|
|
457
|
+
const { discoverOpencodeServer } = await import('../../service/actions.js');
|
|
458
|
+
|
|
459
|
+
const mockPorts = async () => [3000, 4000];
|
|
460
|
+
const mockFetch = async (url) => {
|
|
461
|
+
if (url === 'http://localhost:3000/project/current') {
|
|
462
|
+
// Stale server returning HTML
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
json: async () => { throw new SyntaxError('Unexpected token < in JSON'); }
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (url === 'http://localhost:4000/project/current') {
|
|
469
|
+
return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
|
|
470
|
+
}
|
|
471
|
+
return { ok: false };
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const result = await discoverOpencodeServer('/Users/test/project', {
|
|
475
|
+
getPorts: mockPorts,
|
|
476
|
+
fetch: mockFetch
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
assert.strictEqual(result, 'http://localhost:4000');
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test('skips servers with incomplete project data (missing time)', async () => {
|
|
483
|
+
const { discoverOpencodeServer } = await import('../../service/actions.js');
|
|
484
|
+
|
|
485
|
+
const mockPorts = async () => [3000, 4000];
|
|
486
|
+
const mockFetch = async (url) => {
|
|
487
|
+
if (url === 'http://localhost:3000/project/current') {
|
|
488
|
+
// Server returning incomplete response (missing time.created)
|
|
489
|
+
return { ok: true, json: async () => ({ id: 'broken', worktree: '/Users/test/project', sandboxes: [] }) };
|
|
490
|
+
}
|
|
491
|
+
if (url === 'http://localhost:4000/project/current') {
|
|
492
|
+
return { ok: true, json: async () => ({ id: 'proj', worktree: '/Users/test/project', sandboxes: [], time: { created: 1 } }) };
|
|
440
493
|
}
|
|
441
494
|
return { ok: false };
|
|
442
495
|
};
|
|
@@ -448,6 +501,27 @@ describe('actions.js', () => {
|
|
|
448
501
|
|
|
449
502
|
assert.strictEqual(result, 'http://localhost:4000');
|
|
450
503
|
});
|
|
504
|
+
|
|
505
|
+
test('returns null when server returns unhealthy project data', async () => {
|
|
506
|
+
const { discoverOpencodeServer } = await import('../../service/actions.js');
|
|
507
|
+
|
|
508
|
+
const mockPorts = async () => [3000];
|
|
509
|
+
const mockFetch = async (url) => {
|
|
510
|
+
if (url === 'http://localhost:3000/project/current') {
|
|
511
|
+
// Server returns incomplete/unhealthy data (missing id and time)
|
|
512
|
+
return { ok: true, json: async () => ({ worktree: '/', sandboxes: [] }) };
|
|
513
|
+
}
|
|
514
|
+
return { ok: false };
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Server that returns unhealthy project data should be skipped
|
|
518
|
+
const result = await discoverOpencodeServer('/Users/test/specific-project', {
|
|
519
|
+
getPorts: mockPorts,
|
|
520
|
+
fetch: mockFetch
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
assert.strictEqual(result, null, 'Should return null when server returns unhealthy project data');
|
|
524
|
+
});
|
|
451
525
|
});
|
|
452
526
|
|
|
453
527
|
describe('executeAction', () => {
|