throughline 0.12.8 → 0.12.9
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 +15 -1
- package/docs/adr/0031-serialized-schema-migration.md +43 -0
- package/package.json +1 -1
- package/src/db-contention.test.mjs +27 -0
- package/src/db.mjs +62 -10
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/hook-failure-log.mjs +17 -1
- package/src/hook-failure-log.test.mjs +36 -2
- package/src/hosts/cursor.test.mjs +10 -0
- package/src/hosts/index.mjs +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,19 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.12.9] — 2026-10-04
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- 新しいDBを複数のhookが同時に開くと、どれかが`no such table: judgments`か`database is locked`で落ちる不具合を直す([ADR 0031](docs/adr/0031-serialized-schema-migration.md))。WindowsのCursorの会話では同じhookが2本ほぼ同時に走るので、新しい端末の最初の会話で起きる。schemaの移行は、書き込みlockを取ってから版を読み直し、1つのtransactionで終える。WALへの切り替えは、断られたら読み直して5秒まで待つ。既に現行schemaのDBを開く時の動作は変わらない。
|
|
18
|
+
- 切り離した置き場で再現した。新しいDBを6 processが同時に開くと、Linuxで6回中3回、どれか1本が落ちた。Windowsでも、最初の1回で2本が落ちた。修理後は48回(6 process)で失敗0。
|
|
19
|
+
|
|
20
|
+
### 変更
|
|
21
|
+
|
|
22
|
+
- `~/.throughline/logs/hook-failures.log`に、失敗した位置(`stack`、先頭6行まで)を足す。`database is locked`のような文面だけでは、どの処理で落ちたかが分からないため。端末内にだけ残し、外へは送らない。
|
|
23
|
+
- hookのstdinがJSONとして読めない時の文面を`hook stdin is not valid JSON (N chars): …`にする。hostがstdinを渡さずに閉じた時(0字)と、途中で切れた時を見分けられる。失敗として数えることと、終了codeは変えない。
|
|
24
|
+
- 2026-10-04にWindows(0.12.4)で起きたSessionStartとUserPromptSubmitの失敗各1回は、原因が分かっていない。どちらもCursorの会話で2本ほぼ同時に走ったhookの片方で、もう片方は成功している。現行schemaのDBで起きたので、上の修正の形ではない。この2つの変更は、次に起きた時に理由を端末へ残すためのもの。
|
|
25
|
+
|
|
13
26
|
## [0.12.8] — 2026-10-04
|
|
14
27
|
|
|
15
28
|
### 変更
|
|
@@ -1621,7 +1634,8 @@ two attempts, instrument first instead of patching again.
|
|
|
1621
1634
|
|
|
1622
1635
|
---
|
|
1623
1636
|
|
|
1624
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.
|
|
1637
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.12.9...HEAD
|
|
1638
|
+
[0.12.9]: https://github.com/kitepon/Throughline/compare/v0.12.8...v0.12.9
|
|
1625
1639
|
[0.12.8]: https://github.com/kitepon/Throughline/compare/v0.12.7...v0.12.8
|
|
1626
1640
|
[0.12.7]: https://github.com/kitepon/Throughline/compare/v0.12.6...v0.12.7
|
|
1627
1641
|
[0.12.6]: https://github.com/kitepon/Throughline/compare/v0.12.5...v0.12.6
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# ADR 0031: schema の移行は書き込み lock の中で版を読み直して行い、WAL への切り替えは読み直して待つ
|
|
2
|
+
|
|
3
|
+
日付: 2026-10-04
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
`getDb()` は DB を開くたびに `PRAGMA user_version` を読み、現行版より古ければ移行を走らせていた。
|
|
8
|
+
版を読むのも、移行の各 statement も、transaction の外だった。
|
|
9
|
+
|
|
10
|
+
Windows の Cursor の会話では、同じ hook が2本ほぼ同時に走る(2026-10-04 の記録では、1会話の SessionStart が
|
|
11
|
+
数ms〜320ms の間隔で2行残る。`~/.cursor/hooks.json` と `~/.claude/settings.json` の両方に Throughline の登録がある。
|
|
12
|
+
2本目がどちらの登録から来るかは確かめていない)。新しい端末の最初の会話では、2本とも版 0 を読む。片方が先に移行を終えると、もう片方は版 0 のつもりで
|
|
13
|
+
v2 の移行(`DELETE FROM judgments ...`)を走らせ、v4 で消えた表へ触って `no such table: judgments` で落ちる。
|
|
14
|
+
|
|
15
|
+
もう1つ、新しい DB の journal mode を WAL へ切り替える `PRAGMA journal_mode = WAL` は排他 lock を要る。
|
|
16
|
+
他の process が同じ DB を開いている間は、`busy_timeout` を待たずに `database is locked` で断られる。
|
|
17
|
+
|
|
18
|
+
2026-10-04 に、切り離した置き場で再現した。新しい DB を 6 process が同時に開くと、Linux(Node 26)で
|
|
19
|
+
6回中3回、どれか1本が上のどちらかで落ちた。Windows(Node 24)でも、SessionStart と UserPromptSubmit を
|
|
20
|
+
2本ずつ同時に動かした最初の1回で2本が `no such table: judgments` で落ちた。
|
|
21
|
+
既に現行 schema の DB では、どちらも起きない(版が現行なら移行は何も書かず、WAL なら切り替えない)。
|
|
22
|
+
|
|
23
|
+
## Decision
|
|
24
|
+
|
|
25
|
+
1. 移行は `BEGIN IMMEDIATE` で書き込み lock を取り、その中で `PRAGMA user_version` を読み直してから走らせ、
|
|
26
|
+
`user_version` の更新まで1つの transaction で commit する。失敗した時は rollback し、途中の schema を残さない。
|
|
27
|
+
2. lock の外で読んだ版が現行版以上なら、lock を取らない。現行 schema の DB を開く hook は、今までどおり何も書かない。
|
|
28
|
+
3. 他の process が移行している間に開いた process は、`busy_timeout`(5秒)まで lock を待ち、
|
|
29
|
+
取れた後に読み直した版が現行なら何もしない。
|
|
30
|
+
4. WAL への切り替えは、`database is locked` で断られたら 25ms 待って journal mode を読み直し、
|
|
31
|
+
`busy_timeout` と同じ時間まで繰り返す。他の process が先に切り替えれば、読み直しで WAL が見えて終わる。
|
|
32
|
+
5. `migrate --json`(ADR 0018 の正規入口)も同じ関数を通る。結果の形は変えない。
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
- 新しい端末で複数の hook が同時に最初の DB を開いても、全員が現行 schema で開ける。
|
|
37
|
+
- 古い schema からの移行中、他の hook は最長5秒待つ。移行が5秒を超えると、待った hook は
|
|
38
|
+
`database is locked` で失敗する。今までは、移行の途中の schema で走るか、同じ移行をもう一度走らせていた。
|
|
39
|
+
- 移行が途中で失敗した DB は、移行前の schema のまま残る。次に開いた時に最初からやり直す。
|
|
40
|
+
- 2026-10-04 に Windows(0.12.4)で起きた SessionStart と UserPromptSubmit の失敗各1回は、
|
|
41
|
+
現行 schema の DB で起きている。この ADR の2つの形ではなく、原因は分かっていない。
|
|
42
|
+
理由の文面は 0.12.4 では端末に残らない。0.12.9 は `hook-failures.log` に失敗した位置(stack の先頭)を足し、
|
|
43
|
+
hook の stdin が JSON として読めない時の文面に字数を入れる。
|
package/package.json
CHANGED
|
@@ -93,3 +93,30 @@ test('getDb reuses an existing WAL database while another process owns the write
|
|
|
93
93
|
rmSync(home, { recursive: true, force: true });
|
|
94
94
|
}
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
test('getDbは、新しいDBを複数のprocessが同時に開いても、全員が現行schemaで開ける', async () => {
|
|
98
|
+
// WindowsのCursorの会話では同じhookが2本ほぼ同時に走る。初回はどのprocessも版0を読むので、lockの外で読んだ版から
|
|
99
|
+
// 移行をやり直すと、先に進んだprocessが消した表へ触って落ちた(修理前は6本で半分ほどの確率)。
|
|
100
|
+
const rounds = 6;
|
|
101
|
+
const processes = 6;
|
|
102
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
103
|
+
const home = mkdtempSync(join(tmpdir(), 'throughline-db-first-open-'));
|
|
104
|
+
const env = { ...process.env, HOME: home, USERPROFILE: home };
|
|
105
|
+
try {
|
|
106
|
+
const results = await Promise.all(Array.from({ length: processes }, () => {
|
|
107
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
108
|
+
import { getDb, CURRENT_VERSION } from ${JSON.stringify(DB_MODULE_URL)};
|
|
109
|
+
const db = getDb();
|
|
110
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
111
|
+
db.prepare('SELECT count(*) AS n FROM sessions').get();
|
|
112
|
+
db.close();
|
|
113
|
+
if (version !== CURRENT_VERSION) process.exit(2);
|
|
114
|
+
`], { env, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
115
|
+
return waitForExit(child);
|
|
116
|
+
}));
|
|
117
|
+
assert.deepEqual(results, Array.from({ length: processes }, () => ({ code: 0, stderr: '' })));
|
|
118
|
+
} finally {
|
|
119
|
+
rmSync(home, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
package/src/db.mjs
CHANGED
|
@@ -25,10 +25,68 @@ export function openReadOnlyDb(path = DB_PATH) {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const WAL_SWITCH_RETRY_MS = 25;
|
|
29
|
+
|
|
30
|
+
function isSqliteBusy(error) {
|
|
31
|
+
if (typeof error?.errcode === 'number') return (error.errcode & 0xff) === 5;
|
|
32
|
+
return /database is locked/u.test(String(error?.message ?? ''));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function sleepSync(ms) {
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* journal mode を WAL にする。既に WAL なら何も書かない。
|
|
41
|
+
*
|
|
42
|
+
* WAL への切り替えは排他 lock を要り、他の process が同じ DB を開いている間は busy_timeout を待たずに
|
|
43
|
+
* `database is locked` で断られる。新しい DB を複数の hook が同時に開く時に起きるので、
|
|
44
|
+
* busy_timeout と同じ時間まで読み直す (ADR 0031)。他の process が先に切り替えれば、読み直しで WAL が見える。
|
|
45
|
+
*/
|
|
46
|
+
function ensureWalJournalMode(db) {
|
|
47
|
+
const deadline = Date.now() + DB_BUSY_TIMEOUT_MS;
|
|
48
|
+
for (;;) {
|
|
49
|
+
try {
|
|
50
|
+
const journalMode = db.prepare('PRAGMA journal_mode').get().journal_mode;
|
|
51
|
+
if (String(journalMode).toLowerCase() === 'wal') return;
|
|
52
|
+
db.exec('PRAGMA journal_mode = WAL');
|
|
53
|
+
return;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (!isSqliteBusy(error) || Date.now() >= deadline) throw error;
|
|
56
|
+
sleepSync(WAL_SWITCH_RETRY_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readSchemaVersion(db) {
|
|
62
|
+
return db.prepare('PRAGMA user_version').get().user_version ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* schema を現行版へ上げる。現行版なら何も書かない。
|
|
67
|
+
*
|
|
68
|
+
* 移行は書き込み lock を取ってから版を読み直し、1つの transaction で終える (ADR 0031)。
|
|
69
|
+
* lock の外で読んだ版は、他の process が移行を進めた後では古い。古い版から移行をやり直すと、
|
|
70
|
+
* 途中の版で消した表(v4 の judgments)へ触って `no such table` で落ちる。
|
|
71
|
+
*/
|
|
28
72
|
function initSchema(db) {
|
|
29
|
-
|
|
30
|
-
|
|
73
|
+
if (readSchemaVersion(db) >= CURRENT_VERSION) return;
|
|
74
|
+
db.exec('BEGIN IMMEDIATE');
|
|
75
|
+
try {
|
|
76
|
+
const version = readSchemaVersion(db);
|
|
77
|
+
if (version < CURRENT_VERSION) applySchemaMigrations(db, version);
|
|
78
|
+
db.exec('COMMIT');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
try {
|
|
81
|
+
db.exec('ROLLBACK');
|
|
82
|
+
} catch {
|
|
83
|
+
// ignore
|
|
84
|
+
}
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
31
88
|
|
|
89
|
+
function applySchemaMigrations(db, version) {
|
|
32
90
|
// v0 → v1: 全テーブル作成
|
|
33
91
|
if (version < 1) {
|
|
34
92
|
db.exec(`
|
|
@@ -353,10 +411,7 @@ export function migrateDefaultDb() {
|
|
|
353
411
|
};
|
|
354
412
|
}
|
|
355
413
|
|
|
356
|
-
|
|
357
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
358
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
359
|
-
}
|
|
414
|
+
ensureWalJournalMode(db);
|
|
360
415
|
initSchema(db);
|
|
361
416
|
const afterSchemaVersion = Number(db.prepare('PRAGMA user_version').get().user_version ?? 0);
|
|
362
417
|
if (afterSchemaVersion !== CURRENT_VERSION) {
|
|
@@ -397,10 +452,7 @@ export function getDb() {
|
|
|
397
452
|
const db = new DatabaseSync(DB_PATH);
|
|
398
453
|
try {
|
|
399
454
|
db.exec(`PRAGMA busy_timeout = ${DB_BUSY_TIMEOUT_MS}`);
|
|
400
|
-
|
|
401
|
-
if (String(journalMode).toLowerCase() !== 'wal') {
|
|
402
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
403
|
-
}
|
|
455
|
+
ensureWalJournalMode(db);
|
|
404
456
|
db.exec('PRAGMA foreign_keys = ON');
|
|
405
457
|
initSchema(db);
|
|
406
458
|
_db = db;
|
|
@@ -976,7 +976,9 @@ test('a failed hook leaves its reason in the local hook failure log', () => {
|
|
|
976
976
|
['HOOK_SESSION_START_FAILED', 'Error', 'Missing session_id in SessionStart payload'],
|
|
977
977
|
]);
|
|
978
978
|
for (const entry of entries) {
|
|
979
|
-
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message']);
|
|
979
|
+
assert.deepEqual(Object.keys(entry), ['ts', 'code', 'version', 'name', 'message', 'stack']);
|
|
980
|
+
// 失敗した位置は、payloadを検査して投げたhookの入口を指す。
|
|
981
|
+
assert.match(entry.stack[0], /^at .*(turn-processor|session-start)\.mjs:\d+:\d+\)?$/);
|
|
980
982
|
assert.match(entry.ts, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
981
983
|
assert.match(entry.version, /^\d+\.\d+\.\d+$/);
|
|
982
984
|
}
|
package/src/hook-failure-log.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* runtime error store は外へ送れる形(定型 code と回数)だけを持つ (ADR 0025)。失敗した時の
|
|
5
5
|
* 例外の文面はどこにも残らず、後から原因を追えなかった(2026-10-04、Mac と main-server の
|
|
6
|
-
* 7〜8
|
|
6
|
+
* 7〜8月の失敗)。このログは理由と失敗した位置(stack の先頭)を端末内にだけ残す。runtime error の収集・送信の設定とは
|
|
7
7
|
* 独立で、外へは送らない。
|
|
8
8
|
* 書き込み失敗は stderr に出す(黙って握りつぶさない)。
|
|
9
9
|
*/
|
|
@@ -17,11 +17,23 @@ const require = createRequire(import.meta.url);
|
|
|
17
17
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
18
18
|
|
|
19
19
|
export const HOOK_FAILURE_MESSAGE_LIMIT = 1000;
|
|
20
|
+
export const HOOK_FAILURE_STACK_FRAMES = 6;
|
|
21
|
+
export const HOOK_FAILURE_STACK_FRAME_LIMIT = 300;
|
|
20
22
|
|
|
21
23
|
export function hookFailureLogPath({ home = homedir() } = {}) {
|
|
22
24
|
return join(home, '.throughline', 'logs', 'hook-failures.log');
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
function stackFrames(error) {
|
|
28
|
+
if (!(error instanceof Error) || typeof error.stack !== 'string') return [];
|
|
29
|
+
return error.stack
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.trim())
|
|
32
|
+
.filter((line) => line.startsWith('at '))
|
|
33
|
+
.slice(0, HOOK_FAILURE_STACK_FRAMES)
|
|
34
|
+
.map((line) => line.slice(0, HOOK_FAILURE_STACK_FRAME_LIMIT));
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* @param {string} code runtime error の定型 code(例: HOOK_PROCESS_TURN_FAILED)
|
|
27
39
|
* @param {unknown} error 失敗の原因
|
|
@@ -36,6 +48,10 @@ export function logHookFailure(code, error, { home, now = Date.now(), stderr = p
|
|
|
36
48
|
name: error instanceof Error ? error.name : typeof error,
|
|
37
49
|
message: message.slice(0, HOOK_FAILURE_MESSAGE_LIMIT),
|
|
38
50
|
};
|
|
51
|
+
// 失敗した位置を残す。`database is locked` のような文面だけでは、DB を開く時か書く時か、
|
|
52
|
+
// どの処理で落ちたかが分からない(2026-10-04、Windows の SessionStart と UserPromptSubmit の失敗)。
|
|
53
|
+
const frames = stackFrames(error);
|
|
54
|
+
if (frames.length > 0) entry.stack = frames;
|
|
39
55
|
// どの会話の失敗かを残す(errorが `hookContext` を持つ時)。回数と文面だけでは、失敗した会話を
|
|
40
56
|
// 端末の他の記録から探すことになる。
|
|
41
57
|
const context = error?.hookContext;
|
|
@@ -3,7 +3,13 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
HOOK_FAILURE_MESSAGE_LIMIT,
|
|
8
|
+
HOOK_FAILURE_STACK_FRAMES,
|
|
9
|
+
HOOK_FAILURE_STACK_FRAME_LIMIT,
|
|
10
|
+
hookFailureLogPath,
|
|
11
|
+
logHookFailure,
|
|
12
|
+
} from './hook-failure-log.mjs';
|
|
7
13
|
|
|
8
14
|
test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
9
15
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
@@ -12,9 +18,12 @@ test('logHookFailureは、失敗の理由を1行のJSONで追記する', () => {
|
|
|
12
18
|
logHookFailure('HOOK_CODEX_FAILED', 'thrown string', { home, now: Date.UTC(2026, 9, 4, 0, 0, 1) });
|
|
13
19
|
const lines = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
14
20
|
assert.equal(lines.length, 2);
|
|
15
|
-
|
|
21
|
+
const { stack, ...first } = lines[0];
|
|
22
|
+
assert.deepEqual({ ...first, version: 'x' }, {
|
|
16
23
|
ts: '2026-10-04T00:00:00.005Z', code: 'HOOK_PROCESS_TURN_FAILED', version: 'x', name: 'TypeError', message: 'first reason',
|
|
17
24
|
});
|
|
25
|
+
// Errorは失敗した位置を持つ。文字列を投げた時はstackが無いので、項目ごと残さない。
|
|
26
|
+
assert.equal(Array.isArray(stack) && stack.length > 0, true);
|
|
18
27
|
assert.deepEqual({ ...lines[1], version: 'x' }, {
|
|
19
28
|
ts: '2026-10-04T00:00:01.000Z', code: 'HOOK_CODEX_FAILED', version: 'x', name: 'string', message: 'thrown string',
|
|
20
29
|
});
|
|
@@ -61,6 +70,31 @@ test('logHookFailureは、どの会話の失敗かを残す', () => {
|
|
|
61
70
|
}
|
|
62
71
|
});
|
|
63
72
|
|
|
73
|
+
test('logHookFailureは、失敗した位置をstackの先頭から残す', () => {
|
|
74
|
+
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
75
|
+
try {
|
|
76
|
+
const err = new Error('database is locked');
|
|
77
|
+
err.stack = [
|
|
78
|
+
'Error: database is locked',
|
|
79
|
+
' at getDb (file:///throughline/src/db.mjs:455:5)',
|
|
80
|
+
` at ${'x'.repeat(HOOK_FAILURE_STACK_FRAME_LIMIT + 40)}`,
|
|
81
|
+
...Array.from({ length: HOOK_FAILURE_STACK_FRAMES + 3 }, (_, index) => ` at frame${index} (file:///throughline/src/a.mjs:${index}:1)`),
|
|
82
|
+
].join('\n');
|
|
83
|
+
logHookFailure('HOOK_SESSION_START_FAILED', err, { home });
|
|
84
|
+
const noFrames = new Error('no frames');
|
|
85
|
+
noFrames.stack = 'Error: no frames';
|
|
86
|
+
logHookFailure('HOOK_SESSION_START_FAILED', noFrames, { home });
|
|
87
|
+
const [entry, second] = readFileSync(hookFailureLogPath({ home }), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
88
|
+
assert.equal(entry.stack.length, HOOK_FAILURE_STACK_FRAMES);
|
|
89
|
+
assert.equal(entry.stack[0], 'at getDb (file:///throughline/src/db.mjs:455:5)');
|
|
90
|
+
assert.equal(entry.stack[1].length, HOOK_FAILURE_STACK_FRAME_LIMIT);
|
|
91
|
+
assert.equal(entry.stack[2], 'at frame0 (file:///throughline/src/a.mjs:0:1)');
|
|
92
|
+
assert.equal('stack' in second, false);
|
|
93
|
+
} finally {
|
|
94
|
+
rmSync(home, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
64
98
|
test('logHookFailureは、長い文面を上限で切る', () => {
|
|
65
99
|
const home = mkdtempSync(join(tmpdir(), 'tl-hook-failure-log-'));
|
|
66
100
|
try {
|
|
@@ -25,6 +25,16 @@ test('parseHookPayload accepts one leading BOM and preserves BOM inside prompt t
|
|
|
25
25
|
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), SyntaxError);
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
+
test('parseHookPayloadは、stdinが空の時と途中で切れた時を、文面の字数で見分けられるようにする', () => {
|
|
29
|
+
assert.throws(() => parseHookPayload(''), (error) => {
|
|
30
|
+
assert.equal(error instanceof SyntaxError, true);
|
|
31
|
+
assert.match(error.message, /^hook stdin is not valid JSON \(0 chars\): /u);
|
|
32
|
+
assert.equal(error.cause instanceof SyntaxError, true);
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
assert.throws(() => parseHookPayload('\uFEFF{"conversation_id":'), /^SyntaxError: hook stdin is not valid JSON \(20 chars\): /u);
|
|
36
|
+
});
|
|
37
|
+
|
|
28
38
|
test('isCursorEnvelope detects Cursor events and cursor_version', () => {
|
|
29
39
|
assert.equal(
|
|
30
40
|
isCursorEnvelope({
|
package/src/hosts/index.mjs
CHANGED
|
@@ -29,7 +29,15 @@ const ADAPTERS = Object.freeze({
|
|
|
29
29
|
|
|
30
30
|
/** Parse hook stdin, including the leading UTF-8 BOM emitted by Windows Cursor. */
|
|
31
31
|
export function parseHookPayload(raw, options = {}) {
|
|
32
|
-
|
|
32
|
+
let parsed;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// host が stdin を渡さずに閉じた時(0 字)と、途中で切れた時を、理由の文面で見分けられるようにする。
|
|
37
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
38
|
+
throw new SyntaxError(`hook stdin is not valid JSON (${raw.length} chars): ${detail}`, { cause: error });
|
|
39
|
+
}
|
|
40
|
+
return normalizeHookPayload(parsed, options);
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
/**
|