fix(ledger): Gate F follow-up, Filbert's notes 1 to 3 (#1506)

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]>
This commit is contained in:
2026-09-26 16:57:51 -05:00
co-authored by Claude Opus 5.5
parent a4d38a3d93
commit 3a209eeafe
9 changed files with 268 additions and 7 deletions
+2 -1
View File
@@ -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).
+4 -2
View File
@@ -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 */ }
+31 -4
View File
@@ -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 });