Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/public/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"VITE_GRAPHQL_URL": "https://localhost:9446/graphql",
"VITE_AUTH_BASE_URL": "https://localhost:9446/auth",
"VITE_OBSERVABILITY_URL": "https://localhost:9446/icp/observability",
"VITE_WORKFLOW_URL": "https://localhost:9446/icp/workflow",
"VITE_SSO_ENABLED": false,
"VITE_ICP_VERSION": "2.0.0-SNAPSHOT"
}
6 changes: 6 additions & 0 deletions frontend/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ const ARTIFACT_QUERY_MAP: Record<string, { queryName: string; field: string; fie
fields: 'packageOrg, packageName, packageVersion',
gqlFields: 'packageOrg, packageName, packageVersion, runtimeIds, runtimes { runtimeId, runtimeName, status, executionTimestamps }, executionTimestamp',
},
Workflow: {
queryName: 'workflowsByEnvironmentAndComponent',
field: 'workflowsByEnvironmentAndComponent',
fields: 'name, workerCount',
gqlFields: 'name, isActive, workerCount, inputSchema, state, runtimes { runtimeId, runtimeName, status }',
},
MessageStore: {
queryName: 'messageStoresByEnvironmentAndComponent',
field: 'messageStoresByEnvironmentAndComponent',
Expand Down
322 changes: 322 additions & 0 deletions frontend/src/api/workflows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
/**
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { authenticatedFetch } from '../auth/tokenManager';
import { workflowApiUrl } from '../config/api';

// ── Shared types (runtime-side shapes are loosely typed; known fields declared) ──

export interface WorkflowDefinition {
workflowType: string;
inputSchema?: string | null;
isActive?: boolean;
workerCount?: number;
}

export interface WorkflowInstance {
workflowId: string;
runId?: string;
workflowType?: string;
status?: string;
startTime?: string;
closeTime?: string;
[key: string]: unknown;
}

export interface Page<T> {
items: T[];
nextPageToken?: string | null;
hasMore?: boolean;
}

export interface HumanTask {
taskId: string;
taskName?: string;
title?: string;
description?: string;
payload?: Record<string, unknown>;
formSchema?: Record<string, unknown> | string;
parentWorkflowId?: string;
parentWorkflowType?: string;
status?: string;
startTime?: string;
closeTime?: string;
userRoles?: string[];
eligibleRoles?: string[];
canComplete?: boolean;
result?: unknown;
[key: string]: unknown;
}

export interface RetryTask {
taskId: string;
taskName?: string;
activityName?: string;
parentWorkflowId?: string;
status?: string;
startTime?: string;
[key: string]: unknown;
}

export interface HistoryEvent {
[key: string]: unknown;
}

// ── Low-level request helper (mirrors logs.ts: timeout + error extraction) ──

async function wfRequest<T>(componentId: string, environmentId: string, subpath: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const res = await authenticatedFetch(workflowApiUrl(componentId, environmentId, subpath), {
...init,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!res.ok) {
const text = await res.text();
let message = text;
try {
const json = JSON.parse(text);
message = json?.error?.message || json?.message || text;
} catch {
// keep raw text
}
const error = new Error(message || `Request failed (${res.status})`);
(error as Error & { status?: number }).status = res.status;
throw error;
}
// Some endpoints (204) have no body.
const text = await res.text();
return (text ? JSON.parse(text) : {}) as T;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Workflow service is unavailable. Request timed out.');
}
throw error;
}
}

function jsonBody(init: RequestInit, body: unknown): RequestInit {
return { ...init, headers: { ...(init.headers ?? {}), 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
}

function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const usp = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== '' && v !== null) usp.set(k, String(v));
}
const s = usp.toString();
return s ? `?${s}` : '';
}

// Scope-tuple used in every query key so cached data is isolated per component+env.
type Scope = { componentId: string; environmentId: string };
const enabledFor = (s: Scope) => !!s.componentId && !!s.environmentId;

// ── Definitions ──

export function useWorkflowDefinitions(s: Scope) {
return useQuery({
queryKey: ['wf', 'definitions', s.componentId, s.environmentId],
queryFn: () => wfRequest<{ definitions: WorkflowDefinition[] }>(s.componentId, s.environmentId, 'definitions').then((d) => d.definitions ?? []),
enabled: enabledFor(s),
});
}

// ── Workflow instances ──

export interface WorkflowFilters {
status?: string;
workflowType?: string;
workflowId?: string;
startTimeFrom?: string;
startTimeTo?: string;
limit?: number;
pageToken?: string;
}

export function useWorkflowInstances(s: Scope, filters: WorkflowFilters) {
return useQuery({
queryKey: ['wf', 'instances', s.componentId, s.environmentId, filters],
queryFn: () => wfRequest<Page<WorkflowInstance>>(s.componentId, s.environmentId, `workflows${buildQuery({ ...filters })}`),
enabled: enabledFor(s),
});
}

export function useWorkflowInfo(s: Scope, workflowId: string | null) {
return useQuery({
queryKey: ['wf', 'info', s.componentId, s.environmentId, workflowId],
queryFn: () => wfRequest<WorkflowInstance>(s.componentId, s.environmentId, `workflows/${encodeURIComponent(workflowId!)}`),
enabled: enabledFor(s) && !!workflowId,
});
}

export function useWorkflowHistory(s: Scope, workflowId: string | null) {
return useQuery({
queryKey: ['wf', 'history', s.componentId, s.environmentId, workflowId],
queryFn: () => wfRequest<{ events: HistoryEvent[] }>(s.componentId, s.environmentId, `workflows/${encodeURIComponent(workflowId!)}/history`).then((d) => d.events ?? []),
enabled: enabledFor(s) && !!workflowId,
});
}

export function useWorkflowExecutionGraph(s: Scope, workflowId: string | null) {
return useQuery({
queryKey: ['wf', 'graph', s.componentId, s.environmentId, workflowId],
queryFn: () => wfRequest<unknown>(s.componentId, s.environmentId, `workflows/${encodeURIComponent(workflowId!)}/execution-graph`),
enabled: enabledFor(s) && !!workflowId,
});
}

export function useStartWorkflow(s: Scope) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { workflowType: string; input?: unknown; workflowId?: string; timeoutSeconds?: number }) => wfRequest<WorkflowInstance>(s.componentId, s.environmentId, 'workflows', jsonBody({ method: 'POST' }, body)),
onSuccess: () => qc.invalidateQueries({ queryKey: ['wf', 'instances', s.componentId, s.environmentId] }),
});
}

export type WorkflowLifecycleAction = 'suspend' | 'resume' | 'cancel' | 'terminate';

export function useWorkflowLifecycle(s: Scope) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ workflowId, action, reason }: { workflowId: string; action: WorkflowLifecycleAction; reason?: string }) => {
const init = action === 'terminate' ? jsonBody({ method: 'POST' }, { reason: reason ?? '' }) : { method: 'POST' };
return wfRequest<unknown>(s.componentId, s.environmentId, `workflows/${encodeURIComponent(workflowId)}/${action}`, init);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['wf', 'instances', s.componentId, s.environmentId] });
qc.invalidateQueries({ queryKey: ['wf', 'info', s.componentId, s.environmentId] });
},
});
}

// ── Human tasks ──

export interface HumanTaskFilters {
status?: string;
parentWorkflowId?: string;
parentWorkflowType?: string;
taskName?: string;
limit?: number;
pageToken?: string;
}

export function useHumanTasks(s: Scope, filters: HumanTaskFilters) {
return useQuery({
queryKey: ['wf', 'human-tasks', s.componentId, s.environmentId, filters],
queryFn: () => wfRequest<Page<HumanTask>>(s.componentId, s.environmentId, `human-tasks${buildQuery({ ...filters })}`),
enabled: enabledFor(s),
});
}

export function usePendingTaskCount(s: Scope) {
return useQuery({
queryKey: ['wf', 'pending-count', s.componentId, s.environmentId],
queryFn: () => wfRequest<{ count: number }>(s.componentId, s.environmentId, 'human-tasks/pending-count').then((d) => d.count ?? 0),
enabled: enabledFor(s),
refetchInterval: 30000,
});
}

// Query options for one task's detail; shared by useHumanTask and useQueries-based batch fetches.
export function humanTaskQueryOptions(s: Scope, taskId: string) {
return {
queryKey: ['wf', 'human-task', s.componentId, s.environmentId, taskId] as const,
queryFn: () => wfRequest<HumanTask>(s.componentId, s.environmentId, `human-tasks/${encodeURIComponent(taskId)}`),
};
}

export function useHumanTask(s: Scope, taskId: string | null) {
return useQuery({
...humanTaskQueryOptions(s, taskId ?? ''),
enabled: enabledFor(s) && !!taskId,
});
}

function invalidateHumanTasks(qc: ReturnType<typeof useQueryClient>, s: Scope) {
qc.invalidateQueries({ queryKey: ['wf', 'human-tasks', s.componentId, s.environmentId] });
qc.invalidateQueries({ queryKey: ['wf', 'pending-count', s.componentId, s.environmentId] });
}

export function useCompleteHumanTask(s: Scope) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ taskId, result }: { taskId: string; result: unknown }) => wfRequest<unknown>(s.componentId, s.environmentId, `human-tasks/${encodeURIComponent(taskId)}/complete`, jsonBody({ method: 'POST' }, { result })),
onSuccess: () => invalidateHumanTasks(qc, s),
});
}

export function useFailHumanTask(s: Scope) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ taskId, reason, details }: { taskId: string; reason: string; details?: unknown }) => wfRequest<unknown>(s.componentId, s.environmentId, `human-tasks/${encodeURIComponent(taskId)}/fail`, jsonBody({ method: 'POST' }, { reason, details })),
onSuccess: () => invalidateHumanTasks(qc, s),
});
}

// ── Retry tasks ──

export interface RetryTaskFilters {
status?: string;
parentWorkflowId?: string;
taskName?: string;
startTimeFrom?: string;
startTimeTo?: string;
limit?: number;
pageToken?: string;
}

// Retry-task pages are fetched and combined up to this many pages so client-side
// filters (e.g. by workflow name, which the runtime API cannot filter on) see the
// full set rather than only the first page.
const RETRY_TASK_MAX_PAGES = 20;

export function useRetryTasks(s: Scope, filters: RetryTaskFilters) {
return useQuery({
queryKey: ['wf', 'retry-tasks', s.componentId, s.environmentId, filters],
queryFn: async (): Promise<Page<RetryTask>> => {
const items: RetryTask[] = [];
let pageToken: string | undefined;
for (let i = 0; i < RETRY_TASK_MAX_PAGES; i++) {
const page = await wfRequest<Page<RetryTask>>(s.componentId, s.environmentId, `retry-tasks${buildQuery({ ...filters, pageToken })}`);
items.push(...(page.items ?? []));
if (!page.hasMore || !page.nextPageToken) return { items, hasMore: false };
pageToken = page.nextPageToken;
}
return { items, hasMore: true };
},
enabled: enabledFor(s),
});
}

export type RetryDecision = 'retry' | 'retry-with-input' | 'fail';

export function useRetryDecision(s: Scope) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ taskId, decision, input }: { taskId: string; decision: RetryDecision; input?: unknown }) => {
const init = decision === 'retry-with-input' ? jsonBody({ method: 'POST' }, { input }) : { method: 'POST' };
return wfRequest<unknown>(s.componentId, s.environmentId, `retry-tasks/${encodeURIComponent(taskId)}/${decision}`, init);
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['wf', 'retry-tasks', s.componentId, s.environmentId] }),
});
}
Loading
Loading