Add orchestrator types and error handling configurations

- Introduced new TypeScript types for orchestrator functionality, including `SessionStrategy`, `ErrorHandlingStrategy`, and `OrchestrationStep`.
- Defined interfaces for `OrchestrationPlan` and `ManualOrchestrationParams` to facilitate orchestration management.
- Added a new PNG image file for visual representation.
- Created a placeholder file named 'nul' for future use.
This commit is contained in:
catlog22
2026-02-14 12:54:08 +08:00
parent cdb240d2c2
commit 4d22ae4b2f
56 changed files with 4767 additions and 425 deletions

View File

@@ -356,15 +356,21 @@ export const useCliStreamStore = create<CliStreamState>()(
// Also update in executions
const state = get();
if (state.executions[executionId]) {
set((state) => ({
executions: {
...state.executions,
[executionId]: {
...state.executions[executionId],
output: [...state.executions[executionId].output, line],
set((state) => {
const currentOutput = state.executions[executionId].output;
const updatedOutput = [...currentOutput, line];
return {
executions: {
...state.executions,
[executionId]: {
...state.executions[executionId],
output: updatedOutput.length > MAX_OUTPUT_LINES
? updatedOutput.slice(-MAX_OUTPUT_LINES)
: updatedOutput,
},
},
},
}), false, 'cliStream/updateExecutionOutput');
};
}, false, 'cliStream/updateExecutionOutput');
}
},
@@ -529,11 +535,14 @@ export const useCliStreamStore = create<CliStreamState>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_OUTPUTS: CliOutputLine[] = [];
/**
* Selector for getting outputs by execution ID
*/
export const selectOutputs = (state: CliStreamState, executionId: string) =>
state.outputs[executionId] || [];
state.outputs[executionId] || EMPTY_OUTPUTS;
/**
* Selector for getting addOutput action

View File

@@ -464,6 +464,7 @@ export const useExecutionStore = create<ExecutionStore>()(
);
// Selectors for common access patterns
const EMPTY_TOOL_CALLS: never[] = [];
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
export const selectLogs = (state: ExecutionStore) => state.logs;
@@ -474,7 +475,7 @@ export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollL
export const selectNodeOutputs = (state: ExecutionStore, nodeId: string) =>
state.nodeOutputs[nodeId];
export const selectNodeToolCalls = (state: ExecutionStore, nodeId: string) =>
state.nodeToolCalls[nodeId] || [];
state.nodeToolCalls[nodeId] || EMPTY_TOOL_CALLS;
export const selectSelectedNodeId = (state: ExecutionStore) => state.selectedNodeId;
// Helper to check if execution is active
@@ -489,6 +490,6 @@ export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
// Helper to get selected node's tool calls
export const selectSelectedNodeToolCalls = (state: ExecutionStore) => {
if (!state.selectedNodeId) return [];
return state.nodeToolCalls[state.selectedNodeId] || [];
if (!state.selectedNodeId) return EMPTY_TOOL_CALLS;
return state.nodeToolCalls[state.selectedNodeId] || EMPTY_TOOL_CALLS;
};

View File

@@ -111,6 +111,18 @@ export {
selectHasActiveExecution,
} from './queueExecutionStore';
// Orchestrator Store
export {
useOrchestratorStore,
selectActivePlans,
selectPlan,
selectStepStatuses,
selectStepRunState,
selectHasRunningPlan,
selectActivePlanCount,
selectPlanStepByExecutionId,
} from './orchestratorStore';
// Terminal Panel Store Types
export type {
PanelView,
@@ -131,6 +143,15 @@ export type {
QueueExecutionStore,
} from './queueExecutionStore';
// Orchestrator Store Types
export type {
StepRunState,
OrchestrationRunState,
OrchestratorState,
OrchestratorActions,
OrchestratorStore,
} from './orchestratorStore';
// Re-export types for convenience
export type {
// App Store Types

View File

@@ -0,0 +1,533 @@
// ========================================
// Orchestrator Store
// ========================================
// Zustand store for orchestration plan execution state machine.
// Manages multiple concurrent orchestration plans, step lifecycle,
// and execution-to-step mapping for WebSocket callback chain matching.
//
// NOTE: This is SEPARATE from executionStore.ts (which handles Flow DAG
// execution visualization). This store manages the plan-level state machine
// for automated step advancement.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
OrchestrationPlan,
OrchestrationStatus,
StepStatus,
} from '../types/orchestrator';
// ========== Types ==========
/** Runtime state for a single orchestration step */
export interface StepRunState {
/** Current step status */
status: StepStatus;
/** CLI execution ID assigned when the step starts executing */
executionId?: string;
/** Step execution result (populated on completion) */
result?: unknown;
/** Error message (populated on failure) */
error?: string;
/** ISO timestamp when step started executing */
startedAt?: string;
/** ISO timestamp when step completed/failed */
completedAt?: string;
/** Number of retry attempts for this step */
retryCount: number;
}
/** Runtime state for an entire orchestration plan */
export interface OrchestrationRunState {
/** The orchestration plan definition */
plan: OrchestrationPlan;
/** Current overall status of the plan */
status: OrchestrationStatus;
/** Index of the current step being executed (for sequential tracking) */
currentStepIndex: number;
/** Per-step runtime state keyed by step ID */
stepStatuses: Record<string, StepRunState>;
/** Maps executionId -> stepId for callback chain matching */
executionIdMap: Record<string, string>;
/** Optional session key for session-scoped orchestration */
sessionKey?: string;
/** Error message if the plan itself failed */
error?: string;
}
export interface OrchestratorState {
/** All active orchestration plans keyed by plan ID */
activePlans: Record<string, OrchestrationRunState>;
}
export interface OrchestratorActions {
/** Initialize and start an orchestration plan */
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => void;
/** Pause a running orchestration */
pauseOrchestration: (planId: string) => void;
/** Resume a paused orchestration */
resumeOrchestration: (planId: string) => void;
/** Stop an orchestration (marks as failed) */
stopOrchestration: (planId: string, error?: string) => void;
/** Update a step's status with optional result or error */
updateStepStatus: (
planId: string,
stepId: string,
status: StepStatus,
result?: { data?: unknown; error?: string }
) => void;
/** Register an execution ID mapping for callback chain matching */
registerExecution: (planId: string, stepId: string, executionId: string) => void;
/** Retry a failed step (reset to pending, increment retryCount) */
retryStep: (planId: string, stepId: string) => void;
/** Skip a step (mark as skipped, treated as completed for dependency resolution) */
skipStep: (planId: string, stepId: string) => void;
/**
* Internal: Find and return the next ready step ID.
* Does NOT execute the step - only identifies it and updates currentStepIndex.
* If no steps remain, marks plan as completed.
* Returns the step ID if found, null otherwise.
*/
_advanceToNextStep: (planId: string) => string | null;
/**
* Pure getter: Find the next step whose dependsOn are all completed/skipped.
* Returns the step ID or null if none are ready.
*/
getNextReadyStep: (planId: string) => string | null;
/** Remove a completed/failed plan from active tracking */
removePlan: (planId: string) => void;
/** Clear all plans */
clearAll: () => void;
}
export type OrchestratorStore = OrchestratorState & OrchestratorActions;
// ========== Helpers ==========
/**
* Check if a step's dependencies are all satisfied (completed or skipped).
*/
function areDependenciesSatisfied(
step: { dependsOn: string[] },
stepStatuses: Record<string, StepRunState>
): boolean {
return step.dependsOn.every((depId) => {
const depState = stepStatuses[depId];
return depState && (depState.status === 'completed' || depState.status === 'skipped');
});
}
/**
* Find the next ready step from a plan's steps that is pending and has all deps satisfied.
*/
function findNextReadyStep(
plan: OrchestrationPlan,
stepStatuses: Record<string, StepRunState>
): string | null {
for (const step of plan.steps) {
const state = stepStatuses[step.id];
if (state && state.status === 'pending' && areDependenciesSatisfied(step, stepStatuses)) {
return step.id;
}
}
return null;
}
/**
* Check if all steps are in a terminal state (completed, skipped, or failed).
*/
function areAllStepsTerminal(stepStatuses: Record<string, StepRunState>): boolean {
return Object.values(stepStatuses).every(
(s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed'
);
}
// ========== Initial State ==========
const initialState: OrchestratorState = {
activePlans: {},
};
// ========== Store ==========
export const useOrchestratorStore = create<OrchestratorStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Plan Lifecycle ==========
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => {
// Initialize all step statuses as pending
const stepStatuses: Record<string, StepRunState> = {};
for (const step of plan.steps) {
stepStatuses[step.id] = {
status: 'pending',
retryCount: 0,
};
}
const runState: OrchestrationRunState = {
plan,
status: 'running',
currentStepIndex: 0,
stepStatuses,
executionIdMap: {},
sessionKey,
};
set(
(state) => ({
activePlans: {
...state.activePlans,
[plan.id]: runState,
},
}),
false,
'startOrchestration'
);
},
pauseOrchestration: (planId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'paused',
},
},
};
},
false,
'pauseOrchestration'
);
},
resumeOrchestration: (planId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'paused') return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'running',
},
},
};
},
false,
'resumeOrchestration'
);
},
stopOrchestration: (planId: string, error?: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'failed',
error: error ?? 'Orchestration stopped by user',
},
},
};
},
false,
'stopOrchestration'
);
},
// ========== Step State Updates ==========
updateStepStatus: (
planId: string,
stepId: string,
status: StepStatus,
result?: { data?: unknown; error?: string }
) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
const now = new Date().toISOString();
const isStarting = status === 'running';
const isTerminal =
status === 'completed' || status === 'failed' || status === 'skipped';
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status,
startedAt: isStarting ? now : stepState.startedAt,
completedAt: isTerminal ? now : stepState.completedAt,
result: result?.data ?? stepState.result,
error: result?.error ?? stepState.error,
},
},
},
},
};
},
false,
'updateStepStatus'
);
},
registerExecution: (planId: string, stepId: string, executionId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
executionIdMap: {
...existing.executionIdMap,
[executionId]: stepId,
},
},
},
};
},
false,
'registerExecution'
);
},
retryStep: (planId: string, stepId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
status: 'running',
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status: 'pending',
error: undefined,
result: undefined,
startedAt: undefined,
completedAt: undefined,
retryCount: stepState.retryCount + 1,
},
},
},
},
};
},
false,
'retryStep'
);
},
skipStep: (planId: string, stepId: string) => {
set(
(state) => {
const existing = state.activePlans[planId];
if (!existing) return state;
const stepState = existing.stepStatuses[stepId];
if (!stepState) return state;
return {
activePlans: {
...state.activePlans,
[planId]: {
...existing,
stepStatuses: {
...existing.stepStatuses,
[stepId]: {
...stepState,
status: 'skipped',
completedAt: new Date().toISOString(),
},
},
},
},
};
},
false,
'skipStep'
);
},
// ========== Step Advancement ==========
_advanceToNextStep: (planId: string): string | null => {
const state = get();
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return null;
// Find the next step that is pending with all dependencies satisfied
const nextStepId = findNextReadyStep(existing.plan, existing.stepStatuses);
if (nextStepId) {
// Update currentStepIndex to match the found step
const stepIndex = existing.plan.steps.findIndex((s) => s.id === nextStepId);
set(
(prevState) => {
const plan = prevState.activePlans[planId];
if (!plan) return prevState;
return {
activePlans: {
...prevState.activePlans,
[planId]: {
...plan,
currentStepIndex: stepIndex >= 0 ? stepIndex : plan.currentStepIndex,
},
},
};
},
false,
'_advanceToNextStep'
);
return nextStepId;
}
// No pending steps found - check if all are terminal
if (areAllStepsTerminal(existing.stepStatuses)) {
// Check if any step failed (and was not skipped)
const hasFailed = Object.values(existing.stepStatuses).some(
(s) => s.status === 'failed'
);
set(
(prevState) => {
const plan = prevState.activePlans[planId];
if (!plan) return prevState;
return {
activePlans: {
...prevState.activePlans,
[planId]: {
...plan,
status: hasFailed ? 'failed' : 'completed',
},
},
};
},
false,
'_advanceToNextStep/complete'
);
}
return null;
},
getNextReadyStep: (planId: string): string | null => {
const state = get();
const existing = state.activePlans[planId];
if (!existing || existing.status !== 'running') return null;
return findNextReadyStep(existing.plan, existing.stepStatuses);
},
// ========== Cleanup ==========
removePlan: (planId: string) => {
set(
(state) => {
const { [planId]: _removed, ...remaining } = state.activePlans;
return { activePlans: remaining };
},
false,
'removePlan'
);
},
clearAll: () => {
set(initialState, false, 'clearAll');
},
}),
{ name: 'OrchestratorStore' }
)
);
// ========== Selectors ==========
/** Select all active plans */
export const selectActivePlans = (state: OrchestratorStore) => state.activePlans;
/** Select a specific plan by ID */
export const selectPlan =
(planId: string) =>
(state: OrchestratorStore): OrchestrationRunState | undefined =>
state.activePlans[planId];
/** Select the step statuses for a plan */
export const selectStepStatuses =
(planId: string) =>
(state: OrchestratorStore): Record<string, StepRunState> | undefined =>
state.activePlans[planId]?.stepStatuses;
/** Select a specific step's run state */
export const selectStepRunState =
(planId: string, stepId: string) =>
(state: OrchestratorStore): StepRunState | undefined =>
state.activePlans[planId]?.stepStatuses[stepId];
/** Check if any plan is currently running */
export const selectHasRunningPlan = (state: OrchestratorStore): boolean =>
Object.values(state.activePlans).some((p) => p.status === 'running');
/** Get the count of active (non-terminal) plans */
export const selectActivePlanCount = (state: OrchestratorStore): number =>
Object.values(state.activePlans).filter(
(p) => p.status === 'running' || p.status === 'paused'
).length;
/** Look up which plan and step an executionId belongs to */
export const selectPlanStepByExecutionId =
(executionId: string) =>
(
state: OrchestratorStore
): { planId: string; stepId: string } | undefined => {
for (const [planId, runState] of Object.entries(state.activePlans)) {
const stepId = runState.executionIdMap[executionId];
if (stepId) {
return { planId, stepId };
}
}
return undefined;
};

