textmachine/platform/internal/ingest/supervisor.go

118 lines
4.1 KiB
Go

package ingest
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"time"
)
// Outcome is the engine's ratified exit contract (cmd/tmctl/main.go:30-52). It is read from the
// exit code rather than from the log, and a bank stop is deliberately NOT a failure.
type Outcome string
const (
OutcomeClean Outcome = "clean" // 0
OutcomeFailed Outcome = "failed" // 1 — infra failure, and anything unrecognised
OutcomeFlagged Outcome = "flagged" // 2 — completed with flagged units
OutcomeBankStop Outcome = "bank_stop" // 3 — deliberate human-in-the-loop halt
)
func outcomeOf(code int) Outcome {
switch code {
case 0:
return OutcomeClean
case 2:
return OutcomeFlagged
case 3:
return OutcomeBankStop
default:
return OutcomeFailed
}
}
// stopGrace is how long the engine has to shut down after the interrupt before it is killed. The
// engine stops gracefully on SIGINT/SIGTERM (signal.NotifyContext in tmctl's main), and it holds an
// EXCLUSIVE lock on its project file — a SIGKILL first would leave that lock behind.
const stopGrace = 30 * time.Second
// Supervisor runs one tmctl process per attempt.
//
// Stream discipline (research/23 §2): stdout belongs to the event stream and to nothing else;
// the engine's own logs are on stderr and stay there.
type Supervisor struct {
// Bin is the tmctl binary. The platform never links the engine — it spawns it (D39.81).
Bin string
// Workdir is the book's project directory: book.yaml, the source and the engine's private
// SQLite live there. That SQLite is never opened by us (D39.85 §4).
Workdir string
// Env is the child's environment. Provider keys reach the engine through it and must never be
// logged; nil means the parent's environment.
Env []string
// EngineLog receives the child's stderr verbatim. It is a FILE, not our structured logger: the
// engine logs per-call cost estimates at INFO, and money must not enter the platform's INFO
// stream (D39.84). nil discards.
EngineLog io.Writer
}
// Run spawns the engine and ingests its stream. It returns the outcome even when the stream itself
// failed, because "what did the process do" and "did we materialize all of it" are different
// questions: the second one is answered by reconciling with Status.
func (s *Supervisor) Run(ctx context.Context, sink Sink, args ...string) (Outcome, error) {
cmd := exec.CommandContext(ctx, s.Bin, args...)
cmd.Dir = s.Workdir
cmd.Env = s.Env
cmd.Stderr = s.engineLog()
// CommandContext kills on cancel by default; the engine needs the signal it already handles,
// and WaitDelay is the backstop if it ignores it.
cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) }
cmd.WaitDelay = stopGrace
stdout, err := cmd.StdoutPipe()
if err != nil {
return OutcomeFailed, fmt.Errorf("ingest: stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return OutcomeFailed, fmt.Errorf("ingest: start %s: %w", s.Bin, err)
}
ingestErr := Ingest(ctx, stdout, sink)
// Drain whatever is left so the child never blocks on a full pipe while we are waiting for it.
_, _ = io.Copy(io.Discard, stdout)
waitErr := cmd.Wait()
var exitErr *exec.ExitError
switch {
case waitErr == nil:
return OutcomeClean, ingestErr
case errors.As(waitErr, &exitErr):
return outcomeOf(exitErr.ExitCode()), ingestErr
default:
return OutcomeFailed, errors.Join(waitErr, ingestErr)
}
}
// Status runs the reconciliation channel: `tmctl status --json` on a stopped or finished run. It
// is read-only and free, but NOT free of CPU — every call re-ingests and re-chunks the source
// (1.4-1.5 s on a 23 MB book, engine backlog row 100), so it is a repair path, not a poll.
func (s *Supervisor) Status(ctx context.Context) (StatusReport, error) {
cmd := exec.CommandContext(ctx, s.Bin, "status", "--json")
cmd.Dir = s.Workdir
cmd.Env = s.Env
cmd.Stderr = s.engineLog()
out, err := cmd.Output()
if err != nil {
return StatusReport{}, fmt.Errorf("ingest: tmctl status: %w", err)
}
return DecodeStatus(out)
}
func (s *Supervisor) engineLog() io.Writer {
if s.EngineLog == nil {
return io.Discard
}
return s.EngineLog
}