feat(cli): gbrain apply-migrations — migration runner CLI

Reads ~/.gbrain/migrations/completed.jsonl, diffs against the TS migration
registry, runs pending orchestrators. Resumes status:"partial" entries
(the stopgap bash script writes these so v0.11.1 apply-migrations can
pick up where it left off). Idempotent: rerunning when up-to-date exits 0.

Flags:
  --list                    Show applied + partial + pending + future.
  --dry-run                 Print the plan; take no action.
  --yes / --non-interactive Skip prompts (used by runPostUpgrade + postinstall).
  --mode <a|p|o>            Preset minion_mode (bypasses the Phase C TTY prompt).
  --migration vX.Y.Z        Force-run one specific version.
  --host-dir <path>         Include $PWD in host-file walk (default is
                            $HOME/.claude + $HOME/.openclaw only).
  --no-autopilot-install    Skip Phase F.

Diff rule (Codex H9): apply when no status:"complete" entry exists AND
migration.version ≤ installed VERSION. Previously proposed rule was
"version > currentVersion", which would SKIP v0.11.0 when running v0.11.1;
regression test in apply-migrations.test.ts pins the correct semantics.

Registered in src/cli.ts CLI_ONLY Set; dispatched before connectEngine so
each phase owns its own engine/subprocess lifecycle (no double-connect
when the orchestrator shells out to init --migrate-only or jobs smoke).

