@declaw/sdk 1.3.0 → 1.5.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/index.js CHANGED
@@ -33,13 +33,51 @@ var ConnectionConfig = class {
33
33
  }
34
34
  };
35
35
 
36
+ // src/api/idempotency.ts
37
+ var CODE_IDEMPOTENCY_IN_PROGRESS = "idempotency_in_progress";
38
+ var CODE_IDEMPOTENCY_KEY_REUSED = "idempotency_key_reused";
39
+ var CODE_TEMPLATE_NOT_READY = "template_not_ready";
40
+ var MAX_RETRY_AFTER_MS = 6e4;
41
+ function newIdempotencyKey() {
42
+ const c = globalThis.crypto;
43
+ if (c?.randomUUID) {
44
+ return c.randomUUID();
45
+ }
46
+ if (c?.getRandomValues) {
47
+ const b = c.getRandomValues(new Uint8Array(16));
48
+ b[6] = b[6] & 15 | 64;
49
+ b[8] = b[8] & 63 | 128;
50
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
51
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
52
+ }
53
+ return "";
54
+ }
55
+ function retryAfterMs(response) {
56
+ const raw = response.headers.get("Retry-After");
57
+ if (raw === null) return void 0;
58
+ const secs = Number(raw);
59
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
60
+ return Math.min(secs * 1e3, MAX_RETRY_AFTER_MS);
61
+ }
62
+
36
63
  // src/errors.ts
37
64
  var SandboxError = class extends Error {
38
65
  sandboxId;
66
+ /**
67
+ * Machine-readable error code from the API's `code` field, when present.
68
+ *
69
+ * Branch on this, never on `message`. Messages are prose and change; codes are
70
+ * contract. It matters most where one status means several unrelated things:
71
+ * a 409 from `POST /sandboxes` is either `idempotency_in_progress` (the
72
+ * original create is still running — retry the identical request) or
73
+ * `template_not_ready` (rebuild the template; retrying cannot help).
74
+ */
75
+ code;
39
76
  constructor(message, opts) {
40
77
  super(message);
41
78
  this.name = "SandboxError";
42
79
  this.sandboxId = opts?.sandboxId;
80
+ this.code = opts?.code;
43
81
  }
44
82
  };
45
83
  var TimeoutError = class extends SandboxError {
@@ -85,9 +123,15 @@ var TemplateError = class extends SandboxError {
85
123
  }
86
124
  };
87
125
  var BuildError = class extends TemplateError {
126
+ /** The failed build's ID, when the error comes from a build that ran (`Template.build`). */
127
+ buildId;
128
+ /** The failed build's full output, when the error comes from a build that ran. */
129
+ logs;
88
130
  constructor(message, opts) {
89
131
  super(message, opts);
90
132
  this.name = "BuildError";
133
+ this.buildId = opts?.buildId;
134
+ this.logs = opts?.logs ?? [];
91
135
  }
92
136
  };
93
137
  var FileUploadError = class extends SandboxError {
@@ -297,6 +341,15 @@ var ApiClient = class {
297
341
  await this.delay(attempt);
298
342
  continue;
299
343
  }
344
+ if (response.status === 409 && attempt < this.maxRetries - 1) {
345
+ const parsed = await this.readErrorBody(response);
346
+ if (parsed.code === CODE_IDEMPOTENCY_IN_PROGRESS) {
347
+ const after = retryAfterMs(response);
348
+ await (after !== void 0 ? new Promise((r) => setTimeout(r, after)) : this.delay(attempt));
349
+ continue;
350
+ }
351
+ throw this.errorFrom(response, parsed);
352
+ }
300
353
  if (!response.ok) {
301
354
  throw await this.buildError(response);
302
355
  }
@@ -322,20 +375,36 @@ var ApiClient = class {
322
375
  `Request failed after ${this.maxRetries} retries: ${lastError?.message ?? "unknown error"}`
323
376
  );
324
377
  }
