From d170094e28b99f4b1b7ccf4dc66f5a83afef2668 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Wed, 29 Apr 2026 17:08:49 +0530 Subject: [PATCH 01/13] feat(dashboard): add durable_dashboard package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Embeddable web dashboard for Durable: Plug.Router entry → Phoenix Router → per-page LiveViews (Overview, Workflows, Workflow detail, Inputs, Schedules, Settings) with a single ReactFlow island for the workflow graph. Mounted into a host app via the `use DurableDashboard.Router` macro. --- durable_dashboard/.formatter.exs | 5 + durable_dashboard/.gitignore | 10 + durable_dashboard/CLAUDE.md | 41 + durable_dashboard/DESIGN.md | 524 ++ durable_dashboard/assets/biome.json | 58 + durable_dashboard/assets/components.json | 25 + durable_dashboard/assets/package.json | 44 + durable_dashboard/assets/pnpm-lock.yaml | 6165 +++++++++++++++++ durable_dashboard/assets/src/App.tsx | 199 + .../src/components/layout/AppSidebar.tsx | 111 + .../src/components/layout/CommandPalette.tsx | 143 + .../assets/src/components/layout/Logo.tsx | 68 + .../src/components/layout/ThemeToggle.tsx | 59 + .../assets/src/components/layout/TopBar.tsx | 103 + .../src/components/shared/InputForm.tsx | 258 + .../src/components/shared/LogViewer.tsx | 414 ++ .../src/components/shared/RelativeTime.tsx | 79 + .../src/components/shared/StatusBadge.tsx | 103 + .../assets/src/components/ui/badge.tsx | 45 + .../assets/src/components/ui/breadcrumb.tsx | 103 + .../assets/src/components/ui/button.tsx | 65 + .../assets/src/components/ui/card.tsx | 89 + .../assets/src/components/ui/chart.tsx | 337 + .../assets/src/components/ui/collapsible.tsx | 21 + .../assets/src/components/ui/command.tsx | 179 + .../assets/src/components/ui/dialog.tsx | 143 + .../src/components/ui/dropdown-menu.tsx | 246 + .../assets/src/components/ui/input-group.tsx | 143 + .../assets/src/components/ui/input.tsx | 19 + .../assets/src/components/ui/label.tsx | 19 + .../assets/src/components/ui/popover.tsx | 76 + .../assets/src/components/ui/scroll-area.tsx | 53 + .../assets/src/components/ui/select.tsx | 184 + .../assets/src/components/ui/separator.tsx | 26 + .../assets/src/components/ui/sheet.tsx | 129 + .../assets/src/components/ui/sidebar.tsx | 668 ++ .../assets/src/components/ui/skeleton.tsx | 13 + .../assets/src/components/ui/table.tsx | 89 + .../assets/src/components/ui/tabs.tsx | 78 + .../assets/src/components/ui/textarea.tsx | 18 + .../assets/src/components/ui/toggle-group.tsx | 83 + .../assets/src/components/ui/toggle.tsx | 43 + .../assets/src/components/ui/tooltip.tsx | 51 + .../components/workflow/StepDetailPanel.tsx | 276 + .../src/components/workflow/WorkflowGraph.tsx | 81 + .../components/workflow/WorkflowTimeline.tsx | 510 ++ .../components/workflow/nodes/BranchNode.tsx | 41 + .../components/workflow/nodes/JoinNode.tsx | 26 + .../workflow/nodes/ParallelNode.tsx | 44 + .../components/workflow/nodes/StepNode.tsx | 109 + .../assets/src/hooks/use-mobile.ts | 19 + .../assets/src/hooks/useLiveEvent.ts | 38 + .../assets/src/hooks/useUrlParams.ts | 44 + durable_dashboard/assets/src/index.css | 337 + .../assets/src/lib/graph-layout.ts | 157 + durable_dashboard/assets/src/lib/tokens.ts | 19 + durable_dashboard/assets/src/lib/types.ts | 240 + durable_dashboard/assets/src/lib/utils.ts | 86 + durable_dashboard/assets/src/main.tsx | 127 + .../assets/src/v2/hooks/command_palette.ts | 88 + .../assets/src/v2/hooks/flow_graph.ts | 104 + durable_dashboard/assets/src/v2/main.ts | 45 + .../src/v2/react/edges/animated_flow_edge.tsx | 57 + .../assets/src/v2/react/flow_graph.tsx | 183 + .../assets/src/v2/react/nodes/end_node.tsx | 32 + .../src/v2/react/nodes/gateway_node.tsx | 111 + .../assets/src/v2/react/nodes/hidden_node.tsx | 29 + .../assets/src/v2/react/nodes/start_node.tsx | 30 + .../assets/src/v2/react/nodes/step_node.tsx | 252 + .../assets/src/views/OverviewView.tsx | 336 + .../assets/src/views/PendingInputsView.tsx | 146 + .../assets/src/views/ScheduleListView.tsx | 149 + .../assets/src/views/SettingsView.tsx | 139 + .../assets/src/views/WorkflowDetailView.tsx | 384 + .../assets/src/views/WorkflowListView.tsx | 330 + .../assets/src/views/WorkflowLogsView.tsx | 273 + .../src/views/workflow-tabs/FlowTab.tsx | 25 + .../src/views/workflow-tabs/HistoryTab.tsx | 11 + .../assets/src/views/workflow-tabs/IOTab.tsx | 49 + .../src/views/workflow-tabs/LogsTab.tsx | 119 + .../src/views/workflow-tabs/SummaryTab.tsx | 128 + .../src/views/workflow-tabs/TopologyTab.tsx | 23 + durable_dashboard/assets/tsconfig.json | 22 + durable_dashboard/assets/vite.config.ts | 47 + durable_dashboard/config/config.exs | 9 + durable_dashboard/config/test.exs | 13 + durable_dashboard/lib/durable_dashboard.ex | 25 + .../lib/durable_dashboard/asset_server.ex | 22 + .../components/command/command_palette.ex | 242 + .../lib/durable_dashboard/components/core.ex | 812 +++ .../components/data/data_table.ex | 515 ++ .../components/data/pagination.ex | 78 + .../components/layout/breadcrumb.ex | 58 + .../components/layout/sidebar.ex | 127 + .../components/layout/topbar.ex | 74 + .../components/workflow/children_tab.ex | 250 + .../components/workflow/family_chip.ex | 153 + .../components/workflow/flow_graph.ex | 765 ++ .../components/workflow/history_tab.ex | 113 + .../components/workflow/io_tab.ex | 88 + .../components/workflow/logs_tab.ex | 254 + .../components/workflow/summary_tab.ex | 211 + .../components/workflow/tabs.ex | 64 + .../lib/durable_dashboard/graph_builder.ex | 445 ++ .../lib/durable_dashboard/layouts.ex | 235 + .../durable_dashboard/live/dashboard_live.ex | 732 ++ .../lib/durable_dashboard/live/inputs_live.ex | 249 + .../durable_dashboard/live/overview_live.ex | 220 + .../durable_dashboard/live/schedules_live.ex | 237 + .../durable_dashboard/live/settings_live.ex | 197 + .../lib/durable_dashboard/live/stub_live.ex | 105 + .../durable_dashboard/live/workflow_live.ex | 436 ++ .../durable_dashboard/live/workflows_live.ex | 294 + .../lib/durable_dashboard/metrics.ex | 166 + .../lib/durable_dashboard/path.ex | 58 + .../lib/durable_dashboard/router.ex | 127 + .../lib/durable_dashboard/serializer.ex | 156 + durable_dashboard/mix.exs | 48 + durable_dashboard/mix.lock | 25 + .../priv/static/durable_dashboard/app.css | 1 + .../priv/static/durable_dashboard/app.js | 202 + .../priv/static/durable_dashboard/app_v2.js | 2 + .../durable_dashboard/flow_graph-DnQnc5of.js | 16 + .../geist-cyrillic-wght-normal.woff2 | Bin 0 -> 14692 bytes .../geist-latin-ext-wght-normal.woff2 | Bin 0 -> 15308 bytes .../geist-latin-wght-normal.woff2 | Bin 0 -> 28400 bytes .../graph-layout-DO17v8x0.js | 70 + .../jetbrains-mono-cyrillic-wght-normal.woff2 | Bin 0 -> 12108 bytes .../jetbrains-mono-greek-wght-normal.woff2 | Bin 0 -> 9004 bytes ...jetbrains-mono-latin-ext-wght-normal.woff2 | Bin 0 -> 15196 bytes .../jetbrains-mono-latin-wght-normal.woff2 | Bin 0 -> 40404 bytes ...etbrains-mono-vietnamese-wght-normal.woff2 | Bin 0 -> 7504 bytes .../phoenix_live_view.esm-B3hEjW1b.js | 22 + .../vendor-recharts-COWINUhB.js | 81 + .../vendor-xyflow-BrJO7I5W.js | 15 + .../components/core_test.exs | 186 + .../components/data_table_test.exs | 200 + .../components/flow_graph_test.exs | 395 ++ .../components/logs_tab_test.exs | 110 + .../components/polish_test.exs | 103 + .../components/workflow_tabs_test.exs | 261 + .../test/durable_dashboard/path_test.exs | 64 + .../test/support/test_endpoint.ex | 22 + durable_dashboard/test/test_helper.exs | 4 + 144 files changed, 25952 insertions(+) create mode 100644 durable_dashboard/.formatter.exs create mode 100644 durable_dashboard/.gitignore create mode 100644 durable_dashboard/CLAUDE.md create mode 100644 durable_dashboard/DESIGN.md create mode 100644 durable_dashboard/assets/biome.json create mode 100644 durable_dashboard/assets/components.json create mode 100644 durable_dashboard/assets/package.json create mode 100644 durable_dashboard/assets/pnpm-lock.yaml create mode 100644 durable_dashboard/assets/src/App.tsx create mode 100644 durable_dashboard/assets/src/components/layout/AppSidebar.tsx create mode 100644 durable_dashboard/assets/src/components/layout/CommandPalette.tsx create mode 100644 durable_dashboard/assets/src/components/layout/Logo.tsx create mode 100644 durable_dashboard/assets/src/components/layout/ThemeToggle.tsx create mode 100644 durable_dashboard/assets/src/components/layout/TopBar.tsx create mode 100644 durable_dashboard/assets/src/components/shared/InputForm.tsx create mode 100644 durable_dashboard/assets/src/components/shared/LogViewer.tsx create mode 100644 durable_dashboard/assets/src/components/shared/RelativeTime.tsx create mode 100644 durable_dashboard/assets/src/components/shared/StatusBadge.tsx create mode 100644 durable_dashboard/assets/src/components/ui/badge.tsx create mode 100644 durable_dashboard/assets/src/components/ui/breadcrumb.tsx create mode 100644 durable_dashboard/assets/src/components/ui/button.tsx create mode 100644 durable_dashboard/assets/src/components/ui/card.tsx create mode 100644 durable_dashboard/assets/src/components/ui/chart.tsx create mode 100644 durable_dashboard/assets/src/components/ui/collapsible.tsx create mode 100644 durable_dashboard/assets/src/components/ui/command.tsx create mode 100644 durable_dashboard/assets/src/components/ui/dialog.tsx create mode 100644 durable_dashboard/assets/src/components/ui/dropdown-menu.tsx create mode 100644 durable_dashboard/assets/src/components/ui/input-group.tsx create mode 100644 durable_dashboard/assets/src/components/ui/input.tsx create mode 100644 durable_dashboard/assets/src/components/ui/label.tsx create mode 100644 durable_dashboard/assets/src/components/ui/popover.tsx create mode 100644 durable_dashboard/assets/src/components/ui/scroll-area.tsx create mode 100644 durable_dashboard/assets/src/components/ui/select.tsx create mode 100644 durable_dashboard/assets/src/components/ui/separator.tsx create mode 100644 durable_dashboard/assets/src/components/ui/sheet.tsx create mode 100644 durable_dashboard/assets/src/components/ui/sidebar.tsx create mode 100644 durable_dashboard/assets/src/components/ui/skeleton.tsx create mode 100644 durable_dashboard/assets/src/components/ui/table.tsx create mode 100644 durable_dashboard/assets/src/components/ui/tabs.tsx create mode 100644 durable_dashboard/assets/src/components/ui/textarea.tsx create mode 100644 durable_dashboard/assets/src/components/ui/toggle-group.tsx create mode 100644 durable_dashboard/assets/src/components/ui/toggle.tsx create mode 100644 durable_dashboard/assets/src/components/ui/tooltip.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/StepDetailPanel.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/WorkflowGraph.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/WorkflowTimeline.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/nodes/BranchNode.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/nodes/JoinNode.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/nodes/ParallelNode.tsx create mode 100644 durable_dashboard/assets/src/components/workflow/nodes/StepNode.tsx create mode 100644 durable_dashboard/assets/src/hooks/use-mobile.ts create mode 100644 durable_dashboard/assets/src/hooks/useLiveEvent.ts create mode 100644 durable_dashboard/assets/src/hooks/useUrlParams.ts create mode 100644 durable_dashboard/assets/src/index.css create mode 100644 durable_dashboard/assets/src/lib/graph-layout.ts create mode 100644 durable_dashboard/assets/src/lib/tokens.ts create mode 100644 durable_dashboard/assets/src/lib/types.ts create mode 100644 durable_dashboard/assets/src/lib/utils.ts create mode 100644 durable_dashboard/assets/src/main.tsx create mode 100644 durable_dashboard/assets/src/v2/hooks/command_palette.ts create mode 100644 durable_dashboard/assets/src/v2/hooks/flow_graph.ts create mode 100644 durable_dashboard/assets/src/v2/main.ts create mode 100644 durable_dashboard/assets/src/v2/react/edges/animated_flow_edge.tsx create mode 100644 durable_dashboard/assets/src/v2/react/flow_graph.tsx create mode 100644 durable_dashboard/assets/src/v2/react/nodes/end_node.tsx create mode 100644 durable_dashboard/assets/src/v2/react/nodes/gateway_node.tsx create mode 100644 durable_dashboard/assets/src/v2/react/nodes/hidden_node.tsx create mode 100644 durable_dashboard/assets/src/v2/react/nodes/start_node.tsx create mode 100644 durable_dashboard/assets/src/v2/react/nodes/step_node.tsx create mode 100644 durable_dashboard/assets/src/views/OverviewView.tsx create mode 100644 durable_dashboard/assets/src/views/PendingInputsView.tsx create mode 100644 durable_dashboard/assets/src/views/ScheduleListView.tsx create mode 100644 durable_dashboard/assets/src/views/SettingsView.tsx create mode 100644 durable_dashboard/assets/src/views/WorkflowDetailView.tsx create mode 100644 durable_dashboard/assets/src/views/WorkflowListView.tsx create mode 100644 durable_dashboard/assets/src/views/WorkflowLogsView.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/FlowTab.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/HistoryTab.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/IOTab.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/LogsTab.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/SummaryTab.tsx create mode 100644 durable_dashboard/assets/src/views/workflow-tabs/TopologyTab.tsx create mode 100644 durable_dashboard/assets/tsconfig.json create mode 100644 durable_dashboard/assets/vite.config.ts create mode 100644 durable_dashboard/config/config.exs create mode 100644 durable_dashboard/config/test.exs create mode 100644 durable_dashboard/lib/durable_dashboard.ex create mode 100644 durable_dashboard/lib/durable_dashboard/asset_server.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/command/command_palette.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/core.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/data/data_table.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/data/pagination.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/layout/breadcrumb.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/layout/sidebar.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/layout/topbar.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/children_tab.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/family_chip.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/flow_graph.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/history_tab.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/io_tab.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/logs_tab.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/summary_tab.ex create mode 100644 durable_dashboard/lib/durable_dashboard/components/workflow/tabs.ex create mode 100644 durable_dashboard/lib/durable_dashboard/graph_builder.ex create mode 100644 durable_dashboard/lib/durable_dashboard/layouts.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/dashboard_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/inputs_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/overview_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/schedules_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/settings_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/stub_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/workflow_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/live/workflows_live.ex create mode 100644 durable_dashboard/lib/durable_dashboard/metrics.ex create mode 100644 durable_dashboard/lib/durable_dashboard/path.ex create mode 100644 durable_dashboard/lib/durable_dashboard/router.ex create mode 100644 durable_dashboard/lib/durable_dashboard/serializer.ex create mode 100644 durable_dashboard/mix.exs create mode 100644 durable_dashboard/mix.lock create mode 100644 durable_dashboard/priv/static/durable_dashboard/app.css create mode 100644 durable_dashboard/priv/static/durable_dashboard/app.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/app_v2.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/flow_graph-DnQnc5of.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/geist-cyrillic-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/geist-latin-ext-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/geist-latin-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/graph-layout-DO17v8x0.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/jetbrains-mono-cyrillic-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/jetbrains-mono-greek-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/jetbrains-mono-latin-ext-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/jetbrains-mono-latin-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/jetbrains-mono-vietnamese-wght-normal.woff2 create mode 100644 durable_dashboard/priv/static/durable_dashboard/phoenix_live_view.esm-B3hEjW1b.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/vendor-recharts-COWINUhB.js create mode 100644 durable_dashboard/priv/static/durable_dashboard/vendor-xyflow-BrJO7I5W.js create mode 100644 durable_dashboard/test/durable_dashboard/components/core_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/components/data_table_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/components/logs_tab_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/components/polish_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/components/workflow_tabs_test.exs create mode 100644 durable_dashboard/test/durable_dashboard/path_test.exs create mode 100644 durable_dashboard/test/support/test_endpoint.ex create mode 100644 durable_dashboard/test/test_helper.exs diff --git a/durable_dashboard/.formatter.exs b/durable_dashboard/.formatter.exs new file mode 100644 index 0000000..4872046 --- /dev/null +++ b/durable_dashboard/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:ecto, :ecto_sql, :phoenix, :phoenix_live_view], + subdirectories: ["priv/*/migrations"], + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/durable_dashboard/.gitignore b/durable_dashboard/.gitignore new file mode 100644 index 0000000..235c201 --- /dev/null +++ b/durable_dashboard/.gitignore @@ -0,0 +1,10 @@ +# Elixir +/_build/ +/deps/ +/cover/ +/doc/ +*.ez +erl_crash.dump + +# Node (assets/) +assets/node_modules/ diff --git a/durable_dashboard/CLAUDE.md b/durable_dashboard/CLAUDE.md new file mode 100644 index 0000000..f32e427 --- /dev/null +++ b/durable_dashboard/CLAUDE.md @@ -0,0 +1,41 @@ +# Durable Dashboard + +Hex package providing the web dashboard for the Durable workflow engine. +Architecture: Plug.Router entry point → Phoenix Router → LiveView pages, +with a single ReactFlow island for the workflow graph view (mounted via a +`phx-hook`). + +## Before changing any UI + +Read `DESIGN.md` in this directory. It is the source of truth for colors, +typography, spacing, motion, status semantics, component primitives, and +composition patterns. New visual decisions are made *there*, then applied +in code — not the other way around. + +If a needed pattern isn't in `DESIGN.md`, add it there in the same PR. + +## Stateless visual components + +Live in `lib/durable_dashboard/components/core.ex`: +`button, badge, status_pill, card, heading, code, kbd, relative_time, +icon, skeleton, empty_state, error_state`. Use these instead of +hand-rolling HTML — see `DESIGN.md` §5 for the API contract. + +## Build & test + +```bash +# Assets (use pnpm, not npm) +cd assets && pnpm install && pnpm build + +# Type / lint / format checks +cd assets && pnpm exec tsc --noEmit && pnpm exec biome check src/ + +# Elixir +mix compile --warnings-as-errors +mix test +``` + +## Test DB + +Phoenix demo runs against port `53412`. See `examples/phoenix_demo` in the +repo root. diff --git a/durable_dashboard/DESIGN.md b/durable_dashboard/DESIGN.md new file mode 100644 index 0000000..025dbfe --- /dev/null +++ b/durable_dashboard/DESIGN.md @@ -0,0 +1,524 @@ +# Durable Dashboard — Design Language + +This document is the source of truth for every visual decision in the Durable +Dashboard. Read it before adding or modifying any UI surface. If a decision +isn't here, codify it here first; don't make it twice. + +> **Audience.** Any contributor (human or AI assistant) shipping a UI piece. +> **Goal.** A new contributor can answer *"how do I render a list of executions?"* +> after five minutes with this doc. + +## 1. Philosophy + +Durable Dashboard is a **workflow-engine console**. The aesthetic priorities +are, in order: + +1. **Data density.** Operators read this thing all day. Every pixel that + isn't carrying information is a tax. +2. **Dark-first.** The default theme is dark; light theme is supported with + the same visual fidelity for ops on bright monitors. +3. **Restrained color.** Color carries semantic meaning (status). It is + never used for branding or decoration. +4. **No chrome.** No drop shadows for depth, no gradients for polish, no + illustrations, no marketing copy. Elevation comes from background + contrast, not effects. + +**Inspirations** (not to imitate, but to calibrate against): + +- **Temporal Web** — data density and disciplined hierarchy. +- **Inngest** — polish and motion vocabulary. +- **Linear** — typography rhythm and command-palette discipline. +- **Argo Workflows / n8n** — workflow graph conventions. + +**Visual budget.** Every surface earns its weight. No card-in-card-in-card. +Whitespace is structural, not decorative — it separates regions, not +paragraphs. + +## 2. Foundations + +### 2.1 Colors + +Every color comes from a CSS custom property declared in +`assets/src/index.css`. Light + dark values are paired. Tailwind utility +classes (`bg-card`, `text-primary`, `border-border`) read these tokens via +the `@theme inline` block. + +| Token | Light | Dark | Use | +| ----------------------- | ------------------------ | ------------------------- | --------------------------------------------- | +| `--background` | `oklch(1 0 0)` | `oklch(0.145 0 0)` | App canvas | +| `--foreground` | `oklch(0.18 0 0)` | `oklch(0.98 0 0)` | Primary text | +| `--card` | `oklch(0.985 0 0)` | `oklch(0.185 0 0)` | Surfaces above the canvas | +| `--popover` | `oklch(0.99 0 0)` | `oklch(0.205 0 0)` | Floating surfaces | +| `--primary` | `oklch(0.5 0.2 250)` | `oklch(0.72 0.16 250)` | Primary actions, focus, active nav | +| `--secondary` | `oklch(0.96 0 0)` | `oklch(0.22 0 0)` | Secondary buttons, neutral chips | +| `--accent` | `oklch(0.95 0 0)` | `oklch(0.24 0 0)` | Hover backgrounds | +| `--muted` | `oklch(0.96 0 0)` | `oklch(0.21 0 0)` | Subdued surfaces | +| `--muted-foreground` | `oklch(0.5 0 0)` | `oklch(0.62 0 0)` | Secondary text | +| `--success` | `oklch(0.55 0.18 155)` | `oklch(0.78 0.16 155)` | Status: completed / running | +| `--warning` | `oklch(0.65 0.18 75)` | `oklch(0.82 0.16 80)` | Status: waiting / compensating | +| `--destructive` | `oklch(0.55 0.22 22)` | `oklch(0.72 0.20 22)` | Status: failed / timeout, destructive actions | +| `--info` | `oklch(0.55 0.16 230)` | `oklch(0.78 0.13 230)` | Status: scheduled | +| `--border` | `oklch(0.92 0 0)` | `oklch(0.27 0 0)` | Hairline dividers | +| `--input` | `oklch(0.94 0 0)` | `oklch(1 0 0 / 8%)` | Input borders/backgrounds | +| `--ring` | `oklch(0.5 0.2 250 / 0.4)` | `oklch(0.72 0.16 250 / 0.55)` | Focus rings | + +#### Forbidden + +- Hex literals — `#3b82f6`, `#22c55e`, `#fff`. Use tokens. +- Inline OKLCH/HSL/RGB in components — `oklch(0.78 0.16 155)`, + `rgb(34 197 94)`. Use tokens. +- Tailwind palette colors — `text-blue-500`, `bg-green-100`. Use tokens. +- Any new color outside the table without first adding it here. + +#### Opacity + +Use the slash modifier on token classes for tints: `bg-success/10`, +`text-primary/80`, `border-destructive/20`. Standard tints: `/5`, `/10`, +`/15`, `/20`, `/40`, `/60`, `/80`. Anything else needs justification. + +### 2.2 Typography + +- `--font-sans` = **Inter Variable** with `cv11` enabled. +- `--font-mono` = **JetBrains Mono Variable** with ligatures off. + +Use `font-mono` (or the `text-numeric` utility for tabular numerics) on: +IDs, durations, timestamps, JSON, code, status pills, dense numeric tables. + +#### Type scale + +Six sizes only. Anything else needs a comment explaining why. + +| Class | Size | Use | +| -------------- | ---- | ------------------------------------------------------ | +| `text-[9px]` | 9 | *Exception only:* graph-marker micro-eyebrows (start/end labels, group badges). | +| `text-[10px]` | 10 | Eyebrow / uppercase chip / kbd / footer hints | +| `text-[11px]` | 11 | Footnote, dense table cell, tertiary metadata | +| `text-xs` | 12 | Body small, control labels, navigation items | +| `text-[13px]` | 13 | Default body, default button text, form fields | +| `text-sm` | 14 | Card titles, primary body, list-row titles | +| `text-[18px]` | 18 | Section heading (h2) | +| `text-[22px]` | 22 | Page heading (h1) | + +Two heading utilities are declared in `index.css`: + +- `text-heading` — applies `font-weight: 600`, `letter-spacing: -0.015em`, + `line-height: 1.2`. Use on h1/h2/h3. +- `text-numeric` — applies `font-mono`, tabular nums, slashed zero. + +### 2.3 Spacing + +4 px grid. Allowed tailwind values: `0.5, 1, 1.5, 2, 2.5, 3, 4, 6, 8, 12, 16`. +Snap everything else. + +Common rhythms: + +- `gap-1.5` — chips, inline status indicators +- `gap-2` — adjacent controls (button + button) +- `gap-3` — related controls (input + button group) +- `gap-4` — within a card body +- `gap-6` — between major sections + +Page padding: `px-6 py-4` standard. Sheet / dialog inner padding: `p-4`. + +### 2.4 Radius + +Maps to `--radius-*` tokens; use the Tailwind shortcuts: + +| Class | px | Use | +| ---------------- | --- | ------------------------------------------------ | +| `rounded-sm` | 2 | Chips, badges, kbd, dense controls | +| `rounded-md` | 4 | Buttons, inputs, cards, surface containers | +| `rounded-lg` | 6 | Sheets, dialogs, large surfaces | +| `rounded-full` | ∞ | Avatars, status dots, circular buttons | + +No 8 px or 10 px radii. They look like marketing components. + +### 2.5 Borders + +- 1 px hairline default — `border border-border`. +- 1.5 px only for focus rings (`ring-2 ring-ring/40`). +- 2 px reserved for status emphasis (e.g. "current execution" outline on a + graph node — see §11). +- Avoid double borders (border + ring without offset). + +### 2.6 Shadows + +`shadow-sm` only. Elevation is achieved via *background contrast* (card +above canvas), not blurred shadows. The one exception: `` +and other floating overlays may use `shadow-lg` for popovers. + +## 3. Motion + +Three named animations only. Defined in `assets/src/index.css`. + +| Name | Duration | Easing | Use | +| ----------------- | -------- | ----------- | ---------------------------------------------------- | +| `led-dot` | 1.6 s | ease-in-out | Running/active status dots; pulses opacity + glow. | +| `dash-flow` | 1.0 s | linear | Flowing edges in the workflow graph. | +| `animate-pulse` | 2.0 s | cubic-bezier| Skeleton loaders only (never on real content). | + +Transitions: `transition-colors duration-150` for hover/focus. Nothing +slower than 200 ms. Layout transitions (`transition-all`) are forbidden — +they make data-dense UIs feel sluggish. + +`prefers-reduced-motion: reduce` disables all three animations through a +single media query in `index.css`. Don't bypass it. + +## 4. Status semantics — canonical table + +This is the **single source of truth** for every status string the system +emits. Do not invent local mappings. + +| Status | Color tier | Dot | Label | +| ----------------- | --------------- | ------- | -------------- | +| `pending` | `muted` | none | "pending" | +| `running` | `success` | pulse | "running" | +| `waiting` | `warning` | solid | "waiting" | +| `completed` | `success` | solid | "completed" | +| `failed` | `destructive` | none | "failed" | +| `cancelled` | `muted` | none | "cancelled" | +| `compensating` | `warning` | pulse | "compensating" | +| `compensated` | `muted` | none | "compensated" | +| `compensation_failed` | `destructive` | none | "comp. failed" | +| `scheduled` | `info` | none | "scheduled" | +| `timeout` | `destructive` | none | "timeout" | + +To add a status: + +1. Update this table. +2. Update `Components.Core.status_meta/1` (HEEx-side) and the `toneFor` + helpers in `step_node.tsx` and any other React node components. +3. Update the workflow query/schema if needed. + +If those four don't agree, the table wins. + +## 5. Component primitives — `Components.Core` API contract + +Stateless visual primitives live in +`lib/durable_dashboard/components/core.ex`. Every primitive listed here is +already implemented; see the source for the full attr list. + +### `<.button>` + +Variants: `primary | secondary | ghost | destructive | link`. +Sizes: `sm` (28h) | `md` (32h, default) | `lg` (40h). + +```heex +<.button kind="primary" type="submit">Save +<.button kind="ghost" size="sm" phx-click="cancel">Cancel +``` + +**Don't** roll your own ` + ); +} diff --git a/durable_dashboard/assets/src/components/layout/TopBar.tsx b/durable_dashboard/assets/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..665eb7b --- /dev/null +++ b/durable_dashboard/assets/src/components/layout/TopBar.tsx @@ -0,0 +1,103 @@ +import { useMemo } from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Separator } from "@/components/ui/separator"; +import { SidebarTrigger } from "@/components/ui/sidebar"; +import type { NavigateFn, ViewName } from "@/lib/types"; + +interface TopBarProps { + currentView: ViewName; + viewParams: Record; + navigate: NavigateFn; +} + +type Crumb = { + label: string; + onClick?: () => void; +}; + +const VIEW_LABELS: Record = { + overview: "Overview", + workflows: "Workflows", + workflow_detail: "Workflows", + schedules: "Schedules", + inputs: "Inputs", + settings: "Settings", +}; + +const TAB_LABELS: Record = { + summary: "Summary", + flow: "Flow", + topology: "Topology", + logs: "Logs", + io: "I/O", + history: "History", +}; + +export function TopBar({ currentView, viewParams, navigate }: TopBarProps) { + const crumbs = useMemo(() => { + const items: Crumb[] = []; + + if (currentView === "workflow_detail") { + items.push({ + label: "Workflows", + onClick: () => navigate("workflows"), + }); + + const name = viewParams.name || viewParams.id?.slice(0, 8) || "Detail"; + items.push({ + label: name, + onClick: viewParams.tab + ? () => + navigate("workflow_detail", { + id: viewParams.id, + }) + : undefined, + }); + + if (viewParams.tab) { + items.push({ + label: TAB_LABELS[viewParams.tab] || viewParams.tab, + }); + } + } else { + items.push({ + label: VIEW_LABELS[currentView] || currentView, + }); + } + + return items; + }, [currentView, viewParams, navigate]); + + return ( +
+ + + + + {crumbs.map((crumb, i) => { + const isLast = i === crumbs.length - 1; + return ( + + {i > 0 && } + {isLast || !crumb.onClick ? ( + {crumb.label} + ) : ( + + {crumb.label} + + )} + + ); + })} + + +
+ ); +} diff --git a/durable_dashboard/assets/src/components/shared/InputForm.tsx b/durable_dashboard/assets/src/components/shared/InputForm.tsx new file mode 100644 index 0000000..254d6a5 --- /dev/null +++ b/durable_dashboard/assets/src/components/shared/InputForm.tsx @@ -0,0 +1,258 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { PendingInput } from "@/lib/types"; + +interface InputFormProps { + input: PendingInput; + onSubmit: (data: unknown) => void; +} + +export function InputForm({ input, onSubmit }: InputFormProps) { + switch (input.input_type) { + case "approval": + return ; + case "single_choice": + return ; + case "multi_choice": + return ; + case "free_text": + return ; + case "form": + return ; + default: + return ; + } +} + +function ApprovalForm({ onSubmit }: { input: PendingInput; onSubmit: (data: unknown) => void }) { + const [submitting, setSubmitting] = useState(false); + + const handle = async (approved: boolean) => { + setSubmitting(true); + await onSubmit(approved ? "approved" : "rejected"); + setSubmitting(false); + }; + + return ( +
+ + +
+ ); +} + +function SingleChoiceForm({ + input, + onSubmit, +}: { + input: PendingInput; + onSubmit: (data: unknown) => void; +}) { + const [selected, setSelected] = useState(""); + const [submitting, setSubmitting] = useState(false); + const choices = input.fields || []; + + const handle = async () => { + if (!selected) return; + setSubmitting(true); + await onSubmit(selected); + setSubmitting(false); + }; + + return ( +
+
+ {choices.map((choice, i) => { + const val = choice.value || choice.label || String(i); + return ( + + ); + })} +
+ +
+ ); +} + +function MultiChoiceForm({ + input, + onSubmit, +}: { + input: PendingInput; + onSubmit: (data: unknown) => void; +}) { + const [selected, setSelected] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + const choices = input.fields || []; + + const toggle = (value: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(value)) next.delete(value); + else next.add(value); + return next; + }); + }; + + const handle = async () => { + setSubmitting(true); + await onSubmit(Array.from(selected)); + setSubmitting(false); + }; + + return ( +
+
+ {choices.map((choice, i) => { + const val = choice.value || choice.label || String(i); + return ( + + ); + })} +
+ +
+ ); +} + +function FreeTextForm({ + input, + onSubmit, +}: { + input: PendingInput; + onSubmit: (data: unknown) => void; +}) { + const [text, setText] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handle = async () => { + if (!text.trim()) return; + setSubmitting(true); + await onSubmit(text); + setSubmitting(false); + }; + + return ( +
+ + <% "number" -> %> + + <% _ -> %> + + <% end %> + + """ + end + + defp option_value(%{value: v}), do: to_string(v) + defp option_value(%{"value" => v}), do: to_string(v) + defp option_value(s) when is_binary(s), do: s + defp option_value(s) when is_atom(s), do: Atom.to_string(s) + + defp option_label(%{label: l}), do: l + defp option_label(%{"label" => l}), do: l + defp option_label(s) when is_binary(s), do: s + defp option_label(s) when is_atom(s), do: Atom.to_string(s) + + defp sget(map, key) when is_binary(key) do + Map.get(map, key) || Map.get(map, String.to_atom(key)) + end + + defp humanize(nil), do: "" + defp humanize(name), do: name |> to_string() |> String.replace("_", " ") |> String.capitalize() +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_controller.ex b/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_controller.ex deleted file mode 100644 index 6d6b312..0000000 --- a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_controller.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule PhoenixDemoWeb.PageController do - use PhoenixDemoWeb, :controller - - def home(conn, _params) do - redirect(conn, to: ~p"/workflows") - end -end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html.ex b/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html.ex index a7bd233..e69de29 100644 --- a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html.ex +++ b/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html.ex @@ -1,10 +0,0 @@ -defmodule PhoenixDemoWeb.PageHTML do - @moduledoc """ - This module contains pages rendered by PageController. - - See the `page_html` directory for all templates available. - """ - use PhoenixDemoWeb, :html - - embed_templates "page_html/*" -end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html/home.html.heex b/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html/home.html.heex deleted file mode 100644 index b107fd0..0000000 --- a/examples/phoenix_demo/lib/phoenix_demo_web/controllers/page_html/home.html.heex +++ /dev/null @@ -1,202 +0,0 @@ - - -
-
- -
-

- Phoenix Framework - - v{Application.spec(:phoenix, :vsn)} - -

- -
- -

- Peace of mind from prototype to production. -

-

- Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale. -

- -
-
diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/approval_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/approval_live.ex deleted file mode 100644 index 9070a2b..0000000 --- a/examples/phoenix_demo/lib/phoenix_demo_web/live/approval_live.ex +++ /dev/null @@ -1,287 +0,0 @@ -defmodule PhoenixDemoWeb.ApprovalLive do - @moduledoc """ - LiveView for managing human-in-the-loop approvals. - Shows pending approval requests and allows users to approve or reject them. - """ - - use PhoenixDemoWeb, :live_view - - alias Durable.Config - alias Durable.Storage.Schemas.{PendingInput, WorkflowExecution} - alias Durable.Wait - - import Ecto.Query - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") - :timer.send_interval(2000, self(), :refresh) - end - - {:ok, - assign(socket, - page_title: "Pending Approvals", - pending_approvals: list_pending_approvals(), - selected: nil, - reason: "" - )} - end - - @impl true - def handle_info(:refresh, socket) do - {:noreply, assign(socket, pending_approvals: list_pending_approvals())} - end - - def handle_info({:workflow_started, _id}, socket) do - {:noreply, assign(socket, pending_approvals: list_pending_approvals())} - end - - def handle_info({:workflow_completed, _id, _report}, socket) do - {:noreply, assign(socket, pending_approvals: list_pending_approvals())} - end - - def handle_info({:workflow_rejected, _id, _result}, socket) do - {:noreply, assign(socket, pending_approvals: list_pending_approvals())} - end - - @impl true - def handle_event("select", %{"id" => id}, socket) do - selected = Enum.find(socket.assigns.pending_approvals, &(&1.id == id)) - {:noreply, assign(socket, selected: selected, reason: "")} - end - - @impl true - def handle_event("close_modal", _params, socket) do - {:noreply, assign(socket, selected: nil, reason: "")} - end - - @impl true - def handle_event("update_reason", %{"reason" => reason}, socket) do - {:noreply, assign(socket, reason: reason)} - end - - @impl true - def handle_event("approve", %{"id" => id}, socket) do - pending = Enum.find(socket.assigns.pending_approvals, &(&1.id == id)) - - if pending do - response = %{ - "approved" => true, - "approved_by" => "manager", - "approved_at" => DateTime.utc_now() |> DateTime.to_iso8601() - } - - case Wait.provide_input(pending.workflow_id, pending.input_name, response) do - :ok -> - {:noreply, - socket - |> assign( - pending_approvals: list_pending_approvals(), - selected: nil, - reason: "" - ) - |> put_flash(:info, "Approval granted successfully!")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to approve: #{inspect(reason)}")} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("reject", %{"id" => id}, socket) do - pending = Enum.find(socket.assigns.pending_approvals, &(&1.id == id)) - - if pending do - response = %{ - "approved" => false, - "reason" => socket.assigns.reason || "Rejected by reviewer", - "rejected_by" => "manager", - "rejected_at" => DateTime.utc_now() |> DateTime.to_iso8601() - } - - case Wait.provide_input(pending.workflow_id, pending.input_name, response) do - :ok -> - {:noreply, - socket - |> assign( - pending_approvals: list_pending_approvals(), - selected: nil, - reason: "" - ) - |> put_flash(:info, "Workflow rejected.")} - - {:error, reason} -> - {:noreply, put_flash(socket, :error, "Failed to reject: #{inspect(reason)}")} - end - else - {:noreply, socket} - end - end - - defp list_pending_approvals do - config = Config.get(Durable) - repo = config.repo - - repo.all( - from(p in PendingInput, - join: w in WorkflowExecution, - on: p.workflow_id == w.id, - where: p.status == :pending and p.input_type == :approval, - order_by: [asc: p.inserted_at], - preload: [:workflow], - select_merge: %{workflow: w} - ) - ) - end - - defp format_time(nil), do: "-" - - defp format_time(datetime) do - Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S") - end - - @impl true - def render(assigns) do - ~H""" - <.header> - Pending Approvals - <:subtitle>Review and approve document processing requests - <:actions> - <.button navigate={~p"/workflows"}> - <.icon name="hero-arrow-left" class="size-4 mr-1" /> Back to Dashboard - - - - -
-
- <.icon name="hero-inbox" class="size-16 mx-auto text-base-content/30" /> -

No pending approvals

-

- When workflows need approval, they'll appear here. -

- <.button navigate={~p"/workflows/new"} class="mt-4" variant="primary"> - Create a Workflow - -
- -
-
-
-
-
-

- <.icon name="hero-document-text" class="size-5" /> - {approval.input_name} -

-

- {approval.prompt} -

-
- Pending -
- -
- -
-
- Workflow ID: - - {String.slice(approval.workflow_id, 0, 8)}... - -
-
- Step: - {approval.step_name} -
-
- Created: - {format_time(approval.inserted_at)} -
-
- Timeout: - {format_time(approval.timeout_at)} -
-
- -
-
- - View Document Data - -
-
{Jason.encode!(approval.metadata, pretty: true)}
-
-
-
- -
- -
-
-
-
-
- - - - """ - end -end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/document_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/document_live.ex deleted file mode 100644 index c3cfbb0..0000000 --- a/examples/phoenix_demo/lib/phoenix_demo_web/live/document_live.ex +++ /dev/null @@ -1,248 +0,0 @@ -defmodule PhoenixDemoWeb.DocumentLive do - @moduledoc """ - LiveView for uploading documents and starting processing workflows. - Supports actual file uploads with real processing. - """ - - use PhoenixDemoWeb, :live_view - - alias PhoenixDemo.Workflows.DocumentWorkflow - - @impl true - def mount(_params, _session, socket) do - {:ok, - socket - |> assign(page_title: "Upload Document", workflow_id: nil, error: nil) - |> allow_upload(:document, - accept: ~w(.pdf .txt .csv .md .json .xml), - max_entries: 1, - max_file_size: 10_000_000 - )} - end - - @impl true - def handle_event("validate", _params, socket) do - {:noreply, socket} - end - - @impl true - def handle_event("cancel-upload", %{"ref" => ref}, socket) do - {:noreply, cancel_upload(socket, :document, ref)} - end - - @impl true - def handle_event("submit", _params, socket) do - case uploaded_entries(socket, :document) do - {[_ | _], []} -> - # Process the upload - [result] = - consume_uploaded_entries(socket, :document, fn %{path: path}, entry -> - # Save file to uploads directory - filename = entry.client_name - dest_path = Path.join(uploads_dir(), "#{entry.uuid}_#{filename}") - File.cp!(path, dest_path) - - {:ok, - %{ - "filename" => filename, - "path" => dest_path, - "size" => entry.client_size, - "content_type" => entry.client_type - }} - end) - - # Start the workflow with file info - case Durable.start(DocumentWorkflow, result) do - {:ok, workflow_id} -> - Phoenix.PubSub.broadcast( - PhoenixDemo.PubSub, - "workflows", - {:workflow_started, workflow_id} - ) - - {:noreply, - socket - |> assign(workflow_id: workflow_id, error: nil) - |> put_flash(:info, "Document uploaded and workflow started!")} - - {:error, reason} -> - {:noreply, assign(socket, error: "Failed to start workflow: #{inspect(reason)}")} - end - - {[], []} -> - {:noreply, assign(socket, error: "Please select a file to upload")} - - {_, [_ | _]} -> - {:noreply, socket} - end - end - - defp uploads_dir do - Path.join(:code.priv_dir(:phoenix_demo), "uploads") - end - - defp error_to_string(:too_large), do: "File is too large (max 10MB)" - defp error_to_string(:not_accepted), do: "File type not accepted" - defp error_to_string(:too_many_files), do: "Only one file allowed" - defp error_to_string(err), do: "Error: #{inspect(err)}" - - @impl true - def render(assigns) do - ~H""" - <.header> - Upload Document - <:subtitle>Upload a document to start the processing workflow - <:actions> - <.button navigate={~p"/workflows"}> - <.icon name="hero-arrow-left" class="size-4 mr-1" /> Back to Dashboard - - - - -
-
-
-

Select Document

- - <.form for={%{}} phx-change="validate" phx-submit="submit" class="mt-4"> -
- <.live_file_input upload={@uploads.document} class="hidden" /> - -
- <.icon name="hero-cloud-arrow-up" class="size-12 mx-auto text-base-content/40" /> -

- Drag and drop a file here, or - -

-

- Supported: PDF, TXT, CSV, MD, JSON, XML (max 10MB) -

-
- - <%= for entry <- @uploads.document.entries do %> -
-
- <.icon name={file_icon(entry.client_name)} class="size-8 text-primary" /> -
-

{entry.client_name}

-

- {format_bytes(entry.client_size)} -

-
-
- -
- -
0 and entry.progress < 100} class="mt-2"> - - -
- - <%= for err <- upload_errors(@uploads.document, entry) do %> -

{error_to_string(err)}

- <% end %> - <% end %> -
- -
- <.icon name="hero-exclamation-circle" class="size-5" /> - {@error} -
- -
- <.button - type="submit" - variant="primary" - disabled={@uploads.document.entries == []} - > - <.icon name="hero-arrow-up-tray" class="size-4 mr-1" /> Upload & Process - -
- -
-
- -
-
- <.icon name="hero-check-circle" class="size-6" /> -
-

Workflow Started!

-

- Workflow ID: {@workflow_id} -

-
-
- <.link navigate={~p"/workflows/#{@workflow_id}"} class="btn btn-sm"> - View Progress - -
-
-
- -
-
-
-

- <.icon name="hero-cog-6-tooth" class="size-5" /> Processing Steps -

-
    -
  • Validate file format
  • -
  • Analyze content (size, lines, etc.)
  • -
  • Check if approval needed
  • -
  • Transform & generate report
  • -
-
-
- -
-
-

- <.icon name="hero-shield-check" class="size-5" /> Approval Rules -

-
-
- Requires Approval - PDF files -
-
- Auto-Approved - TXT, CSV, MD, JSON, XML -
-
-
-
-
-
- """ - end - - defp file_icon(filename) do - ext = Path.extname(filename) |> String.downcase() - - case ext do - ".pdf" -> "hero-document" - ".txt" -> "hero-document-text" - ".csv" -> "hero-table-cells" - ".md" -> "hero-document-text" - ".json" -> "hero-code-bracket" - ".xml" -> "hero-code-bracket" - _ -> "hero-document" - end - end - - defp format_bytes(bytes) when bytes < 1024, do: "#{bytes} B" - defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB" - defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB" -end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/executions_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/executions_live.ex new file mode 100644 index 0000000..868f971 --- /dev/null +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/executions_live.ex @@ -0,0 +1,211 @@ +defmodule PhoenixDemoWeb.ExecutionsLive do + @moduledoc """ + Lists workflow executions with URL-driven filters. Replaces the older + WorkflowLive dashboard. + """ + use PhoenixDemoWeb, :live_view + + alias Durable.Config + alias Durable.Storage.Schemas.WorkflowExecution + + import Ecto.Query + + @statuses ~w(all pending running waiting completed failed cancelled) + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") + :timer.send_interval(2_000, self(), :refresh) + end + + {:ok, + assign(socket, + page_title: "Executions", + active_nav: :executions, + status: "all", + module: nil, + workflows: [] + )} + end + + @impl true + def handle_params(params, _uri, socket) do + status = if params["status"] in @statuses, do: params["status"], else: "all" + module = params["workflow"] + + socket = + socket + |> assign(status: status, module: module) + |> assign(workflows: list_workflows(status, module)) + |> assign(modules: list_modules()) + + {:noreply, socket} + end + + @impl true + def handle_info(:refresh, socket) do + {:noreply, assign(socket, workflows: list_workflows(socket.assigns.status, socket.assigns.module), modules: list_modules())} + end + + def handle_info({event, _, _}, socket) when event in [:workflow_completed, :workflow_rejected] do + {:noreply, assign(socket, workflows: list_workflows(socket.assigns.status, socket.assigns.module))} + end + + def handle_info(_, socket), do: {:noreply, socket} + + defp list_workflows(status, module) do + config = Config.get(Durable) + repo = config.repo + + query = + from(w in WorkflowExecution, + order_by: [desc: w.inserted_at], + limit: 100 + ) + + query = + case status do + "all" -> query + s -> from(w in query, where: w.status == ^String.to_atom(s)) + end + + query = + case module do + nil -> query + "" -> query + m -> from(w in query, where: w.workflow_module == ^m) + end + + repo.all(query) + end + + defp list_modules do + config = Config.get(Durable) + repo = config.repo + + repo.all( + from(w in WorkflowExecution, + select: w.workflow_module, + distinct: true, + order_by: w.workflow_module + ) + ) + |> Enum.reject(&is_nil/1) + end + + defp status_badge(status) do + case status do + :pending -> "badge badge-warning" + :running -> "badge badge-info" + :waiting -> "badge badge-secondary" + :completed -> "badge badge-success" + :failed -> "badge badge-error" + :cancelled -> "badge badge-ghost" + _ -> "badge" + end + end + + defp short_module(nil), do: "-" + defp short_module(mod), do: mod |> String.split(".") |> List.last() + + defp format_time(nil), do: "-" + defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S") + + @impl true + def render(assigns) do + ~H""" + +
+
+

Executions

+

All workflow runs across the demo.

+
+ <.link navigate={~p"/"} class="btn btn-sm btn-ghost"> + <.icon name="hero-plus" class="size-4" /> Run a workflow + +
+ +
+
+ <.link + :for={s <- ~w(all pending running waiting completed failed cancelled)} + patch={status_path(s, @module)} + role="tab" + class={["tab", @status == s && "tab-active"]} + > + {s} + +
+ +
+
+ +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
IDWorkflowStatusStepCreated
{String.slice(w.id, 0, 8)} +
{w.workflow_name}
+
{short_module(w.workflow_module)}
+
{w.status}{w.current_step || "-"}{format_time(w.inserted_at)} + <.link navigate={~p"/executions/#{w.id}"} class="btn btn-xs btn-ghost"> + View + +
+ No executions match these filters. +
+
+
+ """ + end + + @impl true + def handle_event("filter_module", %{"module" => mod}, socket) do + {:noreply, push_patch(socket, to: status_path(socket.assigns.status, blank_to_nil(mod)))} + end + + defp blank_to_nil(""), do: nil + defp blank_to_nil(v), do: v + + defp status_path(status, module) do + params = + [{"status", status}, {"workflow", module}] + |> Enum.reject(fn {_k, v} -> v in [nil, "", "all"] end) + + case params do + [] -> ~p"/executions" + _ -> ~p"/executions?#{params}" + end + end +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/home_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/home_live.ex new file mode 100644 index 0000000..b00c59a --- /dev/null +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/home_live.ex @@ -0,0 +1,277 @@ +defmodule PhoenixDemoWeb.HomeLive do + @moduledoc """ + Workflow hub: a card per demo workflow. Each card opens a modal with a + simulated-input form that triggers the workflow via `Durable.start/2` + (or `Durable.trigger_schedule/1` for the cron entry). + """ + use PhoenixDemoWeb, :live_view + + import PhoenixDemoWeb.WorkflowForm + + alias PhoenixDemo.Workflows + + @workflows [ + %{ + key: "order_fulfillment", + title: "Order Fulfillment", + module: Workflows.OrderFulfillmentWorkflow, + description: + "End-to-end order: reserve inventory → call PaymentWorkflow → ship → confirm. Toggle the failure flag to watch saga compensation cascade.", + pills: ["call_workflow", "saga", "compensate"], + fields: [ + %{name: "order_id", label: "Order ID", type: "text", default: "ORD-1042"}, + %{name: "amount", label: "Amount ($)", type: "number", default: 99.99, required: true}, + %{ + name: "force_failure", + label: "Force shipping failure (triggers saga rollback)", + type: "checkbox", + default: false + } + ] + }, + %{ + key: "payment", + title: "Payment", + module: Workflows.PaymentWorkflow, + description: + "Standalone or child workflow. Authorize step has retry: max_attempts: 3, backoff: :exponential — random failures show as separate attempts.", + pills: ["retry", "exponential backoff", "composition"], + fields: [ + %{name: "amount", label: "Amount ($)", type: "number", default: 49.50, required: true}, + %{ + name: "force_failure", + label: "Force authorization failure", + type: "checkbox", + default: false + } + ] + }, + %{ + key: "expense_approval", + title: "Expense Approval", + module: Workflows.ExpenseApprovalWorkflow, + description: + "Collects an expense via wait_for_form, then routes to single (manager) or dual (manager + CFO via wait_for_all) approval based on amount.", + pills: ["wait_for_form", "wait_for_all", "decision"], + fields: [ + %{name: "employee", label: "Employee", type: "text", default: "Alice", required: true} + ] + }, + %{ + key: "content_moderation", + title: "Content Moderation", + module: Workflows.ContentModerationWorkflow, + description: + "Three parallel mock-AI scans aggregate to a max-score, then branch routes to auto-remove / human-review / auto-approve.", + pills: ["parallel", "branch", "wait_for_choice"], + fields: [ + %{name: "content_id", label: "Content ID", type: "text", default: "POST-9001"}, + %{ + name: "content_type", + label: "Content type", + type: "select", + options: ["text", "image", "video"], + default: "image" + } + ] + }, + %{ + key: "payment_reconciliation", + title: "Payment Reconciliation", + module: Workflows.PaymentReconciliationWorkflow, + description: + "Submits to a (mock) processor and parks waiting for a webhook event. Send the event from /pending-events to resume.", + pills: ["wait_for_event", "send_event"], + fields: [ + %{name: "transaction_id", label: "Transaction ID", type: "text", default: "TXN-7700"} + ] + }, + %{ + key: "drip_email", + title: "Drip Email Campaign", + module: Workflows.DripEmailCampaignWorkflow, + description: + "Welcome → schedule_at(+30s) → day-2 → sleep(30s) → day-7. The status flips :running ↔ :waiting visibly across the run.", + pills: ["sleep", "schedule_at", "multi-stage"], + fields: [ + %{ + name: "customer_email", + label: "Customer email", + type: "text", + default: "alice@example.com", + required: true + }, + %{name: "campaign", label: "Campaign", type: "text", default: "welcome"} + ] + }, + %{ + key: "hourly_metrics_cron", + kind: :cron, + schedule_name: "hourly_metrics", + title: "Hourly Metrics (cron)", + module: Workflows.HourlyMetricsCronWorkflow, + description: + "Auto-runs every minute via @schedule. Click Run now to fire one immediately, or visit /schedules to watch it tick.", + pills: ["@schedule", "cron", "scheduled"], + fields: [] + } + ] + + @impl true + def mount(_params, _session, socket) do + {:ok, + assign(socket, + page_title: "Durable Demo", + active_nav: :home, + workflows: @workflows, + open_modal: nil + )} + end + + @impl true + def handle_event("open_modal", %{"key" => key}, socket) do + case Enum.find(@workflows, &(&1.key == key)) do + nil -> {:noreply, socket} + wf -> {:noreply, assign(socket, open_modal: wf)} + end + end + + def handle_event("close_modal", _params, socket) do + {:noreply, assign(socket, open_modal: nil)} + end + + def handle_event("run_workflow", params, socket) do + case socket.assigns.open_modal do + nil -> + {:noreply, socket} + + %{kind: :cron, schedule_name: name} -> + case Durable.trigger_schedule(name) do + {:ok, workflow_id} -> + {:noreply, + socket + |> assign(open_modal: nil) + |> put_flash(:info, "Triggered scheduled run.") + |> push_navigate(to: ~p"/executions/#{workflow_id}")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Trigger failed: #{inspect(reason)}")} + end + + %{module: module, fields: fields} -> + input = build_input(fields, params["fields"] || %{}) + + case Durable.start(module, input) do + {:ok, workflow_id} -> + {:noreply, + socket + |> assign(open_modal: nil) + |> put_flash(:info, "Workflow started.") + |> push_navigate(to: ~p"/executions/#{workflow_id}")} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Start failed: #{inspect(reason)}")} + end + end + end + + defp build_input(fields, submitted) do + Enum.reduce(fields, %{}, fn field, acc -> + name = field[:name] + raw = Map.get(submitted, name, field[:default]) + Map.put(acc, name, coerce(field[:type], raw)) + end) + end + + defp coerce("number", v) when is_number(v), do: v + defp coerce("number", v) when is_binary(v) do + case Float.parse(v) do + {n, _} -> n + :error -> 0 + end + end + + defp coerce("checkbox", "true"), do: true + defp coerce("checkbox", true), do: true + defp coerce("checkbox", _), do: false + + defp coerce(_, v), do: v + + @impl true + def render(assigns) do + ~H""" + +
+

Workflow Showcase

+

+ Each card runs a workflow that demonstrates a specific Durable feature. Click Run demo + to trigger one with simulated input. Crons fire automatically — open <.link navigate={~p"/schedules"} class="link link-primary">/schedules + to watch them tick. +

+
+ +
+
+
+

+ <.icon :if={wf[:kind] == :cron} name="hero-clock" class="size-4 text-accent" /> + {wf.title} +

+

{wf.description}

+
+ + {pill} + +
+
+ +
+
+
+
+ + +
+ """ + end +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_events_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_events_live.ex new file mode 100644 index 0000000..32736d1 --- /dev/null +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_events_live.ex @@ -0,0 +1,195 @@ +defmodule PhoenixDemoWeb.PendingEventsLive do + @moduledoc """ + Sends external events to workflows blocked on `wait_for_event`. For + recognised event names (e.g. `webhook_received`) the page offers preset + payload buttons; everything else gets a free-form payload editor. + """ + use PhoenixDemoWeb, :live_view + + alias Durable.Wait + alias Durable.Storage.Schemas.WorkflowExecution + alias Durable.Config + + import Ecto.Query + + @presets %{ + "webhook_received" => [ + %{label: "Send: settled", payload: %{"status" => "settled", "amount" => 99.99}}, + %{label: "Send: failed", payload: %{"status" => "failed", "code" => "card_declined"}}, + %{label: "Send: timeout marker", payload: %{"status" => "timeout"}} + ] + } + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") + :timer.send_interval(2_000, self(), :refresh) + end + + {:ok, + assign(socket, + page_title: "Pending Events", + active_nav: :pending_events, + pending: load_pending(), + payloads: %{} + )} + end + + @impl true + def handle_info(:refresh, socket) do + {:noreply, assign(socket, pending: load_pending())} + end + + def handle_info(_, socket), do: {:noreply, socket} + + defp load_pending do + pending = Wait.list_pending_events(limit: 100) + workflows = preload_workflows(Enum.map(pending, & &1.workflow_id)) + + Enum.map(pending, fn p -> + Map.put(p, :workflow, Map.get(workflows, p.workflow_id)) + end) + end + + defp preload_workflows([]), do: %{} + + defp preload_workflows(ids) do + config = Config.get(Durable) + repo = config.repo + + from(w in WorkflowExecution, where: w.id in ^ids, select: {w.id, w}) + |> repo.all() + |> Map.new() + end + + @impl true + def handle_event("update_payload", %{"_id" => id, "payload" => payload}, socket) do + {:noreply, assign(socket, payloads: Map.put(socket.assigns.payloads, id, payload))} + end + + def handle_event("send_preset", %{"id" => id, "preset" => preset_idx}, socket) do + pending = Enum.find(socket.assigns.pending, &(&1.id == id)) + + with %{} = p <- pending, + presets when is_list(presets) <- Map.get(@presets, p.event_name), + idx when is_integer(idx) <- parse_int(preset_idx), + %{payload: payload} <- Enum.at(presets, idx) do + send_event(socket, p, payload) + else + _ -> {:noreply, put_flash(socket, :error, "Preset not found")} + end + end + + def handle_event("send_custom", %{"_id" => id}, socket) do + pending = Enum.find(socket.assigns.pending, &(&1.id == id)) + + with %{} = p <- pending, + raw <- Map.get(socket.assigns.payloads, id, "{}"), + {:ok, decoded} <- Jason.decode(raw) do + send_event(socket, p, decoded) + else + {:error, %Jason.DecodeError{}} -> + {:noreply, put_flash(socket, :error, "Payload is not valid JSON")} + + _ -> + {:noreply, socket} + end + end + + defp send_event(socket, p, payload) do + case Wait.send_event(p.workflow_id, p.event_name, payload) do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Sent #{p.event_name} to #{String.slice(p.workflow_id, 0, 8)}") + |> assign(pending: load_pending())} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed: #{inspect(reason)}")} + end + end + + defp parse_int(s) when is_binary(s), do: String.to_integer(s) + defp parse_int(n) when is_integer(n), do: n + defp parse_int(_), do: nil + + defp short_module(nil), do: "-" + defp short_module(mod), do: mod |> String.split(".") |> List.last() + + defp format_time(nil), do: "-" + defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S") + + @impl true + def render(assigns) do + presets = @presets + + assigns = assign(assigns, presets: presets) + + ~H""" + +
+

Pending Events

+

+ Workflows blocked on wait_for_event. Send a payload below to resume them. +

+
+ +
+ <.icon name="hero-bolt" class="size-12 mx-auto text-base-content/30" /> +

No workflows waiting on events

+

Trigger Payment Reconciliation from the home page to populate this list.

+
+ +
+
+
+
+
+
{p.event_name}
+
+ {short_module(p.workflow && p.workflow.workflow_module)} · step {p.step_name} +
+
+ {p.wait_type} +
+ +
+ WF {String.slice(p.workflow_id, 0, 8)} + timeout {format_time(p.timeout_at)} +
+ +
+ +
+ +
+ Custom payload +
+ + +
+ +
+
+
+
+
+
+
+ """ + end +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_inputs_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_inputs_live.ex new file mode 100644 index 0000000..abd3718 --- /dev/null +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/pending_inputs_live.ex @@ -0,0 +1,377 @@ +defmodule PhoenixDemoWeb.PendingInputsLive do + @moduledoc """ + Surfaces every workflow blocked on a pending input. Each card renders an + inline form whose fields come from the wait spec (form / choice / text / + approval) and submits via `Durable.Wait.provide_input/3`. + """ + use PhoenixDemoWeb, :live_view + + alias Durable.Wait + alias Durable.Storage.Schemas.WorkflowExecution + alias Durable.Config + + import Ecto.Query + + @impl true + def mount(params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") + :timer.send_interval(2_000, self(), :refresh) + end + + type_filter = normalize_type_filter(params["type"]) + + {:ok, + assign(socket, + page_title: "Pending Inputs", + active_nav: :pending_inputs, + type_filter: type_filter, + pending: load_pending(type_filter), + reasons: %{} + )} + end + + @impl true + def handle_params(params, _uri, socket) do + type_filter = normalize_type_filter(params["type"]) + {:noreply, assign(socket, type_filter: type_filter, pending: load_pending(type_filter))} + end + + @impl true + def handle_info(:refresh, socket) do + {:noreply, assign(socket, pending: load_pending(socket.assigns.type_filter))} + end + + def handle_info(_, socket), do: {:noreply, socket} + + defp normalize_type_filter(nil), do: :all + defp normalize_type_filter(""), do: :all + defp normalize_type_filter("all"), do: :all + defp normalize_type_filter("approval"), do: :approval + defp normalize_type_filter("single_choice"), do: :single_choice + defp normalize_type_filter("free_text"), do: :free_text + defp normalize_type_filter("form"), do: :form + defp normalize_type_filter(_), do: :all + + defp load_pending(filter) do + pending = Wait.list_pending_inputs(limit: 100) + + pending = + case filter do + :all -> pending + type -> Enum.filter(pending, &(&1.input_type == type)) + end + + workflows = preload_workflows(Enum.map(pending, & &1.workflow_id)) + + Enum.map(pending, fn p -> + Map.put(p, :workflow, Map.get(workflows, p.workflow_id)) + end) + end + + defp preload_workflows([]), do: %{} + + defp preload_workflows(ids) do + config = Config.get(Durable) + repo = config.repo + + from(w in WorkflowExecution, where: w.id in ^ids, select: {w.id, w}) + |> repo.all() + |> Map.new() + end + + @impl true + def handle_event("submit_form", %{"_id" => id, "fields" => fields}, socket) do + pending = Enum.find(socket.assigns.pending, &(&1.id == id)) + + if pending do + case Wait.provide_input(pending.workflow_id, pending.input_name, fields) do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Input provided.") + |> assign(pending: load_pending(socket.assigns.type_filter))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed: #{inspect(reason)}")} + end + else + {:noreply, socket} + end + end + + def handle_event("submit_choice", %{"_id" => id, "fields" => %{"value" => value}}, socket) do + do_provide(socket, id, value) + end + + def handle_event("submit_text", %{"_id" => id, "fields" => %{"text" => text}}, socket) do + do_provide(socket, id, text) + end + + def handle_event("approve", %{"id" => id}, socket) do + response = %{ + "approved" => true, + "approved_by" => "demo-user", + "approved_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + do_provide(socket, id, response) + end + + def handle_event("reject", %{"_id" => id}, socket) do + reason = Map.get(socket.assigns.reasons, id, "") + + response = %{ + "approved" => false, + "reason" => reason, + "rejected_by" => "demo-user", + "rejected_at" => DateTime.utc_now() |> DateTime.to_iso8601() + } + + do_provide(socket, id, response) + end + + def handle_event("update_reason", %{"_id" => id, "reason" => reason}, socket) do + {:noreply, assign(socket, reasons: Map.put(socket.assigns.reasons, id, reason))} + end + + def handle_event("update_reason", _params, socket), do: {:noreply, socket} + + defp do_provide(socket, id, response) do + pending = Enum.find(socket.assigns.pending, &(&1.id == id)) + + if pending do + case Wait.provide_input(pending.workflow_id, pending.input_name, response) do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Response submitted.") + |> assign(pending: load_pending(socket.assigns.type_filter))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Failed: #{inspect(reason)}")} + end + else + {:noreply, socket} + end + end + + defp short_module(nil), do: "-" + defp short_module(mod), do: mod |> String.split(".") |> List.last() + + defp format_time(nil), do: "-" + defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S") + + defp type_label(:approval), do: "Approval" + defp type_label(:single_choice), do: "Choice" + defp type_label(:free_text), do: "Text" + defp type_label(:form), do: "Form" + defp type_label(other), do: to_string(other) + + defp normalize_fields(fields) when is_list(fields), do: fields + defp normalize_fields(_), do: [] + + @impl true + def render(assigns) do + ~H""" + +
+

Pending Inputs

+

+ Workflows blocked on a human response. Submit one to resume the workflow. +

+
+ +
+ <.link patch={~p"/pending-inputs"} role="tab" class={["tab", @type_filter == :all && "tab-active"]}>All + <.link patch={~p"/pending-inputs?type=approval"} role="tab" class={["tab", @type_filter == :approval && "tab-active"]}>Approvals + <.link patch={~p"/pending-inputs?type=form"} role="tab" class={["tab", @type_filter == :form && "tab-active"]}>Forms + <.link patch={~p"/pending-inputs?type=single_choice"} role="tab" class={["tab", @type_filter == :single_choice && "tab-active"]}>Choices + <.link patch={~p"/pending-inputs?type=free_text"} role="tab" class={["tab", @type_filter == :free_text && "tab-active"]}>Text +
+ +
+ <.icon name="hero-inbox" class="size-12 mx-auto text-base-content/30" /> +

No pending inputs

+

Trigger a workflow that waits for input to populate this list.

+
+ +
+
+
+
+
+
{p.input_name}
+
+ {short_module(p.workflow && p.workflow.workflow_module)} · step {p.step_name} +
+
+ {type_label(p.input_type)} +
+ +

{p.prompt}

+ +
+ WF {String.slice(p.workflow_id, 0, 8)} + timeout {format_time(p.timeout_at)} +
+ +
+ Metadata +
{Jason.encode!(p.metadata, pretty: true)}
+
+ +
+ + <%= case p.input_type do %> + <% :approval -> %> +
+ + +
+ + +
+
+ + <% :single_choice -> %> +
+ +
+ +
+
+ +
+
+ + <% :free_text -> %> +
+ + +
+ +
+
+ + <% :form -> %> +
+ + <.form_fields fields={normalize_fields(p.fields)} /> +
+ +
+
+ + <% _ -> %> +

Unsupported input type: {p.input_type}

+ <% end %> +
+
+
+
+ """ + end + + attr :fields, :list, required: true + + defp form_fields(assigns) do + ~H""" +
+ +
+ """ + end + + defp field_name(f), do: get_str(f, "name") + defp field_label(f), do: get_str(f, "label") || get_str(f, "name") + defp field_type(f), do: get_str(f, "type") || "text" + + defp field_required?(f) do + case get_str(f, "required") do + true -> true + "true" -> true + _ -> false + end + end + + defp field_options(f) do + case get_str(f, "options") do + list when is_list(list) -> list + _ -> [] + end + end + + defp choice_value(%{"value" => v}), do: to_string(v) + defp choice_value(%{value: v}), do: to_string(v) + defp choice_value(s) when is_binary(s), do: s + defp choice_value(s) when is_atom(s), do: Atom.to_string(s) + + defp choice_label(%{"label" => l}), do: l + defp choice_label(%{label: l}), do: l + defp choice_label(s) when is_binary(s), do: s + defp choice_label(s) when is_atom(s), do: Atom.to_string(s) + + defp get_str(map, key) when is_map(map) do + Map.get(map, key) || Map.get(map, String.to_atom(key)) + end + + defp get_str(_, _), do: nil +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/schedules_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/schedules_live.ex new file mode 100644 index 0000000..c75ec05 --- /dev/null +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/schedules_live.ex @@ -0,0 +1,125 @@ +defmodule PhoenixDemoWeb.SchedulesLive do + @moduledoc """ + Lists registered scheduled workflows. Supports Run-now, Enable, and + Disable actions per row. + """ + use PhoenixDemoWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + :timer.send_interval(5_000, self(), :refresh) + end + + {:ok, + assign(socket, + page_title: "Schedules", + active_nav: :schedules, + schedules: Durable.list_schedules(limit: 100) + )} + end + + @impl true + def handle_info(:refresh, socket) do + {:noreply, assign(socket, schedules: Durable.list_schedules(limit: 100))} + end + + @impl true + def handle_event("trigger", %{"name" => name}, socket) do + case Durable.trigger_schedule(name) do + {:ok, workflow_id} -> + {:noreply, + socket + |> put_flash(:info, "Triggered: workflow #{String.slice(workflow_id, 0, 8)}") + |> assign(schedules: Durable.list_schedules(limit: 100))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Trigger failed: #{inspect(reason)}")} + end + end + + def handle_event("toggle", %{"name" => name, "enabled" => "true"}, socket) do + case Durable.disable_schedule(name) do + {:ok, _} -> {:noreply, assign(socket, schedules: Durable.list_schedules(limit: 100))} + err -> {:noreply, put_flash(socket, :error, "Disable failed: #{inspect(err)}")} + end + end + + def handle_event("toggle", %{"name" => name}, socket) do + case Durable.enable_schedule(name) do + {:ok, _} -> {:noreply, assign(socket, schedules: Durable.list_schedules(limit: 100))} + err -> {:noreply, put_flash(socket, :error, "Enable failed: #{inspect(err)}")} + end + end + + defp short_module(nil), do: "-" + defp short_module(mod), do: mod |> to_string() |> String.split(".") |> List.last() + + defp format_time(nil), do: "—" + defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S") + + @impl true + def render(assigns) do + ~H""" + +
+

Schedules

+

+ Cron-driven workflows registered via @schedule. The scheduler polls every 5 seconds, so the demo's * * * * * entry fires within a minute of boot. +

+
+ +
+ <.icon name="hero-clock" class="size-12 mx-auto text-base-content/30" /> +

No schedules registered

+

Confirm scheduled_modules: is set in application.ex.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
NameModuleCronEnabledLast runNext runFailures
{s.name}{short_module(s.workflow_module)}{s.cron_expression} + + {if s.enabled, do: "enabled", else: "disabled"} + + {format_time(s.last_run_at)}{format_time(s.next_run_at)}{s.consecutive_failures || 0} + + +
+
+
+ """ + end +end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_detail_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_detail_live.ex index 8f07cf7..95a367d 100644 --- a/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_detail_live.ex +++ b/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_detail_live.ex @@ -1,7 +1,7 @@ defmodule PhoenixDemoWeb.WorkflowDetailLive do @moduledoc """ - LiveView for viewing detailed workflow execution information. - Shows the workflow status, context, and step execution history. + Detailed view for a single workflow execution: status header, input, + context, error, child workflows, and step timeline. """ use PhoenixDemoWeb, :live_view @@ -15,7 +15,7 @@ defmodule PhoenixDemoWeb.WorkflowDetailLive do def mount(%{"id" => id}, _session, socket) do if connected?(socket) do Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") - :timer.send_interval(2000, self(), :refresh) + :timer.send_interval(2_000, self(), :refresh) end case get_workflow(id) do @@ -23,38 +23,46 @@ defmodule PhoenixDemoWeb.WorkflowDetailLive do {:ok, socket |> put_flash(:error, "Workflow not found") - |> redirect(to: ~p"/workflows")} + |> redirect(to: ~p"/executions")} workflow -> {:ok, assign(socket, page_title: "Workflow Details", + active_nav: :executions, workflow: workflow, - steps: get_steps(id) + steps: get_steps(id), + children: Durable.list_children(id) )} end end @impl true - def handle_info(:refresh, socket) do - workflow = get_workflow(socket.assigns.workflow.id) - steps = get_steps(socket.assigns.workflow.id) - {:noreply, assign(socket, workflow: workflow, steps: steps)} - end + def handle_info(:refresh, socket), do: refresh(socket) + def handle_info({:workflow_completed, _, _}, socket), do: refresh(socket) + def handle_info({:workflow_rejected, _, _}, socket), do: refresh(socket) + def handle_info(_, socket), do: {:noreply, socket} - def handle_info({:workflow_completed, id, _report}, socket) do - if id == socket.assigns.workflow.id do - {:noreply, assign(socket, workflow: get_workflow(id), steps: get_steps(id))} - else - {:noreply, socket} - end + defp refresh(socket) do + id = socket.assigns.workflow.id + workflow = get_workflow(id) + steps = get_steps(id) + children = Durable.list_children(id) + + {:noreply, assign(socket, workflow: workflow, steps: steps, children: children)} end - def handle_info({:workflow_rejected, id, _result}, socket) do - if id == socket.assigns.workflow.id do - {:noreply, assign(socket, workflow: get_workflow(id), steps: get_steps(id))} - else - {:noreply, socket} + @impl true + def handle_event("cancel_workflow", _params, socket) do + case Durable.cancel(socket.assigns.workflow.id, "Cancelled from UI") do + :ok -> + {:noreply, + socket + |> put_flash(:info, "Workflow cancelled (parent only — children continue).") + |> assign(workflow: get_workflow(socket.assigns.workflow.id))} + + {:error, reason} -> + {:noreply, put_flash(socket, :error, "Cancel failed: #{inspect(reason)}")} end end @@ -100,162 +108,162 @@ defmodule PhoenixDemoWeb.WorkflowDetailLive do end end - defp format_time(nil), do: "-" + defp short_module(nil), do: "-" + defp short_module(mod), do: mod |> to_string() |> String.split(".") |> List.last() - defp format_time(datetime) do - Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S") - end + defp format_time(nil), do: "-" + defp format_time(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S") @impl true def render(assigns) do ~H""" - <.header> - Workflow Details - <:subtitle> - {@workflow.id} - - <:actions> - <.button navigate={~p"/workflows"}> - <.icon name="hero-arrow-left" class="size-4 mr-1" /> Back to Dashboard - - - - -
- -
-
-
-

Status

- - {@workflow.status} - -
+ +
+
+

{@workflow.workflow_name}

+ {@workflow.id} +
+
+ <.link navigate={~p"/executions"} class="btn btn-sm btn-ghost"> + <.icon name="hero-arrow-left" class="size-4" /> Executions + + +
+
-
-
-
Workflow
-
{@workflow.workflow_name}
-
-
-
Current Step
-
{@workflow.current_step || "-"}
+
+
+
+
+

Status

+ {@workflow.status}
-
-
Queue
-
{@workflow.queue}
+ +
+
+
Module
+
{short_module(@workflow.workflow_module)}
+
+
+
Current step
+
{@workflow.current_step || "-"}
+
+
+
Queue
+
{@workflow.queue}
+
+
+
Priority
+
{@workflow.priority}
+
-
-
Priority
-
{@workflow.priority}
+ +
+
Created: {format_time(@workflow.inserted_at)}
+
Started: {format_time(@workflow.started_at)}
+
Completed: {format_time(@workflow.completed_at)}
+
Scheduled: {format_time(@workflow.scheduled_at)}
+
-
-
-
Created
-
{format_time(@workflow.inserted_at)}
-
-
-
Started
-
{format_time(@workflow.started_at)}
-
-
-
Completed
-
{format_time(@workflow.completed_at)}
-
-
-
Scheduled
-
{format_time(@workflow.scheduled_at)}
+
+
+

Child workflows

+
    +
  • +
    + {c.status} + {c.workflow_name} + {String.slice(c.id, 0, 8)} +
    + <.link navigate={~p"/executions/#{c.id}"} class="btn btn-xs btn-ghost">View +
  • +
+
+
+ +
+
+
+

Input

+
{Jason.encode!(@workflow.input || %{}, pretty: true)}
-
- <.link navigate={~p"/approvals"} class="btn btn-primary btn-sm"> - <.icon name="hero-hand-raised" class="size-4 mr-1" /> Go to Approvals - +
+
+

Context

+
{Jason.encode!(@workflow.context || %{}, pretty: true)}
+
-
- - -
-
-

Input

-
{Jason.encode!(@workflow.input || %{}, pretty: true)}
-
-
- - -
-
-

Context

-
{Jason.encode!(@workflow.context || %{}, pretty: true)}
-
-
- - -
-
-

- <.icon name="hero-exclamation-triangle" class="size-5" /> Error -

-
{Jason.encode!(@workflow.error, pretty: true)}
-
-
- - -
-
-

Step History

-
- No steps executed yet +
+
+

+ <.icon name="hero-exclamation-triangle" class="size-4" /> Error +

+
{Jason.encode!(@workflow.error, pretty: true)}
+
-
    -
  • -
    0} class={step.status == :completed && "bg-success"} /> -
    - {format_time(step.started_at)} -
    -
    - <.icon - name={step_icon(step.status)} - class={[ - "size-5", - step.status == :completed && "text-success", - step.status == :failed && "text-error", - step.status == :running && "text-info animate-spin" - ]} - /> -
    -
    -
    - {step.step_name} - - {step.status} - +
    +
    +

    Step history

    + +
    + No steps executed yet +
    + +
      +
    • +
      0} class={step.status == :completed && "bg-success"} /> +
      + {format_time(step.started_at)}
      -
      - Type: {step.step_type} | Attempt: {step.attempt} - | Duration: {step.duration_ms}ms +
      + <.icon + name={step_icon(step.status)} + class={[ + "size-5", + step.status == :completed && "text-success", + step.status == :failed && "text-error", + step.status == :running && "text-info motion-safe:animate-spin" + ]} + />
      -
      - View Output -
      {Jason.encode!(step.output, pretty: true)}
      -
      -
      - View Error -
      {Jason.encode!(step.error, pretty: true)}
      -
      -
      -
      -
    • -
    +
    +
    + {step.step_name} + {step.status} +
    +
    + Type: {step.step_type} · attempt {step.attempt} · {step.duration_ms}ms +
    +
    + Output +
    {Jason.encode!(step.output, pretty: true)}
    +
    +
    + Error +
    {Jason.encode!(step.error, pretty: true)}
    +
    +
    +
    +
  • +
