Skip to content

feat(migrate): genie host-migrations framework + 2 initial migrations - #1619

Merged
namastex888 merged 1 commit into
devfrom
wish/genie-host-migrations
May 3, 2026
Merged

namastex888 merged 1 commit into
devfrom
wish/genie-host-migrations

Conversation

@namastex888

Copy link
Copy Markdown
Contributor

Summary

Adds versioned, applied-once host-state migrations that detect and fix drift between current code expectations and persisted host state (pm2 env blocks, embedded pgserve fantasmas, config drifts). Auto-runs on bun add -g @automagik/genie@latest via postinstall hook so users get fixes transparently.

Wish: .genie/wishes/genie-host-migrations/WISH.md (lint clean, 3 EGs)

Why

Closes the upgrade-silent-breakage class: a code fix lands but pm2 process configs persist with old behavior. Live example: commit 5567e202 (fix(install): bake DATABASE_URL) requires manual genie install re-run on existing hosts — users have no way to know. Result: genie-serve silently spawns its own embedded pgserve instead of connecting to canonical, breaking genie send and other genie ops.

What ships

Framework (4 modules):

  • src/migrations/index.ts — orchestrator
  • src/migrations/runner.ts — check → apply → validate per-step contract
  • src/migrations/store.ts — atomic ~/.genie/migrations.json (file-based, not PG)
  • src/migrations/discover.ts — scan steps/, alphabetical = apply order

CLI verb genie migrate with --dry-run, --status, --quiet flags.

Postinstall scripts/postinstall-migrations.js — soft-fail hook chained after existing tmux+hook-binary postinstalls. GENIE_SKIP_MIGRATIONS=1 escape hatch.

Initial 2 migrations (close live breaks):

  • 001-pm2-env-databaseurl-bake — re-bakes DATABASE_URL into pm2 genie-serve env when canonical pgserve registered (fixes 5567e202 upgrade gap)
  • 002-kill-embedded-pgserve-legacy — stops legacy embedded pgserve listening on non-canonical ports when canonical 8432 is healthy

Constraints honored

  • File-based tracking (not PG): migrations may RUN before pgserve healthy
  • Atomic store write (tmp + rename) prevents partial JSON
  • Soft-fail postinstall: bun install never breaks
  • Failed migrations recorded + retried (loud, never silently skipped)
  • All steps idempotent (safe to re-run)
  • Worktree isolated — no working-clone churn

Test plan

  • bun test src/migrations/__tests__/ test/migrations/ → 8/8 pass
  • Felipe's box (currently broken) → after merge + ship: genie update --next triggers postinstall → both migrations apply → genie send works without manual intervention
  • Fresh container install: postinstall runs migrations → both check → false → SKIP records, exit 0
  • CI green on dev pipeline

Sibling

Companion to namastexlabs/pgserve#66 (autopg upgrade command) — same self-heal philosophy, scoped to pgserve subsystem.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 3, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f3678b41-4366-4fe6-bde3-74fa7782526b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wish/genie-host-migrations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a versioned host-migration framework for the Genie CLI to manage host-state drift, including an orchestrator, a JSON-based tracking store, and a post-install hook for automatic updates. While the framework is well-structured, several critical issues must be addressed for production readiness. The migration discovery logic will fail in the distributed package because the steps directory is not included in the build, and the pathing for package.json is inconsistent. Furthermore, Migration 002 is incompatible with macOS and will crash when run under Node.js due to the use of Bun.sleepSync. Other concerns include the use of process.exit() which bypasses CLI cleanup routines and the lack of file locking in the migration store to prevent race conditions.

Comment on lines +34 to +46
export function discoverMigrations(): DiscoveredMigration[] {
const stepsDir = join(__dirname, 'steps');
if (!existsSync(stepsDir)) return [];
const files = readdirSync(stepsDir);
const matched: DiscoveredMigration[] = [];
for (const file of files) {
const m = file.match(FILE_PATTERN);
if (!m) continue;
matched.push({ id: m[1], filePath: join(stepsDir, file) });
}
matched.sort((a, b) => a.id.localeCompare(b.id));
return matched;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The migration discovery logic will fail in production. The src/migrations/steps/ directory is not included in the package.json files array, nor is it copied to the dist/ directory during the build process. Since discoverMigrations relies on readdirSync at runtime to find .ts or .js files, it will find no migrations in the published npm package. You should add the steps directory to the package files and ensure they are accessible relative to the bundled executable, or use a build-time macro to register them.

}

