viewable-one 0.0.0-stage → 0.12.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +1460 -0
  2. package/LICENSE +21 -0
  3. package/README.md +755 -2
  4. package/SPAWN.md +2105 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +978 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +123 -0
  9. package/lib/agents.mjs +913 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1387 -0
  12. package/lib/authenticatedHttp.mjs +68 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1252 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +747 -0
  20. package/lib/codexApps.mjs +332 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +605 -0
  28. package/lib/devserver.mjs +659 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/glytchFolder.mjs +105 -0
  33. package/lib/hosted.mjs +112 -0
  34. package/lib/imageAttachments.mjs +31 -0
  35. package/lib/init-connect.mjs +418 -0
  36. package/lib/kill.mjs +104 -0
  37. package/lib/logindaemon.mjs +976 -0
  38. package/lib/logs.mjs +120 -0
  39. package/lib/mcpjson.mjs +242 -0
  40. package/lib/memberPreview.mjs +165 -0
  41. package/lib/multiwatch.mjs +428 -0
  42. package/lib/pagePictures.mjs +396 -0
  43. package/lib/pageRoutes.mjs +67 -0
  44. package/lib/paths.mjs +9 -0
  45. package/lib/permissionAsk.mjs +418 -0
  46. package/lib/picker.mjs +300 -0
  47. package/lib/platform.mjs +60 -0
  48. package/lib/previewGateway.mjs +129 -0
  49. package/lib/previewTunnel.mjs +85 -0
  50. package/lib/procs.mjs +428 -0
  51. package/lib/project-mcp.mjs +288 -0
  52. package/lib/projectPaths.mjs +31 -0
  53. package/lib/refusal.mjs +70 -0
  54. package/lib/registry.mjs +204 -0
  55. package/lib/repomap.mjs +322 -0
  56. package/lib/restartPass.mjs +151 -0
  57. package/lib/retired-address.mjs +78 -0
  58. package/lib/rowsAnswers.mjs +389 -0
  59. package/lib/rowsEvents.mjs +86 -0
  60. package/lib/run-engine.mjs +16 -0
  61. package/lib/runner-health.mjs +57 -0
  62. package/lib/runner.mjs +1569 -0
  63. package/lib/runnerAdmission.mjs +35 -0
  64. package/lib/runnerApi.mjs +312 -0
  65. package/lib/runnerCompletion.mjs +406 -0
  66. package/lib/runnerLease.mjs +37 -0
  67. package/lib/runnerMaintenance.mjs +27 -0
  68. package/lib/runnerPace.mjs +283 -0
  69. package/lib/runnerPerformance.mjs +26 -0
  70. package/lib/runnerRealtime.mjs +340 -0
  71. package/lib/runnerRegister.mjs +96 -0
  72. package/lib/runnerSettings.mjs +40 -0
  73. package/lib/runnerSettlement.mjs +138 -0
  74. package/lib/runnerWorkspace.mjs +162 -0
  75. package/lib/serverSweep.mjs +90 -0
  76. package/lib/sharedPreview.mjs +292 -0
  77. package/lib/signIn.mjs +47 -0
  78. package/lib/spawn.mjs +330 -0
  79. package/lib/sweep.mjs +161 -0
  80. package/lib/sync.mjs +484 -0
  81. package/lib/uninstall-project.mjs +588 -0
  82. package/lib/uninstall.mjs +457 -0
  83. package/lib/update.mjs +302 -0
  84. package/lib/usageLimit.mjs +108 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1138 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,283 @@
1
+ // HOW OFTEN THE RUNNER CALLS THE BOARD (ticket e8, measured 2026-10-01,
2
+ // approved by the owner 2026-10-03).
3
+ //
4
+ // Each watched folder ran two fixed loops: claim every 5 s and beat every
5
+ // 10 s, with no backoff when idle and none on a server error. Idle runners
6
+ // made about 3,500 board-runner calls per half hour, and during an hour of
7
+ // 502s they kept the same pace. This file is the one place that decides the
8
+ // next wait, for both loops:
9
+ //
10
+ // - work, or a run already active: the base interval (claim 5 s, beat 10 s)
11
+ // - an empty claim: the claim interval grows by half the base per empty
12
+ // claim up to 6x the base (5 s -> 30 s; 4x until #616), so a queued run
13
+ // is still picked up within about 30 s
14
+ // - a server error (5xx, 429) or a network failure: exponential backoff
15
+ // with jitter, from the base up to 12 claim intervals or 6 beats (60 s
16
+ // at the defaults), reset by the first success
17
+ //
18
+ // The caps scale with the base interval rather than being fixed numbers, so
19
+ // a test that polls every 10 ms backs off in milliseconds too.
20
+ //
21
+ // ONE BEAT PER RUNNER, NOT PER FOLDER (#616, 2026-10-16). Every folder ran
22
+ // its own heartbeat and claim, about 9 calls a minute per idle folder, and a
23
+ // runner with 5 folders tripped Cloudflare's per-IP rate limit on the
24
+ // function. Now:
25
+ //
26
+ // - one heartbeat covers every folder of the process (`makeHeartbeatHub`)
27
+ // - its `pending` field says which folders have a run waiting, and only
28
+ // those claim; an idle folder makes no claim call at all
29
+ // - `register` is shared and only repeats every 5 minutes, or at once
30
+ // when the folder list changes
31
+ // - against a function without `pending` (an older deploy) each folder
32
+ // polls `claim` as before, slowing to 6x the base (30 s) while idle
33
+ // - a 429 that says Retry-After waits that long (`failed(retryAfterMs)`),
34
+ // up to an hour, because calling sooner only extends the block
35
+ //
36
+ // A 401 is not a server error here. It keeps its own path (auth.mjs marks
37
+ // the credential dead and later calls do not reach the network at all).
38
+ //
39
+ // Nothing else counts as a failure for backoff: a refusal (`not yours`, a
40
+ // full runs-at-once count, a local lock) keeps the base interval, because none of
41
+ // those get better by calling less often and none of them load the server.
42
+
43
+ /** The wait after `failures` server errors in a row: base * 2^(n-1), capped,
44
+ * then jittered down by up to 20% so many runners do not retry in step. Never
45
+ * shorter than the base interval. */
46
+ export function backoffMs(failures, { baseMs, capMs, random = Math.random }) {
47
+ if (failures <= 0) return baseMs;
48
+ const ceiling = Math.min(capMs, baseMs * 2 ** (failures - 1));
49
+ return Math.max(baseMs, Math.round(ceiling * (0.8 + 0.2 * random())));
50
+ }
51
+
52
+ /** The longest Retry-After the runner honours (#616). Cloudflare's block
53
+ * lasts minutes; a bigger number is more likely a bad header than a plan. */
54
+ export const RETRY_AFTER_CAP_MS = 60 * 60_000;
55
+
56
+ /** The wait after `empty` empty claims in a row: base plus half the base per
57
+ * empty claim, up to `maxMs`. */
58
+ export function idleMs(empty, { baseMs, maxMs }) {
59
+ return Math.min(Math.max(baseMs, maxMs), baseMs + (empty * baseMs) / 2);
60
+ }
61
+
62
+ /** One loop's pace. Each method records what the last call did and returns
63
+ * how long to wait before the next one.
64
+ *
65
+ * - `work()` the call did something, or a run is active: base interval
66
+ * - `idle()` a claim found nothing: grows toward `idleMaxMs`
67
+ * - `failed(retryAfterMs?)` a server error or network failure: backs off
68
+ * toward `errorCapMs`, and never sooner than a Retry-After the server sent
69
+ * - `steady()` anything else (a refusal, a local wait): base interval */
70
+ export function makePace({ baseMs, idleMaxMs = baseMs, errorCapMs = baseMs, random = Math.random }) {
71
+ let failures = 0;
72
+ let empty = 0;
73
+ return {
74
+ work() {
75
+ failures = 0;
76
+ empty = 0;
77
+ return baseMs;
78
+ },
79
+ steady() {
80
+ failures = 0;
81
+ empty = 0;
82
+ return baseMs;
83
+ },
84
+ idle() {
85
+ failures = 0;
86
+ empty += 1;
87
+ return idleMs(empty, { baseMs, maxMs: idleMaxMs });
88
+ },
89
+ failed(retryAfterMs) {
90
+ empty = 0;
91
+ failures += 1;
92
+ const backoff = backoffMs(failures, { baseMs, capMs: errorCapMs, random });
93
+ return Number.isFinite(retryAfterMs) && retryAfterMs > backoff ? Math.min(retryAfterMs, RETRY_AFTER_CAP_MS) : backoff;
94
+ },
95
+ };
96
+ }
97
+
98
+ /** The claim loop's pace for these settings: 5 s base, 30 s when idle, 60 s
99
+ * under errors, at the defaults. */
100
+ export function claimPace(settings) {
101
+ const baseMs = settings.pollMs;
102
+ return makePace({
103
+ baseMs,
104
+ idleMaxMs: settings.idleMaxMs ?? baseMs * 6,
105
+ errorCapMs: settings.errorCapMs ?? baseMs * 12,
106
+ random: settings.random,
107
+ });
108
+ }
109
+
110
+ /** The beat's pace: 10 s base, 60 s under errors. A healthy beat stays at the
111
+ * base, which keeps presence inside the 30 s window the app and doctor read
112
+ * as "runner online" (RUNNER_STALE_MS). Under errors the beats are failing
113
+ * anyway, so presence is already going stale whatever the interval. */
114
+ export function beatPace(settings, fallbackMs) {
115
+ const baseMs = settings.beatMs ?? fallbackMs;
116
+ return makePace({ baseMs, errorCapMs: settings.errorCapMs ?? baseMs * 6, random: settings.random });
117
+ }
118
+
119
+ /** How long a successful `register` answer is reused (#616). The folder list
120
+ * is the key, so a folder joining or leaving registers at once; this only
121
+ * spaces out the repeats of an unchanged list. Presence stays fresh through
122
+ * the heartbeat, which bumps the same `last_seen_at`. */
123
+ export const REGISTER_REFRESH_MS = 5 * 60_000;
124
+
125
+ /** One shared answer: reused while in flight and until it expires. A success
126
+ * lives `ttlMs`; a failure `failureTtlMs`, or as long as its Retry-After. A
127
+ * failure is shared too, so a server that just failed one folder is not
128
+ * asked again by the next folder a second later. */
129
+ function shareAnswer(send, { at, ttlMs, failureTtlMs }) {
130
+ const entry = { at, pending: true, expiresAt: Infinity, serverFailed: false };
131
+ entry.promise = Promise.resolve()
132
+ .then(send)
133
+ .then((answer) => {
134
+ const failed = !answer || answer.ok !== true;
135
+ const hold = failed ? Math.max(failureTtlMs, Math.min(answer?.retryAfterMs ?? 0, RETRY_AFTER_CAP_MS)) : ttlMs;
136
+ entry.expiresAt = at + hold;
137
+ entry.serverFailed = failed && answer?.transient === true;
138
+ return answer;
139
+ }, (error) => {
140
+ entry.expiresAt = at + failureTtlMs;
141
+ entry.serverFailed = true;
142
+ throw error;
143
+ })
144
+ .finally(() => {
145
+ entry.pending = false;
146
+ });
147
+ return entry;
148
+ }
149
+ const fresh = (entry, at) => entry.pending || at < entry.expiresAt;
150
+
151
+ /** Coalesce `register` across the folders of one process (ticket e8 item 3).
152
+ * Every folder's beat registers the whole folder list, so the second and third
153
+ * folder beating inside the same window would send exactly the same call
154
+ * again. Within `ttlMs` of the last register for the same list, or while it is
155
+ * still in flight, callers share its answer; a failure for `failureTtlMs`. */
156
+ export function makeRegistrar({ ttlMs, failureTtlMs = ttlMs, now = Date.now }) {
157
+ let last = null;
158
+ return {
159
+ register(key, send) {
160
+ const at = now();
161
+ if (last && last.key === key && fresh(last, at)) return last.promise;
162
+ last = Object.assign(shareAnswer(send, { at, ttlMs, failureTtlMs }), { key });
163
+ return last.promise;
164
+ },
165
+ // A login to another account makes every cached project id obsolete.
166
+ invalidate() {
167
+ last = null;
168
+ },
169
+ };
170
+ }
171
+
172
+ /** ONE HEARTBEAT FOR EVERY FOLDER OF A PROCESS (#616).
173
+ *
174
+ * `beat(projectId, send)` joins the folder and answers with a heartbeat that
175
+ * covers it: the last one, if it is fresh (in flight, or under `ttlMs` old)
176
+ * and already covered this folder, or a new `send(projectIds)` for every
177
+ * folder that beat in the last `memberTtlMs`. With folders beating every
178
+ * `ttlMs / 0.9`, the process sends one heartbeat per beat interval however
179
+ * many folders it watches. The answer holds every folder's stops and ops;
180
+ * each folder keeps its own (`op.project_id`).
181
+ *
182
+ * A function that predates #616 sends no `pending` and no `project_id` on
183
+ * run ops, so its answer cannot be split. The first such answer turns the
184
+ * hub off for good: a covering answer for more than this folder is thrown
185
+ * away (its ops come back on the next beat), and each folder beats on its
186
+ * own from then on, as before.
187
+ *
188
+ * A server failure (5xx, 429, network) is shared with every folder while it
189
+ * is fresh, covered or not: the server is failing for all of them, and a 429
190
+ * holds for its whole Retry-After.
191
+ *
192
+ * A refusal ("not yours": a folder the server no longer knows) would refuse
193
+ * the whole batch, so each folder of that batch asks alone once, and a folder
194
+ * refused alone keeps beating alone until the server takes it again. The
195
+ * others go back to one shared call. `leave` drops a folder at once (a
196
+ * forget, a watcher that ended).
197
+ *
198
+ * Every answer comes back as a copy with `coveredProjectIds`, the folders the
199
+ * call was for, so a folder can tell an op that is its own from one that
200
+ * names no project.
201
+ *
202
+ * PUSH (#813). `ttlMs` and `memberTtlMs` may be functions, read at each beat,
203
+ * so the hub follows the beat: 10 s while polling, 5 min while the runner is
204
+ * joined to its topic. A failed answer lives `failureTtlMs` (number or
205
+ * function, never longer than `ttlMs`), so a retry 10 s after a blip is a real
206
+ * call and not the cached failure. `invalidate(at)` makes every answer started before
207
+ * `at` stale, so the next beat really asks: a join, a dropped socket or an op
208
+ * event. Every folder hearing the same event passes the same `at`, so they
209
+ * still share the one call that follows. */
210
+ export function makeHeartbeatHub({ ttlMs, memberTtlMs, failureTtlMs, now = Date.now }) {
211
+ const ttl = () => (typeof ttlMs === "function" ? ttlMs() : ttlMs);
212
+ const failureTtl = () => Math.min(typeof failureTtlMs === "function" ? failureTtlMs() : failureTtlMs ?? Infinity, ttl());
213
+ const memberTtl = () => (typeof memberTtlMs === "function" ? memberTtlMs() : memberTtlMs ?? 3 * ttl());
214
+ const members = new Map(); // projectId -> when it last beat
215
+ const alone = new Set(); // refused inside a batch and alone: beats alone
216
+ let last = null;
217
+ let notBefore = -Infinity;
218
+ let batching = true;
219
+ const covered = (answer, ids) => (answer && typeof answer === "object" ? { ...answer, coveredProjectIds: ids } : answer);
220
+ const single = async (projectId, send) => {
221
+ const answer = await send([projectId]);
222
+ if (batching) {
223
+ if (answer?.ok === false && answer.transient !== true) alone.add(projectId);
224
+ else if (answer?.ok === true) alone.delete(projectId);
225
+ }
226
+ return covered(answer, [projectId]);
227
+ };
228
+ return {
229
+ get batching() {
230
+ return batching;
231
+ },
232
+ async beat(projectId, send) {
233
+ if (!batching || alone.has(projectId)) return single(projectId, send);
234
+ const at = now();
235
+ members.set(projectId, at);
236
+ let entry = last && last.at >= notBefore && fresh(last, at) && (last.ids.includes(projectId) || last.serverFailed) ? last : null;
237
+ if (!entry) {
238
+ const memberMs = memberTtl();
239
+ for (const [id, seen] of members) if (at - seen > memberMs) members.delete(id);
240
+ const ids = [...members.keys()].sort();
241
+ const ttlNow = ttl();
242
+ entry = Object.assign(shareAnswer(() => send(ids), { at, ttlMs: ttlNow, failureTtlMs: failureTtl() }), { ids });
243
+ last = entry;
244
+ }
245
+ const answer = await entry.promise;
246
+ const mine = entry.ids.length === 1 && entry.ids[0] === projectId;
247
+ if (answer?.ok === true && !Array.isArray(answer.pending)) {
248
+ batching = false;
249
+ if (last === entry) last = null;
250
+ return mine ? covered(answer, entry.ids) : single(projectId, send);
251
+ }
252
+ if (answer?.ok === false && answer.transient !== true && !mine) {
253
+ if (last === entry) last = null;
254
+ members.delete(projectId);
255
+ return single(projectId, send);
256
+ }
257
+ return covered(answer, entry.ids);
258
+ },
259
+ leave(projectId) {
260
+ members.delete(projectId);
261
+ alone.delete(projectId);
262
+ if (last?.ids.includes(projectId)) last = null;
263
+ },
264
+ invalidate(at = now()) {
265
+ notBefore = Math.max(notBefore, at);
266
+ },
267
+ };
268
+ }
269
+
270
+ /** A wait that ends early when `signal` aborts, so a long backoff never holds
271
+ * up a stop. */
272
+ export function sleep(ms, signal) {
273
+ return new Promise((resolve) => {
274
+ if (signal?.aborted) return resolve();
275
+ const done = () => {
276
+ clearTimeout(timer);
277
+ signal?.removeEventListener("abort", done);
278
+ resolve();
279
+ };
280
+ const timer = setTimeout(done, ms);
281
+ signal?.addEventListener("abort", done, { once: true });
282
+ });
283
+ }
@@ -0,0 +1,26 @@
1
+ // Operational run timings only. Never inspect prompts, logs, paths or identity.
2
+ export function createRunnerPerformance(run, now = () => performance.now()) {
3
+ const began = now();
4
+ const created = typeof run.created_at === 'string' ? Date.parse(run.created_at) : NaN;
5
+ const claimed = typeof run.claimed_at === 'string' ? Date.parse(run.claimed_at) : NaN;
6
+ const queue = claimed - created;
7
+ let launched, firstOutput, exited;
8
+ const elapsed = (end, start) => Math.max(0, Math.round(end - start));
9
+ return {
10
+ launched() { launched ??= now(); },
11
+ output() { if (launched !== undefined) firstOutput ??= now(); },
12
+ exited() { if (launched !== undefined) exited ??= now(); },
13
+ snapshot() {
14
+ const ended = now();
15
+ return {
16
+ schema_version: 1,
17
+ ...(Number.isFinite(queue) && queue >= 0 ? { queue_ms: queue } : {}),
18
+ setup_ms: elapsed(launched ?? ended, began),
19
+ ...(launched !== undefined ? { execution_ms: elapsed(exited ?? ended, launched) } : {}),
20
+ ...(firstOutput !== undefined ? { first_output_ms: elapsed(firstOutput, launched) } : {}),
21
+ ...(exited !== undefined ? { completion_ms: elapsed(ended, exited) } : {}),
22
+ total_ms: elapsed(ended, began),
23
+ };
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,340 @@
1
+ // THE RUNNER'S PUSH CHANNEL (#813, server in #811 / PR 819, report #795
2
+ // option c).
3
+ //
4
+ // The database tells the runner the moment something happens: a run is
5
+ // waiting (`pending`), a Stop (`stop`), an op on a run (`op`). It sends each
6
+ // one to the account's private Realtime topic. This file holds the runner's
7
+ // end of that topic and nothing else:
8
+ //
9
+ // - asks `board-runner` for the topic (`realtime` op: url, public key,
10
+ // topic, expiresAt, renewAt), joins it, and tracks presence there as
11
+ // `{runnerId, version, folders}`, keyed by the runner id
12
+ // - asks again from `renewAt` and moves to the next topic, leaving the old
13
+ // one once the new one has joined; no access token, no setAuth
14
+ // - tells its listeners when it joins or drops (`joined`/`left`) and passes
15
+ // each event on, with the moment it arrived (`at`)
16
+ //
17
+ // It never claims, stops or takes an op itself. The watch loop (runner.mjs)
18
+ // turns an event into the call it already makes: `claimOne` for a waiting
19
+ // run (so runs-at-once still applies, #364), `stopped.add` for a Stop, a
20
+ // heartbeat for an op. The heartbeat stays the full catch-up, so an event
21
+ // that never arrives costs time, never a run: while not joined the runner
22
+ // beats every 10 s, as before; while joined, every 5 minutes.
23
+ //
24
+ // THE LIBRARY IS ON TRIAL (library-choices rule). `@supabase/realtime-js` is
25
+ // reached only through `connectRealtime` below, imported on first use. The
26
+ // state machine takes any `connect` with the same small shape, which is what
27
+ // the tests pass (test/runnerRealtime.test.mjs, a fake socket).
28
+
29
+ import * as runnerApiModule from "./runnerApi.mjs";
30
+ import { backoffMs } from "./runnerPace.mjs";
31
+
32
+ /** The beat while joined. The app counts a 0.12 runner online for 6 minutes
33
+ * after its last beat when it cannot see its presence (src/runs/projects.ts,
34
+ * RUNNER_PUSH_STALE_MS), so this must stay under that. */
35
+ export const PUSH_BEAT_MS = 5 * 60_000;
36
+ /** The beat interval of the moment: `pushBeatMs` (5 min) while `realtime`
37
+ * is joined, `beatMs` (10 s) otherwise or without one. */
38
+ export const beatBaseMs = (realtime, beatMs, pushBeatMs = PUSH_BEAT_MS) => (realtime?.joined ? pushBeatMs : beatMs);
39
+ /** A join that has not come back SUBSCRIBED by then is dropped and asked
40
+ * for again. */
41
+ export const JOIN_WAIT_MS = 60_000;
42
+ /** Asking again before the server has a new topic gets the same one back. */
43
+ export const SAME_TOPIC_RETRY_MS = 60_000;
44
+ /** Backoff while the op fails on a server error or the network. */
45
+ const RETRY_BASE_MS = 10_000;
46
+ const RETRY_CAP_MS = 5 * 60_000;
47
+ /** An older function ("Unknown op"), or one with no topic to give: ask now
48
+ * and then, not every few seconds. */
49
+ const REFUSED_RETRY_MS = 30 * 60_000;
50
+
51
+ /** The `realtime` op's answer, checked. null when it is not usable. */
52
+ export function readGrant(out, now = Date.now()) {
53
+ if (!out || out.ok !== true) return null;
54
+ const { url, anonKey, topic } = out;
55
+ const expiresAt = Date.parse(out.expiresAt);
56
+ const renewAt = Date.parse(out.renewAt);
57
+ if (typeof url !== "string" || !/^https?:\/\//.test(url)) return null;
58
+ if (typeof anonKey !== "string" || anonKey === "") return null;
59
+ if (typeof topic !== "string" || !topic.startsWith("runner:")) return null;
60
+ if (!Number.isFinite(expiresAt) || !Number.isFinite(renewAt) || expiresAt <= now) return null;
61
+ return { url, anonKey, topic, expiresAt, renewAt };
62
+ }
63
+
64
+ /** The real socket, through `@supabase/realtime-js`. `connect({url,
65
+ * anonKey})` -> `{join(topic, {key, onStatus, onBroadcast}) -> {track(meta),
66
+ * leave()}, close()}`. The public key goes as the socket's `apikey` and as
67
+ * the join's access token, as supabase-js sends it for a client with no
68
+ * session; nothing here ever calls setAuth. */
69
+ export async function connectRealtime({ url, anonKey }, { RealtimeClient } = {}) {
70
+ const Client = RealtimeClient ?? (await import("@supabase/realtime-js")).RealtimeClient;
71
+ const endpoint = `${url.replace(/^http/, "ws").replace(/\/+$/, "")}/realtime/v1`;
72
+ const client = new Client(endpoint, { params: { apikey: anonKey }, accessToken: async () => anonKey });
73
+ return {
74
+ join(topic, { key, onStatus, onBroadcast }) {
75
+ const channel = client.channel(topic, { config: { private: true, presence: { key } } });
76
+ for (const event of ["pending", "stop", "op", "reply"]) {
77
+ channel.on("broadcast", { event }, (message) => onBroadcast(event, message?.payload ?? {}));
78
+ }
79
+ channel.subscribe((status, error) => onStatus(status, error));
80
+ return {
81
+ track: (meta) => channel.track(meta),
82
+ leave: () => client.removeChannel(channel),
83
+ };
84
+ },
85
+ close: () => client.disconnect(),
86
+ };
87
+ }
88
+
89
+ const errorText = (error) => (error instanceof Error ? error.message : String(error ?? ""));
90
+
91
+ /**
92
+ * One per process. `start()` once; `close()` on the way out.
93
+ *
94
+ * `subscribe(fn)` -> unsubscribe. `fn({type, at, payload?})`, where `type` is
95
+ * `joined`, `left`, `pending`, `stop`, `op` or `reply`, and `at` is when it happened
96
+ * here: every folder hearing one event gets the same `at`, which is what the
97
+ * heartbeat hub keys on (`invalidate(at)`).
98
+ *
99
+ * `joined` is true while a topic is SUBSCRIBED and has not expired.
100
+ * `setFolder(projectId, on)` keeps presence's `folders` current.
101
+ */
102
+ export function makeRunnerRealtime({
103
+ runnerId,
104
+ version,
105
+ runnerApi = runnerApiModule,
106
+ env,
107
+ fetchImpl,
108
+ connect = connectRealtime,
109
+ now = Date.now,
110
+ setTimer = setTimeout,
111
+ clearTimer = clearTimeout,
112
+ random = Math.random,
113
+ log = () => {},
114
+ } = {}) {
115
+ const listeners = new Set();
116
+ const folders = new Set();
117
+ let socket = null; // {url, anonKey, conn} once connected
118
+ let held = null; // the topic events come from: {grant, handle, joined, timers}
119
+ let joining = null; // the next topic, while it joins
120
+ let asking = null;
121
+ let askTimer = null;
122
+ let failures = 0;
123
+ let closed = false;
124
+ let wasJoined = false;
125
+ let lastComplaint = "";
126
+
127
+ const emit = (event) => {
128
+ for (const fn of [...listeners]) {
129
+ try { fn(event); } catch (error) { log(`realtime listener failed: ${errorText(error)}`); }
130
+ }
131
+ };
132
+ const isJoined = () => Boolean(held?.joined && now() < held.grant.expiresAt);
133
+ // `joined`/`left` only on a real change, so a rejoin that lands while the
134
+ // runner is already joined does not beat twice.
135
+ const settle = () => {
136
+ const joined = isJoined();
137
+ if (joined === wasJoined) return;
138
+ wasJoined = joined;
139
+ emit({ type: joined ? "joined" : "left", at: now() });
140
+ };
141
+ const meta = () => ({ runnerId, version, folders: [...folders].sort() });
142
+ const track = (entry) => {
143
+ if (!entry?.joined) return;
144
+ Promise.resolve()
145
+ .then(() => entry.handle.track(meta()))
146
+ .catch((error) => log(`realtime presence failed: ${errorText(error)}`));
147
+ };
148
+ const clearEntry = (entry) => {
149
+ if (!entry) return;
150
+ entry.retired = true;
151
+ for (const timer of entry.timers) clearTimer(timer);
152
+ entry.timers.length = 0;
153
+ Promise.resolve()
154
+ .then(() => entry.handle?.leave())
155
+ .catch(() => {});
156
+ };
157
+ const schedule = (entry, ms, fn) => {
158
+ const timer = setTimer(fn, Math.max(0, ms));
159
+ timer?.unref?.();
160
+ entry.timers.push(timer);
161
+ };
162
+ const askLater = (ms) => {
163
+ if (closed) return;
164
+ clearTimer(askTimer);
165
+ askTimer = setTimer(() => {
166
+ askTimer = null;
167
+ void ask();
168
+ }, Math.max(0, ms));
169
+ askTimer?.unref?.();
170
+ };
171
+ const complain = (line) => {
172
+ if (line === lastComplaint) return;
173
+ lastComplaint = line;
174
+ log(line);
175
+ };
176
+
177
+ const join = (grant) => {
178
+ // A new client for a new address or key, and for a topic joined again:
179
+ // realtime-js hands back the channel it already has for a topic, and a
180
+ // dead join is the one thing a rejoin must not reuse.
181
+ const again = [held, joining].some((entry) => entry && entry.grant.topic === grant.topic);
182
+ if (!socket || again || socket.url !== grant.url || socket.anonKey !== grant.anonKey) {
183
+ const old = socket;
184
+ clearEntry(held);
185
+ clearEntry(joining);
186
+ held = null;
187
+ joining = null;
188
+ socket = { url: grant.url, anonKey: grant.anonKey, conn: Promise.resolve(connect({ url: grant.url, anonKey: grant.anonKey })) };
189
+ if (old) void old.conn.then((c) => c.close()).catch(() => {});
190
+ }
191
+ const entry = { grant, handle: null, joined: false, retired: false, timers: [] };
192
+ // A topic that is joined replaces `held` once it joins; one that is not
193
+ // replaces it now, since nothing comes from it anyway.
194
+ if (held?.joined) {
195
+ clearEntry(joining);
196
+ joining = entry;
197
+ } else {
198
+ clearEntry(held);
199
+ clearEntry(joining);
200
+ joining = null;
201
+ held = entry;
202
+ }
203
+ const onStatus = (status, error) => {
204
+ if (entry.retired || closed) return;
205
+ if (status === "SUBSCRIBED") {
206
+ entry.joined = true;
207
+ if (joining === entry) {
208
+ clearEntry(held);
209
+ held = entry;
210
+ joining = null;
211
+ }
212
+ track(entry);
213
+ lastComplaint = "";
214
+ // ONE HEARTBEAT ON EVERY JOIN AFTER A GAP, rejoins included: what was
215
+ // sent while the socket was down is what that beat catches up on. A
216
+ // move to the next topic has no gap (the old one stayed joined until
217
+ // now), so it beats nothing.
218
+ settle();
219
+ return;
220
+ }
221
+ // CHANNEL_ERROR, TIMED_OUT, CLOSED: the socket dropped, or the topic
222
+ // refused the join. The library rejoins on its own when it can; until
223
+ // then the runner beats every 10 s. One that stays down past
224
+ // JOIN_WAIT_MS is asked for again, in case the topic itself is gone.
225
+ entry.joined = false;
226
+ if (status !== "CLOSED") complain(`realtime: ${status}${error ? `: ${errorText(error)}` : ""}; checking in every 10 s until it is back`);
227
+ schedule(entry, JOIN_WAIT_MS, () => {
228
+ if (!entry.retired && !entry.joined && (held === entry || joining === entry)) void ask();
229
+ });
230
+ settle();
231
+ };
232
+ const onBroadcast = (type, payload) => {
233
+ if (entry.retired || closed) return;
234
+ emit({ type, payload: payload ?? {}, at: now() });
235
+ };
236
+ // Expiry: the database stops sending to a topic at `expiresAt`. A renewal
237
+ // that worked has moved on long before; one that did not ends here.
238
+ schedule(entry, grant.expiresAt - now(), () => {
239
+ if (entry.retired) return;
240
+ entry.joined = false;
241
+ if (held === entry) held = null;
242
+ if (joining === entry) joining = null;
243
+ clearEntry(entry);
244
+ settle();
245
+ void ask();
246
+ });
247
+ schedule(entry, JOIN_WAIT_MS, () => {
248
+ if (!entry.retired && !entry.joined && (held === entry || joining === entry)) void ask();
249
+ });
250
+ void socket.conn
251
+ .then((conn) => {
252
+ if (entry.retired || closed) return;
253
+ entry.handle = conn.join(grant.topic, { key: runnerId, onStatus, onBroadcast });
254
+ })
255
+ .catch((error) => {
256
+ complain(`realtime: could not connect: ${errorText(error)}`);
257
+ if (held === entry) held = null;
258
+ if (joining === entry) joining = null;
259
+ clearEntry(entry);
260
+ socket = null;
261
+ failures += 1;
262
+ askLater(backoffMs(failures, { baseMs: RETRY_BASE_MS, capMs: RETRY_CAP_MS, random }));
263
+ });
264
+ };
265
+
266
+ async function askOnce() {
267
+ // A runner api with no `realtime` (an injected fake) is an older server.
268
+ const out = typeof runnerApi.realtime === "function"
269
+ ? await runnerApi.realtime({ env, fetchImpl })
270
+ : { ok: false, error: "no realtime op" };
271
+ if (closed) return;
272
+ const grant = readGrant(out, now());
273
+ if (!grant) {
274
+ failures += 1;
275
+ const transient = out?.ok === false && out.transient === true;
276
+ complain(`realtime: ${out?.ok === false ? out.error : "the answer had no usable topic"}; checking in every 10 s`);
277
+ const wait = transient ? backoffMs(failures, { baseMs: RETRY_BASE_MS, capMs: RETRY_CAP_MS, random }) : REFUSED_RETRY_MS;
278
+ askLater(Number.isFinite(out?.retryAfterMs) ? Math.max(wait, out.retryAfterMs) : wait);
279
+ return;
280
+ }
281
+ failures = 0;
282
+ const current = joining ?? held;
283
+ if (current && current.grant.topic === grant.topic && current.grant.url === grant.url) {
284
+ // Same topic. While it is joined (or still joining) there is nothing
285
+ // to do but ask again at its renew time; a dead join is rejoined.
286
+ if (current.joined || (!current.retired && current === joining)) {
287
+ current.grant = grant;
288
+ askLater(grant.renewAt > now() ? grant.renewAt - now() : SAME_TOPIC_RETRY_MS);
289
+ return;
290
+ }
291
+ }
292
+ join(grant);
293
+ askLater(grant.renewAt > now() ? grant.renewAt - now() : SAME_TOPIC_RETRY_MS);
294
+ }
295
+ const ask = () => {
296
+ if (closed) return Promise.resolve();
297
+ clearTimer(askTimer);
298
+ askTimer = null;
299
+ return (asking ??= askOnce()
300
+ .catch((error) => {
301
+ failures += 1;
302
+ complain(`realtime: ${errorText(error)}`);
303
+ askLater(backoffMs(failures, { baseMs: RETRY_BASE_MS, capMs: RETRY_CAP_MS, random }));
304
+ })
305
+ .finally(() => { asking = null; }));
306
+ };
307
+
308
+ return {
309
+ get joined() {
310
+ return isJoined();
311
+ },
312
+ start: () => ask(),
313
+ subscribe(fn) {
314
+ listeners.add(fn);
315
+ return () => listeners.delete(fn);
316
+ },
317
+ setFolder(projectId, on) {
318
+ if (typeof projectId !== "string" || projectId === "") return;
319
+ const had = folders.has(projectId);
320
+ if (on === had) return;
321
+ if (on) folders.add(projectId);
322
+ else folders.delete(projectId);
323
+ track(held);
324
+ },
325
+ async close() {
326
+ if (closed) return;
327
+ closed = true;
328
+ clearTimer(askTimer);
329
+ clearEntry(held);
330
+ clearEntry(joining);
331
+ held = null;
332
+ joining = null;
333
+ wasJoined = false;
334
+ listeners.clear();
335
+ const conn = socket?.conn;
336
+ socket = null;
337
+ if (conn) await conn.then((c) => c.close()).catch(() => {});
338
+ },
339
+ };
340
+ }