Compare commits

..
Author SHA1 Message Date
SinabinaandClaude Fable 5 39f750e831 fix(migrations): scope v0.32.2 dirty-check to targeted sources; surface failed phase detail
- 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: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 14:24:42 -07:00
6 changed files with 83 additions and 318 deletions
+7
View File
@@ -438,6 +438,13 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
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 {
-109
View File
@@ -151,100 +151,6 @@ export function shouldSpawnAutopilotWorker(args: string[]): boolean {
return !args.includes('--no-worker');
}
/**
* #1525 — positional subcommand translation.
*
* Pre-fix, `gbrain autopilot status` silently fell through to "start daemon"
* because `runAutopilot()` only branched on flag forms (`--status`, etc.).
* `status` was treated as a stray positional and ignored.
*
* This translator maps known positional subcommands to their flag form so
* `autopilot status` is equivalent to `autopilot --status`, then rejects
* any unrecognized positional with a fail-loud error before any side
* effect (lockfile, daemon spawn, sync dispatch) runs.
*
* Scope decisions:
* - Known aliases: `status` → `--status`, `install` → `--install`,
* `uninstall` → `--uninstall`, `start` → (drop; default daemon launch).
* - `stop` is intentionally NOT aliased here. Stopping a running daemon
* is a new behavior (read PID from lock, SIGTERM, drain) that deserves
* its own design and PR. Users typing `gbrain autopilot stop` today get
* the unknown-positional error with the canonical alternatives.
* - At most one positional allowed; multiple positionals fail loud.
*/
// Every flag that consumes the NEXT argv token. Missing one here makes the
// translator misread the flag's value as a positional subcommand and exit 2
// (e.g. `--install --target linux-cron`). Keep in sync with parseArg call sites.
const AUTOPILOT_VALUE_FLAGS = new Set(['--repo', '--interval', '--target']);
const AUTOPILOT_POSITIONAL_ALIASES: Record<string, string | null> = {
status: '--status',
install: '--install',
uninstall: '--uninstall',
start: null, // drop the positional; default behavior is daemon launch
};
export type PositionalTranslation =
| { ok: true; args: string[] }
| {
ok: false;
reason: 'unknown_subcommand' | 'multiple_subcommands';
message: string;
};
export function translatePositionalSubcommands(args: string[]): PositionalTranslation {
const out: string[] = [];
let positionalSeen = false;
let i = 0;
while (i < args.length) {
const a = args[i];
if (AUTOPILOT_VALUE_FLAGS.has(a)) {
// Pass through the flag and its value untouched. If the value is
// missing at end-of-argv, fall through so the existing parseArg
// path can report the broken usage.
out.push(a);
if (i + 1 < args.length) {
out.push(args[i + 1]);
i += 2;
} else {
i += 1;
}
continue;
}
if (a.startsWith('-')) {
out.push(a);
i += 1;
continue;
}
// Positional subcommand.
if (positionalSeen) {
const known = Object.keys(AUTOPILOT_POSITIONAL_ALIASES).join(', ');
return {
ok: false,
reason: 'multiple_subcommands',
message: `Multiple subcommands given. Use only one of: ${known}.`,
};
}
positionalSeen = true;
if (a in AUTOPILOT_POSITIONAL_ALIASES) {
const alias = AUTOPILOT_POSITIONAL_ALIASES[a];
if (alias) out.push(alias);
i += 1;
continue;
}
const known = Object.keys(AUTOPILOT_POSITIONAL_ALIASES).join(', ');
return {
ok: false,
reason: 'unknown_subcommand',
message:
`Unknown subcommand: \`${a}\`.\n` +
`Allowed subcommands: ${known}.\n` +
`Or use the flag form: --status, --install, --uninstall.\n` +
`Run \`gbrain autopilot --help\` for full usage.`,
};
}
return { ok: true, args: out };
}
export function isPidAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
@@ -457,11 +363,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
' gbrain autopilot --install [--repo <path>]\n' +
' gbrain autopilot --uninstall\n' +
' gbrain autopilot --status [--json]\n\n' +
'Subcommand aliases:\n' +
' gbrain autopilot status → --status\n' +
' gbrain autopilot install → --install\n' +
' gbrain autopilot uninstall → --uninstall\n' +
' gbrain autopilot start → (default daemon launch)\n\n' +
'Self-maintaining brain daemon. Runs the full maintenance cycle\n' +
'(lint + backlinks + sync + extract + embed + orphans) on an interval.\n\n' +
'For a one-shot cron-triggered cycle, see `gbrain dream`.',
@@ -469,16 +370,6 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
return;
}
// #1525: translate positional subcommands to their flag form BEFORE any
// side effect (lockfile, daemon spawn, sync dispatch). Unknown positionals
// fail loud here rather than silently starting the daemon.
const translated = translatePositionalSubcommands(args);
if (!translated.ok) {
console.error(translated.message);
process.exit(2);
}
args = translated.args;
if (args.includes('--install')) {
await installDaemon(engine, args);
return;
+15 -11
View File
@@ -186,17 +186,6 @@ async function phaseBFenceFacts(
const localPathById = new Map<string, string | null>();
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<LegacyFactRow>(
@@ -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)!;
+13
View File
@@ -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/,
);
});
});
@@ -1,197 +0,0 @@
/**
* Tests for translatePositionalSubcommands() — the v0.41.x #1525 fix that
* prevents `gbrain autopilot status` from silently starting the daemon.
*
* IRON RULE regression guard: the exact ticket repro (`gbrain autopilot
* status`) MUST translate to `--status`, not fall through to the default
* daemon launch. Verified by the "ticket-exact repro" case below.
*/
import { describe, test, expect } from 'bun:test';
import { translatePositionalSubcommands } from '../src/commands/autopilot.ts';
describe('translatePositionalSubcommands — known aliases', () => {
test('IRON RULE — `autopilot status` translates to `--status` (ticket #1525 repro)', () => {
const r = translatePositionalSubcommands(['status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status']);
});
test('`install` translates to `--install`', () => {
const r = translatePositionalSubcommands(['install']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install']);
});
test('`uninstall` translates to `--uninstall`', () => {
const r = translatePositionalSubcommands(['uninstall']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--uninstall']);
});
test('`start` drops the positional (default daemon launch)', () => {
const r = translatePositionalSubcommands(['start']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual([]);
});
test('`start --json` drops only the positional, keeps the flag', () => {
const r = translatePositionalSubcommands(['start', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--json']);
});
});
describe('translatePositionalSubcommands — flag/positional interleaving', () => {
test('`status --json` preserves the trailing flag', () => {
const r = translatePositionalSubcommands(['status', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status', '--json']);
});
test('`--json status` preserves the leading flag', () => {
const r = translatePositionalSubcommands(['--json', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--json', '--status']);
});
test('`--repo /foo status` does not mis-classify the path as positional', () => {
const r = translatePositionalSubcommands(['--repo', '/foo', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo', '/foo', '--status']);
});
test('`--interval 300 install` does not mis-classify the number as positional', () => {
const r = translatePositionalSubcommands(['--interval', '300', 'install']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--interval', '300', '--install']);
});
test('`--install --target linux-cron` does not mis-classify the target as positional', () => {
// --target is installDaemon's value flag; its value must never be read
// as a positional subcommand (regression guard for the review fix).
const r = translatePositionalSubcommands(['--install', '--target', 'linux-cron']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install', '--target', 'linux-cron']);
});
test('`install --target macos` keeps the alias translation and the target value', () => {
const r = translatePositionalSubcommands(['install', '--target', 'macos']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--install', '--target', 'macos']);
});
test('value-flag at end of argv with missing value passes through (so parseArg can report it)', () => {
const r = translatePositionalSubcommands(['--repo']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo']);
});
test('value-flag whose value looks like an alias is NOT translated', () => {
// `--repo status` means "use repo path 'status'", not "show status".
// Translator must not destructure the value of --repo.
const r = translatePositionalSubcommands(['--repo', 'status']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--repo', 'status']);
});
});
describe('translatePositionalSubcommands — pass-through cases', () => {
test('empty args returns empty args', () => {
const r = translatePositionalSubcommands([]);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual([]);
});
test('flag-only invocation passes through unchanged', () => {
const r = translatePositionalSubcommands(['--status', '--json']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['--status', '--json']);
});
test('short flag `-h` passes through unchanged', () => {
const r = translatePositionalSubcommands(['-h']);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(['-h']);
});
test('all known bare flags pass through unchanged', () => {
const flags = ['--help', '--install', '--uninstall', '--status', '--json', '--inline', '--no-worker'];
const r = translatePositionalSubcommands(flags);
expect(r.ok).toBe(true);
if (r.ok) expect(r.args).toEqual(flags);
});
});
describe('translatePositionalSubcommands — rejection of unknown positionals', () => {
test('unknown positional `foo` fails with reason=unknown_subcommand + structured message', () => {
const r = translatePositionalSubcommands(['foo']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `foo`');
expect(r.message).toContain('status');
expect(r.message).toContain('install');
expect(r.message).toContain('uninstall');
expect(r.message).toContain('--help');
}
});
test('unknown positional `stop` fails with reason=unknown_subcommand (NOT silently aliased)', () => {
// Stop is mentioned in the ticket but deliberately NOT aliased in this
// PR — stopping a running daemon is a new behavior, not just an alias.
// Until that feature lands separately, `stop` must fail loud rather
// than starting the daemon (the bug we're fixing).
const r = translatePositionalSubcommands(['stop']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `stop`');
}
});
test('unknown positional `status-detail` (close-but-not-matching) fails', () => {
const r = translatePositionalSubcommands(['status-detail']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('Unknown subcommand: `status-detail`');
}
});
test('multiple positionals fail with reason=multiple_subcommands (`start install`)', () => {
const r = translatePositionalSubcommands(['start', 'install']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('multiple_subcommands');
expect(r.message).toContain('Multiple subcommands');
}
});
test('multiple positionals fail even when both are known aliases (`status install`)', () => {
const r = translatePositionalSubcommands(['status', 'install']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('multiple_subcommands');
expect(r.message).toContain('Multiple subcommands');
}
});
test('known-then-unknown rejects with multiple_subcommands (first-positional-wins)', () => {
// First positional is known, second is not. Rejection comes from the
// multiple-positional rule, which fires before the unknown check; the
// intent is "only one subcommand allowed."
const r = translatePositionalSubcommands(['status', 'garbage']);
expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toBe('multiple_subcommands');
});
test('unknown-then-known rejects on the unknown (unknown fires before second-positional check)', () => {
const r = translatePositionalSubcommands(['garbage', 'status']);
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.reason).toBe('unknown_subcommand');
expect(r.message).toContain('garbage');
}
});
});
+48 -1
View File
@@ -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' });