@vertesia/client 1.5.0-dev.20260910.052912Z → 1.6.0-dev.20260912.110338Z

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 (59) hide show
  1. package/lib/DelegationGrantsApi.d.ts +11 -0
  2. package/lib/DelegationGrantsApi.d.ts.map +1 -0
  3. package/lib/DelegationGrantsApi.js +22 -0
  4. package/lib/DelegationGrantsApi.js.map +1 -0
  5. package/lib/InteractionBase.d.ts +52 -0
  6. package/lib/InteractionBase.d.ts.map +1 -1
  7. package/lib/InteractionsApi.d.ts +1 -1
  8. package/lib/InteractionsApi.d.ts.map +1 -1
  9. package/lib/InteractionsApi.js +2 -2
  10. package/lib/InteractionsApi.js.map +1 -1
  11. package/lib/RunsApi.d.ts +4 -9
  12. package/lib/RunsApi.d.ts.map +1 -1
  13. package/lib/RunsApi.js +2 -0
  14. package/lib/RunsApi.js.map +1 -1
  15. package/lib/client.d.ts +2 -0
  16. package/lib/client.d.ts.map +1 -1
  17. package/lib/client.js +2 -0
  18. package/lib/client.js.map +1 -1
  19. package/lib/execute.d.ts +2 -1
  20. package/lib/execute.d.ts.map +1 -1
  21. package/lib/execute.js +3 -2
  22. package/lib/execute.js.map +1 -1
  23. package/lib/index.d.ts +1 -0
  24. package/lib/index.d.ts.map +1 -1
  25. package/lib/index.js +1 -0
  26. package/lib/index.js.map +1 -1
  27. package/lib/store/AgentsApi.d.ts +12 -1
  28. package/lib/store/AgentsApi.d.ts.map +1 -1
  29. package/lib/store/AgentsApi.js +23 -0
  30. package/lib/store/AgentsApi.js.map +1 -1
  31. package/lib/store/ObjectsApi.d.ts.map +1 -1
  32. package/lib/store/ObjectsApi.js +4 -3
  33. package/lib/store/ObjectsApi.js.map +1 -1
  34. package/lib/store/SchedulesApi.d.ts +17 -5
  35. package/lib/store/SchedulesApi.d.ts.map +1 -1
  36. package/lib/store/SchedulesApi.js +17 -5
  37. package/lib/store/SchedulesApi.js.map +1 -1
  38. package/lib/store/signed-url.d.ts +3 -2
  39. package/lib/store/signed-url.d.ts.map +1 -1
  40. package/lib/store/signed-url.js +8 -2
  41. package/lib/store/signed-url.js.map +1 -1
  42. package/lib/vertesia-client.js +9 -9
  43. package/lib/vertesia-client.js.map +1 -1
  44. package/package.json +9 -9
  45. package/src/DelegationGrantsApi.ts +22 -0
  46. package/src/InteractionsApi.ts +2 -0
  47. package/src/RunsApi.test.ts +24 -0
  48. package/src/RunsApi.ts +7 -9
  49. package/src/client.ts +2 -0
  50. package/src/execute.test.ts +22 -0
  51. package/src/execute.ts +4 -1
  52. package/src/index.ts +1 -0
  53. package/src/store/AgentsApi.history.test.ts +29 -0
  54. package/src/store/AgentsApi.ts +22 -1
  55. package/src/store/ObjectsApi.test.ts +50 -0
  56. package/src/store/ObjectsApi.ts +5 -3
  57. package/src/store/SchedulesApi.ts +17 -5
  58. package/src/store/signed-url.test.ts +24 -2
  59. package/src/store/signed-url.ts +8 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertesia/client",
3
- "version": "1.5.0-dev.20260910.052912Z",
3
+ "version": "1.6.0-dev.20260912.110338Z",
4
4
  "type": "module",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -12,16 +12,16 @@
12
12
  "license": "Apache-2.0",
13
13
  "devDependencies": {
14
14
  "@types/node": "^24.13.3",
15
- "rolldown": "1.2.4",
15
+ "@vertesia/tsconfig": "0.1.0",
16
+ "rolldown": "1.2.6",
16
17
  "typescript": "7.0.2",
17
- "vitest": "^4.1.10",
18
- "@vertesia/tsconfig": "0.1.0"
18
+ "vitest": "^4.1.11"
19
19
  },
