feat(workflows): M6 — dashboard run-history + DAG view

A per-app "Workflows" tab: list definitions, browse run history, and inspect a
run's per-step progress with a static layered DAG.

- API: the run-detail endpoint now returns each step's `depends_on` (loaded
  from the definition via a new `get_workflow_by_id` reader) so the dashboard
  can draw the graph edges — the run's step rows don't carry them.
- dashboard `$lib/api`: a `workflows` namespace (list / runs / start / run) +
  the matching types.
- `apps/[slug]/workflows/+page.svelte`: workflow table → drill into runs →
  drill into a run. The run view renders a step table plus an SVG DAG (nodes =
  steps colored by status, laid out by longest-path level; edges = depends_on,
  arrowed) and a "Start run" button. Nested/parked steps show their child run.
- AppTabBar + the per-app layout gain the Workflows tab (admin-only).

Tests: `npm run check` clean (0 errors); two Playwright smoke tests
(navigation tabs — workflows loads cleanly + renders its empty state) pass
against the full stack.

With M6 the v1.2 Workflows track (M1 schema/validation → M2 durable
orchestrator → M3 when/templating → M4 nested → M5 SDK/API/CLI → M6 dashboard)
is COMPLETE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-12 17:57:56 +02:00
parent 5a630e1d9f
commit bd9c3fa4f0
7 changed files with 576 additions and 5 deletions

View File

