Darkwing's follow-up to the T3 thread source: manifest 382f5bb0 pins t3.mjs, ledger.test.mjs and README.md. Filbert approved it (review 6fd693b6). Ledger 51/51; the eight suites pass on the index. Co-Authored-By: Claude Opus 5.5 <[email protected]>
100 lines
6.6 KiB
Diff
100 lines
6.6 KiB
Diff
diff --git a/packages/ledger/README.md b/packages/ledger/README.md
|
|
index 393e9c37..3a2ce27c 100644
|
|
--- a/packages/ledger/README.md
|
|
+++ b/packages/ledger/README.md
|
|
@@ -39,7 +39,8 @@ No install, build, service restart, or configuration change is needed.
|
|
duplicated entries in copied logs are not deduplicated. No transcript content
|
|
leaves the parser. Assistant messages and logs outside repo seats do not count.
|
|
Symlink source directories are refused and symlink files are not followed.
|
|
- A line ends at `\n` only. A U+2028 inside a JSON string does not split a record.
|
|
+ A line ends at `\n` only. A U+2028 or U+2029 inside a JSON string does not
|
|
+ split a record.
|
|
- Table 2 also counts T3 thread messages with role `user`. The T3 source
|
|
follows. A seat's row sums its Pi and T3 counts; the JSON keeps the split in
|
|
`pi` (Pi rows) and `t3.seats` (T3 rows).
|
|
diff --git a/packages/ledger/src/t3.mjs b/packages/ledger/src/t3.mjs
|
|
index 9672fcc9..fc9da14e 100644
|
|
--- a/packages/ledger/src/t3.mjs
|
|
+++ b/packages/ledger/src/t3.mjs
|
|
@@ -140,8 +140,10 @@ export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault
|
|
result = query(db, root, range, seats);
|
|
db.exec('COMMIT');
|
|
} catch (error) {
|
|
- if (error instanceof SourceError) throw error;
|
|
- throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode ?? 'error'}); ${SKIP}`);
|
|
+ // Only a SQLite failure carries an errcode. Anything else is a bug and
|
|
+ // surfaces as itself, not as a database problem.
|
|
+ if (error instanceof SourceError || typeof error?.errcode !== 'number') throw error;
|
|
+ throw new SourceError(`T3 database cannot be read: ${dbPath} (SQLite ${error.errcode}); ${SKIP}`);
|
|
} finally {
|
|
try { if (db?.isTransaction) db.exec('ROLLBACK'); } catch { /* the close below still runs */ }
|
|
try { db?.close(); } catch { /* nothing was written */ }
|
|
diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs
|
|
index 8e60b96b..834dbd55 100644
|
|
--- a/packages/ledger/tests/ledger.test.mjs
|
|
+++ b/packages/ledger/tests/ledger.test.mjs
|
|
@@ -7,6 +7,7 @@ import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
|
|
+import { readT3 } from '../src/t3.mjs';
|
|
|
|
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
|
|
const range = dateRange('2026-09-06', '2026-09-12');
|
|
@@ -49,7 +50,8 @@ const fixtureIssues = [
|
|
function fixture(t) {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
|
|
// No test opens the real ~/.t3: every CLI run gets this HOME, with an empty
|
|
- // T3 database at the default path. The CLI's root is a realpath.
|
|
+ // T3 database at the default path. The one in-process readT3 call passes an
|
|
+ // explicit fixture path. The CLI's root is a realpath.
|
|
const home = mkdtempSync(path.join(os.tmpdir(), 'ledger-home-'));
|
|
t.after(() => { rmSync(root, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true }); });
|
|
const defaultDb = path.join(home, '.t3/userdata/state.sqlite');
|
|
@@ -134,10 +136,11 @@ test('partial or malformed session log refuses with location, not content', t =>
|
|
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
|
|
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
|
|
});
|
|
-test('a U+2028 inside a session string is one line, not a malformed record', t => {
|
|
+test('a U+2028 or U+2029 inside a session string is one line, not a malformed record', t => {
|
|
const f = fixture(t);
|
|
- f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
|
- assert.ok(readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8').includes('\u2028'));
|
|
+ f.put('.pi/state/bob/sessions/sep.jsonl', [f.entry('Jason: one\u2028two\u2029three #2'), f.entry('[h:alice -> h:bob] ok')].map(x => JSON.stringify(x)).join('\r\n') + '\r\n');
|
|
+ const written = readFileSync(path.join(f.root, '.pi/state/bob/sessions/sep.jsonl'), 'utf8');
|
|
+ assert.ok(written.includes('\u2028') && written.includes('\u2029'));
|
|
const r = f.run(['--json']); assert.equal(r.status, 0, r.stderr);
|
|
assert.deepEqual(JSON.parse(r.stdout).seats[1], { seat: 'bob', board: 0, agent: 1, human: 1 });
|
|
});
|
|
@@ -334,6 +337,30 @@ test('T3: a missing orchestration_events makes the diagnostic unknown and keeps
|
|
assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
|
assert.deepEqual(after.seats, before.seats); assert.deepEqual(after.totals, before.totals);
|
|
});
|
|
+test('T3: a human message with no event counts in humanWithoutEvent', t => {
|
|
+ const f = t3Fixture(t);
|
|
+ f.write({ messages: [...f.messages, { thread: T1, text: 'typed, no event', origin: 'none' }] });
|
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
+ assert.deepEqual(JSON.parse(r.stdout).t3.diagnostic, { humanSentThroughApi: 1, humanWithoutEvent: 1 });
|
|
+});
|
|
+const badEvent = payload => `insert into orchestration_events (stream_id, event_type, payload_json, metadata_json) values ('${T1}', 'thread.message-sent', '${payload}', '{}')`;
|
|
+for (const [name, payload] of [['an unparseable event', '{bad'], ['an event with no string messageId', '{"messageId":7}']]) {
|
|
+ test(`T3: ${name} makes the diagnostic unknown and keeps the counts`, t => {
|
|
+ const f = t3Fixture(t); f.write();
|
|
+ const before = JSON.parse(f.run(['--json', '--t3-db', f.db]).stdout);
|
|
+ f.write({ after: [badEvent(payload)] });
|
|
+ const r = f.run(['--json', '--t3-db', f.db]); assert.equal(r.status, 0, r.stderr);
|
|
+ const after = JSON.parse(r.stdout);
|
|
+ assert.deepEqual(after.t3.diagnostic, { humanSentThroughApi: 'unknown', humanWithoutEvent: 'unknown' });
|
|
+ assert.deepEqual(after.seats, before.seats);
|
|
+ });
|
|
+}
|
|
+test('T3: an error that is not from SQLite is rethrown, not reported as a database failure', async t => {
|
|
+ const f = t3Fixture(t); f.write();
|
|
+ // In process with an explicit path, so the real ~/.t3 stays closed. A null
|
|
+ // range makes inRange throw a TypeError inside the read transaction.
|
|
+ await assert.rejects(readT3(f.real, null, { dbPath: f.db, isDefault: false }), TypeError);
|
|
+});
|
|
for (const link of ['.t3', '.t3/userdata', '.t3/userdata/state.sqlite']) test(`T3: a symlink at ~/${link} exits 1`, t => {
|
|
const f = fixture(t), target = path.join(f.home, 'real', link);
|
|
mkdirSync(path.dirname(target), { recursive: true });
|