View File

@@ -157,21 +157,33 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_EXECUTIONS: QueueExecution[] = [];
/** Select all executions as a record */
export const selectQueueExecutions = (state: QueueExecutionStore) => state.executions;
/** Select only currently running executions */
/**
* Select only currently running executions.
* WARNING: Returns new array each call — use with useMemo in components.
*/
export const selectActiveExecutions = (state: QueueExecutionStore): QueueExecution[] => {
return Object.values(state.executions).filter((exec) => exec.status === 'running');
const all = Object.values(state.executions);
const running = all.filter((exec) => exec.status === 'running');
return running.length === 0 ? EMPTY_EXECUTIONS : running;
};
/** Select executions for a specific queue item */
/**
* Select executions for a specific queue item.
* WARNING: Returns new array each call — use with useMemo in components.
*/
export const selectByQueueItem =
(queueItemId: string) =>
(state: QueueExecutionStore): QueueExecution[] => {
return Object.values(state.executions).filter(
const matched = Object.values(state.executions).filter(
(exec) => exec.queueItemId === queueItemId
);
return matched.length === 0 ? EMPTY_EXECUTIONS : matched;
};
/** Compute execution statistics by status */

View File

@@ -7,16 +7,28 @@ import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import type { TeamMessageFilter } from '@/types/team';
export type TeamDetailTab = 'artifacts' | 'messages';
interface TeamStore {
selectedTeam: string | null;
autoRefresh: boolean;
messageFilter: TeamMessageFilter;
timelineExpanded: boolean;
viewMode: 'list' | 'detail';
locationFilter: 'active' | 'archived' | 'all';
searchQuery: string;
detailTab: TeamDetailTab;
setSelectedTeam: (name: string | null) => void;
toggleAutoRefresh: () => void;
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
clearMessageFilter: () => void;
setTimelineExpanded: (expanded: boolean) => void;
setViewMode: (mode: 'list' | 'detail') => void;
setLocationFilter: (filter: 'active' | 'archived' | 'all') => void;
setSearchQuery: (query: string) => void;
setDetailTab: (tab: TeamDetailTab) => void;
selectTeamAndShowDetail: (name: string) => void;
backToList: () => void;
}
export const useTeamStore = create<TeamStore>()(
@@ -27,12 +39,22 @@ export const useTeamStore = create<TeamStore>()(
autoRefresh: true,
messageFilter: {},
timelineExpanded: true,
viewMode: 'list',
locationFilter: 'active',
searchQuery: '',
detailTab: 'artifacts',
setSelectedTeam: (name) => set({ selectedTeam: name }),
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
setMessageFilter: (filter) =>
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
clearMessageFilter: () => set({ messageFilter: {} }),
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
setViewMode: (mode) => set({ viewMode: mode }),
setLocationFilter: (filter) => set({ locationFilter: filter }),
setSearchQuery: (query) => set({ searchQuery: query }),
setDetailTab: (tab) => set({ detailTab: tab }),
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'artifacts' }),
backToList: () => set({ viewMode: 'list', detailTab: 'artifacts' }),
}),
{ name: 'ccw-team-store' }
),

