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,279 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
symlinkSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import test from 'node:test';
|
|
15
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
16
|
+
|
|
17
|
+
import { captureCodexRolloutToDb } from './codex-capture.mjs';
|
|
18
|
+
import { runCodexUserPromptSubmitHook } from './cli/codex-hook.mjs';
|
|
19
|
+
import { findCodexThreadCandidate } from './codex-thread-index.mjs';
|
|
20
|
+
|
|
21
|
+
const THREAD_ID = '019dfaba-f87e-7f41-a144-d5ca7c6dd7f9';
|
|
22
|
+
|
|
23
|
+
function makeCaptureDb() {
|
|
24
|
+
const db = new DatabaseSync(':memory:');
|
|
25
|
+
db.exec(`
|
|
26
|
+
CREATE TABLE sessions (
|
|
27
|
+
session_id TEXT PRIMARY KEY,
|
|
28
|
+
project_path TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
30
|
+
created_at INTEGER NOT NULL,
|
|
31
|
+
updated_at INTEGER NOT NULL,
|
|
32
|
+
merged_into TEXT
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE skeletons (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
session_id TEXT NOT NULL,
|
|
37
|
+
origin_session_id TEXT,
|
|
38
|
+
turn_number INTEGER NOT NULL,
|
|
39
|
+
role TEXT NOT NULL,
|
|
40
|
+
summary TEXT NOT NULL,
|
|
41
|
+
created_at INTEGER NOT NULL
|
|
42
|
+
);
|
|
43
|
+
CREATE TABLE bodies (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
session_id TEXT NOT NULL,
|
|
46
|
+
origin_session_id TEXT NOT NULL,
|
|
47
|
+
turn_number INTEGER NOT NULL,
|
|
48
|
+
role TEXT NOT NULL,
|
|
49
|
+
text TEXT NOT NULL,
|
|
50
|
+
token_count INTEGER,
|
|
51
|
+
created_at INTEGER NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE TABLE details (
|
|
54
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
55
|
+
session_id TEXT NOT NULL,
|
|
56
|
+
origin_session_id TEXT,
|
|
57
|
+
turn_number INTEGER,
|
|
58
|
+
tool_name TEXT NOT NULL,
|
|
59
|
+
input_text TEXT,
|
|
60
|
+
output_text TEXT,
|
|
61
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
created_at INTEGER NOT NULL,
|
|
63
|
+
kind TEXT,
|
|
64
|
+
source_id TEXT
|
|
65
|
+
);
|
|
66
|
+
`);
|
|
67
|
+
return db;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function event(type, payload = {}) {
|
|
71
|
+
return {
|
|
72
|
+
timestamp: '2026-07-13T00:00:00.000Z',
|
|
73
|
+
type: 'event_msg',
|
|
74
|
+
payload: { type, ...payload },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function developerMemory(text = '## Throughline: Active Work Context\ninternal memory') {
|
|
79
|
+
return {
|
|
80
|
+
timestamp: '2026-07-13T00:00:01.000Z',
|
|
81
|
+
type: 'response_item',
|
|
82
|
+
payload: {
|
|
83
|
+
type: 'message',
|
|
84
|
+
role: 'developer',
|
|
85
|
+
content: [{ type: 'input_text', text }],
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toolInput() {
|
|
91
|
+
return {
|
|
92
|
+
timestamp: '2026-07-13T00:00:01.000Z',
|
|
93
|
+
type: 'response_item',
|
|
94
|
+
payload: {
|
|
95
|
+
type: 'function_call',
|
|
96
|
+
name: 'exec_command',
|
|
97
|
+
arguments: '{"cmd":"pwd"}',
|
|
98
|
+
call_id: 'call_inflight',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeRollout(home, { cwd, id = THREAD_ID, events = [] }) {
|
|
104
|
+
const dir = join(home, 'sessions', '2026', '07', '13');
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
const path = join(dir, `rollout-2026-07-13T00-00-00-${id}.jsonl`);
|
|
107
|
+
const rows = [
|
|
108
|
+
{
|
|
109
|
+
timestamp: '2026-07-13T00:00:00.000Z',
|
|
110
|
+
type: 'session_meta',
|
|
111
|
+
payload: { id, cwd, source: 'vscode', cli_version: '0.128.0-alpha.1' },
|
|
112
|
+
},
|
|
113
|
+
...events,
|
|
114
|
+
];
|
|
115
|
+
writeFileSync(path, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`);
|
|
116
|
+
return path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test('Phase 0: capture projection preserves one completed user/assistant pair and its Codex origin identity', () => {
|
|
120
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-home-'));
|
|
121
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-phase0-capture-project-'));
|
|
122
|
+
const db = makeCaptureDb();
|
|
123
|
+
try {
|
|
124
|
+
writeRollout(home, {
|
|
125
|
+
cwd: project,
|
|
126
|
+
events: [
|
|
127
|
+
event('user_message', { message: 'completed user request' }),
|
|
128
|
+
event('task_started'),
|
|
129
|
+
event('agent_message', { message: 'completed assistant response' }),
|
|
130
|
+
event('task_complete'),
|
|
131
|
+
developerMemory(),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = captureCodexRolloutToDb(db, { threadId: THREAD_ID, codexHome: home, projectPath: project });
|
|
136
|
+
assert.equal(result.status, 'captured');
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
db
|
|
139
|
+
.prepare('SELECT origin_session_id, turn_number, role, text FROM bodies ORDER BY id')
|
|
140
|
+
.all()
|
|
141
|
+
.map((row) => ({ ...row })),
|
|
142
|
+
[
|
|
143
|
+
{ origin_session_id: `codex:${THREAD_ID}`, turn_number: 1, role: 'user', text: 'completed user request' },
|
|
144
|
+
{
|
|
145
|
+
origin_session_id: `codex:${THREAD_ID}`,
|
|
146
|
+
turn_number: 1,
|
|
147
|
+
role: 'assistant',
|
|
148
|
+
text: 'completed assistant response',
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
'audit projection candidates are completed conversation pairs only',
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
db.close();
|
|
155
|
+
rmSync(home, { recursive: true, force: true });
|
|
156
|
+
rmSync(project, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('Phase 0: read-only WAL audit harness sees committed data during a writer transaction without writing DB sidecars', () => {
|
|
161
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-phase0-wal-'));
|
|
162
|
+
const path = join(dir, 'throughline.db');
|
|
163
|
+
const writer = new DatabaseSync(path);
|
|
164
|
+
let reader;
|
|
165
|
+
try {
|
|
166
|
+
writer.exec('PRAGMA journal_mode = WAL; CREATE TABLE audit_probe (value TEXT); INSERT INTO audit_probe VALUES (\'committed\');');
|
|
167
|
+
writer.exec("BEGIN IMMEDIATE; UPDATE audit_probe SET value = 'uncommitted';");
|
|
168
|
+
|
|
169
|
+
const before = snapshotSqliteFiles(path);
|
|
170
|
+
reader = new DatabaseSync(path, { readOnly: true });
|
|
171
|
+
assert.equal(reader.prepare('SELECT value FROM audit_probe').get().value, 'committed');
|
|
172
|
+
assert.throws(() => reader.exec("INSERT INTO audit_probe VALUES ('forbidden')"));
|
|
173
|
+
assert.deepEqual(snapshotSqliteFiles(path), before, 'audit reader must not modify DB, -wal, or -shm');
|
|
174
|
+
} finally {
|
|
175
|
+
reader?.close();
|
|
176
|
+
writer.exec('ROLLBACK');
|
|
177
|
+
writer.close();
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('Phase 0: Spotter child environment prevents Throughline Codex hook re-entry before any capture side effect', async () => {
|
|
183
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-home-'));
|
|
184
|
+
const project = mkdtempSync(join(tmpdir(), 'tl-phase0-spotter-project-'));
|
|
185
|
+
try {
|
|
186
|
+
writeRollout(home, {
|
|
187
|
+
cwd: project,
|
|
188
|
+
events: [
|
|
189
|
+
event('user_message', { message: 'must not be captured from Spotter child' }),
|
|
190
|
+
event('task_started'),
|
|
191
|
+
event('agent_message', { message: 'must not be captured from Spotter child' }),
|
|
192
|
+
event('task_complete'),
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
for (const childEnv of ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']) {
|
|
197
|
+
const db = makeCaptureDb();
|
|
198
|
+
let monitorWrites = 0;
|
|
199
|
+
let taskEnsures = 0;
|
|
200
|
+
try {
|
|
201
|
+
const result = await runCodexUserPromptSubmitHook({
|
|
202
|
+
args: { codexThreadId: THREAD_ID, codexHome: home, projectPath: project },
|
|
203
|
+
env: { [childEnv]: '1' },
|
|
204
|
+
db,
|
|
205
|
+
ensureMonitorTask: () => {
|
|
206
|
+
taskEnsures++;
|
|
207
|
+
},
|
|
208
|
+
writeMonitorState: () => {
|
|
209
|
+
monitorWrites++;
|
|
210
|
+
},
|
|
211
|
+
buildMonitorUsage: () => null,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
assert.equal(result.status, 'skipped', childEnv);
|
|
215
|
+
assert.equal(result.reason, 'spotter_child_backend', childEnv);
|
|
216
|
+
assert.equal(taskEnsures, 0, childEnv);
|
|
217
|
+
assert.equal(monitorWrites, 0, childEnv);
|
|
218
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM bodies').get().count, 0, childEnv);
|
|
219
|
+
} finally {
|
|
220
|
+
db.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
rmSync(home, { recursive: true, force: true });
|
|
225
|
+
rmSync(project, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('Phase 0: project identity accepts a rollout under a marker root subdirectory through symlink and Windows-style case', () => {
|
|
230
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-phase0-identity-home-'));
|
|
231
|
+
const markerRoot = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-root-'));
|
|
232
|
+
const aliasParent = mkdtempSync(join(tmpdir(), 'tl-phase0-marker-alias-'));
|
|
233
|
+
const child = join(markerRoot, 'packages', 'adapter');
|
|
234
|
+
const alias = join(aliasParent, 'spotter-link');
|
|
235
|
+
try {
|
|
236
|
+
mkdirSync(child, { recursive: true });
|
|
237
|
+
symlinkSync(markerRoot, alias);
|
|
238
|
+
assert.ok(lstatSync(alias).isSymbolicLink());
|
|
239
|
+
|
|
240
|
+
writeRollout(home, { cwd: child });
|
|
241
|
+
assert.equal(
|
|
242
|
+
findCodexThreadCandidate({ threadId: THREAD_ID, codexHome: home, projectPath: alias })?.id,
|
|
243
|
+
THREAD_ID,
|
|
244
|
+
'marker root must include rollout cwd descendants after symlink resolution',
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
const windowsThreadId = '019dfabb-1111-7111-8111-111111111111';
|
|
248
|
+
writeRollout(home, {
|
|
249
|
+
id: windowsThreadId,
|
|
250
|
+
cwd: 'C:\\Users\\Kite\\Developer\\Spotter\\packages\\adapter',
|
|
251
|
+
});
|
|
252
|
+
assert.equal(
|
|
253
|
+
findCodexThreadCandidate({
|
|
254
|
+
threadId: windowsThreadId,
|
|
255
|
+
codexHome: home,
|
|
256
|
+
projectPath: 'c:/users/kite/developer/spotter',
|
|
257
|
+
})?.id,
|
|
258
|
+
windowsThreadId,
|
|
259
|
+
'Windows-style path case must retain marker-root descendant identity',
|
|
260
|
+
);
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(home, { recursive: true, force: true });
|
|
263
|
+
rmSync(markerRoot, { recursive: true, force: true });
|
|
264
|
+
rmSync(aliasParent, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
function snapshotSqliteFiles(path) {
|
|
269
|
+
return [path, `${path}-wal`, `${path}-shm`].map((file) => {
|
|
270
|
+
if (!existsSync(file)) return { file, exists: false };
|
|
271
|
+
const stat = lstatSync(file);
|
|
272
|
+
try {
|
|
273
|
+
return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, bytes: readFileSync(file).toString('hex') };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error?.code === 'EBUSY') return { file, exists: true, size: stat.size, mtimeMs: stat.mtimeMs, readError: 'EBUSY' };
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { basename, delimiter, dirname, extname, isAbsolute, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
function windowsPath(env) {
|
|
6
|
+
return Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function pairedPowerShellShim(command) {
|
|
10
|
+
const extension = extname(command).toLowerCase();
|
|
11
|
+
if (extension !== '.cmd' && extension !== '.bat') return null;
|
|
12
|
+
const sibling = join(dirname(command), `${basename(command, extension)}.ps1`);
|
|
13
|
+
return existsSync(sibling) ? sibling : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveWindowsCommand(command, env) {
|
|
17
|
+
const extension = extname(command).toLowerCase();
|
|
18
|
+
if (isAbsolute(command) || command.includes('\\') || command.includes('/')) {
|
|
19
|
+
return pairedPowerShellShim(command) ?? command;
|
|
20
|
+
}
|
|
21
|
+
for (const directory of windowsPath(env).split(delimiter).filter(Boolean)) {
|
|
22
|
+
for (const suffix of ['.exe', '.ps1', '.cmd', '.bat', '']) {
|
|
23
|
+
const candidate = join(directory, `${command}${suffix}`);
|
|
24
|
+
if (!existsSync(candidate)) continue;
|
|
25
|
+
return pairedPowerShellShim(candidate) ?? candidate;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return command;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function spawnPortableSync(command, args, options = {}) {
|
|
32
|
+
const platform = options.platform ?? process.platform;
|
|
33
|
+
const spawnOptions = { ...options };
|
|
34
|
+
delete spawnOptions.platform;
|
|
35
|
+
spawnOptions.shell = false;
|
|
36
|
+
|
|
37
|
+
if (platform !== 'win32') return spawnSync(command, args, spawnOptions);
|
|
38
|
+
|
|
39
|
+
const env = spawnOptions.env ?? process.env;
|
|
40
|
+
const resolved = resolveWindowsCommand(command, env);
|
|
41
|
+
const extension = extname(resolved).toLowerCase();
|
|
42
|
+
if (['.js', '.cjs', '.mjs'].includes(extension)) {
|
|
43
|
+
return spawnSync(process.execPath, [resolved, ...args], spawnOptions);
|
|
44
|
+
}
|
|
45
|
+
if (extension === '.ps1') {
|
|
46
|
+
return spawnSync('powershell.exe', [
|
|
47
|
+
'-NoLogo',
|
|
48
|
+
'-NoProfile',
|
|
49
|
+
'-NonInteractive',
|
|
50
|
+
'-ExecutionPolicy',
|
|
51
|
+
'Bypass',
|
|
52
|
+
'-File',
|
|
53
|
+
resolved,
|
|
54
|
+
...args,
|
|
55
|
+
], spawnOptions);
|
|
56
|
+
}
|
|
57
|
+
return spawnSync(resolved, args, spawnOptions);
|
|
58
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
7
|
+
|
|
8
|
+
test('spawnPortableSync: Windows cmd shim preserves argv boundaries and stdin', {
|
|
9
|
+
skip: process.platform !== 'win32' ? 'Windows cmd shim contract' : undefined,
|
|
10
|
+
}, () => {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-portable-spawn-'));
|
|
12
|
+
try {
|
|
13
|
+
const child = join(dir, 'child.mjs');
|
|
14
|
+
const command = join(dir, 'child.cmd');
|
|
15
|
+
const powerShellShim = join(dir, 'child.ps1');
|
|
16
|
+
writeFileSync(child, `
|
|
17
|
+
process.stdin.setEncoding('utf8');
|
|
18
|
+
let input = '';
|
|
19
|
+
process.stdin.on('data', (chunk) => { input += chunk; });
|
|
20
|
+
process.stdin.on('end', () => {
|
|
21
|
+
process.stdout.write(JSON.stringify({ args: process.argv.slice(2), input }));
|
|
22
|
+
});
|
|
23
|
+
`);
|
|
24
|
+
writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(child)} %*\r\n`);
|
|
25
|
+
writeFileSync(powerShellShim, `& ${JSON.stringify(process.execPath)} ${JSON.stringify(child)} @args\nexit $LASTEXITCODE\n`);
|
|
26
|
+
|
|
27
|
+
const result = spawnPortableSync(command, ['review prompt', 'a&b', '100%'], {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
input: 'stdin body',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(result.status, 0, result.stderr || result.error?.message);
|
|
33
|
+
assert.deepEqual(JSON.parse(result.stdout), {
|
|
34
|
+
args: ['review prompt', 'a&b', '100%'],
|
|
35
|
+
input: 'stdin body',
|
|
36
|
+
});
|
|
37
|
+
} finally {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -32,6 +32,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
|
|
|
32
32
|
import { join, dirname } from 'node:path';
|
|
33
33
|
import { homedir } from 'node:os';
|
|
34
34
|
import { pathToFileURL } from 'node:url';
|
|
35
|
+
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
35
36
|
|
|
36
37
|
// Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
|
|
37
38
|
const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
|
|
@@ -243,6 +244,7 @@ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
|
|
|
243
244
|
|
|
244
245
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
245
246
|
run().catch((err) => {
|
|
247
|
+
recordRuntimeErrorBestEffort('HOOK_PROMPT_SUBMIT_FAILED');
|
|
246
248
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
247
249
|
process.stderr.write(`[prompt-submit] error: ${msg}\n`);
|
|
248
250
|
process.exit(1);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { defaultFactoryReporterConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
|
|
9
|
+
import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
|
|
10
|
+
|
|
11
|
+
const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
function createEnabledEnvironment(prefix) {
|
|
14
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
15
|
+
const env = {
|
|
16
|
+
...process.env,
|
|
17
|
+
HOME: root,
|
|
18
|
+
USERPROFILE: root,
|
|
19
|
+
LOCALAPPDATA: root,
|
|
20
|
+
XDG_CONFIG_HOME: join(root, 'config'),
|
|
21
|
+
XDG_STATE_HOME: join(root, 'state'),
|
|
22
|
+
};
|
|
23
|
+
const configPath = defaultFactoryReporterConfigPath(env);
|
|
24
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
25
|
+
writeFileSync(configPath, JSON.stringify({
|
|
26
|
+
schema_version: '1.0',
|
|
27
|
+
host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
|
|
28
|
+
collection: { enabled: true },
|
|
29
|
+
reporting: { enabled: false },
|
|
30
|
+
}));
|
|
31
|
+
applyWindowsPrivateAcl(configPath);
|
|
32
|
+
return { root, env };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('top-level hook owners record one fixed aggregate per failure without replacing hook failure', () => {
|
|
36
|
+
const { env } = createEnabledEnvironment('throughline-runtime-hook-');
|
|
37
|
+
const cases = [
|
|
38
|
+
['session-start'],
|
|
39
|
+
['prompt-submit'],
|
|
40
|
+
['process-turn'],
|
|
41
|
+
['codex-hook', 'stop'],
|
|
42
|
+
];
|
|
43
|
+
for (const args of cases) {
|
|
44
|
+
const result = spawnSync(process.execPath, [BIN, ...args], {
|
|
45
|
+
env,
|
|
46
|
+
input: '{invalid-json',
|
|
47
|
+
encoding: 'utf8',
|
|
48
|
+
});
|
|
49
|
+
assert.notEqual(result.status, 0, args.join(' '));
|
|
50
|
+
assert.notEqual(result.stderr, '', args.join(' '));
|
|
51
|
+
assert.doesNotMatch(result.stderr, /store_unavailable/);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const storePath = defaultRuntimeErrorStorePath(env);
|
|
55
|
+
let store = JSON.parse(readFileSync(storePath, 'utf8'));
|
|
56
|
+
assert.equal(store.records.length, 4);
|
|
57
|
+
assert.deepEqual(store.records.map((record) => record.error_code).sort(), [
|
|
58
|
+
'HOOK_CODEX_FAILED',
|
|
59
|
+
'HOOK_PROCESS_TURN_FAILED',
|
|
60
|
+
'HOOK_PROMPT_SUBMIT_FAILED',
|
|
61
|
+
'HOOK_SESSION_START_FAILED',
|
|
62
|
+
]);
|
|
63
|
+
assert.ok(store.records.every((record) => record.count === 1));
|
|
64
|
+
|
|
65
|
+
spawnSync(process.execPath, [BIN, 'process-turn'], {
|
|
66
|
+
env,
|
|
67
|
+
input: '{invalid-json',
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
});
|
|
70
|
+
store = JSON.parse(readFileSync(storePath, 'utf8'));
|
|
71
|
+
assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
|
|
75
|
+
const { env } = createEnabledEnvironment('throughline-runtime-hook-store-fail-');
|
|
76
|
+
const storePath = defaultRuntimeErrorStorePath(env);
|
|
77
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
78
|
+
writeFileSync(storePath, '{broken');
|
|
79
|
+
|
|
80
|
+
const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
|
|
81
|
+
env,
|
|
82
|
+
input: '{invalid-json',
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
});
|
|
85
|
+
assert.notEqual(result.status, 0);
|
|
86
|
+
assert.match(result.stderr, /store_unavailable/);
|
|
87
|
+
assert.match(result.stderr, /SyntaxError|JSON/);
|
|
88
|
+
assert.doesNotMatch(result.stderr, /runtime error store schema invalid/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('FIFO config cannot block the original hook failure', { skip: process.platform === 'win32' }, () => {
|
|
92
|
+
const { env } = createEnabledEnvironment('throughline-runtime-hook-fifo-');
|
|
93
|
+
const config = defaultFactoryReporterConfigPath(env);
|
|
94
|
+
execFileSync('rm', ['-f', config]);
|
|
95
|
+
execFileSync('mkfifo', [config]);
|
|
96
|
+
const started = Date.now();
|
|
97
|
+
const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
|
|
98
|
+
env,
|
|
99
|
+
input: '{invalid-json',
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
timeout: 2_000,
|
|
102
|
+
});
|
|
103
|
+
assert.notEqual(result.status, 0);
|
|
104
|
+
assert(Date.now() - started < 1_500);
|
|
105
|
+
assert.match(result.stderr, /SyntaxError|JSON/);
|
|
106
|
+
});
|