@open-mercato/ui 0.10.1-develop.7619.1.97f9a4bda8 → 0.10.1-develop.7623.1.b7e6aaf96d
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
4
|
-
import {
|
|
4
|
+
import { apiCall } from "../../backend/utils/apiCall.js";
|
|
5
5
|
const logger = createLogger("ui").child({ component: "useAiPendingActionPolling" });
|
|
6
6
|
const TERMINAL_STATUSES = [
|
|
7
7
|
"confirmed",
|
|
@@ -9,29 +9,47 @@ const TERMINAL_STATUSES = [
|
|
|
9
9
|
"failed",
|
|
10
10
|
"expired"
|
|
11
11
|
];
|
|
12
|
+
const TERMINAL_HTTP_ERROR_CODES = ["not_found", "unauthorized", "forbidden"];
|
|
13
|
+
const MAX_BACKOFF_MS = 6e4;
|
|
14
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
12
15
|
function isTerminal(status) {
|
|
13
16
|
if (!status) return false;
|
|
14
17
|
return TERMINAL_STATUSES.includes(status);
|
|
15
18
|
}
|
|
19
|
+
function httpStatusToErrorCode(status) {
|
|
20
|
+
if (status === 404) return "not_found";
|
|
21
|
+
if (status === 401) return "unauthorized";
|
|
22
|
+
if (status === 403) return "forbidden";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function isHiddenDocument() {
|
|
26
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
27
|
+
}
|
|
16
28
|
async function fetchPendingAction(pendingActionId, endpoint) {
|
|
17
29
|
const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`;
|
|
18
|
-
const call = await
|
|
30
|
+
const call = await apiCall(url, { method: "GET" });
|
|
19
31
|
const body = call.result;
|
|
32
|
+
if (!call.ok) {
|
|
33
|
+
const code = httpStatusToErrorCode(call.status) ?? body?.code ?? "request_failed";
|
|
34
|
+
const message = body?.error ?? `Request failed with status ${call.status}.`;
|
|
35
|
+
return { ok: false, pendingAction: null, error: { code, message } };
|
|
36
|
+
}
|
|
20
37
|
if (body && typeof body === "object") {
|
|
21
38
|
if (body.pendingAction) {
|
|
22
|
-
return { pendingAction: body.pendingAction, error: null };
|
|
39
|
+
return { ok: true, pendingAction: body.pendingAction, error: null };
|
|
23
40
|
}
|
|
24
41
|
if (typeof body.id === "string" && typeof body.status === "string") {
|
|
25
|
-
return { pendingAction: body, error: null };
|
|
42
|
+
return { ok: true, pendingAction: body, error: null };
|
|
26
43
|
}
|
|
27
44
|
if (body.error) {
|
|
28
45
|
return {
|
|
46
|
+
ok: true,
|
|
29
47
|
pendingAction: null,
|
|
30
48
|
error: { message: body.error, code: body.code }
|
|
31
49
|
};
|
|
32
50
|
}
|
|
33
51
|
}
|
|
34
|
-
return { pendingAction: null, error: null };
|
|
52
|
+
return { ok: true, pendingAction: null, error: null };
|
|
35
53
|
}
|
|
36
54
|
function useAiPendingActionPolling(options) {
|
|
37
55
|
const {
|
|
@@ -46,6 +64,12 @@ function useAiPendingActionPolling(options) {
|
|
|
46
64
|
const mountedRef = React.useRef(true);
|
|
47
65
|
const timerRef = React.useRef(null);
|
|
48
66
|
const statusRef = React.useRef(null);
|
|
67
|
+
const terminalErrorCodeRef = React.useRef(null);
|
|
68
|
+
const consecutiveFailuresRef = React.useRef(0);
|
|
69
|
+
const stoppedRef = React.useRef(false);
|
|
70
|
+
const hiddenRef = React.useRef(isHiddenDocument());
|
|
71
|
+
const scheduleNextRef = React.useRef(() => {
|
|
72
|
+
});
|
|
49
73
|
const clearTimer = React.useCallback(() => {
|
|
50
74
|
if (timerRef.current) {
|
|
51
75
|
clearTimeout(timerRef.current);
|
|
@@ -54,26 +78,46 @@ function useAiPendingActionPolling(options) {
|
|
|
54
78
|
}, []);
|
|
55
79
|
const refresh = React.useCallback(async () => {
|
|
56
80
|
if (!pendingActionId) return null;
|
|
81
|
+
let pendingAction = null;
|
|
57
82
|
try {
|
|
58
83
|
const result = await fetchPendingAction(pendingActionId, endpoint);
|
|
59
84
|
if (!mountedRef.current) return result.pendingAction ?? null;
|
|
60
|
-
|
|
61
|
-
|
|
85
|
+
pendingAction = result.pendingAction ?? null;
|
|
86
|
+
if (result.ok) {
|
|
87
|
+
consecutiveFailuresRef.current = 0;
|
|
88
|
+
stoppedRef.current = false;
|
|
89
|
+
terminalErrorCodeRef.current = null;
|
|
90
|
+
setError(result.error ?? null);
|
|
91
|
+
if (result.pendingAction) {
|
|
92
|
+
setAction(result.pendingAction);
|
|
93
|
+
statusRef.current = result.pendingAction.status;
|
|
94
|
+
}
|
|
62
95
|
} else {
|
|
63
|
-
setError(null);
|
|
96
|
+
setError(result.error ?? null);
|
|
97
|
+
const code = result.error?.code;
|
|
98
|
+
if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {
|
|
99
|
+
terminalErrorCodeRef.current = code;
|
|
100
|
+
} else {
|
|
101
|
+
consecutiveFailuresRef.current += 1;
|
|
102
|
+
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
103
|
+
stoppedRef.current = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
64
106
|
}
|
|
65
|
-
if (result.pendingAction) {
|
|
66
|
-
setAction(result.pendingAction);
|
|
67
|
-
statusRef.current = result.pendingAction.status;
|
|
68
|
-
}
|
|
69
|
-
return result.pendingAction;
|
|
70
107
|
} catch (err) {
|
|
71
108
|
logger.error("Failed to load the pending AI action", { err, pendingActionId });
|
|
72
109
|
if (!mountedRef.current) return null;
|
|
73
110
|
const message = err instanceof Error ? err.message : "Failed to load pending action.";
|
|
74
111
|
setError({ message });
|
|
75
|
-
|
|
112
|
+
consecutiveFailuresRef.current += 1;
|
|
113
|
+
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
114
|
+
stoppedRef.current = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (mountedRef.current) {
|
|
118
|
+
scheduleNextRef.current();
|
|
76
119
|
}
|
|
120
|
+
return pendingAction;
|
|
77
121
|
}, [endpoint, pendingActionId]);
|
|
78
122
|
const scheduleNext = React.useCallback(() => {
|
|
79
123
|
clearTimer();
|
|
@@ -86,19 +130,50 @@ function useAiPendingActionPolling(options) {
|
|
|
86
130
|
setIsPolling(false);
|
|
87
131
|
return;
|
|
88
132
|
}
|
|
133
|
+
if (terminalErrorCodeRef.current) {
|
|
134
|
+
setIsPolling(false);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (stoppedRef.current) {
|
|
138
|
+
setIsPolling(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (hiddenRef.current) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
89
144
|
setIsPolling(true);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
logger.error("Polling the pending AI action failed unexpectedly", { err });
|
|
95
|
-
}
|
|
96
|
-
scheduleNext();
|
|
97
|
-
}, intervalMs);
|
|
145
|
+
const delay = consecutiveFailuresRef.current > 0 ? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS) : intervalMs;
|
|
146
|
+
timerRef.current = setTimeout(() => {
|
|
147
|
+
void refresh();
|
|
148
|
+
}, delay);
|
|
98
149
|
}, [clearTimer, disabled, intervalMs, refresh]);
|
|
150
|
+
React.useEffect(() => {
|
|
151
|
+
scheduleNextRef.current = scheduleNext;
|
|
152
|
+
}, [scheduleNext]);
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (disabled) return;
|
|
155
|
+
const handleVisibilityChange = () => {
|
|
156
|
+
const wasHidden = hiddenRef.current;
|
|
157
|
+
hiddenRef.current = isHiddenDocument();
|
|
158
|
+
if (!wasHidden && hiddenRef.current) {
|
|
159
|
+
clearTimer();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (wasHidden && !hiddenRef.current && mountedRef.current) {
|
|
163
|
+
void refresh();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
167
|
+
return () => {
|
|
168
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
169
|
+
};
|
|
170
|
+
}, [clearTimer, disabled, refresh]);
|
|
99
171
|
React.useEffect(() => {
|
|
100
172
|
mountedRef.current = true;
|
|
101
173
|
statusRef.current = null;
|
|
174
|
+
terminalErrorCodeRef.current = null;
|
|
175
|
+
consecutiveFailuresRef.current = 0;
|
|
176
|
+
stoppedRef.current = false;
|
|
102
177
|
if (disabled) {
|
|
103
178
|
setIsPolling(false);
|
|
104
179
|
return () => {
|
|
@@ -107,13 +182,7 @@ function useAiPendingActionPolling(options) {
|
|
|
107
182
|
};
|
|
108
183
|
}
|
|
109
184
|
setIsPolling(true);
|
|
110
|
-
void refresh()
|
|
111
|
-
logger.error("Loading the pending AI action failed unexpectedly", { err });
|
|
112
|
-
return null;
|
|
113
|
-
}).then(() => {
|
|
114
|
-
if (!mountedRef.current) return;
|
|
115
|
-
scheduleNext();
|
|
116
|
-
});
|
|
185
|
+
void refresh();
|
|
117
186
|
return () => {
|
|
118
187
|
mountedRef.current = false;
|
|
119
188
|
clearTimer();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/ai/parts/useAiPendingActionPolling.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { apiCallOrThrow } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\nconst logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n if (result.error) {\n setError(result.error)\n } else {\n setError(null)\n }\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n return result.pendingAction\n } catch (err) {\n logger.error('Failed to load the pending AI action', { err, pendingActionId })\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n return null\n }\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n setIsPolling(true)\n timerRef.current = setTimeout(async () => {\n try {\n await refresh()\n } catch (err) {\n logger.error('Polling the pending AI action failed unexpectedly', { err })\n }\n scheduleNext()\n }, intervalMs)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee. The catch\n // runs before the scheduling step so a rejected first fetch degrades to a\n // logged error instead of silently killing the polling loop.\n void refresh()\n .catch((err) => {\n logger.error('Loading the pending AI action failed unexpectedly', { err })\n return null\n })\n .then(() => {\n if (!mountedRef.current) return\n scheduleNext()\n })\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
|
|
5
|
-
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,oBAAoB;AAC7B,SAAS,
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { apiCall } from '../../backend/utils/apiCall'\nimport type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'\n\nconst logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })\n\n/**\n * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).\n *\n * Responsibilities:\n * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the\n * server previously streamed a preview card. This is the \"reconnect\" path:\n * after a page reload or navigation away+back, the card recovers the\n * current pending-action state instead of staying blank.\n * - Poll every 3 seconds while the status is non-terminal\n * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,\n * `failed`, `expired`) stop polling.\n * - A 404/401/403 from the endpoint is also terminal \u2014 the row is gone or\n * unreachable and retrying cannot help (issue #7550: without this, every\n * card whose row disappeared polled every 3s forever, in every tab).\n * - Any other failure (5xx, network error) backs off exponentially (capped\n * at 60s) and gives up after 5 consecutive failures.\n * - Polling pauses while the tab is hidden (`document.visibilityState`) and\n * resumes on `visibilitychange`.\n * - Expose a `refresh()` force-fetch helper the confirmation card uses after\n * a confirm POST races with the polling loop. `refresh()` also restarts a\n * stopped loop (backoff-exhausted or resumed-from-hidden) without\n * creating a second timer chain.\n *\n * The hook owns a single `setInterval`/`setTimeout` \u2014 unmounting clears\n * every outstanding timer. This is required for the Jest fake-timers\n * \"mount, unmount mid-poll\" test contract.\n */\n\nconst TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [\n 'confirmed',\n 'cancelled',\n 'failed',\n 'expired',\n]\n\nconst TERMINAL_HTTP_ERROR_CODES: ReadonlyArray<string> = ['not_found', 'unauthorized', 'forbidden']\n\nconst MAX_BACKOFF_MS = 60_000\nconst MAX_CONSECUTIVE_FAILURES = 5\n\nfunction isTerminal(status: AiPendingActionCardStatus | null): boolean {\n if (!status) return false\n return TERMINAL_STATUSES.includes(status)\n}\n\nfunction httpStatusToErrorCode(status: number): string | null {\n if (status === 404) return 'not_found'\n if (status === 401) return 'unauthorized'\n if (status === 403) return 'forbidden'\n return null\n}\n\nfunction isHiddenDocument(): boolean {\n return typeof document !== 'undefined' && document.visibilityState === 'hidden'\n}\n\nexport interface UseAiPendingActionPollingOptions {\n pendingActionId: string\n /**\n * Poll interval in ms while the status is non-terminal. Defaults to 3000.\n */\n intervalMs?: number\n /**\n * Endpoint base. Override to point at a mock during tests.\n */\n endpoint?: string\n /**\n * When true, the hook does NOT schedule any network activity. Used by the\n * result card which already holds a terminal state and only needs to read\n * what the preview card fetched.\n */\n disabled?: boolean\n}\n\nexport interface AiPendingActionFetchResult {\n ok: boolean\n pendingAction: AiPendingActionCardAction | null\n error?: { code?: string; message: string } | null\n}\n\nexport interface UseAiPendingActionPollingResult {\n action: AiPendingActionCardAction | null\n status: AiPendingActionCardStatus | null\n isPolling: boolean\n error: { code?: string; message: string } | null\n refresh: () => Promise<AiPendingActionCardAction | null>\n}\n\nasync function fetchPendingAction(\n pendingActionId: string,\n endpoint: string,\n): Promise<AiPendingActionFetchResult> {\n const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`\n const call = await apiCall<unknown>(url, { method: 'GET' })\n const body = call.result as\n | (Partial<AiPendingActionCardAction> & {\n pendingAction?: AiPendingActionCardAction\n error?: string\n code?: string\n })\n | null\n | undefined\n\n if (!call.ok) {\n const code = httpStatusToErrorCode(call.status) ?? body?.code ?? 'request_failed'\n const message = body?.error ?? `Request failed with status ${call.status}.`\n return { ok: false, pendingAction: null, error: { code, message } }\n }\n\n // The GET route returns the bare action object (`serializePendingActionForClient(row)`),\n // but earlier client code expected it under a `pendingAction` envelope.\n // Accept BOTH shapes so the cards work whether the dispatcher wraps or\n // not \u2014 bare-object payloads were the actual cause of empty\n // `fieldDiff` and missing `executionResult.error` displays in\n // MutationPreviewCard / ConfirmationCard / MutationResultCard.\n if (body && typeof body === 'object') {\n if (body.pendingAction) {\n return { ok: true, pendingAction: body.pendingAction, error: null }\n }\n if (\n typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&\n typeof (body as Partial<AiPendingActionCardAction>).status === 'string'\n ) {\n return { ok: true, pendingAction: body as AiPendingActionCardAction, error: null }\n }\n if (body.error) {\n return {\n ok: true,\n pendingAction: null,\n error: { message: body.error, code: body.code },\n }\n }\n }\n return { ok: true, pendingAction: null, error: null }\n}\n\nexport function useAiPendingActionPolling(\n options: UseAiPendingActionPollingOptions,\n): UseAiPendingActionPollingResult {\n const {\n pendingActionId,\n intervalMs = 3000,\n endpoint = '/api/ai_assistant/ai/actions',\n disabled = false,\n } = options\n\n const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)\n const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)\n const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)\n\n const mountedRef = React.useRef(true)\n const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)\n const terminalErrorCodeRef = React.useRef<string | null>(null)\n const consecutiveFailuresRef = React.useRef(0)\n const stoppedRef = React.useRef(false)\n const hiddenRef = React.useRef(isHiddenDocument())\n const scheduleNextRef = React.useRef<() => void>(() => {})\n\n const clearTimer = React.useCallback(() => {\n if (timerRef.current) {\n clearTimeout(timerRef.current)\n timerRef.current = null\n }\n }, [])\n\n const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {\n if (!pendingActionId) return null\n let pendingAction: AiPendingActionCardAction | null = null\n try {\n const result = await fetchPendingAction(pendingActionId, endpoint)\n if (!mountedRef.current) return result.pendingAction ?? null\n pendingAction = result.pendingAction ?? null\n if (result.ok) {\n consecutiveFailuresRef.current = 0\n stoppedRef.current = false\n terminalErrorCodeRef.current = null\n setError(result.error ?? null)\n if (result.pendingAction) {\n setAction(result.pendingAction)\n statusRef.current = result.pendingAction.status\n }\n } else {\n setError(result.error ?? null)\n const code = result.error?.code\n if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {\n terminalErrorCodeRef.current = code\n } else {\n consecutiveFailuresRef.current += 1\n if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {\n stoppedRef.current = true\n }\n }\n }\n } catch (err) {\n logger.error('Failed to load the pending AI action', { err, pendingActionId })\n if (!mountedRef.current) return null\n const message = err instanceof Error ? err.message : 'Failed to load pending action.'\n setError({ message })\n consecutiveFailuresRef.current += 1\n if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {\n stoppedRef.current = true\n }\n }\n if (mountedRef.current) {\n scheduleNextRef.current()\n }\n return pendingAction\n }, [endpoint, pendingActionId])\n\n const scheduleNext = React.useCallback(() => {\n clearTimer()\n if (!mountedRef.current) return\n if (disabled) {\n setIsPolling(false)\n return\n }\n if (isTerminal(statusRef.current)) {\n setIsPolling(false)\n return\n }\n if (terminalErrorCodeRef.current) {\n setIsPolling(false)\n return\n }\n if (stoppedRef.current) {\n setIsPolling(false)\n return\n }\n if (hiddenRef.current) {\n // Paused while the tab is hidden. `visibilitychange` resumes via refresh().\n return\n }\n setIsPolling(true)\n const delay =\n consecutiveFailuresRef.current > 0\n ? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS)\n : intervalMs\n timerRef.current = setTimeout(() => {\n void refresh()\n }, delay)\n }, [clearTimer, disabled, intervalMs, refresh])\n\n React.useEffect(() => {\n scheduleNextRef.current = scheduleNext\n }, [scheduleNext])\n\n React.useEffect(() => {\n if (disabled) return\n const handleVisibilityChange = () => {\n const wasHidden = hiddenRef.current\n hiddenRef.current = isHiddenDocument()\n if (!wasHidden && hiddenRef.current) {\n // Cancel any already-scheduled poll immediately \u2014 do not wait for\n // it to fire before honoring the pause.\n clearTimer()\n return\n }\n if (wasHidden && !hiddenRef.current && mountedRef.current) {\n // Resume immediately rather than waiting out a stale backoff delay.\n void refresh()\n }\n }\n document.addEventListener('visibilitychange', handleVisibilityChange)\n return () => {\n document.removeEventListener('visibilitychange', handleVisibilityChange)\n }\n }, [clearTimer, disabled, refresh])\n\n React.useEffect(() => {\n mountedRef.current = true\n statusRef.current = null\n terminalErrorCodeRef.current = null\n consecutiveFailuresRef.current = 0\n stoppedRef.current = false\n if (disabled) {\n setIsPolling(false)\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n }\n setIsPolling(true)\n // Always fetch on mount \u2014 the \"reconnect behavior\" guarantee. `refresh`\n // schedules the next poll itself once it settles.\n void refresh()\n return () => {\n mountedRef.current = false\n clearTimer()\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [pendingActionId, endpoint, intervalMs, disabled])\n\n const status = action?.status ?? null\n\n return {\n action,\n status,\n isPolling: isPolling && !isTerminal(status),\n error,\n refresh,\n }\n}\n\nexport default useAiPendingActionPolling\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7623.1.b7e6aaf96d",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.10.1-develop.7623.1.b7e6aaf96d",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.10.1-develop.7623.1.b7e6aaf96d",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -6,7 +6,7 @@ import * as React from 'react'
|
|
|
6
6
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
7
7
|
|
|
8
8
|
jest.mock('../../../backend/utils/apiCall', () => ({
|
|
9
|
-
|
|
9
|
+
apiCall: jest.fn(),
|
|
10
10
|
}))
|
|
11
11
|
|
|
12
12
|
jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
@@ -22,7 +22,7 @@ jest.mock('@open-mercato/shared/lib/logger', () => {
|
|
|
22
22
|
})
|
|
23
23
|
|
|
24
24
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
25
|
-
import {
|
|
25
|
+
import { apiCall } from '../../../backend/utils/apiCall'
|
|
26
26
|
import { useAiPendingActionPolling } from '../useAiPendingActionPolling'
|
|
27
27
|
import type { AiPendingActionCardAction, AiPendingActionCardStatus } from '../types'
|
|
28
28
|
|
|
@@ -51,8 +51,8 @@ function makeAction(
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function
|
|
55
|
-
;(
|
|
54
|
+
function mockOkResponse(action: AiPendingActionCardAction) {
|
|
55
|
+
;(apiCall as jest.Mock).mockImplementation(async () => ({
|
|
56
56
|
ok: true,
|
|
57
57
|
status: 200,
|
|
58
58
|
result: { pendingAction: action },
|
|
@@ -61,17 +61,35 @@ function mockResponse(action: AiPendingActionCardAction) {
|
|
|
61
61
|
}))
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
function mockStatusResponse(status: number) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status,
|
|
68
|
+
result: null,
|
|
69
|
+
response: {},
|
|
70
|
+
cacheStatus: null,
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setDocumentVisibility(state: 'visible' | 'hidden') {
|
|
75
|
+
Object.defineProperty(document, 'visibilityState', {
|
|
76
|
+
configurable: true,
|
|
77
|
+
get: () => state,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
const loggerError = createLogger('ui').error as jest.Mock
|
|
65
82
|
|
|
66
83
|
describe('useAiPendingActionPolling', () => {
|
|
67
84
|
beforeEach(() => {
|
|
68
|
-
;(
|
|
85
|
+
;(apiCall as jest.Mock).mockReset()
|
|
69
86
|
loggerError.mockClear()
|
|
87
|
+
setDocumentVisibility('visible')
|
|
70
88
|
})
|
|
71
89
|
|
|
72
90
|
it('fetches on mount and continues polling while status is pending', async () => {
|
|
73
91
|
let status: AiPendingActionCardStatus = 'pending'
|
|
74
|
-
;(
|
|
92
|
+
;(apiCall as jest.Mock).mockImplementation(async () => ({
|
|
75
93
|
ok: true,
|
|
76
94
|
status: 200,
|
|
77
95
|
result: { pendingAction: makeAction({ status }) },
|
|
@@ -90,7 +108,7 @@ describe('useAiPendingActionPolling', () => {
|
|
|
90
108
|
await Promise.resolve()
|
|
91
109
|
await Promise.resolve()
|
|
92
110
|
})
|
|
93
|
-
expect(
|
|
111
|
+
expect(apiCall).toHaveBeenCalledTimes(1)
|
|
94
112
|
expect(result.current.status).toBe('pending')
|
|
95
113
|
|
|
96
114
|
await act(async () => {
|
|
@@ -98,7 +116,7 @@ describe('useAiPendingActionPolling', () => {
|
|
|
98
116
|
await Promise.resolve()
|
|
99
117
|
await Promise.resolve()
|
|
100
118
|
})
|
|
101
|
-
expect((
|
|
119
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
102
120
|
|
|
103
121
|
status = 'confirmed'
|
|
104
122
|
await act(async () => {
|
|
@@ -107,13 +125,13 @@ describe('useAiPendingActionPolling', () => {
|
|
|
107
125
|
await Promise.resolve()
|
|
108
126
|
})
|
|
109
127
|
expect(result.current.status).toBe('confirmed')
|
|
110
|
-
const callsAfterTerminal = (
|
|
128
|
+
const callsAfterTerminal = (apiCall as jest.Mock).mock.calls.length
|
|
111
129
|
|
|
112
130
|
await act(async () => {
|
|
113
131
|
jest.advanceTimersByTime(5000)
|
|
114
132
|
await Promise.resolve()
|
|
115
133
|
})
|
|
116
|
-
expect((
|
|
134
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterTerminal)
|
|
117
135
|
expect(result.current.isPolling).toBe(false)
|
|
118
136
|
|
|
119
137
|
unmount()
|
|
@@ -123,14 +141,14 @@ describe('useAiPendingActionPolling', () => {
|
|
|
123
141
|
})
|
|
124
142
|
|
|
125
143
|
it('refresh() force-fetches and updates state', async () => {
|
|
126
|
-
|
|
144
|
+
mockOkResponse(makeAction({ status: 'pending' }))
|
|
127
145
|
const { result } = renderHook(() =>
|
|
128
146
|
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 99999 }),
|
|
129
147
|
)
|
|
130
148
|
|
|
131
149
|
await waitFor(() => expect(result.current.action?.status).toBe('pending'))
|
|
132
150
|
|
|
133
|
-
;(
|
|
151
|
+
;(apiCall as jest.Mock).mockImplementationOnce(async () => ({
|
|
134
152
|
ok: true,
|
|
135
153
|
status: 200,
|
|
136
154
|
result: { pendingAction: makeAction({ status: 'confirmed' }) },
|
|
@@ -148,26 +166,26 @@ describe('useAiPendingActionPolling', () => {
|
|
|
148
166
|
it('clears outstanding timers when unmounted mid-poll', async () => {
|
|
149
167
|
jest.useFakeTimers()
|
|
150
168
|
try {
|
|
151
|
-
|
|
169
|
+
mockOkResponse(makeAction({ status: 'pending' }))
|
|
152
170
|
const { unmount } = renderHook(() =>
|
|
153
171
|
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
154
172
|
)
|
|
155
|
-
await waitFor(() => expect(
|
|
173
|
+
await waitFor(() => expect(apiCall).toHaveBeenCalledTimes(1))
|
|
156
174
|
unmount()
|
|
157
|
-
const callsAtUnmount = (
|
|
175
|
+
const callsAtUnmount = (apiCall as jest.Mock).mock.calls.length
|
|
158
176
|
// Advance well past several intervals — no more calls must be fired.
|
|
159
177
|
await act(async () => {
|
|
160
178
|
jest.advanceTimersByTime(10_000)
|
|
161
179
|
})
|
|
162
|
-
expect((
|
|
180
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAtUnmount)
|
|
163
181
|
} finally {
|
|
164
182
|
jest.useRealTimers()
|
|
165
183
|
}
|
|
166
184
|
})
|
|
167
185
|
|
|
168
|
-
it('logs a failed fetch and keeps polling until it recovers', async () => {
|
|
186
|
+
it('logs a failed fetch and keeps polling (with backoff) until it recovers', async () => {
|
|
169
187
|
const failure = new Error('[internal] pending action endpoint unreachable')
|
|
170
|
-
;(
|
|
188
|
+
;(apiCall as jest.Mock)
|
|
171
189
|
.mockImplementationOnce(async () => {
|
|
172
190
|
throw failure
|
|
173
191
|
})
|
|
@@ -196,8 +214,9 @@ describe('useAiPendingActionPolling', () => {
|
|
|
196
214
|
expect(result.current.error?.message).toBe(failure.message)
|
|
197
215
|
expect(result.current.action).toBeNull()
|
|
198
216
|
|
|
217
|
+
// First retry after a failure backs off to intervalMs * 2.
|
|
199
218
|
await act(async () => {
|
|
200
|
-
jest.advanceTimersByTime(
|
|
219
|
+
jest.advanceTimersByTime(2000)
|
|
201
220
|
await Promise.resolve()
|
|
202
221
|
await Promise.resolve()
|
|
203
222
|
})
|
|
@@ -209,4 +228,152 @@ describe('useAiPendingActionPolling', () => {
|
|
|
209
228
|
jest.useRealTimers()
|
|
210
229
|
}
|
|
211
230
|
})
|
|
231
|
+
|
|
232
|
+
it('stops polling on a 404 and reports error.code=not_found', async () => {
|
|
233
|
+
;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(404))
|
|
234
|
+
|
|
235
|
+
jest.useFakeTimers()
|
|
236
|
+
try {
|
|
237
|
+
const { result, unmount } = renderHook(() =>
|
|
238
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
await act(async () => {
|
|
242
|
+
await Promise.resolve()
|
|
243
|
+
await Promise.resolve()
|
|
244
|
+
})
|
|
245
|
+
expect(result.current.error?.code).toBe('not_found')
|
|
246
|
+
expect(result.current.isPolling).toBe(false)
|
|
247
|
+
const callsAfterNotFound = (apiCall as jest.Mock).mock.calls.length
|
|
248
|
+
|
|
249
|
+
await act(async () => {
|
|
250
|
+
jest.advanceTimersByTime(60_000)
|
|
251
|
+
await Promise.resolve()
|
|
252
|
+
})
|
|
253
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterNotFound)
|
|
254
|
+
|
|
255
|
+
unmount()
|
|
256
|
+
} finally {
|
|
257
|
+
jest.useRealTimers()
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('backs off exponentially on repeated 500s and stops after 5 consecutive failures', async () => {
|
|
262
|
+
;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(500))
|
|
263
|
+
|
|
264
|
+
jest.useFakeTimers()
|
|
265
|
+
try {
|
|
266
|
+
const { result, unmount } = renderHook(() =>
|
|
267
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
// Initial fetch (failure #1), then drive through failures #2-#5 by
|
|
271
|
+
// advancing past each capped backoff delay.
|
|
272
|
+
for (let i = 0; i < 5; i += 1) {
|
|
273
|
+
await act(async () => {
|
|
274
|
+
jest.advanceTimersByTime(60_000)
|
|
275
|
+
await Promise.resolve()
|
|
276
|
+
await Promise.resolve()
|
|
277
|
+
})
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
expect(result.current.isPolling).toBe(false)
|
|
281
|
+
const callsAfterStop = (apiCall as jest.Mock).mock.calls.length
|
|
282
|
+
expect(callsAfterStop).toBeGreaterThanOrEqual(5)
|
|
283
|
+
|
|
284
|
+
await act(async () => {
|
|
285
|
+
jest.advanceTimersByTime(60_000)
|
|
286
|
+
await Promise.resolve()
|
|
287
|
+
})
|
|
288
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBe(callsAfterStop)
|
|
289
|
+
|
|
290
|
+
unmount()
|
|
291
|
+
} finally {
|
|
292
|
+
jest.useRealTimers()
|
|
293
|
+
}
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('refresh() restarts a stopped loop', async () => {
|
|
297
|
+
;(apiCall as jest.Mock).mockImplementation(async () => mockStatusResponse(500))
|
|
298
|
+
|
|
299
|
+
jest.useFakeTimers()
|
|
300
|
+
try {
|
|
301
|
+
const { result, unmount } = renderHook(() =>
|
|
302
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
for (let i = 0; i < 5; i += 1) {
|
|
306
|
+
await act(async () => {
|
|
307
|
+
jest.advanceTimersByTime(60_000)
|
|
308
|
+
await Promise.resolve()
|
|
309
|
+
await Promise.resolve()
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
expect(result.current.isPolling).toBe(false)
|
|
313
|
+
|
|
314
|
+
;(apiCall as jest.Mock).mockImplementation(async () => ({
|
|
315
|
+
ok: true,
|
|
316
|
+
status: 200,
|
|
317
|
+
result: { pendingAction: makeAction({ status: 'pending' }) },
|
|
318
|
+
response: {},
|
|
319
|
+
cacheStatus: null,
|
|
320
|
+
}))
|
|
321
|
+
|
|
322
|
+
await act(async () => {
|
|
323
|
+
await result.current.refresh()
|
|
324
|
+
})
|
|
325
|
+
expect(result.current.action?.status).toBe('pending')
|
|
326
|
+
expect(result.current.isPolling).toBe(true)
|
|
327
|
+
|
|
328
|
+
const callsAfterRestart = (apiCall as jest.Mock).mock.calls.length
|
|
329
|
+
await act(async () => {
|
|
330
|
+
jest.advanceTimersByTime(1000)
|
|
331
|
+
await Promise.resolve()
|
|
332
|
+
await Promise.resolve()
|
|
333
|
+
})
|
|
334
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThan(callsAfterRestart)
|
|
335
|
+
|
|
336
|
+
unmount()
|
|
337
|
+
} finally {
|
|
338
|
+
jest.useRealTimers()
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('pauses polling while the tab is hidden and resumes on visibilitychange', async () => {
|
|
343
|
+
mockOkResponse(makeAction({ status: 'pending' }))
|
|
344
|
+
|
|
345
|
+
jest.useFakeTimers()
|
|
346
|
+
try {
|
|
347
|
+
const { unmount } = renderHook(() =>
|
|
348
|
+
useAiPendingActionPolling({ pendingActionId: 'pa-1', intervalMs: 1000 }),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
await act(async () => {
|
|
352
|
+
await Promise.resolve()
|
|
353
|
+
await Promise.resolve()
|
|
354
|
+
})
|
|
355
|
+
const callsBeforeHidden = (apiCall as jest.Mock).mock.calls.length
|
|
356
|
+
|
|
357
|
+
setDocumentVisibility('hidden')
|
|
358
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
359
|
+
|
|
360
|
+
await act(async () => {
|
|
361
|
+
jest.advanceTimersByTime(10_000)
|
|
362
|
+
await Promise.resolve()
|
|
363
|
+
})
|
|
364
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBe(callsBeforeHidden)
|
|
365
|
+
|
|
366
|
+
setDocumentVisibility('visible')
|
|
367
|
+
await act(async () => {
|
|
368
|
+
document.dispatchEvent(new Event('visibilitychange'))
|
|
369
|
+
await Promise.resolve()
|
|
370
|
+
await Promise.resolve()
|
|
371
|
+
})
|
|
372
|
+
expect((apiCall as jest.Mock).mock.calls.length).toBeGreaterThan(callsBeforeHidden)
|
|
373
|
+
|
|
374
|
+
unmount()
|
|
375
|
+
} finally {
|
|
376
|
+
jest.useRealTimers()
|
|
377
|
+
}
|
|
378
|
+
})
|
|
212
379
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
5
|
-
import {
|
|
5
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
6
6
|
import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
|
|
7
7
|
|
|
8
8
|
const logger = createLogger('ui').child({ component: 'useAiPendingActionPolling' })
|
|
@@ -18,8 +18,17 @@ const logger = createLogger('ui').child({ component: 'useAiPendingActionPolling'
|
|
|
18
18
|
* - Poll every 3 seconds while the status is non-terminal
|
|
19
19
|
* (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,
|
|
20
20
|
* `failed`, `expired`) stop polling.
|
|
21
|
+
* - A 404/401/403 from the endpoint is also terminal — the row is gone or
|
|
22
|
+
* unreachable and retrying cannot help (issue #7550: without this, every
|
|
23
|
+
* card whose row disappeared polled every 3s forever, in every tab).
|
|
24
|
+
* - Any other failure (5xx, network error) backs off exponentially (capped
|
|
25
|
+
* at 60s) and gives up after 5 consecutive failures.
|
|
26
|
+
* - Polling pauses while the tab is hidden (`document.visibilityState`) and
|
|
27
|
+
* resumes on `visibilitychange`.
|
|
21
28
|
* - Expose a `refresh()` force-fetch helper the confirmation card uses after
|
|
22
|
-
* a confirm POST races with the polling loop.
|
|
29
|
+
* a confirm POST races with the polling loop. `refresh()` also restarts a
|
|
30
|
+
* stopped loop (backoff-exhausted or resumed-from-hidden) without
|
|
31
|
+
* creating a second timer chain.
|
|
23
32
|
*
|
|
24
33
|
* The hook owns a single `setInterval`/`setTimeout` — unmounting clears
|
|
25
34
|
* every outstanding timer. This is required for the Jest fake-timers
|
|
@@ -33,11 +42,27 @@ const TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [
|
|
|
33
42
|
'expired',
|
|
34
43
|
]
|
|
35
44
|
|
|
45
|
+
const TERMINAL_HTTP_ERROR_CODES: ReadonlyArray<string> = ['not_found', 'unauthorized', 'forbidden']
|
|
46
|
+
|
|
47
|
+
const MAX_BACKOFF_MS = 60_000
|
|
48
|
+
const MAX_CONSECUTIVE_FAILURES = 5
|
|
49
|
+
|
|
36
50
|
function isTerminal(status: AiPendingActionCardStatus | null): boolean {
|
|
37
51
|
if (!status) return false
|
|
38
52
|
return TERMINAL_STATUSES.includes(status)
|
|
39
53
|
}
|
|
40
54
|
|
|
55
|
+
function httpStatusToErrorCode(status: number): string | null {
|
|
56
|
+
if (status === 404) return 'not_found'
|
|
57
|
+
if (status === 401) return 'unauthorized'
|
|
58
|
+
if (status === 403) return 'forbidden'
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isHiddenDocument(): boolean {
|
|
63
|
+
return typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
64
|
+
}
|
|
65
|
+
|
|
41
66
|
export interface UseAiPendingActionPollingOptions {
|
|
42
67
|
pendingActionId: string
|
|
43
68
|
/**
|
|
@@ -57,6 +82,7 @@ export interface UseAiPendingActionPollingOptions {
|
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export interface AiPendingActionFetchResult {
|
|
85
|
+
ok: boolean
|
|
60
86
|
pendingAction: AiPendingActionCardAction | null
|
|
61
87
|
error?: { code?: string; message: string } | null
|
|
62
88
|
}
|
|
@@ -74,7 +100,7 @@ async function fetchPendingAction(
|
|
|
74
100
|
endpoint: string,
|
|
75
101
|
): Promise<AiPendingActionFetchResult> {
|
|
76
102
|
const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`
|
|
77
|
-
const call = await
|
|
103
|
+
const call = await apiCall<unknown>(url, { method: 'GET' })
|
|
78
104
|
const body = call.result as
|
|
79
105
|
| (Partial<AiPendingActionCardAction> & {
|
|
80
106
|
pendingAction?: AiPendingActionCardAction
|
|
@@ -84,6 +110,12 @@ async function fetchPendingAction(
|
|
|
84
110
|
| null
|
|
85
111
|
| undefined
|
|
86
112
|
|
|
113
|
+
if (!call.ok) {
|
|
114
|
+
const code = httpStatusToErrorCode(call.status) ?? body?.code ?? 'request_failed'
|
|
115
|
+
const message = body?.error ?? `Request failed with status ${call.status}.`
|
|
116
|
+
return { ok: false, pendingAction: null, error: { code, message } }
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
// The GET route returns the bare action object (`serializePendingActionForClient(row)`),
|
|
88
120
|
// but earlier client code expected it under a `pendingAction` envelope.
|
|
89
121
|
// Accept BOTH shapes so the cards work whether the dispatcher wraps or
|
|
@@ -92,22 +124,23 @@ async function fetchPendingAction(
|
|
|
92
124
|
// MutationPreviewCard / ConfirmationCard / MutationResultCard.
|
|
93
125
|
if (body && typeof body === 'object') {
|
|
94
126
|
if (body.pendingAction) {
|
|
95
|
-
return { pendingAction: body.pendingAction, error: null }
|
|
127
|
+
return { ok: true, pendingAction: body.pendingAction, error: null }
|
|
96
128
|
}
|
|
97
129
|
if (
|
|
98
130
|
typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&
|
|
99
131
|
typeof (body as Partial<AiPendingActionCardAction>).status === 'string'
|
|
100
132
|
) {
|
|
101
|
-
return { pendingAction: body as AiPendingActionCardAction, error: null }
|
|
133
|
+
return { ok: true, pendingAction: body as AiPendingActionCardAction, error: null }
|
|
102
134
|
}
|
|
103
135
|
if (body.error) {
|
|
104
136
|
return {
|
|
137
|
+
ok: true,
|
|
105
138
|
pendingAction: null,
|
|
106
139
|
error: { message: body.error, code: body.code },
|
|
107
140
|
}
|
|
108
141
|
}
|
|
109
142
|
}
|
|
110
|
-
return { pendingAction: null, error: null }
|
|
143
|
+
return { ok: true, pendingAction: null, error: null }
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
export function useAiPendingActionPolling(
|
|
@@ -127,6 +160,11 @@ export function useAiPendingActionPolling(
|
|
|
127
160
|
const mountedRef = React.useRef(true)
|
|
128
161
|
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
129
162
|
const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)
|
|
163
|
+
const terminalErrorCodeRef = React.useRef<string | null>(null)
|
|
164
|
+
const consecutiveFailuresRef = React.useRef(0)
|
|
165
|
+
const stoppedRef = React.useRef(false)
|
|
166
|
+
const hiddenRef = React.useRef(isHiddenDocument())
|
|
167
|
+
const scheduleNextRef = React.useRef<() => void>(() => {})
|
|
130
168
|
|
|
131
169
|
const clearTimer = React.useCallback(() => {
|
|
132
170
|
if (timerRef.current) {
|
|
@@ -137,26 +175,46 @@ export function useAiPendingActionPolling(
|
|
|
137
175
|
|
|
138
176
|
const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {
|
|
139
177
|
if (!pendingActionId) return null
|
|
178
|
+
let pendingAction: AiPendingActionCardAction | null = null
|
|
140
179
|
try {
|
|
141
180
|
const result = await fetchPendingAction(pendingActionId, endpoint)
|
|
142
181
|
if (!mountedRef.current) return result.pendingAction ?? null
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
pendingAction = result.pendingAction ?? null
|
|
183
|
+
if (result.ok) {
|
|
184
|
+
consecutiveFailuresRef.current = 0
|
|
185
|
+
stoppedRef.current = false
|
|
186
|
+
terminalErrorCodeRef.current = null
|
|
187
|
+
setError(result.error ?? null)
|
|
188
|
+
if (result.pendingAction) {
|
|
189
|
+
setAction(result.pendingAction)
|
|
190
|
+
statusRef.current = result.pendingAction.status
|
|
191
|
+
}
|
|
145
192
|
} else {
|
|
146
|
-
setError(null)
|
|
193
|
+
setError(result.error ?? null)
|
|
194
|
+
const code = result.error?.code
|
|
195
|
+
if (code && TERMINAL_HTTP_ERROR_CODES.includes(code)) {
|
|
196
|
+
terminalErrorCodeRef.current = code
|
|
197
|
+
} else {
|
|
198
|
+
consecutiveFailuresRef.current += 1
|
|
199
|
+
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
200
|
+
stoppedRef.current = true
|
|
201
|
+
}
|
|
202
|
+
}
|
|
147
203
|
}
|
|
148
|
-
if (result.pendingAction) {
|
|
149
|
-
setAction(result.pendingAction)
|
|
150
|
-
statusRef.current = result.pendingAction.status
|
|
151
|
-
}
|
|
152
|
-
return result.pendingAction
|
|
153
204
|
} catch (err) {
|
|
154
205
|
logger.error('Failed to load the pending AI action', { err, pendingActionId })
|
|
155
206
|
if (!mountedRef.current) return null
|
|
156
207
|
const message = err instanceof Error ? err.message : 'Failed to load pending action.'
|
|
157
208
|
setError({ message })
|
|
158
|
-
|
|
209
|
+
consecutiveFailuresRef.current += 1
|
|
210
|
+
if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_FAILURES) {
|
|
211
|
+
stoppedRef.current = true
|
|
212
|
+
}
|
|
159
213
|
}
|
|
214
|
+
if (mountedRef.current) {
|
|
215
|
+
scheduleNextRef.current()
|
|
216
|
+
}
|
|
217
|
+
return pendingAction
|
|
160
218
|
}, [endpoint, pendingActionId])
|
|
161
219
|
|
|
162
220
|
const scheduleNext = React.useCallback(() => {
|
|
@@ -170,20 +228,60 @@ export function useAiPendingActionPolling(
|
|
|
170
228
|
setIsPolling(false)
|
|
171
229
|
return
|
|
172
230
|
}
|
|
231
|
+
if (terminalErrorCodeRef.current) {
|
|
232
|
+
setIsPolling(false)
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
if (stoppedRef.current) {
|
|
236
|
+
setIsPolling(false)
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
if (hiddenRef.current) {
|
|
240
|
+
// Paused while the tab is hidden. `visibilitychange` resumes via refresh().
|
|
241
|
+
return
|
|
242
|
+
}
|
|
173
243
|
setIsPolling(true)
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}, intervalMs)
|
|
244
|
+
const delay =
|
|
245
|
+
consecutiveFailuresRef.current > 0
|
|
246
|
+
? Math.min(intervalMs * 2 ** consecutiveFailuresRef.current, MAX_BACKOFF_MS)
|
|
247
|
+
: intervalMs
|
|
248
|
+
timerRef.current = setTimeout(() => {
|
|
249
|
+
void refresh()
|
|
250
|
+
}, delay)
|
|
182
251
|
}, [clearTimer, disabled, intervalMs, refresh])
|
|
183
252
|
|
|
253
|
+
React.useEffect(() => {
|
|
254
|
+
scheduleNextRef.current = scheduleNext
|
|
255
|
+
}, [scheduleNext])
|
|
256
|
+
|
|
257
|
+
React.useEffect(() => {
|
|
258
|
+
if (disabled) return
|
|
259
|
+
const handleVisibilityChange = () => {
|
|
260
|
+
const wasHidden = hiddenRef.current
|
|
261
|
+
hiddenRef.current = isHiddenDocument()
|
|
262
|
+
if (!wasHidden && hiddenRef.current) {
|
|
263
|
+
// Cancel any already-scheduled poll immediately — do not wait for
|
|
264
|
+
// it to fire before honoring the pause.
|
|
265
|
+
clearTimer()
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (wasHidden && !hiddenRef.current && mountedRef.current) {
|
|
269
|
+
// Resume immediately rather than waiting out a stale backoff delay.
|
|
270
|
+
void refresh()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
274
|
+
return () => {
|
|
275
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
276
|
+
}
|
|
277
|
+
}, [clearTimer, disabled, refresh])
|
|
278
|
+
|
|
184
279
|
React.useEffect(() => {
|
|
185
280
|
mountedRef.current = true
|
|
186
281
|
statusRef.current = null
|
|
282
|
+
terminalErrorCodeRef.current = null
|
|
283
|
+
consecutiveFailuresRef.current = 0
|
|
284
|
+
stoppedRef.current = false
|
|
187
285
|
if (disabled) {
|
|
188
286
|
setIsPolling(false)
|
|
189
287
|
return () => {
|
|
@@ -192,18 +290,9 @@ export function useAiPendingActionPolling(
|
|
|
192
290
|
}
|
|
193
291
|
}
|
|
194
292
|
setIsPolling(true)
|
|
195
|
-
// Always fetch on mount — the "reconnect behavior" guarantee.
|
|
196
|
-
//
|
|
197
|
-
// logged error instead of silently killing the polling loop.
|
|
293
|
+
// Always fetch on mount — the "reconnect behavior" guarantee. `refresh`
|
|
294
|
+
// schedules the next poll itself once it settles.
|
|
198
295
|
void refresh()
|
|
199
|
-
.catch((err) => {
|
|
200
|
-
logger.error('Loading the pending AI action failed unexpectedly', { err })
|
|
201
|
-
return null
|
|
202
|
-
})
|
|
203
|
-
.then(() => {
|
|
204
|
-
if (!mountedRef.current) return
|
|
205
|
-
scheduleNext()
|
|
206
|
-
})
|
|
207
296
|
return () => {
|
|
208
297
|
mountedRef.current = false
|
|
209
298
|
clearTimer()
|