@computekit/react 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -22,6 +22,161 @@ import {
22
22
  type PoolStats,
23
23
  } from '@computekit/core';
24
24
 
25
+ // ============================================================================
26
+ // Pipeline Types (defined here for React, also exported from @computekit/core)
27
+ // ============================================================================
28
+
29
+ /** Status of a pipeline stage */
30
+ export type StageStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
31
+
32
+ /** Detailed information about a single pipeline stage */
33
+ export interface StageInfo<TInput = unknown, TOutput = unknown> {
34
+ /** Unique identifier for the stage */
35
+ id: string;
36
+ /** Display name for the stage */
37
+ name: string;
38
+ /** Name of the registered compute function to execute */
39
+ functionName: string;
40
+ /** Current status of this stage */
41
+ status: StageStatus;
42
+ /** Input data for this stage (set when stage starts) */
43
+ input?: TInput;
44
+ /** Output data from this stage (set when stage completes) */
45
+ output?: TOutput;
46
+ /** Error if stage failed */
47
+ error?: Error;
48
+ /** Start timestamp (ms since epoch) */
49
+ startedAt?: number;
50
+ /** End timestamp (ms since epoch) */
51
+ completedAt?: number;
52
+ /** Duration in milliseconds */
53
+ duration?: number;
54
+ /** Progress within this stage (0-100) */
55
+ progress?: number;
56
+ /** Number of retry attempts */
57
+ retryCount: number;
58
+ /** Compute options specific to this stage */
59
+ options?: ComputeOptions;
60
+ }
61
+
62
+ /** Configuration for a pipeline stage */
63
+ export interface StageConfig<TInput = unknown, TOutput = unknown> {
64
+ /** Unique identifier for the stage */
65
+ id: string;
66
+ /** Display name for the stage */
67
+ name: string;
68
+ /** Name of the registered compute function */
69
+ functionName: string;
70
+ /** Transform input before passing to compute function */
71
+ transformInput?: (input: TInput, previousResults: unknown[]) => unknown;
72
+ /** Transform output after compute function returns */
73
+ transformOutput?: (output: unknown) => TOutput;
74
+ /** Whether to skip this stage based on previous results */
75
+ shouldSkip?: (input: TInput, previousResults: unknown[]) => boolean;
76
+ /** Maximum retry attempts on failure (default: 0) */
77
+ maxRetries?: number;
78
+ /** Delay between retries in ms (default: 1000) */
79
+ retryDelay?: number;
80
+ /** Compute options for this stage */
81
+ options?: ComputeOptions;
82
+ }
83
+
84
+ /** Overall pipeline status */
85
+ export type PipelineStatus =
86
+ | 'idle' // Not started
87
+ | 'running' // Currently executing
88
+ | 'paused' // Paused mid-execution
89
+ | 'completed' // All stages completed successfully
90
+ | 'failed' // A stage failed (and wasn't recovered)
91
+ | 'cancelled'; // User cancelled
92
+
93
+ /** Metrics for pipeline debugging and reporting */
94
+ export interface PipelineMetrics {
95
+ /** Total stages in pipeline */
96
+ totalStages: number;
97
+ /** Number of completed stages */
98
+ completedStages: number;
99
+ /** Number of failed stages */
100
+ failedStages: number;
101
+ /** Number of skipped stages */
102
+ skippedStages: number;
103
+ /** Total retry attempts across all stages */
104
+ totalRetries: number;
105
+ /** Slowest stage info */
106
+ slowestStage: { id: string; name: string; duration: number } | null;
107
+ /** Fastest stage info */
108
+ fastestStage: { id: string; name: string; duration: number } | null;
109
+ /** Average stage duration */
110
+ averageStageDuration: number;
111
+ /** Timestamp of each stage transition for timeline view */
112
+ timeline: Array<{
113
+ stageId: string;
114
+ stageName: string;
115
+ event: 'started' | 'completed' | 'failed' | 'skipped' | 'retry';
116
+ timestamp: number;
117
+ duration?: number;
118
+ error?: string;
119
+ }>;
120
+ }
121
+
122
+ /** Comprehensive pipeline state for debugging */
123
+ export interface PipelineState<TInput = unknown, TOutput = unknown> {
124
+ /** Overall pipeline status */
125
+ status: PipelineStatus;
126
+ /** All stage information */
127
+ stages: StageInfo[];
128
+ /** Index of currently executing stage (-1 if not running) */
129
+ currentStageIndex: number;
130
+ /** Current stage info (convenience) */
131
+ currentStage: StageInfo | null;
132
+ /** Overall progress percentage (0-100) */
133
+ progress: number;
134
+ /** Final output from the last stage */
135
+ output: TOutput | null;
136
+ /** Initial input that started the pipeline */
137
+ input: TInput | null;
138
+ /** Error that caused pipeline failure */
139
+ error: Error | null;
140
+ /** Pipeline start timestamp */
141
+ startedAt: number | null;
142
+ /** Pipeline completion timestamp */
143
+ completedAt: number | null;
144
+ /** Total duration in milliseconds */
145
+ totalDuration: number | null;
146
+ /** Results from each completed stage */
147
+ stageResults: unknown[];
148
+ /** Execution metrics for debugging */
149
+ metrics: PipelineMetrics;
150
+ }
151
+
152
+ /** Result of a single item in parallel batch */
153
+ export interface BatchItemResult<TOutput = unknown> {
154
+ /** Index of the item in original array */
155
+ index: number;
156
+ /** Whether this item succeeded */
157
+ success: boolean;
158
+ /** Result if successful */
159
+ data?: TOutput;
160
+ /** Error if failed */
161
+ error?: Error;
162
+ /** Duration in ms */
163
+ duration: number;
164
+ }
165
+
166
+ /** Aggregate result of parallel batch processing */
167
+ export interface ParallelBatchResult<TOutput = unknown> {
168
+ /** All individual results */
169
+ results: BatchItemResult<TOutput>[];
170
+ /** Successfully processed items */
171
+ successful: TOutput[];
172
+ /** Failed items with their errors */
173
+ failed: Array<{ index: number; error: Error }>;
174
+ /** Total duration */
175
+ totalDuration: number;
176
+ /** Success rate (0-1) */
177
+ successRate: number;
178
+ }
179
+
25
180
  // ============================================================================
