121 lines
6.5 KiB
Go
121 lines
6.5 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"textmachine/backend/internal/store"
|
|
)
|
|
|
|
// refusal.go: the shell contract's REFUSAL classes (row 165 / PD-196 of the platform).
|
|
//
|
|
// The defect this closes was not cosmetic. `tmctl` mapped every failure it did not recognise onto exit
|
|
// 1, so "this source is unreadable", "this config is broken" and "another process holds the project"
|
|
// arrived at an automated caller as one number — and the platform's intake, which retries a book five
|
|
// times and then rejects it as `source_unreadable`, came within one step of deleting a user's upload
|
|
// because an operator mistyped a key in a hand-written book.yaml.
|
|
//
|
|
// The form is a CLASS carried by a typed error, mapped to an exit code by cmd/tmctl. It is a class and
|
|
// not a code here on purpose: the engine owns the vocabulary, the shell contract owns the numbers, and a
|
|
// class this build of a reader has never heard of still lands inside the reserved band and reads as
|
|
// "refused" rather than as "failed" — which is the difference between waiting and destroying data.
|
|
|
|
// RefusalClass names WHY an invocation was turned down. Values are stable strings: a new class is a new
|
|
// value here and a new number in cmd/tmctl's table, never a new branch in a consumer.
|
|
type RefusalClass string
|
|
|
|
const (
|
|
// RefusalBadConfig is a configuration this engine will not run: unreadable, unparseable, or invalid.
|
|
// The operator's file is the thing to fix; the book's source is untouched and blameless.
|
|
RefusalBadConfig RefusalClass = "config_invalid"
|
|
// RefusalSourceUnreadable is a book whose SOURCE cannot be read or decoded. This is the one class
|
|
// that says something about the user's text rather than about the operator's config.
|
|
RefusalSourceUnreadable RefusalClass = "source_unreadable"
|
|
// RefusalProjectLocked is another tmctl process owning the project. Nothing is wrong with anything —
|
|
// the answer is to come back later.
|
|
RefusalProjectLocked RefusalClass = "project_locked"
|
|
// RefusalSchemaMismatch is a project database whose schema version is not this binary's. Nothing is
|
|
// wrong with the book either: the answer is `tmctl migrate` (or, in the other direction, a newer
|
|
// binary), and the two numbers a caller needs to tell those apart ride the message as the stable
|
|
// token `schema_mismatch found=<N> expected=<M>` (store.SchemaMismatchError).
|
|
//
|
|
// It is its own class because read-only opens never migrate, so an engine upgrade refuses every
|
|
// existing book until a write command touches it — and the platform's own pre-spawn call is one of
|
|
// those read-only opens, so that write command never came (the deploy deadlock, backlog row 174).
|
|
// A caller that can SEE this class self-heals ("caught it → migrate → retry") where it used to stop
|
|
// the world over what looked like a broken project.
|
|
RefusalSchemaMismatch RefusalClass = "schema_mismatch"
|
|
)
|
|
|
|
// Refusal is an invocation the engine turned down before doing any work of its own: nothing reached a
|
|
// provider, nothing was spent, and nothing this process would have written was written. It says nothing
|
|
// about whether the BOOK is untouched — a resumed run refused at ingest has been paid for before — only
|
|
// that THIS process changed nothing.
|
|
type Refusal struct {
|
|
Class RefusalClass
|
|
err error
|
|
}
|
|
|
|
func (e *Refusal) Error() string { return e.err.Error() }
|
|
func (e *Refusal) Unwrap() error { return e.err }
|
|
|
|
// refuse wraps err as a refusal of class c. A nil err is nil: the wrapper never invents a failure.
|
|
func refuse(c RefusalClass, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
return &Refusal{Class: c, err: err}
|
|
}
|
|
|
|
// RefuseConfig classifies a config-load failure for a caller OUTSIDE this package. The pre-flight backup
|
|
// guard is one: it loads book.yaml before the runner exists, so it — not openRunner — is what a broken
|
|
// config meets first on the `translate` path, and an unclassified error there would put every refusal
|
|
// back on exit 1 no matter how carefully the runner classifies its own.
|
|
func RefuseConfig(err error) error {
|
|
return refuse(RefusalBadConfig, err)
|
|
}
|
|
|
|
// RefuseStoreOpen classifies a store-open failure. It is exported and it is the ONE place the mapping
|
|
// lives, because two callers open a project store: openRunner below, and `tmctl migrate`, which opens
|
|
// one with no runner at all. A second copy of this switch is how "another process holds it" and "this
|
|
// binary does not match the schema" drift back into a single exit 1 on one of the two paths.
|
|
//
|
|
// An unrecognised failure is returned UNCHANGED rather than swept into the band: the band means "turned
|
|
// down before doing any work", and a caller destroys or waits on data because of it.
|
|
func RefuseStoreOpen(err error) error {
|
|
var mismatch *store.SchemaMismatchError
|
|
switch {
|
|
case err == nil:
|
|
return nil
|
|
case errors.Is(err, store.ErrLocked):
|
|
return refuse(RefusalProjectLocked, err)
|
|
case errors.As(err, &mismatch):
|
|
return refuse(RefusalSchemaMismatch, err)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// refuseSource classifies a failure to obtain the book's text, and it classifies almost all of them as a
|
|
// CONFIG fault. That is deliberate, and it is the most consequential decision in this file.
|
|
//
|
|
// RefusalSourceUnreadable is the verdict an automated intake acts on by DELETING the user's upload. So
|
|
// it may only be returned for something no configuration knob can explain — and a read failure is not
|
|
// that: a path that is not there, a permission, an I/O error, a vanished mount all say something about
|
|
// the deployment (a template naming a filename the upload route did not use, a chown bug) and nothing
|
|
// about the text. Neither is a DECODE failure, which was the first answer here and is wrong for the same
|
|
// reason: decoding is driven by the book's declared `encoding` and `source_lang`, so "these bytes are
|
|
// not text" and "you told me the wrong way to read them" are the same error. Two independent reviews
|
|
// arrived at that case from opposite directions.
|
|
//
|
|
// What is left, and the only thing the engine can assert about the TEXT with no config in the way, is
|
|
// sourceHasNoContent below: the bytes were read and cut, and there is no book in them.
|
|
func refuseSource(err error) error {
|
|
return refuse(RefusalBadConfig, err)
|
|
}
|
|
|
|
// sourceHasNoContent is the one refusal that IS about the user's text: reading and cutting the source
|
|
// succeeded and produced nothing to translate. No `encoding`, `source_lang` or path setting explains an
|
|
// empty result from a successful read, which is what makes it safe to act on.
|
|
func sourceHasNoContent(err error) error {
|
|
return refuse(RefusalSourceUnreadable, err)
|
|
}
|