@robosystems/core 0.11.2 → 0.11.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.
@@ -1,4 +1,19 @@
1
1
  import * as SDK from '@robosystems/client';
2
+ import { isTransientError, unwrapSdk } from '../lib/sdk-errors';
3
+ /** Consecutive transient status-read failures tolerated before giving up. */
4
+ const DEFAULT_MAX_CONSECUTIVE_ERRORS = 5;
5
+ /** Upper bound on the backoff between status reads after a failure. */
6
+ const MAX_ERROR_BACKOFF_MS = 30 * 1000;
7
+ /** Statuses of an operation that has not finished (API `OperationStatus`). */
8
+ const NON_TERMINAL_STATUSES = new Set([
9
+ 'pending',
10
+ 'running',
11
+ 'awaiting_input',
12
+ 'in_progress',
13
+ 'retrying',
14
+ ]);
15
+ /** Rejection message when watching stopped locally (`stopPolling`). */
16
+ export const POLLING_CANCELLED = 'Task polling was cancelled';
2
17
  export class TaskMonitor {
3
18
  constructor() {
4
19
  this.activeTasks = new Map();
@@ -10,110 +25,139 @@ export class TaskMonitor {
10
25
  return TaskMonitor.instance;
11
26
  }
12
27
  async pollTask({ taskId, onProgress, onComplete, onError, pollInterval = 2000, maxAttempts = 150, // 5 minutes at 2s intervals
13
- }) {
28
+ maxConsecutiveErrors = DEFAULT_MAX_CONSECUTIVE_ERRORS, }) {
14
29
  let attempts = 0;
30
+ let consecutiveErrors = 0;
15
31
  const abortController = new AbortController();
32
+ // One poller per task: a second pollTask for the same id replaces (and
33
+ // stops) the first, which could otherwise never be stopped.
34
+ this.stopPolling(taskId);
16
35
  this.activeTasks.set(taskId, abortController);
17
36
  return new Promise((resolve, reject) => {
37
+ let settled = false;
38
+ let timer = null;
39
+ const finish = (outcome) => {
40
+ if (settled)
41
+ return;
42
+ settled = true;
43
+ if (timer)
44
+ clearTimeout(timer);
45
+ if (this.activeTasks.get(taskId) === abortController) {
46
+ this.activeTasks.delete(taskId);
47
+ }
48
+ outcome();
49
+ };
50
+ const fail = (error, notify = true) => finish(() => {
51
+ if (notify)
52
+ onError === null || onError === void 0 ? void 0 : onError(error.message);
53
+ reject(error);
54
+ });
55
+ // Stopping (unmount, explicit cancel) settles the promise at once, even
56
+ // while a status request is in flight — its answer is then ignored.
57
+ abortController.signal.addEventListener('abort', () => fail(new Error(POLLING_CANCELLED), false));
58
+ const schedule = (delay) => {
59
+ if (settled)
60
+ return;
61
+ timer = setTimeout(poll, delay);
62
+ };
18
63
  const poll = async () => {
64
+ if (settled)
65
+ return;
66
+ attempts++;
67
+ let status;
19
68
  try {
20
- if (abortController.signal.aborted) {
21
- this.activeTasks.delete(taskId);
22
- reject(new Error('Task polling was cancelled'));
23
- return;
24
- }
25
- attempts++;
26
69
  const response = await SDK.getOperationStatus({
27
70
  path: { operation_id: taskId },
28
71
  });
29
- if (!response.data) {
72
+ if (settled)
73
+ return;
74
+ const data = unwrapSdk(response);
75
+ if (!data) {
30
76
  throw new Error('No data received from task status endpoint');
31
77
  }
32
- const status = response.data;
33
- switch (status.status) {
34
- case 'pending':
35
- case 'in_progress':
36
- case 'retrying':
37
- onProgress === null || onProgress === void 0 ? void 0 : onProgress(status);
38
- if (attempts < maxAttempts && !abortController.signal.aborted) {
39
- setTimeout(poll, pollInterval);
40
- }
41
- else if (attempts >= maxAttempts) {
42
- this.activeTasks.delete(taskId);
43
- const timeoutError = new Error(`Task polling timeout after ${attempts} attempts`);
44
- onError === null || onError === void 0 ? void 0 : onError(timeoutError.message);
45
- reject(timeoutError);
46
- }
47
- break;
48
- case 'completed':
49
- this.activeTasks.delete(taskId);
50
- onComplete === null || onComplete === void 0 ? void 0 : onComplete(status.details || status);
51
- resolve(status);
52
- break;
53
- case 'failed': {
54
- this.activeTasks.delete(taskId);
55
- const failureError = new Error(status.message || 'Task failed without error message');
56
- onError === null || onError === void 0 ? void 0 : onError(failureError.message);
57
- reject(failureError);
58
- break;
59
- }
60
- case 'cancelled': {
61
- this.activeTasks.delete(taskId);
62
- const cancelError = new Error('Task was cancelled');
63
- onError === null || onError === void 0 ? void 0 : onError(cancelError.message);
64
- reject(cancelError);
65
- break;
66
- }
67
- default:
68
- // Handle unknown status as in_progress
69
- onProgress === null || onProgress === void 0 ? void 0 : onProgress(status);
70
- if (attempts < maxAttempts && !abortController.signal.aborted) {
71
- setTimeout(poll, pollInterval);
72
- }
73
- else if (attempts >= maxAttempts) {
74
- this.activeTasks.delete(taskId);
75
- const unknownError = new Error(`Unknown task status: ${status.status}`);
76
- onError === null || onError === void 0 ? void 0 : onError(unknownError.message);
77
- reject(unknownError);
78
- }
79
- }
78
+ status = data;
79
+ consecutiveErrors = 0;
80
80
  }
81
81
  catch (error) {
82
- this.activeTasks.delete(taskId);
83
- const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
82
+ if (settled)
83
+ return;
84
+ // A blip (network, 429, 5xx) says nothing about the task: keep
85
+ // polling with backoff, and only give up after several in a row.
86
+ if (isTransientError(error) &&
87
+ ++consecutiveErrors < maxConsecutiveErrors) {
88
+ console.warn('Task status read failed, retrying:', error);
89
+ schedule(Math.min(pollInterval * 2 ** consecutiveErrors, MAX_ERROR_BACKOFF_MS));
90
+ return;
91
+ }
84
92
  console.error('Task polling error:', error);
85
- onError === null || onError === void 0 ? void 0 : onError(errorMessage);
86
- reject(error);
93
+ fail(error instanceof Error ? error : new Error('Unknown error occurred'));
94
+ return;
95
+ }
96
+ switch (status.status) {
97
+ case 'completed':
98
+ finish(() => {
99
+ onComplete === null || onComplete === void 0 ? void 0 : onComplete(status.details || status);
100
+ resolve(status);
101
+ });
102
+ return;
103
+ case 'failed':
104
+ fail(new Error(status.message || 'Task failed without error message'));
105
+ return;
106
+ case 'cancelled':
107
+ fail(new Error('Task was cancelled'));
108
+ return;
109
+ default:
110
+ // Non-terminal: the API's pending / running / awaiting_input,
111
+ // older in_progress / retrying, and any status added later.
112
+ onProgress === null || onProgress === void 0 ? void 0 : onProgress(status);
113
+ if (attempts >= maxAttempts) {
114
+ fail(new Error(NON_TERMINAL_STATUSES.has(status.status)
115
+ ? `Task is still ${status.status.replace(/_/g, ' ')} after ${attempts} checks. ` +
116
+ 'It continues on the server; check back later.'
117
+ : `Unknown task status: ${status.status}`));
118
+ return;
119
+ }
120
+ schedule(pollInterval);
87
121
  }
88
122
  };
89
123
  // Start polling
90
124
  poll();
91
125
  });
92
126
  }
127
+ /**
128
+ * Stop watching a task locally. The operation itself keeps running on the
129
+ * server — this is what leaving a page should do.
130
+ */
131
+ stopPolling(taskId) {
132
+ const abortController = this.activeTasks.get(taskId);
133
+ if (!abortController)
134
+ return false;
135
+ this.activeTasks.delete(taskId);
136
+ abortController.abort();
137
+ return true;
138
+ }
139
+ /**
140
+ * Cancel the operation on the server, then stop watching it. Only for an
141
+ * explicit user action. Returns false when the server refused — the task
142
+ * is then still being watched, and its real outcome still arrives.
143
+ */
93
144
  async cancelTask(taskId) {
94
145
  try {
95
- // Cancel local polling
96
- const abortController = this.activeTasks.get(taskId);
97
- if (abortController) {
98
- abortController.abort();
99
- this.activeTasks.delete(taskId);
100
- }
101
- // Cancel task on server
102
- await SDK.cancelOperation({
146
+ unwrapSdk(await SDK.cancelOperation({
103
147
  path: { operation_id: taskId },
104
- });
105
- return true;
148
+ }));
106
149
  }
107
150
  catch (error) {
108
151
  console.error('Failed to cancel task:', error);
109
152
  return false;
110
153
  }
154
+ this.stopPolling(taskId);
155
+ return true;
111
156
  }
112
157
  cancelAllTasks() {
113
- for (const [, abortController] of this.activeTasks.entries()) {
114
- abortController.abort();
158
+ for (const taskId of Array.from(this.activeTasks.keys())) {
159
+ this.stopPolling(taskId);
115
160
  }
116
- this.activeTasks.clear();
117
161
  }
118
162
  getActiveTasks() {
119
163
  return Array.from(this.activeTasks.keys());
@@ -127,6 +171,9 @@ export const pollTaskStatus = (options) => {
127
171
  export const cancelTask = (taskId) => {
128
172
  return taskMonitor.cancelTask(taskId);
129
173
  };
174
+ export const stopPollingTask = (taskId) => {
175
+ return taskMonitor.stopPolling(taskId);
176
+ };
130
177
  export const getActiveTasks = () => {
131
178
  return taskMonitor.getActiveTasks();
132
179
  };
@@ -7,7 +7,7 @@ export interface TaskStatusResponse {
7
7
  result?: any;
8
8
  error?: string;
9
9
  }
10
- export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled' | 'retrying';
10
+ export type TaskStatus = 'pending' | 'running' | 'awaiting_input' | 'in_progress' | 'retrying' | 'completed' | 'failed' | 'cancelled';
11
11
  export interface TaskPollingOptions {
12
12
  taskId: string;
13
13
  onProgress?: (status: TaskStatusResponse) => void;
@@ -15,6 +15,7 @@ export interface TaskPollingOptions {
15
15
  onError?: (error: string) => void;
16
16
  pollInterval?: number;
17
17
  maxAttempts?: number;
18
+ maxConsecutiveErrors?: number;
18
19
  }
19
20
  export interface TaskMonitorState {
20
21
  isLoading: boolean;