View File

@@ -28,6 +28,8 @@ export interface TerminalPanelActions {
openTerminal: (sessionKey: string) => void;
/** Close the terminal panel (keeps terminal order intact) */
closePanel: () => void;
/** Toggle panel open/closed; when opening, restores last active or shows queue */
togglePanel: () => void;
/** Switch active terminal without opening/closing */
setActiveTerminal: (sessionKey: string) => void;
/** Switch panel view between 'terminal' and 'queue' */
@@ -80,6 +82,24 @@ export const useTerminalPanelStore = create<TerminalPanelStore>()(
set({ isPanelOpen: false }, false, 'closePanel');
},
togglePanel: () => {
const { isPanelOpen, activeTerminalId, terminalOrder } = get();
if (isPanelOpen) {
set({ isPanelOpen: false }, false, 'togglePanel/close');
} else {
const nextActive = activeTerminalId ?? terminalOrder[0] ?? null;
set(
{
isPanelOpen: true,
activeTerminalId: nextActive,
panelView: nextActive ? 'terminal' : 'queue',
},
false,
'togglePanel/open'
);
}
},
// ========== Terminal Selection ==========
setActiveTerminal: (sessionKey: string) => {

View File

@@ -4,6 +4,7 @@
// Zustand store for managing CLI Viewer layout and tab state
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
@@ -909,6 +910,9 @@ export const useViewerStore = create<ViewerState>()(
// ========== Selectors ==========
/** Stable empty array to avoid new references */
const EMPTY_TABS: TabState[] = [];
/**
* Select the current layout
*/
@@ -940,11 +944,12 @@ export const selectPane = (state: ViewerState, paneId: PaneId) => state.panes[pa
export const selectTab = (state: ViewerState, tabId: TabId) => state.tabs[tabId];
/**
* Select tabs for a specific pane, sorted by order
* Select tabs for a specific pane, sorted by order.
* WARNING: Returns new array each call — use with useMemo or useShallow in components.
*/
export const selectPaneTabs = (state: ViewerState, paneId: PaneId): TabState[] => {
const pane = state.panes[paneId];
if (!pane) return [];
if (!pane) return EMPTY_TABS;
return [...pane.tabs].sort((a, b) => a.order - b.order);
};
@@ -964,7 +969,7 @@ export const selectActiveTab = (state: ViewerState, paneId: PaneId): TabState |
* Useful for components that only need actions, not the full state
*/
export const useViewerActions = () => {
return useViewerStore((state) => ({
return useViewerStore(useShallow((state) => ({
setLayout: state.setLayout,
addPane: state.addPane,
removePane: state.removePane,
@@ -976,5 +981,5 @@ export const useViewerActions = () => {
setFocusedPane: state.setFocusedPane,
initializeDefaultLayout: state.initializeDefaultLayout,
reset: state.reset,
}));
})));
};