@shogo-ai/worker 2.3.11 → 2.3.13

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,223 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * The tunnel, in its own thread (SHO-185 §5)
5
+ *
6
+ * `WorkerTunnel` answers the cloud's `{type:'ping'}` with `{type:'pong'}` and
7
+ * drops its socket after an idle window. Run on the local API's thread — which
8
+ * is what it did — a busy moment (a `lsof`, a migration, an agent writing a
9
+ * large file) means no pong, and the cloud closes the socket: the desktop
10
+ * disappears from Studio with a 1006 and the person is told the machine is
11
+ * offline while it is sitting right there.
12
+ *
13
+ * So the socket, its timers and its `fetch`es live here, in a Bun `Worker`,
14
+ * where the only thing that can delay a pong is this file. What the tunnel
15
+ * needs from the main thread — which local URL a request maps to, which runtime
16
+ * token it wants, which projects are up — arrives as messages; the parts of
17
+ * `RuntimeResolver` that are synchronous per heartbeat read a snapshot the host
18
+ * refreshes before each one.
19
+ */
20
+
21
+ import { WorkerTunnel, type ResolveRejection, type RuntimeResolver } from './tunnel'
22
+
23
+ export interface TunnelWorkerInit {
24
+ type: 'init'
25
+ config: {
26
+ apiKey: string
27
+ cloudUrl: string
28
+ name?: string
29
+ kind?: string
30
+ wsUrlOverride?: string
31
+ }
32
+ }
33
+
34
+ export interface HostProjectStatus {
35
+ projectId: string
36
+ status: string
37
+ agentPort?: number
38
+ }
39
+
40
+ /** Worker → host: something the tunnel needs resolved on the main thread. */
41
+ export type WorkerRequest =
42
+ | { type: 'resolve'; id: number; path: string; projectId?: string }
43
+ | { type: 'token'; id: number; projectId: string }
44
+ | { type: 'refresh'; id: number }
45
+ | { type: 'machineSnapshot'; id: number }
46
+
47
+ /** Host → worker: the answer, plus what the host learned meanwhile. */
48
+ export type HostReply =
49
+ | {
50
+ type: 'resolved'
51
+ id: number
52
+ url: string | null
53
+ rejection?: ResolveRejection
54
+ }
55
+ | { type: 'token-result'; id: number; token: string | null }
56
+ | {
57
+ type: 'refresh-result'
58
+ id: number
59
+ projects: HostProjectStatus[]
60
+ /** Keyed `path|projectId`, filled in by the `resolve` that just failed. */
61
+ rejections?: Record<string, ResolveRejection>
62
+ }
63
+ | { type: 'machine-snapshot-result'; id: number; snapshot: Record<string, unknown> | null }
64
+
65
+ /** Worker → host: what happened, so the main thread can answer questions. */
66
+ export type WorkerEvent =
67
+ | { type: 'event'; name: 'instance'; instanceId: string | null }
68
+ | { type: 'event'; name: 'connection'; connected: boolean }
69
+ | { type: 'event'; name: 'auth-revoked'; reason: string }
70
+ | { type: 'log'; level: 'log' | 'warn' | 'error'; message: string }
71
+ | { type: 'ready' }
72
+
73
+ type Pending = { resolve: (value: any) => void }
74
+
75
+ /** A request before the id is assigned. */
76
+ type WorkerRequestInput =
77
+ | { type: 'resolve'; path: string; projectId?: string }
78
+ | { type: 'token'; projectId: string }
79
+ | { type: 'refresh' }
80
+ | { type: 'machineSnapshot' }
81
+
82
+ /**
83
+ * `RuntimeResolver` for the worker: async calls round-trip to the host, sync
84
+ * calls read the snapshot the last `refresh()` left behind.
85
+ */
86
+ class BridgedResolver implements RuntimeResolver {
87
+ private projects: HostProjectStatus[] = []
88
+ private rejections = new Map<string, ResolveRejection>()
89
+ private pending = new Map<number, Pending>()
90
+ private nextId = 1
91
+
92
+ constructor(private readonly post: (message: WorkerRequest) => void) {}
93
+
94
+ /** Called by the tunnel before each heartbeat. */
95
+ async refresh(): Promise<void> {
96
+ const reply = (await this.request({ type: 'refresh' })) as Extract<HostReply, { type: 'refresh-result' }>
97
+ this.projects = reply.projects ?? []
98
+ this.rejections.clear()
99
+ for (const [key, rejection] of Object.entries(reply.rejections ?? {})) {
100
+ this.rejections.set(key, rejection)
101
+ }
102
+ }
103
+
104
+ private request(request: WorkerRequestInput): Promise<HostReply> {
105
+ const id = this.nextId++
106
+ return new Promise<HostReply>((resolve) => {
107
+ this.pending.set(id, { resolve })
108
+ this.post({ ...request, id } as WorkerRequest)
109
+ })
110
+ }
111
+
112
+ /** The host's answer to one of our requests. */
113
+ handleReply(reply: HostReply): void {
114
+ const entry = this.pending.get(reply.id)
115
+ if (!entry) return
116
+ this.pending.delete(reply.id)
117
+ if (reply.type === 'resolved' && reply.url === null && reply.rejection) {
118
+ entry.resolve(reply)
119
+ return
120
+ }
121
+ entry.resolve(reply)
122
+ }
123
+
124
+ async resolveLocalUrl(pathWithQuery: string, projectId?: string): Promise<string | null> {
125
+ const reply = (await this.request({ type: 'resolve', path: pathWithQuery, projectId })) as Extract<
126
+ HostReply,
127
+ { type: 'resolved' }
128
+ >
129
+ if (reply.url === null && reply.rejection) {
130
+ this.rejections.set(`${pathWithQuery}|${projectId ?? ''}`, reply.rejection)
131
+ }
132
+ return reply.url
133
+ }
134
+
135
+ async deriveRuntimeToken(projectId: string): Promise<string | null> {
136
+ const reply = (await this.request({ type: 'token', projectId })) as Extract<
137
+ HostReply,
138
+ { type: 'token-result' }
139
+ >
140
+ return reply.token
141
+ }
142
+
143
+ getActiveProjects(): string[] {
144
+ return this.projects.map((project) => project.projectId)
145
+ }
146
+
147
+ status(projectId: string): { status: string; agentPort?: number } | null {
148
+ const project = this.projects.find((entry) => entry.projectId === projectId)
149
+ if (!project) return null
150
+ return { status: project.status, agentPort: project.agentPort }
151
+ }
152
+
153
+ describeRejection(pathWithQuery: string, projectId?: string): ResolveRejection {
154
+ return (
155
+ this.rejections.get(`${pathWithQuery}|${projectId ?? ''}`) ?? {
156
+ code: 'NO_LOCAL_RUNTIME',
157
+ message: `no local runtime available for path: ${pathWithQuery}`,
158
+ }
159
+ )
160
+ }
161
+
162
+ async machineSnapshot(): Promise<Record<string, unknown> | null> {
163
+ const reply = (await this.request({ type: 'machineSnapshot' })) as Extract<
164
+ HostReply,
165
+ { type: 'machine-snapshot-result' }
166
+ >
167
+ return reply.snapshot
168
+ }
169
+ }
170
+
171
+ const scope = self as unknown as {
172
+ postMessage: (message: unknown) => void
173
+ onmessage: ((event: MessageEvent) => void) | null
174
+ }
175
+
176
+ const post = (message: WorkerEvent | WorkerRequest) => scope.postMessage(message)
177
+
178
+ let tunnel: WorkerTunnel | null = null
179
+ let resolver: BridgedResolver | null = null
180
+ let connected = false
181
+ let instanceId: string | null = null
182
+
183
+ scope.onmessage = (event: MessageEvent) => {
184
+ const data = event.data as TunnelWorkerInit | HostReply
185
+ if (!data || typeof data !== 'object') return
186
+
187
+ if ((data as TunnelWorkerInit).type === 'init') {
188
+ const init = data as TunnelWorkerInit
189
+ resolver = new BridgedResolver(post as (message: WorkerRequest) => void)
190
+ tunnel = new WorkerTunnel({
191
+ ...init.config,
192
+ resolver,
193
+ logger: {
194
+ log: (message: string) => post({ type: 'log', level: 'log', message }),
195
+ warn: (message: string) => post({ type: 'log', level: 'warn', message }),
196
+ error: (message: string) => post({ type: 'log', level: 'error', message }),
197
+ },
198
+ onAuthRevoked: (reason: string) => post({ type: 'event', name: 'auth-revoked', reason }),
199
+ })
200
+ tunnel.start()
201
+ post({ type: 'ready' })
202
+ // The cloud only knows this machine's id after the first heartbeat, and the
203
+ // machine copy (SHO-185) pushes under it, so the host has to learn it here.
204
+ setInterval(() => {
205
+ if (!tunnel) return
206
+ const nextInstance = tunnel.instanceId ?? null
207
+ if (nextInstance !== instanceId) {
208
+ instanceId = nextInstance
209
+ post({ type: 'event', name: 'instance', instanceId })
210
+ }
211
+ const nextConnected = tunnel.isConnected()
212
+ if (nextConnected !== connected) {
213
+ connected = nextConnected
214
+ post({ type: 'event', name: 'connection', connected })
215
+ }
216
+ }, 1_000)
217
+ return
218
+ }
219
+
220
+ if (typeof (data as any).type === 'string' && (data as any).id !== undefined) {
221
+ resolver?.handleReply(data as HostReply)
222
+ }
223
+ }
package/src/lib/tunnel.ts CHANGED
@@ -77,7 +77,20 @@ export interface RuntimeResolver {
77
77
  * carry an actionable code + message. Optional — when absent the
78
78
  * tunnel falls back to a generic `NO_LOCAL_RUNTIME` payload.
79
79
  */
80
- describeRejection?(pathWithQuery: string, projectId?: string): ResolveRejection;
80
+ describeRejection?(
81
+ pathWithQuery: string,
82
+ projectId?: string,
83
+ ): ResolveRejection | Promise<ResolveRejection>;
84
+
85
+ /**
86
+ * Refresh whatever the synchronous calls above read from.
87
+ *
88
+ * The tunnel runs on its own thread (SHO-185 §5), and `getActiveProjects()` /
89
+ * `status()` are synchronous per heartbeat — so a resolver that cannot answer
90
+ * those from its own memory (a bridged one asks the main thread) refreshes in
91
+ * one awaited round trip here, before the heartbeat that reads them.
92
+ */
93
+ refresh?(): Promise<void>;
81
94
 
82
95
  /**
83
96
  * Owner-gated snapshot of this machine's own workspaces (ids, names,
@@ -183,6 +196,12 @@ export class WorkerTunnel {
183
196
  private consecutiveAuthFailures = 0;
184
197
  private consecutiveAuthSuccesses = 0;
185
198
  private serverPublishedWsUrl: string | null = null;
199
+ /**
200
+ * The cloud `instances` row id the last heartbeat reported. The machine copy
201
+ * (SHO-185) pushes under it, and it only exists once the cloud has answered a
202
+ * heartbeat, so it is read from there rather than assumed.
203
+ */
204
+ private lastInstanceId: string | null = null;
186
205
  private readonly activeAbortControllers = new Map<string, AbortController>();
187
206
 
188
207
  constructor(opts: WorkerTunnelOptions) {
@@ -190,6 +209,11 @@ export class WorkerTunnel {
190
209
  this.log = opts.logger ?? console;
191
210
  }
192
211
 
212
+ /** The cloud instance id for this machine, or null before the first heartbeat. */
213
+ get instanceId(): string | null {
214
+ return this.lastInstanceId;
215
+ }
216
+
193
217
  // ─── Public lifecycle ────────────────────────────────────────────
194
218
 
195
219
  start(): void {
@@ -270,6 +294,13 @@ export class WorkerTunnel {
270
294
  }
271
295
 
272
296
  private async collectMetadata(): Promise<Record<string, unknown>> {
297
+ // One awaited round trip for everything read synchronously below, so the
298
+ // heartbeat carries this cycle's runtimes rather than the last thread's.
299
+ try {
300
+ await this.opts.resolver.refresh?.();
301
+ } catch {
302
+ // A stale snapshot is better than no heartbeat at all.
303
+ }
273
304
  const meta: Record<string, unknown> = {
274
305
  hostname: osHostname(),
275
306
  os: platform(),
@@ -334,6 +365,10 @@ export class WorkerTunnel {
334
365
 
335
366
  const data = (await resp.json()) as HeartbeatResponse;
336
367
 
368
+ if (typeof data.instanceId === 'string' && data.instanceId.length > 0) {
369
+ this.lastInstanceId = data.instanceId;
370
+ }
371
+
337
372
  if (typeof data.wsUrl === 'string' && data.wsUrl.length > 0) {
338
373
  if (data.wsUrl !== this.serverPublishedWsUrl) {
339
374
  this.log.log(`[WorkerTunnel] Cloud advertised tunnel WS URL: ${data.wsUrl}`);
@@ -439,7 +474,7 @@ export class WorkerTunnel {
439
474
  // back the original path so the operator doesn't have to
440
475
  // correlate request-ids to figure out which fetch failed.
441
476
  const rejection: ResolveRejection = this.opts.resolver.describeRejection
442
- ? this.opts.resolver.describeRejection(msg.path, msg.projectId)
477
+ ? await this.opts.resolver.describeRejection(msg.path, msg.projectId)
443
478
  : { code: 'NO_LOCAL_RUNTIME', message: `no local runtime available for path: ${msg.path}` };
444
479
  this.sendFrame({
445
480
  type: 'response',
@@ -0,0 +1,23 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (C) 2026 Shogo Technologies, Inc.
3
+ /**
4
+ * The slice of the web Worker API the tunnel uses (SHO-185 §5).
5
+ *
6
+ * This package compiles against `lib: ["ES2022"]` — no DOM, no WebWorker — while
7
+ * Bun supplies both globals at runtime. Declaring only what the tunnel touches
8
+ * keeps the surface honest: a new worker global has to be added here, and the
9
+ * tunnel keeps its real types instead of a cast at every use.
10
+ */
11
+
12
+ declare const self: {
13
+ postMessage: (message: unknown) => void
14
+ onmessage: ((event: MessageEvent) => void) | null
15
+ }
16
+
17
+ declare class Worker {
18
+ constructor(url: URL)
19
+ onmessage: ((event: MessageEvent) => void) | null
20
+ onerror: ((event: unknown) => void) | null
21
+ postMessage(message: unknown): void
22
+ terminate(): void
23
+ }