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.
Files changed (42) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +17 -2
  3. package/bin/throughline.mjs +31 -0
  4. package/docs/00_overview.md +2 -0
  5. package/docs/04_public_release_plan.md +2 -0
  6. package/docs/13_native_factory_diagnostics_plan.md +48 -0
  7. package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +73 -0
  8. package/package.json +2 -2
  9. package/src/auditor-context.test.mjs +8 -1
  10. package/src/cli/codex-hook.mjs +7 -4
  11. package/src/cli/codex-hook.test.mjs +4 -0
  12. package/src/cli/codex-restore-smoke.mjs +2 -1
  13. package/src/cli/codex-restore-source-audit.mjs +1 -1
  14. package/src/cli/doctor.mjs +5 -1
  15. package/src/cli/factory-diagnostics.mjs +248 -0
  16. package/src/cli/factory-diagnostics.test.mjs +226 -0
  17. package/src/cli/runtime-errors.mjs +85 -0
  18. package/src/cli/runtime-errors.test.mjs +75 -0
  19. package/src/cli/trim.mjs +4 -4
  20. package/src/codex-handoff-model-smoke.mjs +2 -3
  21. package/src/codex-sidecar-cli.test.mjs +19 -8
  22. package/src/codex-sidecar.mjs +2 -5
  23. package/src/codex-sidecar.test.mjs +17 -9
  24. package/src/codex-thread-index.mjs +7 -1
  25. package/src/db.mjs +1 -1
  26. package/src/factory-diagnostics.mjs +117 -0
  27. package/src/factory-diagnostics.test.mjs +115 -0
  28. package/src/haiku-summarizer.mjs +3 -5
  29. package/src/haiku-summarizer.test.mjs +52 -47
  30. package/src/hook-entrypoints.test.mjs +3 -1
  31. package/src/phase0-spotter-contract.test.mjs +6 -7
  32. package/src/portable-spawn-sync.mjs +58 -0
  33. package/src/portable-spawn-sync.test.mjs +40 -0
  34. package/src/prompt-submit.mjs +2 -0
  35. package/src/runtime-error-hook.test.mjs +106 -0
  36. package/src/runtime-error-observer.mjs +8 -0
  37. package/src/runtime-error-store.mjs +637 -0
  38. package/src/runtime-error-store.test.mjs +355 -0
  39. package/src/session-start.mjs +2 -0
  40. package/src/test-env.mjs +59 -2
  41. package/src/turn-processor.mjs +2 -0
  42. package/src/windows-acl-test-helper.mjs +58 -0
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { basename, delimiter, dirname, extname, isAbsolute, join } from 'node:path';
4
+
5
+ function windowsPath(env) {
6
+ return Object.entries(env).find(([key]) => key.toLowerCase() === 'path')?.[1] ?? '';
7
+ }
8
+
9
+ function pairedPowerShellShim(command) {
10
+ const extension = extname(command).toLowerCase();
11
+ if (extension !== '.cmd' && extension !== '.bat') return null;
12
+ const sibling = join(dirname(command), `${basename(command, extension)}.ps1`);
13
+ return existsSync(sibling) ? sibling : null;
14
+ }
15
+
16
+ function resolveWindowsCommand(command, env) {
17
+ const extension = extname(command).toLowerCase();
18
+ if (isAbsolute(command) || command.includes('\\') || command.includes('/')) {
19
+ return pairedPowerShellShim(command) ?? command;
20
+ }
21
+ for (const directory of windowsPath(env).split(delimiter).filter(Boolean)) {
22
+ for (const suffix of ['.exe', '.ps1', '.cmd', '.bat', '']) {
23
+ const candidate = join(directory, `${command}${suffix}`);
24
+ if (!existsSync(candidate)) continue;
25
+ return pairedPowerShellShim(candidate) ?? candidate;
26
+ }
27
+ }
28
+ return command;
29
+ }
30
+
31
+ export function spawnPortableSync(command, args, options = {}) {
32
+ const platform = options.platform ?? process.platform;
33
+ const spawnOptions = { ...options };
34
+ delete spawnOptions.platform;
35
+ spawnOptions.shell = false;
36
+
37
+ if (platform !== 'win32') return spawnSync(command, args, spawnOptions);
38
+
39
+ const env = spawnOptions.env ?? process.env;
40
+ const resolved = resolveWindowsCommand(command, env);
41
+ const extension = extname(resolved).toLowerCase();
42
+ if (['.js', '.cjs', '.mjs'].includes(extension)) {
43
+ return spawnSync(process.execPath, [resolved, ...args], spawnOptions);
44
+ }
45
+ if (extension === '.ps1') {
46
+ return spawnSync('powershell.exe', [
47
+ '-NoLogo',
48
+ '-NoProfile',
49
+ '-NonInteractive',
50
+ '-ExecutionPolicy',
51
+ 'Bypass',
52
+ '-File',
53
+ resolved,
54
+ ...args,
55
+ ], spawnOptions);
56
+ }
57
+ return spawnSync(resolved, args, spawnOptions);
58
+ }
@@ -0,0 +1,40 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { spawnPortableSync } from './portable-spawn-sync.mjs';
7
+
8
+ test('spawnPortableSync: Windows cmd shim preserves argv boundaries and stdin', {
9
+ skip: process.platform !== 'win32' ? 'Windows cmd shim contract' : undefined,
10
+ }, () => {
11
+ const dir = mkdtempSync(join(tmpdir(), 'tl-portable-spawn-'));
12
+ try {
13
+ const child = join(dir, 'child.mjs');
14
+ const command = join(dir, 'child.cmd');
15
+ const powerShellShim = join(dir, 'child.ps1');
16
+ writeFileSync(child, `
17
+ process.stdin.setEncoding('utf8');
18
+ let input = '';
19
+ process.stdin.on('data', (chunk) => { input += chunk; });
20
+ process.stdin.on('end', () => {
21
+ process.stdout.write(JSON.stringify({ args: process.argv.slice(2), input }));
22
+ });
23
+ `);
24
+ writeFileSync(command, `@echo off\r\n${JSON.stringify(process.execPath)} ${JSON.stringify(child)} %*\r\n`);
25
+ writeFileSync(powerShellShim, `& ${JSON.stringify(process.execPath)} ${JSON.stringify(child)} @args\nexit $LASTEXITCODE\n`);
26
+
27
+ const result = spawnPortableSync(command, ['review prompt', 'a&b', '100%'], {
28
+ encoding: 'utf8',
29
+ input: 'stdin body',
30
+ });
31
+
32
+ assert.equal(result.status, 0, result.stderr || result.error?.message);
33
+ assert.deepEqual(JSON.parse(result.stdout), {
34
+ args: ['review prompt', 'a&b', '100%'],
35
+ input: 'stdin body',
36
+ });
37
+ } finally {
38
+ rmSync(dir, { recursive: true, force: true });
39
+ }
40
+ });
@@ -32,6 +32,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
32
32
  import { join, dirname } from 'node:path';
