500 lines
24 KiB
Go
500 lines
24 KiB
Go
// tmctl is the TextMachine CLI: translate / report / status / export / build / redrive / manifest /
|
||
// backup / migrate / seed-lint / bank-apply (dispatchCommands).
|
||
// 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"
|
||
"io"
|
||
"os"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"strings"
|
||
"syscall"
|
||
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/obs"
|
||
"textmachine/backend/internal/pipeline"
|
||
"textmachine/backend/internal/store"
|
||
)
|
||
|
||
func main() {
|
||
os.Exit(exitOf(os.Stderr, run))
|
||
}
|
||
|
||
// exitOf runs the command behind a recover so a panic of the MAIN goroutine leaves through the exit
|
||
// contract rather than the runtime's handler (obs.PanicError). Worker goroutines carry their own recover
|
||
// at the wave seam (pipeline.runWave); this covers everything else, from parsing to the renderers.
|
||
//
|
||
// `diag` is the diagnostics sink (main passes os.Stderr, tests a buffer) for the same reason
|
||
// parseInvocation takes one: what reaches stderr on a crash is the contract, so it is asserted rather
|
||
// than assumed. os.Exit is the CALLER's — it skips deferred functions, including this recover.
|
||
func exitOf(diag io.Writer, body func() error) (code int) {
|
||
defer func() {
|
||
if p := recover(); p != nil {
|
||
err := obs.NewPanicError("tmctl", p)
|
||
fmt.Fprintln(diag, "tmctl:", err)
|
||
code = exitCode(err)
|
||
}
|
||
}()
|
||
err := body()
|
||
if err != nil {
|
||
fmt.Fprintln(diag, "tmctl:", err)
|
||
}
|
||
return exitCode(err)
|
||
}
|
||
|
||
// The refusal band. A code in [refusalFirst, refusalLast] means the invocation was TURNED DOWN:
|
||
// nothing reached a provider, nothing was spent, no work needs rolling back and a retry is safe — and
|
||
// the particular number names why. «Nothing was written» is NOT the band's promise any more, it is a
|
||
// clause of the individual classes: exit 15 (write incomplete) legitimately answers with files on disk,
|
||
// and its report's `written_delta`/`written_rejects` carry which. A consumer keys a destructive action
|
||
// on a CLASS it knows, never on band membership (pipeline/refusal.go — the two-tier letter).
|
||
//
|
||
// 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
|
||
exitSchemaMismatch = 13 // this project's schema is not this binary's: run `tmctl migrate` (row 174)
|
||
exitDecisionsRejected = 14 // the decision document was read and declined; the USER re-decides (D39.156)
|
||
exitWriteIncomplete = 15 // the document was accepted and the write did not complete; the report says which file landed — re-send the same document
|
||
exitBookIncomplete = 16 // `build` refused to write a book with holes; the message lists them — finish the book, or ask for the marked copy with --partial
|
||
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,
|
||
pipeline.RefusalSchemaMismatch: exitSchemaMismatch,
|
||
pipeline.RefusalDecisionsRejected: exitDecisionsRejected,
|
||
pipeline.RefusalWriteIncomplete: exitWriteIncomplete,
|
||
pipeline.RefusalBookIncomplete: exitBookIncomplete,
|
||
}
|
||
|
||
// 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 and a recovered PANIC
|
||
// (*obs.PanicError, row 176)
|
||
// 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
|
||
var panicked *obs.PanicError
|
||
switch {
|
||
case err == nil:
|
||
return 0
|
||
case errors.As(err, &panicked):
|
||
// A crash is 1, not a new number: both bands are frozen seams and a fresh code would be a word
|
||
// added to a ratified dictionary. Checked FIRST so a panic cannot be read as one of the
|
||
// deliberate stops below even if a layer joined it with one.
|
||
return 1
|
||
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
|
||
}
|
||
|
||
// dispatchCommands is the list the switch in run() dispatches, in usage order. It exists as ONE list
|
||
// because the usage line and the unknown-command hint were two hand-maintained copies of it, and the
|
||
// usage line fell three commands behind (row 176) while the hint stayed current.
|
||
var dispatchCommands = []string{
|
||
"translate", "report", "status", "export", "build", "redrive", "manifest", "backup", "migrate", "seed-lint",
|
||
"bank-apply",
|
||
}
|
||
|
||
func run() error {
|
||
inv, err := parseInvocation(os.Args[1:], os.Stderr)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
// KEY CHAIN, in precedence order. The DEPLOYMENT's file is first and therefore wins, because
|
||
// loadDotEnv never overrides a variable that is already set: explicit beats convention, and the stand
|
||
// books that pass no flag keep the behaviour they have always had. It is also the only link that can
|
||
// FAIL — a file a caller named and that cannot be read is a deployment fault, not a missing option,
|
||
// and the alternative is a paid run that dies at its first provider call (backlog row 211).
|
||
if inv.keysFile != "" {
|
||
if err := loadKeysFile(inv.keysFile, os.Stderr); err != nil {
|
||
return pipeline.RefuseConfig(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, inv.maxUnits)
|
||
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 "build":
|
||
return build(inv.cfgPath, inv.buildFormats, inv.buildOut, inv.buildPartial)
|
||
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 "migrate":
|
||
return migrateCmd(inv.cfgPath, os.Stdout)
|
||
case "seed-lint":
|
||
return seedLint(inv.seedPath)
|
||
case "bank-apply":
|
||
return bankApply(ctx, os.Stdout, inv.cfgPath, inv.decisionsPath, inv.dryRun)
|
||
default:
|
||
return fmt.Errorf("unknown command %q (want %s)", inv.cmd, strings.Join(dispatchCommands, "|"))
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// --max-units is the FOURTH flag and the second CEILING, and it is orthogonal to --ceiling-usd in the way
|
||
// that matters most (D39.165 §1б): that one bounds the book's CUMULATIVE money, this one bounds THIS
|
||
// run's WORK, in output units — the granularity the manifest publishes and a purchase of chapters
|
||
// converts into exactly. Both can be in force; whichever binds first stops the run, and the two stops are
|
||
// different answers. Money stops the run in the middle of what it meant to do — exit 4, resumable, "add
|
||
// money". Volume means the run did precisely what was bought — exit 0, an ordinary completion, and the
|
||
// exit-code dictionary above is not touched. Which one stopped it is said in the run's report and its log
|
||
// (pipeline.VolumeStop), never by a new code: the platform reads an unknown exit code as a FAILURE, so a
|
||
// user who received exactly what they paid for would have been shown a service error.
|
||
func translate(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, verifyBank bool, ceilingUSD float64, maxUnits int) 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
|
||
r.MaxUnits = maxUnits
|
||
|
||
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)
|
||
}
|
||
}
|
||
// …and the SPEND CEILING is now a stop with a result behind it (backlog row 277): every admitted
|
||
// call is finished before the run departs, so there is delivered book to account for. Printing it
|
||
// is not decoration — the operator reading the ceiling line otherwise learns that the run stopped
|
||
// and nothing about what it produced on the way, which reads as «nothing happened» and is the
|
||
// state this pack exists to end.
|
||
// …and a run somebody STOPPED gets its own account, because it is the one exit with nothing to
|
||
// print: the driver returns no result on a cancellation, every renderer here takes one, and the
|
||
// operator was left with a line of error text about a run that may have translated half a book
|
||
// (backlog row 389). What is printed comes from the STORE — see renderStoppedRun.
|
||
if errors.Is(err, context.Canceled) {
|
||
renderStoppedRun(os.Stdout, r.Book.BookID,
|
||
func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) },
|
||
func() ([]store.ChunkStatus, error) { return r.Store.ChunkStatusesForBook(r.Book.BookID) })
|
||
}
|
||
var ceiling *pipeline.CeilingHalt
|
||
if errors.As(err, &ceiling) && res != nil {
|
||
if rerr := renderTranslate(os.Stdout, res, func() (float64, float64, error) {
|
||
return r.Store.SpentUSD(r.Book.BookID)
|
||
}); rerr != nil {
|
||
// The ceiling stop is the verdict; a failure to PRINT its ledger must not replace it, or
|
||
// the caller would be handed exit 1 for a run that stopped on money and is resumable.
|
||
fmt.Fprintf(os.Stderr, "tmctl: could not render the partial ledger of the ceiling stop: %v\n", rerr)
|
||
}
|
||
}
|
||
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)
|
||
}
|
||
|
||
// build writes the READER's copy of the book (backlog row 236): EPUB and/or plain text from the same
|
||
// export projection `export` prints, beside the project database (pipeline.BuildBook). $0 and key-less
|
||
// like export — it reads the store and writes a file; no provider is called. A book with any hole is
|
||
// refused (exit 16, the holes listed) unless --partial asks for the marked copy. Its own verb rather than a
|
||
// flag on `export`: `export` is a pure read whose stdout IS its output, and a command that writes files
|
||
// beside the database is a different thing to script around (the `manifest` precedent).
|
||
func build(cfgPath string, formats []string, out string, partial bool) error {
|
||
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer r.Close()
|
||
|
||
rep, err := r.BuildBook(pipeline.BuildOptions{Formats: formats, Out: out, Partial: partial})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return renderBuild(os.Stdout, rep)
|
||
}
|
||
|
||
// 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)
|
||
})
|
||
}
|