const result = await migrate({ quiet: options.quiet, dryRun: options.dryRun });
process.exit(result.ok ? 0 : 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Calling process.exit() here is problematic. The main CLI entry point in src/genie.ts wraps command execution in try...finally blocks to ensure that database connections are closed and observability receivers are stopped. process.exit() terminates the process immediately, bypassing these essential cleanup steps. Consider throwing an error or returning a status to let the top-level handler manage the exit code while allowing cleanup to proceed.

Suggested change
process.exit(result.ok ? 0 : 1);
if (!result.ok) throw new Error('genie host-migrations failed');

Comment on lines +29 to +81
const out = execSync('ss -tlnp', { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
const out_lines = out.split('\n');
const found: ListeningPg[] = [];
const seen = new Set<number>();
for (const line of out_lines) {
// Match "127.0.0.1:<port>" with users:(("postgres",pid=<n>,...))
const portMatch = line.match(/127\.0\.0\.1:(\d+)\s/);
const procMatch = line.match(/users:\(\("postgres",pid=(\d+)/);
if (portMatch && procMatch) {
const pid = parseInt(procMatch[1], 10);
const port = parseInt(portMatch[1], 10);
if (!seen.has(pid)) {
seen.add(pid);
found.push({ pid, port });
}
}
}
return found;
} catch {
return [];
}
}

function canonicalReachable(): boolean {
try {
execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}

function findLegacyEmbedded(): ListeningPg | undefined {
if (process.env.GENIE_KEEP_LEGACY_PG === '1') return undefined;
if (!canonicalReachable()) return undefined;
return listListeningPgserve().find((p) => p.port !== CANONICAL_PORT);
}

export async function check(_ctx: MigrationContext): Promise<boolean> {
return findLegacyEmbedded() !== undefined;
}

export async function apply(ctx: MigrationContext): Promise<void> {
const target = findLegacyEmbedded();
if (!target) {
ctx.log('no legacy embedded found at apply time (race resolved)');
return;
}
ctx.log(`stopping legacy embedded pgserve PID ${target.pid} (port ${target.port})`);
// Try pg_ctl stop via discovered data dir from the process
try {
// Process cmdline to find -D <dataDir>
const cmdline = execSync(`cat /proc/${target.pid}/cmdline | tr '\\0' ' '`, { stdio: ['ignore', 'pipe', 'pipe'] }).toString();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This migration is not compatible with macOS. It relies on Linux-specific tools and filesystems: ss -tlnp (line 29) and /proc/${pid}/cmdline (line 81). Since Genie is intended to support both Linux and macOS, this migration will fail to detect or stop legacy processes on macOS. Consider using more portable alternatives like lsof or ps -p <pid> -o command=. Additionally, the tr command on line 81 uses \0 which may be interpreted as a literal backslash and zero rather than the null character depending on the shell environment.

Comment thread src/migrations/index.ts
Comment on lines +37 to +46
function getGenieVersion(): string {
try {
// genie cli installed structure: <root>/dist/genie.js + <root>/package.json
const pkgPath = join(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
return pkg.version || 'unknown';
} catch {
return 'unknown';
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The path logic for finding package.json is inconsistent between development and production. While ../../package.json works from src/migrations/ in the source tree, it will point to the wrong location when the code is bundled into dist/genie.js. In the distributed package, package.json is typically located at ../package.json relative to the dist/ directory.

Comment thread src/migrations/store.ts
Comment on lines +57 to +82
export function recordApplied(id: string, version: string, detail?: string): void {
const store = loadStore();
// Strip any prior FAILED record for this id; record APPLIED authoritatively.
store.applied = store.applied.filter((r) => r.id !== id);
store.applied.push({
id,
status: 'APPLIED',
appliedAt: new Date().toISOString(),
appliedFrom: version,
detail,
});
saveStore(store);
}

export function recordFailed(id: string, version: string, reason: string): void {
const store = loadStore();
store.applied = store.applied.filter((r) => r.id !== id);
store.applied.push({
id,
status: 'FAILED',
appliedAt: new Date().toISOString(),
appliedFrom: version,
detail: reason,
});
saveStore(store);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The migration store is susceptible to race conditions. recordApplied and recordFailed perform a read-modify-write operation on migrations.json without any file locking. If multiple instances of genie migrate run concurrently (e.g., a postinstall hook triggered during a manual update), one process could overwrite the updates made by another. Consider using a simple advisory lock file to synchronize access to the store.

while (Date.now() < deadline) {
if (findLegacyEmbedded() === undefined) return;
// small sleep
Bun.sleepSync(200);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using Bun.sleepSync will cause a crash if the migration is run using Node.js. While the CLI entry point uses a Bun shebang, the scripts/postinstall-migrations.js hook uses #!/usr/bin/env node and spawns the migration command using process.execPath. If a user installs the package using npm, process.execPath will be Node.js, leading to a ReferenceError when this line is reached. Use a more portable sleep implementation or check for the Bun global before calling it.

@namastex888
namastex888 force-pushed the wish/genie-host-migrations branch 3 times, most recently from 8d2854d to 23af388 Compare May 3, 2026 18:48
Adds versioned, applied-once host-state migrations that detect and fix
drift between current code expectations and persisted host state (pm2
env blocks, embedded pgserve fantasmas, config drifts). Same pattern as
DB migrations but for HOST state. Auto-runs on `bun add -g
@automagik/genie@latest` via postinstall hook so users get fixes
transparently.

Closes the upgrade-silent-breakage class where a code fix lands but pm2
process configs persist with old behavior — live example: commit
5567e20 (`fix(install): bake DATABASE_URL`) requires manual `genie
install` re-run; users had no way to know.

Implementation:
- src/migrations/index.ts — orchestrator (discover, filter pending, apply)
- src/migrations/runner.ts — per-step check → apply → validate contract
- src/migrations/store.ts — atomic ~/.genie/migrations.json read/write
- src/migrations/discover.ts — scan steps/, sort alphabetical = apply order
- src/migrations/steps/001-pm2-env-databaseurl-bake.ts — re-bake DATABASE_URL
  into pm2 genie-serve env when canonical pgserve registered
- src/migrations/steps/002-kill-embedded-pgserve-legacy.ts — stop legacy
  embedded pgserve on non-canonical ports when canonical 8432 is healthy
- src/genie-commands/migrate.ts — CLI verb (--dry-run / --status / --quiet)
- src/genie.ts — wire `genie migrate` subcommand
- scripts/postinstall-migrations.js — soft-fail postinstall hook
- package.json — chain postinstall (preserves existing tmux + hook-binary)
- src/migrations/README.md — operator + contributor guide
- src/migrations/__tests__/orchestrator.test.ts — 6 framework tests
- test/migrations/postinstall.test.ts — 2 postinstall behavior tests
- CHANGELOG.md — v4.x.x contract sentence

Constraints honored:
- File-based tracking (not PG): migrations may need to RUN before
  genie-serve / canonical pgserve are healthy
- Atomic store write (tmp + rename) prevents partial JSON on crash
- Soft-fail postinstall: bun install never breaks
- GENIE_SKIP_MIGRATIONS=1 escape hatch
- GENIE_KEEP_LEGACY_PG=1 escape hatch for migration 002
- Failed migrations recorded + retried (loud, never silently skipped)
- All steps idempotent (safe to re-run)
- Worktree isolated (working clone untouched)

Validation:
- bun test src/migrations/__tests__/ test/migrations/ → 8/8 pass

Wish: .genie/wishes/genie-host-migrations/WISH.md (lint clean)
Sibling: pgserve/autopg-upgrade-command (same self-heal philosophy,
pgserve subsystem)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@namastex888
namastex888 force-pushed the wish/genie-host-migrations branch from 23af388 to b7ebecc Compare May 3, 2026 18:50
@namastex888
namastex888 merged commit 5004de5 into dev May 3, 2026
16 checks passed
namastex888 added a commit that referenced this pull request May 4, 2026
PR #1619 added the host-migrations framework but `package.json`
`files` array did not include `scripts/postinstall-migrations.js`
nor `src/migrations/`. Result on published 4.260503.5:

  $ tar -tzf @automagik-genie-4.260503.5.tgz | grep migration
  package/src/db/migrations/...   # only DB migrations shipped
  # scripts/postinstall-migrations.js MISSING
  # src/migrations/{runner,discover,store,steps/*}.ts MISSING

Symptom on `bun add -g @automagik/genie@next && genie update --next`:
- postinstall chain has `node scripts/postinstall-migrations.js` but
  the file is absent (silent failure under bun's default trust policy).
- `genie migrate --status` returns "No migrations discovered."
  because `discoverMigrations()` scans a non-existent directory.

Wish acceptance bullet from `.genie/wishes/genie-host-migrations`:
  "Auto-runs on `bun add -g @automagik/genie@latest` via postinstall
   hook so users get fixes transparently."
This made the bullet impossible. Two-line fix to make it actually
work in the next release.

Verified via `npm pack --dry-run`:
  npm notice 2.2kB scripts/postinstall-migrations.js
  npm notice 1.8kB src/migrations/discover.ts
  npm notice 3.8kB src/migrations/index.ts
  npm notice 2.4kB src/migrations/runner.ts
  npm notice 3.4kB src/migrations/steps/001-pm2-env-databaseurl-bake.ts
  npm notice 3.8kB src/migrations/steps/002-kill-embedded-pgserve-legacy.ts
  npm notice 2.6kB src/migrations/store.ts
  npm notice 2.8kB src/migrations/README.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@automagik-genie
automagik-genie deleted the wish/genie-host-migrations branch September 25, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant