@runblackcat/sdk 0.1.0-alpha.57 → 0.1.0-alpha.71

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/README.md CHANGED
@@ -26,8 +26,8 @@ are in [docs/PRODUCT.md](docs/PRODUCT.md).
26
26
  rather than a connection.
27
27
  - **Queue and backpressure** — a request the fleet has no room for waits durably
28
28
  instead of failing, and is placed by a background drain as capacity arrives.
29
- Queued work is charged against tenant quota exactly like running work, so the
30
- quota is the backpressure limit and no second limiter exists.
29
+ Fleet capacity and the durable queue are the only backpressure; there are no
30
+ per-tenant quotas.
31
31
  - **Demand-driven capacity** — the placer sizes the fleet from the backlog it
32
32
  cannot place, through a provider seam, and retires idle workers back to zero.
33
33
  - **Forkable templates** — build a Docker image into a validated state, memory,
@@ -49,7 +49,7 @@ are in [docs/PRODUCT.md](docs/PRODUCT.md).
49
49
  | | |
50
50
  |---|---|
51
51
  | **Gateway** | Stateless. The single authenticated entry point; resolves sandbox→node and proxies. Scales horizontally. |
52
- | **Placer** | Single writer. Bin-packs by memory and vCPU, proposes placements, owns quotas, the queue and the node registry. Sole writer of substrate desired capacity. |
52
+ | **Placer** | Single writer. Bin-packs by memory and vCPU, proposes placements, owns the queue and the node registry. Sole writer of substrate desired capacity. |
53
53
  | **Node agent** | One per host. Firecracker lifecycle, jailer, networking, UFFD handler. **Final authority on admission** — the placer proposes, the node accepts or rejects. |
54
54
  | **Guest agent** | Inside each sandbox, reached over vsock. Exec, file I/O, template-declared boot services. |
55
55
  | **Plugin supervisor (in development)** | Runs operator-installed, digest-pinned job-source plugins outside sandbox hosts. Each installation has its own identity, secret scope and sandbox grant. A small Rust SDK calls the scoped private API. |
@@ -71,7 +71,7 @@ flowchart TB
71
71
  subgraph ctrl["Control host"]
72
72
  direction LR
73
73
  GW["blackcat-gateway<br/>auth · capabilities · routing<br/>listens :443"]
74
- PL["blackcat-placer<br/>single writer · best-fit<br/>quotas · node registry"]
74
+ PL["blackcat-placer<br/>single writer · best-fit<br/>queue · node registry"]
75
75
  DB[("placer.sqlite")]
76
76
  GW <-->|"Unix socket"| PL
77
77
  PL --- DB
@@ -158,8 +158,7 @@ construction. Everything else is a network hop.
158
158
  ## Capacity and observability
159
159
 
160
160
  Creation is admitted durably and answered immediately. A request the fleet cannot
161
- place becomes a **queued acquisition** — it keeps its sandbox identity and its
162
- quota charge, and a background drain places it as capacity appears. Callers poll
161
+ place becomes a **queued acquisition** — it keeps its sandbox identity, and a background drain places it as capacity appears. Callers poll
163
162
  the claim they already hold:
164
163
 
165
164
  ```
@@ -169,14 +168,27 @@ GET /v1/sandboxes/acquisitions/{id} -> 202 pending … -> 200 {"state":"p
169
168
  POST /v1/sandboxes/{id}/renew {ttl_secs} -> 200 {"expires_at":…, "renewable_until":…}
170
169
  ```
171
170
 
171
+ To start many identical sandboxes, add `"count": N` (1 to 1000) to the create.
172
+ `request_id` then names the batch and member `i` is `request_id.i`. The whole batch
173
+ is recorded in one durable transaction and answered `202` at once, with no
174
+ connection held through restores; a retry returns the same members.
175
+
176
+ ```
177
+ POST /v1/sandboxes {request_id,template,count,lease:{…}}
178
+ -> 202 {"request_id":…, "count":N, "counts":{"pending":N}, "members":[…]}
179
+ GET /v1/sandboxes/batches/{request_id} -> 202 while any member is pending, then 200
180
+ GET /v1/sandboxes/acquisitions/{id}.{i} -> one member, as an ordinary claim
181
+ ```
182
+
172
183
  `request_id` is caller-supplied and is the idempotency boundary, so repeating a
173
184
  create is always safe: it returns the same claim rather than allocating a second
174
185
  sandbox.
175
186
 
176
- Backpressure uses separate tenant limits for running allocations and retained
177
- snapshots. Pending/resuming work reserves running capacity; hibernating and
178
- resuming transitions reserve both classes until they commit. A queued acquisition
179
- that waits past the queue deadline is torn down by the ordinary expiry reconciler.
187
+ Backpressure is fleet capacity and the durable queue; there are no per-tenant
188
+ quotas. A queued acquisition that waits past the queue deadline is torn down by
189
+ the ordinary expiry reconciler. Request-rate limits are operator-set fleet state
190
+ (`blackcat admin rate-limits get|set`): token mint and OIDC limits are on by
191
+ default, every workload limit is unlimited until set.
180
192
 
181
193
  The placer sizes the fleet from that backlog through the `CapacityProvider`
182
194
  seam, and is the **only** writer of substrate desired capacity — it raises the
@@ -194,12 +206,53 @@ blackcat_demand_unsatisfied{tenant="…"} queued acquisitions
194
206
  blackcat_demand_oldest_age_seconds longest current wait
195
207
  blackcat_capacity_target_nodes workers last asked for
196
208
  blackcat_nodes{health="healthy"} workers actually registered
