workflow-ts-architecture

Build and review TypeScript feature logic with workflow-ts (`@workflow-ts/core` and `@workflow-ts/react`) using explicit state machines, typed renderings, workers, composition, and runtime-first tests. Use when requests involve creating or refactoring workflows, wiring React hooks, modeling async worker flows, composing parent/child workflows, adding snapshots/interceptors/devtools, or writing tests for workflow behavior in this repository.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "workflow-ts-architecture" with this command: npx skills add benedictp/workflow-ts/benedictp-workflow-ts-workflow-ts-architecture

Workflow-ts Architecture

Goal

Build deterministic application flows in this repository with workflow-ts primitives and documented project conventions.

Quick start

  1. Add @workflow-ts/core for workflow runtime logic.
  2. Add @workflow-ts/react when a React screen should subscribe to rendering.
  3. Model State and Rendering as explicit discriminated unions.
  4. Keep actions pure and route side effects through workers.
  5. Validate behavior with runtime-level tests before UI tests.

Canonical references

Read references/doc-map.md first, then load only the specific guide needed for the task.

How to build a basic workflow

Use this template and customize:

import { createWorker, type Worker, type Workflow } from '@workflow-ts/core';

interface Props {
  userId: string;
}

type State =
  | { type: 'loading' }
  | { type: 'loaded'; name: string }
  | { type: 'error'; message: string };

type Output = { type: 'closed' };

type Rendering =
  | { type: 'loading'; close: () => void }
  | { type: 'loaded'; name: string; reload: () => void; close: () => void }
  | { type: 'error'; message: string; retry: () => void; close: () => void };

type LoadResult = { ok: true; name: string } | { ok: false; message: string };

interface WorkerProvider {
  loadProfileWorker: () => Worker<LoadResult>;
}

const defaultWorkerProvider: WorkerProvider = {
  loadProfileWorker: () =>
    createWorker<LoadResult>('load-profile', async (signal) => {
      // Cooperate with cancellation via AbortSignal.
      if (signal.aborted) return { ok: false, message: 'Cancelled' };
      return { ok: true as const, name: 'Ada' };
    }),
};

export function createProfileWorkflow(
  workerProvider: WorkerProvider,
): Workflow<Props, State, Output, Rendering> {
  return {
    initialState: () => ({ type: 'loading' }),

    render: (_props, state, ctx) => {
      switch (state.type) {
        case 'loading':
          ctx.runWorker(workerProvider.loadProfileWorker(), 'profile-load', (result) => () => ({
            state: result.ok
              ? { type: 'loaded', name: result.name }
              : { type: 'error', message: result.message },
          }));
          return {
            type: 'loading',
            close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
          };
        case 'loaded':
          return {
            type: 'loaded',
            name: state.name,
            reload: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
            close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
          };
        case 'error':
          return {
            type: 'error',
            message: state.message,
            retry: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
            close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
          };
      }
    },
  };
}

export const profileWorkflow = createProfileWorkflow(defaultWorkerProvider);
  • DO keep state transitions explicit with discriminated unions.
  • DO model expected business failures as output data from workers.
  • DO inject workers via a WorkerProvider interface for deterministic tests.
  • DO NOT mutate state; always return new state.
  • DO NOT rely on thrown worker errors for domain transitions.

How to integrate with React

Use useWorkflow as a single subscription point and map renderings to components:

import { useWorkflow } from '@workflow-ts/react';
import type { JSX } from 'react';

export function ProfileScreen({ userId }: { userId: string }): JSX.Element {
  const rendering = useWorkflow(profileWorkflow, { userId });

  switch (rendering.type) {
    case 'loading':
      return <button onClick={rendering.close}>Close</button>;
    case 'loaded':
      return (
        <>
          <h1>{rendering.name}</h1>
          <button onClick={rendering.reload}>Reload</button>
          <button onClick={rendering.close}>Close</button>
        </>
      );
    case 'error':
      return (
        <>
          <p>{rendering.message}</p>
          <button onClick={rendering.retry}>Retry</button>
          <button onClick={rendering.close}>Close</button>
        </>
      );
  }
}
  • DO keep props small and immutable.
  • DO pass only minimal derived inputs to useWorkflow.
  • DO NOT use workflow-ts as a selector-based global store by default.
  • DO NOT call updateProps with identical references to force refreshes.

How to compose child workflows

Use stable keys and explicit output mapping:

const childRendering = ctx.renderChild(childWorkflow, childProps, 'child-key', (childOutput) => (state) => ({
  state: handleChildOutput(state, childOutput),
}));
  • DO use stable child keys for long-lived child state.
  • DO map child output into parent actions/state transitions.
  • DO NOT change keys unless creating a new child instance intentionally.

How to run worker-based async flows

Run workers only from render, key them by logical effect identity, and inject workers through a provider interface for testability. Inside render, structure logic as:

  • optional pre-switch worker startup only when it must run regardless of state
  • switch (state.type) as the main rendering/state-handling structure
