From e1156a56427e9526f4c3844b63b1639a564419a5 Mon Sep 17 00:00:00 2001 From: Time Attakc <89218912+time-attack@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:26:28 -0700 Subject: [PATCH] fix(migrations): scope v0.32.2 dirty-check to targeted sources; surface failed phase detail (#3093) - phaseBFenceFacts now queries legacy rows FIRST and dirty-checks only the source_ids it will actually write into. Zero fenceable rows (or rows scoped to clean sources) no longer fail on an unrelated dirty source. Targeted-dirty-source refusal unchanged. Fixes #927. - apply-migrations now prints each failed phase's name + detail to stderr alongside 'reported status=failed', instead of burying the actionable message in the ledger. Fixes #921. Co-authored-by: Sinabina Co-authored-by: Claude Fable 5 --- src/commands/apply-migrations.ts | 7 +++++ src/commands/migrations/v0_32_2.ts | 26 +++++++++------- test/apply-migrations.test.ts | 13 ++++++++ test/migrations-v0_32_2.test.ts | 49 +++++++++++++++++++++++++++++- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/commands/apply-migrations.ts b/src/commands/apply-migrations.ts index 259409485..c21d33cc4 100644 --- a/src/commands/apply-migrations.ts +++ b/src/commands/apply-migrations.ts @@ -438,6 +438,13 @@ export async function runApplyMigrations(args: string[]): Promise { const result = await m.orchestrator(orchestratorOptsFrom(cli)); if (result.status === 'failed') { console.error(`Migration v${m.version} reported status=failed.`); + // Surface each failed phase's detail — the ledger records it, but + // the operator needs it on stderr to act (#921). + for (const p of result.phases) { + if (p.status === 'failed') { + console.error(` phase ${p.name}: ${p.detail ?? '(no detail)'}`); + } + } // Record the attempt as 'partial' (not 'complete') so the cap counts // it. Don't let a failed orchestrator look like it never ran. try { diff --git a/src/commands/migrations/v0_32_2.ts b/src/commands/migrations/v0_32_2.ts index 51286ccc7..af851547b 100644 --- a/src/commands/migrations/v0_32_2.ts +++ b/src/commands/migrations/v0_32_2.ts @@ -186,17 +186,6 @@ async function phaseBFenceFacts( const localPathById = new Map(); for (const s of sources) localPathById.set(s.id, s.local_path); - // Dirty-tree refusal: check every source's local_path before writing. - for (const [id, localPath] of localPathById) { - if (localPath && isLocalPathDirty(localPath)) { - return { - name: 'fence_facts', - status: 'failed', - detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`, - }; - } - } - // Walk legacy rows in (source_id, entity_slug) groups for per-page // atomic writes. const legacy = await engine.executeRaw( @@ -235,6 +224,21 @@ async function phaseBFenceFacts( groups.set(key, list); } + // Dirty-tree refusal: check ONLY the sources we are about to write + // into. A dirty tree in an unrelated source (or zero fenceable rows + // at all) must not block a no-op or a targeted backfill (#927). + const targetSourceIds = new Set([...groups.keys()].map(k => k.split('\0')[0])); + for (const id of targetSourceIds) { + const localPath = localPathById.get(id); + if (localPath && isLocalPathDirty(localPath)) { + return { + name: 'fence_facts', + status: 'failed', + detail: `source "${id}" has uncommitted changes in ${localPath}. Commit or stash, then re-run.`, + }; + } + } + for (const [key, group] of groups) { const [sourceId, entitySlug] = key.split('\0'); const localPath = localPathById.get(sourceId)!; diff --git a/test/apply-migrations.test.ts b/test/apply-migrations.test.ts index 223c28d50..06cdbaf2f 100644 --- a/test/apply-migrations.test.ts +++ b/test/apply-migrations.test.ts @@ -180,3 +180,16 @@ describe('runApplyMigrations exit codes (v0.36.1.x #1062)', () => { expect(src).toMatch(/All migrations up to date[\s\S]{0,80}process\.exit\(0\)/); }); }); + +// #921: a failed orchestrator must print each failed phase's detail to +// stderr — not just "reported status=failed" — so the operator can act +// without digging through the ledger. +describe('failed migration prints phase detail (#921)', () => { + test('runner loops result.phases and console.errors failed phase details', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/commands/apply-migrations.ts', 'utf8'); + expect(src).toMatch( + /reported status=failed[\s\S]{0,400}for \(const p of result\.phases\)[\s\S]{0,200}p\.status === 'failed'[\s\S]{0,200}console\.error\([\s\S]{0,80}p\.name[\s\S]{0,80}p\.detail/, + ); + }); +}); diff --git a/test/migrations-v0_32_2.test.ts b/test/migrations-v0_32_2.test.ts index 5d5b336de..efe8eeee7 100644 --- a/test/migrations-v0_32_2.test.ts +++ b/test/migrations-v0_32_2.test.ts @@ -10,10 +10,11 @@ * __setTestEngineOverride so we don't need a configured brain. */ -import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test'; import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; import { PGLiteEngine } from '../src/core/pglite-engine.ts'; import { v0_32_2, __setTestEngineOverride, __testing } from '../src/commands/migrations/v0_32_2.ts'; @@ -238,6 +239,52 @@ describe('phaseBFenceFacts — happy path backfill', () => { }); }); +describe('phaseBFenceFacts — dirty-tree refusal scoping (#927)', () => { + let dirtyDir: string; + + beforeEach(async () => { + // A second source whose local_path is a git repo with uncommitted changes. + dirtyDir = mkdtempSync(join(tmpdir(), 'mig-v0_32_2-dirty-')); + execFileSync('git', ['-C', dirtyDir, 'init', '-q']); + writeFileSync(join(dirtyDir, 'uncommitted.md'), 'dirty', 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query( + `INSERT INTO sources (id, name, local_path) VALUES ('other', 'other', $1)`, + [dirtyDir], + ); + }); + + afterEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (engine as any).db.query(`DELETE FROM sources WHERE id = 'other'`); + rmSync(dirtyDir, { recursive: true, force: true }); + }); + + test('no legacy facts at all → complete, dirty unrelated source ignored', async () => { + const r = await __testing.phaseBFenceFacts(engine, OPTS); + expect(r.status).toBe('complete'); + expect(r.detail).toContain('scanned=0'); + }); + + test('facts scoped to a clean source fence despite dirty unrelated source', async () => { + await seedLegacyFact({ entity_slug: 'people/alice', fact: 'Founded Acme' }); + + const r = await __testing.phaseBFenceFacts(engine, OPTS); + expect(r.status).toBe('complete'); + expect(r.detail).toContain('fenced=1'); + expect(existsSync(join(brainDir, 'people/alice.md'))).toBe(true); + }); + + test('still refuses when the TARGETED source is dirty', async () => { + await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1', source_id: 'other' }); + + const r = await __testing.phaseBFenceFacts(engine, OPTS); + expect(r.status).toBe('failed'); + expect(r.detail).toContain('"other"'); + expect(r.detail).toContain('uncommitted changes'); + }); +}); + describe('phaseCVerify', () => { test('returns complete when fence + DB row counts match', async () => { await seedLegacyFact({ entity_slug: 'people/alice', fact: 'F1' });