throughline 0.6.3 → 0.8.0
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 +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Hook stdout は約 10k 字で `<persisted-output>` に file 化され、モデル可視は先頭 2KB に劣化する
|
|
2
|
+
|
|
3
|
+
- **出典**: 自前実測(tracer 実験 + 全 transcript 掃引)。このMac、Claude Code 2.1.211
|
|
4
|
+
- **取得日**: 2026-07-17
|
|
5
|
+
- **確度**: confirmed(境界2点 + 実運用12件で再現)
|
|
6
|
+
|
|
7
|
+
## 事実
|
|
8
|
+
|
|
9
|
+
SessionStart / UserPromptSubmit の hook **plain stdout**(および文書上は
|
|
10
|
+
`hookSpecificOutput` の context string)は、約 10,000 字を超えると inline 注入されず、
|
|
11
|
+
transcript の `attachment.content` が以下に置換される:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
<persisted-output>
|
|
15
|
+
Output too large (11.7KB). Full output saved to: ~/.claude/projects/<proj>/<session>/tool-results/hook-<uuid>...
|
|
16
|
+
<先頭 ~2KB の preview>
|
|
17
|
+
...
|
|
18
|
+
</persisted-output>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- 全文は attachment の `stdout` field に保存されるが、**モデル可視は `content`
|
|
22
|
+
(= path + 先頭 2KB preview) だけ**。モデルがツールでファイルを読まない限り本文は届かない。
|
|
23
|
+
- 境界実測: **9,501 字は inline 通過 / 15,286 字は file 化**(公称上限は hooks reference の
|
|
24
|
+
「10,000 characters per context string」と整合)。
|
|
25
|
+
- tracer 実験: 11,953 字 stdout の 10k 境界後 tracer は Haiku から不可視、境界前 tracer は可視。
|
|
26
|
+
|
|
27
|
+
## 影響(Throughline での実害)
|
|
28
|
+
|
|
29
|
+
実運用 transcript 掃引の結果、SessionStart 注入で 10k 超だった **12 件(2026-06-28 /
|
|
30
|
+
v2.1.195 以降の全件)が 12 件とも劣化**していた(例: 64,148 字 emit → 可視 2,054 字)。
|
|
31
|
+
記憶注入の L1+L2 本体はモデルに読まれておらず、ヘッダ + 現在地アンカーが偶然先頭 2KB に
|
|
32
|
+
収まっていたため「機能している風」に見えていた。
|
|
33
|
+
|
|
34
|
+
v2.1.195 より前に 10k 超を emit した実績が手元に無いため、「いつ導入されたか」は未確定。
|
|
35
|
+
運用上は「現行版では 10k 超 = 劣化」で確定。
|
|
36
|
+
|
|
37
|
+
## 対処
|
|
38
|
+
|
|
39
|
+
注入は予算内レンダリングで行う(Throughline は `buildBudgetedResumeContext`、上限 9,500 字。
|
|
40
|
+
ヘッダ + アンカー常時全文、L1 → L2 を新しい側から詰め、省略は注入文へ明示)。
|
|
41
|
+
詳細は [ADR 0014](../../docs/adr/0014-two-phase-handoff-ghost-baton.md)。
|
|
42
|
+
|
|
43
|
+
## 追記: 10k 判定は per context string — multi-hook で突破可能(2026-07-18 実測、不採用)
|
|
44
|
+
|
|
45
|
+
同一イベントに複数 hook を登録した場合の挙動を実測した(Claude Code 2.1.211、
|
|
46
|
+
一時 project + 3〜5 本の UserPromptSubmit tracer hook + `claude -p` Haiku):
|
|
47
|
+
|
|
48
|
+
- 各 hook stdout は**独立の `hook_success` attachment** になり、10k 判定も per string。
|
|
49
|
+
3 本 × 9,000 字 = 27,000 字、5 本 × 9,000 字 = 45,000 字が全部モデル可視 inline
|
|
50
|
+
- 1 本だけ 12k にすると**その 1 本だけ**が `<persisted-output>` 化し、隣の 9k は無傷
|
|
51
|
+
- hooks reference の「10,000 characters per context string」「複数 hook の
|
|
52
|
+
additionalContext は all of the values が届く」の文言と整合
|
|
53
|
+
- **罠**: attachment の並び順は登録順と一致しない(hook 並列実行のため非決定)。
|
|
54
|
+
multi-part 注入に使うなら各 part に自己記述ヘッダが必須
|
|
55
|
+
|
|
56
|
+
Throughline では「hook の構造的想定(1 本 = 1 context string)に無い使い方」として
|
|
57
|
+
**不採用**(オーナー裁定 2026-07-18)。注入は 9,500 字 push + `throughline recall` pull の
|
|
58
|
+
二段構成にした([ADR 0016](../../docs/adr/0016-push-pull-recall-injection.md))。
|
|
59
|
+
|
|
60
|
+
## 検証手順(再現用)
|
|
61
|
+
|
|
62
|
+
1. 一時 project に `.claude/settings.json` で UserPromptSubmit hook を登録し、
|
|
63
|
+
10k 境界の前後に tracer を置いた ~12k 字を stdout に emit する
|
|
64
|
+
2. `claude -p --model claude-haiku-4-5-*` で「見えている tracer を列挙して」と問う
|
|
65
|
+
3. transcript の `attachment.content` / `attachment.stdout` を比較する
|
package/rag/INDEX.md
CHANGED
|
@@ -86,6 +86,10 @@ Verified via [openclaude source](04-skills/raw/initialUserMessage-investigation.
|
|
|
86
86
|
|
|
87
87
|
SessionEnd reason enum: `clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other`、default timeout 1.5s(/clear にも適用)— [session-end-reasons.md](01-hooks/raw/session-end-reasons.md)。実測: ビルトイン /clear はどのクライアントでも UserPromptSubmit に届かない(同一セッション /tl 対照実験 ×2 + VSCode 2.1.207)。VSCode は `source:"clear"` を送るが Desktop 2.1.205 は `source:"startup"`(クライアント実装差・バージョン交絡棄却済み)。→ Desktop の /clear 検知は SessionEnd(reason='clear') が唯一の hook 経路候補(実機検証は [docs/12](../docs/12_desktop_clear_handoff_plan.md) A Phase 1)。
|
|
88
88
|
|
|
89
|
+
### Finding 9: hook stdout は ~10k 字で persisted-output に file 化、モデル可視は先頭 2KB のみ (2026-07-17)
|
|
90
|
+
|
|
91
|
+
SessionStart / UserPromptSubmit の hook stdout は約 10,000 字超で `<persisted-output>`(保存ファイルパス + 先頭 2KB preview)に置換され、モデルには preview しか届かない(silent degradation、hook は exit 0 のまま)。境界実測 9,501 字 inline / 15,286 字 file 化。実運用の 10k 超注入 12 件(v2.1.195 以降)は 12/12 劣化 — 記憶注入の L1+L2 本体は読まれていなかった。対処は 9,500 字予算内レンダリング。詳細: [hook-stdout-10k-persisted-output.md](01-hooks/hook-stdout-10k-persisted-output.md)、[ADR 0014](../docs/adr/0014-two-phase-handoff-ghost-baton.md)。
|
|
92
|
+
|
|
89
93
|
---
|
|
90
94
|
|
|
91
95
|
## Throughline 仮説の見直し
|
package/src/auditor-context.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { existsSync, realpathSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
@@ -6,25 +5,21 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
6
5
|
import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
|
|
7
6
|
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
8
7
|
import { getLogicalTurnGroups } from './transcript-reader.mjs';
|
|
8
|
+
import { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
|
|
9
|
+
|
|
10
|
+
export { hashAuditorBody, normalizeAuditorBody } from './body-digest.mjs';
|
|
9
11
|
|
|
10
12
|
export const AUDITOR_CONTEXT_SCHEMA = 'throughline.auditor_context.v1';
|
|
11
|
-
export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION =
|
|
13
|
+
export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 9;
|
|
12
14
|
export const DEFAULT_AUDITOR_RECENT_TURNS = 2;
|
|
13
15
|
export const DEFAULT_AUDITOR_MAX_BODY_CHARS = 1200;
|
|
14
16
|
export const DEFAULT_AUDITOR_MAX_TOTAL_CHARS = 4000;
|
|
17
|
+
const OBSERVER_PROJECTION_BUSY_TIMEOUT_MS = 1_000;
|
|
15
18
|
|
|
16
19
|
export function defaultAuditorContextDbPath() {
|
|
17
20
|
return join(homedir(), '.throughline', 'throughline.db');
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export function normalizeAuditorBody(value) {
|
|
21
|
-
return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function hashAuditorBody(value) {
|
|
25
|
-
return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
|
|
29
24
|
assertNonEmptyString(transcriptPath, 'transcriptPath');
|
|
30
25
|
assertNonEmptyString(sessionId, 'sessionId');
|
|
@@ -182,6 +177,44 @@ export function readAuditorContext({
|
|
|
182
177
|
}
|
|
183
178
|
}
|
|
184
179
|
|
|
180
|
+
/** Read-only, ordered completed-pair projection for the Observer feed. */
|
|
181
|
+
export function readCompletedPairProjection({
|
|
182
|
+
dbPath = defaultAuditorContextDbPath(), sessionId, projectRoot, expectedPairs,
|
|
183
|
+
maxBodyChars = DEFAULT_AUDITOR_MAX_BODY_CHARS, maxTotalChars = DEFAULT_AUDITOR_MAX_TOTAL_CHARS,
|
|
184
|
+
} = {}) {
|
|
185
|
+
assertNonEmptyString(sessionId, 'sessionId');
|
|
186
|
+
assertNonEmptyString(projectRoot, 'projectRoot');
|
|
187
|
+
assertExpectedPairs(expectedPairs);
|
|
188
|
+
assertPositiveInteger(maxBodyChars, 'maxBodyChars');
|
|
189
|
+
if (!Number.isInteger(maxTotalChars) || maxTotalChars < 0) throw new TypeError('maxTotalChars must be an integer >= 0');
|
|
190
|
+
if (!existsSync(dbPath)) return { status: 'pending', reason: 'db_not_found', turns: [] };
|
|
191
|
+
let db;
|
|
192
|
+
try {
|
|
193
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
194
|
+
db.exec(`PRAGMA busy_timeout = ${OBSERVER_PROJECTION_BUSY_TIMEOUT_MS}`);
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
db?.close();
|
|
197
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
201
|
+
if (version !== AUDITOR_CONTEXT_DB_SCHEMA_VERSION) throw new AuditorContextError('E_AUDITOR_CONTEXT_SCHEMA', 'auditor context DB schema is unsupported');
|
|
202
|
+
const session = db.prepare('SELECT session_id, project_path FROM sessions WHERE session_id = ?').get(sessionId);
|
|
203
|
+
if (!session) return { status: 'pending', reason: 'session_not_found', turns: [] };
|
|
204
|
+
if (!isSameProjectOrDescendant(session.project_path, projectRoot)) throw new AuditorContextError('E_AUDITOR_CONTEXT_PROJECT', 'auditor context DB project does not match');
|
|
205
|
+
const rows = db.prepare(
|
|
206
|
+
`SELECT id, origin_session_id, turn_number, role, text, created_at FROM bodies
|
|
207
|
+
WHERE session_id = ? AND role IN ('user', 'assistant') ORDER BY created_at ASC, id ASC`,
|
|
208
|
+
).all(sessionId);
|
|
209
|
+
const matched = matchExpectedPairs(buildCompletedPairs(rows), expectedPairs);
|
|
210
|
+
if (!matched) return { status: 'pending', reason: 'pair_not_found', turns: [] };
|
|
211
|
+
return { status: 'fresh', turns: boundProjectedPairs(matched, { maxBodyChars, maxTotalChars }) };
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
if (cause instanceof AuditorContextError) throw cause;
|
|
214
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_QUERY', 'auditor context query failed', { cause });
|
|
215
|
+
} finally { db.close(); }
|
|
216
|
+
}
|
|
217
|
+
|
|
185
218
|
export class AuditorContextError extends Error {
|
|
186
219
|
constructor(code, message, { cause } = {}) {
|
|
187
220
|
super(message, { cause });
|
|
@@ -262,6 +295,35 @@ function boundCompletedPairs(pairs, { maxBodyChars, maxTotalChars }) {
|
|
|
262
295
|
return { turns: selected, chars, truncated };
|
|
263
296
|
}
|
|
264
297
|
|
|
298
|
+
function matchExpectedPairs(completed, expectedPairs) {
|
|
299
|
+
const matched = [];
|
|
300
|
+
let searchStart = 0;
|
|
301
|
+
for (const expected of expectedPairs) {
|
|
302
|
+
const index = completed.findIndex((pair, pairIndex) => pairIndex >= searchStart &&
|
|
303
|
+
hashAuditorBody(pair.originSessionId) === expected.origin_sha256 &&
|
|
304
|
+
hashAuditorBody(pair.user) === expected.user_sha256 && hashAuditorBody(pair.assistant) === expected.assistant_sha256);
|
|
305
|
+
if (index < 0) return null;
|
|
306
|
+
matched.push({ ...completed[index], expected });
|
|
307
|
+
searchStart = index + 1;
|
|
308
|
+
}
|
|
309
|
+
return matched;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function boundProjectedPairs(pairs, { maxBodyChars, maxTotalChars }) {
|
|
313
|
+
let remaining = maxTotalChars;
|
|
314
|
+
return pairs.map((pair) => {
|
|
315
|
+
const user = tail(pair.user, Math.min(maxBodyChars, remaining));
|
|
316
|
+
remaining -= user.length;
|
|
317
|
+
const assistant = tail(pair.assistant, Math.min(maxBodyChars, remaining));
|
|
318
|
+
remaining -= assistant.length;
|
|
319
|
+
return {
|
|
320
|
+
origin_sha256: pair.expected.origin_sha256, user_sha256: pair.expected.user_sha256,
|
|
321
|
+
assistant_sha256: pair.expected.assistant_sha256, user, assistant,
|
|
322
|
+
truncated: user.length < pair.user.length || assistant.length < pair.assistant.length,
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
265
327
|
function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSchemaVersion } = {}) {
|
|
266
328
|
return {
|
|
267
329
|
schema: AUDITOR_CONTEXT_SCHEMA,
|
|
@@ -289,7 +351,17 @@ function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSc
|
|
|
289
351
|
function canonicalProjectPath(value) {
|
|
290
352
|
const raw = String(value ?? '');
|
|
291
353
|
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
292
|
-
|
|
354
|
+
// Windows の 8.3 短縮名 (例: RUNNER~1) を実体の長い名前へ解決してから比較形へ
|
|
355
|
+
// 正規化する。GitHub Actions は TEMP が短縮名で来るため、realpath を挟まないと
|
|
356
|
+
// 同一ディレクトリの短縮形/長形が別 project と判定される (codex-thread-index の
|
|
357
|
+
// normalizePath と同型)。存在しないパスは lexical のまま (fixture の仮パスを壊さない)。
|
|
358
|
+
let resolved = raw;
|
|
359
|
+
try {
|
|
360
|
+
if (existsSync(raw)) resolved = realpathSync.native(raw);
|
|
361
|
+
} catch {
|
|
362
|
+
// Keep the lexical Windows path when it cannot be resolved.
|
|
363
|
+
}
|
|
364
|
+
return resolved.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
293
365
|
}
|
|
294
366
|
let normalized = isAbsolute(raw) ? raw : resolve(raw);
|
|
295
367
|
try {
|
|
@@ -328,3 +400,12 @@ function assertPositiveInteger(value, name) {
|
|
|
328
400
|
throw new TypeError(`${name} must be an integer >= 1`);
|
|
329
401
|
}
|
|
330
402
|
}
|
|
403
|
+
|
|
404
|
+
function assertExpectedPairs(value) {
|
|
405
|
+
if (!Array.isArray(value) || value.length < 1 || value.some((pair) => !pair || typeof pair !== 'object' ||
|
|
406
|
+
Object.keys(pair).length !== 3 || !isLowercaseSha256(pair.origin_sha256) || !isLowercaseSha256(pair.user_sha256) || !isLowercaseSha256(pair.assistant_sha256))) {
|
|
407
|
+
throw new TypeError('expectedPairs must be a non-empty SHA-256 chain');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function isLowercaseSha256(value) { return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); }
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
2
4
|
import { existsSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
5
|
import { tmpdir } from 'node:os';
|
|
4
6
|
import { join } from 'node:path';
|
|
@@ -9,10 +11,11 @@ import {
|
|
|
9
11
|
AUDITOR_CONTEXT_SCHEMA,
|
|
10
12
|
deriveAuditorFreshnessExpectation,
|
|
11
13
|
hashAuditorBody,
|
|
14
|
+
readCompletedPairProjection,
|
|
12
15
|
readAuditorContext,
|
|
13
16
|
} from './auditor-context.mjs';
|
|
14
17
|
|
|
15
|
-
function withDb(fn, { version =
|
|
18
|
+
function withDb(fn, { version = 9 } = {}) {
|
|
16
19
|
const dir = mkdtempSync(join(tmpdir(), 'tl-auditor-context-'));
|
|
17
20
|
const path = join(dir, 'throughline.db');
|
|
18
21
|
const db = new DatabaseSync(path);
|
|
@@ -279,6 +282,118 @@ test('readAuditorContext: Codex freshness uses exact origin and both pair hashes
|
|
|
279
282
|
});
|
|
280
283
|
});
|
|
281
284
|
|
|
285
|
+
test('readCompletedPairProjection: ordered chain matches each DB pair once and preserves bounded identities', () => {
|
|
286
|
+
withDb(({ db, path }) => {
|
|
287
|
+
const pairs = [
|
|
288
|
+
{ originSessionId: 'origin-a', turnNumber: 1, user: 'first user', assistant: 'first answer' },
|
|
289
|
+
{ originSessionId: 'origin-b', turnNumber: 2, user: 'second user', assistant: 'second answer' },
|
|
290
|
+
];
|
|
291
|
+
seedSession(db, { pairs });
|
|
292
|
+
db.prepare('INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
293
|
+
.run('session-1', 'extra', 3, 'user', 'not completed by chain', 10);
|
|
294
|
+
const expectedPairs = pairs.map((pair) => ({
|
|
295
|
+
origin_sha256: hashAuditorBody(pair.originSessionId), user_sha256: hashAuditorBody(pair.user), assistant_sha256: hashAuditorBody(pair.assistant),
|
|
296
|
+
}));
|
|
297
|
+
const result = readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs, maxBodyChars: 20, maxTotalChars: 0 });
|
|
298
|
+
assert.equal(result.status, 'fresh');
|
|
299
|
+
assert.equal(result.turns.length, 2);
|
|
300
|
+
assert.ok(result.turns.every((turn) => turn.truncated && turn.user === '' && turn.assistant === ''));
|
|
301
|
+
assert.deepEqual(result.turns.map((turn) => turn.origin_sha256), expectedPairs.map((pair) => pair.origin_sha256));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('readCompletedPairProjection: missing or mismatched pair returns pending without partial bodies', () => {
|
|
306
|
+
withDb(({ db, path }) => {
|
|
307
|
+
const pairs = [
|
|
308
|
+
{ originSessionId: 'origin-a', turnNumber: 1, user: 'first user', assistant: 'first answer' },
|
|
309
|
+
{ originSessionId: 'origin-b', turnNumber: 2, user: 'second user', assistant: 'second answer' },
|
|
310
|
+
];
|
|
311
|
+
seedSession(db, { pairs });
|
|
312
|
+
const expectedPairs = [
|
|
313
|
+
{ origin_sha256: hashAuditorBody(pairs[0].originSessionId), user_sha256: hashAuditorBody(pairs[0].user), assistant_sha256: hashAuditorBody(pairs[0].assistant) },
|
|
314
|
+
{ origin_sha256: hashAuditorBody(pairs[1].originSessionId), user_sha256: hashAuditorBody(pairs[1].user), assistant_sha256: hashAuditorBody('different') },
|
|
315
|
+
];
|
|
316
|
+
assert.deepEqual(readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), {
|
|
317
|
+
status: 'pending', reason: 'pair_not_found', turns: [],
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('readCompletedPairProjection: missing session is pending without any body', () => {
|
|
323
|
+
withDb(({ path }) => {
|
|
324
|
+
const expectedPairs = [{
|
|
325
|
+
origin_sha256: hashAuditorBody('origin'), user_sha256: hashAuditorBody('user'), assistant_sha256: hashAuditorBody('assistant'),
|
|
326
|
+
}];
|
|
327
|
+
assert.deepEqual(readCompletedPairProjection({ dbPath: path, sessionId: 'missing-session', projectRoot: '/repo', expectedPairs }), {
|
|
328
|
+
status: 'pending', reason: 'session_not_found', turns: [],
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('readCompletedPairProjection: bounded wait内にwriter lockが解放されれば同じpairを読む', async () => {
|
|
334
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-observer-read-busy-'));
|
|
335
|
+
const path = join(dir, 'throughline.db');
|
|
336
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'user', assistant: 'answer' };
|
|
337
|
+
const expectedPairs = [{
|
|
338
|
+
origin_sha256: hashAuditorBody(pair.originSessionId),
|
|
339
|
+
user_sha256: hashAuditorBody(pair.user),
|
|
340
|
+
assistant_sha256: hashAuditorBody(pair.assistant),
|
|
341
|
+
}];
|
|
342
|
+
const db = new DatabaseSync(path);
|
|
343
|
+
db.exec(`
|
|
344
|
+
PRAGMA journal_mode = DELETE;
|
|
345
|
+
PRAGMA user_version = 9;
|
|
346
|
+
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
347
|
+
CREATE TABLE bodies (
|
|
348
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
349
|
+
session_id TEXT NOT NULL,
|
|
350
|
+
origin_session_id TEXT NOT NULL,
|
|
351
|
+
turn_number INTEGER NOT NULL,
|
|
352
|
+
role TEXT NOT NULL,
|
|
353
|
+
text TEXT NOT NULL,
|
|
354
|
+
created_at INTEGER NOT NULL
|
|
355
|
+
);
|
|
356
|
+
`);
|
|
357
|
+
seedSession(db, { pairs: [pair] });
|
|
358
|
+
db.close();
|
|
359
|
+
const writer = spawn(process.execPath, ['--input-type=module', '-e', `
|
|
360
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
361
|
+
const db = new DatabaseSync(process.argv[1]);
|
|
362
|
+
db.exec('BEGIN EXCLUSIVE');
|
|
363
|
+
process.stdout.write('ready\\n');
|
|
364
|
+
setTimeout(() => { db.exec('ROLLBACK'); db.close(); }, 200);
|
|
365
|
+
`, path], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
366
|
+
const exited = once(writer, 'exit');
|
|
367
|
+
try {
|
|
368
|
+
await once(writer.stdout, 'data');
|
|
369
|
+
const startedAt = Date.now();
|
|
370
|
+
const result = readCompletedPairProjection({
|
|
371
|
+
dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs,
|
|
372
|
+
});
|
|
373
|
+
assert.equal(result.status, 'fresh');
|
|
374
|
+
assert.deepEqual(result.turns.map((turn) => turn.origin_sha256), expectedPairs.map((entry) => entry.origin_sha256));
|
|
375
|
+
assert.ok(Date.now() - startedAt >= 100);
|
|
376
|
+
const [code] = await exited;
|
|
377
|
+
assert.equal(code, 0);
|
|
378
|
+
} finally {
|
|
379
|
+
if (writer.exitCode === null) writer.kill('SIGTERM');
|
|
380
|
+
rmSync(dir, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('readCompletedPairProjection: schema and project mismatch are hard failures', () => {
|
|
385
|
+
const pair = { originSessionId: 'origin-a', turnNumber: 1, user: 'user', assistant: 'answer' };
|
|
386
|
+
const expectedPairs = [{ origin_sha256: hashAuditorBody(pair.originSessionId), user_sha256: hashAuditorBody(pair.user), assistant_sha256: hashAuditorBody(pair.assistant) }];
|
|
387
|
+
withDb(({ db, path }) => {
|
|
388
|
+
seedSession(db, { projectPath: '/other', pairs: [pair] });
|
|
389
|
+
assert.throws(() => readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), (error) => error.code === 'E_AUDITOR_CONTEXT_PROJECT');
|
|
390
|
+
});
|
|
391
|
+
withDb(({ db, path }) => {
|
|
392
|
+
seedSession(db, { pairs: [pair] });
|
|
393
|
+
assert.throws(() => readCompletedPairProjection({ dbPath: path, sessionId: 'session-1', projectRoot: '/repo', expectedPairs }), (error) => error.code === 'E_AUDITOR_CONTEXT_SCHEMA');
|
|
394
|
+
}, { version: 7 });
|
|
395
|
+
});
|
|
396
|
+
|
|
282
397
|
function snapshotSqliteFiles(path) {
|
|
283
398
|
return [path, `${path}-wal`, `${path}-shm`].map((file) => {
|
|
284
399
|
if (!existsSync(file)) return { file, exists: false };
|
package/src/baton.mjs
CHANGED
|
@@ -43,16 +43,29 @@ export function writeBaton(db, { projectPath, sessionId, now = Date.now() }) {
|
|
|
43
43
|
/**
|
|
44
44
|
* 同 project_path のバトンを読み出して削除する (atomic)。
|
|
45
45
|
*
|
|
46
|
+
* 適格性は `bornAt`(= 消費者セッションの誕生時刻)基準で判定する:
|
|
47
|
+
* age = bornAt - baton.created_at
|
|
48
|
+
* - age < 0 : バトンは消費者セッションより後に書かれた(本来の後継は
|
|
49
|
+
* その後に生まれる別セッション)→ **消さずに残し** skip。
|
|
50
|
+
* 二相ハンドオフでは消費が最初のプロンプト時点まで遅延するため、
|
|
51
|
+
* 「自分より後に書かれたバトン」を走行中セッションが横取りしない
|
|
52
|
+
* ためのガード。
|
|
53
|
+
* - age > ttlMs : TTL 超過。従来どおり削除して破棄。
|
|
54
|
+
* - それ以外 : 削除して sessionId を返す。
|
|
55
|
+
* `bornAt` 省略時は now と同値(= 従来の SessionStart 即時消費と同じ判定)。
|
|
56
|
+
*
|
|
46
57
|
* 戻り値:
|
|
47
|
-
* - { sessionId, ageMs }
|
|
48
|
-
* - { sessionId: null, skipReason: 'expired', ageMs }
|
|
49
|
-
* - { sessionId: null, skipReason: '
|
|
58
|
+
* - { sessionId, ageMs } : バトン存在 かつ適格
|
|
59
|
+
* - { sessionId: null, skipReason: 'expired', ageMs } : TTL 超過で破棄
|
|
60
|
+
* - { sessionId: null, skipReason: 'future_baton', ageMs } : 消費者誕生より後の
|
|
61
|
+
* バトン(残置)
|
|
62
|
+
* - { sessionId: null, skipReason: 'missing' } : バトン無し
|
|
50
63
|
*
|
|
51
64
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
52
|
-
* @param {{ projectPath: string, now?: number, ttlMs?: number }} params
|
|
53
|
-
* @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' }}
|
|
65
|
+
* @param {{ projectPath: string, now?: number, bornAt?: number, ttlMs?: number }} params
|
|
66
|
+
* @returns {{ sessionId: string | null, ageMs?: number, skipReason?: 'expired' | 'missing' | 'future_baton' }}
|
|
54
67
|
*/
|
|
55
|
-
export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_TTL_MS }) {
|
|
68
|
+
export function consumeBaton(db, { projectPath, now = Date.now(), bornAt = now, ttlMs = BATON_TTL_MS }) {
|
|
56
69
|
db.exec('BEGIN IMMEDIATE');
|
|
57
70
|
try {
|
|
58
71
|
// Windows 互換: ドライブレターの大小差を吸収するため COLLATE NOCASE
|
|
@@ -67,10 +80,17 @@ export function consumeBaton(db, { projectPath, now = Date.now(), ttlMs = BATON_
|
|
|
67
80
|
return { sessionId: null, skipReason: 'missing' };
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
const ageMs = bornAt - row.created_at;
|
|
84
|
+
|
|
85
|
+
if (ageMs < 0) {
|
|
86
|
+
// 消費者より未来のバトンは本来の後継のために残す(削除しない)
|
|
87
|
+
db.exec('COMMIT');
|
|
88
|
+
return { sessionId: null, skipReason: 'future_baton', ageMs };
|
|
89
|
+
}
|
|
90
|
+
|
|
70
91
|
db.prepare('DELETE FROM handoff_batons WHERE project_path = ? COLLATE NOCASE').run(
|
|
71
92
|
projectPath,
|
|
72
93
|
);
|
|
73
|
-
const ageMs = now - row.created_at;
|
|
74
94
|
|
|
75
95
|
if (ageMs > ttlMs) {
|
|
76
96
|
db.exec('COMMIT');
|
package/src/baton.test.mjs
CHANGED
|
@@ -105,3 +105,47 @@ test('consumeBaton: returns no memoText property in v8 (memo column dropped)', (
|
|
|
105
105
|
assert.equal(result.sessionId, 'S1');
|
|
106
106
|
assert.equal('memoText' in result, false, 'memoText property should be removed in v8');
|
|
107
107
|
});
|
|
108
|
+
|
|
109
|
+
test('consumeBaton: bornAt basis — session born after baton write consumes it', () => {
|
|
110
|
+
const db = makeDb();
|
|
111
|
+
writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
|
|
112
|
+
// セッション誕生 2000、消費 (初回プロンプト) はずっと後の 50 分後でも、
|
|
113
|
+
// TTL は誕生時刻基準なので age = 1 秒で有効
|
|
114
|
+
const result = consumeBaton(db, {
|
|
115
|
+
projectPath: '/proj',
|
|
116
|
+
now: 1000 + 50 * 60 * 1000,
|
|
117
|
+
bornAt: 2000,
|
|
118
|
+
});
|
|
119
|
+
assert.equal(result.sessionId, 'S1');
|
|
120
|
+
assert.equal(result.ageMs, 1000);
|
|
121
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('consumeBaton: bornAt basis — TTL measured from birth, not from consumption time', () => {
|
|
125
|
+
const db = makeDb();
|
|
126
|
+
writeBaton(db, { projectPath: '/proj', sessionId: 'S1', now: 1000 });
|
|
127
|
+
// 誕生が /tl の 2 時間後 → 初回プロンプトが即時でも expired
|
|
128
|
+
const result = consumeBaton(db, {
|
|
129
|
+
projectPath: '/proj',
|
|
130
|
+
now: 1000 + 2 * 60 * 60 * 1000 + 5000,
|
|
131
|
+
bornAt: 1000 + 2 * 60 * 60 * 1000,
|
|
132
|
+
});
|
|
133
|
+
assert.equal(result.sessionId, null);
|
|
134
|
+
assert.equal(result.skipReason, 'expired');
|
|
135
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS c FROM handoff_batons').get().c, 0, 'expired は従来どおり削除');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('consumeBaton: future_baton — baton written after consumer birth is left in place', () => {
|
|
139
|
+
const db = makeDb();
|
|
140
|
+
// 走行中セッション (誕生 1000) が初回プロンプト消費を試みる時、
|
|
141
|
+
// 誕生後 (5000) に書かれた他セッションのバトンは本来の後継のために残す
|
|
142
|
+
writeBaton(db, { projectPath: '/proj', sessionId: 'S-later', now: 5000 });
|
|
143
|
+
const result = consumeBaton(db, { projectPath: '/proj', now: 6000, bornAt: 1000 });
|
|
144
|
+
assert.equal(result.sessionId, null);
|
|
145
|
+
assert.equal(result.skipReason, 'future_baton');
|
|
146
|
+
assert.ok(result.ageMs < 0);
|
|
147
|
+
|
|
148
|
+
const rows = db.prepare('SELECT session_id FROM handoff_batons').all();
|
|
149
|
+
assert.equal(rows.length, 1, 'future baton must NOT be deleted');
|
|
150
|
+
assert.equal(rows[0].session_id, 'S-later');
|
|
151
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function normalizeAuditorBody(value) {
|
|
4
|
+
return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function hashAuditorBody(value) {
|
|
8
|
+
return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
|
|
9
|
+
}
|
|
@@ -20,7 +20,7 @@ function makeDb({ originSessionId = 'origin-1' } = {}) {
|
|
|
20
20
|
const path = join(dir, 'throughline.db');
|
|
21
21
|
const db = new DatabaseSync(path);
|
|
22
22
|
db.exec(`
|
|
23
|
-
PRAGMA user_version =
|
|
23
|
+
PRAGMA user_version = 9;
|
|
24
24
|
CREATE TABLE sessions (session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL);
|
|
25
25
|
CREATE TABLE bodies (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT NOT NULL, turn_number INTEGER NOT NULL, role TEXT NOT NULL, text TEXT NOT NULL, created_at INTEGER NOT NULL);
|
|
26
26
|
INSERT INTO sessions VALUES ('session-1', '/repo');
|
|
@@ -17,6 +17,7 @@ const REQUIRED_DATABASE_COLUMNS = {
|
|
|
17
17
|
bodies: ['id', 'session_id', 'origin_session_id', 'turn_number', 'role', 'text', 'token_count', 'created_at'],
|
|
18
18
|
details: ['id', 'session_id', 'turn_number', 'tool_name', 'input_text', 'output_text', 'token_count', 'created_at', 'origin_session_id', 'kind', 'source_id'],
|
|
19
19
|
handoff_batons: ['project_path', 'session_id', 'created_at'],
|
|
20
|
+
pending_handoffs: ['session_id', 'project_path', 'source', 'auto_predecessor_id', 'created_at'],
|
|
20
21
|
};
|
|
21
22
|
const REQUIRED_DATABASE_INDEXES = ['uq_skeletons_turn_v3', 'uq_details_source'];
|
|
22
23
|
const REQUIRED_INDEX_SHAPES = {
|
|
@@ -116,7 +116,7 @@ test('factory-diagnostics DB inspection rejects version-only fake schema', () =>
|
|
|
116
116
|
const dbPath = join(dir, 'throughline.db');
|
|
117
117
|
try {
|
|
118
118
|
const db = new DatabaseSync(dbPath);
|
|
119
|
-
db.exec('PRAGMA user_version =
|
|
119
|
+
db.exec('PRAGMA user_version = 9; CREATE TABLE sessions (session_id TEXT)');
|
|
120
120
|
db.close();
|
|
121
121
|
assert.equal(inspectFactoryDatabase({ dbPath }).status, 'not_ready');
|
|
122
122
|
} finally {
|
|
@@ -195,7 +195,7 @@ test('factory-diagnostics hook inspection summarizes every canonical ready event
|
|
|
195
195
|
|
|
196
196
|
function createFactorySchema(db) {
|
|
197
197
|
db.exec(`
|
|
198
|
-
PRAGMA user_version =
|
|
198
|
+
PRAGMA user_version = 9;
|
|
199
199
|
CREATE TABLE sessions (
|
|
200
200
|
session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, status TEXT NOT NULL,
|
|
201
201
|
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, merged_into TEXT
|
|
@@ -217,6 +217,10 @@ function createFactorySchema(db) {
|
|
|
217
217
|
CREATE TABLE handoff_batons (
|
|
218
218
|
project_path TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER NOT NULL
|
|
219
219
|
);
|
|
220
|
+
CREATE TABLE pending_handoffs (
|
|
221
|
+
session_id TEXT PRIMARY KEY, project_path TEXT NOT NULL, source TEXT,
|
|
222
|
+
auto_predecessor_id TEXT, created_at INTEGER NOT NULL
|
|
223
|
+
);
|
|
220
224
|
CREATE UNIQUE INDEX uq_skeletons_turn_v3
|
|
221
225
|
ON skeletons(session_id, origin_session_id, turn_number, role);
|
|
222
226
|
CREATE UNIQUE INDEX uq_details_source
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readObserverTurnPage, OBSERVER_READ_SCHEMA } from '../observer-turn-feed.mjs';
|
|
2
|
+
|
|
3
|
+
const ERRORS = Object.freeze({
|
|
4
|
+
args: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_ARGS', message: 'invalid observer-read arguments' },
|
|
5
|
+
input: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_INPUT', message: 'observer read input is invalid' },
|
|
6
|
+
schema: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_SCHEMA', message: 'observer read database schema is unsupported' },
|
|
7
|
+
project: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_PROJECT', message: 'observer read database project does not match' },
|
|
8
|
+
io: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_DB_IO', message: 'observer read database could not be read' },
|
|
9
|
+
internal: { schema: OBSERVER_READ_SCHEMA, status: 'error', code: 'E_OBSERVER_READ_INTERNAL', message: 'observer read failed' },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function parseArgs(argv = []) {
|
|
13
|
+
const out = { projectPath: null, afterCursor: undefined, throughCursor: undefined, pageToken: undefined, limit: undefined, json: false };
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
for (let index = 0; index < argv.length; index++) {
|
|
16
|
+
const arg = argv[index];
|
|
17
|
+
if (arg === '--json') {
|
|
18
|
+
if (seen.has(arg)) throw new TypeError('duplicate option');
|
|
19
|
+
seen.add(arg); out.json = true; continue;
|
|
20
|
+
}
|
|
21
|
+
if (!['--project', '--after-cursor', '--through-cursor', '--page-token', '--limit'].includes(arg) || seen.has(arg)) {
|
|
22
|
+
throw new TypeError('invalid option');
|
|
23
|
+
}
|
|
24
|
+
const value = argv[++index];
|
|
25
|
+
if (!value || value.startsWith('-')) throw new TypeError('missing option value');
|
|
26
|
+
seen.add(arg);
|
|
27
|
+
if (arg === '--project') out.projectPath = value;
|
|
28
|
+
else if (arg === '--after-cursor') out.afterCursor = value;
|
|
29
|
+
else if (arg === '--through-cursor') out.throughCursor = value;
|
|
30
|
+
else if (arg === '--page-token') out.pageToken = value;
|
|
31
|
+
else out.limit = parseLimit(value);
|
|
32
|
+
}
|
|
33
|
+
if (!out.json || !out.projectPath || (out.pageToken !== undefined && (out.afterCursor === undefined || out.throughCursor === undefined))) {
|
|
34
|
+
throw new TypeError('missing required option');
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function run(argv = [], { read = readObserverTurnPage, stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
40
|
+
let args;
|
|
41
|
+
try { args = parseArgs(argv); } catch { writeJson(stderr, ERRORS.args); return 1; }
|
|
42
|
+
try {
|
|
43
|
+
const result = read({
|
|
44
|
+
projectPath: args.projectPath,
|
|
45
|
+
...(args.afterCursor === undefined ? {} : { afterCursor: args.afterCursor }),
|
|
46
|
+
...(args.throughCursor === undefined ? {} : { throughCursor: args.throughCursor }),
|
|
47
|
+
...(args.pageToken === undefined ? {} : { pageToken: args.pageToken }),
|
|
48
|
+
...(args.limit === undefined ? {} : { limit: args.limit }),
|
|
49
|
+
});
|
|
50
|
+
writeJson(stdout, result);
|
|
51
|
+
return 0;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
writeJson(stderr, mapError(error));
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseLimit(value) {
|
|
59
|
+
if (!/^\d+$/.test(value)) throw new TypeError('invalid limit');
|
|
60
|
+
const limit = Number(value);
|
|
61
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new TypeError('invalid limit');
|
|
62
|
+
return limit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mapError(error) {
|
|
66
|
+
if (error?.code === 'E_AUDITOR_CONTEXT_SCHEMA') return ERRORS.schema;
|
|
67
|
+
if (error?.code === 'E_AUDITOR_CONTEXT_PROJECT') return ERRORS.project;
|
|
68
|
+
if (['E_AUDITOR_CONTEXT_DB_OPEN', 'E_AUDITOR_CONTEXT_QUERY'].includes(error?.code)) return ERRORS.io;
|
|
69
|
+
if (error instanceof TypeError) return ERRORS.input;
|
|
70
|
+
return ERRORS.internal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeJson(stream, value) { stream.write(`${JSON.stringify(value)}\n`); }
|