textmachine/platform/cmd/tmplatformctl/runs.go

157 lines
6.2 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"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))
}
// listBooks answers the question an engine upgrade asks: which books may have their project file
// migrated right now (unified backlog row 174).
//
// It is a COMMAND and not a paragraph in the deploy note because the deploy note's step has to be
// executable: `--migratable` prints one directory per line and nothing else, so the upgrade reads
//
// for d in $(tmplatformctl books --migratable); do tmctl migrate --config "$d/book.yaml"; done
//
// and a book somebody can still resume is simply not in that list. Written the other way round — an
// operator eyeballing a table — the book that gets migrated by mistake is the one whose owner comes
// back to a run that no longer starts, or to a hold nothing can close.
func listBooks(ctx context.Context, store *pgstore.Store, args []string, out io.Writer) error {
fs := flag.NewFlagSet("books", flag.ContinueOnError)
migratable := fs.Bool("migratable", false, "print only the project directories that are safe to migrate")
if err := fs.Parse(args); err != nil {
return err
}
books, err := store.BooksForMigration(ctx)
if err != nil {
return err
}
if *migratable {
for _, b := range books {
if b.Migratable() {
if _, err := fmt.Fprintln(out, b.Workdir); err != nil {
return err
}
}
}
return nil
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(w, "BOOK\tMIGRATE\tWHY NOT\tWORKDIR\tTITLE")
for _, b := range books {
verdict, why := "yes", ""
if !b.Migratable() {
var blockers []string
for _, c := range []struct {
blocked bool
name string
}{{b.Live, "live run"}, {b.Resumable, "resumable run"}, {b.Unsettled, "unsettled hold"}} {
if c.blocked {
blockers = append(blockers, c.name)
}
}
verdict, why = "no", strings.Join(blockers, ", ")
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", b.ID, verdict, why, b.Workdir, b.Title)
}
return w.Flush()
}
// 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
}