@agentuity/core 2.0.0-beta.1 → 2.0.0
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/services/oauth/flow.d.ts +31 -0
- package/dist/services/oauth/flow.d.ts.map +1 -1
- package/dist/services/oauth/flow.js +138 -13
- package/dist/services/oauth/flow.js.map +1 -1
- package/dist/services/oauth/index.d.ts +1 -0
- package/dist/services/oauth/index.d.ts.map +1 -1
- package/dist/services/oauth/index.js +1 -0
- package/dist/services/oauth/index.js.map +1 -1
- package/dist/services/oauth/token-storage.d.ts +109 -0
- package/dist/services/oauth/token-storage.d.ts.map +1 -0
- package/dist/services/oauth/token-storage.js +140 -0
- package/dist/services/oauth/token-storage.js.map +1 -0
- package/dist/services/oauth/types.d.ts +21 -0
- package/dist/services/oauth/types.d.ts.map +1 -1
- package/dist/services/oauth/types.js +21 -0
- package/dist/services/oauth/types.js.map +1 -1
- package/dist/services/project/get.d.ts +12 -0
- package/dist/services/project/get.d.ts.map +1 -1
- package/dist/services/project/get.js +9 -0
- package/dist/services/project/get.js.map +1 -1
- package/dist/services/sandbox/create.d.ts +3 -0
- package/dist/services/sandbox/create.d.ts.map +1 -1
- package/dist/services/sandbox/create.js +7 -0
- package/dist/services/sandbox/create.js.map +1 -1
- package/dist/services/sandbox/execute.d.ts.map +1 -1
- package/dist/services/sandbox/execute.js +22 -11
- package/dist/services/sandbox/execute.js.map +1 -1
- package/dist/services/sandbox/run.d.ts +1 -0
- package/dist/services/sandbox/run.d.ts.map +1 -1
- package/dist/services/sandbox/run.js +83 -30
- package/dist/services/sandbox/run.js.map +1 -1
- package/dist/services/sandbox/types.d.ts +10 -0
- package/dist/services/sandbox/types.d.ts.map +1 -1
- package/dist/services/sandbox/types.js +19 -0
- package/dist/services/sandbox/types.js.map +1 -1
- package/dist/services/schedule/service.d.ts +5 -0
- package/dist/services/schedule/service.d.ts.map +1 -1
- package/dist/services/schedule/service.js +16 -0
- package/dist/services/schedule/service.js.map +1 -1
- package/dist/services/schedule/types.d.ts +1 -0
- package/dist/services/schedule/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/services/oauth/flow.ts +156 -15
- package/src/services/oauth/index.ts +1 -0
- package/src/services/oauth/token-storage.ts +220 -0
- package/src/services/oauth/types.ts +28 -0
- package/src/services/project/get.ts +9 -0
- package/src/services/sandbox/create.ts +9 -0
- package/src/services/sandbox/execute.ts +26 -12
- package/src/services/sandbox/run.ts +129 -34
- package/src/services/sandbox/types.ts +21 -0
- package/src/services/schedule/service.ts +20 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import type { KeyValueStorage } from '../keyvalue/service.ts';
|
|
2
|
+
import type { OAuthFlowConfig, OAuthTokenResponse, StoredToken } from './types.ts';
|
|
3
|
+
import { StoredTokenSchema } from './types.ts';
|
|
4
|
+
import { refreshToken, logout } from './flow.ts';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_NAMESPACE = 'oauth-tokens';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check whether a stored token's access token has expired.
|
|
10
|
+
*
|
|
11
|
+
* @param token - The stored token to check
|
|
12
|
+
* @returns true if the token has an expires_at timestamp that is in the past
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const token = await storage.get('user:123');
|
|
17
|
+
* if (token && isTokenExpired(token)) {
|
|
18
|
+
* // Token is expired and auto-refresh wasn't available
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function isTokenExpired(token: StoredToken): boolean {
|
|
23
|
+
if (!token.expires_at) return false;
|
|
24
|
+
return Math.floor(Date.now() / 1000) >= token.expires_at;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for configuring a TokenStorage instance.
|
|
29
|
+
*/
|
|
30
|
+
export interface TokenStorageOptions {
|
|
31
|
+
/**
|
|
32
|
+
* OAuth configuration for auto-refresh and token revocation.
|
|
33
|
+
* If not provided, auto-refresh on get() and server-side revocation on invalidate() are disabled.
|
|
34
|
+
*/
|
|
35
|
+
config?: OAuthFlowConfig;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* KV namespace for storing tokens. Defaults to 'oauth-tokens'.
|
|
39
|
+
*/
|
|
40
|
+
namespace?: string;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Key prefix prepended to all storage keys.
|
|
44
|
+
* Useful for scoping tokens by application or tenant.
|
|
45
|
+
*/
|
|
46
|
+
prefix?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Interface for storing, retrieving, and invalidating OAuth tokens.
|
|
51
|
+
*
|
|
52
|
+
* Implementations handle persistence and may support automatic token refresh
|
|
53
|
+
* on retrieval and server-side revocation on invalidation.
|
|
54
|
+
*/
|
|
55
|
+
export interface TokenStorage {
|
|
56
|
+
/**
|
|
57
|
+
* Retrieve a stored token by key.
|
|
58
|
+
*
|
|
59
|
+
* If the token is expired and a refresh_token is available (and config is provided),
|
|
60
|
+
* the token is automatically refreshed, stored, and the new token is returned.
|
|
61
|
+
* If auto-refresh fails, the expired token is returned so the caller can decide
|
|
62
|
+
* how to handle it (check with {@link isTokenExpired}).
|
|
63
|
+
*
|
|
64
|
+
* @param key - The storage key (e.g. a user ID or session ID)
|
|
65
|
+
* @returns The stored token, or null if no token exists for the key
|
|
66
|
+
*/
|
|
67
|
+
get(key: string): Promise<StoredToken | null>;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Store a token response from a token exchange or refresh.
|
|
71
|
+
*
|
|
72
|
+
* Automatically computes `expires_at` from `expires_in` if present.
|
|
73
|
+
*
|
|
74
|
+
* @param key - The storage key (e.g. a user ID or session ID)
|
|
75
|
+
* @param token - The OAuth token response to store
|
|
76
|
+
*/
|
|
77
|
+
set(key: string, token: OAuthTokenResponse): Promise<void>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Invalidate a stored token: revoke it server-side and remove from storage.
|
|
81
|
+
*
|
|
82
|
+
* If config is provided, the refresh token (or access token as fallback)
|
|
83
|
+
* is revoked via the token revocation endpoint. Revocation is best-effort —
|
|
84
|
+
* the token is removed from storage regardless of whether revocation succeeds.
|
|
85
|
+
*
|
|
86
|
+
* @param key - The storage key to invalidate
|
|
87
|
+
* @returns The token that was removed, or null if no token existed
|
|
88
|
+
*/
|
|
89
|
+
invalidate(key: string): Promise<StoredToken | null>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Convert an OAuth token response to a StoredToken with computed expires_at.
|
|
94
|
+
*/
|
|
95
|
+
function toStoredToken(token: OAuthTokenResponse): StoredToken {
|
|
96
|
+
return {
|
|
97
|
+
access_token: token.access_token,
|
|
98
|
+
token_type: token.token_type,
|
|
99
|
+
refresh_token: token.refresh_token,
|
|
100
|
+
scope: token.scope,
|
|
101
|
+
id_token: token.id_token,
|
|
102
|
+
expires_at: token.expires_in ? Math.floor(Date.now() / 1000) + token.expires_in : undefined,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Token storage backed by Agentuity's Key-Value storage service.
|
|
108
|
+
*
|
|
109
|
+
* Stores tokens as JSON in a KV namespace. Supports automatic token refresh
|
|
110
|
+
* on retrieval when tokens expire (if OAuth config is provided).
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* import { KeyValueTokenStorage } from '@agentuity/core/oauth';
|
|
115
|
+
*
|
|
116
|
+
* // Create storage with auto-refresh enabled
|
|
117
|
+
* const storage = new KeyValueTokenStorage(ctx.kv, {
|
|
118
|
+
* config: { issuer: 'https://auth.example.com' },
|
|
119
|
+
* });
|
|
120
|
+
*
|
|
121
|
+
* // Store a token after initial exchange
|
|
122
|
+
* await storage.set('user:123', tokenResponse);
|
|
123
|
+
*
|
|
124
|
+
* // Retrieve — auto-refreshes if expired
|
|
125
|
+
* const token = await storage.get('user:123');
|
|
126
|
+
*
|
|
127
|
+
* // Logout — revokes server-side and removes from storage
|
|
128
|
+
* await storage.invalidate('user:123');
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
export class KeyValueTokenStorage implements TokenStorage {
|
|
132
|
+
#kv: KeyValueStorage;
|
|
133
|
+
#namespace: string;
|
|
134
|
+
#prefix: string;
|
|
135
|
+
#config?: OAuthFlowConfig;
|
|
136
|
+
|
|
137
|
+
constructor(kv: KeyValueStorage, options?: TokenStorageOptions) {
|
|
138
|
+
this.#kv = kv;
|
|
139
|
+
this.#namespace = options?.namespace ?? DEFAULT_NAMESPACE;
|
|
140
|
+
this.#prefix = options?.prefix ?? '';
|
|
141
|
+
this.#config = options?.config;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async get(key: string): Promise<StoredToken | null> {
|
|
145
|
+
const result = await this.#kv.get<StoredToken>(this.#namespace, this.#resolveKey(key));
|
|
146
|
+
if (!result.exists) return null;
|
|
147
|
+
|
|
148
|
+
const parsed = StoredTokenSchema.safeParse(result.data);
|
|
149
|
+
if (!parsed.success) return null;
|
|
150
|
+
|
|
151
|
+
const token = parsed.data;
|
|
152
|
+
|
|
153
|
+
// Auto-refresh if expired and refresh_token + config are available
|
|
154
|
+
if (isTokenExpired(token) && token.refresh_token && this.#config) {
|
|
155
|
+
try {
|
|
156
|
+
const newTokenResponse = await refreshToken(token.refresh_token, this.#config);
|
|
157
|
+
const newStored = toStoredToken(newTokenResponse);
|
|
158
|
+
await this.#store(key, newStored);
|
|
159
|
+
return newStored;
|
|
160
|
+
} catch {
|
|
161
|
+
// Refresh failed — return the expired token, caller can check isTokenExpired()
|
|
162
|
+
return token;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return token;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async set(key: string, token: OAuthTokenResponse): Promise<void> {
|
|
170
|
+
const stored = toStoredToken(token);
|
|
171
|
+
await this.#store(key, stored);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async invalidate(key: string): Promise<StoredToken | null> {
|
|
175
|
+
const resolvedKey = this.#resolveKey(key);
|
|
176
|
+
const result = await this.#kv.get<StoredToken>(this.#namespace, resolvedKey);
|
|
177
|
+
|
|
178
|
+
if (!result.exists) return null;
|
|
179
|
+
|
|
180
|
+
const parsed = StoredTokenSchema.safeParse(result.data);
|
|
181
|
+
const token = parsed.success ? parsed.data : null;
|
|
182
|
+
|
|
183
|
+
// Revoke server-side (best effort)
|
|
184
|
+
if (token && this.#config) {
|
|
185
|
+
const tokenToRevoke = token.refresh_token ?? token.access_token;
|
|
186
|
+
try {
|
|
187
|
+
await logout(tokenToRevoke, this.#config);
|
|
188
|
+
} catch {
|
|
189
|
+
// Best effort — continue with storage cleanup
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove from storage regardless of revocation result
|
|
194
|
+
await this.#kv.delete(this.#namespace, resolvedKey);
|
|
195
|
+
|
|
196
|
+
return token;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async #store(key: string, token: StoredToken): Promise<void> {
|
|
200
|
+
// Only set explicit TTL for tokens without a refresh_token.
|
|
201
|
+
// Tokens with refresh capability persist until explicitly invalidated
|
|
202
|
+
// (auto-refresh on get() will keep them fresh).
|
|
203
|
+
let ttl: number | undefined;
|
|
204
|
+
if (!token.refresh_token && token.expires_at) {
|
|
205
|
+
const remaining = token.expires_at - Math.floor(Date.now() / 1000);
|
|
206
|
+
if (remaining > 0) {
|
|
207
|
+
ttl = Math.max(remaining, 60); // KV minimum is 60 seconds
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await this.#kv.set(this.#namespace, this.#resolveKey(key), token, {
|
|
212
|
+
ttl,
|
|
213
|
+
contentType: 'application/json',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#resolveKey(key: string): string {
|
|
218
|
+
return this.#prefix ? `${this.#prefix}${key}` : key;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -155,6 +155,8 @@ export const OAuthPermissionLevelSchema = z.object({
|
|
|
155
155
|
label: z.string(),
|
|
156
156
|
value: z.string(),
|
|
157
157
|
scopes: z.array(z.string()),
|
|
158
|
+
warning: z.boolean().optional(),
|
|
159
|
+
warningTitle: z.string().optional(),
|
|
158
160
|
});
|
|
159
161
|
|
|
160
162
|
export type OAuthPermissionLevel = z.infer<typeof OAuthPermissionLevelSchema>;
|
|
@@ -268,6 +270,18 @@ export const OAuthFlowConfigSchema = z.object({
|
|
|
268
270
|
.string()
|
|
269
271
|
.optional()
|
|
270
272
|
.describe('UserInfo endpoint. Defaults to OAUTH_USERINFO_URL or {issuer}/userinfo'),
|
|
273
|
+
revokeUrl: z
|
|
274
|
+
.string()
|
|
275
|
+
.optional()
|
|
276
|
+
.describe(
|
|
277
|
+
'Token revocation endpoint (RFC 7009). Defaults to OAUTH_REVOKE_URL or {issuer}/revoke'
|
|
278
|
+
),
|
|
279
|
+
endSessionUrl: z
|
|
280
|
+
.string()
|
|
281
|
+
.optional()
|
|
282
|
+
.describe(
|
|
283
|
+
'OIDC end session endpoint. Defaults to OAUTH_END_SESSION_URL or {issuer}/end_session'
|
|
284
|
+
),
|
|
271
285
|
scopes: z
|
|
272
286
|
.string()
|
|
273
287
|
.optional()
|
|
@@ -305,3 +319,17 @@ export const OAuthUserInfoSchema = z
|
|
|
305
319
|
.catchall(z.unknown());
|
|
306
320
|
|
|
307
321
|
export type OAuthUserInfo = z.infer<typeof OAuthUserInfoSchema>;
|
|
322
|
+
|
|
323
|
+
export const StoredTokenSchema = z.object({
|
|
324
|
+
access_token: z.string(),
|
|
325
|
+
token_type: z.string().optional(),
|
|
326
|
+
refresh_token: z.string().optional(),
|
|
327
|
+
scope: z.string().optional(),
|
|
328
|
+
id_token: z.string().optional(),
|
|
329
|
+
expires_at: z
|
|
330
|
+
.number()
|
|
331
|
+
.optional()
|
|
332
|
+
.describe('Unix timestamp (seconds) when the access token expires'),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
export type StoredToken = z.infer<typeof StoredTokenSchema>;
|
|
@@ -16,9 +16,18 @@ export const ProjectSchema = z.object({
|
|
|
16
16
|
orgId: z.string().describe('the organization id'),
|
|
17
17
|
cloudRegion: z.string().nullable().optional().describe('the cloud region'),
|
|
18
18
|
vanityHostname: z.string().nullable().optional().describe('the vanity hostname'),
|
|
19
|
+
domains: z.array(z.string()).nullable().optional().describe('custom domains for the project'),
|
|
19
20
|
api_key: z.string().optional().describe('the SDK api key for the project'),
|
|
20
21
|
env: z.record(z.string(), z.string()).optional().describe('the environment key/values'),
|
|
21
22
|
secrets: z.record(z.string(), z.string()).optional().describe('the secrets key/values'),
|
|
23
|
+
urls: z
|
|
24
|
+
.object({
|
|
25
|
+
dashboard: z.string().describe('the dashboard URL for the project'),
|
|
26
|
+
app: z.string().describe('the public URL for the latest deployment'),
|
|
27
|
+
custom: z.array(z.string()).describe('custom domain URLs'),
|
|
28
|
+
})
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('project URLs'),
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
export const ProjectGetResponseSchema = APIResponseSchema(ProjectSchema);
|
|
@@ -102,6 +102,12 @@ export const SandboxCreateRequestSchema = z
|
|
|
102
102
|
.record(z.string(), z.unknown())
|
|
103
103
|
.optional()
|
|
104
104
|
.describe('Optional user-defined metadata to associate with the sandbox'),
|
|
105
|
+
scopes: z
|
|
106
|
+
.array(z.string())
|
|
107
|
+
.optional()
|
|
108
|
+
.describe(
|
|
109
|
+
'Permission scopes for automatic service access (e.g., "services:read", "services:write").'
|
|
110
|
+
),
|
|
105
111
|
})
|
|
106
112
|
.refine(
|
|
107
113
|
(data) => {
|
|
@@ -230,6 +236,9 @@ export async function sandboxCreate(
|
|
|
230
236
|
if (options.metadata) {
|
|
231
237
|
body.metadata = options.metadata;
|
|
232
238
|
}
|
|
239
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
240
|
+
body.scopes = options.scopes;
|
|
241
|
+
}
|
|
233
242
|
|
|
234
243
|
const queryParams = new URLSearchParams();
|
|
235
244
|
if (orgId) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ExecuteOptions, Execution, ExecutionStatus } from './types.ts';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { APIClient } from '../api.ts';
|
|
4
|
-
import { SandboxBusyError, throwSandboxError } from './util.ts';
|
|
4
|
+
import { SandboxBusyError, SandboxNotFoundError, throwSandboxError } from './util.ts';
|
|
5
5
|
|
|
6
6
|
export const ExecuteRequestSchema = z
|
|
7
7
|
.object({
|
|
@@ -114,23 +114,37 @@ export async function sandboxExecute(
|
|
|
114
114
|
signal ?? options.signal
|
|
115
115
|
);
|
|
116
116
|
} catch (error: unknown) {
|
|
117
|
-
// Detect 409 Conflict (sandbox busy) and throw a specific error.
|
|
118
|
-
// The sandbox API client is configured with maxRetries: 0 to fail fast
|
|
119
|
-
// when sandbox is busy (retrying wouldn't help - sandbox is still busy).
|
|
120
|
-
// Convert APIErrorResponse with status 409 to SandboxBusyError for clarity.
|
|
121
117
|
if (
|
|
122
118
|
error &&
|
|
123
119
|
typeof error === 'object' &&
|
|
124
120
|
'_tag' in error &&
|
|
125
121
|
(error as { _tag: string })._tag === 'APIErrorResponse' &&
|
|
126
|
-
'status' in error
|
|
127
|
-
(error as { status: number }).status === 409
|
|
122
|
+
'status' in error
|
|
128
123
|
) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
124
|
+
const status = (error as { status: number }).status;
|
|
125
|
+
|
|
126
|
+
// Detect 404 Not Found — sandbox may not exist or may still be initializing.
|
|
127
|
+
// The server normally handles the creating→idle wait transparently, but this
|
|
128
|
+
// provides a clear typed error if a 404 reaches the SDK for any reason.
|
|
129
|
+
if (status === 404) {
|
|
130
|
+
throw new SandboxNotFoundError({
|
|
131
|
+
message:
|
|
132
|
+
'Sandbox not found. If you just created this sandbox, it may still be initializing. The server should handle this automatically — if this persists, the sandbox may have been destroyed.',
|
|
133
|
+
sandboxId,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Detect 409 Conflict (sandbox busy) and throw a specific error.
|
|
138
|
+
// The sandbox API client is configured with maxRetries: 0 to fail fast
|
|
139
|
+
// when sandbox is busy (retrying wouldn't help - sandbox is still busy).
|
|
140
|
+
// Convert APIErrorResponse with status 409 to SandboxBusyError for clarity.
|
|
141
|
+
if (status === 409) {
|
|
142
|
+
throw new SandboxBusyError({
|
|
143
|
+
message:
|
|
144
|
+
'Sandbox is currently busy executing another command. Please wait for the current execution to complete before submitting a new one.',
|
|
145
|
+
sandboxId,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
134
148
|
}
|
|
135
149
|
throw error;
|
|
136
150
|
}
|
|
@@ -224,20 +224,103 @@ export async function sandboxRun(
|
|
|
224
224
|
logger?.debug('streams completed, fetching final status');
|
|
225
225
|
|
|
226
226
|
// Stream EOF means the sandbox is done — hadron only closes streams after the
|
|
227
|
-
// container exits.
|
|
228
|
-
//
|
|
227
|
+
// container exits. Poll for the exit code with retries because the lifecycle
|
|
228
|
+
// event (carrying the exit code) may still be in flight to Catalyst when the
|
|
229
|
+
// stream completes.
|
|
230
|
+
//
|
|
231
|
+
// Hadron drains container logs for up to 5s after exit, then closes the
|
|
232
|
+
// stream, then sends the lifecycle event in a goroutine. So the exit code
|
|
233
|
+
// typically arrives at Catalyst 5–7s after the container exits. We use a
|
|
234
|
+
// linear 1s polling interval (not exponential backoff) so we don't overshoot
|
|
235
|
+
// the window — 15 attempts × 1s = 15s total, which comfortably covers the
|
|
236
|
+
// drain + lifecycle propagation delay.
|
|
237
|
+
// Abort-aware sleep that rejects when the caller's signal fires.
|
|
238
|
+
const abortAwareSleep = (ms: number): Promise<void> =>
|
|
239
|
+
new Promise((resolve, reject) => {
|
|
240
|
+
if (signal?.aborted) {
|
|
241
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const timer = setTimeout(resolve, ms);
|
|
245
|
+
signal?.addEventListener(
|
|
246
|
+
'abort',
|
|
247
|
+
() => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
250
|
+
},
|
|
251
|
+
{ once: true }
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
229
255
|
let exitCode = 0;
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
256
|
+
const maxStatusRetries = 15;
|
|
257
|
+
const statusPollInterval = 1000;
|
|
258
|
+
const statusPollStart = Date.now();
|
|
259
|
+
for (let attempt = 0; attempt < maxStatusRetries; attempt++) {
|
|
260
|
+
if (signal?.aborted) {
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const sandboxStatus = await sandboxGetStatus(client, { sandboxId, orgId });
|
|
265
|
+
if (sandboxStatus.exitCode != null) {
|
|
266
|
+
exitCode = sandboxStatus.exitCode;
|
|
267
|
+
logger?.debug(
|
|
268
|
+
'[run] exit code %d found on attempt %d/%d (+%dms)',
|
|
269
|
+
exitCode,
|
|
270
|
+
attempt + 1,
|
|
271
|
+
maxStatusRetries,
|
|
272
|
+
Date.now() - statusPollStart
|
|
273
|
+
);
|
|
274
|
+
break;
|
|
275
|
+
} else if (sandboxStatus.status === 'failed') {
|
|
276
|
+
exitCode = 1;
|
|
277
|
+
logger?.debug(
|
|
278
|
+
'[run] sandbox failed on attempt %d/%d (+%dms)',
|
|
279
|
+
attempt + 1,
|
|
280
|
+
maxStatusRetries,
|
|
281
|
+
Date.now() - statusPollStart
|
|
282
|
+
);
|
|
283
|
+
break;
|
|
284
|
+
} else if (sandboxStatus.status === 'terminated') {
|
|
285
|
+
// Sandbox was destroyed. If exit code is missing, the
|
|
286
|
+
// terminated event may have overwritten it. Stop polling —
|
|
287
|
+
// no further updates will come.
|
|
288
|
+
logger?.debug(
|
|
289
|
+
'[run] sandbox terminated without exit code on attempt %d/%d (+%dms)',
|
|
290
|
+
attempt + 1,
|
|
291
|
+
maxStatusRetries,
|
|
292
|
+
Date.now() - statusPollStart
|
|
293
|
+
);
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
// Exit code not yet propagated — wait before next poll.
|
|
297
|
+
if (attempt < maxStatusRetries - 1) {
|
|
298
|
+
await abortAwareSleep(statusPollInterval);
|
|
299
|
+
}
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
// Transient failure (sandbox briefly unavailable, network error).
|
|
305
|
+
// Retry instead of giving up — the lifecycle event may still arrive.
|
|
306
|
+
logger?.debug(
|
|
307
|
+
'[run] sandboxGetStatus attempt %d/%d failed (+%dms): %s',
|
|
308
|
+
attempt + 1,
|
|
309
|
+
maxStatusRetries,
|
|
310
|
+
Date.now() - statusPollStart,
|
|
311
|
+
err
|
|
312
|
+
);
|
|
313
|
+
if (attempt < maxStatusRetries - 1) {
|
|
314
|
+
await abortAwareSleep(statusPollInterval);
|
|
315
|
+
}
|
|
236
316
|
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
317
|
+
}
|
|
318
|
+
if (exitCode === 0) {
|
|
319
|
+
logger?.debug(
|
|
320
|
+
'[run] exit code polling finished with default 0 after %d attempts (+%dms)',
|
|
321
|
+
maxStatusRetries,
|
|
322
|
+
Date.now() - statusPollStart
|
|
323
|
+
);
|
|
241
324
|
}
|
|
242
325
|
|
|
243
326
|
if (timingLogsEnabled)
|
|
@@ -387,44 +470,56 @@ async function streamUrlToWritable(
|
|
|
387
470
|
writable: Writable,
|
|
388
471
|
signal: AbortSignal,
|
|
389
472
|
logger?: Logger,
|
|
390
|
-
|
|
473
|
+
_started?: number
|
|
391
474
|
): Promise<void> {
|
|
475
|
+
const streamStart = Date.now();
|
|
392
476
|
try {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
477
|
+
// Signal to Pulse that this is a v2 stream so it waits for v2 metadata
|
|
478
|
+
// instead of falling back to the legacy download path on a short timeout.
|
|
479
|
+
const v2Url = new URL(url);
|
|
480
|
+
v2Url.searchParams.set('v', '2');
|
|
481
|
+
logger?.debug('[stream] fetching: %s', v2Url.href);
|
|
482
|
+
const response = await fetch(v2Url.href, { signal });
|
|
483
|
+
logger?.debug(
|
|
484
|
+
'[stream] response status=%d in %dms',
|
|
485
|
+
response.status,
|
|
486
|
+
Date.now() - streamStart
|
|
487
|
+
);
|
|
400
488
|
|
|
401
489
|
if (!response.ok || !response.body) {
|
|
402
|
-
logger?.debug('stream
|
|
490
|
+
logger?.debug('[stream] not ok or no body (status=%d) — returning empty', response.status);
|
|
403
491
|
return;
|
|
404
492
|
}
|
|
405
493
|
|
|
406
494
|
const reader = response.body.getReader();
|
|
407
|
-
let
|
|
495
|
+
let chunks = 0;
|
|
496
|
+
let totalBytes = 0;
|
|
408
497
|
|
|
409
498
|
// Read until EOF - Pulse will block until data is available
|
|
410
499
|
while (true) {
|
|
411
500
|
const { done, value } = await reader.read();
|
|
412
501
|
if (done) {
|
|
413
|
-
logger?.debug(
|
|
414
|
-
|
|
415
|
-
|
|
502
|
+
logger?.debug(
|
|
503
|
+
'[stream] EOF after %dms (%d chunks, %d bytes)',
|
|
504
|
+
Date.now() - streamStart,
|
|
505
|
+
chunks,
|
|
506
|
+
totalBytes
|
|
507
|
+
);
|
|
416
508
|
break;
|
|
417
509
|
}
|
|
418
510
|
|
|
419
511
|
if (value) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
)
|
|
425
|
-
|
|
512
|
+
chunks++;
|
|
513
|
+
totalBytes += value.length;
|
|
514
|
+
if (chunks <= 3 || chunks % 100 === 0) {
|
|
515
|
+
logger?.debug(
|
|
516
|
+
'[stream] chunk #%d: %d bytes (total: %d bytes, +%dms)',
|
|
517
|
+
chunks,
|
|
518
|
+
value.length,
|
|
519
|
+
totalBytes,
|
|
520
|
+
Date.now() - streamStart
|
|
521
|
+
);
|
|
426
522
|
}
|
|
427
|
-
logger?.debug('stream chunk: %d bytes', value.length);
|
|
428
523
|
await writeAndDrain(writable, value);
|
|
429
524
|
}
|
|
430
525
|
}
|
|
@@ -433,9 +528,9 @@ async function streamUrlToWritable(
|
|
|
433
528
|
writable.end();
|
|
434
529
|
} catch (err) {
|
|
435
530
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
436
|
-
logger?.debug('stream aborted');
|
|
531
|
+
logger?.debug('[stream] aborted after %dms', Date.now() - streamStart);
|
|
437
532
|
return;
|
|
438
533
|
}
|
|
439
|
-
logger?.debug('stream error: %s', err);
|
|
534
|
+
logger?.debug('[stream] error after %dms: %s', Date.now() - streamStart, err);
|
|
440
535
|
}
|
|
441
536
|
}
|
|
@@ -328,6 +328,13 @@ export const SandboxCreateOptionsSchema = z.object({
|
|
|
328
328
|
.record(z.string(), z.unknown())
|
|
329
329
|
.optional()
|
|
330
330
|
.describe('Optional user-defined metadata to associate with the sandbox.'),
|
|
331
|
+
/** Permission scopes for automatic service access (e.g., "services:read", "services:write"). */
|
|
332
|
+
scopes: z
|
|
333
|
+
.array(z.string())
|
|
334
|
+
.optional()
|
|
335
|
+
.describe(
|
|
336
|
+
'Permission scopes for automatic service access (e.g., "services:read", "services:write").'
|
|
337
|
+
),
|
|
331
338
|
});
|
|
332
339
|
export type SandboxCreateOptions = z.infer<typeof SandboxCreateOptionsSchema>;
|
|
333
340
|
|
|
@@ -398,6 +405,20 @@ export const SandboxSchema = z.object({
|
|
|
398
405
|
.describe('Resume the sandbox from a paused or evacuated state.'),
|
|
399
406
|
/** Destroy the sandbox */
|
|
400
407
|
destroy: z.custom<() => Promise<void>>().describe('Destroy the sandbox'),
|
|
408
|
+
/** Create a new job in the sandbox */
|
|
409
|
+
createJob: z
|
|
410
|
+
.custom<(options: CreateJobOptions) => Promise<Job>>()
|
|
411
|
+
.describe('Create a new job in the sandbox'),
|
|
412
|
+
/** Get a job by ID */
|
|
413
|
+
getJob: z.custom<(jobId: string) => Promise<Job>>().describe('Get a job by ID'),
|
|
414
|
+
/** List jobs in the sandbox */
|
|
415
|
+
listJobs: z
|
|
416
|
+
.custom<(limit?: number) => Promise<{ jobs: Job[] }>>()
|
|
417
|
+
.describe('List jobs in the sandbox'),
|
|
418
|
+
/** Stop a running job */
|
|
419
|
+
stopJob: z
|
|
420
|
+
.custom<(jobId: string, force?: boolean) => Promise<Job>>()
|
|
421
|
+
.describe('Stop a running job'),
|
|
401
422
|
});
|
|
402
423
|
export type Sandbox = z.infer<typeof SandboxSchema>;
|
|
403
424
|
|
|
@@ -58,6 +58,18 @@ export const ScheduleSchema = z.object({
|
|
|
58
58
|
* the schedule fires or the expression is changed.
|
|
59
59
|
*/
|
|
60
60
|
due_date: z.string().describe('ISO 8601 timestamp of the next scheduled execution.'),
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether this is a system-managed schedule.
|
|
64
|
+
*
|
|
65
|
+
* @remarks Internal schedules are created by the system and cannot be modified
|
|
66
|
+
* or deleted by users.
|
|
67
|
+
*/
|
|
68
|
+
internal: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.describe(
|
|
71
|
+
'Whether this is a system-managed schedule. Internal schedules cannot be modified or deleted users.'
|
|
72
|
+
),
|
|
61
73
|
});
|
|
62
74
|
|
|
63
75
|
export type Schedule = z.infer<typeof ScheduleSchema>;
|
|
@@ -204,6 +216,14 @@ export const CreateScheduleParamsSchema = z.object({
|
|
|
204
216
|
*/
|
|
205
217
|
expression: z.string().describe('Cron expression defining when the schedule fires'),
|
|
206
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Whether this is a system-managed schedule.
|
|
221
|
+
*
|
|
222
|
+
* @remarks Internal schedules are created by the system for workflows and cannot
|
|
223
|
+
* be modified or deleted directly by users.
|
|
224
|
+
*/
|
|
225
|
+
internal: z.boolean().optional().describe('Whether this is a system-managed schedule.'),
|
|
226
|
+
|
|
207
227
|
/**
|
|
208
228
|
* Optional array of destinations to create alongside the schedule.
|
|
209
229
|
*
|