20
20
  "dependencies": {
21
- "eventsource": "^5.0.0",
22
- "@vertesia/api-fetch-client": "1.5.0-dev.20260910.052912Z",
23
- "@vertesia/common": "1.5.0-dev.20260910.052912Z",
24
- "@llumiverse/common": "1.5.0-dev.20260910.044936Z"
21
+ "@llumiverse/common": "1.6.0-dev.20260912.104205Z",
22
+ "@vertesia/api-fetch-client": "1.6.0-dev.20260912.110338Z",
23
+ "@vertesia/common": "1.6.0-dev.20260912.110338Z",
24
+ "eventsource": "^5.1.1"
25
25
  },
26
26
  "exports": {
27
27
  ".": {
@@ -48,7 +48,7 @@
48
48
  "ai",
49
49
  "typescript"
50
50
  ],
51
- "gitHead": "95d481ca856012cd67b501b901dc365afeb7cf81",
51
+ "gitHead": "504e8df031c3950ec324c865385caf460b1c1cd7",
52
52
  "scripts": {
53
53
  "lint": "biome lint src",
54
54
  "clean:lib": "rimraf ./lib ./tsconfig.tsbuildinfo",
@@ -0,0 +1,22 @@
1
+ import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
2
+ import type { CreateDelegationGrantPayload, DelegationGrant } from '@vertesia/common';
3
+ export default class DelegationGrantsApi extends ApiTopic {
4
+ constructor(parent: ClientBase) {
5
+ super(parent, '/api/v1/iam/delegation-grants');
6
+ }
7
+ create(payload: CreateDelegationGrantPayload): Promise<DelegationGrant> {
8
+ return this.post('/', { payload });
9
+ }
10
+ list(): Promise<DelegationGrant[]> {
11
+ return this.get('/');
12
+ }
13
+ mine(): Promise<DelegationGrant[]> {
14
+ return this.get('/me');
15
+ }
16
+ retrieve(id: string): Promise<DelegationGrant> {
17
+ return this.get(`/${id}`);
18
+ }
19
+ revoke(id: string): Promise<DelegationGrant> {
20
+ return this.post(`/${id}/revoke`);
21
+ }
22
+ }
@@ -227,12 +227,14 @@ export default class InteractionsApi extends ApiTopic {
227
227
  nameWithTag: string,
228
228
  payload: InteractionExecutionPayload = {},
229
229
  onChunk?: (chunk: string) => void,
230
+ requestOptions?: IRequestParams,
230
231
  ): Promise<EnhancedInteractionExecutionResult<ResultT, ParamsT>> {
231
232
  const r = await executeInteractionByName<ParamsT>(
232
233
  this.client as VertesiaClient,
233
234
  nameWithTag,
234
235
  payload,
235
236
  onChunk,
237
+ requestOptions,
236
238
  ).catch((err) => {
237
239
  if (err instanceof ServerError && hasIdPayload(err.payload)) {
238
240
  throw err.updateDetails({ run_id: err.payload.id });
@@ -0,0 +1,24 @@
1
+ import type { ToolResultsPayload, UserMessagePayload } from '@vertesia/common';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { VertesiaClient } from './client.js';
4
+
5
+ describe('RunsApi resume request options', () => {
6
+ it('sends private headers on tool-result and user-message resumes', async () => {
7
+ const requests: Request[] = [];
8
+ const client = new VertesiaClient({
9
+ serverUrl: 'https://studio.example.com',
10
+ storeUrl: 'https://zeno.example.com',
11
+ fetch: vi.fn(async () => Response.json({ result: [], prompt: [] })),
12
+ onRequest: (request) => requests.push(request),
13
+ });
14
+ const options = { headers: { 'x-vertesia-required-tool-name': 'write_artifact' } };
15
+
16
+ await client.runs.sendToolResults({} as ToolResultsPayload, options);
17
+ await client.runs.sendUserMessage({} as UserMessagePayload, options);
18
+
19
+ expect(requests.map((request) => request.headers.get('x-vertesia-required-tool-name'))).toEqual([
20
+ 'write_artifact',
21
+ 'write_artifact',
22
+ ]);
23
+ });
24
+ });
package/src/RunsApi.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExecutionResponse } from '@llumiverse/common';
2
- import { ApiTopic, type ClientBase } from '@vertesia/api-fetch-client';
2
+ import { ApiTopic, type ClientBase, type IRequestParams } from '@vertesia/api-fetch-client';
3
3
  import type {
4
4
  ComputeRunFacetPayload,
5
5
  ComputeRunFacetsResponse,
@@ -34,6 +34,8 @@ export interface FilterOption {
34
34
 
35
35
  export type { ComputeRunFacetsResponse } from '@vertesia/common';
36
36
 
37
+ type ResumeRequestOptions = Pick<IRequestParams, 'headers' | 'signal' | 'timeoutMs'>;
38
+
37
39
  export class RunsApi extends ApiTopic {
38
40
  constructor(parent: ClientBase) {
39
41
  super(parent, '/api/v1/runs');
@@ -116,12 +118,10 @@ export class RunsApi extends ApiTopic {
116
118
  * @param payload
117
119
  * @returns
118
120
  */
119
- sendToolResults(
120
- payload: ToolResultsPayload,
121
- options?: { timeoutMs?: number | false | null; signal?: AbortSignal },
122
- ): Promise<ExecutionResponse> {
121
+ sendToolResults(payload: ToolResultsPayload, options?: ResumeRequestOptions): Promise<ExecutionResponse> {
123
122
  return this.post(`/tool-results`, {
124
123
  payload,
124
+ headers: options?.headers,
125
125
  timeoutMs: options?.timeoutMs,
126
126
  signal: options?.signal,
127
127
  });
@@ -132,12 +132,10 @@ export class RunsApi extends ApiTopic {
132
132
  * @param payload
133
133
  * @returns
134
134
  */
135
- sendUserMessage(
136
- payload: UserMessagePayload,
137
- options?: { timeoutMs?: number | false | null; signal?: AbortSignal },
138
- ): Promise<ExecutionResponse> {
135
+ sendUserMessage(payload: UserMessagePayload, options?: ResumeRequestOptions): Promise<ExecutionResponse> {
139
136
  return this.post(`/user-message`, {
140
137
  payload,
138
+ headers: options?.headers,
141
139
  timeoutMs: options?.timeoutMs,
142
140
  signal: options?.signal,
143
141
  });
package/src/client.ts CHANGED
@@ -7,6 +7,7 @@ import { ApiKeysApi } from './ApiKeysApi.js';
7
7
  import AppsApi from './AppsApi.js';
8
8
  import AuditTrailApi from './AuditTrailApi.js';
9
9
  import CommandsApi from './CommandsApi.js';
10
+ import DelegationGrantsApi from './DelegationGrantsApi.js';
10
11
  import EnvironmentsApi from './EnvironmentsApi.js';
11
12
  import { IamApi } from './IamApi.js';
12
13
  import InteractionsApi from './InteractionsApi.js';
@@ -450,6 +451,7 @@ export class VertesiaClient extends AbstractFetchClient<VertesiaClient> {
450
451
  analytics = new AnalyticsApi(this);
451
452
  auditTrail = new AuditTrailApi(this);
452
453
  training = new TrainingApi(this);
454
+ delegationGrants = new DelegationGrantsApi(this);
453
455
  users = new UsersApi(this);
454
456
  iam = new IamApi(this);
455
457
  refs = new RefsApi(this);
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { VertesiaClient } from './client.js';
3
+ import { executeInteractionByName } from './execute.js';
4
+
5
+ describe('executeInteractionByName', () => {
6
+ it('forwards private per-request headers without placing them in the execution payload', async () => {
7
+ const post = vi.fn().mockResolvedValue({ id: 'run-1', status: 'completed' });
8
+ const client = { post } as unknown as VertesiaClient;
9
+
10
+ await executeInteractionByName(client, 'sys:test', { data: { message: 'hello' } }, undefined, {
11
+ headers: { 'x-vertesia-required-tool-name': 'app_workspace_init' },
12
+ });
13
+
14
+ expect(post).toHaveBeenCalledWith(
15
+ '/api/v1/execute',
16
+ expect.objectContaining({
17
+ headers: { 'x-vertesia-required-tool-name': 'app_workspace_init' },
18
+ payload: expect.not.objectContaining({ required_tool_name: expect.anything() }),
19
+ }),
20
+ );
21
+ });
22
+ });
package/src/execute.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { IRequestParams } from '@vertesia/api-fetch-client';
1
2
  import {
2
3
  type AsyncExecutionPayload,
3
4
  type AsyncExecutionResult,
@@ -87,16 +88,18 @@ export async function executeInteractionByName<P = unknown>(
87
88
  interaction: string,
88
89
  payload: InteractionExecutionPayload = {},
89
90
  onChunk?: (chunk: string) => void,
91
+ requestOptions?: IRequestParams,
90
92
  ): Promise<InteractionExecutionResult<P>> {
91
93
  const stream = !!onChunk;
92
94
  const response = await client.post<InteractionExecutionResult<P>>('/api/v1/execute', {
95
+ ...requestOptions,
93
96
  payload: {
94
97
  ...payload,
95
98
  interaction,
96
99
  stream,
97
100
  } as NamedInteractionExecutionPayload,
98
101
  // Synchronous interaction execution blocks on the model; keep a long client timeout.
99
- timeoutMs: stream ? undefined : INTERACTION_EXECUTION_TIMEOUT_MS,
102
+ timeoutMs: requestOptions?.timeoutMs ?? (stream ? undefined : INTERACTION_EXECUTION_TIMEOUT_MS),
100
103
  });
101
104
  if (stream) {
102
105
  if (response.status === ExecutionRunStatus.failed) {
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import type { AsyncExecutionResult } from '@vertesia/common';
3
3
  export type { EnableEnvironmentModelPayload, OrphanedAppInstallation } from '@vertesia/common';
4
4
  export { getOAuthPermissionScopes, Permission } from '@vertesia/common';
5
5
  export * from './client.js';
6
+ export { default as DelegationGrantsApi } from './DelegationGrantsApi.js';
6
7
  export type { GroupsQueryOptions } from './GroupsApi.js';
7
8
  export * from './InteractionBase.js';
8
9
  export * from './InteractionOutput.js';
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { VertesiaClient } from '../client.js';
3
+
4
+ describe('agent history cursor transport', () => {
5
+ it('passes the cursor and response metadata through for main and child details', async () => {
6
+ const urls: URL[] = [];
7
+ const response = { history: { type: 'agent', mode: 'delta', next_from: 'next', agentTasks: [] } };
8
+ const client = new VertesiaClient({
9
+ serverUrl: 'https://studio.test',
10
+ storeUrl: 'https://store.test',
11
+ fetch: (async (input: Request | string) => {
12
+ urls.push(new URL(typeof input === 'string' ? input : input.url));
13
+ return Response.json(response);
14
+ }) as typeof fetch,
15
+ });
16
+ const options = { includeHistory: true, hydratePayloads: true, from: 'opaque/+=' };
17
+ expect(await client.agents.getRunDetails('agent', options)).toEqual(response);
18
+ expect(await client.agents.getChildDetails('agent', 'child', options)).toEqual(response);
19
+ expect(urls.map((url) => url.pathname)).toEqual([
20
+ '/api/v1/agents/agent/details',
21
+ '/api/v1/agents/agent/children/child/details',
22
+ ]);
23
+ for (const url of urls) {
24
+ expect(url.searchParams.get('from')).toBe(options.from);
25
+ expect(url.searchParams.get('include_history')).toBe('true');
26
+ expect(url.searchParams.get('hydrate_payloads')).toBe('true');
27
+ }
28
+ });
29
+ });
@@ -7,6 +7,8 @@ import {
7
7
  type AgentMessage,
8
8
  type AgentRun,
9
9
  type AgentRunDetailsStreamEvent,
10
+ type AgentRunFeedbackPayload,
11
+ type AgentRunFeedbackResponse,
10
12
  type AgentRunInternals,
11
13
  type AgentRunResponse,
12
14
  type AgentRunUpdatesResponse,
@@ -165,9 +167,25 @@ export class AgentsApi extends ApiTopic {
165
167
  if (query?.cursor) params.cursor = query.cursor;
166
168
  if (query?.sort) params.sort = query.sort;
167
169
  if (query?.order) params.order = query.order;
170
+ if (query?.evaluation_severity?.length) params.evaluation_severity = query.evaluation_severity.join(',');
171
+ if (query?.evaluation_flag?.length) params.evaluation_flag = query.evaluation_flag.join(',');
172
+ if (query?.feedback_rating) params.feedback_rating = query.feedback_rating;
173
+ if (query?.contradicted !== undefined) params.contradicted = String(query.contradicted);
168
174
  return params;
169
175
  }
170
176
 
177
+ /**
178
+ * Rate an agent run: thumbs up or down, with an optional reason code and comment.
179
+ *
180
+ * `feedback_id` is the client's idempotency key: a retried request with the same id is a no-op.
181
+ * `status: 'replaced'` means an earlier rating by the same user on the same scope was
182
+ * superseded; `disabled` means this deployment does not record feedback. All answer 200 because
183
+ * a rating is never the user's error.
184
+ */
185
+ recordFeedback(id: string, payload: AgentRunFeedbackPayload): Promise<AgentRunFeedbackResponse> {
186
+ return this.post(`/${id}/feedback`, { payload });
187
+ }
188
+
171
189
  /**
172
190
  * Search agent runs via Elasticsearch (full-text + filters).
173
191
  */
@@ -666,11 +684,13 @@ export class AgentsApi extends ApiTopic {
666
684
  options?: {
667
685
  includeHistory?: boolean;
668
686
  hydratePayloads?: boolean;
687
+ from?: string;
669
688
  },
670
689
  ): Promise<WorkflowRunWithDetails> {
671
690
  const query: Record<string, string> = {};
672
691
  if (options?.includeHistory) query.include_history = 'true';
673
692
  if (options?.hydratePayloads) query.hydrate_payloads = 'true';
693
+ if (options?.from !== undefined) query.from = options.from;
674
694
  return this.get(`/${id}/details`, { query });
675
695
  }
676
696
 
@@ -777,11 +797,12 @@ export class AgentsApi extends ApiTopic {
777
797
  getChildDetails(
778
798
  id: string,
779
799
  childWorkflowId: string,
780
- options?: { includeHistory?: boolean; hydratePayloads?: boolean },
800
+ options?: { includeHistory?: boolean; hydratePayloads?: boolean; from?: string },
781
801
  ): Promise<WorkflowRunWithDetails> {
782
802
  const query: Record<string, string> = {};
783
803
  if (options?.includeHistory) query.include_history = 'true';
784
804
  if (options?.hydratePayloads) query.hydrate_payloads = 'true';
805
+ if (options?.from !== undefined) query.from = options.from;
785
806
  return this.get(`/${id}/children/${childWorkflowId}/details`, { query });
786
807
  }
787
808
 
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ZenoClient } from './client.js';
3
+
4
+ describe('ObjectsApi revision labels', () => {
5
+ it.each(['v1.2', 'Review — 日本語', 'Révision – café', '100% complete & reviewed + approved'])(
6
+ 'preserves the revision label %s through the query contract',
7
+ async (revisionLabel) => {
8
+ const requests: Request[] = [];
9
+ const client = new ZenoClient({
10
+ serverUrl: 'https://api.example.com',
11
+ fetch: vi.fn(async () => Response.json({ id: 'document-id' })),
12
+ onRequest: (request) => requests.push(request),
13
+ });
14
+
15
+ await client.objects.update(
16
+ 'document-id',
17
+ { name: 'Updated document' },
18
+ {
19
+ createRevision: true,
20
+ revisionLabel,
21
+ ifMatch: 'previous-etag',
22
+ },
23
+ );
24
+
25
+ expect(requests).toHaveLength(1);
26
+ const request = requests[0];
27
+ expect(request.method).toBe('PUT');
28
+ expect(new URL(request.url).searchParams.get('revision_label')).toBe(revisionLabel);
29
+ expect(request.headers.has('x-revision-label')).toBe(false);
30
+ expect(request.headers.get('x-create-revision')).toBe('true');
31
+ expect(request.headers.get('if-match')).toBe('previous-etag');
32
+ expect(await request.clone().json()).toEqual({ name: 'Updated document' });
33
+ },
34
+ );
35
+
36
+ it('does not send a label when updating in place', async () => {
37
+ const requests: Request[] = [];
38
+ const client = new ZenoClient({
39
+ serverUrl: 'https://api.example.com',
40
+ fetch: vi.fn(async () => Response.json({ id: 'document-id' })),
41
+ onRequest: (request) => requests.push(request),
42
+ });
43
+
44
+ await client.objects.update('document-id', {}, { revisionLabel: 'Unused — label' });
45
+
46
+ expect(new URL(requests[0].url).searchParams.has('revision_label')).toBe(false);
47
+ expect(requests[0].headers.has('x-create-revision')).toBe(false);
48
+ expect(requests[0].headers.has('x-revision-label')).toBe(false);
49
+ });
50
+ });
@@ -437,9 +437,6 @@ export class ObjectsApi extends ApiTopic {
437
437
  }
438
438
  if (options?.createRevision) {
439
439
  headers[ContentObjectApiHeaders.CREATE_REVISION] = 'true';
440
- if (options.revisionLabel) {
441
- headers[ContentObjectApiHeaders.REVISION_LABEL] = options.revisionLabel;
442
- }
443
440
  }
444
441
  if (options?.suppressWorkflows) {
445
442
  headers[ContentObjectApiHeaders.SUPPRESS_WORKFLOWS] = 'true';
@@ -447,6 +444,11 @@ export class ObjectsApi extends ApiTopic {
447
444
 
448
445
  return this.put(`/${id}`, {
449
446
  payload: updatePayload,
447
+ // The existing query contract preserves Unicode labels; HTTP headers only accept ByteString values.
448
+ query:
449
+ options?.createRevision && options.revisionLabel
450
+ ? { revision_label: options.revisionLabel }
451
+ : undefined,
450
452
  headers,
451
453
  });
452
454
  }
@@ -8,9 +8,9 @@ import type {
8
8
  } from '@vertesia/common';
9
9
 
10
10
  /**
11
- * Client API for managing agent schedules.
11
+ * Client API for managing recurring schedules.
12
12
  *
13
- * Schedules allow agents to run on a recurring basis using cron expressions.
13
+ * Schedules allow agents or processes to run on a recurring basis using cron expressions.
14
14
  */
15
15
  export class SchedulesApi extends ApiTopic {
16
16
  constructor(parent: ClientBase) {
@@ -35,7 +35,7 @@ export class SchedulesApi extends ApiTopic {
35
35
  /**
36
36
  * Create a new schedule.
37
37
  *
38
- * @param payload - Schedule configuration including name, interaction, and cron expression
38
+ * @param payload - Agent or process schedule configuration
39
39
  * @returns The created schedule
40
40
  *
41
41
  * @example
@@ -48,6 +48,18 @@ export class SchedulesApi extends ApiTopic {
48
48
  * vars: { reportType: 'summary' }
49
49
  * });
50
50
  * ```
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * const schedule = await client.schedules.create({
55
+ * name: 'Nightly Refresh',
56
+ * target: 'process',
57
+ * process: '68c01a23456789abcdef0123',
58
+ * cron_expression: '0 2 * * *',
59
+ * timezone: 'UTC',
60
+ * context: { collection: 'reports' }
61
+ * });
62
+ * ```
51
63
  */
52
64
  create(payload: CreateSchedulePayload): Promise<AgentSchedule> {
53
65
  return this.post('/', { payload });
@@ -77,9 +89,9 @@ export class SchedulesApi extends ApiTopic {
77
89
  }
78
90
 
79
91
  /**
80
- * Trigger an immediate execution of the scheduled agent.
92
+ * Trigger an immediate execution of the scheduled workflow.
81
93
  *
82
- * This runs the agent immediately without waiting for the next scheduled time.
94
+ * This runs the agent or process immediately without waiting for the next scheduled time.
83
95
  *
84
96
  * @param id - Schedule ID
85
97
  * @returns Success message
@@ -173,10 +173,32 @@ describe('fetchSignedUrl', () => {
173
173
 
174
174
  it('honors a numeric Retry-After header when scheduling the retry', async () => {
175
175
  fetchMock
176
- .mockResolvedValueOnce(response(503, 'slow down', { 'retry-after': '2' }))
176
+ .mockResolvedValueOnce(response(503, 'slow down', { 'retry-after': '30' }))
177
177
  .mockResolvedValueOnce(response(200, 'ok'));
178
- const res = await runAllTimers(fetchSignedUrl('https://storage/x'));
178
+ const pending = fetchSignedUrl('https://storage/x');
179
+ await vi.advanceTimersByTimeAsync(29999);
180
+ expect(fetchMock).toHaveBeenCalledTimes(1);
181
+ await vi.advanceTimersByTimeAsync(1);
182
+ const res = await pending;
179
183
  expect(res.status).toBe(200);
180
184
  expect(fetchMock).toHaveBeenCalledTimes(2);
181
185
  });
182
186
  });
187
+
188
+ describe('Retry-After request budget', () => {
189
+ it.each(['3600', '2147484', '1e308', new Date(Date.now() + 3600000).toUTCString()])(
190
+ 'returns the intact response without an early retry for %s',
191
+ async (retryAfter) => {
192
+ const res = new Response('retry later', { status: 503, headers: { 'Retry-After': retryAfter } });
193
+ const fetch = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(res);
194
+ try {
195
+ const result = await fetchSignedUrl('https://storage/x');
196
+ expect(result).toBe(res);
197
+ expect(await result.text()).toBe('retry later');
198
+ expect(fetch).toHaveBeenCalledTimes(1);
199
+ } finally {
200
+ fetch.mockRestore();
201
+ }
202
+ },
203
+ );
204
+ });
@@ -20,6 +20,7 @@ const DEFAULT_RETRYABLE_STATUSES: ReadonlySet<number> = new Set([429, 500, 502,
20
20
  const DEFAULT_ATTEMPTS = 4;
21
21
  const DEFAULT_BASE_DELAY_MS = 500;
22
22
  const DEFAULT_MAX_DELAY_MS = 8_000;
23
+ const MAX_RETRY_AFTER_MS = 60_000;
23
24
 
24
25
  export interface SignedUrlFetchOptions {
25
26
  method?: string;
@@ -29,7 +30,7 @@ export interface SignedUrlFetchOptions {
29
30
  attempts?: number;
30
31
  /** Base delay for exponential backoff, in milliseconds. Defaults to 500. */
31
32
  baseDelayMs?: number;
32
- /** Upper bound for a single backoff delay, in milliseconds. Defaults to 8000. */
33
+ /** Upper bound for exponential backoff, in milliseconds. Defaults to 8000. Retry-After takes precedence. */
33
34
  maxDelayMs?: number;
34
35
  /** HTTP statuses to retry on. Defaults to 429, 500, 502, 503, 504. */
35
36
  retryableStatuses?: ReadonlySet<number>;
@@ -58,7 +59,7 @@ function retryAfterMs(res: Response): number | undefined {
58
59
  function backoffMs(attempt: number, baseDelayMs: number, maxDelayMs: number, res?: Response): number {
59
60
  const retryAfter = res ? retryAfterMs(res) : undefined;
60
61
  if (retryAfter !== undefined) {
61
- return Math.min(maxDelayMs, retryAfter);
62
+ return retryAfter;
62
63
  }
63
64
  // Exponential backoff with full jitter.
64
65
  const capped = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
@@ -141,7 +142,8 @@ async function toReplayableBody(body: BodyInit | null | undefined): Promise<Body
141
142
  /**
142
143
  * `fetch()` a cloud-storage signed URL, retrying transient failures (connection
143
144
  * errors and retryable HTTP statuses) with exponential backoff. Honors a
144
- * `Retry-After` response header when present.
145
+ * `Retry-After` response header up to 60 seconds. Longer delays return the response
146
+ * without retrying so the caller can schedule recovery outside this request.
145
147
  *
146
148
  * The returned `Response` is only guaranteed to be retried while it carries a
147
149
  * retryable status; a non-retryable error response (e.g. 403, 404) is returned
@@ -167,6 +169,9 @@ export async function fetchSignedUrl(url: string, options: SignedUrlFetchOptions
167
169
  if (res.ok || !retryableStatuses.has(res.status) || isLastAttempt) {
168
170
  return res;
169
171
  }
172
+ // Do not retry earlier than requested or park the caller beyond the retry budget.
173
+ // Returning the response also avoids overflowing JavaScript's timer range.
174
+ if ((retryAfterMs(res) ?? 0) > MAX_RETRY_AFTER_MS) return res;
170
175
  // Retryable status: drain the body so the connection can be reused, then back off.
171
176
  await res.body?.cancel().catch(() => undefined);
172
177
  await sleep(backoffMs(attempt, baseDelayMs, maxDelayMs, res));