package main // migrate.go: the $0 `tmctl migrate` verb — a project's WRITE open with no run behind it (backlog // row 174). // // The deadlock it breaks: the read-only commands (status, report, export, manifest) demand this // binary's exact schema and never migrate — only a write open applies the chain. So upgrading the // engine binary locks every existing book at once: the platform calls `tmctl status --json` before each // spawn and for the money, that call refuses the older schema, and the write command that WOULD have // migrated the project never comes. The deploy step is "stop the runs → put the NEW binary in place → // run THIS command over the books with it → let the runs start again", and a caller that meets exit 13 // in flight can also self-heal from it. // // ⚠ WHICH books: the sweep belongs on projects with no OPEN attempt. A migrated project is above the // schema of every older binary, and the platform settles a finished attempt with the binary that // attempt was PINNED to — so migrating underneath an attempt whose settle has not happened yet would // trade this deadlock for a hold that can never be closed. The engine has no notion of an attempt, so // that rule lives in the caller's sweep (row 175); what the engine guarantees is that a project a live // run holds is refused rather than migrated (exit 12). // // It deliberately loads the BOOK config only. A migration touches no prompt, no provider and no price, // and pulling in the rest of the stack would drag its gates onto a $0 path — `prices_checked` refuses a // models.yaml whose prices are over 120 days old (backlog row 146), and an engine deployed onto a stand // with stale prices would then meet the deadlock again in exactly the shape this command exists to // remove. Same reason the command needs no provider key: it is an operator safety verb, like `backup`. import ( "errors" "fmt" "io" "io/fs" "os" "time" "textmachine/backend/internal/config" "textmachine/backend/internal/pipeline" "textmachine/backend/internal/store" ) // migrateSuffix names what the restore point below is FOR, and it is what keeps this command out of the // paid path's namespace. `translate`'s pre-flight guard writes backups/.db with a stamp of // second precision, and BackupSQLite refuses to overwrite an existing one (backlog row 173) — so // sharing the name would make the very sequence this command enables (migrate, then start the run it // unblocked) fail inside the pre-flight guard whenever both land in the same second, turning a healthy // deploy into exit 1 before anything reached a provider. const migrateSuffix = "-pre-migrate" // migrateCmd brings the book's project database to this binary's schema and reports the transition. // $0: no LLM call, no network, no ledger movement — the only writes are the migration steps themselves // and the stale-reservation recovery every write open performs. // // Idempotent: a project already at head is opened, found current and closed — no step applied, no // restore point taken, no money touched. func migrateCmd(cfgPath string, w io.Writer) error { book, err := config.LoadBook(cfgPath) if err != nil { // Like the pre-flight backup guard, this runs before any runner exists, so it is the first thing // a broken config meets — and a refusal classified here rather than collapsed onto exit 1 is the // whole of PD-196 (see pipeline/refusal.go). return pipeline.RefuseConfig(err) } // Only ABSENT means "never run": a database this call is about to CREATE has no history to protect, // and it is the one case that gets no restore point. Any other stat error read that way would skip // the guard on a file that is there. dbExists := true if _, err := os.Stat(book.ProjectDB); err != nil { if !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("tmctl migrate: cannot stat the project database %s: %w", book.ProjectDB, err) } dbExists = false } // The restore point is taken INSIDE the project lock, and only when a step is actually going to run // (store.Migrate's seam). A migration is the one $0 operation that rewrites the file holding the // owner's signed bank, the checkpoints and the ledger, and the ALTER steps do not converge on a // half-applied database (row 49а) — a damaged file is not something a re-run repairs. Taking it // before the lock instead would copy the whole database of a project a live run owns and then refuse // the migration anyway, once per retry. backup := func(m store.Migration) error { if !dbExists { return nil } path, err := store.BackupSQLite(book.ProjectDB, backupDirFor(book.ProjectDB), backupStamp(time.Now())+migrateSuffix) if err != nil { return fmt.Errorf("tmctl migrate: refusing to migrate a project it could not back up first: %w", err) } fmt.Fprintf(w, "pre-migrate: backed up the project DB at schema v%d to %s (integrity_check green)\n", m.From, path) return nil } applied, err := store.Migrate(book.ProjectDB, backup) if err != nil { // The two classified open failures a caller acts on: another tmctl holds the project (come back // later, exit 12) and a database NEWER than this binary (upgrade tmctl, exit 13 — a migration // cannot repair that direction, so a caller looping on "migrate and retry" has to be told). return pipeline.RefuseStoreOpen(err) } switch { case !dbExists: fmt.Fprintf(w, "migrate: created %s at schema v%d\n", book.ProjectDB, applied.To) case applied.From == applied.To: fmt.Fprintf(w, "migrate: %s already at schema v%d\n", book.ProjectDB, applied.To) default: fmt.Fprintf(w, "migrate: %s schema v%d -> v%d\n", book.ProjectDB, applied.From, applied.To) } return nil }