103 lines
4.3 KiB
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
|
|
}
|