@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.
Files changed (52) hide show
  1. package/dist/services/oauth/flow.d.ts +31 -0
  2. package/dist/services/oauth/flow.d.ts.map +1 -1
  3. package/dist/services/oauth/flow.js +138 -13
  4. package/dist/services/oauth/flow.js.map +1 -1
  5. package/dist/services/oauth/index.d.ts +1 -0
  6. package/dist/services/oauth/index.d.ts.map +1 -1
  7. package/dist/services/oauth/index.js +1 -0
  8. package/dist/services/oauth/index.js.map +1 -1
  9. package/dist/services/oauth/token-storage.d.ts +109 -0
  10. package/dist/services/oauth/token-storage.d.ts.map +1 -0
  11. package/dist/services/oauth/token-storage.js +140 -0
  12. package/dist/services/oauth/token-storage.js.map +1 -0
  13. package/dist/services/oauth/types.d.ts +21 -0
  14. package/dist/services/oauth/types.d.ts.map +1 -1
  15. package/dist/services/oauth/types.js +21 -0
  16. package/dist/services/oauth/types.js.map +1 -1
  17. package/dist/services/project/get.d.ts +12 -0
  18. package/dist/services/project/get.d.ts.map +1 -1
  19. package/dist/services/project/get.js +9 -0
  20. package/dist/services/project/get.js.map +1 -1
  21. package/dist/services/sandbox/create.d.ts +3 -0
  22. package/dist/services/sandbox/create.d.ts.map +1 -1
  23. package/dist/services/sandbox/create.js +7 -0
  24. package/dist/services/sandbox/create.js.map +1 -1
  25. package/dist/services/sandbox/execute.d.ts.map +1 -1
  26. package/dist/services/sandbox/execute.js +22 -11
  27. package/dist/services/sandbox/execute.js.map +1 -1
  28. package/dist/services/sandbox/run.d.ts +1 -0
  29. package/dist/services/sandbox/run.d.ts.map +1 -1
  30. package/dist/services/sandbox/run.js +83 -30
  31. package/dist/services/sandbox/run.js.map +1 -1
  32. package/dist/services/sandbox/types.d.ts +10 -0
  33. package/dist/services/sandbox/types.d.ts.map +1 -1
  34. package/dist/services/sandbox/types.js +19 -0
  35. package/dist/services/sandbox/types.js.map +1 -1
  36. package/dist/services/schedule/service.d.ts +5 -0
  37. package/dist/services/schedule/service.d.ts.map +1 -1
  38. package/dist/services/schedule/service.js +16 -0
  39. package/dist/services/schedule/service.js.map +1 -1
  40. package/dist/services/schedule/types.d.ts +1 -0
  41. package/dist/services/schedule/types.d.ts.map +1 -1
  42. package/package.json +2 -2
  43. package/src/services/oauth/flow.ts +156 -15
  44. package/src/services/oauth/index.ts +1 -0
  45. package/src/services/oauth/token-storage.ts +220 -0
  46. package/src/services/oauth/types.ts +28 -0
  47. package/src/services/project/get.ts +9 -0
  48. package/src/services/sandbox/create.ts +9 -0
  49. package/src/services/sandbox/execute.ts +26 -12
  50. package/src/services/sandbox/run.ts +129 -34
  51. package/src/services/sandbox/types.ts +21 -0
  52. 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
- throw new SandboxBusyError({
130
- message:
131
- 'Sandbox is currently busy executing another command. Please wait for the current execution to complete before submitting a new one.',
132
- sandboxId,
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. Fetch status once for the exit code; if lifecycle events
228
- // haven't propagated to Catalyst yet, default to exit code 0.
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
- try {
231
- const sandboxStatus = await sandboxGetStatus(client, { sandboxId, orgId });
232
- if (sandboxStatus.exitCode != null) {
233
- exitCode = sandboxStatus.exitCode;
234
- } else if (sandboxStatus.status === 'failed') {
235
- exitCode = 1;
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
- } catch {
238
- // Sandbox may already be destroyed (fire-and-forget teardown).
239
- // Stream EOF already confirmed execution completed.
240
- logger?.debug('sandboxGetStatus failed after stream EOF, using default exit code 0');
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
- started?: number
473
+ _started?: number
391
474
  ): Promise<void> {
475
+ const streamStart = Date.now();
392
476
  try {
393
- logger?.debug('fetching stream: %s', url);
394
- const response = await fetch(url, { signal });
395
- logger?.debug('stream response status: %d', response.status);
396
- if (timingLogsEnabled && started)
397
- console.error(
398
- `[TIMING] +${Date.now() - started}ms: stream response received (status: ${response.status})`
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 response not ok or no body');
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 firstChunk = true;
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('stream EOF');
414
- if (timingLogsEnabled && started)
415
- console.error(`[TIMING] +${Date.now() - started}ms: stream EOF`);
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
- if (firstChunk && started) {
421
- if (timingLogsEnabled)
422
- console.error(
423
- `[TIMING] +${Date.now() - started}ms: first chunk (${value.length} bytes)`
424
- );
425
- firstChunk = false;
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
  *