deepline 0.3.74 → 0.3.76
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/client.ts +279 -21
- package/dist/bundling-sources/sdk/src/config.ts +21 -3
- package/dist/bundling-sources/sdk/src/index.ts +1 -0
- package/dist/bundling-sources/sdk/src/play.ts +3 -6
- package/dist/bundling-sources/sdk/src/release.ts +1 -1
- package/dist/bundling-sources/sdk/src/types.ts +3 -2
- package/dist/bundling-sources/shared_libs/monitors/deactivation-reasons.ts +186 -0
- package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +41 -0
- package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +213 -55
- package/dist/bundling-sources/shared_libs/play-runtime/context.ts +172 -299
- package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +10 -0
- package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +13 -32
- package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -1
- package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +7 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +11 -2
- package/dist/bundling-sources/shared_libs/play-runtime/runtime-incident-drills.ts +1 -1
- package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +16 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +1 -0
- package/dist/bundling-sources/shared_libs/play-runtime/tool-call/testkit.ts +5 -4
- package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +12 -2
- package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +1 -1
- package/dist/bundling-sources/shared_libs/plays/deployment-status.ts +74 -0
- package/dist/cli/index.js +940 -181
- package/dist/cli/index.mjs +940 -181
- package/dist/{compiler-manifest-CMNgA_JQ.d.mts → compiler-manifest-Dbov16F7.d.mts} +1 -1
- package/dist/{compiler-manifest-CMNgA_JQ.d.ts → compiler-manifest-Dbov16F7.d.ts} +1 -1
- package/dist/index.d.mts +176 -23
- package/dist/index.d.ts +176 -23
- package/dist/index.js +109 -12
- package/dist/index.mjs +109 -12
- package/dist/install-integrity.json +4 -2
- package/dist/plays/bundle-play-file.d.mts +2 -2
- package/dist/plays/bundle-play-file.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a monitor is desired-inactive.
|
|
3
|
+
*
|
|
4
|
+
* ONE ENUMERATION, ONE MODULE
|
|
5
|
+
* ---------------------------
|
|
6
|
+
* `deactivationReason` distinguishes customer intent from platform action, and
|
|
7
|
+
* that distinction is only worth anything if the set of reasons is closed. A
|
|
8
|
+
* reason invented at a call site is a reason no drift output explains, no
|
|
9
|
+
* billing reader recognises, and no UI can phrase. So every reason is declared
|
|
10
|
+
* here, with who owns it and whether the platform may revive the monitor when
|
|
11
|
+
* the condition clears.
|
|
12
|
+
*
|
|
13
|
+
* ABSENT IS NOT A REASON
|
|
14
|
+
* ----------------------
|
|
15
|
+
* A monitor with `desired: 'inactive'` and NO reason is the customer's own
|
|
16
|
+
* decision. The platform must never revive it, never explain it, and never
|
|
17
|
+
* count it as an outage. That is the whole point of the field: "you turned
|
|
18
|
+
* this off" and "we did, and here is why" must not be the same state.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type MonitorDeactivationOwner = 'policy' | 'fleet';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The delivery window, in days, that `monitors health` reports against.
|
|
25
|
+
*
|
|
26
|
+
* Stated here in the unit a customer reads so the CLI, the API envelope and
|
|
27
|
+
* the Convex predicate cannot disagree about what "silent" means. The
|
|
28
|
+
* millisecond form the predicate uses is derived from it.
|
|
29
|
+
*/
|
|
30
|
+
export const SILENT_MONITOR_WINDOW_DAYS = 3;
|
|
31
|
+
|
|
32
|
+
export type MonitorDeactivationReasonDefinition = {
|
|
33
|
+
id: string;
|
|
34
|
+
/** Who writes it. Only `policy` reasons are automatically reverted. */
|
|
35
|
+
owner: MonitorDeactivationOwner;
|
|
36
|
+
/** One line, customer-facing. This is what drift output prints. */
|
|
37
|
+
summary: string;
|
|
38
|
+
/** What the customer does about it. */
|
|
39
|
+
resolution: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const MONITOR_DEACTIVATION_REASONS: readonly MonitorDeactivationReasonDefinition[] =
|
|
43
|
+
[
|
|
44
|
+
{
|
|
45
|
+
id: 'insufficient_balance',
|
|
46
|
+
owner: 'policy',
|
|
47
|
+
summary:
|
|
48
|
+
'Deactivated because the organization has no Deepline credit left.',
|
|
49
|
+
resolution:
|
|
50
|
+
'Add credits. Monitors are reactivated automatically once the balance is positive again.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'entitlement_expired',
|
|
54
|
+
owner: 'policy',
|
|
55
|
+
summary:
|
|
56
|
+
'Deactivated because the entitlement that admitted this monitor has expired.',
|
|
57
|
+
resolution:
|
|
58
|
+
'Restore the entitlement. Monitors are reactivated automatically once it is in force.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'fleet_removed',
|
|
62
|
+
owner: 'fleet',
|
|
63
|
+
summary:
|
|
64
|
+
'Deactivated because the fleet that owned this monitor no longer wants it.',
|
|
65
|
+
resolution:
|
|
66
|
+
'Re-add the source row and re-sync the fleet, or leave it deactivated.',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const BY_ID = new Map(
|
|
71
|
+
MONITOR_DEACTIVATION_REASONS.map((reason) => [reason.id, reason]),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
export const MONITOR_DEACTIVATION_REASON_IDS: readonly string[] =
|
|
75
|
+
MONITOR_DEACTIVATION_REASONS.map((reason) => reason.id);
|
|
76
|
+
|
|
77
|
+
export function monitorDeactivationReason(
|
|
78
|
+
id: string | undefined | null,
|
|
79
|
+
): MonitorDeactivationReasonDefinition | null {
|
|
80
|
+
if (!id) return null;
|
|
81
|
+
return BY_ID.get(id) ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Refuse an undeclared reason loudly.
|
|
86
|
+
*
|
|
87
|
+
* A writer that could store an unknown string would make the enumeration
|
|
88
|
+
* advisory, and an advisory enumeration is the state this field exists to
|
|
89
|
+
* prevent.
|
|
90
|
+
*/
|
|
91
|
+
export function requireMonitorDeactivationReason(
|
|
92
|
+
id: string,
|
|
93
|
+
): MonitorDeactivationReasonDefinition {
|
|
94
|
+
const reason = BY_ID.get(id);
|
|
95
|
+
if (!reason) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Unknown monitor deactivation reason '${id}'. Declared reasons: ${MONITOR_DEACTIVATION_REASON_IDS.join(', ')}.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return reason;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when the platform, not the customer, turned this monitor off. */
|
|
104
|
+
export function isPolicyDeactivation(id: string | undefined | null): boolean {
|
|
105
|
+
return monitorDeactivationReason(id)?.owner === 'policy';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// The balance policy
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The strictly-positive rule, stated once.
|
|
114
|
+
*
|
|
115
|
+
* A negative balance is permitted where provider truth settles late, but an
|
|
116
|
+
* organization at or below zero opens no new charges. A monitor is a standing
|
|
117
|
+
* charge, so the same rule governs whether it may keep running.
|
|
118
|
+
*/
|
|
119
|
+
export function balanceAdmitsMonitors(balance: number | null): boolean {
|
|
120
|
+
// A wallet nobody has seeded is not a wallet at zero. It is an organization
|
|
121
|
+
// whose balance has never been read, and deactivating every monitor it owns
|
|
122
|
+
// on the strength of an absent record would be the worst possible reading of
|
|
123
|
+
// "we do not know".
|
|
124
|
+
if (balance === null) return true;
|
|
125
|
+
return balance > 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type MonitorPolicyRow = {
|
|
129
|
+
desired?: 'active' | 'inactive' | undefined;
|
|
130
|
+
deactivationReason?: string | undefined;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type MonitorPolicyDecision =
|
|
134
|
+
| { kind: 'deactivate'; reason: string }
|
|
135
|
+
| { kind: 'reactivate' }
|
|
136
|
+
| { kind: 'none' };
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What policy wants for one monitor, given the organization's balance.
|
|
140
|
+
*
|
|
141
|
+
* Pure, so the rule can be driven in every direction by a unit test without a
|
|
142
|
+
* wallet, a Convex runtime, or a monitor. The three cases that matter are all
|
|
143
|
+
* about NOT touching something:
|
|
144
|
+
*
|
|
145
|
+
* - a customer deactivation (inactive, no reason) is never revived;
|
|
146
|
+
* - a fleet deactivation is never revived by policy either - the fleet owns
|
|
147
|
+
* that intent and a re-sync is what restores it;
|
|
148
|
+
* - a monitor already deactivated for this exact reason is left alone, so a
|
|
149
|
+
* repeated sweep writes nothing.
|
|
150
|
+
*/
|
|
151
|
+
export function decideMonitorBalancePolicy(input: {
|
|
152
|
+
row: MonitorPolicyRow;
|
|
153
|
+
balance: number | null;
|
|
154
|
+
}): MonitorPolicyDecision {
|
|
155
|
+
const admits = balanceAdmitsMonitors(input.balance);
|
|
156
|
+
const reason = input.row.deactivationReason;
|
|
157
|
+
if (!admits) {
|
|
158
|
+
if (input.row.desired === 'inactive') {
|
|
159
|
+
// Already off. Only re-label when policy already owned the reason: a
|
|
160
|
+
// customer's own deactivation must not be re-attributed to billing.
|
|
161
|
+
return reason === 'insufficient_balance'
|
|
162
|
+
? { kind: 'none' }
|
|
163
|
+
: isPolicyDeactivation(reason)
|
|
164
|
+
? { kind: 'deactivate', reason: 'insufficient_balance' }
|
|
165
|
+
: { kind: 'none' };
|
|
166
|
+
}
|
|
167
|
+
return { kind: 'deactivate', reason: 'insufficient_balance' };
|
|
168
|
+
}
|
|
169
|
+
// The balance is healthy again. Revive only what THIS policy turned off.
|
|
170
|
+
//
|
|
171
|
+
// ASYMMETRIC ON `null`, DELIBERATELY. An unreadable wallet is permissive for
|
|
172
|
+
// deactivation - "we do not know" must not turn a customer's monitors off -
|
|
173
|
+
// and restrictive here, because reviving is not a state flip. The provider
|
|
174
|
+
// has no activate endpoint, so revival RE-CREATES the monitor and CHARGES for
|
|
175
|
+
// it, and an absent record is not evidence that anybody paid. An organization
|
|
176
|
+
// whose wallet cannot be read is revived by the next pass that can read it,
|
|
177
|
+
// or by the customer running the reactivate verb.
|
|
178
|
+
if (
|
|
179
|
+
input.balance !== null &&
|
|
180
|
+
input.row.desired === 'inactive' &&
|
|
181
|
+
reason === 'insufficient_balance'
|
|
182
|
+
) {
|
|
183
|
+
return { kind: 'reactivate' };
|
|
184
|
+
}
|
|
185
|
+
return { kind: 'none' };
|
|
186
|
+
}
|
|
@@ -14,3 +14,44 @@
|
|
|
14
14
|
* the contract module's copy and never learns this file exists.
|
|
15
15
|
*/
|
|
16
16
|
export const MONITOR_FLEET_MAX_MEMBERS = 1_000;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A claim may carry at most this many independent (organization, fleet)
|
|
20
|
+
* batches.
|
|
21
|
+
*
|
|
22
|
+
* A lease starts when the claim commits, not when a particular batch reaches
|
|
23
|
+
* the provider. Letting a 100-item claim contain 100 one-member fleets meant
|
|
24
|
+
* a sequential dispatcher could leave the later leases expired before it ever
|
|
25
|
+
* started their work. A second tick then legitimately reclaimed them while
|
|
26
|
+
* the first was still settling, which is exactly the write contention a lease
|
|
27
|
+
* is meant to prevent. Dispatch these groups concurrently, bounded by this
|
|
28
|
+
* same number, so every claimed group begins within one bounded pass. The app
|
|
29
|
+
* and Convex both use the bounded dispatcher below so a rolling deployment
|
|
30
|
+
* remains safe while an older claim mutation is still live.
|
|
31
|
+
*/
|
|
32
|
+
export const MONITOR_FLEET_MAX_DISPATCH_GROUPS_PER_TICK = 4;
|
|
33
|
+
|
|
34
|
+
/** Run independent fleet dispatches without letting one lease wait behind all others. */
|
|
35
|
+
export async function dispatchMonitorFleetGroups<T, R>(
|
|
36
|
+
groups: readonly T[],
|
|
37
|
+
dispatch: (group: T) => Promise<R>,
|
|
38
|
+
): Promise<R[]> {
|
|
39
|
+
const results: R[] = Array.from({ length: groups.length });
|
|
40
|
+
let next = 0;
|
|
41
|
+
const workers = Array.from(
|
|
42
|
+
{
|
|
43
|
+
length: Math.min(
|
|
44
|
+
MONITOR_FLEET_MAX_DISPATCH_GROUPS_PER_TICK,
|
|
45
|
+
groups.length,
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
async () => {
|
|
49
|
+
while (next < groups.length) {
|
|
50
|
+
const index = next++;
|
|
51
|
+
results[index] = await dispatch(groups[index]!);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
await Promise.all(workers);
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
@@ -90,17 +90,12 @@ export type AsyncOperationContractWire = {
|
|
|
90
90
|
finishAction?: AsyncOperationActionCall;
|
|
91
91
|
cancelAction?: AsyncOperationActionCall;
|
|
92
92
|
};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
| { kind: 'provider_registry' }
|
|
100
|
-
| { kind: 'fixed'; maxConcurrentJobs: number };
|
|
101
|
-
onConflict: 'wait' | 'fail';
|
|
102
|
-
leaseTtlMs: number;
|
|
103
|
-
};
|
|
93
|
+
/**
|
|
94
|
+
* A provider may acknowledge a created job with its handle but no lifecycle
|
|
95
|
+
* status. This rule applies only to that launch response; unknown poll
|
|
96
|
+
* responses still use terminal.defaultOutcome.
|
|
97
|
+
*/
|
|
98
|
+
launch?: { unclassifiedJobOutcome: 'running' };
|
|
104
99
|
job: { idPaths: readonly string[] };
|
|
105
100
|
polling: {
|
|
106
101
|
initialDelayMs: number;
|
|
@@ -112,6 +107,8 @@ export type AsyncOperationContractWire = {
|
|
|
112
107
|
timeoutMs: number;
|
|
113
108
|
/** Optional public start-input field that overrides timeoutMs for this call. */
|
|
114
109
|
timeoutInputField?: string;
|
|
110
|
+
/** Optional public start-input field that bounds poll attempts for this call. */
|
|
111
|
+
maxAttemptsInputField?: string;
|
|
115
112
|
};
|
|
116
113
|
terminal: {
|
|
117
114
|
rules: readonly AsyncOperationTerminalRule[];
|
|
@@ -124,7 +121,8 @@ export type AsyncOperationContractWire = {
|
|
|
124
121
|
kind: 'input_flag';
|
|
125
122
|
field: string;
|
|
126
123
|
default: boolean;
|
|
127
|
-
|
|
124
|
+
/** Value sent only to the provider launch action when a Play polls. */
|
|
125
|
+
launchValue?: boolean;
|
|
128
126
|
}
|
|
129
127
|
| { kind: 'caller_managed' };
|
|
130
128
|
};
|
|
@@ -260,13 +258,6 @@ export function defineAsyncOperation(
|
|
|
260
258
|
validateResultValueSource(source, `result.objectFields.${field}`);
|
|
261
259
|
}
|
|
262
260
|
}
|
|
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
261
|
nonNegativeInteger(contract.polling.initialDelayMs, 'polling.initialDelayMs');
|
|
271
262
|
positiveInteger(contract.polling.intervalMs, 'polling.intervalMs');
|
|
272
263
|
positiveInteger(contract.polling.timeoutMs, 'polling.timeoutMs');
|
|
@@ -276,6 +267,12 @@ export function defineAsyncOperation(
|
|
|
276
267
|
if (contract.polling.timeoutInputField !== undefined) {
|
|
277
268
|
required(contract.polling.timeoutInputField, 'polling.timeoutInputField');
|
|
278
269
|
}
|
|
270
|
+
if (contract.polling.maxAttemptsInputField !== undefined) {
|
|
271
|
+
required(
|
|
272
|
+
contract.polling.maxAttemptsInputField,
|
|
273
|
+
'polling.maxAttemptsInputField',
|
|
274
|
+
);
|
|
275
|
+
}
|
|
279
276
|
if (
|
|
280
277
|
contract.polling.backoffMultiplier !== undefined &&
|
|
281
278
|
(!Number.isFinite(contract.polling.backoffMultiplier) ||
|
|
@@ -379,7 +376,11 @@ function positiveStartInputNumber(
|
|
|
379
376
|
export function resolveAsyncOperationPolling(input: {
|
|
380
377
|
contract: AsyncOperationContract;
|
|
381
378
|
startInput: Readonly<Record<string, unknown>>;
|
|
382
|
-
}): { intervalMs: number; timeoutMs: number } {
|
|
379
|
+
}): { intervalMs: number; timeoutMs: number; maxAttempts?: number } {
|
|
380
|
+
const maxAttempts = positiveStartInputNumber(
|
|
381
|
+
input.startInput,
|
|
382
|
+
input.contract.polling.maxAttemptsInputField,
|
|
383
|
+
);
|
|
383
384
|
return {
|
|
384
385
|
intervalMs:
|
|
385
386
|
positiveStartInputNumber(
|
|
@@ -391,6 +392,7 @@ export function resolveAsyncOperationPolling(input: {
|
|
|
391
392
|
input.startInput,
|
|
392
393
|
input.contract.polling.timeoutInputField,
|
|
393
394
|
) ?? input.contract.polling.timeoutMs,
|
|
395
|
+
...(maxAttempts ? { maxAttempts: Math.floor(maxAttempts) } : {}),
|
|
394
396
|
};
|
|
395
397
|
}
|
|
396
398
|
|
|
@@ -429,12 +431,6 @@ export function defineCallerManagedAsyncOperation(input: {
|
|
|
429
431
|
? { finishAction: companion(input.finishAction) }
|
|
430
432
|
: {}),
|
|
431
433
|
},
|
|
432
|
-
capacity: {
|
|
433
|
-
scope: 'organization_provider',
|
|
434
|
-
limit: { kind: 'provider_registry' },
|
|
435
|
-
onConflict: 'wait',
|
|
436
|
-
leaseTtlMs: 15 * 60_000,
|
|
437
|
-
},
|
|
438
434
|
job: {
|
|
439
435
|
idPaths: input.jobIdPaths ?? [
|
|
440
436
|
'job_id',
|
|
@@ -527,14 +523,21 @@ export function asyncOperationShouldWait(
|
|
|
527
523
|
return typeof value === 'boolean' ? value : contract.wait.default;
|
|
528
524
|
}
|
|
529
525
|
|
|
530
|
-
|
|
526
|
+
/**
|
|
527
|
+
* A Play starts a provider job without asking provider-local code to wait.
|
|
528
|
+
*
|
|
529
|
+
* This only shapes the launch request. The external handle remains in the
|
|
530
|
+
* active lifecycle call stack; it is never written into or recovered from a
|
|
531
|
+
* result receipt. Receipts cache terminal tool output only.
|
|
532
|
+
*/
|
|
533
|
+
export function asyncOperationLaunchInput(
|
|
531
534
|
contract: AsyncOperationContractWire,
|
|
532
535
|
input: Record<string, unknown>,
|
|
533
536
|
): Record<string, unknown> {
|
|
534
537
|
if (contract.wait.kind !== 'input_flag') return input;
|
|
535
538
|
return {
|
|
536
539
|
...input,
|
|
537
|
-
[contract.wait.field]: contract.wait.
|
|
540
|
+
[contract.wait.field]: contract.wait.launchValue ?? false,
|
|
538
541
|
};
|
|
539
542
|
}
|
|
540
543
|
|
|
@@ -621,6 +624,18 @@ export function classifyAsyncOperationResult(
|
|
|
621
624
|
return { outcome: contract.terminal.defaultOutcome };
|
|
622
625
|
}
|
|
623
626
|
|
|
627
|
+
export function hasExplicitAsyncOperationTerminalMatch(
|
|
628
|
+
contract: AsyncOperationContractWire,
|
|
629
|
+
result: unknown,
|
|
630
|
+
): boolean {
|
|
631
|
+
return contract.terminal.rules.some((rule) => {
|
|
632
|
+
const matches = rule.conditions.map((condition) =>
|
|
633
|
+
conditionMatches(result, condition),
|
|
634
|
+
);
|
|
635
|
+
return rule.mode === 'any' ? matches.some(Boolean) : matches.every(Boolean);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
624
639
|
function isEmptyAsyncResult(value: unknown): boolean {
|
|
625
640
|
return (
|
|
626
641
|
value == null ||
|
|
@@ -753,13 +768,57 @@ export function asyncOperationPollDelayMs(
|
|
|
753
768
|
return Math.min(delay, contract.polling.maxIntervalMs ?? delay);
|
|
754
769
|
}
|
|
755
770
|
|
|
771
|
+
function retryablePollRateLimit(error: unknown): {
|
|
772
|
+
retryAfterMs: number | null;
|
|
773
|
+
} | null {
|
|
774
|
+
if (!error || typeof error !== 'object' || Array.isArray(error)) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const record = error as Record<string, unknown>;
|
|
778
|
+
const status = record.status ?? record.statusCode;
|
|
779
|
+
// The Play transport emits this typed retryable envelope after it has
|
|
780
|
+
// already informed the shared provider pacer. Direct provider runtimes may
|
|
781
|
+
// deliberately expose provider-specific incomplete-result responses, so do
|
|
782
|
+
// not intercept their untyped 429s here.
|
|
783
|
+
if (status !== 429 || record.retryable !== true) {
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const retryAfterMs = record.retryAfterMs;
|
|
787
|
+
return {
|
|
788
|
+
retryAfterMs:
|
|
789
|
+
typeof retryAfterMs === 'number' &&
|
|
790
|
+
Number.isFinite(retryAfterMs) &&
|
|
791
|
+
retryAfterMs >= 0
|
|
792
|
+
? Math.ceil(retryAfterMs)
|
|
793
|
+
: null,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function asyncOperationRateLimitPollDelayMs(input: {
|
|
798
|
+
contract: AsyncOperationContractWire;
|
|
799
|
+
pollAttempt: number;
|
|
800
|
+
rateLimitAttempt: number;
|
|
801
|
+
retryAfterMs: number | null;
|
|
802
|
+
}): number {
|
|
803
|
+
// A rejected poll did not observe the remote job, so it must not consume the
|
|
804
|
+
// caller's max_polls budget. It does, however, need a real delay even when
|
|
805
|
+
// the first normal poll was immediate; otherwise concurrent rows retry in a
|
|
806
|
+
// tight herd. Never shorten an explicit provider Retry-After.
|
|
807
|
+
const localBackoffMs = asyncOperationPollDelayMs(
|
|
808
|
+
input.contract,
|
|
809
|
+
Math.max(1, input.pollAttempt + input.rateLimitAttempt),
|
|
810
|
+
);
|
|
811
|
+
return Math.max(input.retryAfterMs ?? 0, localBackoffMs);
|
|
812
|
+
}
|
|
813
|
+
|
|
756
814
|
/**
|
|
757
815
|
* Runtime-neutral executor for a declared async lifecycle.
|
|
758
816
|
*
|
|
759
817
|
* Provider runtimes and Play execution intentionally share this state machine:
|
|
760
818
|
* the provider contract owns polling inputs, terminal classification, result
|
|
761
|
-
* pagination, and materialization. Callers own transport,
|
|
762
|
-
*
|
|
819
|
+
* pagination, and materialization. Callers own transport retries, billing,
|
|
820
|
+
* and terminal-result caching. This executor never persists a provider handle
|
|
821
|
+
* or turns a receipt into provider-job state.
|
|
763
822
|
*/
|
|
764
823
|
export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
765
824
|
contract: AsyncOperationContractWire;
|
|
@@ -774,6 +833,10 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
774
833
|
phase: 'poll' | 'finish' | 'finish_page';
|
|
775
834
|
pollAttempt: number;
|
|
776
835
|
page?: number;
|
|
836
|
+
/** Remaining lifecycle budget for this physical provider request. */
|
|
837
|
+
timeoutMs: number;
|
|
838
|
+
/** Aborts a physical provider request when that budget expires. */
|
|
839
|
+
abortSignal: AbortSignal;
|
|
777
840
|
}) => Promise<Response>;
|
|
778
841
|
responseValue: (response: Response) => unknown;
|
|
779
842
|
sleep?: (ms: number) => Promise<void>;
|
|
@@ -799,6 +862,7 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
799
862
|
jobId: string;
|
|
800
863
|
pollAttempt: number;
|
|
801
864
|
previousPollResult: unknown;
|
|
865
|
+
reason: 'timeout' | 'max_attempts';
|
|
802
866
|
}
|
|
803
867
|
> {
|
|
804
868
|
const jobId =
|
|
@@ -825,31 +889,125 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
825
889
|
};
|
|
826
890
|
let pollAttempt = 0;
|
|
827
891
|
let previousPollResult: unknown = undefined;
|
|
892
|
+
let nextPollDelayMs: number | null = null;
|
|
893
|
+
let consecutiveRateLimitAttempts = 0;
|
|
894
|
+
const timedOut = () => ({
|
|
895
|
+
kind: 'timed_out' as const,
|
|
896
|
+
jobId,
|
|
897
|
+
pollAttempt,
|
|
898
|
+
previousPollResult,
|
|
899
|
+
reason: 'timeout' as const,
|
|
900
|
+
});
|
|
901
|
+
const executeActionWithinDeadline = async (request: {
|
|
902
|
+
action: string;
|
|
903
|
+
input: Record<string, unknown>;
|
|
904
|
+
phase: 'poll' | 'finish' | 'finish_page';
|
|
905
|
+
pollAttempt: number;
|
|
906
|
+
page?: number;
|
|
907
|
+
}): Promise<
|
|
908
|
+
{ kind: 'response'; response: Response } | { kind: 'timed_out' }
|
|
909
|
+
> => {
|
|
910
|
+
const remainingTimeoutMs = Math.max(
|
|
911
|
+
0,
|
|
912
|
+
polling.timeoutMs - (now() - startedAt),
|
|
913
|
+
);
|
|
914
|
+
if (remainingTimeoutMs <= 0) return { kind: 'timed_out' };
|
|
915
|
+
const abortSignal = AbortSignal.timeout(Math.ceil(remainingTimeoutMs));
|
|
916
|
+
try {
|
|
917
|
+
const response = await input.executeAction({
|
|
918
|
+
...request,
|
|
919
|
+
jobId,
|
|
920
|
+
timeoutMs: Math.ceil(remainingTimeoutMs),
|
|
921
|
+
abortSignal,
|
|
922
|
+
});
|
|
923
|
+
return now() - startedAt >= polling.timeoutMs || abortSignal.aborted
|
|
924
|
+
? { kind: 'timed_out' }
|
|
925
|
+
: { kind: 'response', response };
|
|
926
|
+
} catch (error) {
|
|
927
|
+
if (abortSignal.aborted) return { kind: 'timed_out' };
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
};
|
|
828
931
|
while (true) {
|
|
932
|
+
if (
|
|
933
|
+
polling.maxAttempts !== undefined &&
|
|
934
|
+
pollAttempt >= polling.maxAttempts
|
|
935
|
+
) {
|
|
936
|
+
return {
|
|
937
|
+
kind: 'timed_out',
|
|
938
|
+
jobId,
|
|
939
|
+
pollAttempt,
|
|
940
|
+
previousPollResult,
|
|
941
|
+
reason: 'max_attempts',
|
|
942
|
+
};
|
|
943
|
+
}
|
|
829
944
|
if (now() - startedAt >= polling.timeoutMs) {
|
|
830
|
-
return {
|
|
945
|
+
return {
|
|
946
|
+
kind: 'timed_out',
|
|
947
|
+
jobId,
|
|
948
|
+
pollAttempt,
|
|
949
|
+
previousPollResult,
|
|
950
|
+
reason: 'timeout',
|
|
951
|
+
};
|
|
831
952
|
}
|
|
832
953
|
const delayMs =
|
|
833
|
-
|
|
954
|
+
nextPollDelayMs ??
|
|
955
|
+
(pollAttempt === 0 && input.pollImmediately === true
|
|
834
956
|
? 0
|
|
835
|
-
: asyncOperationPollDelayMs(effectiveContract, pollAttempt);
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
957
|
+
: asyncOperationPollDelayMs(effectiveContract, pollAttempt));
|
|
958
|
+
nextPollDelayMs = null;
|
|
959
|
+
const remainingTimeoutMs = Math.max(
|
|
960
|
+
0,
|
|
961
|
+
polling.timeoutMs - (now() - startedAt),
|
|
962
|
+
);
|
|
963
|
+
if (delayMs > 0 && remainingTimeoutMs > 0) {
|
|
964
|
+
await sleep(Math.min(delayMs, remainingTimeoutMs));
|
|
965
|
+
}
|
|
966
|
+
// A provider Retry-After can exceed the caller's remaining wait budget.
|
|
967
|
+
// Never issue a late poll after either a normal cadence or rate-limit
|
|
968
|
+
// delay has consumed that budget.
|
|
969
|
+
if (now() - startedAt >= polling.timeoutMs) {
|
|
970
|
+
return {
|
|
971
|
+
kind: 'timed_out',
|
|
848
972
|
jobId,
|
|
849
|
-
phase: 'poll',
|
|
850
973
|
pollAttempt,
|
|
974
|
+
previousPollResult,
|
|
975
|
+
reason: 'timeout',
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
let pollResponse: Response | null = null;
|
|
979
|
+
let currentPollResult = previousPollResult;
|
|
980
|
+
try {
|
|
981
|
+
for (const action of input.contract.lifecycle.pollActions) {
|
|
982
|
+
const actionExecution = await executeActionWithinDeadline({
|
|
983
|
+
action: action.action,
|
|
984
|
+
input: buildAsyncOperationActionInput({
|
|
985
|
+
mapping: action.input,
|
|
986
|
+
jobId,
|
|
987
|
+
startInput: input.startInput,
|
|
988
|
+
startResult: input.startResult,
|
|
989
|
+
pollResult: currentPollResult,
|
|
990
|
+
}),
|
|
991
|
+
phase: 'poll',
|
|
992
|
+
pollAttempt,
|
|
993
|
+
});
|
|
994
|
+
if (actionExecution.kind === 'timed_out') return timedOut();
|
|
995
|
+
pollResponse = actionExecution.response;
|
|
996
|
+
currentPollResult = input.responseValue(pollResponse);
|
|
997
|
+
}
|
|
998
|
+
previousPollResult = currentPollResult;
|
|
999
|
+
consecutiveRateLimitAttempts = 0;
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
const rateLimit = retryablePollRateLimit(error);
|
|
1002
|
+
if (!rateLimit) throw error;
|
|
1003
|
+
consecutiveRateLimitAttempts += 1;
|
|
1004
|
+
nextPollDelayMs = asyncOperationRateLimitPollDelayMs({
|
|
1005
|
+
contract: effectiveContract,
|
|
1006
|
+
pollAttempt,
|
|
1007
|
+
rateLimitAttempt: consecutiveRateLimitAttempts,
|
|
1008
|
+
retryAfterMs: rateLimit.retryAfterMs,
|
|
851
1009
|
});
|
|
852
|
-
|
|
1010
|
+
continue;
|
|
853
1011
|
}
|
|
854
1012
|
if (!pollResponse) {
|
|
855
1013
|
throw new Error(
|
|
@@ -899,13 +1057,14 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
899
1057
|
startResult: input.startResult,
|
|
900
1058
|
pollResult: previousPollResult,
|
|
901
1059
|
});
|
|
902
|
-
|
|
1060
|
+
const actionExecution = await executeActionWithinDeadline({
|
|
903
1061
|
action: finishAction.action,
|
|
904
1062
|
input: finishInput,
|
|
905
|
-
jobId,
|
|
906
1063
|
phase: 'finish',
|
|
907
1064
|
pollAttempt,
|
|
908
1065
|
});
|
|
1066
|
+
if (actionExecution.kind === 'timed_out') return timedOut();
|
|
1067
|
+
finishResponse = actionExecution.response;
|
|
909
1068
|
finishResult = input.responseValue(finishResponse);
|
|
910
1069
|
const pagination = finishAction.pagination;
|
|
911
1070
|
if (pagination) {
|
|
@@ -937,10 +1096,8 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
937
1096
|
// response instead of issuing unbounded empty page reads.
|
|
938
1097
|
return (
|
|
939
1098
|
!pagination.lastPagePath ||
|
|
940
|
-
readAsyncOperationPath(
|
|
941
|
-
|
|
942
|
-
pagination.lastPagePath,
|
|
943
|
-
) !== false
|
|
1099
|
+
readAsyncOperationPath(currentResult, pagination.lastPagePath) !==
|
|
1100
|
+
false
|
|
944
1101
|
);
|
|
945
1102
|
};
|
|
946
1103
|
while (!isLastPage()) {
|
|
@@ -950,14 +1107,15 @@ export async function executeAsyncOperationLifecycle<Response>(input: {
|
|
|
950
1107
|
`Async operation ${input.contract.lifecycle.startAction} exceeded its ${pagination.maxPages}-page result limit.`,
|
|
951
1108
|
);
|
|
952
1109
|
}
|
|
953
|
-
const
|
|
1110
|
+
const actionExecution = await executeActionWithinDeadline({
|
|
954
1111
|
action: finishAction.action,
|
|
955
1112
|
input: { ...finishInput, [pagination.pageInputField]: page },
|
|
956
|
-
jobId,
|
|
957
1113
|
phase: 'finish_page',
|
|
958
1114
|
pollAttempt,
|
|
959
1115
|
page,
|
|
960
1116
|
});
|
|
1117
|
+
if (actionExecution.kind === 'timed_out') return timedOut();
|
|
1118
|
+
const pageResponse = actionExecution.response;
|
|
961
1119
|
currentResult = input.responseValue(pageResponse);
|
|
962
1120
|
const pageItems = readAsyncOperationPath(
|
|
963
1121
|
currentResult,
|