diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fe66a28..5e01189a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/API-WORKFLOWS.md b/docs/API-WORKFLOWS.md index 0491c5fd..a2f44b8f 100644 --- a/docs/API-WORKFLOWS.md +++ b/docs/API-WORKFLOWS.md @@ -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. diff --git a/docs/WORKFLOW-GUIDE.md b/docs/WORKFLOW-GUIDE.md index d408bbe0..dbc3de18 100644 --- a/docs/WORKFLOW-GUIDE.md +++ b/docs/WORKFLOW-GUIDE.md @@ -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. --- diff --git a/server/src/__tests__/routes/workflow-authoring.test.ts b/server/src/__tests__/routes/workflow-authoring.test.ts index 7be5940e..54463932 100644 --- a/server/src/__tests__/routes/workflow-authoring.test.ts +++ b/server/src/__tests__/routes/workflow-authoring.test.ts @@ -48,6 +48,7 @@ function workflow(overrides: Partial = {}): 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') diff --git a/server/src/routes/workflows.ts b/server/src/routes/workflows.ts index 410219cf..4a694be4 100644 --- a/server/src/routes/workflows.ts +++ b/server/src/routes/workflows.ts @@ -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 */ diff --git a/shared/src/types/workflow.ts b/shared/src/types/workflow.ts index 18501ef9..70a5f61e 100644 --- a/shared/src/types/workflow.ts +++ b/shared/src/types/workflow.ts @@ -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; diff --git a/web/src/__tests__/view-context-navigation.test.tsx b/web/src/__tests__/view-context-navigation.test.tsx index 84a7a30a..06896f23 100644 --- a/web/src/__tests__/view-context-navigation.test.tsx +++ b/web/src/__tests__/view-context-navigation.test.tsx @@ -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(); diff --git a/web/src/__tests__/workflow-browser-actions.test.tsx b/web/src/__tests__/workflow-browser-actions.test.tsx new file mode 100644 index 00000000..46e1a6e5 --- /dev/null +++ b/web/src/__tests__/workflow-browser-actions.test.tsx @@ -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 { + 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 = {}) { + 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(); + + 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(); + 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(); + + 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(); + }); +}); diff --git a/web/src/__tests__/workflow-surfaces-mantine.test.tsx b/web/src/__tests__/workflow-surfaces-mantine.test.tsx index fbf0031a..f1e0cce9 100644 --- a/web/src/__tests__/workflow-surfaces-mantine.test.tsx +++ b/web/src/__tests__/workflow-surfaces-mantine.test.tsx @@ -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(); }); diff --git a/web/src/components/workflows/WorkflowAuthoringPanel.tsx b/web/src/components/workflows/WorkflowAuthoringPanel.tsx index b33743b2..5301f9dd 100644 --- a/web/src/components/workflows/WorkflowAuthoringPanel.tsx +++ b/web/src/components/workflows/WorkflowAuthoringPanel.tsx @@ -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([]); @@ -187,8 +191,8 @@ export function WorkflowAuthoringPanel({ const [materialized, setMaterialized] = useState(null); const [recipeBusy, setRecipeBusy] = useState(false); - const [builderWorkflow, setBuilderWorkflow] = useState(() => - defaultWorkflow() + const [builderWorkflow, setBuilderWorkflow] = useState( + () => initialWorkflow ?? defaultWorkflow() ); const [builderContext, setBuilderContext] = useState({ taskId: '', @@ -199,6 +203,7 @@ export function WorkflowAuthoringPanel({ const [yamlDryRun, setYamlDryRun] = useState(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 ( - + - }> - Recipes - + {saveMode === 'create' && ( + }> + Recipes + + )} }> Builder @@ -342,118 +401,125 @@ export function WorkflowAuthoringPanel({ - - - - - - Recipe Gallery - - {recipes.length} recipes - + {saveMode === 'create' && ( + + + + + + Recipe Gallery + + {recipes.length} recipes + - {recipesLoading ? ( - Loading recipes... - ) : ( - - {recipes.map((recipe) => ( - - - - - {recipe.name} - - - - - {recipe.description} - - - {recipe.tags.map((tag) => ( - - {tag} - - ))} - - - - ))} - - )} - - - - - Recipe Inputs - - {selectedRecipe ? ( - - - - {selectedRecipe.name} - {selectedRecipe.id} - - {selectedRecipe.inputs.map((input) => ( - - setRecipeInputs((current) => ({ ...current, [input.id]: value })) - } - /> - ))} - - - - - - ) : ( - No recipe selected - )} + + + + {recipe.name} + + + + + {recipe.description} + + + {recipe.tags.map((tag) => ( + + {tag} + + ))} + + + + ))} + + )} + - {materialized && ( - saveWorkflow(materialized.workflow)} - /> - )} - - - + + + Recipe Inputs + + {selectedRecipe ? ( + + + + {selectedRecipe.name} + {selectedRecipe.id} + + {selectedRecipe.inputs.map((input) => ( + + setRecipeInputs((current) => ({ ...current, [input.id]: value })) + } + /> + ))} + + + + + + ) : ( + No recipe selected + )} + + {materialized && ( + saveWorkflow(materialized.workflow)} + /> + )} + + + + )} - + saveWorkflow(builderWorkflow)} + saveLabel={saveMode === 'edit' ? 'Save changes' : 'Save workflow'} /> {builderDryRun && } @@ -495,10 +561,13 @@ export function WorkflowAuthoringPanel({ @@ -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({ update({ id: event.currentTarget.value })} /> void; onRenderYaml: () => void; onSave: () => void; + saveLabel: string; }) { return ( @@ -1312,9 +1389,10 @@ function BuilderActions({ diff --git a/web/src/components/workflows/WorkflowDetailView.tsx b/web/src/components/workflows/WorkflowDetailView.tsx new file mode 100644 index 00000000..d056ed7b --- /dev/null +++ b/web/src/components/workflows/WorkflowDetailView.tsx @@ -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 | 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(null); + const [access, setAccess] = useState(null); + const [error, setError] = useState(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 ( + + + } + title="Workflow unavailable" + > + + {error} + + + + + ); + } + + if (!workflow || !access) { + return ( + + + + + + + + + ); + } + + const canEdit = canWriteWorkflows && access.canEdit; + const canDuplicate = canWriteWorkflows && access.canDuplicate; + const canStart = canExecuteWorkflows && access.canExecute; + + return ( + + + + + + + {workflow.name} + + v{workflow.version} + + {provenanceLabel(access)} + + + + {workflow.description} + + + + + {canEdit && ( + + )} + {canDuplicate && ( + + )} + + + + + {!access.canEdit && access.readOnlyReason && ( + } title="Read-only workflow"> + + {access.readOnlyReason} + {canDuplicate && ( + + )} + + + )} + + + + + + + Agents + + + {workflow.agents.length} + + + Assigned roles and runtime defaults + + + + + + + + Steps + + + {workflow.steps.length} + + + Ordered execution blueprint + + + + + + + + Completion + + + {workflow.pipeline?.completion ?? 'Step order'} + + + {workflow.pipeline?.mode ?? 'Sequential workflow'} + + + + + + + + + + + + Execution blueprint + + {workflow.steps.length} ordered steps + + + + {workflow.steps.map((step, index) => ( + + ))} + + + + + + + + + Agents + + {workflow.agents.length} + + + {workflow.agents.map((agent) => ( + + + +
+ {agent.name} + + {agent.id} + +
+ + {agent.role} + +
+ {agent.description} + + {agent.provider && ( + + {agent.provider} + + )} + {(agent.tools ?? []).slice(0, 6).map((tool) => ( + + {tool} + + ))} + +
+
+ ))} +
+
+
+
+ + + + + + Inputs + + {variables.length === 0 ? ( + + No workflow variables are declared. Run context can still be supplied when + starting. + + ) : ( + variables.map(([name, value]) => ( + + {name} + + {typeof value === 'string' ? value : JSON.stringify(value)} + + + )) + )} + + + + + + + Outputs + + {(workflow.outputTargets ?? []).length === 0 ? ( + + Step outputs only + + ) : ( + workflow.outputTargets?.map((target, index) => ( + + +
+ + {target.label ?? target.type} + + {target.path && ( + + {target.path} + + )} +
+ + {target.type} + +
+
+ )) + )} +
+
+ + + + + Provenance + + + + + + + + + + + +
+
+
+ ); +} + +function MetadataRow({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ); +} + +function WorkflowStepDetail({ + step, + index, + isLast, +}: { + step: WorkflowStep; + index: number; + isLast: boolean; +}) { + return ( +
+
+
+ {index + 1} +
+ {!isLast &&
} +
+ + +
+ {step.name} + + {step.id} + {step.agent ? ` · ${step.agent}` : ''} + +
+ + {step.phase && ( + + {step.phase} + + )} + + {step.type} + + +
+ + {step.input && ( + + + + + Input + + + + {step.input} + + + )} + + + + {(step.acceptance_criteria ?? []).length > 0 && ( + + + Acceptance criteria + + {step.acceptance_criteria?.map((criterion, criterionIndex) => ( + + + {criterion} + + ))} + + )} + + {step.output?.file && ( + + Output: {step.output.file} + + )} +
+
+ ); +} + +function StepControls({ step }: { step: WorkflowStep }) { + if (step.type === 'gate') { + return ( + + + Gate condition + + {step.condition ?? 'No condition declared'} + {step.on_false?.escalate_to && ( + + If false: {step.on_false.escalate_to} + + )} + + ); + } + + if (step.type === 'loop') { + return ( + + + + {step.loop?.completion ?? 'completion not set'} + + Over: {step.loop?.over ?? 'not declared'} + {step.loop?.max_iterations !== undefined && ( + Max: {step.loop.max_iterations} + )} + + + ); + } + + if (step.type === 'parallel') { + return ( + + + + + Parallel branches + + + {step.parallel?.completion ?? 'all'} + + + {(step.parallel?.steps ?? []).map((branch) => ( + + {branch.id} + + {branch.agent} + + + ))} + + + ); + } + + return null; +} diff --git a/web/src/components/workflows/WorkflowEditorRoute.tsx b/web/src/components/workflows/WorkflowEditorRoute.tsx new file mode 100644 index 00000000..969608b3 --- /dev/null +++ b/web/src/components/workflows/WorkflowEditorRoute.tsx @@ -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(null); + const [access, setAccess] = useState(null); + const [error, setError] = useState(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 ( + + + + + + + {mode === 'edit' ? 'Edit workflow' : 'Duplicate workflow'} + + Author + + + {mode === 'edit' + ? 'Save a new version of this user-owned workflow definition.' + : 'Create an editable workflow from the read-only source definition.'} + + + {mode === 'edit' ? ( + + ) : ( + + )} + + + {error && ( + } + title="Authoring unavailable" + > + {error} + + )} + + {!error && (!workflow || !access) && ( + + + + + )} + + {workflow && access && !allowed && ( + } + title={mode === 'edit' ? 'This workflow is read-only' : 'Duplicate permission required'} + > + + + {mode === 'edit' + ? access.readOnlyReason + : 'The current identity cannot create workflow definitions.'} + + {mode === 'edit' && canWriteWorkflows && access.canDuplicate && ( + + )} + + + )} + + {workflow && access && allowed && ( + + )} + + ); +} diff --git a/web/src/components/workflows/WorkflowStartDialog.tsx b/web/src/components/workflows/WorkflowStartDialog.tsx new file mode 100644 index 00000000..81e19e56 --- /dev/null +++ b/web/src/components/workflows/WorkflowStartDialog.tsx @@ -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(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; + 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; + } 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 ( + + + + Review the task association and run context before execution. Starting a run is separate + from viewing or editing the workflow. + + + setTaskId(event.currentTarget.value)} + /> + +