textmachine/backend/cmd/tmctl/main.go

295 lines
13 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))
}
// exitCode maps a run() error onto the ratified shell contract (Milestone 2 / R1-FL-A): 0 clean · 2
// completed-with-flags (acceptance allows N flags; a typed sentinel
// *pipeline.CompletedWithFlags, 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) · 1 infra failure and everything else,
// including a flag-parse error. 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 (exit 1).
func exitCode(err error) int {
var flagged *pipeline.CompletedWithFlags
var sigStop *pipeline.WaveSignatureStop
switch {
case err == nil:
return 0
case errors.As(err, &sigStop):
return 3
case errors.As(err, &flagged):
return 2
default:
return 1
}
}
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: obs.NewTraceID(),
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)
})
}