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,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUDITOR_CONTEXT_SCHEMA,
|
|
3
|
+
deriveAuditorFreshnessExpectation,
|
|
4
|
+
readAuditorContext,
|
|
5
|
+
} from '../auditor-context.mjs';
|
|
6
|
+
|
|
7
|
+
const ERROR_SCHEMA = AUDITOR_CONTEXT_SCHEMA;
|
|
8
|
+
const ARGS_ERROR = {
|
|
9
|
+
schema: ERROR_SCHEMA,
|
|
10
|
+
status: 'error',
|
|
11
|
+
code: 'E_AUDITOR_CONTEXT_ARGS',
|
|
12
|
+
message: 'invalid auditor-context arguments',
|
|
13
|
+
};
|
|
14
|
+
const INTERNAL_ERROR = {
|
|
15
|
+
schema: ERROR_SCHEMA,
|
|
16
|
+
status: 'error',
|
|
17
|
+
code: 'E_AUDITOR_CONTEXT_INTERNAL',
|
|
18
|
+
message: 'auditor context could not be read',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function parseArgs(argv = []) {
|
|
22
|
+
const out = {
|
|
23
|
+
sessionId: null,
|
|
24
|
+
projectRoot: null,
|
|
25
|
+
expectedOriginSessionId: null,
|
|
26
|
+
expectedTurnNumber: null,
|
|
27
|
+
expectedUserSha256: null,
|
|
28
|
+
expectedAssistantSha256: null,
|
|
29
|
+
recentTurns: undefined,
|
|
30
|
+
maxBodyChars: undefined,
|
|
31
|
+
maxTotalChars: undefined,
|
|
32
|
+
dbPath: undefined,
|
|
33
|
+
host: null,
|
|
34
|
+
transcriptPath: null,
|
|
35
|
+
json: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (let index = 0; index < argv.length; index++) {
|
|
39
|
+
const arg = argv[index];
|
|
40
|
+
if (arg === '--json') {
|
|
41
|
+
out.json = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const value = argv[++index];
|
|
45
|
+
if (!value || value.startsWith('-')) throw new TypeError('missing option value');
|
|
46
|
+
if (arg === '--session') out.sessionId = value;
|
|
47
|
+
else if (arg === '--project') out.projectRoot = value;
|
|
48
|
+
else if (arg === '--expected-origin-session') out.expectedOriginSessionId = value;
|
|
49
|
+
else if (arg === '--expected-turn-number') out.expectedTurnNumber = parseNonNegativeInteger(value);
|
|
50
|
+
else if (arg === '--expected-user-sha256') out.expectedUserSha256 = parseSha256(value);
|
|
51
|
+
else if (arg === '--expected-assistant-sha256') out.expectedAssistantSha256 = parseSha256(value);
|
|
52
|
+
else if (arg === '--recent-turns') out.recentTurns = parsePositiveInteger(value);
|
|
53
|
+
else if (arg === '--max-body-chars') out.maxBodyChars = parsePositiveInteger(value);
|
|
54
|
+
else if (arg === '--max-total-chars') out.maxTotalChars = parsePositiveInteger(value);
|
|
55
|
+
else if (arg === '--db') out.dbPath = value;
|
|
56
|
+
else if (arg === '--host' && (value === 'claude' || value === 'codex')) out.host = value;
|
|
57
|
+
else if (arg === '--transcript') out.transcriptPath = value;
|
|
58
|
+
else throw new TypeError('unknown option');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
!out.json ||
|
|
63
|
+
!out.sessionId ||
|
|
64
|
+
!out.projectRoot ||
|
|
65
|
+
!hasFreshnessSource(out)
|
|
66
|
+
) {
|
|
67
|
+
throw new TypeError('missing required option');
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function run(argv = [], {
|
|
73
|
+
read = readAuditorContext,
|
|
74
|
+
deriveExpectation = deriveAuditorFreshnessExpectation,
|
|
75
|
+
stdout = process.stdout,
|
|
76
|
+
stderr = process.stderr,
|
|
77
|
+
} = {}) {
|
|
78
|
+
let args;
|
|
79
|
+
try {
|
|
80
|
+
args = parseArgs(argv);
|
|
81
|
+
} catch {
|
|
82
|
+
writeJson(stderr, ARGS_ERROR);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const derived = args.host
|
|
88
|
+
? deriveExpectation({ host: args.host, transcriptPath: args.transcriptPath, sessionId: args.sessionId })
|
|
89
|
+
: null;
|
|
90
|
+
const result = read({
|
|
91
|
+
dbPath: args.dbPath,
|
|
92
|
+
sessionId: args.sessionId,
|
|
93
|
+
projectRoot: args.projectRoot,
|
|
94
|
+
expectedOriginSessionId: derived?.expectedOriginSessionId ?? args.expectedOriginSessionId,
|
|
95
|
+
expectedTurnNumber: derived?.expectedTurnNumber ?? args.expectedTurnNumber,
|
|
96
|
+
expectedUserSha256: derived?.expectedUserSha256 ?? args.expectedUserSha256,
|
|
97
|
+
expectedAssistantSha256: derived?.expectedAssistantSha256 ?? args.expectedAssistantSha256,
|
|
98
|
+
recentTurns: args.recentTurns,
|
|
99
|
+
maxBodyChars: args.maxBodyChars,
|
|
100
|
+
maxTotalChars: args.maxTotalChars,
|
|
101
|
+
});
|
|
102
|
+
writeJson(stdout, result);
|
|
103
|
+
return 0;
|
|
104
|
+
} catch {
|
|
105
|
+
writeJson(stderr, INTERNAL_ERROR);
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hasFreshnessSource(args) {
|
|
111
|
+
const hasTranscript = Boolean(args.host && args.transcriptPath);
|
|
112
|
+
const hasExplicitPair = Boolean(
|
|
113
|
+
args.expectedOriginSessionId &&
|
|
114
|
+
args.expectedTurnNumber !== null &&
|
|
115
|
+
args.expectedUserSha256 &&
|
|
116
|
+
args.expectedAssistantSha256
|
|
117
|
+
);
|
|
118
|
+
return hasTranscript !== hasExplicitPair;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseNonNegativeInteger(value) {
|
|
122
|
+
if (!/^\d+$/.test(value)) throw new TypeError('invalid integer');
|
|
123
|
+
const parsed = Number(value);
|
|
124
|
+
if (!Number.isSafeInteger(parsed)) throw new TypeError('invalid integer');
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parsePositiveInteger(value) {
|
|
129
|
+
const parsed = parseNonNegativeInteger(value);
|
|
130
|
+
if (parsed < 1) throw new TypeError('invalid integer');
|
|
131
|
+
return parsed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseSha256(value) {
|
|
135
|
+
if (!/^[a-fA-F0-9]{64}$/.test(value)) throw new TypeError('invalid hash');
|
|
136
|
+
return value.toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function writeJson(stream, value) {
|
|
140
|
+
stream.write(`${JSON.stringify(value)}\n`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { AUDITOR_CONTEXT_SCHEMA, hashAuditorBody } from '../auditor-context.mjs';
|
|
11
|
+
import { run } from './auditor-context.mjs';
|
|
12
|
+
|
|
13
|
+
const REPO_ROOT = join(fileURLToPath(new URL('../..', import.meta.url)));
|
|
14
|
+
const BIN_PATH = join(REPO_ROOT, 'bin/throughline.mjs');
|
|
15
|
+
const USER = 'auditor user body';
|
|
16
|
+
const ASSISTANT = 'auditor assistant body';
|
|
17
|
+
|
|
18
|
+
function makeDb({ originSessionId = 'origin-1' } = {}) {
|
|
19
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-cli-'));
|
|
20
|
+
const path = join(dir, 'throughline.db');
|
|
21
|
+
const db = new DatabaseSync(path);
|
|
22
|
+
db.exec(`
|
|
23
|
+
PRAGMA user_version = 8;
|
|
24
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
25
|
+
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
26
|
+
INSERT INTO sessions VALUES ('session-1', '/repo');
|
|
27
|
+
INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES ('session-1', '${originSessionId}', 3, 'user', '${USER}', 1);
|
|
28
|
+
INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES ('session-1', '${originSessionId}', 3, 'assistant', '${ASSISTANT}', 2);
|
|
29
|
+
`);
|
|
30
|
+
db.close();
|
|
31
|
+
return { dir, path };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function args(path) {
|
|
35
|
+
return [
|
|
36
|
+
'auditor-context', '--session', 'session-1', '--project', '/repo', '--expected-origin-session', 'origin-1',
|
|
37
|
+
'--expected-turn-number', '3', '--expected-user-sha256', hashAuditorBody(USER),
|
|
38
|
+
'--expected-assistant-sha256', hashAuditorBody(ASSISTANT), '--db', path, '--json',
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('auditor-context CLI prints only fresh JSON to stdout', () => {
|
|
43
|
+
const { dir, path } = makeDb();
|
|
44
|
+
try {
|
|
45
|
+
const result = spawnSync(process.execPath, [BIN_PATH, ...args(path)], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
46
|
+
assert.equal(result.status, 0, result.stderr);
|
|
47
|
+
assert.equal(result.stderr, '');
|
|
48
|
+
const output = JSON.parse(result.stdout);
|
|
49
|
+
assert.equal(output.schema, AUDITOR_CONTEXT_SCHEMA);
|
|
50
|
+
assert.equal(output.status, 'fresh');
|
|
51
|
+
assert.equal(output.turns[0].user, USER);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('auditor-context CLI transcript freshness mode derives a fresh Claude pair', () => {
|
|
58
|
+
const { dir, path } = makeDb({ originSessionId: 'session-1' });
|
|
59
|
+
const transcript = join(dir, 'claude.jsonl');
|
|
60
|
+
try {
|
|
61
|
+
writeFileSync(transcript, [
|
|
62
|
+
claudeRow('user', 'earlier request'),
|
|
63
|
+
claudeRow('assistant', 'earlier answer'),
|
|
64
|
+
claudeRow('user', USER),
|
|
65
|
+
claudeRow('assistant', ASSISTANT),
|
|
66
|
+
].map(JSON.stringify).join('\n'));
|
|
67
|
+
const result = spawnSync(process.execPath, [
|
|
68
|
+
BIN_PATH, 'auditor-context', '--session', 'session-1', '--project', '/repo', '--host', 'claude',
|
|
69
|
+
'--transcript', transcript, '--db', path, '--json',
|
|
70
|
+
], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
71
|
+
assert.equal(result.status, 0, result.stderr);
|
|
72
|
+
assert.equal(result.stderr, '');
|
|
73
|
+
assert.equal(JSON.parse(result.stdout).status, 'fresh');
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(dir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('auditor-context CLI rejects mixed and partial freshness sources with the fixed args error', () => {
|
|
80
|
+
const transcript = '/tmp/auditor-context-transcript.jsonl';
|
|
81
|
+
const base = ['--session', 's', '--project', '/p', '--json'];
|
|
82
|
+
const explicit = [
|
|
83
|
+
'--expected-origin-session', 'origin', '--expected-turn-number', '1',
|
|
84
|
+
'--expected-user-sha256', 'a'.repeat(64), '--expected-assistant-sha256', 'b'.repeat(64),
|
|
85
|
+
];
|
|
86
|
+
const transcriptSource = ['--host', 'claude', '--transcript', transcript];
|
|
87
|
+
for (const argv of [
|
|
88
|
+
[...base, ...explicit, ...transcriptSource],
|
|
89
|
+
[...base, '--host', 'claude'],
|
|
90
|
+
[...base, '--transcript', transcript],
|
|
91
|
+
[...base, '--expected-origin-session', 'origin'],
|
|
92
|
+
]) {
|
|
93
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
94
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
95
|
+
assert.equal(run(argv, { stdout, stderr }), 1);
|
|
96
|
+
assert.equal(stdout.text, '');
|
|
97
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
98
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_ARGS', message: 'invalid auditor-context arguments',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('auditor-context CLI transcript parse failures use fixed JSON without leaking transcript path or body', () => {
|
|
104
|
+
const transcript = '/private/path/with-secret-body.jsonl';
|
|
105
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
106
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
107
|
+
const argv = ['--session', 's', '--project', '/p', '--host', 'codex', '--transcript', transcript, '--json'];
|
|
108
|
+
assert.equal(run(argv, {
|
|
109
|
+
deriveExpectation: () => { throw new Error(`parse failure ${transcript} ${USER}`); },
|
|
110
|
+
stdout,
|
|
111
|
+
stderr,
|
|
112
|
+
}), 1);
|
|
113
|
+
assert.equal(stdout.text, '');
|
|
114
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
115
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_INTERNAL', message: 'auditor context could not be read',
|
|
116
|
+
});
|
|
117
|
+
assert.doesNotMatch(stderr.text, /private|secret|auditor user|jsonl/i);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('auditor-context CLI returns fixed JSON errors without raw body, hash, DB, or thrown error text', () => {
|
|
121
|
+
const stderr = { text: '', write(value) { this.text += value; } };
|
|
122
|
+
const stdout = { text: '', write(value) { this.text += value; } };
|
|
123
|
+
assert.equal(run(['--session', 'only'], { stdout, stderr }), 1);
|
|
124
|
+
assert.equal(stdout.text, '');
|
|
125
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
126
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_ARGS', message: 'invalid auditor-context arguments',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
stderr.text = '';
|
|
130
|
+
assert.equal(run(['--session', 's', '--project', '/p', '--expected-origin-session', 'o', '--expected-turn-number', '1', '--expected-user-sha256', 'a'.repeat(64), '--expected-assistant-sha256', 'b'.repeat(64), '--json'], {
|
|
131
|
+
read: () => { throw new Error(`private ${USER} ${hashAuditorBody(USER)} /secret.db`); }, stdout, stderr,
|
|
132
|
+
}), 1);
|
|
133
|
+
assert.deepEqual(JSON.parse(stderr.text), {
|
|
134
|
+
schema: AUDITOR_CONTEXT_SCHEMA, status: 'error', code: 'E_AUDITOR_CONTEXT_INTERNAL', message: 'auditor context could not be read',
|
|
135
|
+
});
|
|
136
|
+
assert.doesNotMatch(stderr.text, /private|secret|auditor user|[a-f0-9]{64}/i);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('auditor-context bin dispatch and help advertise the JSON-only command', () => {
|
|
140
|
+
const help = spawnSync(process.execPath, [BIN_PATH, '--help'], { cwd: REPO_ROOT, encoding: 'utf8' });
|
|
141
|
+
assert.equal(help.status, 0, help.stderr);
|
|
142
|
+
assert.match(help.stdout, /throughline auditor-context --session <id> --project <root>/);
|
|
143
|
+
assert.match(help.stdout, /always requires --json/);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function claudeRow(role, text) {
|
|
147
|
+
return { type: role, message: { role, content: [{ type: 'text', text }] } };
|
|
148
|
+
}
|
package/src/cli/codex-hook.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recordRuntimeErrorBestEffort } from '../runtime-error-store.mjs';
|
|
2
|
+
|
|
1
3
|
function parseArgs(argv) {
|
|
2
4
|
const out = {
|
|
3
5
|
event: null,
|
|
@@ -57,10 +59,9 @@ function parsePayload(raw) {
|
|
|
57
59
|
|
|
58
60
|
function codexHomeFromTranscriptPath(transcriptPath) {
|
|
59
61
|
if (typeof transcriptPath !== 'string') return null;
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return transcriptPath.slice(0, idx);
|
|
62
|
+
const match = /[\\/]sessions[\\/]/.exec(transcriptPath);
|
|
63
|
+
if (!match || match.index <= 0) return null;
|
|
64
|
+
return transcriptPath.slice(0, match.index);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
function suppressExperimentalWarnings() {
|
|
@@ -88,6 +89,21 @@ async function captureCodexHookSession({
|
|
|
88
89
|
buildMonitorUsage = null,
|
|
89
90
|
summarize = true,
|
|
90
91
|
} = {}) {
|
|
92
|
+
if (isSpotterChildEnvironment(env)) {
|
|
93
|
+
return {
|
|
94
|
+
status: 'skipped',
|
|
95
|
+
reason: 'spotter_child_backend',
|
|
96
|
+
db,
|
|
97
|
+
identity: null,
|
|
98
|
+
projectPath: null,
|
|
99
|
+
codexHome: null,
|
|
100
|
+
captured: null,
|
|
101
|
+
summarized: null,
|
|
102
|
+
monitorState: null,
|
|
103
|
+
usage: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
const [
|
|
92
108
|
{ getDb },
|
|
93
109
|
{ captureCodexRolloutToDb },
|
|
@@ -202,6 +218,11 @@ async function captureCodexHookSession({
|
|
|
202
218
|
};
|
|
203
219
|
}
|
|
204
220
|
|
|
221
|
+
function isSpotterChildEnvironment(env = {}) {
|
|
222
|
+
return ['SPOTTER_PARENT_PID', 'SPOTTER_BACKEND', 'SPOTTER_CHILD_BACKEND']
|
|
223
|
+
.some((name) => typeof env?.[name] === 'string' && env[name].length > 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
205
226
|
export async function runCodexStopHook({
|
|
206
227
|
args = {},
|
|
207
228
|
payload = {},
|
|
@@ -346,6 +367,7 @@ export async function run(argv = []) {
|
|
|
346
367
|
parsed = parseArgs(argv);
|
|
347
368
|
payload = parsePayload(await readStdin());
|
|
348
369
|
} catch (err) {
|
|
370
|
+
recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
|
|
349
371
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
350
372
|
process.stderr.write(`[codex-hook] ${msg}\n`);
|
|
351
373
|
process.exit(1);
|
|
@@ -379,6 +401,7 @@ export async function run(argv = []) {
|
|
|
379
401
|
}
|
|
380
402
|
process.exit(result.status === 'ok' || result.status === 'skipped' ? 0 : 1);
|
|
381
403
|
} catch (err) {
|
|
404
|
+
recordRuntimeErrorBestEffort('HOOK_CODEX_FAILED', { env: process.env });
|
|
382
405
|
const msg = err instanceof Error ? err.message : 'unknown';
|
|
383
406
|
if (parsed.json) {
|
|
384
407
|
process.stdout.write(
|
|
@@ -500,6 +500,10 @@ test('codex-hook stop skips cleanly when Codex thread id is unavailable', async
|
|
|
500
500
|
test('codexHomeFromTranscriptPath infers CODEX_HOME from rollout path', () => {
|
|
501
501
|
const path = '/tmp/codex-home/sessions/2026/05/06/rollout-2026-05-06T09-40-50-id.jsonl';
|
|
502
502
|
assert.equal(_internal.codexHomeFromTranscriptPath(path), '/tmp/codex-home');
|
|
503
|
+
assert.equal(
|
|
504
|
+
_internal.codexHomeFromTranscriptPath('C:\\codex-home\\sessions\\2026\\05\\06\\rollout-2026-05-06T09-40-50-id.jsonl'),
|
|
505
|
+
'C:\\codex-home',
|
|
506
|
+
);
|
|
503
507
|
});
|
|
504
508
|
|
|
505
509
|
test('parseArgs: accepts user-prompt-submit Codex hook event', () => {
|
|
@@ -326,7 +326,8 @@ export async function run(args) {
|
|
|
326
326
|
|
|
327
327
|
if (parsed.json) process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
|
|
328
328
|
else process.stdout.write(renderTextResult(payload) + '\n');
|
|
329
|
-
process.
|
|
329
|
+
process.exitCode =
|
|
330
|
+
payload.status === 'app-server-restart-stable' && !payload.restoreSafetyRiskInspected ? 0 : 1;
|
|
330
331
|
}
|
|
331
332
|
|
|
332
333
|
function buildRestoreTextNeedles(restoreSafety) {
|
|
@@ -296,7 +296,7 @@ export async function run(args) {
|
|
|
296
296
|
|
|
297
297
|
if (parsed.json) process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
298
298
|
else process.stdout.write(renderTextResult(result) + '\n');
|
|
299
|
-
process.
|
|
299
|
+
process.exitCode = result.status === 'restore-source-audit-complete' ? 0 : 1;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
302
|
export const _internal = {
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -493,6 +493,9 @@ function readCodexHookDiagnosis(codexHome) {
|
|
|
493
493
|
expectedPromptCommand,
|
|
494
494
|
expectedPostToolUseCommand,
|
|
495
495
|
hooksReadable: false,
|
|
496
|
+
hooksExists: existsSync(hooksPath),
|
|
497
|
+
configExists: existsSync(configPath),
|
|
498
|
+
configReadable: false,
|
|
496
499
|
featureEnabled: false,
|
|
497
500
|
codexHooksFeatureEnabled: false,
|
|
498
501
|
hooksFeatureEnabled: false,
|
|
@@ -513,6 +516,7 @@ function readCodexHookDiagnosis(codexHome) {
|
|
|
513
516
|
if (existsSync(configPath)) {
|
|
514
517
|
try {
|
|
515
518
|
const config = readFileSync(configPath, 'utf8');
|
|
519
|
+
out.configReadable = true;
|
|
516
520
|
out.codexHooksFeatureEnabled = /^\s*codex_hooks\s*=\s*true\s*$/m.test(config);
|
|
517
521
|
out.hooksFeatureEnabled = /^\s*hooks\s*=\s*true\s*$/m.test(config);
|
|
518
522
|
out.featureEnabled = out.codexHooksFeatureEnabled || out.hooksFeatureEnabled;
|
|
@@ -522,7 +526,7 @@ function readCodexHookDiagnosis(codexHome) {
|
|
|
522
526
|
}
|
|
523
527
|
}
|
|
524
528
|
|
|
525
|
-
if (!
|
|
529
|
+
if (!out.hooksExists) return out;
|
|
526
530
|
let parsed;
|
|
527
531
|
try {
|
|
528
532
|
parsed = JSON.parse(readFileSync(hooksPath, 'utf8'));
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
|
|
5
|
+
import { defaultAuditorContextDbPath } from '../auditor-context.mjs';
|
|
6
|
+
import { buildFactoryDiagnostics } from '../factory-diagnostics.mjs';
|
|
7
|
+
import { findCodexThreadCandidate, defaultCodexHome } from '../codex-thread-index.mjs';
|
|
8
|
+
import { resolveCodexThreadIdentity } from '../codex-thread-identity.mjs';
|
|
9
|
+
import { CURRENT_VERSION } from '../db.mjs';
|
|
10
|
+
import { _internal as doctorInternal } from './doctor.mjs';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const PACKAGE_VERSION = require('../../package.json').version;
|
|
14
|
+
const REQUIRED_DATABASE_COLUMNS = {
|
|
15
|
+
sessions: ['session_id', 'project_path', 'status', 'created_at', 'updated_at', 'merged_into'],
|
|
16
|
+
skeletons: ['id', 'session_id', 'turn_number', 'role', 'summary', 'created_at', 'origin_session_id'],
|
|
17
|
+
bodies: ['id', 'session_id', 'origin_session_id', 'turn_number', 'role', 'text', 'token_count', 'created_at'],
|
|
18
|
+
details: ['id', 'session_id', 'turn_number', 'tool_name', 'input_text', 'output_text', 'token_count', 'created_at', 'origin_session_id', 'kind', 'source_id'],
|
|
19
|
+
handoff_batons: ['project_path', 'session_id', 'created_at'],
|
|
20
|
+
};
|
|
21
|
+
const REQUIRED_DATABASE_INDEXES = ['uq_skeletons_turn_v3', 'uq_details_source'];
|
|
22
|
+
const REQUIRED_INDEX_SHAPES = {
|
|
23
|
+
uq_skeletons_turn_v3: {
|
|
24
|
+
table: 'skeletons',
|
|
25
|
+
columns: ['session_id', 'origin_session_id', 'turn_number', 'role'],
|
|
26
|
+
partial: false,
|
|
27
|
+
},
|
|
28
|
+
uq_details_source: {
|
|
29
|
+
table: 'details',
|
|
30
|
+
columns: ['session_id', 'origin_session_id', 'source_id'],
|
|
31
|
+
partial: true,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function parseArgs(argv = []) {
|
|
36
|
+
if (argv.length !== 1 || argv[0] !== '--json') throw new TypeError('usage error');
|
|
37
|
+
return { json: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function inspectFactoryDatabase({
|
|
41
|
+
dbPath = defaultAuditorContextDbPath(),
|
|
42
|
+
threadId = null,
|
|
43
|
+
projectPath = null,
|
|
44
|
+
} = {}) {
|
|
45
|
+
if (!existsSync(dbPath)) {
|
|
46
|
+
return databaseResult('not_applicable', null, false);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let db;
|
|
50
|
+
try {
|
|
51
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
52
|
+
const schemaVersion = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
53
|
+
if (schemaVersion !== CURRENT_VERSION || !hasFactoryDatabaseShape(db)) {
|
|
54
|
+
return databaseResult('not_ready', schemaVersion, false);
|
|
55
|
+
}
|
|
56
|
+
if (!threadId || typeof projectPath !== 'string' || projectPath.length === 0) {
|
|
57
|
+
return databaseResult('ready', schemaVersion, false);
|
|
58
|
+
}
|
|
59
|
+
const sessionId = `codex:${threadId}`;
|
|
60
|
+
const counts = db.prepare(
|
|
61
|
+
`SELECT
|
|
62
|
+
(SELECT COUNT(*) FROM skeletons WHERE session_id = :sessionId) AS l1,
|
|
63
|
+
(SELECT COUNT(*) FROM bodies WHERE session_id = :sessionId) AS l2,
|
|
64
|
+
(SELECT COUNT(*) FROM details WHERE session_id = :sessionId) AS l3
|
|
65
|
+
WHERE EXISTS (
|
|
66
|
+
SELECT 1 FROM sessions
|
|
67
|
+
WHERE session_id = :sessionId AND lower(project_path) = lower(:projectPath)
|
|
68
|
+
)`,
|
|
69
|
+
).get({ sessionId, projectPath });
|
|
70
|
+
const handoffMemory = counts !== undefined &&
|
|
71
|
+
Number(counts.l1 ?? 0) + Number(counts.l2 ?? 0) + Number(counts.l3 ?? 0) > 0;
|
|
72
|
+
return databaseResult('ready', schemaVersion, handoffMemory);
|
|
73
|
+
} catch {
|
|
74
|
+
return databaseResult('unverified', null, false);
|
|
75
|
+
} finally {
|
|
76
|
+
db?.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function databaseResult(status, schemaVersion, handoffMemory) {
|
|
81
|
+
return {
|
|
82
|
+
status,
|
|
83
|
+
schemaVersion,
|
|
84
|
+
supportedSchemaVersion: CURRENT_VERSION,
|
|
85
|
+
handoffMemory,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasFactoryDatabaseShape(db) {
|
|
90
|
+
const tableInfo = {};
|
|
91
|
+
for (const [table, requiredColumns] of Object.entries(REQUIRED_DATABASE_COLUMNS)) {
|
|
92
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
93
|
+
tableInfo[table] = new Map(rows.map((row) => [row.name, row]));
|
|
94
|
+
const actual = new Set(tableInfo[table].keys());
|
|
95
|
+
if (requiredColumns.some((column) => !actual.has(column))) return false;
|
|
96
|
+
}
|
|
97
|
+
if (tableInfo.sessions.get('session_id')?.pk !== 1 ||
|
|
98
|
+
tableInfo.handoff_batons.get('project_path')?.pk !== 1) return false;
|
|
99
|
+
for (const [table, columns] of Object.entries({
|
|
100
|
+
sessions: ['project_path', 'status', 'created_at', 'updated_at'],
|
|
101
|
+
handoff_batons: ['session_id', 'created_at'],
|
|
102
|
+
})) {
|
|
103
|
+
if (columns.some((column) => tableInfo[table].get(column)?.notnull !== 1)) return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const name of REQUIRED_DATABASE_INDEXES) {
|
|
107
|
+
const expected = REQUIRED_INDEX_SHAPES[name];
|
|
108
|
+
const index = db.prepare(`PRAGMA index_list(${expected.table})`).all()
|
|
109
|
+
.find((row) => row.name === name);
|
|
110
|
+
if (!index || index.unique !== 1 || Boolean(index.partial) !== expected.partial) return false;
|
|
111
|
+
const columns = db.prepare(`PRAGMA index_info(${name})`).all().map((row) => row.name);
|
|
112
|
+
if (columns.length !== expected.columns.length ||
|
|
113
|
+
columns.some((column, indexPosition) => column !== expected.columns[indexPosition])) return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bodiesUnique = db.prepare('PRAGMA index_list(bodies)').all().some((index) => {
|
|
117
|
+
if (index.unique !== 1) return false;
|
|
118
|
+
const columns = db.prepare(`PRAGMA index_info(${index.name})`).all().map((row) => row.name);
|
|
119
|
+
return columns.join('\0') === ['session_id', 'origin_session_id', 'turn_number', 'role'].join('\0');
|
|
120
|
+
});
|
|
121
|
+
return bodiesUnique;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function eventStatus({ hooks, legacyHooks, featureEnabled, expectedCommand, timeoutSec }) {
|
|
125
|
+
if (hooks.length === 0) return featureEnabled ? 'not_ready' : 'not_applicable';
|
|
126
|
+
if (!featureEnabled || legacyHooks.length > 0 || hooks.length !== 1) return 'not_ready';
|
|
127
|
+
const hook = hooks[0];
|
|
128
|
+
return hook.type === 'command' && hook.command === expectedCommand && hook.timeoutSec === timeoutSec &&
|
|
129
|
+
hook.async === false ? 'ready' : 'not_ready';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function inspectFactoryHooks({ codexHome = defaultCodexHome(), readHooks = doctorInternal.readCodexHookDiagnosis } = {}) {
|
|
133
|
+
try {
|
|
134
|
+
const diagnosis = readHooks(codexHome);
|
|
135
|
+
if ((diagnosis.configExists && !diagnosis.configReadable) ||
|
|
136
|
+
(diagnosis.hooksExists && !diagnosis.hooksReadable)) {
|
|
137
|
+
return { status: 'unverified', reason: 'hook_configuration_unreadable', events: {} };
|
|
138
|
+
}
|
|
139
|
+
const events = {
|
|
140
|
+
userPromptSubmit: eventStatus({
|
|
141
|
+
hooks: diagnosis.managedPromptHooks,
|
|
142
|
+
legacyHooks: diagnosis.legacyManagedPromptHooks,
|
|
143
|
+
featureEnabled: diagnosis.featureEnabled,
|
|
144
|
+
expectedCommand: diagnosis.expectedPromptCommand,
|
|
145
|
+
timeoutSec: 30,
|
|
146
|
+
}),
|
|
147
|
+
postToolUse: eventStatus({
|
|
148
|
+
hooks: diagnosis.managedPostToolUseHooks,
|
|
149
|
+
legacyHooks: diagnosis.legacyManagedPostToolUseHooks,
|
|
150
|
+
featureEnabled: diagnosis.featureEnabled,
|
|
151
|
+
expectedCommand: diagnosis.expectedPostToolUseCommand,
|
|
152
|
+
timeoutSec: 30,
|
|
153
|
+
}),
|
|
154
|
+
stop: eventStatus({
|
|
155
|
+
hooks: diagnosis.managedStopHooks,
|
|
156
|
+
legacyHooks: diagnosis.legacyManagedStopHooks,
|
|
157
|
+
featureEnabled: diagnosis.featureEnabled,
|
|
158
|
+
expectedCommand: diagnosis.expectedStopCommand,
|
|
159
|
+
timeoutSec: 300,
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
162
|
+
const values = Object.values(events);
|
|
163
|
+
return {
|
|
164
|
+
status: values.includes('not_ready')
|
|
165
|
+
? 'not_ready'
|
|
166
|
+
: values.includes('ready')
|
|
167
|
+
? 'unverified'
|
|
168
|
+
: 'not_applicable',
|
|
169
|
+
reason: 'hooks_inspected',
|
|
170
|
+
events,
|
|
171
|
+
};
|
|
172
|
+
} catch {
|
|
173
|
+
return { status: 'unverified', reason: 'hook_inspection_failed', events: {} };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function inspectFactoryThread({
|
|
178
|
+
env = process.env,
|
|
179
|
+
cwd = process.cwd(),
|
|
180
|
+
codexHome = env.CODEX_HOME || defaultCodexHome(),
|
|
181
|
+
resolveIdentity = resolveCodexThreadIdentity,
|
|
182
|
+
findCandidate = findCodexThreadCandidate,
|
|
183
|
+
} = {}) {
|
|
184
|
+
const identity = resolveIdentity({ codexThreadId: null }, env);
|
|
185
|
+
if (!identity.codexThreadId) {
|
|
186
|
+
return {
|
|
187
|
+
status: 'not_applicable',
|
|
188
|
+
reason: 'codex_thread_not_detected',
|
|
189
|
+
rolloutAvailable: false,
|
|
190
|
+
threadId: null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const candidate = findCandidate({
|
|
195
|
+
threadId: identity.codexThreadId,
|
|
196
|
+
codexHome,
|
|
197
|
+
projectPath: cwd,
|
|
198
|
+
requireProjectMatch: true,
|
|
199
|
+
});
|
|
200
|
+
return candidate
|
|
201
|
+
? { status: 'ready', reason: 'thread_and_rollout_detected', rolloutAvailable: true, threadId: identity.codexThreadId }
|
|
202
|
+
: { status: 'not_ready', reason: 'rollout_not_found_for_project', rolloutAvailable: false, threadId: null };
|
|
203
|
+
} catch {
|
|
204
|
+
return { status: 'unverified', reason: 'rollout_inspection_failed', rolloutAvailable: false, threadId: identity.codexThreadId };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function collectFactoryDiagnostics({
|
|
209
|
+
env = process.env,
|
|
210
|
+
cwd = process.cwd(),
|
|
211
|
+
version = PACKAGE_VERSION,
|
|
212
|
+
inspectThread = inspectFactoryThread,
|
|
213
|
+
inspectDatabase = inspectFactoryDatabase,
|
|
214
|
+
inspectHooks = inspectFactoryHooks,
|
|
215
|
+
} = {}) {
|
|
216
|
+
const thread = inspectThread({ env, cwd });
|
|
217
|
+
const database = inspectDatabase({ threadId: thread.threadId ?? null, projectPath: cwd });
|
|
218
|
+
const hooks = inspectHooks({ codexHome: env.CODEX_HOME || defaultCodexHome() });
|
|
219
|
+
return buildFactoryDiagnostics({ version, database, hooks, thread });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function run(argv = [], { stdout = process.stdout, ...dependencies } = {}) {
|
|
223
|
+
try {
|
|
224
|
+
parseArgs(argv);
|
|
225
|
+
} catch {
|
|
226
|
+
stdout.write(`${JSON.stringify({
|
|
227
|
+
schema: 'throughline.native_factory_diagnostics.v1',
|
|
228
|
+
version: PACKAGE_VERSION,
|
|
229
|
+
overall: { status: 'unverified' },
|
|
230
|
+
error: 'invalid_diagnostics_request',
|
|
231
|
+
})}\n`);
|
|
232
|
+
return 2;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
stdout.write(`${JSON.stringify(collectFactoryDiagnostics(dependencies))}\n`);
|
|
236
|
+
return 0;
|
|
237
|
+
} catch {
|
|
238
|
+
stdout.write(`${JSON.stringify({
|
|
239
|
+
schema: 'throughline.native_factory_diagnostics.v1',
|
|
240
|
+
version: PACKAGE_VERSION,
|
|
241
|
+
overall: { status: 'unverified' },
|
|
242
|
+
error: 'diagnostics_internal_error',
|
|
243
|
+
})}\n`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
}
|