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,201 @@
|
|
|
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
|
+
function createFactorySchema(db) {
|
|
172
|
+
db.exec(`
|
|
173
|
+
PRAGMA user_version = 8;
|
|
174
|
+
CREATE TABLE sessions (
|
|
175
|
+
session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL,
|
|
176
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT
|
|
177
|
+
);
|
|
178
|
+
CREATE TABLE skeletons (
|
|
179
|
+
id INTEGER, session_id TEXT, turn_number INTEGER, role TEXT, summary TEXT,
|
|
180
|
+
created_at INTEGER, origin_session_id TEXT
|
|
181
|
+
);
|
|
182
|
+
CREATE TABLE bodies (
|
|
183
|
+
id INTEGER, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
|
|
184
|
+
role TEXT, text TEXT, token_count INTEGER, created_at INTEGER,
|
|
185
|
+
UNIQUE(session_id, origin_session_id, turn_number, role)
|
|
186
|
+
);
|
|
187
|
+
CREATE TABLE details (
|
|
188
|
+
id INTEGER, session_id TEXT, turn_number INTEGER, tool_name TEXT,
|
|
189
|
+
input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER,
|
|
190
|
+
origin_session_id TEXT, kind TEXT, source_id TEXT
|
|
191
|
+
);
|
|
192
|
+
CREATE TABLE handoff_batons (
|
|
193
|
+
project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
194
|
+
);
|
|
195
|
+
CREATE UNIQUE INDEX uq_skeletons_turn_v3
|
|
196
|
+
ON skeletons(session_id, origin_session_id, turn_number, role);
|
|
197
|
+
CREATE UNIQUE INDEX uq_details_source
|
|
198
|
+
ON details(session_id, origin_session_id, source_id)
|
|
199
|
+
WHERE source_id IS NOT NULL;
|
|
200
|
+
`);
|
|
201
|
+
}
|
|
@@ -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
|
},
|
|
@@ -8,12 +8,25 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
const REPO_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
10
10
|
|
|
11
|
+
function makeSidecarFixture(dir, body) {
|
|
12
|
+
const script = join(dir, 'fake-sidecar.mjs');
|
|
13
|
+
writeFileSync(script, body);
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
const bin = join(dir, 'fake-sidecar.cmd');
|
|
16
|
+
writeFileSync(bin, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
|
|
17
|
+
writeFileSync(join(dir, 'fake-sidecar.ps1'), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
|
|
18
|
+
return bin;
|
|
19
|
+
}
|
|
20
|
+
const bin = join(dir, 'fake-sidecar');
|
|
21
|
+
writeFileSync(bin, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
|
|
22
|
+
chmodSync(bin, 0o755);
|
|
23
|
+
return bin;
|
|
24
|
+
}
|
|
25
|
+
|
|
11
26
|
test('codex-sidecar-diagnostics CLI exits 0 only for configured diagnostics', () => {
|
|
12
27
|
const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-cli-'));
|
|
13
28
|
try {
|
|
14
|
-
const bin =
|
|
15
|
-
writeFileSync(bin, '#!/usr/bin/env bash\nprintf "ok\\n"\nexit 0\n');
|
|
16
|
-
chmodSync(bin, 0o755);
|
|
29
|
+
const bin = makeSidecarFixture(dir, "process.stdout.write('ok\\n');\n");
|
|
17
30
|
|
|
18
31
|
const result = spawnSync(
|
|
19
32
|
process.execPath,
|
|
@@ -38,12 +51,10 @@ test('codex-sidecar-diagnostics CLI exits 0 only for configured diagnostics', ()
|
|
|
38
51
|
test('codex-sidecar-dry-run CLI exits 0 for normalized dry-run request', () => {
|
|
39
52
|
const dir = mkdtempSync(join(tmpdir(), 'tl-sidecar-dry-run-cli-'));
|
|
40
53
|
try {
|
|
41
|
-
const bin =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
'#!/usr/bin/env bash\nprintf \'{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n\'\n',
|
|
54
|
+
const bin = makeSidecarFixture(
|
|
55
|
+
dir,
|
|
56
|
+
"process.stdout.write('{\"status\":\"dry-run\",\"workflow\":\"risk-check\",\"normalizedRequest\":{\"dryRun\":true}}\\n');\n",
|
|
45
57
|
);
|
|
46
|
-
chmodSync(bin, 0o755);
|
|
47
58
|
|
|
48
59
|
const result = spawnSync(
|
|
49
60
|
process.execPath,
|
package/src/codex-sidecar.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnPortableSync } from './portable-spawn-sync.mjs';
|
|
2
2
|
|
|
3
3
|
export const CODEX_SIDECAR_WORKFLOWS = Object.freeze([
|
|
4
4
|
'review',
|
|
@@ -27,10 +27,7 @@ export function shouldShellWrapSidecarCommand(platform = process.platform) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function runCodexSidecarCommand(command, args, options = {}) {
|
|
30
|
-
return
|
|
31
|
-
...options,
|
|
32
|
-
shell: shouldShellWrapSidecarCommand(),
|
|
33
|
-
});
|
|
30
|
+
return spawnPortableSync(command, args, options);
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
export function inferWorkflowForPreset(preset) {
|
|
@@ -12,8 +12,16 @@ import {
|
|
|
12
12
|
} from './codex-sidecar.mjs';
|
|
13
13
|
|
|
14
14
|
function makeExecutable(dir, name, body) {
|
|
15
|
+
const script = join(dir, `${name}.mjs`);
|
|
16
|
+
writeFileSync(script, body);
|
|
17
|
+
if (process.platform === 'win32') {
|
|
18
|
+
const path = join(dir, `${name}.cmd`);
|
|
19
|
+
writeFileSync(path, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(script)} %*\r\n`);
|
|
20
|
+
writeFileSync(join(dir, `${name}.ps1`), `& ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} @args\nexit $LASTEXITCODE\n`);
|
|
21
|
+
return path;
|
|
22
|
+
}
|
|
15
23
|
const path = join(dir, name);
|
|
16
|
-
writeFileSync(path,
|
|
24
|
+
writeFileSync(path, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(script)} "$@"\n`);
|
|
17
25
|
chmodSync(path, 0o755);
|
|
18
26
|
return path;
|
|
19
27
|
}
|
|
@@ -44,7 +52,7 @@ test('diagnoseCodexSidecar: non-zero diagnostics is unavailable', () => {
|
|
|
44
52
|
const bin = makeExecutable(
|
|
45
53
|
dir,
|
|
46
54
|
'fake-sidecar',
|
|
47
|
-
'
|
|
55
|
+
"process.stderr.write('bad config'); process.exit(7);\n",
|
|
48
56
|
);
|
|
49
57
|
const result = diagnoseCodexSidecar({
|
|
50
58
|
projectPath: '/repo',
|
|
@@ -66,7 +74,7 @@ test('diagnoseCodexSidecar: zero diagnostics is configured', () => {
|
|
|
66
74
|
const bin = makeExecutable(
|
|
67
75
|
dir,
|
|
68
76
|
'fake-sidecar',
|
|
69
|
-
|
|
77
|
+
"process.stdout.write(`ok diagnostics for ${process.argv.slice(2).join(' ')}\\n`);\n",
|
|
70
78
|
);
|
|
71
79
|
const result = diagnoseCodexSidecar({
|
|
72
80
|
projectPath: '/repo',
|
|
@@ -102,9 +110,9 @@ test('runCodexSidecarDryRun: emits a dry-run request for review preset', () => {
|
|
|
102
110
|
const bin = makeExecutable(
|
|
103
111
|
dir,
|
|
104
112
|
'fake-sidecar',
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
`import { writeFileSync } from 'node:fs';
|
|
114
|
+
writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
|
|
115
|
+
process.stdout.write('{"status":"dry-run","workflow":"review","normalizedRequest":{"dryRun":true}}\\n');
|
|
108
116
|
`,
|
|
109
117
|
);
|
|
110
118
|
const result = runCodexSidecarDryRun({
|
|
@@ -141,9 +149,9 @@ test('runCodexSidecarDryRun: infers risk-check workflow from preset', () => {
|
|
|
141
149
|
const bin = makeExecutable(
|
|
142
150
|
dir,
|
|
143
151
|
'fake-sidecar',
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
`import { writeFileSync } from 'node:fs';
|
|
153
|
+
writeFileSync(${JSON.stringify(argsFile)}, process.argv.slice(2).join('\\n') + '\\n');
|
|
154
|
+
process.stdout.write('{"status":"dry-run","workflow":"risk-check","normalizedRequest":{"dryRun":true}}\\n');
|
|
147
155
|
`,
|
|
148
156
|
);
|
|
149
157
|
const result = runCodexSidecarDryRun({
|
|
@@ -28,7 +28,7 @@ export function listCodexThreadCandidates({
|
|
|
28
28
|
const meta = readSessionMeta(rollout.path);
|
|
29
29
|
const indexed = index.get(rollout.threadId) ?? {};
|
|
30
30
|
const cwd = meta?.cwd ?? null;
|
|
31
|
-
const matchesProject = cwd ? normalizePath(cwd)
|
|
31
|
+
const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
|
|
32
32
|
return {
|
|
33
33
|
id: rollout.threadId,
|
|
34
34
|
threadName: indexed.thread_name ?? null,
|
|
@@ -69,7 +69,7 @@ export function findCodexThreadCandidate({
|
|
|
69
69
|
const meta = readSessionMeta(rollout.path);
|
|
70
70
|
const indexed = index.get(rollout.threadId) ?? {};
|
|
71
71
|
const cwd = meta?.cwd ?? null;
|
|
72
|
-
const matchesProject = cwd ? normalizePath(cwd)
|
|
72
|
+
const matchesProject = cwd ? isSameProjectOrDescendant(normalizePath(cwd), normalizedProject) : false;
|
|
73
73
|
return {
|
|
74
74
|
id: rollout.threadId,
|
|
75
75
|
threadName: indexed.thread_name ?? null,
|
|
@@ -169,6 +169,16 @@ function compareCandidates(a, b) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function normalizePath(value) {
|
|
172
|
+
const raw = String(value);
|
|
173
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
174
|
+
let resolved = raw;
|
|
175
|
+
try {
|
|
176
|
+
if (existsSync(raw)) resolved = realpathSync.native(raw);
|
|
177
|
+
} catch {
|
|
178
|
+
// Keep the lexical Windows path when it cannot be resolved.
|
|
179
|
+
}
|
|
180
|
+
return resolved.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
181
|
+
}
|
|
172
182
|
let resolved = resolve(value);
|
|
173
183
|
try {
|
|
174
184
|
if (existsSync(resolved)) resolved = realpathSync.native(resolved);
|
|
@@ -177,3 +187,8 @@ function normalizePath(value) {
|
|
|
177
187
|
}
|
|
178
188
|
return resolved.split(sep).join('/').replace(/\/+$/, '').toLowerCase();
|
|
179
189
|
}
|
|
190
|
+
|
|
191
|
+
function isSameProjectOrDescendant(candidate, root) {
|
|
192
|
+
if (!candidate || !root) return false;
|
|
193
|
+
return candidate === root || candidate.startsWith(`${root}/`);
|
|
194
|
+
}
|