@@ -33,6 +33,12 @@
{ id: 'users', label: 'Users', href: `${base}/apps/${slug}/users`, adminOnly: true },
{ id: 'files', label: 'Files', href: `${base}/apps/${slug}/files`, adminOnly: true },
{ id: 'queues', label: 'Queues', href: `${base}/apps/${slug}/queues`, adminOnly: true },
{
id: 'workflows',
label: 'Workflows',
href: `${base}/apps/${slug}/workflows`,
adminOnly: true
},
{
id: 'dead-letters',
label: 'Dead letters',

View File

@@ -444,6 +444,57 @@ export interface CreateQueueTriggerInput {
retry_base_ms?: number;
}
// v1.2 Workflows — read/run types (admin workflows API).
export interface WorkflowSummary {
name: string;
enabled: boolean;
steps: number;
}
export type WorkflowRunStatus =
| 'pending'
| 'running'
| 'succeeded'
| 'failed'
| 'canceled';
export interface WorkflowRun {
id: string;
workflow_id?: string;
status: WorkflowRunStatus;
error?: string | null;
workflow_depth: number;
created_at: string;
input?: unknown;
output?: unknown;
started_at?: string | null;
finished_at?: string | null;
}
export type WorkflowStepStatus =
| 'pending'
| 'ready'
| 'running'
| 'succeeded'
| 'failed'
| 'skipped';
export interface WorkflowRunStep {
name: string;
status: WorkflowStepStatus;
attempt: number;
max_attempts: number;
error?: string | null;
child_run_id?: string | null;
depends_on: string[];
output?: unknown;
}
export interface WorkflowRunDetail {
run: WorkflowRun;
steps: WorkflowRunStep[];
}
// v1.1.9 — read-only queue inspection types (admin queues API).
export interface QueueSummary {
queue_name: string;
@@ -1214,6 +1265,26 @@ export const api = {
)
},
workflows: {
list: (idOrSlug: string) =>
adminRequest<WorkflowSummary[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows`
),
runs: (idOrSlug: string, name: string) =>
adminRequest<WorkflowRun[]>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`
),
start: (idOrSlug: string, name: string, input: unknown) =>
adminRequest<WorkflowRun>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`,
{ method: 'POST', body: JSON.stringify({ input }) }
),
run: (idOrSlug: string, runId: string) =>
adminRequest<WorkflowRunDetail>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflow-runs/${encodeURIComponent(runId)}`
)
},
topics: {
list: (idOrSlug: string) =>
adminRequest<{ topics: Topic[] }>(

View File

@@ -70,6 +70,8 @@
return 'files';
case 'queues':
return 'queues';
case 'workflows':
return 'workflows';
case 'dead-letters':
return 'dead-letters';
default:

View File

@@ -0,0 +1,433 @@
<script lang="ts">
import { page } from '$app/state';
import { getContext } from 'svelte';
import {
api,
ApiError,
type WorkflowSummary,
type WorkflowRun,
type WorkflowRunDetail,
type WorkflowRunStep
} from '$lib/api';
import { APP_CTX_KEY, type AppContext } from '../+layout.svelte';
let slug = $derived(page.params.slug ?? '');
const ctx = getContext<AppContext>(APP_CTX_KEY);
const appStore = ctx?.app;
let appName = $derived($appStore?.name ?? slug);
let workflows = $state<WorkflowSummary[]>([]);
let selectedWf = $state<string | null>(null);
let runs = $state<WorkflowRun[]>([]);
let detail = $state<WorkflowRunDetail | null>(null);
let error = $state<string | null>(null);
let loading = $state(false);
async function loadWorkflows() {
loading = true;
error = null;
try {
workflows = await api.workflows.list(slug);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
} finally {
loading = false;
}
}
async function selectWorkflow(name: string) {
selectedWf = name;
detail = null;
runs = [];
error = null;
try {
runs = await api.workflows.runs(slug, name);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function selectRun(runId: string) {
error = null;
try {
detail = await api.workflows.run(slug, runId);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function startRun() {
if (!selectedWf) return;
error = null;
try {
const run = await api.workflows.start(slug, selectedWf, null);
await selectWorkflow(selectedWf);
await selectRun(run.id);
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
$effect(() => {
void slug;
void loadWorkflows();
});
// --- static depth-1 DAG layout: layered by longest-path level ---------
const COL_W = 170;
const ROW_H = 64;
const NODE_W = 140;
const NODE_H = 42;
let layout = $derived(computeLayout(detail?.steps ?? []));
function computeLayout(steps: WorkflowRunStep[]) {
const byName = new Map(steps.map((s) => [s.name, s]));
const level = new Map<string, number>();
function lvl(name: string, seen: Set<string> = new Set()): number {
const cached = level.get(name);
if (cached !== undefined) return cached;
if (seen.has(name)) return 0; // cycle guard (validation forbids these)
seen.add(name);
const deps = byName.get(name)?.depends_on ?? [];
const v = deps.length === 0 ? 0 : Math.max(...deps.map((d) => lvl(d, seen) + 1));
level.set(name, v);
return v;
}
for (const s of steps) lvl(s.name);
const cols = new Map<number, WorkflowRunStep[]>();
for (const s of steps) {
const l = level.get(s.name) ?? 0;
(cols.get(l) ?? cols.set(l, []).get(l)!).push(s);
}
const pos = new Map<string, { x: number; y: number }>();
const nodes: { step: WorkflowRunStep; x: number; y: number }[] = [];
for (const [l, arr] of [...cols.entries()].sort((a, b) => a[0] - b[0])) {
arr.forEach((s, i) => {
const x = l * COL_W + 10;
const y = i * ROW_H + 10;
nodes.push({ step: s, x, y });
pos.set(s.name, { x, y });
});
}
const edges: { x1: number; y1: number; x2: number; y2: number }[] = [];
for (const s of steps) {
for (const d of s.depends_on) {
const from = pos.get(d);
const to = pos.get(s.name);
if (from && to) {
edges.push({
x1: from.x + NODE_W,
y1: from.y + NODE_H / 2,
x2: to.x,
y2: to.y + NODE_H / 2
});
}
}
}
const maxLevel = Math.max(0, ...[...cols.keys()]);
const maxRows = Math.max(1, ...[...cols.values()].map((a) => a.length));
return {
nodes,
edges,
width: (maxLevel + 1) * COL_W + 20,
height: maxRows * ROW_H + 20
};
}
function short(id: string) {
return id.slice(0, 8);
}
</script>
<svelte:head>
<title>Workflows — {appName} — PiCloud</title>
</svelte:head>
<header class="panel-head">
<h2>Workflows</h2>
<p class="subtle">
Durable DAG workflows (v1.2). Definitions are authored declaratively
(<code>[[workflows]]</code> in <code>picloud.toml</code>); start runs and
inspect their per-step progress here.
</p>
<div class="toolbar">
<button type="button" class="secondary" onclick={() => loadWorkflows()} disabled={loading}>
{loading ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</header>
{#if error}
<p class="error">{error}</p>
{/if}
{#if loading && workflows.length === 0}
<p class="muted">Loading…</p>
{:else if workflows.length === 0}
<p class="muted" data-testid="workflows-empty-state">
No workflows in this app yet. Author one with a <code>[[workflows]]</code>
block in your manifest and <code>pic apply</code>.
</p>
{:else}
<table class="grid" data-testid="workflows-table">
<thead>
<tr><th>Workflow</th><th class="num">Steps</th><th>Enabled</th><th></th></tr>
</thead>
<tbody>
{#each workflows as w (w.name)}
<tr class:selected={selectedWf === w.name}>
<td><code>{w.name}</code></td>
<td class="num">{w.steps}</td>
<td>{w.enabled ? 'yes' : 'no'}</td>
<td>
<button type="button" class="link" onclick={() => selectWorkflow(w.name)}>
runs →
</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if selectedWf}
<section class="runs-panel">
<div class="runs-head">
<h3>Runs — <code>{selectedWf}</code></h3>
<button type="button" class="secondary" onclick={() => startRun()}>Start run</button>
</div>
{#if runs.length === 0}
<p class="muted">No runs yet.</p>
{:else}
<table class="grid" data-testid="runs-table">
<thead>
<tr><th>Run</th><th>Status</th><th class="num">Depth</th><th>Created</th><th></th></tr>
</thead>
<tbody>
{#each runs as r (r.id)}
<tr class:selected={detail?.run.id === r.id}>
<td><code>{short(r.id)}</code></td>
<td><span class="badge st-{r.status}">{r.status}</span></td>
<td class="num">{r.workflow_depth}</td>
<td class="muted">{r.created_at}</td>
<td>
<button type="button" class="link" onclick={() => selectRun(r.id)}>detail →</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
{/if}
{#if detail}
<section class="detail-panel" data-testid="run-detail">
<h3>
Run <code>{short(detail.run.id)}</code>
<span class="badge st-{detail.run.status}">{detail.run.status}</span>
</h3>
{#if detail.run.error}
<p class="error">{detail.run.error}</p>
{/if}
<h4>DAG</h4>
<div class="dag-wrap">
<svg width={layout.width} height={layout.height} role="img" aria-label="workflow DAG">
{#each layout.edges as e}
<line
x1={e.x1}
y1={e.y1}
x2={e.x2}
y2={e.y2}
stroke="var(--border)"
stroke-width="1.5"
marker-end="url(#arrow)"
/>
{/each}
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
<path d="M0,0 L7,3 L0,6 Z" fill="var(--text-muted)" />
</marker>
</defs>
{#each layout.nodes as n (n.step.name)}
<g transform="translate({n.x},{n.y})">
<rect
width={NODE_W}
height={NODE_H}
rx="6"
class="node st-{n.step.status}"
/>
<text x={NODE_W / 2} y={NODE_H / 2 + 4} text-anchor="middle" class="node-label">
{n.step.name}
</text>
</g>
{/each}
</svg>
</div>
<h4>Steps</h4>
<table class="grid" data-testid="steps-table">
<thead>
<tr><th>Step</th><th>Status</th><th class="num">Attempt</th><th>Depends on</th><th>Error</th></tr>
</thead>
<tbody>
{#each detail.steps as s (s.name)}
<tr>
<td><code>{s.name}</code></td>
<td><span class="badge st-{s.status}">{s.status}</span></td>
<td class="num">{s.attempt}/{s.max_attempts}</td>
<td class="muted">{s.depends_on.join(', ')}</td>
<td class="err-cell">{s.error ?? ''}</td>
</tr>
{/each}
</tbody>
</table>
</section>
{/if}
<style>
.panel-head {
margin-bottom: 1rem;
}
h2 {
margin: 0.25rem 0;
font-size: 1.125rem;
}
h3 {
font-size: 1rem;
margin: 0;
}
h4 {
font-size: 0.85rem;
color: var(--text-muted);
margin: 1rem 0 0.4rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.subtle {
color: var(--text-muted);
font-size: 0.875rem;
}
.toolbar {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: 0.75rem;
}
button.secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
padding: 0.4rem 0.85rem;
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.85rem;
}
button.secondary:hover:not(:disabled) {
background: var(--bg-elevated);
color: var(--text-primary);
}
button.link {
background: none;
border: none;
color: var(--color-accent, #4f8cff);
cursor: pointer;
padding: 0;
font-size: 0.85rem;
}
.error {
color: var(--color-danger);
}
.muted {
color: var(--text-muted);
}
table.grid {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
table.grid th,
table.grid td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.selected {
background: var(--bg-elevated);
}
.err-cell {
color: var(--color-danger);
font-size: 0.8rem;
max-width: 24rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runs-panel,
.detail-panel {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.runs-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.dag-wrap {
overflow: auto;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.5rem;
background: var(--bg-elevated);
}
.node {
fill: var(--bg-surface, #1b1e26);
stroke: var(--border);
stroke-width: 1.5;
}
.node-label {
fill: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono, monospace);
}
/* Status colors, shared by badges + DAG node strokes. */
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
border: 1px solid var(--border);
}
.st-succeeded {
color: #2ecc71;
stroke: #2ecc71;
}
.st-failed {
color: #e74c3c;
stroke: #e74c3c;
}
.st-running {
color: #4f8cff;
stroke: #4f8cff;
}
.st-ready {
color: #f0ad4e;
stroke: #f0ad4e;
}
.st-skipped {
color: var(--text-muted);
stroke: var(--text-muted);
}
.st-pending,
.st-canceled {
color: var(--text-muted);
}
</style>