diff --git a/src/components/ActionStatusPanel.tsx b/src/components/ActionStatusPanel.tsx index 3fde416..7d6315b 100644 --- a/src/components/ActionStatusPanel.tsx +++ b/src/components/ActionStatusPanel.tsx @@ -1,17 +1,18 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useState } from 'react'; import { useShallow } from 'zustand/shallow'; -import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Loader2, Navigation } from 'lucide-react'; +import { Activity, RefreshCw, XCircle, CheckCircle, AlertCircle, Clock, Navigation } from 'lucide-react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { useAppStore, type AppState } from '@/lib/store'; -import type { ExecutionStatus, SovdResourceEntityType } from '@/lib/types'; +import { useAppStore, type AppState, type TrackedExecution } from '@/lib/store'; +import type { ExecutionStatus } from '@/lib/types'; + +/** Delay before hiding terminal state panel (ms) */ +const TERMINAL_STATE_DISPLAY_DELAY_MS = 3000; interface ActionStatusPanelProps { - entityId: string; - operationName: string; + /** Execution ID to display and monitor */ executionId: string; - entityType?: SovdResourceEntityType; } /** @@ -34,6 +35,7 @@ function getStatusStyle(status: ExecutionStatus): { bgColor: 'bg-blue-500/10', }; case 'succeeded': + case 'completed': return { variant: 'default', icon: CheckCircle, @@ -68,7 +70,7 @@ function getStatusStyle(status: ExecutionStatus): { * Check if status is terminal (no more updates expected) */ function isTerminalStatus(status: ExecutionStatus): boolean { - return ['succeeded', 'canceled', 'failed'].includes(status); + return ['succeeded', 'canceled', 'failed', 'completed'].includes(status); } /** @@ -78,69 +80,68 @@ function isActiveStatus(status: ExecutionStatus): boolean { return ['pending', 'running'].includes(status); } -export function ActionStatusPanel({ - entityId, - operationName, - executionId, - entityType = 'components', -}: ActionStatusPanelProps) { - const { - activeExecutions, - autoRefreshExecutions, - refreshExecutionStatus, - cancelExecution, - setAutoRefreshExecutions, - } = useAppStore( - useShallow((state: AppState) => ({ - activeExecutions: state.activeExecutions, - autoRefreshExecutions: state.autoRefreshExecutions, - refreshExecutionStatus: state.refreshExecutionStatus, - cancelExecution: state.cancelExecution, - setAutoRefreshExecutions: state.setAutoRefreshExecutions, - })) - ); +export function ActionStatusPanel({ executionId }: ActionStatusPanelProps) { + const { activeExecutions, autoRefreshExecutions, cancelExecution, startExecutionPolling, refreshExecutionStatus } = + useAppStore( + useShallow((state: AppState) => ({ + activeExecutions: state.activeExecutions, + autoRefreshExecutions: state.autoRefreshExecutions, + cancelExecution: state.cancelExecution, + startExecutionPolling: state.startExecutionPolling, + refreshExecutionStatus: state.refreshExecutionStatus, + })) + ); - const execution = activeExecutions.get(executionId); + const execution = activeExecutions.get(executionId) as TrackedExecution | undefined; const statusStyle = execution ? getStatusStyle(execution.status) : null; const StatusIcon = statusStyle?.icon || Clock; const isTerminal = execution ? isTerminalStatus(execution.status) : false; const isActive = execution ? isActiveStatus(execution.status) : false; const canCancel = execution && ['pending', 'running'].includes(execution.status); + // Track whether to hide terminal state panel (with delay) + const [shouldHide, setShouldHide] = useState(false); + // Manual refresh const handleRefresh = useCallback(() => { - refreshExecutionStatus(entityId, operationName, executionId, entityType); - }, [entityId, operationName, executionId, refreshExecutionStatus, entityType]); + if (execution) { + refreshExecutionStatus(execution.entityId, execution.operationName, executionId, execution.entityType); + } + }, [execution, executionId, refreshExecutionStatus]); // Cancel action const handleCancel = useCallback(async () => { - await cancelExecution(entityId, operationName, executionId, entityType); - }, [entityId, operationName, executionId, cancelExecution, entityType]); + if (execution) { + await cancelExecution(execution.entityId, execution.operationName, executionId, execution.entityType); + } + }, [execution, executionId, cancelExecution]); - // Auto-refresh effect + // Start polling on mount if auto-refresh is enabled and execution is active + // Polling is managed by the store, so this just ensures it's started useEffect(() => { - if (!autoRefreshExecutions || isTerminal) return; - - const interval = setInterval(() => { - refreshExecutionStatus(entityId, operationName, executionId, entityType); - }, 1000); // Refresh every second - - return () => clearInterval(interval); - }, [autoRefreshExecutions, isTerminal, entityId, operationName, executionId, refreshExecutionStatus, entityType]); + if (autoRefreshExecutions && isActive) { + startExecutionPolling(); + } + }, [autoRefreshExecutions, isActive, startExecutionPolling]); - // Initial fetch + // Delay hiding terminal state so users can see the final status useEffect(() => { - if (!execution) { - refreshExecutionStatus(entityId, operationName, executionId, entityType); + if (isTerminal && !shouldHide) { + const timer = setTimeout(() => { + setShouldHide(true); + }, TERMINAL_STATE_DISPLAY_DELAY_MS); + return () => clearTimeout(timer); } - }, [executionId, execution, entityId, operationName, refreshExecutionStatus, entityType]); + // Reset shouldHide if execution becomes active again (e.g., new execution) + if (!isTerminal && shouldHide) { + setShouldHide(false); + } + return undefined; + }, [isTerminal, shouldHide]); - if (!execution) { - return ( -
- -
- ); + // Don't render if no execution or if terminal state delay has passed + if (!execution || (isTerminal && shouldHide)) { + return null; } return ( @@ -167,23 +168,6 @@ export function ActionStatusPanel({
- {/* Auto-refresh checkbox */} - - {/* Manual refresh */}
) : ( -
+
{parameters.map((param) => ( -
+
{topics.map((topic) => { const cleanName = topic.topic.startsWith('/') ? topic.topic.slice(1) : topic.topic; const encodedName = encodeURIComponent(cleanName); @@ -213,7 +213,7 @@ function DataTabContent({
-
+
{topicsInfo.publishes.map((topic: string) => { const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; const encodedName = encodeURIComponent(cleanName); @@ -247,7 +247,7 @@ function DataTabContent({
-
+
{topicsInfo.subscribes.map((topic: string) => { const cleanName = topic.startsWith('/') ? topic.slice(1) : topic; const encodedName = encodeURIComponent(cleanName); diff --git a/src/components/OperationResponse.tsx b/src/components/OperationResponse.tsx index f518b9e..7db34b9 100644 --- a/src/components/OperationResponse.tsx +++ b/src/components/OperationResponse.tsx @@ -1,9 +1,14 @@ -import { CheckCircle, XCircle, Clock, Loader2, Hash } from 'lucide-react'; +import { CheckCircle, XCircle, Clock, Loader2, Hash, FileJson } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import type { CreateExecutionResponse, ExecutionStatus } from '@/lib/types'; +import { useAppStore } from '@/lib/store'; +import type { AppState } from '@/lib/store'; +import { useShallow } from 'zustand/shallow'; interface OperationResponseProps { response: CreateExecutionResponse; + /** Optional executionId to get live status from store */ + executionId?: string; } function getStatusConfig(status: ExecutionStatus): { @@ -13,6 +18,7 @@ function getStatusConfig(status: ExecutionStatus): { } { switch (status) { case 'succeeded': + case 'completed': return { icon: CheckCircle, color: 'text-green-500', variant: 'default' }; case 'running': case 'pending': @@ -26,33 +32,97 @@ function getStatusConfig(status: ExecutionStatus): { } } -export function OperationResponseDisplay({ response }: OperationResponseProps) { - const isSuccess = response.status === 'succeeded'; - const statusConfig = getStatusConfig(response.status); +/** + * Check if response is a service call (no execution ID, has direct result/parameters) + */ +function isServiceResponse(response: CreateExecutionResponse): boolean { + return !response.id; +} + +/** + * Extract result from response - handles both action and service response formats + */ +function extractResult(response: CreateExecutionResponse): unknown { + // Service responses: result is directly in the response (often as `parameters`) + if (isServiceResponse(response)) { + // Prefer explicit parameters field when present + if (response.parameters !== undefined) { + return response.parameters; + } + // Fallback: remove internal envelope fields before returning the result + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { status: _s, kind: _k, error: _e, ...publicResult } = response; + return Object.keys(publicResult).length > 0 ? publicResult : undefined; + } + // Action responses: result is in the result field + return response.result; +} + +/** + * Get border and background classes based on execution status + */ +function getStatusBorderClass(status: ExecutionStatus): string { + if (status === 'succeeded' || status === 'completed') { + return 'border-green-500/30 bg-green-500/5'; + } + if (status === 'failed') { + return 'border-destructive/30 bg-destructive/5'; + } + return 'border-muted bg-muted/5'; +} + +export function OperationResponseDisplay({ response, executionId }: OperationResponseProps) { + // Get live status from store if executionId is provided + const activeExecution = useAppStore( + useShallow((state: AppState) => (executionId ? state.activeExecutions.get(executionId) : undefined)) + ); + + const isService = isServiceResponse(response); + + // Use live status if available (for actions), otherwise infer from response + // Services complete immediately, so status is always 'completed' + const currentStatus: ExecutionStatus = activeExecution?.status ?? (isService ? 'completed' : response.status); + const currentResult = activeExecution?.result ?? extractResult(response); + + const statusConfig = getStatusConfig(currentStatus); const StatusIcon = statusConfig.icon; return ( -
+
{/* Header */}
- {response.status} + {currentStatus} + {isService && (service)}
{/* Body */}
- {/* Execution ID */} -
- - Execution ID: - {response.id} -
+ {/* Execution ID - only for actions */} + {response.id && ( +
+ + Execution ID: + {response.id} +
+ )} + + {/* Result (for services or completed actions) */} + {currentResult !== undefined && currentResult !== null && ( +
+
+ + Result: +
+
+                            {typeof currentResult === 'string' ? currentResult : JSON.stringify(currentResult, null, 2)}
+                        
+
+ )}
); diff --git a/src/components/OperationsPanel.tsx b/src/components/OperationsPanel.tsx index b2fd2e9..c31ab4e 100644 --- a/src/components/OperationsPanel.tsx +++ b/src/components/OperationsPanel.tsx @@ -107,10 +107,8 @@ function isEmptySchema(schema: TopicSchema | null): boolean { */ function OperationRow({ operation, - entityId, onInvoke, defaultExpanded = false, - entityType = 'components', }: { operation: Operation; entityId: string; @@ -190,12 +188,15 @@ function OperationRow({ const response = await onInvoke(operation.name, request); if (response) { + // Determine if this is an action based on operation kind (not response) + const isAction = operation.kind === 'action'; // Add to history (newest first, max 10 entries) const entry: OperationHistoryEntry = { id: crypto.randomUUID(), timestamp: new Date(), response, - executionId: response.kind === 'action' && !response.error ? response.id : undefined, + // For actions, always track executionId for status display + executionId: isAction && response.id ? response.id : undefined, }; setHistory((prev) => [entry, ...prev.slice(0, 9)]); } @@ -356,16 +357,6 @@ function OperationRow({
)} - {/* Action status monitoring for latest action */} - {latestExecutionId && operation.kind === 'action' && ( - - )} - {/* History section */} {history.length > 0 && (
@@ -412,7 +403,10 @@ function OperationRow({
{entry.timestamp.toLocaleTimeString()}
- +
))}
@@ -420,12 +414,22 @@ function OperationRow({ {/* Show only latest response when history is collapsed */} {!showHistory && latestEntry && ( - + )}
)}
+ + {/* Action status monitoring - OUTSIDE CollapsibleContent to keep polling active when collapsed */} + {latestExecutionId && operation.kind === 'action' && ( +
+ +
+ )}
); @@ -496,11 +500,11 @@ export function OperationsPanel({ entityId, highlightOperation, entityType = 'co

This entity has no services or actions

) : ( -
+
{/* Services section */} {services.length > 0 && (
-

+

Services

@@ -522,7 +526,7 @@ export function OperationsPanel({ entityId, highlightOperation, entityType = 'co {/* Actions section */} {actions.length > 0 && (
-

+

Actions

diff --git a/src/lib/sovd-api.ts b/src/lib/sovd-api.ts index f41eee2..e488583 100644 --- a/src/lib/sovd-api.ts +++ b/src/lib/sovd-api.ts @@ -16,6 +16,7 @@ import type { Parameter, // New SOVD-compliant types Execution, + ExecutionStatus, CreateExecutionRequest, CreateExecutionResponse, ListExecutionsResponse, @@ -1021,6 +1022,55 @@ export class SovdApiClient { return await response.json(); } + /** + * Normalize execution status from API response. + * Maps SOVD-compliant status values to frontend ExecutionStatus. + * Uses x-medkit.ros2_status when available for actual ROS 2 outcome. + */ + private normalizeExecutionStatus(apiResponse: { + status?: string; + 'x-medkit'?: { + ros2_status?: string; + }; + }): ExecutionStatus { + const apiStatus = apiResponse.status?.toLowerCase() || 'pending'; + const ros2Status = apiResponse['x-medkit']?.ros2_status?.toLowerCase(); + + // SOVD-compliant: use 'completed' directly for terminal success state + if (apiStatus === 'completed') { + // Check ros2_status for more specific outcome if available + if (ros2Status === 'failed' || ros2Status === 'failure' || ros2Status === 'aborted') { + return 'failed'; + } + if (ros2Status === 'canceled' || ros2Status === 'cancelled') { + return 'canceled'; + } + // Return 'completed' (SOVD standard) - includes succeeded, success, etc. + return 'completed'; + } + + // Map other API statuses + if (apiStatus === 'running' || apiStatus === 'executing' || apiStatus === 'in_progress') { + return 'running'; + } + if (apiStatus === 'pending' || apiStatus === 'created' || apiStatus === 'queued') { + return 'pending'; + } + if (apiStatus === 'canceled' || apiStatus === 'cancelled') { + return 'canceled'; + } + if (apiStatus === 'failed' || apiStatus === 'failure' || apiStatus === 'error') { + return 'failed'; + } + if (apiStatus === 'succeeded' || apiStatus === 'success' || apiStatus === 'finished') { + return 'succeeded'; + } + + // Fallback to a neutral state for unknown statuses and log for investigation + console.warn('[SovdApiClient] Unknown execution status encountered', { apiStatus, ros2Status }); + return 'pending'; + } + /** * Get execution status by ID * @param entityId Entity ID @@ -1049,7 +1099,30 @@ export class SovdApiClient { throw new Error(errorData.message || `HTTP ${response.status}`); } - return await response.json(); + const rawData = await response.json(); + + // Normalize status from API response + const normalizedStatus = this.normalizeExecutionStatus(rawData); + + // Extract result from x-medkit if present + const xMedkit = rawData['x-medkit'] as + | { + result?: unknown; + feedback?: unknown; + error?: string; + } + | undefined; + + return { + id: rawData.id || executionId, + status: normalizedStatus, + created_at: rawData.created_at || new Date().toISOString(), + started_at: rawData.started_at, + finished_at: rawData.finished_at, + result: rawData.result ?? xMedkit?.result, + error: rawData.error ?? xMedkit?.error, + last_feedback: rawData.last_feedback ?? xMedkit?.feedback, + }; } /** @@ -1080,7 +1153,18 @@ export class SovdApiClient { throw new Error(errorData.message || `HTTP ${response.status}`); } - return await response.json(); + const rawData = await response.json(); + + // Normalize status from API response + const normalizedStatus = this.normalizeExecutionStatus(rawData); + + return { + id: rawData.id || executionId, + status: normalizedStatus, + created_at: rawData.created_at || new Date().toISOString(), + result: rawData.result, + error: rawData.error, + }; } // =========================================================================== diff --git a/src/lib/store.ts b/src/lib/store.ts index 6312dd7..64cb852 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -20,9 +20,25 @@ import type { import { createSovdClient, type SovdApiClient, type SovdResourceEntityType } from './sovd-api'; const STORAGE_KEY = 'sovd_web_ui_server_url'; +const EXECUTION_POLL_INTERVAL_MS = 1000; +const EXECUTION_CLEANUP_AFTER_MS = 5 * 60 * 1000; // 5 minutes export type TreeViewMode = 'logical' | 'functional'; +/** + * Extended Execution with metadata needed for polling + */ +export interface TrackedExecution extends Execution { + /** Entity ID for API calls */ + entityId: string; + /** Operation name for API calls */ + operationName: string; + /** Entity type for API calls */ + entityType: SovdResourceEntityType; + /** Timestamp when execution reached terminal state (for cleanup) */ + completedAt?: number; +} + export interface AppState { // Connection state serverUrl: string | null; @@ -53,8 +69,9 @@ export interface AppState { isLoadingOperations: boolean; // Active executions (for monitoring async actions) - SOVD Execution Model - activeExecutions: Map; // executionId -> execution - autoRefreshExecutions: boolean; // checkbox state for auto-refresh + activeExecutions: Map; // executionId -> tracked execution with metadata + autoRefreshExecutions: boolean; // flag for auto-refresh polling + executionPollingIntervalId: ReturnType | null; // polling interval ID // Faults state (diagnostic trouble codes) faults: Fault[]; @@ -107,6 +124,8 @@ export interface AppState { entityType?: SovdResourceEntityType ) => Promise; setAutoRefreshExecutions: (enabled: boolean) => void; + startExecutionPolling: () => void; + stopExecutionPolling: () => void; // Faults actions fetchFaults: () => Promise; @@ -503,7 +522,8 @@ export const useAppStore = create()( // Active executions state - SOVD Execution model activeExecutions: new Map(), - autoRefreshExecutions: false, + autoRefreshExecutions: true, + executionPollingIntervalId: null, // Faults state faults: [], @@ -550,6 +570,9 @@ export const useAppStore = create()( // Disconnect from server disconnect: () => { + // Stop execution polling + get().stopExecutionPolling(); + set({ serverUrl: null, baseEndpoint: '', @@ -562,6 +585,7 @@ export const useAppStore = create()( expandedPaths: [], selectedPath: null, selectedEntity: null, + activeExecutions: new Map(), }); }, @@ -1109,19 +1133,36 @@ export const useAppStore = create()( try { const result = await client.createExecution(entityId, operationName, request, entityType); - if (result.kind === 'action' && !result.error) { - // Track the new execution for actions - const newExecutions = new Map(activeExecutions); - newExecutions.set(result.id, { + // Track all executions with an ID (both running and completed/failed) + // Actions always get an ID, services may or may not depending on backend + if (result.id && !result.error) { + // Track the new execution for actions with metadata for polling + const trackedExecution: TrackedExecution = { id: result.id, status: result.status, created_at: new Date().toISOString(), result: result.result, - }); - set({ activeExecutions: newExecutions }); - toast.success(`Action execution ${result.id.slice(0, 8)}... started`); - } else if (result.kind === 'service' && !result.error) { - toast.success(`Service ${operationName} executed successfully`); + // Metadata for polling + entityId, + operationName, + entityType, + }; + const newExecutions = new Map(activeExecutions); + newExecutions.set(result.id, trackedExecution); + // Enable auto-refresh and start polling when new execution is created + set({ activeExecutions: newExecutions, autoRefreshExecutions: true }); + // Call directly from get() to ensure fresh state + get().startExecutionPolling(); + + // Show appropriate toast based on status + const isRunning = result.status === 'pending' || result.status === 'running'; + if (isRunning) { + toast.success(`Action execution ${result.id.slice(0, 8)}... started`); + } else if (result.status === 'failed') { + toast.error(`Action execution ${result.id.slice(0, 8)}... failed`); + } else if (result.status === 'completed' || result.status === 'succeeded') { + toast.success(`Action execution ${result.id.slice(0, 8)}... completed`); + } } else if (result.error) { toast.error(`Operation failed: ${result.error}`); } @@ -1145,11 +1186,24 @@ export const useAppStore = create()( try { const execution = await client.getExecution(entityId, operationName, executionId, entityType); + // Preserve metadata when updating execution + const trackedExecution: TrackedExecution = { + ...execution, + entityId, + operationName, + entityType, + }; const newExecutions = new Map(activeExecutions); - newExecutions.set(executionId, execution); + newExecutions.set(executionId, trackedExecution); set({ activeExecutions: newExecutions }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[refreshExecutionStatus] Error:', message, { + entityId, + operationName, + executionId, + entityType, + }); toast.error(`Failed to refresh execution status: ${message}`); } }, @@ -1165,8 +1219,15 @@ export const useAppStore = create()( try { const execution = await client.cancelExecution(entityId, operationName, executionId, entityType); + // Preserve metadata when updating execution + const trackedExecution: TrackedExecution = { + ...execution, + entityId, + operationName, + entityType, + }; const newExecutions = new Map(activeExecutions); - newExecutions.set(executionId, execution); + newExecutions.set(executionId, trackedExecution); set({ activeExecutions: newExecutions }); toast.success(`Cancel request sent for execution ${executionId.slice(0, 8)}...`); return true; @@ -1179,6 +1240,109 @@ export const useAppStore = create()( setAutoRefreshExecutions: (enabled: boolean) => { set({ autoRefreshExecutions: enabled }); + if (enabled) { + get().startExecutionPolling(); + } else { + get().stopExecutionPolling(); + } + }, + + startExecutionPolling: () => { + // Atomic check: get current state and immediately set to prevent race + const state = get(); + + // Don't start if already running, disabled, or no client + if (state.executionPollingIntervalId || !state.autoRefreshExecutions || !state.client) { + return; + } + + // Create interval immediately and set it atomically + const intervalId = setInterval(async () => { + const { activeExecutions, autoRefreshExecutions: stillEnabled, client: currentClient } = get(); + + // Stop polling if disabled or no client + if (!stillEnabled || !currentClient) { + get().stopExecutionPolling(); + return; + } + + // Cleanup old completed executions (older than EXECUTION_CLEANUP_AFTER_MS) + const now = Date.now(); + const executionsToCleanup = Array.from(activeExecutions.entries()).filter( + ([, exec]) => exec.completedAt && now - exec.completedAt > EXECUTION_CLEANUP_AFTER_MS + ); + + if (executionsToCleanup.length > 0) { + const cleanedExecutions = new Map(activeExecutions); + for (const [id] of executionsToCleanup) { + cleanedExecutions.delete(id); + } + set({ activeExecutions: cleanedExecutions }); + } + + // Find all running executions + const runningExecutions = Array.from(activeExecutions.values()).filter( + (exec) => exec.status === 'pending' || exec.status === 'running' + ); + + // If no running executions, stop polling + if (runningExecutions.length === 0) { + get().stopExecutionPolling(); + return; + } + + // Refresh all running executions in parallel, then batch update + const results = await Promise.all( + runningExecutions.map(async (exec) => { + try { + const updated = await currentClient.getExecution( + exec.entityId, + exec.operationName, + exec.id, + exec.entityType + ); + const isTerminal = ['succeeded', 'failed', 'canceled', 'completed'].includes( + updated.status + ); + const trackedExec: TrackedExecution = { + ...updated, + entityId: exec.entityId, + operationName: exec.operationName, + entityType: exec.entityType, + completedAt: isTerminal ? Date.now() : undefined, + }; + return { id: exec.id, execution: trackedExec }; + } catch (error) { + console.error('[pollExecution] Error:', error, { executionId: exec.id }); + return null; + } + }) + ); + + // Batch update all successful results in a single set() call + const validResults = results.filter( + (r): r is { id: string; execution: TrackedExecution } => r !== null + ); + if (validResults.length > 0) { + const { activeExecutions: currentExecutions } = get(); + const newExecutions = new Map(currentExecutions); + for (const { id, execution } of validResults) { + newExecutions.set(id, execution); + } + set({ activeExecutions: newExecutions }); + } + }, EXECUTION_POLL_INTERVAL_MS); + + // Set interval ID immediately to prevent race condition + set({ executionPollingIntervalId: intervalId }); + }, + + stopExecutionPolling: () => { + const { executionPollingIntervalId } = get(); + if (executionPollingIntervalId) { + clearInterval(executionPollingIntervalId); + set({ executionPollingIntervalId: null }); + } }, // =========================================================================== diff --git a/src/lib/types.ts b/src/lib/types.ts index 4db0171..af0c77f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -412,7 +412,7 @@ export interface ComponentWithOperations { /** * Execution status values for SOVD operations */ -export type ExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled'; +export type ExecutionStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'canceled' | 'completed'; /** * Execution resource representing an operation invocation (SOVD-compliant) @@ -450,14 +450,16 @@ export interface CreateExecutionRequest { * Response from POST /{entity}/operations/{op}/executions */ export interface CreateExecutionResponse { - /** Execution ID for tracking */ - id: string; + /** Execution ID for tracking (optional for service calls which complete immediately) */ + id?: string; /** Initial execution status */ status: ExecutionStatus; /** Operation kind (service or action) */ kind: OperationKind; /** Result data (for synchronous service calls) */ result?: unknown; + /** Parameters/output data (for service call responses) */ + parameters?: unknown; /** Error message if execution creation failed */ error?: string; }