File size: 3,452 Bytes
d522ffa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { appendFile, mkdir, readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';

import { migrationErrorsLogFile } from './paths.js';

export interface MigrationFailureEntry {
  readonly sourcePath: string;
  readonly reason: string;
}

export interface MigrationErrorsLogInput {
  readonly startedAt: string;
  readonly failures: readonly MigrationFailureEntry[];
}

/**
 * Append this run's outcome to `<targetHome>/migration-errors.log` — an
 * append-only cross-run diagnostic record.
 *
 * Each call contributes one block prefixed by a timestamped header. A run
 * with failures appends per-session diagnostics (source path, reason, and a
 * `context.jsonl` line-count + role histogram). A run with no failures
 * appends a one-line `no failures.` marker — the file therefore captures the
 * complete history of every migration attempt, so a single log shared by a
 * user covers all retries.
 *
 * Best-effort: a finished migration must not be turned into a failure by a
 * log write error, so all I/O is guarded.
 */
export async function writeMigrationErrorsLog(
  targetHome: string,
  input: MigrationErrorsLogInput,
): Promise<void> {
  const lines: string[] = [`===== migration run @ ${input.startedAt} =====`];

  if (input.failures.length === 0) {
    lines.push('no failures.', '');
  } else {
    lines.push(`${input.failures.length} session(s) failed to migrate.`, '');
    let index = 0;
    for (const failure of input.failures) {
      index += 1;
      lines.push(
        `[${index}] ${basename(failure.sourcePath)}`,
        `  source: ${failure.sourcePath}`,
        `  reason: ${failure.reason}`,
        `  ${await describeContext(failure.sourcePath)}`,
        '',
      );
    }
  }

  try {
    await mkdir(targetHome, { recursive: true, mode: 0o700 });
    // POSIX `O_APPEND` makes a single `appendFile` atomic — concurrent runs
    // would still produce well-formed blocks. `mode` only applies when the
    // file is created; an existing log keeps its prior 0600.
    await appendFile(migrationErrorsLogFile(targetHome), lines.join('\n'), {
      mode: 0o600,
    });
  } catch {
    // Best-effort — see the doc comment above.
  }
}

/**
 * One-line `context.jsonl` summary for a failed session: line count plus a
 * role histogram. The histogram is the key diagnostic — it tells a genuine
 * write failure from a session whose context held only markers.
 */
async function describeContext(sessionDir: string): Promise<string> {
  let text: string;
  try {
    text = await readFile(join(sessionDir, 'context.jsonl'), 'utf-8');
  } catch {
    return 'context.jsonl: unreadable';
  }
  const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
  const roleCounts = new Map<string, number>();
  for (const line of lines) {
    let role = '<unparseable>';
    try {
      const parsed: unknown = JSON.parse(line);
      if (typeof parsed === 'object' && parsed !== null) {
        const raw = (parsed as Record<string, unknown>)['role'];
        role = typeof raw === 'string' ? raw : '<no-role>';
      }
    } catch {
      // role stays '<unparseable>'
    }
    roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1);
  }
  const histogram = [...roleCounts.entries()]
    .toSorted((a, b) => b[1] - a[1])
    .map(([role, count]) => `${role}=${count}`)
    .join(' ');
  return `context.jsonl: ${lines.length} lines${histogram === '' ? '' : ` - ${histogram}`}`;
}