@stigmer/react 3.0.8-dev.20260612100207 → 3.0.8-dev.20260613041848
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/execution/useExecutionStream.d.ts +49 -5
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +118 -18
- package/execution/useExecutionStream.js.map +1 -1
- package/internal/backoff.d.ts +61 -0
- package/internal/backoff.d.ts.map +1 -0
- package/internal/backoff.js +79 -0
- package/internal/backoff.js.map +1 -0
- package/internal/store/conversation-store.d.ts +12 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +7 -0
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/store/workflow-execution-event-store.d.ts +12 -0
- package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
- package/internal/store/workflow-execution-event-store.js +7 -0
- package/internal/store/workflow-execution-event-store.js.map +1 -1
- package/internal/stream-controller.d.ts +11 -19
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +24 -1
- package/internal/stream-controller.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +4 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +7 -1
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +1 -0
- package/session/useSessionConversation.js.map +1 -1
- package/src/execution/__tests__/useExecutionStream.test.tsx +184 -0
- package/src/execution/useExecutionStream.ts +174 -30
- package/src/internal/__tests__/backoff.test.ts +99 -0
- package/src/internal/backoff.ts +100 -0
- package/src/internal/store/conversation-store.ts +22 -0
- package/src/internal/store/workflow-execution-event-store.ts +22 -0
- package/src/internal/stream-controller.ts +30 -25
- package/src/session/SessionViewer.tsx +27 -0
- package/src/session/useSessionConversation.ts +8 -1
- package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
- package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
- package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
- package/src/workflow/execution/useWaterfallEntries.ts +2 -1
- package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
- package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
- package/styles.css +1 -1
- package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
- package/workflow/WorkflowExecutionHeader.js +3 -1
- package/workflow/WorkflowExecutionHeader.js.map +1 -1
- package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
- package/workflow/WorkflowExecutionTimeline.js +1 -1
- package/workflow/WorkflowExecutionTimeline.js.map +1 -1
- package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
- package/workflow/execution/useWaterfallEntries.js +1 -1
- package/workflow/execution/useWaterfallEntries.js.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
- package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.js +75 -32
- package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.js +1 -1
- package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
|
@@ -307,6 +307,7 @@ function ConversationColumn({
|
|
|
307
307
|
className="flex-1"
|
|
308
308
|
/>
|
|
309
309
|
<div className="mx-auto w-full max-w-3xl">
|
|
310
|
+
{conv.isReconnecting && <ReconnectingIndicator />}
|
|
310
311
|
{conv.streamError && (
|
|
311
312
|
<StreamErrorBanner
|
|
312
313
|
error={conv.streamError}
|
|
@@ -546,6 +547,32 @@ function SendErrorBanner({ error }: { error: Error }) {
|
|
|
546
547
|
);
|
|
547
548
|
}
|
|
548
549
|
|
|
550
|
+
function ReconnectingIndicator() {
|
|
551
|
+
return (
|
|
552
|
+
<div
|
|
553
|
+
role="status"
|
|
554
|
+
aria-live="polite"
|
|
555
|
+
className="flex items-center gap-2 border-t border-border bg-muted px-4 py-2 text-sm text-muted-foreground"
|
|
556
|
+
>
|
|
557
|
+
<svg
|
|
558
|
+
width="14"
|
|
559
|
+
height="14"
|
|
560
|
+
viewBox="0 0 24 24"
|
|
561
|
+
fill="none"
|
|
562
|
+
stroke="currentColor"
|
|
563
|
+
strokeWidth="2"
|
|
564
|
+
strokeLinecap="round"
|
|
565
|
+
strokeLinejoin="round"
|
|
566
|
+
className="shrink-0 animate-spin motion-reduce:animate-none"
|
|
567
|
+
aria-hidden="true"
|
|
568
|
+
>
|
|
569
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
570
|
+
</svg>
|
|
571
|
+
<span className="truncate">Reconnecting…</span>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
549
576
|
function StreamErrorBanner({
|
|
550
577
|
error,
|
|
551
578
|
onReconnect,
|
|
@@ -179,7 +179,13 @@ export interface UseSessionConversationReturn {
|
|
|
179
179
|
/** Error from session or execution list loading, or `null` when healthy. */
|
|
180
180
|
readonly loadError: Error | null;
|
|
181
181
|
|
|
182
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* `true` while the execution stream is auto-reconnecting after a transient
|
|
184
|
+
* drop. The conversation stays visible and `streamError` remains `null` —
|
|
185
|
+
* surface a subtle "Reconnecting…" hint rather than an error banner.
|
|
186
|
+
*/
|
|
187
|
+
readonly isReconnecting: boolean;
|
|
188
|
+
/** Error from the execution stream, or `null` when healthy or reconnecting. */
|
|
183
189
|
readonly streamError: Error | null;
|
|
184
190
|
/** Reset the stream error and re-establish the execution stream subscription. */
|
|
185
191
|
readonly reconnectStream: () => void;
|
|
@@ -463,6 +469,7 @@ export function useSessionConversation(
|
|
|
463
469
|
isLoading,
|
|
464
470
|
loadError,
|
|
465
471
|
|
|
472
|
+
isReconnecting: stream.isReconnecting,
|
|
466
473
|
streamError: stream.error,
|
|
467
474
|
reconnectStream: stream.reconnect,
|
|
468
475
|
};
|
|
@@ -82,7 +82,10 @@ export const WorkflowExecutionHeader = memo(function WorkflowExecutionHeader({
|
|
|
82
82
|
const isRunning = RUNNING_PHASES.has(phase);
|
|
83
83
|
const isPaused = phase === ExecutionPhase.EXECUTION_PAUSED;
|
|
84
84
|
const isFailed = phase === ExecutionPhase.EXECUTION_FAILED;
|
|
85
|
-
const isLive =
|
|
85
|
+
const isLive =
|
|
86
|
+
streamState.stage === "streaming" ||
|
|
87
|
+
streamState.stage === "connecting" ||
|
|
88
|
+
streamState.stage === "reconnecting";
|
|
86
89
|
|
|
87
90
|
return (
|
|
88
91
|
<header className={cn("flex items-center gap-3 border-b border-border px-4 py-3", className)}>
|
|
@@ -77,7 +77,8 @@ export const WorkflowExecutionTimeline = memo(function WorkflowExecutionTimeline
|
|
|
77
77
|
}
|
|
78
78
|
}, [events.length]);
|
|
79
79
|
|
|
80
|
-
const isLive =
|
|
80
|
+
const isLive =
|
|
81
|
+
streamState.stage === "streaming" || streamState.stage === "reconnecting";
|
|
81
82
|
const isConnecting = streamState.stage === "connecting";
|
|
82
83
|
const isComplete = streamState.stage === "complete";
|
|
83
84
|
const isError = streamState.stage === "error";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
4
|
import { createElement } from "react";
|
|
5
5
|
import { create } from "@bufbuild/protobuf";
|
|
@@ -417,3 +417,119 @@ describe("useWorkflowExecutionEventStream", () => {
|
|
|
417
417
|
expect(result.current.events[0]?.taskName).toBe("beta");
|
|
418
418
|
});
|
|
419
419
|
});
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Auto-reconnect (#174)
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe("useWorkflowExecutionEventStream — auto-reconnect", () => {
|
|
426
|
+
it("auto-reconnects on a transient drop and resumes from the latest sequence", async () => {
|
|
427
|
+
let call = 0;
|
|
428
|
+
const subscribeEvents = vi.fn((_req: any) => {
|
|
429
|
+
call += 1;
|
|
430
|
+
if (call === 1) {
|
|
431
|
+
return (async function* () {
|
|
432
|
+
yield makeTaskStartedEvent(5, "t5");
|
|
433
|
+
throw new TypeError("Load failed");
|
|
434
|
+
})();
|
|
435
|
+
}
|
|
436
|
+
return (async function* () {
|
|
437
|
+
yield makeTaskStartedEvent(6, "t6");
|
|
438
|
+
})();
|
|
439
|
+
});
|
|
440
|
+
const client = makeMockClient({ subscribeEvents });
|
|
441
|
+
|
|
442
|
+
const { result } = renderHook(
|
|
443
|
+
() =>
|
|
444
|
+
useWorkflowExecutionEventStream("wex-001", {
|
|
445
|
+
executionPhase: PHASE.IN_PROGRESS,
|
|
446
|
+
reconnectOptions: { baseDelayMs: 5, maxDelayMs: 5 },
|
|
447
|
+
}),
|
|
448
|
+
{ wrapper: createWrapper(client) },
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
await waitFor(() => expect(subscribeEvents).toHaveBeenCalledTimes(2));
|
|
452
|
+
await waitFor(() => expect(result.current.events).toHaveLength(2));
|
|
453
|
+
|
|
454
|
+
// The resumed subscription continues after the last received sequence (5),
|
|
455
|
+
// so no events are lost or duplicated.
|
|
456
|
+
const secondReq = (subscribeEvents.mock.calls as unknown[][])[1]?.[0] as {
|
|
457
|
+
afterSequence: bigint;
|
|
458
|
+
};
|
|
459
|
+
expect(secondReq.afterSequence).toBe(BigInt(5));
|
|
460
|
+
expect(result.current.error).toBeNull();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it("surfaces an error after exhausting reconnect attempts", async () => {
|
|
464
|
+
const subscribeEvents = vi.fn(
|
|
465
|
+
() =>
|
|
466
|
+
(async function* () {
|
|
467
|
+
throw new TypeError("Load failed");
|
|
468
|
+
})(),
|
|
469
|
+
);
|
|
470
|
+
const client = makeMockClient({ subscribeEvents });
|
|
471
|
+
|
|
472
|
+
const { result } = renderHook(
|
|
473
|
+
() =>
|
|
474
|
+
useWorkflowExecutionEventStream("wex-001", {
|
|
475
|
+
executionPhase: PHASE.IN_PROGRESS,
|
|
476
|
+
reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1, maxAttempts: 2 },
|
|
477
|
+
}),
|
|
478
|
+
{ wrapper: createWrapper(client) },
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
await waitFor(() => expect(result.current.error).not.toBeNull(), {
|
|
482
|
+
timeout: 2000,
|
|
483
|
+
});
|
|
484
|
+
expect(result.current.streamState.stage).toBe("error");
|
|
485
|
+
// 1 initial attempt + 2 retries.
|
|
486
|
+
expect(subscribeEvents).toHaveBeenCalledTimes(3);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("marks unsupported on UNIMPLEMENTED without retrying", async () => {
|
|
490
|
+
const subscribeEvents = vi.fn(
|
|
491
|
+
() =>
|
|
492
|
+
(async function* () {
|
|
493
|
+
throw new Error("UNIMPLEMENTED: event streaming not supported");
|
|
494
|
+
})(),
|
|
495
|
+
);
|
|
496
|
+
const client = makeMockClient({ subscribeEvents });
|
|
497
|
+
|
|
498
|
+
const { result } = renderHook(
|
|
499
|
+
() =>
|
|
500
|
+
useWorkflowExecutionEventStream("wex-001", {
|
|
501
|
+
executionPhase: PHASE.IN_PROGRESS,
|
|
502
|
+
reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1 },
|
|
503
|
+
}),
|
|
504
|
+
{ wrapper: createWrapper(client) },
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
await waitFor(() =>
|
|
508
|
+
expect(result.current.streamState.stage).toBe("unsupported"),
|
|
509
|
+
);
|
|
510
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
511
|
+
expect(subscribeEvents).toHaveBeenCalledTimes(1);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("treats a clean stream end as completion (never a reconnect loop)", async () => {
|
|
515
|
+
const subscribeEvents = vi.fn(async function* () {
|
|
516
|
+
/* no events, then clean end */
|
|
517
|
+
});
|
|
518
|
+
const client = makeMockClient({ subscribeEvents });
|
|
519
|
+
|
|
520
|
+
const { result } = renderHook(
|
|
521
|
+
() =>
|
|
522
|
+
useWorkflowExecutionEventStream("wex-001", {
|
|
523
|
+
executionPhase: PHASE.IN_PROGRESS,
|
|
524
|
+
reconnectOptions: { baseDelayMs: 1, maxDelayMs: 1 },
|
|
525
|
+
}),
|
|
526
|
+
{ wrapper: createWrapper(client) },
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
await waitFor(() =>
|
|
530
|
+
expect(result.current.streamState.stage).toBe("complete"),
|
|
531
|
+
);
|
|
532
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
533
|
+
expect(subscribeEvents).toHaveBeenCalledTimes(1);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
@@ -61,7 +61,8 @@ export function useWaterfallEntries({
|
|
|
61
61
|
executionStartIso,
|
|
62
62
|
executionDurationMs,
|
|
63
63
|
}: UseWaterfallEntriesOptions): UseWaterfallEntriesReturn {
|
|
64
|
-
const isLive =
|
|
64
|
+
const isLive =
|
|
65
|
+
streamState.stage === "streaming" || streamState.stage === "reconnecting";
|
|
65
66
|
const execStartEpoch = useMemo(
|
|
66
67
|
() => (executionStartIso ? new Date(executionStartIso).getTime() : 0),
|
|
67
68
|
[executionStartIso],
|
|
@@ -16,8 +16,15 @@ import {
|
|
|
16
16
|
SubscribeEventsRequestSchema,
|
|
17
17
|
} from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
|
|
18
18
|
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/enum_pb";
|
|
19
|
+
import { isTransientStreamError } from "@stigmer/sdk";
|
|
19
20
|
import { useStigmer } from "../hooks";
|
|
20
21
|
import { toError } from "../internal/toError";
|
|
22
|
+
import {
|
|
23
|
+
computeBackoffDelay,
|
|
24
|
+
sleep,
|
|
25
|
+
DEFAULT_RECONNECT_MAX_ATTEMPTS,
|
|
26
|
+
type BackoffOptions,
|
|
27
|
+
} from "../internal/backoff";
|
|
21
28
|
import {
|
|
22
29
|
WorkflowExecutionEventStore,
|
|
23
30
|
type WorkflowEventStreamState,
|
|
@@ -41,6 +48,20 @@ export interface UseWorkflowExecutionEventStreamOptions {
|
|
|
41
48
|
* (terminal). When omitted, defaults to live streaming.
|
|
42
49
|
*/
|
|
43
50
|
readonly executionPhase?: ExecutionPhase;
|
|
51
|
+
/**
|
|
52
|
+
* Automatically re-establish the live subscription with exponential
|
|
53
|
+
* backoff when it drops with a transient transport error, resuming from
|
|
54
|
+
* the last received `sequence_number` (no events lost). Defaults to `true`.
|
|
55
|
+
*/
|
|
56
|
+
readonly autoReconnect?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Tune the auto-reconnect backoff schedule and attempt cap. Omitted fields
|
|
59
|
+
* fall back to SDK defaults (base 1s, ×2, max 30s, 10 attempts).
|
|
60
|
+
*/
|
|
61
|
+
readonly reconnectOptions?: BackoffOptions & {
|
|
62
|
+
/** Max attempts before surfacing a terminal `error`. */
|
|
63
|
+
readonly maxAttempts?: number;
|
|
64
|
+
};
|
|
44
65
|
}
|
|
45
66
|
|
|
46
67
|
/** Return value of {@link useWorkflowExecutionEventStream}. */
|
|
@@ -59,9 +80,20 @@ export interface UseWorkflowExecutionEventStreamReturn {
|
|
|
59
80
|
readonly isStreaming: boolean;
|
|
60
81
|
/** `true` while connecting to the event stream. */
|
|
61
82
|
readonly isConnecting: boolean;
|
|
62
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* `true` while a transient drop is being retried automatically. Accumulated
|
|
85
|
+
* events stay visible and `error` remains `null`; on success the
|
|
86
|
+
* subscription resumes from the last sequence number with no events lost.
|
|
87
|
+
*/
|
|
88
|
+
readonly isReconnecting: boolean;
|
|
89
|
+
/** 1-based count of the in-flight reconnect attempt; `0` when not reconnecting. */
|
|
90
|
+
readonly reconnectAttempt: number;
|
|
91
|
+
/**
|
|
92
|
+
* Error from the last failed stream attempt, or `null`. Set only once
|
|
93
|
+
* auto-reconnect exhausts its attempts (or for a non-transient failure).
|
|
94
|
+
*/
|
|
63
95
|
readonly error: Error | null;
|
|
64
|
-
/** Re-establish the stream subscription. */
|
|
96
|
+
/** Re-establish the stream subscription (manual fallback). */
|
|
65
97
|
readonly reconnect: () => void;
|
|
66
98
|
}
|
|
67
99
|
|
|
@@ -99,8 +131,10 @@ export function isRecoveryTransition(
|
|
|
99
131
|
* integration.
|
|
100
132
|
*
|
|
101
133
|
* For running executions: subscribes via `subscribeEvents` with
|
|
102
|
-
* replay+live-tail.
|
|
103
|
-
* sequence number
|
|
134
|
+
* replay+live-tail. A transient drop auto-reconnects with exponential
|
|
135
|
+
* backoff, resuming from the last received sequence number so no events are
|
|
136
|
+
* lost; `error` is surfaced only once retries are exhausted. A clean stream
|
|
137
|
+
* end is the server's completion signal and is never retried.
|
|
104
138
|
*
|
|
105
139
|
* For terminal executions: loads the full event log via paginated
|
|
106
140
|
* `getEventLog` calls.
|
|
@@ -136,6 +170,8 @@ export function useWorkflowExecutionEventStream(
|
|
|
136
170
|
|
|
137
171
|
const eventTypes = options?.eventTypes;
|
|
138
172
|
const executionPhase = options?.executionPhase;
|
|
173
|
+
const autoReconnect = options?.autoReconnect ?? true;
|
|
174
|
+
const reconnectOptions = options?.reconnectOptions;
|
|
139
175
|
|
|
140
176
|
// Stable ref for values that should not trigger re-subscription
|
|
141
177
|
const storeRef = useRef(store);
|
|
@@ -239,50 +275,90 @@ export function useWorkflowExecutionEventStream(
|
|
|
239
275
|
}
|
|
240
276
|
})();
|
|
241
277
|
} else {
|
|
242
|
-
// Live-stream events for running executions
|
|
278
|
+
// Live-stream events for running executions, with auto-reconnect.
|
|
243
279
|
currentStore.setStreamState({ stage: "connecting", executionId });
|
|
244
280
|
|
|
245
281
|
(async () => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
const signal = abortController.signal;
|
|
283
|
+
const maxAttempts =
|
|
284
|
+
reconnectOptions?.maxAttempts ?? DEFAULT_RECONNECT_MAX_ATTEMPTS;
|
|
285
|
+
|
|
286
|
+
// 1-based count of consecutive failed attempts, reset by any event.
|
|
287
|
+
let attempt = 0;
|
|
288
|
+
|
|
289
|
+
while (!signal.aborted) {
|
|
290
|
+
try {
|
|
291
|
+
// Re-read each attempt: after a drop we resume from the last
|
|
292
|
+
// sequence number, so the server replays only what we missed and
|
|
293
|
+
// no events are lost or duplicated.
|
|
294
|
+
const afterSequence = currentStore.getLatestSequence();
|
|
295
|
+
|
|
296
|
+
for await (const event of stigmer.workflowExecution.subscribeEvents(
|
|
297
|
+
create(SubscribeEventsRequestSchema, {
|
|
298
|
+
executionId,
|
|
299
|
+
afterSequence,
|
|
300
|
+
eventTypes: eventTypes ? [...eventTypes] : [],
|
|
301
|
+
}),
|
|
302
|
+
signal,
|
|
303
|
+
)) {
|
|
304
|
+
if (signal.aborted) return;
|
|
305
|
+
|
|
306
|
+
attempt = 0; // an event proves the connection is healthy
|
|
307
|
+
startTransition(() => {
|
|
308
|
+
currentStore.appendEvents([event]);
|
|
309
|
+
const stage = currentStore.getStreamState().stage;
|
|
310
|
+
if (stage === "connecting" || stage === "reconnecting") {
|
|
311
|
+
currentStore.setStreamState({ stage: "streaming", executionId });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// A clean end of the event stream is the server's completion
|
|
317
|
+
// signal (the execution finished). Unlike the agent snapshot
|
|
318
|
+
// stream, there is no separate terminal marker to re-check, so we
|
|
319
|
+
// must NOT treat this as a premature drop — doing so would loop
|
|
320
|
+
// forever re-subscribing past the final sequence. Transient drops
|
|
321
|
+
// surface as thrown errors (handled below), not a clean end.
|
|
322
|
+
if (!signal.aborted) {
|
|
323
|
+
currentStore.setStreamState({ stage: "complete", executionId });
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (signal.aborted) return;
|
|
328
|
+
|
|
329
|
+
const error = toError(err);
|
|
330
|
+
const isUnimplemented =
|
|
331
|
+
error.message.includes("UNIMPLEMENTED") ||
|
|
332
|
+
error.message.includes("unimplemented");
|
|
333
|
+
|
|
334
|
+
// A server without event-stream support will never recover —
|
|
335
|
+
// surface the unsupported state immediately, never retry.
|
|
336
|
+
if (isUnimplemented) {
|
|
337
|
+
currentStore.setStreamState({ stage: "unsupported", executionId });
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
!autoReconnect ||
|
|
343
|
+
!isTransientStreamError(error) ||
|
|
344
|
+
attempt >= maxAttempts
|
|
345
|
+
) {
|
|
346
|
+
currentStore.setStreamState({ stage: "error", executionId, error });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
attempt += 1;
|
|
281
351
|
currentStore.setStreamState({
|
|
282
|
-
stage: "
|
|
352
|
+
stage: "reconnecting",
|
|
283
353
|
executionId,
|
|
354
|
+
attempt,
|
|
284
355
|
error,
|
|
285
356
|
});
|
|
357
|
+
try {
|
|
358
|
+
await sleep(computeBackoffDelay(attempt, reconnectOptions), signal);
|
|
359
|
+
} catch {
|
|
360
|
+
return; // aborted mid-backoff
|
|
361
|
+
}
|
|
286
362
|
}
|
|
287
363
|
}
|
|
288
364
|
})();
|
|
@@ -302,6 +378,9 @@ export function useWorkflowExecutionEventStream(
|
|
|
302
378
|
|
|
303
379
|
const isStreaming = streamState.stage === "streaming";
|
|
304
380
|
const isConnecting = streamState.stage === "connecting";
|
|
381
|
+
const isReconnecting = streamState.stage === "reconnecting";
|
|
382
|
+
const reconnectAttempt =
|
|
383
|
+
streamState.stage === "reconnecting" ? streamState.attempt : 0;
|
|
305
384
|
const error = streamState.stage === "error" ? streamState.error : null;
|
|
306
385
|
|
|
307
386
|
return {
|
|
@@ -312,6 +391,8 @@ export function useWorkflowExecutionEventStream(
|
|
|
312
391
|
totalTasks,
|
|
313
392
|
isStreaming,
|
|
314
393
|
isConnecting,
|
|
394
|
+
isReconnecting,
|
|
395
|
+
reconnectAttempt,
|
|
315
396
|
error,
|
|
316
397
|
reconnect,
|
|
317
398
|
};
|
|
@@ -89,7 +89,8 @@ export const WaterfallTimeline = memo(function WaterfallTimeline({
|
|
|
89
89
|
|
|
90
90
|
// Empty state
|
|
91
91
|
if (entries.length === 0) {
|
|
92
|
-
const isConnecting =
|
|
92
|
+
const isConnecting =
|
|
93
|
+
streamState.stage === "connecting" || streamState.stage === "reconnecting";
|
|
93
94
|
return (
|
|
94
95
|
<div className={cn("flex items-center justify-center text-xs text-[var(--stgm-muted-foreground,#737373)]", className)}>
|
|
95
96
|
{isConnecting ? "Loading timeline…" : "No task data available"}
|