xapi-to 0.1.20 → 0.1.21

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.
@@ -7,7 +7,7 @@ import {
7
7
  sandboxQuote,
8
8
  sandboxStateAction,
9
9
  sandboxWait
10
- } from "./chunk-TYY6JR6O.js";
10
+ } from "./chunk-UEQCIJ7T.js";
11
11
 
12
12
  // src/openai-sandbox-client.ts
13
13
  import { randomUUID } from "crypto";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xapi-to",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Agent-friendly CLI for xapi - discover and call capabilities and APIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -466,8 +466,6 @@ The full catalog also spans many other categories — crypto/on-chain data, CEX
466
466
 
467
467
  The CLI retries idempotent metadata reads and `task poll` for transient timeouts, network failures, `408`, `429`, and `502`–`504`. It does not automatically retry arbitrary `call` actions because the upstream may already have completed a write; confirm the result before manually retrying posts, payments, or other mutations. Ordinary JSON execution has a 60-second request ceiling. HTTP SSE streams and raw downloads instead use a 60-second no-data timeout, reset whenever a chunk arrives; override it with `XAPI_TRANSFER_IDLE_TIMEOUT_MS` when an upstream legitimately pauses longer.
468
468
 
469
- ## Tips
470
-
471
469
  - Use `--page` and `--page-size` for pagination on `list`, `search`, and `services`.
472
470
 
473
471
  ## Specialized Guides
@@ -489,6 +487,7 @@ When the user's task involves these workflows, read the corresponding guide file
489
487
  - **`guides/ws_gateway.md`** — xAPI WebSocket Gateway: OpenAI Realtime, streaming ASR/TTS, simultaneous interpretation, podcast generation, service/path routing, browser authentication, native binary protocols, limits, billing, close codes, and reconnects
490
488
  - **`guides/sandbox.md`** — managed Sandbox compute: AI tool selection, one-shot and multi-step lifecycles, provider pinning, files, Cloudflare Web previews, suspension, GPU jobs, parallel agents, cleanup recovery, audit/history, and billing verification
491
489
  - **`guides/sms.md`** — SMS verification: buy virtual phone numbers, receive verification codes, finish/cancel orders (5SIM)
490
+ - **`guides/provider.md`** — Provider management: create/update services, About/changelog, version lifecycle, metrics/events and request receipts, Skill upload/linking, rollback/delete, earnings transfer
492
491
 
493
492
  ## Security
494
493
 
