@shogo-ai/worker 1.7.4 → 1.7.6

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,101 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Pin the wire shape of the structured 502 response WorkerTunnel sends
5
+ * when a resolver declines to forward a tunneled request. Studio reads
6
+ * `code`, `message`, and `path` from the body so a future debugger can
7
+ * tell what was attempted without log archaeology.
8
+ */
9
+ import { describe, expect, it } from 'bun:test';
10
+ import { WorkerTunnel, type ResolveRejection, type RuntimeResolver } from '../tunnel.ts';
11
+
12
+ class FakeWebSocket {
13
+ readyState = 1; // WebSocket.OPEN
14
+ sent: string[] = [];
15
+ send(msg: string): void {
16
+ this.sent.push(msg);
17
+ }
18
+ close(): void {
19
+ this.readyState = 3; // WebSocket.CLOSED
20
+ }
21
+ }
22
+
23
+ function makeResolver(overrides: Partial<RuntimeResolver> = {}): RuntimeResolver {
24
+ return {
25
+ resolveLocalUrl: async () => null,
26
+ deriveRuntimeToken: () => null,
27
+ getActiveProjects: () => [],
28
+ status: () => null,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ const silentLogger = { log: () => {}, warn: () => {}, error: () => {} };
34
+
35
+ describe('WorkerTunnel structured 502', () => {
36
+ it('echoes resolver-provided code+message and the original path', async () => {
37
+ const rejection: ResolveRejection = {
38
+ code: 'CLI_WORKER_HAS_NO_DATA_API',
39
+ message: 'cli-worker only serves /agent/* paths; tried: /api/projects',
40
+ };
41
+ const resolver = makeResolver({
42
+ describeRejection: () => rejection,
43
+ });
44
+ const tunnel = new WorkerTunnel({
45
+ apiKey: 'shogo_sk_x',
46
+ cloudUrl: 'https://api.test',
47
+ resolver,
48
+ logger: silentLogger,
49
+ });
50
+ const fake = new FakeWebSocket();
51
+ tunnel._testing().installFakeWs(fake as unknown as WebSocket);
52
+
53
+ await tunnel._testing().handleRequest({
54
+ type: 'request',
55
+ requestId: 'r-1',
56
+ method: 'GET',
57
+ path: '/api/projects?workspaceId=ws-1',
58
+ stream: false,
59
+ });
60
+
61
+ expect(fake.sent.length).toBe(1);
62
+ const frame = JSON.parse(fake.sent[0]!);
63
+ expect(frame.type).toBe('response');
64
+ expect(frame.requestId).toBe('r-1');
65
+ expect(frame.status).toBe(502);
66
+ expect(frame.headers?.['content-type']).toBe('application/json');
67
+
68
+ const body = JSON.parse(frame.body);
69
+ expect(body.code).toBe('CLI_WORKER_HAS_NO_DATA_API');
70
+ expect(body.message).toBe(rejection.message);
71
+ // Tunnel always echoes back the ORIGINAL path (with query) so a
72
+ // browser request's URL survives the round-trip into the 502 body.
73
+ expect(body.path).toBe('/api/projects?workspaceId=ws-1');
74
+ });
75
+
76
+ it('falls back to a generic NO_LOCAL_RUNTIME body when resolver omits describeRejection', async () => {
77
+ const resolver = makeResolver();
78
+ const tunnel = new WorkerTunnel({
79
+ apiKey: 'shogo_sk_x',
80
+ cloudUrl: 'https://api.test',
81
+ resolver,
82
+ logger: silentLogger,
83
+ });
84
+ const fake = new FakeWebSocket();
85
+ tunnel._testing().installFakeWs(fake as unknown as WebSocket);
86
+
87
+ await tunnel._testing().handleRequest({
88
+ type: 'request',
89
+ requestId: 'r-2',
90
+ method: 'GET',
91
+ path: '/whatever',
92
+ stream: false,
93
+ });
94
+
95
+ expect(fake.sent.length).toBe(1);
96
+ const body = JSON.parse(JSON.parse(fake.sent[0]!).body);
97
+ expect(body.code).toBe('NO_LOCAL_RUNTIME');
98
+ expect(body.path).toBe('/whatever');
99
+ expect(body.message).toContain('/whatever');
100
+ });
101
+ });
@@ -0,0 +1,311 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * Node-only filesystem watcher that pushes local edits back to Shogo
5
+ * Cloud via {@link CloudFileTransport}. Used by:
6
+ * - `shogo project pull --watch` (foreground sync)
7
+ * - `WorkerRuntimeManager` auto-pull (background sync alongside an
8
+ * actively running agent-runtime)
9
+ *
10
+ * Design notes:
11
+ * - Uses `node:fs.watch` recursively when available (Linux >= 20.x via
12
+ * `recursive: true`, macOS, Windows) — no `chokidar` dep.
13
+ * - Debounces a window of changes into a single batch upload to amortize
14
+ * presign round trips. Default 1.5s.
15
+ * - Skips EXCLUDED_DIRS the same way the transport does.
16
+ * - Best-effort: errors are logged but never crash the watcher; the
17
+ * caller's UI surfaces them.
18
+ */
19
+
20
+ import { watch, type FSWatcher, statSync } from 'node:fs';
21
+ import { relative, sep, posix } from 'node:path';
22
+ import type { CloudFileTransport } from '@shogo-ai/sdk';
23
+ import { commitAndPush } from './git-cloner.ts';
24
+
25
+ const DEBOUNCE_MS = 1500;
26
+ const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.vite', '.cache']);
27
+ /** Paths under these directories are gitignored — always route them
28
+ * through the file transport even when the watcher is in 'git' mode. */
29
+ const ALWAYS_FILE_TRANSPORT_PREFIXES = ['.shogo/'];
30
+
31
+ export type WatcherSyncMode = 'git' | 'files';
32
+
33
+ export interface WatcherGitOptions {
34
+ /** Cloud API URL. Forwarded to `commitAndPush`. */
35
+ apiUrl: string;
36
+ /** Bearer API key. */
37
+ apiKey: string;
38
+ /** Project ID. */
39
+ projectId: string;
40
+ /** Branch to push to. Default `HEAD` (current). */
41
+ branch?: string;
42
+ /** Optional author identity for the auto-commits. */
43
+ authorEmail?: string;
44
+ authorName?: string;
45
+ }
46
+
47
+ /** Signature of `commitAndPush` from `./git-cloner`. Extracted so tests
48
+ * can inject a fake without resorting to module-level mocking (which
49
+ * leaks across files in bun:test). */
50
+ export type CommitAndPushFn = (opts: {
51
+ apiUrl: string;
52
+ apiKey: string;
53
+ projectId: string;
54
+ localDir: string;
55
+ message: string;
56
+ branch?: string;
57
+ authorEmail?: string;
58
+ authorName?: string;
59
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
60
+ }) => Promise<{ committed: boolean; commitSha?: string }>;
61
+
62
+ export interface CloudSyncWatcherOptions {
63
+ /** Local directory to watch. */
64
+ rootDir: string;
65
+ /** Transport used to push uploads. Always required — `.shogo/` writes
66
+ * flow through here even in git mode. */
67
+ transport: CloudFileTransport;
68
+ /** Optional debounce window in ms. Default 1500. */
69
+ debounceMs?: number;
70
+ /** Logger. Defaults to console. */
71
+ logger?: Pick<Console, 'log' | 'warn' | 'error'>;
72
+ /** Called whenever a batch flushes. Useful for progress UIs / metrics. */
73
+ onFlush?: (event: { uploaded: string[]; errors: number; committed?: boolean; commitSha?: string }) => void;
74
+ /**
75
+ * Sync strategy. Defaults to `'files'` to keep existing callers
76
+ * (manual `shogo project pull --watch`) working unchanged.
77
+ *
78
+ * In `'git'` mode the flush stages all tracked changes and pushes
79
+ * one commit to the cloud; `.shogo/` writes still PUT through the
80
+ * file transport because they're gitignored.
81
+ */
82
+ mode?: WatcherSyncMode;
83
+ /** Git options. Required when `mode: 'git'`. */
84
+ git?: WatcherGitOptions;
85
+ /** Override for the `commitAndPush` implementation. Tests inject a fake;
86
+ * production callers leave this unset and get the real one. */
87
+ commitAndPush?: CommitAndPushFn;
88
+ }
89
+
90
+ /** Convert an absolute or platform-native path to a forward-slash relative path. */
91
+ function toPosixRel(rootDir: string, abs: string): string {
92
+ const rel = relative(rootDir, abs);
93
+ if (!rel) return '';
94
+ return rel.split(sep).join(posix.sep);
95
+ }
96
+
97
+ function isExcluded(relPath: string): boolean {
98
+ if (!relPath) return true;
99
+ return relPath.split('/').some((part) => EXCLUDED_DIRS.has(part));
100
+ }
101
+
102
+ export class CloudSyncWatcher {
103
+ private readonly rootDir: string;
104
+ private readonly transport: CloudFileTransport;
105
+ private readonly debounceMs: number;
106
+ private readonly logger: Pick<Console, 'log' | 'warn' | 'error'>;
107
+ private readonly onFlush?: CloudSyncWatcherOptions['onFlush'];
108
+ private readonly mode: WatcherSyncMode;
109
+ private readonly git?: WatcherGitOptions;
110
+ private readonly commitAndPush: CommitAndPushFn;
111
+
112
+ private watcher: FSWatcher | null = null;
113
+ private pending = new Set<string>();
114
+ private timer: ReturnType<typeof setTimeout> | null = null;
115
+ private flushing = false;
116
+ private stopped = false;
117
+
118
+ constructor(opts: CloudSyncWatcherOptions) {
119
+ this.rootDir = opts.rootDir;
120
+ this.transport = opts.transport;
121
+ this.debounceMs = opts.debounceMs ?? DEBOUNCE_MS;
122
+ this.logger = opts.logger ?? console;
123
+ this.onFlush = opts.onFlush;
124
+ this.mode = opts.mode ?? 'files';
125
+ if (this.mode === 'git' && !opts.git) {
126
+ throw new Error('CloudSyncWatcher: mode: "git" requires the `git` option block');
127
+ }
128
+ this.git = opts.git;
129
+ this.commitAndPush = opts.commitAndPush ?? commitAndPush;
130
+ }
131
+
132
+ /**
133
+ * Begin watching. Throws if the directory can't be watched (e.g. it
134
+ * doesn't exist). Callers should wrap in try/catch.
135
+ */
136
+ start(): void {
137
+ if (this.watcher) return;
138
+ if (this.stopped) throw new Error('CloudSyncWatcher: already stopped, build a fresh instance');
139
+
140
+ // Some platforms / Node versions don't support `recursive: true`. Fall
141
+ // back to a non-recursive watch on the root + per-discovered-subdir
142
+ // watches in that case. For Linux >= 20 / macOS / Windows, the
143
+ // recursive variant is much cheaper and we use it by default.
144
+ try {
145
+ this.watcher = watch(this.rootDir, { recursive: true }, (eventType, filename) => {
146
+ if (!filename) return;
147
+ this.handleEvent(String(filename));
148
+ });
149
+ } catch (err: any) {
150
+ this.logger.warn(`[CloudSyncWatcher] recursive watch failed (${err?.message ?? err}); falling back to root-only watch.`);
151
+ this.watcher = watch(this.rootDir, (eventType, filename) => {
152
+ if (!filename) return;
153
+ this.handleEvent(String(filename));
154
+ });
155
+ }
156
+ }
157
+
158
+ /** Stop watching and flush any pending uploads. */
159
+ async stop(): Promise<void> {
160
+ if (this.stopped) return;
161
+ this.stopped = true;
162
+ if (this.timer) {
163
+ clearTimeout(this.timer);
164
+ this.timer = null;
165
+ }
166
+ if (this.watcher) {
167
+ try {
168
+ this.watcher.close();
169
+ } catch {
170
+ /* already closed */
171
+ }
172
+ this.watcher = null;
173
+ }
174
+ // Final flush so we don't lose the last edits made before SIGINT.
175
+ if (this.pending.size > 0) {
176
+ await this.flush();
177
+ }
178
+ }
179
+
180
+ private handleEvent(filenameLike: string): void {
181
+ if (this.stopped) return;
182
+ const rel = filenameLike.split(sep).join(posix.sep);
183
+ if (isExcluded(rel)) return;
184
+ // Ignore directory events — we only push file content. A directory rename
185
+ // shows up later as individual file events when their content is touched.
186
+ try {
187
+ const abs = `${this.rootDir}/${rel}`;
188
+ const stat = statSync(abs);
189
+ if (stat.isDirectory()) return;
190
+ } catch {
191
+ // File was deleted between the event firing and the stat — emit a
192
+ // delete intent. We track these as `null` markers in `pending` but
193
+ // since the transport doesn't have a "push pending deletes" mode
194
+ // we just drop them; the next `--delete-remote` push will clean
195
+ // them up. Documented behavior.
196
+ return;
197
+ }
198
+ this.pending.add(rel);
199
+ this.scheduleFlush();
200
+ }
201
+
202
+ private scheduleFlush(): void {
203
+ if (this.timer) return;
204
+ this.timer = setTimeout(() => {
205
+ this.timer = null;
206
+ void this.flush();
207
+ }, this.debounceMs);
208
+ }
209
+
210
+ private async flush(): Promise<void> {
211
+ if (this.flushing) {
212
+ // Re-schedule so we don't drop edits that arrived during the upload.
213
+ this.scheduleFlush();
214
+ return;
215
+ }
216
+ if (this.pending.size === 0) return;
217
+ this.flushing = true;
218
+ const batch = Array.from(this.pending);
219
+ this.pending.clear();
220
+ try {
221
+ if (this.mode === 'git') {
222
+ await this.flushGit(batch);
223
+ } else {
224
+ await this.flushFiles(batch);
225
+ }
226
+ } catch (err: any) {
227
+ this.logger.error(`[CloudSyncWatcher] flush failed: ${err?.message ?? err}`);
228
+ // Re-queue so we'll retry on the next tick.
229
+ for (const p of batch) this.pending.add(p);
230
+ this.scheduleFlush();
231
+ } finally {
232
+ this.flushing = false;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Pure file-transport flush. Used in `mode: 'files'` and as the
238
+ * always-on path for `.shogo/` writes when `mode: 'git'`.
239
+ */
240
+ private async flushFiles(batch: string[]): Promise<void> {
241
+ if (batch.length === 0) return;
242
+ const stats = await this.transport.uploadFiles(batch);
243
+ this.onFlush?.({ uploaded: batch, errors: stats.errors.length });
244
+ if (stats.errors.length > 0) {
245
+ for (const err of stats.errors) {
246
+ this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Git-mode flush: route gitignored `.shogo/` writes through the file
253
+ * transport, then `git add -A && git commit && git push` everything
254
+ * else in one batch. This keeps the desktop checkpoint timeline in
255
+ * sync with what the worker's runtime is producing without having
256
+ * to push individual file PUTs per edit.
257
+ */
258
+ private async flushGit(batch: string[]): Promise<void> {
259
+ if (!this.git) throw new Error('CloudSyncWatcher: missing git options');
260
+ const fileTransportBatch: string[] = [];
261
+ const gitBatch: string[] = [];
262
+ for (const p of batch) {
263
+ if (ALWAYS_FILE_TRANSPORT_PREFIXES.some((prefix) => p === prefix.slice(0, -1) || p.startsWith(prefix))) {
264
+ fileTransportBatch.push(p);
265
+ } else {
266
+ gitBatch.push(p);
267
+ }
268
+ }
269
+
270
+ // Push .shogo/ first so the runtime's DB snapshot lands before
271
+ // any commit references that point at row IDs in it.
272
+ if (fileTransportBatch.length > 0) {
273
+ const stats = await this.transport.uploadFiles(fileTransportBatch);
274
+ if (stats.errors.length > 0) {
275
+ for (const err of stats.errors) {
276
+ this.logger.warn(`[CloudSyncWatcher] upload ${err.path}: ${err.message}`);
277
+ }
278
+ }
279
+ }
280
+
281
+ let committed = false;
282
+ let commitSha: string | undefined;
283
+ if (gitBatch.length > 0) {
284
+ const message = `auto: ${new Date().toISOString()}`;
285
+ try {
286
+ const res = await this.commitAndPush({
287
+ apiUrl: this.git.apiUrl,
288
+ apiKey: this.git.apiKey,
289
+ projectId: this.git.projectId,
290
+ localDir: this.rootDir,
291
+ message,
292
+ branch: this.git.branch,
293
+ authorEmail: this.git.authorEmail,
294
+ authorName: this.git.authorName,
295
+ logger: this.logger,
296
+ });
297
+ committed = res.committed;
298
+ commitSha = res.commitSha;
299
+ } catch (err: any) {
300
+ // commit/push failures bubble up so the caller's retry logic
301
+ // (flush() catch) re-queues the batch.
302
+ throw err;
303
+ }
304
+ }
305
+
306
+ this.onFlush?.({ uploaded: batch, errors: 0, committed, commitSha });
307
+ }
308
+ }
309
+
310
+ // Re-export for tests
311
+ export { toPosixRel as _toPosixRel, isExcluded as _isExcluded };
package/src/lib/config.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs';
8
8
  import { hostname } from 'node:os';
9
- import { CONFIG_FILE, ensureHome } from './paths.ts';
9
+ import { CONFIG_FILE, PROJECTS_DIR, ensureHome } from './paths.ts';
10
10
 
11
11
  export interface WorkerConfig {
12
12
  apiKey?: string;
@@ -14,11 +14,14 @@ export interface WorkerConfig {
14
14
  name?: string;
15
15
  workerDir?: string;
16
16
  port?: number;
17
+ /** Root directory under which pulled project workspaces are stored. */
18
+ projectsDir?: string;
17
19
  }
18
20
 
19
- const DEFAULTS: Required<Pick<WorkerConfig, 'cloudUrl' | 'port'>> = {
21
+ const DEFAULTS: { cloudUrl: string; port: number; projectsDir: string } = {
20
22
  cloudUrl: 'https://studio.shogo.ai',
21
23
  port: 8002,
24
+ projectsDir: PROJECTS_DIR,
22
25
  };
23
26
 
24
27
  export function loadConfig(): WorkerConfig {
@@ -48,6 +51,7 @@ export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfi
48
51
  name: process.env.SHOGO_INSTANCE_NAME,
49
52
  workerDir: process.env.SHOGO_WORKER_DIR,
50
53
  port: process.env.PORT ? parseInt(process.env.PORT, 10) : undefined,
54
+ projectsDir: process.env.SHOGO_PROJECTS_DIR,
51
55
  };
52
56
  const merged = mergeConfig(mergeConfig(fileCfg, env), override);
53
57
  if (!merged.apiKey) {
@@ -59,5 +63,6 @@ export function resolveConfig(override: WorkerConfig = {}): Required<WorkerConfi
59
63
  name: merged.name ?? hostname(),
60
64
  workerDir: merged.workerDir ?? process.cwd(),
61
65
  port: merged.port ?? DEFAULTS.port,
66
+ projectsDir: merged.projectsDir ?? DEFAULTS.projectsDir,
62
67
  };
63
68
  }