26
181
  // Context
27
182
  // ============================================================================
@@ -92,6 +247,11 @@ export function useComputeKit(): ComputeKit {
92
247
  // useCompute Hook
93
248
  // ============================================================================
94
249
 
250
+ /**
251
+ * Status of a compute operation
252
+ */
253
+ export type ComputeStatus = 'idle' | 'running' | 'success' | 'error' | 'cancelled';
254
+
95
255
  /**
96
256
  * State returned by useCompute
97
257
  */
@@ -104,6 +264,8 @@ export interface UseComputeState<T> {
104
264
  error: Error | null;
105
265
  /** Progress information */
106
266
  progress: ComputeProgress | null;
267
+ /** Current status of the computation */
268
+ status: ComputeStatus;
107
269
  }
108
270
 
109
271
  /**
@@ -163,27 +325,37 @@ export function useCompute<TInput = unknown, TOutput = unknown>(
163
325
  ): UseComputeReturn<TInput, TOutput> {
164
326
  const kit = useComputeKit();
165
327
  const abortControllerRef = useRef<AbortController | null>(null);
328
+ const cancelledRef = useRef(false);
166
329
 
167
330
  const [state, setState] = useState<UseComputeState<TOutput>>({
168
331
  data: null,
169
332
  loading: false,
170
333
  error: null,
171
334
  progress: null,
335
+ status: 'idle',
172
336
  });
173
337
 
174
338
  const reset = useCallback(() => {
339
+ cancelledRef.current = false;
175
340
  setState({
176
341
  data: null,
177
342
  loading: false,
178
343
  error: null,
179
344
  progress: null,
345
+ status: 'idle',
180
346
  });
181
347
  }, []);
182
348
 
183
349
  const cancel = useCallback(() => {
184
350
  if (abortControllerRef.current) {
351
+ cancelledRef.current = true;
185
352
  abortControllerRef.current.abort();
186
353
  abortControllerRef.current = null;
354
+ setState((prev) => ({
355
+ ...prev,
356
+ loading: false,
357
+ status: 'cancelled',
358
+ }));
187
359
  }
188
360
  }, []);
189
361
 
@@ -191,6 +363,7 @@ export function useCompute<TInput = unknown, TOutput = unknown>(
191
363
  async (input: TInput, runOptions?: ComputeOptions) => {
192
364
  // Cancel any ongoing computation
193
365
  cancel();
366
+ cancelledRef.current = false;
194
367
 
195
368
  // Create new abort controller
196
369
  const abortController = new AbortController();
@@ -203,9 +376,10 @@ export function useCompute<TInput = unknown, TOutput = unknown>(
203
376
  loading: true,
204
377
  error: null,
205
378
  progress: null,
379
+ status: 'running',
206
380
  }));
207
381
  } else {
208
- setState((prev) => ({ ...prev, loading: true }));
382
+ setState((prev) => ({ ...prev, loading: true, status: 'running' }));
209
383
  }
210
384
 
211
385
  try {
@@ -226,15 +400,17 @@ export function useCompute<TInput = unknown, TOutput = unknown>(
226
400
  loading: false,
227
401
  error: null,
228
402
  progress: null,
403
+ status: 'success',
229
404
  });
230
405
  }
231
406
  } catch (err) {
232
- if (!abortController.signal.aborted) {
407
+ if (!abortController.signal.aborted && !cancelledRef.current) {
233
408
  setState({
234
409
  data: null,
235
410
  loading: false,
236
411
  error: err instanceof Error ? err : new Error(String(err)),
237
412
  progress: null,
413
+ status: 'error',
238
414
  });
239
415
  }
240
416
  }
@@ -396,6 +572,1024 @@ export function useWasmSupport(): boolean {
396
572
  return kit.isWasmSupported();
397
573
  }
398
574
 
575
+ // ============================================================================
576
+ // usePipeline Hook - Multi-stage Processing
577
+ // ============================================================================
578
+
579
+ /**
580
+ * Options for usePipeline hook
581
+ */
582
+ export interface UsePipelineOptions {
583
+ /** Stop pipeline on first stage failure (default: true) */
584
+ stopOnError?: boolean;
585
+ /** Global timeout for entire pipeline in ms */
586
+ timeout?: number;
587
+ /** Enable detailed timeline tracking (default: true) */
588
+ trackTimeline?: boolean;
589
+ /** Called when pipeline state changes */
590
+ onStateChange?: (state: PipelineState) => void;
591
+ /** Called when a stage starts */
592
+ onStageStart?: (stage: StageInfo) => void;
593
+ /** Called when a stage completes */
594
+ onStageComplete?: (stage: StageInfo) => void;
595
+ /** Called when a stage fails */
596
+ onStageError?: (stage: StageInfo, error: Error) => void;
597
+ /** Called when a stage is retried */
598
+ onStageRetry?: (stage: StageInfo, attempt: number) => void;
599
+ /** Automatically run pipeline on mount */
600
+ autoRun?: boolean;
601
+ /** Initial input for autoRun */
602
+ initialInput?: unknown;
603
+ }
604
+
605
+ /**
606
+ * Actions returned by usePipeline
607
+ */
608
+ export interface UsePipelineActions<TInput> {
609
+ /** Start the pipeline with input */
610
+ run: (input: TInput) => Promise<void>;
611
+ /** Cancel the running pipeline */
612
+ cancel: () => void;
613
+ /** Reset pipeline to initial state */
614
+ reset: () => void;
615
+ /** Pause the pipeline (if supported) */
616
+ pause: () => void;
617
+ /** Resume a paused pipeline */
618
+ resume: () => void;
619
+ /** Retry failed stages */
620
+ retry: () => Promise<void>;
621
+ /** Get a formatted report of the pipeline execution */
622
+ getReport: () => PipelineReport;
623
+ }
624
+
625
+ /**
626
+ * Formatted report for debugging
627
+ */
628
+ export interface PipelineReport {
629
+ /** Human-readable summary */
630
+ summary: string;
631
+ /** Detailed stage-by-stage breakdown */
632
+ stageDetails: Array<{
633
+ name: string;
634
+ status: StageStatus;
635
+ duration: string;
636
+ error?: string;
637
+ }>;
638
+ /** Timeline of events */
639
+ timeline: string[];
640
+ /** Performance insights */
641
+ insights: string[];
642
+ /** Raw metrics */
643
+ metrics: PipelineMetrics;
644
+ }
645
+
646
+ /**
647
+ * Return type for usePipeline
648
+ */
649
+ export type UsePipelineReturn<TInput, TOutput> = PipelineState<TInput, TOutput> &
650
+ UsePipelineActions<TInput> & {
651
+ /** Whether pipeline is currently running */
652
+ isRunning: boolean;
653
+ /** Whether pipeline completed successfully */
654
+ isComplete: boolean;
655
+ /** Whether pipeline has failed */
656
+ isFailed: boolean;
657
+ /** Quick access to check if a specific stage is done */
658
+ isStageComplete: (stageId: string) => boolean;
659
+ /** Get a specific stage by ID */
660
+ getStage: (stageId: string) => StageInfo | undefined;
661
+ };
662
+
663
+ /**
664
+ * Create initial pipeline state
665
+ */
666
+ function createInitialPipelineState<TInput, TOutput>(
667
+ stages: StageConfig[]
668
+ ): PipelineState<TInput, TOutput> {
669
+ return {
670
+ status: 'idle',
671
+ stages: stages.map((config) => ({
672
+ id: config.id,
673
+ name: config.name,
674
+ functionName: config.functionName,
675
+ status: 'pending' as StageStatus,
676
+ retryCount: 0,
677
+ options: config.options,
678
+ })),
679
+ currentStageIndex: -1,
680
+ currentStage: null,
681
+ progress: 0,
682
+ output: null,
683
+ input: null,
684
+ error: null,
685
+ startedAt: null,
686
+ completedAt: null,
687
+ totalDuration: null,
688
+ stageResults: [],
689
+ metrics: {
690
+ totalStages: stages.length,
691
+ completedStages: 0,
692
+ failedStages: 0,
693
+ skippedStages: 0,
694
+ totalRetries: 0,
695
+ slowestStage: null,
696
+ fastestStage: null,
697
+ averageStageDuration: 0,
698
+ timeline: [],
699
+ },
700
+ };
701
+ }
702
+
703
+ /**
704
+ * Format duration in human-readable format
705
+ */
706
+ function formatDuration(ms: number): string {
707
+ if (ms < 1000) return `${ms.toFixed(0)}ms`;
708
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`;
709
+ return `${(ms / 60000).toFixed(2)}min`;
710
+ }
711
+
712
+ /**
713
+ * Hook for multi-stage pipeline processing
714
+ *
715
+ * Provides comprehensive debugging, progress tracking, and error handling
716
+ * for complex multi-stage compute workflows.
717
+ *
718
+ * @example
719
+ * ```tsx
720
+ * function FileProcessor() {
721
+ * const pipeline = usePipeline<string[], ProcessedFiles>([
722
+ * { id: 'download', name: 'Download Files', functionName: 'downloadFiles' },
723
+ * { id: 'process', name: 'Process Files', functionName: 'processFiles' },
724
+ * { id: 'compress', name: 'Compress Output', functionName: 'compressFiles' },
725
+ * ]);
726
+ *
727
+ * return (
728
+ * <div>
729
+ * <button onClick={() => pipeline.run(urls)} disabled={pipeline.isRunning}>
730
+ * Start Processing
731
+ * </button>
732
+ *
733
+ * <div>Status: {pipeline.status}</div>
734
+ * <div>Progress: {pipeline.progress.toFixed(0)}%</div>
735
+ *
736
+ * {pipeline.currentStage && (
737
+ * <div>Current: {pipeline.currentStage.name}</div>
738
+ * )}
739
+ *
740
+ * {pipeline.stages.map(stage => (
741
+ * <div key={stage.id}>
742
+ * {stage.name}: {stage.status}
743
+ * {stage.duration && ` (${stage.duration}ms)`}
744
+ * </div>
745
+ * ))}
746
+ *
747
+ * {pipeline.isFailed && (
748
+ * <button onClick={pipeline.retry}>Retry Failed</button>
749
+ * )}
750
+ *
751
+ * {pipeline.isComplete && (
752
+ * <pre>{JSON.stringify(pipeline.getReport(), null, 2)}</pre>
753
+ * )}
754
+ * </div>
755
+ * );
756
+ * }
757
+ * ```
758
+ */
759
+ export function usePipeline<TInput = unknown, TOutput = unknown>(
760
+ stageConfigs: StageConfig[],
761
+ options: UsePipelineOptions = {}
762
+ ): UsePipelineReturn<TInput, TOutput> {
763
+ const kit = useComputeKit();
764
+ const abortControllerRef = useRef<AbortController | null>(null);
765
+ const pausedRef = useRef(false);
766
+ const resumePromiseRef = useRef<{
767
+ resolve: () => void;
768
+ reject: (err: Error) => void;
769
+ } | null>(null);
770
+
771
+ const [state, setState] = useState<PipelineState<TInput, TOutput>>(() =>
772
+ createInitialPipelineState<TInput, TOutput>(stageConfigs)
773
+ );
774
+
775
+ // Memoize stage configs to prevent unnecessary re-renders
776
+ const stages = useMemo(() => stageConfigs, [stageConfigs]);
777
+
778
+ /**
779
+ * Add event to timeline
780
+ */
781
+ const addTimelineEvent = useCallback(
782
+ (
783
+ stageId: string,
784
+ stageName: string,
785
+ event: 'started' | 'completed' | 'failed' | 'skipped' | 'retry',
786
+ duration?: number,
787
+ error?: string
788
+ ) => {
789
+ if (options.trackTimeline === false) return;
790
+
791
+ setState((prev) => ({
792
+ ...prev,
793
+ metrics: {
794
+ ...prev.metrics,
795
+ timeline: [
796
+ ...prev.metrics.timeline,
797
+ {
798
+ stageId,
799
+ stageName,
800
+ event,
801
+ timestamp: Date.now(),
802
+ duration,
803
+ error,
804
+ },
805
+ ],
806
+ },
807
+ }));
808
+ },
809
+ [options.trackTimeline]
810
+ );
811
+
812
+ /**
813
+ * Update metrics after stage completion
814
+ */
815
+ const updateMetrics = useCallback(
816
+ (_completedStage: StageInfo, allStages: StageInfo[]) => {
817
+ const completedStages = allStages.filter((s) => s.status === 'completed');
818
+ const durations = completedStages
819
+ .filter((s) => s.duration !== undefined)
820
+ .map((s) => ({ id: s.id, name: s.name, duration: s.duration! }));
821
+
822
+ const slowest = durations.length
823
+ ? durations.reduce((a, b) => (a.duration > b.duration ? a : b))
824
+ : null;
825
+ const fastest = durations.length
826
+ ? durations.reduce((a, b) => (a.duration < b.duration ? a : b))
827
+ : null;
828
+ const avgDuration = durations.length
829
+ ? durations.reduce((sum, d) => sum + d.duration, 0) / durations.length
830
+ : 0;
831
+
832
+ return {
833
+ totalStages: allStages.length,
834
+ completedStages: completedStages.length,
835
+ failedStages: allStages.filter((s) => s.status === 'failed').length,
836
+ skippedStages: allStages.filter((s) => s.status === 'skipped').length,
837
+ totalRetries: allStages.reduce((sum, s) => sum + s.retryCount, 0),
838
+ slowestStage: slowest,
839
+ fastestStage: fastest,
840
+ averageStageDuration: avgDuration,
841
+ };
842
+ },
843
+ []
844
+ );
845
+
846
+ /**
847
+ * Execute a single stage with retries
848
+ */
849
+ const executeStage = useCallback(
850
+ async (
851
+ stageConfig: StageConfig,
852
+ stageIndex: number,
853
+ input: unknown,
854
+ previousResults: unknown[],
855
+ signal: AbortSignal
856
+ ): Promise<{ success: boolean; output?: unknown; error?: Error }> => {
857
+ const maxRetries = stageConfig.maxRetries ?? 0;
858
+ const retryDelay = stageConfig.retryDelay ?? 1000;
859
+ let lastError: Error | undefined;
860
+
861
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
862
+ // Check for abort
863
+ if (signal.aborted) {
864
+ return { success: false, error: new Error('Pipeline cancelled') };
865
+ }
866
+
867
+ // Check for pause
868
+ if (pausedRef.current) {
869
+ await new Promise<void>((resolve, reject) => {
870
+ resumePromiseRef.current = { resolve, reject };
871
+ });
872
+ }
873
+
874
+ // Check if should skip
875
+ if (stageConfig.shouldSkip?.(input as never, previousResults)) {
876
+ setState((prev) => {
877
+ const newStages = [...prev.stages];
878
+ newStages[stageIndex] = {
879
+ ...newStages[stageIndex],
880
+ status: 'skipped',
881
+ };
882
+ return {
883
+ ...prev,
884
+ stages: newStages,
885
+ };
886
+ });
887
+ addTimelineEvent(stageConfig.id, stageConfig.name, 'skipped');
888
+ options.onStageComplete?.(state.stages[stageIndex]);
889
+ return { success: true, output: previousResults[previousResults.length - 1] };
890
+ }
891
+
892
+ // Transform input if needed
893
+ const transformedInput = stageConfig.transformInput
894
+ ? stageConfig.transformInput(input as never, previousResults)
895
+ : input;
896
+
897
+ const startTime = performance.now();
898
+
899
+ // Update stage to running
900
+ setState((prev) => {
901
+ const newStages = [...prev.stages];
902
+ newStages[stageIndex] = {
903
+ ...newStages[stageIndex],
904
+ status: 'running',
905
+ input: transformedInput,
906
+ startedAt: Date.now(),
907
+ retryCount: attempt,
908
+ };
909
+ return {
910
+ ...prev,
911
+ stages: newStages,
912
+ currentStageIndex: stageIndex,
913
+ currentStage: newStages[stageIndex],
914
+ };
915
+ });
916
+
917
+ if (attempt === 0) {
918
+ addTimelineEvent(stageConfig.id, stageConfig.name, 'started');
919
+ options.onStageStart?.(state.stages[stageIndex]);
920
+ } else {
921
+ addTimelineEvent(stageConfig.id, stageConfig.name, 'retry');
922
+ options.onStageRetry?.(state.stages[stageIndex], attempt);
923
+ }
924
+
925
+ try {
926
+ const result = await kit.run(stageConfig.functionName, transformedInput, {
927
+ ...stageConfig.options,
928
+ signal,
929
+ onProgress: (progress) => {
930
+ setState((prev) => {
931
+ const newStages = [...prev.stages];
932
+ newStages[stageIndex] = {
933
+ ...newStages[stageIndex],
934
+ progress: progress.percent,
935
+ };
936
+ // Calculate overall progress
937
+ const stageProgress = progress.percent / 100;
938
+ const overallProgress =
939
+ ((stageIndex + stageProgress) / stages.length) * 100;
940
+ return {
941
+ ...prev,
942
+ stages: newStages,
943
+ progress: overallProgress,
944
+ };
945
+ });
946
+ },
947
+ });
948
+
949
+ const duration = performance.now() - startTime;
950
+
951
+ // Transform output if needed
952
+ const transformedOutput = stageConfig.transformOutput
953
+ ? stageConfig.transformOutput(result)
954
+ : result;
955
+
956
+ // Update stage to completed
957
+ setState((prev) => {
958
+ const newStages = [...prev.stages];
959
+ newStages[stageIndex] = {
960
+ ...newStages[stageIndex],
961
+ status: 'completed',
962
+ output: transformedOutput,
963
+ completedAt: Date.now(),
964
+ duration,
965
+ progress: 100,
966
+ };
967
+
968
+ const newMetrics = {
969
+ ...prev.metrics,
970
+ ...updateMetrics(newStages[stageIndex], newStages),
971
+ };
972
+
973
+ return {
974
+ ...prev,
975
+ stages: newStages,
976
+ metrics: newMetrics,
977
+ progress: ((stageIndex + 1) / stages.length) * 100,
978
+ };
979
+ });
980
+
981
+ addTimelineEvent(stageConfig.id, stageConfig.name, 'completed', duration);
982
+ options.onStageComplete?.(state.stages[stageIndex]);
983
+
984
+ return { success: true, output: transformedOutput };
985
+ } catch (err) {
986
+ lastError = err instanceof Error ? err : new Error(String(err));
987
+
988
+ if (attempt < maxRetries) {
989
+ // Wait before retry
990
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
991
+ continue;
992
+ }
993
+
994
+ // Final failure
995
+ const duration = performance.now() - startTime;
996
+
997
+ setState((prev) => {
998
+ const newStages = [...prev.stages];
999
+ newStages[stageIndex] = {
1000
+ ...newStages[stageIndex],
1001
+ status: 'failed',
1002
+ error: lastError,
1003
+ completedAt: Date.now(),
1004
+ duration,
1005
+ };
1006
+ return {
1007
+ ...prev,
1008
+ stages: newStages,
1009
+ metrics: {
1010
+ ...prev.metrics,
1011
+ failedStages: prev.metrics.failedStages + 1,
1012
+ },
1013
+ };
1014
+ });
1015
+
1016
+ addTimelineEvent(
1017
+ stageConfig.id,
1018
+ stageConfig.name,
1019
+ 'failed',
1020
+ duration,
1021
+ lastError.message
1022
+ );
1023
+ options.onStageError?.(state.stages[stageIndex], lastError);
1024
+
1025
+ return { success: false, error: lastError };
1026
+ }
1027
+ }
1028
+
1029
+ return { success: false, error: lastError };
1030
+ },
1031
+ [kit, stages, state.stages, addTimelineEvent, updateMetrics, options]
1032
+ );
1033
+
1034
+ /**
1035
+ * Run the pipeline
1036
+ */
1037
+ const run = useCallback(
1038
+ async (input: TInput): Promise<void> => {
1039
+ // Cancel any existing run
1040
+ if (abortControllerRef.current) {
1041
+ abortControllerRef.current.abort();
1042
+ }
1043
+
1044
+ const abortController = new AbortController();
1045
+ abortControllerRef.current = abortController;
1046
+ pausedRef.current = false;
1047
+
1048
+ const startTime = Date.now();
1049
+
1050
+ // Reset state
1051
+ setState(() => ({
1052
+ ...createInitialPipelineState<TInput, TOutput>(stages),
1053
+ status: 'running',
1054
+ input,
1055
+ startedAt: startTime,
1056
+ }));
1057
+
1058
+ const stageResults: unknown[] = [];
1059
+ let currentInput: unknown = input;
1060
+ let finalError: Error | null = null;
1061
+
1062
+ for (let i = 0; i < stages.length; i++) {
1063
+ if (abortController.signal.aborted) {
1064
+ setState((prev) => ({
1065
+ ...prev,
1066
+ status: 'cancelled',
1067
+ completedAt: Date.now(),
1068
+ totalDuration: Date.now() - startTime,
1069
+ }));
1070
+ return;
1071
+ }
1072
+
1073
+ const result = await executeStage(
1074
+ stages[i],
1075
+ i,
1076
+ currentInput,
1077
+ stageResults,
1078
+ abortController.signal
1079
+ );
1080
+
1081
+ if (!result.success) {
1082
+ finalError = result.error ?? new Error('Stage failed');
1083
+
1084
+ if (options.stopOnError !== false) {
1085
+ setState((prev) => ({
1086
+ ...prev,
1087
+ status: 'failed',
1088
+ error: finalError,
1089
+ stageResults,
1090
+ completedAt: Date.now(),
1091
+ totalDuration: Date.now() - startTime,
1092
+ }));
1093
+ return;
1094
+ }
1095
+ }
1096
+
1097
+ if (result.output !== undefined) {
1098
+ stageResults.push(result.output);
1099
+ currentInput = result.output;
1100
+ }
1101
+ }
1102
+
1103
+ // Pipeline completed
1104
+ setState((prev) => ({
1105
+ ...prev,
1106
+ status: finalError ? 'failed' : 'completed',
1107
+ output: (currentInput as TOutput) ?? null,
1108
+ error: finalError,
1109
+ stageResults,
1110
+ completedAt: Date.now(),
1111
+ totalDuration: Date.now() - startTime,
1112
+ currentStageIndex: -1,
1113
+ currentStage: null,
1114
+ progress: 100,
1115
+ }));
1116
+
1117
+ options.onStateChange?.(state);
1118
+ },
1119
+ [stages, executeStage, options, state]
1120
+ );
1121
+
1122
+ /**
1123
+ * Cancel the pipeline
1124
+ */
1125
+ const cancel = useCallback(() => {
1126
+ if (abortControllerRef.current) {
1127
+ abortControllerRef.current.abort();
1128
+ abortControllerRef.current = null;
1129
+ }
1130
+ if (resumePromiseRef.current) {
1131
+ resumePromiseRef.current.reject(new Error('Pipeline cancelled'));
1132
+ resumePromiseRef.current = null;
1133
+ }
1134
+ setState((prev) => ({
1135
+ ...prev,
1136
+ status: 'cancelled',
1137
+ completedAt: Date.now(),
1138
+ totalDuration: prev.startedAt ? Date.now() - prev.startedAt : null,
1139
+ }));
1140
+ }, []);
1141
+
1142
+ /**
1143
+ * Reset the pipeline
1144
+ */
1145
+ const reset = useCallback(() => {
1146
+ cancel();
1147
+ setState(createInitialPipelineState<TInput, TOutput>(stages));
1148
+ }, [cancel, stages]);
1149
+
1150
+ /**
1151
+ * Pause the pipeline
1152
+ */
1153
+ const pause = useCallback(() => {
1154
+ pausedRef.current = true;
1155
+ setState((prev) => ({
1156
+ ...prev,
1157
+ status: 'paused',
1158
+ }));
1159
+ }, []);
1160
+
1161
+ /**
1162
+ * Resume the pipeline
1163
+ */
1164
+ const resume = useCallback(() => {
1165
+ pausedRef.current = false;
1166
+ if (resumePromiseRef.current) {
1167
+ resumePromiseRef.current.resolve();
1168
+ resumePromiseRef.current = null;
1169
+ }
1170
+ setState((prev) => ({
1171
+ ...prev,
1172
+ status: 'running',
1173
+ }));
1174
+ }, []);
1175
+
1176
+ /**
1177
+ * Retry failed stages
1178
+ */
1179
+ const retry = useCallback(async (): Promise<void> => {
1180
+ if (state.status !== 'failed' || !state.input) return;
1181
+
1182
+ // Find first failed stage
1183
+ const failedIndex = state.stages.findIndex((s) => s.status === 'failed');
1184
+ if (failedIndex === -1) return;
1185
+
1186
+ // Get input for failed stage (output of previous stage or original input)
1187
+ const retryInput =
1188
+ failedIndex === 0 ? state.input : state.stageResults[failedIndex - 1];
1189
+
1190
+ // Create new abort controller
1191
+ const abortController = new AbortController();
1192
+ abortControllerRef.current = abortController;
1193
+
1194
+ setState((prev) => ({
1195
+ ...prev,
1196
+ status: 'running',
1197
+ error: null,
1198
+ }));
1199
+
1200
+ const stageResults = [...state.stageResults.slice(0, failedIndex)];
1201
+ let currentInput = retryInput;
1202
+
1203
+ for (let i = failedIndex; i < stages.length; i++) {
1204
+ if (abortController.signal.aborted) {
1205
+ setState((prev) => ({ ...prev, status: 'cancelled' }));
1206
+ return;
1207
+ }
1208
+
1209
+ const result = await executeStage(
1210
+ stages[i],
1211
+ i,
1212
+ currentInput,
1213
+ stageResults,
1214
+ abortController.signal
1215
+ );
1216
+
1217
+ if (!result.success) {
1218
+ setState((prev) => ({
1219
+ ...prev,
1220
+ status: 'failed',
1221
+ error: result.error ?? new Error('Stage failed'),
1222
+ stageResults,
1223
+ }));
1224
+ return;
1225
+ }
1226
+
1227
+ if (result.output !== undefined) {
1228
+ stageResults.push(result.output);
1229
+ currentInput = result.output;
1230
+ }
1231
+ }
1232
+
1233
+ setState((prev) => ({
1234
+ ...prev,
1235
+ status: 'completed',
1236
+ output: currentInput as TOutput,
1237
+ stageResults,
1238
+ completedAt: Date.now(),
1239
+ totalDuration: prev.startedAt ? Date.now() - prev.startedAt : null,
1240
+ progress: 100,
1241
+ }));
1242
+ }, [state, stages, executeStage]);
1243
+
1244
+ /**
1245
+ * Generate execution report
1246
+ */
1247
+ const getReport = useCallback((): PipelineReport => {
1248
+ const stageDetails = state.stages.map((stage) => ({
1249
+ name: stage.name,
1250
+ status: stage.status,
1251
+ duration: stage.duration ? formatDuration(stage.duration) : '-',
1252
+ error: stage.error?.message,
1253
+ }));
1254
+
1255
+ const timeline = state.metrics.timeline.map((event) => {
1256
+ const time = new Date(event.timestamp).toISOString().split('T')[1].split('.')[0];
1257
+ const duration = event.duration ? ` (${formatDuration(event.duration)})` : '';
1258
+ const error = event.error ? ` - ${event.error}` : '';
1259
+ return `[${time}] ${event.stageName}: ${event.event}${duration}${error}`;
1260
+ });
1261
+
1262
+ const insights: string[] = [];
1263
+
1264
+ if (state.metrics.slowestStage) {
1265
+ insights.push(
1266
+ `Slowest stage: ${state.metrics.slowestStage.name} (${formatDuration(
1267
+ state.metrics.slowestStage.duration
1268
+ )})`
1269
+ );
1270
+ }
1271
+
1272
+ if (state.metrics.fastestStage) {
1273
+ insights.push(
1274
+ `Fastest stage: ${state.metrics.fastestStage.name} (${formatDuration(
1275
+ state.metrics.fastestStage.duration
1276
+ )})`
1277
+ );
1278
+ }
1279
+
1280
+ if (state.metrics.totalRetries > 0) {
1281
+ insights.push(`Total retries: ${state.metrics.totalRetries}`);
1282
+ }
1283
+
1284
+ if (state.metrics.averageStageDuration > 0) {
1285
+ insights.push(
1286
+ `Average stage duration: ${formatDuration(state.metrics.averageStageDuration)}`
1287
+ );
1288
+ }
1289
+
1290
+ const successRate =
1291
+ state.metrics.totalStages > 0
1292
+ ? (state.metrics.completedStages / state.metrics.totalStages) * 100
1293
+ : 0;
1294
+
1295
+ const summary = [
1296
+ `Pipeline Status: ${state.status.toUpperCase()}`,
1297
+ `Stages: ${state.metrics.completedStages}/${state.metrics.totalStages} completed`,
1298
+ `Success Rate: ${successRate.toFixed(0)}%`,
1299
+ state.totalDuration ? `Total Duration: ${formatDuration(state.totalDuration)}` : '',
1300
+ state.error ? `Error: ${state.error.message}` : '',
1301
+ ]
1302
+ .filter(Boolean)
1303
+ .join('\n');
1304
+
1305
+ return {
1306
+ summary,
1307
+ stageDetails,
1308
+ timeline,
1309
+ insights,
1310
+ metrics: state.metrics,
1311
+ };
1312
+ }, [state]);
1313
+
1314
+ /**
1315
+ * Check if a stage is complete
1316
+ */
1317
+ const isStageComplete = useCallback(
1318
+ (stageId: string): boolean => {
1319
+ const stage = state.stages.find((s) => s.id === stageId);
1320
+ return stage?.status === 'completed';
1321
+ },
1322
+ [state.stages]
1323
+ );
1324
+
1325
+ /**
1326
+ * Get a stage by ID
1327
+ */
1328
+ const getStage = useCallback(
1329
+ (stageId: string): StageInfo | undefined => {
1330
+ return state.stages.find((s) => s.id === stageId);
1331
+ },
1332
+ [state.stages]
1333
+ );
1334
+
1335
+ // Auto-run on mount if configured
1336
+ useEffect(() => {
1337
+ if (options.autoRun && options.initialInput !== undefined) {
1338
+ run(options.initialInput as TInput);
1339
+ }
1340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1341
+ }, []);
1342
+
1343
+ // Cleanup on unmount
1344
+ useEffect(() => {
1345
+ return () => {
1346
+ cancel();
1347
+ };
1348
+ }, [cancel]);
1349
+
1350
+ return {
1351
+ ...state,
1352
+ run,
1353
+ cancel,
1354
+ reset,
1355
+ pause,
1356
+ resume,
1357
+ retry,
1358
+ getReport,
1359
+ isRunning: state.status === 'running',
1360
+ isComplete: state.status === 'completed',
1361
+ isFailed: state.status === 'failed',
1362
+ isStageComplete,
1363
+ getStage,
1364
+ };
1365
+ }
1366
+
1367
+ // ============================================================================
1368
+ // useParallelBatch Hook - Parallel Processing Within Stages
1369
+ // ============================================================================
1370
+
1371
+ /**
1372
+ * Result type for useParallelBatch
1373
+ */
1374
+ export interface UseParallelBatchReturn<TItem, TOutput> {
1375
+ /** Execute batch processing */
1376
+ run: (items: TItem[]) => Promise<ParallelBatchResult<TOutput>>;
1377
+ /** Current batch result */
1378
+ result: ParallelBatchResult<TOutput> | null;
1379
+ /** Loading state */
1380
+ loading: boolean;
1381
+ /** Current progress (0-100) */
1382
+ progress: number;
1383
+ /** Number of completed items */
1384
+ completedCount: number;
1385
+ /** Total items in current batch */
1386
+ totalCount: number;
1387
+ /** Cancel batch processing */
1388
+ cancel: () => void;
1389
+ /** Reset state */
1390
+ reset: () => void;
1391
+ }
1392
+
1393
+ /**
1394
+ * Hook for parallel batch processing
1395
+ *
1396
+ * Useful for processing multiple items in parallel within a pipeline stage.
1397
+ *
1398
+ * @example
1399
+ * ```tsx
1400
+ * function BatchProcessor() {
1401
+ * const batch = useParallelBatch<string, ProcessedFile>('processFile', {
1402
+ * concurrency: 4
1403
+ * });
1404
+ *
1405
+ * return (
1406
+ * <div>
1407
+ * <button
1408
+ * onClick={() => batch.run(fileUrls)}
1409
+ * disabled={batch.loading}
1410
+ * >
1411
+ * Process {fileUrls.length} Files
1412
+ * </button>
1413
+ *
1414
+ * {batch.loading && (
1415
+ * <div>
1416
+ * Processing: {batch.completedCount}/{batch.totalCount}
1417
+ * ({batch.progress.toFixed(0)}%)
1418
+ * </div>
1419
+ * )}
1420
+ *
1421
+ * {batch.result && (
1422
+ * <div>
1423
+ * Success: {batch.result.successful.length}
1424
+ * Failed: {batch.result.failed.length}
1425
+ * </div>
1426
+ * )}
1427
+ * </div>
1428
+ * );
1429
+ * }
1430
+ * ```
1431
+ */
1432
+ export function useParallelBatch<TItem = unknown, TOutput = unknown>(
1433
+ functionName: string,
1434
+ options: {
1435
+ concurrency?: number;
1436
+ computeOptions?: ComputeOptions;
1437
+ } = {}
1438
+ ): UseParallelBatchReturn<TItem, TOutput> {
1439
+ const kit = useComputeKit();
1440
+ const abortControllerRef = useRef<AbortController | null>(null);
1441
+
1442
+ const [state, setState] = useState<{
1443
+ result: ParallelBatchResult<TOutput> | null;
1444
+ loading: boolean;
1445
+ progress: number;
1446
+ completedCount: number;
1447
+ totalCount: number;
1448
+ }>({
1449
+ result: null,
1450
+ loading: false,
1451
+ progress: 0,
1452
+ completedCount: 0,
1453
+ totalCount: 0,
1454
+ });
1455
+
1456
+ const run = useCallback(
1457
+ async (items: TItem[]): Promise<ParallelBatchResult<TOutput>> => {
1458
+ // Cancel any existing batch
1459
+ if (abortControllerRef.current) {
1460
+ abortControllerRef.current.abort();
1461
+ }
1462
+
1463
+ const abortController = new AbortController();
1464
+ abortControllerRef.current = abortController;
1465
+
1466
+ setState({
1467
+ result: null,
1468
+ loading: true,
1469
+ progress: 0,
1470
+ completedCount: 0,
1471
+ totalCount: items.length,
1472
+ });
1473
+
1474
+ const startTime = performance.now();
1475
+ const results: BatchItemResult<TOutput>[] = [];
1476
+ const concurrency = options.concurrency ?? items.length;
1477
+
1478
+ // Process in batches based on concurrency
1479
+ for (let i = 0; i < items.length; i += concurrency) {
1480
+ if (abortController.signal.aborted) {
1481
+ break;
1482
+ }
1483
+
1484
+ const batch = items.slice(i, i + concurrency);
1485
+ const batchPromises = batch.map(async (item, batchIndex) => {
1486
+ const index = i + batchIndex;
1487
+ const itemStart = performance.now();
1488
+
1489
+ try {
1490
+ const data = await kit.run<TItem, TOutput>(functionName, item, {
1491
+ ...options.computeOptions,
1492
+ signal: abortController.signal,
1493
+ });
1494
+
1495
+ const itemResult: BatchItemResult<TOutput> = {
1496
+ index,
1497
+ success: true,
1498
+ data,
1499
+ duration: performance.now() - itemStart,
1500
+ };
1501
+
1502
+ return itemResult;
1503
+ } catch (err) {
1504
+ const itemResult: BatchItemResult<TOutput> = {
1505
+ index,
1506
+ success: false,
1507
+ error: err instanceof Error ? err : new Error(String(err)),
1508
+ duration: performance.now() - itemStart,
1509
+ };
1510
+
1511
+ return itemResult;
1512
+ }
1513
+ });
1514
+
1515
+ const batchResults = await Promise.all(batchPromises);
1516
+ results.push(...batchResults);
1517
+
1518
+ // Update progress
1519
+ const completed = results.length;
1520
+ setState((prev) => ({
1521
+ ...prev,
1522
+ completedCount: completed,
1523
+ progress: (completed / items.length) * 100,
1524
+ }));
1525
+ }
1526
+
1527
+ const totalDuration = performance.now() - startTime;
1528
+ const successful = results
1529
+ .filter((r) => r.success && r.data !== undefined)
1530
+ .map((r) => r.data as TOutput);
1531
+ const failed = results
1532
+ .filter((r) => !r.success)
1533
+ .map((r) => ({ index: r.index, error: r.error! }));
1534
+
1535
+ const finalResult: ParallelBatchResult<TOutput> = {
1536
+ results,
1537
+ successful,
1538
+ failed,
1539
+ totalDuration,
1540
+ successRate: successful.length / items.length,
1541
+ };
1542
+
1543
+ setState({
1544
+ result: finalResult,
1545
+ loading: false,
1546
+ progress: 100,
1547
+ completedCount: items.length,
1548
+ totalCount: items.length,
1549
+ });
1550
+
1551
+ return finalResult;
1552
+ },
1553
+ [kit, functionName, options.concurrency, options.computeOptions]
1554
+ );
1555
+
1556
+ const cancel = useCallback(() => {
1557
+ if (abortControllerRef.current) {
1558
+ abortControllerRef.current.abort();
1559
+ abortControllerRef.current = null;
1560
+ }
1561
+ setState((prev) => ({
1562
+ ...prev,
1563
+ loading: false,
1564
+ }));
1565
+ }, []);
1566
+
1567
+ const reset = useCallback(() => {
1568
+ cancel();
1569
+ setState({
1570
+ result: null,
1571
+ loading: false,
1572
+ progress: 0,
1573
+ completedCount: 0,
1574
+ totalCount: 0,
1575
+ });
1576
+ }, [cancel]);
1577
+
1578
+ // Cleanup on unmount
1579
+ useEffect(() => {
1580
+ return () => {
1581
+ cancel();
1582
+ };
1583
+ }, [cancel]);
1584
+
1585
+ return {
1586
+ ...state,
1587
+ run,
1588
+ cancel,
1589
+ reset,
1590
+ };
1591
+ }
1592
+
399
1593
  // ============================================================================
400
1594
  // Exports
401
1595
  // ============================================================================
@@ -408,3 +1602,5 @@ export type {
408
1602
  } from '@computekit/core';
409
1603
 
410
1604
  export { ComputeKit } from '@computekit/core';
1605
+
1606
+ // Pipeline types are exported from interface declarations above