@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/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ All notable changes to the Declaw TypeScript / JavaScript SDK are documented in
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0]
9
+
10
+ _2026-09 train: working template builds._
11
+
12
+ ### Added
13
+
14
+ - The `buildTimeout` option on `Template.build()`, `BuildInfo.logs`,
15
+ `TemplateBuildStatus.templateId`, `buildId` / `logs` on `BuildError`, and
16
+ `TemplateBase.hasCopies()`.
17
+ - Status checks that fail temporarily while waiting (5xx, 408, 429, network
18
+ errors) are retried for up to two minutes instead of ending the wait. The
19
+ server keeps the newest 2,000 lines of a build's output; if more arrive
20
+ between two status checks, a `... [earlier build output truncated]` line
21
+ marks the gap.
22
+
23
+ ### Changed
24
+
25
+ - `Template.build()` now waits for the build to finish, as documented, and
26
+ resolves to a `BuildInfo` whose `logs` hold the output. A failed build
27
+ rejects with `BuildError` carrying its `buildId` and `logs`. A build still
28
+ running after `buildTimeout` milliseconds (default one hour) rejects with
29
+ `TimeoutError` naming it; the build keeps running, and
30
+ `Template.getBuildStatus()` follows it. To return immediately, use
31
+ `Template.buildInBackground()`.
32
+ - A template that uses `copy()` now rejects with `InvalidArgumentError` when
33
+ built, before anything is sent. `copy()` never copied anything: a build
34
+ cannot upload local files, and the copies were silently dropped. Fetch files
35
+ in a `runCmd` step, or use `fromDockerfile()`.
36
+
37
+ ### Fixed
38
+
39
+ - Packages added with `aptInstall()` were sent under a name the API ignores,
40
+ so builds succeeded without installing them. They are now installed.
41
+ - `onBuildLogs` never fired. It now receives each new line of build output
42
+ while `Template.build()` waits.
43
+
44
+ ## [1.4.0]
45
+
46
+ _2026-08 train: idempotent sandbox creation._
47
+
48
+ ### Added
49
+
50
+ - `Sandbox.create` now sends an `Idempotency-Key`. A create that times out or is
51
+ retried no longer risks leaving a second running, billable sandbox the caller
52
+ has no handle for. The key is generated once per logical create and reused
53
+ across that call's retries.
54
+ - A `409` carrying `idempotency_in_progress` is retried automatically, honoring
55
+ `Retry-After`.
56
+ - `SandboxError.code` exposes the API's machine-readable error code, with the
57
+ `CODE_IDEMPOTENCY_IN_PROGRESS`, `CODE_IDEMPOTENCY_KEY_REUSED` and
58
+ `CODE_TEMPLATE_NOT_READY` constants exported. Branch on the code, never the
59
+ message.
60
+
61
+ _(Retry backoff already carried jitter in this SDK; unlike the Go and Python
62
+ clients it needed no change.)_
63
+
8
64
  ## [1.3.0]
9
65
 
10
66
  _2026-07 train: credential vault client + injection domain scoping._