325
- async buildError(response) {
326
- let message;
378
+ /**
379
+ * Read an error body ONCE.
380
+ *
381
+ * `Response` bodies are single-read streams, so the 409 path cannot inspect
382
+ * the code and then hand the response to a separate error builder — the
383
+ * second read yields nothing and the error loses its message. Everything that
384
+ * needs the body goes through here, and the parsed result is passed around
385
+ * instead of the response.
386
+ */
387
+ async readErrorBody(response) {
327
388
  try {
328
- const body = await response.json();
389
+ const body = JSON.parse(await response.text());
329
390
  const bodyMsg = body.message ?? body.error ?? response.statusText;
330
- message = `HTTP ${response.status}: ${bodyMsg}`;
391
+ return {
392
+ message: `HTTP ${response.status}: ${bodyMsg}`,
393
+ code: typeof body.code === "string" ? body.code : void 0
394
+ };
331
395
  } catch {
332
- message = `HTTP ${response.status}: ${response.statusText}`;
396
+ return { message: `HTTP ${response.status}: ${response.statusText}` };
333
397
  }
398
+ }
399
+ errorFrom(response, parsed) {
334
400
  const ErrorClass = STATUS_ERROR_MAP[response.status];
335
401
  if (ErrorClass) {
336
- return new ErrorClass(message);
402
+ return new ErrorClass(parsed.message, { code: parsed.code });
337
403
  }
338
- return new SandboxError(message);
404
+ return new SandboxError(parsed.message, { code: parsed.code });
405
+ }
406
+ async buildError(response) {
407
+ return this.errorFrom(response, await this.readErrorBody(response));
339
408
  }
340
409
  async parseResponseBody(response) {
341
410
  const contentLength = response.headers.get("content-length");
@@ -2544,9 +2613,11 @@ var Sandbox = class _Sandbox {
2544
2613
  if (opts?.volumes && opts.volumes.length > 0) {
2545
2614
  body.volumes = opts.volumes.map(volumeAttachmentToJSON);
2546
2615
  }
2616
+ const idempotencyKey = newIdempotencyKey();
2547
2617
  const data = await client.post("/sandboxes", {
2548
2618
  json: body,
2549
- timeout: opts?.requestTimeout
2619
+ timeout: opts?.requestTimeout,
2620
+ ...idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : {}
2550
2621
  });
2551
2622
  const sandboxId = data.sandbox_id;
2552
2623
  assertValidId(sandboxId, "sandbox ID (from server)");
@@ -3043,7 +3114,13 @@ var TemplateBase = class {
3043
3114
  this._runCmds.push(cmds);
3044
3115
  return this;
3045
3116
  }
3046
- /** Add a file copy operation. */
3117
+ /**
3118
+ * Copy a local file into the template.
3119
+ *
3120
+ * Not supported yet: a template build cannot upload local files, so
3121
+ * building a template that uses `copy()` throws `InvalidArgumentError`.
3122
+ * Fetch the file in a `runCmd` step, or use `fromDockerfile`.
3123
+ */
3047
3124
  copy(src, dst, mode) {
3048
3125
  this._copies.push({ src, dst, mode });
3049
3126
  return this;
@@ -3063,6 +3140,14 @@ var TemplateBase = class {
3063
3140
  this._startCmd = cmd;
3064
3141
  return this;
3065
3142
  }
3143
+ /**
3144
+ * Whether `copy()` was used. Builds reject such a template until builds can
3145
+ * upload local files.
3146
+ * @internal
3147
+ */
3148
+ hasCopies() {
3149
+ return this._copies.length > 0;
3150
+ }
3066
3151
  /** Serialize the template to a JSON-friendly object. */
3067
3152
  toJSON() {
3068
3153
  if (this._dockerfile !== void 0) {
@@ -3074,14 +3159,11 @@ var TemplateBase = class {
3074
3159
  if (this._runCmds.length > 0) {
3075
3160
  result.run_cmds = this._runCmds.map((c) => c.join(" "));
3076
3161
  }
3077
- if (this._copies.length > 0) {
3078
- result.copies = this._copies;
3079
- }
3080
3162
  if (Object.keys(this._envs).length > 0) {
3081
3163
  result.envs = this._envs;
3082
3164
  }
3083
3165
  if (this._aptPackages.length > 0) {
3084
- result.apt_packages = this._aptPackages;
3166
+ result.packages = this._aptPackages;
3085
3167
  }
3086
3168
  if (this._startCmd !== void 0) {
3087
3169
  result.start_cmd = this._startCmd;
@@ -3093,19 +3175,70 @@ function parseBuildInfo(data) {
3093
3175
  return {
3094
3176
  buildId: data.build_id ?? data.buildId ?? "",
3095
3177
  status: data.status ?? "",
3096
- templateId: data.template_id ?? data.templateId ?? void 0
3178
+ templateId: data.template_id ?? data.templateId ?? void 0,
3179
+ logs: data.logs ?? []
3097
3180
  };
3098
3181
  }
3099
3182
  function parseTemplateBuildStatus(data) {
3100
3183
  return {
3101
3184
  buildId: data.build_id ?? data.buildId ?? "",
3102
3185
  status: data.status ?? "",
3103
- logs: data.logs ?? []
3186
+ // A build with no output yet serializes its logs as null.
3187
+ logs: data.logs ?? [],
3188
+ templateId: data.template_id ?? data.templateId ?? void 0
3104
3189
  };
3105
3190
  }
3106
3191
 
3107
3192
  // src/template/template.ts
3108
3193
  var VALID_BUILD_ID_RE = /^[a-zA-Z0-9_-]+$/;
3194
+ var BUILD_STATUS_COMPLETED = "completed";
3195
+ var BUILD_STATUS_FAILED = "failed";
3196
+ var DEFAULT_BUILD_TIMEOUT_MS = 60 * 60 * 1e3;
3197
+ var FAILURE_LOG_LINES = 20;
3198
+ var buildPolling = {
3199
+ /** How often a waiting build is re-read. */
3200
+ intervalMs: 3e3,
3201
+ /**
3202
+ * How long status checks may keep failing temporarily (5xx, 408, 429, or no
3203
+ * response) before the wait gives up. It rides out a sandbox-manager
3204
+ * restart; the build itself keeps running.
3205
+ */
3206
+ errorWindowMs: 2 * 60 * 1e3
3207
+ };
3208
+ var BUILD_LOG_TRUNCATION_NOTICE = "... [earlier build output truncated]";
3209
+ var LOG_ANCHOR_LINES = 64;
3210
+ var REJECTIONS = [
3211
+ AuthenticationError,
3212
+ ConflictError,
3213
+ InvalidArgumentError,
3214
+ NotEnoughSpaceError,
3215
+ NotFoundError
3216
+ ];
3217
+ var LogCursor = class {
3218
+ seen = 0;
3219
+ tail = [];
3220
+ newLines(logs) {
3221
+ const fresh = logs.length === 0 || logs[0] !== BUILD_LOG_TRUNCATION_NOTICE ? logs.slice(this.seen) : this.afterTail(logs);
3222
+ this.seen = logs.length;
3223
+ this.tail = [...this.tail, ...fresh].slice(-LOG_ANCHOR_LINES);
3224
+ return fresh;
3225
+ }
3226
+ afterTail(logs) {
3227
+ const window = logs.slice(1);
3228
+ const n = this.tail.length;
3229
+ if (n > 0) {
3230
+ for (let end = window.length; end >= n; end--) {
3231
+ if (window[end - 1] === this.tail[n - 1] && this.tail.every((line, i) => window[end - n + i] === line)) {
3232
+ return window.slice(end);
3233
+ }
3234
+ }
3235
+ }
3236
+ return logs;
3237
+ }
3238
+ };
3239
+ function isTemporaryPollError(err) {
3240
+ return err instanceof SandboxError && !REJECTIONS.some((Rejection) => err instanceof Rejection);
3241
+ }
3109
3242
  function assertValidBuildId(buildId) {
3110
3243
  if (!buildId || !VALID_BUILD_ID_RE.test(buildId)) {
3111
3244
  throw new InvalidArgumentError(
@@ -3113,72 +3246,131 @@ function assertValidBuildId(buildId) {
3113
3246
  );
3114
3247
  }
3115
3248
  }
3249
+ function buildRequestBody(template, alias, opts) {
3250
+ if (template.hasCopies()) {
3251
+ throw new InvalidArgumentError(
3252
+ "TemplateBase.copy() is not supported yet: a template build cannot upload local files. Fetch them in a runCmd step, or use fromDockerfile()."
3253
+ );
3254
+ }
3255
+ const body = {
3256
+ template: template.toJSON(),
3257
+ alias
3258
+ };
3259
+ if (opts?.cpuCount !== void 0) {
3260
+ body.cpu_count = opts.cpuCount;
3261
+ }
3262
+ if (opts?.memoryMb !== void 0) {
3263
+ body.memory_mb = opts.memoryMb;
3264
+ }
3265
+ if (opts?.diskMb !== void 0) {
3266
+ body.disk_mb = opts.diskMb;
3267
+ }
3268
+ return body;
3269
+ }
3270
+ function buildFailedError(status) {
3271
+ let message = `template build ${status.buildId} failed`;
3272
+ const tail = status.logs.slice(-FAILURE_LOG_LINES);
3273
+ if (tail.length > 0) {
3274
+ message += ":\n" + tail.join("\n");
3275
+ }
3276
+ return new BuildError(message, { buildId: status.buildId, logs: status.logs });
3277
+ }
3116
3278
  var Template = class {
3117
3279
  /**
3118
- * Build a template and wait for completion.
3280
+ * Build a template and wait for the build to finish.
3119
3281
  *
3120
- * Sends POST /templates/build with the template definition.
3121
- * Invokes onBuildLogs for each log entry in the response.
3282
+ * Builds usually take several minutes. While waiting, each new line of build
3283
+ * output is passed to `onBuildLogs`. Sandboxes are created from the finished
3284
+ * template by its alias: `Sandbox.create({ template: alias })`.
3285
+ *
3286
+ * @throws {BuildError} The build failed; the error carries its logs.
3287
+ * @throws {TimeoutError} The build was still running after `buildTimeout`.
3288
+ * It keeps running; follow it with `getBuildStatus()`.
3289
+ * @throws {InvalidArgumentError} The template uses `copy()`, which is not
3290
+ * supported yet.
3122
3291
  */
3123
3292
  static async build(template, alias, opts) {
3293
+ const body = buildRequestBody(template, alias, opts);
3124
3294
  const config = new ConnectionConfig({
3125
3295
  apiKey: opts?.apiKey,
3126
3296
  domain: opts?.domain,
3127
3297
  requestTimeout: opts?.requestTimeout
3128
3298
  });
3129
3299
  const client = getSharedClient(config);
3130
- const body = {
3131
- template: template.toJSON(),
3132
- alias
3133
- };
3134
- if (opts?.cpuCount !== void 0) {
3135
- body.cpu_count = opts.cpuCount;
3136
- }
3137
- if (opts?.memoryMb !== void 0) {
3138
- body.memory_mb = opts.memoryMb;
3139
- }
3140
- if (opts?.diskMb !== void 0) {
3141
- body.disk_mb = opts.diskMb;
3142
- }
3143
3300
  const data = await client.post("/templates/build", {
3144
3301
  json: body,
3145
3302
  timeout: opts?.requestTimeout
3146
3303
  });
3147
- const response = data;
3148
- const result = parseBuildInfo(response);
3149
- if (opts?.onBuildLogs && Array.isArray(response.logs)) {
3150
- for (const log of response.logs) {
3151
- opts.onBuildLogs(log);
3304
+ const info = parseBuildInfo(data);
3305
+ assertValidBuildId(info.buildId);
3306
+ let status = {
3307
+ buildId: info.buildId,
3308
+ status: info.status,
3309
+ logs: info.logs,
3310
+ templateId: info.templateId
3311
+ };
3312
+ const buildTimeout = opts?.buildTimeout ?? DEFAULT_BUILD_TIMEOUT_MS;
3313
+ const deadline = Date.now() + buildTimeout;
3314
+ const timedOut = (cause) => new TimeoutError(
3315
+ `template build ${info.buildId} was still running after ${buildTimeout}ms. It keeps running; follow it with Template.getBuildStatus('${info.buildId}').` + (cause instanceof Error ? ` Last status check: ${cause.message}` : "")
3316
+ );
3317
+ const cursor = new LogCursor();
3318
+ for (; ; ) {
3319
+ if (opts?.onBuildLogs) {
3320
+ for (const line of cursor.newLines(status.logs)) {
3321
+ opts.onBuildLogs(line);
3322
+ }
3323
+ }
3324
+ if (status.status === BUILD_STATUS_COMPLETED) {
3325
+ return {
3326
+ buildId: status.buildId,
3327
+ status: status.status,
3328
+ templateId: status.templateId ?? info.templateId,
3329
+ logs: status.logs
3330
+ };
3331
+ }
3332
+ if (status.status === BUILD_STATUS_FAILED) {
3333
+ throw buildFailedError(status);
3334
+ }
3335
+ if (Date.now() >= deadline) {
3336
+ throw timedOut();
3337
+ }
3338
+ let failingSince;
3339
+ for (; ; ) {
3340
+ await new Promise((resolve) => setTimeout(resolve, buildPolling.intervalMs));
3341
+ try {
3342
+ const next = await client.get(`/templates/builds/${info.buildId}`, {
3343
+ timeout: opts?.requestTimeout
3344
+ });
3345
+ status = parseTemplateBuildStatus(next);
3346
+ break;
3347
+ } catch (err) {
3348
+ if (!isTemporaryPollError(err)) {
3349
+ throw err;
3350
+ }
3351
+ failingSince ??= Date.now();
3352
+ if (Date.now() - failingSince >= buildPolling.errorWindowMs) {
3353
+ throw err;
3354
+ }
3355
+ if (Date.now() >= deadline) {
3356
+ throw timedOut(err);
3357
+ }
3358
+ }
3152
3359
  }
3153
3360
  }
3154
- return result;
3155
3361
  }
3156
3362
  /**
3157
- * Start a template build in the background.
3158
- *
3159
- * Sends POST /templates/build with `background: true`.
3363
+ * Start a template build and return as soon as the server has accepted it,
3364
+ * with status `building`. Follow the build with `getBuildStatus()`.
3160
3365
  */
3161
3366
  static async buildInBackground(template, alias, opts) {
3367
+ const body = buildRequestBody(template, alias, opts);
3162
3368
  const config = new ConnectionConfig({
3163
3369
  apiKey: opts?.apiKey,
3164
3370
  domain: opts?.domain,
3165
3371
  requestTimeout: opts?.requestTimeout
3166
3372
  });
3167
3373
  const client = getSharedClient(config);
3168
- const body = {
3169
- template: template.toJSON(),
3170
- alias,
3171
- background: true
3172
- };
3173
- if (opts?.cpuCount !== void 0) {
3174
- body.cpu_count = opts.cpuCount;
3175
- }
3176
- if (opts?.memoryMb !== void 0) {
3177
- body.memory_mb = opts.memoryMb;
3178
- }
3179
- if (opts?.diskMb !== void 0) {
3180
- body.disk_mb = opts.diskMb;
3181
- }
3182
3374
  const data = await client.post("/templates/build", {
3183
3375
  json: body,
3184
3376
  timeout: opts?.requestTimeout
@@ -3186,7 +3378,7 @@ var Template = class {
3186
3378
  return parseBuildInfo(data);
3187
3379
  }
3188
3380
  /**
3189
- * Get the status of a template build.
3381
+ * Get the status of a template build, including its logs so far.
3190
3382
  *
3191
3383
  * Sends GET /templates/builds/:buildId.
3192
3384
  */
@@ -3727,6 +3919,9 @@ export {
3727
3919
  ApiClient,
3728
3920
  AuthenticationError,
3729
3921
  BuildError,
3922
+ CODE_IDEMPOTENCY_IN_PROGRESS,
3923
+ CODE_IDEMPOTENCY_KEY_REUSED,
3924
+ CODE_TEMPLATE_NOT_READY,
3730
3925
  CommandExitError,
3731
3926
  CommandHandle,
3732
3927
  Commands,