textmachine/platform/cmd/tmplatformctl/runs.go

103 lines
4.3 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"time"
"textmachine/platform/internal/pgstore"
"textmachine/platform/internal/runner"
)
// exitMarker records how a run's unit ended. systemd runs it as ExecStopPost=, so it must work with
// nothing at all: no database, no credentials, no network. Everything it writes comes from the
// environment systemd sets ($SERVICE_RESULT, $EXIT_CODE, $EXIT_STATUS) and from its two arguments.
//
// It lives in this CLI rather than in a shell one-liner inside the unit for three reasons that all
// bit somebody once: a shell command inside a systemd property has its own quoting rules and a state
// directory with a space in it silently splits into two arguments; the write has to be ATOMIC,
// because the reader polls and half a marker reads as a finished run; and a Go function can be
// tested, whereas a quoted string in a property cannot.
func exitMarker(args []string) error {
fs := flag.NewFlagSet("exit-marker", flag.ContinueOnError)
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() != 2 {
return errors.New("exit-marker takes <path> <unit>")
}
return runner.WriteMarker(fs.Arg(0), runner.MarkerFromEnv(fs.Arg(1), os.Getenv))
}
// addBook registers a book that already exists on disk.
//
// A DEV tool, and named as one: the contract's own intake (POST /books, a multipart upload) is not
// built, so the library would otherwise have nothing to list. What matters for the pack is the rule
// it enforces — the library reads the read-model, so a book put there by ANY route is a book the API
// serves, and the upload handle is one more writer of the same row rather than a second world.
func addBook(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
in, err := parseBookIntake(args)
if err != nil {
return err
}
id, err := store.AddBook(ctx, in)
if err != nil {
return err
}
_, err = fmt.Fprintf(out, "added %s\n", id)
return err
}
// parseBookIntake validates the arguments and nothing else. Separate from addBook because what a
// book needs to be usable — an engine project directory, a chapter count the ceiling scale can be
// built from — is the part worth asserting, and it is not worth a database to assert it.
func parseBookIntake(args []string) (pgstore.NewBook, error) {
fs := flag.NewFlagSet("book add", flag.ContinueOnError)
user := fs.String("user", "", "account that owns the book")
workdir := fs.String("workdir", "", "the engine's project directory (holds book.yaml)")
title := fs.String("title", "", "title as the reader sees it")
src := fs.String("source-lang", "", "source language CODE, never a name")
dst := fs.String("target-lang", "", "target language CODE, never a name")
genre := fs.String("genre", "", "genre as declared by the user")
// Chapters are given rather than counted: counting them means chunking the source, which is the
// engine's job and costs seconds of CPU per call until it persists a manifest (unified backlog
// row 100). The number matters because it clamps the ceiling scale.
chapters := fs.Int("chapters", 0, "how many chapters the book has")
characters := fs.Int64("characters", 0, "size in characters")
if err := fs.Parse(args); err != nil {
return pgstore.NewBook{}, err
}
switch {
case *user == "" || *workdir == "" || *title == "":
return pgstore.NewBook{}, errors.New("book add needs --user, --workdir and --title")
case *src == "" || *dst == "":
return pgstore.NewBook{}, errors.New("book add needs --source-lang and --target-lang")
case *chapters <= 0:
return pgstore.NewBook{}, errors.New("book add needs --chapters: it is what bounds the run ceiling scale")
}
abs, err := filepath.Abs(*workdir)
if err != nil {
return pgstore.NewBook{}, err
}
// Checked here because the alternative is a run that dies at argument parsing inside a transient
// unit, where the only trace is a marker saying "exit-code 1".
if _, err := os.Stat(filepath.Join(abs, runner.ConfigFile)); err != nil {
return pgstore.NewBook{}, fmt.Errorf("%s does not look like an engine project directory: %w", abs, err)
}
return pgstore.NewBook{
OwnerID: *user,
Title: *title,
SourceLang: *src,
TargetLang: *dst,
Genre: *genre,
ChapterCount: *chapters,
CharacterCount: *characters,
Workdir: abs,
Now: time.Now().UTC(),
}, nil
}