157 lines
6.2 KiB
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
|
|
}
|