package runner import ( "bytes" "context" "errors" "fmt" "os/exec" "path/filepath" "strings" "textmachine/platform/internal/ingest" "textmachine/platform/internal/money" ) // ConfigFile is the engine's per-book configuration, which lives in the book's project directory. // `tmctl` requires --config on every command that touches a book; the platform never edits this // file (D39.110 §2b), it only names it. const ConfigFile = "book.yaml" // CeilingTemplate is how the run's ceiling reaches the engine. // // The flag LANDED as `--ceiling-usd ` (unified backlog row 145, engine commit 0e69bc1, ratified // D39.122), and that form is the configured default. It stays a template because the form belongs to // the engine's CLI and not to this package: an operator running a build whose flag is spelled // differently changes one environment variable instead of waiting for a platform release. // // ⚠ What the number MEANS is the part worth reading before touching any of this. The flag is not a // run budget: it overrides the book's own `ceilings.book_usd` and the engine compares it against the // book's CUMULATIVE committed + reserved on every reservation it takes. Converting the user's // increment into that absolute is the platform's duty and lives in runs.meter.bookCap. // // {{usd}} expands to the amount as a plain decimal number of dollars. type CeilingTemplate []string // ErrCeilingNotWired is a run that cannot be started because the engine has no way to be told its // ceiling. A refusal, deliberately: the alternative is a paid run whose limit is the book's own, // which is a different and possibly much larger number than the money the account reserved. // // Rare since the flag landed: the template has a default, and an override has to be actively made // useless to reach this — an unset variable takes the default, but one holding only whitespace does // not, and renders an empty template. Kept because the type admits the empty value at all, and a nil // template must never mean "no limit". var ErrCeilingNotWired = errors.New("runner: the engine's ceiling argument is not configured (row 145)") // ParseCeilingTemplate reads the template from its configured form: argv separated by whitespace, // with {{usd}} standing for the amount. Empty is legal and means "not wired yet". func ParseCeilingTemplate(s string) (CeilingTemplate, error) { fields := strings.Fields(s) if len(fields) == 0 { return nil, nil } if !strings.Contains(s, "{{usd}}") { return nil, fmt.Errorf("runner: ceiling template %q does not contain {{usd}}", s) } return fields, nil } // Args renders the template for one amount. func (t CeilingTemplate) Args(ceiling money.MicroUSD) ([]string, error) { if len(t) == 0 { return nil, ErrCeilingNotWired } if ceiling <= 0 { // R7 forbids a ledger with no ceiling, and the engine refuses a non-positive one. Catching it // here means the refusal names the platform's own arithmetic instead of arriving as an engine // start-up error hours later in a log nobody reads. return nil, fmt.Errorf("runner: ceiling must be positive, got %d micro-USD", ceiling) } usd := ceiling.USD() out := make([]string, len(t)) for i, a := range t { out[i] = strings.ReplaceAll(a, "{{usd}}", usd) } return out, nil } // TranslateArgs is the engine invocation for a run. // // `--config` is not optional and its absence is not tolerated: tmctl requires it on every book // command, so a call without it fails at argument parsing and never reaches the book. func TranslateArgs(workdir string, verifyBank bool, ceiling []string) []string { args := []string{"translate", "--config", filepath.Join(workdir, ConfigFile)} if verifyBank { args = append(args, "--verify-bank") } return append(args, ceiling...) } // StatusArgs is the reconciliation channel: `tmctl status --json` on a run that is not moving. // // Read-only and free of money, but NOT free of CPU — every call re-ingests and re-chunks the source // (~1.4-1.5 s on a 23 MB book, unified backlog row 100), so it is a repair path and a slow poll, // never a live feed. func StatusArgs(workdir string) []string { return []string{"status", "--config", filepath.Join(workdir, ConfigFile), "--json"} } // Status runs the reconciliation channel and decodes the allowlisted subset of its report. func (r *Runner) Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) { cmd := exec.CommandContext(ctx, binary, StatusArgs(workdir)...) cmd.Dir = workdir var out, errOut bytes.Buffer cmd.Stdout, cmd.Stderr = &out, &errOut if err := cmd.Run(); err != nil { // The engine's stderr is engine vocabulary and must not become a platform log line verbatim; // what travels is the fact of the failure and the first line, which is where the reason is. return ingest.StatusReport{}, fmt.Errorf("runner: tmctl status: %w: %s", err, firstLine(errOut.Bytes())) } return ingest.DecodeStatus(out.Bytes()) } func firstLine(b []byte) []byte { if i := bytes.IndexByte(b, '\n'); i >= 0 { return b[:i] } return b }