throughline 0.6.1 → 0.6.3
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 +34 -0
- package/README.md +17 -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 +48 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +73 -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 +248 -0
- package/src/cli/factory-diagnostics.test.mjs +226 -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 +117 -0
- package/src/factory-diagnostics.test.mjs +115 -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 +637 -0
- package/src/runtime-error-store.test.mjs +355 -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 +58 -0
|
@@ -0,0 +1,248 @@
|
|
|
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('unverified')
|
|
167
|
+
? 'unverified'
|
|
168
|
+
: values.includes('ready')
|
|
169
|
+
? 'ready'
|
|
170
|
+
: 'not_applicable',
|
|
171
|
+
reason: 'hooks_inspected',
|
|
172
|
+
events,
|
|
173
|
+
};
|
|
174
|
+
} catch {
|
|
175
|
+
return { status: 'unverified', reason: 'hook_inspection_failed', events: {} };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function inspectFactoryThread({
|
|
180
|
+
env = process.env,
|
|
181
|
+
cwd = process.cwd(),
|
|
182
|
+
codexHome = env.CODEX_HOME || defaultCodexHome(),
|
|
183
|
+
resolveIdentity = resolveCodexThreadIdentity,
|
|
184
|
+
findCandidate = findCodexThreadCandidate,
|
|
185
|
+
} = {}) {
|
|
186
|
+
const identity = resolveIdentity({ codexThreadId: null }, env);
|
|
187
|
+
if (!identity.codexThreadId) {
|
|
188
|
+
return {
|
|
189
|
+
status: 'not_applicable',
|
|
190
|
+
reason: 'codex_thread_not_detected',
|
|
191
|
+
rolloutAvailable: false,
|
|
192
|
+
threadId: null,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const candidate = findCandidate({
|
|
197
|
+
threadId: identity.codexThreadId,
|
|
198
|
+
codexHome,
|
|
199
|
+
projectPath: cwd,
|
|
200
|
+
requireProjectMatch: true,
|
|
201
|
+
});
|
|
202
|
+
return candidate
|
|
203
|
+
? { status: 'ready', reason: 'thread_and_rollout_detected', rolloutAvailable: true, threadId: identity.codexThreadId }
|
|
204
|
+
: { status: 'not_ready', reason: 'rollout_not_found_for_project', rolloutAvailable: false, threadId: null };
|
|
205
|
+
} catch {
|
|
206
|
+
return { status: 'unverified', reason: 'rollout_inspection_failed', rolloutAvailable: false, threadId: identity.codexThreadId };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function collectFactoryDiagnostics({
|
|
211
|
+
env = process.env,
|
|
212
|
+
cwd = process.cwd(),
|
|
213
|
+
version = PACKAGE_VERSION,
|
|
214
|
+
inspectThread = inspectFactoryThread,
|
|
215
|
+
inspectDatabase = inspectFactoryDatabase,
|
|
216
|
+
inspectHooks = inspectFactoryHooks,
|
|
217
|
+
} = {}) {
|
|
218
|
+
const thread = inspectThread({ env, cwd });
|
|
219
|
+
const database = inspectDatabase({ threadId: thread.threadId ?? null, projectPath: cwd });
|
|
220
|
+
const hooks = inspectHooks({ codexHome: env.CODEX_HOME || defaultCodexHome() });
|
|
221
|
+
return buildFactoryDiagnostics({ version, database, hooks, thread });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function run(argv = [], { stdout = process.stdout, ...dependencies } = {}) {
|
|
225
|
+
try {
|
|
226
|
+
parseArgs(argv);
|
|
227
|
+
} catch {
|
|
228
|
+
stdout.write(`${JSON.stringify({
|
|
229
|
+
schema: 'throughline.native_factory_diagnostics.v1',
|
|
230
|
+
version: PACKAGE_VERSION,
|
|
231
|
+
overall: { status: 'unverified' },
|
|
232
|
+
error: 'invalid_diagnostics_request',
|
|
233
|
+
})}\n`);
|
|
234
|
+
return 2;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
stdout.write(`${JSON.stringify(collectFactoryDiagnostics(dependencies))}\n`);
|
|
238
|
+
return 0;
|
|
239
|
+
} catch {
|
|
240
|
+
stdout.write(`${JSON.stringify({
|
|
241
|
+
schema: 'throughline.native_factory_diagnostics.v1',
|
|
242
|
+
version: PACKAGE_VERSION,
|
|
243
|
+
overall: { status: 'unverified' },
|
|
244
|
+
error: 'diagnostics_internal_error',
|
|
245
|
+
})}\n`);
|
|
246
|
+
return 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
collectFactoryDiagnostics,
|
|
10
|
+
inspectFactoryDatabase,
|
|
11
|
+
inspectFactoryHooks,
|
|
12
|
+
parseArgs,
|
|
13
|
+
run,
|
|
14
|
+
} from './factory-diagnostics.mjs';
|
|
15
|
+
|
|
16
|
+
test('factory-diagnostics CLI: JSON-only contract and schema fixture', () => {
|
|
17
|
+
const output = [];
|
|
18
|
+
const exitCode = run(['--json'], {
|
|
19
|
+
stdout: { write(value) { output.push(value); } },
|
|
20
|
+
version: '0.6.1',
|
|
21
|
+
inspectThread: () => ({ status: 'ready', rolloutAvailable: true }),
|
|
22
|
+
inspectDatabase: () => ({ status: 'ready', schemaVersion: 8, handoffMemory: true }),
|
|
23
|
+
inspectHooks: () => ({
|
|
24
|
+
status: 'ready',
|
|
25
|
+
claudeStatus: 'ready',
|
|
26
|
+
events: { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' },
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
assert.equal(exitCode, 0);
|
|
31
|
+
assert.equal(output.length, 1);
|
|
32
|
+
const parsed = JSON.parse(output[0]);
|
|
33
|
+
assert.equal(parsed.schema, 'throughline.native_factory_diagnostics.v1');
|
|
34
|
+
assert.equal(parsed.version, '0.6.1');
|
|
35
|
+
assert.equal(parsed.readiness.restore.status, 'ready');
|
|
36
|
+
assert.equal(parsed.evidence.restoreSmoke.status, 'unverified');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('factory-diagnostics CLI: invalid request is a fixed JSON error', () => {
|
|
40
|
+
const output = [];
|
|
41
|
+
const exitCode = run(['--project', '/secret/path'], {
|
|
42
|
+
stdout: { write(value) { output.push(value); } },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.equal(exitCode, 2);
|
|
46
|
+
assert.equal(output.length, 1);
|
|
47
|
+
const parsed = JSON.parse(output[0]);
|
|
48
|
+
assert.equal(parsed.error, 'invalid_diagnostics_request');
|
|
49
|
+
assert.doesNotMatch(output[0], /secret\/path/);
|
|
50
|
+
assert.throws(() => parseArgs([]), /usage error/);
|
|
51
|
+
assert.throws(() => parseArgs(['--json', '--json']), /usage error/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('factory-diagnostics CLI: internal failure is not reported as a usage error', () => {
|
|
55
|
+
const output = [];
|
|
56
|
+
const exitCode = run(['--json'], {
|
|
57
|
+
stdout: { write(value) { output.push(value); } },
|
|
58
|
+
inspectThread: () => { throw new Error('/secret/internal'); },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
assert.equal(exitCode, 1);
|
|
62
|
+
assert.equal(JSON.parse(output[0]).error, 'diagnostics_internal_error');
|
|
63
|
+
assert.doesNotMatch(output[0], /secret|internal\//);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('factory-diagnostics CLI: unverified inspection remains unverified', () => {
|
|
67
|
+
const result = collectFactoryDiagnostics({
|
|
68
|
+
version: '0.6.1',
|
|
69
|
+
inspectThread: () => ({ status: 'unverified', rolloutAvailable: false }),
|
|
70
|
+
inspectDatabase: () => ({ status: 'unverified', schemaVersion: null, handoffMemory: false }),
|
|
71
|
+
inspectHooks: () => ({ status: 'unverified', claudeStatus: 'unverified', events: {} }),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
assert.equal(result.overall.status, 'unverified');
|
|
75
|
+
assert.equal(result.readiness.capture.status, 'unverified');
|
|
76
|
+
assert.equal(result.readiness.handoff.status, 'unverified');
|
|
77
|
+
assert.throws(() => parseArgs(['--bad']), /usage error/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('factory-diagnostics DB inspection is read-only and does not create an absent DB', () => {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-factory-diagnostics-'));
|
|
82
|
+
const dbPath = join(dir, 'throughline.db');
|
|
83
|
+
const missingPath = join(dir, 'missing.db');
|
|
84
|
+
try {
|
|
85
|
+
const db = new DatabaseSync(dbPath);
|
|
86
|
+
createFactorySchema(db);
|
|
87
|
+
db.close();
|
|
88
|
+
const before = statSync(dbPath).mtimeMs;
|
|
89
|
+
|
|
90
|
+
const result = inspectFactoryDatabase({ dbPath, threadId: 'thread-1' });
|
|
91
|
+
assert.equal(result.status, 'ready');
|
|
92
|
+
assert.equal(result.handoffMemory, false);
|
|
93
|
+
assert.equal(statSync(dbPath).mtimeMs, before);
|
|
94
|
+
|
|
95
|
+
const writer = new DatabaseSync(dbPath);
|
|
96
|
+
writer.exec(`
|
|
97
|
+
INSERT INTO sessions VALUES ('codex:thread-1', '/project/a', 'active', 1, 1, NULL);
|
|
98
|
+
INSERT INTO skeletons VALUES (1, 'codex:thread-1', 1, 'assistant', 'safe', 1, 'codex:thread-1');
|
|
99
|
+
`);
|
|
100
|
+
writer.close();
|
|
101
|
+
const matching = inspectFactoryDatabase({ dbPath, threadId: 'thread-1', projectPath: '/project/a' });
|
|
102
|
+
const mismatch = inspectFactoryDatabase({ dbPath, threadId: 'thread-1', projectPath: '/project/b' });
|
|
103
|
+
assert.equal(matching.handoffMemory, true);
|
|
104
|
+
assert.equal(mismatch.handoffMemory, false);
|
|
105
|
+
|
|
106
|
+
const missing = inspectFactoryDatabase({ dbPath: missingPath });
|
|
107
|
+
assert.equal(missing.status, 'not_applicable');
|
|
108
|
+
assert.equal(existsSync(missingPath), false);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('factory-diagnostics DB inspection rejects version-only fake schema', () => {
|
|
115
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-factory-fake-schema-'));
|
|
116
|
+
const dbPath = join(dir, 'throughline.db');
|
|
117
|
+
try {
|
|
118
|
+
const db = new DatabaseSync(dbPath);
|
|
119
|
+
db.exec('PRAGMA user_version = 8; CREATE TABLE sessions (session_id TEXT)');
|
|
120
|
+
db.close();
|
|
121
|
+
assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(dir, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('factory-diagnostics DB inspection rejects missing runtime constraints', () => {
|
|
128
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-factory-fake-constraint-'));
|
|
129
|
+
const dbPath = join(dir, 'throughline.db');
|
|
130
|
+
try {
|
|
131
|
+
const db = new DatabaseSync(dbPath);
|
|
132
|
+
createFactorySchema(db);
|
|
133
|
+
db.exec(`
|
|
134
|
+
DROP TABLE handoff_batons;
|
|
135
|
+
CREATE TABLE handoff_batons (project_path TEXT, session_id TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
136
|
+
`);
|
|
137
|
+
db.close();
|
|
138
|
+
assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
|
|
139
|
+
} finally {
|
|
140
|
+
rmSync(dir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('factory-diagnostics hook inspection rejects corrupt and false-ready shapes', () => {
|
|
145
|
+
const corrupt = inspectFactoryHooks({
|
|
146
|
+
readHooks: () => ({ configExists: true, configReadable: false, hooksExists: true, hooksReadable: false }),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(corrupt.status, 'unverified');
|
|
149
|
+
|
|
150
|
+
const malformed = inspectFactoryHooks({
|
|
151
|
+
readHooks: () => ({
|
|
152
|
+
configExists: true,
|
|
153
|
+
configReadable: true,
|
|
154
|
+
hooksExists: true,
|
|
155
|
+
hooksReadable: true,
|
|
156
|
+
featureEnabled: true,
|
|
157
|
+
expectedPromptCommand: 'prompt',
|
|
158
|
+
expectedPostToolUseCommand: 'post',
|
|
159
|
+
expectedStopCommand: 'stop',
|
|
160
|
+
managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: true }],
|
|
161
|
+
legacyManagedPromptHooks: [],
|
|
162
|
+
managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
|
|
163
|
+
legacyManagedPostToolUseHooks: [],
|
|
164
|
+
managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
|
|
165
|
+
legacyManagedStopHooks: [],
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
assert.equal(malformed.status, 'not_ready');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('factory-diagnostics hook inspection summarizes every canonical ready event as ready', () => {
|
|
172
|
+
const result = inspectFactoryHooks({
|
|
173
|
+
readHooks: () => ({
|
|
174
|
+
configExists: true,
|
|
175
|
+
configReadable: true,
|
|
176
|
+
hooksExists: true,
|
|
177
|
+
hooksReadable: true,
|
|
178
|
+
featureEnabled: true,
|
|
179
|
+
expectedPromptCommand: 'prompt',
|
|
180
|
+
expectedPostToolUseCommand: 'post',
|
|
181
|
+
expectedStopCommand: 'stop',
|
|
182
|
+
managedPromptHooks: [{ type: 'command', command: 'prompt', timeoutSec: 30, async: false }],
|
|
183
|
+
legacyManagedPromptHooks: [],
|
|
184
|
+
managedPostToolUseHooks: [{ type: 'command', command: 'post', timeoutSec: 30, async: false }],
|
|
185
|
+
legacyManagedPostToolUseHooks: [],
|
|
186
|
+
managedStopHooks: [{ type: 'command', command: 'stop', timeoutSec: 300, async: false }],
|
|
187
|
+
legacyManagedStopHooks: [],
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(result.events, { userPromptSubmit: 'ready', postToolUse: 'ready', stop: 'ready' });
|
|
192
|
+
assert.equal(result.status, 'ready');
|
|
193
|
+
assert.equal(result.reason, 'hooks_inspected');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
function createFactorySchema(db) {
|
|
197
|
+
db.exec(`
|
|
198
|
+
PRAGMA user_version = 8;
|
|
199
|
+
CREATE TABLE sessions (
|
|
200
|
+
session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL,
|
|
201
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT
|
|
202
|
+
);
|
|
203
|
+
CREATE TABLE skeletons (
|
|
204
|
+
id INTEGER, session_id TEXT, turn_number INTEGER, role TEXT, summary TEXT,
|
|
205
|
+
created_at INTEGER, origin_session_id TEXT
|
|
206
|
+
);
|
|
207
|
+
CREATE TABLE bodies (
|
|
208
|
+
id INTEGER, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
|
|
209
|
+
role TEXT, text TEXT, token_count INTEGER, created_at INTEGER,
|
|
210
|
+
UNIQUE(session_id, origin_session_id, turn_number, role)
|
|
211
|
+
);
|
|
212
|
+
CREATE TABLE details (
|
|
213
|
+
id INTEGER, session_id TEXT, turn_number INTEGER, tool_name TEXT,
|
|
214
|
+
input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER,
|
|
215
|
+
origin_session_id TEXT, kind TEXT, source_id TEXT
|
|
216
|
+
);
|
|
217
|
+
CREATE TABLE handoff_batons (
|
|
218
|
+
project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
219
|
+
);
|
|
220
|
+
CREATE UNIQUE INDEX uq_skeletons_turn_v3
|
|
221
|
+
ON skeletons(session_id, origin_session_id, turn_number, role);
|
|
222
|
+
CREATE UNIQUE INDEX uq_details_source
|
|
223
|
+
ON details(session_id, origin_session_id, source_id)
|
|
224
|
+
WHERE source_id IS NOT NULL;
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acknowledgeRuntimeErrors,
|
|
3
|
+
compactRuntimeErrors,
|
|
4
|
+
getRuntimeErrorDiagnostics,
|
|
5
|
+
readRuntimeErrorSnapshot,
|
|
6
|
+
reopenRuntimeError,
|
|
7
|
+
resolveRuntimeError,
|
|
8
|
+
} from '../runtime-error-store.mjs';
|
|
9
|
+
|
|
10
|
+
const USAGE = 'usage: throughline runtime-errors <snapshot|diagnostics|ack|resolve|reopen|compact> [arguments] --json';
|
|
11
|
+
|
|
12
|
+
export function parseArgs(argv = []) {
|
|
13
|
+
const command = argv[0];
|
|
14
|
+
if (!['snapshot', 'diagnostics', 'ack', 'resolve', 'reopen', 'compact'].includes(command)) {
|
|
15
|
+
throw new TypeError(USAGE);
|
|
16
|
+
}
|
|
17
|
+
const options = { command, json: false, afterCursor: 0, limit: 256, value: null };
|
|
18
|
+
for (let index = 1; index < argv.length; index++) {
|
|
19
|
+
const arg = argv[index];
|
|
20
|
+
if (arg === '--json' && !options.json) {
|
|
21
|
+
options.json = true;
|
|
22
|
+
} else if (command === 'snapshot' && arg === '--after-cursor' && argv[index + 1]) {
|
|
23
|
+
options.afterCursor = parseInteger(argv[++index], '--after-cursor');
|
|
24
|
+
} else if (command === 'snapshot' && arg === '--limit' && argv[index + 1]) {
|
|
25
|
+
options.limit = parseInteger(argv[++index], '--limit');
|
|
26
|
+
} else if (['ack', 'resolve', 'reopen'].includes(command) && options.value === null && !arg.startsWith('-')) {
|
|
27
|
+
options.value = arg;
|
|
28
|
+
} else {
|
|
29
|
+
throw new TypeError(USAGE);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!options.json || (['ack', 'resolve', 'reopen'].includes(command) && options.value === null)) {
|
|
33
|
+
throw new TypeError(USAGE);
|
|
34
|
+
}
|
|
35
|
+
return options;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function run(argv = [], dependencies = {}) {
|
|
39
|
+
let options;
|
|
40
|
+
try {
|
|
41
|
+
options = parseArgs(argv);
|
|
42
|
+
} catch {
|
|
43
|
+
process.stderr.write(`[runtime-errors] ${USAGE}\n`);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const env = dependencies.env ?? process.env;
|
|
49
|
+
let result;
|
|
50
|
+
if (options.command === 'snapshot') {
|
|
51
|
+
result = (dependencies.readSnapshot ?? readRuntimeErrorSnapshot)({
|
|
52
|
+
env,
|
|
53
|
+
afterCursor: options.afterCursor,
|
|
54
|
+
limit: options.limit,
|
|
55
|
+
});
|
|
56
|
+
} else if (options.command === 'diagnostics') {
|
|
57
|
+
result = (dependencies.getDiagnostics ?? getRuntimeErrorDiagnostics)({ env });
|
|
58
|
+
} else if (options.command === 'ack') {
|
|
59
|
+
result = (dependencies.acknowledge ?? acknowledgeRuntimeErrors)(
|
|
60
|
+
parseInteger(options.value, 'cursor'),
|
|
61
|
+
{ env },
|
|
62
|
+
);
|
|
63
|
+
} else if (options.command === 'resolve') {
|
|
64
|
+
result = (dependencies.resolve ?? resolveRuntimeError)(options.value, { env });
|
|
65
|
+
} else if (options.command === 'reopen') {
|
|
66
|
+
result = (dependencies.reopen ?? reopenRuntimeError)(options.value, { env });
|
|
67
|
+
} else {
|
|
68
|
+
result = (dependencies.compact ?? compactRuntimeErrors)({ env });
|
|
69
|
+
}
|
|
70
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
71
|
+
return 0;
|
|
72
|
+
} catch {
|
|
73
|
+
process.stderr.write('[runtime-errors] operation_failed\n');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseInteger(value, name) {
|
|
79
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value)) throw new TypeError(`${name} invalid`);
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
if (!Number.isSafeInteger(parsed)) throw new TypeError(`${name} invalid`);
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const _internal = { USAGE };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { parseArgs, run } from './runtime-errors.mjs';
|
|
10
|
+
import { defaultFactoryReporterConfigPath } from '../runtime-error-store.mjs';
|
|
11
|
+
import { applyWindowsPrivateAcl } from '../windows-acl-test-helper.mjs';
|
|
12
|
+
|
|
13
|
+
test('runtime-errors CLI: strict command surface accepts no raw payload options', () => {
|
|
14
|
+
assert.deepEqual(parseArgs(['snapshot', '--after-cursor', '2', '--limit', '3', '--json']), {
|
|
15
|
+
command: 'snapshot', json: true, afterCursor: 2, limit: 3, value: null,
|
|
16
|
+
});
|
|
17
|
+
assert.equal(parseArgs(['reopen', 'a'.repeat(64), '--json']).command, 'reopen');
|
|
18
|
+
for (const args of [
|
|
19
|
+
['snapshot', '--stderr', 'secret', '--json'],
|
|
20
|
+
['snapshot', '--path', '/Users/private', '--json'],
|
|
21
|
+
['resolve', 'abc', '--context', 'secret', '--json'],
|
|
22
|
+
['diagnostics'],
|
|
23
|
+
]) assert.throws(() => parseArgs(args));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('runtime-errors CLI: internal failure is fixed and does not reflect exceptions or paths', () => {
|
|
27
|
+
const writes = { stdout: '', stderr: '' };
|
|
28
|
+
const originalOut = process.stdout.write;
|
|
29
|
+
const originalErr = process.stderr.write;
|
|
30
|
+
process.stdout.write = (chunk) => { writes.stdout += chunk; return true; };
|
|
31
|
+
process.stderr.write = (chunk) => { writes.stderr += chunk; return true; };
|
|
32
|
+
try {
|
|
33
|
+
const exitCode = run(['snapshot', '--json'], {
|
|
34
|
+
readSnapshot() { throw new Error('secret /Users/private stack'); },
|
|
35
|
+
});
|
|
36
|
+
assert.equal(exitCode, 1);
|
|
37
|
+
} finally {
|
|
38
|
+
process.stdout.write = originalOut;
|
|
39
|
+
process.stderr.write = originalErr;
|
|
40
|
+
}
|
|
41
|
+
assert.equal(writes.stdout, '');
|
|
42
|
+
assert.equal(writes.stderr, '[runtime-errors] operation_failed\n');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('runtime-errors CLI: snapshot and diagnostics are JSON-only and contain no state path', () => {
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), 'throughline-runtime-cli-'));
|
|
47
|
+
const env = {
|
|
48
|
+
...process.env,
|
|
49
|
+
HOME: root,
|
|
50
|
+
USERPROFILE: root,
|
|
51
|
+
LOCALAPPDATA: root,
|
|
52
|
+
XDG_CONFIG_HOME: join(root, 'config'),
|
|
53
|
+
XDG_STATE_HOME: join(root, 'state'),
|
|
54
|
+
};
|
|
55
|
+
const configPath = defaultFactoryReporterConfigPath(env);
|
|
56
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
57
|
+
writeFileSync(configPath, JSON.stringify({
|
|
58
|
+
schema_version: '1.0',
|
|
59
|
+
host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
|
|
60
|
+
collection: { enabled: true },
|
|
61
|
+
reporting: { enabled: false },
|
|
62
|
+
}));
|
|
63
|
+
applyWindowsPrivateAcl(configPath);
|
|
64
|
+
const bin = new URL('../../bin/throughline.mjs', import.meta.url);
|
|
65
|
+
for (const args of [
|
|
66
|
+
['runtime-errors', 'snapshot', '--json'],
|
|
67
|
+
['runtime-errors', 'diagnostics', '--json'],
|
|
68
|
+
]) {
|
|
69
|
+
const result = spawnSync(process.execPath, [fileURLToPath(bin), ...args], { env, encoding: 'utf8' });
|
|
70
|
+
assert.equal(result.status, 0, result.stderr);
|
|
71
|
+
const json = JSON.parse(result.stdout);
|
|
72
|
+
assert.equal(typeof json.schema, 'string');
|
|
73
|
+
assert.doesNotMatch(result.stdout, new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
74
|
+
}
|
|
75
|
+
});
|
package/src/cli/trim.mjs
CHANGED
|
@@ -164,11 +164,11 @@ export async function run(args) {
|
|
|
164
164
|
} else {
|
|
165
165
|
process.stdout.write(renderTrimActionReport(result) + '\n');
|
|
166
166
|
}
|
|
167
|
-
process.
|
|
167
|
+
process.exitCode =
|
|
168
168
|
result.status === 'preflight-ready' || result.status === 'execute-durable-verified'
|
|
169
169
|
? 0
|
|
170
|
-
: 1
|
|
171
|
-
|
|
170
|
+
: 1;
|
|
171
|
+
return;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
174
|
if (parsed.json) {
|
|
@@ -177,7 +177,7 @@ export async function run(args) {
|
|
|
177
177
|
process.stdout.write(renderTrimDryRunReport(plan) + '\n');
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
process.
|
|
180
|
+
process.exitCode = plan.status === 'unavailable' ? 1 : 0;
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
async function runExecute(parsed, plan) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
2
2
|
|
|
3
3
|
export const CODEX_HANDOFF_MODEL_SMOKE_ENV = 'THROUGHLINE_EXPERIMENTAL_CODEX_HANDOFF_MODEL_SMOKE';
|
|
4
4
|
export const DEFAULT_CODEX_HANDOFF_MODEL_SMOKE_TIMEOUT_MS = 120_000;
|
|
@@ -48,7 +48,7 @@ export function runCodexHandoffModelSmoke({
|
|
|
48
48
|
assertNonEmptyString(command, 'command');
|
|
49
49
|
assertPositiveInteger(timeoutMs, 'timeoutMs');
|
|
50
50
|
|
|
51
|
-
const result =
|
|
51
|
+
const result = spawnPortableSync(
|
|
52
52
|
command,
|
|
53
53
|
[
|
|
54
54
|
'exec',
|
|
@@ -65,7 +65,6 @@ export function runCodexHandoffModelSmoke({
|
|
|
65
65
|
{
|
|
66
66
|
encoding: 'utf8',
|
|
67
67
|
timeout: timeoutMs,
|
|
68
|
-
shell: process.platform === 'win32',
|
|
69
68
|
env,
|
|
70
69
|
cwd,
|
|
71
70
|
},
|