package/dist/index.cjs CHANGED
@@ -34,6 +34,9 @@ __export(index_exports, {
34
34
  ApiClient: () => ApiClient,
35
35
  AuthenticationError: () => AuthenticationError,
36
36
  BuildError: () => BuildError,
37
+ CODE_IDEMPOTENCY_IN_PROGRESS: () => CODE_IDEMPOTENCY_IN_PROGRESS,
38
+ CODE_IDEMPOTENCY_KEY_REUSED: () => CODE_IDEMPOTENCY_KEY_REUSED,
39
+ CODE_TEMPLATE_NOT_READY: () => CODE_TEMPLATE_NOT_READY,
37
40
  CommandExitError: () => CommandExitError,
38
41
  CommandHandle: () => CommandHandle,
39
42
  Commands: () => Commands,
@@ -170,13 +173,51 @@ var ConnectionConfig = class {
170
173
  }
171
174
  };
172
175
 
176
+ // src/api/idempotency.ts
177
+ var CODE_IDEMPOTENCY_IN_PROGRESS = "idempotency_in_progress";
178
+ var CODE_IDEMPOTENCY_KEY_REUSED = "idempotency_key_reused";
179
+ var CODE_TEMPLATE_NOT_READY = "template_not_ready";
180
+ var MAX_RETRY_AFTER_MS = 6e4;
181
+ function newIdempotencyKey() {
182
+ const c = globalThis.crypto;
183
+ if (c?.randomUUID) {
184
+ return c.randomUUID();
185
+ }
186
+ if (c?.getRandomValues) {
187
+ const b = c.getRandomValues(new Uint8Array(16));
188
+ b[6] = b[6] & 15 | 64;
189
+ b[8] = b[8] & 63 | 128;
190
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
191
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
192
+ }
193
+ return "";
194
+ }
195
+ function retryAfterMs(response) {
196
+ const raw = response.headers.get("Retry-After");
197
+ if (raw === null) return void 0;
198
+ const secs = Number(raw);
199
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
200
+ return Math.min(secs * 1e3, MAX_RETRY_AFTER_MS);
201
+ }
202
+
173
203
  // src/errors.ts
174
204
  var SandboxError = class extends Error {
175
205
  sandboxId;
206
+ /**
207
+ * Machine-readable error code from the API's `code` field, when present.
208
+ *
209
+ * Branch on this, never on `message`. Messages are prose and change; codes are
210
+ * contract. It matters most where one status means several unrelated things:
211
+ * a 409 from `POST /sandboxes` is either `idempotency_in_progress` (the
212
+ * original create is still running — retry the identical request) or
213
+ * `template_not_ready` (rebuild the template; retrying cannot help).
214
+ */
215
+ code;
176
216
  constructor(message, opts) {
177
217
  super(message);
178
218
  this.name = "SandboxError";
179
219
  this.sandboxId = opts?.sandboxId;
220
+ this.code = opts?.code;
180
221
  }
181
222
  };
182
223
  var TimeoutError = class extends SandboxError {
@@ -222,9 +263,15 @@ var TemplateError = class extends SandboxError {
222
263
  }
223
264
  };
224
265
  var BuildError = class extends TemplateError {
266
+ /** The failed build's ID, when the error comes from a build that ran (`Template.build`). */
267
+ buildId;
268
+ /** The failed build's full output, when the error comes from a build that ran. */
269
+ logs;
225
270
  constructor(message, opts) {
226
271
  super(message, opts);
227
272
  this.name = "BuildError";
273
+ this.buildId = opts?.buildId;
274
+ this.logs = opts?.logs ?? [];
228
275
  }
229
276
  };
230
277
  var FileUploadError = class extends SandboxError {
@@ -434,6 +481,15 @@ var ApiClient = class {
434
481
  await this.delay(attempt);
435
482
  continue;
436
483
  }
484
+ if (response.status === 409 && attempt < this.maxRetries - 1) {
485
+ const parsed = await this.readErrorBody(response);
486
+ if (parsed.code === CODE_IDEMPOTENCY_IN_PROGRESS) {
487
+ const after = retryAfterMs(response);
488
+ await (after !== void 0 ? new Promise((r) => setTimeout(r, after)) : this.delay(attempt));
489
+ continue;
490
+ }
491
+ throw this.errorFrom(response, parsed);
492
+ }
437
493
  if (!response.ok) {
438
494
  throw await this.buildError(response);
439
495
  }
@@ -459,20 +515,36 @@ var ApiClient = class {
459
515
  `Request failed after ${this.maxRetries} retries: ${lastError?.message ?? "unknown error"}`
460
516
  );
461
517
  }
462
- async buildError(response) {
463
- let message;
518
+ /**
519
+ * Read an error body ONCE.
520
+ *
521
+ * `Response` bodies are single-read streams, so the 409 path cannot inspect
522
+ * the code and then hand the response to a separate error builder — the
523
+ * second read yields nothing and the error loses its message. Everything that
524
+ * needs the body goes through here, and the parsed result is passed around
525
+ * instead of the response.
526
+ */
527
+ async readErrorBody(response) {
464
528
  try {
465
- const body = await response.json();
529
+ const body = JSON.parse(await response.text());
466
530
  const bodyMsg = body.message ?? body.error ?? response.statusText;
467
- message = `HTTP ${response.status}: ${bodyMsg}`;
531
+ return {
532
+ message: `HTTP ${response.status}: ${bodyMsg}`,
533
+ code: typeof body.code === "string" ? body.code : void 0
534
+ };
468
535
  } catch {
469
- message = `HTTP ${response.status}: ${response.statusText}`;
536
+ return { message: `HTTP ${response.status}: ${response.statusText}` };
470
537
  }
538
+ }
539
+ errorFrom(response, parsed) {
471
540
  const ErrorClass = STATUS_ERROR_MAP[response.status];
472
541
  if (ErrorClass) {
473
- return new ErrorClass(message);
542
+ return new ErrorClass(parsed.message, { code: parsed.code });
474
543
  }
475
- return new SandboxError(message);
544
+ return new SandboxError(parsed.message, { code: parsed.code });
545
+ }
546
+ async buildError(response) {
547
+ return this.errorFrom(response, await this.readErrorBody(response));
476
548
  }
477
549
  async parseResponseBody(response) {
478
550
  const contentLength = response.headers.get("content-length");
@@ -2681,9 +2753,11 @@ var Sandbox = class _Sandbox {
2681
2753
  if (opts?.volumes && opts.volumes.length > 0) {
2682
2754
  body.volumes = opts.volumes.map(volumeAttachmentToJSON);
2683
2755
  }
2756
+ const idempotencyKey = newIdempotencyKey();
2684
2757
  const data = await client.post("/sandboxes", {
2685
2758
  json: body,
2686
- timeout: opts?.requestTimeout
2759
+ timeout: opts?.requestTimeout,
2760
+ ...idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : {}
2687
2761
  });
2688
2762
  const sandboxId = data.sandbox_id;
2689
2763
  assertValidId(sandboxId, "sandbox ID (from server)");
@@ -3180,7 +3254,13 @@ var TemplateBase = class {
3180
3254
  this._runCmds.push(cmds);
3181
3255
  return this;
3182
3256
  }
3183
- /** Add a file copy operation. */
3257
+ /**
3258
+ * Copy a local file into the template.
3259
+ *
3260
+ * Not supported yet: a template build cannot upload local files, so
3261
+ * building a template that uses `copy()` throws `InvalidArgumentError`.
3262
+ * Fetch the file in a `runCmd` step, or use `fromDockerfile`.
3263
+ */
3184
3264
  copy(src, dst, mode) {
3185
3265
  this._copies.push({ src, dst, mode });
3186
3266
  return this;
@@ -3200,6 +3280,14 @@ var TemplateBase = class {
3200
3280
  this._startCmd = cmd;
3201
3281
  return this;
3202
3282
  }
3283
+ /**
3284
+ * Whether `copy()` was used. Builds reject such a template until builds can
3285
+ * upload local files.
3286
+ * @internal
3287
+ */
3288
+ hasCopies() {
3289
+ return this._copies.length > 0;
3290
+ }
3203
3291
  /** Serialize the template to a JSON-friendly object. */
3204
3292
  toJSON() {
3205
3293
  if (this._dockerfile !== void 0) {
@@ -3211,14 +3299,11 @@ var TemplateBase = class {
3211
3299
  if (this._runCmds.length > 0) {
3212
3300
  result.run_cmds = this._runCmds.map((c) => c.join(" "));
3213
3301
  }
3214
- if (this._copies.length > 0) {
3215
- result.copies = this._copies;
3216
- }
3217
3302
  if (Object.keys(this._envs).length > 0) {
3218
3303
  result.envs = this._envs;
3219
3304
  }
3220
3305
  if (this._aptPackages.length > 0) {
3221
- result.apt_packages = this._aptPackages;
3306
+ result.packages = this._aptPackages;
3222
3307
  }
3223
3308
  if (this._startCmd !== void 0) {
3224
3309
  result.start_cmd = this._startCmd;
@@ -3230,19 +3315,70 @@ function parseBuildInfo(data) {
3230
3315
  return {
3231
3316
  buildId: data.build_id ?? data.buildId ?? "",
3232
3317
  status: data.status ?? "",
3233
- templateId: data.template_id ?? data.templateId ?? void 0
3318
+ templateId: data.template_id ?? data.templateId ?? void 0,
3319
+ logs: data.logs ?? []
3234
3320
  };
3235
3321
  }
3236
3322
  function parseTemplateBuildStatus(data) {
3237
3323
  return {
3238
3324
  buildId: data.build_id ?? data.buildId ?? "",
3239
3325
  status: data.status ?? "",
3240
- logs: data.logs ?? []
3326
+ // A build with no output yet serializes its logs as null.
3327
+ logs: data.logs ?? [],
3328
+ templateId: data.template_id ?? data.templateId ?? void 0
3241
3329
  };
3242
3330
  }
3243
3331
 
3244
3332
  // src/template/template.ts
3245
3333
  var VALID_BUILD_ID_RE = /^[a-zA-Z0-9_-]+$/;
3334
+ var BUILD_STATUS_COMPLETED = "completed";
3335
+ var BUILD_STATUS_FAILED = "failed";
3336
+ var DEFAULT_BUILD_TIMEOUT_MS = 60 * 60 * 1e3;
3337
+ var FAILURE_LOG_LINES = 20;
3338
+ var buildPolling = {
3339
+ /** How often a waiting build is re-read. */
3340
+ intervalMs: 3e3,
3341
+ /**
3342
+ * How long status checks may keep failing temporarily (5xx, 408, 429, or no
3343
+ * response) before the wait gives up. It rides out a sandbox-manager
3344
+ * restart; the build itself keeps running.
3345
+ */
3346
+ errorWindowMs: 2 * 60 * 1e3
3347
+ };
3348
+ var BUILD_LOG_TRUNCATION_NOTICE = "... [earlier build output truncated]";
3349
+ var LOG_ANCHOR_LINES = 64;
3350
+ var REJECTIONS = [
3351
+ AuthenticationError,
3352
+ ConflictError,
3353
+ InvalidArgumentError,
3354
+ NotEnoughSpaceError,
3355
+ NotFoundError
3356
+ ];
3357
+ var LogCursor = class {
3358
+ seen = 0;
3359
+ tail = [];
3360
+ newLines(logs) {
3361
+ const fresh = logs.length === 0 || logs[0] !== BUILD_LOG_TRUNCATION_NOTICE ? logs.slice(this.seen) : this.afterTail(logs);
3362
+ this.seen = logs.length;
3363
+ this.tail = [...this.tail, ...fresh].slice(-LOG_ANCHOR_LINES);
3364
+ return fresh;
3365
+ }
3366
+ afterTail(logs) {
3367
+ const window = logs.slice(1);
3368
+ const n = this.tail.length;
3369
+ if (n > 0) {
3370
+ for (let end = window.length; end >= n; end--) {
3371
+ if (window[end - 1] === this.tail[n - 1] && this.tail.every((line, i) => window[end - n + i] === line)) {
3372
+ return window.slice(end);
3373
+ }
3374
+ }
3375
+ }
3376
+ return logs;
3377
+ }
3378
+ };
3379
+ function isTemporaryPollError(err) {
3380
+ return err instanceof SandboxError && !REJECTIONS.some((Rejection) => err instanceof Rejection);
3381
+ }
3246
3382
  function assertValidBuildId(buildId) {
3247
3383
  if (!buildId || !VALID_BUILD_ID_RE.test(buildId)) {
3248
3384
  throw new InvalidArgumentError(
@@ -3250,72 +3386,131 @@ function assertValidBuildId(buildId) {
3250
3386
  );
3251
3387
  }
3252
3388
  }
3389
+ function buildRequestBody(template, alias, opts) {
3390
+ if (template.hasCopies()) {
3391
+ throw new InvalidArgumentError(
3392
+ "TemplateBase.copy() is not supported yet: a template build cannot upload local files. Fetch them in a runCmd step, or use fromDockerfile()."
3393
+ );
3394
+ }
3395
+ const body = {
3396
+ template: template.toJSON(),
3397
+ alias
3398
+ };
3399
+ if (opts?.cpuCount !== void 0) {
3400
+ body.cpu_count = opts.cpuCount;
3401
+ }
3402
+ if (opts?.memoryMb !== void 0) {
3403
+ body.memory_mb = opts.memoryMb;
3404
+ }
3405
+ if (opts?.diskMb !== void 0) {
3406
+ body.disk_mb = opts.diskMb;
3407
+ }
3408
+ return body;
3409
+ }
3410
+ function buildFailedError(status) {
3411
+ let message = `template build ${status.buildId} failed`;
3412
+ const tail = status.logs.slice(-FAILURE_LOG_LINES);
3413
+ if (tail.length > 0) {
3414
+ message += ":\n" + tail.join("\n");
3415
+ }
3416
+ return new BuildError(message, { buildId: status.buildId, logs: status.logs });
3417
+ }
3253
3418
  var Template = class {
3254
3419
  /**
3255
- * Build a template and wait for completion.
3420
+ * Build a template and wait for the build to finish.
3421
+ *
3422
+ * Builds usually take several minutes. While waiting, each new line of build
3423
+ * output is passed to `onBuildLogs`. Sandboxes are created from the finished
3424
+ * template by its alias: `Sandbox.create({ template: alias })`.
3256
3425
  *
3257
- * Sends POST /templates/build with the template definition.
3258
- * Invokes onBuildLogs for each log entry in the response.
3426
+ * @throws {BuildError} The build failed; the error carries its logs.
3427
+ * @throws {TimeoutError} The build was still running after `buildTimeout`.
3428
+ * It keeps running; follow it with `getBuildStatus()`.
3429
+ * @throws {InvalidArgumentError} The template uses `copy()`, which is not
3430
+ * supported yet.
3259
3431
  */
3260
3432
  static async build(template, alias, opts) {
3433
+ const body = buildRequestBody(template, alias, opts);
3261
3434
  const config = new ConnectionConfig({
3262
3435
  apiKey: opts?.apiKey,
3263
3436
  domain: opts?.domain,
3264
3437
  requestTimeout: opts?.requestTimeout
3265
3438
  });
3266
3439
  const client = getSharedClient(config);
3267
- const body = {
3268
- template: template.toJSON(),
3269
- alias
3270
- };
3271
- if (opts?.cpuCount !== void 0) {
3272
- body.cpu_count = opts.cpuCount;
3273
- }
3274
- if (opts?.memoryMb !== void 0) {
3275
- body.memory_mb = opts.memoryMb;
3276
- }
3277
- if (opts?.diskMb !== void 0) {
3278
- body.disk_mb = opts.diskMb;
3279
- }
3280
3440
  const data = await client.post("/templates/build", {
3281
3441
  json: body,
3282
3442
  timeout: opts?.requestTimeout
3283
3443
  });
3284
- const response = data;
3285
- const result = parseBuildInfo(response);
3286
- if (opts?.onBuildLogs && Array.isArray(response.logs)) {
3287
- for (const log of response.logs) {
3288
- opts.onBuildLogs(log);
3444
+ const info = parseBuildInfo(data);
3445
+ assertValidBuildId(info.buildId);
3446
+ let status = {
3447
+ buildId: info.buildId,
3448
+ status: info.status,
3449
+ logs: info.logs,
3450
+ templateId: info.templateId
3451
+ };
3452
+ const buildTimeout = opts?.buildTimeout ?? DEFAULT_BUILD_TIMEOUT_MS;
3453
+ const deadline = Date.now() + buildTimeout;
3454
+ const timedOut = (cause) => new TimeoutError(
3455
+ `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}` : "")
3456
+ );
3457
+ const cursor = new LogCursor();
3458
+ for (; ; ) {
3459
+ if (opts?.onBuildLogs) {
3460
+ for (const line of cursor.newLines(status.logs)) {
3461
+ opts.onBuildLogs(line);
3462
+ }
3463
+ }
3464
+ if (status.status === BUILD_STATUS_COMPLETED) {
3465
+ return {
3466
+ buildId: status.buildId,
3467
+ status: status.status,
3468
+ templateId: status.templateId ?? info.templateId,
3469
+ logs: status.logs
3470
+ };
3471
+ }
3472
+ if (status.status === BUILD_STATUS_FAILED) {
3473
+ throw buildFailedError(status);
3474
+ }
3475
+ if (Date.now() >= deadline) {
3476
+ throw timedOut();
3477
+ }
3478
+ let failingSince;
3479
+ for (; ; ) {
3480
+ await new Promise((resolve) => setTimeout(resolve, buildPolling.intervalMs));
3481
+ try {
3482
+ const next = await client.get(`/templates/builds/${info.buildId}`, {
3483
+ timeout: opts?.requestTimeout
3484
+ });
3485
+ status = parseTemplateBuildStatus(next);
3486
+ break;
3487
+ } catch (err) {
3488
+ if (!isTemporaryPollError(err)) {
3489
+ throw err;
3490
+ }
3491
+ failingSince ??= Date.now();
3492
+ if (Date.now() - failingSince >= buildPolling.errorWindowMs) {
3493
+ throw err;
3494
+ }
3495
+ if (Date.now() >= deadline) {
3496
+ throw timedOut(err);
3497
+ }
3498
+ }
3289
3499
  }
3290
3500
  }
3291
- return result;
3292
3501
  }
3293
3502
  /**
3294
- * Start a template build in the background.
3295
- *
3296
- * Sends POST /templates/build with `background: true`.
3503
+ * Start a template build and return as soon as the server has accepted it,
3504
+ * with status `building`. Follow the build with `getBuildStatus()`.
3297
3505
  */
3298
3506
  static async buildInBackground(template, alias, opts) {
3507
+ const body = buildRequestBody(template, alias, opts);
3299
3508
  const config = new ConnectionConfig({
3300
3509
  apiKey: opts?.apiKey,
3301
3510
  domain: opts?.domain,
3302
3511
  requestTimeout: opts?.requestTimeout
3303
3512
  });
3304
3513
  const client = getSharedClient(config);
3305
- const body = {
3306
- template: template.toJSON(),
3307
- alias,
3308
- background: true
3309
- };
3310
- if (opts?.cpuCount !== void 0) {
3311
- body.cpu_count = opts.cpuCount;
3312
- }
3313
- if (opts?.memoryMb !== void 0) {
3314
- body.memory_mb = opts.memoryMb;
3315
- }
3316
- if (opts?.diskMb !== void 0) {
3317
- body.disk_mb = opts.diskMb;
3318
- }
3319
3514
  const data = await client.post("/templates/build", {
3320
3515
  json: body,
3321
3516
  timeout: opts?.requestTimeout
@@ -3323,7 +3518,7 @@ var Template = class {
3323
3518
  return parseBuildInfo(data);
3324
3519
  }
3325
3520
  /**
3326
- * Get the status of a template build.
3521
+ * Get the status of a template build, including its logs so far.
3327
3522
  *
3328
3523
  * Sends GET /templates/builds/:buildId.
3329
3524
  */
@@ -3865,6 +4060,9 @@ var Governance = class {
3865
4060
  ApiClient,
3866
4061
  AuthenticationError,
3867
4062
  BuildError,
4063
+ CODE_IDEMPOTENCY_IN_PROGRESS,
4064
+ CODE_IDEMPOTENCY_KEY_REUSED,
4065
+ CODE_TEMPLATE_NOT_READY,
3868
4066
  CommandExitError,
3869
4067
  CommandHandle,
3870
4068
  Commands,