@shogo-ai/worker 1.9.9 → 1.10.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.
@@ -0,0 +1,128 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * `shogo doctor` — diagnose and repair a wedged local Shogo database.
5
+ *
6
+ * shogo doctor — detect + (with confirmation) repair
7
+ * shogo doctor --check — detect only, never mutate
8
+ * shogo doctor --yes — repair without the confirmation prompt
9
+ * shogo doctor --db <path> — target a specific shogo.db
10
+ * shogo doctor --bun <path> — use a specific bun binary
11
+ * shogo doctor --no-backup — skip the pre-repair backup (discouraged)
12
+ *
13
+ * Clears `_prisma_migrations` rows left in a failed (P3009) state so the
14
+ * desktop app can re-apply migrations on its next launch. Always backs up
15
+ * the database first unless `--no-backup` is passed.
16
+ */
17
+ import { createInterface } from 'node:readline';
18
+ import pc from 'picocolors';
19
+ import {
20
+ detectFailedMigrations,
21
+ runDatabaseDoctor,
22
+ resolveDesktopDbPath,
23
+ resolveBunBinary,
24
+ type FailedMigration,
25
+ } from '../lib/db-doctor.ts';
26
+
27
+ export interface DoctorFlags {
28
+ check?: boolean;
29
+ yes?: boolean;
30
+ db?: string;
31
+ bun?: string;
32
+ backup?: boolean; // commander sets `backup: false` for --no-backup
33
+ }
34
+
35
+ function printFailures(failures: FailedMigration[]): void {
36
+ for (const f of failures) {
37
+ const when = Number.isFinite(f.startedAt) ? new Date(f.startedAt).toISOString() : 'unknown time';
38
+ const firstLine = (f.errorExcerpt ?? '').split('\n')[0]?.trim();
39
+ console.log(` ${pc.yellow('•')} ${pc.bold(f.name)} ${pc.dim(`(attempted ${when})`)}`);
40
+ if (firstLine) console.log(` ${pc.dim(firstLine)}`);
41
+ }
42
+ }
43
+
44
+ async function confirm(question: string): Promise<boolean> {
45
+ // Non-interactive (CI, piped) stdin: refuse rather than hang.
46
+ if (!process.stdin.isTTY) {
47
+ console.log(pc.dim('(stdin is not a TTY — re-run with --yes to repair non-interactively)'));
48
+ return false;
49
+ }
50
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
51
+ try {
52
+ const answer = await new Promise<string>((resolve) => rl.question(question, resolve));
53
+ return /^y(es)?$/i.test(answer.trim());
54
+ } finally {
55
+ rl.close();
56
+ }
57
+ }
58
+
59
+ export async function runDoctor(flags: DoctorFlags = {}): Promise<void> {
60
+ const dbPath = flags.db ?? resolveDesktopDbPath();
61
+ const bunPath = resolveBunBinary(flags.bun);
62
+
63
+ if (!bunPath) {
64
+ throw new Error(
65
+ 'Could not find a usable `bun` binary (needed to inspect the SQLite database).\n' +
66
+ ' • Install Bun (https://bun.sh) so `bun` is on your PATH, or\n' +
67
+ ' • pass --bun <path> pointing at the bun shipped inside the Shogo app\n' +
68
+ " (e.g. /Applications/Shogo.app/Contents/Resources/bun/bun on macOS).",
69
+ );
70
+ }
71
+
72
+ console.log(pc.dim(`Database: ${dbPath}`));
73
+ console.log(pc.dim(`Bun: ${bunPath}`));
74
+ console.log();
75
+
76
+ const failures = detectFailedMigrations(bunPath, dbPath);
77
+
78
+ if (failures.length === 0) {
79
+ console.log(pc.green('✓ No failed migrations detected — your local database looks healthy.'));
80
+ return;
81
+ }
82
+
83
+ console.log(pc.red(`✗ Found ${failures.length} failed migration(s):`));
84
+ printFailures(failures);
85
+ console.log();
86
+
87
+ if (flags.check) {
88
+ console.log(
89
+ pc.dim('Run `shogo doctor` (without --check) to back up the database and clear these records.'),
90
+ );
91
+ // Signal "needs repair" to scripts/CI without throwing a stack trace.
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ if (!flags.yes) {
97
+ const backupNote =
98
+ flags.backup === false
99
+ ? pc.red('This will NOT create a backup (--no-backup).')
100
+ : 'Your database will be backed up to a .bak-<timestamp> file first.';
101
+ console.log(backupNote);
102
+ const ok = await confirm(pc.bold('Clear these failed migration records and repair? [y/N] '));
103
+ if (!ok) {
104
+ console.log(pc.dim('Aborted — no changes made.'));
105
+ process.exitCode = 1;
106
+ return;
107
+ }
108
+ console.log();
109
+ }
110
+
111
+ const result = runDatabaseDoctor({
112
+ bunPath,
113
+ dbPath,
114
+ skipBackup: flags.backup === false,
115
+ log: (line) => console.log(pc.dim(` ${line}`)),
116
+ });
117
+
118
+ console.log();
119
+ if (result.status === 'repaired') {
120
+ console.log(pc.green(`✓ ${result.message}`));
121
+ if (result.backupPath) console.log(pc.dim(` Backup: ${result.backupPath}`));
122
+ console.log(pc.bold('\n → Relaunch the Shogo app to finish applying migrations.'));
123
+ } else {
124
+ console.log(pc.red(`✗ ${result.message}`));
125
+ if (result.backupPath) console.log(pc.dim(` Backup: ${result.backupPath}`));
126
+ process.exitCode = 1;
127
+ }
128
+ }
@@ -124,9 +124,17 @@ export async function runStart(flags: StartFlags): Promise<void> {
124
124
  }
125
125
  console.log('');
126
126
 
127
+ // Derive the AI proxy URL from the cloud URL so the agent-runtime
128
+ // routes LLM calls through Shogo Cloud's proxy instead of requiring
129
+ // direct API keys on the machine. The proxy endpoint lives at
130
+ // <cloudUrl>/api/ai/v1 — the same base the desktop app uses.
131
+ const aiProxyUrl = `${cfg.cloudUrl.replace(/\/+$/, '')}/api/ai/v1`;
132
+
127
133
  const defaultSpawnConfig: ProjectSpawnConfig = {
128
134
  cloudUrl: cfg.cloudUrl,
129
135
  apiKey: cfg.apiKey,
136
+ aiProxyUrl,
137
+ aiProxyToken: cfg.apiKey,
130
138
  // No projectDir up front — the runtime manager's `maybeAutoPull`
131
139
  // sets PROJECT_DIR per-project to <projectsDir>/<projectId>/ once
132
140
  // the clone completes. CWD defaults to that same directory.
@@ -261,13 +269,18 @@ function buildChildArgv(flags: StartFlags): string[] {
261
269
  * 2. The compiled binary at /usr/local/bin/shogo on PATH (best-effort).
262
270
  * 3. The bin shim shipped with this package.
263
271
  */
264
- function resolveSelfEntry(): { entry: string; runner: 'bun' | 'node' } {
272
+ function resolveSelfEntry(): { entry: string; runner: 'bun' | 'node' | 'tsx' } {
265
273
  // process.execPath is the bun/node binary; argv[1] is the script.
266
274
  const execPath = process.execPath;
267
275
  const isBun = /\bbun(?:-[^/\\]*)?$/.test(execPath) || typeof (globalThis as any).Bun !== 'undefined';
268
276
  const argvScript = process.argv[1];
269
277
  if (argvScript && existsSync(argvScript)) {
270
- return { entry: argvScript, runner: isBun ? 'bun' : 'node' };
278
+ // When spawned via tsx (from the bin shim), process.execPath is still
279
+ // `node` because tsx runs on top of Node. Detect a .ts entry and route
280
+ // through tsx so the detached child can handle TypeScript natively.
281
+ const isTs = /\.ts$/.test(argvScript);
282
+ const runner = isBun ? 'bun' : (isTs ? 'tsx' : 'node');
283
+ return { entry: argvScript, runner };
271
284
  }
272
285
  // Fallback: the compiled bin shim shipped with the package.
273
286
  // Resolved relative to this file via import.meta.url so it works
@@ -0,0 +1,92 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Smoke tests for the MIT `shogo` agent launcher (argv + env shape).
5
+ * buildAgentSpawn is pure, so no spawn / network / runtime binary is needed.
6
+ */
7
+ import { describe, test, expect } from 'bun:test'
8
+ import { resolve as resolvePath } from 'node:path'
9
+ import { buildAgentSpawn } from '../../commands/agent.ts'
10
+ import type { ResolvedRuntime } from '../runtime-resolver.ts'
11
+
12
+ const runtime: ResolvedRuntime = { path: '/home/u/.shogo/runtime/agent-runtime', source: 'home' }
13
+ const config = { apiKey: 'shogo_sk_test123', cloudUrl: 'https://studio.shogo.ai' }
14
+
15
+ describe('buildAgentSpawn', () => {
16
+ test('interactive launch: argv + billing env point at the proxy', () => {
17
+ const plan = buildAgentSpawn({
18
+ flags: { cwd: '/work/repo' },
19
+ config,
20
+ runtime,
21
+ baseEnv: {},
22
+ })
23
+
24
+ expect(plan.bin).toBe('/home/u/.shogo/runtime/agent-runtime')
25
+ expect(plan.args).toEqual(['interactive'])
26
+ expect(plan.cwd).toBe(resolvePath('/work/repo'))
27
+
28
+ expect(plan.env.SHOGO_INTERACTIVE).toBe('1')
29
+ expect(plan.env.SHOGO_INTERACTIVE_CWD).toBe(resolvePath('/work/repo'))
30
+ expect(plan.env.PROJECT_DIR).toBe(resolvePath('/work/repo'))
31
+ expect(plan.env.WORKSPACE_DIR).toBe(resolvePath('/work/repo'))
32
+ expect(plan.env.SHOGO_API_URL).toBe('https://studio.shogo.ai')
33
+ expect(plan.env.SHOGO_API_KEY).toBe('shogo_sk_test123')
34
+ expect(plan.env.AI_PROXY_URL).toBe('https://studio.shogo.ai/api/ai/v1')
35
+ expect(plan.env.AI_PROXY_TOKEN).toBe('shogo_sk_test123')
36
+ })
37
+
38
+ test('headless -p adds the print argv + SHOGO_PRINT_PROMPT', () => {
39
+ const plan = buildAgentSpawn({
40
+ flags: { print: 'review this', cwd: '/work/repo' },
41
+ config,
42
+ runtime,
43
+ baseEnv: {},
44
+ })
45
+ expect(plan.args).toEqual(['interactive', '-p', 'review this'])
46
+ expect(plan.env.SHOGO_PRINT_PROMPT).toBe('review this')
47
+ })
48
+
49
+ test('--no-tui and --model are threaded through', () => {
50
+ const plan = buildAgentSpawn({
51
+ flags: { noTui: true, model: 'claude-sonnet', cwd: '/work/repo' },
52
+ config,
53
+ runtime,
54
+ baseEnv: {},
55
+ })
56
+ expect(plan.args).toEqual(['interactive', '--no-tui'])
57
+ expect(plan.env.SHOGO_MODEL).toBe('claude-sonnet')
58
+ })
59
+
60
+ test('trailing slash on cloudUrl is stripped before building proxy URL', () => {
61
+ const plan = buildAgentSpawn({
62
+ flags: { cwd: '/work/repo' },
63
+ config: { apiKey: 'shogo_sk_x', cloudUrl: 'https://example.dev/' },
64
+ runtime,
65
+ baseEnv: {},
66
+ })
67
+ expect(plan.env.SHOGO_API_URL).toBe('https://example.dev')
68
+ expect(plan.env.AI_PROXY_URL).toBe('https://example.dev/api/ai/v1')
69
+ })
70
+
71
+ test('base env is preserved (e.g. PATH) and not clobbered', () => {
72
+ const plan = buildAgentSpawn({
73
+ flags: { cwd: '/work/repo' },
74
+ config,
75
+ runtime,
76
+ baseEnv: { PATH: '/usr/bin', HOME: '/home/u' },
77
+ })
78
+ expect(plan.env.PATH).toBe('/usr/bin')
79
+ expect(plan.env.HOME).toBe('/home/u')
80
+ })
81
+
82
+ test('empty -p (no value) still routes to headless argv', () => {
83
+ const plan = buildAgentSpawn({
84
+ flags: { print: '', cwd: '/work/repo' },
85
+ config,
86
+ runtime,
87
+ baseEnv: {},
88
+ })
89
+ expect(plan.args).toEqual(['interactive', '-p', ''])
90
+ expect(plan.env.SHOGO_PRINT_PROMPT).toBe('')
91
+ })
92
+ })
@@ -0,0 +1,204 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ //
4
+ // Tests for the local SQLite migration doctor.
5
+ //
6
+ // The doctor shells out to a `bun` binary to run `bun:sqlite` scripts.
7
+ // Under `bun test` the test process IS bun, so `process.execPath` is a
8
+ // valid bun binary to drive the real shell-out path against synthesized
9
+ // databases (mirrors apps/desktop/test-db-recovery.ts).
10
+
11
+ import { describe, it, expect, afterAll } from 'bun:test';
12
+ import { Database } from 'bun:sqlite';
13
+ import { mkdtempSync, rmSync, existsSync, statSync } from 'node:fs';
14
+ import { tmpdir, homedir } from 'node:os';
15
+ import { join, dirname } from 'node:path';
16
+ import { randomUUID } from 'node:crypto';
17
+
18
+ import {
19
+ detectFailedMigrations,
20
+ backupDatabase,
21
+ repairFailedMigrations,
22
+ runDatabaseDoctor,
23
+ resolveDesktopDataDir,
24
+ resolveDesktopDbPath,
25
+ resolveBunBinary,
26
+ } from '../db-doctor.ts';
27
+
28
+ const BUN_PATH = process.execPath;
29
+
30
+ const tempDirs: string[] = [];
31
+ afterAll(() => {
32
+ for (const dir of tempDirs) {
33
+ try {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ } catch {
36
+ // best-effort
37
+ }
38
+ }
39
+ });
40
+
41
+ interface SeedRow {
42
+ name: string;
43
+ startedAt: number;
44
+ finishedAt: number | null;
45
+ rolledBackAt?: number | null;
46
+ logs?: string;
47
+ }
48
+
49
+ function makeDbPath(): string {
50
+ const dir = mkdtempSync(join(tmpdir(), 'shogo-doctor-test-'));
51
+ tempDirs.push(dir);
52
+ return join(dir, 'shogo.db');
53
+ }
54
+
55
+ function seed(dbPath: string, rows: SeedRow[]): void {
56
+ const db = new Database(dbPath, { create: true });
57
+ db.exec(`
58
+ CREATE TABLE _prisma_migrations (
59
+ id TEXT PRIMARY KEY,
60
+ migration_name TEXT NOT NULL,
61
+ checksum TEXT NOT NULL,
62
+ finished_at INTEGER,
63
+ started_at INTEGER NOT NULL,
64
+ rolled_back_at INTEGER,
65
+ logs TEXT,
66
+ applied_steps_count INTEGER NOT NULL DEFAULT 0
67
+ );
68
+ `);
69
+ const stmt = db.prepare(
70
+ 'INSERT INTO _prisma_migrations (id, migration_name, checksum, started_at, finished_at, rolled_back_at, logs) VALUES (?, ?, ?, ?, ?, ?, ?)',
71
+ );
72
+ for (const r of rows) {
73
+ stmt.run(randomUUID(), r.name, 'fake-checksum', r.startedAt, r.finishedAt, r.rolledBackAt ?? null, r.logs ?? null);
74
+ }
75
+ db.close();
76
+ }
77
+
78
+ describe('detectFailedMigrations', () => {
79
+ it('finds the stuck row on a broken DB', () => {
80
+ const dbPath = makeDbPath();
81
+ seed(dbPath, [
82
+ { name: '0000_baseline', startedAt: 1000, finishedAt: 1100 },
83
+ { name: '0001_bad', startedAt: 2000, finishedAt: null, logs: 'no such table: widgets' },
84
+ ]);
85
+ const failures = detectFailedMigrations(BUN_PATH, dbPath);
86
+ expect(failures).toHaveLength(1);
87
+ expect(failures[0]?.name).toBe('0001_bad');
88
+ expect(failures[0]?.startedAt).toBe(2000);
89
+ expect(failures[0]?.errorExcerpt).toContain('no such table: widgets');
90
+ });
91
+
92
+ it('returns [] for a healthy DB', () => {
93
+ const dbPath = makeDbPath();
94
+ seed(dbPath, [{ name: '0000_baseline', startedAt: 1000, finishedAt: 1100 }]);
95
+ expect(detectFailedMigrations(BUN_PATH, dbPath)).toHaveLength(0);
96
+ });
97
+
98
+ it('returns [] when the DB file does not exist', () => {
99
+ expect(detectFailedMigrations(BUN_PATH, join(tmpdir(), `nope-${Date.now()}.db`))).toHaveLength(0);
100
+ });
101
+ });
102
+
103
+ describe('runDatabaseDoctor', () => {
104
+ it('reports no-database for a missing file', () => {
105
+ const result = runDatabaseDoctor({
106
+ bunPath: BUN_PATH,
107
+ dbPath: join(tmpdir(), `missing-${Date.now()}.db`),
108
+ });
109
+ expect(result.status).toBe('no-database');
110
+ expect(result.detected).toHaveLength(0);
111
+ });
112
+
113
+ it('is a no-op on a healthy DB', () => {
114
+ const dbPath = makeDbPath();
115
+ seed(dbPath, [{ name: '0000_baseline', startedAt: 1000, finishedAt: 1100 }]);
116
+ const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath });
117
+ expect(result.status).toBe('healthy');
118
+ expect(result.backupPath).toBeUndefined();
119
+ expect(result.cleared).toHaveLength(0);
120
+ });
121
+
122
+ it('backs up, clears the stuck row, and reports repaired', () => {
123
+ const dbPath = makeDbPath();
124
+ seed(dbPath, [
125
+ { name: 'good', startedAt: 1000, finishedAt: 1100 },
126
+ { name: 'bad', startedAt: 2000, finishedAt: null, logs: 'oops' },
127
+ ]);
128
+
129
+ const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath });
130
+
131
+ expect(result.status).toBe('repaired');
132
+ expect(result.detected.map((m) => m.name)).toEqual(['bad']);
133
+ expect(result.cleared).toEqual(['bad']);
134
+ expect(result.remaining).toHaveLength(0);
135
+ expect(result.backupPath).toBeDefined();
136
+ expect(existsSync(result.backupPath!)).toBe(true);
137
+ expect(statSync(result.backupPath!).size).toBeGreaterThan(0);
138
+
139
+ // The good row survives; the bad row is gone.
140
+ const db = new Database(dbPath, { readonly: true });
141
+ const names = (db.query('SELECT migration_name FROM _prisma_migrations').all() as Array<{ migration_name: string }>)
142
+ .map((r) => r.migration_name);
143
+ db.close();
144
+ expect(names).toEqual(['good']);
145
+
146
+ // Re-running is idempotent: now healthy.
147
+ expect(runDatabaseDoctor({ bunPath: BUN_PATH, dbPath }).status).toBe('healthy');
148
+ });
149
+
150
+ it('honors skipBackup', () => {
151
+ const dbPath = makeDbPath();
152
+ seed(dbPath, [{ name: 'bad', startedAt: 2000, finishedAt: null, logs: 'oops' }]);
153
+ const result = runDatabaseDoctor({ bunPath: BUN_PATH, dbPath, skipBackup: true });
154
+ expect(result.status).toBe('repaired');
155
+ expect(result.backupPath).toBeUndefined();
156
+ });
157
+ });
158
+
159
+ describe('backupDatabase / repairFailedMigrations', () => {
160
+ it('produces a byte-faithful sibling backup', () => {
161
+ const dbPath = makeDbPath();
162
+ seed(dbPath, [{ name: 'm1', startedAt: 1, finishedAt: 2 }]);
163
+ const backupPath = backupDatabase(dbPath);
164
+ expect(existsSync(backupPath)).toBe(true);
165
+ expect(dirname(backupPath)).toBe(dirname(dbPath));
166
+ expect(backupPath).toMatch(/\.bak-/);
167
+ expect(statSync(backupPath).size).toBe(statSync(dbPath).size);
168
+ });
169
+
170
+ it('leaves already rolled-back rows alone', () => {
171
+ const dbPath = makeDbPath();
172
+ seed(dbPath, [{ name: 'rb', startedAt: 1000, finishedAt: null, rolledBackAt: 1200 }]);
173
+ const deleted = repairFailedMigrations(BUN_PATH, dbPath, ['rb']);
174
+ expect(deleted).toBe(0);
175
+ });
176
+ });
177
+
178
+ describe('path / bun resolution', () => {
179
+ it('resolveDesktopDbPath is shogo.db under the Shogo data dir', () => {
180
+ expect(resolveDesktopDbPath()).toBe(join(resolveDesktopDataDir(), 'shogo.db'));
181
+ expect(resolveDesktopDataDir()).toContain('Shogo');
182
+ });
183
+
184
+ it('resolveDesktopDataDir uses the platform app-data root', () => {
185
+ const dir = resolveDesktopDataDir();
186
+ if (process.platform === 'darwin') {
187
+ expect(dir).toContain(join('Library', 'Application Support'));
188
+ } else if (process.platform === 'win32') {
189
+ // %APPDATA% (Roaming) — just assert it ends with the expected tail.
190
+ expect(dir.endsWith(join('Shogo', 'data'))).toBe(true);
191
+ } else {
192
+ expect(dir).toContain(join('.config'));
193
+ }
194
+ expect(dir.startsWith(homedir()) || process.platform === 'win32').toBe(true);
195
+ });
196
+
197
+ it('resolveBunBinary returns the running bun and honors an explicit override', () => {
198
+ // Under `bun test`, process.execPath is a usable bun.
199
+ expect(resolveBunBinary()).toBe(process.execPath);
200
+ expect(resolveBunBinary(process.execPath)).toBe(process.execPath);
201
+ // A bogus override is rejected (null) rather than returned blindly.
202
+ expect(resolveBunBinary(join(tmpdir(), 'definitely-not-bun'))).toBeNull();
203
+ });
204
+ });
@@ -122,3 +122,68 @@ describe('WorkerRuntimeManager.buildEnv — TREE_SITTER_WASM_DIR injection', ()
122
122
  expect(env.TREE_SITTER_WASM_DIR).toBe('/runtime/tree-sitter-wasm');
123
123
  });
