This document describes the current architecture of TaskFlow for developers working on the codebase.
TaskFlow is a task tracker with kanban boards and workflow state machines. The server is the single source of truth; all clients (CLI, TUI, MCP, simulator) are pure HTTP consumers that import no server internals.
┌──────────────────────────────────────────────────────────┐
│ TaskFlow Server │
│ │
│ taskflow.TaskFlow (Go interface — compile-time safety) │
│ │ │
│ service.Service (business logic, audit, validation) │
│ │ │ │
│ repo.Store eventbus.Bus │
│ │ │ │
│ sqlite.Store SSE endpoints │
│ │
│ HTTP routes derived from model.Resources/Operations │
│ OpenAPI spec auto-generated at startup │
└──────────────────────────────────────────────────────────┘
▲ ▲
│ HTTP + JSON │ SSE (Server-Sent Events)
│ │
┌───────┴─────────────────────────┴────────────────────────┐
│ httpclient.Client │
│ │
│ GetOne, GetMany — typed resource reads │
│ Exec, ExecNoResult — typed operation calls │
│ Subscribe — global event stream with reconnect │
│ │
│ Accepts model.Resource and model.Operation directly │
│ Handles path substitution, query params, auth │
└──────────────────────────────────────────────────────────┘
▲ ▲ ▲ ▲
│ │ │ │
CLI TUI MCP Simulator
The domain surface is defined in two functions:
model.Resources()— 18 read-only endpoints (list boards, get task, etc.)model.Operations()— 23 mutations (create task, transition, delete board, etc.)
Each entry has an explicit Name (e.g. task_list, board_create) that serves as the canonical identifier across all consumers. From these definitions, the system derives:
| Consumer | What's derived |
|---|---|
| HTTP server | Routes, handler mapping, status codes |
| OpenAPI spec | Paths, methods, parameters, schemas, operationIds |
| CLI | Command tree (<resource>_<action> → group + subcommand), flags |
| MCP server | Resources (taskflow:// URIs), tools (input schemas), descriptions |
| httpclient | Path substitution, HTTP method, query string building |
Named references are exported as package-level variables (model.ResTaskList, model.OpTaskCreate, etc.) so consumers reference domain types directly without string lookups.
internal/
├── model/ Domain types and the operation registry
├── taskflow/ Go interface for all business operations
├── service/ Business logic (storage-agnostic)
├── repo/ Storage-agnostic repository interfaces
├── sqlite/ SQLite implementation
├── workflow/ Workflow engine (state machine with JSON definition)
├── transport/ Maps domain actions to HTTP semantics (method, status code)
├── httpclient/ Domain-aware HTTP client for all consumers
├── http/ HTTP server (routes, middleware, OpenAPI generation)
├── mcp/ MCP server (tools + resources derived from model, notifications)
├── cli/ CLI (commands derived from model)
├── eventbus/ In-process pub/sub with ring-buffered subscriptions
├── tui/ Interactive terminal UI (Bubble Tea)
└── testutil/ Test helpers
cmd/
├── taskflow-server/ Server binary
├── taskflow/ CLI binary
├── taskflow-tui/ TUI binary
├── taskflow-mcp/ MCP server binary (stdio transport)
├── taskflow-seed/ Test data generator
└── taskflow-sim/ Activity simulator
Dependencies flow inward. No package imports from a layer above it.
model — zero dependencies (pure domain types)
↑
taskflow — depends on model (interface definition)
↑
repo — depends on model (storage interface)
↑
service — depends on model, repo, taskflow, eventbus, workflow
↑
sqlite — depends on model, repo (storage implementation)
transport — depends on model (HTTP method/status mapping)
httpclient — depends on model, transport, eventbus
http — depends on model, transport, service, taskflow, eventbus
mcp — depends on model, httpclient, eventbus
cli — depends on model, httpclient
tui — depends on model, httpclient, eventbus
The httpclient package has no dependency on the http server package. Clients and server are completely decoupled — they communicate only through HTTP and SSE.
A read-only domain endpoint. Always served via GET, returns 200.
type Resource struct {
Name string // e.g. "task_list"
Path string // e.g. "/boards/{slug}/tasks"
Summary string
MinRole Role // defaults to RoleReadOnly
Output any // zero-value for schema generation
Filter any // struct with `query` tags for filter params
Sort any // struct with `query` tags for sort params
}A domain mutation. HTTP method and status code are derived from the Action.
type Operation struct {
Name string // e.g. "task_create"
Action Action // create, update, delete, transition, etc.
Path string
Summary string
MinRole Role // defaults to RoleMember
Input any // zero-value for schema generation
Output any
}Filter and sort structs use query tags to declare their query parameters:
type TaskFilter struct {
BoardSlug string // path param, no tag — not a query param
State *string `query:"state,Filter by workflow state"`
Assignee *string `query:"assignee,Filter by assignee name"`
Priority *Priority `query:"priority,Filter by priority"`
IncludeClosed bool `query:"include_closed,Include tasks in terminal states"`
}model.QueryParamsFrom(filter) derives the parameter schema (for OpenAPI and CLI flags). model.BuildQueryString(filter) builds a URL query string from a populated struct (for the httpclient). Both use the same tags — the schema and the runtime behaviour are always in sync.
Routes are derived from the model at startup:
model.Resources()→ each becomes a GET routemodel.Operations()→ each becomes a route with method fromtransport.MethodForAction- Handlers are registered in a
map[string]handlerkeyed by Name — no positional coupling
The server also hosts convenience endpoints not in the model:
- SSE event streams (
/boards/{slug}/events,/events) - Batch operations (
/batch) - Dashboard (
/dashboard) - Health check (
/health) - OpenAPI spec (
/openapi.json)
The OpenAPI 3.1 spec is generated at startup from the route list. Each route contributes:
- Path and method
operationIdfrom Name- Path parameters (inferred from
{param}in path) - Query parameters (derived from Filter/Sort struct tags)
- Request body schema (from Input struct's json tags)
- Response schema (from Output type)
The shared HTTP client provides domain-aware access for all consumers. Key design points:
- Constructor:
httpclient.New(baseURL, apiKey)— stores context internally - Generic functions (package-level, due to Go's lack of generic methods):
GetOne[T](client, resource, params, filter)— single typed resultGetMany[T](client, resource, params, filter)— typed sliceExec[T](client, operation, params, body)— typed mutation resultExecNoResult(client, operation, params, body)— no-content mutations
- Event subscription:
client.Subscribe(ctx, opts)returns anEventStreamwith channels for events, errors, and connection status. Reconnects with exponential backoff. - Path substitution:
model.SubstitutePathreplaces{param}placeholders - Query strings:
model.BuildQueryStringserializes filter structs viaquerytags, or acceptsmap[string]stringfor CLI flag values
In-process pub/sub (eventbus.EventBus). The service layer publishes events after successful mutations. Subscribers receive events on buffered channels (256-entry ring buffer — publishing never blocks).
Every event carries before/after task snapshots:
type Event struct {
Type string // e.g. "task.transitioned"
Timestamp time.Time
Actor ActorRef
Board BoardRef
Before *TaskSnapshot // nil for creates
After *TaskSnapshot // nil for deletes
Detail any
}Consumers can diff state without refetching. For creates, Before is nil. For deletes, After is nil.
GET /boards/{slug}/events— board-scoped streamGET /events— global stream with optional?boards=and?assignee=filters
Both include heartbeats and support reconnection via ?token= for auth (since EventSource can't set headers).
The TUI subscribes to the global event stream at startup and routes events into per-board ring buffers. Switching boards swaps the active buffer — no reconnection needed, and event history is preserved.
Each board has a workflow defined as a JSON state machine:
{
"states": ["backlog", "in_progress", "review", "done", "cancelled"],
"initial_state": "backlog",
"terminal_states": ["done", "cancelled"],
"transitions": [
{"from": "backlog", "to": "in_progress", "name": "start"},
{"from": "in_progress", "to": "review", "name": "submit"}
],
"from_all": [{"to": "cancelled", "name": "cancel"}]
}The workflow engine validates transitions, enforces terminal states, and provides health checks (detecting tasks orphaned in states with no outgoing transitions). Workflows are validated on board creation and replacement.
All binaries embed a version string from git describe --tags --always at build time via ldflags. The internal/version.Version variable defaults to "dev" if not set.
- Server adds
X-TaskFlow-Versionto all response headers /healthincludesversionin the JSON response- MCP reports the version during capability negotiation
- The httpclient checks the server version on the first request and warns on stderr if versions differ or the header is missing
API keys are SHA-256 hashed and stored with actor records. Creating an actor via the API generates a random key and returns it once in the response. Keys can be rotated with PATCH /actors/{name}/rotate-key — the old key is immediately invalidated. Three roles:
| Role | Permissions |
|---|---|
admin |
All operations including actor/webhook management and board deletion |
member |
Create/update/transition/delete tasks, comments, deps, attachments |
read_only |
Read all data, no mutations |
Each Resource and Operation declares a MinRole. The HTTP middleware checks the actor's role against the endpoint's requirement.
When a board is soft-deleted, mutations are blocked (403 Forbidden) except for adding comments (append-only). Read operations continue to work. Boards can be listed with include_deleted=true.
The test suite has three layers:
- Unit tests —
inferPathParams,MethodForAction,StatusForAction, operation invariants - Golden tests — full OpenAPI spec and CLI command tree snapshots; any change produces a diff
- Integration tests — HTTP server tests with in-memory SQLite, CLI tests against httptest server
- QA smoke test —
scripts/qa-test.shruns 45 automated checks against a live server: all resource endpoints, mutations, audit, convenience endpoints, OpenAPI spec, dashboard, and CLI commands
Run everything with just test. Run without the smoke test with just test-unit.