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 ") } 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 }