package runner import ( "errors" "fmt" "io" "io/fs" "os" "path/filepath" "gopkg.in/yaml.v3" "textmachine/platform/internal/ingest" ) // artifacts.go: the engine's FILE channels, as opposed to its command channels. // // The bank is the one read-out the engine publishes as a file rather than on stdout (D39.122): it // lives beside the project database, is written atomically, and can therefore be read while a run is // going — which is exactly what a signing stop needs, since the stop IS a live run waiting. // ErrNoBank is a book that has no bank read-out yet: it has never been through a run that produced // one. An ordinary state and not a failure — and the caller saves NOTHING on it: an empty bank // written over a real one would erase a read-out the engine has simply not made yet. var ErrNoBank = errors.New("runner: this book has no bank read-out") // maxBank bounds the read-out — three orders of magnitude above any real bank, so it refuses a file // that is not one rather than sizing one that is. const maxBank = 256 << 20 // ReadBank decodes a book's whole-bank read-out. func ReadBank(workdir string) (ingest.Bank, error) { db, err := projectDB(workdir) if err != nil { return ingest.Bank{}, err } f, err := os.Open(db + ".bank.json") if errors.Is(err, fs.ErrNotExist) { return ingest.Bank{}, ErrNoBank } if err != nil { return ingest.Bank{}, fmt.Errorf("runner: open bank read-out: %w", err) } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, maxBank+1)) if err != nil { return ingest.Bank{}, fmt.Errorf("runner: read bank read-out: %w", err) } if len(raw) > maxBank { return ingest.Bank{}, errors.New("runner: the bank read-out is larger than this platform will read") } return ingest.DecodeBank(raw) } // projectDB asks the book's own configuration where its project database is. // // ⚠ This READS `book.yaml` and does not contradict D39.110 §2b: that decision is about who OWNS the // file — the platform renders one starting copy and never edits it again — not about whether the // path it declares may be followed. The sidecars are named after `project_db` by the engine, so // there is no second place to learn the name from, and the alternative (globbing the directory for // `*.bank.json`) would guess where a configuration states. // // The path is resolved against the BOOK's directory, which is how the engine resolves every relative // path in that file. func projectDB(workdir string) (string, error) { f, err := os.Open(filepath.Join(workdir, ConfigFile)) if err != nil { return "", fmt.Errorf("runner: open book configuration: %w", err) } defer f.Close() raw, err := io.ReadAll(io.LimitReader(f, maxBookConfig+1)) if err != nil { return "", fmt.Errorf("runner: read book configuration: %w", err) } var cfg struct { ProjectDB string `yaml:"project_db"` BookID string `yaml:"book_id"` } if err := yaml.Unmarshal(raw, &cfg); err != nil { return "", fmt.Errorf("runner: read book configuration: %w", err) } if cfg.ProjectDB == "" { // ⚠ The key is OPTIONAL to the engine, which defaults to `.db` beside the file // (backend/internal/config/book.go). Refusing here read the bank as missing on every // deployment whose operator used the template as written, since it carries no `project_db`. if cfg.BookID == "" { return "", errors.New("runner: the book configuration names neither project_db nor book_id") } return filepath.Join(workdir, cfg.BookID+".db"), nil } if filepath.IsAbs(cfg.ProjectDB) { return cfg.ProjectDB, nil } return filepath.Join(workdir, cfg.ProjectDB), nil } // maxBookConfig bounds what will be read as a book configuration — a page of keys. const maxBookConfig = 1 << 20