|
27 | 27 | // (sessionId, seq) tie-break — field-equivalent to v1's "teammate writes the |
28 | 28 | // leader's file". |
29 | 29 |
|
30 | | -import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs'; |
| 30 | +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; |
31 | 31 | import { join, dirname, basename } from 'node:path'; |
32 | 32 | import { reportSwallowed } from '../error-report.js'; |
33 | 33 | import { isMainAgentRequest } from '../interceptor-core.js'; |
34 | | -import { readPromptsHead, collectPromptsFromEvents } from '../user-prompt-extract.js'; |
35 | | -import { readSession, readJsonlTolerant, listSessionIds } from './replay.js'; |
| 34 | +import { readSession } from './replay.js'; |
36 | 35 | import { iterateJsonlLines } from './jsonl-read.js'; |
37 | 36 | import { isDiscardableSession } from './session-select.js'; |
38 | | -import { blobPath, isSupportedWireFormat, dirSizeSync } from './layout.js'; |
| 37 | +import { blobPath, isSupportedWireFormat } from './layout.js'; |
39 | 38 | import { SingleFlight } from './singleflight.js'; |
| 39 | +import { listV2Sessions } from './session-list.js'; |
40 | 40 |
|
41 | 41 | // Same stamping rules as the v1 interceptor (KEEP IN SYNC: server/interceptor.js |
42 | 42 | // requestEntry construction) — recomputed from the journal's url, not from kind, |
@@ -1056,109 +1056,8 @@ export async function streamV2WindowedEntries(sessionDir, opts, onEntry) { |
1056 | 1056 |
|
1057 | 1057 | // ─── session listing (spec §12, list entry pulled forward from S6a) ───────── |
1058 | 1058 |
|
1059 | | -/** Bounded head read: parse the FIRST JSONL line of a file without loading the |
1060 | | - * whole thing (a main conversation's opening snapshot can be multi-MB; the |
1061 | | - * list only wants a preview). Returns null on any shortfall. */ |
1062 | | -function readFirstJsonLine(path, budget = 256 * 1024) { |
1063 | | - let fd; |
1064 | | - try { |
1065 | | - fd = openSync(path, 'r'); |
1066 | | - const buf = Buffer.alloc(budget); |
1067 | | - const n = readSync(fd, buf, 0, budget, 0); |
1068 | | - const head = buf.toString('utf-8', 0, n); |
1069 | | - const nl = head.indexOf('\n'); |
1070 | | - if (nl <= 0) return null; // no complete first line inside the budget |
1071 | | - return JSON.parse(head.slice(0, nl)); |
1072 | | - } catch { |
1073 | | - return null; |
1074 | | - } finally { |
1075 | | - if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } } |
1076 | | - } |
1077 | | -} |
1078 | | - |
1079 | | -/** |
1080 | | - * Summarize every session under LOG_DIR/<project>/ for the log list (spec §12). |
1081 | | - * Deliberately cheap: journal lines only (small) + a bounded head read of the |
1082 | | - * main conversation's first epoch for the preview — conversation bodies are |
1083 | | - * never loaded. Teammate linkage is surfaced via `leader` so the caller can |
1084 | | - * fold those sessions into their leader's view instead of double-listing. |
1085 | | - * @returns {Array<{sid, dir, startTs, leader, turns, size, preview}>} |
1086 | | - */ |
1087 | | -export function listV2Sessions(projectDir) { |
1088 | | - const out = []; |
1089 | | - for (const sid of listSessionIds(projectDir)) { |
1090 | | - try { |
1091 | | - const dir = join(projectDir, 'sessions', sid); |
1092 | | - if (!existsSync(join(dir, 'journal.jsonl'))) continue; |
1093 | | - let meta = null; |
1094 | | - try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { /* tolerated — journal is self-describing */ } |
1095 | | - if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) { |
1096 | | - // Reader version gate (spec §14): don't list a session this build |
1097 | | - // can't read — a garbage preview/turn-count is worse than absence. |
1098 | | - reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${meta.wireFormat}`)); |
1099 | | - continue; |
1100 | | - } |
1101 | | - |
1102 | | - // turns = main requests that completed (journal two-phase fold). The |
1103 | | - // journal sentinel is checked in the same pass: per §14 the per-file |
1104 | | - // sentinel WINS over meta.json, and readSession/adapter refuse such a |
1105 | | - // session — listing it would show a phantom row that opens empty. |
1106 | | - const reqKind = new Map(); |
1107 | | - let turns = 0; |
1108 | | - let sentinelVersion = null; |
1109 | | - let hasMainOrTeammate = false; |
1110 | | - for (const line of readJsonlTolerant(join(dir, 'journal.jsonl'))) { |
1111 | | - if (line.ph === 'req') { |
1112 | | - reqKind.set(line.seq, line.kind); |
1113 | | - if (line.kind === 'main' || line.kind === 'teammate') hasMainOrTeammate = true; |
1114 | | - } |
1115 | | - else if (line.ph === 'done' && reqKind.get(line.seq) === 'main') { |
1116 | | - turns++; |
1117 | | - reqKind.delete(line.seq); // fold duplicate done lines (§14) |
1118 | | - } else if (line.ph === 'meta' && typeof line.wireFormat === 'number' && !isSupportedWireFormat(line.wireFormat)) { |
1119 | | - sentinelVersion = line.wireFormat; |
1120 | | - break; |
1121 | | - } |
1122 | | - } |
1123 | | - if (sentinelVersion != null) { |
1124 | | - reportSwallowed('v2-read.unsupported-wire-format', new Error(`${sid}: wireFormat=${sentinelVersion} (journal sentinel)`)); |
1125 | | - continue; |
1126 | | - } |
1127 | | - |
1128 | | - // preview = ALL user prompts of the session, from the prompts.jsonl |
1129 | | - // display cache (written by V2Writer / the converter; bounded head read |
1130 | | - // so the list stays O(budget) per session). Sessions predating the |
1131 | | - // cache fall back to the first epoch's first line — routed through the |
1132 | | - // shared extractor so command/caveat chrome never leaks into the row. |
1133 | | - let preview = readPromptsHead(join(dir, 'prompts.jsonl')); |
1134 | | - if (preview.length === 0) { |
1135 | | - const first = readFirstJsonLine(join(dir, 'conversations', 'main', 'e0.jsonl')); |
1136 | | - if (first && Array.isArray(first.msgs)) { |
1137 | | - preview = collectPromptsFromEvents([first]); |
1138 | | - } |
1139 | | - } |
1140 | | - |
1141 | | - out.push({ |
1142 | | - sid, |
1143 | | - dir, |
1144 | | - startTs: (meta && meta.startTs) || '', |
1145 | | - leader: (meta && meta.leader) || null, |
1146 | | - turns, |
1147 | | - size: dirSizeSync(dir), |
1148 | | - preview, |
1149 | | - // Discardable-session verdict. KEEP IN SYNC: session-select.js |
1150 | | - // isDiscardableSession is the canonical rule; this fold pre-computes |
1151 | | - // it for free over the FULL journal (the canonical scan is 8MB- |
1152 | | - // budgeted — intentional asymmetry, a first main sits at the head). |
1153 | | - // When the fold says discard, the canonical predicate CONFIRMS it: |
1154 | | - // readJsonlTolerant swallows an I/O error (Windows EBUSY/EPERM lock) |
1155 | | - // into zero lines, which must KEEP the session, not hide it — the |
1156 | | - // canonical path carries that error→keep direction (ioErrorResult). |
1157 | | - // Main-bearing sessions never pay the extra read; probe journals are |
1158 | | - // ~3 lines. |
1159 | | - discard: !(meta && meta.leader) && !hasMainOrTeammate && isDiscardableSession(dir, meta), |
1160 | | - }); |
1161 | | - } catch { /* one unreadable session must not break the list */ } |
1162 | | - } |
1163 | | - return out; |
1164 | | -} |
| 1059 | +// listV2Sessions is re-exported from session-list.js (P0-A row cache, 2026-07-31). |
| 1060 | +// The full per-session summarization logic (incl. the readFirstJsonLine preview |
| 1061 | +// fallback) moved there; this re-export keeps the public API unchanged for all |
| 1062 | +// callers (log-management.js, routes/im.js, tests). |
| 1063 | +export { listV2Sessions }; |
0 commit comments