197
- blackcat_placement_rejected_total{reason="…"} no_capacity | quota
209
+ blackcat_placement_rejected_total{reason="…"} no_capacity
198
210
  ```
199
211
 
200
212
  A fleet pinned at its ceiling shows up as `blackcat_capacity_target_nodes`
201
213
  persistently above `blackcat_nodes`.
202
214
 
215
+ ## Recommended production shape: bare metal
216
+
217
+ Run workers on bare metal, one or a few large `.metal` hosts, and keep nested virtualization (a VM with `/dev/kvm`) as the quick-start
218
+ default: it launches in under a minute and needs no special quota. On metal every guest page fault costs about 7.6 times less than
219
+ nested, so the same fleet restores 2 to 3 times faster, fans out 30 times faster at 100 clones, costs a third as much per clone and
220
+ does not lose VMs to nested VM-entry failures.
221
+
222
+ | | bare metal (`c7i.metal-24xl`) | nested (`m8i.4xlarge`) |
223
+ |---|---|---|
224
+ | create p50 | 29 ms | 60 ms |
225
+ | first exec | 33 ms | 67 ms |
226
+ | snapshot restore | 13 ms | 43 ms |
227
+ | fan-out of 100 clones | 0.6 s | 17.9 s |
228
+ | cost per clone-hour | $0.0043 | $0.0132 |
229
+ | VM-entry deaths | 0 | 2.9 per 1,000 hot restores, 325 per 1,000 at density |
230
+
231
+ (Bake-off, 2 vCPU / 1 GiB app template, `contrib/bench/bakeoff/RESULTS.md`, "Huge pages". The hosts are different CPU
232
+ generations and metal has 12 times the CPUs, so the fan-out column is not virtualization alone. Metal is cheaper per clone because it
233
+ carries about 1,000 clones where the nested host was measured at 64.)
234
+
235
+ North star on one `c7i.metal-48xl` (500 clones of a built-from-source web app at once, each running a short test then destroyed):
236
+ 99% done 112 s after the PR arrived, all 500 passed with no replacements and no VMM deaths, about $0.34 per PR from a stopped node,
237
+ against 300 s, 264 replacements and about $1.00 on five `m8i.8xlarge`.
238
+
239
+ Trade-offs, plainly:
240
+
241
+ * **Boot.** A metal reboot has been quoted at about 7.5 minutes. Measured on a `c7i.metal-48xl`: a fresh launch was registered with SSM
242
+ in 74 s and a stopped node was serving again 47 s after `StartInstances`, but stopping it took 320 s (unbilled). Time from a
243
+ planner launch to an enrolled node on metal has not been measured (see
244
+ `contrib/bench/results/2026-10-04-wave-c-metal-northstar.md`). Nested hosts register in 40 to 45 s.
245
+ * **Coarse sizes.** Metal is `.metal` sizes only: 96 or 192 vCPUs on the `c7i` family. A small or spiky fleet pays for a whole box
246
+ or runs nested.
247
+ * **Thin Spot capacity.** Metal Spot pools are small; the planner falls back to on-demand when one refuses, and the price you plan
248
+ with is the cheaper Spot one.
249
+ * **Large blast radius.** One host carries up to about a thousand clones. Losing it, or restarting its agent, hits all of them; keep
250
+ `fleet max-clones-per-host` as the cap you can accept and keep more than one host for anything that must survive a host loss.
251
+
252
+ The capacity planner prefers metal when a reservation's `needed_by` allows its boot time and the price per clone is lower
253
+ ([docs/capacity-planning.md](docs/capacity-planning.md)); `blackcat_vm_entry_deaths` counts the VM-entry failures that make nested
254
+ hosts unreliable at density. Setup: [docs/bare-mode.md](docs/bare-mode.md#recommended-production-shape-bare-metal).
255
+
203
256
  ## Install (alpha)
204
257
 
205
258
  Identical on bare metal and on AWS:
@@ -286,7 +339,8 @@ Linux x86-64/arm64, run `curl -fsSL https://runblackcat.dev/install.sh | sh`;
286
339
  see [CLI installation](docs/cli-install.md). The installer uses release
287
340
  checksums from the public
