118 lines
4.1 KiB
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
|
|
}
|