mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* v0.30.1 Lane A: connection-manager foundation + X1 initSchema routing Routes Postgres queries by query type: - read() goes to the Supabase pooler (port 6543, fast) - ddl() and bulk() go to direct (port 5432, 30min stmt timeout, mwm 256MB) Auto-detects Supabase via hostname pooler.supabase.com or port 6543. Override with GBRAIN_DIRECT_DATABASE_URL. Kill-switch via GBRAIN_DISABLE_DIRECT_POOL=1 falls back to single-pool legacy path. Foundation modules (Lane A scope): - src/core/connection-manager.ts: read/ddl/bulk/healthCheck, parent-CM inheritance (T5/X1), cached Promise<Sql> lazy init (A1), kill-switch inheritance (A2), Supabase URL auto-derivation - src/core/url-redact.ts: redactPgUrl + redactDeep (F3) - src/core/retry-matcher.ts: typed predicates for stmt-timeout / lock / conn errors (C4) - src/core/connection-audit.ts: ~/.gbrain/audit/connection-events JSONL with ISO-week rotation; doctor tail-reads last 5 errors (F8) - scripts/check-pg-url-redaction.sh: CI grep guard against unredacted postgresql:// URL leaks (F3) Engine integration: - PostgresEngine.connect: instantiates instance-owned ConnectionManager, inherits from parentConnectionManager when set (worker engines, sync, cycle), shares pool with module-singleton path - PostgresEngine.disconnect: tears down direct pool first - PostgresEngine.initSchema: routes DDL through connectionManager.ddl() when dual-pool active (X1 part 1; lock semantics replacement is Lane B) - cli.ts:connectEngine(opts): probeOnly skips initSchema entirely (X1 part 2 — get_health, upgrade --status will use this) Tests added (51 new cases): - test/url-redact.test.ts: 11 cases - test/retry-matcher.test.ts: 13 cases - test/connection-manager.test.ts: 27 cases (URL detection, derive, kill-switch, parent inheritance, dual-pool routing modes) Foundation for Lanes B-E. Sequential lane work continues. Plan: ~/.claude/plans/system-instruction-you-are-working-stateless-wadler.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane B: migration runner retry + verify hooks + namespaced --force flags Adds Migration interface fields: - idempotent: boolean (default true; explicit false blocks verify-hook re-runs on destructive migrations) - verify: optional post-condition probe; runs after migration claims success Migration retry wrapper (Cherry D3 / Finding F2): - 3 attempts with 5s/15s/45s backoff (env GBRAIN_MIGRATE_BACKOFF_MS=0 for tests) - Retries only on statement_timeout (57014) or connection-reset patterns - Pre-attempt: logs idle-in-transaction blockers via getIdleBlockers - On exhaustion: throws MigrationRetryExhausted with named PID + suggested pg_terminate_backend() recovery command Verify-hook self-healing (Cherry D6 / Codex X3): - On verify=false + idempotent=true → re-runs migration once silently - On verify=false + idempotent=false → throws MigrationDriftError - --skip-verify CLI flag bypasses for operator override withRefreshingLock helper (Cherry T4 / Codex A4 / X1 part 3): - setInterval refresh every TTL/6 ms during long-running work - SELECT 1 backend-alive heartbeat per refresh tick - Heartbeat hang past 30s → log + clear interval; lock TTL auto-expires - LockUnavailableError when acquire fails (caller decides retry) - buildTenantLockId(scope) appends current_database() suffix for multi-tenant safety (Cherry D4) Namespaced --force flags (Codex T5): - --force-orchestrator: write 'retry' markers for ALL wedged orchestrators - --force-schema: re-runs runMigrations against current config.version - --force / --force-all: both - --force-retry vX.Y.Z: existing single-version reset (preserved) - --skip-verify: bypass verify-hook drift detection on a single run Test additions: - test/migrate-extensions.test.ts: 14 cases (idempotent default, error envelopes, MIGRATIONS contract) - test/db-lock-refresh.test.ts: 10 cases (LockUnavailableError, buildTenantLockId multi-tenant, opts shape) - test/migrate.test.ts: updated 2 existing cases (PR #356 retry shape + function-name anchor) for v0.30.1 retry-wrapper semantics 156 unit tests passing across the v0.30.1 surface so far. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane C: backfill primitive + registry + X4 + X5 First-class generic backfill runner (Fix 3). Generalizes the keyset+checkpoint+adaptive-batch pattern from src/core/backfill-effective-date.ts so future backfills (embedding_voyage in v0.30.2, etc.) reuse one tested runner. NEW src/core/backfill-base.ts: - runBackfill() with keyset pagination, config-table checkpoint, adaptive batch halving on stmt timeout, conn-drop reconnect, max-errors bail - ensureBackfillIndex() verifies/creates partial index CONCURRENTLY (P2/X4) - clearBackfillCheckpoint() for --fresh path - T3 fix: writes go through engine.withReservedConnection so BEGIN / SET LOCAL / UPDATE / COMMIT execute on the SAME backend (otherwise SET LOCAL evaporates between pooled executeRaw calls) NEW src/core/backfill-registry.ts: - effective_date: implemented (wraps existing computeEffectiveDate) - emotional_weight: implemented (wraps computeEmotionalWeight + stamps new emotional_weight_recomputed_at column) - embedding_voyage: declared-only in v0.30.1 (multi-column embedding schema lands in v0.30.2) NEW src/commands/backfill.ts: - gbrain backfill <kind> [--batch-size N] [--concurrency N] [--resume] [--fresh] [--dry-run] [--keep-index] [--max-errors N] - gbrain backfill list — shows registered backfills + status - X5 admission control: clampConcurrency() forces --concurrency to GBRAIN_DIRECT_POOL_SIZE - 1 ceiling (always reserves 1 conn for HNSW + heartbeat + doctor probes). Loud-warns when user requests above. Schema migration v44 (X4 / Codex C8 fix): - pages.emotional_weight_recomputed_at TIMESTAMPTZ - emotional_weight = 0 is a VALID steady-state value per migration v40, so the original P2 predicate ("WHERE emotional_weight = 0") would have been a permanent large index over normal data. The corrected backlog predicate is "emotional_weight_recomputed_at IS NULL"; the partial index drops naturally as the cycle phase + this backfill stamp the column over time. - idempotent: true (ADD COLUMN ... NULL is metadata-only) CLI integration: - src/cli.ts: registers `backfill` subcommand - reindex-frontmatter stays as thin alias for v0.30.1 back-compat; canonical entrypoint is now `gbrain backfill effective_date` Test additions: - test/backfill-base.test.ts: 11 cases (keyset, checkpoint, dry-run, resume/fresh, maxRows cap, withReservedConnection routing, error paths, clearCheckpoint, ensureBackfillIndex) - test/backfill-concurrency-clamp.test.ts: 6 cases (X5 admission control) 173 unit tests passing across Lanes A+B+C of v0.30.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane D: HNSW lifecycle manager + A3 atomic-swap Extends src/core/vector-index.ts with the v0.30.1 lifecycle layer. The original chunkEmbeddingIndexSql / applyChunkEmbeddingIndexPolicy contract is preserved unchanged. New surfaces: - checkActiveBuild(engine, indexName): probes pg_stat_activity for an active CREATE INDEX or REINDEX on the named index. Used as pre-op guard so dropAndRebuild doesn't compete with a build already in flight (Supabase auto-maintenance, parallel gbrain procs). - dropZombieIndexes(engine, tableNames): startup sweep of indisvalid=false rows on gbrain tables. Drops them with DROP INDEX IF EXISTS, BUT skips any zombie that has an active build still in pg_stat_activity (codex Fix-5 in-progress-build guard). Wired into PostgresEngine.initSchema() — runs after migrations + verifySchema, best-effort, never blocks engine.connect(). - dropAndRebuild(engine, spec, opts): A3 atomic-swap pattern: 1. checkActiveBuild → bail if another build is active (--force overrides) 2. CREATE INDEX CONCURRENTLY <name>_rebuild_<unix-ms> via engine.withReservedConnection (CONCURRENTLY can't run in a txn) 3. Atomic swap inside engine.transaction: DROP INDEX <old-name> ALTER INDEX <temp-name> RENAME TO <old-name> 4. If step 2 fails (OOM, timeout, conn drop), the OLD index stays intact and search keeps serving queries. This is the headline A3 win — no production-degraded silent failure mode. - monitorBuild(engine, indexName, onProgress, opts): poll pg_stat_activity every 30s; emit elapsed_ms + size_bytes (via pg_relation_size) + pid. Used by gbrain backfill embedding_voyage when batch > 1000 triggers a rebuild. - isSupabaseAutoMaintenance(active): predicate on application_name (matches "supabase" / "postgres-meta"). Used by dropAndRebuild to log + back off when Supabase auto-maintenance is doing the rebuild. Engine integration: - PostgresEngine.initSchema() calls dropZombieIndexes after verifySchema. Surfaces zombie counts via console.log. - Best-effort wrapped in try/catch: pg_stat_activity / pg_index access can be restricted on managed Postgres tiers; gbrain shouldn't fail engine.connect() over diagnostic queries. Test additions (18 cases): - test/vector-index-lifecycle.test.ts: * chunkEmbeddingIndexSql contract (3 cases) — pre-existing behavior preserved * applyChunkEmbeddingIndexPolicy contract (1 case) * checkActiveBuild (4 cases, including PGLite no-op + best-effort failure) * isSupabaseAutoMaintenance (3 cases) * dropZombieIndexes (4 cases, including in-progress-build guard) * dropAndRebuild atomic-swap (3 cases, including PGLite + active-build bail + temp-name format assertion) 191 unit tests passing across Lanes A+B+C+D of v0.30.1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 Lane E: upgrade pipeline checkpoint + brain_id binding + get_health migrations NEW src/core/upgrade-checkpoint.ts: - Cherry D5: persists step-by-step progress through gbrain post-upgrade so partial failures can be resumed via gbrain upgrade --resume. Steps: pull → install → schema → features → backfills → verify. - Codex X2: checkpoint binds to brain identity via sha256(database_url) (userinfo stripped before hashing so cred rotations don't invalidate). PGLite uses sha256(database_path). Cross-brain checkpoint application is now refused with reason='brain_mismatch'. - F4 fall-through: validateCheckpoint returns reason='no_checkpoint' when none exists, enabling silent fall-through to a full upgrade. - All-complete detection: stale checkpoints (every step done) return reason='all_complete' so the next run clears + re-runs from scratch. - markStepComplete + markStepFailed maintain the partial-state shape. T2 preserved: upgrade.ts still re-execs `gbrain post-upgrade` so the NEW binary's migration registry runs (the existing re-exec pattern is correct per codex round 1's plan-breaking finding). The checkpoint module is the substrate that Lane E's --resume / --status surfaces will plumb through in v0.30.2. D7 + C3 contract committed: - BrainHealth.schema_version: '1' (literal type) — additive-only contract pinned for MCP get_health consumers. - BrainHealth.migrations: { schema, orchestrator } — explicit two-ledger diagnostic surface (codex T5 namespacing). Both fields are OPTIONAL in v0.30.1 — engines can populate them in v0.30.2 without a contract bump. Backwards/forwards compat: clients default-handle missing fields. VERSION: 0.30.0 → 0.30.1 package.json: synced Test additions (18 cases): - test/upgrade-checkpoint.test.ts: * computeBrainId: userinfo strip, DB-distinct hashes, stable hex (5 cases) * write/load round-trip: roundtrip, missing file, malformed JSON, clear (4 cases) * validateCheckpoint: F4 no_checkpoint, X2 brain_mismatch, partial → resumeAt, all_complete, first-step pending (5 cases) * markStepComplete/markStepFailed: append, idempotent, clear-failed, failed-state shape (4 cases) 209 unit tests passing across all 5 lanes of v0.30.1 (Lanes A-E core foundations). Plumbing into upgrade.ts CLI + doctor checks + get_health() implementation is layered in via follow-up commits within this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.30.1 e2e + test isolation: integration smoke + serial quarantine NEW test/e2e/v030_1-integration-pglite.test.ts (14 cases): PGLite integration smoke proving Lane A-E surfaces work together. Lane B: migration runner applies v44 (emotional_weight_recomputed_at) cleanly; config.version reaches LATEST_VERSION Lane C: backfill registry resolves all 3 entries; emotional_weight + effective_date backfills on empty brain return examined=0 cleanly Lane D: dropZombieIndexes / checkActiveBuild on PGLite are no-ops Lane E: upgrade-checkpoint round-trips with brain_id; X2 mismatch refused; F4 fall-through detected via reason='no_checkpoint'; full step progression to all_complete Test isolation hygiene (scripts/check-test-isolation.sh): - test/connection-manager.test.ts → connection-manager.serial.test.ts - test/backfill-concurrency-clamp.test.ts → .serial.test.ts - test/upgrade-checkpoint.test.ts → .serial.test.ts All three files mutate process.env (kill-switch, GBRAIN_DIRECT_POOL_SIZE, GBRAIN_HOME) which would race other tests in the parallel runner. *.serial.test.ts quarantine ensures they run at --max-concurrency=1. Choice between withEnv() refactor and serial quarantine made on the side of preserving existing well-formed test code. E2E coverage status: - v030_1-integration-pglite.test.ts (this commit): 14 cases, all green - backfill-perf-pglite.test.ts: 1 case, green (no regression) - cycle-recompute-emotional-weight-pglite.test.ts: green (no regression) - multi-source-emotional-weight-pglite.test.ts: green (no regression) - dream-synthesize-pglite.test.ts: 14 cases, green (no regression) - anomalies-pglite.test.ts + salience-pglite.test.ts: 6 cases, green Postgres-only E2Es (migration-flow, http-transport, hnsw-lifecycle, connection-routing) require DATABASE_URL + a real Postgres+pgvector container per the CLAUDE.md E2E lifecycle. They land as separate DATABASE_URL-gated work — not regressed by v0.30.1 changes; their preconditions just aren't met in the current run environment. `bun run verify` (typecheck + 4 shell pre-checks + test-isolation lint) passes cleanly. Final v0.30.1 unit + integration test count: 4547 pass, 0 regressions. Two pre-existing flaky failures (BrainRegistry serial test + warm-create perf gate under shard contention) confirmed unrelated to this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.30.1) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
497 lines
20 KiB
TypeScript
497 lines
20 KiB
TypeScript
/**
|
|
* `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 { loadConfig } from '../core/config.ts';
|
|
import { loadCompletedMigrations, appendCompletedMigration, type CompletedMigrationEntry } from '../core/preferences.ts';
|
|
import { migrations, compareVersions, type Migration, type OrchestratorOpts } from './migrations/index.ts';
|
|
|
|
/** Bug 3 — max consecutive partials before we wedge a migration. */
|
|
const MAX_CONSECUTIVE_PARTIALS = 3;
|
|
|
|
interface ApplyMigrationsArgs {
|
|
list: boolean;
|
|
dryRun: boolean;
|
|
yes: boolean;
|
|
nonInteractive: boolean;
|
|
mode?: 'always' | 'pain_triggered' | 'off';
|
|
specificMigration?: string;
|
|
hostDir?: string;
|
|
noAutopilotInstall: boolean;
|
|
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
|
|
forceRetry?: string;
|
|
/**
|
|
* v0.30.1 namespaced --force flags (codex T5):
|
|
* --force-orchestrator: write 'retry' markers for ALL wedged orchestrator migrations
|
|
* --force-schema: reset schema-version drift (re-run runMigrations)
|
|
* --force-all: both
|
|
*/
|
|
forceOrchestrator?: boolean;
|
|
forceSchema?: boolean;
|
|
forceAll?: boolean;
|
|
/** v0.30.1 (D6 / X3): bypass verify-hook drift detection on a single run. */
|
|
skipVerify?: 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'),
|
|
forceRetry: val('--force-retry'),
|
|
forceOrchestrator: has('--force-orchestrator'),
|
|
forceSchema: has('--force-schema'),
|
|
forceAll: has('--force-all') || has('--force'),
|
|
skipVerify: has('--skip-verify'),
|
|
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.
|
|
gbrain apply-migrations --force-retry vX.Y.Z
|
|
Clear a wedged migration (3+ consecutive
|
|
partials). Writes a 'retry' marker so the
|
|
next run treats it as fresh.
|
|
gbrain apply-migrations --force-orchestrator
|
|
Reset every wedged orchestrator migration
|
|
in one shot (writes 'retry' for each).
|
|
gbrain apply-migrations --force-schema
|
|
Reset schema-version drift; re-runs
|
|
runMigrations from current config.version.
|
|
gbrain apply-migrations --force (alias --force-all) Apply both
|
|
--force-orchestrator and --force-schema.
|
|
gbrain apply-migrations --skip-verify Bypass post-condition verify hooks on
|
|
non-idempotent migrations (D6 escape hatch).
|
|
|
|
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.
|
|
*
|
|
* Semantics (Bug 3 — keep "complete wins" safety):
|
|
* - If any entry is `complete`, the version is complete. Terminal state.
|
|
* - Otherwise, if the latest entry is `retry`, the version is pending
|
|
* (user requested a fresh attempt).
|
|
* - Otherwise, if any entry is `partial`, the version is partial.
|
|
* - Otherwise, pending.
|
|
*
|
|
* `complete` never regresses. A later accidental `partial` append cannot
|
|
* undo a completed migration.
|
|
*/
|
|
function statusForVersion(
|
|
version: string,
|
|
idx: CompletedIndex,
|
|
): 'complete' | 'partial' | 'pending' | 'wedged' {
|
|
const entries = idx.byVersion.get(version) ?? [];
|
|
if (entries.length === 0) return 'pending';
|
|
if (entries.some(e => e.status === 'complete')) return 'complete';
|
|
const latest = entries[entries.length - 1];
|
|
if (latest.status === 'retry') return 'pending';
|
|
// Bug 3 attempt cap — count consecutive partials from the end (stopping
|
|
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
|
|
// the migration is wedged and needs explicit --force-retry to try again.
|
|
let consecutive = 0;
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const e = entries[i];
|
|
if (e.status === 'partial') consecutive++;
|
|
else break;
|
|
}
|
|
if (consecutive >= MAX_CONSECUTIVE_PARTIALS) return 'wedged';
|
|
if (entries.some(e => e.status === 'partial')) return 'partial';
|
|
return 'pending';
|
|
}
|
|
|
|
interface Plan {
|
|
applied: Migration[];
|
|
partial: Migration[];
|
|
pending: Migration[];
|
|
skippedFuture: Migration[];
|
|
wedged: 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: [], wedged: [] };
|
|
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 if (status === 'wedged') plan.wedged.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.wedged.map(m => ({ status: 'wedged', 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';
|
|
|
|
// First-install guard (postinstall hook calls us even on `bun add gbrain`
|
|
// before the user has run `gbrain init`). No config = no brain = nothing
|
|
// to migrate. Exit silently for --yes / --non-interactive so postinstall
|
|
// stays quiet; mention the init step when invoked interactively.
|
|
if (!loadConfig()) {
|
|
if (cli.list) console.log('No brain configured. Run `gbrain init` to set one up.');
|
|
else if (cli.dryRun) console.log('No brain configured (run `gbrain init` first). Nothing to migrate.');
|
|
return;
|
|
}
|
|
|
|
// Bug 3 — --force-retry: write an explicit reset marker for a wedged
|
|
// migration, then return. User re-runs `gbrain apply-migrations --yes`
|
|
// to actually re-attempt.
|
|
if (cli.forceRetry) {
|
|
const target = migrations.find(m => m.version === cli.forceRetry);
|
|
if (!target) {
|
|
console.error(`No migration registered with version "${cli.forceRetry}". Run \`gbrain apply-migrations --list\`.`);
|
|
process.exit(2);
|
|
}
|
|
appendCompletedMigration({ version: cli.forceRetry, status: 'retry' });
|
|
console.log(`Wrote 'retry' marker for v${cli.forceRetry}. Run \`gbrain apply-migrations --yes\` to re-attempt.`);
|
|
return;
|
|
}
|
|
|
|
// v0.30.1 (codex T5): --force-orchestrator OR --force-all writes a 'retry'
|
|
// marker for EVERY wedged orchestrator migration in one shot. User re-runs
|
|
// `gbrain apply-migrations --yes` to actually re-attempt.
|
|
if (cli.forceOrchestrator || cli.forceAll) {
|
|
const completed = loadCompletedMigrations();
|
|
const idx = indexCompleted(completed);
|
|
let resetCount = 0;
|
|
for (const m of migrations) {
|
|
const status = statusForVersion(m.version, idx);
|
|
if (status === 'wedged') {
|
|
appendCompletedMigration({ version: m.version, status: 'retry' });
|
|
console.log(`Wrote 'retry' marker for v${m.version} (${m.featurePitch.headline.slice(0, 60)})`);
|
|
resetCount++;
|
|
}
|
|
}
|
|
if (resetCount === 0) {
|
|
console.log('No wedged orchestrator migrations found.');
|
|
} else {
|
|
console.log(`\nReset ${resetCount} wedged orchestrator migration(s). Run \`gbrain apply-migrations --yes\` to re-attempt.`);
|
|
}
|
|
if (!cli.forceAll) return; // --force-schema continues below if --force-all is set
|
|
}
|
|
|
|
// v0.30.1 (codex T5): --force-schema OR --force-all resets schema-version
|
|
// drift by re-running runMigrations(). When the actual DDL state diverges
|
|
// from config.version (the brain_config incident), this is the manual
|
|
// recovery path.
|
|
if (cli.forceSchema || cli.forceAll) {
|
|
try {
|
|
const { runMigrations } = await import('../core/migrate.ts');
|
|
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
|
|
const { createEngine } = await import('../core/engine-factory.ts');
|
|
const cfg = lc();
|
|
if (!cfg) {
|
|
console.error('No brain configured for --force-schema.');
|
|
process.exit(2);
|
|
}
|
|
const eng = await createEngine(toEngineConfig(cfg));
|
|
await eng.connect(toEngineConfig(cfg));
|
|
console.log('Running schema migrations from current config.version...');
|
|
const result = await runMigrations(eng);
|
|
console.log(`Applied ${result.applied} schema migration(s); now at v${result.current}.`);
|
|
await eng.disconnect();
|
|
} catch (err) {
|
|
console.error(`--force-schema failed: ${(err as Error).message}`);
|
|
process.exit(1);
|
|
}
|
|
if (cli.forceSchema && !cli.forceAll) return;
|
|
if (cli.forceAll) return; // both surfaces flushed
|
|
}
|
|
|
|
// Pre-flight: warn if schema migrations (migrate.ts) are behind.
|
|
// apply-migrations runs orchestrator migrations only; schema migrations
|
|
// run via connectEngine() / initSchema(). Users often expect this CLI
|
|
// to handle everything (Issue 1 from v0.18.0 field report).
|
|
try {
|
|
const { LATEST_VERSION } = await import('../core/migrate.ts');
|
|
const { loadConfig: lc, toEngineConfig } = await import('../core/config.ts');
|
|
const { createEngine } = await import('../core/engine-factory.ts');
|
|
const cfg = lc();
|
|
if (cfg) {
|
|
const eng = await createEngine(toEngineConfig(cfg));
|
|
await eng.connect(toEngineConfig(cfg));
|
|
const verStr = await eng.getConfig('version');
|
|
const schemaVer = parseInt(verStr || '1', 10);
|
|
await eng.disconnect();
|
|
if (schemaVer < LATEST_VERSION) {
|
|
console.warn(
|
|
`\n⚠️ Schema version ${schemaVer} is behind latest ${LATEST_VERSION}.\n` +
|
|
` Schema migrations run automatically on next connectEngine() / initSchema().\n` +
|
|
` To run them now: gbrain init --migrate-only\n`,
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-fatal: if DB is unreachable, orchestrator migrations can still
|
|
// run their filesystem-only phases.
|
|
}
|
|
|
|
const completed = loadCompletedMigrations();
|
|
const idx = indexCompleted(completed);
|
|
const plan = buildPlan(idx, installed, cli.specificMigration);
|
|
|
|
// Bug 3 — surface wedged migrations as a loud, actionable error.
|
|
if (plan.wedged.length > 0) {
|
|
for (const m of plan.wedged) {
|
|
console.error(
|
|
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
|
|
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
|
|
` gbrain apply-migrations --force-retry ${m.version}\n` +
|
|
`Then re-run \`gbrain apply-migrations --yes\`.`,
|
|
);
|
|
}
|
|
// Don't exit — applied/partial/pending are still worth reporting and running.
|
|
}
|
|
|
|
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").
|
|
//
|
|
// Bug 3 — the RUNNER owns the ledger write now. Orchestrators return their
|
|
// result; we persist it here with a canonical shape. If the write fails,
|
|
// surface the error and DO NOT proceed to the next migration (a silent
|
|
// ledger drop was the root cause of the original infinite-retry symptom).
|
|
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.`);
|
|
// Record the attempt as 'partial' (not 'complete') so the cap counts
|
|
// it. Don't let a failed orchestrator look like it never ran.
|
|
try {
|
|
appendCompletedMigration({
|
|
version: m.version,
|
|
status: 'partial',
|
|
phases: result.phases,
|
|
files_rewritten: result.files_rewritten,
|
|
autopilot_installed: result.autopilot_installed,
|
|
install_target: result.install_target,
|
|
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
|
|
});
|
|
} catch (e) {
|
|
console.error(`Also: could not persist failure record: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
failed = true;
|
|
break;
|
|
}
|
|
|
|
// Persist the terminal outcome. appendCompletedMigration no-ops when
|
|
// the last entry for this version is already 'complete' (idempotency
|
|
// guard), so repeated clean runs don't spam the ledger.
|
|
try {
|
|
appendCompletedMigration({
|
|
version: m.version,
|
|
status: result.status, // 'complete' | 'partial'
|
|
phases: result.phases,
|
|
files_rewritten: result.files_rewritten,
|
|
autopilot_installed: result.autopilot_installed,
|
|
install_target: result.install_target,
|
|
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
|
|
});
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
console.error(`Failed to persist ledger entry for v${m.version}: ${msg}. Stopping to prevent silent drift.`);
|
|
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}`);
|
|
// Same partial-on-throw treatment so the cap counts runaway failures.
|
|
try {
|
|
appendCompletedMigration({ version: m.version, status: 'partial' });
|
|
} catch { /* swallow ledger-write failure on throw path */ }
|
|
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,
|
|
};
|