package runner import ( "bytes" "context" "errors" "fmt" "os/exec" "path/filepath" "syscall" "time" "textmachine/platform/internal/ingest" ) // build.go: the export channel — `tmctl build` run as a direct child, like the other $0 commands. // // It is shaped after bank-apply and not after readEngine for the same reason bank-apply is: the // exit code is DATA here and not only a verdict. `build` has a refusal class of its own (16, a book // with holes) and reaches the config class for arguments this side chose, so a caller that read // every non-zero code as "the engine did not answer" could not tell a book it must not have asked // for from a deployment that needs fixing. // // ⚠ NO `--keys-file`. `build` is $0 and key-less — it reads the store and writes a file, no // provider is called — and the engine refuses the flag on every command but `translate` (D20.4: the // $0 read verbs must not demand keys). // BuildArgs is the verb's argv for ONE format written to a path of the platform's own choosing. // // ⚠ `--out` is not a convenience and the door may not drop it. Without it the engine writes beside // its project database and then DELETES every format it was not asked for, from a set that belongs // to the operator's own CLI builds (backend/internal/pipeline/bookbuild.go, the `RemovedFiles` // loop) — so a user asking for `txt` would destroy the operator's `epub`, and two exports of one // book would overwrite each other's artifact. With `--out` the engine does no housekeeping at all // and each export is its own immutable file, which is also what makes the TTL and the GC this // platform's to run. // // ⚠ `--partial` is ratified and not a taste (D39.178 п.1, owner 30.08): the door ALWAYS builds. A // book with a hole comes back WITH the notice on its first page and a mark at every hole, never as // a refusal handed to the reader. Refusing by default stays the operator's handle on the CLI. func BuildArgs(workdir, format, out string) []string { return []string{"build", "--config", filepath.Join(workdir, ConfigFile), "--format", format, "--out", out, "--partial"} } // maxBuildReport bounds what is read back. The report is a page of counts plus two short lists of // paths; the cap refuses a process at the configured path that is not the engine. const maxBuildReport = 8 << 20 // buildStopGrace is how long the verb gets after SIGTERM before it is killed outright. // // ⚠ WHAT IT BUYS IS NOT WHAT AN EARLIER EDITION OF THIS COMMENT CLAIMED, and the correction came from // an adversarial pass. It said the grace lets the process reach its own context check. `tmctl build` // HAS no context check: `main.go` builds one with `signal.NotifyContext` and hands it to `status` and // `translate`, and the `build` case is called without it (`backend/cmd/tmctl/main.go`, `case // "build"`; `pipeline.BuildBook` takes no `context.Context` at all). So SIGTERM is caught by the // runtime's handler and OBSERVED BY NOBODY: the build runs to completion, and what ends it at the // caller's deadline is the SIGKILL after this grace. // // It is still 30 s and still worth having, for the smaller thing it actually does: it bounds how far // a wedged build outlives its budget, and it gives a build that is nearly done the chance to finish // its rename rather than be killed inside one. What it does NOT do is prevent that kill, which is // why the caller removes the engine's staging files as well as its output (exports.Service.discard). const buildStopGrace = 30 * time.Second // BuildOutcome is what running the verb produced, exit code and report together. type BuildOutcome struct { // Report is the decoded report; Decoded says one arrived on stdout and parsed. Every refusal // class speaks on stderr alone, so a refusal legitimately leaves it false. Report ingest.BuildReport Decoded bool // DecodeErr is why stdout did not parse as a report, when it carried bytes that did not. DecodeErr error // ExitCode is the engine's own exit, valid when Exited. A process that did not exit on its own — // killed on the caller's deadline, or never started — leaves Exited false. ExitCode int Exited bool // Stderr is the first line of the engine's stderr — for the operator's log, never for the wire. Stderr string } // Build writes ONE reader's copy of a book to `out` and reads the report back. // // It takes no lock and needs none: `build` opens the project READ-ONLY and without the exclusive // flock (backend/internal/pipeline/runner.go `openRunner`, store.OpenReadOnly), which is precisely // what makes the canon's "a book still being translated may be exported" executable. func (r *Runner) Build(ctx context.Context, binary, workdir, format, out string) (BuildOutcome, error) { cmd := exec.CommandContext(ctx, binary, BuildArgs(workdir, format, out)...) cmd.Dir = workdir var stdout, errOut bytes.Buffer cmd.Stdout = &limitedBuffer{buf: &stdout, limit: maxBuildReport} cmd.Stderr = &errOut // SIGTERM, not the default SIGKILL — see buildStopGrace. cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } cmd.WaitDelay = buildStopGrace err := cmd.Run() res := BuildOutcome{Stderr: string(firstLine(errOut.Bytes()))} if cmd.ProcessState != nil && cmd.ProcessState.Exited() { res.ExitCode, res.Exited = cmd.ProcessState.ExitCode(), true } if body := bytes.TrimSpace(stdout.Bytes()); len(body) > 0 { if rep, derr := ingest.DecodeBuild(body); derr != nil { res.DecodeErr = derr } else { res.Report, res.Decoded = rep, true } } if err != nil && !res.Exited { // Killed, or never a process: the exit code carries nothing, so the error is the answer. The // caller's own deadline is folded in — an *exec.ExitError from a SIGKILL says less than "the // budget ran out". return res, errors.Join(fmt.Errorf("runner: tmctl build: %w: %s", err, res.Stderr), ctx.Err()) } return res, nil }