Crate Tour
This chapter walks through each of the six Rust crates in the main workspace in depth: what it owns, what it deliberately does not own, the key files, and the patterns worth understanding. tack-core, tack-db, tack-api, and tack-cli predate the Part III runner-fleet cycle; tack-orch and tack-runner were added by it. A seventh crate, tack-desktop, sits outside that workspace by design — its own section at the end of this chapter says why.
The SolidJS web UI in
frontend/is covered separately in Frontend & Design System — structure, the design-token system, and theshared/uicomponent kit.This chapter is a per-file walkthrough, not a reference. For crate boundaries stated as a condensed table, design patterns, the DB schema, the full API endpoint list, and troubleshooting, see
docs/ARCHITECTURE.md— the authority when the two disagree on a fact rather than depth.
tack-core
Lives in: crates/tack-core/src/
Owns: domain models, workflow engine, vocabulary system, dependency DAG, typed error enum, and the two contracts Tack reads and writes as plain data: an item's brief (brief.rs: the types, their validation and a Markdown rendering for the harness) and the merge-readiness pack (mrp.rs: the mrp-v1 types and their pull-request rendering).
Does not own: anything that performs I/O. No sqlx, no reqwest, no file operations, no tokio. This is enforced by the Cargo.toml — the crate has no async runtime dependency at all.
models.rs
The single source of truth for every domain struct. Notable types:
Item— the universal work unit. Fields includeitem_type: ItemType,status: String,parent_id: Option<Uuid>,tags: Vec<String>. Thestatusfield is a plain string rather than an enum because valid statuses are project-specific configuration, not compile-time constants.Project— carries theworkflow: WorkflowConfigandvocabulary: VocabularyMapinline. Both are serialised to JSON when stored in SQLite.ItemType— an enum withEpic,Feature,Task,Subtask,Bug,Requirement, andCustom(String). TheCustomvariant allows ad-hoc item types without a code change.CreateItem,UpdateItem,CreateProject,UpdateProject, etc. — all DTOs used for both API deserialization and repository function parameters. Keeping them intack-coremeans the API and CLI reference the same validated shapes.
Validation constraints (length, range) are expressed via the validator crate's derive macros directly on the DTO structs. The API handlers call .validate()? before doing anything with the data.
workflow.rs
Defines WorkflowConfig, which is what gets stored as JSON per project.
#![allow(unused)] fn main() { pub struct WorkflowConfig { pub workflow_type: WorkflowType, pub statuses: Vec<StatusDef>, pub transitions: Option<Vec<Transition>>, } }
Each StatusDef has a name, a category (Todo, InProgress, or Done), an optional wip_limit, and an order integer for display sorting.
validate_transition(from, to) is the central enforcement function. It:
- Checks that both names exist in the status list.
- If
transitionsisSome(list), checks that the pair appears in the list. - Returns
Ok(())orErr(CoreError::InvalidTransition { from, to }).
If transitions is None, any move between two known statuses is allowed. This is the default for Scrum and Kanban workflows.
check_wip_limit(status, current_count) looks up the StatusDef for the target column and returns Err(CoreError::WipLimitExceeded { ... }) if current_count >= limit.
Preset functions produce ready-made configs for each domain:
| Function | Type | Statuses | Transitions |
|---|---|---|---|
scrum_workflow() | Scrum | Backlog, To Do, In Progress (WIP 5), In Review (WIP 3), Done | None (open) |
kanban_workflow() | Kanban | Queue, In Progress (WIP 3), Review (WIP 2), Done | None (open) |
simple_workflow() | Simple | To Do, Doing, Done | None (open) |
construction_workflow() | Construction | Permit, Procurement, Build, Inspect, Handover | Explicit linear list |
workflow_for_type(project_type) maps each ProjectType to the right preset. Adding a new project type means adding a variant to the ProjectType enum, a preset function, and a match arm here.
The test suite in this file covers initial status selection, transition validation for open and constrained workflows, WIP limit edge cases, done-status detection, and parent-completion logic — all without any database or async runtime.
vocabulary.rs
VocabularyMap is a type alias for HashMap<String, String>. It maps canonical keys (like "task", "sprint", "epic") to their display labels for a given project.
resolve(vocab, key) looks up a key in the project's vocabulary, falls back to the default vocabulary, and finally falls back to the key itself. This means partial vocabularies work fine — a construction project only needs to override the terms it cares about.
vocabulary_for_type(project_type) provides preset vocabularies. The construction preset, for example, maps:
"task"→"Work Order""sprint"→"Phase""epic"→"Building""bug"→"Defect"
validate(vocab) ensures that all keys in a user-supplied vocabulary are from the recognised list (VOCABULARY_KEYS). Unknown keys return Err(CoreError::InvalidVocabularyKey(...)) — this prevents typos from silently creating orphaned entries.
dependency.rs
DependencyGraph is an adjacency-list representation of item dependencies:
#![allow(unused)] fn main() { pub struct DependencyGraph { edges: HashMap<Uuid, Vec<(Uuid, DependencyType)>>, // item → items it blocks reverse_edges: HashMap<Uuid, Vec<(Uuid, DependencyType)>>, // item → items that block it } }
DependencyGraph::from_edges(edges) builds the graph from a slice of DependencyEdge values. The API handler loads all existing dependencies for the involved items, builds the graph, then calls validate_new_edge(source, target) before inserting.
would_create_cycle(source, target) runs a depth-first search starting from target, following the edges adjacency list. If it ever reaches source, adding source → target would close a cycle and the function returns true. The check is O(V + E) over the existing graph.
validate_new_edge wraps the check in a Result, also catching the self-reference case (source == target).
error.rs
CoreError is a thiserror-derived enum that covers every domain-level failure:
ItemNotFound(Uuid),ProjectNotFound(Uuid),SprintNotFound(Uuid),RoleNotFound(Uuid)— map to HTTP 404.InvalidTransition { from, to },WipLimitExceeded { column, limit, current },DependencyCycle(Uuid),DuplicateDependency { ... },InvalidVocabularyKey(String),EmptyWorkflow,HasChildren(Uuid, usize),Validation(String)— map to HTTP 400.
The mapping from CoreError to HTTP status codes lives in tack-api/src/error.rs, keeping the core crate free of HTTP knowledge.
tack-db
Lives in: crates/tack-db/src/
Owns: SQLite connection pool initialisation, migration runner, repository pattern over all entities.
Does not own: HTTP concerns, config loading, or business rule enforcement. The repository functions are thin: they translate between Rust structs and SQL rows.
lib.rs
init_pool(database_url) creates a SqlitePool using SqlitePoolOptions with a max of 5 connections, then immediately runs two PRAGMA statements:
PRAGMA journal_mode=WAL— enables Write-Ahead Logging for better concurrent read performance.PRAGMA foreign_keys=ON— SQLite does not enforce foreign keys by default; this enables them.
migrations.rs
Contains every migration (check GET /api/health's migrations_applied field for
the current count rather than trusting a hand-written number here) as const arrays
of SQL strings. Each entry is (&str name, &[&str] statements). The runner:
- Creates
_migrationstable if absent. - For each migration, checks if the name is already recorded.
- Executes each SQL statement in order.
- Records the migration name on success.
Migrations are idempotent — running them on an existing database is safe. Notable migrations:
004_items— creates theitemstable with indexes onproject_id,status,priority,parent_id, andsprint_id.010_fts— creates the FTS5 virtual tableitems_ftsand three triggers (after_item_insert,after_item_update,after_item_delete) that keep the FTS index in sync with theitemstable.012_custom_fields—custom_field_definitionsandcustom_field_valuestables.016_perf_indexes— additional composite indexes added after profiling.039–048— the ten neutral runner-v1 execution-domain tables (execution requests, attempts, events, decisions and artifacts; agent profiles; runner fleets and their members; model profiles).049+ refine execution replay, recovery and attempt-start facts.078–081— item briefs, the decision pack columns (options' details and risks, the recommendation, when a decision was first seen), merge-readiness pack reviews and pull requests.
Each ordinary migration runs in its own transaction with the _migrations record
inserted at commit; a failing statement rolls the whole migration back. Applied
migrations are checked at every startup against the binary's own ordered list by name
and a deterministic checksum — an edited or reordered history refuses to boot
rather than running silently. A small subset (037/038, a table
copy/verify/swap rebuild predating Part III) additionally takes an automatic
VACUUM INTO snapshot before its first attempt. See docs/MIGRATION-GUIDE.md for the
operator-facing version of this and docs/adr/0008-transactional-migration-rebuild-recovery.md
for the design rationale.
repo.rs and repo/
repo.rs declares the Repository struct, which holds a SqlitePool, and re-exports a method for each database operation by delegating to the appropriate submodule:
#![allow(unused)] fn main() { pub struct Repository { pool: SqlitePool, } impl Repository { pub async fn create_item(&self, ...) -> Result<Item, sqlx::Error> { items::create_item(self.pool(), ...).await } // ... } }
This design gives callers a single repo value to pass around while keeping each entity's SQL in its own file.
Per-entity submodules (items.rs, projects.rs, sprints.rs, roles.rs, comments.rs, dependencies.rs, attachments.rs, boards.rs, templates.rs, custom_fields.rs, briefs.rs, pull_requests.rs, metrics.rs, github_links.rs, execution.rs):
- Functions take
&SqlitePool(or&selffor the struct-based submodules) and returnResult<T, sqlx::Error>orResult<T, DependencyError>. - Queries use
sqlx::query/sqlx::query_aswith positional?parameters. - UUIDs are stored as
TEXT— bound as.bind(id.to_string())and parsed back from the row. - JSON fields (
workflow,vocabulary,tags) are serialised to/from strings withserde_json. - Timestamps are stored as RFC 3339 strings and parsed via
chrono::DateTime<Utc>.
Notable function: check_and_update_parent_status (in items.rs). After an item is moved to a Done-category status, the handler calls this function with the item's parent_id. It queries whether all sibling items are also in a done status, and if so, updates the parent. The WorkflowConfig::should_complete_parent(all_siblings_done) call in tack-core provides the decision logic — the repository only handles the data queries.
tack-orch
Lives in: crates/tack-orch/src/
Owns: the neutral runner-v1 execution domain (execution/): lifecycle
validation, fencing/idempotency types, and the pure state-machine rules that both
tack-api's handlers and tack-runner's protocol implementation must agree on, plus
the scheduler, model-policy resolver, and the execution domain's own
retention/observability/provenance background modules.
Does not own: HTTP handling or SQL. Depends only on tack-core and tack-db; the
dependency points inward deliberately — tack-api depends on this crate (to run the
scheduler/retention/observability tasks and expose the execution routes), never the
reverse. This boundary is load-bearing: it's what lets tack-runner's tests exercise
the same lifecycle rules as the server without linking Axum.
execution/lifecycle.rs
validate_transition(from, to, actor) is the single authority for every legal
execution-attempt state change. States: queued | leased | preparing | running | waiting_decision | succeeded | failed | cancelled | lost | needs_operator. Each
transition is validated against both the state pair and which TransitionActor
(Scheduler, Operator, LeaseOwner, RecoveryService) is allowed to request it —
for example, only RecoveryService may move an attempt into lost or
needs_operator; a lease owner reporting the same crash cannot self-authorize it. See
the Recovery Runbook for what drives those
transitions operationally.
execution/types.rs
Wire-shape-adjacent types shared by both the server and (once wired) the runner:
AgentProfileSnapshot { name, instructions, tool_policy, timeout_seconds, budgets },
RepositorySnapshot { kind, remote, base_revision }, PermissionPolicy { tools, network }, and EnvironmentValue { value, secret_reference } — the last one is why
execution requests never store a raw secret: every environment entry is either a
literal non-secret value or an opaque reference the runner resolves locally.
scheduler/
The deterministic fleet scheduler: given a candidate set of runners (health,
capacity, labels, declared harness and model support) and a request (exact runner or
fleet selector, required harness, optional provider/model, priority), it decides
which runner gets the work, or a typed reason none qualify. Two entry points:
select::select_runner for one request against a candidate pool, and batch::schedule
for several requests sharing one pool, ordered by priority then FIFO fairness. Both
are pure and synchronous — no database, no network client — and neither grants the
authoritative lease; only the repository's fenced claim
(docs/contracts/runner-v1/) can do that. wiring::choose_request_for_runner is the
live bridge: it loads real agent_runners / agent_fleet_members /
execution_requests rows and calls into the pure core above, called ahead of the
naive ORDER BY created_at LIMIT 1 match in tack-db's claim query. The only
production caller is the claim handler in tack-api's runner protocol.
model_policy/
Deterministic model-selection precedence: request override → agent-profile default →
project default → fleet default → nothing configured, meaning auto-select.
resolve_model_policy is pure; wiring is the tack-db-backed caller that fetches
each tier's configured default and hands the result in, mirroring the scheduler's own
pure-core/live-wiring split. Every resolved value is still a request, whichever tier
supplied it — intersecting it against a runner's declared capability is the
scheduler's job, not this module's; and a resolved value is never conflated with the
actual model an attempt reports back, which usage_provenance compares separately.
execution_retention.rs and execution_observability.rs
Two sibling background tasks — not submodules of execution/, which is deliberately
I/O-free — because both are persistence-bearing work that runs on a timer. Retention
sweeps stale terminal-attempt event rows out of execution_events on an injectable
clock (RetentionClock, so tests never depend on wall time) with a cancellation
signal raced against its inter-sweep sleep; there is no daily roll-up table for
execution_events yet, so this purges rows outright rather than aggregating them,
and says so rather than calling itself a "roll up". Observability computes a periodic,
id-free snapshot of runner/queue/lease/
event counts and logs alerts from it — keyed only by the domain's two small, closed
state vocabularies (agent_runners.state, execution_requests.state), never by
attempt/request/runner id, so the label set stays bounded regardless of fleet size.
usage_provenance.rs
Two independent pure concerns, neither performing I/O. compare_model_provenance
checks the request's resolved model (or "no model requested") against the attempt's
actual, observed execution — visible as a mismatch, never silently reconciled.
build_usage_economics keeps runner-observed wall-clock time cost structurally
separate from the harness/vendor's own self-reported token or dollar usage, never
summed into one opaque number. Every dollar-valued field in this crate is named
*_usd_estimated, never *_usd alone, and absent usage is a Measurement with
source: NotMeasured, never a fabricated 0.
tack-api
Lives in: crates/tack-api/src/
Owns: HTTP server startup, route registration, request/response handling, configuration, WebSocket management, error mapping.
Does not own: SQL queries (those are in tack-db) or business rules (those are in tack-core). Handlers orchestrate calls to both.
This crate is a library only — it does not produce its own binary. The single
tack binary (in tack-cli) calls tack_api::serve() to start the server.
server.rs
Exposes pub async fn serve(), the server entry point. It does these things in order:
- Loads
AppConfig(TOML file or environment variables). - Initialises the
tracingsubscriber (plain text or JSON depending on config). - Applies any staged database restore (rename
.restorefile into place). - Calls
init_pool()andmigrations::run_all(). - Ensures a default workspace row exists (creates one if the table is empty).
- Builds
AppState, callsbuild_router(state), and startsaxum::servewith graceful shutdown onCTRL+C.
tack-cli builds a Tokio runtime and calls serve() when you run tack with no
subcommand (or tack serve).
router.rs
AppState is the shared state cloned into every handler:
#![allow(unused)] fn main() { pub struct AppState { pub repo: Repository, pub config: AppConfig, pub workspace_id: Uuid, pub broadcast_tx: broadcast::Sender<BoardEvent>, } }
build_router(state) assembles the Axum Router. Routes are grouped by entity and nested under /api. The file also wires up:
- CORS — reads
config.allowed_origins, constructs atower_http::cors::CorsLayer. - Body limit —
DefaultBodyLimit::max(config.max_body_size_bytes)globally; the attachment upload route overrides this to 50 MB. - Security headers —
X-Content-Type-Options: nosniff,Referrer-Policy: same-origin,X-Frame-Options: DENYviaSetResponseHeaderLayer. - Request tracing —
TraceLayerlogs every request with method and URI. - Token gate —
middleware::from_fn_with_state(state, require_token)wraps all/apiroutes. - embed-spa feature — when compiled with
--features embed-spa, a fallback handler serves the bundled SPA.
handlers/
One file per entity group. The agent-work ones: executions.rs and attempt_lists.rs (requests, and the attempts with their pull request), decisions.rs, briefs.rs (/items/{id}/brief), mrp.rs (an attempt's merge-readiness pack, its viewed mark and its review), metrics.rs (/projects/{id}/metrics/factory) and runner_protocol/ (the runner's own surface).
A typical handler follows this shape:
#![allow(unused)] fn main() { pub async fn update_item( State(state): State<AppState>, Path(id): Path<Uuid>, Json(input): Json<UpdateItem>, ) -> ApiResult<Json<Item>> { input.validate().map_err(|e| ApiError::BadRequest(e.to_string()))?; // load, validate, persist, broadcast } }
Handlers return ApiResult<Json<T>>, which is Result<Json<T>, ApiError>. The ApiError type implements IntoResponse, so Axum converts errors to JSON automatically.
websocket.rs is somewhat different from other handler files — see the Architecture Overview for a full walkthrough of the connection lifecycle. The key public API it exposes to other handlers is:
#![allow(unused)] fn main() { pub fn broadcast_event(state: &AppState, event: BoardEvent) { ... } }
Any handler that mutates data calls this after a successful write.
config.rs
AppConfig::load() tries to read tack.toml from the current directory. If that fails, it reads environment variables (TACK_HOST, TACK_PORT, TACK_DATABASE_URL, etc.) over a Default::default() base. There is no figment or other config framework — the logic is a straightforward chain of if let Ok(v) = std::env::var(...) assignments.
The API token is never logged. The only place it appears in logs is a boolean "token configured: true/false" in the startup message.
error.rs
ApiError is the unified error type for all handlers. It implements IntoResponse with this mapping:
ApiError variant | HTTP status |
|---|---|
NotFound | 404 |
BadRequest | 400 |
Conflict | 409 |
Core(CoreError::*NotFound*) | 404 |
Core(CoreError::InvalidTransition | WipLimitExceeded | …) | 400 |
Database | 500 |
Internal | 500 |
The response body is always { "error": { "status": <code>, "message": "<text>" } }.
tack-runner
Lives in: crates/tack-runner/src/
Owns: its own binary (tack-runner, entirely separate from tack) — local
enrollment/credential handling, the isolated per-attempt workspace, the owner-only
attempt journal, the harness adapter layer, and the steps that follow a succeeded attempt (evidence.rs, verify.rs and the branch push in git.rs). Does not own anything the API
must not touch: vendor credentials, workspace contents, and the harness subprocess
never leave this crate. See Agent Runners & Fleet Execution
for the operator-facing view of everything below.
config.rs
RunnerConfig::from_sources layers defaults → TOML file → environment
(TACK_RUNNER_API_URL, TACK_RUNNER_ID, TACK_RUNNER_STATE_DIR,
TACK_RUNNER_ENROLLMENT_TOKEN) → CLI flags. EnrollmentCredential's Debug/
Display are hardcoded to print [REDACTED] — redaction here is structural, not a
convention a future println! could accidentally bypass.
journal.rs and workspace.rs
A WorkspaceJournal record is written to TACK_RUNNER_STATE_DIR before any
harness process is allowed to spawn — this ordering is what makes crash recovery
possible (see the Recovery Runbook).
JournalState tracks Prepared → ProcessObservedRunning → ... → Reported; a restart
finds this file and reports what it actually observed rather than guessing.
WorktreeProvisioner is the sole trait allowed to create a workspace's git worktree —
tests inject a fake so unit tests never touch a real checkout.
client.rs — RunnerProtocolClient
Defines the trait the runner's runtime loop drives: enroll, refresh, claim,
heartbeat, and per-attempt accept/start/events/decisions/artifacts/
completion/cancellation/recovery-observation. The HTTP-backed implementation is
transport::HttpPullProtocol, which bootstrap::build_runtime wires for both the
standalone binary and the embedded runner. UnavailableProtocolClient remains only as
the typed RunnerError::ProtocolUnavailable fallback for a runtime built without a
client — see
What actually runs today.
evidence.rs, verify.rs and the push in git.rs
What the engine does between a terminal outcome and deleting the workspace. evidence.rs
reads the attempt's change out of the workspace for every harness (changes.patch,
files.json, evidence.json, plus brief.json when the request carried one; the shape is
docs/contracts/evidence-v1/). verify.rs runs the operator's [verify] program over that
evidence and stages the merge-readiness pack it writes. git.rs also holds the branch push
([git] push_branches). None of the three can change the attempt's outcome: a failure is an
event (attempt.verify_failed, attempt.push_failed) and the attempt still completes.
harness/
The adapter layer: process.rs (bounded output capture, timeouts, process-group
cancellation), event_sink.rs (backpressure), redact.rs, artifact.rs, and
local_process.rs — the one lifecycle every local CLI harness shares: locating the
binary, the version probe, the request policy, environment and secrets, provider
injection, spawn, cancel, reconcile, log staging and the outcome. A harness adds a
HarnessDescriptor (data) and a four-method HarnessGrammar (its command line, how its
output is read, what it supports, and optionally how it drives a conversation so a run can pause and ask): codex.rs, claude_code.rs, docket.rs, opencode.rs. docket's contract and flags are probed when the runner starts, never assumed from its version. Adding one is a module
plus a line in harness::DESCRIPTORS and one in harness::discover. The harness
vocabulary itself stays open (HarnessKind::Other(String)). Two engine-facing
traits:
HarnessAdapter (per-attempt lifecycle: validate/start/cancel/wait/
reconcile) and HarnessProbe (version/capability discovery). AdapterRegistry
implements HarnessAdapter by dispatching on harness kind and refuses to register
any probe claiming cancel: supported unless its harness announces the process groups
its tools start (only docket on contract 1.1 does) — every other harness's own shell tool
spawns its subprocess in a new session outside the runner's process group, confirmed
against the real binaries with ps. Live harness
tests are #[ignore]d and never required in CI; harness/fixtures/fake_harness.sh,
driven by TACK_FAKE_HARNESS_MODE, is the always-runnable path every required test
uses instead.
tack-cli
Lives in: crates/tack-cli/src/
Owns: the single tack binary — both starting the server and the command-line client (parsing, human-readable output, HTTP calls to the API).
Does not own: any tack-core or tack-db types directly. The client commands work entirely through the HTTP API — they serialise to JSON for requests and deserialise from serde_json::Value for responses (no strongly-typed response structs). This keeps the CLI decoupled from internal model changes that do not affect the API contract. (To run the server it depends on tack-api and calls tack_api::serve().)
main.rs
Uses clap's derive API. The top-level Cli struct has two global flags (--api-url, --token) and an optional Commands enum. Run tack --help for the authoritative, current list; as of this writing it is:
- Board basics:
serve,init,projects,add,list,move,board,branch,search,sprint,config,completions - Backup/restore:
backup,backups,restore - Project setup:
template,role,comment,field - Agent onboarding and the runner-fleet surface:
mcp(MCP server over stdio),execution(create/list/cancel/reconcile execution requests),fleet(runner fleets),runner(enroll/revoke execution runners),service(runtackas a systemd/launchd background service),agent-profile(instructions, tool policy, limits)
Running tack with no subcommand — or tack serve — starts the server + web UI: run_server() builds a Tokio runtime and calls tack_api::serve(). This is the primary, UI-first entry point. Everything else is the CLI client.
Three cases (serve, config, and completions) are handled before the TackClient is constructed, since they do not need a live connection.
All other commands instantiate a TackClient, call the appropriate method, and then either print raw JSON (with --json) or format a human-readable table. The table formatter (print_table_row) pads and truncates columns to fixed widths, which keeps the output readable in standard terminals.
The add and list commands fetch the project's vocabulary via vocab::fetch() and translate item_type strings through it before printing — so a construction project shows Work Order rather than task in the output.
client.rs
TackClient wraps reqwest::blocking::Client. All methods prepend /api to the supplied path and attach the Authorization: Bearer <token> header when a token is configured.
Public methods:
get(path)→serde_json::Valuepost(path, body)→serde_json::Valuepatch(path, body)→serde_json::Valuedelete(path)→()get_bytes(path)→Vec<u8>— used for backup downloadpost_bytes(path, data)→serde_json::Value— used for restore upload
Error handling: the extract() helper parses the response body regardless of status code, then returns the body on success or extracts the error.message field and surfaces it as an anyhow::Error on failure.
config.rs
Config::load(base_url_override, token_override) applies a precedence chain:
- CLI flag value (passed as
Option<String>) - Environment variable (
TACK_API_URL,TACK_API_TOKEN) ~/.tackrc— a TOML file withbase_urland optionaltokenfields- Default:
http://127.0.0.1:3210
config::save(base_url, token) writes ~/.tackrc. This is what tack config --url <url> does.
tack-desktop
Lives in: crates/tack-desktop/src/
Owns: the Tauri shell — a window, a system tray icon, and the supervisor that either attaches to a tack serve already answering on the configured port or starts one itself as a bundled sidecar. Built with make desktop; excluded from the root workspace (see the crate map in the top-level CLAUDE.md) so a contributor without Tauri's system dependencies (GTK, WebKit) still builds every other crate with a plain cargo build --workspace. Its own Cargo.lock, CI job, and Dependabot entry follow from that same exclusion.
Does not own: the server. It never opens the database or reimplements anything tack-api already does — only starts, attaches to, and supervises the tack binary as a child process, and only ever stops a server it started itself.
main.rs
Builds the Tauri app and calls attach_or_start before creating the window — the window exists only on Ok. Of the ways that call can fail, two render a dialog naming the reason before exiting (a port already held by something that isn't Tack; an attached server older than the bundle), and a catch-all arm covers everything else. The window opens at 1200x800 (WebviewWindowBuilder::inner_size).
supervisor.rs
attach_or_start probes the configured port for a Tack server already answering at a compatible version and attaches to it instead of spawning a second one; otherwise it spawns the bundled sidecar binary and waits up to a fixed health timeout for /api/health to answer. A server this process did not start is never signalled to stop, on any exit path.
first_run.rs
ensure_settings runs before the supervisor does: on an empty data root it shows a first-run dialog (database path, port) and writes the answer to settings.json before anything tries to reach a server. The dialog call itself runs off the main thread — Tauri's dialog plugin deadlocks the event loop if it doesn't.
tray.rs
Builds the tray icon and its menu: Open Tack (show or refocus the window), a disabled agent execution status line, a checked Launch at login toggle (on by default the first time), and Quit. The status line polls GET /api/local-runner every three seconds — at the port this app's own settings.json names, falling back to the default — and renders one of six typed labels: on, off, turning on…, turning off… (the persisted preference and the runtime state disagreeing while a toggle takes effect), waiting for the server… (nothing answering yet), and status unavailable (request failed). It is a status line and not a switch: it never writes, and a server that has not answered is never rendered as off. Closing the window only hides it — the server keeps running; Quit is the action that actually stops it.