import type { RenderContext, Worker } from '@workflow-ts/core';

interface WorkerProvider {
  loadProfileWorker: () => Worker<LoadResult>;
}

function render(_props: Props, state: State, ctx: RenderContext<State, Output>): Rendering {
  // Pre-switch work is only for workers that must run regardless of state.
  // ctx.runWorker(workerProvider.auditWorker(), 'audit', () => (s) => ({ state: s }));

  switch (state.type) {
    case 'loading':
      ctx.runWorker(workerProvider.loadProfileWorker(), 'profile-load', (result) => () => ({
        state: result.ok
          ? { type: 'loaded', name: result.name }
          : { type: 'error', message: result.message },
      }));
      return {
        type: 'loading',
        close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
      };
    case 'loaded':
      return {
        type: 'loaded',
        name: state.name,
        reload: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
        close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
      };
    case 'error':
      return {
        type: 'error',
        message: state.message,
        retry: () => ctx.actionSink.send(() => ({ state: { type: 'loading' } })),
        close: () => ctx.actionSink.send((s) => ({ state: s, output: { type: 'closed' } })),
      };
  }
}
  • Same key + running worker keeps the worker alive and updates handlers.
  • Missing key in the next render cancels the worker at end of render cycle.
  • Disposing runtime cancels all active workers.
  • DO define worker dependencies behind a WorkerProvider interface and inject it into workflow factories.
  • DO keep render primarily as switch (state.type) branches for clarity and exhaustiveness.
  • DO NOT place general branching/return logic before the switch; reserve pre-switch code for unconditional worker startup only.

How to write tests first

Prefer runtime-level tests in @workflow-ts/core for behavior:

import { createRuntime } from '@workflow-ts/core';
import { expect, it } from 'vitest';

it('transitions loading -> loaded', () => {
  const runtime = createRuntime(profileWorkflow, { userId: 'u1' });

  expect(runtime.getRendering().type).toBe('loading');
  runtime.send(() => ({ state: { type: 'loaded', name: 'Ada' } }));
  expect(runtime.getRendering().type).toBe('loaded');

  runtime.dispose();
});
  • Prefer provider-based worker stubs for deterministic retry/failure tests:
import { createWorker } from '@workflow-ts/core';
import { vi } from 'vitest';

const workerProvider: WorkerProvider = {
  loadProfileWorker: vi
    .fn()
    .mockReturnValueOnce(createWorker('load-profile-fail', async () => ({ ok: false, message: 'TEST' })))
    .mockReturnValueOnce(createWorker('load-profile-ok', async () => ({ ok: true, name: 'Ada' }))),
};
  • DO test initial state/rendering, callbacks, outputs, props updates, and worker behavior.
  • DO dispose runtime in every test.
  • DO test worker cancellation and retry paths deterministically.
  • DO use WorkerProvider to stub sequential worker outcomes (for example fail then success on retry).
  • DO NOT rely on timing sleeps when a deferred completion pattern can be used.

How to persist and restore snapshots

Define snapshot and hydrate via initialState(props, snapshot):

const runtime = createRuntime(workflow, props, { snapshot: savedSnapshot });
const nextSnapshot = runtime.snapshot();

Call runtime.snapshot() at lifecycle checkpoints (for example app backgrounding) and restore it on next runtime creation.

How to add diagnostics

Use runtime interceptors for cross-cutting side effects and DevTools for event timelines:

const runtime = createRuntime(workflow, props, {
  interceptors: [loggingInterceptor({ prefix: '[workflow]' })],
  devTools: createDevTools(),
});
  • DO keep action functions pure.
  • DO use interceptors for analytics/logging/metrics.
  • DO wrap actions with named(...) when stable action names are needed.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

Coding

openclaw-version-monitor

监控 OpenClaw GitHub 版本更新,获取最新版本发布说明,翻译成中文, 并推送到 Telegram 和 Feishu。用于:(1) 定时检查版本更新 (2) 推送版本更新通知 (3) 生成中文版发布说明

Archived SourceRecently Updated
Coding

ask-claude

Delegate a task to Claude Code CLI and immediately report the result back in chat. Supports persistent sessions with full context memory. Safe execution: no data exfiltration, no external calls, file operations confined to workspace. Use when the user asks to run Claude, delegate a coding task, continue a previous Claude session, or any task benefiting from Claude Code's tools (file editing, code analysis, bash, etc.).

Archived SourceRecently Updated
Coding

ai-dating

This skill enables dating and matchmaking workflows. Use it when a user asks to make friends, find a partner, run matchmaking, or provide dating preferences/profile updates. The skill should execute `dating-cli` commands to complete profile setup, task creation/update, match checking, contact reveal, and review.

Archived SourceRecently Updated
Coding

clawhub-rate-limited-publisher

Queue and publish local skills to ClawHub with a strict 5-per-hour cap using the local clawhub CLI and host scheduler.

Archived SourceRecently Updated