claude-spotter 1.4.21 → 1.4.23
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 +26 -1
- package/README.ja.md +1 -1
- package/README.md +37 -3
- package/bin/spotter.mjs +15 -0
- package/package.json +2 -2
- package/src/cli/codex-hook-cmd.mjs +21 -2
- package/src/cli/daemon-cmd.mjs +7 -1
- package/src/cli/diagnostics-cmd.mjs +98 -1
- package/src/cli/factory-diagnostics.mjs +178 -0
- package/src/core/runtime-error-store-worker.mjs +51 -0
- package/src/core/runtime-error-store.mjs +936 -0
- package/src/daemon/daemon.mjs +67 -19
- package/src/index.mjs +17 -0
|
@@ -0,0 +1,936 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
constants,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
rename,
|
|
10
|
+
rm,
|
|
11
|
+
} from 'node:fs/promises';
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { homedir, platform as currentPlatform, arch as currentArch } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { version } from '../version.mjs';
|
|
18
|
+
|
|
19
|
+
export const RUNTIME_ERROR_STORE_SCHEMA = 'spotter.runtime_errors.v1';
|
|
20
|
+
export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
|
|
21
|
+
export const RUNTIME_ERROR_STORE_FAILURE_DIAGNOSTIC = 'spotter-runtime-errors: local aggregate store unavailable\n';
|
|
22
|
+
|
|
23
|
+
export const RUNTIME_ERROR_DEFINITIONS = deepFreeze({
|
|
24
|
+
daemon_transport: {
|
|
25
|
+
component: 'daemon_transport',
|
|
26
|
+
errorCode: 'SPOTTER.DAEMON.TRANSPORT',
|
|
27
|
+
messageTemplate: 'Spotter daemon transport failed',
|
|
28
|
+
severity: 'high',
|
|
29
|
+
},
|
|
30
|
+
daemon_persistence: {
|
|
31
|
+
component: 'daemon_persistence',
|
|
32
|
+
errorCode: 'SPOTTER.DAEMON.PERSISTENCE',
|
|
33
|
+
messageTemplate: 'Spotter daemon state persistence failed',
|
|
34
|
+
severity: 'high',
|
|
35
|
+
},
|
|
36
|
+
auditor_unavailable: {
|
|
37
|
+
component: 'auditor',
|
|
38
|
+
errorCode: 'SPOTTER.AUDITOR.UNAVAILABLE',
|
|
39
|
+
messageTemplate: 'Spotter auditor backend was unavailable',
|
|
40
|
+
severity: 'warn',
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const CONFIG_TOP_KEYS = new Set(['schema_version', 'host', 'collection', 'reporting']);
|
|
45
|
+
const HOST_KEYS = new Set(['id', 'profile']);
|
|
46
|
+
const COLLECTION_KEYS = new Set(['enabled']);
|
|
47
|
+
const REPORTING_KEYS = new Set(['enabled', 'endpoint', 'credential_file']);
|
|
48
|
+
const HOST_PROFILES = new Set(['server', 'mac', 'wsl', 'windows-native']);
|
|
49
|
+
const SEVERITIES = new Set(['fatal', 'high', 'warn', 'info']);
|
|
50
|
+
const STATUS_VALUES = new Set(['open', 'resolved']);
|
|
51
|
+
const MAX_SNAPSHOT_LIMIT = 500;
|
|
52
|
+
const MAX_RECEIPTS = 1_024;
|
|
53
|
+
// A cold Node worker plus its independent receipt reconciler must both fit in
|
|
54
|
+
// this budget under the parallel full-suite/CI load. 500 ms was below that
|
|
55
|
+
// measured boundary and caused valid committed receipts to be reported as
|
|
56
|
+
// unavailable. The observer remains absolutely bounded. Receipt reconciliation
|
|
57
|
+
// is prewarmed in parallel, so its cold-start cost is not paid after timeout.
|
|
58
|
+
const DEFAULT_ISOLATED_TIMEOUT_MS = 1_500;
|
|
59
|
+
const WINDOWS_DEFAULT_ISOLATED_TIMEOUT_MS = 5_000;
|
|
60
|
+
const RUNTIME_ERROR_WORKER = fileURLToPath(new URL('./runtime-error-store-worker.mjs', import.meta.url));
|
|
61
|
+
const pathQueues = new Map();
|
|
62
|
+
|
|
63
|
+
export function defaultFactoryReporterConfigPath({
|
|
64
|
+
env = process.env,
|
|
65
|
+
platform = process.platform,
|
|
66
|
+
homeDir = homedir(),
|
|
67
|
+
} = {}) {
|
|
68
|
+
if (platform === 'win32') {
|
|
69
|
+
const base = env.LOCALAPPDATA;
|
|
70
|
+
if (typeof base !== 'string' || base.length === 0) return null;
|
|
71
|
+
return join(base, 'dotagents', 'factory-reporter', 'config.json');
|
|
72
|
+
}
|
|
73
|
+
const base = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.length > 0
|
|
74
|
+
? env.XDG_CONFIG_HOME
|
|
75
|
+
: join(homeDir, '.config');
|
|
76
|
+
return join(base, 'dotagents', 'factory-reporter.json');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function defaultRuntimeErrorStorePath({
|
|
80
|
+
env = process.env,
|
|
81
|
+
platform = process.platform,
|
|
82
|
+
homeDir = homedir(),
|
|
83
|
+
} = {}) {
|
|
84
|
+
if (platform === 'win32') {
|
|
85
|
+
const base = typeof env.LOCALAPPDATA === 'string' && env.LOCALAPPDATA.length > 0
|
|
86
|
+
? env.LOCALAPPDATA
|
|
87
|
+
: homeDir;
|
|
88
|
+
return join(base, 'Spotter', 'runtime-errors-v1.json');
|
|
89
|
+
}
|
|
90
|
+
return join(homeDir, '.spotter', 'runtime-errors-v1.json');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function runtimeErrorFingerprint(definition) {
|
|
94
|
+
validateDefinition(definition);
|
|
95
|
+
const canonical = [
|
|
96
|
+
'factory-v1',
|
|
97
|
+
'spotter',
|
|
98
|
+
definition.component,
|
|
99
|
+
definition.errorCode,
|
|
100
|
+
definition.messageTemplate,
|
|
101
|
+
].join('\n');
|
|
102
|
+
return createHash('sha256').update(canonical, 'utf8').digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function readRuntimeCollectionMode(options = {}) {
|
|
106
|
+
const configPath = options.configPath ?? defaultFactoryReporterConfigPath(options);
|
|
107
|
+
if (!configPath) return { mode: 'config_missing', enabled: false };
|
|
108
|
+
let raw;
|
|
109
|
+
try {
|
|
110
|
+
raw = decodeUtf8(await readPrivateFile(configPath, options, { enforceWindowsAcl: false }));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return error?.code === 'ENOENT'
|
|
113
|
+
? { mode: 'config_missing', enabled: false }
|
|
114
|
+
: { mode: 'config_malformed', enabled: false };
|
|
115
|
+
}
|
|
116
|
+
let value;
|
|
117
|
+
try {
|
|
118
|
+
value = JSON.parse(raw);
|
|
119
|
+
} catch {
|
|
120
|
+
return { mode: 'config_malformed', enabled: false };
|
|
121
|
+
}
|
|
122
|
+
if (!isCanonicalReporterConfig(value)) return { mode: 'config_malformed', enabled: false };
|
|
123
|
+
return value.collection.enabled === true
|
|
124
|
+
? { mode: 'enabled', enabled: true }
|
|
125
|
+
: { mode: 'disabled', enabled: false };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function observeRuntimeError(input, options = {}) {
|
|
129
|
+
const kind = validateObservationInput(input);
|
|
130
|
+
const observationId = options.observationId === undefined
|
|
131
|
+
? null
|
|
132
|
+
: validateObservationId(options.observationId);
|
|
133
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
134
|
+
if (!collection.enabled) return { collected: false, reason: collection.mode };
|
|
135
|
+
const definition = RUNTIME_ERROR_DEFINITIONS[kind];
|
|
136
|
+
const fingerprint = runtimeErrorFingerprint(definition);
|
|
137
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
138
|
+
const result = await mutateStore(storePath, options, (store) => {
|
|
139
|
+
const receipt = observationId
|
|
140
|
+
? store.receipts.find((candidate) => candidate.id === observationId)
|
|
141
|
+
: null;
|
|
142
|
+
if (receipt) {
|
|
143
|
+
if (receipt.fingerprint !== fingerprint) {
|
|
144
|
+
throw storeError('runtime error observation id conflicts with another fingerprint');
|
|
145
|
+
}
|
|
146
|
+
const existing = store.records.find((record) => record.fingerprint === fingerprint);
|
|
147
|
+
return { collected: true, fingerprint, sequence: existing?.sequence ?? null, duplicate: true };
|
|
148
|
+
}
|
|
149
|
+
const timestamp = nowIso(options.now);
|
|
150
|
+
const existing = store.records.find((record) => record.fingerprint === fingerprint);
|
|
151
|
+
const sequence = store.next_sequence++;
|
|
152
|
+
if (existing) {
|
|
153
|
+
existing.product_version = validateProductVersion(options.productVersion ?? version);
|
|
154
|
+
existing.occurrence_count += 1;
|
|
155
|
+
existing.last_seen = timestamp;
|
|
156
|
+
existing.status = 'open';
|
|
157
|
+
existing.resolved_at = null;
|
|
158
|
+
existing.reason_code = null;
|
|
159
|
+
existing.sequence = sequence;
|
|
160
|
+
} else {
|
|
161
|
+
store.records.push({
|
|
162
|
+
product: 'spotter',
|
|
163
|
+
product_version: validateProductVersion(options.productVersion ?? version),
|
|
164
|
+
component: definition.component,
|
|
165
|
+
error_code: definition.errorCode,
|
|
166
|
+
message_template: definition.messageTemplate,
|
|
167
|
+
severity: definition.severity,
|
|
168
|
+
fingerprint,
|
|
169
|
+
occurrence_count: 1,
|
|
170
|
+
first_seen: timestamp,
|
|
171
|
+
last_seen: timestamp,
|
|
172
|
+
state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
|
|
173
|
+
os: validatePlatform(options.platform ?? currentPlatform()),
|
|
174
|
+
arch: validateArch(options.arch ?? currentArch()),
|
|
175
|
+
status: 'open',
|
|
176
|
+
resolved_at: null,
|
|
177
|
+
reason_code: null,
|
|
178
|
+
sequence,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (observationId) {
|
|
182
|
+
store.receipts.push({ id: observationId, fingerprint });
|
|
183
|
+
if (store.receipts.length > MAX_RECEIPTS) store.receipts.splice(0, store.receipts.length - MAX_RECEIPTS);
|
|
184
|
+
}
|
|
185
|
+
return { collected: true, fingerprint, sequence };
|
|
186
|
+
});
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function observeRuntimeErrorSafe(input, options = {}) {
|
|
191
|
+
try {
|
|
192
|
+
return await observeRuntimeError(input, options);
|
|
193
|
+
} catch {
|
|
194
|
+
try {
|
|
195
|
+
(options.writeError ?? ((text) => process.stderr.write(text)))(RUNTIME_ERROR_STORE_FAILURE_DIAGNOSTIC);
|
|
196
|
+
} catch {
|
|
197
|
+
// Runtime error reporting must never stop Spotter, including when stderr is unavailable.
|
|
198
|
+
}
|
|
199
|
+
return { collected: false, reason: 'store_unavailable' };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function observeRuntimeErrorIsolatedSafe(input, options = {}) {
|
|
204
|
+
let kind;
|
|
205
|
+
try {
|
|
206
|
+
kind = validateObservationInput(input);
|
|
207
|
+
} catch {
|
|
208
|
+
return emitFixedStoreFailure(options);
|
|
209
|
+
}
|
|
210
|
+
const platform = options.platform ?? process.platform;
|
|
211
|
+
const timeoutMs = options.timeoutMs ?? (platform === 'win32'
|
|
212
|
+
? WINDOWS_DEFAULT_ISOLATED_TIMEOUT_MS
|
|
213
|
+
: DEFAULT_ISOLATED_TIMEOUT_MS);
|
|
214
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 10 || timeoutMs > 10_000) {
|
|
215
|
+
return emitFixedStoreFailure(options);
|
|
216
|
+
}
|
|
217
|
+
const observationId = randomUUID().replaceAll('-', '');
|
|
218
|
+
const expectedFingerprint = runtimeErrorFingerprint(RUNTIME_ERROR_DEFINITIONS[kind]);
|
|
219
|
+
const workerOptions = {
|
|
220
|
+
configPath: options.configPath ?? defaultFactoryReporterConfigPath(options),
|
|
221
|
+
storePath: options.storePath ?? defaultRuntimeErrorStorePath(options),
|
|
222
|
+
productVersion: options.productVersion ?? version,
|
|
223
|
+
platform: options.platform ?? process.platform,
|
|
224
|
+
arch: options.arch ?? process.arch,
|
|
225
|
+
beforeOpenDelayMs: options.beforeOpenDelayMs,
|
|
226
|
+
observationId,
|
|
227
|
+
};
|
|
228
|
+
const encoded = Buffer.from(JSON.stringify(workerOptions), 'utf8').toString('base64url');
|
|
229
|
+
const deadline = Date.now() + timeoutMs;
|
|
230
|
+
const reconciliationReserveMs = Math.min(750, Math.max(100, Math.floor(timeoutMs * 0.4)));
|
|
231
|
+
const observeBudgetMs = Math.max(1, timeoutMs - reconciliationReserveMs);
|
|
232
|
+
const workerPath = options.workerPath ?? RUNTIME_ERROR_WORKER;
|
|
233
|
+
const reconciliationOptions = { ...workerOptions, expectedFingerprint, waitMs: timeoutMs };
|
|
234
|
+
delete reconciliationOptions.beforeOpenDelayMs;
|
|
235
|
+
const reconciliationEncoded = Buffer.from(JSON.stringify(reconciliationOptions), 'utf8').toString('base64url');
|
|
236
|
+
const reconciliationController = new AbortController();
|
|
237
|
+
const reconciliation = runRuntimeWorker(
|
|
238
|
+
options.reconciliationWorkerPath ?? RUNTIME_ERROR_WORKER,
|
|
239
|
+
['receipt', observationId, reconciliationEncoded],
|
|
240
|
+
timeoutMs,
|
|
241
|
+
reconciliationController.signal,
|
|
242
|
+
);
|
|
243
|
+
const observed = await runRuntimeWorker(workerPath, ['observe', kind, encoded], observeBudgetMs);
|
|
244
|
+
if (observed.kind === 'exit' && observed.code === 0) { reconciliationController.abort(); await reconciliation; return { collected: true }; }
|
|
245
|
+
if (observed.kind === 'exit' && observed.code === 10) { reconciliationController.abort(); await reconciliation; return { collected: false, reason: 'collection_disabled' }; }
|
|
246
|
+
|
|
247
|
+
const remainingMs = deadline - Date.now();
|
|
248
|
+
if (remainingMs > 0) {
|
|
249
|
+
const reconciled = await reconciliation;
|
|
250
|
+
if (reconciled.kind === 'exit' && reconciled.code === 0) return { collected: true };
|
|
251
|
+
}
|
|
252
|
+
reconciliationController.abort(); await reconciliation;
|
|
253
|
+
return emitFixedStoreFailure(options);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function runRuntimeWorker(workerPath, args, timeoutMs, signal) {
|
|
257
|
+
return new Promise((resolve) => {
|
|
258
|
+
let settled = false;
|
|
259
|
+
let stopping = false;
|
|
260
|
+
let child;
|
|
261
|
+
const finish = (result) => {
|
|
262
|
+
if (settled) return;
|
|
263
|
+
settled = true;
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
signal?.removeEventListener('abort', abort);
|
|
266
|
+
resolve(result);
|
|
267
|
+
};
|
|
268
|
+
const stop = (kind) => { if (stopping || settled) return; stopping = true; void killWorkerTree(child).then(() => finish({ kind })); };
|
|
269
|
+
const abort = () => stop('cancelled');
|
|
270
|
+
const killGraceMs = process.platform === 'win32' ? 250 : 0;
|
|
271
|
+
const timer = setTimeout(() => stop('timeout'), Math.max(1, timeoutMs - killGraceMs));
|
|
272
|
+
try {
|
|
273
|
+
child = spawn(process.execPath, [workerPath, ...args], {
|
|
274
|
+
stdio: 'ignore',
|
|
275
|
+
windowsHide: true,
|
|
276
|
+
detached: process.platform !== 'win32',
|
|
277
|
+
env: { ...process.env, SPOTTER_RUNTIME_ERROR_WORKER: '1' },
|
|
278
|
+
});
|
|
279
|
+
} catch {
|
|
280
|
+
finish({ kind: 'error' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
child.once('error', () => finish({ kind: 'error' }));
|
|
284
|
+
child.once('close', (code) => { if (!stopping) finish({ kind: 'exit', code }); });
|
|
285
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
286
|
+
if (signal?.aborted) abort();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function killWorkerTree(child) {
|
|
291
|
+
if (!Number.isSafeInteger(child?.pid) || child.pid <= 0) return Promise.resolve();
|
|
292
|
+
if (process.platform === 'win32') {
|
|
293
|
+
return new Promise((resolve) => {
|
|
294
|
+
let done = false; let fallback; const finish = () => { if (done) return; done = true; clearTimeout(fallback); resolve(); };
|
|
295
|
+
let killer; try { killer = spawn('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true }); } catch { child.kill('SIGKILL'); finish(); return; }
|
|
296
|
+
fallback = setTimeout(() => { child.kill('SIGKILL'); finish(); }, 225);
|
|
297
|
+
killer.once('error', () => { child.kill('SIGKILL'); finish(); });
|
|
298
|
+
killer.once('close', finish);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
303
|
+
} catch {
|
|
304
|
+
child.kill('SIGKILL');
|
|
305
|
+
}
|
|
306
|
+
return Promise.resolve();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function hasRuntimeErrorReceipt(observationId, expectedFingerprint, options = {}) {
|
|
310
|
+
const receipt = validateObservationId(observationId);
|
|
311
|
+
const fingerprint = validateFingerprint(expectedFingerprint);
|
|
312
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
313
|
+
if (!collection.enabled) return false;
|
|
314
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
315
|
+
const store = await readStore(storePath, options);
|
|
316
|
+
const found = store.receipts.find((candidate) => candidate.id === receipt);
|
|
317
|
+
if (!found) return false;
|
|
318
|
+
if (found.fingerprint !== fingerprint) {
|
|
319
|
+
throw storeError('runtime error receipt fingerprint conflict');
|
|
320
|
+
}
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function emitFixedStoreFailure(options) {
|
|
325
|
+
try {
|
|
326
|
+
(options.writeError ?? ((text) => process.stderr.write(text)))(RUNTIME_ERROR_STORE_FAILURE_DIAGNOSTIC);
|
|
327
|
+
} catch {
|
|
328
|
+
// The runtime observer remains non-blocking even when stderr is unavailable.
|
|
329
|
+
}
|
|
330
|
+
return { collected: false, reason: 'store_unavailable' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function readRuntimeErrorSnapshot(options = {}) {
|
|
334
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
335
|
+
const afterCursor = validateCursor(options.afterCursor ?? 0, 'afterCursor');
|
|
336
|
+
const limit = validateLimit(options.limit ?? 100);
|
|
337
|
+
if (!collection.enabled) return emptySnapshot(collection.mode, afterCursor);
|
|
338
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
339
|
+
const store = await readStore(storePath, options);
|
|
340
|
+
const records = store.records
|
|
341
|
+
.filter((record) => record.sequence > afterCursor)
|
|
342
|
+
.sort((a, b) => a.sequence - b.sequence);
|
|
343
|
+
const selected = records.slice(0, limit).map(copyRecord);
|
|
344
|
+
return {
|
|
345
|
+
schema: RUNTIME_ERROR_STORE_SCHEMA,
|
|
346
|
+
collection: 'enabled',
|
|
347
|
+
records: selected,
|
|
348
|
+
after_cursor: afterCursor,
|
|
349
|
+
next_cursor: selected.length > 0 ? selected.at(-1).sequence : afterCursor,
|
|
350
|
+
latest_sequence: store.next_sequence - 1,
|
|
351
|
+
acknowledged_through: store.acknowledged_through,
|
|
352
|
+
has_more: records.length > selected.length,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function acknowledgeRuntimeErrors(input, options = {}) {
|
|
357
|
+
assertExactObject(input, new Set(['cursor']));
|
|
358
|
+
const cursor = validateCursor(input.cursor, 'cursor');
|
|
359
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
360
|
+
if (!collection.enabled) return { acknowledged: false, reason: collection.mode, acknowledged_through: 0 };
|
|
361
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
362
|
+
return mutateStore(storePath, options, (store) => {
|
|
363
|
+
const latest = store.next_sequence - 1;
|
|
364
|
+
if (cursor > latest) throw inputError('cursor exceeds latest sequence');
|
|
365
|
+
store.acknowledged_through = Math.max(store.acknowledged_through, cursor);
|
|
366
|
+
return { acknowledged: true, acknowledged_through: store.acknowledged_through };
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export async function resolveRuntimeError(input, options = {}) {
|
|
371
|
+
return transitionRuntimeError(input, 'resolved', options);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function reopenRuntimeError(input, options = {}) {
|
|
375
|
+
return transitionRuntimeError(input, 'open', options);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function transitionRuntimeError(input, status, options) {
|
|
379
|
+
assertExactObject(input, new Set(['fingerprint']));
|
|
380
|
+
const fingerprint = validateFingerprint(input.fingerprint);
|
|
381
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
382
|
+
if (!collection.enabled) return { changed: false, reason: collection.mode };
|
|
383
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
384
|
+
return mutateStore(storePath, options, (store) => {
|
|
385
|
+
const record = store.records.find((item) => item.fingerprint === fingerprint);
|
|
386
|
+
if (!record || record.status === status) return { changed: false, status: record?.status ?? null };
|
|
387
|
+
const resolvedAt = status === 'resolved' ? nowIso(options.now) : null;
|
|
388
|
+
if (resolvedAt !== null && resolvedAt < record.last_seen) {
|
|
389
|
+
throw inputError('resolution timestamp precedes last observation');
|
|
390
|
+
}
|
|
391
|
+
record.status = status;
|
|
392
|
+
record.resolved_at = resolvedAt;
|
|
393
|
+
record.reason_code = status === 'resolved' ? 'operator_resolved' : null;
|
|
394
|
+
record.sequence = store.next_sequence++;
|
|
395
|
+
return { changed: true, status, sequence: record.sequence };
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function compactRuntimeErrors(options = {}) {
|
|
400
|
+
const maxResolvedRecords = options.maxResolvedRecords ?? 100;
|
|
401
|
+
if (!Number.isSafeInteger(maxResolvedRecords) || maxResolvedRecords < 0 || maxResolvedRecords > 10_000) {
|
|
402
|
+
throw inputError('maxResolvedRecords must be an integer between 0 and 10000');
|
|
403
|
+
}
|
|
404
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
405
|
+
if (!collection.enabled) return { compacted: false, reason: collection.mode, removed: 0 };
|
|
406
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
407
|
+
return mutateStore(storePath, options, (store) => {
|
|
408
|
+
const compactable = store.records
|
|
409
|
+
.filter((record) => record.status === 'resolved' && record.sequence <= store.acknowledged_through)
|
|
410
|
+
.sort((a, b) => b.sequence - a.sequence);
|
|
411
|
+
const keep = new Set(compactable.slice(0, maxResolvedRecords).map((record) => record.fingerprint));
|
|
412
|
+
const before = store.records.length;
|
|
413
|
+
store.records = store.records.filter((record) => (
|
|
414
|
+
record.status !== 'resolved'
|
|
415
|
+
|| record.sequence > store.acknowledged_through
|
|
416
|
+
|| keep.has(record.fingerprint)
|
|
417
|
+
));
|
|
418
|
+
return { compacted: true, removed: before - store.records.length };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function readRuntimeErrorStoreStatus(options = {}) {
|
|
423
|
+
const collection = await readRuntimeCollectionMode(options);
|
|
424
|
+
const base = {
|
|
425
|
+
schema: 'spotter.runtime_error_status.v1',
|
|
426
|
+
collection: collection.mode,
|
|
427
|
+
store: collection.enabled ? 'absent' : 'not_accessed',
|
|
428
|
+
records: 0,
|
|
429
|
+
open: 0,
|
|
430
|
+
resolved: 0,
|
|
431
|
+
unacknowledged: 0,
|
|
432
|
+
latest_sequence: 0,
|
|
433
|
+
acknowledged_through: 0,
|
|
434
|
+
};
|
|
435
|
+
if (!collection.enabled) return base;
|
|
436
|
+
const storePath = options.storePath ?? defaultRuntimeErrorStorePath(options);
|
|
437
|
+
let store;
|
|
438
|
+
try {
|
|
439
|
+
store = await readStore(storePath, options, { missingOk: false });
|
|
440
|
+
} catch (error) {
|
|
441
|
+
base.store = error?.code === 'ENOENT' ? 'absent' : 'unavailable';
|
|
442
|
+
return base;
|
|
443
|
+
}
|
|
444
|
+
base.store = 'available';
|
|
445
|
+
base.records = store.records.length;
|
|
446
|
+
base.open = store.records.filter((record) => record.status === 'open').length;
|
|
447
|
+
base.resolved = store.records.filter((record) => record.status === 'resolved').length;
|
|
448
|
+
base.latest_sequence = store.next_sequence - 1;
|
|
449
|
+
base.acknowledged_through = store.acknowledged_through;
|
|
450
|
+
base.unacknowledged = store.records.filter((record) => record.sequence > store.acknowledged_through).length;
|
|
451
|
+
return base;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function mutateStore(storePath, options, mutate) {
|
|
455
|
+
return enqueue(storePath, async () => {
|
|
456
|
+
const directory = dirname(storePath);
|
|
457
|
+
await ensurePrivateDirectory(directory, options);
|
|
458
|
+
const lockPath = runtimeErrorLockPath(storePath);
|
|
459
|
+
return withStoreLock(lockPath, options, async () => {
|
|
460
|
+
const store = await readStore(storePath, options);
|
|
461
|
+
const result = mutate(store);
|
|
462
|
+
validateStore(store);
|
|
463
|
+
const atomicWriteFn = options.atomicWriteFn ?? atomicWriteStore;
|
|
464
|
+
await atomicWriteFn(storePath, `${JSON.stringify(store)}\n`, options);
|
|
465
|
+
return result;
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export function runtimeErrorLockPath(storePath) {
|
|
471
|
+
return `${storePath}.lock.sqlite`;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function withStoreLock(lockPath, options, operation) {
|
|
475
|
+
await ensurePrivateLockFile(lockPath, options);
|
|
476
|
+
const database = new DatabaseSync(lockPath);
|
|
477
|
+
let active = false;
|
|
478
|
+
try {
|
|
479
|
+
await enforcePrivatePath(lockPath, options, { directory: false, repair: false });
|
|
480
|
+
database.exec('PRAGMA busy_timeout=10000; PRAGMA synchronous=FULL; BEGIN IMMEDIATE');
|
|
481
|
+
active = true;
|
|
482
|
+
const result = await operation();
|
|
483
|
+
database.exec('COMMIT');
|
|
484
|
+
active = false;
|
|
485
|
+
return result;
|
|
486
|
+
} finally {
|
|
487
|
+
if (active) { try { database.exec('ROLLBACK'); } catch {} }
|
|
488
|
+
database.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function ensurePrivateLockFile(lockPath, options) {
|
|
493
|
+
let handle;
|
|
494
|
+
try {
|
|
495
|
+
handle = await open(lockPath, 'wx', 0o600);
|
|
496
|
+
await handle.sync();
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
499
|
+
} finally {
|
|
500
|
+
await handle?.close().catch(() => {});
|
|
501
|
+
}
|
|
502
|
+
await enforcePrivatePath(lockPath, options, {
|
|
503
|
+
directory: false,
|
|
504
|
+
repair: (options.platform ?? process.platform) === 'win32',
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function atomicWriteStore(storePath, bytes, options = {}) {
|
|
509
|
+
const temporary = `${storePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
510
|
+
let handle;
|
|
511
|
+
try {
|
|
512
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
513
|
+
await handle.writeFile(bytes, 'utf8');
|
|
514
|
+
await handle.sync();
|
|
515
|
+
await handle.close();
|
|
516
|
+
handle = null;
|
|
517
|
+
await rename(temporary, storePath);
|
|
518
|
+
await enforcePrivatePath(storePath, options, { directory: false, repair: true });
|
|
519
|
+
} catch (error) {
|
|
520
|
+
await handle?.close().catch(() => {});
|
|
521
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
522
|
+
throw error;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function readStore(storePath, options = {}, { missingOk = true } = {}) {
|
|
527
|
+
let raw;
|
|
528
|
+
try {
|
|
529
|
+
raw = decodeUtf8(await readPrivateFile(storePath, options, { enforceWindowsAcl: true }));
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (missingOk && error?.code === 'ENOENT') return emptyStore();
|
|
532
|
+
throw error;
|
|
533
|
+
}
|
|
534
|
+
let store;
|
|
535
|
+
try {
|
|
536
|
+
store = JSON.parse(raw);
|
|
537
|
+
} catch {
|
|
538
|
+
throw storeError('runtime error store is malformed');
|
|
539
|
+
}
|
|
540
|
+
validateStore(store);
|
|
541
|
+
return store;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function ensurePrivateDirectory(directory, options) {
|
|
545
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
546
|
+
await enforcePrivatePath(directory, options, { directory: true, repair: true });
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function readPrivateFile(path, options, { enforceWindowsAcl }) {
|
|
550
|
+
const platform = options.platform ?? process.platform;
|
|
551
|
+
if (enforceWindowsAcl) {
|
|
552
|
+
await enforcePrivatePath(dirname(path), options, { directory: true, repair: platform === 'win32' });
|
|
553
|
+
}
|
|
554
|
+
let info = await lstat(path);
|
|
555
|
+
validatePrivateStat(info, options, { directory: false, platform });
|
|
556
|
+
if (enforceWindowsAcl && platform === 'win32') {
|
|
557
|
+
await enforcePrivatePath(path, options, { directory: false, repair: true });
|
|
558
|
+
info = await lstat(path);
|
|
559
|
+
validatePrivateStat(info, options, { directory: false, platform });
|
|
560
|
+
}
|
|
561
|
+
if (Number.isSafeInteger(options.beforeOpenDelayMs) && options.beforeOpenDelayMs > 0) {
|
|
562
|
+
await new Promise((resolve) => setTimeout(resolve, options.beforeOpenDelayMs));
|
|
563
|
+
}
|
|
564
|
+
const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
|
|
565
|
+
const handle = await open(path, flags);
|
|
566
|
+
try {
|
|
567
|
+
const openedInfo = await handle.stat();
|
|
568
|
+
validatePrivateStat(openedInfo, options, { directory: false, platform });
|
|
569
|
+
return await handle.readFile();
|
|
570
|
+
} finally {
|
|
571
|
+
await handle.close();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function enforcePrivatePath(path, options, { directory, repair }) {
|
|
576
|
+
const platform = options.platform ?? process.platform;
|
|
577
|
+
if (platform === 'win32') {
|
|
578
|
+
if (repair) await runWindowsAcl(path, { directory }, options);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
if (repair) await chmod(path, directory ? 0o700 : 0o600);
|
|
582
|
+
const info = await lstat(path);
|
|
583
|
+
validatePrivateStat(info, options, { directory, platform });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function validatePrivateStat(info, options, { directory, platform }) {
|
|
587
|
+
if ((directory ? !info.isDirectory() : !info.isFile()) || info.isSymbolicLink()) {
|
|
588
|
+
throw storeError('runtime error path type is unsafe');
|
|
589
|
+
}
|
|
590
|
+
if (platform === 'win32') return;
|
|
591
|
+
const expectedMode = directory ? 0o700 : 0o600;
|
|
592
|
+
if ((info.mode & 0o777) !== expectedMode) throw storeError('runtime error path mode is unsafe');
|
|
593
|
+
const getuid = options.getuidFn ?? process.getuid?.bind(process);
|
|
594
|
+
if (typeof getuid === 'function' && info.uid !== getuid()) throw storeError('runtime error path owner is unsafe');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
export function buildWindowsAclPowerShell({ directory }) {
|
|
598
|
+
const securityType = directory ? 'DirectorySecurity' : 'FileSecurity';
|
|
599
|
+
const ioType = directory ? 'Directory' : 'File';
|
|
600
|
+
const inheritance = directory
|
|
601
|
+
? '[System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit'
|
|
602
|
+
: '[System.Security.AccessControl.InheritanceFlags]::None';
|
|
603
|
+
return [
|
|
604
|
+
'$ErrorActionPreference = "Stop"',
|
|
605
|
+
'$target = $env:SPOTTER_ACL_PATH',
|
|
606
|
+
'$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User',
|
|
607
|
+
`$acl = New-Object System.Security.AccessControl.${securityType}`,
|
|
608
|
+
'$acl.SetOwner($sid)',
|
|
609
|
+
'$acl.SetAccessRuleProtection($true, $false)',
|
|
610
|
+
`$inheritance = ${inheritance}`,
|
|
611
|
+
'$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, "FullControl", $inheritance, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)',
|
|
612
|
+
'$acl.AddAccessRule($rule)',
|
|
613
|
+
`[System.IO.${ioType}]::SetAccessControl($target, $acl)`,
|
|
614
|
+
`$readback = [System.IO.${ioType}]::GetAccessControl($target)`,
|
|
615
|
+
'$ownerSid = $readback.GetOwner([System.Security.Principal.SecurityIdentifier]).Value',
|
|
616
|
+
'$entries = @($readback.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))',
|
|
617
|
+
'$valid = $entries.Count -eq 1 -and $ownerSid -eq $sid.Value -and $entries[0].IdentityReference.Value -eq $sid.Value -and $entries[0].AccessControlType -eq [System.Security.AccessControl.AccessControlType]::Allow -and -not $entries[0].IsInherited -and (($entries[0].FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl)',
|
|
618
|
+
'if (-not $valid) { exit 7 }',
|
|
619
|
+
].join('; ');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async function runWindowsAcl(path, { directory }, options) {
|
|
623
|
+
const executable = options.powerShellPath ?? 'powershell.exe';
|
|
624
|
+
const script = buildWindowsAclPowerShell({ directory });
|
|
625
|
+
await spawnForExit(executable, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
626
|
+
timeoutMs: options.aclTimeoutMs ?? 3_000,
|
|
627
|
+
env: { ...process.env, SPOTTER_ACL_PATH: path },
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export async function processStartIdentity(pid, options = {}) {
|
|
632
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return null;
|
|
633
|
+
const platform = options.platform ?? process.platform;
|
|
634
|
+
try {
|
|
635
|
+
if (platform === 'linux') {
|
|
636
|
+
const raw = await readFile(`/proc/${pid}/stat`, 'utf8');
|
|
637
|
+
const close = raw.lastIndexOf(')');
|
|
638
|
+
if (close < 0) return null;
|
|
639
|
+
const fields = raw.slice(close + 2).trim().split(/\s+/);
|
|
640
|
+
return fields[19] ? `linux:${fields[19]}` : null;
|
|
641
|
+
}
|
|
642
|
+
if (platform === 'win32') {
|
|
643
|
+
const script = `$p = Get-Process -Id ${pid} -ErrorAction Stop; [Console]::Out.Write($p.StartTime.ToUniversalTime().Ticks)`;
|
|
644
|
+
const output = await spawnCapture(options.powerShellPath ?? 'powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { timeoutMs: 1_000 });
|
|
645
|
+
return output ? `windows:${output.trim()}` : null;
|
|
646
|
+
}
|
|
647
|
+
const output = await spawnCapture('/bin/ps', ['-o', 'lstart=', '-p', String(pid)], { timeoutMs: 1_000 });
|
|
648
|
+
return output.trim() ? `posix:${output.trim()}` : null;
|
|
649
|
+
} catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function spawnForExit(command, args, { timeoutMs, env = process.env }) {
|
|
655
|
+
return new Promise((resolve, reject) => {
|
|
656
|
+
let settled = false;
|
|
657
|
+
const child = spawn(command, args, { stdio: 'ignore', windowsHide: true, env });
|
|
658
|
+
const finish = (error) => {
|
|
659
|
+
if (settled) return;
|
|
660
|
+
settled = true;
|
|
661
|
+
clearTimeout(timer);
|
|
662
|
+
error ? reject(storeError('private ACL operation failed')) : resolve();
|
|
663
|
+
};
|
|
664
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
665
|
+
child.once('error', finish);
|
|
666
|
+
child.once('close', (code) => finish(code === 0 ? null : new Error('nonzero')));
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function spawnCapture(command, args, { timeoutMs }) {
|
|
671
|
+
return new Promise((resolve, reject) => {
|
|
672
|
+
let settled = false;
|
|
673
|
+
let stdout = '';
|
|
674
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
|
|
675
|
+
child.stdout.setEncoding('utf8');
|
|
676
|
+
child.stdout.on('data', (chunk) => {
|
|
677
|
+
if (stdout.length < 4_096) stdout += chunk.slice(0, 4_096 - stdout.length);
|
|
678
|
+
});
|
|
679
|
+
const finish = (error) => {
|
|
680
|
+
if (settled) return;
|
|
681
|
+
settled = true;
|
|
682
|
+
clearTimeout(timer);
|
|
683
|
+
error ? reject(error) : resolve(stdout);
|
|
684
|
+
};
|
|
685
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
|
|
686
|
+
child.once('error', finish);
|
|
687
|
+
child.once('close', (code) => finish(code === 0 ? null : new Error('process identity unavailable')));
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function emptyStore() {
|
|
692
|
+
return {
|
|
693
|
+
schema: RUNTIME_ERROR_STORE_SCHEMA,
|
|
694
|
+
state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
|
|
695
|
+
next_sequence: 1,
|
|
696
|
+
acknowledged_through: 0,
|
|
697
|
+
records: [],
|
|
698
|
+
receipts: [],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function validateStore(store) {
|
|
703
|
+
assertObject(store, 'store');
|
|
704
|
+
const keys = new Set(['schema', 'state_schema_version', 'next_sequence', 'acknowledged_through', 'records', 'receipts']);
|
|
705
|
+
if (!hasOnlyKeys(store, keys)
|
|
706
|
+
|| store.schema !== RUNTIME_ERROR_STORE_SCHEMA
|
|
707
|
+
|| store.state_schema_version !== RUNTIME_ERROR_STATE_SCHEMA_VERSION
|
|
708
|
+
|| !Number.isSafeInteger(store.next_sequence) || store.next_sequence < 1
|
|
709
|
+
|| !Number.isSafeInteger(store.acknowledged_through) || store.acknowledged_through < 0
|
|
710
|
+
|| store.acknowledged_through >= store.next_sequence
|
|
711
|
+
|| !Array.isArray(store.records)
|
|
712
|
+
|| !Array.isArray(store.receipts) || store.receipts.length > MAX_RECEIPTS) {
|
|
713
|
+
throw storeError('runtime error store schema mismatch');
|
|
714
|
+
}
|
|
715
|
+
const fingerprints = new Set();
|
|
716
|
+
const sequences = new Set();
|
|
717
|
+
for (const record of store.records) {
|
|
718
|
+
validateRecord(record, store.next_sequence);
|
|
719
|
+
if (fingerprints.has(record.fingerprint)) throw storeError('duplicate runtime error fingerprint');
|
|
720
|
+
if (sequences.has(record.sequence)) throw storeError('duplicate runtime error sequence');
|
|
721
|
+
fingerprints.add(record.fingerprint);
|
|
722
|
+
sequences.add(record.sequence);
|
|
723
|
+
}
|
|
724
|
+
const receipts = new Set();
|
|
725
|
+
for (const receipt of store.receipts) {
|
|
726
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|
|
727
|
+
|| Object.keys(receipt).length !== 2 || !Object.hasOwn(receipt, 'id') || !Object.hasOwn(receipt, 'fingerprint')
|
|
728
|
+
|| typeof receipt.id !== 'string' || !/^[a-f0-9]{32}$/.test(receipt.id)
|
|
729
|
+
|| typeof receipt.fingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.fingerprint)) {
|
|
730
|
+
throw storeError('invalid runtime error receipt');
|
|
731
|
+
}
|
|
732
|
+
if (!Object.values(RUNTIME_ERROR_DEFINITIONS).some((definition) => runtimeErrorFingerprint(definition) === receipt.fingerprint)) {
|
|
733
|
+
throw storeError('unknown runtime error receipt fingerprint');
|
|
734
|
+
}
|
|
735
|
+
if (receipts.has(receipt.id)) throw storeError('duplicate runtime error receipt');
|
|
736
|
+
receipts.add(receipt.id);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function validateRecord(record, nextSequence) {
|
|
741
|
+
const keys = new Set([
|
|
742
|
+
'product', 'product_version', 'component', 'error_code', 'message_template', 'severity',
|
|
743
|
+
'fingerprint', 'occurrence_count', 'first_seen', 'last_seen', 'state_schema_version',
|
|
744
|
+
'os', 'arch', 'status', 'resolved_at', 'reason_code', 'sequence',
|
|
745
|
+
]);
|
|
746
|
+
assertObject(record, 'record');
|
|
747
|
+
const definition = Object.values(RUNTIME_ERROR_DEFINITIONS).find((candidate) => (
|
|
748
|
+
candidate.component === record.component
|
|
749
|
+
&& candidate.errorCode === record.error_code
|
|
750
|
+
&& candidate.messageTemplate === record.message_template
|
|
751
|
+
&& candidate.severity === record.severity
|
|
752
|
+
));
|
|
753
|
+
if (!hasOnlyKeys(record, keys) || Object.keys(record).length !== keys.size
|
|
754
|
+
|| record.product !== 'spotter'
|
|
755
|
+
|| !definition
|
|
756
|
+
|| record.fingerprint !== runtimeErrorFingerprint(definition)
|
|
757
|
+
|| !Number.isSafeInteger(record.occurrence_count) || record.occurrence_count < 1
|
|
758
|
+
|| !validTimestamp(record.first_seen) || !validTimestamp(record.last_seen) || record.first_seen > record.last_seen
|
|
759
|
+
|| record.state_schema_version !== RUNTIME_ERROR_STATE_SCHEMA_VERSION
|
|
760
|
+
|| !STATUS_VALUES.has(record.status)
|
|
761
|
+
|| (record.status === 'open' && (record.resolved_at !== null || record.reason_code !== null))
|
|
762
|
+
|| (record.status === 'resolved' && (!validTimestamp(record.resolved_at)
|
|
763
|
+
|| record.resolved_at < record.last_seen || record.reason_code !== 'operator_resolved'))
|
|
764
|
+
|| !Number.isSafeInteger(record.sequence) || record.sequence < 1 || record.sequence >= nextSequence) {
|
|
765
|
+
throw storeError('runtime error record schema mismatch');
|
|
766
|
+
}
|
|
767
|
+
validateProductVersion(record.product_version);
|
|
768
|
+
validatePlatform(record.os);
|
|
769
|
+
validateArch(record.arch);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function validateObservationInput(input) {
|
|
773
|
+
if (typeof input === 'string') {
|
|
774
|
+
if (!Object.hasOwn(RUNTIME_ERROR_DEFINITIONS, input)) throw inputError('unknown runtime error kind');
|
|
775
|
+
return input;
|
|
776
|
+
}
|
|
777
|
+
assertExactObject(input, new Set(['kind']));
|
|
778
|
+
if (typeof input.kind !== 'string' || !Object.hasOwn(RUNTIME_ERROR_DEFINITIONS, input.kind)) {
|
|
779
|
+
throw inputError('unknown runtime error kind');
|
|
780
|
+
}
|
|
781
|
+
return input.kind;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function validateObservationId(value) {
|
|
785
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{32}$/.test(value)) {
|
|
786
|
+
throw inputError('invalid runtime error observation id');
|
|
787
|
+
}
|
|
788
|
+
return value;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function validateDefinition(value) {
|
|
792
|
+
assertObject(value, 'definition');
|
|
793
|
+
if (!validStableId(value.component)
|
|
794
|
+
|| typeof value.errorCode !== 'string'
|
|
795
|
+
|| typeof value.messageTemplate !== 'string'
|
|
796
|
+
|| !SEVERITIES.has(value.severity)) {
|
|
797
|
+
throw inputError('invalid runtime error definition');
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isCanonicalReporterConfig(value) {
|
|
802
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !hasOnlyKeys(value, CONFIG_TOP_KEYS)) return false;
|
|
803
|
+
if (Object.keys(value).length !== CONFIG_TOP_KEYS.size || value.schema_version !== '1.0') return false;
|
|
804
|
+
const { host, collection, reporting } = value;
|
|
805
|
+
if (!host || typeof host !== 'object' || Array.isArray(host) || !hasOnlyKeys(host, HOST_KEYS) || Object.keys(host).length !== 2) return false;
|
|
806
|
+
if (typeof host.id !== 'string' || !/^[a-z0-9][a-z0-9._-]*$/.test(host.id) || host.id.length > 64 || !HOST_PROFILES.has(host.profile)) return false;
|
|
807
|
+
if (!collection || typeof collection !== 'object' || Array.isArray(collection) || !hasOnlyKeys(collection, COLLECTION_KEYS) || Object.keys(collection).length !== 1 || typeof collection.enabled !== 'boolean') return false;
|
|
808
|
+
if (!reporting || typeof reporting !== 'object' || Array.isArray(reporting) || !hasOnlyKeys(reporting, REPORTING_KEYS) || typeof reporting.enabled !== 'boolean') return false;
|
|
809
|
+
if (reporting.enabled === true) {
|
|
810
|
+
if (!isCanonicalHttpEndpoint(reporting.endpoint)) return false;
|
|
811
|
+
if (typeof reporting.credential_file !== 'string' || reporting.credential_file.length < 1 || reporting.credential_file.length > 4096) return false;
|
|
812
|
+
}
|
|
813
|
+
if (reporting.endpoint !== undefined && !isCanonicalHttpEndpoint(reporting.endpoint)) return false;
|
|
814
|
+
if (reporting.credential_file !== undefined && (typeof reporting.credential_file !== 'string' || reporting.credential_file.length < 1 || reporting.credential_file.length > 4096)) return false;
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function isCanonicalHttpEndpoint(value) {
|
|
819
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > 2048) return false;
|
|
820
|
+
try {
|
|
821
|
+
const parsed = new URL(value);
|
|
822
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
823
|
+
&& parsed.username === ''
|
|
824
|
+
&& parsed.password === ''
|
|
825
|
+
&& parsed.hash === ''
|
|
826
|
+
&& parsed.href === value;
|
|
827
|
+
} catch {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function emptySnapshot(collection, afterCursor) {
|
|
833
|
+
return {
|
|
834
|
+
schema: RUNTIME_ERROR_STORE_SCHEMA,
|
|
835
|
+
collection,
|
|
836
|
+
records: [],
|
|
837
|
+
after_cursor: afterCursor,
|
|
838
|
+
next_cursor: afterCursor,
|
|
839
|
+
latest_sequence: 0,
|
|
840
|
+
acknowledged_through: 0,
|
|
841
|
+
has_more: false,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function copyRecord(record) {
|
|
846
|
+
return { ...record };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function validateCursor(value, name) {
|
|
850
|
+
if (!Number.isSafeInteger(value) || value < 0) throw inputError(`${name} must be a non-negative integer`);
|
|
851
|
+
return value;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function validateLimit(value) {
|
|
855
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > MAX_SNAPSHOT_LIMIT) {
|
|
856
|
+
throw inputError(`limit must be an integer between 1 and ${MAX_SNAPSHOT_LIMIT}`);
|
|
857
|
+
}
|
|
858
|
+
return value;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function validateFingerprint(value) {
|
|
862
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) throw inputError('invalid fingerprint');
|
|
863
|
+
return value;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function validateProductVersion(value) {
|
|
867
|
+
if (typeof value !== 'string' || !/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/.test(value)) {
|
|
868
|
+
throw inputError('invalid product version');
|
|
869
|
+
}
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function validatePlatform(value) {
|
|
874
|
+
if (typeof value !== 'string' || !/^[a-z0-9_-]{1,32}$/.test(value)) throw inputError('invalid platform');
|
|
875
|
+
return value;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function validateArch(value) {
|
|
879
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(value)) throw inputError('invalid architecture');
|
|
880
|
+
return value;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function nowIso(now) {
|
|
884
|
+
const value = typeof now === 'function' ? now() : new Date();
|
|
885
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
886
|
+
if (!Number.isFinite(date.getTime())) throw inputError('invalid observation timestamp');
|
|
887
|
+
return date.toISOString();
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function validTimestamp(value) {
|
|
891
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function validStableId(value) {
|
|
895
|
+
return typeof value === 'string' && /^[a-z][a-z0-9._-]{0,95}$/.test(value);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function decodeUtf8(bytes) {
|
|
899
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function assertExactObject(value, allowedKeys) {
|
|
903
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !hasOnlyKeys(value, allowedKeys) || Object.keys(value).length !== allowedKeys.size) {
|
|
904
|
+
throw inputError('runtime error API accepts allow-listed fields only');
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function assertObject(value, name) {
|
|
909
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw storeError(`${name} must be an object`);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function hasOnlyKeys(value, allowed) {
|
|
913
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function inputError(message) {
|
|
917
|
+
return Object.assign(new TypeError(message), { code: 'E_RUNTIME_ERROR_INPUT' });
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function storeError(message) {
|
|
921
|
+
return Object.assign(new Error(message), { code: 'E_RUNTIME_ERROR_STORE' });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function enqueue(path, operation) {
|
|
925
|
+
const previous = pathQueues.get(path) ?? Promise.resolve();
|
|
926
|
+
const current = previous.catch(() => {}).then(operation);
|
|
927
|
+
pathQueues.set(path, current);
|
|
928
|
+
return current.finally(() => {
|
|
929
|
+
if (pathQueues.get(path) === current) pathQueues.delete(path);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function deepFreeze(value) {
|
|
934
|
+
for (const item of Object.values(value)) Object.freeze(item);
|
|
935
|
+
return Object.freeze(value);
|
|
936
|
+
}
|