@kitkat-vn/kitcode 0.2.0-staging.3 → 0.2.0-staging.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -21
- package/bin/kitcode.mjs +182 -13
- package/package.json +2 -2
- package/src/api.mjs +31 -21
- package/src/companion-window.mjs +10 -8
- package/src/diagnostics.mjs +872 -0
- package/src/git.mjs +48 -8
- package/src/hook-prompt.mjs +19 -3
- package/src/integration-spec.mjs +46 -37
- package/src/onboarding-electron.mjs +8 -0
- package/src/onboarding-window.mjs +47 -7
- package/src/reward.mjs +4 -41
- package/src/runtime.mjs +30 -13
- package/src/source-snapshot.mjs +11 -3
- package/src/tracker-problems.mjs +1 -0
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import {writeFileAtomically} from './atomic-file.mjs';
|
|
7
|
+
import {ensureInstallId, readInstallId} from './install-id.mjs';
|
|
8
|
+
import {DIAGNOSTICS_URL} from './integration-spec.mjs';
|
|
9
|
+
import {STORE_DIR} from './store.mjs';
|
|
10
|
+
|
|
11
|
+
// Tester diagnostics (docs/backend.md, "Tester diagnostics"; docs/security.md, "Tester diagnostics"; the voucher scheme's
|
|
12
|
+
// phase 4): a tester build tells its staging server about KitCode itself, so that the operator can trace a tester's
|
|
13
|
+
// problem without asking them for its symptoms. Only a tester build: DIAGNOSTICS_URL is null in the repository and in every
|
|
14
|
+
// release of it, and then nothing here collects, writes or sends anything (diagnosticsEnabled is false, and every function
|
|
15
|
+
// below returns at once).
|
|
16
|
+
//
|
|
17
|
+
// What is collected is KitCode's own events, in the contract's closed vocabulary (packages/claim-contract,
|
|
18
|
+
// diagnostics.v1.schema.json): which, when, and fields of known shapes. Never a path, a file or project name, a user or
|
|
19
|
+
// host name, a prompt, code, a git ref or a commit; an error only by its code and name, never its message or its stack,
|
|
20
|
+
// which hold paths, and paths hold the user's name. A project is an opaque reference, a keyed hash of KitCode's project id
|
|
21
|
+
// under a key that never leaves this computer, since that id is a hash of the project's path, which a guess could confirm.
|
|
22
|
+
//
|
|
23
|
+
// Each event is its own small file in ~/.kitcode/diagnostics/queue, written whole and then renamed into place, so that the
|
|
24
|
+
// prompt hook, the tracker and a command can each add one, and send and remove them, without a lock and without losing
|
|
25
|
+
// another's: a file is removed only once the server has said it stored it (or refused it for good). The queue is bounded,
|
|
26
|
+
// by count and by bytes, its oldest events dropped first, and every drop counted in an event of its own. Nothing here is
|
|
27
|
+
// ever in state.json, whose shape this does not change (hard rule 4).
|
|
28
|
+
//
|
|
29
|
+
// Nothing here ever throws to its caller: the hook must fail open and finish well inside its 5 s (hard rule 7), and the
|
|
30
|
+
// tracker must count whatever happens to its diagnostics.
|
|
31
|
+
|
|
32
|
+
export const DIAGNOSTICS_DIR = path.join(STORE_DIR, 'diagnostics');
|
|
33
|
+
export const DIAGNOSTICS_DISPLAY_PATH = '~/.kitcode/diagnostics';
|
|
34
|
+
const QUEUE_DIR = path.join(DIAGNOSTICS_DIR, 'queue');
|
|
35
|
+
const HOOK_DIR = path.join(DIAGNOSTICS_DIR, 'hook');
|
|
36
|
+
const KEY_PATH = path.join(DIAGNOSTICS_DIR, 'key');
|
|
37
|
+
const INVITED_EMAIL_PATH = path.join(DIAGNOSTICS_DIR, 'invited-email');
|
|
38
|
+
const CLOSED_PATH = path.join(DIAGNOSTICS_DIR, 'closed-until');
|
|
39
|
+
const LAST_SEND_PATH = path.join(DIAGNOSTICS_DIR, 'last-send.json');
|
|
40
|
+
const FILE_MODE = 0o600;
|
|
41
|
+
const DIR_MODE = 0o700;
|
|
42
|
+
|
|
43
|
+
// The queue's caps: at most this many events, and this many bytes, the oldest dropped first. An event is a few hundred
|
|
44
|
+
// bytes; one larger than MAX_EVENT_BYTES is not written at all.
|
|
45
|
+
export const MAX_QUEUED_EVENTS = 500;
|
|
46
|
+
export const MAX_QUEUE_BYTES = 512 * 1024;
|
|
47
|
+
export const MAX_EVENT_BYTES = 2048;
|
|
48
|
+
// A batch: at most the contract's 100 events, and this many bytes, under the server's 64 KiB.
|
|
49
|
+
export const MAX_BATCH_EVENTS = 100;
|
|
50
|
+
export const MAX_BATCH_BYTES = 48 * 1024;
|
|
51
|
+
// A hook run this slow is recorded: its agent waits 5 s for it.
|
|
52
|
+
export const HOOK_SLOW_MS = 2000;
|
|
53
|
+
// A hook run's start marker still there this long after it began, its process gone, is a run that never finished: its agent
|
|
54
|
+
// stopped it at its 5 s timeout, or it crashed. Still there this long, process or not, it hung.
|
|
55
|
+
export const HOOK_UNFINISHED_MS = 10_000;
|
|
56
|
+
export const HOOK_HUNG_MS = 60_000;
|
|
57
|
+
// The server said it takes no diagnostics now (410): the round is over, or paused. Sending waits this long before it tries
|
|
58
|
+
// again, whatever process tries, unless the tester asks to send now.
|
|
59
|
+
export const CLOSED_PAUSE_MS = 24 * 60 * 60 * 1000;
|
|
60
|
+
|
|
61
|
+
const EVENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
|
|
62
|
+
const QUEUED_NAME = /^([0-9]{13})-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.json$/u;
|
|
63
|
+
const MARKER_NAME = /^([0-9]{13})-([0-9]{1,10})-(claude|codex|unknown)-[0-9a-f]{8}$/u;
|
|
64
|
+
const AGENTS = new Set(['claude', 'codex']);
|
|
65
|
+
const OPERATING_SYSTEMS = new Set(['aix', 'android', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32']);
|
|
66
|
+
const ARCHITECTURES = new Set(['arm', 'arm64', 'ia32', 'loong64', 'mips', 'mipsel', 'ppc', 'ppc64', 'riscv64', 's390', 's390x', 'x64']);
|
|
67
|
+
// What the contract takes for an email typed in Welcome (diagnostics.v1.schema.json, invitedEmail).
|
|
68
|
+
const INVITED_EMAIL = /^[^\s@"<>]{1,64}@[^\s@"<>]{1,189}$/u;
|
|
69
|
+
|
|
70
|
+
// Where this build sends: DIAGNOSTICS_URL, an https URL, or, for this repository's tests, an http one on this machine;
|
|
71
|
+
// anything else, null above all, is no destination, and diagnostics are off.
|
|
72
|
+
export function diagnosticsDestination(setting = DIAGNOSTICS_URL) {
|
|
73
|
+
if (typeof setting !== 'string') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const url = new URL(setting);
|
|
79
|
+
const loopback = url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
80
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && loopback) ? url : null;
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function diagnosticsEnabled() {
|
|
87
|
+
return diagnosticsDestination() !== null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function agentOf(source) {
|
|
91
|
+
return AGENTS.has(source) ? source : 'unknown';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// An error's code and its name, of the shapes the contract takes, and never its message or its stack.
|
|
95
|
+
export function errorCodeOf(error) {
|
|
96
|
+
const code = error?.code;
|
|
97
|
+
return typeof code === 'string' && /^[A-Z][A-Z0-9_]{1,39}$/u.test(code) ? code : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function errorNameOf(error) {
|
|
101
|
+
const name = error?.name;
|
|
102
|
+
return typeof name === 'string' && /^[A-Z][A-Za-z0-9]{0,39}$/u.test(name) ? name : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Why the hook installer left an agent's settings as they are (hook-installers.mjs, ConfigFileError), as a fixed code: its
|
|
106
|
+
// words name the file, and can quote it.
|
|
107
|
+
export function configProblemCode(error) {
|
|
108
|
+
if (error === undefined || error === null) {
|
|
109
|
+
return 'none';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (error.readOnly) {
|
|
113
|
+
return 'read-only';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const problem = String(error.problem ?? '');
|
|
117
|
+
const codes = [
|
|
118
|
+
[/^is not a regular file/u, 'not-a-file'],
|
|
119
|
+
[/^could not be read/u, 'unreadable'],
|
|
120
|
+
[/^is not valid JSON/u, 'not-json'],
|
|
121
|
+
[/^does not hold a JSON object/u, 'not-an-object'],
|
|
122
|
+
[/^has a "hooks" value/u, 'hooks-not-an-object'],
|
|
123
|
+
[/^has a "hooks\.UserPromptSubmit" value/u, 'hooks-not-a-list'],
|
|
124
|
+
[/^changed while KitCode/u, 'changed'],
|
|
125
|
+
[/^could not be written/u, 'unwritable'],
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
return codes.find(([pattern]) => pattern.test(problem))?.[1] ?? 'other';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureDir(folder) {
|
|
132
|
+
fs.mkdirSync(folder, {recursive: true, mode: DIR_MODE});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The key a project's reference is made with: 32 random bytes, made once per HOME, 0600, never sent anywhere. Written whole,
|
|
136
|
+
// then linked into place, as the install id is, so that two processes making it at once keep one.
|
|
137
|
+
function diagnosticsKey() {
|
|
138
|
+
try {
|
|
139
|
+
return Buffer.from(fs.readFileSync(KEY_PATH, 'utf8').trim(), 'hex');
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error?.code !== 'ENOENT') {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
ensureDir(DIAGNOSTICS_DIR);
|
|
148
|
+
const temporary = path.join(DIAGNOSTICS_DIR, `.key.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
|
|
149
|
+
fs.writeFileSync(temporary, `${crypto.randomBytes(32).toString('hex')}\n`, {mode: FILE_MODE, flag: 'wx'});
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
fs.linkSync(temporary, KEY_PATH);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (error?.code !== 'EEXIST') {
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
fs.rmSync(temporary, {force: true});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Buffer.from(fs.readFileSync(KEY_PATH, 'utf8').trim(), 'hex');
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// A key for this process alone, when the file's cannot be read or made: references then hold within the process.
|
|
168
|
+
let processKey = null;
|
|
169
|
+
|
|
170
|
+
// A project's reference: the first 12 hex characters of an HMAC-SHA-256 of KitCode's project id, under the key above. It
|
|
171
|
+
// tells one project of this install from another, and nothing more: without the key, which stays here, no guess of a
|
|
172
|
+
// project's path can be checked against it.
|
|
173
|
+
export function projectRef(projectId) {
|
|
174
|
+
let key = diagnosticsKey();
|
|
175
|
+
|
|
176
|
+
if (key === null || key.length !== 32) {
|
|
177
|
+
processKey ??= crypto.randomBytes(32);
|
|
178
|
+
key = processKey;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return crypto.createHmac('sha256', key).update(String(projectId)).digest('hex').slice(0, 12);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function eventName(at, id) {
|
|
185
|
+
return `${String(at.getTime()).padStart(13, '0')}-${id}.json`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Writes one event into the queue, whole: to a temporary file in the queue's folder, then renamed into place.
|
|
189
|
+
function writeEvent(event) {
|
|
190
|
+
const text = `${JSON.stringify(event)}\n`;
|
|
191
|
+
|
|
192
|
+
if (Buffer.byteLength(text) > MAX_EVENT_BYTES) {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ensureDir(QUEUE_DIR);
|
|
197
|
+
writeFileAtomically(path.join(QUEUE_DIR, eventName(new Date(event.at), event.id)), text, {mode: FILE_MODE});
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function queuedNames() {
|
|
202
|
+
try {
|
|
203
|
+
return fs.readdirSync(QUEUE_DIR).filter((name) => QUEUED_NAME.test(name)).sort();
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeQueued(names) {
|
|
210
|
+
for (const name of names) {
|
|
211
|
+
try {
|
|
212
|
+
fs.rmSync(path.join(QUEUE_DIR, name), {force: true});
|
|
213
|
+
} catch {
|
|
214
|
+
// Windows will not remove a file another process holds open: another sender, reading it. It is sent again, and the
|
|
215
|
+
// server, which has it, says so; then it goes.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Keeps the queue under its caps, by count and, with `bytes`, by size, with room for one event more and the record of a
|
|
221
|
+
// drop: once a cap is reached, the oldest tenth of the queue goes, so that a queue kept full by a stream of events drops in
|
|
222
|
+
// chunks, each counted in one event of its own, which stays, rather than writing a record for every event it drops. The
|
|
223
|
+
// prompt hook counts only, which costs one listing of a folder of at most MAX_QUEUED_EVENTS names; the tracker and the
|
|
224
|
+
// commands that send measure the bytes too.
|
|
225
|
+
export function enforceQueueCaps({source, bytes = true} = {}) {
|
|
226
|
+
if (!diagnosticsEnabled()) {
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
let names = queuedNames();
|
|
232
|
+
let dropped = 0;
|
|
233
|
+
let reason = 'count-cap';
|
|
234
|
+
|
|
235
|
+
if (names.length + 2 > MAX_QUEUED_EVENTS) {
|
|
236
|
+
const over = names.slice(0, Math.max(names.length + 2 - MAX_QUEUED_EVENTS, MAX_QUEUED_EVENTS / 10));
|
|
237
|
+
removeQueued(over);
|
|
238
|
+
dropped += over.length;
|
|
239
|
+
names = names.slice(over.length);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (bytes) {
|
|
243
|
+
const sizes = names.map((name) => {
|
|
244
|
+
try {
|
|
245
|
+
return fs.statSync(path.join(QUEUE_DIR, name)).size;
|
|
246
|
+
} catch {
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
let total = sizes.reduce((sum, size) => sum + size, 0);
|
|
251
|
+
|
|
252
|
+
if (total + 2 * MAX_EVENT_BYTES > MAX_QUEUE_BYTES) {
|
|
253
|
+
let first = 0;
|
|
254
|
+
|
|
255
|
+
while (total + 2 * MAX_EVENT_BYTES > MAX_QUEUE_BYTES * 0.9 && first < names.length) {
|
|
256
|
+
total -= sizes[first];
|
|
257
|
+
first += 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
removeQueued(names.slice(0, first));
|
|
261
|
+
dropped += first;
|
|
262
|
+
reason = dropped === first ? 'size-cap' : reason;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (dropped > 0) {
|
|
267
|
+
writeEvent(newEvent('diagnostics-dropped', {count: dropped, reason}, {source}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return dropped;
|
|
271
|
+
} catch {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function newEvent(type, fields, {source, now = new Date()}) {
|
|
277
|
+
return {id: crypto.randomUUID(), at: now.toISOString(), source, type, ...fields};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Records one event, of `type` and its fields, from `source` (tracker, command or hook): written into the queue, which is
|
|
281
|
+
// kept under its caps. Returns whether it was written. Off unless this is a tester build; never throws.
|
|
282
|
+
export function recordEvent(type, fields, {source, now = new Date(), bytes = true} = {}) {
|
|
283
|
+
if (!diagnosticsEnabled()) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
enforceQueueCaps({source, bytes});
|
|
289
|
+
return writeEvent(newEvent(type, fields, {source, now}));
|
|
290
|
+
} catch {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The queue, oldest first: each event as it was written, with its file. A file that does not hold one is removed: only a
|
|
296
|
+
// person, or another program, could have written it.
|
|
297
|
+
export function queuedEvents() {
|
|
298
|
+
const events = [];
|
|
299
|
+
|
|
300
|
+
for (const name of queuedNames()) {
|
|
301
|
+
try {
|
|
302
|
+
const event = JSON.parse(fs.readFileSync(path.join(QUEUE_DIR, name), 'utf8'));
|
|
303
|
+
|
|
304
|
+
if (event !== null && typeof event === 'object' && EVENT_ID.test(event.id ?? '')) {
|
|
305
|
+
events.push({name, event});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (error?.code === 'ENOENT') {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
removeQueued([name]);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return events;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// --- The prompt hook --------------------------------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
// A run of the hook begins: a start marker, an empty file named for the time, the process and the agent. hookFinished
|
|
323
|
+
// removes it; one left behind is a run that never finished (sweepHookMarkers). Returns its path, or null.
|
|
324
|
+
export function hookStarted(source, now = Date.now()) {
|
|
325
|
+
if (!diagnosticsEnabled()) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const marker = path.join(HOOK_DIR, `${String(now).padStart(13, '0')}-${process.pid}-${agentOf(source)}-${crypto.randomBytes(4).toString('hex')}`);
|
|
330
|
+
|
|
331
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
332
|
+
try {
|
|
333
|
+
fs.writeFileSync(marker, '', {mode: FILE_MODE, flag: 'wx'});
|
|
334
|
+
return marker;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error?.code !== 'ENOENT' || attempt > 0) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
ensureDir(HOOK_DIR);
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// The run ends, however it went: its marker goes, and one event at most is recorded: `failure`, an error it met, logged in
|
|
352
|
+
// hook.log and failed open on, by its code and name alone; or else a run past HOOK_SLOW_MS. It never throws.
|
|
353
|
+
export function hookFinished(marker, source, ms, failure = null) {
|
|
354
|
+
if (marker !== null) {
|
|
355
|
+
try {
|
|
356
|
+
fs.rmSync(marker, {force: true});
|
|
357
|
+
} catch {
|
|
358
|
+
// Left behind, it is swept as a run that never finished: wrongly, and harmlessly.
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (failure !== null) {
|
|
363
|
+
hookFailed(source, failure, ms);
|
|
364
|
+
} else if (ms >= HOOK_SLOW_MS) {
|
|
365
|
+
recordEvent('hook-slow', {agent: agentOf(source), ms: Math.min(3_600_000, Math.round(ms))}, {source: 'hook', bytes: false});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function hookFailed(source, error, ms) {
|
|
370
|
+
recordEvent('hook-failed', {agent: agentOf(source), errorCode: errorCodeOf(error), errorName: errorNameOf(error), ms: Math.min(3_600_000, Math.round(ms))}, {source: 'hook', bytes: false});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isAlive(pid) {
|
|
374
|
+
try {
|
|
375
|
+
process.kill(pid, 0);
|
|
376
|
+
return true;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return error?.code === 'EPERM';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The start markers runs of the hook left behind: each one older than HOOK_UNFINISHED_MS whose process is gone, or older
|
|
383
|
+
// than HOOK_HUNG_MS whatever its process, is a run that never finished, recorded and removed. Run by the tracker, and by the
|
|
384
|
+
// commands that send, since the hook itself cannot record its own end once its agent has stopped it.
|
|
385
|
+
export function sweepHookMarkers({source, now = Date.now()} = {}) {
|
|
386
|
+
if (!diagnosticsEnabled()) {
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let names;
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
names = fs.readdirSync(HOOK_DIR);
|
|
394
|
+
} catch {
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let found = 0;
|
|
399
|
+
|
|
400
|
+
for (const name of names) {
|
|
401
|
+
const marker = path.join(HOOK_DIR, name);
|
|
402
|
+
const match = MARKER_NAME.exec(name);
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
if (match === null) {
|
|
406
|
+
// Not a marker: the hook leaves nothing else here, so it is no one's.
|
|
407
|
+
fs.rmSync(marker, {force: true, recursive: true});
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const startedMs = Number(match[1]);
|
|
412
|
+
const age = now - startedMs;
|
|
413
|
+
|
|
414
|
+
if (age < HOOK_UNFINISHED_MS || (age < HOOK_HUNG_MS && isAlive(Number(match[2])))) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// The run may have ended between the listing and now, and taken its marker with it: then it finished.
|
|
419
|
+
if (!fs.existsSync(marker)) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
fs.rmSync(marker, {force: true});
|
|
424
|
+
recordEvent('hook-unfinished', {agent: match[3], startedAt: new Date(startedMs).toISOString(), ageSeconds: Math.max(0, Math.floor(age / 1000))}, {source});
|
|
425
|
+
found += 1;
|
|
426
|
+
} catch {
|
|
427
|
+
// The next sweep tries it again.
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return found;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- The invited email, from Welcome ----------------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
// The address a tester typed in Welcome, so that staff can find their logs by it: trimmed and lower-cased; null for none;
|
|
437
|
+
// undefined for one the contract would not take.
|
|
438
|
+
export function normalizeInvitedEmail(value) {
|
|
439
|
+
const email = String(value ?? '').trim().toLowerCase();
|
|
440
|
+
|
|
441
|
+
if (email === '') {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return email.length <= 254 && INVITED_EMAIL.test(email) ? email : undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function readInvitedEmail() {
|
|
449
|
+
if (!diagnosticsEnabled()) {
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
try {
|
|
454
|
+
return normalizeInvitedEmail(fs.readFileSync(INVITED_EMAIL_PATH, 'utf8')) ?? null;
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Keeps the address typed in Welcome, outside state.json, in a file of its own, 0600; an empty one removes it. Returns
|
|
461
|
+
// {ok: true, email} or {ok: false}, for an address the contract would not take, and keeps nothing then.
|
|
462
|
+
export function setInvitedEmail(value) {
|
|
463
|
+
if (!diagnosticsEnabled()) {
|
|
464
|
+
return {ok: true, email: null};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const email = normalizeInvitedEmail(value);
|
|
468
|
+
|
|
469
|
+
if (email === undefined) {
|
|
470
|
+
return {ok: false};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
try {
|
|
474
|
+
if (email === null) {
|
|
475
|
+
fs.rmSync(INVITED_EMAIL_PATH, {force: true});
|
|
476
|
+
} else {
|
|
477
|
+
ensureDir(DIAGNOSTICS_DIR);
|
|
478
|
+
writeFileAtomically(INVITED_EMAIL_PATH, `${email}\n`, {mode: FILE_MODE});
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {ok: true, email};
|
|
482
|
+
} catch {
|
|
483
|
+
return {ok: false};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// --- Sending ----------------------------------------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
function cliVersion() {
|
|
490
|
+
try {
|
|
491
|
+
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
492
|
+
} catch {
|
|
493
|
+
return '0.0.0';
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// What sent the batch: the CLI's version, and the platform, each of a shape the contract takes; never a host or user name.
|
|
498
|
+
export function clientInfo() {
|
|
499
|
+
const release = os.release();
|
|
500
|
+
return {
|
|
501
|
+
kind: 'cli',
|
|
502
|
+
version: cliVersion(),
|
|
503
|
+
os: OPERATING_SYSTEMS.has(process.platform) ? process.platform : 'other',
|
|
504
|
+
osRelease: /^[0-9]{1,5}(\.[0-9]{1,6}){0,4}([-+._][0-9A-Za-z]{1,24}){0,6}$/u.test(release) && release.length <= 40 ? release : '0',
|
|
505
|
+
arch: ARCHITECTURES.has(process.arch) ? process.arch : 'other',
|
|
506
|
+
node: /^v[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/u.test(process.version) ? process.version : 'v0.0.0',
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// The install id the batch is linked by: the claim bundle's. A tester build makes it when there is none yet, so that a
|
|
511
|
+
// command that fails before any tracker ever started is linked to the same install as everything after.
|
|
512
|
+
function installIdForBatch() {
|
|
513
|
+
try {
|
|
514
|
+
return readInstallId() ?? ensureInstallId();
|
|
515
|
+
} catch {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function closedUntil(now = new Date()) {
|
|
521
|
+
try {
|
|
522
|
+
const until = new Date(fs.readFileSync(CLOSED_PATH, 'utf8').trim());
|
|
523
|
+
return Number.isFinite(until.getTime()) && until > now ? until : null;
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function setClosed(until) {
|
|
530
|
+
try {
|
|
531
|
+
ensureDir(DIAGNOSTICS_DIR);
|
|
532
|
+
writeFileAtomically(CLOSED_PATH, `${until.toISOString()}\n`, {mode: FILE_MODE});
|
|
533
|
+
} catch {
|
|
534
|
+
// Without it, the next process tries again, and is told again.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function readLastSend() {
|
|
539
|
+
try {
|
|
540
|
+
const last = JSON.parse(fs.readFileSync(LAST_SEND_PATH, 'utf8'));
|
|
541
|
+
return last !== null && typeof last === 'object' ? last : null;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function writeLastSend(outcome, now) {
|
|
548
|
+
try {
|
|
549
|
+
ensureDir(DIAGNOSTICS_DIR);
|
|
550
|
+
writeFileAtomically(LAST_SEND_PATH, `${JSON.stringify({at: now.toISOString(), ...outcome})}\n`, {mode: FILE_MODE});
|
|
551
|
+
} catch {
|
|
552
|
+
// The next send writes it.
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// The batches the queue makes, oldest events first: each at most MAX_BATCH_EVENTS events and MAX_BATCH_BYTES bytes.
|
|
557
|
+
function batchOf(queued, envelope) {
|
|
558
|
+
const events = [];
|
|
559
|
+
let bytes = Buffer.byteLength(JSON.stringify({...envelope, events: []}));
|
|
560
|
+
|
|
561
|
+
for (const entry of queued) {
|
|
562
|
+
const size = Buffer.byteLength(JSON.stringify(entry.event)) + 1;
|
|
563
|
+
|
|
564
|
+
if (events.length >= MAX_BATCH_EVENTS || bytes + size > MAX_BATCH_BYTES) {
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
events.push(entry);
|
|
569
|
+
bytes += size;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return events;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// The server's answer to a batch: the ids it stored, those it had stored already, and the events it refused, by their place
|
|
576
|
+
// in the batch. Anything else, or ids it was not sent, is no answer.
|
|
577
|
+
function answerOf(body, sent) {
|
|
578
|
+
const ids = new Set(sent.map((entry) => entry.event.id));
|
|
579
|
+
const isIdList = (value) => Array.isArray(value) && value.every((id) => typeof id === 'string' && ids.has(id));
|
|
580
|
+
|
|
581
|
+
if (body === null || typeof body !== 'object' || !isIdList(body.stored) || !isIdList(body.duplicates) || !Array.isArray(body.refused)) {
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const refused = body.refused.map((refusal) => refusal?.index);
|
|
586
|
+
|
|
587
|
+
if (!refused.every((index) => Number.isInteger(index) && index >= 0 && index < sent.length)) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {stored: new Set(body.stored), duplicates: new Set(body.duplicates), refused: new Set(refused)};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function readBody(response) {
|
|
595
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
596
|
+
|
|
597
|
+
if (buffer.length > 256 * 1024) {
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
try {
|
|
602
|
+
return JSON.parse(buffer.toString('utf8'));
|
|
603
|
+
} catch {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Sends the queue, oldest first, in at most `maxBatches` batches, each POSTed to the destination with `timeoutMs` to answer.
|
|
609
|
+
// An event leaves the queue only once the server says it stored it, had it already, or refused it for good. Returns what
|
|
610
|
+
// happened, in the shape describeOutcome puts in words; never throws. `manual`: the tester asked to send now, which tries
|
|
611
|
+
// even while the server has said it is closed.
|
|
612
|
+
export async function sendQueued({timeoutMs = 10_000, maxBatches = 1, manual = false, fetchImpl = globalThis.fetch, now = () => new Date(), source = 'command'} = {}) {
|
|
613
|
+
const destination = diagnosticsDestination();
|
|
614
|
+
|
|
615
|
+
if (destination === null) {
|
|
616
|
+
return {outcome: 'disabled'};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
try {
|
|
620
|
+
const pausedUntil = closedUntil(now());
|
|
621
|
+
|
|
622
|
+
if (pausedUntil !== null && !manual) {
|
|
623
|
+
return {outcome: 'closed', until: pausedUntil.toISOString(), queued: queuedNames().length};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
enforceQueueCaps({source});
|
|
627
|
+
const totals = {stored: 0, duplicates: 0, refused: 0};
|
|
628
|
+
|
|
629
|
+
for (let batch = 0; batch < maxBatches; batch += 1) {
|
|
630
|
+
const queued = queuedEvents();
|
|
631
|
+
|
|
632
|
+
if (queued.length === 0) {
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const envelope = {schemaVersion: 1, installId: installIdForBatch(), client: clientInfo(), invitedEmail: readInvitedEmail(), sentAt: now().toISOString()};
|
|
637
|
+
const sent = batchOf(queued, envelope);
|
|
638
|
+
let response;
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
response = await fetchImpl(destination, {
|
|
642
|
+
method: 'POST',
|
|
643
|
+
headers: {'content-type': 'application/json'},
|
|
644
|
+
body: JSON.stringify({...envelope, events: sent.map((entry) => entry.event)}),
|
|
645
|
+
redirect: 'error',
|
|
646
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
647
|
+
});
|
|
648
|
+
} catch (error) {
|
|
649
|
+
return finish({outcome: 'failed', reason: error?.name === 'TimeoutError' ? 'timeout' : 'unreachable', ...totals});
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (response.status === 200) {
|
|
653
|
+
const answer = answerOf(await readBody(response), sent);
|
|
654
|
+
|
|
655
|
+
if (answer === null) {
|
|
656
|
+
return finish({outcome: 'failed', reason: 'bad-answer', ...totals});
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
removeQueued(sent.filter((entry, index) => answer.stored.has(entry.event.id) || answer.duplicates.has(entry.event.id) || answer.refused.has(index)).map((entry) => entry.name));
|
|
660
|
+
totals.stored += answer.stored.size;
|
|
661
|
+
totals.duplicates += answer.duplicates.size;
|
|
662
|
+
totals.refused += answer.refused.size;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (response.status === 410) {
|
|
667
|
+
setClosed(new Date(now().getTime() + CLOSED_PAUSE_MS));
|
|
668
|
+
return finish({outcome: 'closed', until: closedUntil(now())?.toISOString() ?? null, ...totals});
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (response.status === 429) {
|
|
672
|
+
const seconds = Number(response.headers.get('retry-after'));
|
|
673
|
+
return finish({outcome: 'rate-limited', retryAfterSeconds: Number.isInteger(seconds) && seconds > 0 ? seconds : 60, ...totals});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (response.status === 404) {
|
|
677
|
+
return finish({outcome: 'failed', reason: 'not-found', status: 404, ...totals});
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// A login, or a path not forwarded, in front of the route: the staging site's nginx before it forwards the path (the
|
|
681
|
+
// shared login answers 401), not the batch. The route itself never answers a CLI's post with these, so the batch
|
|
682
|
+
// waits for the next send, as for a 404, and is not dropped.
|
|
683
|
+
if (response.status === 401 || response.status === 403 || response.status === 405) {
|
|
684
|
+
return finish({outcome: 'failed', reason: 'not-let-through', status: response.status, ...totals});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Any other refusal of the batch is for good: sending it again would be refused again (the ingest-hot-path skill, §7).
|
|
688
|
+
if (response.status >= 400 && response.status < 500) {
|
|
689
|
+
removeQueued(sent.map((entry) => entry.name));
|
|
690
|
+
return finish({outcome: 'refused', status: response.status, dropped: sent.length, ...totals});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return finish({outcome: 'failed', reason: 'server-error', status: response.status, ...totals});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return finish(totals.stored + totals.duplicates + totals.refused > 0 ? {outcome: 'stored', ...totals} : {outcome: 'nothing', ...totals});
|
|
697
|
+
} catch {
|
|
698
|
+
return finish({outcome: 'failed', reason: 'error', stored: 0, duplicates: 0, refused: 0});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function finish(outcome) {
|
|
702
|
+
const result = {...outcome, queued: queuedNames().length};
|
|
703
|
+
writeLastSend(result, now());
|
|
704
|
+
return result;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function plural(count, one, many = `${one}s`) {
|
|
709
|
+
return `${count} ${count === 1 ? one : many}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// What a send did, in plain words, for `kitcode diagnostics send` and the dashboard's Send logs now: exactly what the
|
|
713
|
+
// server confirmed, or why nothing was sent, and what waits.
|
|
714
|
+
export function describeOutcome(result) {
|
|
715
|
+
const waiting = result.queued === undefined ? '' : ` ${result.queued === 0 ? 'Nothing waits to be sent.' : `${plural(result.queued, 'event')} ${result.queued === 1 ? 'waits' : 'wait'}, and ${result.queued === 1 ? 'is' : 'are'} sent later.`}`;
|
|
716
|
+
const sentSoFar = result.stored + result.duplicates > 0 ? ` Before that, it stored ${plural(result.stored + result.duplicates, 'event')}.` : '';
|
|
717
|
+
|
|
718
|
+
switch (result.outcome) {
|
|
719
|
+
case 'disabled':
|
|
720
|
+
return 'This KitCode is not a tester build: it collects, writes and sends no diagnostics.';
|
|
721
|
+
case 'nothing':
|
|
722
|
+
return 'Nothing to send: no event waits.';
|
|
723
|
+
case 'stored': {
|
|
724
|
+
const before = result.duplicates > 0 ? ` (${result.duplicates} of them it had already)` : '';
|
|
725
|
+
const refused = result.refused > 0 ? ` It refused ${plural(result.refused, 'event')} it does not know, which ${result.refused === 1 ? 'is' : 'are'} dropped.` : '';
|
|
726
|
+
return `Sent: the staging server stored ${plural(result.stored + result.duplicates, 'event')}${before}.${refused}${waiting}`;
|
|
727
|
+
}
|
|
728
|
+
case 'closed':
|
|
729
|
+
return `Not sent: the staging server is not taking diagnostics now; the test round may be over.${result.until ? ` KitCode tries again after ${result.until}.` : ''}${sentSoFar}${waiting}`;
|
|
730
|
+
case 'rate-limited':
|
|
731
|
+
return `Not sent: the staging server asked KitCode to wait ${plural(result.retryAfterSeconds, 'second')}.${sentSoFar}${waiting}`;
|
|
732
|
+
case 'refused':
|
|
733
|
+
return `Not sent: the staging server refused the batch (HTTP ${result.status}), so its ${plural(result.dropped, 'event')} ${result.dropped === 1 ? 'is' : 'are'} dropped: sending ${result.dropped === 1 ? 'it' : 'them'} again would not help.${sentSoFar}${waiting}`;
|
|
734
|
+
default: {
|
|
735
|
+
const why = {
|
|
736
|
+
unreachable: 'the staging server could not be reached',
|
|
737
|
+
timeout: 'the staging server did not answer in time',
|
|
738
|
+
'server-error': `the staging server failed (HTTP ${result.status})`,
|
|
739
|
+
'not-found': 'the staging server has no place for diagnostics (HTTP 404)',
|
|
740
|
+
'not-let-through': `the staging site did not let it through to its server (HTTP ${result.status})`,
|
|
741
|
+
'bad-answer': 'the staging server\'s answer could not be read',
|
|
742
|
+
}[result.reason] ?? 'something went wrong on this computer';
|
|
743
|
+
return `Not sent: ${why}.${sentSoFar}${waiting}`;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// What `kitcode diagnostics status` and the dashboard show: whether this build sends, where to, what waits, the last send.
|
|
749
|
+
export function diagnosticsStatus(now = new Date()) {
|
|
750
|
+
const destination = diagnosticsDestination();
|
|
751
|
+
|
|
752
|
+
if (destination === null) {
|
|
753
|
+
return {enabled: false};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
let installId = null;
|
|
757
|
+
|
|
758
|
+
try {
|
|
759
|
+
installId = readInstallId();
|
|
760
|
+
} catch {
|
|
761
|
+
// Shown as none: the bundle route says what is wrong with the file.
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
enabled: true,
|
|
766
|
+
destination: destination.origin,
|
|
767
|
+
installId,
|
|
768
|
+
invitedEmail: readInvitedEmail(),
|
|
769
|
+
queued: queuedNames().length,
|
|
770
|
+
closedUntil: closedUntil(now)?.toISOString() ?? null,
|
|
771
|
+
lastSend: readLastSend(),
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// --- The tracker's loop -----------------------------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
// While the tracker runs: every `intervalMs`, the hook's markers swept and the queue sent, a batch at a time while there is
|
|
778
|
+
// more, backing off after a failure (from a minute to an hour, with full jitter), and waiting as the server asks; every
|
|
779
|
+
// `heartbeatMs`, a heartbeat. `snapshot()` says how the tracker is doing, for the heartbeat. Nothing here can stop, block or
|
|
780
|
+
// slow the tracker: each round runs alone, its send has its own timeout, and every failure stays here.
|
|
781
|
+
export function startDiagnosticsLoop({snapshot, intervalMs = 60_000, heartbeatMs = 60 * 60 * 1000, fetchImpl = globalThis.fetch, now = () => new Date(), random = Math.random} = {}) {
|
|
782
|
+
if (!diagnosticsEnabled()) {
|
|
783
|
+
return {stop: async () => {}, sendNow: async () => ({outcome: 'disabled'})};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const startedAt = now();
|
|
787
|
+
let failures = 0;
|
|
788
|
+
let nextAttemptAt = 0;
|
|
789
|
+
let sending = null;
|
|
790
|
+
let stopped = false;
|
|
791
|
+
|
|
792
|
+
const heartbeat = () => {
|
|
793
|
+
try {
|
|
794
|
+
const status = snapshot?.() ?? {projects: 0, tracking: 'ok', problems: []};
|
|
795
|
+
recordEvent('tracker-heartbeat', {
|
|
796
|
+
uptimeSeconds: Math.max(0, Math.floor((now().getTime() - startedAt.getTime()) / 1000)),
|
|
797
|
+
projects: status.projects,
|
|
798
|
+
tracking: status.tracking,
|
|
799
|
+
problems: [...new Set(status.problems)].slice(0, 5),
|
|
800
|
+
}, {source: 'tracker'});
|
|
801
|
+
} catch {
|
|
802
|
+
// The next one is an hour away.
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
const send = async ({manual = false, timeoutMs = 10_000, maxBatches = 1} = {}) => {
|
|
807
|
+
if (sending !== null) {
|
|
808
|
+
await sending.catch(() => {});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const attempt = (async () => {
|
|
812
|
+
sweepHookMarkers({source: 'tracker'});
|
|
813
|
+
const result = await sendQueued({timeoutMs, maxBatches, manual, fetchImpl, now, source: 'tracker'});
|
|
814
|
+
|
|
815
|
+
if (result.outcome === 'failed') {
|
|
816
|
+
failures += 1;
|
|
817
|
+
const ceiling = Math.min(60 * 60 * 1000, intervalMs * 2 ** Math.min(failures, 10));
|
|
818
|
+
nextAttemptAt = now().getTime() + Math.floor(random() * ceiling);
|
|
819
|
+
} else if (result.outcome === 'rate-limited') {
|
|
820
|
+
nextAttemptAt = now().getTime() + result.retryAfterSeconds * 1000;
|
|
821
|
+
} else {
|
|
822
|
+
failures = 0;
|
|
823
|
+
nextAttemptAt = 0;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return result;
|
|
827
|
+
})();
|
|
828
|
+
|
|
829
|
+
sending = attempt;
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
return await attempt;
|
|
833
|
+
} finally {
|
|
834
|
+
if (sending === attempt) {
|
|
835
|
+
sending = null;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
const round = () => {
|
|
841
|
+
if (stopped || sending !== null || now().getTime() < nextAttemptAt) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
send().catch(() => {});
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const interval = setInterval(round, intervalMs);
|
|
849
|
+
const beats = setInterval(heartbeat, heartbeatMs);
|
|
850
|
+
interval.unref?.();
|
|
851
|
+
beats.unref?.();
|
|
852
|
+
|
|
853
|
+
return {
|
|
854
|
+
// The tester's Send logs now: every waiting event, past any backoff, a batch at a time, up to five.
|
|
855
|
+
sendNow: () => send({manual: true, maxBatches: 5}),
|
|
856
|
+
// As the tracker stops: one last batch, whatever the backoff says, so that its stop reaches the server. 5 s to answer,
|
|
857
|
+
// well inside the 10 s `untrack` waits for the tracker to end; one not answered by then is sent by the next send.
|
|
858
|
+
async stop({finalSend = true, timeoutMs = 5000} = {}) {
|
|
859
|
+
stopped = true;
|
|
860
|
+
clearInterval(interval);
|
|
861
|
+
clearInterval(beats);
|
|
862
|
+
|
|
863
|
+
if (finalSend) {
|
|
864
|
+
try {
|
|
865
|
+
await send({timeoutMs, maxBatches: 1});
|
|
866
|
+
} catch {
|
|
867
|
+
// Sent by the next process that sends.
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
}
|