mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
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:
co-authored by
Claude Opus 4.7
parent
de027ceb43
commit
aaca2d4d57
+10
-1
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user