textmachine/backend/cmd/tmctl/migrate.go
2026-08-15 17:39:09 +03:00

108 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<stamp>.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
}