textmachine/backend/cmd/tmctl/main.go

224 lines
8.8 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 / redrive.
// 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"))
loadDotEnv(".env")
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":
return translate(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill)
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":
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.sel)
case "seed-lint":
return seedLint(inv.seedPath)
default:
return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|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) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
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 (terms + 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)
}
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) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
summary, res, err := r.Redrive(ctx, sel)
if err != nil {
return err
}
return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) {
return r.Store.SpentUSD(r.Book.BookID)
})
}