From de027ceb4347ed2a27e49cc14b192188218e097c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 18 Apr 2026 13:14:20 +0800 Subject: [PATCH] feat(migrations): TS registry replaces filesystem migration scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Codex flagged that bun build --compile produces a self-contained binary, and the existing findMigrationsDir() in upgrade.ts:145 walks skills/migrations/v*.md on disk — which fails on a compiled install because the markdown files aren't bundled. The plan's fix is a TS registry: migrations are code, imported directly, visible to both source installs and compiled binaries. - src/commands/migrations/types.ts: shared Migration, OrchestratorOpts, OrchestratorResult types. - src/commands/migrations/index.ts: exports the migrations[] array, getMigration(version), and compareVersions() (semver comparator). The feature_pitch data that lived in the MD file frontmatter now lives here as a code constant on each Migration, so runPostUpgrade's post-upgrade pitch printer can consume it without a filesystem read. - src/commands/migrations/v0_11_0.ts: stub orchestrator + pitch. The full phase implementation lands in Lane C-1; for now the stub throws a clear "not yet implemented" so apply-migrations --list (Lane A-4) can still enumerate the migration. test/migrations-registry.test.ts: 9 tests covering ascending-semver ordering, feature_pitch shape invariants, getMigration lookup, and compareVersions edge cases (equal / newer / older / single-digit across major bumps). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/migrations/index.ts | 42 ++++++++++++++++++++ src/commands/migrations/types.ts | 60 ++++++++++++++++++++++++++++ src/commands/migrations/v0_11_0.ts | 46 +++++++++++++++++++++ test/migrations-registry.test.ts | 64 ++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 src/commands/migrations/index.ts create mode 100644 src/commands/migrations/types.ts create mode 100644 src/commands/migrations/v0_11_0.ts create mode 100644 test/migrations-registry.test.ts diff --git a/src/commands/migrations/index.ts b/src/commands/migrations/index.ts new file mode 100644 index 000000000..e8ca6721a --- /dev/null +++ b/src/commands/migrations/index.ts @@ -0,0 +1,42 @@ +/** + * TS migration registry. Compiled into the gbrain binary so migration + * discovery works on both source installs and `bun build --compile` + * distributions without reading `skills/migrations/*.md` from disk. + * + * Each migration module exports a `Migration` object. Add new migrations + * to the `migrations` array in chronological (semver) order. The registry + * is the runtime source of truth; the markdown file at + * `skills/migrations/vX.Y.Z.md` remains as the host-agent instruction + * manual (read on demand when pending-host-work.jsonl is non-empty). + */ + +import type { Migration } from './types.ts'; +import { v0_11_0 } from './v0_11_0.ts'; + +export const migrations: Migration[] = [ + v0_11_0, +]; + +/** Look up a migration by exact version string. */ +export function getMigration(version: string): Migration | null { + return migrations.find(m => m.version === version) ?? null; +} + +export type { Migration, FeaturePitch, OrchestratorOpts, OrchestratorResult } from './types.ts'; + +/** + * Compare two semver strings (MAJOR.MINOR.PATCH). Returns -1 / 0 / 1. + * Extracted from src/commands/upgrade.ts#isNewerThan for shared use across + * the migration runner + post-upgrade pitch path. + */ +export function compareVersions(a: string, b: string): -1 | 0 | 1 { + const va = a.split('.').map(n => parseInt(n, 10) || 0); + const vb = b.split('.').map(n => parseInt(n, 10) || 0); + for (let i = 0; i < 3; i++) { + const da = va[i] ?? 0; + const db = vb[i] ?? 0; + if (da > db) return 1; + if (da < db) return -1; + } + return 0; +} diff --git a/src/commands/migrations/types.ts b/src/commands/migrations/types.ts new file mode 100644 index 000000000..fc4bee514 --- /dev/null +++ b/src/commands/migrations/types.ts @@ -0,0 +1,60 @@ +/** + * Shared types for the migration registry + orchestrators. + * + * Each migration is a module that exports a `Migration` object; the registry + * at `./index.ts` lists them in version order. Compiled binaries ship the + * registry directly — no filesystem walk of `skills/migrations/*.md` is + * needed at runtime. + */ + +export interface FeaturePitch { + /** One-line headline printed post-upgrade. */ + headline: string; + /** Optional multi-line description. */ + description?: string; + /** Optional integration recipe name printed as a follow-up. */ + recipe?: string; +} + +/** + * Options passed to every orchestrator. The orchestrator must be idempotent: + * re-running after a partial run must complete missed phases without + * duplicating side-effects. + */ +export interface OrchestratorOpts { + /** Non-interactive: skip prompts, use defaults with explicit print. */ + yes: boolean; + /** Explicit minion_mode override (bypasses the Phase C prompt). */ + mode?: 'always' | 'pain_triggered' | 'off'; + /** Dry-run: print intended actions, take no side effects. */ + dryRun: boolean; + /** Include $PWD in host-file walk (default: $HOME/.claude + $HOME/.openclaw). */ + hostDir?: string; + /** Skip autopilot install (Phase F). */ + noAutopilotInstall: boolean; +} + +export interface OrchestratorPhaseResult { + name: string; + status: 'complete' | 'skipped' | 'failed'; + detail?: string; +} + +export interface OrchestratorResult { + version: string; + status: 'complete' | 'partial' | 'failed'; + phases: OrchestratorPhaseResult[]; + files_rewritten?: number; + autopilot_installed?: boolean; + install_target?: string; + pending_host_work?: number; +} + +export interface Migration { + /** Semver string, e.g. "0.11.0". */ + version: string; + /** Agent-readable feature pitch printed by runPostUpgrade. */ + featurePitch: FeaturePitch; + /** Run the migration. Must be idempotent. */ + orchestrator: (opts: OrchestratorOpts) => Promise; +} diff --git a/src/commands/migrations/v0_11_0.ts b/src/commands/migrations/v0_11_0.ts new file mode 100644 index 000000000..42b46e8b7 --- /dev/null +++ b/src/commands/migrations/v0_11_0.ts @@ -0,0 +1,46 @@ +/** + * v0.11.0 migration orchestrator — GBrain Minions adoption. + * + * Phases (all idempotent; resumable from a prior `status: "partial"` run): + * A. Schema — `gbrain init --migrate-only`. + * B. Smoke — `gbrain jobs smoke`. + * C. Mode — resolve minion_mode (flag / default / TTY prompt). + * D. Prefs — write ~/.gbrain/preferences.json. + * E. Host — AGENTS.md injection + cron rewrites (gbrain builtins only); + * emit pending-host-work.jsonl TODOs for non-builtins. + * F. Install — gbrain autopilot --install (env-aware). + * G. Record — append completed.jsonl. + * + * This file contains the phase implementations + their shared helpers. + * Full implementation lands as Lane C-1 (see /Users/garrytan/.claude/plans/ + * system-instruction-you-are-working-curious-toucan.md §4 + §10). + */ + +import type { Migration, OrchestratorOpts, OrchestratorResult } from './types.ts'; + +async function orchestrator(opts: OrchestratorOpts): Promise { + // Phases A–G will be implemented in Lane C-1. + // For now, throw a clear error so callers know this is not yet wired. + // apply-migrations (Lane A-4) can still list this migration via the + // registry without invoking it. + void opts; + throw new Error( + 'v0.11.0 orchestrator not yet implemented (Lane C-1 pending). ' + + 'Registry entry exists so apply-migrations --list can report v0.11.0. ' + + 'Do not invoke until Lane C-1 lands.', + ); +} + +export const v0_11_0: Migration = { + version: '0.11.0', + featurePitch: { + headline: 'GBrain Minions — durable background agents', + description: + 'Turn any long-running agent task into a durable job that survives gateway ' + + 'restarts, streams progress, and can be paused, resumed, or steered mid-flight. ' + + 'Postgres-native, zero infra beyond your existing brain. Replaces flaky ' + + 'subagent spawns for multi-step work, parallel fan-out, and anything the ' + + 'user might ask about later.', + }, + orchestrator, +}; diff --git a/test/migrations-registry.test.ts b/test/migrations-registry.test.ts new file mode 100644 index 000000000..13894f210 --- /dev/null +++ b/test/migrations-registry.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for the TS migration registry (src/commands/migrations/index.ts). + * + * The registry replaces filesystem discovery of skills/migrations/*.md so + * the compiled `gbrain` binary can enumerate migrations without a readdir. + */ + +import { describe, test, expect } from 'bun:test'; +import { migrations, getMigration, compareVersions } from '../src/commands/migrations/index.ts'; + +describe('migration registry', () => { + test('exports a non-empty migrations array', () => { + expect(migrations.length).toBeGreaterThan(0); + }); + + test('every migration has version + featurePitch.headline + orchestrator', () => { + for (const m of migrations) { + expect(typeof m.version).toBe('string'); + expect(m.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(typeof m.featurePitch.headline).toBe('string'); + expect(m.featurePitch.headline.length).toBeGreaterThan(0); + expect(typeof m.orchestrator).toBe('function'); + } + }); + + test('migrations are in ascending semver order', () => { + for (let i = 1; i < migrations.length; i++) { + expect(compareVersions(migrations[i].version, migrations[i - 1].version)).toBe(1); + } + }); + + test('v0.11.0 is present', () => { + const m = getMigration('0.11.0'); + expect(m).not.toBeNull(); + expect(m!.featurePitch.headline).toContain('Minions'); + }); + + test('getMigration returns null for unknown versions', () => { + expect(getMigration('99.99.99')).toBeNull(); + expect(getMigration('')).toBeNull(); + }); +}); + +describe('compareVersions', () => { + test('equal versions return 0', () => { + expect(compareVersions('1.2.3', '1.2.3')).toBe(0); + }); + + test('newer returns 1', () => { + expect(compareVersions('1.2.4', '1.2.3')).toBe(1); + expect(compareVersions('1.3.0', '1.2.9')).toBe(1); + expect(compareVersions('2.0.0', '1.99.99')).toBe(1); + }); + + test('older returns -1', () => { + expect(compareVersions('1.2.2', '1.2.3')).toBe(-1); + expect(compareVersions('0.11.0', '0.11.1')).toBe(-1); + expect(compareVersions('0.11.0', '0.12.0')).toBe(-1); + }); + + test('handles single-digit versions', () => { + expect(compareVersions('9.0.0', '10.0.0')).toBe(-1); + }); +});