textmachine/backend/cmd/tmctl/main.go

382 lines
17 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.

// tmctl is the TextMachine CLI: translate / report / status / export / redrive / manifest / backup.
// main.go — thin wiring (package №4): argument parsing — invocation.go,
// output renderers — render.go, .env — dotenv.go; here just the
// «parse → env → ctx → fetch → render» wiring and exit-code mapping.
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/store"
)
func main() {
err := run()
if err != nil {
fmt.Fprintln(os.Stderr, "tmctl:", err)
}
os.Exit(exitCode(err))
}
// The refusal band. A code in [refusalFirst, refusalLast] means the invocation was TURNED DOWN before
// it did any work of its own — nothing reached a provider, nothing was spent, nothing was written — and
// the particular number names why.
//
// It is a BAND and not a list because the vocabulary is the engine's and it will grow: a caller looks up
// the number, and a class this build of the caller has never heard of still lands in the band and reads
// as "refused" rather than as "failed". That difference is the whole of PD-196 — the platform's intake
// rejects a book as `source_unreadable` after five failures and deletes the upload, and every refusal
// used to arrive as the same exit 1, so an operator's typo in a hand-written book.yaml was one step from
// destroying a user's file. A new class is a new constant here and in pipeline.RefusalClass; no consumer
// has to enumerate them to stay safe.
const (
refusalFirst = 10
refusalLast = 19
exitConfigInvalid = 10 // the configuration will not run: unreadable, unparseable, invalid, no key
exitSourceUnreadable = 11 // the BOOK's source cannot be read or decoded — the one class about the text
exitProjectLocked = 12 // another tmctl owns this project right now; come back later
exitRefusedOther = 19 // a refusal class this build of tmctl has no number for
)
// refusalExit is the dictionary the band is read through.
var refusalExit = map[pipeline.RefusalClass]int{
pipeline.RefusalBadConfig: exitConfigInvalid,
pipeline.RefusalSourceUnreadable: exitSourceUnreadable,
pipeline.RefusalProjectLocked: exitProjectLocked,
}
// exitCode maps a run() error onto the ratified shell contract (Milestone 2 / R1-FL-A):
//
// 0 clean
// 1 infra failure and everything else, including a flag-parse error
// 2 completed-with-flags (acceptance allows N flags; the typed *pipeline.CompletedWithFlags,
// which survives %w-wraps via errors.As)
// 3 bank-mining signature stop (*pipeline.WaveSignatureStop — the run paused before the edit wave
// for owner sign; a DELIBERATE human-in-the-loop halt, not a crash)
// 4 ceiling halt (*pipeline.CeilingHalt — the book or day USD ceiling stopped the run)
// 5 graceful stop (SIGINT/SIGTERM was caught and the run wound down)
// 10-19 refusal band, see above
//
// 3 is DISTINCT from both 2 (flagged chunks) and 1 (crash) so exit-code automation can tell a
// sign-boundary pause from a real failure — a human sees the stderr text either way, but a script that
// shipped "translate && export" must NOT treat the pause as success (exit 0) nor as an infra crash.
//
// 4 and 5 exist for the same reason one level up, and they are money (row 165). A supervisor learns how
// a run ended from its exit code, so while a ceiling halt and a caught SIGTERM both mapped to 1 the
// platform recorded `failed` for both — for a stop its own contract calls `paused` and forbids calling
// `failed` (PD-113), and for the user's own stop and for an ordinary host reboot (PD-152). Since the
// ceiling argument is now set flush against the hold (PD-158), "the money ran out" and "the machine
// broke" were the same number at exactly the moment they became opposite facts.
//
// 5 reads a CANCELLED context because in tmctl the run context is cancelled by signal.NotifyContext and
// by nothing else (see run): a cancelled run is one somebody asked to stop. It is checked after the
// typed sentinels, so a run that reached its own terminal state before the signal keeps its own code.
func exitCode(err error) int {
var flagged *pipeline.CompletedWithFlags
var sigStop *pipeline.WaveSignatureStop
var ceiling *pipeline.CeilingHalt
var refusal *pipeline.Refusal
switch {
case err == nil:
return 0
case errors.As(err, &sigStop):
return 3
case errors.As(err, &flagged):
return 2
case errors.As(err, &ceiling):
return 4
case errors.As(err, &refusal):
if code, ok := refusalExit[refusal.Class]; ok {
return code
}
return exitRefusedOther
case errors.Is(err, context.Canceled):
return 5
default:
return 1
}
}
// traceID is the identity of this invocation: its log axis, its request_log column, and — since the
// event seam exists — the `engine_run_id` half of the ratified idempotency key (engine_run_id, seq).
//
// It is ACCEPTED from the environment (row 102) and minted only when none was given. A run the platform
// spawned should be ONE trace end to end, and letting the caller name it means the idempotency namespace
// of the stream is the caller's by construction rather than something it has to learn from a handshake
// (the reader's own comment anticipates exactly this). The environment and not a flag: argv is the one
// channel this zone keeps deliberately free of run identity (PD-99), and a supervisor already has an
// Env for the unit.
//
// A value it cannot use is REFUSED rather than trimmed into something else: an id is compared, joined
// and stored, so silently correcting it would make two runs share a namespace. The shape is what a log
// axis and a database key can both carry — printable, no spaces, bounded.
func traceID() string {
v := os.Getenv("TM_TRACE_ID")
if v == "" {
return obs.NewTraceID()
}
if len(v) > 64 {
fmt.Fprintf(os.Stderr, "tmctl: TM_TRACE_ID is %d characters (max 64) — minting one instead\n", len(v))
return obs.NewTraceID()
}
for _, c := range v {
if c <= ' ' || c > '~' {
fmt.Fprintln(os.Stderr, "tmctl: TM_TRACE_ID must be printable ASCII without spaces — minting one instead")
return obs.NewTraceID()
}
}
return v
}
func run() error {
inv, err := parseInvocation(os.Args[1:], os.Stderr)
if err != nil {
return err
}
loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env"), os.Stderr)
loadDotEnv(".env", os.Stderr)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// LogBodies is decided once at admission (privacy by default): the content
// of LLM exchanges reaches the logs only when LOG_LLM_BODIES=1 AND LOG_LEVEL=debug.
ctx = obs.WithReqInfo(ctx, obs.ReqInfo{
TraceID: traceID(),
LogBodies: os.Getenv("LOG_LLM_BODIES") == "1",
})
switch inv.cmd {
case "translate":
// F4 (row 83): back up + integrity-check the project DB before a paid run starts. This lives HERE
// (the boevoy command dispatch), not in translate()/the Runner, so a fake-provider test that drives
// those directly never triggers it — no provider-name discriminator needed.
if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil {
return err
}
return translate(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.verifyBank, inv.ceilingUSD)
case "report":
return report(inv.cfgPath)
case "status":
return status(ctx, inv.cfgPath, inv.asJSON)
case "export":
return export(inv.cfgPath, inv.asPlaintext, inv.asPairs)
case "redrive":
// A dry-run redrive spends nothing and mutates nothing; only a real redrive re-attacks and re-bills.
if !inv.sel.DryRun {
if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil {
return err
}
}
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.sel, inv.verifyBank, inv.ceilingUSD)
case "manifest":
return manifestCmd(inv.cfgPath, inv.asJSON)
case "backup":
return backupCmd(inv.cfgPath, os.Stdout)
case "seed-lint":
return seedLint(inv.seedPath)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|manifest|backup|seed-lint)", inv.cmd)
}
}
// translate runs the book. The three money flags are ORTHOGONAL (D20.2-Q2 / row 145): --resnapshot
// grants permission to re-pin jobs onto the current config snapshot, --accept-rebill[=usd] consents to
// the AMOUNT that re-pin would re-pay, and --ceiling-usd replaces the BOOK ceiling for this process.
//
// ⚠ --ceiling-usd is NOT a per-run budget. The ledger admits against the book's CUMULATIVE spend
// (store/ledger.go: `bookTotal + estimate > BookUSD`, where bookTotal is committed+reserved across every
// run of the book), so the flag says "this book may reach $X while I am running", not "this run may
// spend $X". A caller that wants to grant an increment has to pass already-committed + increment, and a
// value below what the book has already spent denies the very first reservation.
//
// Over the book's consent threshold the run stops with the sum before reserving anything — a flag that
// names no money cannot carry a Р6 consent to a spend.
func translate(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, verifyBank bool, ceilingUSD float64) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
r.VerifyBank = verifyBank
r.CeilingUSD = ceilingUSD
res, err := r.TranslateBook(ctx)
if err != nil {
// R1-FL-A: the bank-mining signature stop is a typed sentinel, not an infra failure — render the
// operator's next steps to stdout (the bank table + the signature-map path + resume guidance) and
// return it so main() maps it to the distinct exit code 3 (the Error() text also prints to stderr).
var sigStop *pipeline.WaveSignatureStop
if errors.As(err, &sigStop) {
renderSignatureStop(os.Stdout, sigStop)
// …and the MONEY (S17): the draft wave the pause sits behind is already paid for, and a stop
// that reports zero about it leaves the operator guessing what the run has cost so far.
if committed, reserved, lerr := r.Store.SpentUSD(r.Book.BookID); lerr == nil {
fmt.Fprintf(os.Stdout, "Book ledger so far: committed=$%.6f reserved=$%.6f\n", committed, reserved)
}
}
return err
}
return renderTranslate(os.Stdout, res, func() (float64, float64, error) {
return r.Store.SpentUSD(res.BookID)
})
}
// seedLint validates a glossary seed YAML (a manual seed or the emitted mined delta) through the REAL
// loadGlossarySeed fail-louds + the shared-key collision check (WS3). $0, no store, no provider keys — a
// pre-flight the operator runs on the mined delta before the W1.5 reseed. Prints "OK" on a clean seed.
func seedLint(seedPath string) error {
if err := membank.SeedLint(seedPath); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "seed-lint OK: %s is loadable (0 fail-louds, 0 shared-key collisions)\n", seedPath)
return nil
}
func report(cfgPath string) error {
// Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
// Reads via callbacks (interleaved with printing, as historically): a read failure
// mid-audit leaves the already-printed part on stdout (see renderReport).
if err := renderReport(os.Stdout,
func() ([]store.RequestLogView, error) { return r.Store.RequestLogRows(r.Book.BookID) },
func() ([]store.ChunkStatus, error) { return r.Store.ChunkStatusesForBook(r.Book.BookID) },
func() ([]store.RetrievalState, error) { return r.Store.RetrievalStatesForBook(r.Book.BookID) },
func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) }); err != nil {
return err
}
// Per-run quality-report (D39 layer 5): aggregate the deterministic quality signals into a readable
// summary — the "telemetry from day one" the owner asked for (H5). Read-only ($0), never a gate.
q, err := r.QualityReport()
if err != nil {
return err
}
return renderQuality(os.Stdout, q)
}
// export prints the READ-ONLY export projection (D39 layer 6 / D39.2-open №1): every chunk's FINAL
// export text EXACTLY by prod semantics (final_hash → checkpoint → exportNormalize), so the polygon
// extractor / reader-samples read the SAME bytes the backend ships instead of the raw checkpoint. Like
// report/status it is $0 and needs no provider keys (D20.4 — an audit surface must not demand keys).
// Default output is stable JSON (the machine surface); --plaintext emits the human concatenation.
func export(cfgPath string, asPlaintext, asPairs bool) error {
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
exp, err := r.Export(asPairs)
if err != nil {
return err
}
return renderExport(os.Stdout, exp, asPlaintext)
}
// status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the
// StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human
// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2
// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer
// never reads a clean 0 over a book that still needs attention.
func status(ctx context.Context, cfgPath string, asJSON bool) error {
// Read-only ($0): no API keys required (D20.4 — a progress projection must not demand keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
rep, err := r.Status(ctx)
if err != nil {
return err
}
if asJSON {
return renderStatusJSON(os.Stdout, rep)
}
return renderStatusHuman(os.Stdout, rep, cfgPath)
}
// manifestCmd rebuilds and persists the book's chapter/chunk manifest (backlog row 100). $0 and key-less
// like the other read commands: it ingests + cuts the source and writes a sidecar beside the project DB —
// no LLM, no provider key, no wave.
//
// It opens the store exactly as `status` does (NewReadOnlyRunner): read-only, no flock — EXCEPT on the
// first touch of a project, where that path falls back to a full Open and therefore CREATES and migrates
// the database. That is the state this command is most often run in ("parsed, never run"), so the
// side effect is the common case rather than the corner one, and it is the same one `status` has always
// had. It also needs the project directory to be writable, unlike status/report/export.
//
// It exists because the manifest is needed BEFORE any run: a book that has been accepted and cut but
// never translated still has a chapter tree, and every other producer of that structure is a paid path.
// A translate rewrites the manifest itself (bookrun.go), so this command is for the not-yet-run case and
// for refreshing the artifact by hand after a source edit.
func manifestCmd(cfgPath string, asJSON bool) error {
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
m, err := r.BuildAndPersistManifest()
if err != nil {
return err
}
return renderManifest(os.Stdout, m, r.ManifestPath(), asJSON)
}
// redrive re-attacks the FLAGGED chunks matching the selector (D15.3): it resets their terminal
// flag (chunk_status + checkpoints of the flagged/skipped stages) and re-runs the durable loop
// with a FRESH retry/escalation budget, never touching DispOK work. On --dry-run it only reports
// the plan. Redrive-calls bill normally; money already spent on the discarded attempts stays
// committed (honest). By default it requires the current config to render the SAME snapshot the
// flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips
// that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but
// silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it).
// --accept-rebill[=usd] is the separate consent to the AMOUNT such a re-pin re-pays (D20.2-Q2): over
// the threshold Redrive refuses BEFORE its destructive reset, so the flag telemetry survives a refusal.
// --ceiling-usd applies here for the same reason it applies to translate: a redrive re-attacks flagged
// chunks with real provider calls, so leaving it on the book's ceiling alone would be a hole in exactly
// the surface row 145 exists to close.
func redrive(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, sel pipeline.RedriveSelector, verifyBank bool, ceilingUSD float64) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
r.VerifyBank = verifyBank
r.CeilingUSD = ceilingUSD
summary, res, err := r.Redrive(ctx, sel)
if err != nil {
// A redrive can hit the bank-mining stop too (it re-runs the durable loop), and its reset is
// DESTRUCTIVE and already done by then — so a bare error text left the operator with reset rows,
// exit 3 and no idea why (S17). Print the same banner translate does.
var sigStop *pipeline.WaveSignatureStop
if errors.As(err, &sigStop) {
renderSignatureStop(os.Stdout, sigStop)
}
return err
}
return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) {
return r.Store.SpentUSD(r.Book.BookID)
})
}