A registration that is not a typed operation is spelled Raw now, with the method
named, so opting out of the typed model reads as what it is.
The zip this pins also states the wire explicitly, which matters more than the
version gap suggests: JSON semantics follow the toolchain, so the older pin
answered with different bodies than the code was written against once the
builder moved.
Co-authored-by: Hanzo Dev <[email protected]>
Architecture
One SQLite shard per (org, namespace) under <DataDir>/<org>/<ns>.db
with WAL mode, single writer, 5s busy timeout. Replaces the prior
shared memdb/zapdb store. Shard manager opens/caches/idle-evicts;
prefix scans, namespace isolation, and cross-restart durability all
proven by tests.
Replication (post-quantum, native)
New pkg/tasks/replication/ exposes a Replicator interface with two
drivers:
local — single-node passthrough (default for embedded/dev)
quasar — wraps github.com/luxfi/consensus NewPQ (BLS + Corona
dual-threshold). On every WAL-affecting op the shard
builds a Frame, calls Propose, awaits Decision, applies
on Accept. Followers receive accepted frames via the
same Subscribe path so every node converges.
Sticky task-queue routing
New pkg/tasks/routing/ — rendezvous-style smallest-hash leader
election keyed by SHA-256(shard|taskQueue|seed). OnChange fan-out
re-hashes on cluster membership change. Dispatcher consults Router
before accepting subscriptions and forwards non-leader requests.
Namespace migration
New pkg/tasks/migration/ — lock → checkpoint → copy → unlock with
a job lifecycle (pending|locking|copying|replaying|released|failed).
CLI: `tasksd migrate --org <id> --namespace <name> --to <node>`.
HTTP: POST /v1/tasks/namespaces/{ns}/migrate.
New env / flags
--data / TASKSD_DATA_DIR shard root (./tasks-data)
--replicator / TASKSD_REPLICATOR local | quasar (default local)
--node-id / TASKSD_NODE_ID stable id (required for quasar)
--validators / TASKSD_VALIDATORS CSV host:port
--validator-key / TASKSD_VALIDATOR_KEY (reserved for PQ key path)
TASKSD_STORE sqlite | memory (default sqlite)
New routes
GET /v1/tasks/cluster
GET /v1/tasks/cluster/health
POST /v1/tasks/namespaces/{ns}/migrate
UI bundle (synced)
Cluster page with status / validators / shards / leader-map.
NamespaceDetail "Migrate to node" button gated by
canWriteNamespace + cluster.enabled. MigrationDialog enforces
typed-name confirmation. Sidebar Cluster nav hidden when running
single-node.
Tests
+33 backend tests (store: 11, replication: 10, routing: 5,
migration: 3, cluster routes: 2, store factory: 2). All packages
PASS with -race; go vet clean.
+13 UI tests (effectiveHealth: 6, canSubmitMigration: 4,
useCluster: 3). 169 total UI tests, 1 pre-existing skip.
Migration v3.8.x → v3.9.0
Fresh-data startup. zapdb backend dropped. Operators drain old
cluster, snapshot for archival, deploy v3.9.0 with new --data
directory. To enable HA, scale to 3+ replicas with --replicator
quasar and matching --node-id / --validators.
Bundles the wire-shape fix (Deployment: name + versions). Front-end was
reading seriesName/buildIds from the v3.7.x type which v3.8.0 renamed.
1 Deployment list page rendered blank with TypeError; now renders
correctly.
Security (red-team pass)
- ZAP wire handlers were org-blind: every opcode bypassed WithOrg
and used the unscoped engine. Patched: zapHandlers takes a
JWTValidator; per-request engine is scoped to claims.Owner.
TASKSD_REQUIRE_IDENTITY enforces validation in production.
- /v1/tasks/mcp tools/call ran on the unscoped engine — patched
to use auth.OrgID(r.Context()).
- /v1/tasks/events SSE fan-out was global; events now stamp OrgID
via engine.emit(ev) and the SSE handler filters per-request org.
- AddSearchAttribute rejected reserved/system names (WorkflowId,
ExecutionStatus, TaskQueue, *StartTime, Temporal*).
- Path-traversal guards on every {ns}/{id} URL slot (validIdent +
validPathSegment).
- dispatcher.queries map is now bounded (4096 entries, 5m TTL,
drop-oldest on overflow) — closes a DoS via silent worker.
Durability
- newStoreFromEnv selects memdb (default) or zapdb via
TASKSD_STORE=zapdb. Survives restart; covered by
TestStore_ZapdbPersistsAcrossOpen.
Inverse-of-options
- RevokeIdentity, DeleteNexusEndpoint, RemoveSearchAttribute,
DeprecateNamespace, DeleteDeploymentVersion, UnsetCurrent.
- Schedule full update + trigger + matching-times.
13 new HTTP routes
GET /v1/tasks/settings
DELETE /v1/tasks/namespaces/{ns}
POST /v1/tasks/namespaces/{ns}/schedules/{id} (full update)
POST /v1/tasks/namespaces/{ns}/schedules/{id}/trigger
GET /v1/tasks/namespaces/{ns}/schedules/{id}/matching-times
POST /v1/tasks/namespaces/{ns}/deployments/{name}/set-current
DELETE /v1/tasks/namespaces/{ns}/deployments/{name}/versions/{buildId}
GET /v1/tasks/namespaces/{ns}/archival
GET /v1/tasks/namespaces/{ns}/workflows/{id}/executions
GET /v1/tasks/namespaces/{ns}/task-queues/{q}/partitions
GET /v1/tasks/namespaces/{ns}/workers/{identity}
DELETE /v1/tasks/namespaces/{ns}/nexus/{id}
DELETE /v1/tasks/namespaces/{ns}/identities/{email}
DELETE /v1/tasks/namespaces/{ns}/search-attributes/{name}
Tests
go test ./pkg/tasks/... -race -count=1 → PASS, +22 new tests
(cross-org keyspace isolation, query non-widening, reserved attrs,
pendingQuery bounds, TTL eviction, Identifier validation, persistence
cross-restart). go vet clean.
UI bundle (synced)
Canonical Hanzo H mark in HanzoMark.tsx replaces stylized H rectangle.
useSettings() hook gates write actions when namespaceWriteDisabled,
archival/heartbeats/advanced-visibility flags. New inverse buttons
(worker stop, identity revoke, search-attr delete, deployment unset,
batch terminate confirm).
UI's cross-namespace Nexus list page (no ns selector) hit /v1/tasks/nexus
and got 404. Backend now serves a top-level GET that scans the nx/ key
prefix across namespaces. Read-only — namespace-scoped routes remain the
only mutation surface.
Bundles UI fix for EventDetail (handles flat events response + string
eventId param).
Closes the remaining 501 surfaces and routes worker-bound RPCs through
the existing ZAP dispatcher (no polling).
Cancel handshake
Running → CANCELING via OpcodeDeliverCancelRequest (0x00B3) push to
the workflow's task-queue subscribers. Worker acks via existing
RespondWorkflowTaskCompleted with a Canceled command (Kind=3) →
CANCELED. 60s sweep auto-acks if no subscriber.
Query push
Allocates a query token, picks a subscriber via the existing
round-robin dispatcher, sends OpcodeDeliverQuery (0x00B4),
blocks on a per-token channel with ctx timeout.
Worker replies via OpcodeRespondQuery (0x00C4).
HTTP returns 503 when no workers are subscribed, 504 on timeout.
Visibility grammar
Replaces the AND-only parser with full grammar:
OR / NOT / parens / IN / NOT IN / BETWEEN. Existing AND-only
callers round-trip unchanged.
Admin endpoints (no longer 501)
POST /v1/tasks/namespaces/{ns}/search-attributes (+ GET list)
POST /v1/tasks/namespaces/{ns}/metadata
POST /v1/tasks/namespaces/{ns}/batches/{id}/terminate
POST /v1/tasks/namespaces/{ns}/workflows/{id}/metadata
UI bundle
Embedded SPA refreshed to the React 19 + hanzogui port with
route-level code-splitting (main entry 67KB / 14KB gz).
Tests
go test ./pkg/tasks/... -race -count=1 → 36 tests, 0 failures.
The embedded UI in tasksd is now the @hanzo/gui Tamagui build from
~/work/hanzo/gui/code/admin-tasks/. v1 React/shadcn deleted.
What's still here:
ui/embed.go — Go //go:embed wrapper (handler unchanged)
ui/dist/ — synced from admin-tasks build output
ui/README.md — explains the bundle is built externally
scripts/sync-admin-ui.sh — copies admin-tasks/dist → ui/dist + stamp
Makefile — `ui` target now calls the sync script
Dockerfile — drops the node ui-build stage; expects pre-built
dist/ in the repo or built upstream via CI
What's deleted (62 files):
ui/{src,public,node_modules}/ entire trees
ui/{package.json, vite.config.ts, tailwind.config.js, postcss.config.js,
tsconfig.json, index.html, PERF.md, .gitignore, pnpm-lock.yaml,
vite.perf2.config.ts, tsconfig.tsbuildinfo}
UI-TAMAGUI-MOVED.md (no longer accurate)
Live bundle (verified curl http://127.0.0.1:7243/):
/_/tasks/ → 200
/_/tasks/assets/index-BKuZ1al1.js → 200, 674,748 bytes
/_/tasks/assets/index-DTXHKKx4.css → 200, 9,104 bytes
/v1/tasks/namespaces → 200
Bundle: 339 KB → 675 KB raw / 104 KB → 199 KB gzip. Larger because
Tamagui ships react-native-web + Tamagui core + theme primitives
that shadcn doesn't. Trade: design-system consistency across all
Hanzo admin UIs (kms, commerce, console, etc.) vs 95 KB gzip.
CI note: .github/workflows/build.yml's docker build needs a pre-step
that checks out hanzo/gui and builds admin-tasks, OR commits ui/dist/
to this repo. Dockerfile fails fast with a clear message until then.
Tag v3.0.0 because this is a meaningful UI architecture change.
Tamagui Tasks UI moved into the canonical location at
~/work/hanzo/gui/code/admin-tasks/ where @hanzogui/static can resolve
workspace deps. Build now extracts static CSS, bundle drops to
199 KB gzip (under the 200 KB target).
UI-TAMAGUI-MOVED.md — one-page pointer for anyone looking for the
old ui-tamagui-spike/ directory. Points at the new home.
Embedded production UI at ui/ (React + @hanzo/ui-shadcn) is unchanged
— it remains the binary form factor for tasksd. admin-tasks deploys
as a standalone web app at tasks.hanzo.ai when v2.1 ships (lucide
deep-import + React.lazy).
ui/PERF.md — Scientist's bundle audit. The big number: lucide-icons-2
ships all 1,761 icons through the umbrella for the 32 we use. That
single shim saves 783 KB raw / 73 KB gzip and is a 1-hour patch.
Combined with React.lazy() on 13 of 14 routes, v2 Tamagui bundle
lands at 590 KB raw / 183 KB gzip — under the 200 KB target and
better than today's React/shadcn v1 production embed (104 KB gzip).
ui-tamagui-spike/ — refactored to consume @hanzogui/admin instead of
its own copies. Sidebar/TopBar/PageShell + Alert/Badge/CopyField/
Empty/HanzoMark + lib/format/useFetch/lib/events all moved out
(~963 LOC deduped). Spike now at ~250 LOC of app-specific code.
Spike doesn't ship as the embedded UI yet — Tamagui static extraction
needs to clear up (see task #24: workspace move OR vite-plugin
resolver fix). But the structural win is real: the next Hanzo admin
UI (commerce, kms, console) lands by composing @hanzogui/admin, not
by copy-pasting chrome.
Cross-monorepo dedup target: ~3,749 LOC of admin chrome lives across
tasks/ui (v1) + ui-tamagui-spike + iam/ui + console/web. 1,418 LOC
already in the new package; another ~1,800 LOC saves on next round
when StatusBadge / DataTable / DialogForm / FilterBar+SavedViewsRail
extract.
v2.0.0/v2.0.1 shipped a blank Tamagui UI: TamaguiProvider throws
"Missing theme" because @hanzogui/static can't resolve @hanzogui/core
from its `.hanzogui/` temp dir while we live outside the hanzo/gui
workspace. Disabling the extractor avoids the build error but skips
the CSS layer Tamagui needs to populate the theme registry at runtime.
Restoring the proven-working React/shadcn UI as the embedded production
UI (was at ui-shadcn-deprecated/, renamed back to ui/). Tamagui port
moves to ui-tamagui-spike/ as a dev-only reference — runs at :5174
via `pnpm dev` but does not embed.
ui/ — production React + @hanzo/ui-shadcn primitives
ui-tamagui-spike/ — dev-only Tamagui port; embed when Tasks moves
into ~/work/hanzo/gui workspace OR when the
extractor's temp-dir resolution is fixed upstream
Honest tradeoff: ship reliability over v2 perfection. Tamagui v2 ships
when the workspace move OR @hanzogui/vite-plugin's resolver lands.
v2.0.0 shipped a blank UI: TamaguiProvider threw "Missing theme."
synchronously on mount because the previous hanzogui.config.ts was
spreading defaultConfig.themes into a new object literal, which
breaks the static shape getThemeProxied() depends on at runtime.
Reverted to the canonical pattern from
~/work/hanzo/gui/code/starters/expo-router/hanzogui.config.ts:
export const config = createHanzogui(defaultConfig)
Hanzo brand recoloring will land via CSS variable overrides in
src/index.css (a follow-up — the canonical theme renders correctly
out of the box). Build clean, page renders, all routes 200.
Bundle 1.51 MB raw / 297 KB gzipped (unchanged — fix is shape-only).
Two parallel deliverables.
(1) v1 React UI feature port — pages added, all wired through SSE realtime:
- TaskQueues — list of queues (derived server-side from
listed workflows; running/total counts)
- TaskQueueDetail — workflows in a queue, summary stats
- WorkflowHistory — synthetic event timeline (start +
signal counter + terminal); UI shows the
"synthetic: true" hint until per-event
durable history lands in v1.42
- Workers — empty-state stub until the worker SDK
runtime ships (honest, not fake)
- EventDetail — full event payload viewer with JSON tree
- WorkflowDetail (rewrite) — Summary | Call stack | History tabs;
Call stack tab calls QueryWorkflow with
type "__stack_trace" and renders the
501 gracefully
Sidebar gains Task Queues + Workers between Deployments and Nexus.
Routes mounted in main.tsx through PageShell. Bundle 320 → 339 KB
(+5.6%, +2.7 KB gzip).
(2) Server surface — pkg/tasks/embed.go:
GET /v1/tasks/namespaces/{ns}/task-queues
GET /v1/tasks/namespaces/{ns}/task-queues/{q}
GET /v1/tasks/namespaces/{ns}/task-queues/{q}/workers -> []
GET /v1/tasks/namespaces/{ns}/workers -> []
GET /v1/tasks/namespaces/{ns}/workflows/{wf}/history -> synthetic
POST /v1/tasks/namespaces/{ns}/workflows/{wf}/query -> 501
No new opcodes — these are HTTP-only conveniences derived from
engine state. ZAP wire stays the canonical native transport.
pkg/tasks/taskqueues_test.go — both list + detail cases pass.
(3) v2 Tamagui UI spike at ui-tamagui/ — the hanzogui-native rewrite:
- Plain Vite + Tamagui (gui-workspace starters use workspace:* deps,
unusable standalone — pragmatic over heroic)
- Single working Workflows page rendering live data via @hanzo/gui
primitives (XStack/YStack/Card/Button/Text)
- SSE realtime hook + 30-line useFetch in place of SWR
- Bundle 446 KB JS / 139 KB gzip (+23% over v1; static extractor
disabled because hanzogui workspace deps don't resolve outside
the gui repo — revisit when we move ui-tamagui into the workspace)
- README documents what's missing before v2 can replace v1
- 5-day estimate to port the remaining 8 pages + chrome layer
go test ./pkg/sdk/... → 11/11 PASS over native ZAP.
Binary 7.66 MB. No gRPC, no proxy, no upstream temporal.io.
ui/src/lib/events.ts — useRealtime() React hook subscribes to
/v1/tasks/events via EventSource and invalidates SWR caches when
workflow / schedule / batch / namespace transitions affect the
current view. No polling. No proxy. Native SSE only.
Wired into:
Workflows page — live workflow.{started,canceled,terminated,signaled}
Schedules page — live schedule.{created,paused,resumed,deleted}
Batches page — live batch.started
Namespaces page — live namespace.registered
pkg/sdk/client/events.go — Go callers get the same stream natively
via client.SubscribeEvents(ctx, httpAddr) → <-chan Event. No SDK
spin-up, no JSON-RPC overhead — long-lived TCP read with inline SSE
parsing. End-to-end verified with a /tmp demo: two POST workflow
starts hit the channel in real time as workflow.started events.
README.md — drop the stale gRPC compat reference. The wire is ZAP
plus an HTTP/JSON shim plus SSE + MCP for browsers and agents. No
gRPC, no go.temporal.io, anywhere.
Bundle 320.7 KB (+0.6 from 320.1). Binary 7.6 MB unchanged.
4/4 integration tests still pass.
Engine (pkg/tasks):
- store.go — KV layer over luxfi/database memdb (snapshot-and-iterate
list to avoid RWMutex upgrade deadlock)
- types.go — wire types: Namespace, WorkflowExecution, Schedule,
BatchOperation, Deployment, NexusEndpoint, Identity
- engine.go — domain ops: register/list/describe ns; start/list/
describe/cancel/terminate/signal workflow; create/list/
describe/delete/pause/unpause schedule; start/list/
describe batch; create/list deployment; create/list
nexus endpoint; grant/list identity; cron sweeper goroutine
- embed.go — every ZAP opcode (0x0060-0x0090) + every HTTP route
dispatches through the same model funcs; no drift
HTTP surface (browser-only JSON shim, ZAP is the canonical wire):
GET /v1/tasks/namespaces
POST /v1/tasks/namespaces
GET /v1/tasks/namespaces/{ns}
GET /v1/tasks/namespaces/{ns}/workflows
POST /v1/tasks/namespaces/{ns}/workflows
GET /v1/tasks/namespaces/{ns}/workflows/{id}
POST /v1/tasks/namespaces/{ns}/workflows/{id}/cancel
POST /v1/tasks/namespaces/{ns}/workflows/{id}/terminate
POST /v1/tasks/namespaces/{ns}/workflows/{id}/signal
GET /v1/tasks/namespaces/{ns}/schedules
POST /v1/tasks/namespaces/{ns}/schedules
GET /v1/tasks/namespaces/{ns}/schedules/{id}
DELETE /v1/tasks/namespaces/{ns}/schedules/{id}
POST /v1/tasks/namespaces/{ns}/schedules/{id}/pause
POST /v1/tasks/namespaces/{ns}/schedules/{id}/unpause
GET /v1/tasks/namespaces/{ns}/batches
POST /v1/tasks/namespaces/{ns}/batches
GET /v1/tasks/namespaces/{ns}/batches/{id}
GET /v1/tasks/namespaces/{ns}/deployments
POST /v1/tasks/namespaces/{ns}/deployments
GET /v1/tasks/namespaces/{ns}/nexus
POST /v1/tasks/namespaces/{ns}/nexus
GET /v1/tasks/namespaces/{ns}/identities
POST /v1/tasks/namespaces/{ns}/identities
UI (full Temporal Cloud parity):
- @hanzo/ui primitives in src/components/ui/{button,card,tabs,badge,
alert,dialog,input,label,skeleton,dropdown-menu}.tsx — same shadcn
pattern @hanzo/ui itself uses; CSS tokens from @hanzo/ui design system
- App.tsx: sidebar layout (Workflows/Schedules/Batches/Deployments/
Namespaces/Nexus/Support) with top bar (UTC clock, theme toggle,
account chip, ns indicator, embedded badge)
- pages/Namespaces.tsx + pages/NamespaceDetail.tsx — list + detail w/
Overview+Identities tabs, Region/Retention/APS cards, Connect
dropdown showing ZAP+HTTP endpoints with copy buttons, Active badge
- pages/Workflows.tsx — list + Start Workflow dialog (real POST)
- pages/WorkflowDetail.tsx — execution details
- pages/Schedules.tsx — list w/ cron / interval describe
- pages/Batches.tsx — list + Start Batch dialog (operation enum)
- pages/Deployments.tsx — list of worker version series
- pages/Nexus.tsx — list of cross-ns endpoints
- pages/Support.tsx — 4 cards linking to docs / GitHub / health
- ThemeToggle (light/dark, persisted), UTCClock (live ticker),
AccountChip (popover w/ identity + sign out)
Binary: 7.5 MB. Workflow execution semantics (deterministic replay of
user code) still pending — engine records state and tracks history len
on signals, but does NOT run user-supplied workflow goroutines yet.
Worker SDK runtime is the next phase.
ZAP binary protocol duplex RPC standardizes on :9999 across every
service. Lux validator staking/RPC ports (9630/9631 mainnet, 9650/9651
devnet) are unchanged — those are luxd network protocol ports, not ZAP.
One ZAP port everywhere (was a mix of 9651/9652/9653/9633/9900).
One and one way only:
/healthz k8s / docker HEALTHCHECK (Hanzo convention;
matches ~/work/hanzo/kms cmd/kmsd)
/v1/tasks/health per-service REST health, survives API-gateway
multi-service fan-in without colliding
/v1/tasks/healthz alias for tooling that expects `healthz`
under every path prefix
/v1/tasks/... JSON API (namespaces, workflows, schedules)
/_/tasks/* embedded Vite SPA (react-router basename)
/ 404 — no root route
ZAP transport collapsed to ONE service type: _tasks._tcp on :9652.
The separate _tasks-sdk._tcp listener is gone; same opcodes
(0x0050 submit, 0x0051 schedule), one canonical name.
UI routing: vite.config.ts sets `base: '/_/tasks/'` and
BrowserRouter has `basename="/_/tasks"`, so the SPA deep-links
correctly under the /_/ admin mount (Hanzo Base convention).
Added pkg/tasks/embed.go as the composable SDK surface for
running tasks in-process from a Base app:
srv, _ := tasks.Embed(ctx, tasks.EmbedConfig{DataDir: dir})
defer srv.Stop(ctx)
The contract is exposed; implementation is stubbed pending task
#41 (internal-gRPC → ZAP rewrite) so the persistence + RPC layer
can move off go.temporal.io imports without breaking callers.
External HTTP surface is now one path prefix: /v1/tasks/*. The
runtime.ServeMux + JSON-over-gRPC transcoding layer is gone.
Browsers get JSON here because they have to; every other caller
uses the ZAP binary transport on :9652 (_tasks-sdk._tcp).
Backend:
- service/frontend/http_api_server.go rewritten around gofiber
v3. Four read routes wired directly to WorkflowHandler
methods via Go function calls — no gRPC codec, no inline
client conn, no grpc-gateway registration:
GET /v1/tasks/healthz
GET /v1/tasks/namespaces
GET /v1/tasks/namespaces/:ns/workflows
GET /v1/tasks/namespaces/:ns/workflows/:id
GET /v1/tasks/namespaces/:ns/schedules
Embedded SPA catches everything else for client-routed deep
links.
- fx.go: operatorHandler, grpcServerOptions.UnaryInterceptors,
*mux.Router kept as accepted-but-unused until the fx graph
can be pruned (next commit). No runtime dependency on them.
- Response marshalling: protojson.MarshalOptions. The request
and response types are still go.temporal.io/api protos; task
#41 replaces them with ZAP schemas end-to-end.
UI:
- Every fetch URL flips /api/v1/ → /v1/tasks/.
- @hanzogui/lucide-icons-2 → lucide-react. The hanzo-gui
package was pulling react-native transitively and Vite 8 /
rolldown chokes on Flow syntax. lucide-react is the same
icon set, pure React, no native shim.
- vite.config.ts proxies /v1/tasks → :7234 so the SPA can use
relative URLs in dev.
Verified: /v1/tasks/namespaces returns "default" namespace over
the gofiber path with gRPC-Gateway unloaded from the binary.
Self-contained admin UI for hanzoai/tasks. Lives in ui/, builds to
ui/dist/ via Vite 8, gets embedded into the tasksd binary with
go:embed, and is served at / on the HTTP API port (default :7234).
Stack:
- Vite 8
- React 18 + TypeScript strict
- react-router (client-side routing, deep-link safe)
- SWR (zero-config fetch + revalidation)
- Tailwind 3 (dark-first palette; no webfont)
- @hanzogui/lucide-icons-2 (shared Hanzo icon set)
v1 pages:
- /namespaces — list all namespaces + retention
- /namespaces/:ns/workflows — running + recent executions
- /namespaces/:ns/workflows/:id — describe single execution
- /namespaces/:ns/schedules — schedule list + spec
The UI talks to the existing Temporal HTTP gateway at /api/v1/*
(auto-generated from the workflowservice + operatorservice protos),
so no new backend routes were added. mux.Router registers /api/*
first, then falls through to ui.Handler() for everything else; the
SPA's client-side router takes over for unknown paths.
Build integration:
- ui/embed.go exposes Handler() over an embed.FS rooted at dist/
- Dockerfile adds a node:22-alpine UI build stage that produces
ui/dist, then the Go build stage overlays that onto the
placeholder dist/index.html already committed (so `go build`
works on a developer laptop without running pnpm first).
- service/frontend/http_api_server.go imports tasksui and mounts
at /. The existing catch-all was split:
1. PathPrefix("/api/") → Temporal gRPC-Gateway
2. PathPrefix("/") → embedded SPA
- Anything that isn't a registered /api/... handler renders the
SPA shell, which lets react-router resolve the path.
Dev workflow:
pnpm --prefix ui install
pnpm --prefix ui dev # Vite on :5173, proxies /api to :7234
go run ./cmd/tasksd embedded-sqlite # :7234 backs the API
Next: ZAP port for the internal gRPC services (task #41).