252 lines
10 KiB
Go
252 lines
10 KiB
Go
// tmctl is the TextMachine CLI: translate / report / status / redrive / 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)
|
||
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)
|
||
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|backup|seed-lint)", inv.cmd)
|
||
}
|
||
}
|
||
|
||
// translate runs the book. The two money flags are ORTHOGONAL (D20.2-Q2): --resnapshot grants
|
||
// permission to re-pin jobs onto the current config snapshot, while --accept-rebill[=usd] consents to
|
||
// the AMOUNT that re-pin would re-pay. 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) 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
|
||
|
||
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)
|
||
}
|
||
|
||
// 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.
|
||
func redrive(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, sel pipeline.RedriveSelector, verifyBank bool) 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
|
||
|
||
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)
|
||
})
|
||
}
|