Skip to content

Repository files navigation

浮世は夢 — ukiyo wa yume

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.

What it does

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 an EventSink. Everything downstream still asks for a plain WorkflowEngine and transparently gets the decorated one.
  • The database is the only contract. Events land in a ukiyo_events table alongside the cluster's own cluster_messages/cluster_replies. The read side depends only on SqlClient, 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/cluster roster (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.

Architecture

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)

Packages

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.

How interception works

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.

Controls without the engine

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.

Shard placement & runners

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:

  • PLACEMENT column on the dashboard — each execution's shard group (primary), plus its shard and owning runner. For active executions the runner is resolved live from the cluster_locks lease, 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.

Integration

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.

Running the dashboard

Standalone binary

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)

Embedded (one call)

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.

Development

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 .ts directly).
  • Typecheck: tsgo (@typescript/native-preview). Effect 4's types are heavy enough to misbehave on tsc; typescript@6 stays installed only for the editor LSP fallback.
  • Single-file binary: build.ts vite-builds the dashboard, embeds the output via Bun's side-effect file imports, and bun 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.

License

See repository for license details.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages