feat: add workflow view and edit actions

This commit is contained in:
Brad Groux 2026-07-25 04:03:42 -05:00
parent 880914b97b
commit 8dc6bf32c9
16 changed files with 1952 additions and 227 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added a first-class workflow definition browser with deep-linked view, edit, and duplicate routes. Definition detail now exposes agents, ordered steps, phases, inputs, acceptance criteria, gates, loops, parallel branches, outputs, and provenance before execution. Server-owned access evidence distinguishes user-owned, shared, and built-in workflows; read-only definitions explain the restriction and offer duplication when permitted. User-owned edits save through Author with version-bound conflict protection that preserves the draft on failure. Starting a run is now a separate configuration step for optional task association and JSON context (#940).
- Enforced active phase authority across run tool discovery, mediated
invocation, approvals, completion evidence, REST, CLI, and the task run
timeline. MCP `readOnlyHint` annotations now classify external reads while

View file

@ -64,21 +64,13 @@ curl http://localhost:3001/api/workflows
"id": "feature-dev",
"name": "Feature Development Workflow",
"version": 2,
"description": "End-to-end feature development pipeline",
"agentCount": 4,
"stepCount": 7,
"createdAt": "2026-02-09T12:00:00Z",
"updatedAt": "2026-02-09T14:30:00Z"
"description": "End-to-end feature development pipeline"
},
{
"id": "security-audit",
"name": "Security Audit & Remediation",
"version": 1,
"description": "Scan, prioritize, and fix security issues",
"agentCount": 3,
"stepCount": 5,
"createdAt": "2026-02-09T10:00:00Z",
"updatedAt": "2026-02-09T10:00:00Z"
"description": "Scan, prioritize, and fix security issues"
}
]
```
@ -173,6 +165,49 @@ X-Resource-Revision: 2
---
### GET /api/workflows/:id/access
Resolve workflow-specific actions and provenance for the current identity. Clients use this server-owned result to distinguish editable user workflows from built-in or shared read-only definitions.
**Request**:
```bash
curl http://localhost:3001/api/workflows/feature-dev/access
```
**Response**:
```json
{
"workflowId": "feature-dev",
"canView": true,
"canEdit": false,
"canExecute": true,
"canDuplicate": true,
"readOnlyReason": "Built-in workflows are read-only. Duplicate this workflow to customize it.",
"provenance": {
"kind": "built-in",
"owner": "system",
"createdBy": "system",
"updatedBy": "system",
"createdAt": "2026-02-09T12:00:00Z",
"updatedAt": "2026-02-09T14:30:00Z"
}
}
```
`provenance.kind` is `built-in`, `user-owned`, or `shared`. `canEdit` and `canExecute` combine the authenticated request permissions with the workflow ACL decision. `canDuplicate` reflects whether the authenticated request may create workflows. A read-only response includes an actionable reason suitable for the workflow browser.
**Status Codes**:
- `200 OK` — Access and provenance resolved
- `403 Forbidden` — No view permission
- `404 Not Found` — Workflow not found
**Permissions**: Requires `workflow:read` and workflow-level `view` permission.
---
### POST /api/workflows
Create a new workflow.

View file

@ -142,8 +142,20 @@ curl -X POST http://localhost:3001/api/workflows/hello-world/runs \
1. Open Veritas Kanban in your browser
2. Navigate to **Workflows** tab (header navigation)
3. Click on "Hello World Workflow"
4. You'll see your active run with real-time step progress
3. Select the workflow name or **View details** to inspect its definition before execution
4. Select **Start Run**, review the optional task association and JSON run context, then confirm the run
5. Open **View workflow runs** to see real-time step progress
### Browse, Edit, and Duplicate Workflows
The workflow browser separates inspection, authoring, and execution:
- A workflow name or **View details** opens a deep-linkable read-only definition at `/workflows/:id`. The definition shows provenance, variables, agents, ordered steps, phases, gate conditions, loop controls, parallel branches, step inputs, acceptance criteria, and outputs.
- User-owned workflows with edit permission expose **Edit** and save through the Author builder at `/workflows/:id/edit`. The workflow ID remains fixed and the loaded version is sent with the update, so a stale save fails with a conflict instead of overwriting a newer definition. The draft remains in the editor after validation, permission, or conflict errors.
- Built-in and shared read-only workflows explain why they cannot be edited. Identities with workflow write permission can choose **Duplicate to customize**, select a new ID and name in Author, and save an independent workflow.
- **Start Run** always opens a separate configuration dialog. An optional task ID associates the run with an existing task, while the JSON object supplies initial workflow context.
Browser Back returns from edit or duplicate to the source definition and from the definition to the workflow browser. Direct links use the same safe fallback instead of leaving the application.
---

View file

@ -48,6 +48,7 @@ function workflow(overrides: Partial<WorkflowDefinition> = {}): WorkflowDefiniti
describe('workflow authoring routes', () => {
let app: express.Express;
let testRoot: string;
let requestPermissions: string[];
let disposeWorkflowService: (() => void) | undefined;
beforeEach(async () => {
@ -56,6 +57,7 @@ describe('workflow authoring routes', () => {
process.env.VERITAS_DATA_DIR = testRoot;
process.env.DATA_DIR = testRoot;
process.env.VERITAS_DISABLE_WATCHERS = '1';
requestPermissions = ['*'];
const [{ workflowRoutes }, workflowService, { errorHandler }] = await Promise.all([
import('../../routes/workflows.js'),
@ -75,7 +77,7 @@ describe('workflow authoring routes', () => {
workspaceId: 'local',
actorType: 'user',
authMethod: 'session',
permissions: ['*'],
permissions: requestPermissions,
};
next();
});
@ -122,6 +124,68 @@ describe('workflow authoring routes', () => {
expect(materialized.body.lint.ok).toBe(true);
});
it('reports server-owned editability and provenance for owned and built-in workflows', async () => {
const owned = workflow({
id: 'owned-workflow',
name: 'Owned workflow',
createdBy: 'user:workflow-authoring-user',
});
const created = await request(app).post('/api/workflows').send(owned);
expect(created.status).toBe(201);
const ownedAccess = await request(app).get('/api/workflows/owned-workflow/access');
expect(ownedAccess.status).toBe(200);
expect(ownedAccess.body).toMatchObject({
workflowId: 'owned-workflow',
canView: true,
canEdit: true,
canExecute: true,
canDuplicate: true,
provenance: {
kind: 'user-owned',
owner: 'workflow-authoring-user',
},
});
expect(ownedAccess.body.readOnlyReason).toBeUndefined();
const { getWorkflowService } = await import('../../services/workflow-service.js');
await getWorkflowService().saveWorkflow(
workflow({
id: 'built-in-workflow',
name: 'Built-in workflow',
createdBy: 'system',
})
);
const builtInAccess = await request(app).get('/api/workflows/built-in-workflow/access');
expect(builtInAccess.status).toBe(200);
expect(builtInAccess.body).toMatchObject({
workflowId: 'built-in-workflow',
canView: true,
canEdit: false,
canExecute: true,
canDuplicate: true,
readOnlyReason: 'Built-in workflows are read-only. Duplicate this workflow to customize it.',
provenance: {
kind: 'built-in',
owner: 'system',
createdBy: 'system',
},
});
requestPermissions = ['workflow:read'];
const readOnlyAccess = await request(app).get('/api/workflows/owned-workflow/access');
expect(readOnlyAccess.status).toBe(200);
expect(readOnlyAccess.body).toMatchObject({
workflowId: 'owned-workflow',
canView: true,
canEdit: false,
canExecute: false,
canDuplicate: false,
readOnlyReason: 'Workflow write permission is required to edit or duplicate this workflow.',
});
});
it('materializes the OpenClaw audit recipe with an orchestrated subagent pipeline', async () => {
const materialized = await request(app)
.post('/api/workflows/recipes/openclaw-audit/materialize')

View file

@ -5,7 +5,7 @@
import { Router } from 'express';
import { z } from 'zod';
import type { WorkflowDefinition, WorkflowACL } from '../types/workflow.js';
import type { WorkflowDefinition, WorkflowACL, WorkflowAccess } from '@veritas-kanban/shared';
import { getWorkflowService } from '../services/workflow-service.js';
import { getWorkflowRunService } from '../services/workflow-run-service.js';
import { getWorkflowAuthoringService } from '../services/workflow-authoring-service.js';
@ -39,6 +39,11 @@ function getRequestPermissions(req: AuthenticatedRequest): string[] | undefined
return req.auth?.permissions;
}
function requestHasPermission(req: AuthenticatedRequest, permission: string): boolean {
const permissions = getRequestPermissions(req);
return permissions?.includes('*') === true || permissions?.includes(permission) === true;
}
// Validation schemas
const startRunSchema = z.object({
taskId: z.string().optional(),
@ -244,6 +249,59 @@ router.get(
})
);
/**
* GET /api/workflows/:id/access Resolve workflow-specific actions and provenance
*/
router.get(
'/:id/access',
asyncHandler(async (req: AuthenticatedRequest, res) => {
const workflowId = getStringParam(req.params.id);
const userId = getUserId(req);
const workflow = await workflowService.loadWorkflow(workflowId);
if (!workflow) {
throw new NotFoundError(`Workflow ${workflowId} not found`);
}
await assertWorkflowPermission(workflowId, userId, 'view');
const acl = await workflowService.loadACL(workflowId);
const [aclCanEdit, aclCanExecute] = await Promise.all([
checkWorkflowPermission(workflowId, userId, 'edit'),
checkWorkflowPermission(workflowId, userId, 'execute'),
]);
const canWriteWorkflows = requestHasPermission(req, 'workflow:write');
const canExecuteWorkflows = requestHasPermission(req, 'workflow:execute');
const canEdit = canWriteWorkflows && aclCanEdit;
const canExecute = canExecuteWorkflows && aclCanExecute;
const builtIn = !acl || acl.owner === 'system';
const owned = acl?.owner === userId;
const access: WorkflowAccess = {
workflowId,
canView: true,
canEdit,
canExecute,
canDuplicate: canWriteWorkflows,
readOnlyReason: canEdit
? undefined
: !canWriteWorkflows
? 'Workflow write permission is required to edit or duplicate this workflow.'
: builtIn
? 'Built-in workflows are read-only. Duplicate this workflow to customize it.'
: 'You can view this workflow, but its owner has not granted edit access.',
provenance: {
kind: builtIn ? 'built-in' : owned ? 'user-owned' : 'shared',
owner: acl?.owner ?? 'system',
createdBy: workflow.createdBy,
updatedBy: workflow.updatedBy,
createdAt: workflow.createdAt,
updatedAt: workflow.updatedAt,
},
};
res.json(access);
})
);
/**
* GET /api/workflows/:id Get a specific workflow
*/

View file

@ -24,6 +24,25 @@ export interface WorkflowDefinition {
updatedAt?: string;
}
export type WorkflowProvenanceKind = 'built-in' | 'user-owned' | 'shared';
export interface WorkflowAccess {
workflowId: string;
canView: boolean;
canEdit: boolean;
canExecute: boolean;
canDuplicate: boolean;
readOnlyReason?: string;
provenance: {
kind: WorkflowProvenanceKind;
owner: string | null;
createdBy?: string;
updatedBy?: string;
createdAt?: string;
updatedAt?: string;
};
}
export interface WorkflowConfig {
timeout?: number; // seconds
fresh_session_default?: boolean;

View file

@ -96,6 +96,14 @@ describe('ViewContext route history', () => {
expect(window.location.pathname).toBe('/');
});
it('keeps deep workflow view and edit links inside the Workflows view', () => {
window.history.replaceState({}, '', '/workflows/release-blueprint/edit');
renderNavigationHarness();
expect(screen.getByTestId('current-view').textContent).toBe('workflows');
expect(window.location.pathname).toBe('/workflows/release-blueprint/edit');
});
it('maps Cmd+[ to in-app browser Back semantics', async () => {
const user = userEvent.setup();
renderNavigationHarness();

View file

@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
import { WorkflowsPage } from '@/components/workflows/WorkflowsPage';
import { renderWithProviders } from './test-utils';
const mocks = vi.hoisted(() => ({
hasPermission: vi.fn(),
toast: vi.fn(),
}));
vi.mock('@/hooks/useIdentity', () => ({
useIdentity: () => ({
hasPermission: mocks.hasPermission,
}),
}));
vi.mock('@/hooks/useToast', () => ({
useToast: () => ({ toast: mocks.toast }),
}));
vi.mock('@/hooks/useSandboxPolicies', () => ({
useSandboxPolicies: () => ({ data: [] }),
}));
function response(data: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => (status >= 400 ? data : { data }),
} as Response;
}
function workflow(overrides: Partial<WorkflowDefinition> = {}): WorkflowDefinition {
return {
id: 'wf-blueprint',
name: 'Release blueprint',
version: 4,
description: 'Build, verify, and approve a release.',
variables: {
releaseChannel: 'stable',
requireApproval: true,
},
outputTargets: [
{ type: 'work-product', label: 'Release report', path: 'work-products/release.md' },
],
agents: [
{
id: 'builder',
name: 'Builder',
role: 'developer',
provider: 'codex-cli',
description: 'Builds the release.',
tools: ['Read', 'Edit', 'exec'],
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'reviewer',
description: 'Reviews the release evidence.',
},
],
steps: [
{
id: 'build',
name: 'Build release',
type: 'agent',
phase: 'implement',
agent: 'builder',
input: 'Build the {{releaseChannel}} release.',
acceptance_criteria: ['The release artifact exists.'],
output: { file: 'release.md' },
},
{
id: 'matrix',
name: 'Verify targets',
type: 'parallel',
phase: 'verify',
parallel: {
completion: 'all',
fail_fast: true,
steps: [
{ id: 'desktop', agent: 'reviewer', input: 'Verify desktop.' },
{ id: 'server', agent: 'reviewer', input: 'Verify server.' },
],
},
},
{
id: 'approval',
name: 'Release approval',
type: 'gate',
phase: 'publish',
condition: 'verification.passed == true',
on_false: { escalate_to: 'human' },
},
],
createdBy: 'system',
updatedBy: 'system',
createdAt: '2026-07-01T12:00:00.000Z',
updatedAt: '2026-07-20T12:00:00.000Z',
...overrides,
};
}
function access(overrides: Record<string, unknown> = {}) {
return {
workflowId: 'wf-blueprint',
canView: true,
canEdit: false,
canExecute: true,
canDuplicate: true,
readOnlyReason: 'Built-in workflows are read-only. Duplicate this workflow to customize it.',
provenance: {
kind: 'built-in',
owner: 'system',
createdBy: 'system',
updatedBy: 'system',
createdAt: '2026-07-01T12:00:00.000Z',
updatedAt: '2026-07-20T12:00:00.000Z',
},
...overrides,
};
}
describe('workflow browser view and edit actions', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.hasPermission.mockReturnValue(true);
});
afterEach(() => {
cleanup();
window.history.replaceState({}, '', '/');
vi.restoreAllMocks();
});
it('renders a deep-linked read-only blueprint and returns a direct link to the browser', async () => {
window.history.replaceState({}, '', '/workflows/wf-blueprint');
const user = userEvent.setup();
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith('/workflows')) {
return response([
{
id: 'wf-blueprint',
name: 'Release blueprint',
version: 4,
description: 'Build, verify, and approve a release.',
},
]);
}
if (url.endsWith('/workflows/wf-blueprint/access')) return response(access());
if (url.endsWith('/workflows/wf-blueprint')) return response(workflow());
return response({ error: 'Not found' }, 404);
}) as typeof fetch;
renderWithProviders(<WorkflowsPage onBack={vi.fn()} />);
expect(await screen.findByRole('heading', { name: 'Release blueprint' })).toBeDefined();
expect(screen.getByText('Execution blueprint')).toBeDefined();
expect(screen.getByText('Built-in')).toBeDefined();
expect(screen.getByText('Build the {{releaseChannel}} release.')).toBeDefined();
expect(screen.getByText('The release artifact exists.')).toBeDefined();
expect(screen.getByText('Parallel branches')).toBeDefined();
expect(screen.getByText('verification.passed == true')).toBeDefined();
expect(screen.getByRole('button', { name: 'Duplicate to customize' })).toBeDefined();
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
await user.click(screen.getByRole('button', { name: 'Back to workflows' }));
expect(window.location.pathname).toBe('/workflows');
expect(await screen.findByPlaceholderText('Search workflows...')).toBeDefined();
});
it('keeps an editable draft after validation fails and saves with its loaded version', async () => {
window.history.replaceState({}, '', '/workflows');
const user = userEvent.setup();
let currentName = 'Owned workflow';
let updateAttempts = 0;
const ownedWorkflow = () =>
workflow({
id: 'wf-owned',
name: currentName,
version: 7,
createdBy: 'user:owner',
updatedBy: 'user:owner',
});
const ownedAccess = access({
workflowId: 'wf-owned',
canEdit: true,
readOnlyReason: undefined,
provenance: {
kind: 'user-owned',
owner: 'owner',
createdBy: 'user:owner',
updatedBy: 'user:owner',
},
});
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/workflows') && !init?.method) {
return response([
{
id: 'wf-owned',
name: currentName,
version: 7,
description: 'Build, verify, and approve a release.',
},
]);
}
if (url.endsWith('/workflows/wf-owned/access') && !init?.method) {
return response(ownedAccess);
}
if (url.endsWith('/workflows/wf-owned') && !init?.method) {
return response(ownedWorkflow());
}
if (url.endsWith('/workflows/authoring/yaml') && init?.method === 'POST') {
return response({ yaml: 'id: wf-owned\nname: Owned workflow\n' });
}
if (url.endsWith('/workflows/wf-owned') && init?.method === 'PUT') {
updateAttempts += 1;
const submitted = JSON.parse(String(init.body)) as WorkflowDefinition;
if (submitted.name.endsWith('!')) {
return response(
{
error: 'Workflow name cannot end with an exclamation point.',
},
400
);
}
currentName = submitted.name;
return response({ success: true, version: 8 });
}
return response({ error: 'Not found' }, 404);
});
globalThis.fetch = fetchMock as typeof fetch;
renderWithProviders(<WorkflowsPage onBack={vi.fn()} />);
await user.click(await screen.findByRole('button', { name: 'View Owned workflow' }));
expect(window.location.pathname).toBe('/workflows/wf-owned');
await user.click(await screen.findByRole('button', { name: 'Edit' }));
expect(window.location.pathname).toBe('/workflows/wf-owned/edit');
await screen.findByRole('heading', { name: 'Workflow Definition' });
const nameInput = screen
.getAllByLabelText('Name')
.find((input) => (input as HTMLInputElement).value === 'Owned workflow');
expect(nameInput).toBeDefined();
if (!nameInput) throw new Error('Workflow name input was not rendered');
await user.clear(nameInput);
await user.type(nameInput, 'Owned workflow revised!');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => expect(updateAttempts).toBe(1));
expect((nameInput as HTMLInputElement).value).toBe('Owned workflow revised!');
expect(mocks.toast).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Workflow update failed',
description: expect.stringContaining('Workflow name cannot end with an exclamation point'),
})
);
await user.clear(nameInput);
await user.type(nameInput, 'Owned workflow revised');
expect((nameInput as HTMLInputElement).value).toBe('Owned workflow revised');
await user.click(screen.getByRole('button', { name: 'Save changes' }));
await waitFor(() => {
expect(window.location.pathname).toBe('/workflows/wf-owned');
expect(currentName).toBe('Owned workflow revised');
});
const putCall = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/workflows/wf-owned') && init?.method === 'PUT'
);
expect(putCall?.[1]?.headers).toMatchObject({
'X-Resource-Revision': '7',
});
expect(await screen.findByRole('heading', { name: 'Owned workflow revised' })).toBeDefined();
await user.click(screen.getByRole('button', { name: 'Back to workflows' }));
expect(window.location.pathname).toBe('/workflows');
expect(await screen.findByPlaceholderText('Search workflows...')).toBeDefined();
});
it('shows an actionable missing-definition state', async () => {
window.history.replaceState({}, '', '/workflows/missing-workflow');
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith('/workflows')) return response([]);
return response({ error: 'Workflow missing-workflow not found' }, 404);
}) as typeof fetch;
renderWithProviders(<WorkflowsPage onBack={vi.fn()} />);
expect(await screen.findByText('Workflow unavailable')).toBeDefined();
expect(screen.getByText('Workflow missing-workflow not found')).toBeDefined();
expect(screen.getByRole('button', { name: 'Try again' })).toBeDefined();
expect(screen.getByRole('button', { name: 'Back to workflows' })).toBeDefined();
});
});

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, cleanup, screen, waitFor } from '@testing-library/react';
import { act, cleanup, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WorkflowsPage } from '@/components/workflows/WorkflowsPage';
@ -179,6 +179,24 @@ describe('workflow surfaces Mantine migration', () => {
if (url.endsWith('/workflows/recipes') && !init?.method) {
return jsonResponse([]);
}
if (url.endsWith('/workflows/wf-release') && !init?.method) {
return jsonResponse({
id: 'wf-release',
name: 'Release workflow',
version: 3,
description: 'Build and smoke the release',
variables: { releaseChannel: 'stable' },
agents: [
{
id: 'codex',
name: 'Codex',
role: 'builder',
description: 'Builds the release.',
},
],
steps: [{ id: 'build', name: 'Build', type: 'agent', agent: 'codex' }],
});
}
if (url.endsWith('/workflows/wf-release/runs') && init?.method === 'POST') {
return jsonResponse({ id: 'run-1' });
}
@ -199,9 +217,20 @@ describe('workflow surfaces Mantine migration', () => {
await user.click(screen.getByRole('button', { name: 'Start Run' }));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByLabelText('Task ID')).toBeDefined();
expect((within(dialog).getByLabelText('Run context') as HTMLTextAreaElement).value).toContain(
'releaseChannel'
);
await user.type(within(dialog).getByLabelText('Task ID'), 'task_release');
await user.click(within(dialog).getByRole('button', { name: 'Start Run' }));
expect(fetchMock).toHaveBeenCalledWith(
'/api/workflows/wf-release/runs',
expect.objectContaining({ method: 'POST' })
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"taskId":"task_release"'),
})
);
expect(await screen.findByText('Workflow Runs')).toBeDefined();
});

View file

@ -57,7 +57,9 @@ import { useSandboxPolicies } from '@/hooks/useSandboxPolicies';
interface WorkflowAuthoringPanelProps {
canSaveWorkflow: boolean;
onWorkflowCreated: () => void;
onWorkflowCreated: (workflowId: string) => void;
initialWorkflow?: WorkflowDefinition;
saveMode?: 'create' | 'edit';
}
type BuilderContext = {
@ -178,6 +180,8 @@ function upsertTarget(
export function WorkflowAuthoringPanel({
canSaveWorkflow,
onWorkflowCreated,
initialWorkflow,
saveMode = 'create',
}: WorkflowAuthoringPanelProps) {
const { toast } = useToast();
const [recipes, setRecipes] = useState<WorkflowRecipe[]>([]);
@ -187,8 +191,8 @@ export function WorkflowAuthoringPanel({
const [materialized, setMaterialized] = useState<WorkflowRecipeMaterialization | null>(null);
const [recipeBusy, setRecipeBusy] = useState(false);
const [builderWorkflow, setBuilderWorkflow] = useState<WorkflowDefinition>(() =>
defaultWorkflow()
const [builderWorkflow, setBuilderWorkflow] = useState<WorkflowDefinition>(
() => initialWorkflow ?? defaultWorkflow()
);
const [builderContext, setBuilderContext] = useState<BuilderContext>({
taskId: '',
@ -199,6 +203,7 @@ export function WorkflowAuthoringPanel({
const [yamlDryRun, setYamlDryRun] = useState<WorkflowDryRunResult | null>(null);
const [builderBusy, setBuilderBusy] = useState(false);
const [yamlBusy, setYamlBusy] = useState(false);
const [saveBusy, setSaveBusy] = useState(false);
const selectedRecipe = useMemo(
() => recipes.find((recipe) => recipe.id === selectedRecipeId) ?? null,
@ -206,6 +211,10 @@ export function WorkflowAuthoringPanel({
);
useEffect(() => {
if (saveMode === 'edit') {
setRecipesLoading(false);
return;
}
let cancelled = false;
workflowsApi
.recipes()
@ -226,7 +235,7 @@ export function WorkflowAuthoringPanel({
return () => {
cancelled = true;
};
}, [toast]);
}, [saveMode, toast]);
useEffect(() => {
if (!selectedRecipe) return;
@ -238,6 +247,29 @@ export function WorkflowAuthoringPanel({
setMaterialized(null);
}, [selectedRecipe]);
useEffect(() => {
if (!initialWorkflow) return;
let cancelled = false;
setBuilderWorkflow(initialWorkflow);
setBuilderDryRun(null);
setYamlDryRun(null);
workflowsApi
.renderYaml(initialWorkflow)
.then((result) => {
if (!cancelled) setYamlDraft(result.yaml);
})
.catch((error: unknown) => {
if (cancelled) return;
toast({
title: 'Workflow YAML unavailable',
description: error instanceof Error ? error.message : 'Unknown error',
});
});
return () => {
cancelled = true;
};
}, [initialWorkflow, toast]);
const materializeRecipe = async () => {
if (!selectedRecipe) return;
setRecipeBusy(true);
@ -267,12 +299,37 @@ export function WorkflowAuthoringPanel({
});
return;
}
await workflowsApi.create(workflow);
toast({
title: 'Workflow saved',
description: `${workflow.name} is available for runs.`,
});
onWorkflowCreated();
setSaveBusy(true);
try {
if (saveMode === 'edit') {
await workflowsApi.update(
workflow.id,
workflow,
initialWorkflow?.version ?? workflow.version
);
toast({
title: 'Workflow updated',
description: `${workflow.name} now uses the saved definition.`,
});
} else {
await workflowsApi.create(workflow);
toast({
title: 'Workflow saved',
description: `${workflow.name} is available for runs.`,
});
}
onWorkflowCreated(workflow.id);
} catch (error) {
toast({
title: saveMode === 'edit' ? 'Workflow update failed' : 'Workflow save failed',
description:
error instanceof Error
? error.message
: 'The workflow draft is still available. Correct the error and try again.',
});
} finally {
setSaveBusy(false);
}
};
const runBuilderDryRun = async () => {
@ -329,11 +386,13 @@ export function WorkflowAuthoringPanel({
};
return (
<Tabs defaultValue="recipes" className="w-full">
<Tabs defaultValue={initialWorkflow ? 'builder' : 'recipes'} className="w-full">
<Tabs.List className="w-fit">
<Tabs.Tab value="recipes" leftSection={<BookOpen className="h-4 w-4" />}>
Recipes
</Tabs.Tab>
{saveMode === 'create' && (
<Tabs.Tab value="recipes" leftSection={<BookOpen className="h-4 w-4" />}>
Recipes
</Tabs.Tab>
)}
<Tabs.Tab value="builder" leftSection={<GitBranch className="h-4 w-4" />}>
Builder
</Tabs.Tab>
@ -342,118 +401,125 @@ export function WorkflowAuthoringPanel({
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="recipes" pt="md">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Title order={2} className="text-lg">
Recipe Gallery
</Title>
<Badge variant="light">{recipes.length} recipes</Badge>
</Group>
{saveMode === 'create' && (
<Tabs.Panel value="recipes" pt="md">
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Title order={2} className="text-lg">
Recipe Gallery
</Title>
<Badge variant="light">{recipes.length} recipes</Badge>
</Group>
{recipesLoading ? (
<Text c="dimmed">Loading recipes...</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{recipes.map((recipe) => (
<Paper
key={recipe.id}
className="p-4 transition-colors hover:bg-accent/50"
radius="md"
withBorder
>
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Title order={3} className="text-base">
{recipe.name}
</Title>
<Button
size="xs"
variant={selectedRecipeId === recipe.id ? 'filled' : 'light'}
onClick={() => setSelectedRecipeId(recipe.id)}
>
Select
</Button>
</Group>
<Text size="sm" c="dimmed">
{recipe.description}
</Text>
<Group gap={6}>
{recipe.tags.map((tag) => (
<Badge key={tag} size="xs" variant="outline">
{tag}
</Badge>
))}
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
)}
</Stack>
<Stack gap="md">
<Title order={2} className="text-lg">
Recipe Inputs
</Title>
{selectedRecipe ? (
<Paper className="p-4" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600}>{selectedRecipe.name}</Text>
<Badge variant="outline">{selectedRecipe.id}</Badge>
</Group>
{selectedRecipe.inputs.map((input) => (
<RecipeInputControl
key={input.id}
input={input}
value={recipeInputs[input.id] ?? recipeInputDefault(input)}
onChange={(value) =>
setRecipeInputs((current) => ({ ...current, [input.id]: value }))
}
/>
))}
<Group justify="flex-end">
<Button
leftSection={<Wand2 className="h-4 w-4" />}
loading={recipeBusy}
onClick={materializeRecipe}
{recipesLoading ? (
<Text c="dimmed">Loading recipes...</Text>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{recipes.map((recipe) => (
<Paper
key={recipe.id}
className="p-4 transition-colors hover:bg-accent/50"
radius="md"
withBorder
>
Build Recipe
</Button>
</Group>
</Stack>
</Paper>
) : (
<Text c="dimmed">No recipe selected</Text>
)}
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Title order={3} className="text-base">
{recipe.name}
</Title>
<Button
size="xs"
variant={selectedRecipeId === recipe.id ? 'filled' : 'light'}
onClick={() => setSelectedRecipeId(recipe.id)}
>
Select
</Button>
</Group>
<Text size="sm" c="dimmed">
{recipe.description}
</Text>
<Group gap={6}>
{recipe.tags.map((tag) => (
<Badge key={tag} size="xs" variant="outline">
{tag}
</Badge>
))}
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
)}
</Stack>
{materialized && (
<WorkflowMaterializationPreview
materialized={materialized}
canSaveWorkflow={canSaveWorkflow}
onSave={() => saveWorkflow(materialized.workflow)}
/>
)}
</Stack>
</SimpleGrid>
</Tabs.Panel>
<Stack gap="md">
<Title order={2} className="text-lg">
Recipe Inputs
</Title>
{selectedRecipe ? (
<Paper className="p-4" radius="md" withBorder>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={600}>{selectedRecipe.name}</Text>
<Badge variant="outline">{selectedRecipe.id}</Badge>
</Group>
{selectedRecipe.inputs.map((input) => (
<RecipeInputControl
key={input.id}
input={input}
value={recipeInputs[input.id] ?? recipeInputDefault(input)}
onChange={(value) =>
setRecipeInputs((current) => ({ ...current, [input.id]: value }))
}
/>
))}
<Group justify="flex-end">
<Button
leftSection={<Wand2 className="h-4 w-4" />}
loading={recipeBusy}
onClick={materializeRecipe}
>
Build Recipe
</Button>
</Group>
</Stack>
</Paper>
) : (
<Text c="dimmed">No recipe selected</Text>
)}
{materialized && (
<WorkflowMaterializationPreview
materialized={materialized}
canSaveWorkflow={canSaveWorkflow}
onSave={() => saveWorkflow(materialized.workflow)}
/>
)}
</Stack>
</SimpleGrid>
</Tabs.Panel>
)}
<Tabs.Panel value="builder" pt="md">
<SimpleGrid cols={{ base: 1, xl: 2 }} spacing="md">
<Stack gap="md">
<WorkflowDefinitionEditor workflow={builderWorkflow} onChange={setBuilderWorkflow} />
<WorkflowDefinitionEditor
workflow={builderWorkflow}
onChange={setBuilderWorkflow}
lockWorkflowId={saveMode === 'edit'}
/>
</Stack>
<Stack gap="md">
<ContextEditor context={builderContext} onChange={setBuilderContext} />
<BuilderActions
canSaveWorkflow={canSaveWorkflow}
isBusy={builderBusy}
isBusy={builderBusy || saveBusy}
onDryRun={runBuilderDryRun}
onRenderYaml={renderBuilderYaml}
onSave={() => saveWorkflow(builderWorkflow)}
saveLabel={saveMode === 'edit' ? 'Save changes' : 'Save workflow'}
/>
{builderDryRun && <DryRunResultPanel result={builderDryRun} />}
</Stack>
@ -495,10 +561,13 @@ export function WorkflowAuthoringPanel({
</Button>
<Button
leftSection={<Save className="h-4 w-4" />}
disabled={!canSaveWorkflow || !yamlDryRun?.workflow || !yamlDryRun.canRun}
disabled={
saveBusy || !canSaveWorkflow || !yamlDryRun?.workflow || !yamlDryRun.canRun
}
loading={saveBusy}
onClick={() => yamlDryRun?.workflow && saveWorkflow(yamlDryRun.workflow)}
>
Save YAML Workflow
{saveMode === 'edit' ? 'Save YAML changes' : 'Save YAML workflow'}
</Button>
</Group>
</Stack>
@ -693,9 +762,11 @@ function PipelineSummaryPanel({ pipeline }: { pipeline: WorkflowPipelineSummary
function WorkflowDefinitionEditor({
workflow,
onChange,
lockWorkflowId = false,
}: {
workflow: WorkflowDefinition;
onChange: (workflow: WorkflowDefinition) => void;
lockWorkflowId?: boolean;
}) {
const { data: sandboxPresets = [] } = useSandboxPolicies();
const agentOptions = workflow.agents.map((agent) => ({
@ -799,6 +870,10 @@ function WorkflowDefinitionEditor({
<TextInput
label="ID"
value={workflow.id}
disabled={lockWorkflowId}
description={
lockWorkflowId ? 'Workflow IDs cannot change after creation.' : undefined
}
onChange={(event) => update({ id: event.currentTarget.value })}
/>
<TextInput
@ -1283,12 +1358,14 @@ function BuilderActions({
onDryRun,
onRenderYaml,
onSave,
saveLabel,
}: {
canSaveWorkflow: boolean;
isBusy: boolean;
onDryRun: () => void;
onRenderYaml: () => void;
onSave: () => void;
saveLabel: string;
}) {
return (
<Paper className="p-4" radius="md" withBorder>
@ -1312,9 +1389,10 @@ function BuilderActions({
<Button
leftSection={<Save className="h-4 w-4" />}
disabled={!canSaveWorkflow}
loading={isBusy}
onClick={onSave}
>
Save Workflow
{saveLabel}
</Button>
</Group>
</Paper>

View file

@ -0,0 +1,569 @@
import { useEffect, useMemo, useState } from 'react';
import type { WorkflowAccess, WorkflowDefinition, WorkflowStep } from '@veritas-kanban/shared';
import {
Alert,
Badge,
Button,
Code,
Divider,
Group,
Paper,
SimpleGrid,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import {
AlertTriangle,
ArrowLeft,
Bot,
Braces,
CheckCircle2,
Copy,
GitBranch,
ListOrdered,
Pencil,
Play,
RefreshCw,
ShieldCheck,
} from 'lucide-react';
import { workflowsApi } from '@/lib/api/workflows';
interface WorkflowDetailViewProps {
workflowId: string;
canWriteWorkflows: boolean;
canExecuteWorkflows: boolean;
onBack: () => void;
onEdit: () => void;
onDuplicate: () => void;
onStartRun: (workflow: WorkflowDefinition) => void;
onViewRuns: () => void;
}
function formatTimestamp(value?: string): string {
if (!value) return 'Not recorded';
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
}
function provenanceLabel(access: WorkflowAccess): string {
if (access.provenance.kind === 'built-in') return 'Built-in';
if (access.provenance.kind === 'user-owned') return 'Owned by you';
return 'Shared with you';
}
function cloneRecord(value: Record<string, unknown> | undefined): Array<[string, unknown]> {
return Object.entries(value ?? {}).sort(([left], [right]) => left.localeCompare(right));
}
export function WorkflowDetailView({
workflowId,
canWriteWorkflows,
canExecuteWorkflows,
onBack,
onEdit,
onDuplicate,
onStartRun,
onViewRuns,
}: WorkflowDetailViewProps) {
const [workflow, setWorkflow] = useState<WorkflowDefinition | null>(null);
const [access, setAccess] = useState<WorkflowAccess | null>(null);
const [error, setError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
let cancelled = false;
setWorkflow(null);
setAccess(null);
setError(null);
Promise.all([workflowsApi.get(workflowId), workflowsApi.access(workflowId)])
.then(([nextWorkflow, nextAccess]) => {
if (cancelled) return;
setWorkflow(nextWorkflow);
setAccess(nextAccess);
})
.catch((loadError: unknown) => {
if (cancelled) return;
setError(
loadError instanceof Error
? loadError.message
: `Workflow ${workflowId} could not be loaded.`
);
});
return () => {
cancelled = true;
};
}, [reloadKey, workflowId]);
const variables = useMemo(() => cloneRecord(workflow?.variables), [workflow?.variables]);
if (error) {
return (
<Stack gap="md">
<Button
variant="subtle"
size="sm"
leftSection={<ArrowLeft className="h-4 w-4" />}
onClick={onBack}
className="self-start"
>
Back to workflows
</Button>
<Alert
color="red"
icon={<AlertTriangle className="h-4 w-4" />}
title="Workflow unavailable"
>
<Stack gap="sm">
<Text size="sm">{error}</Text>
<Button
size="xs"
variant="light"
leftSection={<RefreshCw className="h-3.5 w-3.5" />}
onClick={() => setReloadKey((current) => current + 1)}
className="self-start"
>
Try again
</Button>
</Stack>
</Alert>
</Stack>
);
}
if (!workflow || !access) {
return (
<Stack gap="md" aria-label="Loading workflow definition">
<Skeleton h={36} w={220} />
<Skeleton h={150} />
<SimpleGrid cols={{ base: 1, lg: 2 }}>
<Skeleton h={260} />
<Skeleton h={260} />
</SimpleGrid>
</Stack>
);
}
const canEdit = canWriteWorkflows && access.canEdit;
const canDuplicate = canWriteWorkflows && access.canDuplicate;
const canStart = canExecuteWorkflows && access.canExecute;
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start" gap="md">
<Stack gap={8}>
<Button
variant="subtle"
size="sm"
leftSection={<ArrowLeft className="h-4 w-4" />}
onClick={onBack}
className="self-start"
>
Back to workflows
</Button>
<Group gap="sm" align="center">
<Title order={1} className="text-2xl">
{workflow.name}
</Title>
<Badge variant="outline">v{workflow.version}</Badge>
<Badge color={access.provenance.kind === 'built-in' ? 'gray' : 'blue'} variant="light">
{provenanceLabel(access)}
</Badge>
</Group>
<Text c="dimmed" maw={780} className="whitespace-pre-wrap">
{workflow.description}
</Text>
</Stack>
<Group gap="xs" justify="flex-end">
{canEdit && (
<Button variant="light" leftSection={<Pencil className="h-4 w-4" />} onClick={onEdit}>
Edit
</Button>
)}
{canDuplicate && (
<Button
variant="outline"
leftSection={<Copy className="h-4 w-4" />}
onClick={onDuplicate}
>
Duplicate
</Button>
)}
<Button
leftSection={<Play className="h-4 w-4" />}
disabled={!canStart}
title={canStart ? 'Configure and start a run' : 'Workflow execute permission required'}
onClick={() => onStartRun(workflow)}
>
Start Run
</Button>
</Group>
</Group>
{!access.canEdit && access.readOnlyReason && (
<Alert color="blue" icon={<ShieldCheck className="h-4 w-4" />} title="Read-only workflow">
<Group justify="space-between" align="center" gap="md">
<Text size="sm">{access.readOnlyReason}</Text>
{canDuplicate && (
<Button size="xs" variant="light" onClick={onDuplicate}>
Duplicate to customize
</Button>
)}
</Group>
</Alert>
)}
<SimpleGrid cols={{ base: 1, lg: 3 }} spacing="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="xs">
<Group gap="xs">
<Bot className="h-4 w-4 text-muted-foreground" />
<Text fw={600}>Agents</Text>
</Group>
<Title order={2} className="text-2xl">
{workflow.agents.length}
</Title>
<Text size="sm" c="dimmed">
Assigned roles and runtime defaults
</Text>
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="xs">
<Group gap="xs">
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<Text fw={600}>Steps</Text>
</Group>
<Title order={2} className="text-2xl">
{workflow.steps.length}
</Title>
<Text size="sm" c="dimmed">
Ordered execution blueprint
</Text>
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="xs">
<Group gap="xs">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<Text fw={600}>Completion</Text>
</Group>
<Text fw={700} size="lg">
{workflow.pipeline?.completion ?? 'Step order'}
</Text>
<Text size="sm" c="dimmed">
{workflow.pipeline?.mode ?? 'Sequential workflow'}
</Text>
</Stack>
</Paper>
</SimpleGrid>
<SimpleGrid cols={{ base: 1, xl: 3 }} spacing="md">
<Stack gap="md" className="xl:col-span-2">
<Paper p="md" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Title order={2} className="text-lg">
Execution blueprint
</Title>
<Badge variant="light">{workflow.steps.length} ordered steps</Badge>
</Group>
<Divider />
<Stack gap={0}>
{workflow.steps.map((step, index) => (
<WorkflowStepDetail
key={step.id}
step={step}
index={index}
isLast={index === workflow.steps.length - 1}
/>
))}
</Stack>
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Title order={2} className="text-lg">
Agents
</Title>
<Badge variant="outline">{workflow.agents.length}</Badge>
</Group>
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{workflow.agents.map((agent) => (
<Paper key={agent.id} p="sm" radius="sm" withBorder>
<Stack gap={6}>
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>{agent.name}</Text>
<Text size="xs" c="dimmed">
{agent.id}
</Text>
</div>
<Badge size="xs" variant="light">
{agent.role}
</Badge>
</Group>
<Text size="sm">{agent.description}</Text>
<Group gap={6}>
{agent.provider && (
<Badge size="xs" variant="outline">
{agent.provider}
</Badge>
)}
{(agent.tools ?? []).slice(0, 6).map((tool) => (
<Badge key={tool} size="xs" color="gray" variant="light">
{tool}
</Badge>
))}
</Group>
</Stack>
</Paper>
))}
</SimpleGrid>
</Stack>
</Paper>
</Stack>
<Stack gap="md">
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Title order={2} className="text-lg">
Inputs
</Title>
{variables.length === 0 ? (
<Text size="sm" c="dimmed">
No workflow variables are declared. Run context can still be supplied when
starting.
</Text>
) : (
variables.map(([name, value]) => (
<Group key={name} justify="space-between" align="flex-start" gap="sm">
<Code>{name}</Code>
<Text size="sm" ta="right" className="break-all">
{typeof value === 'string' ? value : JSON.stringify(value)}
</Text>
</Group>
))
)}
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Title order={2} className="text-lg">
Outputs
</Title>
{(workflow.outputTargets ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
Step outputs only
</Text>
) : (
workflow.outputTargets?.map((target, index) => (
<Paper key={`${target.type}-${index}`} p="xs" radius="sm" withBorder>
<Group justify="space-between" align="flex-start">
<div>
<Text size="sm" fw={600}>
{target.label ?? target.type}
</Text>
{target.path && (
<Text size="xs" c="dimmed" className="break-all">
{target.path}
</Text>
)}
</div>
<Badge size="xs" variant="outline">
{target.type}
</Badge>
</Group>
</Paper>
))
)}
</Stack>
</Paper>
<Paper p="md" radius="md" withBorder>
<Stack gap="sm">
<Title order={2} className="text-lg">
Provenance
</Title>
<MetadataRow label="Workflow ID" value={workflow.id} />
<MetadataRow label="Owner" value={access.provenance.owner ?? 'Not recorded'} />
<MetadataRow
label="Created by"
value={access.provenance.createdBy ?? 'Not recorded'}
/>
<MetadataRow
label="Updated by"
value={access.provenance.updatedBy ?? 'Not recorded'}
/>
<MetadataRow label="Created" value={formatTimestamp(access.provenance.createdAt)} />
<MetadataRow label="Updated" value={formatTimestamp(access.provenance.updatedAt)} />
</Stack>
</Paper>
<Button variant="subtle" onClick={onViewRuns}>
View workflow runs
</Button>
</Stack>
</SimpleGrid>
</Stack>
);
}
function MetadataRow({ label, value }: { label: string; value: string }) {
return (
<Group justify="space-between" align="flex-start" gap="sm">
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="xs" ta="right" className="break-all">
{value}
</Text>
</Group>
);
}
function WorkflowStepDetail({
step,
index,
isLast,
}: {
step: WorkflowStep;
index: number;
isLast: boolean;
}) {
return (
<div className="grid grid-cols-[2.5rem_1fr] gap-3">
<div className="flex flex-col items-center">
<div className="flex h-8 w-8 items-center justify-center rounded-full border bg-muted text-xs font-semibold">
{index + 1}
</div>
{!isLast && <div className="min-h-8 flex-1 border-l border-dashed" aria-hidden />}
</div>
<Stack gap="xs" pb={isLast ? 0 : 'lg'}>
<Group justify="space-between" align="flex-start" gap="sm">
<div>
<Text fw={650}>{step.name}</Text>
<Text size="xs" c="dimmed">
{step.id}
{step.agent ? ` · ${step.agent}` : ''}
</Text>
</div>
<Group gap={6}>
{step.phase && (
<Badge size="xs" color="blue" variant="light">
{step.phase}
</Badge>
)}
<Badge size="xs" variant="outline">
{step.type}
</Badge>
</Group>
</Group>
{step.input && (
<Paper p="xs" radius="sm" className="bg-muted/35" withBorder>
<Group gap={6} mb={4}>
<Braces className="h-3.5 w-3.5 text-muted-foreground" />
<Text size="xs" fw={600}>
Input
</Text>
</Group>
<Text size="sm" className="whitespace-pre-wrap">
{step.input}
</Text>
</Paper>
)}
<StepControls step={step} />
{(step.acceptance_criteria ?? []).length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600}>
Acceptance criteria
</Text>
{step.acceptance_criteria?.map((criterion, criterionIndex) => (
<Group key={`${step.id}-criterion-${criterionIndex}`} gap={6} align="flex-start">
<CheckCircle2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-teal-500" />
<Text size="sm">{criterion}</Text>
</Group>
))}
</Stack>
)}
{step.output?.file && (
<Text size="xs" c="dimmed">
Output: <Code>{step.output.file}</Code>
</Text>
)}
</Stack>
</div>
);
}
function StepControls({ step }: { step: WorkflowStep }) {
if (step.type === 'gate') {
return (
<Paper p="xs" radius="sm" withBorder>
<Text size="xs" fw={600}>
Gate condition
</Text>
<Code block>{step.condition ?? 'No condition declared'}</Code>
{step.on_false?.escalate_to && (
<Text size="xs" c="dimmed" mt={4}>
If false: {step.on_false.escalate_to}
</Text>
)}
</Paper>
);
}
if (step.type === 'loop') {
return (
<Paper p="xs" radius="sm" withBorder>
<Group gap="sm">
<Badge size="xs" variant="light">
{step.loop?.completion ?? 'completion not set'}
</Badge>
<Text size="xs">Over: {step.loop?.over ?? 'not declared'}</Text>
{step.loop?.max_iterations !== undefined && (
<Text size="xs">Max: {step.loop.max_iterations}</Text>
)}
</Group>
</Paper>
);
}
if (step.type === 'parallel') {
return (
<Paper p="xs" radius="sm" withBorder>
<Stack gap={6}>
<Group justify="space-between">
<Text size="xs" fw={600}>
Parallel branches
</Text>
<Badge size="xs" variant="light">
{step.parallel?.completion ?? 'all'}
</Badge>
</Group>
{(step.parallel?.steps ?? []).map((branch) => (
<Group key={branch.id} justify="space-between" gap="sm">
<Text size="sm">{branch.id}</Text>
<Badge size="xs" variant="outline">
{branch.agent}
</Badge>
</Group>
))}
</Stack>
</Paper>
);
}
return null;
}

View file

@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import type { WorkflowAccess, WorkflowDefinition } from '@veritas-kanban/shared';
import { Alert, Badge, Button, Group, Skeleton, Stack, Text, Title } from '@mantine/core';
import { AlertTriangle, ArrowLeft, Copy, Pencil } from 'lucide-react';
import { workflowsApi } from '@/lib/api/workflows';
import { WorkflowAuthoringPanel } from './WorkflowAuthoringPanel';
interface WorkflowEditorRouteProps {
sourceWorkflowId: string;
mode: 'edit' | 'duplicate';
canWriteWorkflows: boolean;
onBack: () => void;
onSaved: (workflowId: string) => void;
onDuplicate: () => void;
}
function duplicateWorkflow(workflow: WorkflowDefinition): WorkflowDefinition {
const suffix = '-copy';
const baseId = workflow.id.slice(0, Math.max(1, 100 - suffix.length));
return {
...workflow,
id: `${baseId}${suffix}`,
name: `${workflow.name} Copy`,
version: 1,
createdBy: undefined,
updatedBy: undefined,
createdAt: undefined,
updatedAt: undefined,
agents: workflow.agents.map((agent) => ({
...agent,
tools: agent.tools ? [...agent.tools] : undefined,
budget: agent.budget
? {
...agent.budget,
limits: agent.budget.limits ? { ...agent.budget.limits } : undefined,
}
: undefined,
})),
steps: workflow.steps.map((step) => ({
...step,
acceptance_criteria: step.acceptance_criteria ? [...step.acceptance_criteria] : undefined,
parallel: step.parallel
? {
...step.parallel,
steps: step.parallel.steps.map((branch) => ({ ...branch })),
}
: undefined,
})),
};
}
export function WorkflowEditorRoute({
sourceWorkflowId,
mode,
canWriteWorkflows,
onBack,
onSaved,
onDuplicate,
}: WorkflowEditorRouteProps) {
const [workflow, setWorkflow] = useState<WorkflowDefinition | null>(null);
const [access, setAccess] = useState<WorkflowAccess | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setWorkflow(null);
setAccess(null);
setError(null);
Promise.all([workflowsApi.get(sourceWorkflowId), workflowsApi.access(sourceWorkflowId)])
.then(([nextWorkflow, nextAccess]) => {
if (cancelled) return;
setWorkflow(mode === 'duplicate' ? duplicateWorkflow(nextWorkflow) : nextWorkflow);
setAccess(nextAccess);
})
.catch((loadError: unknown) => {
if (cancelled) return;
setError(
loadError instanceof Error
? loadError.message
: `Workflow ${sourceWorkflowId} could not be loaded.`
);
});
return () => {
cancelled = true;
};
}, [mode, sourceWorkflowId]);
const allowed =
canWriteWorkflows && access && (mode === 'edit' ? access.canEdit : access.canDuplicate);
return (
<Stack gap="lg">
<Group justify="space-between" align="flex-start">
<Stack gap={6}>
<Button
variant="subtle"
size="sm"
leftSection={<ArrowLeft className="h-4 w-4" />}
onClick={onBack}
className="self-start"
>
Back to workflow
</Button>
<Group gap="sm">
<Title order={1} className="text-2xl">
{mode === 'edit' ? 'Edit workflow' : 'Duplicate workflow'}
</Title>
<Badge variant="light">Author</Badge>
</Group>
<Text c="dimmed">
{mode === 'edit'
? 'Save a new version of this user-owned workflow definition.'
: 'Create an editable workflow from the read-only source definition.'}
</Text>
</Stack>
{mode === 'edit' ? (
<Pencil className="h-5 w-5 text-muted-foreground" aria-hidden />
) : (
<Copy className="h-5 w-5 text-muted-foreground" aria-hidden />
)}
</Group>
{error && (
<Alert
color="red"
icon={<AlertTriangle className="h-4 w-4" />}
title="Authoring unavailable"
>
{error}
</Alert>
)}
{!error && (!workflow || !access) && (
<Stack gap="md" aria-label="Loading workflow authoring">
<Skeleton h={80} />
<Skeleton h={320} />
</Stack>
)}
{workflow && access && !allowed && (
<Alert
color="blue"
icon={<AlertTriangle className="h-4 w-4" />}
title={mode === 'edit' ? 'This workflow is read-only' : 'Duplicate permission required'}
>
<Stack gap="sm">
<Text size="sm">
{mode === 'edit'
? access.readOnlyReason
: 'The current identity cannot create workflow definitions.'}
</Text>
{mode === 'edit' && canWriteWorkflows && access.canDuplicate && (
<Button size="xs" variant="light" onClick={onDuplicate} className="self-start">
Duplicate to customize
</Button>
)}
</Stack>
</Alert>
)}
{workflow && access && allowed && (
<WorkflowAuthoringPanel
key={`${mode}:${sourceWorkflowId}:${workflow.version}`}
canSaveWorkflow
initialWorkflow={workflow}
saveMode={mode === 'edit' ? 'edit' : 'create'}
onWorkflowCreated={onSaved}
/>
)}
</Stack>
);
}

View file

@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
import { AlertTriangle, Play } from 'lucide-react';
import { workflowsApi, type WorkflowRunStartResponse } from '@/lib/api/workflows';
interface WorkflowStartDialogProps {
workflow: WorkflowDefinition | null;
onClose: () => void;
onStarted: (run: WorkflowRunStartResponse) => void;
}
function initialContext(workflow: WorkflowDefinition | null): string {
return JSON.stringify(workflow?.variables ?? {}, null, 2);
}
export function WorkflowStartDialog({ workflow, onClose, onStarted }: WorkflowStartDialogProps) {
const [taskId, setTaskId] = useState('');
const [contextDraft, setContextDraft] = useState(() => initialContext(workflow));
const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
useEffect(() => {
setTaskId('');
setContextDraft(initialContext(workflow));
setError(null);
setIsStarting(false);
}, [workflow]);
const startRun = async () => {
if (!workflow) return;
setError(null);
let context: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(contextDraft || '{}');
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('Run context must be a JSON object.');
}
context = parsed as Record<string, unknown>;
} catch (parseError) {
setError(
parseError instanceof SyntaxError
? 'Run context is not valid JSON. Correct it before starting the run.'
: parseError instanceof Error
? parseError.message
: 'Run context must be a JSON object.'
);
return;
}
setIsStarting(true);
try {
const run = await workflowsApi.startRun(workflow.id, {
taskId: taskId.trim() || undefined,
context,
});
onStarted(run);
} catch (startError) {
setError(
startError instanceof Error
? startError.message
: `Workflow ${workflow.name} could not be started.`
);
} finally {
setIsStarting(false);
}
};
return (
<Modal
opened={workflow !== null}
onClose={onClose}
title={workflow ? `Start ${workflow.name}` : 'Start workflow'}
centered
size="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Review the task association and run context before execution. Starting a run is separate
from viewing or editing the workflow.
</Text>
<TextInput
label="Task ID"
description="Optional. Associate this run with an existing task."
placeholder="task_..."
value={taskId}
onChange={(event) => setTaskId(event.currentTarget.value)}
/>
<Textarea
label="Run context"
description="JSON object supplied to the workflow as its initial context."
value={contextDraft}
onChange={(event) => setContextDraft(event.currentTarget.value)}
minRows={8}
className="font-mono"
spellCheck={false}
/>
{error && (
<Alert color="red" icon={<AlertTriangle className="h-4 w-4" />} title="Run not started">
{error}
</Alert>
)}
<Group justify="flex-end">
<Button variant="subtle" onClick={onClose} disabled={isStarting}>
Cancel
</Button>
<Button
leftSection={<Play className="h-4 w-4" />}
loading={isStarting}
onClick={() => void startRun()}
>
Start Run
</Button>
</Group>
</Stack>
</Modal>
);
}

View file

@ -10,6 +10,7 @@
*/
import { lazy, Suspense, useState, useMemo, useEffect, useCallback } from 'react';
import type { WorkflowDefinition } from '@veritas-kanban/shared';
import {
Badge,
Button,
@ -21,13 +22,17 @@ import {
Text,
TextInput,
Title,
UnstyledButton,
} from '@mantine/core';
import { ArrowLeft, Search, Play, Users, ListOrdered, BarChart3 } from 'lucide-react';
import { ArrowLeft, Search, Play, Users, ListOrdered, BarChart3, Eye } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import { WorkflowRunList } from './WorkflowRunList';
import { WorkflowAuthoringPanel } from './WorkflowAuthoringPanel';
import { useIdentity } from '@/hooks/useIdentity';
import { workflowsApi, type WorkflowSummary } from '@/lib/api/workflows';
import { WorkflowDetailView } from './WorkflowDetailView';
import { WorkflowEditorRoute } from './WorkflowEditorRoute';
import { WorkflowStartDialog } from './WorkflowStartDialog';
const WorkflowDashboard = lazy(() =>
import('./WorkflowDashboard').then((mod) => ({ default: mod.WorkflowDashboard }))
@ -37,6 +42,58 @@ interface WorkflowsPageProps {
onBack: () => void;
}
type WorkflowRoute =
| { kind: 'browse' }
| { kind: 'view'; workflowId: string }
| { kind: 'edit'; workflowId: string }
| { kind: 'duplicate'; workflowId: string };
const WORKFLOW_ROUTE_STATE_KEY = 'veritasWorkflowNavigation';
const appBasePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '');
function workflowBasePath(): string {
return `${appBasePath}/workflows`.replace(/\/+/g, '/');
}
function workflowRouteFromLocation(): WorkflowRoute {
if (typeof window === 'undefined') return { kind: 'browse' };
const base = workflowBasePath();
const normalized = window.location.pathname.replace(/\/+$/, '') || '/';
if (normalized === base) return { kind: 'browse' };
if (!normalized.startsWith(`${base}/`)) return { kind: 'browse' };
const segments = normalized.slice(base.length + 1).split('/');
if (!segments[0]) return { kind: 'browse' };
let workflowId: string;
try {
workflowId = decodeURIComponent(segments[0]);
} catch {
return { kind: 'browse' };
}
if (segments[1] === 'edit') return { kind: 'edit', workflowId };
if (segments[1] === 'duplicate') return { kind: 'duplicate', workflowId };
return { kind: 'view', workflowId };
}
function workflowRoutePath(route: WorkflowRoute): string {
const base = workflowBasePath();
if (route.kind === 'browse') return base;
const workflowPath = `${base}/${encodeURIComponent(route.workflowId)}`;
if (route.kind === 'edit') return `${workflowPath}/edit`;
if (route.kind === 'duplicate') return `${workflowPath}/duplicate`;
return workflowPath;
}
function hasWorkflowHistoryOrigin(): boolean {
if (typeof window === 'undefined') return false;
const state = window.history.state;
return Boolean(
state &&
typeof state === 'object' &&
(state as Record<string, unknown>)[WORKFLOW_ROUTE_STATE_KEY]
);
}
export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
const [search, setSearch] = useState('');
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
@ -44,6 +101,8 @@ export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [showDashboard, setShowDashboard] = useState(false);
const [activeTab, setActiveTab] = useState<string | null>('browse');
const [workflowRoute, setWorkflowRoute] = useState<WorkflowRoute>(workflowRouteFromLocation);
const [workflowToStart, setWorkflowToStart] = useState<WorkflowDefinition | null>(null);
const { toast } = useToast();
const { hasPermission } = useIdentity();
const canExecuteWorkflows = hasPermission('workflow:execute');
@ -69,6 +128,49 @@ export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
void fetchWorkflows();
}, [fetchWorkflows]);
useEffect(() => {
const handlePopState = () => {
setWorkflowRoute(workflowRouteFromLocation());
setSelectedWorkflowId(null);
setShowDashboard(false);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
const navigateWorkflowRoute = useCallback(
(nextRoute: WorkflowRoute, options: { replace?: boolean } = {}) => {
const nextPath = workflowRoutePath(nextRoute);
const state: Record<string, unknown> =
window.history.state && typeof window.history.state === 'object'
? { ...(window.history.state as Record<string, unknown>) }
: {};
if (!options.replace) {
state[WORKFLOW_ROUTE_STATE_KEY] = { from: window.location.pathname };
}
if (options.replace) {
window.history.replaceState(state, '', nextPath);
} else {
window.history.pushState(state, '', nextPath);
}
setWorkflowRoute(nextRoute);
setSelectedWorkflowId(null);
setShowDashboard(false);
},
[]
);
const backFromWorkflowRoute = useCallback(
(fallback: WorkflowRoute) => {
if (hasWorkflowHistoryOrigin()) {
window.history.back();
return;
}
navigateWorkflowRoute(fallback, { replace: true });
},
[navigateWorkflowRoute]
);
// Filter workflows
const filteredWorkflows = useMemo(() => {
return workflows.filter(
@ -80,25 +182,15 @@ export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
);
}, [workflows, search]);
const handleStartRun = async (workflowId: string) => {
try {
if (!canExecuteWorkflows) {
throw new Error('Workflow execute permission required');
}
const run = await workflowsApi.startRun(workflowId);
const handleStartRun = (workflow: WorkflowDefinition) => {
if (!canExecuteWorkflows) {
toast({
title: 'Workflow run started',
description: `Run ID: ${run.id}`,
});
// Open the run view
setSelectedWorkflowId(workflowId);
} catch (error) {
toast({
title: '❌ Failed to start workflow run',
description: error instanceof Error ? error.message : 'Unknown error',
title: 'Workflow execute permission required',
description: 'The current identity cannot start workflow runs.',
});
return;
}
setWorkflowToStart(workflow);
};
if (showDashboard) {
@ -123,107 +215,208 @@ export function WorkflowsPage({ onBack }: WorkflowsPageProps) {
);
}
if (workflowRoute.kind === 'view') {
return (
<>
<WorkflowDetailView
workflowId={workflowRoute.workflowId}
canWriteWorkflows={canWriteWorkflows}
canExecuteWorkflows={canExecuteWorkflows}
onBack={() => backFromWorkflowRoute({ kind: 'browse' })}
onEdit={() =>
navigateWorkflowRoute({ kind: 'edit', workflowId: workflowRoute.workflowId })
}
onDuplicate={() =>
navigateWorkflowRoute({ kind: 'duplicate', workflowId: workflowRoute.workflowId })
}
onStartRun={handleStartRun}
onViewRuns={() => setSelectedWorkflowId(workflowRoute.workflowId)}
/>
<WorkflowStartDialog
workflow={workflowToStart}
onClose={() => setWorkflowToStart(null)}
onStarted={(run) => {
setWorkflowToStart(null);
toast({
title: 'Workflow run started',
description: `Run ID: ${run.id}`,
});
setSelectedWorkflowId(workflowRoute.workflowId);
}}
/>
</>
);
}
if (workflowRoute.kind === 'edit' || workflowRoute.kind === 'duplicate') {
const sourceWorkflowId = workflowRoute.workflowId;
return (
<WorkflowEditorRoute
sourceWorkflowId={sourceWorkflowId}
mode={workflowRoute.kind}
canWriteWorkflows={canWriteWorkflows}
onBack={() => backFromWorkflowRoute({ kind: 'view', workflowId: sourceWorkflowId })}
onDuplicate={() =>
navigateWorkflowRoute(
{ kind: 'duplicate', workflowId: sourceWorkflowId },
{ replace: true }
)
}
onSaved={(workflowId) => {
void fetchWorkflows();
if (workflowRoute.kind === 'edit') {
backFromWorkflowRoute({ kind: 'view', workflowId });
} else {
navigateWorkflowRoute({ kind: 'view', workflowId }, { replace: true });
}
}}
/>
);
}
return (
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center">
<Group gap="md" align="center">
<>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="center">
<Group gap="md" align="center">
<Button
variant="subtle"
size="sm"
leftSection={<ArrowLeft className="h-4 w-4" />}
onClick={onBack}
>
Back
</Button>
<Title order={1} className="text-2xl">
Workflows
</Title>
<Badge variant="light">{filteredWorkflows.length} workflows</Badge>
</Group>
<Button
variant="subtle"
size="sm"
leftSection={<ArrowLeft className="h-4 w-4" />}
onClick={onBack}
leftSection={<BarChart3 className="h-4 w-4" />}
onClick={() => setShowDashboard(true)}
>
Back
Dashboard
</Button>
<Title order={1} className="text-2xl">
Workflows
</Title>
<Badge variant="light">{filteredWorkflows.length} workflows</Badge>
</Group>
<Button
leftSection={<BarChart3 className="h-4 w-4" />}
onClick={() => setShowDashboard(true)}
>
Dashboard
</Button>
</Group>
<Tabs value={activeTab} onChange={setActiveTab} className="w-full">
<Tabs.List className="w-fit">
<Tabs.Tab value="browse">Browse</Tabs.Tab>
<Tabs.Tab value="author">Author</Tabs.Tab>
</Tabs.List>
<Tabs value={activeTab} onChange={setActiveTab} className="w-full">
<Tabs.List className="w-fit">
<Tabs.Tab value="browse">Browse</Tabs.Tab>
<Tabs.Tab value="author">Author</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="browse" pt="md">
<Stack gap="md">
{/* Search */}
<TextInput
className="max-w-md"
leftSection={<Search className="h-4 w-4" />}
placeholder="Search workflows..."
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
<Tabs.Panel value="browse" pt="md">
<Stack gap="md">
{/* Search */}
<TextInput
className="max-w-md"
leftSection={<Search className="h-4 w-4" />}
placeholder="Search workflows..."
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
{/* Workflow List */}
{isLoading ? (
<Stack gap="sm">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} h={128} />
))}
</Stack>
) : filteredWorkflows.length === 0 ? (
<Text ta="center" c="dimmed" py="xl">
{search ? 'No workflows match your search' : 'No workflows available'}
</Text>
) : (
<Stack gap="md">
{filteredWorkflows.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
onViewDetails={() =>
navigateWorkflowRoute({ kind: 'view', workflowId: workflow.id })
}
onStartRun={async () => {
try {
const definition = await workflowsApi.get(workflow.id);
handleStartRun(definition);
} catch (error) {
toast({
title: 'Workflow unavailable',
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}}
onViewRuns={() => setSelectedWorkflowId(workflow.id)}
canStartRun={canExecuteWorkflows}
/>
))}
</Stack>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="author" pt="md">
<WorkflowAuthoringPanel
canSaveWorkflow={canWriteWorkflows}
onWorkflowCreated={(workflowId) => {
void fetchWorkflows();
setActiveTab('browse');
navigateWorkflowRoute({ kind: 'view', workflowId });
}}
/>
{/* Workflow List */}
{isLoading ? (
<Stack gap="sm">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} h={128} />
))}
</Stack>
) : filteredWorkflows.length === 0 ? (
<Text ta="center" c="dimmed" py="xl">
{search ? 'No workflows match your search' : 'No workflows available'}
</Text>
) : (
<Stack gap="md">
{filteredWorkflows.map((workflow) => (
<WorkflowCard
key={workflow.id}
workflow={workflow}
onStartRun={() => handleStartRun(workflow.id)}
onViewRuns={() => setSelectedWorkflowId(workflow.id)}
canStartRun={canExecuteWorkflows}
/>
))}
</Stack>
)}
</Stack>
</Tabs.Panel>
<Tabs.Panel value="author" pt="md">
<WorkflowAuthoringPanel
canSaveWorkflow={canWriteWorkflows}
onWorkflowCreated={() => {
void fetchWorkflows();
setActiveTab('browse');
}}
/>
</Tabs.Panel>
</Tabs>
</Stack>
</Tabs.Panel>
</Tabs>
</Stack>
<WorkflowStartDialog
workflow={workflowToStart}
onClose={() => setWorkflowToStart(null)}
onStarted={(run) => {
const workflowId = workflowToStart?.id;
setWorkflowToStart(null);
toast({
title: 'Workflow run started',
description: `Run ID: ${run.id}`,
});
if (workflowId) setSelectedWorkflowId(workflowId);
}}
/>
</>
);
}
interface WorkflowCardProps {
workflow: WorkflowSummary;
onViewDetails: () => void;
onStartRun: () => void;
onViewRuns: () => void;
canStartRun: boolean;
}
function WorkflowCard({ workflow, onStartRun, onViewRuns, canStartRun }: WorkflowCardProps) {
function WorkflowCard({
workflow,
onViewDetails,
onStartRun,
onViewRuns,
canStartRun,
}: WorkflowCardProps) {
return (
<Paper className="p-6 transition-colors hover:bg-accent/50" radius="md" withBorder>
<Group align="flex-start" justify="space-between" gap="md">
<div className="flex-1 min-w-0">
<Group gap="sm" mb="xs">
<Title order={3} className="text-lg">
{workflow.name}
</Title>
<UnstyledButton
onClick={onViewDetails}
aria-label={`View ${workflow.name}`}
className="rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Title order={3} className="text-lg">
{workflow.name}
</Title>
</UnstyledButton>
<Badge variant="outline" className="text-xs">
v{workflow.version}
</Badge>
@ -253,15 +446,36 @@ function WorkflowCard({ workflow, onStartRun, onViewRuns, canStartRun }: Workflo
<Stack gap="xs" className="shrink-0">
<Button
size="sm"
onClick={onStartRun}
onClick={(event) => {
event.stopPropagation();
onStartRun();
}}
disabled={!canStartRun}
title={canStartRun ? 'Start run' : 'Workflow execute permission required'}
leftSection={<Play className="h-3 w-3" />}
>
Start Run
</Button>
<Button
size="sm"
variant="subtle"
leftSection={<Eye className="h-3 w-3" />}
onClick={(event) => {
event.stopPropagation();
onViewDetails();
}}
>
View details
</Button>
{workflow.activeRunCount !== undefined && workflow.activeRunCount > 0 && (
<Button size="sm" variant="outline" onClick={onViewRuns}>
<Button
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
onViewRuns();
}}
>
View Runs
</Button>
)}

View file

@ -66,7 +66,13 @@ function normalizeAppPath(pathname: string): string {
function getViewFromLocation(): AppView {
if (typeof window === 'undefined') return 'board';
const path = normalizeAppPath(window.location.pathname);
const entry = Object.entries(VIEW_PATHS).find(([, value]) => value === path);
const entries = Object.entries(VIEW_PATHS).sort(
([, leftPath], [, rightPath]) => rightPath.length - leftPath.length
);
const entry = entries.find(
([view, value]) =>
value === path || (view !== 'board' && value !== '/' && path.startsWith(`${value}/`))
);
return (entry?.[0] as AppView | undefined) || 'board';
}

View file

@ -7,6 +7,7 @@ import type {
WorkflowStep,
WorkflowSkillAuditSummary,
AgentBudgetPolicy,
WorkflowAccess,
} from '@veritas-kanban/shared';
import { API_BASE, apiFetch } from './helpers';
@ -141,6 +142,20 @@ export const workflowsApi = {
return unwrapData(response);
},
get: async (workflowId: string): Promise<WorkflowDefinition> => {
const response = await apiFetch<WorkflowDefinition | { data: WorkflowDefinition }>(
`${API_BASE}/workflows/${encodeURIComponent(workflowId)}`
);
return unwrapData(response);
},
access: async (workflowId: string): Promise<WorkflowAccess> => {
const response = await apiFetch<WorkflowAccess | { data: WorkflowAccess }>(
`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/access`
);
return unwrapData(response);
},
create: async (workflow: WorkflowDefinition): Promise<{ success: true; workflowId: string }> =>
apiFetch(`${API_BASE}/workflows`, {
method: 'POST',
@ -148,9 +163,27 @@ export const workflowsApi = {
body: JSON.stringify(workflow),
}),
update: async (
workflowId: string,
workflow: WorkflowDefinition,
expectedVersion: number
): Promise<{ success: true; version: number }> =>
apiFetch(`${API_BASE}/workflows/${encodeURIComponent(workflowId)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Resource-Revision': String(expectedVersion),
},
body: JSON.stringify(workflow),
}),
startRun: async (
workflowId: string,
options: { budget?: AgentBudgetPolicy; context?: Record<string, unknown> } = {}
options: {
taskId?: string;
budget?: AgentBudgetPolicy;
context?: Record<string, unknown>;
} = {}
): Promise<WorkflowRunStartResponse> => {
const response = await apiFetch<WorkflowRunStartResponse | { data: WorkflowRunStartResponse }>(
`${API_BASE}/workflows/${encodeURIComponent(workflowId)}/runs`,