@@ -0,0 +1,198 @@
1
+ # Provider service management
2
+
3
+ Read this guide for provider-side service authoring and operations. These
4
+ commands use `XAPI-KEY` directly and never exchange it for a broad JWT session.
5
+ The key must belong to the service owner and carry the scope named by the
6
+ operation.
7
+
8
+ ## Inspect capabilities and scopes
9
+
10
+ ```bash
11
+ npx xapi-to provider --help
12
+ npx xapi-to skill --help
13
+ npx xapi-to skill spec
14
+ ```
15
+
16
+ Use narrowly scoped keys. Common scopes are:
17
+
18
+ - `service:create`, `service:read`, `service:update`
19
+ - `version:create`, `service:publish`, `service:rollback`
20
+ - `observability:read`
21
+ - `skill:read`, `skill:submit`
22
+ - `earnings:read`; `earnings:transfer` only when reinvestment is intended
23
+ - `service:delete` only for deliberate removal workflows
24
+
25
+ Scope permission and ownership are independent. A key with a scope still cannot
26
+ manage another provider's service or Skill.
27
+
28
+ ## Create and describe a service
29
+
30
+ Create uses the backend service DTO as JSON so credentials and endpoint
31
+ contracts do not have to appear in shell history:
32
+
33
+ ```bash
34
+ npx xapi-to provider create --file ./service.json
35
+ npx xapi-to provider list
36
+ npx xapi-to provider get <service-id>
37
+ ```
38
+
39
+ Keep the three service content layers distinct:
40
+
41
+ - `description` is the short marketplace-card summary.
42
+ - `aboutMarkdown` is the long About tab.
43
+ - `website` is a public HTTP(S) link.
44
+
45
+ Prefer files for long text:
46
+
47
+ ```bash
48
+ npx xapi-to provider update <service-id> \
49
+ --description "Short marketplace summary" \
50
+ --about-file ./ABOUT.md \
51
+ --website https://example.com
52
+ ```
53
+
54
+ Use `--clear-about` or `--clear-website` to clear a value. Provider metadata
55
+ updates cannot modify the version contract or upstream credentials; use the
56
+ version command for those fields.
57
+
58
+ ## Edit and publish a revision
59
+
60
+ ```bash
61
+ # Inspect current majors and revisions
62
+ npx xapi-to provider versions <service-id>
63
+
64
+ # Either create a new major or pull a working revision from an existing major
65
+ npx xapi-to provider major create <service-id>
66
+ npx xapi-to provider revision start <service-id> <major>
67
+
68
+ # Merge a partial version-contract update; add --replace for full replacement
69
+ npx xapi-to provider version update \
70
+ <service-id> <version-id> --file ./contract.json
71
+
72
+ # Inspect before publishing
73
+ npx xapi-to provider diff <service-id> <major>
74
+
75
+ # Submit through the normal review gate with public release notes
76
+ npx xapi-to provider publish \
77
+ <service-id> <revision-id> --changelog-file ./CHANGELOG.md
78
+
79
+ # Inspect the review result
80
+ npx xapi-to provider review <service-id> <revision-id>
81
+ ```
82
+
83
+ The changelog is provider-authored, public release information associated with
84
+ that revision. It is not a system deployment log. Build, review, and runtime
85
+ events are generated by the platform and should only be read, never uploaded as
86
+ if they were evidence.
87
+
88
+ `publish` can change live service behavior after review. The CLI does not
89
+ automatically retry this write after an ambiguous transport failure. Read the
90
+ version overview and review state before deciding whether to submit again.
91
+
92
+ ## Create, upload, and link the usage Skill
93
+
94
+ Generate a service-specific starting point from the currently serving endpoints:
95
+
96
+ ```bash
97
+ npx xapi-to provider skill scaffold \
98
+ <service-id> --output ./my-service/SKILL.md
99
+ ```
100
+
101
+ The scaffold command refuses to overwrite an existing file unless `--force` is
102
+ explicitly supplied. Complete the instructions and metadata, then submit either
103
+ a local directory or a public GitHub tree:
104
+
105
+ ```bash
106
+ npx xapi-to skill submit --dir ./my-service
107
+
108
+ npx xapi-to skill submit \
109
+ --github https://github.com/org/repo/tree/main/skills/my-service \
110
+ --version 1.0.0
111
+ ```
112
+
113
+ Local submission skips symlinks, `.git`, and `node_modules`; requires a root
114
+ `SKILL.md`; permits at most 100 files; limits each file to 512 KiB and the encoded
115
+ package to 2 MiB. The server still performs manifest validation and secret
116
+ scanning. A successful upload creates or updates the owned Skill version and
117
+ submits it for review; it is not immediately public.
118
+
119
+ Use the returned submission ID:
120
+
121
+ ```bash
122
+ npx xapi-to skill status <submission-id>
123
+ npx xapi-to skill wait <submission-id> --timeout 10m
124
+ ```
125
+
126
+ After the Skill is published, bind it as the service's primary tutorial and
127
+ record the serving-contract fingerprint:
128
+
129
+ ```bash
130
+ npx xapi-to provider skill link <service-id> <skill-id>
131
+ npx xapi-to provider skill fingerprint \
132
+ <service-id> --skill-version-id <skill-version-id>
133
+ npx xapi-to provider skill context <service-id>
134
+ ```
135
+
136
+ Only a Skill owned by the same provider can be linked, and one Skill can be the
137
+ primary Skill of only one service. The backend permits linking a pending Skill,
138
+ but the public marketplace exposes only a published version; wait for publication
139
+ unless intentionally preparing the association early. Use `provider skill unlink`
140
+ to remove the primary association. The context response reports drift when host,
141
+ major version, or serving endpoints no longer match the stored fingerprint; update
142
+ and resubmit the Skill rather than merely overwriting the fingerprint.
143
+
144
+ ## Observe and recover
145
+
146
+ ```bash
147
+ npx xapi-to provider metrics --days 30
148
+ npx xapi-to provider metrics <service-id> --days 7
149
+ npx xapi-to provider events --limit 50
150
+ npx xapi-to provider events --after '<opaque-next-cursor>' --limit 50
151
+ ```
152
+
153
+ Pass event cursors back unchanged. Metrics and events are owner-scoped; usage
154
+ events are also restricted to the current key where applicable.
155
+
156
+ Verify the finalized cost of a canary or provider call with its receipt ID. Use
157
+ the `X-XAPI-Request-Id` response header or the final `xapi.usage` SSE event, and
158
+ wait when asynchronous billing has not finalized yet:
159
+
160
+ ```bash
161
+ npx xapi-to usage <request-id>
162
+ npx xapi-to usage wait <request-id> --timeout 1m
163
+ ```
164
+
165
+ Receipt reads are idempotent. The wait command applies one total deadline,
166
+ retries not-found and transient transport failures within that deadline, and
167
+ fails immediately for permanent authorization or validation errors.
168
+
169
+ Rollback and default-major changes affect live routing:
170
+
171
+ ```bash
172
+ npx xapi-to provider rollback \
173
+ <service-id> <major> --revision <published-revision-id> \
174
+ --reason "Restore the last known-good contract"
175
+ npx xapi-to provider default-major <service-id> <major>
176
+ npx xapi-to provider deprecate <service-id> <major>
177
+ npx xapi-to provider restore <service-id> <major>
178
+ ```
179
+
180
+ Inspect the target revision before rollback. Do not automatically retry an
181
+ ambiguous rollback response. Deletion requires both `service:delete` and an
182
+ explicit service name or ID confirmation:
183
+
184
+ ```bash
185
+ npx xapi-to provider delete <service-id> --confirm <service-name-or-id>
186
+ ```
187
+
188
+ ## Earnings reinvestment
189
+
190
+ ```bash
191
+ npx xapi-to earnings
192
+ npx xapi-to earnings list --status SETTLED
193
+ npx xapi-to earnings transfer 1 --idempotency-key <stable-operation-key>
194
+ ```
195
+
196
+ Transfer is one-way: it converts settled provider earnings into spendable xAPI
197
+ balance. Confirm the amount and available settled balance first. A transfer may
198
+ be retried only with the same idempotency key and the same amount.
@@ -7,32 +7,45 @@ not an ordinary per-call action, so cleanup and audit are part of task success.
7
7
 
