170 lines
7.8 KiB
Go
170 lines
7.8 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"}
|
|
}
|
|
|
|
// 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
|
|
|
|
// Manifest builds the book's chapter tree and returns the allowlisted summary of it.
|
|
func (r *Runner) Manifest(ctx context.Context, binary, workdir string) (ingest.Manifest, error) {
|
|
cmd := exec.CommandContext(ctx, binary, ManifestArgs(workdir)...)
|
|
cmd.Dir = workdir
|
|
var errOut bytes.Buffer
|
|
cmd.Stderr = &errOut
|
|
out, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err)
|
|
}
|
|
if err := cmd.Start(); err != nil {
|
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w", err)
|
|
}
|
|
doc, readErr := io.ReadAll(io.LimitReader(out, maxManifest+1))
|
|
// Whatever is left is DRAINED, and that is not tidiness: a pipe nobody reads blocks the child on
|
|
// its next write and Wait then blocks on the child. Stopping at the cap without draining does not
|
|
// truncate the document — it hangs the caller, or, once the pipe is closed, kills the engine with
|
|
// EPIPE and reports that as a host which cannot run the engine.
|
|
_, _ = io.Copy(io.Discard, out)
|
|
if err := cmd.Wait(); err != nil {
|
|
// The engine's stderr is engine vocabulary and must not become a platform log line verbatim,
|
|
// let alone a product phrase; what travels is the fact and the first line. The error is
|
|
// returned UNWRAPPED as an *exec.ExitError underneath, because the caller tells "the engine
|
|
// answered no" from "the engine could not be run" by exactly that type.
|
|
return ingest.Manifest{}, fmt.Errorf("runner: tmctl manifest: %w: %s", err, firstLine(errOut.Bytes()))
|
|
}
|
|
if readErr != nil {
|
|
return ingest.Manifest{}, fmt.Errorf("runner: read manifest: %w", readErr)
|
|
}
|
|
if int64(len(doc)) > maxManifest {
|
|
return ingest.Manifest{}, fmt.Errorf("runner: the manifest is over %d bytes, which no book produces", maxManifest)
|
|
}
|
|
return ingest.DecodeManifest(doc)
|
|
}
|
|
|
|
// 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
|
|
}
|