33
33
  import { homedir } from 'node:os';
34
34
  import { pathToFileURL } from 'node:url';
35
+ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
35
36
 
36
37
  // Phase 0-5 spike marker (SessionStart の spike-inject.flag とは別)
37
38
  const PROMPT_SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-prompt.flag');
@@ -243,6 +244,7 @@ async function maybeRunPromptSpike({ payload, sessionId, projectPath }) {
243
244
 
244
245
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
245
246
  run().catch((err) => {
247
+ recordRuntimeErrorBestEffort('HOOK_PROMPT_SUBMIT_FAILED');
246
248
  const msg = err instanceof Error ? err.message : 'unknown';
247
249
  process.stderr.write(`[prompt-submit] error: ${msg}\n`);
248
250
  process.exit(1);
@@ -0,0 +1,106 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { defaultFactoryReporterConfigPath, defaultRuntimeErrorStorePath } from './runtime-error-store.mjs';
9
+ import { applyWindowsPrivateAcl } from './windows-acl-test-helper.mjs';
10
+
11
+ const BIN = fileURLToPath(new URL('../bin/throughline.mjs', import.meta.url));
12
+
13
+ function createEnabledEnvironment(prefix) {
14
+ const root = mkdtempSync(join(tmpdir(), prefix));
15
+ const env = {
16
+ ...process.env,
17
+ HOME: root,
18
+ USERPROFILE: root,
19
+ LOCALAPPDATA: root,
20
+ XDG_CONFIG_HOME: join(root, 'config'),
21
+ XDG_STATE_HOME: join(root, 'state'),
22
+ };
23
+ const configPath = defaultFactoryReporterConfigPath(env);
24
+ mkdirSync(dirname(configPath), { recursive: true });
25
+ writeFileSync(configPath, JSON.stringify({
26
+ schema_version: '1.0',
27
+ host: { id: 'test-host', profile: process.platform === 'win32' ? 'windows-native' : 'mac' },
28
+ collection: { enabled: true },
29
+ reporting: { enabled: false },
30
+ }));
31
+ applyWindowsPrivateAcl(configPath);
32
+ return { root, env };
33
+ }
34
+
35
+ test('top-level hook owners record one fixed aggregate per failure without replacing hook failure', () => {
36
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-');
37
+ const cases = [
38
+ ['session-start'],
39
+ ['prompt-submit'],
40
+ ['process-turn'],
41
+ ['codex-hook', 'stop'],
42
+ ];
43
+ for (const args of cases) {
44
+ const result = spawnSync(process.execPath, [BIN, ...args], {
45
+ env,
46
+ input: '{invalid-json',
47
+ encoding: 'utf8',
48
+ });
49
+ assert.notEqual(result.status, 0, args.join(' '));
50
+ assert.notEqual(result.stderr, '', args.join(' '));
51
+ assert.doesNotMatch(result.stderr, /store_unavailable/);
52
+ }
53
+
54
+ const storePath = defaultRuntimeErrorStorePath(env);
55
+ let store = JSON.parse(readFileSync(storePath, 'utf8'));
56
+ assert.equal(store.records.length, 4);
57
+ assert.deepEqual(store.records.map((record) => record.error_code).sort(), [
58
+ 'HOOK_CODEX_FAILED',
59
+ 'HOOK_PROCESS_TURN_FAILED',
60
+ 'HOOK_PROMPT_SUBMIT_FAILED',
61
+ 'HOOK_SESSION_START_FAILED',
62
+ ]);
63
+ assert.ok(store.records.every((record) => record.count === 1));
64
+
65
+ spawnSync(process.execPath, [BIN, 'process-turn'], {
66
+ env,
67
+ input: '{invalid-json',
68
+ encoding: 'utf8',
69
+ });
70
+ store = JSON.parse(readFileSync(storePath, 'utf8'));
71
+ assert.equal(store.records.find((record) => record.error_code === 'HOOK_PROCESS_TURN_FAILED').count, 2);
72
+ });
73
+
74
+ test('store failure preserves product failure and emits only fixed storage diagnostic', () => {
75
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-store-fail-');
76
+ const storePath = defaultRuntimeErrorStorePath(env);
77
+ mkdirSync(dirname(storePath), { recursive: true });
78
+ writeFileSync(storePath, '{broken');
79
+
80
+ const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
81
+ env,
82
+ input: '{invalid-json',
83
+ encoding: 'utf8',
84
+ });
85
+ assert.notEqual(result.status, 0);
86
+ assert.match(result.stderr, /store_unavailable/);
87
+ assert.match(result.stderr, /SyntaxError|JSON/);
88
+ assert.doesNotMatch(result.stderr, /runtime error store schema invalid/);
89
+ });
90
+
91
+ test('FIFO config cannot block the original hook failure', { skip: process.platform === 'win32' }, () => {
92
+ const { env } = createEnabledEnvironment('throughline-runtime-hook-fifo-');
93
+ const config = defaultFactoryReporterConfigPath(env);
94
+ execFileSync('rm', ['-f', config]);
95
+ execFileSync('mkfifo', [config]);
96
+ const started = Date.now();
97
+ const result = spawnSync(process.execPath, [BIN, 'prompt-submit'], {
98
+ env,
99
+ input: '{invalid-json',
100
+ encoding: 'utf8',
101
+ timeout: 2_000,
102
+ });
103
+ assert.notEqual(result.status, 0);
104
+ assert(Date.now() - started < 1_500);
105
+ assert.match(result.stderr, /SyntaxError|JSON/);
106
+ });
@@ -0,0 +1,8 @@
1
+ import { observeRuntimeError } from './runtime-error-store.mjs';
2
+
3
+ try {
4
+ const result = observeRuntimeError({ code: process.argv[2] });
5
+ process.exitCode = result.status === 'disabled' ? 3 : 0;
6
+ } catch {
7
+ process.exitCode = 1;
8
+ }