mandala-computer 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.github/workflows/ci.yml +17 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.gitignore +3 -0
- mandala_computer-0.4.0/CHANGELOG.md +78 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/PKG-INFO +290 -64
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/README.md +289 -63
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/pyproject.toml +1 -1
- mandala_computer-0.4.0/scripts/check_internals.py +215 -0
- mandala_computer-0.4.0/scripts/check_surface.py +546 -0
- mandala_computer-0.4.0/scripts/internal-names.sha256 +2550 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/__init__.py +1 -1
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_agent.py +26 -8
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_api.py +72 -51
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_async_computer.py +87 -25
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_async_resources.py +19 -1
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_cli.py +2 -3
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_client.py +48 -21
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_computer.py +227 -40
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_events.py +3 -4
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_exceptions.py +41 -8
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_models.py +21 -15
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_resources.py +24 -6
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_sse.py +22 -8
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/_webhooks.py +25 -17
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/surface_tables.py +2 -1
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_adversarial_values.py +38 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_agent.py +111 -3
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_async_client.py +75 -6
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_building.py +27 -0
- mandala_computer-0.4.0/tests/test_check_internals.py +144 -0
- mandala_computer-0.4.0/tests/test_check_surface.py +581 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_cli.py +2 -2
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_client.py +158 -32
- mandala_computer-0.4.0/tests/test_guest_deadline.py +194 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_sse.py +64 -0
- mandala_computer-0.4.0/tests/test_sse_transport_chunks.py +117 -0
- mandala_computer-0.4.0/tests/test_start.py +214 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_surface.py +17 -13
- mandala_computer-0.4.0/tests/test_template_transfer.py +270 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_templates.py +9 -8
- mandala_computer-0.2.0/scripts/check_surface.py +0 -653
- mandala_computer-0.2.0/scripts/surface_text.py +0 -232
- mandala_computer-0.2.0/tests/test_check_surface.py +0 -345
- mandala_computer-0.2.0/tests/test_surface_parser.py +0 -258
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/.github/workflows/release.yml +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/LICENSE +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/SECURITY.md +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/scripts/smoke_events.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/src/mandala_computer/py.typed +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/surface_inventory.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_api_contract.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_async_building.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_events.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_moves.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_parity.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_retention.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_smoke_events.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_surface_inventory.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_usage.py +0 -0
- {mandala_computer-0.2.0 → mandala_computer-0.4.0}/tests/test_webhooks.py +0 -0
|
@@ -19,6 +19,10 @@ jobs:
|
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
steps:
|
|
21
21
|
- uses: actions/checkout@v4
|
|
22
|
+
with:
|
|
23
|
+
# The commit-message half of check_internals needs the base branch to
|
|
24
|
+
# diff against; the default shallow clone has no origin/main.
|
|
25
|
+
fetch-depth: 0
|
|
22
26
|
- uses: actions/setup-python@v5
|
|
23
27
|
with:
|
|
24
28
|
python-version: "3.12"
|
|
@@ -27,6 +31,19 @@ jobs:
|
|
|
27
31
|
- run: ruff check .
|
|
28
32
|
- run: ruff format --check .
|
|
29
33
|
- run: mypy
|
|
34
|
+
# This repo is public and the platform it speaks to is not. Every batch of
|
|
35
|
+
# client fixes is written by reading that platform, and the reading leaves
|
|
36
|
+
# fingerprints — file names, internal function names — in comments that
|
|
37
|
+
# SHIP. OPL-4613 and OPL-4617 scrubbed two of these repos by hand and left
|
|
38
|
+
# nothing behind to catch the next one; this is that (OPL-4635).
|
|
39
|
+
#
|
|
40
|
+
# Both halves. A commit message is public the moment it is pushed, and
|
|
41
|
+
# three of the five references found in the batch that prompted this were
|
|
42
|
+
# in messages and PR descriptions rather than in files — which the file
|
|
43
|
+
# scan cannot see. Needs the full history, hence fetch-depth above.
|
|
44
|
+
- run: python3 scripts/check_internals.py
|
|
45
|
+
- run: python3 scripts/check_internals.py --messages origin/${{ github.event.pull_request.base.ref || 'main' }}..HEAD
|
|
46
|
+
if: github.event_name == 'pull_request'
|
|
30
47
|
|
|
31
48
|
test:
|
|
32
49
|
runs-on: ubuntu-latest
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Notable changes to `mandala-computer`. Dates are release dates; the format
|
|
4
|
+
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
|
5
|
+
project is pre-1.0, so a minor version may carry a behaviour change.
|
|
6
|
+
|
|
7
|
+
The reasoning behind a change lives in its commit message rather than here.
|
|
8
|
+
This is the summary you read to decide whether to upgrade.
|
|
9
|
+
|
|
10
|
+
## [0.4.0] — 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`Computer.read_text_file()` and `AsyncComputer.read_text_file()`** — the
|
|
15
|
+
same request `read_file()` makes, decoded as UTF-8. `read_file()` stays the
|
|
16
|
+
primitive and stays `bytes`, because a guest file is not promised to be text
|
|
17
|
+
and decoding one that is not would put replacement characters at the SDK
|
|
18
|
+
boundary for every caller. The decode is `errors="replace"`, on the same
|
|
19
|
+
terms as `ExecResult.stdout_text`: the caller asked for text, and the bytes
|
|
20
|
+
are one call away.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **An empty clipboard write now clears the selection** instead of being
|
|
25
|
+
refused locally. `set_clipboard("")` is a thing the platform accepts and a
|
|
26
|
+
thing callers want; refusing it here was this SDK inventing a rule.
|
|
27
|
+
- **`reason: "revoked"` is classified as permanent.** The platform added a
|
|
28
|
+
fifth refusal word for the case where the authority a request arrived with no
|
|
29
|
+
longer holds — suspended, demoted, removed, or a retired session — and it is
|
|
30
|
+
the first of these words about the *caller* rather than about a computer.
|
|
31
|
+
Nothing was broken before this: a 401 or 403 is none of the four transient
|
|
32
|
+
classes, so an unrecognised word already answered `False` from
|
|
33
|
+
`is_transient()`. What changes is that it is answered on purpose, so a future
|
|
34
|
+
status for this refusal cannot quietly make a permission failure look
|
|
35
|
+
replayable. The status still says what to do: 401 means present a credential
|
|
36
|
+
again, 403 means the role changed and signing in again will not help.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **A mid-run refusal no longer discards the partial agent run.** Authorization
|
|
41
|
+
is rechecked before each further piece of work, so a non-streaming
|
|
42
|
+
`agent()` can be stopped after steps have already run on the desktop and
|
|
43
|
+
already cost model tokens — and the refusal body is the only place either is
|
|
44
|
+
ever reported. Those steps and that usage now survive the error.
|
|
45
|
+
- **The documented install works on a Homebrew or distribution Python.** The
|
|
46
|
+
README opened with `pip install mandala-computer`, which answers
|
|
47
|
+
`externally-managed-environment` and installs nothing on the ordinary
|
|
48
|
+
developer Mac (PEP 668). The install guidance now leads with a virtual
|
|
49
|
+
environment rather than with a command that fails.
|
|
50
|
+
|
|
51
|
+
### Documentation
|
|
52
|
+
|
|
53
|
+
- **A run's steps are not its model calls.** Three places said they were and
|
|
54
|
+
offered `max_steps` as a way to size Anthropic spend on that basis. The
|
|
55
|
+
platform counts a step per *tool call* and encourages a model to ask for
|
|
56
|
+
several actions in one reply, so one model call can spend several steps; a
|
|
57
|
+
paused turn is resubmitted for tokens and no step; and a bash call or a
|
|
58
|
+
cursor read takes no screenshot. A caller budgeting from the old sentence was
|
|
59
|
+
wrong whichever way their run went. The README also now states the default,
|
|
60
|
+
which nothing here did: omit `max_steps` and you get 20, and the ceiling
|
|
61
|
+
is 100.
|
|
62
|
+
- Webhook idempotency and API usage guidance clarified, and the template
|
|
63
|
+
section restored the two refusals on the custom-build path that never clear.
|
|
64
|
+
|
|
65
|
+
### Internal
|
|
66
|
+
|
|
67
|
+
No effect on the published surface, listed because it is most of the window.
|
|
68
|
+
|
|
69
|
+
- The drift check reads the platform's published **surface manifest** instead
|
|
70
|
+
of scanning its TypeScript and Go as text. Eleven hundred lines of
|
|
71
|
+
hand-written reader are gone, along with the class of defect they kept
|
|
72
|
+
producing: a false all-clear, reporting the mirror in step because the scan
|
|
73
|
+
had silently read nothing. The new reader fails closed on a manifest that is
|
|
74
|
+
missing, unparseable, of an unknown version, or that names a key twice.
|
|
75
|
+
- Surface inventory parsing hardened in the same direction, before the scanner
|
|
76
|
+
was retired.
|
|
77
|
+
|
|
78
|
+
[0.4.0]: https://github.com/mandalacomputer/python-sdk/compare/v0.3.0...v0.4.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mandala-computer
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Python SDK for Mandala Computer — cloud desktops for AI agents
|
|
5
5
|
Project-URL: Homepage, https://mandala.computer
|
|
6
6
|
Project-URL: Source, https://github.com/mandalacomputer/python-sdk
|
|
@@ -61,6 +61,28 @@ pip install mandala-computer
|
|
|
61
61
|
Python 3.10 or newer. The install also puts a `mandala` command on your PATH —
|
|
62
62
|
see [The `mandala` CLI](#the-mandala-cli).
|
|
63
63
|
|
|
64
|
+
If that command answers `error: externally-managed-environment`, nothing is
|
|
65
|
+
wrong: the Python you are asking is managed by something else — Homebrew on a
|
|
66
|
+
Mac, the distribution's package manager on Linux — and refuses to install into
|
|
67
|
+
itself ([PEP 668](https://peps.python.org/pep-0668/)). Install into a virtual
|
|
68
|
+
environment instead:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
python3 -m venv .venv
|
|
72
|
+
source .venv/bin/activate # fish: source .venv/bin/activate.fish
|
|
73
|
+
pip install mandala-computer
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
For the `mandala` command on your PATH rather than the library in a project,
|
|
77
|
+
let a tool manager own the environment:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
pipx install mandala-computer # or: uv tool install mandala-computer
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Do not reach for `--break-system-packages`. It does what it says to the Python
|
|
84
|
+
the rest of your system is depending on.
|
|
85
|
+
|
|
64
86
|
## Use
|
|
65
87
|
|
|
66
88
|
Authentication is an API key from the dashboard (Settings → API keys), read from
|
|
@@ -165,6 +187,76 @@ t = client.templates.publish(doc)
|
|
|
165
187
|
c = client.computers.create(template=t.ref)
|
|
166
188
|
```
|
|
167
189
|
|
|
190
|
+
When `create()` returns a 409 with `body["code"] == "template_image_preparing"`,
|
|
191
|
+
inspect `body["preparation"]`: it retains the server's `state` and `error`,
|
|
192
|
+
including failed preparation. A failure needs diagnosis before a deliberate
|
|
193
|
+
retry. `APIError.retry_after` preserves the `Retry-After` header as seconds,
|
|
194
|
+
including HTTP dates; absent or malformed metadata is `None`.
|
|
195
|
+
|
|
196
|
+
For an explicit continuation, wait the returned delay, repeat **all original
|
|
197
|
+
create arguments, including the same template**, and add the returned
|
|
198
|
+
`template_transfer` token. The token and template must be nonempty strings;
|
|
199
|
+
`size` cannot be combined with a token. An opaque token is sent unchanged,
|
|
200
|
+
including surrounding whitespace. A missing or different template can produce
|
|
201
|
+
`NotFoundError`. The token preserves the selected build, **not create
|
|
202
|
+
idempotency**: stop after success and never blindly retry an ambiguous or lost
|
|
203
|
+
response. `is_transient()` returns `False` for this preparation refusal,
|
|
204
|
+
including preparation still in progress.
|
|
205
|
+
|
|
206
|
+
This helper accepts only a preparation response with usable continuation
|
|
207
|
+
metadata. It surfaces failed preparation for diagnosis:
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
def preparation_retry(error):
|
|
211
|
+
body = error.body
|
|
212
|
+
if not isinstance(body, dict) or body.get("code") != "template_image_preparing":
|
|
213
|
+
raise error
|
|
214
|
+
preparation = body.get("preparation")
|
|
215
|
+
token = body.get("template_transfer")
|
|
216
|
+
if (
|
|
217
|
+
not isinstance(preparation, dict)
|
|
218
|
+
or preparation.get("state") not in ("preparing", "copying", "ready")
|
|
219
|
+
or not isinstance(token, str)
|
|
220
|
+
or not token.strip()
|
|
221
|
+
or error.retry_after is None
|
|
222
|
+
):
|
|
223
|
+
raise error
|
|
224
|
+
return error.retry_after, token
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A synchronous caller can make one deliberate continuation attempt:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
import time
|
|
231
|
+
from mandala_computer import ConflictError
|
|
232
|
+
|
|
233
|
+
original = {"template": t.ref, "name": "dev", "cpu": 4, "ram_mb": 8192}
|
|
234
|
+
try:
|
|
235
|
+
c = client.computers.create(**original)
|
|
236
|
+
except ConflictError as error:
|
|
237
|
+
delay, token = preparation_retry(error)
|
|
238
|
+
time.sleep(delay)
|
|
239
|
+
c = client.computers.create(**original, template_transfer=token)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The async equivalent uses the same original arguments and validation:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
import asyncio
|
|
246
|
+
from mandala_computer import ConflictError
|
|
247
|
+
|
|
248
|
+
original = {"template": t.ref, "name": "dev", "cpu": 4, "ram_mb": 8192}
|
|
249
|
+
try:
|
|
250
|
+
c = await client.computers.create(**original)
|
|
251
|
+
except ConflictError as error:
|
|
252
|
+
delay, token = preparation_retry(error)
|
|
253
|
+
await asyncio.sleep(delay)
|
|
254
|
+
c = await client.computers.create(**original, template_transfer=token)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
A second refusal is surfaced in both examples so the caller can inspect the
|
|
258
|
+
new state and decide whether another bounded continuation is appropriate.
|
|
259
|
+
|
|
168
260
|
**The namespace is your account.** `metadata.namespace` has to be your account
|
|
169
261
|
id — anything else is a `PermissionDeniedError`, `system` included — and this SDK
|
|
170
262
|
does not rewrite it, because publishing a ref that is not the one in your file
|
|
@@ -295,15 +387,35 @@ normally share an image, which is what makes a repeated build cheap;
|
|
|
295
387
|
`start(doc, no_reuse=True)` builds again even when an image already carries this
|
|
296
388
|
document's build digest.
|
|
297
389
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
390
|
+
After a successful build, launch the published template with
|
|
391
|
+
`client.computers.create(template=t.ref)`. A pinned template version selects
|
|
392
|
+
the latest successful image built from that exact document; an unpinned ref
|
|
393
|
+
first selects the newest published template version. Existing computers keep
|
|
394
|
+
their original image when a later build becomes available.
|
|
395
|
+
|
|
396
|
+
A create may need to prepare that image before it can launch. Follow the
|
|
397
|
+
[template preparation guidance](#your-own-templates) for a
|
|
398
|
+
`template_image_preparing` response: inspect its state and error, then make a
|
|
399
|
+
deliberate continuation with the returned delay and token and all original
|
|
400
|
+
create arguments. For other image refusals, inspect the response before deciding
|
|
401
|
+
whether to rebuild or retry.
|
|
402
|
+
|
|
403
|
+
Two of those refusals never clear, and both arrive as a `400` — a bare
|
|
404
|
+
`APIError` rather than the `UnavailableError` a `503` becomes, because a retry
|
|
405
|
+
loop reads a `503` as an answer worth waiting for and these are not. A document
|
|
406
|
+
that builds into a family this account may not build into is one: no hypervisor
|
|
407
|
+
will launch it, and what changes the answer is publishing a new version, not
|
|
408
|
+
sending the create again. A document that builds nothing and names an account
|
|
409
|
+
family is the other: a create is served only from the families that ship with
|
|
410
|
+
the product.
|
|
411
|
+
|
|
412
|
+
The refusals beside them mean something else. A `409` — `ConflictError` — says
|
|
413
|
+
no verified image for this exact document is available yet: finish the build,
|
|
414
|
+
or restore an image that has gone missing or is still being verified. A `503`
|
|
415
|
+
— `UnavailableError` — says no hypervisor has an image for that template *right
|
|
416
|
+
now*, which is the one to retry as it stands. A create can answer `503` for
|
|
417
|
+
capacity as well, and that one is worth a smaller size rather than only a
|
|
418
|
+
wait.
|
|
307
419
|
|
|
308
420
|
Everything here has an async twin: `await client.templates.publish(doc)`,
|
|
309
421
|
`await client.builds.wait(build.id)`, and `async for p in client.builds.events(...)`.
|
|
@@ -322,6 +434,22 @@ c.start() # resumes it; ~1s, not a boot
|
|
|
322
434
|
c.stop() # discards the session instead
|
|
323
435
|
```
|
|
324
436
|
|
|
437
|
+
`start(resume_only=True)` resumes a saved session without cold-booting a stopped
|
|
438
|
+
computer. If no saved session remains, the request succeeds and the computer
|
|
439
|
+
stays stopped. The method refreshes and returns the computer; check its state
|
|
440
|
+
when you need to know whether it is running. The async client accepts the same
|
|
441
|
+
keyword. Omit it (or pass `False`) for the ordinary start behavior.
|
|
442
|
+
|
|
443
|
+
After a stopped no-op, do not chain `wait_until_running()` or `wait_for_guest()`.
|
|
444
|
+
Both reject a stopped computer the platform reports it is holding nothing for,
|
|
445
|
+
which is the ordinary case here; where it reports nothing at all they wait
|
|
446
|
+
instead, and spend the whole timeout. Call ordinary `start()` if you intend to
|
|
447
|
+
boot the computer.
|
|
448
|
+
|
|
449
|
+
This option requires platform support for `resume_only`. Older servers may
|
|
450
|
+
ignore the parameter and cold-boot a stopped computer; the SDK cannot infer
|
|
451
|
+
support from a successful response.
|
|
452
|
+
|
|
325
453
|
`stop()` asks the guest to shut down and gives it time to. `stop(force=True)`
|
|
326
454
|
skips the asking and pulls the power — the equivalent of holding the button in.
|
|
327
455
|
It is what to reach for when a guest will not come down on its own, and it loses
|
|
@@ -340,8 +468,12 @@ next: `screenshot()` keeps answering, while `clipboard()` starts raising
|
|
|
340
468
|
`ConflictError`, because a suspended computer has no clipboard to read. Drive
|
|
341
469
|
the desktop, or accept the resume.
|
|
342
470
|
|
|
343
|
-
`wait_until_running()` raises rather than spinning if it finds a suspended
|
|
344
|
-
computer
|
|
471
|
+
`wait_until_running()` raises rather than spinning if it finds a suspended or
|
|
472
|
+
stopped computer **that nobody is starting**, because that state does not
|
|
473
|
+
resolve on its own — `start()` is the fix. One whose start the platform has
|
|
474
|
+
already admitted is waited for: a boot reads as stopped and a resume as
|
|
475
|
+
suspended until the guest process exists, so those two statuses do not say on
|
|
476
|
+
their own whether anything is coming.
|
|
345
477
|
|
|
346
478
|
### Showing somebody the desktop
|
|
347
479
|
|
|
@@ -843,8 +975,8 @@ on_clipboard = c.clipboard() # "" is an empty clipboard
|
|
|
843
975
|
`set_clipboard()` takes at most 64 KiB of UTF-8; `clipboard()` returns at most
|
|
844
976
|
128 KiB. They are different bounds on different channels, and the read is
|
|
845
977
|
**refused rather than truncated** past its own — half a password is not less of
|
|
846
|
-
an answer, it is a wrong one that looks completely normal. Empty text
|
|
847
|
-
|
|
978
|
+
an answer, it is a wrong one that looks completely normal. Empty text clears the
|
|
979
|
+
clipboard; a NUL is refused here, before the request goes out.
|
|
848
980
|
|
|
849
981
|
The platform confirms the write by reading the selection back before it
|
|
850
982
|
answers, so `set_clipboard()` returning means the desktop is *holding* the text
|
|
@@ -854,10 +986,12 @@ rather than that a command ran.
|
|
|
854
986
|
an `APIError.reason`: `contention` and `starting` clear on their own, while
|
|
855
987
|
`unavailable` and `unsupported` require a different action. `is_transient()`
|
|
856
988
|
therefore answers `True` for the first pair and `False` for the second. A
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
989
|
+
response classified as `unavailable` requires starting the computer or otherwise
|
|
990
|
+
restoring access, rather than retrying unchanged. Read the returned reason:
|
|
991
|
+
being suspended alone does not determine the classification. If an older
|
|
992
|
+
platform response has no recognised reason, the SDK preserves the historical
|
|
993
|
+
`ConflictError` fallback of `True`, so code that must support unclassified
|
|
994
|
+
responses should verify the computer state and
|
|
861
995
|
keep its retry loop bounded.
|
|
862
996
|
|
|
863
997
|
Two others worth knowing. A **400** never clears: a computer built from a golden
|
|
@@ -916,10 +1050,17 @@ if not result.finished:
|
|
|
916
1050
|
**It runs on your own Anthropic key**, passed as `model_key` and sent on that
|
|
917
1051
|
one request as `X-Model-Key`. The platform never stores one, never bills you for
|
|
918
1052
|
it, and will not fall back to anything — so the key is a per-call argument
|
|
919
|
-
rather than something the client holds.
|
|
920
|
-
|
|
921
|
-
bounds the loop
|
|
922
|
-
|
|
1053
|
+
rather than something the client holds.
|
|
1054
|
+
|
|
1055
|
+
`max_steps` bounds the loop, and bounds your Anthropic spend only loosely. A
|
|
1056
|
+
step is one **action on the desktop**, not one exchange with the model, and the
|
|
1057
|
+
two do not line up in either direction: one model reply may ask for several
|
|
1058
|
+
actions and spends a step on each, while a reply that asks for none — or a
|
|
1059
|
+
paused turn, resubmitted — costs tokens and no step at all. Nor does every step
|
|
1060
|
+
take a screenshot; a `bash` call or a cursor read does not. Size the run from
|
|
1061
|
+
this; size the bill from your own key. It defaults to 20 and is a whole number
|
|
1062
|
+
up to 100 — the platform's ceiling, which the SDK refuses past rather than
|
|
1063
|
+
spending a round trip to be told.
|
|
923
1064
|
|
|
924
1065
|
**The computer must already be running.** This route will not start one for you:
|
|
925
1066
|
starting is billable, and it is not a decision to make on your behalf because
|
|
@@ -962,6 +1103,15 @@ eight stays an account of eight steps rather than only a message — the spend i
|
|
|
962
1103
|
on a key the platform never meters, and the clicks are still on the desktop.
|
|
963
1104
|
`agent_stream()` hands the same record over as an `AgentFailed` event.
|
|
964
1105
|
|
|
1106
|
+
`agent_once()` can be stopped the same way, and it arrives differently. The API
|
|
1107
|
+
rechecks authorization before each further model call and before each tool, so a
|
|
1108
|
+
key revoked, a role dropped, an account suspended or a plan downgraded mid-run
|
|
1109
|
+
ends that one request with a 401, a 403 or a 402 rather than with a body — after
|
|
1110
|
+
steps that already ran on the desktop and already cost model tokens. Where the
|
|
1111
|
+
refusal says how far the run got, `e.agent` carries it there too. What it is not
|
|
1112
|
+
is a reason to send the request again: the credential, the role or the plan has
|
|
1113
|
+
to change first, and `is_transient()` answers `False` for all three.
|
|
1114
|
+
|
|
965
1115
|
```python
|
|
966
1116
|
import mandala_computer as mc
|
|
967
1117
|
|
|
@@ -1094,15 +1244,35 @@ times out and a nominated tree never armed, the error says so.
|
|
|
1094
1244
|
|
|
1095
1245
|
**Match on what `hello` gives back, not on what you sent.** The host normalises
|
|
1096
1246
|
a nomination — a trailing slash and a `.` segment are cleaned away — and the
|
|
1097
|
-
cleaned form is what every event carries in `ev.watch`.
|
|
1098
|
-
where the answer is, and it carries the half a client gets wrong
|
|
1247
|
+
cleaned form is what every event carries in `ev.watch`. The cleaned nomination
|
|
1248
|
+
is where the answer is, and it carries the half a client gets wrong — through
|
|
1249
|
+
`on_connect`, which is handed the state as the connection opens, or from
|
|
1250
|
+
`stream.watching` once it has:
|
|
1099
1251
|
|
|
1100
1252
|
```python
|
|
1101
|
-
|
|
1102
|
-
|
|
1253
|
+
from contextlib import closing
|
|
1254
|
+
from mandala_computer import Hello
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def show_watches(hello: Hello) -> None:
|
|
1258
|
+
for tree in hello.watching or []:
|
|
1103
1259
|
print(tree.path, tree.armed)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
with closing(c.events(watch=["/home/user/out/", "/srv/build"], on_connect=show_watches)) as stream:
|
|
1263
|
+
for ev in stream:
|
|
1264
|
+
if ev.type == "file.changed" and ev.path:
|
|
1265
|
+
print(ev.watch, ev.kind, ev.path)
|
|
1104
1266
|
```
|
|
1105
1267
|
|
|
1268
|
+
Iteration opens the connection and calls `show_watches` with the initial,
|
|
1269
|
+
normalised watch state before yielding events. The loop then receives every
|
|
1270
|
+
event, including the first; interrupt it when finished, and `closing` releases
|
|
1271
|
+
the connection. The callback runs again if the stream reconnects. `on_connect`
|
|
1272
|
+
rather than reading `stream.watching` before the loop, because nothing has
|
|
1273
|
+
connected yet at that point: read from the stream first and `watching` is
|
|
1274
|
+
`None`, which is not the same statement as "nothing armed".
|
|
1275
|
+
|
|
1106
1276
|
`armed` is whether the tree is *already being watched*. A watch is not live the
|
|
1107
1277
|
moment the nomination is accepted — the guest has to be asked, and on a computer
|
|
1108
1278
|
nobody has opened a terminal on the host installs the watcher first — and inotify
|
|
@@ -1247,21 +1417,31 @@ def handle(headers: dict[str, str], raw: bytes) -> int:
|
|
|
1247
1417
|
if not verify(secret, headers, raw):
|
|
1248
1418
|
return 401
|
|
1249
1419
|
event = json.loads(raw)
|
|
1250
|
-
...
|
|
1420
|
+
... # atomically persist the delivery id and enqueue new work durably
|
|
1251
1421
|
return 200
|
|
1252
1422
|
```
|
|
1253
1423
|
|
|
1254
1424
|
`verify` checks the signature against the raw bytes and refuses a
|
|
1255
|
-
`webhook-timestamp` more than 300 seconds from your clock.
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1425
|
+
`webhook-timestamp` more than 300 seconds from your clock. This checks freshness,
|
|
1426
|
+
not whether you already handled the delivery. A captured attempt can remain
|
|
1427
|
+
valid for up to twice the tolerance after its earliest acceptance, because the
|
|
1428
|
+
check accepts timestamps on either side of your clock. Retries also carry the
|
|
1429
|
+
same `webhook-id` with a fresh timestamp and signature.
|
|
1430
|
+
|
|
1431
|
+
Keep delivery ids in durable storage for the full redelivery horizon your
|
|
1432
|
+
receiver supports, with a margin, and cover that captured-attempt validity
|
|
1433
|
+
window too. Atomically record the id with the durable enqueue or business
|
|
1434
|
+
operation so concurrent requests and crashes cannot admit duplicate work.
|
|
1435
|
+
Acknowledge an already recorded delivery without enqueueing it again. A
|
|
1436
|
+
retention period alone does not guarantee exactly-once processing; downstream
|
|
1437
|
+
work must also handle retries idempotently. A secret of the wrong shape, or a
|
|
1438
|
+
body handed over as text, is a `ValueError` rather than a quiet `False` — neither
|
|
1439
|
+
is something a forged request can cause.
|
|
1440
|
+
|
|
1441
|
+
Acknowledge with a 2xx after that durable acceptance, before doing slow work.
|
|
1442
|
+
An attempt is cut at ten seconds and counted as a failure; anything else is
|
|
1443
|
+
retried, eight attempts over about fourteen hours, and then the delivery is
|
|
1444
|
+
`exhausted` — visible, never
|
|
1265
1445
|
silently dropped:
|
|
1266
1446
|
|
|
1267
1447
|
```python
|
|
@@ -1291,12 +1471,23 @@ public address — a private, loopback or link-local one is a 400 at create.
|
|
|
1291
1471
|
- `wait_until_built()` — a cloned computer's disk has been copied. Only clones
|
|
1292
1472
|
need this; it returns at once for anything else.
|
|
1293
1473
|
- `wait_until_running()` — the VM is up. The guest OS is still booting. Raises
|
|
1294
|
-
rather than waiting out the timeout on a failed build or a suspended
|
|
1295
|
-
|
|
1474
|
+
rather than waiting out the timeout on a failed build, or on a suspended,
|
|
1475
|
+
stopped or still-building computer the platform is holding nothing for — none
|
|
1476
|
+
of which becomes "running" on its own. A start it HAS admitted is waited for,
|
|
1477
|
+
since a boot reads as stopped and a resume as suspended until the guest
|
|
1478
|
+
process exists; so is a memory-snapshot fork, which reserves its RAM at the
|
|
1479
|
+
start of its disk copy and resumes itself at the end of it while reporting
|
|
1480
|
+
`building` throughout.
|
|
1296
1481
|
- `wait_for_guest()` — the guest agent answers. Linux and Windows both; the probe
|
|
1297
|
-
is `exit 0`, which bash and cmd.exe both have as a builtin. A failed start or
|
|
1298
|
-
stopped computer is reported immediately. A suspended
|
|
1299
|
-
the probe is an `exec()`, so it resumes the session as
|
|
1482
|
+
is `exit 0`, which bash and cmd.exe both have as a builtin. A failed start, or
|
|
1483
|
+
a stopped computer nobody is starting, is reported immediately. A suspended
|
|
1484
|
+
computer is different: the probe is an `exec()`, so it resumes the session as
|
|
1485
|
+
use normally does.
|
|
1486
|
+
|
|
1487
|
+
Both waits read `running_ram_mb` to tell those apart, and treat its ABSENCE as
|
|
1488
|
+
unknown rather than as zero: a host too old to report it, or one that could not
|
|
1489
|
+
be reached, has not said nothing is coming. Waiting there costs a timeout;
|
|
1490
|
+
refusing would cost a machine.
|
|
1300
1491
|
|
|
1301
1492
|
The last of those is about the agent, not the desktop, and the agent answers
|
|
1302
1493
|
first — on Windows by a wide margin, since it runs in session 0 and replies
|
|
@@ -1765,6 +1956,20 @@ c.write_file("/home/user/app/.env", "API_TOKEN=hunter2\n")
|
|
|
1765
1956
|
report = c.read_file("/home/user/report.csv")
|
|
1766
1957
|
```
|
|
1767
1958
|
|
|
1959
|
+
`read_file()` answers **bytes**, because a guest file is not promised to be
|
|
1960
|
+
text and decoding one that is not would put replacement characters at the SDK
|
|
1961
|
+
boundary for every caller, including the one reading a tarball. When you know
|
|
1962
|
+
it is text, `read_text_file()` is the same request with the decode done:
|
|
1963
|
+
|
|
1964
|
+
```python
|
|
1965
|
+
hostname = c.read_text_file("/etc/hostname")
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
That decode is `errors="replace"`, so it never raises on a file that turns out
|
|
1969
|
+
not to be text — the same bargain `ExecResult.stdout_text` makes, and
|
|
1970
|
+
acceptable for the same reason: the caller asked for text, and the bytes are
|
|
1971
|
+
one call away.
|
|
1972
|
+
|
|
1768
1973
|
Guest paths are absolute; a relative path is refused before the request is
|
|
1769
1974
|
made, because nothing about a transfer runs in a shell with a working
|
|
1770
1975
|
directory. A transfer resumes a suspended computer, like any other use.
|
|
@@ -1863,6 +2068,20 @@ this SDK refuses before it sends anything does not — see [below](#refused-befo
|
|
|
1863
2068
|
| `ConnectionInterruptedError` | the request was dispatched and the answer was lost; do not replay a create |
|
|
1864
2069
|
| `TimeoutError` | a `wait_*` helper gave up, or a request outran its budget |
|
|
1865
2070
|
|
|
2071
|
+
**401, 403 and 402 can arrive at the end of a wait, not only at the start.**
|
|
2072
|
+
Authorization is not settled once per request: the API rechecks it after the
|
|
2073
|
+
waits inside a call and before each further piece of work, so a key revoked, a
|
|
2074
|
+
membership removed, a role dropped, an account suspended or a plan downgraded
|
|
2075
|
+
while a call was in flight comes back as one of those three even though the same
|
|
2076
|
+
call was authorized when it was sent. Two consequences worth writing code for.
|
|
2077
|
+
None of the three is a transport failure — `is_transient()` answers `False`, and
|
|
2078
|
+
repeating the request unchanged gets the same answer. And the refusal is about
|
|
2079
|
+
the credential rather than the resource: a 401 here does not mean the computer is
|
|
2080
|
+
gone. Where the work is a durable write the API refuses before writing, so a
|
|
2081
|
+
`403` on a create, a move, a webhook or a template publish leaves nothing behind;
|
|
2082
|
+
the exception is the agent loop, whose completed steps stand (see
|
|
2083
|
+
[Letting the platform drive](#letting-the-platform-drive)).
|
|
2084
|
+
|
|
1866
2085
|
<a name="refused-before-it-was-sent"></a>
|
|
1867
2086
|
**An argument refused before it was sent is not a `MandalaError`.** Nothing left
|
|
1868
2087
|
the process when you get one, and there is no status behind it to name: text
|
|
@@ -1906,10 +2125,12 @@ length in `size`, which is the whole point of the status: you asked about a file
|
|
|
1906
2125
|
whose length you did not know, and the number comes back with the refusal
|
|
1907
2126
|
instead of behind another request. See [Files](#files).
|
|
1908
2127
|
|
|
1909
|
-
`
|
|
1910
|
-
`
|
|
1911
|
-
|
|
1912
|
-
|
|
2128
|
+
`APIError.retry_after` carries a valid `Retry-After` header in seconds, including
|
|
2129
|
+
on `RateLimitError` and template preparation refusals. It is `None` when that
|
|
2130
|
+
metadata is absent or malformed; a delay alone does not make an operation safe
|
|
2131
|
+
to retry. Every route on this surface is metered, including ones that go on to
|
|
2132
|
+
answer 404 — the meter runs before the routing does — so a burst of anything
|
|
2133
|
+
counts against the same budget.
|
|
1913
2134
|
That budget is generous, in the low thousands of requests a minute even at the
|
|
1914
2135
|
bottom of the range, so hitting it usually means a poll loop with no sleep in it
|
|
1915
2136
|
rather than real load.
|
|
@@ -1962,10 +2183,10 @@ that creates deserves a look first.
|
|
|
1962
2183
|
|
|
1963
2184
|
`is_transient(err)` is that rule as a function, and it answers for the riskiest
|
|
1964
2185
|
caller — code wrapping an arbitrary call, possibly a `create`. It says yes to
|
|
1965
|
-
`ConflictError` (
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2186
|
+
`ConflictError` (except `MoveRequiredError` and `template_image_preparing`),
|
|
2187
|
+
`RateLimitError`, `UnavailableError` and `ConnectionError` (except
|
|
2188
|
+
`ConnectionInterruptedError`), and no to everything above whose outcome is
|
|
2189
|
+
unknown, 502 and 504 included.
|
|
1969
2190
|
|
|
1970
2191
|
The `wait_*` helpers do not ask it. They replay idempotent reads under a
|
|
1971
2192
|
deadline you set, so they ride out every 5xx — a hypervisor briefly away during
|
|
@@ -1985,15 +2206,18 @@ clears itself: something is in flight that the operation cannot run alongside
|
|
|
1985
2206
|
a disk still being copied, a snapshot being taken, a delete already under way, a
|
|
1986
2207
|
guest agent that has not finished coming up, or a suspend committed to the
|
|
1987
2208
|
computer a moment before your call. Waiting and retrying is the fix; changing
|
|
1988
|
-
the request is not.
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
2209
|
+
the request is not. Template preparation is an exception: inspect its state
|
|
2210
|
+
and error, then deliberately continue with the returned token and the original
|
|
2211
|
+
create arguments when appropriate, as shown above.
|
|
2212
|
+
|
|
2213
|
+
Some require a different action. `MoveRequiredError` is one, and it has a class
|
|
2214
|
+
you can catch. For clipboard refusals, read `APIError.reason`:
|
|
2215
|
+
`unavailable` and `unsupported` make `is_transient()` answer `False`, while
|
|
2216
|
+
`contention` and `starting` answer `True`. A suspended computer's read is refused,
|
|
2217
|
+
but its status alone does not determine the reason; follow the returned
|
|
2218
|
+
classification. An absent or unknown reason is deliberately treated as
|
|
2219
|
+
unclassified and falls back to the exception type, so a legacy `ConflictError`
|
|
2220
|
+
still answers `True`. If you support such
|
|
1997
2221
|
responses, check the computer state and keep the retry loop bounded.
|
|
1998
2222
|
|
|
1999
2223
|
A retry loop on it terminates because it has a deadline, not because of any
|
|
@@ -2093,11 +2317,13 @@ matter how far behind the client falls, so closing a gap means deleting a line
|
|
|
2093
2317
|
from `UNIMPLEMENTED` rather than nobody noticing.
|
|
2094
2318
|
|
|
2095
2319
|
A mirror nobody compares is a comment, though. `scripts/check_surface.py` does
|
|
2096
|
-
the comparison against the platform's
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2320
|
+
the comparison against the platform's published surface manifest — a file the
|
|
2321
|
+
platform generates from its own tables and commits like a lockfile — whenever a
|
|
2322
|
+
checkout of the platform is available to it (next door, or wherever
|
|
2323
|
+
`MANDALA_PLATFORM_REPO` points — a maintainer's setup, not something a
|
|
2324
|
+
contributor needs), and says so and exits 0 when it is not. A manifest it cannot
|
|
2325
|
+
read is a failure, never a comparison of nothing. The suite runs it too: `pytest`
|
|
2326
|
+
skips it where the platform is not checked out and fails on drift where it is.
|
|
2101
2327
|
|
|
2102
2328
|
Response objects keep the raw payload in `.raw`, so a server that starts
|
|
2103
2329
|
returning more fields does not break older clients. `.raw` is evidence rather
|