+
-
+ """ end end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_live.ex b/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_live.ex deleted file mode 100644 index e3c984f..0000000 --- a/examples/phoenix_demo/lib/phoenix_demo_web/live/workflow_live.ex +++ /dev/null @@ -1,154 +0,0 @@ -defmodule PhoenixDemoWeb.WorkflowLive do - @moduledoc """ - LiveView dashboard for monitoring workflow executions. - Shows all workflows with real-time status updates via PubSub. - """ - - use PhoenixDemoWeb, :live_view - - alias Durable.Config - alias Durable.Storage.Schemas.WorkflowExecution - - import Ecto.Query - - @impl true - def mount(_params, _session, socket) do - if connected?(socket) do - Phoenix.PubSub.subscribe(PhoenixDemo.PubSub, "workflows") - # Poll for updates every 2 seconds - :timer.send_interval(2000, self(), :refresh) - end - - {:ok, assign(socket, page_title: "Workflow Dashboard", workflows: list_workflows())} - end - - @impl true - def handle_info(:refresh, socket) do - {:noreply, assign(socket, workflows: list_workflows())} - end - - def handle_info({:workflow_completed, _id, _report}, socket) do - {:noreply, assign(socket, workflows: list_workflows())} - end - - def handle_info({:workflow_rejected, _id, _result}, socket) do - {:noreply, assign(socket, workflows: list_workflows())} - end - - defp list_workflows do - config = Config.get(Durable) - repo = config.repo - - repo.all( - from(w in WorkflowExecution, - order_by: [desc: w.inserted_at], - limit: 50 - ) - ) - end - - defp status_badge(status) do - case status do - :pending -> "badge badge-warning" - :running -> "badge badge-info" - :waiting -> "badge badge-secondary" - :completed -> "badge badge-success" - :failed -> "badge badge-error" - :cancelled -> "badge badge-ghost" - _ -> "badge" - end - end - - defp format_time(nil), do: "-" - - defp format_time(datetime) do - Calendar.strftime(datetime, "%Y-%m-%d %H:%M:%S") - end - - @impl true - def render(assigns) do - ~H""" - <.header> - Workflow Dashboard - <:subtitle>Monitor and manage workflow executions - <:actions> - <.button navigate={~p"/workflows/new"} variant="primary"> - <.icon name="hero-plus" class="size-4 mr-1" /> New Workflow - - - - -
-
-
-
Total
-
{length(@workflows)}
-
-
-
Running
-
{Enum.count(@workflows, &(&1.status == :running))}
-
-
-
Waiting
-
- {Enum.count(@workflows, &(&1.status == :waiting))} -
-
-
-
Completed
-
- {Enum.count(@workflows, &(&1.status == :completed))} -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - -
IDWorkflowStatusCurrent StepCreatedActions
- {String.slice(workflow.id, 0, 8)}... - {workflow.workflow_name} - - {workflow.status} - - {workflow.current_step || "-"}{format_time(workflow.inserted_at)} - <.link navigate={~p"/workflows/#{workflow.id}"} class="btn btn-ghost btn-xs"> - View - - <.link - :if={workflow.status == :waiting} - navigate={~p"/approvals"} - class="btn btn-primary btn-xs" - > - Review - -
- No workflows yet. Create one to get started! -
-
-
- """ - end -end diff --git a/examples/phoenix_demo/lib/phoenix_demo_web/router.ex b/examples/phoenix_demo/lib/phoenix_demo_web/router.ex index 9270cbf..114bbbf 100644 --- a/examples/phoenix_demo/lib/phoenix_demo_web/router.ex +++ b/examples/phoenix_demo/lib/phoenix_demo_web/router.ex @@ -17,20 +17,19 @@ defmodule PhoenixDemoWeb.Router do scope "/", PhoenixDemoWeb do pipe_through :browser - # Redirect home to workflows dashboard - get "/", PageController, :home + live "/", HomeLive, :index + live "/executions", ExecutionsLive, :index + live "/executions/:id", WorkflowDetailLive, :show + live "/pending-inputs", PendingInputsLive, :index + live "/pending-events", PendingEventsLive, :index + live "/schedules", SchedulesLive, :index - # Workflow routes - live "/workflows", WorkflowLive, :index - live "/workflows/new", DocumentLive, :new + # Legacy redirects so older bookmarks keep working. + live "/workflows", ExecutionsLive, :index live "/workflows/:id", WorkflowDetailLive, :show - - # Approval routes - live "/approvals", ApprovalLive, :index end - # Other scopes may use custom stacks. - # scope "/api", PhoenixDemoWeb do - # pipe_through :api - # end + # Durable Dashboard — one line, mounts at /dashboard with its own + # pipelines (asset routes skip CSRF for cross-origin script-tag fetches). + use DurableDashboard.Router, mount: "/dashboard", durable: Durable end diff --git a/examples/phoenix_demo/mix.exs b/examples/phoenix_demo/mix.exs index 6d1c3e1..d32d51f 100644 --- a/examples/phoenix_demo/mix.exs +++ b/examples/phoenix_demo/mix.exs @@ -63,7 +63,8 @@ defmodule PhoenixDemo.MixProject do {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, # Durable workflow engine - {:durable, path: "../.."} + {:durable, path: "../.."}, + {:durable_dashboard, path: "../../durable_dashboard"} ] end @@ -75,15 +76,27 @@ defmodule PhoenixDemo.MixProject do # See the documentation for `Mix` for more info on aliases. defp aliases do [ - setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + setup: [ + "deps.get", + "cmd --cd ../../durable_dashboard/assets pnpm install", + "ecto.setup", + "assets.setup", + "assets.build" + ], "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], "ecto.reset": ["ecto.drop", "ecto.setup"], test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], - "assets.build": ["compile", "tailwind phoenix_demo", "esbuild phoenix_demo"], + "assets.build": [ + "compile", + "tailwind phoenix_demo", + "esbuild phoenix_demo", + "cmd --cd ../../durable_dashboard/assets pnpm build" + ], "assets.deploy": [ "tailwind phoenix_demo --minify", "esbuild phoenix_demo --minify", + "cmd --cd ../../durable_dashboard/assets pnpm build", "phx.digest" ], precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"] From df132c8afd1edb5202094771cfdc43d658735c73 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Wed, 29 Apr 2026 19:14:32 +0530 Subject: [PATCH 09/13] Fix dashboard live navigation flash --- durable_dashboard/assets/src/v2/main.ts | 9 --- .../components/command/command_palette.ex | 4 +- .../components/data/data_table.ex | 10 +-- .../components/layout/breadcrumb.ex | 2 +- .../components/layout/sidebar.ex | 2 +- .../lib/durable_dashboard/layouts.ex | 2 +- .../durable_dashboard/live/overview_live.ex | 9 +-- .../lib/durable_dashboard/live/stub_live.ex | 2 +- .../durable_dashboard/live/workflow_live.ex | 2 +- .../lib/durable_dashboard/path.ex | 4 +- .../priv/static/durable_dashboard/app_v2.js | 2 +- .../test/durable_dashboard/layouts_test.exs | 70 +++++++++++++++++++ 12 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 durable_dashboard/test/durable_dashboard/layouts_test.exs diff --git a/durable_dashboard/assets/src/v2/main.ts b/durable_dashboard/assets/src/v2/main.ts index 65cf70f..4601b97 100644 --- a/durable_dashboard/assets/src/v2/main.ts +++ b/durable_dashboard/assets/src/v2/main.ts @@ -32,14 +32,5 @@ const liveSocket = new LiveSocket(liveSocketPath, Socket, { liveSocket.connect(); -// Plain browser navigation triggered from server-side via JS.dispatch. -// Used by DataTable row click — bypasses LV's live_redirect entirely. -document.documentElement.addEventListener("durable:goto", (e) => { - const detail = (e as CustomEvent<{ href?: string }>).detail; - if (detail?.href) { - window.location.href = detail.href; - } -}); - // Expose for debugging. (window as unknown as Record).liveSocket = liveSocket; diff --git a/durable_dashboard/lib/durable_dashboard/components/command/command_palette.ex b/durable_dashboard/lib/durable_dashboard/components/command/command_palette.ex index c9d571e..c54ef30 100644 --- a/durable_dashboard/lib/durable_dashboard/components/command/command_palette.ex +++ b/durable_dashboard/lib/durable_dashboard/components/command/command_palette.ex @@ -10,7 +10,7 @@ defmodule DurableDashboard.Components.Command.CommandPalette do - `palette:close` — close (Esc, click overlay, click X) - `palette:search` `%{"value" => q}` — phx-change on the input - `palette:move` `%{"dir" => "up" | "down"}` — keyboard nav - - `palette:activate` — Enter key → patch to the selected result + - `palette:activate` — Enter key → live-navigate to the selected result ## Items @@ -83,7 +83,7 @@ defmodule DurableDashboard.Components.Command.CommandPalette do {:noreply, socket |> assign(open?: false, query: "") - |> push_patch(to: item.href)} + |> push_navigate(to: item.href)} end end diff --git a/durable_dashboard/lib/durable_dashboard/components/data/data_table.ex b/durable_dashboard/lib/durable_dashboard/components/data/data_table.ex index 84e0b61..2fc7de6 100644 --- a/durable_dashboard/lib/durable_dashboard/components/data/data_table.ex +++ b/durable_dashboard/lib/durable_dashboard/components/data/data_table.ex @@ -23,7 +23,7 @@ defmodule DurableDashboard.Components.Data.DataTable do | `:filters` | no | List of filter definitions (see below); default `[]` | | `:query` | yes | Current query — usually parsed from URL by the parent | | `:row_id` | no | `(row -> id_string)` for stream identity (default: `& &1.id`) | - | `:row_navigate` | no | `(row -> url_string)` — clicking a row patches here | + | `:row_navigate` | no | `(row -> url_string)` — clicking a row live-navigates here | | `:per_page` | no | default 20 | | `:empty_title` | no | empty-state title (default `"No results"`) | | `:empty_description` | no | empty-state subtitle (optional) | @@ -503,13 +503,7 @@ defmodule DurableDashboard.Components.Data.DataTable do defp row_click(nil, _row), do: nil - # Use a plain custom DOM event handled by the BrowserNav JS hook in - # `assets/src/v2/main.ts`. We deliberately DON'T use JS.navigate / JS.patch - # here: both attempt a live_redirect first, which fails with `unauthorized` - # under a forwarded sub-router because Phoenix LV's session validation - # uses the host endpoint's router (which only sees the `forward` route). - # A plain `window.location.href = ...` sidesteps the validation entirely. defp row_click(navigate_fn, row) do - JS.dispatch("durable:goto", to: "html", detail: %{href: navigate_fn.(row)}) + JS.navigate(navigate_fn.(row)) end end diff --git a/durable_dashboard/lib/durable_dashboard/components/layout/breadcrumb.ex b/durable_dashboard/lib/durable_dashboard/components/layout/breadcrumb.ex index 02c2a82..a3d513e 100644 --- a/durable_dashboard/lib/durable_dashboard/components/layout/breadcrumb.ex +++ b/durable_dashboard/lib/durable_dashboard/components/layout/breadcrumb.ex @@ -42,7 +42,7 @@ defmodule DurableDashboard.Components.Layout.Breadcrumb do defp crumb_item(%{crumb: %{href: href}, last?: false} = assigns) when is_binary(href) do ~H""" - <.link href={@crumb.href} class="hover:text-foreground transition-colors"> + <.link navigate={@crumb.href} class="hover:text-foreground transition-colors"> {@crumb.label} """ diff --git a/durable_dashboard/lib/durable_dashboard/components/layout/sidebar.ex b/durable_dashboard/lib/durable_dashboard/components/layout/sidebar.ex index 99c87a8..cd2d418 100644 --- a/durable_dashboard/lib/durable_dashboard/components/layout/sidebar.ex +++ b/durable_dashboard/lib/durable_dashboard/components/layout/sidebar.ex @@ -63,7 +63,7 @@ defmodule DurableDashboard.Components.Layout.Sidebar do ~H""" <.link - href={@item.href} + navigate={@item.href} class={[ "flex items-center gap-2.5 px-2.5 h-8 rounded-md", "text-[13px] font-medium transition-colors", diff --git a/durable_dashboard/lib/durable_dashboard/layouts.ex b/durable_dashboard/lib/durable_dashboard/layouts.ex index ba67321..ced44d2 100644 --- a/durable_dashboard/lib/durable_dashboard/layouts.ex +++ b/durable_dashboard/lib/durable_dashboard/layouts.ex @@ -34,6 +34,7 @@ defmodule DurableDashboard.Layouts do href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" /> + - <% else %> diff --git a/durable_dashboard/lib/durable_dashboard/live/overview_live.ex b/durable_dashboard/lib/durable_dashboard/live/overview_live.ex index aea0e2f..a6b9343 100644 --- a/durable_dashboard/lib/durable_dashboard/live/overview_live.ex +++ b/durable_dashboard/lib/durable_dashboard/live/overview_live.ex @@ -92,7 +92,7 @@ defmodule DurableDashboard.Live.OverviewLive do <:title>Recent workflows <:action> - + View all @@ -118,12 +118,7 @@ defmodule DurableDashboard.Live.OverviewLive do {short_id(exec.id)} {exec.workflow_name} diff --git a/durable_dashboard/lib/durable_dashboard/live/stub_live.ex b/durable_dashboard/lib/durable_dashboard/live/stub_live.ex index 85d77b2..d9d5754 100644 --- a/durable_dashboard/lib/durable_dashboard/live/stub_live.ex +++ b/durable_dashboard/lib/durable_dashboard/live/stub_live.ex @@ -53,7 +53,7 @@ defmodule DurableDashboard.Live.StubLive do description={"This view will be migrated to LiveView during phase " <> @phase <> " of the dashboard rewrite."} > <:action> - + Back to Overview diff --git a/durable_dashboard/lib/durable_dashboard/live/workflow_live.ex b/durable_dashboard/lib/durable_dashboard/live/workflow_live.ex index d1291a9..c3c6688 100644 --- a/durable_dashboard/lib/durable_dashboard/live/workflow_live.ex +++ b/durable_dashboard/lib/durable_dashboard/live/workflow_live.ex @@ -121,7 +121,7 @@ defmodule DurableDashboard.Live.WorkflowLive do description={"No execution with id " <> (@workflow_id || "—") <> " on this instance."} > <:action> - + Back to workflows diff --git a/durable_dashboard/lib/durable_dashboard/path.ex b/durable_dashboard/lib/durable_dashboard/path.ex index 6bcaffb..19eecf1 100644 --- a/durable_dashboard/lib/durable_dashboard/path.ex +++ b/durable_dashboard/lib/durable_dashboard/path.ex @@ -8,8 +8,8 @@ defmodule DurableDashboard.Path do ## Usage - <.link href={Path.workflows(@base_path)}>Workflows - <.link href={Path.workflow(@base_path, exec.id)}>... + <.link navigate={Path.workflows(@base_path)}>Workflows + <.link navigate={Path.workflow(@base_path, exec.id)}>... <.link patch={Path.workflow_tab(@base_path, exec.id, "logs")}>Logs """ diff --git a/durable_dashboard/priv/static/durable_dashboard/app_v2.js b/durable_dashboard/priv/static/durable_dashboard/app_v2.js index 42f70c0..a73c853 100644 --- a/durable_dashboard/priv/static/durable_dashboard/app_v2.js +++ b/durable_dashboard/priv/static/durable_dashboard/app_v2.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./flow_graph-DnQnc5of.js","./vendor-xyflow-BrJO7I5W.js","./vendor-recharts-COWINUhB.js","./graph-layout-DO17v8x0.js"])))=>i.map(i=>d[i]); -import{L as _,S as y}from"./phoenix_live_view.esm-B3hEjW1b.js";const b={mounted(){const o=e=>{const c=this.el.dataset.open==="true";if((e.metaKey||e.ctrlKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),this.pushEventTo(this.el,"palette:open",{});return}if(c)switch(e.key){case"Escape":e.preventDefault(),this.pushEventTo(this.el,"palette:close",{});break;case"ArrowDown":e.preventDefault(),this.pushEventTo(this.el,"palette:move",{dir:"down"});break;case"ArrowUp":e.preventDefault(),this.pushEventTo(this.el,"palette:move",{dir:"up"});break;case"Enter":e.preventDefault(),this.pushEventTo(this.el,"palette:activate",{});break}},i=()=>{this.pushEventTo(this.el,"palette:open",{})};document.addEventListener("keydown",o),document.documentElement.addEventListener("durable:open-palette",i),this._onKeyDown=o,this._onOpenEvent=i,this._focusObserver=new MutationObserver(()=>{if(this.el.dataset.open==="true"){const e=this.el.querySelector("[data-palette-input]");e&&document.activeElement!==e&&e.focus()}}),this._focusObserver.observe(this.el,{attributes:!0,attributeFilter:["data-open"]})},destroyed(){var o;this._onKeyDown&&document.removeEventListener("keydown",this._onKeyDown),this._onOpenEvent&&document.documentElement.removeEventListener("durable:open-palette",this._onOpenEvent),(o=this._focusObserver)==null||o.disconnect()}},S="modulepreload",g=function(o,i){return new URL(o,i).href},f={},L=function(i,e,c){let h=Promise.resolve();if(e&&e.length>0){let t=function(r){return Promise.all(r.map(d=>Promise.resolve(d).then(u=>({status:"fulfilled",value:u}),u=>({status:"rejected",reason:u}))))};const n=document.getElementsByTagName("link"),a=document.querySelector("meta[property=csp-nonce]"),v=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));h=t(e.map(r=>{if(r=g(r,c),r in f)return;f[r]=!0;const d=r.endsWith(".css"),u=d?'[rel="stylesheet"]':"";if(!!c)for(let p=n.length-1;p>=0;p--){const m=n[p];if(m.href===r&&(!d||m.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${r}"]${u}`))return;const l=document.createElement("link");if(l.rel=d?"stylesheet":S,d||(l.as="script"),l.crossOrigin="",l.href=r,v&&l.setAttribute("nonce",v),document.head.appendChild(l),d)return new Promise((p,m)=>{l.addEventListener("load",p),l.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${r}`)))})}))}function s(t){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=t,window.dispatchEvent(n),!n.defaultPrevented)throw t}return h.then(t=>{for(const n of t||[])n.status==="rejected"&&s(n.reason);return i().catch(s)})},O={async mounted(){const o=this.el.id||`flow-${Math.random().toString(36).slice(2,8)}`,i=`${o}:replace`,e=`${o}:patch-node`;this._eventNames={replace:i,patchNode:e};let c={nodes:[],edges:[]};try{const s=this.el.dataset.graph;s&&(c=JSON.parse(s))}catch(s){console.warn("[FlowGraph] failed to parse data-graph attribute:",s)}const{mountFlowIsland:h}=await L(async()=>{const{mountFlowIsland:s}=await import("./flow_graph-DnQnc5of.js");return{mountFlowIsland:s}},__vite__mapDeps([0,1,2,3]),import.meta.url);this._island=h(this.el,c),this.handleEvent(i,s=>{var n;const{graph:t}=s;t&&((n=this._island)==null||n.setGraph(t))}),this.handleEvent(e,s=>{var a;const{id:t,patch:n}=s;t&&((a=this._island)==null||a.patchNode(t,n||{}))}),this._onStepClicked=s=>{var n;const t=s;(n=t.detail)!=null&&n.stepName&&this.pushEventTo(this.el,"step-clicked",{step_name:t.detail.stepName,step_execution_id:t.detail.stepExecutionId,child_workflow_id:t.detail.childWorkflowId})},this.el.addEventListener("durable:step-clicked",this._onStepClicked)},destroyed(){var o;(o=this._island)==null||o.destroy(),this._island=void 0,this._onStepClicked&&(this.el.removeEventListener("durable:step-clicked",this._onStepClicked),this._onStepClicked=void 0)}};var E;const C=(E=document.querySelector("meta[name='csrf-token']"))==null?void 0:E.getAttribute("content");var k;const D=((k=document.querySelector("meta[name='live-socket-path']"))==null?void 0:k.getAttribute("content"))||"/live",w=new _(D,y,{hooks:{FlowGraph:O,CommandPalette:b},params:{_csrf_token:C}});w.connect();document.documentElement.addEventListener("durable:goto",o=>{const i=o.detail;i!=null&&i.href&&(window.location.href=i.href)});window.liveSocket=w; +import{L as _,S as y}from"./phoenix_live_view.esm-B3hEjW1b.js";const b={mounted(){const o=e=>{const c=this.el.dataset.open==="true";if((e.metaKey||e.ctrlKey)&&(e.key==="k"||e.key==="K")){e.preventDefault(),this.pushEventTo(this.el,"palette:open",{});return}if(c)switch(e.key){case"Escape":e.preventDefault(),this.pushEventTo(this.el,"palette:close",{});break;case"ArrowDown":e.preventDefault(),this.pushEventTo(this.el,"palette:move",{dir:"down"});break;case"ArrowUp":e.preventDefault(),this.pushEventTo(this.el,"palette:move",{dir:"up"});break;case"Enter":e.preventDefault(),this.pushEventTo(this.el,"palette:activate",{});break}},r=()=>{this.pushEventTo(this.el,"palette:open",{})};document.addEventListener("keydown",o),document.documentElement.addEventListener("durable:open-palette",r),this._onKeyDown=o,this._onOpenEvent=r,this._focusObserver=new MutationObserver(()=>{if(this.el.dataset.open==="true"){const e=this.el.querySelector("[data-palette-input]");e&&document.activeElement!==e&&e.focus()}}),this._focusObserver.observe(this.el,{attributes:!0,attributeFilter:["data-open"]})},destroyed(){var o;this._onKeyDown&&document.removeEventListener("keydown",this._onKeyDown),this._onOpenEvent&&document.documentElement.removeEventListener("durable:open-palette",this._onOpenEvent),(o=this._focusObserver)==null||o.disconnect()}},S="modulepreload",g=function(o,r){return new URL(o,r).href},f={},L=function(r,e,c){let h=Promise.resolve();if(e&&e.length>0){let t=function(i){return Promise.all(i.map(d=>Promise.resolve(d).then(u=>({status:"fulfilled",value:u}),u=>({status:"rejected",reason:u}))))};const n=document.getElementsByTagName("link"),a=document.querySelector("meta[property=csp-nonce]"),m=(a==null?void 0:a.nonce)||(a==null?void 0:a.getAttribute("nonce"));h=t(e.map(i=>{if(i=g(i,c),i in f)return;f[i]=!0;const d=i.endsWith(".css"),u=d?'[rel="stylesheet"]':"";if(!!c)for(let p=n.length-1;p>=0;p--){const v=n[p];if(v.href===i&&(!d||v.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${i}"]${u}`))return;const l=document.createElement("link");if(l.rel=d?"stylesheet":S,d||(l.as="script"),l.crossOrigin="",l.href=i,m&&l.setAttribute("nonce",m),document.head.appendChild(l),d)return new Promise((p,v)=>{l.addEventListener("load",p),l.addEventListener("error",()=>v(new Error(`Unable to preload CSS for ${i}`)))})}))}function s(t){const n=new Event("vite:preloadError",{cancelable:!0});if(n.payload=t,window.dispatchEvent(n),!n.defaultPrevented)throw t}return h.then(t=>{for(const n of t||[])n.status==="rejected"&&s(n.reason);return r().catch(s)})},O={async mounted(){const o=this.el.id||`flow-${Math.random().toString(36).slice(2,8)}`,r=`${o}:replace`,e=`${o}:patch-node`;this._eventNames={replace:r,patchNode:e};let c={nodes:[],edges:[]};try{const s=this.el.dataset.graph;s&&(c=JSON.parse(s))}catch(s){console.warn("[FlowGraph] failed to parse data-graph attribute:",s)}const{mountFlowIsland:h}=await L(async()=>{const{mountFlowIsland:s}=await import("./flow_graph-DnQnc5of.js");return{mountFlowIsland:s}},__vite__mapDeps([0,1,2,3]),import.meta.url);this._island=h(this.el,c),this.handleEvent(r,s=>{var n;const{graph:t}=s;t&&((n=this._island)==null||n.setGraph(t))}),this.handleEvent(e,s=>{var a;const{id:t,patch:n}=s;t&&((a=this._island)==null||a.patchNode(t,n||{}))}),this._onStepClicked=s=>{var n;const t=s;(n=t.detail)!=null&&n.stepName&&this.pushEventTo(this.el,"step-clicked",{step_name:t.detail.stepName,step_execution_id:t.detail.stepExecutionId,child_workflow_id:t.detail.childWorkflowId})},this.el.addEventListener("durable:step-clicked",this._onStepClicked)},destroyed(){var o;(o=this._island)==null||o.destroy(),this._island=void 0,this._onStepClicked&&(this.el.removeEventListener("durable:step-clicked",this._onStepClicked),this._onStepClicked=void 0)}};var E;const C=(E=document.querySelector("meta[name='csrf-token']"))==null?void 0:E.getAttribute("content");var k;const D=((k=document.querySelector("meta[name='live-socket-path']"))==null?void 0:k.getAttribute("content"))||"/live",w=new _(D,y,{hooks:{FlowGraph:O,CommandPalette:b},params:{_csrf_token:C}});w.connect();window.liveSocket=w; diff --git a/durable_dashboard/test/durable_dashboard/layouts_test.exs b/durable_dashboard/test/durable_dashboard/layouts_test.exs new file mode 100644 index 0000000..f322293 --- /dev/null +++ b/durable_dashboard/test/durable_dashboard/layouts_test.exs @@ -0,0 +1,70 @@ +defmodule DurableDashboard.LayoutsTest do + use ExUnit.Case, async: false + + import Phoenix.LiveViewTest + + alias DurableDashboard.Components.Layout.{Breadcrumb, Sidebar} + alias DurableDashboard.Layouts + + setup do + previous = Application.get_env(:durable_dashboard, :dev_mode) + + on_exit(fn -> + if is_nil(previous) do + Application.delete_env(:durable_dashboard, :dev_mode) + else + Application.put_env(:durable_dashboard, :dev_mode, previous) + end + end) + + :ok + end + + test "root_v2 loads the dev stylesheet before body content" do + Application.put_env(:durable_dashboard, :dev_mode, true) + + html = + render_component(&Layouts.root_v2/1, + config: %{live_socket_path: "/live", base_path: "/dashboard"}, + inner_content: Phoenix.HTML.raw(~s(
)) + ) + + stylesheet = ~s(href="http://localhost:5173/src/index.css") + + assert html =~ stylesheet + assert offset(html, stylesheet) < offset(html, "") + assert offset(html, stylesheet) < offset(html, ~s( index + :nomatch -> flunk("expected #{inspect(pattern)} in rendered HTML") + end + end +end From 3db37a34f7b61e4bc477b7a35dd83f1eb4cbc66b Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Fri, 1 May 2026 18:50:56 +0530 Subject: [PATCH 10/13] Add Durable migration upgrade tasks --- README.md | 11 ++ lib/durable/migration.ex | 51 ++++++- lib/durable/migration/migrator.ex | 68 +++++++++- lib/durable/migration/schema_migration.ex | 23 +++- lib/mix/tasks/durable.gen.migration.ex | 1 + lib/mix/tasks/durable.gen.upgrade.ex | 139 +++++++++++++++++++ lib/mix/tasks/durable.migrations.ex | 141 ++++++++++++++++++++ mix.exs | 2 + test/durable/migration_test.exs | 57 ++++++++ test/mix/tasks/durable_gen_upgrade_test.exs | 95 +++++++++++++ test/mix/tasks/durable_migrations_test.exs | 93 +++++++++++++ 11 files changed, 673 insertions(+), 8 deletions(-) create mode 100644 lib/mix/tasks/durable.gen.upgrade.ex create mode 100644 lib/mix/tasks/durable.migrations.ex create mode 100644 test/durable/migration_test.exs create mode 100644 test/mix/tasks/durable_gen_upgrade_test.exs create mode 100644 test/mix/tasks/durable_migrations_test.exs diff --git a/README.md b/README.md index 78098db..54b87e1 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,17 @@ defmodule MyApp.Repo.Migrations.AddDurable do end ``` +When a Durable upgrade ships new internal migrations, generate a new wrapper +migration and run your normal Ecto migration flow: + +```bash +mix durable.gen.upgrade -r MyApp.Repo +mix ecto.migrate +``` + +Use `mix durable.migrations -r MyApp.Repo --check` in CI or deploy gates to +fail when the database is behind the Durable library version. + ### 2. Add to Supervision Tree ```elixir diff --git a/lib/durable/migration.ex b/lib/durable/migration.ex index 6f12b3b..8637ad7 100644 --- a/lib/durable/migration.ex +++ b/lib/durable/migration.ex @@ -20,6 +20,16 @@ defmodule Durable.Migration do mix ecto.migrate + When Durable adds new internal migrations in a future release, generate a + new host-app wrapper migration: + + mix durable.gen.upgrade -r MyApp.Repo + mix ecto.migrate + + To check whether a database is behind the Durable library version: + + mix durable.migrations -r MyApp.Repo --check + ## Options * `:prefix` - The PostgreSQL schema name (default: `"durable"`) @@ -83,6 +93,18 @@ defmodule Durable.Migration do @spec all_versions() :: [pos_integer()] defdelegate all_versions(), to: Migrator + @doc """ + Returns the latest available Durable migration version. + """ + @spec current_version() :: pos_integer() + defdelegate current_version(), to: Migrator + + @doc """ + Returns the migration version immediately before `version`, or 0 for the first migration. + """ + @spec previous_version(pos_integer()) :: non_neg_integer() + defdelegate previous_version(version \\ Migrator.current_version()), to: Migrator + @doc """ Returns the list of applied migration versions. @@ -91,9 +113,34 @@ defmodule Durable.Migration do @spec migrated_versions(keyword()) :: [pos_integer()] defdelegate migrated_versions(opts \\ []), to: Migrator + @doc """ + Returns the latest applied Durable migration version, or 0 when none are applied. + + Pass an Ecto repo to check outside an Ecto migration: + + Durable.Migration.migrated_version(MyApp.Repo) + Durable.Migration.migrated_version(MyApp.Repo, prefix: "private") + + Without a repo, this uses the current Ecto migration runner context. + """ + @spec migrated_version(keyword() | module()) :: non_neg_integer() + def migrated_version(opts_or_repo \\ []) + + def migrated_version(opts) when is_list(opts), do: Migrator.migrated_version(opts) + def migrated_version(repo) when is_atom(repo), do: Migrator.migrated_version(repo) + + @spec migrated_version(module(), keyword()) :: non_neg_integer() + defdelegate migrated_version(repo, opts), to: Migrator + @doc """ Returns pending migrations (not yet applied). """ - @spec pending_versions(keyword()) :: [pos_integer()] - defdelegate pending_versions(opts \\ []), to: Migrator + @spec pending_versions(keyword() | module()) :: [pos_integer()] + def pending_versions(opts_or_repo \\ []) + + def pending_versions(opts) when is_list(opts), do: Migrator.pending_versions(opts) + def pending_versions(repo) when is_atom(repo), do: Migrator.pending_versions(repo) + + @spec pending_versions(module(), keyword()) :: [pos_integer()] + defdelegate pending_versions(repo, opts), to: Migrator end diff --git a/lib/durable/migration/migrator.ex b/lib/durable/migration/migrator.ex index ee290f4..4139bc7 100644 --- a/lib/durable/migration/migrator.ex +++ b/lib/durable/migration/migrator.ex @@ -38,6 +38,26 @@ defmodule Durable.Migration.Migrator do |> Enum.sort_by(&elem(&1, 0)) end + @doc """ + Returns the latest Durable migration version. + """ + @spec current_version() :: pos_integer() + def current_version do + all_versions() + |> List.last() + end + + @doc """ + Returns the migration version before the given version, or 0 for the first migration. + """ + @spec previous_version(pos_integer()) :: non_neg_integer() + def previous_version(version \\ current_version()) do + all_versions() + |> Enum.filter(&(&1 < version)) + |> List.last() || + 0 + end + @doc """ Runs pending migrations up. @@ -157,17 +177,61 @@ defmodule Durable.Migration.Migrator do SchemaMigration.versions(prefix) end + @spec migrated_versions(module(), keyword()) :: [pos_integer()] + def migrated_versions(repo, opts) when is_atom(repo) and is_list(opts) do + prefix = Keyword.get(opts, :prefix, "durable") + SchemaMigration.versions(repo, prefix) + end + + @doc """ + Returns the latest applied migration version, or 0 when Durable hasn't been migrated. + """ + @spec migrated_version(keyword() | module()) :: non_neg_integer() + def migrated_version(opts_or_repo \\ []) + + def migrated_version(opts) when is_list(opts) do + opts + |> migrated_versions() + |> latest_version() + end + + def migrated_version(repo) when is_atom(repo) do + migrated_version(repo, []) + end + + @spec migrated_version(module(), keyword()) :: non_neg_integer() + def migrated_version(repo, opts) when is_atom(repo) and is_list(opts) do + repo + |> migrated_versions(opts) + |> latest_version() + end + @doc """ Returns pending migration versions. """ - @spec pending_versions(keyword()) :: [pos_integer()] - def pending_versions(opts \\ []) do + @spec pending_versions(keyword() | module()) :: [pos_integer()] + def pending_versions(opts_or_repo \\ []) + + def pending_versions(opts) when is_list(opts) do applied = migrated_versions(opts) all_versions() -- applied end + def pending_versions(repo) when is_atom(repo) do + pending_versions(repo, []) + end + + @spec pending_versions(module(), keyword()) :: [pos_integer()] + def pending_versions(repo, opts) when is_atom(repo) and is_list(opts) do + applied = migrated_versions(repo, opts) + all_versions() -- applied + end + # Private helpers + defp latest_version([]), do: 0 + defp latest_version(versions), do: List.last(versions) + defp filter_to_version(migrations, nil, _direction), do: migrations defp filter_to_version(migrations, target, :up) do diff --git a/lib/durable/migration/schema_migration.ex b/lib/durable/migration/schema_migration.ex index 103bad4..c567c42 100644 --- a/lib/durable/migration/schema_migration.ex +++ b/lib/durable/migration/schema_migration.ex @@ -14,6 +14,11 @@ defmodule Durable.Migration.SchemaMigration do """ @spec ensure_table!(String.t()) :: :ok def ensure_table!(prefix) do + ensure_table!(get_repo(), prefix) + end + + @spec ensure_table!(module(), String.t()) :: :ok + def ensure_table!(repo, prefix) do # Use direct repo query to ensure table is created immediately # (not deferred like Ecto.Migration's execute) query = """ @@ -23,7 +28,7 @@ defmodule Durable.Migration.SchemaMigration do ) """ - get_repo().query!(query, []) + repo.query!(query, []) :ok end @@ -32,6 +37,11 @@ defmodule Durable.Migration.SchemaMigration do """ @spec table_exists?(String.t()) :: boolean() def table_exists?(prefix) do + table_exists?(get_repo(), prefix) + end + + @spec table_exists?(module(), String.t()) :: boolean() + def table_exists?(repo, prefix) do query = """ SELECT EXISTS ( SELECT FROM information_schema.tables @@ -40,7 +50,7 @@ defmodule Durable.Migration.SchemaMigration do ) """ - %{rows: [[exists]]} = get_repo().query!(query, [prefix, @table_name]) + %{rows: [[exists]]} = repo.query!(query, [prefix, @table_name]) exists end @@ -49,9 +59,14 @@ defmodule Durable.Migration.SchemaMigration do """ @spec versions(String.t()) :: [pos_integer()] def versions(prefix) do - if table_exists?(prefix) do + versions(get_repo(), prefix) + end + + @spec versions(module(), String.t()) :: [pos_integer()] + def versions(repo, prefix) do + if table_exists?(repo, prefix) do query = "SELECT version FROM #{prefix}.#{@table_name} ORDER BY version" - %{rows: rows} = get_repo().query!(query, []) + %{rows: rows} = repo.query!(query, []) Enum.map(rows, fn [v] -> v end) else [] diff --git a/lib/mix/tasks/durable.gen.migration.ex b/lib/mix/tasks/durable.gen.migration.ex index b3c8e1d..a99d8c6 100644 --- a/lib/mix/tasks/durable.gen.migration.ex +++ b/lib/mix/tasks/durable.gen.migration.ex @@ -5,6 +5,7 @@ defmodule Mix.Tasks.Durable.Gen.Migration do Generates a new Durable internal migration. This is for Durable library developers only, not end users. + Host applications should use `mix durable.gen.upgrade` instead. $ mix durable.gen.migration add_compensation_tracking diff --git a/lib/mix/tasks/durable.gen.upgrade.ex b/lib/mix/tasks/durable.gen.upgrade.ex new file mode 100644 index 0000000..d38f3ad --- /dev/null +++ b/lib/mix/tasks/durable.gen.upgrade.ex @@ -0,0 +1,139 @@ +defmodule Mix.Tasks.Durable.Gen.Upgrade do + @shortdoc "Generates a host-app migration for pending Durable migrations" + + @moduledoc """ + Generates an Ecto migration that upgrades Durable's internal database schema. + + This is the host-application counterpart to `mix durable.gen.migration`. + Durable migrations are explicit: when Durable adds new internal migrations, + generate a new wrapper migration and run `mix ecto.migrate`. + + ## Usage + + mix durable.gen.upgrade + mix durable.gen.upgrade -r MyApp.Repo + mix durable.gen.upgrade --prefix private + mix durable.gen.upgrade --to 20260413000000 + + ## Options + + * `-r`, `--repo` - The Ecto repo to generate the migration for + * `--prefix` - Durable PostgreSQL schema prefix (default: `"durable"`) + * `--to` - Durable migration version to upgrade to (default: latest) + * `--migrations-path` - Destination migrations path + """ + + use Mix.Task + + import Macro, only: [camelize: 1] + import Mix.Ecto + import Mix.EctoSQL + import Mix.Generator + + alias Durable.Migration + + @aliases [r: :repo] + @switches [ + repo: [:string, :keep], + prefix: :string, + to: :integer, + migrations_path: :string, + no_compile: :boolean, + no_deps_check: :boolean + ] + + @impl Mix.Task + def run(args) do + repos = parse_repos!(args) + {opts, _argv} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + Enum.map(repos, fn repo -> + ensure_repo(repo, args) + generate(repo, opts) + end) + end + + defp parse_repos!(args) do + case parse_repo(args) do + [] -> Mix.raise("Could not find an Ecto repo. Pass one with -r MyApp.Repo.") + repos -> repos + end + end + + defp generate(repo, opts) do + target_version = target_version!(opts) + prefix = Keyword.get(opts, :prefix, "durable") + previous_version = Migration.previous_version(target_version) + path = Keyword.get(opts, :migrations_path) || Path.join(source_repo_priv(repo), "migrations") + base_name = "upgrade_durable_to_v#{target_version}" + file = Path.join(path, "#{timestamp()}_#{base_name}.exs") + + unless File.dir?(path), do: create_directory(path) + ensure_unique!(path, base_name) + + create_file( + file, + migration_template(repo, target_version, previous_version, prefix, base_name) + ) + + Mix.shell().info(""" + + Run `mix ecto.migrate` to apply Durable migrations up to #{target_version}. + """) + + file + end + + defp target_version!(opts) do + target_version = Keyword.get(opts, :to, Migration.current_version()) + + if target_version in Migration.all_versions() do + target_version + else + Mix.raise( + "Unknown Durable migration version #{inspect(target_version)}. " <> + "Known versions: #{Enum.join(Migration.all_versions(), ", ")}" + ) + end + end + + defp ensure_unique!(path, base_name) do + fuzzy_path = Path.join(path, "*_#{base_name}.exs") + + if Path.wildcard(fuzzy_path) != [] do + Mix.raise("migration can't be created, there is already a migration file for #{base_name}.") + end + end + + defp migration_template(repo, target_version, previous_version, prefix, base_name) do + module_name = Module.concat([repo, Migrations, camelize(base_name)]) + + """ + defmodule #{inspect(module_name)} do + use Ecto.Migration + + def up do + Durable.Migration.up(to: #{target_version}, prefix: #{inspect(prefix)}) + end + + def down do + Durable.Migration.down(to: #{previous_version}, prefix: #{inspect(prefix)}) + end + end + """ + end + + defp timestamp do + {{year, month, day}, {hour, minute, second}} = :calendar.universal_time() + + "#{year}" <> + pad(month) <> + pad(day) <> + pad(hour) <> + pad(minute) <> + pad(second) + end + + defp pad(value) when value < 10, do: "0#{value}" + defp pad(value), do: to_string(value) +end diff --git a/lib/mix/tasks/durable.migrations.ex b/lib/mix/tasks/durable.migrations.ex new file mode 100644 index 0000000..7c0de64 --- /dev/null +++ b/lib/mix/tasks/durable.migrations.ex @@ -0,0 +1,141 @@ +defmodule Mix.Tasks.Durable.Migrations do + @shortdoc "Displays Durable internal migration status" + + @moduledoc """ + Displays Durable's internal migration status for an Ecto repo. + + Use `--check` in CI or deploy gates to fail when the database is behind the + Durable library version. + + ## Usage + + mix durable.migrations + mix durable.migrations -r MyApp.Repo --check + mix durable.migrations --prefix private + mix durable.migrations --json + + ## Options + + * `-r`, `--repo` - The Ecto repo to inspect + * `--prefix` - Durable PostgreSQL schema prefix (default: `"durable"`) + * `--json` - Emit JSON instead of text + * `--check` - Raise if any Durable migrations are pending + """ + + use Mix.Task + + import Mix.Ecto + + alias Durable.Migration + + @aliases [r: :repo] + @switches [ + repo: [:string, :keep], + prefix: :string, + json: :boolean, + check: :boolean, + no_compile: :boolean, + no_deps_check: :boolean + ] + + @impl Mix.Task + def run(args) do + repos = parse_repos!(args) + {opts, _argv} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) + + reports = Enum.map(repos, &build_report(&1, args, opts)) + emit_reports(reports, opts) + maybe_raise_for_pending(reports, opts) + + :ok + end + + defp parse_repos!(args) do + case parse_repo(args) do + [] -> Mix.raise("Could not find an Ecto repo. Pass one with -r MyApp.Repo.") + repos -> repos + end + end + + defp build_report(repo, args, opts) do + ensure_repo(repo, args) + prefix = Keyword.get(opts, :prefix, "durable") + + case Ecto.Migrator.with_repo(repo, &migration_status(&1, prefix), mode: :temporary) do + {:ok, report, _started} -> + report + + {:error, error} -> + Mix.raise("Could not start repo #{inspect(repo)}, error: #{inspect(error)}") + end + end + + defp migration_status(repo, prefix) do + current_version = Migration.current_version() + migrated_version = Migration.migrated_version(repo, prefix: prefix) + pending_versions = Migration.pending_versions(repo, prefix: prefix) + + %{ + repo: inspect(repo), + prefix: prefix, + current_version: current_version, + migrated_version: migrated_version, + pending_versions: pending_versions, + status: status(pending_versions) + } + end + + defp status([]), do: "up" + defp status(_pending_versions), do: "pending" + + defp emit_reports(reports, opts) do + if Keyword.get(opts, :json, false) do + emit_json(reports) + else + Enum.each(reports, &emit_text/1) + end + end + + defp emit_json([report]), do: Mix.shell().info(Jason.encode!(report, pretty: true)) + defp emit_json(reports), do: Mix.shell().info(Jason.encode!(reports, pretty: true)) + + defp emit_text(report) do + Mix.shell().info(""" + + Repo: #{report.repo} + Prefix: #{report.prefix} + Current Durable version: #{report.current_version} + Migrated database version: #{report.migrated_version} + Pending versions: #{format_pending(report.pending_versions)} + Status: #{report.status} + """) + end + + defp format_pending([]), do: "none" + defp format_pending(versions), do: Enum.join(versions, ", ") + + defp maybe_raise_for_pending(reports, opts) do + if Keyword.get(opts, :check, false) and Enum.any?(reports, &pending?/1) do + Mix.raise(check_error(reports)) + end + end + + defp pending?(report), do: report.pending_versions != [] + + defp check_error(reports) do + pending = + reports + |> Enum.filter(&pending?/1) + |> Enum.map_join("\n", fn report -> + " #{report.repo} prefix=#{inspect(report.prefix)} pending=#{format_pending(report.pending_versions)}" + end) + + """ + Durable migrations are pending: + #{pending} + + Generate an upgrade migration with `mix durable.gen.upgrade -r YourApp.Repo`, + then run `mix ecto.migrate`. + """ + end +end diff --git a/mix.exs b/mix.exs index 897a618..dd482b2 100644 --- a/mix.exs +++ b/mix.exs @@ -84,6 +84,8 @@ defmodule Durable.MixProject do ], groups_for_modules: [ "Mix Tasks": [ + Mix.Tasks.Durable.Migrations, + Mix.Tasks.Durable.Gen.Upgrade, Mix.Tasks.Durable.Status, Mix.Tasks.Durable.List, Mix.Tasks.Durable.Run, diff --git a/test/durable/migration_test.exs b/test/durable/migration_test.exs new file mode 100644 index 0000000..c768db4 --- /dev/null +++ b/test/durable/migration_test.exs @@ -0,0 +1,57 @@ +defmodule Durable.MigrationTest do + use Durable.DataCase, async: false + + alias Durable.Migration + alias Durable.Migration.Migrator + + test "current_version returns the latest registered migration version" do + assert Migration.current_version() == List.last(Migration.all_versions()) + end + + test "previous_version returns the version before the target" do + [first, second | _] = Migration.all_versions() + + assert Migration.previous_version(first) == 0 + assert Migration.previous_version(second) == first + assert Migration.previous_version() == Enum.at(Migration.all_versions(), -2) + end + + test "explicit repo helpers report default schema state" do + assert Migration.migrated_version(Durable.TestRepo) == Migration.current_version() + assert Migration.pending_versions(Durable.TestRepo) == [] + end + + test "explicit repo helpers report missing prefixes as unmigrated" do + prefix = "durable_missing_#{System.unique_integer([:positive])}" + + assert Migration.migrated_version(Durable.TestRepo, prefix: prefix) == 0 + + assert Migration.pending_versions(Durable.TestRepo, prefix: prefix) == + Migration.all_versions() + end + + test "all migration files are registered with the migrator" do + file_modules = + "lib/durable/migration/migrations/v*.ex" + |> Path.wildcard() + |> Enum.map(&module_from_file!/1) + |> Enum.sort() + + registered_modules = + Migrator.all_migrations() + |> Enum.map(fn {_version, mod} -> mod end) + |> Enum.sort() + + assert registered_modules == file_modules + end + + defp module_from_file!(path) do + path + |> File.read!() + |> then(&Regex.run(~r/defmodule\s+([A-Za-z0-9_.]+)/, &1)) + |> case do + [_match, module] -> String.to_existing_atom("Elixir." <> module) + nil -> flunk("Expected #{path} to define a module") + end + end +end diff --git a/test/mix/tasks/durable_gen_upgrade_test.exs b/test/mix/tasks/durable_gen_upgrade_test.exs new file mode 100644 index 0000000..eeaba01 --- /dev/null +++ b/test/mix/tasks/durable_gen_upgrade_test.exs @@ -0,0 +1,95 @@ +defmodule Mix.Tasks.Durable.GenUpgradeTest do + use ExUnit.Case, async: false + + alias Durable.Migration + alias Mix.Tasks.Durable.Gen.Upgrade, as: GenUpgradeTask + + setup do + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(Mix.Shell.IO) end) + end + + @tag :tmp_dir + test "generates a Durable upgrade migration for the current version", %{tmp_dir: tmp_dir} do + target = Migration.current_version() + previous = Migration.previous_version(target) + + assert [file] = + GenUpgradeTask.run([ + "-r", + "Durable.TestRepo", + "--migrations-path", + tmp_dir + ]) + + assert Path.basename(file) =~ "_upgrade_durable_to_v#{target}.exs" + + content = File.read!(file) + assert content =~ "defmodule Durable.TestRepo.Migrations.UpgradeDurableToV#{target}" + assert content =~ "Durable.Migration.up(to: #{target}, prefix: \"durable\")" + assert content =~ "Durable.Migration.down(to: #{previous}, prefix: \"durable\")" + end + + @tag :tmp_dir + test "supports custom prefix and target version", %{tmp_dir: tmp_dir} do + target = hd(Migration.all_versions()) + + assert [file] = + GenUpgradeTask.run([ + "-r", + "Durable.TestRepo", + "--migrations-path", + tmp_dir, + "--prefix", + "private", + "--to", + Integer.to_string(target) + ]) + + content = File.read!(file) + assert content =~ "Durable.Migration.up(to: #{target}, prefix: \"private\")" + assert content =~ "Durable.Migration.down(to: 0, prefix: \"private\")" + end + + @tag :tmp_dir + test "refuses to generate a duplicate upgrade migration", %{tmp_dir: tmp_dir} do + target = Migration.current_version() + base_name = "upgrade_durable_to_v#{target}" + File.write!(Path.join(tmp_dir, "20250101000000_#{base_name}.exs"), "") + + assert_raise Mix.Error, ~r/already a migration file/, fn -> + GenUpgradeTask.run([ + "-r", + "Durable.TestRepo", + "--migrations-path", + tmp_dir + ]) + end + end + + @tag :tmp_dir + test "raises for unknown target versions", %{tmp_dir: tmp_dir} do + assert_raise Mix.Error, ~r/Unknown Durable migration version/, fn -> + GenUpgradeTask.run([ + "-r", + "Durable.TestRepo", + "--migrations-path", + tmp_dir, + "--to", + "99999999999999" + ]) + end + end + + @tag :tmp_dir + test "raises for an invalid repo", %{tmp_dir: tmp_dir} do + assert_raise Mix.Error, ~r/Could not load Missing.Repo/, fn -> + GenUpgradeTask.run([ + "-r", + "Missing.Repo", + "--migrations-path", + tmp_dir + ]) + end + end +end diff --git a/test/mix/tasks/durable_migrations_test.exs b/test/mix/tasks/durable_migrations_test.exs new file mode 100644 index 0000000..429ae1a --- /dev/null +++ b/test/mix/tasks/durable_migrations_test.exs @@ -0,0 +1,93 @@ +defmodule Mix.Tasks.Durable.MigrationsTest do + use Durable.DataCase, async: false + + alias Durable.Migration + alias Durable.Migration.SchemaMigration + alias Mix.Tasks.Durable.Migrations, as: MigrationsTask + + setup do + Mix.shell(Mix.Shell.Process) + on_exit(fn -> Mix.shell(Mix.Shell.IO) end) + end + + test "reports the default schema as migrated" do + assert :ok = MigrationsTask.run(["-r", "Durable.TestRepo"]) + + output = collect_all_output() + assert output =~ "Repo: Durable.TestRepo" + assert output =~ "Current Durable version: #{Migration.current_version()}" + assert output =~ "Migrated database version: #{Migration.current_version()}" + assert output =~ "Pending versions: none" + assert output =~ "Status: up" + end + + test "emits JSON status" do + assert :ok = MigrationsTask.run(["-r", "Durable.TestRepo", "--json"]) + + output = collect_all_output() + decoded = Jason.decode!(output) + + assert decoded["repo"] == "Durable.TestRepo" + assert decoded["prefix"] == "durable" + assert decoded["current_version"] == Migration.current_version() + assert decoded["migrated_version"] == Migration.current_version() + assert decoded["pending_versions"] == [] + assert decoded["status"] == "up" + end + + test "--check passes when no Durable migrations are pending" do + assert :ok = MigrationsTask.run(["-r", "Durable.TestRepo", "--check"]) + end + + test "--check raises when Durable migrations are pending" do + prefix = unique_prefix("missing") + + assert_raise Mix.Error, ~r/Durable migrations are pending/, fn -> + MigrationsTask.run(["-r", "Durable.TestRepo", "--prefix", prefix, "--check"]) + end + end + + test "reports partially migrated prefixes" do + prefix = unique_prefix("partial") + [applied | pending] = Migration.all_versions() + + create_schema(prefix) + SchemaMigration.ensure_table!(Durable.TestRepo, prefix) + insert_schema_version(prefix, applied) + + assert :ok = MigrationsTask.run(["-r", "Durable.TestRepo", "--prefix", prefix]) + + output = collect_all_output() + assert output =~ "Migrated database version: #{applied}" + assert output =~ "Pending versions: #{Enum.join(pending, ", ")}" + assert output =~ "Status: pending" + end + + defp unique_prefix(label) do + "durable_#{label}_#{System.unique_integer([:positive])}" + end + + defp create_schema(prefix) do + Durable.TestRepo.query!("CREATE SCHEMA IF NOT EXISTS #{prefix}", []) + end + + defp insert_schema_version(prefix, version) do + Durable.TestRepo.query!( + "INSERT INTO #{prefix}.durable_schema_migrations (version, inserted_at) VALUES ($1, $2)", + [version, DateTime.utc_now()] + ) + end + + defp collect_all_output do + collect_all_output("") + end + + defp collect_all_output(acc) do + receive do + {:mix_shell, :info, [line]} -> collect_all_output(acc <> "\n" <> line) + {:mix_shell, :error, [line]} -> collect_all_output(acc <> "\n" <> line) + after + 100 -> String.trim(acc) + end + end +end From 96517c065293d4a126fa09d2e2c94c6d672d989e Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Sat, 2 May 2026 13:23:04 +0530 Subject: [PATCH 11/13] chore: restructure into path-dep monorepo workspace Move :durable into durable/ as a sibling to durable_dashboard, modeled on elixir-nx/nx. Each package keeps its own mix.exs, mix.lock, _build/, and Hex publishing pipeline; cross-references go through path: deps. A thin root mix.exs fans out setup/compile/test/format/precommit to each published package. CI is rewritten as per-package jobs gated by dorny/paths-filter with a single ci-status aggregator for branch protection. --- .github/workflows/ci.yml | 136 ++++- .gitignore | 31 +- README.md | 559 ++---------------- .credo.exs => durable/.credo.exs | 0 .formatter.exs => durable/.formatter.exs | 0 durable/.gitignore | 27 + durable/LICENSE | 21 + durable/README.md | 542 +++++++++++++++++ {config => durable/config}/config.exs | 0 {config => durable/config}/dev.exs | 0 {config => durable/config}/prod.exs | 0 {config => durable/config}/runtime.exs | 0 {config => durable/config}/test.exs | 0 {guides => durable/guides}/ai_workflows.md | 0 {guides => durable/guides}/branching.md | 0 {guides => durable/guides}/compensations.md | 0 {guides => durable/guides}/orchestration.md | 0 {guides => durable/guides}/parallel.md | 0 {guides => durable/guides}/waiting.md | 0 {lib => durable/lib}/durable.ex | 0 {lib => durable/lib}/durable/application.ex | 0 {lib => durable/lib}/durable/config.ex | 0 {lib => durable/lib}/durable/context.ex | 0 {lib => durable/lib}/durable/definition.ex | 0 {lib => durable/lib}/durable/dsl/step.ex | 0 .../lib}/durable/dsl/time_helpers.ex | 0 {lib => durable/lib}/durable/dsl/workflow.ex | 0 {lib => durable/lib}/durable/executor.ex | 0 .../lib}/durable/executor/backoff.ex | 0 .../durable/executor/compensation_runner.ex | 0 .../lib}/durable/executor/step_runner.ex | 0 {lib => durable/lib}/durable/helpers.ex | 0 {lib => durable/lib}/durable/log_capture.ex | 0 .../lib}/durable/log_capture/handler.ex | 0 .../lib}/durable/log_capture/io_server.ex | 0 {lib => durable/lib}/durable/migration.ex | 0 .../lib}/durable/migration/base.ex | 0 .../v20260103000000_initial_schema.ex | 0 .../v20260104000000_add_wait_primitives.ex | 0 ...20260413000000_add_scheduler_resilience.ex | 0 .../lib}/durable/migration/migrator.ex | 0 .../durable/migration/schema_migration.ex | 0 {lib => durable/lib}/durable/orchestration.ex | 0 {lib => durable/lib}/durable/pubsub.ex | 0 {lib => durable/lib}/durable/query.ex | 0 {lib => durable/lib}/durable/queue/adapter.ex | 0 .../lib}/durable/queue/adapters/postgres.ex | 0 {lib => durable/lib}/durable/queue/manager.ex | 0 {lib => durable/lib}/durable/queue/poller.ex | 0 .../lib}/durable/queue/stale_job_recovery.ex | 0 {lib => durable/lib}/durable/queue/worker.ex | 0 {lib => durable/lib}/durable/repo.ex | 0 {lib => durable/lib}/durable/scheduler/api.ex | 0 {lib => durable/lib}/durable/scheduler/dsl.ex | 0 .../lib}/durable/scheduler/scheduler.ex | 0 .../durable/storage/schemas/pending_event.ex | 0 .../durable/storage/schemas/pending_input.ex | 0 .../storage/schemas/scheduled_workflow.ex | 0 .../durable/storage/schemas/step_execution.ex | 0 .../durable/storage/schemas/wait_group.ex | 0 .../storage/schemas/workflow_execution.ex | 0 {lib => durable/lib}/durable/supervisor.ex | 0 {lib => durable/lib}/durable/wait.ex | 0 .../lib}/durable/wait/timeout_worker.ex | 0 {lib => durable/lib}/mix/helpers.ex | 0 .../lib}/mix/tasks/durable.cancel.ex | 0 .../lib}/mix/tasks/durable.cleanup.ex | 0 .../lib}/mix/tasks/durable.doctor.ex | 0 .../lib}/mix/tasks/durable.gen.migration.ex | 0 .../lib}/mix/tasks/durable.gen.upgrade.ex | 0 .../lib}/mix/tasks/durable.inspect.ex | 0 .../lib}/mix/tasks/durable.install.ex | 0 .../lib}/mix/tasks/durable.list.ex | 0 .../lib}/mix/tasks/durable.migrations.ex | 0 .../lib}/mix/tasks/durable.pending.ex | 0 .../lib}/mix/tasks/durable.provide_input.ex | 0 .../lib}/mix/tasks/durable.retry.ex | 0 {lib => durable/lib}/mix/tasks/durable.run.ex | 0 .../lib}/mix/tasks/durable.send_event.ex | 0 .../lib}/mix/tasks/durable.status.ex | 0 durable/mix.exs | 106 ++++ mix.lock => durable/mix.lock | 0 ...41229000001_create_workflow_executions.exs | 0 .../20241229000002_create_step_executions.exs | 0 .../20241229000003_create_pending_inputs.exs | 0 ...41229000004_create_scheduled_workflows.exs | 0 .../20241229000001_create_durable_tables.exs | 0 .../test}/durable/branch_test.exs | 0 .../test}/durable/compensation_test.exs | 0 .../test}/durable/context_test.exs | 0 .../test}/durable/decision_test.exs | 0 .../durable/executor/crash_modes_test.exs | 0 .../executor/error_sanitization_test.exs | 0 .../test}/durable/integration_test.exs | 0 .../durable/log_capture/handler_test.exs | 0 .../durable/log_capture/integration_test.exs | 0 .../durable/log_capture/io_server_test.exs | 0 .../test}/durable/log_capture_test.exs | 0 .../test}/durable/migration_test.exs | 0 .../test}/durable/orchestration_test.exs | 0 .../test}/durable/parallel_test.exs | 0 .../test}/durable/pubsub_test.exs | 0 .../durable/queue/adapters/postgres_test.exs | 0 .../stale_job_recovery_integration_test.exs | 0 .../test}/durable/queue/worker_test.exs | 0 .../test}/durable/resume_edge_cases_test.exs | 0 .../test}/durable/retry_context_test.exs | 0 .../durable/sanitization_boundaries_test.exs | 0 .../durable/scheduler_resilience_test.exs | 0 .../test}/durable/scheduler_test.exs | 0 .../test}/durable/validation_test.exs | 0 .../wait/timeout_worker_integration_test.exs | 0 {test => durable/test}/durable/wait_test.exs | 0 {test => durable/test}/durable_test.exs | 0 .../test}/mix/tasks/durable_cancel_test.exs | 0 .../test}/mix/tasks/durable_cleanup_test.exs | 0 .../mix/tasks/durable_gen_upgrade_test.exs | 0 .../test}/mix/tasks/durable_list_test.exs | 0 .../mix/tasks/durable_migrations_test.exs | 0 .../test}/mix/tasks/durable_run_test.exs | 0 .../test}/mix/tasks/durable_status_test.exs | 0 {test => durable/test}/support/data_case.ex | 0 .../test}/support/telemetry_handler.ex | 0 {test => durable/test}/support/test_repo.ex | 0 .../test}/support/test_workflows.ex | 0 .../test}/support/workflows/sink_workflow.ex | 0 {test => durable/test}/test_helper.exs | 0 durable_dashboard/mix.exs | 2 +- examples/phoenix_demo/mix.exs | 2 +- mix.exs | 118 +--- 130 files changed, 881 insertions(+), 663 deletions(-) rename .credo.exs => durable/.credo.exs (100%) rename .formatter.exs => durable/.formatter.exs (100%) create mode 100644 durable/.gitignore create mode 100644 durable/LICENSE create mode 100644 durable/README.md rename {config => durable/config}/config.exs (100%) rename {config => durable/config}/dev.exs (100%) rename {config => durable/config}/prod.exs (100%) rename {config => durable/config}/runtime.exs (100%) rename {config => durable/config}/test.exs (100%) rename {guides => durable/guides}/ai_workflows.md (100%) rename {guides => durable/guides}/branching.md (100%) rename {guides => durable/guides}/compensations.md (100%) rename {guides => durable/guides}/orchestration.md (100%) rename {guides => durable/guides}/parallel.md (100%) rename {guides => durable/guides}/waiting.md (100%) rename {lib => durable/lib}/durable.ex (100%) rename {lib => durable/lib}/durable/application.ex (100%) rename {lib => durable/lib}/durable/config.ex (100%) rename {lib => durable/lib}/durable/context.ex (100%) rename {lib => durable/lib}/durable/definition.ex (100%) rename {lib => durable/lib}/durable/dsl/step.ex (100%) rename {lib => durable/lib}/durable/dsl/time_helpers.ex (100%) rename {lib => durable/lib}/durable/dsl/workflow.ex (100%) rename {lib => durable/lib}/durable/executor.ex (100%) rename {lib => durable/lib}/durable/executor/backoff.ex (100%) rename {lib => durable/lib}/durable/executor/compensation_runner.ex (100%) rename {lib => durable/lib}/durable/executor/step_runner.ex (100%) rename {lib => durable/lib}/durable/helpers.ex (100%) rename {lib => durable/lib}/durable/log_capture.ex (100%) rename {lib => durable/lib}/durable/log_capture/handler.ex (100%) rename {lib => durable/lib}/durable/log_capture/io_server.ex (100%) rename {lib => durable/lib}/durable/migration.ex (100%) rename {lib => durable/lib}/durable/migration/base.ex (100%) rename {lib => durable/lib}/durable/migration/migrations/v20260103000000_initial_schema.ex (100%) rename {lib => durable/lib}/durable/migration/migrations/v20260104000000_add_wait_primitives.ex (100%) rename {lib => durable/lib}/durable/migration/migrations/v20260413000000_add_scheduler_resilience.ex (100%) rename {lib => durable/lib}/durable/migration/migrator.ex (100%) rename {lib => durable/lib}/durable/migration/schema_migration.ex (100%) rename {lib => durable/lib}/durable/orchestration.ex (100%) rename {lib => durable/lib}/durable/pubsub.ex (100%) rename {lib => durable/lib}/durable/query.ex (100%) rename {lib => durable/lib}/durable/queue/adapter.ex (100%) rename {lib => durable/lib}/durable/queue/adapters/postgres.ex (100%) rename {lib => durable/lib}/durable/queue/manager.ex (100%) rename {lib => durable/lib}/durable/queue/poller.ex (100%) rename {lib => durable/lib}/durable/queue/stale_job_recovery.ex (100%) rename {lib => durable/lib}/durable/queue/worker.ex (100%) rename {lib => durable/lib}/durable/repo.ex (100%) rename {lib => durable/lib}/durable/scheduler/api.ex (100%) rename {lib => durable/lib}/durable/scheduler/dsl.ex (100%) rename {lib => durable/lib}/durable/scheduler/scheduler.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/pending_event.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/pending_input.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/scheduled_workflow.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/step_execution.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/wait_group.ex (100%) rename {lib => durable/lib}/durable/storage/schemas/workflow_execution.ex (100%) rename {lib => durable/lib}/durable/supervisor.ex (100%) rename {lib => durable/lib}/durable/wait.ex (100%) rename {lib => durable/lib}/durable/wait/timeout_worker.ex (100%) rename {lib => durable/lib}/mix/helpers.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.cancel.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.cleanup.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.doctor.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.gen.migration.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.gen.upgrade.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.inspect.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.install.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.list.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.migrations.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.pending.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.provide_input.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.retry.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.run.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.send_event.ex (100%) rename {lib => durable/lib}/mix/tasks/durable.status.ex (100%) create mode 100644 durable/mix.exs rename mix.lock => durable/mix.lock (100%) rename {priv => durable/priv}/repo/migrations/20241229000001_create_workflow_executions.exs (100%) rename {priv => durable/priv}/repo/migrations/20241229000002_create_step_executions.exs (100%) rename {priv => durable/priv}/repo/migrations/20241229000003_create_pending_inputs.exs (100%) rename {priv => durable/priv}/repo/migrations/20241229000004_create_scheduled_workflows.exs (100%) rename {priv => durable/priv}/test_repo/migrations/20241229000001_create_durable_tables.exs (100%) rename {test => durable/test}/durable/branch_test.exs (100%) rename {test => durable/test}/durable/compensation_test.exs (100%) rename {test => durable/test}/durable/context_test.exs (100%) rename {test => durable/test}/durable/decision_test.exs (100%) rename {test => durable/test}/durable/executor/crash_modes_test.exs (100%) rename {test => durable/test}/durable/executor/error_sanitization_test.exs (100%) rename {test => durable/test}/durable/integration_test.exs (100%) rename {test => durable/test}/durable/log_capture/handler_test.exs (100%) rename {test => durable/test}/durable/log_capture/integration_test.exs (100%) rename {test => durable/test}/durable/log_capture/io_server_test.exs (100%) rename {test => durable/test}/durable/log_capture_test.exs (100%) rename {test => durable/test}/durable/migration_test.exs (100%) rename {test => durable/test}/durable/orchestration_test.exs (100%) rename {test => durable/test}/durable/parallel_test.exs (100%) rename {test => durable/test}/durable/pubsub_test.exs (100%) rename {test => durable/test}/durable/queue/adapters/postgres_test.exs (100%) rename {test => durable/test}/durable/queue/stale_job_recovery_integration_test.exs (100%) rename {test => durable/test}/durable/queue/worker_test.exs (100%) rename {test => durable/test}/durable/resume_edge_cases_test.exs (100%) rename {test => durable/test}/durable/retry_context_test.exs (100%) rename {test => durable/test}/durable/sanitization_boundaries_test.exs (100%) rename {test => durable/test}/durable/scheduler_resilience_test.exs (100%) rename {test => durable/test}/durable/scheduler_test.exs (100%) rename {test => durable/test}/durable/validation_test.exs (100%) rename {test => durable/test}/durable/wait/timeout_worker_integration_test.exs (100%) rename {test => durable/test}/durable/wait_test.exs (100%) rename {test => durable/test}/durable_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_cancel_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_cleanup_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_gen_upgrade_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_list_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_migrations_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_run_test.exs (100%) rename {test => durable/test}/mix/tasks/durable_status_test.exs (100%) rename {test => durable/test}/support/data_case.ex (100%) rename {test => durable/test}/support/telemetry_handler.ex (100%) rename {test => durable/test}/support/test_repo.ex (100%) rename {test => durable/test}/support/test_workflows.ex (100%) rename {test => durable/test}/support/workflows/sink_workflow.ex (100%) rename {test => durable/test}/test_helper.exs (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0553153..674f4d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,18 +6,43 @@ on: pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + env: MIX_ENV: test + ELIXIR_VERSION: "1.19" + OTP_VERSION: "28" jobs: - test: - name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }}) + changes: + name: Detect changes runs-on: ubuntu-latest + outputs: + durable: ${{ steps.filter.outputs.durable }} + dashboard: ${{ steps.filter.outputs.dashboard }} + ci: ${{ steps.filter.outputs.ci }} + steps: + - uses: actions/checkout@v6 - strategy: - matrix: - elixir: ["1.19"] - otp: ["28"] + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + durable: + - 'durable/**' + dashboard: + - 'durable/**' + - 'durable_dashboard/**' + ci: + - '.github/workflows/**' + + durable: + name: Durable + needs: changes + if: needs.changes.outputs.durable == 'true' || needs.changes.outputs.ci == 'true' || github.event_name == 'push' + runs-on: ubuntu-latest services: postgres: @@ -33,28 +58,32 @@ jobs: --health-timeout=5s --health-retries=5 + defaults: + run: + working-directory: durable + steps: - uses: actions/checkout@v6 - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: ${{ matrix.elixir }} - otp-version: ${{ matrix.otp }} + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} - name: Restore dependencies cache uses: actions/cache@v4 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + path: durable/deps + key: ${{ runner.os }}-durable-mix-${{ hashFiles('durable/mix.lock') }} + restore-keys: ${{ runner.os }}-durable-mix- - name: Restore build cache - uses: actions/cache@v5 + uses: actions/cache@v4 with: - path: _build - key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}- + path: durable/_build + key: ${{ runner.os }}-durable-build-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('durable/mix.lock') }} + restore-keys: ${{ runner.os }}-durable-build-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}- - name: Install dependencies run: mix deps.get @@ -65,31 +94,86 @@ jobs: - name: Compile run: mix compile --warnings-as-errors + - name: Credo + run: mix credo --strict + - name: Run tests run: mix test - credo: - name: Credo + dashboard: + name: Dashboard + needs: changes + if: needs.changes.outputs.dashboard == 'true' || needs.changes.outputs.ci == 'true' || github.event_name == 'push' runs-on: ubuntu-latest + defaults: + run: + working-directory: durable_dashboard + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: "1.19" - otp-version: "28" + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + cache-dependency-path: durable_dashboard/assets/pnpm-lock.yaml - name: Restore dependencies cache uses: actions/cache@v4 with: - path: deps - key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-mix- + path: durable_dashboard/deps + key: ${{ runner.os }}-dashboard-mix-${{ hashFiles('durable_dashboard/mix.lock', 'durable/mix.lock') }} + restore-keys: ${{ runner.os }}-dashboard-mix- - - name: Install dependencies + - name: Restore build cache + uses: actions/cache@v4 + with: + path: durable_dashboard/_build + key: ${{ runner.os }}-dashboard-build-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}-${{ hashFiles('durable_dashboard/mix.lock', 'durable/mix.lock') }} + restore-keys: ${{ runner.os }}-dashboard-build-${{ env.OTP_VERSION }}-${{ env.ELIXIR_VERSION }}- + + - name: Install Elixir dependencies run: mix deps.get - - name: Run Credo - run: mix credo --strict + - name: Install JS dependencies + working-directory: durable_dashboard/assets + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: mix format --check-formatted + + - name: Build assets + working-directory: durable_dashboard/assets + run: pnpm build + + - name: Compile + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test + + ci-status: + name: CI status + needs: [changes, durable, dashboard] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify all required jobs passed + run: | + set -e + [[ "${{ needs.changes.result }}" == "success" ]] || exit 1 + [[ "${{ needs.durable.result }}" =~ ^(success|skipped)$ ]] || exit 1 + [[ "${{ needs.dashboard.result }}" =~ ^(success|skipped)$ ]] || exit 1 diff --git a/.gitignore b/.gitignore index e949aa8..2d75449 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,10 @@ -# The directory Mix will write compiled artifacts to. -/_build/ - -# If you run "mix test --cover", coverage assets end up here. -/cover/ - -# The directory Mix downloads your dependencies sources to. -/deps/ - -# Where third-party dependencies like ExDoc output generated docs. -/doc/ +# Editor / IDE +.elixir_ls/ -# Temporary files, for example, from tests. -/tmp/ - -# If the VM crashes, it generates a dump, let's ignore it too. +# Crash dumps erl_crash.dump -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Ignore package tarball (built via "mix hex.build"). -durable-*.tar - -# JS package manager -node_modules/ -package-lock.json +# Workspace-level generated docs (per-project ignores live in each subdir) +/doc/ +/_build/ +/deps/ diff --git a/README.md b/README.md index 54b87e1..04fc96f 100644 --- a/README.md +++ b/README.md @@ -1,542 +1,65 @@ -# Durable +# Durable workspace -[![Build Status](https://github.com/wavezync/durable/actions/workflows/ci.yml/badge.svg)](https://github.com/wavezync/durable/actions/workflows/ci.yml) -[![Hex.pm](https://img.shields.io/hexpm/v/durable.svg)](https://hex.pm/packages/durable) +This is a path-dep monorepo (pnpm-workspaces style) containing the Durable +workflow engine and its surrounding packages. Each subdirectory is an +independent Mix project with its own `mix.exs`, `_build/`, `deps/`, and Hex +publishing pipeline. -A durable, resumable workflow engine for Elixir. Similar to Temporal/Inngest. +## Packages -## Features +| Path | Package | Description | +| --- | --- | --- | +| [`durable/`](durable/) | `:durable` | Core workflow engine — resumable, reliable workflows with PostgreSQL persistence. | +| [`durable_dashboard/`](durable_dashboard/) | `:durable_dashboard` | LiveView-first web dashboard for monitoring and managing Durable workflows. | +| [`examples/phoenix_demo/`](examples/phoenix_demo/) | (unpublished) | Reference Phoenix app wiring up `:durable` and `:durable_dashboard`. | -- **Pipeline Model** - Context flows from step to step, simple and explicit -- **Resumability** - Sleep, wait for events, wait for human input -- **Branching** - Pattern-matched conditional flow control -- **Parallel** - Run steps concurrently with result collection -- **Compensations** - Saga pattern with automatic rollback -- **Cron Scheduling** - Recurring workflows with cron expressions -- **Reliability** - Automatic retries with exponential/linear/constant backoff -- **Orchestration** - Parent/child workflow composition -- **Persistence** - PostgreSQL-backed execution state +## Workspace commands -## Installation - -```elixir -def deps do - [{:durable, "~> 0.0.0-alpha"}] -end -``` - -## Quick Start - -### 1. Create Migration - -```elixir -defmodule MyApp.Repo.Migrations.AddDurable do - use Ecto.Migration - def up, do: Durable.Migration.up() - def down, do: Durable.Migration.down() -end -``` - -When a Durable upgrade ships new internal migrations, generate a new wrapper -migration and run your normal Ecto migration flow: +A thin root `mix.exs` fans out common commands to each published package: ```bash -mix durable.gen.upgrade -r MyApp.Repo -mix ecto.migrate -``` - -Use `mix durable.migrations -r MyApp.Repo --check` in CI or deploy gates to -fail when the database is behind the Durable library version. - -### 2. Add to Supervision Tree - -```elixir -children = [ - MyApp.Repo, - {Durable, repo: MyApp.Repo, queues: %{default: [concurrency: 10]}} -] -``` - -### 3. Define & Run - -```elixir -defmodule MyApp.OrderWorkflow do - use Durable - use Durable.Helpers - - workflow "process_order", timeout: hours(2) do - # First step receives workflow input - step :validate, fn input -> - {:ok, %{ - order_id: input["id"], - items: input["items"], - customer_id: input["customer_id"] - }} - end - - # Each step receives previous step's output as context - step :calculate_total, fn ctx -> - total = ctx.items |> Enum.map(& &1["price"]) |> Enum.sum() - {:ok, assign(ctx, :total, total)} - end - - step :charge_payment, [retry: [max_attempts: 3, backoff: :exponential]], fn ctx -> - {:ok, charge} = PaymentService.charge(ctx.order_id, ctx.total) - {:ok, assign(ctx, :charge_id, charge.id)} - end - - step :send_confirmation, fn ctx -> - EmailService.send_confirmation(ctx.order_id) - {:ok, ctx} - end - end -end - -# Start it -{:ok, id} = Durable.start(MyApp.OrderWorkflow, %{"id" => "order_123", "items" => items}) -``` - -## Examples - -### Approval Workflow - -Wait for human approval with timeout fallback. - -```elixir -defmodule MyApp.ExpenseApproval do - use Durable - use Durable.Helpers - use Durable.Wait - - workflow "expense_approval" do - step :request_approval, fn ctx -> - result = wait_for_approval("manager", - prompt: "Approve $#{ctx["amount"]} expense?", - timeout: days(3), - timeout_value: :auto_rejected - ) - {:ok, assign(ctx, :decision, result)} - end - - branch on: fn ctx -> ctx.decision end do - :approved -> - step :process, fn ctx -> - Expenses.reimburse(ctx["employee_id"], ctx["amount"]) - {:ok, assign(ctx, :status, :reimbursed)} - end - - _ -> - step :notify_rejection, fn ctx -> - Mailer.send_rejection(ctx["employee_id"]) - {:ok, assign(ctx, :status, :rejected)} - end - end - end -end - -# Approve externally -Durable.provide_input(workflow_id, "manager", :approved) -``` - -### Parallel Data Fetch - -Fetch data concurrently, then combine results. - -```elixir -defmodule MyApp.DashboardBuilder do - use Durable - use Durable.Helpers - - workflow "build_dashboard" do - step :init, fn input -> - {:ok, %{user_id: input["user_id"]}} - end - - # Parallel steps produce results in __results__ map - parallel do - step :user, fn ctx -> - {:ok, %{user: Users.get(ctx.user_id)}} - end - - step :orders, fn ctx -> - {:ok, %{orders: Orders.recent(ctx.user_id)}} - end - - step :notifications, fn ctx -> - {:ok, %{notifs: Notifications.unread(ctx.user_id)}} - end - end - - # Access results from __results__ map - step :render, fn ctx -> - results = ctx[:__results__] - - # Results are tagged tuples: ["ok", data] or ["error", reason] - user = case results["user"] do - ["ok", data] -> data.user - _ -> nil - end - - orders = case results["orders"] do - ["ok", data] -> data.orders - _ -> [] - end - - notifs = case results["notifications"] do - ["ok", data] -> data.notifs - _ -> [] - end - - dashboard = Dashboard.build(user, orders, notifs) - {:ok, assign(ctx, :dashboard, dashboard)} - end - end -end - -# Or use into: to transform results directly -defmodule MyApp.DashboardBuilderWithInto do - use Durable - use Durable.Helpers - - workflow "build_dashboard_v2" do - step :init, fn input -> - {:ok, %{user_id: input["user_id"]}} - end - - parallel into: fn ctx, results -> - # results contains tuples: %{user: {:ok, data}, orders: {:ok, data}, ...} - case {results[:user], results[:orders], results[:notifications]} do - {{:ok, user_data}, {:ok, orders_data}, {:ok, notifs_data}} -> - {:ok, Map.merge(ctx, %{ - user: user_data.user, - orders: orders_data.orders, - notifs: notifs_data.notifs - })} - - _ -> - {:error, "Failed to fetch dashboard data"} - end - end do - step :user, fn ctx -> {:ok, %{user: Users.get(ctx.user_id)}} end - step :orders, fn ctx -> {:ok, %{orders: Orders.recent(ctx.user_id)}} end - step :notifications, fn ctx -> {:ok, %{notifs: Notifications.unread(ctx.user_id)}} end - end - - step :render, fn ctx -> - dashboard = Dashboard.build(ctx.user, ctx.orders, ctx.notifs) - {:ok, assign(ctx, :dashboard, dashboard)} - end - end -end -``` - -### Batch Processing - -Process items with controlled concurrency using `Task.async_stream`. - -```elixir -defmodule MyApp.BulkEmailer do - use Durable - use Durable.Helpers - - workflow "send_campaign" do - step :load, fn input -> - recipients = Subscribers.active(input["campaign_id"]) - {:ok, %{campaign_id: input["campaign_id"], recipients: recipients}} - end - - step :send_emails, fn ctx -> - results = - ctx.recipients - |> Task.async_stream( - fn recipient -> - case Mailer.send_campaign(recipient, ctx.campaign_id) do - :ok -> {:ok, recipient} - {:error, reason} -> {:error, {recipient, reason}} - end - end, - max_concurrency: 10, - timeout: :infinity - ) - |> Enum.map(fn {:ok, r} -> r end) - - sent = for {:ok, _} <- results, do: 1 - failed = for {:error, _} <- results, do: 1 - - {:ok, ctx - |> assign(:sent_count, length(sent)) - |> assign(:failed_count, length(failed))} - end - end -end -``` - -### Trip Booking (Saga) - -Book multiple services with automatic rollback on failure. - -```elixir -defmodule MyApp.TripBooking do - use Durable - use Durable.Helpers - - workflow "book_trip" do - step :book_flight, [compensate: :cancel_flight], fn ctx -> - booking = Flights.book(ctx["flight"]) - {:ok, assign(ctx, :flight, booking)} - end - - step :book_hotel, [compensate: :cancel_hotel], fn ctx -> - booking = Hotels.book(ctx["hotel"]) - {:ok, assign(ctx, :hotel, booking)} - end - - step :charge, fn ctx -> - total = ctx.flight.price + ctx.hotel.price - Payments.charge(ctx["card"], total) - {:ok, assign(ctx, :charged, true)} - end - - compensate :cancel_flight, fn ctx -> - Flights.cancel(ctx.flight.id) - {:ok, ctx} - end - - compensate :cancel_hotel, fn ctx -> - Hotels.cancel(ctx.hotel.id) - {:ok, ctx} - end - end -end -``` - -### Scheduled Reports - -Run daily at 9am. - -```elixir -defmodule MyApp.DailyReport do - use Durable - use Durable.Helpers - use Durable.Scheduler.DSL - - @schedule cron: "0 9 * * *", timezone: "America/New_York" - workflow "daily_sales_report" do - step :generate, fn _input -> - report = Reports.sales_summary(Date.utc_today()) - {:ok, %{report: report}} - end - - step :distribute, fn ctx -> - Mailer.send_report(ctx.report, to: "team@company.com") - Slack.post_summary(ctx.report, channel: "#sales") - {:ok, ctx} - end - end -end - -# Register in supervision tree -{Durable, repo: MyApp.Repo, scheduled_modules: [MyApp.DailyReport]} -``` - -### Delayed & Scheduled Execution - -Sleep, schedule for specific times, and wait for events. - -```elixir -defmodule MyApp.TrialReminder do - use Durable - use Durable.Helpers - use Durable.Wait - - workflow "trial_reminder" do - step :welcome, fn ctx -> - Mailer.send_welcome(ctx["user_id"]) - {:ok, %{user_id: ctx["user_id"], trial_started_at: ctx["trial_started_at"]}} - end - - step :wait_3_days, fn ctx -> - sleep(days(3)) - {:ok, ctx} - end - - step :check_in, fn ctx -> - Mailer.send_tips(ctx.user_id) - {:ok, ctx} - end - - step :wait_until_trial_ends, fn ctx -> - trial_end = DateTime.add(ctx.trial_started_at, 14, :day) - schedule_at(trial_end) - {:ok, ctx} - end - - step :convert_or_remind, fn ctx -> - if Subscriptions.active?(ctx.user_id) do - {:ok, assign(ctx, :converted, true)} - else - Mailer.send_upgrade_reminder(ctx.user_id) - {:ok, assign(ctx, :converted, false)} - end - end - end -end -``` - -### Event-Driven Workflow - -Wait for external webhook events. - -```elixir -defmodule MyApp.PaymentFlow do - use Durable - use Durable.Helpers - use Durable.Wait - - workflow "payment_flow" do - step :create_invoice, fn ctx -> - invoice = Invoices.create(ctx["order_id"], ctx["amount"]) - {:ok, %{order_id: ctx["order_id"], invoice_id: invoice.id}} - end - - step :await_payment, fn ctx -> - {event, _payload} = wait_for_any(["payment.success", "payment.failed"], - timeout: days(7), - timeout_value: {"payment.expired", nil} - ) - {:ok, assign(ctx, :result, event)} - end - - branch on: fn ctx -> ctx.result end do - "payment.success" -> - step :fulfill, fn ctx -> - Orders.fulfill(ctx.order_id) - {:ok, assign(ctx, :status, :fulfilled)} - end - - _ -> - step :cancel, fn ctx -> - Orders.cancel(ctx.order_id) - {:ok, assign(ctx, :status, :cancelled)} - end - end - end -end - -# Webhook handler sends event -Durable.send_event(workflow_id, "payment.success", %{transaction_id: "txn_123"}) -``` - -## Reference - -### Helper Functions - -```elixir -use Durable.Helpers - -assign(ctx, :key, value) # Set a value -assign(ctx, %{a: 1, b: 2}) # Merge multiple values -update(ctx, :key, default, fn old -> new end) -append(ctx, :list, item) # Append to list -increment(ctx, :count) # Increment by 1 -increment(ctx, :count, 5) # Increment by 5 +mix setup # mix deps.get in durable/, then durable_dashboard/ +mix compile # compile both +mix test # run both test suites +mix format # format both projects +mix precommit # run each project's precommit alias ``` -### Time Helpers +`examples/phoenix_demo` is intentionally outside the fan-out — it's an +integration sample, not a published package, and uses its own DB on port +`53412`. Run it directly via `cd examples/phoenix_demo && mix ...`. -```elixir -seconds(30) # 30_000 ms -minutes(5) # 300_000 ms -hours(2) # 7_200_000 ms -days(7) # 604_800_000 ms -``` - -### Orchestration - -```elixir -use Durable.Orchestration - -# Synchronous: call child and wait for result -case call_workflow(MyApp.PaymentWorkflow, %{"amount" => 100}, timeout: hours(1)) do - {:ok, result} -> {:ok, assign(data, :payment, result)} - {:error, reason} -> {:error, reason} -end +## Working in a single package -# Fire-and-forget: start child and continue -{:ok, child_id} = start_workflow(MyApp.EmailWorkflow, %{"to" => email}, ref: :welcome) +Each package is a normal Mix project. From inside any subdirectory, the usual +commands work: `mix deps.get`, `mix compile`, `mix test`, `mix format`, +`mix credo --strict` (in `durable/`), `mix hex.publish`, etc. -# call_workflow also works inside parallel blocks (executed inline) -parallel do - step :payment, fn data -> - case call_workflow(MyApp.PaymentWorkflow, %{"amount" => data.total}, ref: :pay) do - {:ok, result} -> {:ok, assign(data, :payment, result)} - {:error, reason} -> {:error, reason} - end - end - - step :shipping, fn data -> - case call_workflow(MyApp.ShippingWorkflow, %{"id" => data.order_id}, ref: :ship) do - {:ok, result} -> {:ok, assign(data, :shipping, result)} - {:error, reason} -> {:error, reason} - end - end -end -``` +## Cross-package references -### API +Packages link to each other via `path:` deps in dev. To publish, swap the +`path:` line for the Hex version: ```elixir -Durable.start(Module, input) -Durable.start(Module, input, queue: :priority, scheduled_at: datetime) -Durable.get_execution(id) -Durable.list_executions(workflow: Module, status: :running) -Durable.cancel(id, "reason") -Durable.send_event(id, "event", payload) -Durable.provide_input(id, "input_name", data) -Durable.list_children(parent_id) +# durable_dashboard/mix.exs +{:durable, path: "../durable"} # dev +{:durable, "~> 0.1"} # release ``` -## Mix Tasks +## Database -Durable includes mix tasks for managing workflows from the command line. +A shared `docker-compose.yml` at the workspace root brings up the Postgres +instance the core durable test suite expects (port `54321`): ```bash -# Show queue status and workflow summary -mix durable.status - -# List workflow executions (with filters) -mix durable.list # all executions -mix durable.list --status running # filter by status -mix durable.list --workflow MyApp.OrderWorkflow # filter by workflow -mix durable.list --limit 20 --format json # limit results, JSON output - -# Start a workflow -mix durable.run MyApp.OrderWorkflow # no input -mix durable.run MyApp.OrderWorkflow --input '{"id": 123}' # with JSON input -mix durable.run MyApp.OrderWorkflow --queue high_priority # specific queue - -# Cancel a workflow -mix durable.cancel -mix durable.cancel --reason "no longer needed" - -# Clean up old executions -mix durable.cleanup --older-than 30d # completed/failed older than 30 days -mix durable.cleanup --older-than 7d --status completed # only completed, older than 7 days -mix durable.cleanup --older-than 24h --dry-run # preview what would be deleted +docker compose up -d ``` -## Guides - -- [Branching](guides/branching.md) - Conditional flow control -- [Parallel](guides/parallel.md) - Concurrent execution -- [Compensations](guides/compensations.md) - Saga pattern -- [Waiting](guides/waiting.md) - Sleep, events, human input -- [Orchestration](guides/orchestration.md) - Parent/child workflow composition - -## Coming Soon - -- Phoenix LiveView dashboard +The Phoenix demo runs against its own Postgres on port `53412`; see +[`examples/phoenix_demo/docker-compose.yml`](examples/phoenix_demo/docker-compose.yml). -## License +## Layout reference -MIT +This layout is modeled on [`elixir-nx/nx`](https://github.com/elixir-nx/nx), +which uses the same path-dep + thin-coordinator pattern across `nx/`, +`exla/`, and `torchx/`. diff --git a/.credo.exs b/durable/.credo.exs similarity index 100% rename from .credo.exs rename to durable/.credo.exs diff --git a/.formatter.exs b/durable/.formatter.exs similarity index 100% rename from .formatter.exs rename to durable/.formatter.exs diff --git a/durable/.gitignore b/durable/.gitignore new file mode 100644 index 0000000..e949aa8 --- /dev/null +++ b/durable/.gitignore @@ -0,0 +1,27 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Temporary files, for example, from tests. +/tmp/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +durable-*.tar + +# JS package manager +node_modules/ +package-lock.json diff --git a/durable/LICENSE b/durable/LICENSE new file mode 100644 index 0000000..c927461 --- /dev/null +++ b/durable/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 WaveZync + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/durable/README.md b/durable/README.md new file mode 100644 index 0000000..54b87e1 --- /dev/null +++ b/durable/README.md @@ -0,0 +1,542 @@ +# Durable + +[![Build Status](https://github.com/wavezync/durable/actions/workflows/ci.yml/badge.svg)](https://github.com/wavezync/durable/actions/workflows/ci.yml) +[![Hex.pm](https://img.shields.io/hexpm/v/durable.svg)](https://hex.pm/packages/durable) + +A durable, resumable workflow engine for Elixir. Similar to Temporal/Inngest. + +## Features + +- **Pipeline Model** - Context flows from step to step, simple and explicit +- **Resumability** - Sleep, wait for events, wait for human input +- **Branching** - Pattern-matched conditional flow control +- **Parallel** - Run steps concurrently with result collection +- **Compensations** - Saga pattern with automatic rollback +- **Cron Scheduling** - Recurring workflows with cron expressions +- **Reliability** - Automatic retries with exponential/linear/constant backoff +- **Orchestration** - Parent/child workflow composition +- **Persistence** - PostgreSQL-backed execution state + +## Installation + +```elixir +def deps do + [{:durable, "~> 0.0.0-alpha"}] +end +``` + +## Quick Start + +### 1. Create Migration + +```elixir +defmodule MyApp.Repo.Migrations.AddDurable do + use Ecto.Migration + def up, do: Durable.Migration.up() + def down, do: Durable.Migration.down() +end +``` + +When a Durable upgrade ships new internal migrations, generate a new wrapper +migration and run your normal Ecto migration flow: + +```bash +mix durable.gen.upgrade -r MyApp.Repo +mix ecto.migrate +``` + +Use `mix durable.migrations -r MyApp.Repo --check` in CI or deploy gates to +fail when the database is behind the Durable library version. + +### 2. Add to Supervision Tree + +```elixir +children = [ + MyApp.Repo, + {Durable, repo: MyApp.Repo, queues: %{default: [concurrency: 10]}} +] +``` + +### 3. Define & Run + +```elixir +defmodule MyApp.OrderWorkflow do + use Durable + use Durable.Helpers + + workflow "process_order", timeout: hours(2) do + # First step receives workflow input + step :validate, fn input -> + {:ok, %{ + order_id: input["id"], + items: input["items"], + customer_id: input["customer_id"] + }} + end + + # Each step receives previous step's output as context + step :calculate_total, fn ctx -> + total = ctx.items |> Enum.map(& &1["price"]) |> Enum.sum() + {:ok, assign(ctx, :total, total)} + end + + step :charge_payment, [retry: [max_attempts: 3, backoff: :exponential]], fn ctx -> + {:ok, charge} = PaymentService.charge(ctx.order_id, ctx.total) + {:ok, assign(ctx, :charge_id, charge.id)} + end + + step :send_confirmation, fn ctx -> + EmailService.send_confirmation(ctx.order_id) + {:ok, ctx} + end + end +end + +# Start it +{:ok, id} = Durable.start(MyApp.OrderWorkflow, %{"id" => "order_123", "items" => items}) +``` + +## Examples + +### Approval Workflow + +Wait for human approval with timeout fallback. + +```elixir +defmodule MyApp.ExpenseApproval do + use Durable + use Durable.Helpers + use Durable.Wait + + workflow "expense_approval" do + step :request_approval, fn ctx -> + result = wait_for_approval("manager", + prompt: "Approve $#{ctx["amount"]} expense?", + timeout: days(3), + timeout_value: :auto_rejected + ) + {:ok, assign(ctx, :decision, result)} + end + + branch on: fn ctx -> ctx.decision end do + :approved -> + step :process, fn ctx -> + Expenses.reimburse(ctx["employee_id"], ctx["amount"]) + {:ok, assign(ctx, :status, :reimbursed)} + end + + _ -> + step :notify_rejection, fn ctx -> + Mailer.send_rejection(ctx["employee_id"]) + {:ok, assign(ctx, :status, :rejected)} + end + end + end +end + +# Approve externally +Durable.provide_input(workflow_id, "manager", :approved) +``` + +### Parallel Data Fetch + +Fetch data concurrently, then combine results. + +```elixir +defmodule MyApp.DashboardBuilder do + use Durable + use Durable.Helpers + + workflow "build_dashboard" do + step :init, fn input -> + {:ok, %{user_id: input["user_id"]}} + end + + # Parallel steps produce results in __results__ map + parallel do + step :user, fn ctx -> + {:ok, %{user: Users.get(ctx.user_id)}} + end + + step :orders, fn ctx -> + {:ok, %{orders: Orders.recent(ctx.user_id)}} + end + + step :notifications, fn ctx -> + {:ok, %{notifs: Notifications.unread(ctx.user_id)}} + end + end + + # Access results from __results__ map + step :render, fn ctx -> + results = ctx[:__results__] + + # Results are tagged tuples: ["ok", data] or ["error", reason] + user = case results["user"] do + ["ok", data] -> data.user + _ -> nil + end + + orders = case results["orders"] do + ["ok", data] -> data.orders + _ -> [] + end + + notifs = case results["notifications"] do + ["ok", data] -> data.notifs + _ -> [] + end + + dashboard = Dashboard.build(user, orders, notifs) + {:ok, assign(ctx, :dashboard, dashboard)} + end + end +end + +# Or use into: to transform results directly +defmodule MyApp.DashboardBuilderWithInto do + use Durable + use Durable.Helpers + + workflow "build_dashboard_v2" do + step :init, fn input -> + {:ok, %{user_id: input["user_id"]}} + end + + parallel into: fn ctx, results -> + # results contains tuples: %{user: {:ok, data}, orders: {:ok, data}, ...} + case {results[:user], results[:orders], results[:notifications]} do + {{:ok, user_data}, {:ok, orders_data}, {:ok, notifs_data}} -> + {:ok, Map.merge(ctx, %{ + user: user_data.user, + orders: orders_data.orders, + notifs: notifs_data.notifs + })} + + _ -> + {:error, "Failed to fetch dashboard data"} + end + end do + step :user, fn ctx -> {:ok, %{user: Users.get(ctx.user_id)}} end + step :orders, fn ctx -> {:ok, %{orders: Orders.recent(ctx.user_id)}} end + step :notifications, fn ctx -> {:ok, %{notifs: Notifications.unread(ctx.user_id)}} end + end + + step :render, fn ctx -> + dashboard = Dashboard.build(ctx.user, ctx.orders, ctx.notifs) + {:ok, assign(ctx, :dashboard, dashboard)} + end + end +end +``` + +### Batch Processing + +Process items with controlled concurrency using `Task.async_stream`. + +```elixir +defmodule MyApp.BulkEmailer do + use Durable + use Durable.Helpers + + workflow "send_campaign" do + step :load, fn input -> + recipients = Subscribers.active(input["campaign_id"]) + {:ok, %{campaign_id: input["campaign_id"], recipients: recipients}} + end + + step :send_emails, fn ctx -> + results = + ctx.recipients + |> Task.async_stream( + fn recipient -> + case Mailer.send_campaign(recipient, ctx.campaign_id) do + :ok -> {:ok, recipient} + {:error, reason} -> {:error, {recipient, reason}} + end + end, + max_concurrency: 10, + timeout: :infinity + ) + |> Enum.map(fn {:ok, r} -> r end) + + sent = for {:ok, _} <- results, do: 1 + failed = for {:error, _} <- results, do: 1 + + {:ok, ctx + |> assign(:sent_count, length(sent)) + |> assign(:failed_count, length(failed))} + end + end +end +``` + +### Trip Booking (Saga) + +Book multiple services with automatic rollback on failure. + +```elixir +defmodule MyApp.TripBooking do + use Durable + use Durable.Helpers + + workflow "book_trip" do + step :book_flight, [compensate: :cancel_flight], fn ctx -> + booking = Flights.book(ctx["flight"]) + {:ok, assign(ctx, :flight, booking)} + end + + step :book_hotel, [compensate: :cancel_hotel], fn ctx -> + booking = Hotels.book(ctx["hotel"]) + {:ok, assign(ctx, :hotel, booking)} + end + + step :charge, fn ctx -> + total = ctx.flight.price + ctx.hotel.price + Payments.charge(ctx["card"], total) + {:ok, assign(ctx, :charged, true)} + end + + compensate :cancel_flight, fn ctx -> + Flights.cancel(ctx.flight.id) + {:ok, ctx} + end + + compensate :cancel_hotel, fn ctx -> + Hotels.cancel(ctx.hotel.id) + {:ok, ctx} + end + end +end +``` + +### Scheduled Reports + +Run daily at 9am. + +```elixir +defmodule MyApp.DailyReport do + use Durable + use Durable.Helpers + use Durable.Scheduler.DSL + + @schedule cron: "0 9 * * *", timezone: "America/New_York" + workflow "daily_sales_report" do + step :generate, fn _input -> + report = Reports.sales_summary(Date.utc_today()) + {:ok, %{report: report}} + end + + step :distribute, fn ctx -> + Mailer.send_report(ctx.report, to: "team@company.com") + Slack.post_summary(ctx.report, channel: "#sales") + {:ok, ctx} + end + end +end + +# Register in supervision tree +{Durable, repo: MyApp.Repo, scheduled_modules: [MyApp.DailyReport]} +``` + +### Delayed & Scheduled Execution + +Sleep, schedule for specific times, and wait for events. + +```elixir +defmodule MyApp.TrialReminder do + use Durable + use Durable.Helpers + use Durable.Wait + + workflow "trial_reminder" do + step :welcome, fn ctx -> + Mailer.send_welcome(ctx["user_id"]) + {:ok, %{user_id: ctx["user_id"], trial_started_at: ctx["trial_started_at"]}} + end + + step :wait_3_days, fn ctx -> + sleep(days(3)) + {:ok, ctx} + end + + step :check_in, fn ctx -> + Mailer.send_tips(ctx.user_id) + {:ok, ctx} + end + + step :wait_until_trial_ends, fn ctx -> + trial_end = DateTime.add(ctx.trial_started_at, 14, :day) + schedule_at(trial_end) + {:ok, ctx} + end + + step :convert_or_remind, fn ctx -> + if Subscriptions.active?(ctx.user_id) do + {:ok, assign(ctx, :converted, true)} + else + Mailer.send_upgrade_reminder(ctx.user_id) + {:ok, assign(ctx, :converted, false)} + end + end + end +end +``` + +### Event-Driven Workflow + +Wait for external webhook events. + +```elixir +defmodule MyApp.PaymentFlow do + use Durable + use Durable.Helpers + use Durable.Wait + + workflow "payment_flow" do + step :create_invoice, fn ctx -> + invoice = Invoices.create(ctx["order_id"], ctx["amount"]) + {:ok, %{order_id: ctx["order_id"], invoice_id: invoice.id}} + end + + step :await_payment, fn ctx -> + {event, _payload} = wait_for_any(["payment.success", "payment.failed"], + timeout: days(7), + timeout_value: {"payment.expired", nil} + ) + {:ok, assign(ctx, :result, event)} + end + + branch on: fn ctx -> ctx.result end do + "payment.success" -> + step :fulfill, fn ctx -> + Orders.fulfill(ctx.order_id) + {:ok, assign(ctx, :status, :fulfilled)} + end + + _ -> + step :cancel, fn ctx -> + Orders.cancel(ctx.order_id) + {:ok, assign(ctx, :status, :cancelled)} + end + end + end +end + +# Webhook handler sends event +Durable.send_event(workflow_id, "payment.success", %{transaction_id: "txn_123"}) +``` + +## Reference + +### Helper Functions + +```elixir +use Durable.Helpers + +assign(ctx, :key, value) # Set a value +assign(ctx, %{a: 1, b: 2}) # Merge multiple values +update(ctx, :key, default, fn old -> new end) +append(ctx, :list, item) # Append to list +increment(ctx, :count) # Increment by 1 +increment(ctx, :count, 5) # Increment by 5 +``` + +### Time Helpers + +```elixir +seconds(30) # 30_000 ms +minutes(5) # 300_000 ms +hours(2) # 7_200_000 ms +days(7) # 604_800_000 ms +``` + +### Orchestration + +```elixir +use Durable.Orchestration + +# Synchronous: call child and wait for result +case call_workflow(MyApp.PaymentWorkflow, %{"amount" => 100}, timeout: hours(1)) do + {:ok, result} -> {:ok, assign(data, :payment, result)} + {:error, reason} -> {:error, reason} +end + +# Fire-and-forget: start child and continue +{:ok, child_id} = start_workflow(MyApp.EmailWorkflow, %{"to" => email}, ref: :welcome) + +# call_workflow also works inside parallel blocks (executed inline) +parallel do + step :payment, fn data -> + case call_workflow(MyApp.PaymentWorkflow, %{"amount" => data.total}, ref: :pay) do + {:ok, result} -> {:ok, assign(data, :payment, result)} + {:error, reason} -> {:error, reason} + end + end + + step :shipping, fn data -> + case call_workflow(MyApp.ShippingWorkflow, %{"id" => data.order_id}, ref: :ship) do + {:ok, result} -> {:ok, assign(data, :shipping, result)} + {:error, reason} -> {:error, reason} + end + end +end +``` + +### API + +```elixir +Durable.start(Module, input) +Durable.start(Module, input, queue: :priority, scheduled_at: datetime) +Durable.get_execution(id) +Durable.list_executions(workflow: Module, status: :running) +Durable.cancel(id, "reason") +Durable.send_event(id, "event", payload) +Durable.provide_input(id, "input_name", data) +Durable.list_children(parent_id) +``` + +## Mix Tasks + +Durable includes mix tasks for managing workflows from the command line. + +```bash +# Show queue status and workflow summary +mix durable.status + +# List workflow executions (with filters) +mix durable.list # all executions +mix durable.list --status running # filter by status +mix durable.list --workflow MyApp.OrderWorkflow # filter by workflow +mix durable.list --limit 20 --format json # limit results, JSON output + +# Start a workflow +mix durable.run MyApp.OrderWorkflow # no input +mix durable.run MyApp.OrderWorkflow --input '{"id": 123}' # with JSON input +mix durable.run MyApp.OrderWorkflow --queue high_priority # specific queue + +# Cancel a workflow +mix durable.cancel +mix durable.cancel --reason "no longer needed" + +# Clean up old executions +mix durable.cleanup --older-than 30d # completed/failed older than 30 days +mix durable.cleanup --older-than 7d --status completed # only completed, older than 7 days +mix durable.cleanup --older-than 24h --dry-run # preview what would be deleted +``` + +## Guides + +- [Branching](guides/branching.md) - Conditional flow control +- [Parallel](guides/parallel.md) - Concurrent execution +- [Compensations](guides/compensations.md) - Saga pattern +- [Waiting](guides/waiting.md) - Sleep, events, human input +- [Orchestration](guides/orchestration.md) - Parent/child workflow composition + +## Coming Soon + +- Phoenix LiveView dashboard + +## License + +MIT diff --git a/config/config.exs b/durable/config/config.exs similarity index 100% rename from config/config.exs rename to durable/config/config.exs diff --git a/config/dev.exs b/durable/config/dev.exs similarity index 100% rename from config/dev.exs rename to durable/config/dev.exs diff --git a/config/prod.exs b/durable/config/prod.exs similarity index 100% rename from config/prod.exs rename to durable/config/prod.exs diff --git a/config/runtime.exs b/durable/config/runtime.exs similarity index 100% rename from config/runtime.exs rename to durable/config/runtime.exs diff --git a/config/test.exs b/durable/config/test.exs similarity index 100% rename from config/test.exs rename to durable/config/test.exs diff --git a/guides/ai_workflows.md b/durable/guides/ai_workflows.md similarity index 100% rename from guides/ai_workflows.md rename to durable/guides/ai_workflows.md diff --git a/guides/branching.md b/durable/guides/branching.md similarity index 100% rename from guides/branching.md rename to durable/guides/branching.md diff --git a/guides/compensations.md b/durable/guides/compensations.md similarity index 100% rename from guides/compensations.md rename to durable/guides/compensations.md diff --git a/guides/orchestration.md b/durable/guides/orchestration.md similarity index 100% rename from guides/orchestration.md rename to durable/guides/orchestration.md diff --git a/guides/parallel.md b/durable/guides/parallel.md similarity index 100% rename from guides/parallel.md rename to durable/guides/parallel.md diff --git a/guides/waiting.md b/durable/guides/waiting.md similarity index 100% rename from guides/waiting.md rename to durable/guides/waiting.md diff --git a/lib/durable.ex b/durable/lib/durable.ex similarity index 100% rename from lib/durable.ex rename to durable/lib/durable.ex diff --git a/lib/durable/application.ex b/durable/lib/durable/application.ex similarity index 100% rename from lib/durable/application.ex rename to durable/lib/durable/application.ex diff --git a/lib/durable/config.ex b/durable/lib/durable/config.ex similarity index 100% rename from lib/durable/config.ex rename to durable/lib/durable/config.ex diff --git a/lib/durable/context.ex b/durable/lib/durable/context.ex similarity index 100% rename from lib/durable/context.ex rename to durable/lib/durable/context.ex diff --git a/lib/durable/definition.ex b/durable/lib/durable/definition.ex similarity index 100% rename from lib/durable/definition.ex rename to durable/lib/durable/definition.ex diff --git a/lib/durable/dsl/step.ex b/durable/lib/durable/dsl/step.ex similarity index 100% rename from lib/durable/dsl/step.ex rename to durable/lib/durable/dsl/step.ex diff --git a/lib/durable/dsl/time_helpers.ex b/durable/lib/durable/dsl/time_helpers.ex similarity index 100% rename from lib/durable/dsl/time_helpers.ex rename to durable/lib/durable/dsl/time_helpers.ex diff --git a/lib/durable/dsl/workflow.ex b/durable/lib/durable/dsl/workflow.ex similarity index 100% rename from lib/durable/dsl/workflow.ex rename to durable/lib/durable/dsl/workflow.ex diff --git a/lib/durable/executor.ex b/durable/lib/durable/executor.ex similarity index 100% rename from lib/durable/executor.ex rename to durable/lib/durable/executor.ex diff --git a/lib/durable/executor/backoff.ex b/durable/lib/durable/executor/backoff.ex similarity index 100% rename from lib/durable/executor/backoff.ex rename to durable/lib/durable/executor/backoff.ex diff --git a/lib/durable/executor/compensation_runner.ex b/durable/lib/durable/executor/compensation_runner.ex similarity index 100% rename from lib/durable/executor/compensation_runner.ex rename to durable/lib/durable/executor/compensation_runner.ex diff --git a/lib/durable/executor/step_runner.ex b/durable/lib/durable/executor/step_runner.ex similarity index 100% rename from lib/durable/executor/step_runner.ex rename to durable/lib/durable/executor/step_runner.ex diff --git a/lib/durable/helpers.ex b/durable/lib/durable/helpers.ex similarity index 100% rename from lib/durable/helpers.ex rename to durable/lib/durable/helpers.ex diff --git a/lib/durable/log_capture.ex b/durable/lib/durable/log_capture.ex similarity index 100% rename from lib/durable/log_capture.ex rename to durable/lib/durable/log_capture.ex diff --git a/lib/durable/log_capture/handler.ex b/durable/lib/durable/log_capture/handler.ex similarity index 100% rename from lib/durable/log_capture/handler.ex rename to durable/lib/durable/log_capture/handler.ex diff --git a/lib/durable/log_capture/io_server.ex b/durable/lib/durable/log_capture/io_server.ex similarity index 100% rename from lib/durable/log_capture/io_server.ex rename to durable/lib/durable/log_capture/io_server.ex diff --git a/lib/durable/migration.ex b/durable/lib/durable/migration.ex similarity index 100% rename from lib/durable/migration.ex rename to durable/lib/durable/migration.ex diff --git a/lib/durable/migration/base.ex b/durable/lib/durable/migration/base.ex similarity index 100% rename from lib/durable/migration/base.ex rename to durable/lib/durable/migration/base.ex diff --git a/lib/durable/migration/migrations/v20260103000000_initial_schema.ex b/durable/lib/durable/migration/migrations/v20260103000000_initial_schema.ex similarity index 100% rename from lib/durable/migration/migrations/v20260103000000_initial_schema.ex rename to durable/lib/durable/migration/migrations/v20260103000000_initial_schema.ex diff --git a/lib/durable/migration/migrations/v20260104000000_add_wait_primitives.ex b/durable/lib/durable/migration/migrations/v20260104000000_add_wait_primitives.ex similarity index 100% rename from lib/durable/migration/migrations/v20260104000000_add_wait_primitives.ex rename to durable/lib/durable/migration/migrations/v20260104000000_add_wait_primitives.ex diff --git a/lib/durable/migration/migrations/v20260413000000_add_scheduler_resilience.ex b/durable/lib/durable/migration/migrations/v20260413000000_add_scheduler_resilience.ex similarity index 100% rename from lib/durable/migration/migrations/v20260413000000_add_scheduler_resilience.ex rename to durable/lib/durable/migration/migrations/v20260413000000_add_scheduler_resilience.ex diff --git a/lib/durable/migration/migrator.ex b/durable/lib/durable/migration/migrator.ex similarity index 100% rename from lib/durable/migration/migrator.ex rename to durable/lib/durable/migration/migrator.ex diff --git a/lib/durable/migration/schema_migration.ex b/durable/lib/durable/migration/schema_migration.ex similarity index 100% rename from lib/durable/migration/schema_migration.ex rename to durable/lib/durable/migration/schema_migration.ex diff --git a/lib/durable/orchestration.ex b/durable/lib/durable/orchestration.ex similarity index 100% rename from lib/durable/orchestration.ex rename to durable/lib/durable/orchestration.ex diff --git a/lib/durable/pubsub.ex b/durable/lib/durable/pubsub.ex similarity index 100% rename from lib/durable/pubsub.ex rename to durable/lib/durable/pubsub.ex diff --git a/lib/durable/query.ex b/durable/lib/durable/query.ex similarity index 100% rename from lib/durable/query.ex rename to durable/lib/durable/query.ex diff --git a/lib/durable/queue/adapter.ex b/durable/lib/durable/queue/adapter.ex similarity index 100% rename from lib/durable/queue/adapter.ex rename to durable/lib/durable/queue/adapter.ex diff --git a/lib/durable/queue/adapters/postgres.ex b/durable/lib/durable/queue/adapters/postgres.ex similarity index 100% rename from lib/durable/queue/adapters/postgres.ex rename to durable/lib/durable/queue/adapters/postgres.ex diff --git a/lib/durable/queue/manager.ex b/durable/lib/durable/queue/manager.ex similarity index 100% rename from lib/durable/queue/manager.ex rename to durable/lib/durable/queue/manager.ex diff --git a/lib/durable/queue/poller.ex b/durable/lib/durable/queue/poller.ex similarity index 100% rename from lib/durable/queue/poller.ex rename to durable/lib/durable/queue/poller.ex diff --git a/lib/durable/queue/stale_job_recovery.ex b/durable/lib/durable/queue/stale_job_recovery.ex similarity index 100% rename from lib/durable/queue/stale_job_recovery.ex rename to durable/lib/durable/queue/stale_job_recovery.ex diff --git a/lib/durable/queue/worker.ex b/durable/lib/durable/queue/worker.ex similarity index 100% rename from lib/durable/queue/worker.ex rename to durable/lib/durable/queue/worker.ex diff --git a/lib/durable/repo.ex b/durable/lib/durable/repo.ex similarity index 100% rename from lib/durable/repo.ex rename to durable/lib/durable/repo.ex diff --git a/lib/durable/scheduler/api.ex b/durable/lib/durable/scheduler/api.ex similarity index 100% rename from lib/durable/scheduler/api.ex rename to durable/lib/durable/scheduler/api.ex diff --git a/lib/durable/scheduler/dsl.ex b/durable/lib/durable/scheduler/dsl.ex similarity index 100% rename from lib/durable/scheduler/dsl.ex rename to durable/lib/durable/scheduler/dsl.ex diff --git a/lib/durable/scheduler/scheduler.ex b/durable/lib/durable/scheduler/scheduler.ex similarity index 100% rename from lib/durable/scheduler/scheduler.ex rename to durable/lib/durable/scheduler/scheduler.ex diff --git a/lib/durable/storage/schemas/pending_event.ex b/durable/lib/durable/storage/schemas/pending_event.ex similarity index 100% rename from lib/durable/storage/schemas/pending_event.ex rename to durable/lib/durable/storage/schemas/pending_event.ex diff --git a/lib/durable/storage/schemas/pending_input.ex b/durable/lib/durable/storage/schemas/pending_input.ex similarity index 100% rename from lib/durable/storage/schemas/pending_input.ex rename to durable/lib/durable/storage/schemas/pending_input.ex diff --git a/lib/durable/storage/schemas/scheduled_workflow.ex b/durable/lib/durable/storage/schemas/scheduled_workflow.ex similarity index 100% rename from lib/durable/storage/schemas/scheduled_workflow.ex rename to durable/lib/durable/storage/schemas/scheduled_workflow.ex diff --git a/lib/durable/storage/schemas/step_execution.ex b/durable/lib/durable/storage/schemas/step_execution.ex similarity index 100% rename from lib/durable/storage/schemas/step_execution.ex rename to durable/lib/durable/storage/schemas/step_execution.ex diff --git a/lib/durable/storage/schemas/wait_group.ex b/durable/lib/durable/storage/schemas/wait_group.ex similarity index 100% rename from lib/durable/storage/schemas/wait_group.ex rename to durable/lib/durable/storage/schemas/wait_group.ex diff --git a/lib/durable/storage/schemas/workflow_execution.ex b/durable/lib/durable/storage/schemas/workflow_execution.ex similarity index 100% rename from lib/durable/storage/schemas/workflow_execution.ex rename to durable/lib/durable/storage/schemas/workflow_execution.ex diff --git a/lib/durable/supervisor.ex b/durable/lib/durable/supervisor.ex similarity index 100% rename from lib/durable/supervisor.ex rename to durable/lib/durable/supervisor.ex diff --git a/lib/durable/wait.ex b/durable/lib/durable/wait.ex similarity index 100% rename from lib/durable/wait.ex rename to durable/lib/durable/wait.ex diff --git a/lib/durable/wait/timeout_worker.ex b/durable/lib/durable/wait/timeout_worker.ex similarity index 100% rename from lib/durable/wait/timeout_worker.ex rename to durable/lib/durable/wait/timeout_worker.ex diff --git a/lib/mix/helpers.ex b/durable/lib/mix/helpers.ex similarity index 100% rename from lib/mix/helpers.ex rename to durable/lib/mix/helpers.ex diff --git a/lib/mix/tasks/durable.cancel.ex b/durable/lib/mix/tasks/durable.cancel.ex similarity index 100% rename from lib/mix/tasks/durable.cancel.ex rename to durable/lib/mix/tasks/durable.cancel.ex diff --git a/lib/mix/tasks/durable.cleanup.ex b/durable/lib/mix/tasks/durable.cleanup.ex similarity index 100% rename from lib/mix/tasks/durable.cleanup.ex rename to durable/lib/mix/tasks/durable.cleanup.ex diff --git a/lib/mix/tasks/durable.doctor.ex b/durable/lib/mix/tasks/durable.doctor.ex similarity index 100% rename from lib/mix/tasks/durable.doctor.ex rename to durable/lib/mix/tasks/durable.doctor.ex diff --git a/lib/mix/tasks/durable.gen.migration.ex b/durable/lib/mix/tasks/durable.gen.migration.ex similarity index 100% rename from lib/mix/tasks/durable.gen.migration.ex rename to durable/lib/mix/tasks/durable.gen.migration.ex diff --git a/lib/mix/tasks/durable.gen.upgrade.ex b/durable/lib/mix/tasks/durable.gen.upgrade.ex similarity index 100% rename from lib/mix/tasks/durable.gen.upgrade.ex rename to durable/lib/mix/tasks/durable.gen.upgrade.ex diff --git a/lib/mix/tasks/durable.inspect.ex b/durable/lib/mix/tasks/durable.inspect.ex similarity index 100% rename from lib/mix/tasks/durable.inspect.ex rename to durable/lib/mix/tasks/durable.inspect.ex diff --git a/lib/mix/tasks/durable.install.ex b/durable/lib/mix/tasks/durable.install.ex similarity index 100% rename from lib/mix/tasks/durable.install.ex rename to durable/lib/mix/tasks/durable.install.ex diff --git a/lib/mix/tasks/durable.list.ex b/durable/lib/mix/tasks/durable.list.ex similarity index 100% rename from lib/mix/tasks/durable.list.ex rename to durable/lib/mix/tasks/durable.list.ex diff --git a/lib/mix/tasks/durable.migrations.ex b/durable/lib/mix/tasks/durable.migrations.ex similarity index 100% rename from lib/mix/tasks/durable.migrations.ex rename to durable/lib/mix/tasks/durable.migrations.ex diff --git a/lib/mix/tasks/durable.pending.ex b/durable/lib/mix/tasks/durable.pending.ex similarity index 100% rename from lib/mix/tasks/durable.pending.ex rename to durable/lib/mix/tasks/durable.pending.ex diff --git a/lib/mix/tasks/durable.provide_input.ex b/durable/lib/mix/tasks/durable.provide_input.ex similarity index 100% rename from lib/mix/tasks/durable.provide_input.ex rename to durable/lib/mix/tasks/durable.provide_input.ex diff --git a/lib/mix/tasks/durable.retry.ex b/durable/lib/mix/tasks/durable.retry.ex similarity index 100% rename from lib/mix/tasks/durable.retry.ex rename to durable/lib/mix/tasks/durable.retry.ex diff --git a/lib/mix/tasks/durable.run.ex b/durable/lib/mix/tasks/durable.run.ex similarity index 100% rename from lib/mix/tasks/durable.run.ex rename to durable/lib/mix/tasks/durable.run.ex diff --git a/lib/mix/tasks/durable.send_event.ex b/durable/lib/mix/tasks/durable.send_event.ex similarity index 100% rename from lib/mix/tasks/durable.send_event.ex rename to durable/lib/mix/tasks/durable.send_event.ex diff --git a/lib/mix/tasks/durable.status.ex b/durable/lib/mix/tasks/durable.status.ex similarity index 100% rename from lib/mix/tasks/durable.status.ex rename to durable/lib/mix/tasks/durable.status.ex diff --git a/durable/mix.exs b/durable/mix.exs new file mode 100644 index 0000000..dd482b2 --- /dev/null +++ b/durable/mix.exs @@ -0,0 +1,106 @@ +defmodule Durable.MixProject do + use Mix.Project + + @version "0.0.0-alpha" + @source_url "https://github.com/wavezync/durable" + @homepage_url "https://durable.wavezync.com" + + def project do + [ + app: :durable, + version: @version, + elixir: "~> 1.15", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps(), + name: "Durable", + homepage_url: @homepage_url, + description: "A durable, resumable workflow engine for Elixir", + source_url: @source_url, + docs: docs(), + package: package() + ] + end + + def cli do + [ + preferred_envs: [precommit: :test] + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Durable.Application, []} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + # Core + {:ecto_sql, "~> 3.12"}, + {:postgrex, "~> 0.19"}, + {:jason, "~> 1.4"}, + {:telemetry, "~> 1.3"}, + {:nimble_options, "~> 1.1"}, + {:crontab, "~> 1.1"}, + {:igniter, "~> 0.6", optional: true}, + {:phoenix_pubsub, "~> 2.1", optional: true}, + + # Dev/Test + {:ex_doc, "~> 0.34", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + setup: ["deps.get", "ecto.setup"], + "ecto.setup": ["ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], + precommit: ["format", "compile --warnings-as-errors", "credo --strict", "test"] + ] + end + + defp docs do + [ + main: "readme", + source_url: @source_url, + source_ref: "v#{@version}", + extras: [ + "README.md", + "guides/ai_workflows.md", + "guides/branching.md", + "guides/compensations.md", + "guides/orchestration.md", + "guides/parallel.md", + "guides/waiting.md" + ], + groups_for_modules: [ + "Mix Tasks": [ + Mix.Tasks.Durable.Migrations, + Mix.Tasks.Durable.Gen.Upgrade, + Mix.Tasks.Durable.Status, + Mix.Tasks.Durable.List, + Mix.Tasks.Durable.Run, + Mix.Tasks.Durable.Cancel, + Mix.Tasks.Durable.Cleanup + ] + ] + ] + end + + defp package do + [ + licenses: ["MIT"], + links: %{"GitHub" => @source_url}, + files: ~w(lib priv .formatter.exs mix.exs README.md LICENSE) + ] + end +end diff --git a/mix.lock b/durable/mix.lock similarity index 100% rename from mix.lock rename to durable/mix.lock diff --git a/priv/repo/migrations/20241229000001_create_workflow_executions.exs b/durable/priv/repo/migrations/20241229000001_create_workflow_executions.exs similarity index 100% rename from priv/repo/migrations/20241229000001_create_workflow_executions.exs rename to durable/priv/repo/migrations/20241229000001_create_workflow_executions.exs diff --git a/priv/repo/migrations/20241229000002_create_step_executions.exs b/durable/priv/repo/migrations/20241229000002_create_step_executions.exs similarity index 100% rename from priv/repo/migrations/20241229000002_create_step_executions.exs rename to durable/priv/repo/migrations/20241229000002_create_step_executions.exs diff --git a/priv/repo/migrations/20241229000003_create_pending_inputs.exs b/durable/priv/repo/migrations/20241229000003_create_pending_inputs.exs similarity index 100% rename from priv/repo/migrations/20241229000003_create_pending_inputs.exs rename to durable/priv/repo/migrations/20241229000003_create_pending_inputs.exs diff --git a/priv/repo/migrations/20241229000004_create_scheduled_workflows.exs b/durable/priv/repo/migrations/20241229000004_create_scheduled_workflows.exs similarity index 100% rename from priv/repo/migrations/20241229000004_create_scheduled_workflows.exs rename to durable/priv/repo/migrations/20241229000004_create_scheduled_workflows.exs diff --git a/priv/test_repo/migrations/20241229000001_create_durable_tables.exs b/durable/priv/test_repo/migrations/20241229000001_create_durable_tables.exs similarity index 100% rename from priv/test_repo/migrations/20241229000001_create_durable_tables.exs rename to durable/priv/test_repo/migrations/20241229000001_create_durable_tables.exs diff --git a/test/durable/branch_test.exs b/durable/test/durable/branch_test.exs similarity index 100% rename from test/durable/branch_test.exs rename to durable/test/durable/branch_test.exs diff --git a/test/durable/compensation_test.exs b/durable/test/durable/compensation_test.exs similarity index 100% rename from test/durable/compensation_test.exs rename to durable/test/durable/compensation_test.exs diff --git a/test/durable/context_test.exs b/durable/test/durable/context_test.exs similarity index 100% rename from test/durable/context_test.exs rename to durable/test/durable/context_test.exs diff --git a/test/durable/decision_test.exs b/durable/test/durable/decision_test.exs similarity index 100% rename from test/durable/decision_test.exs rename to durable/test/durable/decision_test.exs diff --git a/test/durable/executor/crash_modes_test.exs b/durable/test/durable/executor/crash_modes_test.exs similarity index 100% rename from test/durable/executor/crash_modes_test.exs rename to durable/test/durable/executor/crash_modes_test.exs diff --git a/test/durable/executor/error_sanitization_test.exs b/durable/test/durable/executor/error_sanitization_test.exs similarity index 100% rename from test/durable/executor/error_sanitization_test.exs rename to durable/test/durable/executor/error_sanitization_test.exs diff --git a/test/durable/integration_test.exs b/durable/test/durable/integration_test.exs similarity index 100% rename from test/durable/integration_test.exs rename to durable/test/durable/integration_test.exs diff --git a/test/durable/log_capture/handler_test.exs b/durable/test/durable/log_capture/handler_test.exs similarity index 100% rename from test/durable/log_capture/handler_test.exs rename to durable/test/durable/log_capture/handler_test.exs diff --git a/test/durable/log_capture/integration_test.exs b/durable/test/durable/log_capture/integration_test.exs similarity index 100% rename from test/durable/log_capture/integration_test.exs rename to durable/test/durable/log_capture/integration_test.exs diff --git a/test/durable/log_capture/io_server_test.exs b/durable/test/durable/log_capture/io_server_test.exs similarity index 100% rename from test/durable/log_capture/io_server_test.exs rename to durable/test/durable/log_capture/io_server_test.exs diff --git a/test/durable/log_capture_test.exs b/durable/test/durable/log_capture_test.exs similarity index 100% rename from test/durable/log_capture_test.exs rename to durable/test/durable/log_capture_test.exs diff --git a/test/durable/migration_test.exs b/durable/test/durable/migration_test.exs similarity index 100% rename from test/durable/migration_test.exs rename to durable/test/durable/migration_test.exs diff --git a/test/durable/orchestration_test.exs b/durable/test/durable/orchestration_test.exs similarity index 100% rename from test/durable/orchestration_test.exs rename to durable/test/durable/orchestration_test.exs diff --git a/test/durable/parallel_test.exs b/durable/test/durable/parallel_test.exs similarity index 100% rename from test/durable/parallel_test.exs rename to durable/test/durable/parallel_test.exs diff --git a/test/durable/pubsub_test.exs b/durable/test/durable/pubsub_test.exs similarity index 100% rename from test/durable/pubsub_test.exs rename to durable/test/durable/pubsub_test.exs diff --git a/test/durable/queue/adapters/postgres_test.exs b/durable/test/durable/queue/adapters/postgres_test.exs similarity index 100% rename from test/durable/queue/adapters/postgres_test.exs rename to durable/test/durable/queue/adapters/postgres_test.exs diff --git a/test/durable/queue/stale_job_recovery_integration_test.exs b/durable/test/durable/queue/stale_job_recovery_integration_test.exs similarity index 100% rename from test/durable/queue/stale_job_recovery_integration_test.exs rename to durable/test/durable/queue/stale_job_recovery_integration_test.exs diff --git a/test/durable/queue/worker_test.exs b/durable/test/durable/queue/worker_test.exs similarity index 100% rename from test/durable/queue/worker_test.exs rename to durable/test/durable/queue/worker_test.exs diff --git a/test/durable/resume_edge_cases_test.exs b/durable/test/durable/resume_edge_cases_test.exs similarity index 100% rename from test/durable/resume_edge_cases_test.exs rename to durable/test/durable/resume_edge_cases_test.exs diff --git a/test/durable/retry_context_test.exs b/durable/test/durable/retry_context_test.exs similarity index 100% rename from test/durable/retry_context_test.exs rename to durable/test/durable/retry_context_test.exs diff --git a/test/durable/sanitization_boundaries_test.exs b/durable/test/durable/sanitization_boundaries_test.exs similarity index 100% rename from test/durable/sanitization_boundaries_test.exs rename to durable/test/durable/sanitization_boundaries_test.exs diff --git a/test/durable/scheduler_resilience_test.exs b/durable/test/durable/scheduler_resilience_test.exs similarity index 100% rename from test/durable/scheduler_resilience_test.exs rename to durable/test/durable/scheduler_resilience_test.exs diff --git a/test/durable/scheduler_test.exs b/durable/test/durable/scheduler_test.exs similarity index 100% rename from test/durable/scheduler_test.exs rename to durable/test/durable/scheduler_test.exs diff --git a/test/durable/validation_test.exs b/durable/test/durable/validation_test.exs similarity index 100% rename from test/durable/validation_test.exs rename to durable/test/durable/validation_test.exs diff --git a/test/durable/wait/timeout_worker_integration_test.exs b/durable/test/durable/wait/timeout_worker_integration_test.exs similarity index 100% rename from test/durable/wait/timeout_worker_integration_test.exs rename to durable/test/durable/wait/timeout_worker_integration_test.exs diff --git a/test/durable/wait_test.exs b/durable/test/durable/wait_test.exs similarity index 100% rename from test/durable/wait_test.exs rename to durable/test/durable/wait_test.exs diff --git a/test/durable_test.exs b/durable/test/durable_test.exs similarity index 100% rename from test/durable_test.exs rename to durable/test/durable_test.exs diff --git a/test/mix/tasks/durable_cancel_test.exs b/durable/test/mix/tasks/durable_cancel_test.exs similarity index 100% rename from test/mix/tasks/durable_cancel_test.exs rename to durable/test/mix/tasks/durable_cancel_test.exs diff --git a/test/mix/tasks/durable_cleanup_test.exs b/durable/test/mix/tasks/durable_cleanup_test.exs similarity index 100% rename from test/mix/tasks/durable_cleanup_test.exs rename to durable/test/mix/tasks/durable_cleanup_test.exs diff --git a/test/mix/tasks/durable_gen_upgrade_test.exs b/durable/test/mix/tasks/durable_gen_upgrade_test.exs similarity index 100% rename from test/mix/tasks/durable_gen_upgrade_test.exs rename to durable/test/mix/tasks/durable_gen_upgrade_test.exs diff --git a/test/mix/tasks/durable_list_test.exs b/durable/test/mix/tasks/durable_list_test.exs similarity index 100% rename from test/mix/tasks/durable_list_test.exs rename to durable/test/mix/tasks/durable_list_test.exs diff --git a/test/mix/tasks/durable_migrations_test.exs b/durable/test/mix/tasks/durable_migrations_test.exs similarity index 100% rename from test/mix/tasks/durable_migrations_test.exs rename to durable/test/mix/tasks/durable_migrations_test.exs diff --git a/test/mix/tasks/durable_run_test.exs b/durable/test/mix/tasks/durable_run_test.exs similarity index 100% rename from test/mix/tasks/durable_run_test.exs rename to durable/test/mix/tasks/durable_run_test.exs diff --git a/test/mix/tasks/durable_status_test.exs b/durable/test/mix/tasks/durable_status_test.exs similarity index 100% rename from test/mix/tasks/durable_status_test.exs rename to durable/test/mix/tasks/durable_status_test.exs diff --git a/test/support/data_case.ex b/durable/test/support/data_case.ex similarity index 100% rename from test/support/data_case.ex rename to durable/test/support/data_case.ex diff --git a/test/support/telemetry_handler.ex b/durable/test/support/telemetry_handler.ex similarity index 100% rename from test/support/telemetry_handler.ex rename to durable/test/support/telemetry_handler.ex diff --git a/test/support/test_repo.ex b/durable/test/support/test_repo.ex similarity index 100% rename from test/support/test_repo.ex rename to durable/test/support/test_repo.ex diff --git a/test/support/test_workflows.ex b/durable/test/support/test_workflows.ex similarity index 100% rename from test/support/test_workflows.ex rename to durable/test/support/test_workflows.ex diff --git a/test/support/workflows/sink_workflow.ex b/durable/test/support/workflows/sink_workflow.ex similarity index 100% rename from test/support/workflows/sink_workflow.ex rename to durable/test/support/workflows/sink_workflow.ex diff --git a/test/test_helper.exs b/durable/test/test_helper.exs similarity index 100% rename from test/test_helper.exs rename to durable/test/test_helper.exs diff --git a/durable_dashboard/mix.exs b/durable_dashboard/mix.exs index 0063765..0c80205 100644 --- a/durable_dashboard/mix.exs +++ b/durable_dashboard/mix.exs @@ -30,7 +30,7 @@ defmodule DurableDashboard.MixProject do defp deps do [ - {:durable, path: ".."}, + {:durable, path: "../durable"}, {:phoenix_live_view, "~> 1.1"}, {:phoenix, "~> 1.8"}, {:jason, "~> 1.4"}, diff --git a/examples/phoenix_demo/mix.exs b/examples/phoenix_demo/mix.exs index d32d51f..421b920 100644 --- a/examples/phoenix_demo/mix.exs +++ b/examples/phoenix_demo/mix.exs @@ -63,7 +63,7 @@ defmodule PhoenixDemo.MixProject do {:dns_cluster, "~> 0.2.0"}, {:bandit, "~> 1.5"}, # Durable workflow engine - {:durable, path: "../.."}, + {:durable, path: "../../durable"}, {:durable_dashboard, path: "../../durable_dashboard"} ] end diff --git a/mix.exs b/mix.exs index dd482b2..ca72dbe 100644 --- a/mix.exs +++ b/mix.exs @@ -1,106 +1,38 @@ -defmodule Durable.MixProject do +defmodule DurableWorkspace.MixProject do use Mix.Project - @version "0.0.0-alpha" - @source_url "https://github.com/wavezync/durable" - @homepage_url "https://durable.wavezync.com" + @apps ~w(durable durable_dashboard) def project do [ - app: :durable, - version: @version, + app: :durable_workspace, + version: "0.0.0", elixir: "~> 1.15", - elixirc_paths: elixirc_paths(Mix.env()), - start_permanent: Mix.env() == :prod, - aliases: aliases(), - deps: deps(), - name: "Durable", - homepage_url: @homepage_url, - description: "A durable, resumable workflow engine for Elixir", - source_url: @source_url, - docs: docs(), - package: package() - ] - end - - def cli do - [ - preferred_envs: [precommit: :test] - ] - end - - def application do - [ - extra_applications: [:logger], - mod: {Durable.Application, []} - ] - end - - defp elixirc_paths(:test), do: ["lib", "test/support"] - defp elixirc_paths(_), do: ["lib"] - - defp deps do - [ - # Core - {:ecto_sql, "~> 3.12"}, - {:postgrex, "~> 0.19"}, - {:jason, "~> 1.4"}, - {:telemetry, "~> 1.3"}, - {:nimble_options, "~> 1.1"}, - {:crontab, "~> 1.1"}, - {:igniter, "~> 0.6", optional: true}, - {:phoenix_pubsub, "~> 2.1", optional: true}, - - # Dev/Test - {:ex_doc, "~> 0.34", only: :dev, runtime: false}, - {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} - ] - end - - defp aliases do - [ - setup: ["deps.get", "ecto.setup"], - "ecto.setup": ["ecto.create", "ecto.migrate"], - "ecto.reset": ["ecto.drop", "ecto.setup"], - test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], - precommit: ["format", "compile --warnings-as-errors", "credo --strict", "test"] - ] - end - - defp docs do - [ - main: "readme", - source_url: @source_url, - source_ref: "v#{@version}", - extras: [ - "README.md", - "guides/ai_workflows.md", - "guides/branching.md", - "guides/compensations.md", - "guides/orchestration.md", - "guides/parallel.md", - "guides/waiting.md" + deps: [ + {:durable, path: "durable"}, + {:durable_dashboard, path: "durable_dashboard"} ], - groups_for_modules: [ - "Mix Tasks": [ - Mix.Tasks.Durable.Migrations, - Mix.Tasks.Durable.Gen.Upgrade, - Mix.Tasks.Durable.Status, - Mix.Tasks.Durable.List, - Mix.Tasks.Durable.Run, - Mix.Tasks.Durable.Cancel, - Mix.Tasks.Durable.Cleanup - ] + aliases: [ + setup: cmd("deps.get"), + compile: cmd("compile"), + test: cmd("test"), + format: cmd("format"), + precommit: cmd("precommit") ] ] end - defp package do - [ - licenses: ["MIT"], - links: %{"GitHub" => @source_url}, - files: ~w(lib priv .formatter.exs mix.exs README.md LICENSE) - ] + defp cmd(command) do + for app <- @apps do + fn args -> + {_, code} = + System.cmd("mix", [command | args], + into: IO.binstream(:stdio, :line), + cd: app + ) + + if code > 0, do: System.at_exit(fn _ -> exit({:shutdown, 1}) end) + end + end end end From 211ea9b4dc37aac7ad1af2fa9a5453bc27b638e4 Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Sat, 2 May 2026 13:30:40 +0530 Subject: [PATCH 12/13] ci(dashboard): mix format pre-existing files The new dashboard CI job runs `mix format --check-formatted`, which caught two committed files that were never run through the formatter. --- .../components/workflow/flow_graph.ex | 4 +- .../components/flow_graph_test.exs | 41 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/durable_dashboard/lib/durable_dashboard/components/workflow/flow_graph.ex b/durable_dashboard/lib/durable_dashboard/components/workflow/flow_graph.ex index 33471e8..563a92e 100644 --- a/durable_dashboard/lib/durable_dashboard/components/workflow/flow_graph.ex +++ b/durable_dashboard/lib/durable_dashboard/components/workflow/flow_graph.ex @@ -614,7 +614,9 @@ defmodule DurableDashboard.Components.Workflow.FlowGraph do defp error_field(_, _), do: "" defp format_duration(ms) when is_integer(ms) and ms < 1000, do: "#{ms}ms" - defp format_duration(ms) when is_integer(ms) and ms < 60_000, do: "#{Float.round(ms / 1000, 2)}s" + + defp format_duration(ms) when is_integer(ms) and ms < 60_000, + do: "#{Float.round(ms / 1000, 2)}s" defp format_duration(ms) when is_integer(ms), do: "#{div(ms, 60_000)}m #{div(rem(ms, 60_000), 1000)}s" diff --git a/durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs b/durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs index 7a03ee3..f07aa09 100644 --- a/durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs +++ b/durable_dashboard/test/durable_dashboard/components/flow_graph_test.exs @@ -87,7 +87,8 @@ defmodule DurableDashboard.Components.FlowGraphTest do escaped_edges = Enum.filter(edges, fn e -> - String.starts_with?(e.source, "parallel_100__") and not String.contains?(e.target, "parallel_100") + String.starts_with?(e.source, "parallel_100__") and + not String.contains?(e.target, "parallel_100") end) assert escaped_edges == [], @@ -349,17 +350,42 @@ defmodule DurableDashboard.Components.FlowGraphTest do module: __MODULE__, steps: [ %Definition.Step{name: :register_employee, type: :step, module: __MODULE__, opts: %{}}, - %Definition.Step{name: :collect_equipment_preferences, type: :step, module: __MODULE__, opts: %{}}, + %Definition.Step{ + name: :collect_equipment_preferences, + type: :step, + module: __MODULE__, + opts: %{} + }, %Definition.Step{ name: :provision_parallel, type: :parallel, module: __MODULE__, opts: %{steps: children, all_steps: children} }, - %Definition.Step{name: :setup_email, type: :step, module: __MODULE__, opts: %{parallel_id: 1}}, - %Definition.Step{name: :setup_dev_tools, type: :step, module: __MODULE__, opts: %{parallel_id: 1}}, - %Definition.Step{name: :order_equipment, type: :step, module: __MODULE__, opts: %{parallel_id: 1}}, - %Definition.Step{name: :create_payroll_record, type: :step, module: __MODULE__, opts: %{parallel_id: 1}}, + %Definition.Step{ + name: :setup_email, + type: :step, + module: __MODULE__, + opts: %{parallel_id: 1} + }, + %Definition.Step{ + name: :setup_dev_tools, + type: :step, + module: __MODULE__, + opts: %{parallel_id: 1} + }, + %Definition.Step{ + name: :order_equipment, + type: :step, + module: __MODULE__, + opts: %{parallel_id: 1} + }, + %Definition.Step{ + name: :create_payroll_record, + type: :step, + module: __MODULE__, + opts: %{parallel_id: 1} + }, %Definition.Step{name: :manager_review, type: :step, module: __MODULE__, opts: %{}}, %Definition.Step{name: :schedule_orientation, type: :step, module: __MODULE__, opts: %{}}, %Definition.Step{name: :send_welcome_package, type: :step, module: __MODULE__, opts: %{}} @@ -373,7 +399,8 @@ defmodule DurableDashboard.Components.FlowGraphTest do defp step_execution(opts) do %{ - id: Keyword.get(opts, :id, "exec-" <> Integer.to_string(:erlang.unique_integer([:positive]))), + id: + Keyword.get(opts, :id, "exec-" <> Integer.to_string(:erlang.unique_integer([:positive]))), step_name: Keyword.fetch!(opts, :name), status: Keyword.fetch!(opts, :status), attempt: Keyword.get(opts, :attempt, 1), From f78c6659f84e6e40a7f335ca682f6de079474f8d Mon Sep 17 00:00:00 2001 From: Kasun Vithanage Date: Sat, 2 May 2026 13:34:03 +0530 Subject: [PATCH 13/13] ci(dashboard): replace removed Plug.Conn.put_secret_key_base/2 Plug 1.19 dropped this helper. Use put_in/2 on the conn struct, which is what cookie_session does internally. Caught by the new dashboard mix compile --warnings-as-errors step. --- durable_dashboard/test/support/test_endpoint.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/durable_dashboard/test/support/test_endpoint.ex b/durable_dashboard/test/support/test_endpoint.ex index bc67885..6ebf3e1 100644 --- a/durable_dashboard/test/support/test_endpoint.ex +++ b/durable_dashboard/test/support/test_endpoint.ex @@ -17,6 +17,6 @@ defmodule DurableDashboard.TestEndpoint do plug :put_secret_key_base defp put_secret_key_base(conn, _) do - Plug.Conn.put_secret_key_base(conn, String.duplicate("a", 64)) + put_in(conn.secret_key_base, String.duplicate("a", 64)) end end