Module: PWN::Migrate
- Defined in:
- lib/pwn/migrate.rb
Overview
PWN::Migrate — the ~/.pwn state doctor and auto-migrator.
PWN persists a growing set of files under ~/.pwn (encrypted config,
memory, learning outcomes, metrics, mistakes, extrospection, cron
jobs, agents, skills, sessions, swarm buses, …). Each file is owned
by a different module and each release can add keys or change shape.
A user upgrading gem install pwn between two versions could
therefore hit KeyError, NoMethodError for nil, or a silent
empty-fallback because their on-disk state predates the new loader.
PWN::Migrate closes that gap. It is called from:
* `pwn setup --migrate`
It works by:
1. Stamping `~/.pwn/.schema` with `{ schema:, pwn_version:, at: }`
the first time it runs. Any release that changes the on-disk
shape of a `~/.pwn` file bumps `SCHEMA_VERSION` here and appends
an idempotent lambda to `MIGRATIONS`.
2. Declaratively every state file against its OWNING
module's own loader (`STATE_FILES`) — no re-implemented parsers.
If the raw file has bytes but the owner returned its
empty-fallback, the file is flagged incompatible.
3. Autofixing: timestamped backup → ordered schema migrations →
per-file repair (quarantine corrupt / re-seed missing) →
`pwn.yaml` deep-key backfill (missing keys from the current
`PWN::Config.env_template` are merged in-place under the user's
values, then the vault is re-encrypted with the SAME key/iv).
Everything is idempotent, dry-run capable, and never overwrites a user-set value.
Constant Summary collapse
- PWN_ROOT =
File.join(Dir.home, '.pwn')
- SCHEMA_FILE =
File.join(PWN_ROOT, '.schema')
- BACKUP_ROOT =
File.join(PWN_ROOT, 'backup')
- QUARANTINE_DIR =
File.join(PWN_ROOT, 'quarantine')
- SCHEMA_VERSION =
Bump this whenever the shape of any file under ~/.pwn changes in a way that requires a one-time transform. Add the transform as an entry in MIGRATIONS keyed by the NEW schema number.
7- OK =
"\e[32mok\e[0m"- BAD =
"\e[31mFAIL\e[0m"- WARN =
"\e[33mdrift\e[0m"- STATE_FILES =
────────────────────────────────────────────────────────────────── Declarative registry of every persisted ~/.pwn artefact.
Each entry knows how to (a) locate itself, (b) ask its owning module to load it, and (c) decide whether the loaded shape is what this pwn version expects. Verifiers are intentionally SHALLOW — they detect "loader gave up and returned the fallback" rather than re-validating every key, so ownership stays with the module.
:fix values:
:quarantine──────────────────────────────────────────────────────────────────
{ 'pwn.yaml' => { owner: 'PWN::Config', kind: :vault, fix: :vault, verify: lambda { |p, _raw| return { ok: false, why: 'missing' } unless File.file?(p) enc = PWN::Plugins::Vault.file_encrypted?(file: p) # Shape (missing keys vs env_template) is checked separately in # .vault_drift because it requires the decryptor. { ok: enc, why: enc ? nil : 'not encrypted (run PWN::Plugins::Vault.create)' } } }, 'memory.json' => { owner: 'PWN::Memory', kind: :json, fix: :quarantine, verify: lambda { |_p, raw| m = PWN::Memory.load bad = m.reject { |_k, v| v.is_a?(Hash) && v.key?(:value) } { ok: !(m.empty? && raw.to_s.length > 4) && bad.empty?, why: bad.any? ? "#{bad.length} entries missing :value" : nil } } }, 'memory.idx' => { owner: 'PWN::MemoryIndex', kind: :blob, fix: :quarantine, verify: ->(_p, _raw) { { ok: true } } }, 'learning.jsonl' => { owner: 'PWN::AI::Agent::Learning', kind: :jsonl, fix: :jsonl, verify: :jsonl }, 'preferences.jsonl' => { owner: 'PWN::AI::Agent::Reward', kind: :jsonl, fix: :jsonl, verify: :jsonl }, 'metrics.json' => { owner: 'PWN::AI::Agent::Metrics', kind: :json, fix: :quarantine, verify: lambda { |_p, raw| m = PWN::AI::Agent::Metrics.load fell_back = m == { tools: {}, updated_at: nil } && raw.to_s.length > 4 { ok: m.is_a?(Hash) && m.key?(:tools) && !fell_back, why: fell_back ? 'loader fell back to empty (unparsable / wrong shape)' : nil } } }, 'mistakes.json' => { owner: 'PWN::AI::Agent::Mistakes', kind: :json, fix: :quarantine, verify: lambda { |_p, raw| m = PWN::AI::Agent::Mistakes.load fell_back = m.empty? && raw.to_s.length > 4 { ok: m.is_a?(Hash) && !fell_back, why: fell_back ? 'loader fell back to {}' : nil } } }, 'extrospection.json' => { owner: 'PWN::AI::Agent::Extrospection', kind: :json, fix: :quarantine, verify: lambda { |_p, raw| m = PWN::AI::Agent::Extrospection.load shape = m.is_a?(Hash) && m.key?(:observations) && m[:observations].is_a?(Array) fell_back = shape && m[:snapshot].to_h.empty? && m[:observations].empty? && raw.to_s.length > 8 { ok: shape && !fell_back, why: shape ? nil : 'missing :observations array' } } }, 'reward_sentinel.json' => { owner: 'PWN::AI::Agent::Reward', kind: :json, fix: :quarantine, verify: ->(_p, _raw) { { ok: true } } }, 'agents.yml' => { owner: 'PWN::AI::Agent::Swarm', kind: :yaml, fix: :quarantine, verify: lambda { |_p, raw| m = PWN::AI::Agent::Swarm.personas fell_back = m.empty? && raw.to_s.strip.length > 4 bad = m.reject { |_k, v| v.is_a?(Hash) && v[:role] } { ok: !fell_back && bad.empty?, why: (fell_back && 'loader fell back to {}') || (bad.any? && "#{bad.length} personas missing :role") || nil } } }, 'cron/jobs.yml' => { owner: 'PWN::Cron', kind: :yaml, fix: :seed, verify: lambda { |_p, raw| j = PWN::Cron.list fell_back = j.empty? && raw.to_s.strip.length > 8 bad = j.reject { |_id, v| v.is_a?(Hash) && v[:schedule] } { ok: !fell_back && bad.empty?, why: (fell_back && 'loader fell back to {}') || (bad.any? && "#{bad.length} jobs missing :schedule") || nil } } }, 'skills/' => { owner: 'PWN::Config', kind: :dir, fix: :skills, verify: lambda { |p, _raw| return { ok: true } unless File.directory?(p) legacy = Dir.glob(File.join(p, '*.{rb,md,txt,skill,yml,yaml}')).length { ok: legacy.zero?, why: legacy.zero? ? nil : "#{legacy} legacy flat skill file(s)" } } }, 'sessions/' => { owner: 'PWN::Sessions', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } }, 'swarm/' => { owner: 'PWN::AI::Agent::Swarm', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } }, 'curriculum/' => { owner: 'PWN::AI::Agent::Curriculum', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } }, 'finetune/' => { owner: 'PWN::AI::Agent::Curriculum', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } }, 'extrospection/' => { owner: 'PWN::AI::Agent::Extrospection', kind: :dir, fix: :seed, verify: ->(_p, _r) { { ok: true } } } }.freeze
- MIGRATIONS =
Ordered, idempotent one-shot transforms. Key == the schema number a ~/.pwn tree is AT after the lambda runs. Add a new entry every time SCHEMA_VERSION is bumped. Each lambda receives (root, io).
{ 1 => lambda { |root, io| # v0 → v1 : agentskills.io directory layout + seeded RL cron jobs # + ensure all state subdirs exist. %w[skills sessions swarm cron curriculum finetune extrospection].each do |d| FileUtils.mkdir_p(File.join(root, d)) end r = begin PWN::Config.migrate_legacy_skills rescue StandardError { migrated: 0 } end io.puts " · migrate_legacy_skills → #{r[:migrated]} converted" if r[:migrated].to_i.positive? PWN::Config.install_default_skills if defined?(PWN::Config) PWN::Cron.install_defaults if defined?(PWN::Cron) }, 2 => lambda { |root, io| path = File.join(root, 'agents.yml') r = if defined?(PWN::AI::Agent::Swarm) && PWN::AI::Agent::Swarm.respond_to?(:migrate_personas) PWN::AI::Agent::Swarm.migrate_personas(path: path) else { changed: false } end io.puts " · agents.yml #{Array(r[:patched]).join(', ')}" if r[:changed] }, 3 => lambda { |root, io| path = File.join(root, 'agents.yml') next unless File.file?(path) raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) next unless raw.is_a?(Hash) patched = 0 raw.each_value do |persona| next unless persona.is_a?(Hash) next if persona.key?('model') || persona.key?(:model) persona['model'] = nil patched += 1 end next if patched.zero? File.write(path, YAML.dump(raw)) io.puts " · agents.yml added default model to #{patched} personas" }, 4 => lambda { |_root, io| io.puts ' · pwn.yaml ai_sandbox, ai_router, model_routes task classes' PWN::Migrate.backfill_vault(io: io) }, 5 => lambda { |_root, io| io.puts ' · pwn.yaml ai.agent.skill_review' PWN::Migrate.backfill_vault(io: io) }, 6 => lambda { |_root, io| io.puts ' · pwn.yaml ai.tui.theme' PWN::Migrate.backfill_vault(io: io) }, 7 => lambda { |_root, io| io.puts ' · pwn.yaml ai.tui.theme.title' PWN::Migrate.backfill_vault(io: io) } }.freeze
Class Method Summary collapse
-
.authors ⇒ Object
- Author(s)
0day Inc.
-
.backfill_vault(opts = {}) ⇒ Object
- Supported Method Parameters
r = PWN::Migrate.backfill_vault( key: 'optional', iv: 'optional', dry_run: false, io: $stdout ).
-
.check(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::Migrate.check( io: 'optional - IO to write the report to (default $stdout)' ).
-
.env_template ⇒ Object
- Supported Method Parameters
tmpl = PWN::Migrate.env_template.
-
.help ⇒ Object
Display Usage for this Module.
-
.installed_schema ⇒ Object
- Supported Method Parameters
schema = PWN::Migrate.installed_schema.
-
.needed? ⇒ Boolean
- Supported Method Parameters
bool = PWN::Migrate.needed?.
-
.run(opts = {}) ⇒ Object
- Supported Method Parameters
report = PWN::Migrate.run( fix: 'optional - also autofix incompatible files (default: schema-migrations only)', backup: 'optional - take timestamped backup of ~/.pwn first (default true)', dry_run: 'optional - report what WOULD happen, write nothing (default false)', key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)', iv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)', io: 'optional - IO to write to (default $stdout)' ).
-
.status ⇒ Object
- Supported Method Parameters
rows = PWN::Migrate.status.
-
.vault_drift(opts = {}) ⇒ Object
- Supported Method Parameters
drift = PWN::Migrate.vault_drift( key: 'optional - vault key', iv: 'optional - vault iv' ).
Class Method Details
.authors ⇒ Object
- Author(s)
0day Inc. [email protected]
717 718 719 720 721 |
# File 'lib/pwn/migrate.rb', line 717 public_class_method def self. "AUTHOR(S): 0day Inc. <[email protected]> " end |
.backfill_vault(opts = {}) ⇒ Object
- Supported Method Parameters
r = PWN::Migrate.backfill_vault( key: 'optional', iv: 'optional', dry_run: false, io: $stdout )
Decrypt → deep-merge template UNDER user (never overwrites) →
re-encrypt with the SAME key/iv. This is the fix for the #1
upgrade break: NoMethodError: undefined method '[]' for nil when
a release added env[:ai][:new_engine][:…] and the user's vault
predates it.
460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 |
# File 'lib/pwn/migrate.rb', line 460 public_class_method def self.backfill_vault(opts = {}) io = opts[:io] || $stdout dry_run = opts[:dry_run] ? true : false d = vault_drift(key: opts[:key], iv: opts[:iv]) unless d io.puts ' vault : skipped (no decryptor available — run with --pwn-dec or set key:/iv:)' return nil end if d[:missing].empty? io.puts " vault : #{OK} (no missing keys vs PWN::Config.env_template)" return { rel: 'pwn.yaml', added: 0, missing: [] } end io.puts " vault : #{WARN} #{d[:missing].length} missing key(s) — backfilling under user values" d[:missing].first(8).each { |p| io.puts " + #{p}" } io.puts " … (+#{d[:missing].length - 8} more)" if d[:missing].length > 8 return { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing], dry_run: true } if dry_run merged = deep_defaults(user: d[:user], tmpl: d[:template]) yaml = File.join(PWN_ROOT, 'pwn.yaml') key, iv = decryptor(opts) # Same colon-stripping default_env uses so the file stays diff-clean. File.write(yaml, YAML.dump(merged).gsub(/^(\s*):/, '\1')) File.chmod(0o600, yaml) PWN::Plugins::Vault.encrypt(file: yaml, key: key, iv: iv) { rel: 'pwn.yaml', added: d[:missing].length, missing: d[:missing] } rescue StandardError => e io.puts " vault : #{BAD} #{e.class}: #{e.message}" nil end |
.check(opts = {}) ⇒ Object
- Supported Method Parameters
PWN::Migrate.check( io: 'optional - IO to write the report to (default $stdout)' )
Human-readable ~/.pwn state doctor. Read-only. Called from
PWN::Setup.check so pwn setup shows this alongside the
native-gem / toolchain report.
332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 |
# File 'lib/pwn/migrate.rb', line 332 public_class_method def self.check(opts = {}) io = opts[:io] || $stdout st = status io.puts "~/.pwn state schema #{st[:schema_installed]} → #{st[:schema_current]} " \ "#{st[:needs_migration] ? "(#{WARN} — run `pwn setup --migrate`)" : "(#{OK})"}" st[:files].each do |f| mark = f[:ok] ? OK : BAD note = f[:ok] ? '' : "→ #{f[:why]} [fix: #{f[:fix]}]" sz = f[:exists] ? human_size(bytes: f[:size]) : '—' io.puts " #{f[:rel].ljust(22)} #{mark} #{sz.to_s.ljust(8)} #{f[:owner].to_s.ljust(30)} #{note}" end unless st[:incompatible].empty? io.puts io.puts "#{st[:incompatible].length} file(s) incompatible with pwn #{st[:pwn_version]} — " \ 'run `pwn setup --migrate --fix` to autofix (a timestamped backup is taken first).' end st rescue StandardError => e raise e end |
.env_template ⇒ Object
- Supported Method Parameters
tmpl = PWN::Migrate.env_template
The canonical current-release pwn.yaml shape, WITHOUT the I/O side-effects of PWN::Config.default_env. Prefers PWN::Config.env_template when that refactor is present.
499 500 501 502 503 504 505 |
# File 'lib/pwn/migrate.rb', line 499 public_class_method def self.env_template return PWN::Config.env_template if PWN::Config.respond_to?(:env_template) # Fallback: intercept default_env's write path. Never reached once # PWN::Config.env_template ships, but keeps Migrate self-sufficient. {} end |
.help ⇒ Object
Display Usage for this Module
725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 |
# File 'lib/pwn/migrate.rb', line 725 public_class_method def self.help puts "USAGE: # Run installed schema and return its result #{self}.installed_schema # Run needed and return its result #{self}.needed? # Run status and return its result #{self}.status # Human-readable ~/.pwn state doctor. Read-only. Called from #{self}.check( io: 'optional - IO to write the report to (default $stdout)' ) # Run run and return its result #{self}.run( fix: 'optional - also autofix incompatible files (default: schema-migrations only)', backup: 'optional - take timestamped backup of ~/.pwn first (default true)', dry_run: 'optional - report what WOULD happen, write nothing (default false)', key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)', iv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)', io: 'optional - IO to write to (default $stdout)' ) # Run vault drift and return its result #{self}.vault_drift( key: 'optional - vault key, iv: optional - vault iv' ) # Run backfill vault and return its result #{self}.backfill_vault( key: 'optional - optional, iv: optional, dry_run: false, io: $stdout', io: 'optional - io value consumed by #backfill_vault', dry_run: 'optional - dry run value consumed by #backfill_vault', iv: 'optional - iv value consumed by #backfill_vault' ) # Run env template and return its result #{self}.env_template # Print the AUTHOR(S) string for this module. #{self}.authors " constants.sort end |
.installed_schema ⇒ Object
- Supported Method Parameters
schema = PWN::Migrate.installed_schema
→ Integer schema number stamped in ~/.pwn/.schema (0 when absent).
272 273 274 275 276 277 278 |
# File 'lib/pwn/migrate.rb', line 272 public_class_method def self.installed_schema return 0 unless File.file?(SCHEMA_FILE) JSON.parse(File.read(SCHEMA_FILE))['schema'].to_i rescue StandardError 0 end |
.needed? ⇒ Boolean
- Supported Method Parameters
bool = PWN::Migrate.needed?
Cheap predicate for PWN::Config.refresh_env — schema stamp only, no per-file probes, so it adds ~0 to launch time.
286 287 288 289 290 |
# File 'lib/pwn/migrate.rb', line 286 public_class_method def self.needed? File.directory?(PWN_ROOT) && installed_schema < SCHEMA_VERSION rescue StandardError false end |
.run(opts = {}) ⇒ Object
- Supported Method Parameters
report = PWN::Migrate.run( fix: 'optional - also autofix incompatible files (default: schema-migrations only)', backup: 'optional - take timestamped backup of ~/.pwn first (default true)', dry_run: 'optional - report what WOULD happen, write nothing (default false)', key: 'optional - vault key (default: read ~/.pwn/pwn.yaml.decryptor)', iv: 'optional - vault iv (default: read ~/.pwn/pwn.yaml.decryptor)', io: 'optional - IO to write to (default $stdout)' )
- backup 2. schema migrations installed→current 3. per-file autofix (when fix:true) 4. pwn.yaml key backfill (when fix:true and decryptor available) 5. stamp .schema.
368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 |
# File 'lib/pwn/migrate.rb', line 368 public_class_method def self.run(opts = {}) fix = opts[:fix] ? true : false dry_run = opts[:dry_run] ? true : false backup = opts.fetch(:backup, true) io = opts[:io] || $stdout FileUtils.mkdir_p(PWN_ROOT) before = status io.puts "PWN::Migrate — pwn #{before[:pwn_version]} · ~/.pwn schema " \ "#{before[:schema_installed]} → #{before[:schema_current]}" \ "#{' (dry-run)' if dry_run}" bpath = nil if backup && !dry_run && File.directory?(PWN_ROOT) bpath = backup! io.puts " backup : #{bpath}" end # ── ordered schema migrations ──────────────────────────────────── applied = [] ((before[:schema_installed] + 1)..SCHEMA_VERSION).each do |n| m = MIGRATIONS[n] next unless m io.puts " migrate : v#{n - 1} → v#{n}" m.call(PWN_ROOT, io) unless dry_run applied << n end # ── per-file autofix ───────────────────────────────────────────── fixed = [] if fix before[:incompatible].each do |f| io.puts " fix : #{f[:rel].ljust(22)} [#{f[:fix]}] #{f[:why]}" next if dry_run fixed << f.merge(result: apply_fix(file: f)) end # pwn.yaml key backfill runs even when the vault verifier passed — # "encrypted" ≠ "has every key this release added". vd = backfill_vault(key: opts[:key], iv: opts[:iv], dry_run: dry_run, io: io) fixed << vd if vd && vd[:added].to_i.positive? end stamp! unless dry_run after = status io.puts io.puts " result : #{after[:incompatible].length} incompatible remaining " \ "(was #{before[:incompatible].length}) · schema now #{after[:schema_installed]}" { backup: bpath, applied_migrations: applied, fixed: fixed, before: before, after: after, dry_run: dry_run } rescue StandardError => e raise e end |
.status ⇒ Object
- Supported Method Parameters
rows = PWN::Migrate.status
Per-file compatibility report — the machine-readable half of
.check. Never writes. Never raises (each verifier is rescued).
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/pwn/migrate.rb', line 298 public_class_method def self.status files = STATE_FILES.map do |rel, | path = File.join(PWN_ROOT, rel) exists = [:kind] == :dir ? File.directory?(path) : File.file?(path) raw = exists && [:kind] != :dir ? safe_read(path: path) : nil v = verify_one(path: path, raw: raw, meta: ) { rel: rel, path: path, owner: [:owner], kind: [:kind], exists: exists, size: exists ? File.size?(path) : nil, ok: v[:ok], why: v[:why], fix: [:fix] } end { root: PWN_ROOT, schema_installed: installed_schema, schema_current: SCHEMA_VERSION, pwn_version: (defined?(PWN::VERSION) ? PWN::VERSION : nil), needs_migration: needed?, files: files, incompatible: files.reject { |f| f[:ok] } } rescue StandardError => e raise e end |
.vault_drift(opts = {}) ⇒ Object
- Supported Method Parameters
drift = PWN::Migrate.vault_drift( key: 'optional - vault key', iv: 'optional - vault iv' )
Deep-diff the user's decrypted pwn.yaml against the current PWN::Config.env_template. → { missing: [dotted.paths], user: … } Returns nil when the decryptor is unavailable (never prompts).
435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/pwn/migrate.rb', line 435 public_class_method def self.vault_drift(opts = {}) key, iv = decryptor(opts) return nil unless key && iv yaml = File.join(PWN_ROOT, 'pwn.yaml') return nil unless File.file?(yaml) && PWN::Plugins::Vault.file_encrypted?(file: yaml) user = PWN::Plugins::Vault.dump(file: yaml, key: key, iv: iv) tmpl = env_template { missing: missing_paths(tmpl: tmpl, user: user), user: user, template: tmpl } rescue StandardError nil end |