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.
- package/CHANGELOG.md +1460 -0
- package/LICENSE +21 -0
- package/README.md +755 -2
- package/SPAWN.md +2105 -0
- package/glytch.mjs +517 -0
- package/lib/acp.mjs +978 -0
- package/lib/admission.mjs +105 -0
- package/lib/agent-selection.mjs +123 -0
- package/lib/agents.mjs +913 -0
- package/lib/atomic.mjs +96 -0
- package/lib/auth.mjs +1387 -0
- package/lib/authenticatedHttp.mjs +68 -0
- package/lib/boardside.mjs +225 -0
- package/lib/child.mjs +1252 -0
- package/lib/cli-output.mjs +176 -0
- package/lib/cloudflared-release.json +25 -0
- package/lib/cloudflared.mjs +81 -0
- package/lib/codex-rpc.mjs +92 -0
- package/lib/codex.mjs +747 -0
- package/lib/codexApps.mjs +332 -0
- package/lib/codexPlanWindow.mjs +84 -0
- package/lib/codexToolInputs.mjs +139 -0
- package/lib/codingChecks.mjs +55 -0
- package/lib/compose.mjs +76 -0
- package/lib/computerApps.mjs +110 -0
- package/lib/conversationRules.mjs +45 -0
- package/lib/detect.mjs +605 -0
- package/lib/devserver.mjs +659 -0
- package/lib/digest.mjs +290 -0
- package/lib/doctor.mjs +464 -0
- package/lib/exitClass.mjs +64 -0
- package/lib/glytchFolder.mjs +105 -0
- package/lib/hosted.mjs +112 -0
- package/lib/imageAttachments.mjs +31 -0
- package/lib/init-connect.mjs +418 -0
- package/lib/kill.mjs +104 -0
- package/lib/logindaemon.mjs +976 -0
- package/lib/logs.mjs +120 -0
- package/lib/mcpjson.mjs +242 -0
- package/lib/memberPreview.mjs +165 -0
- package/lib/multiwatch.mjs +428 -0
- package/lib/pagePictures.mjs +396 -0
- package/lib/pageRoutes.mjs +67 -0
- package/lib/paths.mjs +9 -0
- package/lib/permissionAsk.mjs +418 -0
- package/lib/picker.mjs +300 -0
- package/lib/platform.mjs +60 -0
- package/lib/previewGateway.mjs +129 -0
- package/lib/previewTunnel.mjs +85 -0
- package/lib/procs.mjs +428 -0
- package/lib/project-mcp.mjs +288 -0
- package/lib/projectPaths.mjs +31 -0
- package/lib/refusal.mjs +70 -0
- package/lib/registry.mjs +204 -0
- package/lib/repomap.mjs +322 -0
- package/lib/restartPass.mjs +151 -0
- package/lib/retired-address.mjs +78 -0
- package/lib/rowsAnswers.mjs +389 -0
- package/lib/rowsEvents.mjs +86 -0
- package/lib/run-engine.mjs +16 -0
- package/lib/runner-health.mjs +57 -0
- package/lib/runner.mjs +1569 -0
- package/lib/runnerAdmission.mjs +35 -0
- package/lib/runnerApi.mjs +312 -0
- package/lib/runnerCompletion.mjs +406 -0
- package/lib/runnerLease.mjs +37 -0
- package/lib/runnerMaintenance.mjs +27 -0
- package/lib/runnerPace.mjs +283 -0
- package/lib/runnerPerformance.mjs +26 -0
- package/lib/runnerRealtime.mjs +340 -0
- package/lib/runnerRegister.mjs +96 -0
- package/lib/runnerSettings.mjs +40 -0
- package/lib/runnerSettlement.mjs +138 -0
- package/lib/runnerWorkspace.mjs +162 -0
- package/lib/serverSweep.mjs +90 -0
- package/lib/sharedPreview.mjs +292 -0
- package/lib/signIn.mjs +47 -0
- package/lib/spawn.mjs +330 -0
- package/lib/sweep.mjs +161 -0
- package/lib/sync.mjs +484 -0
- package/lib/uninstall-project.mjs +588 -0
- package/lib/uninstall.mjs +457 -0
- package/lib/update.mjs +302 -0
- package/lib/usageLimit.mjs +108 -0
- package/lib/windows-command.mjs +85 -0
- package/lib/worktree.mjs +1138 -0
- package/lib/writePolicy.mjs +45 -0
- package/package.json +58 -4
- 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
|
+
}
|