288
341
  [`blackcat-releases`](https://github.com/TreebeardHQ/blackcat-releases) mirror
289
- and needs neither Docker nor Rust on the client. The site itself lives in
342
+ and needs neither Docker nor Rust on the client. To run a fleet on plain
343
+ Debian or Ubuntu servers, see [Bare mode](docs/bare-mode.md). The site itself lives in
290
344
  [`site/`](site/) and publishes an allowlisted subset of `docs/`.
291
345
  `blackcat template list` shows launchable templates to members without their
292
346
  source; `blackcat sandbox claim alpine-e2e-matched` resolves the latest version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runblackcat/sdk",
3
- "version": "0.1.0-alpha.57",
3
+ "version": "0.1.0-alpha.71",
4
4
  "description": "TypeScript SDK for the Blackcat sandbox gateway",
5
5
  "license": "Apache-2.0",
6
6
  "repository": "https://github.com/TreebeardHQ/blackcat",
@@ -21,6 +21,10 @@ export declare const CAPABILITY_OPS: {
21
21
  readonly RENEW: 4096;
22
22
  };
23
23
  export type CapabilityOps = number;
24
+ export declare const BLACKCAT_ERROR_CODES: {
25
+ readonly SANDBOX_TRANSITIONING: "sandbox_transitioning";
26
+ readonly FLEET_UPGRADE_QUIESCING: "fleet_upgrade_quiescing";
27
+ };
24
28
  export type FetchFunction = (input: RequestInfo | URL, init?: RequestInit & {
25
29
  tls?: {
26
30
  ca: string;
@@ -48,6 +52,17 @@ export interface TemplateDefaults {
48
52
  memory_mib: number;
49
53
  hibernate?: HibernateMode;
50
54
  on_interruption?: "evacuate" | "destroy";
55
+ /** MiB of virtio-mem region booted into the checkpoint; a multiple of 128. Defaults to the template's memory. */
56
+ hotplug_max_mib?: number;
57
+ }
58
+ /**
59
+ * The size a stored template version carries: `memory_mib` is the size it was
60
+ * captured at (boot memory plus what was plugged), and a sandbox can later
61
+ * take any size from `base_memory_mib` to `base_memory_mib + hotplug_max_mib`.
62
+ */
63
+ export interface TemplateShapeDefaults extends TemplateDefaults {
64
+ base_memory_mib: number;
65
+ hotplug_max_mib: number;
51
66
  }
52
67
  export type HibernateMode = "memory";
53
68
  export type TemplateEgressPolicy = {
@@ -95,6 +110,9 @@ interface TemplateDockerSourceBase {
95
110
  defaults: TemplateDefaults;
96
111
  egress: TemplateEgressPolicy;
97
112
  restore_health: TemplateRestoreHealth;
113
+ /** Runs in the guest after it is ready and before the capture, so every
114
+ * clone restores with the app's caches and JIT already hot. Must exit 0. */
115
+ warmup?: TemplateRestoreHealth;
98
116
  }
99
117
  interface TemplatePreparation {
100
118
  parameters: Record<string, {
@@ -133,8 +151,11 @@ export interface UploadedImage {
133
151
  unchanged: boolean;
134
152
  }
135
153
  export interface TemplateSandboxSource {
154
+ /** `warmup` runs in the sandbox just before it is captured; omitted, the
155
+ * sandbox's template's own warm-up (if any) runs. */
136
156
  sandbox: {
137
157
  id: string;
158
+ warmup?: TemplateRestoreHealth;
138
159
  };
139
160
  /**
140
161
  * A promoted sandbox keeps the machine dimensions it was captured on, so
@@ -144,20 +165,33 @@ export interface TemplateSandboxSource {
144
165
  egress?: TemplateEgressPolicy;
145
166
  }
146
167
  export type TemplateSource = TemplateDockerSource | TemplateUploadedImageSource | TemplateSandboxSource;
168
+ /** What a promotion adds to a template create: how it is warmed and packed. */
169
+ export interface PromotionOptions {
170
+ /** Warm the new version onto this many more nodes in bulk, beyond the node
171
+ * that captured it (at most 99), or onto `"all"` the other healthy nodes.
172
+ * Nodes bound to a capacity reservation that follows the template are warmed
173
+ * regardless. */
174
+ prefetch_copies?: number | "all";
175
+ /** Millicores each clone of the new version reserves, at most 1000 per vCPU.
176
+ * Omitted, the version inherits what the sandbox's own template reserves. */
177
+ cpu_reservation_mcpu?: number;
178
+ }
147
179
  export type CreateTemplateInput = {
148
180
  name: string;
149
181
  source: TemplateDockerSource;
150
182
  } | {
151
183
  name: string;
152
184
  source: TemplateUploadedImageSource;
153
- } | {
185
+ } | ({
154
186
  name?: string;
155
187
  source: TemplateSandboxSource;
156
- };
188
+ } & PromotionOptions);
157
189
  export interface Template {
158
190
  id: string;
159
191
  name: string;
160
192
  version: number;
193
+ /** `local_ready` while a promotion's chunks are still being published. */
194
+ state?: "local_ready" | "published";
161
195
  /** The gateway stores the complete source manifest as JSON text. */
162
196
  source: string;
163
197
  current: boolean;
@@ -187,27 +221,156 @@ export interface PrepareSandboxResponse {
187
221
  export interface CreateTemplateResponse {
188
222
  id: string;
189
223
  version: number;
224
+ /**
225
+ * `local_ready`: a promotion whose capture has been proved but whose chunks
226
+ * are still being published. The version is current and claimable, but only
227
+ * on the node that captured it; clones depend only on that node's files and
228
+ * are destroyed if the publication fails. `published`: every chunk is in the
229
+ * object store.
230
+ */
231
+ state?: "local_ready" | "published";
232
+ /**
233
+ * Capture/publish phase breakdown for a checkpoint promotion, when this
234
+ * create captured a running sandbox. Absent for a template created without
235
+ * a capture, and for a server that predates this field.
236
+ */
237
+ capture_timing?: {
238
+ capture_ms: number;
239
+ /** How long the source VM stood paused: the memory/state dump, the disk
240
+ * freeze and the resume. Sealing and upload happen after it. */
241
+ pause_ms: number;
242
+ /** Absent while the version is `local_ready`: publication is not over. */
243
+ publish_ms?: number;
244
+ /** Restoring a disposable clone from the capture and running the
245
+ * template's restore-health command in it; both overlap the upload. */
246
+ validation_restore_ms?: number;
247
+ validation_health_ms?: number;
248
+ /** Whether the capturing node was left holding the new version's memory
249
+ * and disk for fault-free restores. */
250
+ warmed?: {
251
+ memory: boolean;
252
+ disk: boolean;
253
+ };
254
+ };
255
+ /** Present for a promotion that asked for or achieved warming. */
256
+ warming?: {
257
+ capturing_node_warm: boolean;
258
+ prefetch_copies: number;
259
+ /** Pass to `templates.prefetchStatus` to follow the extra copies. */
260
+ prefetch_id?: string;
261
+ error?: string;
262
+ };
190
263
  }
191
- export type CreateSandboxInput = ({
192
- template: string;
193
- vcpus?: never;
194
- memory_mib?: never;
195
- } | {
196
- template: string;
197
- vcpus: number;
198
- memory_mib: number;
199
- }) & {
264
+ export type CreateSandboxInput = {
265
+ /**
266
+ * The template name or version id. Omitted, it is the tenant's `default`
267
+ * template, which the gateway builds on the tenant's first use: that first
268
+ * create fails with a 503 `default_template_building` until the build is
269
+ * ready, and repeating the same request is how to wait for it.
270
+ */
271
+ template?: string;
272
+ /** Must equal the checkpoint's vCPUs, which are fixed at capture; omit it. */
273
+ vcpus?: number;
274
+ /**
275
+ * Guest memory in MiB: the checkpoint's captured size or larger, up to its
276
+ * boot memory plus hotplug region (the extra is plugged right after restore).
277
+ */
278
+ memory_mib?: number;
279
+ /** CPU entitlement in millicores, at most the checkpoint's vCPUs * 1000. */
280
+ cpu_mcpu?: number;
200
281
  /** Stable across retries of one acquisition; never reuse for another request. */
201
282
  request_id: string;
202
283
  ssh_public_key?: string;
203
284
  lease: SandboxLeaseInput;
285
+ /** A capacity reservation this create should draw a bound slot from. */
286
+ reservation?: string;
287
+ /**
288
+ * Ask for a capability scoped to each sandbox this request creates, returned
289
+ * in the response (`capability` on the receipt, or on each batch member), so
290
+ * a caller driving many sandboxes mints none of its own. `ops` must be within
291
+ * the creating token's; `ttl_secs` is capped at 900 and renewable like any
292
+ * token. Only the request that creates a sandbox gets its capability: a
293
+ * repeat of the same `request_id` returns none.
294
+ */
295
+ capability?: {
296
+ ops: string[] | CapabilityOps;
297
+ ttl_secs: number;
298
+ };
299
+ };
300
+ /** A capability scoped to one sandbox, as returned by a create. */
301
+ export interface SandboxCapability {
302
+ token: string;
303
+ token_id: string;
304
+ ops: string[];
305
+ /** Epoch milliseconds. */
306
+ expires_at: number;
307
+ renewable_until: number;
308
+ }
309
+ /** A batch create: `request_id` names the batch, member `i` is `<request_id>.<i>`. */
310
+ export type CreateSandboxBatchInput = CreateSandboxInput & {
311
+ /** How many identical sandboxes to create, 1 to 1000. */
312
+ count: number;
204
313
  };
314
+ export type SandboxBatchMemberState = "pending" | "placed" | "failed" | "destroyed";
315
+ export interface SandboxBatchMember {
316
+ /** Poll this member alone with `sandboxes.acquisition(request_id)`. */
317
+ request_id: string;
318
+ id: string;
319
+ state: SandboxBatchMemberState;
320
+ /** Present when the create asked for `capability`. */
321
+ capability?: SandboxCapability;
322
+ }
323
+ export interface SandboxBatch {
324
+ /** The batch's request id. */
325
+ request_id: string;
326
+ count: number;
327
+ /** Members in each state; a state with no members is absent. */
328
+ counts: Partial<Record<SandboxBatchMemberState, number>>;
329
+ members: SandboxBatchMember[];
330
+ }
205
331
  export interface SandboxLeaseInput {
206
332
  /** Initial lease duration, between 60 seconds and 24 hours. */
207
333
  ttl_secs: number;
208
334
  /** Hard lifetime from acquisition, at least ttl_secs and at most 30 days. */
209
335
  renewable_for_secs: number;
210
336
  }
337
+ export type CreateCapacityReservationInput = ({
338
+ /** One immutable template version, pinned for the reservation's life. */
339
+ template: string;
340
+ template_resource?: never;
341
+ } | {
342
+ /** A template resource name: the reservation follows whichever version
343
+ * is current, even one that does not exist yet, and its bound nodes are
344
+ * warmed with each new version as it is activated. */
345
+ template_resource: string;
346
+ template?: never;
347
+ }) & {
348
+ /** How many sandboxes of this template/shape to reserve. */
349
+ count: number;
350
+ vcpus: number;
351
+ memory_mib: number;
352
+ /** How long to hold the reservation, between 1 second and 4 hours. */
353
+ ttl_secs: number;
354
+ };
355
+ export interface CapacityReservation {
356
+ id: string;
357
+ state: "active" | "released" | "expired";
358
+ /** The version nodes are warmed with; null while a reservation following a
359
+ * template resource waits for the resource's first version. */
360
+ template: string | null;
361
+ /** The template resource this reservation follows, or null when pinned. */
362
+ template_resource: string | null;
363
+ count: number;
364
+ vcpus: number;
365
+ memory_mib: number;
366
+ /** Slots consumed by an actual sandbox create. */
367
+ claimed: number;
368
+ /** Slots bound to a specific node and not yet claimed. */
369
+ bound: number;
370
+ /** Bound slots whose node already has the template warm. */
371
+ warm: number;
372
+ expires_at: SystemTimeValue;
373
+ }
211
374
  /**
212
375
  * Response returned by POST /v1/sandboxes (the gateway has no template field).
213
376
  * The gateway sends a `failed` receipt with HTTP 409, so `create` and
@@ -221,6 +384,14 @@ export type CreateSandboxResponse = {
221
384
  tier: "boot" | "warm_pool" | "restore";
222
385
  expires_at: SystemTimeValue;
223
386
  renewable_until: SystemTimeValue;
387
+ /**
388
+ * Phase-timing breakdown for the launch that placed this sandbox, once one
389
+ * has committed. Absent while still queued/pending, and for a server that
390
+ * predates this field.
391
+ */
392
+ restore_timing?: RestoreTiming;
393
+ /** Present when the create asked for `capability` and created this sandbox. */
394
+ capability?: SandboxCapability;
224
395
  } & ({
225
396
  state: "placed";
226
397
  node: string;
@@ -232,19 +403,96 @@ export type CreateSandboxResponse = {
232
403
  node: null;
233
404
  error: string;
234
405
  });
406
+ /**
407
+ * Restore phase breakdown a node reported for a checkpoint launch (Proposal
408
+ * F, docs/restore-performance.md). `phase_ms` mirrors the node's own
409
+ * `checkpoint vm timing` log line; `chunk_source` reports how much of the
410
+ * checkpoint's memory/disk was fetched remotely vs. served locally, how many
411
+ * UFFD faults and NBD reads this restore actually served (both zero on the
412
+ * file-restore path), and the checkpoint's logical sizes. `memory_source` is
413
+ * `"file"` when a node-local materialized copy let this restore skip
414
+ * UFFD/NBD lazy faulting entirely (fault-free warm restore,
415
+ * docs/restore-performance.md), or `"uffd"` for the lazy chunk-source path
416
+ * every checkpoint restore used before it.
417
+ */
418
+ export interface RestoreTiming {
419
+ proposal_ms: number;
420
+ restore_vm_ms: number;
421
+ commit_ms: number;
422
+ total_ms: number;
423
+ phase_ms: {
424
+ open: number;
425
+ network: number;
426
+ firecracker: number;
427
+ state: number;
428
+ disk: number;
429
+ pager: number;
430
+ load: number;
431
+ activation: number;
432
+ /** Milliseconds from the TAP being raised until the guest's gateway and resolver answered; absent from a node that predates it, 0 from a guest that does. */
433
+ network_ready?: number;
434
+ };
435
+ memory_source?: "file" | "uffd";
436
+ chunk_source: {
437
+ remote_requests: number;
438
+ remote_bytes: number;
439
+ local_requests?: number;
440
+ local_bytes?: number;
441
+ uffd_faults?: number;
442
+ uffd_fault_bytes?: number;
443
+ nbd_reads?: number;
444
+ nbd_read_bytes?: number;
445
+ logical_memory_bytes: number;
446
+ logical_disk_bytes: number;
447
+ };
448
+ }
235
449
  export interface Sandbox {
236
450
  id: string;
237
451
  template: string;
238
452
  state: string;
239
453
  node?: string;
454
+ /** vCPUs the guest has now: the template's, until a cold resize reboots it onto another count. */
455
+ vcpus: number;
456
+ /** Current guest memory in MiB (boot memory plus what is plugged). */
457
+ memory_mib: number;
458
+ /** Current CPU entitlement in millicores. */
459
+ cpu_mcpu: number;
240
460
  expires_at: SystemTimeValue;
241
461
  renewable_until: SystemTimeValue;
242
462
  }
463
+ /** A short-lived SSH key to authorize on a running sandbox. */
464
+ export interface GrantSshKeyInput {
465
+ /** One `ssh-ed25519` public key line. */
466
+ public_key: string;
467
+ /** Lifetime in seconds, 1 to 86400. The key only matters while connecting. */
468
+ ttl_secs: number;
469
+ }
470
+ export interface SshKeyGrant {
471
+ /** Unix milliseconds at which the key stops authenticating. */
472
+ expires_at: number;
473
+ /** The login account in the sandbox. */
474
+ user: string;
475
+ /** The sandbox's SSH host public key; pin it before connecting. */
476
+ host_key: string;
477
+ }
243
478
  export interface RenewSandboxResponse {
244
479
  id: string;
245
480
  expires_at: SystemTimeValue;
246
481
  renewable_until: SystemTimeValue;
247
482
  }
483
+ /** The CPU a node runs on, as it reports it. */
484
+ export interface CpuIdentity {
485
+ vendor: string;
486
+ model_name: string;
487
+ family: number;
488
+ model: number;
489
+ stepping: number;
490
+ microcode: string;
491
+ /** Hash of the CPUID feature leaves a guest's CPUID is derived from. */
492
+ features_hash: string;
493
+ /** The host is itself a guest (nested virtualization). */
494
+ nested: boolean;
495
+ }
248
496
  /**
249
497
  * Tenant-scoped view returned by GET /v1/fleet. Residents and cached snapshots
250
498
  * are already filtered by the gateway to the caller's tenant.
@@ -262,6 +510,78 @@ export interface FleetNode {
262
510
  residents: string[];
263
511
  cached_snapshots: string[];
264
512
  interruption: FleetInterruption | null;
513
+ /** Charged versus measured memory and the node's pressure state. */
514
+ memory: FleetNodeMemory;
515
+ /** Whether the host runs with SMT active. */
516
+ smt_active: boolean;
517
+ /** The node's CPU; absent from nodes older than CPU-aware placement. */
518
+ cpu?: CpuIdentity | null;
519
+ /** 1000 charges file-backed clones in full; 2000 charges them at half. */
520
+ max_memory_ratio_milli: number;
521
+ max_vcpu_ratio_milli: number;
522
+ /** How the fleet decides what a sandbox costs. */
523
+ packing_mode?: "learned" | "manual";
524
+ }
525
+ export type MemoryPressure = "normal" | "high" | "critical";
526
+ export interface FleetNodeMemory {
527
+ /** What admission charged for the node's residents. */
528
+ charged_mib: number;
529
+ /** The residents' sizes before the overcommit discount. */
530
+ plugged_mib: number;
531
+ /** Resident memory of the residents' VMMs, shared pages counted per mapper. */
532
+ measured_rss_mib: number;
533
+ /** Proportional memory the residents cost; compare with `charged_mib`. */
534
+ measured_pss_mib: number;
535
+ /** Memory only one resident maps (Private_Clean + Private_Dirty). */
536
+ measured_uss_mib: number;
537
+ host_available_mib: number;
538
+ psi_some_avg10_milli: number;
539
+ pressure: MemoryPressure;
540
+ }
541
+ export type FleetTrust = "single_trust" | "multi_tenant";
542
+ export interface ResizeSandboxInput {
543
+ /**
544
+ * Target vCPU count. A count different from the sandbox's reboots the guest
545
+ * cold on its own disk: every process ends, template boot services restart,
546
+ * processes started by hand do not.
547
+ */
548
+ vcpus?: number;
549
+ /** Target guest memory in MiB: boot memory plus a multiple of 128. */
550
+ memory_mib?: number;
551
+ /** Target CPU entitlement in millicores. */
552
+ cpu_mcpu?: number;
553
+ /** Drop the guest's page caches before a shrink. Defaults to true. */
554
+ drop_caches?: boolean;
555
+ /** Stable across retries of one resize; generated when omitted. */
556
+ request_id?: string;
557
+ }
558
+ /** The size a resize reached. A shrink the guest could not fully honour reports what it reached. */
559
+ export interface SandboxResize {
560
+ id: string;
561
+ state: "placed";
562
+ request_id: string;
563
+ /** True when the guest was rebooted to change its vCPU count. */
564
+ cold: boolean;
565
+ vcpus: number;
566
+ memory_mib: number;
567
+ cpu_mcpu: number;
568
+ /** False when the guest stopped short of the requested memory. */
569
+ reached_target: boolean;
570
+ elapsed_ms: number;
571
+ /** Cold resizes only: durable processes the reboot ended. */
572
+ processes_ended?: number;
573
+ /** Cold resizes only: where the reboot spent its time, in milliseconds. */
574
+ phases?: {
575
+ quiesce_ms: number;
576
+ stop_ms: number;
577
+ boot_ms: number;
578
+ plug_ms: number;
579
+ handshake_ms: number;
580
+ health_ms: number;
581
+ };
582
+ /** Cold resizes only: unexpired SSH grants installed again, and ones that failed. */
583
+ ssh_grants_restored?: number;
584
+ ssh_grants_failed?: number;
265
585
  }
266
586
  export interface FleetInterruption {
267
587
  reason: string;
@@ -269,6 +589,9 @@ export interface FleetInterruption {
269
589
  }
270
590
  export interface FleetResponse {
271
591
  nodes: FleetNode[];
592
+ fleet_trust: FleetTrust;
593
+ /** `native`, or a CPU template (`t2`, `t2s`, `t2cl`, `t2a`). */
594
+ cpu_baseline?: string;
272
595
  }
273
596
  export interface TemplateRefreshPolicy {
274
597
  interval_seconds: number;
@@ -395,6 +718,8 @@ export interface RenewTokenResponse {
395
718
  }
396
719
  export declare class BlackcatApiError extends Error {
397
720
  readonly status: number;
721
+ /** Stable gateway error code when one is provided. */
722
+ readonly code?: string;
398
723
  readonly retryAfter?: number;
399
724
  readonly requestId?: string;
400
725
  readonly details: unknown;
@@ -406,6 +731,7 @@ declare const MAX_PROCESS_STDIN: number;
406
731
  declare const MAX_EXEC_STDIN: number;
407
732
  export declare function epochMillis(value: unknown, field: string): number;
408
733
  export declare function parseCreateSandboxResponse(value: unknown, requestId: string): CreateSandboxResponse;
734
+ export declare function parseSandboxBatch(value: unknown, requestId: string): SandboxBatch;
409
735
  export declare function assertSecureBlackcatGatewayUrl(baseUrl: string): void;
410
736
  declare class GatewayClient {
411
737
  readonly baseUrl: string;
@@ -476,6 +802,16 @@ declare class FleetResource {
476
802
  /** List the nodes and this tenant's residents/snapshots visible to INSPECT. */
477
803
  list(): Promise<FleetResponse>;
478
804
  }
805
+ declare class ReservationsResource {
806
+ private readonly client;
807
+ constructor(client: GatewayClient);
808
+ /** Reserve capacity for a burst of sandboxes of one template/shape. */
809
+ create(input: CreateCapacityReservationInput): Promise<CapacityReservation>;
810
+ get(id: string): Promise<CapacityReservation>;
811
+ /** Release early. Unclaimed slots free immediately; bound nodes then fall
812
+ * back to the normal empty-worker scale-in grace period. */
813
+ release(id: string): Promise<CapacityReservation>;
814
+ }
479
815
  declare class SandboxesResource {
480
816
  private readonly client;
481
817
  constructor(client: GatewayClient);
@@ -487,10 +823,25 @@ declare class SandboxesResource {
487
823
  * no automatic retry, new ID, or replacement allocation is synthesized here.
488
824
  */
489
825
  create(input: CreateSandboxInput): Promise<CreateSandboxResponse>;
826
+ /**
827
+ * Create `count` identical sandboxes under one request ID. Resolves as soon
828
+ * as the whole batch is durably recorded; nothing waits on a restore.
829
+ * Repeat the exact request to recover a lost response: member IDs are
830
+ * stable. Poll `batch` for counts by state, or a member with `acquisition`.
831
+ */
832
+ createBatch(input: CreateSandboxBatchInput): Promise<SandboxBatch>;
833
+ /** Counts by state and every member of a batch created with `createBatch`. */
834
+ batch(requestId: string): Promise<SandboxBatch>;
490
835
  acquisition(requestId: string): Promise<CreateSandboxResponse>;
491
836
  get(id: string): Promise<Sandbox>;
492
837
  /** Extend the soft expiry without moving the immutable hard deadline. */
493
838
  renew(id: string, ttl_secs: number): Promise<RenewSandboxResponse>;
839
+ /**
840
+ * Authorize `public_key` on a running sandbox for `ttl_secs`, whether or not
841
+ * it was created with a key. Needs the sandbox-scoped exec capability. Connect
842
+ * with `CONNECT /v1/sandboxes/{id}/ssh`, or let `blackcat sandbox ssh` do it.
843
+ */
844
+ grantSshKey(id: string, input: GrantSshKeyInput): Promise<SshKeyGrant>;
494
845
  exec(id: string, input: ExecInput): Promise<ExecResult>;
495
846
  execStream(id: string, input: ExecInput): AsyncGenerator<ExecStreamChunk>;
496
847
  startProcess(id: string, requestId: string, input: ProcessStartInput): Promise<ProcessRecord>;
@@ -528,10 +879,32 @@ declare class SandboxesResource {
528
879
  snapshot(id: string): Promise<{
529
880
  template: string;
530
881
  }>;
882
+ /**
883
+ * Pause a running sandbox's VM in memory: processes, memory and network
884
+ * state are kept, nothing is captured, and `resume` continues it where it
885
+ * stopped. Memory stays resident and charged (`hibernate` frees it); the CPU
886
+ * entitlement is released. Idempotent. Requests that need the guest are
887
+ * refused while it is paused.
888
+ */
889
+ pause(id: string): Promise<void>;
531
890
  hibernate(id: string, input?: {
532
891
  mode: HibernateMode;
533
892
  }): Promise<void>;
893
+ /**
894
+ * Change a running sandbox's memory (live, within its checkpoint's hotplug
895
+ * region) and CPU entitlement; processes keep running. A `vcpus` different
896
+ * from the sandbox's is a cold reboot instead (`cold: true`). Repeating the
897
+ * same `request_id` replays the result.
898
+ */
899
+ resize(id: string, input: ResizeSandboxInput): Promise<SandboxResize>;
900
+ /** Continue a paused sandbox, or restore a hibernated one. */
534
901
  resume(id: string): Promise<void>;
902
+ /**
903
+ * Destroy a sandbox. Resolves when its VM is dead and the node has released
904
+ * its capacity, not when the node has finished removing its network device,
905
+ * jail and cgroup: that follows in the background, usually within a second.
906
+ * Destroying a sandbox that is already destroyed succeeds.
907
+ */
535
908
  destroy(id: string): Promise<void>;
536
909
  }
537
910
  export declare class BlackcatClient extends GatewayClient {
@@ -541,6 +914,7 @@ export declare class BlackcatClient extends GatewayClient {
541
914
  readonly sandboxes: SandboxesResource;
542
915
  readonly tokens: TokensResource;
543
916
  readonly fleet: FleetResource;
917
+ readonly reservations: ReservationsResource;
544
918
  constructor(options: BlackcatClientOptions);
545
919
  /**
546
920
  * Create a client that uses a sandbox capability for resource operations.
package/sdk/dist/index.js CHANGED
@@ -14,9 +14,17 @@ var CAPABILITY_OPS = {
14
14
  PROCESS_CANCEL: 2048,
15
15
  RENEW: 4096
16
16
  };
17
+ var BLACKCAT_ERROR_CODES = {
18
+ SANDBOX_TRANSITIONING: "sandbox_transitioning",
19
+ FLEET_UPGRADE_QUIESCING: "fleet_upgrade_quiescing"
20
+ };
21
+ function withTemplate(input) {
22
+ return { ...input, template: input.template ?? "default" };
23
+ }
17
24
 
18
25
  class BlackcatApiError extends Error {
19
26
  status;
27
+ code;
20
28
  retryAfter;
21
29
  requestId;
22
30
  details;
@@ -25,6 +33,11 @@ class BlackcatApiError extends Error {
25
33
  this.name = "BlackcatApiError";
26
34
  this.status = status;
27
35
  this.details = details;
36
+ if (typeof details === "object" && details !== null && !Array.isArray(details)) {
37
+ const code = details.code;
38
+ if (typeof code === "string")
39
+ this.code = code;
40
+ }
28
41
  this.retryAfter = retryAfter;
29
42
  this.requestId = typeof requestId === "string" && requestId.length === 32 && /^[a-f0-9]{32}$/i.test(requestId) ? requestId : undefined;
30
43
  }
@@ -83,6 +96,20 @@ function parseCreateSandboxResponse(value, requestId) {
83
96
  }
84
97
  return value;
85
98
  }
99
+ var BATCH_STATES = ["pending", "placed", "failed", "destroyed"];
100
+ function parseSandboxBatch(value, requestId) {
101
+ const malformed = () => new BlackcatProtocolError("sandbox batch is malformed or belongs to another request");
102
+ if (typeof value !== "object" || value === null || Array.isArray(value))
103
+ throw malformed();
104
+ const row = value;
105
+ if (row.request_id !== requestId || typeof row.count !== "number" || typeof row.counts !== "object" || row.counts === null || !Array.isArray(row.members) || row.members.length !== row.count || !row.members.every((member) => {
106
+ const m = member;
107
+ return typeof m === "object" && m !== null && typeof m.request_id === "string" && typeof m.id === "string" && BATCH_STATES.includes(String(m.state));
108
+ })) {
109
+ throw malformed();
110
+ }
111
+ return value;
112
+ }
86
113
  function parseMintTokenResponse(value) {
87
114
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
88
115
  throw new BlackcatProtocolError("token mint response is not an object");
@@ -395,6 +422,22 @@ class FleetResource {
395
422
  }
396
423
  }
397
424
 
425
+ class ReservationsResource {
426
+ client;
427
+ constructor(client) {
428
+ this.client = client;
429
+ }
430
+ create(input) {
431
+ return this.client.json("POST", "/v1/capacity-reservations", input, "token");
432
+ }
433
+ get(id) {
434
+ return this.client.json("GET", `/v1/capacity-reservations/${pathSegment(id)}`, undefined, "token");
435
+ }
436
+ release(id) {
437
+ return this.client.json("DELETE", `/v1/capacity-reservations/${pathSegment(id)}`, undefined, "token");
438
+ }
439
+ }
440
+
398
441
  class SandboxesResource {
399
442
  client;
400
443
  constructor(client) {
@@ -404,7 +447,13 @@ class SandboxesResource {
404
447
  return this.client.json("GET", "/v1/sandboxes", undefined, "token");
405
448
  }
406
449
  async create(input) {
407
- return parseCreateSandboxResponse(await this.client.json("POST", "/v1/sandboxes", input, "token", false), input.request_id);
450
+ return parseCreateSandboxResponse(await this.client.json("POST", "/v1/sandboxes", withTemplate(input), "token", false), input.request_id);
451
+ }
452
+ async createBatch(input) {
453
+ return parseSandboxBatch(await this.client.json("POST", "/v1/sandboxes", withTemplate(input), "token", false), input.request_id);
454
+ }
455
+ async batch(requestId) {
456
+ return parseSandboxBatch(await this.client.json("GET", `/v1/sandboxes/batches/${pathSegment(requestId)}`, undefined, "token"), requestId);
408
457
  }
409
458
  async acquisition(requestId) {
410
459
  return parseCreateSandboxResponse(await this.client.json("GET", `/v1/sandboxes/acquisitions/${pathSegment(requestId)}`, undefined, "token"), requestId);
@@ -415,6 +464,9 @@ class SandboxesResource {
415
464
  renew(id, ttl_secs) {
416
465
  return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/renew`, { ttl_secs }, "token");
417
466
  }
467
+ grantSshKey(id, input) {
468
+ return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/ssh-keys`, input, "token");
469
+ }
418
470
  async exec(id, input) {
419
471
  return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/exec`, input, "token", false, execDeadline(input)).then(execResult);
420
472
  }
@@ -596,11 +648,19 @@ class SandboxesResource {
596
648
  snapshot(id) {
597
649
  return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/snapshot`, undefined, "token");
598
650
  }
651
+ pause(id) {
652
+ return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/pause`, undefined, "token").then(() => {
653
+ return;
654
+ });
655
+ }
599
656
  hibernate(id, input) {
600
657
  return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/hibernate`, input === undefined ? undefined : JSON.stringify(input), "token", input === undefined ? undefined : "application/json", false).then(() => {
601
658
  return;
602
659
  });
603
660
  }
661
+ resize(id, input) {
662
+ return this.client.json("POST", `/v1/sandboxes/${pathSegment(id)}/resize`, { ...input, request_id: input.request_id ?? `resize-${crypto.randomUUID()}` }, "token", false);
663
+ }
604
664
  resume(id) {
605
665
  return this.client.raw("POST", `/v1/sandboxes/${pathSegment(id)}/resume`, undefined, "token").then(() => {
606
666
  return;
@@ -618,6 +678,7 @@ class BlackcatClient extends GatewayClient {
618
678
  sandboxes;
619
679
  tokens;
620
680
  fleet;
681
+ reservations;
621
682
  constructor(options) {
622
683
  if (options.apiKey !== undefined)
623
684
  assertSecureBlackcatGatewayUrl(options.baseUrl);
@@ -628,6 +689,7 @@ class BlackcatClient extends GatewayClient {
628
689
  this.sandboxes = new SandboxesResource(this);
629
690
  this.tokens = new TokensResource(this);
630
691
  this.fleet = new FleetResource(this);
692
+ this.reservations = new ReservationsResource(this);
631
693
  }
632
694
  withToken(token, renewCapability) {
633
695
  const capability = typeof token === "string" ? undefined : token;
@@ -644,6 +706,7 @@ class BlackcatClient extends GatewayClient {
644
706
  }
645
707
  }
646
708
  export {
709
+ parseSandboxBatch,
647
710
  parseCreateSandboxResponse,
648
711
  epochMillis,
649
712
  assertSecureBlackcatGatewayUrl,
@@ -654,5 +717,6 @@ export {
654
717
  MAX_EXEC_STDIN,
655
718
  CAPABILITY_OPS,
656
719
  BlackcatClient,
657
- BlackcatApiError
720
+ BlackcatApiError,
721
+ BLACKCAT_ERROR_CODES
658
722
  };