textmachine/platform/internal/runner/artifacts.go

98 lines
3.7 KiB
Go

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 `<book_id>.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