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 }