import { createHash } from "node:crypto"; import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { boundSourceSummary, createEvidenceId } from "./evidence"; import { readJsonl } from "./jsonl"; import { resolvePaths } from "./paths"; import { scanSecrets, shouldBlockPersistence } from "./secret-scanner"; import type { EvidenceRecord, EvidenceSourceKind, MemoryRecord } from "./types"; export interface LegacyEvidenceProposal { evidence: EvidenceRecord; record_references: Array<{ memory_id: string; original_ref: string }>; source_sha256: string; } export interface LegacyEvidenceFinding { memory_id: string; reference: string; reason: "missing" | "outside_store" | "secret_blocked"; } export interface LegacyEvidenceMigrationApplyResult { dry_run: false; mutation_performed: boolean; fingerprint: string; evidence_created: number; records_updated: number; backup_path?: string; report_path?: string; } export interface LegacyEvidenceMigrationPlan { dry_run: true; mutation_performed: false; fingerprint: string; records_scanned: number; references_scanned: number; evidence_to_create: number; existing_evidence_skipped: number; unresolved_references: number; blocked_secret_references: number; proposals: LegacyEvidenceProposal[]; findings: LegacyEvidenceFinding[]; } function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } function sourceKind(reference: string): EvidenceSourceKind { return reference.replaceAll("\\", "/").startsWith("daily/") ? "conversation" : "file"; } function resolveReference(root: string, reference: string): { path?: string; reason?: "missing" | "outside_store" } { const rootPath = realpathSync(root); const candidate = isAbsolute(reference) ? resolve(reference) : resolve(root, reference); const rel = relative(rootPath, candidate); if (rel.startsWith("..") || isAbsolute(rel)) return { reason: "outside_store" }; if (!existsSync(candidate)) return { reason: "missing" }; return { path: candidate }; } export function planLegacyEvidenceMigration( root: string, records: MemoryRecord[], existingEvidence: EvidenceRecord[], ): LegacyEvidenceMigrationPlan { const existingIds = new Set(existingEvidence.map((evidence) => evidence.id)); const proposals = new Map(); const findings: LegacyEvidenceFinding[] = []; let referencesScanned = 0; let existingEvidenceSkipped = 0; let blockedSecretReferences = 0; for (const record of records) { if (record.status === "deleted") continue; for (const inline of record.evidence) { referencesScanned++; if (existingIds.has(inline.ref)) { existingEvidenceSkipped++; continue; } const resolved = resolveReference(root, inline.ref); if (!resolved.path) { findings.push({ memory_id: record.id, reference: inline.ref, reason: resolved.reason! }); continue; } const sourceText = readFileSync(resolved.path, "utf-8"); if (shouldBlockPersistence(scanSecrets(sourceText))) { blockedSecretReferences++; findings.push({ memory_id: record.id, reference: inline.ref, reason: "secret_blocked" }); continue; } const source_kind = sourceKind(inline.ref); const source_summary = boundSourceSummary(sourceText.trim().replace(/\s+/g, " ") || `Legacy source ${inline.ref}`); const profile_id = record.profile_id ?? "legacy-default"; const evidenceId = createEvidenceId({ profile_id, source_kind, source_ref: inline.ref, source_summary, }); if (existingIds.has(evidenceId)) { existingEvidenceSkipped++; continue; } const current = proposals.get(evidenceId); if (current) { if (!current.evidence.related_memory_ids.includes(record.id)) current.evidence.related_memory_ids.push(record.id); current.record_references.push({ memory_id: record.id, original_ref: inline.ref }); continue; } proposals.set(evidenceId, { evidence: { id: evidenceId, resource_id: record.resource_id ?? "legacy-migration", profile_id, created_at: record.created_at, source_kind, source_file: inline.ref, source_ref: inline.ref, source_summary, trust_class: "unknown", polarity: "supports", durability_signal: "unknown", related_memory_ids: [record.id], scope_level: record.scope.type, scope_ref: record.scope.type === "project" ? record.scope.project : record.scope.type === "domain" ? record.scope.domains?.join(",") : undefined, tags: ["legacy-evidence-backfill"], notes: "legacy_evidence_backfill_v1", }, record_references: [{ memory_id: record.id, original_ref: inline.ref }], source_sha256: sha256(sourceText), }); } } const ordered = [...proposals.values()].sort((a, b) => a.evidence.id.localeCompare(b.evidence.id)); const fingerprint = sha256(JSON.stringify({ records: records.map((record) => ({ id: record.id, status: record.status, evidence: record.evidence })), existing_ids: [...existingIds].sort(), proposals: ordered, findings, })); return { dry_run: true, mutation_performed: false, fingerprint, records_scanned: records.length, references_scanned: referencesScanned, evidence_to_create: ordered.length, existing_evidence_skipped: existingEvidenceSkipped, unresolved_references: findings.filter((finding) => finding.reason !== "secret_blocked").length, blocked_secret_references: blockedSecretReferences, proposals: ordered, findings, }; } interface CanonicalRow { file: string; record: MemoryRecord; } export interface MigrationFileChange { file: string; expected: Buffer | null; next: Buffer; } function writeBufferAtomic(file: string, content: Buffer): void { mkdirSync(dirname(file), { recursive: true }); const temporary = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; const fd = openSync(temporary, "wx", 0o600); try { writeSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); } renameSync(temporary, file); } function assertMigrationTargetUnchanged(change: MigrationFileChange): void { const current = existsSync(change.file) ? readFileSync(change.file) : null; if ((current === null) !== (change.expected === null) || (current && change.expected && !current.equals(change.expected))) { throw new Error(`Migration target changed after preview: ${change.file}`); } } export function commitMigrationFileSet( changes: MigrationFileChange[], beforeRename?: (file: string, index: number) => void, ): void { for (const change of changes) assertMigrationTargetUnchanged(change); const staged = changes.map((change, index) => { const file = `${change.file}.migration-${process.pid}-${Date.now()}-${index}`; mkdirSync(dirname(file), { recursive: true }); const fd = openSync(file, "wx", 0o600); try { writeSync(fd, change.next); fsyncSync(fd); } finally { closeSync(fd); } return file; }); let committed = 0; try { for (let index = 0; index < changes.length; index++) { beforeRename?.(changes[index].file, index); assertMigrationTargetUnchanged(changes[index]); renameSync(staged[index], changes[index].file); committed++; } } catch (error) { for (let index = committed - 1; index >= 0; index--) { const original = changes[index].expected; if (original === null) { if (existsSync(changes[index].file)) unlinkSync(changes[index].file); } else { writeBufferAtomic(changes[index].file, original); } } throw error; } finally { for (const file of staged) if (existsSync(file)) unlinkSync(file); } } function jsonlBuffer(records: T[]): Buffer { const content = records.map((record) => JSON.stringify(record)).join("\n"); return Buffer.from(content ? `${content}\n` : "", "utf-8"); } function canonicalFilePaths(root: string): string[] { const paths = resolvePaths(root); const files = [paths.memory.L1, paths.memory.L2]; if (existsSync(paths.memory.projects)) { files.push(...readdirSync(paths.memory.projects) .filter((name) => name.endsWith(".jsonl")) .sort() .map((name) => join(paths.memory.projects, name))); } return files.filter(existsSync); } function parseJsonlBuffer(content: Buffer): T[] { return content.toString("utf-8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line) as T); } function canonicalRows(root: string): CanonicalRow[] { return canonicalFilePaths(root).flatMap((file) => readJsonl(file).map((record) => ({ file, record }))); } export function scanLegacyEvidenceMigration(root: string): LegacyEvidenceMigrationPlan { const paths = resolvePaths(root); const records = canonicalRows(root).map((row) => row.record); const evidence = existsSync(paths.memory.evidence) ? readJsonl(paths.memory.evidence) : []; return planLegacyEvidenceMigration(root, records, evidence); } function timestampSlug(now: string): string { return now.replace(/[^0-9]/g, "").slice(0, 17); } interface PreparedMigrationTransaction { state: "prepared" | "committed" | "rolled_back"; targets: Array<{ file: string; backup: string | null; existed: boolean }>; plan_fingerprint?: string; recovered_at?: string; } export function recoverInterruptedLegacyEvidenceMigration(root: string): number { const backups = join(root, "backups"); if (!existsSync(backups)) return 0; let recovered = 0; for (const name of readdirSync(backups).filter((item) => item.startsWith("legacy-evidence-backfill-v1-")).sort()) { const directory = join(backups, name); const transactionPath = join(directory, "transaction.json"); if (!existsSync(transactionPath)) continue; const transaction = JSON.parse(readFileSync(transactionPath, "utf-8")) as PreparedMigrationTransaction; if (transaction.state !== "prepared" || !Array.isArray(transaction.targets)) continue; for (const target of transaction.targets) { const file = resolve(root, target.file); if (relative(resolve(root), file).startsWith("..")) throw new Error(`Unsafe migration recovery target: ${target.file}`); if (target.existed) { if (!target.backup) throw new Error(`Missing migration recovery backup for ${target.file}`); const backup = resolve(directory, target.backup); if (relative(directory, backup).startsWith("..") || !existsSync(backup)) throw new Error(`Missing migration recovery backup for ${target.file}`); writeBufferAtomic(file, readFileSync(backup)); } else if (existsSync(file)) { unlinkSync(file); } } writeFileSync(transactionPath, `${JSON.stringify({ ...transaction, state: "rolled_back", recovered_at: new Date().toISOString() }, null, 2)}\n`, "utf-8"); recovered++; } return recovered; } function processIsAlive(pid: number): boolean { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } export function applyLegacyEvidenceMigration( root: string, expectedFingerprint: string, now = new Date().toISOString(), ): LegacyEvidenceMigrationApplyResult { mkdirSync(root, { recursive: true }); const lockPath = join(root, ".legacy-evidence-migration.lock"); if (existsSync(lockPath)) { let owner = 0; try { owner = Number(JSON.parse(readFileSync(lockPath, "utf-8")).pid ?? 0); } catch { /* stale malformed lock */ } if (processIsAlive(owner)) throw new Error(`Legacy evidence migration is already running (pid ${owner}).`); recoverInterruptedLegacyEvidenceMigration(root); unlinkSync(lockPath); } else { recoverInterruptedLegacyEvidenceMigration(root); } const lock = openSync(lockPath, "wx", 0o600); writeSync(lock, JSON.stringify({ pid: process.pid, started_at: now })); fsyncSync(lock); try { const paths = resolvePaths(root); const memorySnapshots = new Map(canonicalFilePaths(root).map((file) => [file, readFileSync(file)])); const rows = [...memorySnapshots].flatMap(([file, content]) => parseJsonlBuffer(content).map((record) => ({ file, record }))); const evidenceSnapshot = existsSync(paths.memory.evidence) ? readFileSync(paths.memory.evidence) : null; const existingEvidence = evidenceSnapshot ? parseJsonlBuffer(evidenceSnapshot) : []; const plan = planLegacyEvidenceMigration(root, rows.map((row) => row.record), existingEvidence); if (plan.fingerprint !== expectedFingerprint) throw new Error("Legacy evidence migration preview is stale; run preview again."); const base = { dry_run: false as const, mutation_performed: false, fingerprint: plan.fingerprint, evidence_created: 0, records_updated: 0, }; if (plan.proposals.length === 0) return base; const evidenceByReference = new Map(); for (const proposal of plan.proposals) { for (const reference of proposal.record_references) { evidenceByReference.set(`${reference.memory_id}\n${reference.original_ref}`, proposal.evidence.id); } } const updatedRows = rows.map((row) => { const additions = row.record.evidence.flatMap((inline) => { const id = evidenceByReference.get(`${row.record.id}\n${inline.ref}`); if (!id || row.record.evidence.some((item) => item.ref === id)) return []; return [{ type: "source" as const, ref: id, note: "Structured evidence created by legacy_evidence_backfill_v1." }]; }); return additions.length > 0 ? { ...row, record: { ...row.record, evidence: [...row.record.evidence, ...additions] } } : row; }); const affectedFiles = [...new Set(updatedRows .filter((row, index) => row.record !== rows[index].record) .map((row) => row.file))].sort(); const slug = timestampSlug(now); const backupPath = join(root, "backups", `legacy-evidence-backfill-v1-${slug}`); const backupSnapshots = new Map(); if (evidenceSnapshot) backupSnapshots.set(paths.memory.evidence, evidenceSnapshot); for (const file of affectedFiles) backupSnapshots.set(file, memorySnapshots.get(file)!); for (const [file, snapshot] of backupSnapshots) { const destination = join(backupPath, relative(root, file)); mkdirSync(dirname(destination), { recursive: true }); writeFileSync(destination, snapshot); } const result: LegacyEvidenceMigrationApplyResult = { ...base, mutation_performed: true, evidence_created: plan.proposals.length, records_updated: new Set(plan.proposals.flatMap((proposal) => proposal.evidence.related_memory_ids)).size, backup_path: backupPath, report_path: join(root, "reports", `legacy-evidence-backfill-${slug}.json`), }; mkdirSync(dirname(result.report_path!), { recursive: true }); const changes: MigrationFileChange[] = [ { file: paths.memory.evidence, expected: evidenceSnapshot, next: jsonlBuffer([...existingEvidence, ...plan.proposals.map((proposal) => proposal.evidence)]) }, ...affectedFiles.map((file) => ({ file, expected: memorySnapshots.get(file)!, next: jsonlBuffer(updatedRows.filter((row) => row.file === file).map((row) => row.record)) })), ]; const transactionPath = join(backupPath, "transaction.json"); const transactionTargets = changes.map((change) => ({ file: relative(root, change.file), backup: change.expected === null ? null : relative(backupPath, join(backupPath, relative(root, change.file))), existed: change.expected !== null, })); writeFileSync(transactionPath, `${JSON.stringify({ state: "prepared", targets: transactionTargets, plan_fingerprint: plan.fingerprint }, null, 2)}\n`, "utf-8"); writeFileSync(result.report_path!, `${JSON.stringify({ ...result, mutation_performed: false, state: "prepared", plan }, null, 2)}\n`, "utf-8"); commitMigrationFileSet(changes); writeFileSync(transactionPath, `${JSON.stringify({ state: "committed", targets: transactionTargets, plan_fingerprint: plan.fingerprint }, null, 2)}\n`, "utf-8"); writeFileSync(result.report_path!, `${JSON.stringify({ ...result, state: "committed", plan }, null, 2)}\n`, "utf-8"); return result; } finally { closeSync(lock); if (existsSync(lockPath)) unlinkSync(lockPath); } }