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 */}
-
- setAutoRefreshExecutions(e.target.checked)}
- className="rounded border-muted-foreground focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-primary"
- disabled={isTerminal}
- aria-label="Auto-refresh execution status"
- />
- Auto-refresh
-
-
{/* Manual refresh */}
- {execution.status === 'succeeded' && 'Execution completed successfully'}
+ {(execution.status === 'succeeded' || execution.status === 'completed') &&
+ 'Execution completed successfully'}
{execution.status === 'canceled' && 'Execution was canceled'}
{execution.status === 'failed' && 'Execution failed'}
diff --git a/src/components/ConfigurationPanel.tsx b/src/components/ConfigurationPanel.tsx
index d08dfcf..12e3e24 100644
--- a/src/components/ConfigurationPanel.tsx
+++ b/src/components/ConfigurationPanel.tsx
@@ -367,7 +367,7 @@ export function ConfigurationPanel({ entityId, highlightParam, entityType = 'com
No parameters available for this component.
) : (
-
+
{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;
}