@shogo-ai/worker 1.7.4 → 1.7.7
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 +218 -10
- package/package.json +1 -1
- package/src/cli.ts +36 -1
- package/src/commands/project-checkout.ts +179 -0
- package/src/commands/project-pull.ts +133 -0
- package/src/commands/project-push.ts +89 -0
- package/src/commands/start.ts +28 -3
- package/src/lib/__tests__/cloud-sync-watcher.test.ts +209 -0
- package/src/lib/__tests__/config.test.ts +5 -0
- package/src/lib/__tests__/git-cloner.test.ts +258 -0
- package/src/lib/__tests__/runtime-manager-auto-pull.test.ts +275 -0
- package/src/lib/__tests__/runtime-manager-describe-rejection.test.ts +42 -0
- package/src/lib/__tests__/runtime-manager-git-pull.test.ts +207 -0
- package/src/lib/__tests__/runtime-manager-tree-sitter-env.test.ts +124 -0
- package/src/lib/__tests__/tunnel-structured-502.test.ts +101 -0
- package/src/lib/cloud-sync-watcher.ts +311 -0
- package/src/lib/config.ts +7 -2
- package/src/lib/git-cloner.ts +354 -0
- package/src/lib/paths.ts +18 -0
- package/src/lib/runtime-manager.ts +469 -8
- package/src/lib/tunnel.ts +40 -1
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the `WorkerRuntimeManager` auto-pull hook (Chunk E of the
|
|
5
|
+
* project-pull feature).
|
|
6
|
+
*
|
|
7
|
+
* We exercise the path via the public `ensurePulled(projectId, config)`
|
|
8
|
+
* entry point so we don't have to spawn an actual agent-runtime binary.
|
|
9
|
+
* The internal CloudFileTransport is exercised end-to-end against a fake
|
|
10
|
+
* `fetch` that scripts the manifest + presign + download responses.
|
|
11
|
+
*
|
|
12
|
+
* Coverage:
|
|
13
|
+
* - autoPull disabled → no clone, projectDir not injected
|
|
14
|
+
* - autoPull enabled + empty target dir → clone runs, projectDir set
|
|
15
|
+
* - autoPull enabled + populated target dir → no clone (skip)
|
|
16
|
+
* - autoPull enabled + cloud failure → soft-fail, projectDir still set
|
|
17
|
+
* - second call for same projectId is short-circuited (idempotent)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
|
21
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { tmpdir } from 'node:os';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
// We need to inject a fake fetch into the global so the SDK's
|
|
26
|
+
// CloudFileTransport picks it up.
|
|
27
|
+
let scriptedFetch: typeof fetch | null = null;
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
globalThis.fetch = ((input: any, init?: RequestInit) => {
|
|
31
|
+
if (!scriptedFetch) throw new Error('no scripted fetch set');
|
|
32
|
+
return scriptedFetch(input, init);
|
|
33
|
+
}) as any;
|
|
34
|
+
});
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
globalThis.fetch = originalFetch;
|
|
37
|
+
scriptedFetch = null;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function scriptManifest(files: Array<{ path: string; size: number; content: string }>) {
|
|
41
|
+
return (async (input: any, init?: RequestInit) => {
|
|
42
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
43
|
+
if (url.includes('/workspace/manifest')) {
|
|
44
|
+
return new Response(
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
ok: true,
|
|
47
|
+
projectId: 'p',
|
|
48
|
+
source: 's3',
|
|
49
|
+
generatedAt: '',
|
|
50
|
+
files: files.map((f) => ({ path: f.path, size: f.size, lastModified: null, etag: null })),
|
|
51
|
+
}),
|
|
52
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (url.includes('/s3/presign')) {
|
|
56
|
+
const body = JSON.parse((init?.body as string) || '{}') as { files: Array<{ path: string; action: string }> };
|
|
57
|
+
return new Response(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
ok: true,
|
|
60
|
+
urls: body.files.map((f) => ({ path: f.path, action: f.action, url: `https://dl.test/${f.path}` })),
|
|
61
|
+
}),
|
|
62
|
+
{ status: 200 },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (url.startsWith('https://dl.test/')) {
|
|
66
|
+
const path = url.slice('https://dl.test/'.length);
|
|
67
|
+
const file = files.find((f) => f.path === path);
|
|
68
|
+
if (!file) return new Response('not found', { status: 404 });
|
|
69
|
+
return new Response(file.content, { status: 200 });
|
|
70
|
+
}
|
|
71
|
+
return new Response('unhandled', { status: 500 });
|
|
72
|
+
}) as unknown as typeof fetch;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('WorkerRuntimeManager auto-pull', () => {
|
|
76
|
+
let dir: string;
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
dir = mkdtempSync(join(tmpdir(), 'wrm-auto-pull-'));
|
|
79
|
+
});
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('throws a loud, multi-line error when auto-pull is disabled and no workspace was pre-pulled', async () => {
|
|
85
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
86
|
+
scriptedFetch = scriptManifest([{ path: 'a.ts', size: 1, content: 'A' }]);
|
|
87
|
+
|
|
88
|
+
const mgr = new WorkerRuntimeManager({
|
|
89
|
+
autoPull: { enabled: false, projectsDir: dir },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let caught: Error | null = null;
|
|
93
|
+
try {
|
|
94
|
+
await mgr.ensurePulled('proj-1', {
|
|
95
|
+
cloudUrl: 'https://api.test',
|
|
96
|
+
apiKey: 'shogo_sk_x',
|
|
97
|
+
});
|
|
98
|
+
} catch (err: any) {
|
|
99
|
+
caught = err;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(caught).not.toBeNull();
|
|
103
|
+
const msg = caught!.message;
|
|
104
|
+
// Multi-line so a future debugger reading the worker's stderr can
|
|
105
|
+
// see the full menu of fixes without log archaeology.
|
|
106
|
+
expect(msg.split('\n').length).toBeGreaterThan(5);
|
|
107
|
+
expect(msg).toContain('auto-pull was disabled');
|
|
108
|
+
expect(msg).toContain('--no-auto-pull');
|
|
109
|
+
expect(msg).toContain('shogo project pull');
|
|
110
|
+
expect(msg).toContain('--projects-dir');
|
|
111
|
+
expect(msg).toContain('SHOGO_PROJECTS_DIR');
|
|
112
|
+
expect(msg).toContain(join(dir, 'proj-1'));
|
|
113
|
+
expect(msg).toContain('https://shogo.ai/docs/self-hosted-worker');
|
|
114
|
+
|
|
115
|
+
// The ask was loud-failure, not silent-mkdir: the dir must NOT have
|
|
116
|
+
// been touched (so a follow-up `shogo project pull` lands cleanly).
|
|
117
|
+
expect(existsSync(join(dir, 'proj-1'))).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('honours a pre-pulled workspace when auto-pull is disabled', async () => {
|
|
121
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
122
|
+
// No scripted fetch — the manager must NOT try to clone.
|
|
123
|
+
scriptedFetch = (async () => {
|
|
124
|
+
throw new Error('auto-pull is disabled, no fetch should fire');
|
|
125
|
+
}) as unknown as typeof fetch;
|
|
126
|
+
|
|
127
|
+
const target = join(dir, 'proj-prepulled');
|
|
128
|
+
mkdirSync(target, { recursive: true });
|
|
129
|
+
writeFileSync(join(target, 'AGENTS.md'), 'pre-pulled by `shogo project pull`');
|
|
130
|
+
|
|
131
|
+
const mgr = new WorkerRuntimeManager({
|
|
132
|
+
autoPull: { enabled: false, projectsDir: dir },
|
|
133
|
+
});
|
|
134
|
+
const result = await mgr.ensurePulled('proj-prepulled', {
|
|
135
|
+
cloudUrl: 'https://api.test',
|
|
136
|
+
apiKey: 'shogo_sk_x',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.projectDir).toBe(target);
|
|
140
|
+
expect(readFileSync(join(target, 'AGENTS.md'), 'utf-8')).toBe(
|
|
141
|
+
'pre-pulled by `shogo project pull`',
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('throws when no autoPull config and no caller-provided projectDir', async () => {
|
|
146
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
147
|
+
|
|
148
|
+
const mgr = new WorkerRuntimeManager({});
|
|
149
|
+
|
|
150
|
+
let caught: Error | null = null;
|
|
151
|
+
try {
|
|
152
|
+
await mgr.ensurePulled('proj-x', {
|
|
153
|
+
cloudUrl: 'https://api.test',
|
|
154
|
+
apiKey: 'shogo_sk_x',
|
|
155
|
+
});
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
caught = err;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
expect(caught).not.toBeNull();
|
|
161
|
+
expect(caught!.message).toContain('without an `autoPull` config');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('honours an existing caller-provided projectDir without touching autoPull', async () => {
|
|
165
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
166
|
+
scriptedFetch = (async () => {
|
|
167
|
+
throw new Error('caller-provided projectDir — no fetch should fire');
|
|
168
|
+
}) as unknown as typeof fetch;
|
|
169
|
+
|
|
170
|
+
const target = join(dir, 'desktop-managed');
|
|
171
|
+
mkdirSync(target, { recursive: true });
|
|
172
|
+
|
|
173
|
+
const mgr = new WorkerRuntimeManager({
|
|
174
|
+
// autoPull intentionally omitted — desktop adapter case.
|
|
175
|
+
});
|
|
176
|
+
const result = await mgr.ensurePulled('proj-desktop', {
|
|
177
|
+
cloudUrl: 'https://api.test',
|
|
178
|
+
apiKey: 'shogo_sk_x',
|
|
179
|
+
projectDir: target,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(result.projectDir).toBe(target);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('clones into <projectsDir>/<projectId> when the dir is empty', async () => {
|
|
186
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
187
|
+
scriptedFetch = scriptManifest([
|
|
188
|
+
{ path: 'config.json', size: 2, content: '{}' },
|
|
189
|
+
{ path: 'AGENTS.md', size: 5, content: 'AGENT' },
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const mgr = new WorkerRuntimeManager({
|
|
193
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
194
|
+
});
|
|
195
|
+
const result = await mgr.ensurePulled('proj-2', {
|
|
196
|
+
cloudUrl: 'https://api.test',
|
|
197
|
+
apiKey: 'shogo_sk_x',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.projectDir).toBe(join(dir, 'proj-2'));
|
|
201
|
+
expect(existsSync(join(dir, 'proj-2', 'config.json'))).toBe(true);
|
|
202
|
+
expect(readFileSync(join(dir, 'proj-2', 'config.json'), 'utf-8')).toBe('{}');
|
|
203
|
+
expect(readFileSync(join(dir, 'proj-2', 'AGENTS.md'), 'utf-8')).toBe('AGENT');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('skips clone when the target directory already has files', async () => {
|
|
207
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
208
|
+
const target = join(dir, 'proj-3');
|
|
209
|
+
mkdirSync(target, { recursive: true });
|
|
210
|
+
writeFileSync(join(target, 'config.json'), '{"existing": true}');
|
|
211
|
+
|
|
212
|
+
// Set the manifest to return a different file — if clone runs, it would
|
|
213
|
+
// overwrite. Use atomic-replace semantics from downloadAll() so we'd
|
|
214
|
+
// see the existing file blown away. We assert it's preserved.
|
|
215
|
+
scriptedFetch = scriptManifest([{ path: 'fresh.md', size: 5, content: 'NEW' }]);
|
|
216
|
+
|
|
217
|
+
const mgr = new WorkerRuntimeManager({
|
|
218
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
219
|
+
});
|
|
220
|
+
const result = await mgr.ensurePulled('proj-3', {
|
|
221
|
+
cloudUrl: 'https://api.test',
|
|
222
|
+
apiKey: 'shogo_sk_x',
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(result.projectDir).toBe(target);
|
|
226
|
+
expect(existsSync(join(target, 'fresh.md'))).toBe(false);
|
|
227
|
+
expect(readFileSync(join(target, 'config.json'), 'utf-8')).toBe('{"existing": true}');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('soft-fails when the cloud is unreachable, still sets projectDir', async () => {
|
|
231
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
232
|
+
// Manifest returns 500 — autoPull should swallow and continue.
|
|
233
|
+
scriptedFetch = (async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
|
|
234
|
+
|
|
235
|
+
const warnings: string[] = [];
|
|
236
|
+
const logger = {
|
|
237
|
+
log: () => {},
|
|
238
|
+
warn: (...args: any[]) => warnings.push(args.join(' ')),
|
|
239
|
+
error: () => {},
|
|
240
|
+
};
|
|
241
|
+
const mgr = new WorkerRuntimeManager({
|
|
242
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, logger, useGit: false },
|
|
243
|
+
});
|
|
244
|
+
const result = await mgr.ensurePulled('proj-4', {
|
|
245
|
+
cloudUrl: 'https://api.test',
|
|
246
|
+
apiKey: 'shogo_sk_x',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.projectDir).toBe(join(dir, 'proj-4'));
|
|
250
|
+
expect(warnings.some((w) => w.includes('auto-pull: failed for proj-4'))).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('is idempotent — second call short-circuits the manifest fetch', async () => {
|
|
254
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
255
|
+
let manifestRequests = 0;
|
|
256
|
+
scriptedFetch = (async (input: any, init?: RequestInit) => {
|
|
257
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
258
|
+
if (url.includes('/workspace/manifest')) {
|
|
259
|
+
manifestRequests++;
|
|
260
|
+
return new Response(JSON.stringify({ ok: true, files: [], source: 's3', generatedAt: '' }), { status: 200 });
|
|
261
|
+
}
|
|
262
|
+
return new Response('', { status: 200 });
|
|
263
|
+
}) as unknown as typeof fetch;
|
|
264
|
+
|
|
265
|
+
const mgr = new WorkerRuntimeManager({
|
|
266
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false },
|
|
267
|
+
});
|
|
268
|
+
const config = { cloudUrl: 'https://api.test', apiKey: 'shogo_sk_x' };
|
|
269
|
+
await mgr.ensurePulled('proj-5', config);
|
|
270
|
+
await mgr.ensurePulled('proj-5', config);
|
|
271
|
+
await mgr.ensurePulled('proj-5', config);
|
|
272
|
+
|
|
273
|
+
expect(manifestRequests).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Coverage for `WorkerRuntimeManager.describeRejection` — the
|
|
5
|
+
* structured-rejection hook that the {@link WorkerTunnel} reads when
|
|
6
|
+
* `resolveLocalUrl` returns null.
|
|
7
|
+
*
|
|
8
|
+
* The tunnel echoes the returned `code` + `message` (plus the original
|
|
9
|
+
* path) into the 502 body so a Studio client reading the response can
|
|
10
|
+
* tell exactly what the worker refused to do. This file pins the wire
|
|
11
|
+
* shape so a future refactor doesn't silently change the body Studio
|
|
12
|
+
* relies on.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'bun:test';
|
|
15
|
+
import { WorkerRuntimeManager } from '../runtime-manager.ts';
|
|
16
|
+
|
|
17
|
+
describe('WorkerRuntimeManager.describeRejection', () => {
|
|
18
|
+
it('flags non-/agent paths with CLI_WORKER_HAS_NO_DATA_API and the original path', () => {
|
|
19
|
+
const mgr = new WorkerRuntimeManager({});
|
|
20
|
+
const r = mgr.describeRejection('/api/projects?workspaceId=abc', 'p-1');
|
|
21
|
+
expect(r.code).toBe('CLI_WORKER_HAS_NO_DATA_API');
|
|
22
|
+
// Path in the message must be the pathname only — `?workspaceId=abc`
|
|
23
|
+
// is verbose and the tunnel echoes the full path separately.
|
|
24
|
+
expect(r.message).toContain('/api/projects');
|
|
25
|
+
expect(r.message).toContain('cli-worker only serves /agent/* paths');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('flags /agent paths missing a project context with CLI_WORKER_NO_PROJECT_FOR_PATH', () => {
|
|
29
|
+
const mgr = new WorkerRuntimeManager({});
|
|
30
|
+
const r = mgr.describeRejection('/agent/chat', undefined);
|
|
31
|
+
expect(r.code).toBe('CLI_WORKER_NO_PROJECT_FOR_PATH');
|
|
32
|
+
expect(r.message).toContain('/agent/chat');
|
|
33
|
+
expect(r.message).toContain('projectId=none');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('preserves the projectId when one is provided but no runtime exists', () => {
|
|
37
|
+
const mgr = new WorkerRuntimeManager({});
|
|
38
|
+
const r = mgr.describeRejection('/agent/chat', 'proj-42');
|
|
39
|
+
expect(r.code).toBe('CLI_WORKER_NO_PROJECT_FOR_PATH');
|
|
40
|
+
expect(r.message).toContain('projectId=proj-42');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the GIT-mode branch of `WorkerRuntimeManager.maybeAutoPull`.
|
|
5
|
+
*
|
|
6
|
+
* Strategy:
|
|
7
|
+
* - Mock `../git-cloner` so `cloneProject()` doesn't actually spawn git.
|
|
8
|
+
* - Mock `globalThis.fetch` for the `.shogo/` SQLite top-up pass and
|
|
9
|
+
* the manifest endpoint.
|
|
10
|
+
* - Assert that:
|
|
11
|
+
* * a fresh project triggers `cloneProject()`
|
|
12
|
+
* * the `.shogo/` top-up issues a manifest+download for only those entries
|
|
13
|
+
* * a populated `.git/` dir skips re-clone
|
|
14
|
+
* * cloneProject failure flips the mode to 'files' and runs the
|
|
15
|
+
* file-transport fallback
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
19
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
|
|
23
|
+
// ─── git-cloner fakes injected via `autoPull.gitOps` ────────────────
|
|
24
|
+
type CloneCall = { apiUrl: string; apiKey: string; projectId: string; localDir: string; shallow?: boolean };
|
|
25
|
+
const cloneCalls: CloneCall[] = [];
|
|
26
|
+
let cloneShouldThrow = false;
|
|
27
|
+
const gitOps = {
|
|
28
|
+
cloneProject: async (opts: CloneCall) => {
|
|
29
|
+
cloneCalls.push(opts);
|
|
30
|
+
if (cloneShouldThrow) throw new Error('simulated clone failure');
|
|
31
|
+
mkdirSync(join(opts.localDir, '.git'), { recursive: true });
|
|
32
|
+
writeFileSync(join(opts.localDir, 'AGENTS.md'), 'AGENT');
|
|
33
|
+
return { commitSha: 'deadbeef' };
|
|
34
|
+
},
|
|
35
|
+
gitIsAvailable: async () => true,
|
|
36
|
+
isGitRepo: (dir: string) => existsSync(join(dir, '.git')),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// ─── fetch shim ────────────────────────────────────────────────────
|
|
40
|
+
let scriptedFetch: typeof fetch | null = null;
|
|
41
|
+
const originalFetch = globalThis.fetch;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
cloneCalls.length = 0;
|
|
44
|
+
cloneShouldThrow = false;
|
|
45
|
+
globalThis.fetch = ((input: any, init?: RequestInit) => {
|
|
46
|
+
if (!scriptedFetch) throw new Error('no scripted fetch set');
|
|
47
|
+
return scriptedFetch(input, init);
|
|
48
|
+
}) as any;
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
globalThis.fetch = originalFetch;
|
|
52
|
+
scriptedFetch = null;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function scriptShogoTopup(files: Array<{ path: string; content: string }>) {
|
|
56
|
+
return (async (input: any, init?: RequestInit) => {
|
|
57
|
+
const url = typeof input === 'string' ? input : input.url;
|
|
58
|
+
if (url.includes('/workspace/manifest')) {
|
|
59
|
+
return new Response(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
ok: true,
|
|
62
|
+
projectId: 'p',
|
|
63
|
+
source: 's3',
|
|
64
|
+
generatedAt: '',
|
|
65
|
+
files: files.map((f) => ({ path: f.path, size: f.content.length, lastModified: null, etag: null })),
|
|
66
|
+
}),
|
|
67
|
+
{ status: 200 },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (url.includes('/s3/presign')) {
|
|
71
|
+
const body = JSON.parse((init?.body as string) || '{}') as { files: Array<{ path: string; action: string }> };
|
|
72
|
+
return new Response(
|
|
73
|
+
JSON.stringify({
|
|
74
|
+
ok: true,
|
|
75
|
+
urls: body.files.map((f) => ({ path: f.path, action: f.action, url: `https://dl.test/${encodeURIComponent(f.path)}` })),
|
|
76
|
+
}),
|
|
77
|
+
{ status: 200 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (url.startsWith('https://dl.test/')) {
|
|
81
|
+
const path = decodeURIComponent(url.slice('https://dl.test/'.length));
|
|
82
|
+
const file = files.find((f) => f.path === path);
|
|
83
|
+
return file
|
|
84
|
+
? new Response(file.content, { status: 200 })
|
|
85
|
+
: new Response('not found', { status: 404 });
|
|
86
|
+
}
|
|
87
|
+
return new Response('unhandled', { status: 500 });
|
|
88
|
+
}) as unknown as typeof fetch;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('WorkerRuntimeManager auto-pull (git mode)', () => {
|
|
92
|
+
let dir: string;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
dir = mkdtempSync(join(tmpdir(), 'wrm-git-pull-'));
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('clones via git when git is available and the dir is empty', async () => {
|
|
101
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
102
|
+
scriptedFetch = scriptShogoTopup([]); // no .shogo entries
|
|
103
|
+
const mgr = new WorkerRuntimeManager({
|
|
104
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
105
|
+
});
|
|
106
|
+
const result = await mgr.ensurePulled('proj-g1', {
|
|
107
|
+
cloudUrl: 'https://api.test',
|
|
108
|
+
apiKey: 'shogo_sk_x',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.projectDir).toBe(join(dir, 'proj-g1'));
|
|
112
|
+
expect(cloneCalls.length).toBe(1);
|
|
113
|
+
expect(cloneCalls[0]!.projectId).toBe('proj-g1');
|
|
114
|
+
expect(cloneCalls[0]!.localDir).toBe(join(dir, 'proj-g1'));
|
|
115
|
+
expect(cloneCalls[0]!.shallow).toBe(true);
|
|
116
|
+
// The stub created AGENTS.md.
|
|
117
|
+
expect(existsSync(join(dir, 'proj-g1', 'AGENTS.md'))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('downloads .shogo/* via the file transport after a successful git clone', async () => {
|
|
121
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
122
|
+
scriptedFetch = scriptShogoTopup([
|
|
123
|
+
{ path: '.shogo/db.sqlite', content: 'SQLITE-DB' },
|
|
124
|
+
{ path: '.shogo/snapshots/2024.sqlite', content: 'SNAP' },
|
|
125
|
+
// non-.shogo entries in the manifest must be ignored by the top-up.
|
|
126
|
+
{ path: 'src/App.tsx', content: 'APP' },
|
|
127
|
+
]);
|
|
128
|
+
const mgr = new WorkerRuntimeManager({
|
|
129
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
130
|
+
});
|
|
131
|
+
await mgr.ensurePulled('proj-g2', {
|
|
132
|
+
cloudUrl: 'https://api.test',
|
|
133
|
+
apiKey: 'shogo_sk_x',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(cloneCalls.length).toBe(1);
|
|
137
|
+
const target = join(dir, 'proj-g2');
|
|
138
|
+
// .shogo/* was downloaded via the file transport.
|
|
139
|
+
expect(existsSync(join(target, '.shogo', 'db.sqlite'))).toBe(true);
|
|
140
|
+
expect(existsSync(join(target, '.shogo', 'snapshots', '2024.sqlite'))).toBe(true);
|
|
141
|
+
// src/App.tsx was NOT downloaded — git handles that.
|
|
142
|
+
expect(existsSync(join(target, 'src', 'App.tsx'))).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('skips re-cloning when the local dir already has a .git/', async () => {
|
|
146
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
147
|
+
const target = join(dir, 'proj-g3');
|
|
148
|
+
mkdirSync(join(target, '.git'), { recursive: true });
|
|
149
|
+
writeFileSync(join(target, 'AGENTS.md'), 'existing');
|
|
150
|
+
|
|
151
|
+
scriptedFetch = scriptShogoTopup([]); // top-up still fires but is a no-op
|
|
152
|
+
const mgr = new WorkerRuntimeManager({
|
|
153
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, gitOps },
|
|
154
|
+
});
|
|
155
|
+
await mgr.ensurePulled('proj-g3', {
|
|
156
|
+
cloudUrl: 'https://api.test',
|
|
157
|
+
apiKey: 'shogo_sk_x',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(cloneCalls.length).toBe(0);
|
|
161
|
+
// Existing content is preserved.
|
|
162
|
+
expect(existsSync(join(target, 'AGENTS.md'))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('falls back to file transport when cloneProject throws', async () => {
|
|
166
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
167
|
+
cloneShouldThrow = true;
|
|
168
|
+
// Manifest serves a real file so the fallback downloadAll can produce something.
|
|
169
|
+
scriptedFetch = scriptShogoTopup([
|
|
170
|
+
{ path: 'fallback.txt', content: 'FROM-FILES' },
|
|
171
|
+
]);
|
|
172
|
+
const warnings: string[] = [];
|
|
173
|
+
const logger = {
|
|
174
|
+
log: () => {},
|
|
175
|
+
warn: (...args: any[]) => warnings.push(args.join(' ')),
|
|
176
|
+
error: () => {},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const mgr = new WorkerRuntimeManager({
|
|
180
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: true, logger, gitOps },
|
|
181
|
+
});
|
|
182
|
+
await mgr.ensurePulled('proj-g4', {
|
|
183
|
+
cloudUrl: 'https://api.test',
|
|
184
|
+
apiKey: 'shogo_sk_x',
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(cloneCalls.length).toBe(1);
|
|
188
|
+
expect(warnings.some((w) => w.includes('git clone failed'))).toBe(true);
|
|
189
|
+
expect(existsSync(join(dir, 'proj-g4', 'fallback.txt'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('uses file transport (not git) when useGit=false', async () => {
|
|
193
|
+
const { WorkerRuntimeManager } = await import('../runtime-manager.ts');
|
|
194
|
+
scriptedFetch = scriptShogoTopup([
|
|
195
|
+
{ path: 'plain.txt', content: 'P' },
|
|
196
|
+
]);
|
|
197
|
+
const mgr = new WorkerRuntimeManager({
|
|
198
|
+
autoPull: { enabled: true, projectsDir: dir, watch: false, useGit: false, gitOps },
|
|
199
|
+
});
|
|
200
|
+
await mgr.ensurePulled('proj-g5', {
|
|
201
|
+
cloudUrl: 'https://api.test',
|
|
202
|
+
apiKey: 'shogo_sk_x',
|
|
203
|
+
});
|
|
204
|
+
expect(cloneCalls.length).toBe(0);
|
|
205
|
+
expect(existsSync(join(dir, 'proj-g5', 'plain.txt'))).toBe(true);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (C) 2026 Shogo Technologies, Inc.
|
|
3
|
+
/**
|
|
4
|
+
* Tests for the belt-and-suspenders `TREE_SITTER_WASM_DIR` injection
|
|
5
|
+
* added to `WorkerRuntimeManager.buildEnv` in PR #2 ("Bundle tree-
|
|
6
|
+
* sitter WASMs alongside compiled agent-runtime binary").
|
|
7
|
+
*
|
|
8
|
+
* The contract we verify:
|
|
9
|
+
*
|
|
10
|
+
* 1. When the worker spawns an `agent-runtime` binary at
|
|
11
|
+
* `/path/to/shogo-agent-runtime`, the spawn env contains
|
|
12
|
+
* `TREE_SITTER_WASM_DIR=/path/to/tree-sitter-wasm`. Operators
|
|
13
|
+
* who run `env | grep TREE_SITTER` on a debugging session see
|
|
14
|
+
* exactly where the runtime is configured to look.
|
|
15
|
+
*
|
|
16
|
+
* 2. An operator-provided override on the worker host
|
|
17
|
+
* (`process.env.TREE_SITTER_WASM_DIR=/custom/path`) flows
|
|
18
|
+
* through to the spawned runtime — `buildEnv` does NOT clobber
|
|
19
|
+
* a pre-existing env value. The runtime's resolver also reads
|
|
20
|
+
* the env first, so the override stack-traces correctly.
|
|
21
|
+
*
|
|
22
|
+
* 3. When a per-project `extraEnv` block sets the same variable,
|
|
23
|
+
* the per-project value wins over both the inherited env and
|
|
24
|
+
* the binary-adjacent default. (extraEnv is documented as the
|
|
25
|
+
* last-merged layer; we keep that invariant.)
|
|
26
|
+
*
|
|
27
|
+
* `buildEnv` is a private method. We invoke it via a typed cast —
|
|
28
|
+
* the same pattern other tests in this directory use for accessing
|
|
29
|
+
* private internals (`runtime-manager-describe-rejection.test.ts`).
|
|
30
|
+
* The signature of `buildEnv(slot, runtimeBinPath)` is exercised
|
|
31
|
+
* directly with a synthesized `slot` rather than going through the
|
|
32
|
+
* full spawn pipeline, because the spawn pipeline requires a real
|
|
33
|
+
* binary on disk. The test surface is the env-shape contract, not
|
|
34
|
+
* the spawn-orchestration logic (which is covered elsewhere).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { describe, expect, it } from 'bun:test';
|
|
38
|
+
import { dirname, join } from 'node:path';
|
|
39
|
+
import { WorkerRuntimeManager } from '../runtime-manager';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Construct a fake `InternalRuntime` slot just rich enough for
|
|
43
|
+
* `buildEnv` to read every field it touches. Mirrors the shape of
|
|
44
|
+
* `InternalRuntime` in runtime-manager.ts; if that shape grows new
|
|
45
|
+
* fields used by buildEnv, this fixture must follow.
|
|
46
|
+
*/
|
|
47
|
+
function fakeSlot(overrides: Record<string, unknown> = {}): unknown {
|
|
48
|
+
return {
|
|
49
|
+
projectId: 'proj-tree-sitter-env-test',
|
|
50
|
+
agentPort: 41000,
|
|
51
|
+
apiServerPort: 41100,
|
|
52
|
+
spawnConfig: {
|
|
53
|
+
cloudUrl: 'https://cloud.test',
|
|
54
|
+
apiKey: 'api-key',
|
|
55
|
+
...((overrides.spawnConfig as Record<string, unknown>) ?? {}),
|
|
56
|
+
},
|
|
57
|
+
...overrides,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('WorkerRuntimeManager.buildEnv — TREE_SITTER_WASM_DIR injection', () => {
|
|
62
|
+
it('exports TREE_SITTER_WASM_DIR=dirname(runtimeBinPath)/tree-sitter-wasm by default', () => {
|
|
63
|
+
const mgr = new WorkerRuntimeManager({
|
|
64
|
+
env: {} as NodeJS.ProcessEnv, // start from a clean env so we can isolate the injection
|
|
65
|
+
}) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
|
|
66
|
+
|
|
67
|
+
const runtimeBinPath = '/opt/shogo/runtime/shogo-agent-runtime-linux-x64';
|
|
68
|
+
const env = mgr.buildEnv(fakeSlot(), runtimeBinPath);
|
|
69
|
+
|
|
70
|
+
expect(env.TREE_SITTER_WASM_DIR).toBe(join(dirname(runtimeBinPath), 'tree-sitter-wasm'));
|
|
71
|
+
expect(env.TREE_SITTER_WASM_DIR).toBe('/opt/shogo/runtime/tree-sitter-wasm');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('honors a worker-host override when process.env already sets TREE_SITTER_WASM_DIR', () => {
|
|
75
|
+
const mgr = new WorkerRuntimeManager({
|
|
76
|
+
env: {
|
|
77
|
+
TREE_SITTER_WASM_DIR: '/operator/override/path',
|
|
78
|
+
} as NodeJS.ProcessEnv,
|
|
79
|
+
}) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
|
|
80
|
+
|
|
81
|
+
const env = mgr.buildEnv(fakeSlot(), '/opt/shogo/runtime/shogo-agent-runtime-linux-x64');
|
|
82
|
+
expect(env.TREE_SITTER_WASM_DIR).toBe('/operator/override/path');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('per-project extraEnv override beats both worker-host env and binary-adjacent default', () => {
|
|
86
|
+
const mgr = new WorkerRuntimeManager({
|
|
87
|
+
env: {
|
|
88
|
+
TREE_SITTER_WASM_DIR: '/worker/host/path',
|
|
89
|
+
} as NodeJS.ProcessEnv,
|
|
90
|
+
}) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
|
|
91
|
+
|
|
92
|
+
const env = mgr.buildEnv(
|
|
93
|
+
fakeSlot({
|
|
94
|
+
spawnConfig: {
|
|
95
|
+
cloudUrl: 'https://cloud.test',
|
|
96
|
+
apiKey: 'api-key',
|
|
97
|
+
extraEnv: { TREE_SITTER_WASM_DIR: '/per/project/override' },
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
'/opt/shogo/runtime/shogo-agent-runtime-linux-x64',
|
|
101
|
+
);
|
|
102
|
+
expect(env.TREE_SITTER_WASM_DIR).toBe('/per/project/override');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('preserves the cli-worker contract: PROJECT_ID, PORT, SHOGO_API_KEY co-exist with TREE_SITTER_WASM_DIR', () => {
|
|
106
|
+
// Drive-by: assert injecting the new variable doesn't clobber the
|
|
107
|
+
// existing env keys. Reduces blast radius if a future refactor of
|
|
108
|
+
// buildEnv reorders the assignments.
|
|
109
|
+
const mgr = new WorkerRuntimeManager({
|
|
110
|
+
env: {} as NodeJS.ProcessEnv,
|
|
111
|
+
}) as unknown as { buildEnv(slot: unknown, runtimeBinPath: string): NodeJS.ProcessEnv };
|
|
112
|
+
|
|
113
|
+
const env = mgr.buildEnv(
|
|
114
|
+
fakeSlot({ projectId: 'co-exist-check', agentPort: 42424, apiServerPort: 42525 }),
|
|
115
|
+
'/runtime/shogo-agent-runtime',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(env.PROJECT_ID).toBe('co-exist-check');
|
|
119
|
+
expect(env.PORT).toBe('42424');
|
|
120
|
+
expect(env.API_SERVER_PORT).toBe('42525');
|
|
121
|
+
expect(env.SHOGO_API_KEY).toBe('api-key');
|
|
122
|
+
expect(env.TREE_SITTER_WASM_DIR).toBe('/runtime/tree-sitter-wasm');
|
|
123
|
+
});
|
|
124
|
+
});
|