test/apply-migrations.test.ts: 18 unit tests covering parseArgs for every
flag, indexCompleted/statusForVersion correctness (including stopgap-then-
complete transition), and buildPlan's four buckets (applied / partial /
pending / skippedFuture) with the Codex H9 regression pinned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-18 13:17:02 +08:00
co-authored by Claude Opus 4.7
parent de027ceb43
commit aaca2d4d57
3 changed files with 439 additions and 1 deletions
+10 -1
View File
@@ -18,7 +18,7 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'jobs']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'jobs', 'apply-migrations']);
async function main() {
const args = process.argv.slice(2);
@@ -281,6 +281,15 @@ async function handleCliOnly(command: string, args: string[]) {
await runReport(args);
return;
}
if (command === 'apply-migrations') {
// Does not need connectEngine — each phase (schema, smoke, host-rewrite)
// manages its own subprocess or file-layer access directly. Avoids
// connecting a second time when the orchestrator shells out to
// `gbrain init --migrate-only` and `gbrain jobs smoke`.
const { runApplyMigrations } = await import('./commands/apply-migrations.ts');
await runApplyMigrations(args);
return;
}
if (command === 'doctor') {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
+271
View File
@@ -0,0 +1,271 @@
/**
* `gbrain apply-migrations` — migration runner CLI.
*
* Reads ~/.gbrain/migrations/completed.jsonl, diffs against the TS migration
* registry, runs any pending orchestrators. Resumes `status: "partial"`
* entries (stopgap bash script writes these). Idempotent: rerunning is
* cheap when nothing is pending.
*
* Invoked from:
* - `gbrain upgrade` → runPostUpgrade() tail (Lane A-5)
* - package.json `postinstall` (Lane A-5)
* - explicit user / host-agent after registering new handlers (Lane C-1)
*/
import { VERSION } from '../version.ts';
import { loadCompletedMigrations, type CompletedMigrationEntry } from '../core/preferences.ts';
import { migrations, compareVersions, type Migration, type OrchestratorOpts } from './migrations/index.ts';
interface ApplyMigrationsArgs {
list: boolean;
dryRun: boolean;
yes: boolean;
nonInteractive: boolean;
mode?: 'always' | 'pain_triggered' | 'off';
specificMigration?: string;
hostDir?: string;
noAutopilotInstall: boolean;
help: boolean;
}
function parseArgs(args: string[]): ApplyMigrationsArgs {
const has = (flag: string) => args.includes(flag);
const val = (flag: string): string | undefined => {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
};
const mode = val('--mode') as ApplyMigrationsArgs['mode'];
if (mode && !['always', 'pain_triggered', 'off'].includes(mode)) {
console.error(`Invalid --mode "${mode}". Allowed: always, pain_triggered, off.`);
process.exit(2);
}
return {
list: has('--list'),
dryRun: has('--dry-run'),
yes: has('--yes'),
nonInteractive: has('--non-interactive'),
mode,
specificMigration: val('--migration'),
hostDir: val('--host-dir'),
noAutopilotInstall: has('--no-autopilot-install'),
help: has('--help') || has('-h'),
};
}
function printHelp(): void {
console.log(`gbrain apply-migrations — run pending migration orchestrators.
Usage:
gbrain apply-migrations Run all pending migrations interactively.
gbrain apply-migrations --yes Non-interactive; uses default mode (pain_triggered).
gbrain apply-migrations --dry-run Print the plan; take no action.
gbrain apply-migrations --list Show applied + pending migrations.
gbrain apply-migrations --migration vX.Y.Z
Force-run a specific migration by version.
Flags:
--mode <always|pain_triggered|off> Set minion_mode without prompting.
--host-dir <path> Include this directory in host-file walk
(default scope: \$HOME/.claude + \$HOME/.openclaw).
--no-autopilot-install Skip the Phase F autopilot install step.
--non-interactive Equivalent to --yes; never prompt.
Exit codes:
0 Success (including "nothing to do").
1 An orchestrator failed.
2 Invalid arguments.
`);
}
interface CompletedIndex {
byVersion: Map<string, CompletedMigrationEntry[]>;
}
function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
const byVersion = new Map<string, CompletedMigrationEntry[]>();
for (const e of entries) {
const list = byVersion.get(e.version) ?? [];
list.push(e);
byVersion.set(e.version, list);
}
return byVersion.size > 0
? { byVersion }
: { byVersion: new Map() };
}
/** Returns the resolved status for a migration based on its entries. */
function statusForVersion(
version: string,
idx: CompletedIndex,
): 'complete' | 'partial' | 'pending' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
if (entries.some(e => e.status === 'partial')) return 'partial';
return 'pending';
}
interface Plan {
applied: Migration[];
partial: Migration[];
pending: Migration[];
skippedFuture: Migration[];
}
/**
* Build the run plan.
*
* - applied: has a `status: "complete"` entry for its version.
* - partial: has only `status: "partial"` entries (stopgap wrote one) →
* orchestrator runs to finish missing phases.
* - pending: has no entries at all and migration.version ≤ installed VERSION.
* - skippedFuture: migration.version > installed VERSION (binary is older
* than the migration; wait for a newer install).
*
* Codex H9: we never compare against `current VERSION >` — that rule would
* skip v0.11.0 when running v0.11.1. Compare against completed.jsonl.
*/
function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: string): Plan {
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [] };
for (const m of migrations) {
if (filterVersion && m.version !== filterVersion) continue;
if (compareVersions(m.version, installed) > 0) {
plan.skippedFuture.push(m);
continue;
}
const status = statusForVersion(m.version, idx);
if (status === 'complete') plan.applied.push(m);
else if (status === 'partial') plan.partial.push(m);
else plan.pending.push(m);
}
return plan;
}
function printList(plan: Plan, installed: string): void {
console.log(`Installed gbrain version: ${installed}\n`);
console.log(' Status Version Headline');
console.log(' ------- -------- -----------------------------------------');
const rows: Array<{ status: string; m: Migration }> = [
...plan.applied.map(m => ({ status: 'applied', m })),
...plan.partial.map(m => ({ status: 'partial', m })),
...plan.pending.map(m => ({ status: 'pending', m })),
...plan.skippedFuture.map(m => ({ status: 'future', m })),
];
for (const r of rows) {
const ver = r.m.version.padEnd(8);
const status = r.status.padEnd(7);
console.log(` ${status} ${ver} ${r.m.featurePitch.headline}`);
}
if (rows.length === 0) console.log(' (no migrations registered)');
console.log('');
const needsWork = plan.pending.length + plan.partial.length;
if (needsWork === 0) {
console.log('All migrations up to date.');
} else {
console.log(`${needsWork} migration(s) need action. Run \`gbrain apply-migrations --yes\` to apply.`);
}
}
function printDryRun(plan: Plan, installed: string): void {
console.log(`Dry run — installed gbrain version: ${installed}`);
console.log('');
if (plan.applied.length) {
console.log('Already applied:');
for (const m of plan.applied) console.log(` ✓ v${m.version}${m.featurePitch.headline}`);
console.log('');
}
if (plan.partial.length) {
console.log('Would RESUME (previously partial):');
for (const m of plan.partial) console.log(` ⟳ v${m.version}${m.featurePitch.headline}`);
console.log('');
}
if (plan.pending.length) {
console.log('Would APPLY:');
for (const m of plan.pending) console.log(` → v${m.version}${m.featurePitch.headline}`);
console.log('');
}
if (plan.skippedFuture.length) {
console.log('Skipped (newer than installed binary):');
for (const m of plan.skippedFuture) console.log(` ⧗ v${m.version}`);
console.log('');
}
if (plan.pending.length + plan.partial.length === 0) {
console.log('Nothing to do.');
} else {
console.log('Re-run without --dry-run to apply. Use --yes to skip prompts.');
}
}
function orchestratorOptsFrom(cli: ApplyMigrationsArgs): OrchestratorOpts {
return {
yes: cli.yes || cli.nonInteractive,
mode: cli.mode,
dryRun: cli.dryRun,
hostDir: cli.hostDir,
noAutopilotInstall: cli.noAutopilotInstall,
};
}
/**
* Entry point. Does not call connectEngine — each phase inside an
* orchestrator manages its own engine / subprocess lifecycle.
*/
export async function runApplyMigrations(args: string[]): Promise<void> {
const cli = parseArgs(args);
if (cli.help) { printHelp(); return; }
const installed = VERSION.replace(/^v/, '').trim() || '0.0.0';
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
}
if (cli.list) { printList(plan, installed); return; }
if (cli.dryRun) { printDryRun(plan, installed); return; }
const toRun: Migration[] = [...plan.partial, ...plan.pending];
if (toRun.length === 0) {
console.log('All migrations up to date.');
return;
}
// Run each orchestrator in registry order. An orchestrator failure aborts
// the rest of the chain; fixing the failure and re-running picks up where
// we left off (per-phase idempotency markers + resume from "partial").
let failed = false;
for (const m of toRun) {
console.log(`\n=== Applying migration v${m.version}: ${m.featurePitch.headline} ===`);
try {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
failed = true;
break;
}
if (result.status === 'partial') {
console.log(`Migration v${m.version} finished as PARTIAL. Re-run \`gbrain apply-migrations --yes\` after resolving any pending host-work items.`);
} else {
console.log(`Migration v${m.version} complete.`);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Migration v${m.version} threw: ${msg}`);
failed = true;
break;
}
}
if (failed) process.exit(1);
}
/** Exported for unit tests only. Do not use from production code. */
export const __testing = {
parseArgs,
buildPlan,
indexCompleted,
statusForVersion,
};
+158
View File
@@ -0,0 +1,158 @@
/**
* Tests for `gbrain apply-migrations` — the migration runner CLI.
*
* Unit-scope: exercises the pure helpers (parseArgs, indexCompleted, buildPlan,
* statusForVersion). End-to-end integration against real orchestrators is
* covered by test/e2e/migration-flow.test.ts (Lane C-5).
*/
import { describe, test, expect } from 'bun:test';
import { __testing } from '../src/commands/apply-migrations.ts';
import type { CompletedMigrationEntry } from '../src/core/preferences.ts';
const { parseArgs, indexCompleted, buildPlan, statusForVersion } = __testing;
describe('parseArgs', () => {
test('default flags', () => {
const a = parseArgs([]);
expect(a.list).toBe(false);
expect(a.dryRun).toBe(false);
expect(a.yes).toBe(false);
expect(a.nonInteractive).toBe(false);
expect(a.mode).toBeUndefined();
expect(a.specificMigration).toBeUndefined();
expect(a.hostDir).toBeUndefined();
expect(a.noAutopilotInstall).toBe(false);
});
test('--list / --dry-run / --yes / --non-interactive', () => {
expect(parseArgs(['--list']).list).toBe(true);
expect(parseArgs(['--dry-run']).dryRun).toBe(true);
expect(parseArgs(['--yes']).yes).toBe(true);
expect(parseArgs(['--non-interactive']).nonInteractive).toBe(true);
});
test('--mode accepts valid values', () => {
expect(parseArgs(['--mode', 'always']).mode).toBe('always');
expect(parseArgs(['--mode', 'pain_triggered']).mode).toBe('pain_triggered');
expect(parseArgs(['--mode', 'off']).mode).toBe('off');
});
test('--migration and --host-dir parse values', () => {
const a = parseArgs(['--migration', '0.11.0', '--host-dir', '/tmp/abc']);
expect(a.specificMigration).toBe('0.11.0');
expect(a.hostDir).toBe('/tmp/abc');
});
test('--no-autopilot-install flips flag', () => {
expect(parseArgs(['--no-autopilot-install']).noAutopilotInstall).toBe(true);
});
test('--help sets help flag', () => {
expect(parseArgs(['--help']).help).toBe(true);
expect(parseArgs(['-h']).help).toBe(true);
});
});
describe('indexCompleted + statusForVersion', () => {
test('no entries → pending', () => {
const idx = indexCompleted([]);
expect(statusForVersion('0.11.0', idx)).toBe('pending');
});
test('one complete entry → complete', () => {
const entries: CompletedMigrationEntry[] = [
{ version: '0.11.0', status: 'complete', mode: 'always' },
];
const idx = indexCompleted(entries);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
test('only partial entries → partial', () => {
const entries: CompletedMigrationEntry[] = [
{ version: '0.11.0', status: 'partial', apply_migrations_pending: true },
];
const idx = indexCompleted(entries);
expect(statusForVersion('0.11.0', idx)).toBe('partial');
});
test('partial then complete → complete (stopgap then v0.11.1 apply-migrations)', () => {
const entries: CompletedMigrationEntry[] = [
{ version: '0.11.0', status: 'partial', apply_migrations_pending: true },
{ version: '0.11.0', status: 'complete', mode: 'always' },
];
const idx = indexCompleted(entries);
expect(statusForVersion('0.11.0', idx)).toBe('complete');
});
test('only looks at the queried version', () => {
const entries: CompletedMigrationEntry[] = [
{ version: '0.10.0', status: 'complete' },
];
const idx = indexCompleted(entries);
expect(statusForVersion('0.11.0', idx)).toBe('pending');
expect(statusForVersion('0.10.0', idx)).toBe('complete');
});
});
describe('buildPlan — diff against completed + installed VERSION', () => {
test('fresh install (no entries) — v0.11.0 is pending when installed ≥ 0.11.0', () => {
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.11.1');
expect(plan.applied).toEqual([]);
expect(plan.partial).toEqual([]);
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
expect(plan.skippedFuture).toEqual([]);
});
test('already applied → v0.11.0 lands in `applied` bucket, not pending', () => {
const idx = indexCompleted([{ version: '0.11.0', status: 'complete' }]);
const plan = buildPlan(idx, '0.11.1');
expect(plan.applied.map(m => m.version)).toContain('0.11.0');
expect(plan.pending).toEqual([]);
});
test('stopgap wrote partial → v0.11.0 lands in `partial` bucket (resumable)', () => {
const idx = indexCompleted([
{ version: '0.11.0', status: 'partial', apply_migrations_pending: true },
]);
const plan = buildPlan(idx, '0.11.1');
expect(plan.partial.map(m => m.version)).toContain('0.11.0');
expect(plan.applied).toEqual([]);
expect(plan.pending).toEqual([]);
});
test('Codex H9 regression: installed older than migration → skippedFuture, not skipped silently', () => {
// Running a v0.10.x binary that somehow loaded a v0.11.0 migration registry:
// migration is skippedFuture (wait for a newer install), NOT ignored.
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.10.5');
expect(plan.skippedFuture.map(m => m.version)).toContain('0.11.0');
expect(plan.pending).toEqual([]);
});
test('Codex H9 regression: installed > migration version → still runs (not skipped)', () => {
// This is the critical bug Codex caught: the plan was "apply when version >
// installed", which would SKIP v0.11.0 when running v0.11.1. The correct
// rule is "apply when not in completed.jsonl AND version ≤ installed".
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.12.0');
expect(plan.pending.map(m => m.version)).toContain('0.11.0');
expect(plan.skippedFuture).toEqual([]);
});
test('--migration filter narrows to one version', () => {
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.11.1', '0.11.0');
expect(plan.pending.map(m => m.version)).toEqual(['0.11.0']);
});
test('--migration filter for unknown version → empty plan', () => {
const idx = indexCompleted([]);
const plan = buildPlan(idx, '0.11.1', '99.99.99');
expect(plan.applied).toEqual([]);
expect(plan.pending).toEqual([]);
expect(plan.partial).toEqual([]);
expect(plan.skippedFuture).toEqual([]);
});
});