mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: add workflow view and edit actions
This commit is contained in:
parent
880914b97b
commit
8dc6bf32c9
16 changed files with 1952 additions and 227 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
304
web/src/__tests__/workflow-browser-actions.test.tsx
Normal file
304
web/src/__tests__/workflow-browser-actions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
569
web/src/components/workflows/WorkflowDetailView.tsx
Normal file
569
web/src/components/workflows/WorkflowDetailView.tsx
Normal 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;
|
||||
}
|
||||
172
web/src/components/workflows/WorkflowEditorRoute.tsx
Normal file
172
web/src/components/workflows/WorkflowEditorRoute.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
web/src/components/workflows/WorkflowStartDialog.tsx
Normal file
123
web/src/components/workflows/WorkflowStartDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue