@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.
- package/README.md +45 -0
- package/dist/cli.mjs +63 -0
- package/package.json +5 -3
- package/src/cli.ts +37 -0
- package/src/commands/agent.ts +123 -0
- package/src/commands/doctor.ts +128 -0
- package/src/commands/start.ts +15 -2
- package/src/lib/__tests__/agent-launch.test.ts +92 -0
- package/src/lib/__tests__/db-doctor.test.ts +204 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +65 -0
- package/src/lib/db-doctor.ts +355 -0
- package/src/lib/process-manager.ts +1 -1
- package/src/lib/runtime-install.ts +1 -1
- package/src/lib/runtime-manager.ts +124 -5
- package/src/lib/tunnel.ts +12 -4
|
@@ -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
|
+
}
|
package/src/commands/start.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|