mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 20:50:34 +00:00
feat(migrations): TS registry replaces filesystem migration scan
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
3f287dca79
commit
de027ceb43
@@ -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;
|
||||
}
|
||||
@@ -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<OrchestratorResult>;
|
||||
}
|
||||
@@ -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<OrchestratorResult> {
|
||||
// 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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user