Rust CLI and Server
Status: Current Last updated: 2026-09-15 18:27 EDT
This page covers the Rust control plane that powers batchalign3: the CLI
client, the HTTP server, and how to extend them.
The current worker-boundary replacement plan is documented separately in Worker Protocol V2. That spec is the source of truth for replacing the legacy stdio JSON-lines worker contract.
Crate Map
After the 2026-04-28 monorepo merge, batchalign source lives as a small set of sibling crates inside this workspace:
| Crate | Role |
|---|---|
crates/batchalign/ | The runtime crate: Clap CLI, dispatch router, direct-host bootstrap, Axum HTTP server, job store, worker pool, cache, and command-owned orchestration. The chat_ops/ module owns CHAT extraction, injection, validation, ASR post-processing, and DP alignment that is batchalign-specific. |
crates/batchalign/src/commands/ | (submodule) released-command definitions, author-facing constructors, and the command catalog |
crates/batchalign-types/ | Domain newtypes, worker IPC types (V2), shared between the runtime crate and the PyO3 bridge |
crates/batchalign-pyo3/ | PyO3 bridge crate (batchalign_core); workspace member, slim dep tree (batchalign-types + talkbank-transform + pyo3/numpy/serde/tracing) |
crates/talkbank-{model,parser,transform,clan,...} | CHAT data model, parser, pipelines, CLAN tools, shared across the workspace; batchalign depends on the first three by workspace path |
Common Developer Commands
cargo check --workspace
cargo test --workspace
cargo check --manifest-path crates/batchalign-pyo3/Cargo.toml # PyO3 crate (separate)
cargo test --manifest-path crates/batchalign-pyo3/Cargo.toml
CLI Command Dispatch (Single Source of Truth)
batchalign::cli::run_command() in
crates/batchalign/src/cli/mod.rs:251 is the
single canonical command router. The standalone binary (main.rs) calls it.
The installed batchalign3 console command is a tiny Python wrapper
(batchalign/_cli.py) that finds and execs the standalone binary, either
packaged in the wheel at batchalign/_bin/batchalign3, or from
target/debug/batchalign3 in a source checkout.
main.rs → batchalign::cli::run_command(cli)
batchalign/_cli.py → os.execv(batchalign/_bin/batchalign3) [installed]
→ os.execv(target/debug/batchalign3) [dev checkout]
main.rs and batchalign/_cli.py are thin wrappers.
No command-specific logic lives in either of them.
The CLI layer now exposes two contributor-facing named seams:
ReleasedCommandincrates/batchalign-types/src/domain.rs:36is the closed released command vocabulary for contributor-facing Rust code. Parse external strings into this enum as early as possible; keep the old string-backedCommandNameonly at wire/storage boundaries.CommandProfileincrates/batchalign/src/cli/args/mod.rs:148keeps the command identity, language, file extensions, and speaker count together as a typed profile instead of a positional tuple.DispatchRequestincrates/batchalign/src/cli/dispatch/mod.rs:42carries the typed command profile, I/O settings, and runtime flags into the dispatcher as one named boundary object.
The dispatcher also consults
batchalign::released_command_uses_local_audio() and the shared released
command catalog to decide whether a requested command uses the shared-filesystem
audio path under an explicit --server submission or can use ordinary
content-mode submission.
On the app side, the current execution split is now:
ExecutionEngine: shared command execution coreServerExecutionHost: queue/store/server-owned lifecycle behaviorDirectHost/DirectExecutionHost: inline local execution without queueing or registry discoveryServerBackend/LocalServerBackend, route-facing server control-plane seam over persisted jobs, orchestration, event subscription, traces, and runtime shutdownprepare_workers*()vsprepare_direct_workers(): explicit separation between server worker bootstrap and direct local worker bootstrap
Align / FA host flow
The part that was easiest to misunderstand during the recent align emergency
was where forced alignment actually runs.
align now has two honest host paths:
- Direct mode (no
--server) does not start Axum, an HTTP server, a queue, or registry discovery. The CLI prepares local workers and runs the job throughDirectHost. - Explicit server mode (
--server URL) submits a shared-filesystempaths_modejob. The server must be able to read the submitted source paths and write the requested output paths on the execution host.
Both paths converge on the same FA runner code in
crates/batchalign/src/runner/dispatch/fa_pipeline.rs.
flowchart TD
cli["batchalign3 align"]
route{"--server?"}
prep["prepare_paths_submission()\nsource_paths + output_paths\nmedia_mapping + media_subdir"]
direct["dispatch_direct_mode()"]
direct_workers["prepare_direct_workers()"]
direct_host["DirectHost::submit_submission()\nDirectHost::run_job()"]
server["dispatch_single_server()"]
post["POST /jobs (paths_mode)"]
backend["ServerBackend / queue / orchestrator"]
runner["process_one_fa_file()\nfa_pipeline.rs"]
fa["media resolution\n→ ensure_wav\n→ %wor / incremental reuse\n→ optional UTR\n→ FA transport\n→ traces + output"]
cli --> route
route -->|no| direct
route -->|yes| server
direct --> prep --> direct_workers --> direct_host --> runner
server --> prep --> post --> backend --> runner
runner --> fa
That shared convergence is deliberate: direct mode and server mode should differ in host/orchestration behavior, not in the actual forced-alignment logic.
When the CLI is polling or writing file results, FileErrorDetail
in crates/batchalign/src/cli/dispatch/helpers.rs:24 keeps
file-scoped failures as a named record instead of spreading
filename/message pairs through the progress code.
The command-specific logic starts from one declaration in
crates/batchalign/src/recipe_runner/catalog.rs: a CatalogEntry per released
command, naming its stage recipe and stating its family, planner, execution
mode, capability kind, io profile, dispatch kind, worker tasks and output
policy. The shared runner dispatches from that entry, so nothing per-command
needs to import store/queue/host plumbing to reach an existing family executor.
There is no per-command authoring layer any more. A commands/ module with one
module per command, six declare_*_command! macros and a CommandDefinition
type existed until 2026-07-28, when removing an #![allow(dead_code)] proved it
produced values nothing read; the compatibility views over the catalog followed
on 2026-07-29.
That is an intentional contributor contract:
- new commands should be authored direct-first and laptop-friendly
- command authors should not need to understand server internals
- command authors should usually only pick a family helper, not hand-author scheduling/runtime metadata
- server mode may opt into a different host/backend, but it should reuse the same generated command definition
command_family.rs keeps the small family enum used by command metadata,
text_batch.rs keeps reusable text-family helpers, and runner/dispatch/
keeps shared execution helpers. crates/batchalign/src/runner/ should stay
focused on job lifecycle, queueing, and policy rather than becoming a second
authoring surface for commands.
HTTP routes, SSE, and WebSocket handlers should prefer ServerBackend over
reaching through AppState to raw JobStore, queue, or runtime internals.
One dependency-graph cleanup already landed here: the standalone binary’s OTLP
telemetry stack and update-check helper are now gated behind the
batchalign crate’s binary-entry feature. The PyO3 cli_entry path
still shares run_command(), but it no longer drags those binary-only
dependencies into the extension build.
The embedded CLI bootstrap path now also lives in batchalign
(run_embedded_cli_from_argv()), so pyo3 no longer owns its own clap
parsing or Tokio runtime setup.
Under uv tool install, the Python wrapper now also primes
BATCHALIGN_SELF_EXE before it execs the packaged Rust binary. That gives
Rust server/daemon re-exec paths one explicit source of truth for “which binary
am I?” instead of forcing them to guess from current_exe() or PATH.
Server control-plane replacement guidance
The current recommendation is:
- Do not replace Axum just to say the server is off the shelf. The HTTP shell is not the main pain source.
- Do not move the primary CLI or server into Python. That would re-center orchestration around the worker/package layer instead of the typed Rust control plane.
- If we replace something, replace the control-plane backend behind
ServerBackend: queued-job claims, retries, recovery, runtime supervision, persisted progress, and cancellation.
That keeps the architectural split honest:
ExecutionEngineremains the canonical command execution core.DirectHostremains the BA2-style local/default execution path.ServerBackendbecomes the place where embedded-vs-durable server behavior can differ without teaching commands about server internals.
For local and single-host installs, the in-process local backend is the implemented server architecture.
Current backend contract:
ExecutionEngineowns canonical command execution only.DirectHostremains the direct/local execution path.ServerBackendowns app-facing job submission, inspection, cancellation, traces, event subscription, and runtime shutdown.LocalServerBackendowns queued-job orchestration, restart/recovery, runtime supervision, and store-backed lifecycle behavior.- The shared runner may report failures and progress, but it does not own higher-level server policy.
The important current design point is that direct mode stays simple while the server control plane remains local, explicit, and fully owned by the Rust runtime in this repository.
Validated so far:
cargo check -p batchalign -p batchalign
cargo test -p batchalign --lib -q
cargo test -p batchalign --test contract_suite json_compat:: -q
cargo test -p batchalign --lib -q
batchalign3 serve start --foreground --test-echo
batchalign3 jobs --server http://127.0.0.1:8111 <JOB_ID>
curl -X POST http://127.0.0.1:8111/jobs/<JOB_ID>/restart
curl -X DELETE http://127.0.0.1:8111/jobs/<JOB_ID>
Important validation caveat: existing e2e coverage already treats text-only
infer-task commands like morphotag as expected failures under --test-echo.
Use --test-echo to validate control-plane behavior, not infer-task success.
First-class debuggability
Direct mode and server mode should share the shape of the debugging handles they expose, but they should not be forced to share one live control-plane implementation just for symmetry.
What should be shared:
- stable
job_id - stable staging/artifact directory
- bug-report identifiers / files
- optional persisted trace artifact file
What should remain mode-specific:
- HTTP polling / WebSocket / dashboard transport
- queue persistence and recovery model
- live event fan-out
- server-only operational state
Direct mode now persists a machine-readable debug-artifacts.json file inside
the per-job staging directory and exports debug-traces.json when traces were
captured. The CLI also prints the direct job ID and artifact directory up front,
so a human or LLM agent can later inspect a failed local run by job ID instead
of relying on transient terminal output alone.
Opt-in telemetry is still worth considering, but only as an additive debugging aid for fleet/server deployments. It should not replace inspectable local artifacts. The first-class debugging path must remain: “here is the job ID and here are the files to inspect.”
For day-to-day command work, prefer the command layer first:
- add or extend
crates/batchalign/src/commands/<name>.rs - choose the existing runner family it should reuse
- keep the CLI argument plumbing thin
- let runner/dispatch handle lifecycle and resource policy, not semantics
Adding a New CLI Command
When adding a new processing command (e.g., batchalign3 foo), these files
must be updated:
1. CLI argument definition
crates/batchalign/src/cli/args/mod.rs: Add
Commands::Foo(FooArgs) variant to the Commands enum.
crates/batchalign/src/cli/args/commands.rs: Define FooArgs
struct with clap attributes. Include CommonOpts if the command
processes files.
2. CLI dispatch
crates/batchalign/src/cli/mod.rs: Add the match arm in
run_command() (defined at cli/mod.rs:251). For processing
commands, this typically falls through to the cmd => wildcard arm
that calls cli::dispatch::dispatch(). For utility commands (like
serve, jobs, models), add an explicit arm.
3. Typed command options
crates/batchalign/src/types/options.rs: Add
CommandOptions::Foo { ... } variant to the serde-tagged enum. This is the
wire format between CLI and server.
crates/batchalign/src/cli/args/options.rs: Add the builder in
build_typed_options() that converts FooArgs → CommandOptions::Foo.
4. Server-side task routing and capability gate
crates/batchalign/src/recipe_runner/recipes.rs: add the stage recipe.
crates/batchalign/src/recipe_runner/catalog.rs: declare the
CatalogEntry. That is the whole registration.
crates/batchalign/src/runner/policy.rs answers
command_requires_chat_infer() straight off that entry. It is total: a
ReleasedCommand always has an entry (pinned by
recipe_runner::catalog::tests::every_released_command_has_a_spec), so it
returns no Option for callers to unwrap.
Availability reads two different facts at two moments:
command_supported()incrates/batchalign/src/capability.rs: the worker’s admitted report (admitted once, inWorkerPool::record_capabilities()) advertises the entry’scapabilities.primary_infer_task. This alone decides what/healthadvertises and which submissions are accepted, from any report, including one a lazily loading worker gave before loading anything. It is the only availability rule in that module, besideWorkerCapabilitySnapshot.- At dispatch (
runner/routing.rs),WorkerPool::ensure_command_capabilities()loads the command’s task on the selected worker (ensure_task) and returnsLoadedCapabilities: the task it loaded and the report taken after that load. Routing runs step 1 again against that report. Then the forced-alignment dispatch arm, and only that arm, reads the FA engine withFaCacheNamespace::from_loaded(engine_reports.rs), because every FA cache row is namespaced by it. It refuses a report taken after a different task loaded (LoadedAnotherTask), an engine still unnamed after the load (UnreportedAfterLoad) and a worker that does not support FA (NotSupported). No catalog field declares this requirement; the arm that reads the namespace is the only place that says so.
The critical implementation rule is that startup capability state is not authoritative for execution. The current server intentionally allows an optimistic cold-start snapshot so app creation does not have to spawn a dedicated probe worker. Execution then resolves a live capability snapshot before it trusts infer-task gating:
WorkerCapabilitySnapshot::resolve()incrates/batchalign/src/capability.rsprefers the pool’s first admitted report over the startup view of every released command- the router (
runner/routing.rs) forces a command-appropriate live probe throughWorkerPool::ensure_command_capabilities(), which returns the selected key’s admitted report, before applying the availability rule WorkerPool::discover_from_registry()admits the report probed from a healthy TCP registry daemon under that daemon’s worker key, so registry-only deployments do not start withinfer_tasks = []
This split is deliberate. It avoids the old failure mode where lazy startup said
“we will discover capabilities later” but the first real morphotag or
compare job was still judged by an empty startup snapshot.
One implementation detail matters here: sequential TCP daemons accept one
connection at a time. Registry discovery therefore probes capabilities on the
same TcpWorkerHandle it already opened for the discovery health check, instead
of trying to race a second connection.
A checked-out TCP handle is a TcpCheckout (worker/pool/dispatch.rs), which
owns the handle and its group slot together. When the exchange ends, the
handle goes back to its group unless WorkerError::worker_after_failure()
(worker/error.rs) answers Retire. Only WorkerResponse, Bootstrap,
MemoryGuard, NoWorker and PoolShuttingDown leave the worker reusable;
every other error (a dead process, a protocol or I/O failure,
CapabilitiesRefused, and the rest) retires the handle. Retiring drops the
handle, which closes the connection, and releases the slot; a checkout dropped
without finishing (a cancelled exchange) does the same, because a half-read
stream could hand a later exchange a stale reply. The daemon itself is left
running and is adopted again by the next registry sweep.
A worker whose capability report was refused is never pooled, on any path: a
spawned worker is shut down, a registry GPU daemon is disconnected, a TCP
handle is dropped, a checked-out worker is taken out of its group, and
registry discovery (worker/pool/discovery.rs) does not integrate it.
The registry layer now also carries explicit daemon ownership metadata:
externaldaemons are preserved on routine shutdownserver_owneddaemons are tagged withserver_instance_idandserver_pid- shutdown only retires daemons owned by the current server instance
- discovery skips foreign live owners and reaps stale foreign owned daemons
- discovery refuses, without reaping it or removing its entry, a daemon whose
entry names another build or no build. Entries carry
build_identity, which the daemon reads fromBATCHALIGN_BUILD_IDENTITY, set by the server’s daemon spawner and bybatchalign3 worker start. The logged refusal names the remedy:batchalign3 worker stop, thenbatchalign3 worker startwith this build. Each sweep’s refusals are listed in/healthunderrefused_registry_workers(see Observability)
That ownership model is the durable fix for the old orphan-daemon/kill-all whackamole around server-spawned TCP workers.
On the Python side, you must also add the InferTask to _INFER_TASK_PROBES in
batchalign/worker/_handlers.py. See
Adding Inference Providers
for details.
5. Server-side dispatch shape
crates/batchalign/src/runner/routing.rs matches the catalog’s
RunnerDispatchKind exhaustively. Batched text commands (morphotag,
utseg, translate, coref, compare) are routed by name in
dispatch_batched_text_command() to the recipe-owned modules under
crates/batchalign/src/execution/ (morphotag/, utseg.rs, translate.rs,
coref.rs, and kernel.rs for compare); a new text command needs an arm
there. Every other kind goes to its module under
crates/batchalign/src/runner/dispatch/:
fa_pipeline.rs:dispatch_fa_infer()for per-file forced alignmenttranscribe_pipeline.rs:dispatch_transcribe_infer()for audio-to-CHAT generationbenchmark_pipeline.rs:dispatch_benchmark_infer()for transcribe + compare compositionmedia_analysis_v2.rs:dispatch_media_analysis_v2()for opensmile/avqi/diarizespeaker_identity_pipeline.rs:dispatch_speaker_identity()for speaker identification
Recipe-driven execution (new model): Compare has been migrated from
runner/dispatch/ to the recipe-driven execution/ kernel. New commands
should prefer the execution/ model when they have multi-stage workflows.
See crates/batchalign/src/execution/ for the StageExecutor trait
and crates/batchalign/src/planning/ for build_job_plan().
6. Orchestrator module
crates/batchalign/src/commands/foo.rs: The command-owned wrapper that
owns the command’s semantic shape, shared plan selection, and materialization
policy.
crates/batchalign/src/foo.rs or runner/dispatch/*: Keep shared
algorithmic code and reusable runner families here when it improves clarity, but
do not make them the only obvious home of the released command.
For batch text workflows, prefer the named wrappers in
crates/batchalign/src/text_batch.rs over raw tuples:
TextBatchFileInputkeeps one file name and one owned CHAT payload together.TextBatchFileResultskeeps the per-file outcome shape explicit.TextWorkflowFileErrorkeeps file-scoped failure details separate from file identity instead of returningStringerror messages.
7. Worker support
batchalign/worker/_model_loading/: Register the dynamic batch-infer
handler for InferTask.FOO during worker bootstrap if the task needs loaded
runtime state or engine-specific wiring.
batchalign/worker/_infer.py: Only update this file if the task is a
pure static route that does not need bootstrap-installed runtime wiring.
batchalign/inference/foo.py: The Python inference module (pure model
invocation, no CHAT awareness).
8. CHAT operations (if needed)
crates/batchalign/src/foo.rs: Payload collection, cache key
computation, result injection functions used by the orchestrator.
OpenAPI Workflow
# Generate OpenAPI schema
cargo run -q -p batchalign -- openapi --output openapi.json
# Verify schema is up to date (CI gate)
cargo run -q -p batchalign -- openapi --check --output openapi.json
Relationship to the PyO3 Layer
The CLI/server workspace and the PyO3 extension are separate build targets:
- Root workspace (
crates/): operational control plane (CLI + server) crates/batchalign-pyo3/: Python extension module (batchalign_core)
Both share CHAT operations through batchalign. The PyO3 crate
also depends on batchalign (for run_command()) and batchalign
(for OpenAPI types), but it now does so with default-features = false so the
extension path does not compile the standalone binary’s OTLP stack.
See Building & Development for the recommended fast
local loop (one cargo build -p batchalign for the source-checkout
fallback; uv run maturin develop -m crates/batchalign-pyo3/Cargo.toml -F pyo3/extension-module or the
make batchalign-build-wheel → make batchalign-python-prepare
chain when you need the PyO3 extension installed into the dev env).
This page last changed: 2026-09-16 (commit 197c81e6). The whole book last changed: 2026-09-16 (commit 34d249d8).