cmd/taisce/main.go
cmd/taisce · 496 lines · 10 declarations · source
This file carries the package documentation, rendered on the package page.
Declarations
const envMemoryDSN, envRegistryDSN, envSchema and 7 more
const (
envMemoryDSN = "TAISCE_MEMORY_DSN"
envRegistryDSN = "TAISCE_REGISTRY_DSN"
envSchema = "TAISCE_SCHEMA"
envAddr = "TAISCE_ADDR"
// The bundle budget, in CHARACTERS. Not tokens: a token count is a property of the caller's
// tokeniser, not ours, and one computed with the wrong tokeniser is wrong in a way they cannot
// correct. Not rows: a row is not a size.
envBundleBudget = "TAISCE_BUNDLE_CHARACTERS"
// The identity that may change the schema. Only the operator commands use it; the server never
// holds a connection that can run DDL.
envAdminDSN = "TAISCE_ADMIN_DSN"
// Which halves of the service this process runs: "all" (default), "api" or "worker".
envRole = "TAISCE_ROLE"
envTurnBudget = "TAISCE_FORMATION_TURN_BUDGET"
envRequireFormation = "TAISCE_REQUIRE_FORMATION"
envHealthAddr = "TAISCE_HEALTH_ADDR"
)
Environment. Prefixed, because a process reading a bare DATABASE_URL picks up whatever the shell that started it happened to export.
const roleAll, roleAPI, roleWorker
const (
roleAll = "all"
roleAPI = "api"
roleWorker = "worker"
)
Roles.
Why one binary and not two
Serving reads and forming the backlog have genuinely different shapes: the API is fast and database-bound, formation is slow and model-bound, and one of them can be paused during a provider incident while the other keeps answering. Those are real reasons to run them apart.
They are not reasons to BUILD them apart. Two binaries is two images, two build pipelines, two version numbers that can disagree, and a schema migration that has to land in both — paid permanently, for a separation that is a runtime choice. So this is one image whose command decides what it does, and running them apart becomes a line in a chart rather than a change to the code.
The default runs both, because that is what one machine wants and because nothing here has been measured needing otherwise. Splitting should follow a number, not a diagram.
var version
var version = "unknown"
main owns the two things a process owns and a function should not: where the signals come from, and the exit status.
A misconfigured start exits non-zero rather than serving. Anything supervising this — a compose file, a Kubernetes probe, an operator watching a terminal — decides whether the instance is alive from that status, and a process that binds a port it cannot serve from is an instance that looks healthy and answers everything with an error. version is stamped at build time by the Makefile, from scripts/version.sh.
"unknown" when built without it — an honest answer rather than a plausible wrong one. A binary reporting a version it does not have is worse than one admitting it does not know, because the first is believed.
func main
func main()
func reportFailure
func reportFailure(args []string, err error, serverLog *slog.Logger, stderr io.Writer)
reportFailure says why the process is exiting. The server's failure goes to its log, beside everything else it logged. A command's failure goes to stderr, as the error panel in a terminal and as one JSON line otherwise, because stdout is the command's output: a script parsing it must not read the failure as data.
func run
func run(ctx context.Context, log *slog.Logger) error
run is everything main does that can be tested: configuration, connection, serving and shutdown.
The context arrives as an argument rather than being built here, so a test can end the server the same way a signal does. Signal handling stays in main, where the process boundary is.
func bundleBudget
func bundleBudget(log *slog.Logger) recall.Budget
func startDriver
func startDriver(ctx context.Context, log *slog.Logger, memory *pgxpool.Pool, schema pg.Schema,
stopped chan struct{}, responsive ...func()) error
startDriver brings up formation behind the append.
Without it, turns are stored, freshness never advances, and a recall returns nothing — a deployment that looks like it works and holds no memory.
Why a missing model does not stop the process
Extraction needs one and none can be shipped: no key belongs in an open repository, and a hosted provider would put a signup inside the first five minutes. Storage, recall of what is already formed, and erasure all work without a model, so refusing to start would turn a five-minute install into a failure in minute one.
What it must not do is look fine. So it says so, loudly, at the level an operator reads.
func configuredTurnBudget
func configuredTurnBudget(fallback time.Duration) (time.Duration, error)
configuredTurnBudget reads how long one formation attempt may take, or keeps the default.
A conversational turn never approaches the default, but a document-sized turn under a shared model server can: measured on a news corpus, articles over ten kilobytes were cut off by the client while the model was still writing their claims. Until documents are segmented on the way in, this is the operator's only knob, so it is a setting rather than a constant. A value that is not a positive duration is refused at startup: a budget of zero would fail every turn, and a silently ignored typo would leave the operator believing they had raised it.
func configuredMemorySchema
func configuredMemorySchema() (pg.Schema, error)
One namespace is selected for the entire instance at startup, never by a request or project. The optional setting preserves existing installations and isolated database test fixtures.