diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml index 23cf137195..e07df83cb9 100644 --- a/docs/cookbook/adding-a-session-format-version.i18n.yaml +++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-session-format-version.md -adding-a-session-format-version.md: 8ba899cc699a1962bb3084dd17798f1679e943ec -adding-a-session-format-version.zh.md: 76a551533420c40b40b0982860bb76a8f262def7 +adding-a-session-format-version.md: b962ffb1c14b9ce95741071821e601ea483061dd +adding-a-session-format-version.zh.md: 398d77adddfcf28c794fea5c516ae09e2b4fa03c diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md index 8ba899cc69..b962ffb1c1 100644 --- a/docs/cookbook/adding-a-session-format-version.md +++ b/docs/cookbook/adding-a-session-format-version.md @@ -127,7 +127,7 @@ pnpm run migrate:sessions-to-v4 The default root is `~/.dsh/sessions`. Use `--sessions-dir /path/to/sessions-copy` for another corpus, or `--help` for usage. Concurrent jobs default to the available CPU count capped at 16; `--jobs N` accepts any positive safe integer, including larger expert overrides, and `--jobs 1` runs serially. A bounded queue opens only that many Sessions at once, regardless of corpus size. Each historical Session goes through the normal locked, validated publication path to create a V4 successor beside its unchanged source files. Existing V4 Sessions are opened read-only; rerunning does not reconvert them. The script makes no model requests and does not change conversion or refusal rules. -The terminal reports each Session's selected file, version, progress, outcome, and elapsed time. Individual errors do not stop later Sessions. The final summary lists every failure and the full diagnostic log under the system temporary directory; any failure returns a nonzero exit status. Include that log and the printed checkout commit when reporting a migration problem. The report distinguishes Sessions converted during this run from those already at V4. +The terminal reports each Session's selected file, version, progress, outcome, and elapsed time. Individual errors do not stop later Sessions. The final summary lists every failure and the full diagnostic log under the system temporary directory; any failure returns a nonzero exit status. It also prints JSON and saves the same report as `summary.json` beside `migration.log`: counts by source version and outcome, grouped failure reasons, per-input diagnostics, runtime details, and both report paths. Known diagnostics expose event types, unexpected members, child-log involvement, and sequence numbers; unrecognized errors retain their messages without inferred causes. Include these reports when reporting a migration problem. Sessions converted during this run remain distinct from those already at V4. Concurrent conversion can invalidate a parent’s child evidence when the child publishes V4. Only that source-change error receives one sequential retry after all initial jobs finish; corruption, unsupported formats, and held locks are reported immediately. Completion lines keep the original input index and a separate completed count, so out-of-order results remain readable. diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md index 76a5515334..398d77addd 100644 --- a/docs/cookbook/adding-a-session-format-version.zh.md +++ b/docs/cookbook/adding-a-session-format-version.zh.md @@ -127,7 +127,7 @@ pnpm run migrate:sessions-to-v4 默认根目录是 `~/.dsh/sessions`。可用 `--sessions-dir /path/to/sessions-copy` 指定其他日志集,或用 `--help` 查看用法。默认并发数取可用 CPU 数且最多为 16;`--jobs N` 接受任意正安全整数,专家用户可指定更大的值,`--jobs 1` 则串行运行。有界队列只同时打开指定数量的 Session,不随日志集大小增加。每个历史 Session 都通过正常的加锁、校验和发布流程,在未修改的源文件旁创建 V4 后继。已有 V4 Session 仅以只读方式打开;重复运行不会重新转换它们。脚本不发起模型请求,也不改变转换或拒绝规则。 -终端逐个报告 Session 选中的文件、版本、进度、结果和耗时。单个错误不会阻止后续 Session。最终摘要列出全部失败项及系统临时目录中的完整诊断日志;存在任何失败时返回非零退出状态。报告迁移问题时,请附上该日志及输出的工作区 commit。报告区分本次转换成功的 Session 和运行前已为 V4 的 Session。 +终端逐个报告 Session 选中的文件、版本、进度、结果和耗时。单个错误不会阻止后续 Session。最终摘要列出全部失败项及系统临时目录中的完整诊断日志;存在任何失败时返回非零退出状态。同时打印 JSON,并把相同报告保存为 `migration.log` 旁的 `summary.json`:包含按源版本和结果统计的数量、失败原因分组、逐项诊断、运行时信息和两份报告路径。已知诊断提取事件类型、非预期字段、是否涉及子日志及序号;未知错误保留消息,不推断原因。报告迁移问题时,请附上这些报告。本次转换成功的 Session 与运行前已为 V4 的 Session 分开统计。 并发转换时,子 Session 发布 V4 可能使父 Session 使用的子日志证据失效。只有这种源变化错误会在全部首次任务结束后串行重试一次;损坏、不支持的格式和已持有的写入锁会立即报告。完成行保留原始输入编号,并单独显示已完成数量,使乱序结果仍易于阅读。 diff --git a/scripts/migrate-sessions-to-v4.spec.ts b/scripts/migrate-sessions-to-v4.spec.ts index e658fd83b0..06503c2fb9 100644 --- a/scripts/migrate-sessions-to-v4.spec.ts +++ b/scripts/migrate-sessions-to-v4.spec.ts @@ -28,10 +28,12 @@ async function runAt(entrypoint: string, ...args: string[]) { stopProcesses.push(async () => { child.kill('SIGKILL'); await child }) const result = await child const logPath = [...result.stdout.matchAll(/^Full log: (.+)$/gmu)].at(-1)?.[1] + const summaryPath = [...result.stdout.matchAll(/^JSON summary: (.+)$/gmu)].at(-1)?.[1] if (logPath !== undefined) directories.add(dirname(logPath)) expect(result.timedOut, result.stderr).toBe(false) expect(result.signal, result.stderr).toBeUndefined() - return { stdout: result.stdout, stderr: result.stderr, status: result.exitCode, logPath } + const summary: unknown = summaryPath === undefined ? undefined : JSON.parse(readFileSync(summaryPath, 'utf8')) + return { stdout: result.stdout, stderr: result.stderr, status: result.exitCode, logPath, summaryPath, summary } } function run(...args: string[]) { @@ -167,6 +169,27 @@ describe('one-time V4 migration command', () => { expect(first.stdout).toContain('converted=2, already-V4=1, failed=0, skipped=1') expect(first.stdout).toContain('START session.v3.jsonl') expect(first.stdout).toContain('V3 -> V4: session.v4.jsonl') + const checkoutCommit = /^Git HEAD: ([0-9a-f]{40})$/mu.exec(first.stdout)?.[1] + expect(checkoutCommit).toBeDefined() + expect(first.summary).toMatchObject({ + schemaVersion: 1, + targetVersion: 4, + sessionRoot: root, + inputCount: 4, + jobs: Math.min(availableParallelism(), 16), + checkoutCommit, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + textLogPath: first.logPath, + summaryPath: first.summaryPath, + totals: { converted: 2, alreadyV4: 1, failed: 0, skipped: 1 }, + bySourceVersion: { + '0': { converted: 1, alreadyV4: 0, failed: 0, skipped: 0 }, + '3': { converted: 1, alreadyV4: 0, failed: 0, skipped: 0 }, + '4': { converted: 0, alreadyV4: 1, failed: 0, skipped: 0 }, + unknown: { converted: 0, alreadyV4: 0, failed: 0, skipped: 1 }, + }, + failureGroups: [], + }) const target = join(source.directory, generationLogFilename(4, compression)) const targetBytes = await readFile(target) const decoded = compression === 'none' ? targetBytes : Buffer.concat(await Promise.all( @@ -178,6 +201,11 @@ describe('one-time V4 migration command', () => { const second = await run('--sessions-dir', root) expect(second.status, second.stdout + second.stderr).toBe(0) expect(second.stdout).toContain('converted=0, already-V4=3, failed=0, skipped=1') + expect(second.summary).toMatchObject({ + totals: { converted: 0, alreadyV4: 3, failed: 0, skipped: 1 }, + bySourceVersion: { '4': { converted: 0, alreadyV4: 3, failed: 0, skipped: 0 } }, + failureGroups: [], + }) expect(await readFile(target)).toEqual(targetBytes) for (const original of [old, older, source, current]) expect(await readFile(original.path)).toEqual(original.bytes) expect(first.logPath).toBeDefined() @@ -185,7 +213,14 @@ describe('one-time V4 migration command', () => { expect(log).toContain('Git HEAD: ') expect(log).toContain(`Node: ${process.version}; platform: ${process.platform}/${process.arch}`) expect(log).toContain(first.stdout.trim()) - if (process.platform !== 'win32') expect(statSync(first.logPath!).mode & 0o777).toBe(0o600) + expect(first.summaryPath).toBe(join(dirname(first.logPath!), 'summary.json')) + const json = readFileSync(first.summaryPath!, 'utf8') + expect(first.stdout.endsWith(json.trimEnd())).toBe(true) + expect(log.endsWith(json)).toBe(true) + if (process.platform !== 'win32') { + expect(statSync(first.logPath!).mode & 0o777).toBe(0o600) + expect(statSync(first.summaryPath!).mode & 0o777).toBe(0o600) + } }) it.each(['none', 'zstd'] as const)('preserves %s parent/child history and catalog with serial or parallel jobs', async (compression) => { @@ -236,6 +271,57 @@ describe('one-time V4 migration command', () => { expect(existsSync(join(good.directory, 'session.v4.jsonl'))).toBe(true) }) + it('groups matching failures by source version while retaining each input diagnostic', async () => { + const root = temporaryRoot() + const event = { type: 'unrecognized/required', data: {} } + const first = await fixture(root, 'a-v3', 3, 'none', [event]) + const second = await fixture(root, 'b-v3', 3, 'none', [event]) + const legacy = await fixture(root, 'c-v0', 0, 'none', [event]) + const result = await run('--sessions-dir', root) + expect(result.status, result.stdout + result.stderr).toBe(1) + const anyString: unknown = expect.any(String) + expect(result.summary).toMatchObject({ + inputCount: 3, + totals: { converted: 0, alreadyV4: 0, failed: 3, skipped: 0 }, + bySourceVersion: { + '0': { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 }, + '3': { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 }, + }, + failureGroups: [ + { sourceVersion: 0, reason: 'unknown_event', eventType: 'unrecognized/required', count: 1, + items: [{ inputPath: legacy.path, reason: 'unknown_event', errorName: anyString, message: anyString }] }, + { sourceVersion: 3, reason: 'unknown_event', eventType: 'unrecognized/required', count: 2, + items: [ + { inputPath: first.path, reason: 'unknown_event', errorName: anyString, message: anyString }, + { inputPath: second.path, reason: 'unknown_event', errorName: anyString, message: anyString }, + ] }, + ], + }) + for (const source of [first, second, legacy]) expect(await readFile(source.path)).toEqual(source.bytes) + }) + + it('groups sequence failures without dropping their distinct sequence coordinates', async () => { + const root = temporaryRoot() + const items: Array<{ inputPath: string; expectedSeq: number; actualSeq: number }> = [] + for (const [id, actualSeq] of [['a-gap', 2], ['b-gap', 4]] as const) { + const source = await fixture(root, id, 2, 'none', [ + { type: 'turn/start', data: { turn: 1 } }, + { type: 'step/start', data: { turn: 1, step: 1 } }, + { type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }, + ]) + await writeFile(source.path, source.bytes.toString().replace('"seq":1', `"seq":${actualSeq}`)) + items.push({ inputPath: source.path, expectedSeq: 1, actualSeq }) + } + const result = await run('--sessions-dir', root) + expect(result.status, result.stdout + result.stderr).toBe(1) + expect(result.summary).toMatchObject({ + inputCount: 2, + totals: { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 }, + bySourceVersion: { '2': { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 } }, + failureGroups: [{ sourceVersion: 2, reason: 'sequence_gap', count: 2, items }], + }) + }) + it('opens an existing V4 torn tail without repairing its bytes', async () => { const root = temporaryRoot() const current = await fixture(root, 'current', 4, 'none') @@ -265,10 +351,17 @@ describe('one-time V4 migration command', () => { expect(help.stdout).toContain('Defaults to ~/.dsh/sessions') expect(help.stdout).toContain('CPU count capped at 16') expect((await run('--unknown')).status).toBe(1) - const result = await run('--sessions-dir', join(temporaryRoot(), 'missing')) + const missing = join(temporaryRoot(), 'missing') + const result = await run('--sessions-dir', missing) expect(result.status).toBe(1) expect(result.stdout).toContain('converted=0, already-V4=0, failed=1, skipped=0') expect(result.logPath).toBeDefined() + expect(result.summary).toMatchObject({ + inputCount: 0, + totals: { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 }, + bySourceVersion: { unknown: { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 } }, + failureGroups: [{ sourceVersion: 'unknown', count: 1, items: [{ inputPath: missing }] }], + }) }) it('accepts an explicit job count above the default cap', async () => { @@ -276,6 +369,12 @@ describe('one-time V4 migration command', () => { expect(result.status, result.stdout + result.stderr).toBe(0) expect(result.stdout).toContain('Session jobs: 32') expect(result.stdout).toContain('converted=0, already-V4=0, failed=0, skipped=0') + expect(result.summary).toMatchObject({ + inputCount: 0, + totals: { converted: 0, alreadyV4: 0, failed: 0, skipped: 0 }, + bySourceVersion: {}, + failureGroups: [], + }) }) it('runs the entrypoint through a symbolic link to the checkout', async () => { diff --git a/scripts/migrate-sessions-to-v4.ts b/scripts/migrate-sessions-to-v4.ts index 54ad000788..208821a16b 100644 --- a/scripts/migrate-sessions-to-v4.ts +++ b/scripts/migrate-sessions-to-v4.ts @@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process' import assert from 'node:assert/strict' -import { appendFileSync, closeSync, mkdtempSync, openSync, realpathSync } from 'node:fs' +import { appendFileSync, closeSync, mkdtempSync, openSync, realpathSync, writeFileSync } from 'node:fs' import { readdir, stat } from 'node:fs/promises' import { availableParallelism, homedir, tmpdir } from 'node:os' import { basename, join, relative, resolve } from 'node:path' @@ -13,13 +13,14 @@ import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { encodeSegment, generationLogFilename, parseGenerationLogFilename, type JsonlCompression } from '../packages/session/session-persistence-jsonl/src/format.ts' import { JsonlGenerationSourceChangedError } from '../packages/session/session-persistence-jsonl/src/generation.ts' +import { classifyMigrationFailure, type MigrationFailureDiagnostic } from './migration-failure-summary.ts' const usage = `Usage: pnpm run migrate:sessions-to-v4 [--sessions-dir PATH] [--jobs N] Publish V4 successors beside unchanged historical Session generations. Defaults to ~/.dsh/sessions. Already-V4 Sessions are opened read-only. No model or API key is used. Failures do not stop subsequent Sessions. -The complete report is saved in a private OS temporary directory. +The text log and final JSON summary are saved in a private OS temporary directory. Options: --sessions-dir PATH Session root to migrate @@ -33,6 +34,54 @@ interface Candidate { readonly error?: unknown } +interface OutcomeCounts { + converted: number + alreadyV4: number + failed: number + skipped: number +} + +type SourceVersion = number | 'unknown' +interface Failure extends MigrationFailureDiagnostic { + inputPath: string + sourceVersion: SourceVersion +} + +interface FailureGroup extends Pick { + sourceVersion: SourceVersion + count: number + items: Array +} + +function emptyCounts(): OutcomeCounts { + return { converted: 0, alreadyV4: 0, failed: 0, skipped: 0 } +} + +function summarizeFailures(failures: readonly Failure[]): FailureGroup[] { + const groups = new Map() + for (const { sourceVersion, ...item } of failures) { + const key = JSON.stringify([ + String(sourceVersion), item.reason, item.errorName, item.eventType ?? null, item.member ?? null, item.child ?? false, + ]) + let group = groups.get(key) + if (group === undefined) { + group = { + sourceVersion, reason: item.reason, errorName: item.errorName, + ...item.eventType === undefined ? {} : { eventType: item.eventType }, + ...item.member === undefined ? {} : { member: item.member }, + ...item.child === undefined ? {} : { child: item.child }, + count: 0, items: [], + } + groups.set(key, group) + } + group.count += 1 + group.items.push(item) + } + return [...groups].sort(([left], [right]) => left.localeCompare(right)).map(([, group]) => ({ + ...group, items: group.items.sort((left, right) => left.inputPath.localeCompare(right.inputPath)), + })) +} + async function inspectDirectory(directory: string): Promise { try { const entries = await readdir(directory) @@ -115,32 +164,44 @@ export async function runMigrationJobs( } async function migrate(root: string, jobs: number): Promise { - const logPath = join(mkdtempSync(join(tmpdir(), 'dsh-migrate-v4-')), 'migration.log') + const reportDirectory = mkdtempSync(join(tmpdir(), 'dsh-migrate-v4-')) + const logPath = join(reportDirectory, 'migration.log') + const summaryPath = join(reportDirectory, 'summary.json') const log = openSync(logPath, 'wx', 0o600) const write = (line: string): void => { appendFileSync(log, `${line}\n`) console.log(line) } - const failures: { directory: string; message: string }[] = [] - const fail = (directory: string, error: unknown): void => { - const message = error instanceof Error ? error.message : String(error) - failures.push({ directory, message }) - write(`ERROR ${JSON.stringify(directory)}: ${message}`) + const totals = emptyCounts() + const bySourceVersion: Record = {} + const recordOutcome = (version: SourceVersion, outcome: keyof OutcomeCounts): void => { + totals[outcome] += 1 + const counts = bySourceVersion[String(version)] ??= emptyCounts() + counts[outcome] += 1 + } + const failures: Failure[] = [] + const fail = (inputPath: string, error: unknown, sourceVersion: SourceVersion = 'unknown'): void => { + const diagnostic = classifyMigrationFailure(error) + failures.push({ inputPath, sourceVersion, ...diagnostic }) + recordOutcome(sourceVersion, 'failed') + write(`ERROR ${JSON.stringify(inputPath)}: ${diagnostic.message}`) appendFileSync(log, `${inspect(error, { depth: null, colors: false })}\n`) } - let converted = 0 - let current = 0 - let skipped = 0 + const startedAt = new Date().toISOString() + let inputCount = 0 + let checkoutCommit: string | null = null try { - write(`Session migration to V4 started ${new Date().toISOString()}`) + write(`Session migration to V4 started ${startedAt}`) write(`Root: ${root}`) write(`Session jobs: ${jobs}`) write(`Node: ${process.version}; platform: ${process.platform}/${process.arch}`) - write(`Git HEAD: ${execFileSync('git', ['rev-parse', 'HEAD'], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }).trim()}`) + checkoutCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }).trim() + write(`Git HEAD: ${checkoutCommit}`) write(`Full log: ${logPath}`) assert.equal(SESSION_FORMAT_VERSION, 4, 'this one-time command requires a V4 Session writer') assert.equal(sessionFormatCatalog.currentVersion, 4, 'this one-time command requires a V4 format catalog') const candidates = await discover(root) + inputCount = candidates.length write(`Discovered ${candidates.length} Session directories or invalid layout entries.`) const compression = candidates.find(candidate => candidate.source !== undefined)?.source?.compression ?? 'zstd' const ctx = new Context() @@ -163,15 +224,14 @@ async function migrate(root: string, jobs: number): Promise { throw candidate.error instanceof Error ? candidate.error : new Error('directory inspection failed', { cause: candidate.error }) } if (source === undefined) { - skipped += 1 + recordOutcome('unknown', 'skipped') finish('SKIPPED: no canonical Session generation') return false } const id = directoryId(directory) const handle = await ctx.sessionPersistence.open(id, source.version === 4 ? 'read' : 'write') await handle.close() - if (source.version === 4) current += 1 - else converted += 1 + recordOutcome(source.version, source.version === 4 ? 'alreadyV4' : 'converted') finish(`${source.version === 4 ? 'already V4 (opened successfully)' : `V${source.version} -> V4: ${generationLogFilename(4, source.compression)}`} (${((performance.now() - started) / 1000).toFixed(2)}s)`) } catch (error: unknown) { if (!retry && error instanceof JsonlGenerationSourceChangedError) { @@ -180,7 +240,7 @@ async function migrate(root: string, jobs: number): Promise { return true } finish(`FAILED (${((performance.now() - started) / 1000).toFixed(2)}s)`) - fail(source === undefined ? directory : join(directory, source.filename), error) + fail(source === undefined ? directory : join(directory, source.filename), error, source?.version) } return false }) @@ -190,15 +250,37 @@ async function migrate(root: string, jobs: number): Promise { } catch (error: unknown) { fail(root, error) } finally { - write(`Summary: converted=${converted}, already-V4=${current}, failed=${failures.length}, skipped=${skipped}`) - if (failures.length > 0) { - write('Failures (full stacks and causes are in the log):') - for (const failure of failures) write(`- ${JSON.stringify(failure.directory)}: ${failure.message}`) + try { + write(`Summary: converted=${totals.converted}, already-V4=${totals.alreadyV4}, failed=${totals.failed}, skipped=${totals.skipped}`) + if (failures.length > 0) { + write('Failures (full stacks and causes are in the log):') + for (const failure of failures) write(`- ${JSON.stringify(failure.inputPath)}: ${failure.message}`) + } + write(`Full log: ${logPath}`) + const summary = JSON.stringify({ + schemaVersion: 1, + targetVersion: 4, + sessionRoot: root, + inputCount, + jobs, + startedAt, + finishedAt: new Date().toISOString(), + checkoutCommit, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + textLogPath: logPath, + summaryPath, + totals, + bySourceVersion, + failureGroups: summarizeFailures(failures), + }, null, 2) + writeFileSync(summaryPath, `${summary}\n`, { flag: 'wx', mode: 0o600 }) + write(`JSON summary: ${summaryPath}`) + write(summary) + } finally { + closeSync(log) } - write(`Full log: ${logPath}`) - closeSync(log) } - return failures.length > 0 ? 1 : 0 + return totals.failed > 0 ? 1 : 0 } if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) { diff --git a/scripts/migration-failure-summary.spec.ts b/scripts/migration-failure-summary.spec.ts new file mode 100644 index 0000000000..6932adb303 --- /dev/null +++ b/scripts/migration-failure-summary.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { classifyMigrationFailure } from './migration-failure-summary.ts' + +function failure(name: string, message: string): Error { + return Object.assign(new Error(message), { name }) +} + +describe('migration failure report facts', () => { + it('distinguishes a direct field refusal from a child prerequisite failure', () => { + const message = '@deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session: ' + + 'permission/preset 0 data has unexpected member "origin"; source v0 artifact remains unchanged' + const direct = classifyMigrationFailure(failure('SessionFormatUnsupportedError', message)) + expect(direct).toEqual({ reason: 'unexpected_member', errorName: 'SessionFormatUnsupportedError', + message, eventType: 'permission/preset', member: 'origin' }) + const child = classifyMigrationFailure(failure('SessionFormatUnsupportedError', `child Session fixture-child: ${message}`)) + expect(child).toMatchObject({ reason: 'unexpected_member', child: true, eventType: direct.eventType, member: direct.member }) + }) + + it.each(['historical ', ''])('extracts unknown %sevent names', (qualifier) => { + const message = `format v0 contains unknown ${qualifier}event type "fixture/event" at seq 3` + expect(classifyMigrationFailure(failure('SessionFormatUnsupportedError', message))) + .toEqual({ reason: 'unknown_event', errorName: 'SessionFormatUnsupportedError', message, eventType: 'fixture/event' }) + }) + + it('retains sequence values without diagnosing the producer or repairing the log', () => { + const message = 'session "fixture": stored log is corrupt: SessionFormatError: released v2 row 8 has seq gap (expected 8, got 5)' + expect(classifyMigrationFailure(failure('SessionPersistenceCorruptionError', message))) + .toEqual({ reason: 'sequence_gap', errorName: 'SessionPersistenceCorruptionError', message, expectedSeq: 8, actualSeq: 5 }) + }) + + it.each([ + ['JsonlGenerationSourceChangedError', 'source_changed'], + ['SessionAlreadyOwnedError', 'write_locked'], + ['SessionFormatUnsupportedError', 'unsupported_input'], + ['SessionPersistenceCorruptionError', 'corrupt_log'], + ['Error', 'other'], + ])('preserves unrecognized text for %s', (name, reason) => { + expect(classifyMigrationFailure(failure(name, 'unrecognized diagnostic'))) + .toEqual({ reason, errorName: name, message: 'unrecognized diagnostic' }) + }) + + it('keeps non-Error failures in the report', () => { + expect(classifyMigrationFailure('cancelled')).toEqual({ reason: 'other', errorName: 'NonError', message: 'cancelled' }) + }) +}) diff --git a/scripts/migration-failure-summary.ts b/scripts/migration-failure-summary.ts new file mode 100644 index 0000000000..892183d8ba --- /dev/null +++ b/scripts/migration-failure-summary.ts @@ -0,0 +1,43 @@ +/** Structured facts extracted from existing migration diagnostics for the batch report. */ + +import { inspect } from 'node:util' + +/** One failure's diagnostic fields; classification never changes migration admission. */ +export interface MigrationFailureDiagnostic { + readonly reason: 'unexpected_member' | 'unknown_event' | 'sequence_gap' | 'source_changed' + | 'write_locked' | 'unsupported_input' | 'corrupt_log' | 'other' + readonly errorName: string + readonly message: string + readonly eventType?: string + readonly member?: string + readonly child?: true + readonly expectedSeq?: number + readonly actualSeq?: number +} + +/** + * Classify recognized diagnostic text without inferring its historical cause. + * @param error - Failure returned by discovery or persistence. + * @returns Original diagnostic plus any recognized event, field, and sequence facts. + */ +export function classifyMigrationFailure(error: unknown): MigrationFailureDiagnostic { + const errorName = error instanceof Error ? error.name : 'NonError' + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : inspect(error) + const base = { errorName, message, ...(/^child Session /u.test(message) ? { child: true as const } : {}) } + const member = /has unexpected member "([^"]+)"/u.exec(message)?.[1] + if (member !== undefined) { + const eventType = /([^\s]+) \d+ data has unexpected member /u.exec(message)?.[1] + return { ...base, reason: 'unexpected_member', member, + ...(eventType === undefined ? {} : { eventType }) } + } + const eventType = /unknown (?:historical )?event type "([^"]+)"/u.exec(message)?.[1] + if (eventType !== undefined) return { ...base, reason: 'unknown_event', eventType } + const gap = /has seq gap \(expected (\d+), got (\d+)\)/u.exec(message) + if (gap !== null) return { ...base, reason: 'sequence_gap', expectedSeq: Number(gap[1]), actualSeq: Number(gap[2]) } + const reason = errorName === 'JsonlGenerationSourceChangedError' ? 'source_changed' + : errorName === 'SessionAlreadyOwnedError' ? 'write_locked' + : errorName === 'SessionFormatUnsupportedError' ? 'unsupported_input' + : errorName === 'SessionPersistenceCorruptionError' ? 'corrupt_log' + : 'other' + return { ...base, reason } +}