124
124
  });
125
+
126
+ describe('WorkerRuntimeManager.buildEnv — WORKSPACE_API_PORT_BASE (per-runtime preview sidecar base)', () => {
127
+ // Regression: every workspace runtime previously fell back to the fixed
128
+ // default base (3101) for its preview sidecars, so the first project of each
129
+ // concurrently-warm runtime bound the SAME port — crash-looping the
130
+ // preview-manager and SIGKILL-restart-storming the agent-runtime. The base
131
+ // must be derived from the runtime's unique agent port instead.
132
+ function mgr() {
133
+ return new WorkerRuntimeManager({ env: {} as NodeJS.ProcessEnv }) as unknown as {
134
+ buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv;
135
+ };
136
+ }
137
+
138
+ it('anchors WORKSPACE_API_PORT_BASE at agentPort + 2 (above agent + its API server)', () => {
139
+ const env = mgr().buildEnv(
140
+ fakeSlot({ agentPort: 37646, apiServerPort: 37647 }),
141
+ '/runtime/shogo-agent-runtime',
142
+ );
143
+ expect(env.WORKSPACE_API_PORT_BASE).toBe('37648');
144
+ });
145
+
146
+ it('gives two distinct runtimes distinct sidecar bases (no cross-runtime 3101 collision)', () => {
147
+ const m = mgr();
148
+ const a = m.buildEnv(fakeSlot({ agentPort: 37646 }), '/runtime/shogo-agent-runtime');
149
+ const b = m.buildEnv(fakeSlot({ agentPort: 37404 }), '/runtime/shogo-agent-runtime');
150
+ expect(a.WORKSPACE_API_PORT_BASE).not.toBe(b.WORKSPACE_API_PORT_BASE);
151
+ expect(a.WORKSPACE_API_PORT_BASE).toBe('37648');
152
+ expect(b.WORKSPACE_API_PORT_BASE).toBe('37406');
153
+ });
154
+ });
155
+
156
+ describe('WorkerRuntimeManager.allocatePort — per-runtime port block reservation', () => {
157
+ function rawMgr() {
158
+ return new WorkerRuntimeManager({ env: {} as NodeJS.ProcessEnv }) as unknown as {
159
+ allocatePort(): Promise<number>;
160
+ releasePort(port: number): void;
161
+ usedPorts: Set<number>;
162
+ };
163
+ }
164
+
165
+ it('reserves a contiguous block so a second allocation never overlaps the first runtime sidecar range', async () => {
166
+ const m = rawMgr();
167
+ const p1 = await m.allocatePort();
168
+ const p2 = await m.allocatePort();
169
+ // The two blocks [p, p+15] must be disjoint — otherwise runtime 2's agent
170
+ // port (or a sidecar) could land inside runtime 1's sidecar range.
171
+ const block1 = new Set<number>();
172
+ for (let off = 0; off < 16; off++) block1.add(p1 + off);
173
+ for (let off = 0; off < 16; off++) {
174
+ expect(block1.has(p2 + off)).toBe(false);
175
+ }
176
+ });
177
+
178
+ it('releasePort frees the whole block so the ports can be reused', async () => {
179
+ const m = rawMgr();
180
+ const p = await m.allocatePort();
181
+ // Sidecar ports (p+2 … p+15) are reserved, not just p and p+1.
182
+ expect(m.usedPorts.has(p + 2)).toBe(true);
183
+ expect(m.usedPorts.has(p + 15)).toBe(true);
184
+ m.releasePort(p);
185
+ for (let off = 0; off < 16; off++) {
186
+ expect(m.usedPorts.has(p + off)).toBe(false);
187
+ }
188
+ });
189
+ });