8
8
  ## Contents
9
9
 
10
+ - [Product boundary](#product-boundary)
10
11
  - [Choose the shortest safe lifecycle](#choose-the-shortest-safe-lifecycle)
11
12
  - [Authentication and gateway selection](#authentication-and-gateway-selection)
12
13
  - [Inspect offerings and quote first](#inspect-offerings-and-quote-first)
13
14
  - [One-shot execution](#one-shot-execution)
14
- - [Multi-step agent lifecycle](#multi-step-agent-lifecycle)
15
+ - [Multi-step client lifecycle](#multi-step-client-lifecycle)
15
16
  - [Files and artifacts](#files-and-artifacts)
16
17
  - [Web preview and background processes](#web-preview-and-background-processes)
17
18
  - [Suspend and resume](#suspend-and-resume)
18
19
  - [GPU jobs](#gpu-jobs)
19
- - [Parallel agents](#parallel-agents)
20
- - [OpenAI SandboxAgent with xAPI DeepSeek](#openai-sandboxagent-with-xapi-deepseek)
20
+ - [Parallel isolated instances](#parallel-isolated-instances)
21
+ - [OpenAI SandboxAgent integration example](#openai-sandboxagent-integration-example)
21
22
  - [Audit, history, and billing](#audit-history-and-billing)
22
- - [Run the real Playground acceptance suite](#run-the-real-playground-acceptance-suite)
23
+ - [Run the real Playground recipe acceptance suite](#run-the-real-playground-recipe-acceptance-suite)
23
24
  - [Failure and interruption recovery](#failure-and-interruption-recovery)
24
25
  - [AI operating rules](#ai-operating-rules)
25
26
 
27
+ ## Product boundary
28
+
29
+ xAPI is the Sandbox resource and capability provider. The CLI is a thin client
30
+ for discovery, quote, lifecycle, exec, files, ports, provider extensions,
31
+ history, audit, and billing. It does not implement prompts, model loops, memory,
32
+ multi-agent orchestration, job DAGs, queues, or human approval workflows.
33
+
34
+ Agent, CI, browser, and data examples in this guide are client-side recipes that
35
+ compose Sandbox primitives. They are not additional Gateway workflow APIs.
36
+ Provider-native features remain valid Sandbox extensions when the live Offering
37
+ declares their schemas, limits, state effects, and billing behavior.
38
+
26
39
  ## Choose the shortest safe lifecycle
27
40
 
28
- | Need | Preferred command | Cleanup behavior |
29
- |---|---|---|
30
- | Run one command and get stdout | `sandbox run` | Terminates automatically |
31
- | Several exec/file calls | `create` + primitives | Agent must terminate |
32
- | Inspect price/capabilities | `offerings`, `quote` | No instance created |
33
- | Publish a temporary port | `port` after starting a server | Terminate afterward |
34
- | Pause a reusable workspace | `suspend` | Storage may keep billing |
35
- | Inspect prior work/cost | `history`, `get`, `audit` | Read-only |
41
+ | Need | Preferred command | Cleanup behavior |
42
+ | ------------------------------ | ------------------------------ | ------------------------ |
43
+ | Run one command and get stdout | `sandbox run` | Terminates automatically |
44
+ | Several exec/file calls | `create` + primitives | Agent must terminate |
45
+ | Inspect price/capabilities | `offerings`, `quote` | No instance created |
46
+ | Publish a temporary port | `port` after starting a server | Terminate afterward |
47
+ | Pause a reusable workspace | `suspend` | Storage may keep billing |
48
+ | Inspect prior work/cost | `history`, `get`, `audit` | Read-only |
36
49
 
37
50
  Prefer `sandbox run` whenever the task fits one remote shell command. A shorter
38
51
  lifecycle reduces orphan risk and returns one machine-readable JSON result.
@@ -79,6 +92,7 @@ npx xapi-to sandbox quote \
79
92
  --capabilities exec,files \
80
93
  --cpu 2 \
81
94
  --memory 4 \
95
+ --min-runtime 24h \
82
96
  --max-hourly-usd 0.20 \
83
97
  --format pretty
84
98
  ```
@@ -121,9 +135,9 @@ shells and AI runners can detect failure without parsing stdout.
121
135
  `--keep` suppresses automatic termination. Use it only after the user explicitly
122
136
  asks to retain the instance and understands that billing continues.
123
137
 
124
- ## Multi-step agent lifecycle
138
+ ## Multi-step client lifecycle
125
139
 
126
- Use granular commands when an agent must alternate between files and commands.
140
+ Use granular commands when a client must alternate between files and commands.
127
141
  Capture the instance ID without logging credentials:
128
142
 
129
143
  ```bash
@@ -205,15 +219,17 @@ port response contains `headers`, include them in external requests; they can
205
219
  carry a provider preview token. Do not emulate this mode with `nohup ... &` on
206
220
  an Offering that does not declare `backgroundExec`.
207
221
 
208
- Cloudflare currently uses its provider-specific command/preview behavior rather
209
- than the standard background session capability. Pin `cf-edge` only when the
210
- user explicitly wants Cloudflare. Port `8080` is the currently verified preview
211
- path for the deployed bridge:
222
+ Cloudflare declares the standard background command surface and maps it to its
223
+ native managed process API. Pin `cf-edge` only when the user explicitly wants
224
+ Cloudflare. For a one-day workspace, require `--min-runtime 24h` and explicitly
225
+ enable `cloudflare.set_keep_alive`; the runtime requirement filters offerings,
226
+ while keepAlive prevents the default ten-minute idle reset:
212
227
 
213
228
  ```bash
214
229
  box_json="$(npx xapi-to sandbox create \
215
230
  --provider cf-edge \
216
- --capabilities exec,files,ports \
231
+ --capabilities exec,backgroundExec,files,ports,lifecycle.keep_alive \
232
+ --min-runtime 24h \
217
233
  --wait)"
218
234
  box_id="$(printf '%s' "$box_json" | jq -r '.id')"
219
235
  port=8080
@@ -225,11 +241,11 @@ npx xapi-to sandbox file write "$box_id" index.html \
225
241
  --provider cf-edge \
226
242
  --content '<!doctype html><h1>xAPI preview</h1>'
227
243
 
228
- npx xapi-to sandbox exec "$box_id" --provider cf-edge --command \
229
- "nohup python3 -m http.server $port >/tmp/server.log 2>&1 & \
230
- for i in 1 2 3 4 5 6 7 8 9 10; do \
231
- curl -sf http://127.0.0.1:$port/ && exit 0; sleep 1; done; \
232
- cat /tmp/server.log >&2; exit 1"
244
+ npx xapi-to sandbox extension "$box_id" cloudflare.set_keep_alive \
245
+ --provider cf-edge --input '{"keepAlive":true}'
246
+
247
+ npx xapi-to sandbox exec "$box_id" --provider cf-edge --background --command \
248
+ "python3 -m http.server $port --directory /workspace"
233
249
 
234
250
  npx xapi-to sandbox port "$box_id" "$port" --provider cf-edge
235
251
  ```
@@ -242,6 +258,37 @@ terminate instead of leaving the instance billing. The URL stops working after
242
258
  termination. Quick Tunnels are for previews; use a stable, supported named
243
259
  tunnel or application deployment for production traffic.
244
260
 
261
+ Cloudflare extensions also expose managed process logs/readiness, persistent
262
+ shell sessions, stateful Python/JavaScript/TypeScript code contexts, Git
263
+ checkout, file-change cursors, bucket mounts, and R2 backup/restore. Inspect
264
+ `capabilities.extensionIds` before calling them. A default idle reset starts a
265
+ fresh container and does not retain files, processes, sessions, or interpreter
266
+ state. `keepAlive` removes that idle cutoff but does not guarantee that platform
267
+ maintenance can never restart the host. Use R2 backup or external storage for
268
+ state that must survive restarts, disable keepAlive in `finally`, and terminate.
269
+
270
+ If the Offering declares `cloudflare.browser.*`, Cloudflare Browser Run can be
271
+ used through the same generic extension command. Prefer a Quick Action for
272
+ read-only page understanding before paying for a multi-step browser session:
273
+
274
+ ```bash
275
+ npx xapi-to sandbox extension "$box_id" cloudflare.browser.snapshot \
276
+ --provider cf-edge \
277
+ --input '{"url":"https://example.com/","formats":["screenshot","markdown","accessibilityTree"]}'
278
+
279
+ npx xapi-to sandbox extension "$box_id" cloudflare.browser.automate \
280
+ --provider cf-edge \
281
+ --input '{"url":"https://demo.playwright.dev/todomvc/","actions":[{"type":"fill","selector":".new-todo","value":"xAPI task"},{"type":"press","selector":".new-todo","key":"Enter"}],"extract":[{"name":"todos","selector":".todo-list li label","all":true}],"screenshot":{"type":"png","fullPage":true}}'
282
+ ```
283
+
284
+ Browser Run and the Sandbox container do not share a filesystem. Write the
285
+ returned page data into the sandbox through `sandbox file write` if a later
286
+ container command must analyze it. Browser operations have separate operation
287
+ prices; inspect the quote instead of assuming the container hourly cap includes
288
+ them. The current automation extension is one-shot and closes the browser.
289
+ Persistent CDP/Live View/HITL are not supported until xAPI owns and authorizes
290
+ the session and proxies its WebSocket without exposing Cloudflare credentials.
291
+
245
292
  ## Suspend and resume
246
293
 
247
294
  Check offering lifecycle fields first because not every provider supports an
@@ -290,58 +337,65 @@ npx xapi-to sandbox terminate <id> --provider runpod
290
337
  GPU work is usually more expensive. Quote first, set a deliberate ceiling, use
291
338
  a command timeout, and terminate immediately after artifacts are retrieved.
292
339
 
293
- ## Parallel agents
340
+ ## Parallel isolated instances
294
341
 
295
- Give each agent a separate instance. Do not share a mutable workspace when the
296
- goal is isolation. Use unique idempotency keys and record every instance ID.
342
+ Give each concurrent worker or agent a separate instance. Do not share a mutable
343
+ workspace when the goal is isolation. Use unique idempotency keys and record every instance ID.
297
344
  Run cleanup for all IDs even if one agent fails; then verify `sandbox list` has
298
345
  no active instance from the job.
299
346
 
300
347
  Limit concurrency based on budget. Parallel creation multiplies reservation and
301
348
  running cost, even when the individual hourly quote is small.
302
349
 
303
- ## OpenAI SandboxAgent with xAPI DeepSeek
350
+ ## OpenAI SandboxAgent integration example
304
351
 
305
352
  The OpenAI Agents SDK keeps the model provider and sandbox provider separate.
306
353
  Use the SDK's OpenAI-compatible model provider for DeepSeek through
307
354
  `https://ai.xapi.to/v1`, and the xAPI adapter for Sandbox compute:
308
355
 
356
+ The Agents SDK owns the model loop, tool choice, prompt, and conversation state.
357
+ xAPI owns only the Sandbox resource, execution, lifecycle, audit, and billing.
358
+
309
359
  ```ts
310
- import { OpenAIProvider, Runner } from '@openai/agents';
311
- import { Manifest, SandboxAgent, shell } from '@openai/agents/sandbox';
312
- import { XapiAgentsSandboxClient } from 'xapi-to/openai-sandbox';
360
+ import { OpenAIProvider, Runner } from "@openai/agents";
361
+ import { Manifest, SandboxAgent, shell } from "@openai/agents/sandbox";
362
+ import { XapiAgentsSandboxClient } from "xapi-to/openai-sandbox";
313
363
 
314
364
  const sandboxApiKey = process.env.XAPI_SANDBOX_KEY;
315
365
  const aiApiKey = process.env.XAPI_AI_KEY;
316
- if (!sandboxApiKey) throw new Error('XAPI_SANDBOX_KEY is required');
317
- if (!aiApiKey) throw new Error('XAPI_AI_KEY is required');
366
+ if (!sandboxApiKey) throw new Error("XAPI_SANDBOX_KEY is required");
367
+ if (!aiApiKey) throw new Error("XAPI_AI_KEY is required");
318
368
 
319
369
  const sandbox = new XapiAgentsSandboxClient({
320
370
  apiKey: sandboxApiKey,
321
- sandboxHost: 'sandbox.test.xapi.to',
322
- provider: 'daytona',
323
- model: 'deepseek-v4-pro',
371
+ sandboxHost: "sandbox.test.xapi.to",
372
+ provider: "daytona",
373
+ model: "deepseek-v4-pro",
324
374
  });
325
375
  const modelProvider = new OpenAIProvider({
326
376
  apiKey: aiApiKey,
327
- baseURL: 'https://ai.xapi.to/v1',
377
+ baseURL: "https://ai.xapi.to/v1",
328
378
  useResponses: false,
329
379
  strictFeatureValidation: true,
330
380
  });
331
381
  const runner = new Runner({ modelProvider, tracingDisabled: true });
332
382
  const agent = new SandboxAgent({
333
- name: 'xAPI DeepSeek sandbox agent',
334
- model: 'deepseek-v4-pro',
383
+ name: "xAPI DeepSeek sandbox agent",
384
+ model: "deepseek-v4-pro",
335
385
  defaultManifest: new Manifest({ root: sandbox.workspaceRoot }),
336
386
  capabilities: [shell()],
337
- instructions: 'Use shell to complete and verify the task.',
387
+ instructions: "Use shell to complete and verify the task.",
338
388
  });
339
389
 
340
390
  try {
341
- const result = await runner.run(agent, 'Write SDK_OK=42 to result.txt and read it.', {
342
- maxTurns: 8,
343
- sandbox: { client: sandbox },
344
- });
391
+ const result = await runner.run(
392
+ agent,
393
+ "Write SDK_OK=42 to result.txt and read it.",
394
+ {
395
+ maxTurns: 8,
396
+ sandbox: { client: sandbox },
397
+ },
398
+ );
345
399
  console.log(result.finalOutput);
346
400
  } finally {
347
401
  await sandbox.lastSession?.close();
@@ -406,9 +460,9 @@ For acceptance, verify:
406
460
 
407
461
  Use the returned billing data rather than recomputing cost from wall-clock time.
408
462
 
409
- ## Run the real Playground acceptance suite
463
+ ## Run the real Playground recipe acceptance suite
410
464
 
411
- From an xapi-cli development checkout, run the same nine real workflows shown
465
+ From an xapi-cli development checkout, run the same nine client recipes shown
412
466
  in the Web Playground. The suite uses normal CLI configuration, never accepts a
413
467
  key on argv, records audit/billing evidence, terminates every tracked instance
414
468
  in `finally`, and fails if any instance created after its baseline remains
package/src/client.ts CHANGED
@@ -26,6 +26,13 @@ export interface ClientOptions {
26
26
  apiKey?: string;
27
27
  }
28
28
 
29
+ export interface ApiKeyApiRequestOptions {
30
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
31
+ body?: unknown;
32
+ timeoutMs?: number;
33
+ retries?: number;
34
+ }
35
+
29
36
  export class HttpError extends Error {
30
37
  constructor(
31
38
  public readonly status: number,
@@ -100,8 +107,28 @@ function parseRetryAfterMs(res: Response): number | undefined {
100
107
  return Number.isFinite(at) ? Math.max(0, at - Date.now()) : undefined;
101
108
  }
102
109
 
103
- function sleep(ms: number): Promise<void> {
104
- return new Promise((resolve) => setTimeout(resolve, ms));
110
+ function abortError(signal?: AbortSignal | null): Error {
111
+ const reason = signal?.reason;
112
+ return reason instanceof Error
113
+ ? reason
114
+ : new DOMException('The operation was aborted', 'AbortError');
115
+ }
116
+
117
+ function sleep(ms: number, signal?: AbortSignal | null): Promise<void> {
118
+ if (signal?.aborted) return Promise.reject(abortError(signal));
119
+ return new Promise((resolve, reject) => {
120
+ let timer: ReturnType<typeof setTimeout> | undefined;
121
+ const onAbort = () => {
122
+ if (timer !== undefined) clearTimeout(timer);
123
+ signal?.removeEventListener('abort', onAbort);
124
+ reject(abortError(signal));
125
+ };
126
+ timer = setTimeout(() => {
127
+ signal?.removeEventListener('abort', onAbort);
128
+ resolve();
129
+ }, ms);
130
+ signal?.addEventListener('abort', onAbort, { once: true });
131
+ });
105
132
  }
106
133
 
107
134
  export async function request<T>(
@@ -138,7 +165,7 @@ export async function request<T>(
138
165
  if (isRetryableStatus(res.status) && attempt < retries) {
139
166
  await res.text().catch(() => ''); // drain body so the socket can be reused
140
167
  clearTimeout(timer);
141
- await sleep(backoffDelayMs(attempt, retryAfterMs));
168
+ await sleep(backoffDelayMs(attempt, retryAfterMs), callerSignal);
142
169
  attempt++;
143
170
  continue;
144
171
  }
@@ -174,7 +201,7 @@ export async function request<T>(
174
201
  if (timedOut) {
175
202
  const timeoutError = new RequestTimeoutError(timeoutMs);
176
203
  if (attempt < retries) {
177
- await sleep(backoffDelayMs(attempt));
204
+ await sleep(backoffDelayMs(attempt), callerSignal);
178
205
  attempt++;
179
206
  continue;
180
207
  }
@@ -182,7 +209,7 @@ export async function request<T>(
182
209
  }
183
210
  if (isRetryableNetworkError(e) && attempt < retries) {
184
211
  clearTimeout(timer);
185
- await sleep(backoffDelayMs(attempt));
212
+ await sleep(backoffDelayMs(attempt), callerSignal);
186
213
  attempt++;
187
214
  continue;
188
215
  }
@@ -200,6 +227,30 @@ function headers(apiKey?: string): Record<string, string> {
200
227
  return h;
201
228
  }
202
229
 
230
+ /** Call a scoped API-Key control-plane endpoint without exchanging for a JWT. */
231
+ export function apiKeyApiRequest<T>(
232
+ apiHost: string,
233
+ apiKey: string,
234
+ path: string,
235
+ options: ApiKeyApiRequestOptions = {},
236
+ ): Promise<T> {
237
+ const method = options.method ?? 'GET';
238
+ const requestHeaders: Record<string, string> = { 'XAPI-KEY': apiKey };
239
+ if (options.body !== undefined) requestHeaders['Content-Type'] = 'application/json';
240
+ return request<T>(
241
+ `${scheme(apiHost)}://${apiHost}${path}`,
242
+ {
243
+ method,
244
+ headers: requestHeaders,
245
+ ...(options.body !== undefined
246
+ ? { body: JSON.stringify(options.body) }
247
+ : {}),
248
+ },
249
+ options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
250
+ options.retries ?? 0,
251
+ );
252
+ }
253
+
203
254
  function baseUrl(opts: ClientOptions): string {
204
255
  return `${scheme(opts.actionHost)}://${opts.actionHost}`;
205
256
  }
@@ -26,6 +26,7 @@ export interface SandboxRequirements {
26
26
  gpu?: { count?: number; model?: string };
27
27
  regions?: string[];
28
28
  capabilities?: string[];
29
+ minContinuousRuntimeSeconds?: number;
29
30
  [key: string]: unknown;
30
31
  }
31
32
 
@@ -263,24 +264,43 @@ export async function sandboxWait(
263
264
  signal?: AbortSignal,
264
265
  ): Promise<SandboxDetail> {
265
266
  const deadline = Date.now() + timeoutMs;
267
+ const deadlineController = new AbortController();
268
+ const abortFromCaller = () => deadlineController.abort();
269
+ const deadlineTimer = setTimeout(() => deadlineController.abort(), Math.max(0, timeoutMs));
270
+ if (signal?.aborted) deadlineController.abort();
271
+ else signal?.addEventListener('abort', abortFromCaller, { once: true });
266
272
  let last: SandboxDetail | undefined;
267
- while (Date.now() < deadline) {
268
- if (signal?.aborted) throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`);
269
- last = await sandboxGet(opts, id, signal);
270
- const state = String(last.observedState || '');
271
- if (wanted.includes(state)) return last;
272
- if (['FAILED', 'TERMINATED'].includes(state) && !wanted.includes(state)) {
273
- throw new Error(`sandbox ${id} entered ${state} while waiting for ${wanted.join(' or ')}`);
273
+ try {
274
+ while (Date.now() < deadline) {
275
+ if (signal?.aborted) throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`);
276
+ try {
277
+ last = await sandboxGet(opts, id, deadlineController.signal);
278
+ } catch (error) {
279
+ if (signal?.aborted) {
280
+ throw new Error(`sandbox wait interrupted while waiting for ${wanted.join(' or ')}`);
281
+ }
282
+ if (Date.now() >= deadline) break;
283
+ throw error;
284
+ }
285
+ if (Date.now() >= deadline) break;
286
+ const state = String(last.observedState || '');
287
+ if (wanted.includes(state)) return last;
288
+ if (['FAILED', 'TERMINATED'].includes(state) && !wanted.includes(state)) {
289
+ throw new Error(`sandbox ${id} entered ${state} while waiting for ${wanted.join(' or ')}`);
290
+ }
291
+ await new Promise<void>((resolve) => {
292
+ const done = () => {
293
+ clearTimeout(timer);
294
+ signal?.removeEventListener('abort', done);
295
+ resolve();
296
+ };
297
+ const timer = setTimeout(done, Math.min(intervalMs, Math.max(0, deadline - Date.now())));
298
+ signal?.addEventListener('abort', done, { once: true });
299
+ });
274
300
  }
275
- await new Promise<void>((resolve) => {
276
- const done = () => {
277
- clearTimeout(timer);
278
- signal?.removeEventListener('abort', done);
279
- resolve();
280
- };
281
- const timer = setTimeout(done, Math.min(intervalMs, Math.max(0, deadline - Date.now())));
282
- signal?.addEventListener('abort', done, { once: true });
283
- });
301
+ } finally {
302
+ clearTimeout(deadlineTimer);
303
+ signal?.removeEventListener('abort', abortFromCaller);
284
304
  }
285
305
  throw new Error(
286
306
  `sandbox ${id} did not enter ${wanted.join(' or ')} within ${timeoutMs}ms` +