deepline 0.3.64 → 0.3.65
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/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +999 -0
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +265 -35
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +7 -0
- package/dist/bundling-sources/shared_libs/play-runtime/play-runtime-batching-registry.ts +2 -0
- package/dist/bundling-sources/shared_libs/play-runtime/test-async-batching.ts +141 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +57 -10
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.mjs +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/install-integrity.json +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
export type AsyncOperationJsonValue =
|
|
2
|
+
| string
|
|
3
|
+
| number
|
|
4
|
+
| boolean
|
|
5
|
+
| null
|
|
6
|
+
| readonly AsyncOperationJsonValue[]
|
|
7
|
+
| { readonly [key: string]: AsyncOperationJsonValue };
|
|
8
|
+
|
|
9
|
+
export type AsyncOperationTerminalOutcome =
|
|
10
|
+
| 'running'
|
|
11
|
+
| 'succeeded'
|
|
12
|
+
| 'no_result'
|
|
13
|
+
| 'failed'
|
|
14
|
+
| 'cancelled';
|
|
15
|
+
|
|
16
|
+
export type AsyncOperationValueSource =
|
|
17
|
+
| { source: 'job_id' }
|
|
18
|
+
| { source: 'start_input'; path: string }
|
|
19
|
+
| { source: 'start_result'; path: string }
|
|
20
|
+
| { source: 'poll_result'; path: string }
|
|
21
|
+
| { source: 'literal'; value: AsyncOperationJsonValue };
|
|
22
|
+
|
|
23
|
+
export type AsyncOperationResultValueSource =
|
|
24
|
+
| AsyncOperationValueSource
|
|
25
|
+
| { source: 'finish_result'; path: string };
|
|
26
|
+
|
|
27
|
+
export type AsyncOperationInputMapping = Readonly<
|
|
28
|
+
Record<string, AsyncOperationValueSource>
|
|
29
|
+
>;
|
|
30
|
+
|
|
31
|
+
export type AsyncOperationCondition = {
|
|
32
|
+
path: string;
|
|
33
|
+
equals?: string | number | boolean | null;
|
|
34
|
+
in?: readonly (string | number | boolean | null)[];
|
|
35
|
+
exists?: boolean;
|
|
36
|
+
normalize?: 'lowercase' | 'uppercase';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type AsyncOperationTerminalRule = {
|
|
40
|
+
outcome: AsyncOperationTerminalOutcome;
|
|
41
|
+
mode?: 'all' | 'any';
|
|
42
|
+
conditions: readonly AsyncOperationCondition[];
|
|
43
|
+
messagePath?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AsyncOperationActionCall = {
|
|
47
|
+
action: string;
|
|
48
|
+
input: AsyncOperationInputMapping;
|
|
49
|
+
/** Reuse the terminal poll response when poll and finish are the same read. */
|
|
50
|
+
reuseTerminalPollResult?: boolean;
|
|
51
|
+
pagination?: {
|
|
52
|
+
pageInputField: string;
|
|
53
|
+
startPage: number;
|
|
54
|
+
maxPages: number;
|
|
55
|
+
itemsPath: string;
|
|
56
|
+
totalPagesPath?: string;
|
|
57
|
+
lastPagePath?: string;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type AsyncOperationResultMaterialization = {
|
|
62
|
+
source: 'start_result' | 'poll_result' | 'finish_result';
|
|
63
|
+
path?: string;
|
|
64
|
+
emptyIsNoResult?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Compose the public result envelope without provider-specific runner code.
|
|
67
|
+
* Fields overwrite the selected result object. Set mergeSourceObject=false
|
|
68
|
+
* when the historical interface is an envelope rather than the raw result.
|
|
69
|
+
*/
|
|
70
|
+
objectFields?: Readonly<Record<string, AsyncOperationResultValueSource>>;
|
|
71
|
+
mergeSourceObject?: boolean;
|
|
72
|
+
/** Restore batch item identities from submitted input when a provider omits them. */
|
|
73
|
+
correlateByPosition?: {
|
|
74
|
+
inputPath: string;
|
|
75
|
+
resultPath: string;
|
|
76
|
+
fields: readonly string[];
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type AsyncOperationContractWire = {
|
|
81
|
+
version: 1;
|
|
82
|
+
/** Preserve the provider's historical public async metadata exactly. */
|
|
83
|
+
compatibility?: {
|
|
84
|
+
/** null intentionally suppresses the legacy asyncGetAction projection. */
|
|
85
|
+
asyncGetAction?: string | null;
|
|
86
|
+
};
|
|
87
|
+
lifecycle: {
|
|
88
|
+
startAction: string;
|
|
89
|
+
pollActions: readonly AsyncOperationActionCall[];
|
|
90
|
+
finishAction?: AsyncOperationActionCall;
|
|
91
|
+
cancelAction?: AsyncOperationActionCall;
|
|
92
|
+
};
|
|
93
|
+
capacity: {
|
|
94
|
+
scope:
|
|
95
|
+
| 'organization_provider'
|
|
96
|
+
| 'organization_connection'
|
|
97
|
+
| 'organization_action';
|
|
98
|
+
limit:
|
|
99
|
+
| { kind: 'provider_registry' }
|
|
100
|
+
| { kind: 'fixed'; maxConcurrentJobs: number };
|
|
101
|
+
onConflict: 'wait' | 'fail';
|
|
102
|
+
leaseTtlMs: number;
|
|
103
|
+
};
|
|
104
|
+
job: { idPaths: readonly string[] };
|
|
105
|
+
polling: {
|
|
106
|
+
initialDelayMs: number;
|
|
107
|
+
intervalMs: number;
|
|
108
|
+
/** Optional public start-input field that overrides intervalMs for this call. */
|
|
109
|
+
intervalInputField?: string;
|
|
110
|
+
backoffMultiplier?: number;
|
|
111
|
+
maxIntervalMs?: number;
|
|
112
|
+
timeoutMs: number;
|
|
113
|
+
/** Optional public start-input field that overrides timeoutMs for this call. */
|
|
114
|
+
timeoutInputField?: string;
|
|
115
|
+
};
|
|
116
|
+
terminal: {
|
|
117
|
+
rules: readonly AsyncOperationTerminalRule[];
|
|
118
|
+
defaultOutcome: 'running' | 'failed';
|
|
119
|
+
};
|
|
120
|
+
result: AsyncOperationResultMaterialization;
|
|
121
|
+
wait:
|
|
122
|
+
| { kind: 'always' }
|
|
123
|
+
| {
|
|
124
|
+
kind: 'input_flag';
|
|
125
|
+
field: string;
|
|
126
|
+
default: boolean;
|
|
127
|
+
durableValue?: boolean;
|
|
128
|
+
}
|
|
129
|
+
| { kind: 'caller_managed' };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type AsyncOperationContract = AsyncOperationContractWire;
|
|
133
|
+
export type AsyncActionFlowCompatibilityProjection = {
|
|
134
|
+
pollActions: readonly string[];
|
|
135
|
+
finishAction?: string;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
function required(value: string, label: string): string {
|
|
139
|
+
const normalized = value.trim();
|
|
140
|
+
if (!normalized) throw new Error(`AsyncOperationContract requires ${label}.`);
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function positiveInteger(value: number, label: string): number {
|
|
145
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`AsyncOperationContract ${label} must be a positive safe integer.`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function nonNegativeInteger(value: number, label: string): number {
|
|
154
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`AsyncOperationContract ${label} must be a non-negative safe integer.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateActionCall(
|
|
163
|
+
call: AsyncOperationActionCall,
|
|
164
|
+
label: string,
|
|
165
|
+
): void {
|
|
166
|
+
required(call.action, `${label}.action`);
|
|
167
|
+
for (const [field, source] of Object.entries(call.input)) {
|
|
168
|
+
required(field, `${label}.input field`);
|
|
169
|
+
if (source.source !== 'job_id' && source.source !== 'literal') {
|
|
170
|
+
required(source.path, `${label}.input.${field}.path`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateResultValueSource(
|
|
176
|
+
source: AsyncOperationResultValueSource,
|
|
177
|
+
label: string,
|
|
178
|
+
): void {
|
|
179
|
+
if (source.source !== 'job_id' && source.source !== 'literal') {
|
|
180
|
+
required(source.path, `${label}.path`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function defineAsyncOperation(
|
|
185
|
+
contract: AsyncOperationContract,
|
|
186
|
+
): AsyncOperationContract {
|
|
187
|
+
required(contract.lifecycle.startAction, 'lifecycle.startAction');
|
|
188
|
+
if (contract.lifecycle.pollActions.length === 0) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
'AsyncOperationContract requires at least one lifecycle.pollActions entry.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
contract.lifecycle.pollActions.forEach((call, index) =>
|
|
194
|
+
validateActionCall(call, `lifecycle.pollActions[${index}]`),
|
|
195
|
+
);
|
|
196
|
+
if (contract.lifecycle.finishAction) {
|
|
197
|
+
validateActionCall(
|
|
198
|
+
contract.lifecycle.finishAction,
|
|
199
|
+
'lifecycle.finishAction',
|
|
200
|
+
);
|
|
201
|
+
const pagination = contract.lifecycle.finishAction.pagination;
|
|
202
|
+
if (pagination) {
|
|
203
|
+
required(
|
|
204
|
+
pagination.pageInputField,
|
|
205
|
+
'lifecycle.finishAction.pagination.pageInputField',
|
|
206
|
+
);
|
|
207
|
+
nonNegativeInteger(
|
|
208
|
+
pagination.startPage,
|
|
209
|
+
'lifecycle.finishAction.pagination.startPage',
|
|
210
|
+
);
|
|
211
|
+
positiveInteger(
|
|
212
|
+
pagination.maxPages,
|
|
213
|
+
'lifecycle.finishAction.pagination.maxPages',
|
|
214
|
+
);
|
|
215
|
+
required(
|
|
216
|
+
pagination.itemsPath,
|
|
217
|
+
'lifecycle.finishAction.pagination.itemsPath',
|
|
218
|
+
);
|
|
219
|
+
if (pagination.totalPagesPath !== undefined) {
|
|
220
|
+
required(
|
|
221
|
+
pagination.totalPagesPath,
|
|
222
|
+
'lifecycle.finishAction.pagination.totalPagesPath',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (pagination.lastPagePath !== undefined) {
|
|
226
|
+
required(
|
|
227
|
+
pagination.lastPagePath,
|
|
228
|
+
'lifecycle.finishAction.pagination.lastPagePath',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
if (
|
|
232
|
+
pagination.totalPagesPath === undefined &&
|
|
233
|
+
pagination.lastPagePath === undefined
|
|
234
|
+
) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
'AsyncOperationContract paginated finish action requires totalPagesPath or lastPagePath.',
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (contract.lifecycle.cancelAction) {
|
|
242
|
+
validateActionCall(
|
|
243
|
+
contract.lifecycle.cancelAction,
|
|
244
|
+
'lifecycle.cancelAction',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (contract.job.idPaths.length === 0) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
'AsyncOperationContract requires at least one job.idPaths entry.',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
contract.job.idPaths.forEach((path, index) =>
|
|
253
|
+
required(path, `job.idPaths[${index}]`),
|
|
254
|
+
);
|
|
255
|
+
if (contract.result.objectFields) {
|
|
256
|
+
for (const [field, source] of Object.entries(
|
|
257
|
+
contract.result.objectFields,
|
|
258
|
+
)) {
|
|
259
|
+
required(field, 'result.objectFields field');
|
|
260
|
+
validateResultValueSource(source, `result.objectFields.${field}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
positiveInteger(contract.capacity.leaseTtlMs, 'capacity.leaseTtlMs');
|
|
264
|
+
if (contract.capacity.limit.kind === 'fixed') {
|
|
265
|
+
positiveInteger(
|
|
266
|
+
contract.capacity.limit.maxConcurrentJobs,
|
|
267
|
+
'capacity.limit.maxConcurrentJobs',
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
nonNegativeInteger(contract.polling.initialDelayMs, 'polling.initialDelayMs');
|
|
271
|
+
positiveInteger(contract.polling.intervalMs, 'polling.intervalMs');
|
|
272
|
+
positiveInteger(contract.polling.timeoutMs, 'polling.timeoutMs');
|
|
273
|
+
if (contract.polling.intervalInputField !== undefined) {
|
|
274
|
+
required(contract.polling.intervalInputField, 'polling.intervalInputField');
|
|
275
|
+
}
|
|
276
|
+
if (contract.polling.timeoutInputField !== undefined) {
|
|
277
|
+
required(contract.polling.timeoutInputField, 'polling.timeoutInputField');
|
|
278
|
+
}
|
|
279
|
+
if (
|
|
280
|
+
contract.polling.backoffMultiplier !== undefined &&
|
|
281
|
+
(!Number.isFinite(contract.polling.backoffMultiplier) ||
|
|
282
|
+
contract.polling.backoffMultiplier < 1)
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'AsyncOperationContract polling.backoffMultiplier must be at least 1.',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (contract.polling.maxIntervalMs !== undefined) {
|
|
289
|
+
positiveInteger(contract.polling.maxIntervalMs, 'polling.maxIntervalMs');
|
|
290
|
+
if (contract.polling.maxIntervalMs < contract.polling.intervalMs) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'AsyncOperationContract polling.maxIntervalMs must be at least polling.intervalMs.',
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (contract.terminal.rules.length === 0) {
|
|
297
|
+
throw new Error('AsyncOperationContract requires terminal.rules.');
|
|
298
|
+
}
|
|
299
|
+
contract.terminal.rules.forEach((rule, ruleIndex) => {
|
|
300
|
+
if (rule.conditions.length === 0) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`AsyncOperationContract terminal.rules[${ruleIndex}] requires conditions.`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
rule.conditions.forEach((condition, conditionIndex) => {
|
|
306
|
+
required(
|
|
307
|
+
condition.path,
|
|
308
|
+
`terminal.rules[${ruleIndex}].conditions[${conditionIndex}].path`,
|
|
309
|
+
);
|
|
310
|
+
const predicates = [
|
|
311
|
+
condition.equals !== undefined,
|
|
312
|
+
condition.in !== undefined,
|
|
313
|
+
condition.exists !== undefined,
|
|
314
|
+
].filter(Boolean).length;
|
|
315
|
+
if (predicates !== 1) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`AsyncOperationContract terminal.rules[${ruleIndex}].conditions[${conditionIndex}] requires exactly one predicate.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
if (
|
|
323
|
+
contract.result.source === 'finish_result' &&
|
|
324
|
+
!contract.lifecycle.finishAction
|
|
325
|
+
) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
'AsyncOperationContract result.source="finish_result" requires lifecycle.finishAction.',
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (contract.result.path !== undefined)
|
|
331
|
+
required(contract.result.path, 'result.path');
|
|
332
|
+
if (contract.wait.kind === 'input_flag')
|
|
333
|
+
required(contract.wait.field, 'wait.field');
|
|
334
|
+
if (typeof contract.compatibility?.asyncGetAction === 'string') {
|
|
335
|
+
required(
|
|
336
|
+
contract.compatibility.asyncGetAction,
|
|
337
|
+
'compatibility.asyncGetAction',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return contract;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function projectAsyncActionFlow(
|
|
344
|
+
contract: AsyncOperationContract,
|
|
345
|
+
): AsyncActionFlowCompatibilityProjection {
|
|
346
|
+
return {
|
|
347
|
+
pollActions: contract.lifecycle.pollActions.map((call) => call.action),
|
|
348
|
+
...(contract.lifecycle.finishAction
|
|
349
|
+
? { finishAction: contract.lifecycle.finishAction.action }
|
|
350
|
+
: {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function projectAsyncGetAction(
|
|
355
|
+
contract: AsyncOperationContract,
|
|
356
|
+
): string | undefined {
|
|
357
|
+
if (contract.compatibility?.asyncGetAction !== undefined) {
|
|
358
|
+
return contract.compatibility.asyncGetAction ?? undefined;
|
|
359
|
+
}
|
|
360
|
+
return (
|
|
361
|
+
contract.lifecycle.finishAction?.action ??
|
|
362
|
+
contract.lifecycle.pollActions[contract.lifecycle.pollActions.length - 1]!
|
|
363
|
+
.action
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function positiveStartInputNumber(
|
|
368
|
+
input: Readonly<Record<string, unknown>>,
|
|
369
|
+
field: string | undefined,
|
|
370
|
+
): number | null {
|
|
371
|
+
if (!field) return null;
|
|
372
|
+
const value = readAsyncOperationPath(input, field);
|
|
373
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
374
|
+
? value
|
|
375
|
+
: null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Resolve per-call polling controls without changing the provider's public input. */
|
|
379
|
+
export function resolveAsyncOperationPolling(input: {
|
|
380
|
+
contract: AsyncOperationContract;
|
|
381
|
+
startInput: Readonly<Record<string, unknown>>;
|
|
382
|
+
}): { intervalMs: number; timeoutMs: number } {
|
|
383
|
+
return {
|
|
384
|
+
intervalMs:
|
|
385
|
+
positiveStartInputNumber(
|
|
386
|
+
input.startInput,
|
|
387
|
+
input.contract.polling.intervalInputField,
|
|
388
|
+
) ?? input.contract.polling.intervalMs,
|
|
389
|
+
timeoutMs:
|
|
390
|
+
positiveStartInputNumber(
|
|
391
|
+
input.startInput,
|
|
392
|
+
input.contract.polling.timeoutInputField,
|
|
393
|
+
) ?? input.contract.polling.timeoutMs,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Compatibility-preserving declaration for intentionally caller-managed jobs.
|
|
399
|
+
* The runner publishes the full lifecycle metadata but never polls it unless a
|
|
400
|
+
* later provider migration explicitly changes `wait`.
|
|
401
|
+
*/
|
|
402
|
+
export function defineCallerManagedAsyncOperation(input: {
|
|
403
|
+
startAction: string;
|
|
404
|
+
pollActions: readonly string[];
|
|
405
|
+
finishAction?: string;
|
|
406
|
+
jobIdPaths?: readonly string[];
|
|
407
|
+
/**
|
|
408
|
+
* Preserve an existing public asyncGetAction, or pass null when the legacy
|
|
409
|
+
* action exposed only asyncFlow metadata.
|
|
410
|
+
*/
|
|
411
|
+
asyncGetAction?: string | null;
|
|
412
|
+
}): AsyncOperationContract {
|
|
413
|
+
const companion = (action: string): AsyncOperationActionCall => ({
|
|
414
|
+
action,
|
|
415
|
+
input: {},
|
|
416
|
+
});
|
|
417
|
+
return defineAsyncOperation({
|
|
418
|
+
version: 1,
|
|
419
|
+
compatibility: {
|
|
420
|
+
asyncGetAction:
|
|
421
|
+
input.asyncGetAction !== undefined
|
|
422
|
+
? input.asyncGetAction
|
|
423
|
+
: (input.finishAction ?? null),
|
|
424
|
+
},
|
|
425
|
+
lifecycle: {
|
|
426
|
+
startAction: input.startAction,
|
|
427
|
+
pollActions: input.pollActions.map(companion),
|
|
428
|
+
...(input.finishAction
|
|
429
|
+
? { finishAction: companion(input.finishAction) }
|
|
430
|
+
: {}),
|
|
431
|
+
},
|
|
432
|
+
capacity: {
|
|
433
|
+
scope: 'organization_provider',
|
|
434
|
+
limit: { kind: 'provider_registry' },
|
|
435
|
+
onConflict: 'wait',
|
|
436
|
+
leaseTtlMs: 15 * 60_000,
|
|
437
|
+
},
|
|
438
|
+
job: {
|
|
439
|
+
idPaths: input.jobIdPaths ?? [
|
|
440
|
+
'job_id',
|
|
441
|
+
'jobId',
|
|
442
|
+
'task_id',
|
|
443
|
+
'taskId',
|
|
444
|
+
'run_id',
|
|
445
|
+
'runId',
|
|
446
|
+
'id',
|
|
447
|
+
'data.id',
|
|
448
|
+
],
|
|
449
|
+
},
|
|
450
|
+
polling: {
|
|
451
|
+
initialDelayMs: 0,
|
|
452
|
+
intervalMs: 1_000,
|
|
453
|
+
timeoutMs: 15 * 60_000,
|
|
454
|
+
},
|
|
455
|
+
terminal: {
|
|
456
|
+
rules: [
|
|
457
|
+
{
|
|
458
|
+
outcome: 'running',
|
|
459
|
+
conditions: [{ path: '$', exists: true }],
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
defaultOutcome: 'running',
|
|
463
|
+
},
|
|
464
|
+
result: { source: 'start_result' },
|
|
465
|
+
wait: { kind: 'caller_managed' },
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function asyncOperationCompanionActions(
|
|
470
|
+
contract: AsyncOperationContract,
|
|
471
|
+
): string[] {
|
|
472
|
+
return [
|
|
473
|
+
...contract.lifecycle.pollActions.map((call) => call.action),
|
|
474
|
+
...(contract.lifecycle.finishAction
|
|
475
|
+
? [contract.lifecycle.finishAction.action]
|
|
476
|
+
: []),
|
|
477
|
+
...(contract.lifecycle.cancelAction
|
|
478
|
+
? [contract.lifecycle.cancelAction.action]
|
|
479
|
+
: []),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function readAsyncOperationPath(value: unknown, path: string): unknown {
|
|
484
|
+
if (path === '$' || path === '') return value;
|
|
485
|
+
let current = value;
|
|
486
|
+
for (const segment of path.split('.').filter(Boolean)) {
|
|
487
|
+
if (!current || typeof current !== 'object' || Array.isArray(current))
|
|
488
|
+
return undefined;
|
|
489
|
+
current = (current as Record<string, unknown>)[segment];
|
|
490
|
+
}
|
|
491
|
+
return current;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function writeAsyncOperationPath(
|
|
495
|
+
value: unknown,
|
|
496
|
+
path: string,
|
|
497
|
+
replacement: unknown,
|
|
498
|
+
): unknown {
|
|
499
|
+
if (path === '$' || path === '') return replacement;
|
|
500
|
+
const segments = path.split('.').filter(Boolean);
|
|
501
|
+
if (segments.length === 0) return replacement;
|
|
502
|
+
const root =
|
|
503
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
504
|
+
? { ...(value as Record<string, unknown>) }
|
|
505
|
+
: {};
|
|
506
|
+
let target = root;
|
|
507
|
+
for (const segment of segments.slice(0, -1)) {
|
|
508
|
+
const child = target[segment];
|
|
509
|
+
const cloned =
|
|
510
|
+
child && typeof child === 'object' && !Array.isArray(child)
|
|
511
|
+
? { ...(child as Record<string, unknown>) }
|
|
512
|
+
: {};
|
|
513
|
+
target[segment] = cloned;
|
|
514
|
+
target = cloned;
|
|
515
|
+
}
|
|
516
|
+
target[segments.at(-1)!] = replacement;
|
|
517
|
+
return root;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function asyncOperationShouldWait(
|
|
521
|
+
contract: AsyncOperationContractWire,
|
|
522
|
+
input: Record<string, unknown>,
|
|
523
|
+
): boolean {
|
|
524
|
+
if (contract.wait.kind === 'always') return true;
|
|
525
|
+
if (contract.wait.kind === 'caller_managed') return false;
|
|
526
|
+
const value = input[contract.wait.field];
|
|
527
|
+
return typeof value === 'boolean' ? value : contract.wait.default;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function asyncOperationDurableStartInput(
|
|
531
|
+
contract: AsyncOperationContractWire,
|
|
532
|
+
input: Record<string, unknown>,
|
|
533
|
+
): Record<string, unknown> {
|
|
534
|
+
if (contract.wait.kind !== 'input_flag') return input;
|
|
535
|
+
return {
|
|
536
|
+
...input,
|
|
537
|
+
[contract.wait.field]: contract.wait.durableValue ?? false,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function extractAsyncOperationJobId(
|
|
542
|
+
contract: AsyncOperationContractWire,
|
|
543
|
+
startResult: unknown,
|
|
544
|
+
): string | null {
|
|
545
|
+
for (const path of contract.job.idPaths) {
|
|
546
|
+
const value = readAsyncOperationPath(startResult, path);
|
|
547
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
548
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
549
|
+
return String(value);
|
|
550
|
+
}
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
export function buildAsyncOperationActionInput(input: {
|
|
555
|
+
mapping: AsyncOperationInputMapping;
|
|
556
|
+
jobId: string;
|
|
557
|
+
startInput: Record<string, unknown>;
|
|
558
|
+
startResult: unknown;
|
|
559
|
+
pollResult?: unknown;
|
|
560
|
+
}): Record<string, unknown> {
|
|
561
|
+
return Object.fromEntries(
|
|
562
|
+
Object.entries(input.mapping).map(([field, source]) => {
|
|
563
|
+
switch (source.source) {
|
|
564
|
+
case 'job_id':
|
|
565
|
+
return [field, input.jobId];
|
|
566
|
+
case 'literal':
|
|
567
|
+
return [field, source.value];
|
|
568
|
+
case 'start_input':
|
|
569
|
+
return [field, readAsyncOperationPath(input.startInput, source.path)];
|
|
570
|
+
case 'start_result':
|
|
571
|
+
return [
|
|
572
|
+
field,
|
|
573
|
+
readAsyncOperationPath(input.startResult, source.path),
|
|
574
|
+
];
|
|
575
|
+
case 'poll_result':
|
|
576
|
+
return [field, readAsyncOperationPath(input.pollResult, source.path)];
|
|
577
|
+
}
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function conditionMatches(
|
|
583
|
+
result: unknown,
|
|
584
|
+
condition: AsyncOperationCondition,
|
|
585
|
+
): boolean {
|
|
586
|
+
const raw = readAsyncOperationPath(result, condition.path);
|
|
587
|
+
const value =
|
|
588
|
+
typeof raw === 'string' && condition.normalize === 'lowercase'
|
|
589
|
+
? raw.toLowerCase()
|
|
590
|
+
: typeof raw === 'string' && condition.normalize === 'uppercase'
|
|
591
|
+
? raw.toUpperCase()
|
|
592
|
+
: raw;
|
|
593
|
+
if (condition.exists !== undefined)
|
|
594
|
+
return condition.exists === (raw !== undefined && raw !== null);
|
|
595
|
+
if (condition.in !== undefined)
|
|
596
|
+
return condition.in.some((candidate) => candidate === value);
|
|
597
|
+
return value === condition.equals;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function classifyAsyncOperationResult(
|
|
601
|
+
contract: AsyncOperationContractWire,
|
|
602
|
+
result: unknown,
|
|
603
|
+
): { outcome: AsyncOperationTerminalOutcome; message?: string } {
|
|
604
|
+
for (const rule of contract.terminal.rules) {
|
|
605
|
+
const matches = rule.conditions.map((condition) =>
|
|
606
|
+
conditionMatches(result, condition),
|
|
607
|
+
);
|
|
608
|
+
const matched =
|
|
609
|
+
rule.mode === 'any' ? matches.some(Boolean) : matches.every(Boolean);
|
|
610
|
+
if (!matched) continue;
|
|
611
|
+
const message = rule.messagePath
|
|
612
|
+
? readAsyncOperationPath(result, rule.messagePath)
|
|
613
|
+
: undefined;
|
|
614
|
+
return {
|
|
615
|
+
outcome: rule.outcome,
|
|
616
|
+
...(typeof message === 'string' && message.trim()
|
|
617
|
+
? { message: message.trim() }
|
|
618
|
+
: {}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
return { outcome: contract.terminal.defaultOutcome };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function isEmptyAsyncResult(value: unknown): boolean {
|
|
625
|
+
return (
|
|
626
|
+
value == null ||
|
|
627
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
628
|
+
(typeof value === 'object' &&
|
|
629
|
+
!Array.isArray(value) &&
|
|
630
|
+
Object.keys(value as Record<string, unknown>).length === 0)
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function materializeAsyncOperationResult(input: {
|
|
635
|
+
contract: AsyncOperationContractWire;
|
|
636
|
+
startInput: Record<string, unknown>;
|
|
637
|
+
startResult: unknown;
|
|
638
|
+
pollResult: unknown;
|
|
639
|
+
finishResult?: unknown;
|
|
640
|
+
}): { outcome: 'succeeded' | 'no_result'; result: unknown } {
|
|
641
|
+
const selected =
|
|
642
|
+
input.contract.result.source === 'start_result'
|
|
643
|
+
? input.startResult
|
|
644
|
+
: input.contract.result.source === 'finish_result'
|
|
645
|
+
? input.finishResult
|
|
646
|
+
: input.pollResult;
|
|
647
|
+
let result = input.contract.result.path
|
|
648
|
+
? readAsyncOperationPath(selected, input.contract.result.path)
|
|
649
|
+
: selected;
|
|
650
|
+
const objectFields = input.contract.result.objectFields;
|
|
651
|
+
if (objectFields) {
|
|
652
|
+
const composed =
|
|
653
|
+
input.contract.result.mergeSourceObject !== false &&
|
|
654
|
+
result &&
|
|
655
|
+
typeof result === 'object' &&
|
|
656
|
+
!Array.isArray(result)
|
|
657
|
+
? { ...(result as Record<string, unknown>) }
|
|
658
|
+
: {};
|
|
659
|
+
const jobId = extractAsyncOperationJobId(input.contract, input.startResult);
|
|
660
|
+
for (const [field, source] of Object.entries(objectFields)) {
|
|
661
|
+
switch (source.source) {
|
|
662
|
+
case 'job_id':
|
|
663
|
+
composed[field] = jobId;
|
|
664
|
+
break;
|
|
665
|
+
case 'literal':
|
|
666
|
+
composed[field] = source.value;
|
|
667
|
+
break;
|
|
668
|
+
case 'start_input':
|
|
669
|
+
composed[field] = readAsyncOperationPath(
|
|
670
|
+
input.startInput,
|
|
671
|
+
source.path,
|
|
672
|
+
);
|
|
673
|
+
break;
|
|
674
|
+
case 'start_result':
|
|
675
|
+
composed[field] = readAsyncOperationPath(
|
|
676
|
+
input.startResult,
|
|
677
|
+
source.path,
|
|
678
|
+
);
|
|
679
|
+
break;
|
|
680
|
+
case 'poll_result':
|
|
681
|
+
composed[field] = readAsyncOperationPath(
|
|
682
|
+
input.pollResult,
|
|
683
|
+
source.path,
|
|
684
|
+
);
|
|
685
|
+
break;
|
|
686
|
+
case 'finish_result':
|
|
687
|
+
composed[field] = readAsyncOperationPath(
|
|
688
|
+
input.finishResult,
|
|
689
|
+
source.path,
|
|
690
|
+
);
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
result = composed;
|
|
695
|
+
}
|
|
696
|
+
const correlation = input.contract.result.correlateByPosition;
|
|
697
|
+
if (correlation) {
|
|
698
|
+
const sourceRows = readAsyncOperationPath(
|
|
699
|
+
input.startInput,
|
|
700
|
+
correlation.inputPath,
|
|
701
|
+
);
|
|
702
|
+
const resultRows = readAsyncOperationPath(result, correlation.resultPath);
|
|
703
|
+
if (Array.isArray(sourceRows) && Array.isArray(resultRows)) {
|
|
704
|
+
const correlatedRows = resultRows.map((row, index) => {
|
|
705
|
+
const sourceRow = sourceRows[index];
|
|
706
|
+
if (
|
|
707
|
+
!row ||
|
|
708
|
+
typeof row !== 'object' ||
|
|
709
|
+
Array.isArray(row) ||
|
|
710
|
+
!sourceRow ||
|
|
711
|
+
typeof sourceRow !== 'object' ||
|
|
712
|
+
Array.isArray(sourceRow)
|
|
713
|
+
) {
|
|
714
|
+
return row;
|
|
715
|
+
}
|
|
716
|
+
const output = { ...(row as Record<string, unknown>) };
|
|
717
|
+
for (const field of correlation.fields) {
|
|
718
|
+
if (output[field] == null || output[field] === '') {
|
|
719
|
+
output[field] = (sourceRow as Record<string, unknown>)[field];
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return output;
|
|
723
|
+
});
|
|
724
|
+
if (correlation.resultPath === '$' || correlation.resultPath === '') {
|
|
725
|
+
result = correlatedRows;
|
|
726
|
+
} else {
|
|
727
|
+
result = writeAsyncOperationPath(
|
|
728
|
+
result,
|
|
729
|
+
correlation.resultPath,
|
|
730
|
+
correlatedRows,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
outcome:
|
|
737
|
+
input.contract.result.emptyIsNoResult && isEmptyAsyncResult(result)
|
|
738
|
+
? 'no_result'
|
|
739
|
+
: 'succeeded',
|
|
740
|
+
result,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function asyncOperationPollDelayMs(
|
|
745
|
+
contract: AsyncOperationContractWire,
|
|
746
|
+
pollAttempt: number,
|
|
747
|
+
): number {
|
|
748
|
+
if (pollAttempt <= 0) return contract.polling.initialDelayMs;
|
|
749
|
+
const delay = Math.round(
|
|
750
|
+
contract.polling.intervalMs *
|
|
751
|
+
Math.pow(contract.polling.backoffMultiplier ?? 1, pollAttempt - 1),
|
|
752
|
+
);
|
|
753
|
+
return Math.min(delay, contract.polling.maxIntervalMs ?? delay);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Runtime-neutral executor for a declared async lifecycle.
|
|
758
|
+
*
|
|
759
|
+
* Provider runtimes and Play execution intentionally share this state machine:
|
|
760
|
+
* the provider contract owns polling inputs, terminal classification, result
|
|
761
|
+
* pagination, and materialization. Callers own transport, durable receipts,
|
|
762
|
+
* retry policy, billing, and recovery persistence around each action.
|
|
763
|
+
*/
|
|
764
|
+
export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
765
|
+
contract: AsyncOperationContractWire;
|
|
766
|
+
startInput: Record<string, unknown>;
|
|
767
|
+
startResult: unknown;
|
|
768
|
+
/** Transport envelopes may carry a job id outside the provider result body. */
|
|
769
|
+
jobId?: string | null;
|
|
770
|
+
executeAction: (request: {
|
|
771
|
+
action: string;
|
|
772
|
+
input: Record<string, unknown>;
|
|
773
|
+
jobId: string;
|
|
774
|
+
phase: 'poll' | 'finish' | 'finish_page';
|
|
775
|
+
pollAttempt: number;
|
|
776
|
+
page?: number;
|
|
777
|
+
}) => Promise<Response>;
|
|
778
|
+
responseValue: (response: Response) => unknown;
|
|
779
|
+
sleep?: (ms: number) => Promise<void>;
|
|
780
|
+
now?: () => number;
|
|
781
|
+
/** Legacy direct callers historically poll once immediately after launch. */
|
|
782
|
+
pollImmediately?: boolean;
|
|
783
|
+
}): Promise<
|
|
784
|
+
| {
|
|
785
|
+
kind: 'terminal';
|
|
786
|
+
jobId: string;
|
|
787
|
+
classification: {
|
|
788
|
+
outcome: Exclude<AsyncOperationTerminalOutcome, 'running'>;
|
|
789
|
+
message?: string;
|
|
790
|
+
};
|
|
791
|
+
pollResponse: Response;
|
|
792
|
+
pollResult: unknown;
|
|
793
|
+
finishResponse?: Response;
|
|
794
|
+
finishResult?: unknown;
|
|
795
|
+
materialized?: { outcome: 'succeeded' | 'no_result'; result: unknown };
|
|
796
|
+
}
|
|
797
|
+
| {
|
|
798
|
+
kind: 'timed_out';
|
|
799
|
+
jobId: string;
|
|
800
|
+
pollAttempt: number;
|
|
801
|
+
previousPollResult: unknown;
|
|
802
|
+
}
|
|
803
|
+
> {
|
|
804
|
+
const jobId =
|
|
805
|
+
extractAsyncOperationJobId(input.contract, input.startResult) ??
|
|
806
|
+
input.jobId?.trim() ??
|
|
807
|
+
null;
|
|
808
|
+
if (!jobId) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
`Async operation ${input.contract.lifecycle.startAction} returned running without a provider job id.`,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const sleep =
|
|
814
|
+
input.sleep ??
|
|
815
|
+
((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
816
|
+
const now = input.now ?? Date.now;
|
|
817
|
+
const startedAt = now();
|
|
818
|
+
const polling = resolveAsyncOperationPolling({
|
|
819
|
+
contract: input.contract,
|
|
820
|
+
startInput: input.startInput,
|
|
821
|
+
});
|
|
822
|
+
const effectiveContract: AsyncOperationContractWire = {
|
|
823
|
+
...input.contract,
|
|
824
|
+
polling: { ...input.contract.polling, intervalMs: polling.intervalMs },
|
|
825
|
+
};
|
|
826
|
+
let pollAttempt = 0;
|
|
827
|
+
let previousPollResult: unknown = undefined;
|
|
828
|
+
while (true) {
|
|
829
|
+
if (now() - startedAt >= polling.timeoutMs) {
|
|
830
|
+
return { kind: 'timed_out', jobId, pollAttempt, previousPollResult };
|
|
831
|
+
}
|
|
832
|
+
const delayMs =
|
|
833
|
+
pollAttempt === 0 && input.pollImmediately === true
|
|
834
|
+
? 0
|
|
835
|
+
: asyncOperationPollDelayMs(effectiveContract, pollAttempt);
|
|
836
|
+
if (delayMs > 0) await sleep(delayMs);
|
|
837
|
+
let pollResponse: Response | null = null;
|
|
838
|
+
for (const action of input.contract.lifecycle.pollActions) {
|
|
839
|
+
pollResponse = await input.executeAction({
|
|
840
|
+
action: action.action,
|
|
841
|
+
input: buildAsyncOperationActionInput({
|
|
842
|
+
mapping: action.input,
|
|
843
|
+
jobId,
|
|
844
|
+
startInput: input.startInput,
|
|
845
|
+
startResult: input.startResult,
|
|
846
|
+
pollResult: previousPollResult,
|
|
847
|
+
}),
|
|
848
|
+
jobId,
|
|
849
|
+
phase: 'poll',
|
|
850
|
+
pollAttempt,
|
|
851
|
+
});
|
|
852
|
+
previousPollResult = input.responseValue(pollResponse);
|
|
853
|
+
}
|
|
854
|
+
if (!pollResponse) {
|
|
855
|
+
throw new Error(
|
|
856
|
+
`Async operation ${input.contract.lifecycle.startAction} has no executable poll action.`,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
const classification = classifyAsyncOperationResult(
|
|
860
|
+
input.contract,
|
|
861
|
+
previousPollResult,
|
|
862
|
+
);
|
|
863
|
+
if (classification.outcome === 'running') {
|
|
864
|
+
pollAttempt += 1;
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const terminalClassification = classification as {
|
|
868
|
+
outcome: Exclude<AsyncOperationTerminalOutcome, 'running'>;
|
|
869
|
+
message?: string;
|
|
870
|
+
};
|
|
871
|
+
if (
|
|
872
|
+
terminalClassification.outcome === 'failed' ||
|
|
873
|
+
terminalClassification.outcome === 'cancelled'
|
|
874
|
+
) {
|
|
875
|
+
return {
|
|
876
|
+
kind: 'terminal',
|
|
877
|
+
jobId,
|
|
878
|
+
classification: terminalClassification,
|
|
879
|
+
pollResponse,
|
|
880
|
+
pollResult: previousPollResult,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
let finishResponse: Response | undefined;
|
|
885
|
+
let finishResult: unknown = undefined;
|
|
886
|
+
const finishAction = input.contract.lifecycle.finishAction;
|
|
887
|
+
// A terminal no-result is already authoritative. Fetching a result page
|
|
888
|
+
// after it both contradicts the terminal classification and can turn a
|
|
889
|
+
// successful empty lookup into a spurious provider-read failure.
|
|
890
|
+
if (finishAction && terminalClassification.outcome === 'succeeded') {
|
|
891
|
+
if (finishAction.reuseTerminalPollResult) {
|
|
892
|
+
finishResponse = pollResponse;
|
|
893
|
+
finishResult = previousPollResult;
|
|
894
|
+
} else {
|
|
895
|
+
const finishInput = buildAsyncOperationActionInput({
|
|
896
|
+
mapping: finishAction.input,
|
|
897
|
+
jobId,
|
|
898
|
+
startInput: input.startInput,
|
|
899
|
+
startResult: input.startResult,
|
|
900
|
+
pollResult: previousPollResult,
|
|
901
|
+
});
|
|
902
|
+
finishResponse = await input.executeAction({
|
|
903
|
+
action: finishAction.action,
|
|
904
|
+
input: finishInput,
|
|
905
|
+
jobId,
|
|
906
|
+
phase: 'finish',
|
|
907
|
+
pollAttempt,
|
|
908
|
+
});
|
|
909
|
+
finishResult = input.responseValue(finishResponse);
|
|
910
|
+
const pagination = finishAction.pagination;
|
|
911
|
+
if (pagination) {
|
|
912
|
+
const firstItems = readAsyncOperationPath(
|
|
913
|
+
finishResult,
|
|
914
|
+
pagination.itemsPath,
|
|
915
|
+
);
|
|
916
|
+
if (!Array.isArray(firstItems)) {
|
|
917
|
+
throw new Error(
|
|
918
|
+
`Async operation ${input.contract.lifecycle.startAction} paginated finish result is missing array ${pagination.itemsPath}.`,
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
const allItems = [...firstItems];
|
|
922
|
+
let page = pagination.startPage;
|
|
923
|
+
let currentResult = finishResult;
|
|
924
|
+
const isLastPage = () => {
|
|
925
|
+
const totalPages = pagination.totalPagesPath
|
|
926
|
+
? readAsyncOperationPath(currentResult, pagination.totalPagesPath)
|
|
927
|
+
: undefined;
|
|
928
|
+
if (
|
|
929
|
+
typeof totalPages === 'number' &&
|
|
930
|
+
Number.isSafeInteger(totalPages)
|
|
931
|
+
) {
|
|
932
|
+
return page + 1 >= totalPages;
|
|
933
|
+
}
|
|
934
|
+
// Older providers sometimes omit both pagination markers for a
|
|
935
|
+
// one-page result. Treat only an explicit `last: false` as proof
|
|
936
|
+
// that another page exists; otherwise preserve that one-page
|
|
937
|
+
// response instead of issuing unbounded empty page reads.
|
|
938
|
+
return (
|
|
939
|
+
!pagination.lastPagePath ||
|
|
940
|
+
readAsyncOperationPath(
|
|
941
|
+
currentResult,
|
|
942
|
+
pagination.lastPagePath,
|
|
943
|
+
) !== false
|
|
944
|
+
);
|
|
945
|
+
};
|
|
946
|
+
while (!isLastPage()) {
|
|
947
|
+
page += 1;
|
|
948
|
+
if (page - pagination.startPage >= pagination.maxPages) {
|
|
949
|
+
throw new Error(
|
|
950
|
+
`Async operation ${input.contract.lifecycle.startAction} exceeded its ${pagination.maxPages}-page result limit.`,
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
const pageResponse = await input.executeAction({
|
|
954
|
+
action: finishAction.action,
|
|
955
|
+
input: { ...finishInput, [pagination.pageInputField]: page },
|
|
956
|
+
jobId,
|
|
957
|
+
phase: 'finish_page',
|
|
958
|
+
pollAttempt,
|
|
959
|
+
page,
|
|
960
|
+
});
|
|
961
|
+
currentResult = input.responseValue(pageResponse);
|
|
962
|
+
const pageItems = readAsyncOperationPath(
|
|
963
|
+
currentResult,
|
|
964
|
+
pagination.itemsPath,
|
|
965
|
+
);
|
|
966
|
+
if (!Array.isArray(pageItems)) {
|
|
967
|
+
throw new Error(
|
|
968
|
+
`Async operation ${input.contract.lifecycle.startAction} result page ${page} is missing array ${pagination.itemsPath}.`,
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
allItems.push(...pageItems);
|
|
972
|
+
}
|
|
973
|
+
finishResult = writeAsyncOperationPath(
|
|
974
|
+
finishResult,
|
|
975
|
+
pagination.itemsPath,
|
|
976
|
+
allItems,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
const materialized = materializeAsyncOperationResult({
|
|
982
|
+
contract: input.contract,
|
|
983
|
+
startInput: input.startInput,
|
|
984
|
+
startResult: input.startResult,
|
|
985
|
+
pollResult: previousPollResult,
|
|
986
|
+
finishResult,
|
|
987
|
+
});
|
|
988
|
+
return {
|
|
989
|
+
kind: 'terminal',
|
|
990
|
+
jobId,
|
|
991
|
+
classification: terminalClassification,
|
|
992
|
+
pollResponse,
|
|
993
|
+
pollResult: previousPollResult,
|
|
994
|
+
...(finishResponse ? { finishResponse } : {}),
|
|
995
|
+
...(finishAction ? { finishResult } : {}),
|
|
996
|
+
materialized,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
}
|