throughline 0.6.0 → 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 +33 -0
- package/README.md +46 -5
- package/bin/throughline.mjs +41 -0
- package/docs/00_overview.md +2 -0
- package/docs/04_public_release_plan.md +2 -0
- package/docs/12_desktop_clear_handoff_plan.md +3 -3
- 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.mjs +330 -0
- package/src/auditor-context.test.mjs +303 -0
- package/src/cli/auditor-context.mjs +141 -0
- package/src/cli/auditor-context.test.mjs +148 -0
- package/src/cli/codex-hook.mjs +27 -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 +17 -2
- 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 +279 -0
- 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-backfill.test.mjs +2 -2
- package/src/turn-processor.mjs +2 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
acknowledgeRuntimeErrors,
|
|
10
|
+
compactRuntimeErrors,
|
|
11
|
+
defaultFactoryReporterConfigPath,
|
|
12
|
+
defaultRuntimeErrorStorePath,
|
|
13
|
+
getRuntimeErrorDiagnostics,
|
|
14
|
+
observeRuntimeError,
|
|
15
|
+
readRuntimeErrorSnapshot,
|
|
16
|
+
reopenRuntimeError,
|
|
17
|
+
resolveRuntimeError,
|
|
18
|
+
} from './runtime-error-store.mjs';
|
|
19
|
+
import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
|
|
20
|
+
|
|
21
|
+
const TEST_PLATFORM = process.platform === 'win32' ? 'win32' : 'darwin';
|
|
22
|
+
|
|
23
|
+
function sandbox() {
|
|
24
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-runtime-errors-'));
|
|
25
|
+
const env = {
|
|
26
|
+
HOME: root,
|
|
27
|
+
USERPROFILE: root,
|
|
28
|
+
LOCALAPPDATA: root,
|
|
29
|
+
XDG_CONFIG_HOME: join(root, 'config'),
|
|
30
|
+
XDG_STATE_HOME: join(root, 'state'),
|
|
31
|
+
};
|
|
32
|
+
const configPath = defaultFactoryReporterConfigPath(env);
|
|
33
|
+
const storePath = defaultRuntimeErrorStorePath(env);
|
|
34
|
+
return { root, env, configPath, storePath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function enableCollection(box, reporting = { enabled: false }) {
|
|
38
|
+
mkdirSync(dirname(box.configPath), { recursive: true });
|
|
39
|
+
writeFileSync(box.configPath, JSON.stringify({
|
|
40
|
+
schema_version: '1.0',
|
|
41
|
+
host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
|
|
42
|
+
collection: { enabled: true },
|
|
43
|
+
reporting,
|
|
44
|
+
}));
|
|
45
|
+
applyWindowsPrivateAcl(box.configPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test('runtime error store: missing/false/malformed config is fail-closed and creates no state', () => {
|
|
49
|
+
for (const config of [
|
|
50
|
+
null,
|
|
51
|
+
{ collection: { enabled: true } },
|
|
52
|
+
{ collection: { enabled: false } },
|
|
53
|
+
{ collection: { enabled: 'true' } },
|
|
54
|
+
'{malformed',
|
|
55
|
+
]) {
|
|
56
|
+
const box = sandbox();
|
|
57
|
+
if (config !== null) {
|
|
58
|
+
mkdirSync(dirname(box.configPath), { recursive: true });
|
|
59
|
+
writeFileSync(box.configPath, typeof config === 'string' ? config : JSON.stringify(config));
|
|
60
|
+
applyWindowsPrivateAcl(box.configPath);
|
|
61
|
+
}
|
|
62
|
+
assert.deepEqual(observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED' }, { env: box.env }), {
|
|
63
|
+
status: 'disabled',
|
|
64
|
+
});
|
|
65
|
+
assert.equal(getRuntimeErrorDiagnostics({ env: box.env }).collection, 'disabled');
|
|
66
|
+
assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('runtime error store: Windows native uses the canonical LocalAppData paths', () => {
|
|
71
|
+
const env = {
|
|
72
|
+
OS: 'Windows_NT',
|
|
73
|
+
USERPROFILE: 'C:\\Users\\kite_',
|
|
74
|
+
LOCALAPPDATA: 'C:\\Users\\kite_\\AppData\\Local',
|
|
75
|
+
};
|
|
76
|
+
assert.equal(
|
|
77
|
+
defaultFactoryReporterConfigPath(env),
|
|
78
|
+
join(env.LOCALAPPDATA, 'dotagents', 'factory-reporter', 'config.json'),
|
|
79
|
+
);
|
|
80
|
+
assert.equal(
|
|
81
|
+
defaultRuntimeErrorStorePath(env),
|
|
82
|
+
join(env.LOCALAPPDATA, 'throughline', 'runtime-errors.json'),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('runtime error store: reporting config and credentials are ignored and no network API is accepted', () => {
|
|
87
|
+
const box = sandbox();
|
|
88
|
+
enableCollection(box, {
|
|
89
|
+
enabled: true,
|
|
90
|
+
endpoint: 'https://should-never-be-read.invalid/private',
|
|
91
|
+
credential_file: process.platform === 'win32' ? 'C:\\private\\token' : '/private/token',
|
|
92
|
+
});
|
|
93
|
+
const result = observeRuntimeError(
|
|
94
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:00:00.000Z' },
|
|
95
|
+
{ env: box.env, version: '0.6.1', platform: TEST_PLATFORM, arch: 'arm64' },
|
|
96
|
+
);
|
|
97
|
+
assert.equal(result.status, 'recorded');
|
|
98
|
+
const bytes = readFileSync(box.storePath, 'utf8');
|
|
99
|
+
assert.doesNotMatch(bytes, /should-never-be-read|private\/token|endpoint|credential/i);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('runtime error store: observation API rejects raw or arbitrary fields', () => {
|
|
103
|
+
const box = sandbox();
|
|
104
|
+
enableCollection(box);
|
|
105
|
+
for (const forbidden of [
|
|
106
|
+
{ exception: new Error('secret') },
|
|
107
|
+
{ stderr: 'raw stderr' },
|
|
108
|
+
{ stack: 'raw stack' },
|
|
109
|
+
{ prompt: 'private prompt' },
|
|
110
|
+
{ session: 'session-id' },
|
|
111
|
+
{ path: '/Users/private/file' },
|
|
112
|
+
{ context: { arbitrary: true } },
|
|
113
|
+
]) {
|
|
114
|
+
assert.throws(
|
|
115
|
+
() => observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED', ...forbidden }, { env: box.env }),
|
|
116
|
+
/固定 code と時刻だけ/,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
assert.throws(
|
|
120
|
+
() => observeRuntimeError(
|
|
121
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED' },
|
|
122
|
+
{ env: box.env, stderr: 'raw stderr' },
|
|
123
|
+
),
|
|
124
|
+
/未定義 option/,
|
|
125
|
+
);
|
|
126
|
+
assert.throws(
|
|
127
|
+
() => observeRuntimeError({ code: 'UNKNOWN_FAILURE' }, { env: box.env }),
|
|
128
|
+
/未登録の runtime error code/,
|
|
129
|
+
);
|
|
130
|
+
assert.throws(() => statSync(box.storePath), { code: 'ENOENT' });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('runtime error store: fixed template SHA-256 fingerprint aggregates and reopens', () => {
|
|
134
|
+
const box = sandbox();
|
|
135
|
+
enableCollection(box);
|
|
136
|
+
const options = { env: box.env, version: '0.6.1', platform: TEST_PLATFORM, arch: 'arm64' };
|
|
137
|
+
const first = observeRuntimeError(
|
|
138
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:00:00.000Z' }, options,
|
|
139
|
+
);
|
|
140
|
+
const second = observeRuntimeError(
|
|
141
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:01:00.000Z' }, options,
|
|
142
|
+
);
|
|
143
|
+
assert.match(first.fingerprint, /^[0-9a-f]{64}$/);
|
|
144
|
+
assert.equal(second.fingerprint, first.fingerprint);
|
|
145
|
+
|
|
146
|
+
let snapshot = readRuntimeErrorSnapshot({ env: box.env });
|
|
147
|
+
assert.equal(snapshot.runtime_errors.length, 1);
|
|
148
|
+
assert.deepEqual(snapshot.runtime_errors[0], {
|
|
149
|
+
error_code: 'HOOK_PROCESS_TURN_FAILED',
|
|
150
|
+
component: 'claude_stop_hook',
|
|
151
|
+
status: 'open',
|
|
152
|
+
severity: 'high',
|
|
153
|
+
fingerprint: first.fingerprint,
|
|
154
|
+
message_template: 'Throughline Claude Stop hook processing failed',
|
|
155
|
+
occurrence_count: 2,
|
|
156
|
+
first_seen: '2026-07-13T00:00:00.000Z',
|
|
157
|
+
last_seen: '2026-07-13T00:01:00.000Z',
|
|
158
|
+
state_schema_version: '1.0',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const resolved = resolveRuntimeError(first.fingerprint, {
|
|
162
|
+
env: box.env,
|
|
163
|
+
now: '2026-07-13T00:02:00.000Z',
|
|
164
|
+
});
|
|
165
|
+
assert.equal(resolved.status, 'resolved');
|
|
166
|
+
snapshot = readRuntimeErrorSnapshot({ env: box.env });
|
|
167
|
+
assert.equal(snapshot.runtime_errors.length, 0);
|
|
168
|
+
assert.deepEqual(snapshot.resolutions, [{
|
|
169
|
+
fingerprint: first.fingerprint,
|
|
170
|
+
resolved_at: '2026-07-13T00:02:00.000Z',
|
|
171
|
+
reason_code: 'manual',
|
|
172
|
+
}]);
|
|
173
|
+
assert.equal(reopenRuntimeError(first.fingerprint, { env: box.env }).status, 'open');
|
|
174
|
+
assert.equal(readRuntimeErrorSnapshot({ env: box.env }).resolutions.length, 0);
|
|
175
|
+
resolveRuntimeError(first.fingerprint, { env: box.env, now: '2026-07-13T00:02:30.000Z', reasonCode: 'recovered' });
|
|
176
|
+
|
|
177
|
+
observeRuntimeError(
|
|
178
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:03:00.000Z' }, options,
|
|
179
|
+
);
|
|
180
|
+
snapshot = readRuntimeErrorSnapshot({ env: box.env });
|
|
181
|
+
assert.equal(snapshot.runtime_errors[0].status, 'open');
|
|
182
|
+
assert.equal(snapshot.runtime_errors[0].occurrence_count, 3);
|
|
183
|
+
assert.equal(snapshot.runtime_errors[0].last_seen, '2026-07-13T00:03:00.000Z');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('runtime error store: cursor/ack are monotonic and snapshots are bounded', () => {
|
|
187
|
+
const box = sandbox();
|
|
188
|
+
enableCollection(box);
|
|
189
|
+
const options = { env: box.env, version: '0.6.1' };
|
|
190
|
+
observeRuntimeError({ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-07-13T00:00:00.000Z' }, options);
|
|
191
|
+
observeRuntimeError({ code: 'HOOK_SESSION_START_FAILED', now: '2026-07-13T00:01:00.000Z' }, options);
|
|
192
|
+
observeRuntimeError({ code: 'HOOK_PROMPT_SUBMIT_FAILED', now: '2026-07-13T00:02:00.000Z' }, options);
|
|
193
|
+
|
|
194
|
+
const page = readRuntimeErrorSnapshot({ env: box.env, afterCursor: 0, limit: 2 });
|
|
195
|
+
assert.equal(page.runtime_errors.length, 2);
|
|
196
|
+
assert.equal(page.diagnostics.truncated, true);
|
|
197
|
+
assert.equal(page.cursor.high_watermark, 3);
|
|
198
|
+
assert.equal(page.cursor.acknowledged_through, 0);
|
|
199
|
+
assert.equal(acknowledgeRuntimeErrors(2, { env: box.env }).acknowledgedThrough, 2);
|
|
200
|
+
assert.equal(acknowledgeRuntimeErrors(1, { env: box.env }).acknowledgedThrough, 2);
|
|
201
|
+
assert.throws(() => acknowledgeRuntimeErrors(999, { env: box.env }), /high watermark/);
|
|
202
|
+
assert.equal(readRuntimeErrorSnapshot({ env: box.env }).cursor.acknowledged_through, 2);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('runtime error store: compact removes only acknowledged resolved expired records', () => {
|
|
206
|
+
const box = sandbox();
|
|
207
|
+
enableCollection(box);
|
|
208
|
+
const options = { env: box.env, version: '0.6.1' };
|
|
209
|
+
const old = observeRuntimeError(
|
|
210
|
+
{ code: 'HOOK_PROCESS_TURN_FAILED', now: '2026-06-01T00:00:00.000Z' }, options,
|
|
211
|
+
);
|
|
212
|
+
const pending = observeRuntimeError(
|
|
213
|
+
{ code: 'HOOK_SESSION_START_FAILED', now: '2026-06-01T00:00:00.000Z' }, options,
|
|
214
|
+
);
|
|
215
|
+
resolveRuntimeError(old.fingerprint, { env: box.env, now: '2026-06-02T00:00:00.000Z' });
|
|
216
|
+
resolveRuntimeError(pending.fingerprint, { env: box.env, now: '2026-06-02T00:00:00.000Z' });
|
|
217
|
+
acknowledgeRuntimeErrors(3, { env: box.env });
|
|
218
|
+
|
|
219
|
+
const result = compactRuntimeErrors({
|
|
220
|
+
env: box.env,
|
|
221
|
+
now: '2026-07-13T00:00:00.000Z',
|
|
222
|
+
retentionMs: 30 * 24 * 60 * 60 * 1000,
|
|
223
|
+
});
|
|
224
|
+
assert.equal(result.removed, 1);
|
|
225
|
+
const snapshot = readRuntimeErrorSnapshot({ env: box.env });
|
|
226
|
+
assert.equal(snapshot.runtime_errors.length, 0);
|
|
227
|
+
assert.equal(snapshot.resolutions.length, 1);
|
|
228
|
+
assert.equal(snapshot.resolutions[0].fingerprint, pending.fingerprint);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('runtime error store: atomic private store has owner-only modes and bounded diagnostics', () => {
|
|
232
|
+
const box = sandbox();
|
|
233
|
+
enableCollection(box);
|
|
234
|
+
observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env, version: '0.6.1' });
|
|
235
|
+
if (process.platform !== 'win32') {
|
|
236
|
+
assert.equal(statSync(dirname(box.storePath)).mode & 0o777, 0o700);
|
|
237
|
+
assert.equal(statSync(box.storePath).mode & 0o777, 0o600);
|
|
238
|
+
}
|
|
239
|
+
assert.doesNotThrow(() => JSON.parse(readFileSync(box.storePath, 'utf8')));
|
|
240
|
+
|
|
241
|
+
const diagnostics = getRuntimeErrorDiagnostics({ env: box.env });
|
|
242
|
+
assert.deepEqual(Object.keys(diagnostics).sort(), [
|
|
243
|
+
'acknowledged_through', 'collection', 'high_watermark', 'open_count',
|
|
244
|
+
'pending_count', 'schema', 'status', 'total_count',
|
|
245
|
+
]);
|
|
246
|
+
assert.doesNotMatch(JSON.stringify(diagnostics), /Users|\\|\.throughline|runtime-errors\.json/);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('runtime error store: non-canonical reporting values keep collection fail-closed', () => {
|
|
250
|
+
const box = sandbox();
|
|
251
|
+
enableCollection(box, { enabled: true, endpoint: 'ftp://invalid', credential_file: '' });
|
|
252
|
+
assert.deepEqual(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env }), { status: 'disabled' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('runtime error store: unknown top-level fields and future ack are rejected before compaction', () => {
|
|
256
|
+
const box = sandbox();
|
|
257
|
+
enableCollection(box);
|
|
258
|
+
const captured = observeRuntimeError({ code: 'HOOK_CODEX_FAILED', now: '2026-06-01T00:00:00.000Z' }, { env: box.env });
|
|
259
|
+
resolveRuntimeError(captured.fingerprint, { env: box.env, now: '2026-06-02T00:00:00.000Z' });
|
|
260
|
+
const store = JSON.parse(readFileSync(box.storePath, 'utf8'));
|
|
261
|
+
store.secret = '/Users/private Bearer secret-token';
|
|
262
|
+
store.acknowledged_through = 999;
|
|
263
|
+
writeFileSync(box.storePath, JSON.stringify(store), { mode: 0o600 });
|
|
264
|
+
assert.equal(getRuntimeErrorDiagnostics({ env: box.env }).status, 'unavailable');
|
|
265
|
+
assert.throws(() => compactRuntimeErrors({ env: box.env, now: '2026-07-13T00:00:00.000Z', retentionMs: 0 }));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('runtime error store: mode drift is unavailable and a crashed SQLite lock owner is released by the OS', { skip: process.platform === 'win32' }, async () => {
|
|
269
|
+
const box = sandbox();
|
|
270
|
+
enableCollection(box);
|
|
271
|
+
observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env });
|
|
272
|
+
chmodSync(box.storePath, 0o644);
|
|
273
|
+
assert.equal(getRuntimeErrorDiagnostics({ env: box.env }).status, 'unavailable');
|
|
274
|
+
chmodSync(box.storePath, 0o600);
|
|
275
|
+
const lockPath = `${box.storePath}.lock.sqlite`;
|
|
276
|
+
const script = `import {DatabaseSync} from 'node:sqlite'; const db=new DatabaseSync(${JSON.stringify(lockPath)}); db.exec('BEGIN IMMEDIATE'); console.log('READY'); setInterval(()=>{},1000);`;
|
|
277
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
278
|
+
await new Promise((resolve, reject) => {
|
|
279
|
+
child.stdout.setEncoding('utf8');
|
|
280
|
+
child.stdout.once('data', (chunk) => String(chunk).includes('READY') ? resolve() : reject(new Error('lock fixture did not start')));
|
|
281
|
+
child.once('error', reject);
|
|
282
|
+
});
|
|
283
|
+
child.kill('SIGKILL');
|
|
284
|
+
await new Promise((resolve) => child.once('exit', resolve));
|
|
285
|
+
assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, { env: box.env }).status, 'recorded');
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('runtime error store: atomic lock publication preserves all concurrent process observations', {
|
|
289
|
+
skip: process.platform === 'win32' ? 'SQLite排他はPOSIX matrix、Windowsはnative ACL/store試験で固定' : undefined,
|
|
290
|
+
}, async () => {
|
|
291
|
+
const box = sandbox();
|
|
292
|
+
enableCollection(box);
|
|
293
|
+
const modulePath = new URL('./runtime-error-store.mjs', import.meta.url).href;
|
|
294
|
+
const script = `import {observeRuntimeError} from ${JSON.stringify(modulePath)}; observeRuntimeError({code:'HOOK_CODEX_FAILED'});`;
|
|
295
|
+
const results = await Promise.all(Array.from({ length: 20 }, () => new Promise((resolve) => {
|
|
296
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
|
297
|
+
env: { ...process.env, ...box.env }, stdio: ['ignore', 'ignore', 'pipe'],
|
|
298
|
+
});
|
|
299
|
+
let stderr = '';
|
|
300
|
+
child.stderr.setEncoding('utf8');
|
|
301
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
302
|
+
child.once('exit', (code) => resolve({ code, stderr }));
|
|
303
|
+
})));
|
|
304
|
+
assert.deepEqual(results, Array.from({ length: 20 }, () => ({ code: 0, stderr: '' })));
|
|
305
|
+
const snapshot = readRuntimeErrorSnapshot({ env: box.env });
|
|
306
|
+
assert.equal(snapshot.runtime_errors[0].occurrence_count, 20);
|
|
307
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
37
37
|
import { join, dirname } from 'node:path';
|
|
38
38
|
import { homedir } from 'node:os';
|
|
39
39
|
import { pathToFileURL } from 'node:url';
|
|
40
|
+
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
40
41
|
|
|
41
42
|
// SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
|
|
42
43
|
// docs/10_transcript_injection_plan.md §3 Phase 0-2 参照。
|
|
@@ -318,6 +319,7 @@ export async function run() {
|
|
|
318
319
|
|
|
319
320
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
320
321
|
run().catch((err) => {
|
|
322
|
+
recordRuntimeErrorBestEffort('HOOK_SESSION_START_FAILED');
|
|
321
323
|
process.stderr.write(`[session-start] error: ${err.message}\n`);
|
|
322
324
|
process.exit(1);
|
|
323
325
|
});
|
package/src/test-env.mjs
CHANGED
|
@@ -1,4 +1,61 @@
|
|
|
1
1
|
// Keep tests hermetic when they are run from inside a live Codex session.
|
|
2
2
|
// Individual tests that need these values set them explicitly in child env.
|
|
3
|
-
|
|
4
|
-
delete process.env.
|
|
3
|
+
if (process.env.THROUGHLINE_TEST_ENV_ACTIVE !== '1') {
|
|
4
|
+
delete process.env.THROUGHLINE_CODEX_THREAD_ID;
|
|
5
|
+
delete process.env.CODEX_THREAD_ID;
|
|
6
|
+
process.env.THROUGHLINE_TEST_ENV_ACTIVE = '1';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// node:sqlite emits an ExperimentalWarning on Node 22. Several CLI tests
|
|
10
|
+
// intentionally assert their child process stderr contract, so suppress the
|
|
11
|
+
// runtime warning for test children without changing production stderr.
|
|
12
|
+
process.env.NODE_NO_WARNINGS = '1';
|
|
13
|
+
|
|
14
|
+
// Windows cannot execute the POSIX shebang used by the ephemeral fake Codex
|
|
15
|
+
// app-server fixtures. Run their .mjs bodies with this test runner's Node
|
|
16
|
+
// executable, preserving the command arguments the production code sends.
|
|
17
|
+
import childProcess from 'node:child_process';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { syncBuiltinESMExports } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const { spawn: nativeSpawn, spawnSync: nativeSpawnSync } = childProcess;
|
|
22
|
+
|
|
23
|
+
function normalizeNodeFixture(command, args = []) {
|
|
24
|
+
if (process.platform !== 'win32' || typeof command !== 'string' || !Array.isArray(args)) {
|
|
25
|
+
return [command, args];
|
|
26
|
+
}
|
|
27
|
+
if (command.endsWith('.mjs')) return [process.execPath, [command, ...args]];
|
|
28
|
+
try {
|
|
29
|
+
const header = readFileSync(command, 'utf8').slice(0, 128);
|
|
30
|
+
if (/^#!.*\bnode(?:\.exe)?\b/.test(header)) return [process.execPath, [command, ...args]];
|
|
31
|
+
if (/^#!.*\b(?:ba)?sh\b/.test(header)) return ['bash', [command, ...args]];
|
|
32
|
+
} catch {
|
|
33
|
+
// Missing/non-file commands must retain their native spawn error contract.
|
|
34
|
+
}
|
|
35
|
+
return [command, args];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
childProcess.spawn = function spawn(command, args, options) {
|
|
39
|
+
const [normalizedCommand, normalizedArgs] = normalizeNodeFixture(command, args);
|
|
40
|
+
return nativeSpawn(normalizedCommand, normalizedArgs, options);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
childProcess.spawnSync = function spawnSync(command, args, options) {
|
|
44
|
+
const [normalizedCommand, normalizedArgs] = normalizeNodeFixture(command, args);
|
|
45
|
+
return nativeSpawnSync(normalizedCommand, normalizedArgs, {
|
|
46
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
47
|
+
...options,
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
syncBuiltinESMExports();
|
|
52
|
+
|
|
53
|
+
// CLI tests start nested Node processes which in turn launch the fixture
|
|
54
|
+
// executable. Carry this test-only adapter into those children on Windows.
|
|
55
|
+
if (process.platform === 'win32') {
|
|
56
|
+
const importOption = `--import=${import.meta.url}`;
|
|
57
|
+
const current = process.env.NODE_OPTIONS?.trim() ?? '';
|
|
58
|
+
if (!current.includes(importOption)) {
|
|
59
|
+
process.env.NODE_OPTIONS = current ? `${current} ${importOption}` : importOption;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -207,7 +207,7 @@ test('backfillBodies: sidechain entries and missing or empty paths produce no gr
|
|
|
207
207
|
|
|
208
208
|
test('deriveTranscriptPath munges slash and dot characters with one leading dash', () => {
|
|
209
209
|
assert.equal(
|
|
210
|
-
deriveTranscriptPath('/Users/
|
|
211
|
-
join(homedir(), '.claude', 'projects', '-Users-
|
|
210
|
+
deriveTranscriptPath('/Users/example/Developer/Through.line', 'session-id'),
|
|
211
|
+
join(homedir(), '.claude', 'projects', '-Users-example-Developer-Through-line', 'session-id.jsonl'),
|
|
212
212
|
);
|
|
213
213
|
});
|
package/src/turn-processor.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import { summarizeToL1 } from './haiku-summarizer.mjs';
|
|
|
43
43
|
import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
44
44
|
import { readLatestUsage } from './transcript-usage.mjs';
|
|
45
45
|
import { pathToFileURL } from 'node:url';
|
|
46
|
+
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
46
47
|
|
|
47
48
|
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
48
49
|
export const L2_WINDOW = 20;
|
|
@@ -291,6 +292,7 @@ export async function run() {
|
|
|
291
292
|
|
|
292
293
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
293
294
|
run().catch((err) => {
|
|
295
|
+
recordRuntimeErrorBestEffort('HOOK_PROCESS_TURN_FAILED');
|
|
294
296
|
const msg = err instanceof Error ? err.message : String(err);
|
|
295
297
|
process.stderr.write(`[turn-processor] error: ${msg}\n`);
|
|
296
298
|
process.exit(1);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function applyWindowsPrivateAcl(path, directory = false) {
|
|
4
|
+
if (process.platform !== 'win32') return;
|
|
5
|
+
const script = String.raw`
|
|
6
|
+
$ErrorActionPreference='Stop'
|
|
7
|
+
$target=$env:THROUGHLINE_TEST_ACL_PATH; $isDir=$env:THROUGHLINE_TEST_ACL_DIRECTORY -eq '1'
|
|
8
|
+
$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
9
|
+
$acl=if($isDir){New-Object System.Security.AccessControl.DirectorySecurity}else{New-Object System.Security.AccessControl.FileSecurity}
|
|
10
|
+
$acl.SetOwner($sid); $acl.SetAccessRuleProtection($true,$false)
|
|
11
|
+
$inherit=if($isDir){[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit'}else{[System.Security.AccessControl.InheritanceFlags]::None}
|
|
12
|
+
$rule=New-Object System.Security.AccessControl.FileSystemAccessRule($sid,'FullControl',$inherit,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)
|
|
13
|
+
$acl.AddAccessRule($rule)
|
|
14
|
+
if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO.File]::SetAccessControl($target,$acl)}
|
|
15
|
+
`;
|
|
16
|
+
const result = spawnSync('powershell.exe', [
|
|
17
|
+
'-NoProfile', '-NonInteractive', '-Command', script,
|
|
18
|
+
], {
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
timeout: 3_000,
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
env: {
|
|
23
|
+
...process.env,
|
|
24
|
+
THROUGHLINE_TEST_ACL_PATH: path,
|
|
25
|
+
THROUGHLINE_TEST_ACL_DIRECTORY: directory ? '1' : '0',
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 0) throw new Error(result.stderr || 'Windows ACL fixture setup failed');
|
|
29
|
+
}
|