269 lines
13 KiB
Go
269 lines
13 KiB
Go
package runner
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"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 <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"}
|
||
}
|
||
|
||
// ManifestArgs is the intake channel: `tmctl manifest --config <book.yaml> --json`.
|
||
//
|
||
// $0 and key-less like `status` — it ingests and cuts the source and writes a sidecar, with no
|
||
// provider call and no wave — and it is the ONE engine command the platform runs before a book has
|
||
// ever been translated. ⚠ Its first touch of a project CREATES and migrates the engine's database
|
||
// (ratified D39.122), which is why intake needs the book's directory writable and why it is this
|
||
// command rather than `status`: `status` has the same side effect and answers a different question.
|
||
func ManifestArgs(workdir string) []string {
|
||
return []string{"manifest", "--config", filepath.Join(workdir, ConfigFile), "--json"}
|
||
}
|
||
|
||
// ExportArgs is the TEXT channel: `tmctl export --config <book.yaml> --json --pairs`.
|
||
//
|
||
// $0 and key-less like the other two — a pure read projection of the persisted rows joined onto the
|
||
// source cut — and the only channel that carries a pair's source and translation at all. `--pairs`
|
||
// is what adds the SOURCE column, and it costs the full re-chunk: the manifest alone carries no
|
||
// text, so a run without the flag would answer the reading surface with an empty source for every
|
||
// pair.
|
||
//
|
||
// ⚠ Run at the BOUNDARIES of the work only. The engine holds its project exclusively while a run is
|
||
// going, and the contract declares exactly this freshness for a pair's translation: updated at the
|
||
// boundaries and at stops, never continuously.
|
||
func ExportArgs(workdir string) []string {
|
||
return []string{"export", "--config", filepath.Join(workdir, ConfigFile), "--json", "--pairs"}
|
||
}
|
||
|
||
// maxManifest bounds what the platform will read from the manifest command. The document is counts
|
||
// and identities — no source text — so a real one is single-digit megabytes even for a 2283-chapter
|
||
// book; the cap is against a process that is not the engine, not against the engine.
|
||
const maxManifest = 64 << 20
|
||
|
||
// maxExport bounds the export document, and it is the largest of the three because it is the only
|
||
// one that carries TEXT — both columns of a whole book, tens of megabytes on a corpus book
|
||
// (PLATFORM_DIRECTION §5б). Still a refusal of a process that is not the engine.
|
||
const maxExport = 512 << 20
|
||
|
||
// Manifest builds the book's chapter tree and returns the allowlisted summary of it.
|
||
//
|
||
// It reads the SAME rule as Status about exit 2 — the command completed and something in the book
|
||
// needs a human — and not because the manifest renderer can produce it: it cannot today (the
|
||
// flagged sentinel is built in four places in backend/cmd/tmctl/render.go, belonging to `translate`,
|
||
// `status` and `redrive`, and `renderManifest` returns nil for every document it prints). It reads
|
||
// it because the alternative is an invariant of another zone that nothing here tests: were a later
|
||
// engine to flag something during a cut, intake would spend the book's whole budget and end its
|
||
// walk, for every book on the host at once. Applying the rule costs one condition; depending on the
|
||
// invariant costs a comment that has to stay true (raised by the seam lens of the dofix review).
|
||
func (r *Runner) Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) {
|
||
doc, _, err := readEngine(ctx, binary, workdir, ManifestArgs(workdir), maxManifest, "manifest")
|
||
if err != nil {
|
||
return ingest.Manifest{}, err
|
||
}
|
||
return ingest.DecodeManifest(doc)
|
||
}
|
||
|
||
// Export reads a book's pairs — source, translation and the state derived from them.
|
||
//
|
||
// It carries the SAME exit-2 rule as Manifest and Status, and here the rule is load-bearing rather
|
||
// than defensive: a book with a flagged unit is the ordinary case for this command, and reading a
|
||
// non-zero code as "the engine did not answer" would leave exactly those books without a reading
|
||
// surface (the shape of ФП-1, which cost the money path of every book that ever flagged a unit).
|
||
func (r *Runner) Export(ctx context.Context, binary, workdir string) (ingest.Export, error) {
|
||
doc, _, err := readEngine(ctx, binary, workdir, ExportArgs(workdir), maxExport, "export")
|
||
if err != nil {
|
||
return ingest.Export{}, err
|
||
}
|
||
return ingest.DecodeExport(doc)
|
||
}
|
||
|
||
// readEngine runs one $0 read command and returns its document, plus the exit error when the engine
|
||
// completed WITH FLAGS — an answer rather than a refusal, which only `status` has to weigh further.
|
||
//
|
||
// One function for all three commands, because everything below is the same problem three times: a
|
||
// pipe nobody drains blocks the child, a cap at the mercy of the thing it bounds is not a cap, exit
|
||
// 2 is an answer, and the engine's stderr must not become a platform log line verbatim.
|
||
func readEngine(ctx context.Context, binary, workdir string, args []string, limit int64, what string) ([]byte, error, error) {
|
||
cmd := exec.CommandContext(ctx, binary, args...)
|
||
cmd.Dir = workdir
|
||
var errOut bytes.Buffer
|
||
cmd.Stderr = &errOut
|
||
out, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return nil, nil, fmt.Errorf("runner: tmctl %s: %w", what, err)
|
||
}
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, nil, fmt.Errorf("runner: tmctl %s: %w", what, err)
|
||
}
|
||
doc, readErr := io.ReadAll(io.LimitReader(out, limit+1))
|
||
overflowed := drain(cmd, out, int64(len(doc)) > limit)
|
||
// The fact and the first line of stderr, never the whole of it. The error carries an
|
||
// *exec.ExitError underneath, because the caller tells "the engine answered no" from "the engine
|
||
// could not be run" by exactly that type.
|
||
var flagged error
|
||
if err := cmd.Wait(); overflowed == nil && err != nil {
|
||
refused := fmt.Errorf("runner: tmctl %s: %w: %s", what, err, firstLine(errOut.Bytes()))
|
||
if !ingest.CompletedWithFlags(err) {
|
||
return nil, nil, refused
|
||
}
|
||
flagged = refused
|
||
}
|
||
if overflowed != nil {
|
||
return nil, nil, fmt.Errorf("runner: tmctl %s: %w", what, overflowed)
|
||
}
|
||
if readErr != nil {
|
||
return nil, nil, fmt.Errorf("runner: read %s: %w", what, readErr)
|
||
}
|
||
return doc, flagged, nil
|
||
}
|
||
|
||
// maxStatus bounds what the platform will read from the status command, for the same reason and
|
||
// against the same thing as maxManifest: a process at the configured path that is not the engine.
|
||
// Measured rather than guessed — a real 2283-chapter book's `status --json` is 1.1 MB — so the cap
|
||
// is three orders of magnitude above the largest report anyone produces.
|
||
const maxStatus = 16 << 20
|
||
|
||
// Status runs the reconciliation channel and decodes the allowlisted subset of its report.
|
||
//
|
||
// ⚠ Exit 2 is an ANSWER here, not a refusal: `status --json` prints the whole report and then exits
|
||
// 2 for a book with flagged units (ingest.CompletedWithFlags). THREE things have to hold together
|
||
// for that answer to be taken — the code, a report that parses, and the report agreeing that the
|
||
// book has a flagged unit, which is the only condition under which the engine emits the code
|
||
// (backend/cmd/tmctl/render.go `if rep.Flagged > 0`). Anything else is a refusal, as every other
|
||
// non-zero code is.
|
||
//
|
||
// The third check is what the money review of this dofix asked for, and it is not ceremony: this
|
||
// call settles a run, so a process at the pinned path that is not that build — a wrapper, a
|
||
// version directory replaced in place, a half-finished deploy — printing any JSON object with a
|
||
// committed figure and exiting 2 would be CHARGED rather than deferred.
|
||
func (r *Runner) Status(ctx context.Context, binary, workdir string) (ingest.StatusReport, error) {
|
||
doc, flagged, err := readEngine(ctx, binary, workdir, StatusArgs(workdir), maxStatus, "status")
|
||
if err != nil {
|
||
return ingest.StatusReport{}, err
|
||
}
|
||
rep, decodeErr := ingest.DecodeStatus(doc)
|
||
if flagged != nil && (decodeErr != nil || rep.Flagged == 0) {
|
||
// It exited 2 and what came back is not the report that code means. The EXIT error is the more
|
||
// informative half — the decoder can only say that the bytes are not a report.
|
||
return ingest.StatusReport{}, flagged
|
||
}
|
||
return rep, decodeErr
|
||
}
|
||
|
||
// drain finishes with the child's stdout once the caller has read what it wanted, and reports the
|
||
// overflow when there was one.
|
||
//
|
||
// The ordinary case is a DRAIN, and that is not tidiness: a pipe nobody reads blocks the child on its
|
||
// next write and Wait then blocks on the child. Closing it instead kills the engine with EPIPE, which
|
||
// this side would then report as a host that cannot run the engine.
|
||
//
|
||
// Over the cap the process is KILLED instead, and that half was found by writing the test for the
|
||
// cap: a writer that never stops is exactly what the cap exists for, and draining it never returns —
|
||
// the caller hangs until its own deadline, which on the intake path is fifteen minutes of a worker.
|
||
// A cap that is at the mercy of the thing it bounds is not a cap.
|
||
func drain(cmd *exec.Cmd, out io.Reader, over bool) error {
|
||
if !over {
|
||
_, _ = io.Copy(io.Discard, out)
|
||
return nil
|
||
}
|
||
if cmd.Process != nil {
|
||
_ = cmd.Process.Kill()
|
||
}
|
||
_, _ = io.Copy(io.Discard, out) // returns at once now: the writer is gone
|
||
return errors.New("the engine printed more than this platform will read, which no book produces")
|
||
}
|
||
|
||
func firstLine(b []byte) []byte {
|
||
if i := bytes.IndexByte(b, '\n'); i >= 0 {
|
||
return b[:i]
|
||
}
|
||
return b
|
||
}
|