A Temporal-inspired monitoring dashboard for Effect workflows. Drop one line into your cluster wiring and ukiyo records an append-only, replay-aware history of every workflow lifecycle transition, then serves it through a live dashboard — embedded in your host process or as a standalone binary pointed at the shared database.
ukiyo decorates an existing WorkflowEngine and emits a structured event stream
for every lifecycle transition — workflow start/complete/suspend/resume/interrupt,
activity schedule/complete, deferred await/done, and clock scheduling. That
stream is the Temporal-style history a dashboard reads.
- One-line integration. Wrap your engine layer in
instrument(...)and provide anEventSink. Everything downstream still asks for a plainWorkflowEngineand transparently gets the decorated one. - The database is the only contract. Events land in a
ukiyo_eventstable alongside the cluster's owncluster_messages/cluster_replies. The read side depends only onSqlClient, so the same code runs embedded in the host or as a standalone process against the shared DB. - Live dashboard. A React SPA lists executions, renders ordered history timelines, streams new events live (reconnect-from-cursor), and offers interrupt/resume controls.
- Shard-placement aware. Every execution shows where it runs — its shard
group, shard, and owning runner — and a Runners page lists the live
@effect/clusterroster (health, groups, weight, shard counts, active executions). This is faithful to the cluster's consistent-hash sharding, not a Temporal queue/worker approximation. - Multi-cluster. One standalone dashboard can view several clusters (e.g. prod + staging), each its own database, with per-source isolation, capability probing, and graceful degradation when a source runs a drifted schema or goes offline.
ukiyo splits cleanly into a write side (lives in the host, needs the engine), a read side (needs only the database), and a shared physical-table contract:
ukiyo-schema (pure event + view schemas — one source of truth)
└── ukiyo-sql (physical DB contract: DDL, row maps, exit codec)
├── ukiyo-engine (write side: instrument + EventSink + ControlDrainer)
└── ukiyo-read (read side: ReadStore + ReadApi; standalone-safe)
├── ukiyo-server (standalone HTTP binary + embedded UI)
└── ukiyo-ui (the dashboard SPA)
| Package | Folder | Role |
|---|---|---|
ukiyo-schema |
packages/schema |
Shared event (WorkflowEvent, 11 variants) + view schemas. effect only. |
ukiyo-sql |
packages/sql |
Physical contract for ukiyo_events + ukiyo_control: dialect-aware DDL, row maps, the Exit codec. |
ukiyo-engine |
packages/engine |
Write side: instrument (the layer combinator), decorateService, EventSink (layerLog/layerSql), the host-side ControlDrainer. |
ukiyo-read |
packages/read |
Read side: ReadStore (data access) + ReadApi (transport-agnostic RpcGroup) + control-write mailbox. Depends only on SqlClient. |
ukiyo-server |
packages/server |
Standalone HTTP binary: serves ReadApi over RPC + the embedded UI; multi-dialect (pg/mysql/sqlite); single- or multi-cluster. |
ukiyo-ui |
packages/ui |
The dashboard SPA (React 19 + Vite + TanStack Router + @effect/atom-react + Tailwind v4/shadcn on Base UI). |
ukiyo |
packages/ukiyo |
One-call facade: Ukiyo() wires write + serve + auto platform. Re-exports the building blocks. |
ukiyo-embed |
packages/embed |
Serve side, embeddable: RPC + inlined UI over the host's SqlClient. Single-source; no SQL drivers. |
ukiyo-ui-assets |
packages/ui-assets |
The dashboard, inlined as bytes, plus layerUi. effect-only; shared by ukiyo-embed and ukiyo-server. |
ukiyo intercepts at the typed WorkflowEngine service rather than the lower-level
Encoded contract. instrument reads the inner engine under a private tag,
publishes a decorated service under the real WorkflowEngine tag, and re-provides
the decorated engine into each workflow body so in-workflow activity/deferred/clock
calls also route through it. This requires zero upstream changes to Effect and
works against both layerMemory and the real ClusterWorkflowEngine.
Interrupt/Resume in standalone mode is decoupled via a DB control mailbox: the
dashboard writes a pending row to ukiyo_control; a host-side ControlDrainer
fiber claims it (FOR UPDATE SKIP LOCKED, multi-host safe) and calls the real
Workflow.interrupt/resume, preserving all engine correctness.
ukiyo reports where each execution runs using @effect/cluster's own routing
model, not a Temporal queue/worker analogy. The cluster has no queue you submit
to and no workers that poll — routing is consistent-hash sharding:
Shard group ("default", "orders") ← per-workflow annotation; the workload-isolation boundary
└─ Shard {group, id} ← the entity (executionId) hashes here, stable for its life
└─ leased to one Runner ← cluster_locks lease, rebalances dynamically
└─ Runner host:port ← a cluster node: groups, weight, health, heartbeat
An execution is an entity whose id hashes to a stable shard; the ShardManager leases each shard to exactly one runner. Placement is assigned, not pulled — so the meaningful routing unit is the shard group (the workload-isolation boundary), with the specific shard and runner as finer detail. Two surfaces expose this:
PLACEMENTcolumn on the dashboard — each execution's shard group (primary), plus its shard and owning runner. For active executions the runner is resolved live from thecluster_lockslease, so a rebalance shows immediately; for terminal ones it's where the entity finished.- Runners page — the live roster: address, health (heartbeat-age), groups, weight, shard count (with per-group breakdown), and active-execution count.
Both degrade gracefully: under WorkflowEngine.layerMemory or a source without
cluster runner-storage, placement is absent (the column shows —) and the
Runners page shows a single-runner state. No path errors.
Today a host wires (roughly):
const EngineLive = ClusterWorkflowEngine.layer.pipe(
Layer.provide(Sharding.layer),
Layer.provide(SqlMessageStorage.layer),
);Drop-in becomes:
import { instrument, EventSink } from "ukiyo-engine";
const EngineLive = instrument(ClusterWorkflowEngine.layer).pipe(
Layer.provide(EventSink.layerSql), // reuses the host's existing SqlClient
Layer.provide(Sharding.layer),
Layer.provide(SqlMessageStorage.layer),
);That is the whole write-side surface: wrap the engine layer and provide an
EventSink. The SQL sink uses a bounded, non-blocking mailbox (drop-oldest under
backpressure), so emitting events never blocks a workflow.
ukiyo-server is configured via environment variables and points at the shared
database the cluster uses:
| Variable | Purpose |
|---|---|
UKIYO_DIALECT |
postgres | mysql | sqlite |
UKIYO_DB |
connection URL or sqlite filename |
UKIYO_PORT |
HTTP port |
UKIYO_RPC_PATH |
RPC mount path (default /rpc) |
UKIYO_UI_ROOT |
serve UI from disk instead of the embedded bundle |
UKIYO_CLUSTERS |
JSON [{id,label,dialect,db}] to enable multi-cluster mode (supersedes UKIYO_DIALECT/UKIYO_DB) |
Embed the whole dashboard — write instrumentation, the RPC server, and the
bundled UI — into your host process with a single layer. It reuses your runtime
and SqlClient, and auto-detects Bun vs Node:
import { Ukiyo } from "ukiyo"
const EngineLive = Ukiyo(ClusterWorkflowEngine.layer, {
workflows: [myWorkflow], // for the interrupt/resume control drainer
port: 4000, // dashboard served here
}).pipe(
Layer.provide(Sharding.layer),
Layer.provide(SqlMessageStorage.layer),
)
// → Layer<WorkflowEngine, …, SqlClient> (downstream still gets the decorated engine)Ukiyo is also a namespace of building blocks for advanced wiring:
Ukiyo.write (write side only), Ukiyo.embedBun / Ukiyo.embedNode (serve on
a port), Ukiyo.embed (serve; you provide the HttpServer), and Ukiyo.routes
(mount the dashboard onto your own HttpRouter, e.g. at a subpath via
basePath). Pass platform: "node" | "bun" | <HttpServer layer> to override
auto-detection.
The repo is a Bun workspace.
bun install
bun test # bun test across all packages
bun run typecheck # tsgo (TS7 native preview) over the workspace
bun run build # vite-build the UI, then compile a single-file server binary- Runtime/test: Bun (runs
.tsdirectly). - Typecheck:
tsgo(@typescript/native-preview). Effect 4's types are heavy enough to misbehave ontsc;typescript@6stays installed only for the editor LSP fallback. - Single-file binary:
build.tsvite-builds the dashboard, embeds the output via Bun's side-effect file imports, andbun build --compiles the server into one self-contained executable (dist/ukiyo-server) — no Node, no vite at runtime, no external files.
The UI is excluded from the root tsconfig.json (it targets DOM/JSX, incompatible
with the Bun-target base config) and typechecks via its own bun run typecheck.
See repository for license details.