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.
Files changed (34) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +279 -21
  2. package/dist/bundling-sources/sdk/src/config.ts +21 -3
  3. package/dist/bundling-sources/sdk/src/index.ts +1 -0
  4. package/dist/bundling-sources/sdk/src/play.ts +3 -6
  5. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  6. package/dist/bundling-sources/sdk/src/types.ts +3 -2
  7. package/dist/bundling-sources/shared_libs/monitors/deactivation-reasons.ts +186 -0
  8. package/dist/bundling-sources/shared_libs/monitors/monitor-fleet-limits.ts +41 -0
  9. package/dist/bundling-sources/shared_libs/play-runtime/async-operation.ts +213 -55
  10. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +172 -299
  11. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +10 -0
  12. package/dist/bundling-sources/shared_libs/play-runtime/durable-call-cache.ts +13 -32
  13. package/dist/bundling-sources/shared_libs/play-runtime/governor/governor.ts +5 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/resource-governor.ts +7 -2
  15. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +11 -2
  16. package/dist/bundling-sources/shared_libs/play-runtime/runtime-incident-drills.ts +1 -1
  17. package/dist/bundling-sources/shared_libs/play-runtime/suspension.ts +16 -0
  18. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/native-batch.ts +1 -0
  19. package/dist/bundling-sources/shared_libs/play-runtime/tool-call/testkit.ts +5 -4
  20. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +12 -2
  21. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +1 -1
  22. package/dist/bundling-sources/shared_libs/plays/deployment-status.ts +74 -0
  23. package/dist/cli/index.js +940 -181
  24. package/dist/cli/index.mjs +940 -181
  25. package/dist/{compiler-manifest-CMNgA_JQ.d.mts → compiler-manifest-Dbov16F7.d.mts} +1 -1
  26. package/dist/{compiler-manifest-CMNgA_JQ.d.ts → compiler-manifest-Dbov16F7.d.ts} +1 -1
  27. package/dist/index.d.mts +176 -23
  28. package/dist/index.d.ts +176 -23
  29. package/dist/index.js +109 -12
  30. package/dist/index.mjs +109 -12
  31. package/dist/install-integrity.json +4 -2
  32. package/dist/plays/bundle-play-file.d.mts +2 -2
  33. package/dist/plays/bundle-play-file.d.ts +2 -2
  34. 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
- 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
- };
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
- durableValue?: boolean;
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
- export function asyncOperationDurableStartInput(
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.durableValue ?? false,
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, durable receipts,
762
- * retry policy, billing, and recovery persistence around each action.
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 { kind: 'timed_out', jobId, pollAttempt, previousPollResult };
945
+ return {
946
+ kind: 'timed_out',
947
+ jobId,
948
+ pollAttempt,
949
+ previousPollResult,
950
+ reason: 'timeout',
951
+ };
831
952
  }
832
953
  const delayMs =
833
- pollAttempt === 0 && input.pollImmediately === true
954
+ nextPollDelayMs ??
955
+ (pollAttempt === 0 && input.pollImmediately === true
834
956
  ? 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
- }),
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
- previousPollResult = input.responseValue(pollResponse);
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
- finishResponse = await input.executeAction({
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
- currentResult,
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 pageResponse = await input.executeAction({
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,