T3 messages with role reasoning and an id starting "reasoning:" are now skipped before any other check and counted as reasoningMessages. Any other unknown role, including reasoning with another id prefix, still refuses. Issues updated since the start date are read in pages of 50 until a short page, at most 10 pages. A full tenth page, a duplicate issue number across pages, a page over 50 rows or any failed page refuses with exit 2, so partial totals are never reported. The report prints how many pages it read. The README describes the skip, the page loop and its refusals. Candidate manifest 4eed1d6405a505e88cde5062c61f768639d899b8f301019b1403d68fb15d9d36 (Darkwing round 1, packetd6ea56831), BASEcc48b124f. Approved by Filbert (comment 28407). Sage gate rerun at644ef5310plus the candidate passed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
157 lines
8.8 KiB
JavaScript
157 lines
8.8 KiB
JavaScript
import { lstat } from 'node:fs/promises';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { pathToFileURL } from 'node:url';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { SourceError, UNKNOWN, clean, inRange, issueNumbers, messageKind, readSeats, t3Header } from './ledger.mjs';
|
|
|
|
// T3 keeps every thread message in one SQLite database. This reader opens that
|
|
// file read-only and nothing else in ~/.t3. See
|
|
// docs/plans/2026-09-26_ledger-t3-source.md for the rules below.
|
|
export const UNMAPPED = 't3:unmapped';
|
|
const SKIP = 'use --no-t3 to skip T3';
|
|
const REQUIRED = {
|
|
projection_projects: ['project_id', 'workspace_root', 'deleted_at'],
|
|
projection_threads: ['thread_id', 'project_id', 'title', 'archived_at', 'deleted_at'],
|
|
projection_thread_messages: ['message_id', 'thread_id', 'role', 'text', 'created_at'],
|
|
};
|
|
const DIAGNOSTIC = { orchestration_events: ['stream_id', 'event_type', 'payload_json', 'metadata_json'] };
|
|
|
|
export const defaultT3Path = () => path.join(os.homedir(), '.t3', 'userdata', 'state.sqlite');
|
|
|
|
// Every named path must exist and must not be a symlink. Skipping one would be
|
|
// a silent zero, so each problem refuses the report.
|
|
async function checkPaths(dbPath, isDefault) {
|
|
const dirs = isDefault ? [path.dirname(path.dirname(dbPath)), path.dirname(dbPath)] : [path.dirname(dbPath)];
|
|
for (const [target, wantDir] of [...dirs.map(d => [d, true]), [dbPath, false]]) {
|
|
let stat;
|
|
try { stat = await lstat(target); }
|
|
catch { throw new SourceError(`T3 database unavailable: ${target} is missing or unreadable; ${SKIP}`); }
|
|
if (stat.isSymbolicLink()) throw new SourceError(`T3 database refused: ${target} is a symlink; ${SKIP}`);
|
|
if (wantDir ? !stat.isDirectory() : !stat.isFile()) {
|
|
throw new SourceError(`T3 database refused: ${target} is not a ${wantDir ? 'directory' : 'regular file'}; ${SKIP}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function missingColumns(db, tables) {
|
|
const missing = [];
|
|
for (const [table, columns] of Object.entries(tables)) {
|
|
const have = new Set(db.prepare('select name from pragma_table_info(?)').all(table).map(r => r.name));
|
|
if (!have.size) missing.push(table);
|
|
else for (const column of columns) if (!have.has(column)) missing.push(`${table}.${column}`);
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
// Seat for a thread title: the lower-cased title equals the seat or starts
|
|
// with the seat and a space. Longest seat first, so the most specific wins.
|
|
export function seatForTitle(title, seats) {
|
|
const lower = title.toLowerCase();
|
|
return [...seats].sort((a, b) => b.length - a.length).find(s => lower === s || lower.startsWith(`${s} `)) ?? null;
|
|
}
|
|
|
|
// Origin per message id from thread.message-sent events. Any missing table,
|
|
// column or unparseable event makes the diagnostic unknown; it decides nothing.
|
|
function origins(db, projectId) {
|
|
if (missingColumns(db, DIAGNOSTIC).length) return null;
|
|
const byMessage = new Map();
|
|
const events = db.prepare(`select e.payload_json, e.metadata_json from orchestration_events e
|
|
join projection_threads t on t.thread_id = e.stream_id
|
|
where e.event_type = 'thread.message-sent' and t.project_id = ?`).all(projectId);
|
|
for (const event of events) {
|
|
let payload, metadata;
|
|
try { payload = JSON.parse(event.payload_json); metadata = JSON.parse(event.metadata_json); }
|
|
catch { return null; }
|
|
if (typeof payload?.messageId !== 'string') return null;
|
|
byMessage.set(payload.messageId, typeof metadata?.origin?.appVersion === 'string');
|
|
}
|
|
return byMessage;
|
|
}
|
|
|
|
function query(db, root, range, seats) {
|
|
const missing = missingColumns(db, REQUIRED);
|
|
if (missing.length) throw new SourceError(`T3 schema changed: missing ${missing.join(', ')}`);
|
|
// Compared in JavaScript so a declared collation can't loosen the match.
|
|
const projects = db.prepare('select project_id, workspace_root from projection_projects where deleted_at is null').all()
|
|
.filter(p => p.workspace_root === root);
|
|
if (projects.length !== 1) {
|
|
throw new SourceError(`T3 has ${projects.length ? 'more than one project' : 'no project'} for ${root}; a project opened through a symlink does not match; ${SKIP}`);
|
|
}
|
|
const projectId = projects[0].project_id;
|
|
const threads = new Map(), excluded = { importedThreads: 0, deletedThreads: 0, reasoningMessages: 0 };
|
|
for (const t of db.prepare('select thread_id, title, archived_at, deleted_at from projection_threads where project_id = ?').all(projectId)) {
|
|
if (typeof t.thread_id !== 'string' || typeof t.title !== 'string') throw new SourceError('T3 thread with a non-text id or title');
|
|
if (t.thread_id.startsWith('import:')) { excluded.importedThreads++; continue; }
|
|
if (t.deleted_at !== null) { excluded.deletedThreads++; continue; }
|
|
threads.set(t.thread_id, { id: t.thread_id, title: t.title, archived: t.archived_at !== null, seat: seatForTitle(t.title, seats) });
|
|
}
|
|
const rows = new Map([...seats, UNMAPPED].map(s => [s, { board: 0, agent: 0, human: 0 }]));
|
|
const mentions = new Map(), human = [];
|
|
const messages = db.prepare(`select m.message_id, m.thread_id, m.role, m.text, m.created_at from projection_thread_messages m
|
|
join projection_threads t on t.thread_id = m.thread_id where t.project_id = ?`).all(projectId);
|
|
for (const m of messages) {
|
|
const thread = threads.get(m.thread_id);
|
|
if (!thread) continue;
|
|
// T3 stores reasoning summaries as messages since 2026-10-13. They are
|
|
// skipped before any check, so their content can't refuse or change a run.
|
|
if (m.role === 'reasoning' && typeof m.message_id === 'string' && m.message_id.startsWith('reasoning:')) { excluded.reasoningMessages++; continue; }
|
|
const where = `T3 message ${clean(m.message_id)} in thread ${clean(m.thread_id)}`;
|
|
if (m.role !== 'user' && m.role !== 'assistant') throw new SourceError(`${where} has an unknown role`);
|
|
if (typeof m.text !== 'string') throw new SourceError(`${where} has non-text content`);
|
|
if (typeof m.created_at !== 'string' || !Number.isFinite(Date.parse(m.created_at))) throw new SourceError(`${where} has an invalid created_at`);
|
|
if (m.role !== 'user') continue;
|
|
// A header addressed to its own thread must agree with the title mapping.
|
|
const header = t3Header(m.text);
|
|
if (header && header.toId === thread.id) {
|
|
const to = header.to.toLowerCase();
|
|
if (thread.seat ? to !== thread.seat : seats.includes(to)) {
|
|
throw new SourceError(`T3 header conflict: thread ${clean(thread.id)} "${clean(thread.title)}" maps to ${thread.seat ?? 'no seat'}, but a header addresses ${clean(header.to)}`);
|
|
}
|
|
}
|
|
if (!inRange(m.created_at, range)) continue;
|
|
const kind = messageKind(m.text), seat = thread.seat ?? UNMAPPED;
|
|
rows.get(seat)[kind]++;
|
|
if (kind === 'human') human.push(m.message_id);
|
|
for (const number of issueNumbers(m.text)) {
|
|
if (!mentions.has(number)) mentions.set(number, new Set());
|
|
mentions.get(number).add(seat);
|
|
}
|
|
}
|
|
const byMessage = origins(db, projectId);
|
|
const sentThroughApi = byMessage === null ? UNKNOWN : human.filter(id => byMessage.get(id) === false).length;
|
|
const noEvent = byMessage === null ? UNKNOWN : human.filter(id => !byMessage.has(id)).length;
|
|
const listed = seat => [...threads.values()].filter(t => (t.seat ?? UNMAPPED) === seat)
|
|
.sort((a, b) => a.id.localeCompare(b.id)).map(({ id, title, archived }) => ({ id, title, archived }));
|
|
const seatRows = seats.map(seat => ({ seat, ...rows.get(seat), threads: listed(seat) })).filter(r => r.threads.length);
|
|
return { rows, mentions, excluded, seats: seatRows, unmapped: { ...rows.get(UNMAPPED), threads: listed(UNMAPPED) },
|
|
diagnostic: { humanSentThroughApi: sentThroughApi, humanWithoutEvent: noEvent } };
|
|
}
|
|
|
|
// Reads one snapshot of T3's database. Returns the per-seat rows and issue
|
|
// mentions the ledger merges with Pi, and the report's `t3` section.
|
|
export async function readT3(root, range, { dbPath = defaultT3Path(), isDefault = true } = {}) {
|
|
dbPath = path.resolve(dbPath);
|
|
await checkPaths(dbPath, isDefault);
|
|
const seats = await readSeats(root);
|
|
const url = pathToFileURL(dbPath);
|
|
url.searchParams.set('mode', 'ro');
|
|
let db, result;
|
|
try {
|
|
db = new DatabaseSync(url, { readOnly: true, timeout: 5000 });
|
|
db.exec('BEGIN');
|
|
result = query(db, root, range, seats);
|
|
db.exec('COMMIT');
|
|
} catch (error) {
|
|
// 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 */ }
|
|
}
|
|
const { rows, mentions, ...section } = result;
|
|
return { rows, mentions, section: { read: true, database: { path: dbPath, default: isDefault }, ...section } };
|
|
}
|