108 lines
5.6 KiB
Go
108 lines
5.6 KiB
Go
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
|
||
}
|