throughline 0.6.1 → 0.6.2
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 +20 -0
- package/README.md +14 -2
- package/bin/throughline.mjs +31 -0
- package/docs/00_overview.md +2 -0
- package/docs/04_public_release_plan.md +2 -0
- package/docs/13_native_factory_diagnostics_plan.md +46 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +52 -0
- package/package.json +2 -2
- package/src/auditor-context.test.mjs +8 -1
- package/src/cli/codex-hook.mjs +7 -4
- package/src/cli/codex-hook.test.mjs +4 -0
- package/src/cli/codex-restore-smoke.mjs +2 -1
- package/src/cli/codex-restore-source-audit.mjs +1 -1
- package/src/cli/doctor.mjs +5 -1
- package/src/cli/factory-diagnostics.mjs +246 -0
- package/src/cli/factory-diagnostics.test.mjs +201 -0
- package/src/cli/runtime-errors.mjs +85 -0
- package/src/cli/runtime-errors.test.mjs +75 -0
- package/src/cli/trim.mjs +4 -4
- package/src/codex-handoff-model-smoke.mjs +2 -3
- package/src/codex-sidecar-cli.test.mjs +19 -8
- package/src/codex-sidecar.mjs +2 -5
- package/src/codex-sidecar.test.mjs +17 -9
- package/src/codex-thread-index.mjs +7 -1
- package/src/db.mjs +1 -1
- package/src/factory-diagnostics.mjs +118 -0
- package/src/factory-diagnostics.test.mjs +97 -0
- package/src/haiku-summarizer.mjs +3 -5
- package/src/haiku-summarizer.test.mjs +52 -47
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/phase0-spotter-contract.test.mjs +6 -7
- package/src/portable-spawn-sync.mjs +58 -0
- package/src/portable-spawn-sync.test.mjs +40 -0
- package/src/prompt-submit.mjs +2 -0
- package/src/runtime-error-hook.test.mjs +106 -0
- package/src/runtime-error-observer.mjs +8 -0
- package/src/runtime-error-store.mjs +595 -0
- package/src/runtime-error-store.test.mjs +307 -0
- package/src/session-start.mjs +2 -0
- package/src/test-env.mjs +59 -2
- package/src/turn-processor.mjs +2 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { arch as hostArch, homedir, platform as hostPlatform } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
21
|
+
|
|
22
|
+
export const RUNTIME_ERROR_STORE_SCHEMA = 'throughline.runtime_errors.v1';
|
|
23
|
+
export const RUNTIME_ERROR_STATE_SCHEMA_VERSION = '1.0';
|
|
24
|
+
export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unavailable\n';
|
|
25
|
+
const DEFAULT_SNAPSHOT_LIMIT = 256;
|
|
26
|
+
const BEST_EFFORT_TIMEOUT_MS = 750;
|
|
27
|
+
const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
|
|
28
|
+
const WINDOWS_ACL_TIMEOUT_MS = 3_000;
|
|
29
|
+
const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
|
|
30
|
+
|
|
31
|
+
const DEFINITIONS = Object.freeze({
|
|
32
|
+
HOOK_SESSION_START_FAILED: Object.freeze({
|
|
33
|
+
component: 'claude_session_start_hook',
|
|
34
|
+
severity: 'high',
|
|
35
|
+
template: 'Throughline Claude SessionStart hook processing failed',
|
|
36
|
+
}),
|
|
37
|
+
HOOK_PROMPT_SUBMIT_FAILED: Object.freeze({
|
|
38
|
+
component: 'claude_prompt_submit_hook',
|
|
39
|
+
severity: 'high',
|
|
40
|
+
template: 'Throughline Claude UserPromptSubmit hook processing failed',
|
|
41
|
+
}),
|
|
42
|
+
HOOK_PROCESS_TURN_FAILED: Object.freeze({
|
|
43
|
+
component: 'claude_stop_hook',
|
|
44
|
+
severity: 'high',
|
|
45
|
+
template: 'Throughline Claude Stop hook processing failed',
|
|
46
|
+
}),
|
|
47
|
+
HOOK_CODEX_FAILED: Object.freeze({
|
|
48
|
+
component: 'codex_hook',
|
|
49
|
+
severity: 'high',
|
|
50
|
+
template: 'Throughline Codex hook processing failed',
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export function defaultFactoryReporterConfigPath(env = process.env) {
|
|
55
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
56
|
+
if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
|
|
57
|
+
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'dotagents', 'factory-reporter', 'config.json');
|
|
58
|
+
}
|
|
59
|
+
return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'dotagents', 'factory-reporter.json');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function defaultRuntimeErrorStorePath(env = process.env) {
|
|
63
|
+
const home = env.HOME || env.USERPROFILE || homedir();
|
|
64
|
+
if ((env.OS === 'Windows_NT' || hostPlatform() === 'win32')) {
|
|
65
|
+
return join(env.LOCALAPPDATA || join(home, 'AppData', 'Local'), 'throughline', 'runtime-errors.json');
|
|
66
|
+
}
|
|
67
|
+
return join(env.XDG_STATE_HOME || join(home, '.local', 'state'), 'throughline', 'runtime-errors.json');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function isRuntimeErrorCollectionEnabled({ env = process.env, configPath } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const path = configPath || defaultFactoryReporterConfigPath(env);
|
|
73
|
+
const info = lstatSync(path);
|
|
74
|
+
if (!info.isFile() || info.isSymbolicLink()) return false;
|
|
75
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
76
|
+
return isCanonicalFactoryReporterConfig(parsed) && parsed.collection.enabled === true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function observeRuntimeError(input, options = {}) {
|
|
83
|
+
assertExactInput(input, ['code', 'now']);
|
|
84
|
+
assertExactOptions(options, ['env', 'configPath', 'storePath', 'version', 'platform', 'arch']);
|
|
85
|
+
const definition = DEFINITIONS[input.code];
|
|
86
|
+
if (!definition) throw new TypeError('未登録の runtime error code です');
|
|
87
|
+
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
88
|
+
|
|
89
|
+
return withStoreLock(options, () => {
|
|
90
|
+
const now = normalizeTimestamp(input.now);
|
|
91
|
+
const version = normalizeVersion(options.version ?? PACKAGE_VERSION);
|
|
92
|
+
const fingerprint = createHash('sha256')
|
|
93
|
+
.update(['throughline', definition.component, input.code, definition.template].join('\0'))
|
|
94
|
+
.digest('hex');
|
|
95
|
+
const store = readStore(options);
|
|
96
|
+
const existing = store.records.find((record) => record.fingerprint === fingerprint);
|
|
97
|
+
const sequence = nextSequence(store);
|
|
98
|
+
if (existing) {
|
|
99
|
+
existing.product_version = version;
|
|
100
|
+
existing.os = normalizePlatform(options.platform ?? hostPlatform());
|
|
101
|
+
existing.arch = normalizeArch(options.arch ?? hostArch());
|
|
102
|
+
existing.count += 1;
|
|
103
|
+
existing.last_seen = now;
|
|
104
|
+
existing.status = 'open';
|
|
105
|
+
existing.resolved_at = null;
|
|
106
|
+
existing.reason_code = null;
|
|
107
|
+
existing.sequence = sequence;
|
|
108
|
+
} else {
|
|
109
|
+
store.records.push({
|
|
110
|
+
product: 'throughline', product_version: version, component: definition.component,
|
|
111
|
+
error_code: input.code, message_template: definition.template, severity: definition.severity,
|
|
112
|
+
fingerprint, count: 1, first_seen: now, last_seen: now,
|
|
113
|
+
state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
|
|
114
|
+
os: normalizePlatform(options.platform ?? hostPlatform()), arch: normalizeArch(options.arch ?? hostArch()),
|
|
115
|
+
status: 'open', resolved_at: null, reason_code: null, sequence,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
writeStore(store, options);
|
|
119
|
+
return { status: 'recorded', fingerprint, sequence };
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveRuntimeError(fingerprint, options = {}) {
|
|
124
|
+
assertExactOptions(options, ['env', 'configPath', 'storePath', 'now', 'reasonCode']);
|
|
125
|
+
assertFingerprint(fingerprint);
|
|
126
|
+
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
127
|
+
return withStoreLock(options, () => {
|
|
128
|
+
const store = readStore(options);
|
|
129
|
+
const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
|
|
130
|
+
if (!record) return { status: 'not_found' };
|
|
131
|
+
if (record.status === 'resolved') return { status: 'resolved', sequence: record.sequence };
|
|
132
|
+
const reasonCode = options.reasonCode ?? 'manual';
|
|
133
|
+
if (!RESOLUTION_REASONS.has(reasonCode)) throw new TypeError('reasonCode が未登録です');
|
|
134
|
+
record.status = 'resolved';
|
|
135
|
+
record.resolved_at = normalizeTimestamp(options.now);
|
|
136
|
+
record.reason_code = reasonCode;
|
|
137
|
+
record.sequence = nextSequence(store);
|
|
138
|
+
writeStore(store, options);
|
|
139
|
+
return { status: 'resolved', sequence: record.sequence };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function reopenRuntimeError(fingerprint, options = {}) {
|
|
144
|
+
assertExactOptions(options, ['env', 'configPath', 'storePath']);
|
|
145
|
+
assertFingerprint(fingerprint);
|
|
146
|
+
if (!collectionEnabled(options)) return { status: 'disabled' };
|
|
147
|
+
return withStoreLock(options, () => {
|
|
148
|
+
const store = readStore(options);
|
|
149
|
+
const record = store.records.find((candidate) => candidate.fingerprint === fingerprint);
|
|
150
|
+
if (!record) return { status: 'not_found' };
|
|
151
|
+
if (record.status === 'open') return { status: 'open', sequence: record.sequence };
|
|
152
|
+
record.status = 'open';
|
|
153
|
+
record.resolved_at = null;
|
|
154
|
+
record.reason_code = null;
|
|
155
|
+
record.sequence = nextSequence(store);
|
|
156
|
+
writeStore(store, options);
|
|
157
|
+
return { status: 'open', sequence: record.sequence };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function acknowledgeRuntimeErrors(cursor, options = {}) {
|
|
162
|
+
if (!Number.isSafeInteger(cursor) || cursor < 0) throw new TypeError('cursor は非負の整数が必要です');
|
|
163
|
+
if (!collectionEnabled(options)) return { status: 'disabled', acknowledgedThrough: 0 };
|
|
164
|
+
return withStoreLock(options, () => {
|
|
165
|
+
const store = readStore(options);
|
|
166
|
+
const highWatermark = store.next_sequence - 1;
|
|
167
|
+
if (cursor > highWatermark) throw new RangeError('cursor がstore high watermarkを超えています');
|
|
168
|
+
store.acknowledged_through = Math.max(store.acknowledged_through, cursor);
|
|
169
|
+
writeStore(store, options);
|
|
170
|
+
return { status: 'acknowledged', acknowledgedThrough: store.acknowledged_through };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function compactRuntimeErrors({
|
|
175
|
+
env = process.env,
|
|
176
|
+
configPath,
|
|
177
|
+
storePath,
|
|
178
|
+
now,
|
|
179
|
+
retentionMs = 30 * 24 * 60 * 60 * 1000,
|
|
180
|
+
} = {}) {
|
|
181
|
+
if (!Number.isSafeInteger(retentionMs) || retentionMs < 0) {
|
|
182
|
+
throw new TypeError('retentionMs は非負の整数が必要です');
|
|
183
|
+
}
|
|
184
|
+
const options = { env, configPath, storePath };
|
|
185
|
+
if (!collectionEnabled(options)) return { status: 'disabled', removed: 0 };
|
|
186
|
+
return withStoreLock(options, () => {
|
|
187
|
+
const store = readStore(options);
|
|
188
|
+
const cutoff = Date.parse(normalizeTimestamp(now)) - retentionMs;
|
|
189
|
+
const before = store.records.length;
|
|
190
|
+
store.records = store.records.filter((record) => {
|
|
191
|
+
const acknowledged = record.sequence <= store.acknowledged_through;
|
|
192
|
+
const expired = Date.parse(record.last_seen) <= cutoff;
|
|
193
|
+
return !(acknowledged && record.status === 'resolved' && expired);
|
|
194
|
+
});
|
|
195
|
+
writeStore(store, options);
|
|
196
|
+
return { status: 'compacted', removed: before - store.records.length };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function readRuntimeErrorSnapshot({
|
|
201
|
+
env = process.env,
|
|
202
|
+
configPath,
|
|
203
|
+
storePath,
|
|
204
|
+
afterCursor = 0,
|
|
205
|
+
limit = DEFAULT_SNAPSHOT_LIMIT,
|
|
206
|
+
} = {}) {
|
|
207
|
+
if (!Number.isSafeInteger(afterCursor) || afterCursor < 0) {
|
|
208
|
+
throw new TypeError('afterCursor は非負の整数が必要です');
|
|
209
|
+
}
|
|
210
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > DEFAULT_SNAPSHOT_LIMIT) {
|
|
211
|
+
throw new TypeError(`limit は 1..${DEFAULT_SNAPSHOT_LIMIT} が必要です`);
|
|
212
|
+
}
|
|
213
|
+
const options = { env, configPath, storePath };
|
|
214
|
+
const enabled = collectionEnabled(options);
|
|
215
|
+
const store = enabled ? readStore(options, { missingIsEmpty: true }) : emptyStore();
|
|
216
|
+
const candidates = store.records
|
|
217
|
+
.filter((record) => record.sequence > afterCursor)
|
|
218
|
+
.sort((left, right) => left.sequence - right.sequence);
|
|
219
|
+
const selected = candidates.slice(0, limit);
|
|
220
|
+
return {
|
|
221
|
+
schema: RUNTIME_ERROR_STORE_SCHEMA,
|
|
222
|
+
product: 'throughline',
|
|
223
|
+
version: PACKAGE_VERSION,
|
|
224
|
+
state_schema_version: RUNTIME_ERROR_STATE_SCHEMA_VERSION,
|
|
225
|
+
cursor: {
|
|
226
|
+
high_watermark: store.next_sequence - 1,
|
|
227
|
+
acknowledged_through: store.acknowledged_through,
|
|
228
|
+
next: selected.at(-1)?.sequence ?? afterCursor,
|
|
229
|
+
},
|
|
230
|
+
runtime_errors: selected.filter((record) => record.status === 'open').map(toPublicRuntimeError),
|
|
231
|
+
resolutions: selected.filter((record) => record.status === 'resolved').map((record) => ({
|
|
232
|
+
fingerprint: record.fingerprint,
|
|
233
|
+
resolved_at: record.resolved_at,
|
|
234
|
+
reason_code: record.reason_code,
|
|
235
|
+
})),
|
|
236
|
+
diagnostics: {
|
|
237
|
+
collection: enabled ? 'enabled' : 'disabled',
|
|
238
|
+
status: enabled ? 'ready' : 'not_applicable',
|
|
239
|
+
total_count: store.records.length,
|
|
240
|
+
pending_count: store.records.filter((record) => record.sequence > store.acknowledged_through).length,
|
|
241
|
+
truncated: candidates.length > selected.length,
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function getRuntimeErrorDiagnostics(options = {}) {
|
|
247
|
+
const enabled = collectionEnabled(options);
|
|
248
|
+
if (!enabled) return diagnosticProjection('disabled', 'not_applicable', emptyStore());
|
|
249
|
+
try {
|
|
250
|
+
return diagnosticProjection('enabled', 'ready', readStore(options, { missingIsEmpty: true }));
|
|
251
|
+
} catch {
|
|
252
|
+
return diagnosticProjection('enabled', 'unavailable', emptyStore());
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function recordRuntimeErrorBestEffort(code, options = {}) {
|
|
257
|
+
const { stderr = process.stderr, ...storeOptions } = options;
|
|
258
|
+
try {
|
|
259
|
+
const child = spawnSync(process.execPath, [fileURLToPath(new URL('./runtime-error-observer.mjs', import.meta.url)), code], {
|
|
260
|
+
env: storeOptions.env ?? process.env,
|
|
261
|
+
encoding: 'utf8',
|
|
262
|
+
stdio: 'ignore',
|
|
263
|
+
timeout: isWindows(storeOptions.env) ? WINDOWS_BEST_EFFORT_TIMEOUT_MS : BEST_EFFORT_TIMEOUT_MS,
|
|
264
|
+
windowsHide: true,
|
|
265
|
+
});
|
|
266
|
+
if (child.status === 0) return { status: 'recorded' };
|
|
267
|
+
if (child.status === 3) return { status: 'disabled' };
|
|
268
|
+
throw new Error('runtime error observer failed');
|
|
269
|
+
} catch {
|
|
270
|
+
stderr.write(RUNTIME_ERROR_DIAGNOSTIC);
|
|
271
|
+
return { status: 'store_unavailable' };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function collectionEnabled(options) {
|
|
276
|
+
return isRuntimeErrorCollectionEnabled({
|
|
277
|
+
env: options.env,
|
|
278
|
+
configPath: options.configPath,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function emptyStore() {
|
|
283
|
+
return {
|
|
284
|
+
schema: RUNTIME_ERROR_STORE_SCHEMA,
|
|
285
|
+
next_sequence: 1,
|
|
286
|
+
acknowledged_through: 0,
|
|
287
|
+
records: [],
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readStore(options, { missingIsEmpty = true } = {}) {
|
|
292
|
+
const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
|
|
293
|
+
let parsed;
|
|
294
|
+
try {
|
|
295
|
+
const info = lstatSync(path);
|
|
296
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
|
|
297
|
+
assertPrivateStoreDirectory(dirname(path), options.env);
|
|
298
|
+
assertPrivateStoreFile(info, options.env, path);
|
|
299
|
+
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (missingIsEmpty && error?.code === 'ENOENT') return emptyStore();
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
validateStore(parsed);
|
|
305
|
+
return parsed;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function writeStore(store, options) {
|
|
309
|
+
validateStore(store);
|
|
310
|
+
const path = options.storePath || defaultRuntimeErrorStorePath(options.env);
|
|
311
|
+
const directory = dirname(path);
|
|
312
|
+
ensurePrivateStoreDirectory(directory, options.env);
|
|
313
|
+
const temporary = join(directory, `.runtime-errors.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(temporary, `${JSON.stringify(store)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
316
|
+
if (!isWindows(options.env)) chmodSync(temporary, 0o600);
|
|
317
|
+
renameSync(temporary, path);
|
|
318
|
+
if (!isWindows(options.env)) chmodSync(path, 0o600);
|
|
319
|
+
else applyAndVerifyWindowsAcl(path, false);
|
|
320
|
+
assertPrivateStoreFile(lstatSync(path), options.env, path);
|
|
321
|
+
} finally {
|
|
322
|
+
rmSync(temporary, { force: true });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function withStoreLock(options, operation) {
|
|
327
|
+
const storePath = options.storePath || defaultRuntimeErrorStorePath(options.env);
|
|
328
|
+
const directory = dirname(storePath);
|
|
329
|
+
ensurePrivateStoreDirectory(directory, options.env);
|
|
330
|
+
const lockPath = `${storePath}.lock.sqlite`;
|
|
331
|
+
let created = false;
|
|
332
|
+
try {
|
|
333
|
+
writeFileSync(lockPath, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
334
|
+
created = true;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
337
|
+
}
|
|
338
|
+
if (created) {
|
|
339
|
+
if (isWindows(options.env)) applyAndVerifyWindowsAcl(lockPath, false);
|
|
340
|
+
else chmodSync(lockPath, 0o600);
|
|
341
|
+
}
|
|
342
|
+
assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
|
|
343
|
+
const database = new DatabaseSync(lockPath);
|
|
344
|
+
let active = false;
|
|
345
|
+
try {
|
|
346
|
+
assertPrivateStoreFile(lstatSync(lockPath), options.env, lockPath);
|
|
347
|
+
database.exec('PRAGMA busy_timeout=5000; PRAGMA synchronous=FULL');
|
|
348
|
+
database.exec('BEGIN IMMEDIATE');
|
|
349
|
+
active = true;
|
|
350
|
+
const result = operation();
|
|
351
|
+
database.exec('COMMIT');
|
|
352
|
+
active = false;
|
|
353
|
+
return result;
|
|
354
|
+
} finally {
|
|
355
|
+
if (active) { try { database.exec('ROLLBACK'); } catch {} }
|
|
356
|
+
database.close();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function ensurePrivateStoreDirectory(directory, env = process.env) {
|
|
361
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
362
|
+
const info = lstatSync(directory);
|
|
363
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
|
|
364
|
+
if (isWindows(env)) {
|
|
365
|
+
applyAndVerifyWindowsAcl(directory, true);
|
|
366
|
+
} else {
|
|
367
|
+
chmodSync(directory, 0o700);
|
|
368
|
+
}
|
|
369
|
+
assertPrivateStoreDirectory(directory, env);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function isWindows(env = process.env) {
|
|
373
|
+
return env.OS === 'Windows_NT' || hostPlatform() === 'win32';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isCanonicalFactoryReporterConfig(value) {
|
|
377
|
+
if (!isPlainObject(value) || !exactKeys(value, ['schema_version', 'host', 'collection', 'reporting']) || value.schema_version !== '1.0') return false;
|
|
378
|
+
if (!isPlainObject(value.host) || !exactKeys(value.host, ['id', 'profile']) ||
|
|
379
|
+
typeof value.host.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,63}$/.test(value.host.id) ||
|
|
380
|
+
!['server', 'mac', 'wsl', 'windows-native'].includes(value.host.profile)) return false;
|
|
381
|
+
if (!isPlainObject(value.collection) || !exactKeys(value.collection, ['enabled']) || typeof value.collection.enabled !== 'boolean') return false;
|
|
382
|
+
if (!isPlainObject(value.reporting) || !exactKeys(value.reporting, ['enabled', 'endpoint', 'credential_file'], true) || typeof value.reporting.enabled !== 'boolean') return false;
|
|
383
|
+
if (value.reporting.endpoint !== undefined) {
|
|
384
|
+
if (typeof value.reporting.endpoint !== 'string' || value.reporting.endpoint.length > 2048) return false;
|
|
385
|
+
try { if (!['http:', 'https:'].includes(new URL(value.reporting.endpoint).protocol)) return false; } catch { return false; }
|
|
386
|
+
}
|
|
387
|
+
if (value.reporting.credential_file !== undefined &&
|
|
388
|
+
(typeof value.reporting.credential_file !== 'string' || value.reporting.credential_file.length < 1 || value.reporting.credential_file.length > 4096)) return false;
|
|
389
|
+
return !value.reporting.enabled || (value.reporting.endpoint !== undefined && value.reporting.credential_file !== undefined);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
393
|
+
function exactKeys(value, allowed, optional = false) {
|
|
394
|
+
const keys = Object.keys(value);
|
|
395
|
+
if (keys.some((key) => !allowed.includes(key))) return false;
|
|
396
|
+
return optional ? keys.includes('enabled') : allowed.every((key) => keys.includes(key));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function validateStore(store) {
|
|
400
|
+
if (!store || typeof store !== 'object' || Array.isArray(store) ||
|
|
401
|
+
!exactKeys(store, ['schema', 'next_sequence', 'acknowledged_through', 'records']) ||
|
|
402
|
+
store.schema !== RUNTIME_ERROR_STORE_SCHEMA ||
|
|
403
|
+
!Number.isSafeInteger(store.next_sequence) || store.next_sequence < 1 ||
|
|
404
|
+
!Number.isSafeInteger(store.acknowledged_through) || store.acknowledged_through < 0 ||
|
|
405
|
+
store.acknowledged_through >= store.next_sequence ||
|
|
406
|
+
!Array.isArray(store.records)) {
|
|
407
|
+
throw new Error('runtime error store schema invalid');
|
|
408
|
+
}
|
|
409
|
+
const fingerprints = new Set();
|
|
410
|
+
const sequences = new Set();
|
|
411
|
+
for (const record of store.records) {
|
|
412
|
+
validateRecord(record, store.next_sequence);
|
|
413
|
+
if (fingerprints.has(record.fingerprint) || sequences.has(record.sequence)) {
|
|
414
|
+
throw new Error('runtime error record uniqueness invalid');
|
|
415
|
+
}
|
|
416
|
+
fingerprints.add(record.fingerprint);
|
|
417
|
+
sequences.add(record.sequence);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function validateRecord(record, nextSequenceValue) {
|
|
422
|
+
const keys = [
|
|
423
|
+
'product', 'product_version', 'component', 'error_code', 'message_template', 'severity',
|
|
424
|
+
'fingerprint', 'count', 'first_seen', 'last_seen', 'state_schema_version', 'os', 'arch',
|
|
425
|
+
'status', 'resolved_at', 'reason_code', 'sequence',
|
|
426
|
+
];
|
|
427
|
+
if (!record || typeof record !== 'object' || Array.isArray(record) ||
|
|
428
|
+
Object.keys(record).length !== keys.length || keys.some((key) => !(key in record))) {
|
|
429
|
+
throw new Error('runtime error record schema invalid');
|
|
430
|
+
}
|
|
431
|
+
assertFingerprint(record.fingerprint);
|
|
432
|
+
const definition = DEFINITIONS[record.error_code];
|
|
433
|
+
const expectedFingerprint = definition && createHash('sha256')
|
|
434
|
+
.update(['throughline', definition.component, record.error_code, definition.template].join('\0'))
|
|
435
|
+
.digest('hex');
|
|
436
|
+
if (record.product !== 'throughline' || !DEFINITIONS[record.error_code] ||
|
|
437
|
+
DEFINITIONS[record.error_code].component !== record.component ||
|
|
438
|
+
DEFINITIONS[record.error_code].template !== record.message_template ||
|
|
439
|
+
DEFINITIONS[record.error_code].severity !== record.severity ||
|
|
440
|
+
!Number.isSafeInteger(record.count) || record.count < 1 ||
|
|
441
|
+
!Number.isSafeInteger(record.sequence) || record.sequence < 1 || record.sequence >= nextSequenceValue ||
|
|
442
|
+
!['open', 'resolved'].includes(record.status) ||
|
|
443
|
+
record.state_schema_version !== RUNTIME_ERROR_STATE_SCHEMA_VERSION ||
|
|
444
|
+
record.fingerprint !== expectedFingerprint ||
|
|
445
|
+
(record.status === 'open' && (record.resolved_at !== null || record.reason_code !== null)) ||
|
|
446
|
+
(record.status === 'resolved' && (!isCanonicalTimestamp(record.resolved_at) || !RESOLUTION_REASONS.has(record.reason_code)))) {
|
|
447
|
+
throw new Error('runtime error record value invalid');
|
|
448
|
+
}
|
|
449
|
+
if (Date.parse(normalizeTimestamp(record.first_seen)) > Date.parse(normalizeTimestamp(record.last_seen))) {
|
|
450
|
+
throw new Error('runtime error record timestamp invalid');
|
|
451
|
+
}
|
|
452
|
+
normalizeVersion(record.product_version);
|
|
453
|
+
normalizePlatform(record.os);
|
|
454
|
+
normalizeArch(record.arch);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function nextSequence(store) {
|
|
458
|
+
const current = store.next_sequence;
|
|
459
|
+
store.next_sequence += 1;
|
|
460
|
+
return current;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function toPublicRuntimeError(record) {
|
|
464
|
+
return {
|
|
465
|
+
error_code: record.error_code,
|
|
466
|
+
component: record.component,
|
|
467
|
+
status: record.status,
|
|
468
|
+
severity: record.severity,
|
|
469
|
+
fingerprint: record.fingerprint,
|
|
470
|
+
message_template: record.message_template,
|
|
471
|
+
occurrence_count: record.count,
|
|
472
|
+
first_seen: record.first_seen,
|
|
473
|
+
last_seen: record.last_seen,
|
|
474
|
+
state_schema_version: record.state_schema_version,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function diagnosticProjection(collection, status, store) {
|
|
479
|
+
return {
|
|
480
|
+
schema: 'throughline.runtime_error_diagnostics.v1',
|
|
481
|
+
collection,
|
|
482
|
+
status,
|
|
483
|
+
total_count: store.records.length,
|
|
484
|
+
open_count: store.records.filter((record) => record.status === 'open').length,
|
|
485
|
+
pending_count: store.records.filter((record) => record.sequence > store.acknowledged_through).length,
|
|
486
|
+
high_watermark: store.next_sequence - 1,
|
|
487
|
+
acknowledged_through: store.acknowledged_through,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isCanonicalTimestamp(value) {
|
|
492
|
+
if (typeof value !== 'string') return false;
|
|
493
|
+
const parsed = Date.parse(value);
|
|
494
|
+
return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function assertPrivateStoreDirectory(directory, env = process.env) {
|
|
498
|
+
const info = lstatSync(directory);
|
|
499
|
+
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('runtime error store directory unsafe');
|
|
500
|
+
if (isWindows(env)) verifyWindowsAcl(directory, true);
|
|
501
|
+
else assertPosixOwnerMode(info, 0o700);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function assertPrivateStoreFile(info, env = process.env, path) {
|
|
505
|
+
if (!info.isFile() || info.isSymbolicLink()) throw new Error('runtime error store path unsafe');
|
|
506
|
+
if (isWindows(env)) verifyWindowsAcl(path, false);
|
|
507
|
+
else assertPosixOwnerMode(info, 0o600);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function assertPosixOwnerMode(info, expectedMode) {
|
|
511
|
+
if ((info.mode & 0o777) !== expectedMode) throw new Error('runtime error store permissions unsafe');
|
|
512
|
+
if (typeof process.getuid === 'function' && info.uid !== process.getuid()) throw new Error('runtime error store owner unsafe');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function applyAndVerifyWindowsAcl(path, directory) {
|
|
516
|
+
runWindowsAclScript(path, directory, WINDOWS_ACL_APPLY_SCRIPT);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function verifyWindowsAcl(path, directory) {
|
|
520
|
+
runWindowsAclScript(path, directory, WINDOWS_ACL_VERIFY_SCRIPT);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function runWindowsAclScript(path, directory, script) {
|
|
524
|
+
const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
525
|
+
env: { ...process.env, FACTORY_ACL_PATH: path, FACTORY_ACL_DIRECTORY: directory ? '1' : '0' },
|
|
526
|
+
stdio: 'ignore', timeout: WINDOWS_ACL_TIMEOUT_MS, windowsHide: true,
|
|
527
|
+
});
|
|
528
|
+
if (result.status !== 0) throw new Error('Windows owner-only ACL verification failed');
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const WINDOWS_ACL_VERIFY_SCRIPT = String.raw`
|
|
532
|
+
$p=$env:FACTORY_ACL_PATH; $isDir=$env:FACTORY_ACL_DIRECTORY -eq '1'; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
533
|
+
$acl=if($isDir){[System.IO.Directory]::GetAccessControl($p)}else{[System.IO.File]::GetAccessControl($p)}
|
|
534
|
+
$owner=$acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
|
535
|
+
if($owner -ne $sid){exit 41}; $rules=@($acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier])); if($rules.Count -ne 1){exit 42}
|
|
536
|
+
$r=$rules[0]; if($r.IdentityReference.Value -ne $sid -or $r.AccessControlType -ne 'Allow' -or $r.IsInherited -or ($r.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl){exit 43}
|
|
537
|
+
`;
|
|
538
|
+
|
|
539
|
+
const WINDOWS_ACL_APPLY_SCRIPT = String.raw`
|
|
540
|
+
$p=$env:FACTORY_ACL_PATH; $sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
541
|
+
$isDir=$env:FACTORY_ACL_DIRECTORY -eq '1'; $acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}; $acl.SetAccessRuleProtection($true,$false)
|
|
542
|
+
$flags=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
|
|
543
|
+
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
|
|
544
|
+
$acl.SetOwner($sid); $acl.AddAccessRule($rule); if($isDir){[System.IO.Directory]::SetAccessControl($p,$acl)}else{[System.IO.File]::SetAccessControl($p,$acl)}
|
|
545
|
+
` + WINDOWS_ACL_VERIFY_SCRIPT;
|
|
546
|
+
|
|
547
|
+
function assertExactInput(input, allowed) {
|
|
548
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) ||
|
|
549
|
+
Object.keys(input).some((key) => !allowed.includes(key))) {
|
|
550
|
+
throw new TypeError('runtime error API は固定 code と時刻だけを受け付けます');
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function assertExactOptions(options, allowed) {
|
|
555
|
+
if (!options || typeof options !== 'object' || Array.isArray(options) ||
|
|
556
|
+
Object.keys(options).some((key) => !allowed.includes(key))) {
|
|
557
|
+
throw new TypeError('runtime error API は未定義 option を受け付けません');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function assertFingerprint(value) {
|
|
562
|
+
if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) {
|
|
563
|
+
throw new TypeError('fingerprint が不正です');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function normalizeTimestamp(value) {
|
|
568
|
+
const date = value === undefined ? new Date() : new Date(value);
|
|
569
|
+
if (!Number.isFinite(date.getTime())) throw new TypeError('時刻が不正です');
|
|
570
|
+
return date.toISOString();
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function normalizeVersion(value) {
|
|
574
|
+
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(value)) {
|
|
575
|
+
throw new TypeError('version が不正です');
|
|
576
|
+
}
|
|
577
|
+
return value;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function normalizePlatform(value) {
|
|
581
|
+
if (!['darwin', 'linux', 'windows', 'win32'].includes(value)) throw new TypeError('OS が不正です');
|
|
582
|
+
return value === 'win32' ? 'windows' : value;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function normalizeArch(value) {
|
|
586
|
+
if (!['x64', 'arm64', 'arm', 'ia32'].includes(value)) throw new TypeError('arch が不正です');
|
|
587
|
+
return value;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export const _internal = {
|
|
591
|
+
DEFINITIONS,
|
|
592
|
+
emptyStore,
|
|
593
|
+
validateStore,
|
|
594
|
+
writeStore,
|
|
595
|
+
};
|