solari-runloop-compat 0.1.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.
Files changed (58) hide show
  1. solari_runloop_compat-0.1.0/.gitignore +6 -0
  2. solari_runloop_compat-0.1.0/BLUEPRINTS.md +84 -0
  3. solari_runloop_compat-0.1.0/COMPAT.md +125 -0
  4. solari_runloop_compat-0.1.0/GATEWAY-ENDPOINTS.md +64 -0
  5. solari_runloop_compat-0.1.0/NOTICE +25 -0
  6. solari_runloop_compat-0.1.0/PKG-INFO +47 -0
  7. solari_runloop_compat-0.1.0/README.md +29 -0
  8. solari_runloop_compat-0.1.0/pyproject.toml +29 -0
  9. solari_runloop_compat-0.1.0/solari_runloop/__init__.py +42 -0
  10. solari_runloop_compat-0.1.0/solari_runloop/_branding.py +10 -0
  11. solari_runloop_compat-0.1.0/solari_runloop/_client.py +107 -0
  12. solari_runloop_compat-0.1.0/solari_runloop/_config.py +57 -0
  13. solari_runloop_compat-0.1.0/solari_runloop/_context.py +91 -0
  14. solari_runloop_compat-0.1.0/solari_runloop/_dockerfile.py +348 -0
  15. solari_runloop_compat-0.1.0/solari_runloop/_exceptions.py +122 -0
  16. solari_runloop_compat-0.1.0/solari_runloop/_gateway.py +1048 -0
  17. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari-run.sh +14 -0
  18. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_extract.py +101 -0
  19. solari_runloop_compat-0.1.0/solari_runloop/_guest/solari_sizecheck.py +14 -0
  20. solari_runloop_compat-0.1.0/solari_runloop/_loop.py +54 -0
  21. solari_runloop_compat-0.1.0/solari_runloop/_mapping.py +322 -0
  22. solari_runloop_compat-0.1.0/solari_runloop/_models.py +13 -0
  23. solari_runloop_compat-0.1.0/solari_runloop/_resources.py +416 -0
  24. solari_runloop_compat-0.1.0/solari_runloop/_sync.py +76 -0
  25. solari_runloop_compat-0.1.0/solari_runloop/lib/__init__.py +0 -0
  26. solari_runloop_compat-0.1.0/solari_runloop/lib/polling.py +118 -0
  27. solari_runloop_compat-0.1.0/solari_runloop/pagination.py +66 -0
  28. solari_runloop_compat-0.1.0/solari_runloop/sdk.py +594 -0
  29. solari_runloop_compat-0.1.0/solari_runloop/types/__init__.py +17 -0
  30. solari_runloop_compat-0.1.0/solari_runloop/types/blueprint_build_log.py +16 -0
  31. solari_runloop_compat-0.1.0/solari_runloop/types/blueprint_build_logs_list_view.py +16 -0
  32. solari_runloop_compat-0.1.0/solari_runloop/types/blueprint_build_parameters.py +123 -0
  33. solari_runloop_compat-0.1.0/solari_runloop/types/blueprint_view.py +97 -0
  34. solari_runloop_compat-0.1.0/solari_runloop/types/devbox_async_execution_detail_view.py +46 -0
  35. solari_runloop_compat-0.1.0/solari_runloop/types/devbox_execution_detail_view.py +24 -0
  36. solari_runloop_compat-0.1.0/solari_runloop/types/devbox_snapshot_list_view.py +17 -0
  37. solari_runloop_compat-0.1.0/solari_runloop/types/devbox_snapshot_view.py +33 -0
  38. solari_runloop_compat-0.1.0/solari_runloop/types/devbox_view.py +176 -0
  39. solari_runloop_compat-0.1.0/solari_runloop/types/devboxes/__init__.py +0 -0
  40. solari_runloop_compat-0.1.0/solari_runloop/types/devboxes/devbox_logs_list_view.py +39 -0
  41. solari_runloop_compat-0.1.0/solari_runloop/types/secret_view.py +23 -0
  42. solari_runloop_compat-0.1.0/solari_runloop/types/shared/__init__.py +0 -0
  43. solari_runloop_compat-0.1.0/solari_runloop/types/shared/after_idle.py +15 -0
  44. solari_runloop_compat-0.1.0/solari_runloop/types/shared/code_mount_parameters.py +30 -0
  45. solari_runloop_compat-0.1.0/solari_runloop/types/shared/launch_parameters.py +104 -0
  46. solari_runloop_compat-0.1.0/solari_runloop/types/shared/lifecycle_configuration.py +38 -0
  47. solari_runloop_compat-0.1.0/solari_runloop/types/shared/lifecycle_hooks.py +28 -0
  48. solari_runloop_compat-0.1.0/solari_runloop/types/shared/resume_triggers.py +17 -0
  49. solari_runloop_compat-0.1.0/solari_runloop/types/tunnel_view.py +47 -0
  50. solari_runloop_compat-0.1.0/tests/run_runloop_examples.sh +16 -0
  51. solari_runloop_compat-0.1.0/tests/test_config.py +102 -0
  52. solari_runloop_compat-0.1.0/tests/test_context.py +95 -0
  53. solari_runloop_compat-0.1.0/tests/test_dockerfile.py +140 -0
  54. solari_runloop_compat-0.1.0/tests/test_extract.py +111 -0
  55. solari_runloop_compat-0.1.0/tests/test_gateway_wire.py +539 -0
  56. solari_runloop_compat-0.1.0/tests/test_live_staging.py +237 -0
  57. solari_runloop_compat-0.1.0/tests/test_mapping.py +119 -0
  58. solari_runloop_compat-0.1.0/tests/test_surface.py +183 -0
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .venv/
@@ -0,0 +1,84 @@
1
+ # Blueprints (Runloop) on Solari: design and status
2
+
3
+ A Runloop *blueprint* is a reusable devbox image built from a Dockerfile (plus setup commands, file mounts, a build
4
+ context, launch parameters). A Solari *template* is a golden VM snapshot built by running ordered steps inside a VM
5
+ cloned from a base golden (`POST /templates` with a `CompiledImage`: `{base|fromTemplate, packages, steps[]}`,
6
+ `building -> ready|failed`, build logs at `GET /templates/{id}/logs`). So the mapping is "Dockerfile -> recipe", not
7
+ "Dockerfile -> OCI image".
8
+
9
+ ## Level 1: shipped in this PR (client side, no gateway change)
10
+ `solari_runloop/_dockerfile.py` compiles a Dockerfile subset into the existing recipe, and `client.blueprints.*` /
11
+ `sdk.blueprint.*` drive `/templates`. Verified on staging: a Dockerfile + `system_setup_commands` + `file_mounts` blueprint
12
+ builds in ~40 s, a devbox created from it sees the baked files, and the template is deleted afterwards.
13
+
14
+ | Runloop | Solari |
15
+ |---|---|
16
+ | `dockerfile` `FROM ubuntu/debian[:tag]`, `base`, `workstation` | `base` golden |
17
+ | `base_blueprint_name` / `base_blueprint_id` | `fromTemplate` (name resolved to `tpl_` id) |
18
+ | `RUN` (shell or JSON form) | `{op:"run"}` step (ARGs exported into the command) |
19
+ | `ENV` | `{op:"env"}` step |
20
+ | `WORKDIR`, `ENTRYPOINT`, `CMD` | `{op:"workdir","dir"}`, `{op:"entrypoint"}`, `{op:"cmd"}` |
21
+ | `ARG` + `build_args` | substituted / exported |
22
+ | `system_setup_commands` | `run` steps after the Dockerfile |
23
+ | `file_mounts {path: contents}` | `run` step that base64-writes each file (small files only) |
24
+ | `launch_parameters` size | `cpu`/`memMb` on the template row, same size table as devboxes |
25
+ | status `building / ready / failed` | `building / build_complete / failed` |
26
+ | blueprint id | `templateId` (`tpl_...`); `create(template=)` takes the **id** or a built-in name |
27
+
28
+ Rejected loudly (`SolariCompatUnsupported`): a `FROM` from a registry outside the allowlist, `FROM scratch`, multi-stage builds,
29
+ `COPY`/`ADD`, `USER`, `VOLUME`, `HEALTHCHECK`, `ONBUILD`, `build_context`, `code_mounts`, `services`, `secrets`.
30
+
31
+ Findings worth fixing in the gateway docs: `openapi.yaml` shows `{op:"workdir", path}` but the SDKs (and so the
32
+ pipeline) use `{op:"workdir", dir}`; `localFiles` is rejected (`LocalFilesUnsupported`), which is why `COPY` is not in level 1.
33
+
34
+ ## Level 2: `COPY`/`ADD` and `build_context`
35
+ **Shipped in the compat layer (staging-verified) for contexts up to ~700 KB**: `sdk.storage_object.upload_from_dir()` builds a deterministic tar.gz (honouring `.dockerignore`), `build_context=obj.as_build_context()` embeds it in the recipe, and `COPY`/`ADD` become `cp -a` steps from `/tmp/solari-context` (into `/srv/root/...` for imported images). The proper version below replaces the embedding with real objects.
36
+
37
+ ### Proper version (needs bug2's storage objects)
38
+ 1. Build context = a storage object (`.tar.gz`, `GET /objects/{id}/download-url`).
39
+ 2. New recipe field `contextObjectId` and a new step op `{op:"copy", src, dest, chown?}`; the build VM downloads and extracts the
40
+ object to `/tmp/build-context`, then `copy` is `cp -a` from there (`ADD` of a local tar = `tar -x`).
41
+ 3. Compat layer: `build_context={type:"object", object_id}` passes through; `COPY`/`ADD` translate to `copy` steps;
42
+ `.dockerignore` is applied client-side when the SDK uploads the directory (`storage_object.upload_from_dir`).
43
+ Gateway work: `contextObjectId` validation (same org), the `copy` op in the build executor, ~2 days; tests for wrong-org objects.
44
+
45
+ ## Level 3a: any public `FROM <image>`, shipped in this PR (compat layer only, staging-verified)
46
+ `FROM python:3.12-alpine`, `FROM node:20`, `FROM ghcr.io/org/img:tag`, and (with `build_args={"SOLARI_IMPORT": "1"}`) `FROM ubuntu`/`debian`
47
+ **as an imported image** instead of the native base. How: the build steps (a) install `crane` (pinned `v0.20.3`, tarball sha256 checked
48
+ at download, the binary's sha256 **re-checked before every use**, the build fails on a mismatch), (b) check the manifest size against
49
+ the cap before downloading, (c) `crane export --platform linux/amd64 <image>` piped into `solari-extract`, a validating tar extractor,
50
+ into `/srv/root`, (d) install `solari-run`, which chroots into `/srv/root` with the Dockerfile's `ENV` (`/etc/solari-env`) and `WORKDIR`
51
+ (`/etc/solari-workdir`). The existing orchestrator snapshots the builder as a normal template. At runtime the compat layer sees
52
+ `/etc/solari-rootdir` and runs `cmd.exec` through `solari-run`, and maps file paths under the root.
53
+ Verified live: `python:3.12-alpine` (musl, `python --version` = 3.12), `debian:bookworm-slim`, `ubuntu:22.04`: the process sees the image's
54
+ `/etc/os-release`, `ENV`, `WORKDIR`, baked files; file API reads are inside the root; a symlink out of the root is refused (403).
55
+
56
+ | Guard | Where | Test |
57
+ |---|---|---|
58
+ | crane pinned by version + sha256, re-verified at use | `import_steps` | `test_crane_is_pinned_and_checked_at_use` |
59
+ | registry allowlist (docker.io, ghcr.io, public.ecr.aws, gcr.io, quay.io, mcr.microsoft.com); no credentials (`DOCKER_CONFIG` is an empty temp dir) | `_resolve_from` | `test_other_registries_refused`, `..._pinned...` |
60
+ | compressed-size cap 2 GiB (from the manifest, before download), extracted cap 4 GiB, 900 s timeout, 400k members | `solari_sizecheck.py`, `solari_extract.py` | `test_size_cap`, pinned test |
61
+ | tar: absolute names, `..`, write through a symlink out of the root, hardlinks out of the root, device nodes (skipped), setuid/setgid/sticky (stripped) | `solari_extract.py` | `tests/test_extract.py` (10 tests, each fails without its check) |
62
+ | file API never follows a symlink out of the root (guest `realpath` re-check) | `Gateway._abs` | live test |
63
+ | `chroot` is **not** a security boundary here: it is a compatibility layer inside the customer's own microVM, which is the boundary | docs | n/a |
64
+
65
+ **Loud limits** (also in COMPAT.md): the extractor writes an absolute symlink that points at a directory as a symlink but refuses to write *through* it, so a later member cannot land outside `/srv/root`; the image needs a `/bin/sh` (distroless images fail the build with a clear message); ~2 GB free in the builder
66
+ VM today (the builder disk size is not configurable yet, so big images fail on disk); `ENTRYPOINT`/`CMD` are recorded in `/etc/solari-entrypoint` and
67
+ `/etc/solari-cmd` but **not started**; public registries only; amd64 only; absolute symlinks inside the image resolve against the host in the file API,
68
+ so reading such a path is refused (exec through `solari-run` resolves them correctly); processes from different `exec` calls share the chroot, but there is
69
+ no PID 1/init in the image (no systemd), and a `RUN` that needs a running daemon fails.
70
+ A warning is logged at compile time whenever import mode is used.
71
+
72
+ ## Level 3b: native, for every SDK (assigned to bug1; archi-sandbox's block list applies)
73
+ guest-agent `rootDir` mode (exec chroots, fs ops use a symlink-safe join, rootDir immutable, capabilities dropped), `fromImage` on `POST /templates`
74
+ (org-scoped, registry allowlist, digest pinned at build time and recorded), builder `diskGb` (bounded), the OCI config (Env, WorkingDir, Entrypoint, Cmd, User)
75
+ applied at create. Needs a guest rebake + re-pin before prod. The marker-file contract above keeps 3a templates working.
76
+
77
+ ## Level 3c: real rootfs rebuild (fidelity; later)
78
+ `Dockerfile.sandbox` with `ARG BASE_IMAGE` so a Debian-family image gets systemd as PID 1 and the Solari layer baked in. Only for images that need an init.
79
+ The `python:X` -> `base` + install shortcut stays out unless opted in explicitly.
80
+
81
+ ## Risks
82
+ Untrusted builds (egress, CPU time, disk) need quotas before this is exposed beyond staging; `RUN` steps that rely on BuildKit
83
+ features (`--mount=type=cache`, heredocs) are not supported; blueprint *versioning* (Runloop's immutable ids per build) is
84
+ `tpl_` ids here, so rebuilding under the same name creates a new id.
@@ -0,0 +1,125 @@
1
+ # COMPAT.md: Runloop SDK compatibility (target: runloop_api_client 2.0.0)
2
+
3
+ ## At a glance (2026-10-21 night, verified against the staging integration build `desktop-gateway:staging-3015d4e085a1`)
4
+ - **Works today**: devbox create / retrieve / list (paged) / update (name, metadata) / suspend / resume / keep-alive / shutdown; `cmd.exec` and `exec_async` (with streaming callbacks); file read / write / upload / download; disk snapshots (sync and async, update, create-from-snapshot); open and authenticated **tunnels**; **blueprints** from a Dockerfile (Ubuntu/Debian *or any public image*, `COPY`/`ADD` with a build context); storage objects and object mounts; devbox logs; sizes (capped at 8 vCPU / 16 GiB); Runloop's error classes, retries, pagination and env vars.
5
+ - **Runloop's own example recipes**: 4 of 4 supported ones pass with only the import changed (`devbox_snapshots`, `devbox_snapshot_resume`, `devbox_from_blueprint_lifecycle`, `devbox_tunnel`): 19 checks, 0 failures, run repeatedly. The other three example recipes (`blueprint_with_build_context`, `secrets_with_devbox`, `mcp_github_tools`) need features Solari lacks (see below) and fail loudly with `SolariCompatUnsupported`.
6
+ - **Live test suite** (`pytest -m live`, creates and deletes its own resources): 14 of 15 on the last full run; the one failure was a transient gateway `DependencyUnavailable` (503) on a devbox create, which the client now retries for ~45 s. 143 offline unit tests.
7
+ - **Needs a gateway rollout or a key before it works**: named shells (`devbox.shell(name)`: the gateway is live but sandbox images need the new golden pin), durable executions and gateway-side logs (#570, waiting on a Firestore TTL), account secrets (#583: wired as `sdk.secret.*`, `client.secrets.*` and `devbox.create(secrets={ENV: secret_name})`; answers `SolariCompatUnsupported` while the gateway has no `ORG_SECRETS_KEY`).
8
+ - **Not supported (raises `SolariCompatUnsupported`)**: agents, axons, gateways/MCP configs, network policies, restricted keys, SSH keys, PTY tunnels, multi-stage Dockerfiles, `FROM` private registries, `code_mounts`.
9
+
10
+ Legend: **OK** works and is tested; **DIFF** works with a behaviour difference (explained); **GATEWAY** waits on a
11
+ gateway endpoint (see `GATEWAY-ENDPOINTS.md`); **NO** raises `SolariCompatUnsupported`.
12
+ "Live" = exercised against staging by `tests/test_live_staging.py` with the staging nightly-verify key.
13
+
14
+ ## Setup and configuration
15
+ | Runloop | Here |
16
+ |---|---|
17
+ | `Runloop(bearer_token=...)` / env `RUNLOOP_API_KEY` | OK, with a safety rule: sources are tried in order (argument, `SOLARI_API_KEY`, `RUNLOOP_API_KEY`) and **a key that does not start with `slr_` is never used or sent** (it is probably a Runloop secret): it is skipped with a warning that does not print it, and if no `slr_` key remains the constructor raises `RunloopError`. An `slr_` key is accepted from any source. |
18
+ | `base_url` / env `RUNLOOP_BASE_URL` (default `https://api.runloop.ai`) | OK. Order: argument, `SOLARI_BASE_URL`, `RUNLOOP_BASE_URL`, default `https://api.getsolari.com`. |
19
+ | `timeout`, `default_headers`, `http_client`, `with_options()` | Accepted, ignored. |
20
+ | `max_retries` | OK (default 5), Solari's own policy: idempotent calls retry on network errors, 502/503/504/507 and `retryable` bodies. |
21
+ | Sync and async (`Runloop`/`AsyncRunloop`, `RunloopSDK`/`AsyncRunloopSDK`) | OK. The sync API drives the async core on one background thread per client (`_loop.py`); don't call it from inside a callback it invoked. |
22
+
23
+ ## Object layer (`RunloopSDK`)
24
+ | Call | Status | Notes |
25
+ |---|---|---|
26
+ | `devbox.create(**params)` | OK, live | Waits for `running` (Solari is already running when create returns). See create parameters below. |
27
+ | `devbox.create_from_snapshot(id)`, `snapshot.create_devbox()` | OK, live | Maps to `fromSnapshot`. |
28
+ | `devbox.create_from_blueprint_name(name)`, `create_from_blueprint_id(id)`, `blueprint.create_devbox()` | OK, live | A Solari template: a built-in name (`base`) or one built with `blueprint.create`. Custom names are resolved to the `tpl_` id for you. |
29
+ | `devbox.from_id(id)`, `devbox.list(**)` | OK, live | |
30
+ | `Devbox.get_info / await_running / await_suspended / shutdown / suspend / resume / keep_alive` | OK, live | `suspend`/`resume` = Solari pause/resume (RAM+disk). `keep_alive` re-arms the idle window the devbox was created with (default 30 min). |
31
+ | `with ... as devbox` (sync) / `async with` | OK | Shuts the devbox down on exit. |
32
+ | `devbox.cmd.exec(cmd, stdout=, stderr=, output=, last_n=)` | OK, live | Runs `bash -lc "<cmd>"` from `$HOME`. Callbacks receive lines. Output is never truncated (`*_truncated` is always false). `ExecutionResult.stdout(num_lines=)` etc. as in Runloop. |
33
+ | `devbox.cmd.exec_async(...)` -> `Execution` | DIFF | `result()`, `get_state()`, `kill()`, `send_std_in()`, `join()` work **inside the creating process** (an in-memory registry). On a gateway with durable executions (pinetree-desktop#570, detected by probing `GET /sandboxes/{id}/executions`) every command runs through it, so `executions.retrieve / await_completed / kill / send_std_in` also work by `(devbox_id, execution_id)` from **another process**; callbacks are fed by polling the execution (~0.4 s). Against a gateway without it, the in-process registry above is used. `close_std_in` NO. |
34
+ | `devbox.shell(name).exec / exec_async / delete`, `shell_name=` | DIFF | Wired to the gateway's named shells (pinetree-desktop#569): one persistent bash per name (cwd, env, functions, jobs persist). Output reaches `stdout=/stderr=/output=` callbacks when the command ends, not while it runs; a shell command cannot be killed or given stdin (end the shell with `shell.delete()`). Raises `SolariCompatUnsupported` on a gateway or sandbox image without shells (the guest image needs a new golden). Not yet run against staging: #569 is not deployed. |
35
+ | `devbox.file.read / write / upload / download` | OK, live | Relative paths resolve against `$HOME`; parent dirs are created on write/upload. `upload(file=)` takes bytes, `Path`, str path or a file object. |
36
+ | `devbox.snapshot_disk(name=, commit_message=, metadata=)` / `_async` | DIFF | Synchronous, returns a `Snapshot`. Snapshots are RAM+disk. `commit_message`/`metadata` are returned but not stored until GATEWAY (snapshot metadata). |
37
+ | `snapshot.get_info / delete / await_completed`, `snapshot.list`, `snapshot.from_id` | OK, live | `snapshot.update(name=, metadata=)`: OK on a gateway with `PATCH /snapshots/{id}` (#566), else unsupported. Deleting a snapshot with live children is a 409 `ConflictError`. |
38
+ | `devbox.get_tunnel()`, `get_tunnel_url(port)`, `net.enable_tunnel / get_tunnel / remove_tunnel`, create `tunnel={'auth_mode','wake_on_http','http_keep_alive'}` | DIFF, live | Real tunnel API (pinetree-desktop#575): one switch for every guest port, `https://<label>-<port>.<preview domain>`; `tunnel_key` is the `<label>`. `authenticated` (Solari's default) returns an `auth_token` that must be sent in the **`x-solari-preview-token`** header (Runloop uses `Authorization: Bearer`); `open` needs nothing. `get_tunnel()` returns `None` when no tunnel is enabled (as Runloop). **Difference:** Solari refuses `open` + `wake_on_http` (anonymous traffic could wake and bill a sandbox); the shim raises `SolariCompatUnsupported` for it, use `authenticated`. Verified on 2 gateway replicas: 40/40 requests succeed with the token, 20/20 are refused without it, revocation takes effect within 3 s. On a gateway without the tunnel API it falls back to an open per-port preview URL. |
39
+ | `devbox.logs(execution_id=, shell_name=)` | DIFF | Uses the gateway's `GET /sandboxes/{id}/logs` when it exists (feat2's work); until then it returns the output of commands run through **this client instance** (in-process). |
40
+ | `devbox.create_ssh_key()`, PTY tunnels, gateway/MCP tokens, `watch_evictions` | NO | Runloop platform features. |
41
+ | `blueprint.create(name, dockerfile=, system_setup_commands=, file_mounts=, base_blueprint_name=/id=, build_args=, launch_parameters=)` | DIFF, live | Builds a Solari template from a **Dockerfile subset** and waits for it (`FROM` ubuntu/debian = the native base; **any other public image** (e.g. `python:3.12-alpine`) is imported into a chroot, see the limits below; `RUN ENV ARG WORKDIR ENTRYPOINT CMD`; `COPY`/`ADD` with a `build_context` (see below); not multi-stage, `USER`). See `BLUEPRINTS.md`. `build_context`, `code_mounts`, `services`, `secrets` NO. |
42
+ | `blueprint.from_id / list`, `Blueprint.get_info / logs / delete` | OK, live | `list()` returns your org's custom templates only. `logs()` has no timestamps/levels (Solari returns plain lines). |
43
+ | `storage_object.upload_from_dir/bytes/text/file`, `as_build_context()`, `from_id`, `delete` | DIFF, live | Not real storage objects: the context is a deterministic `.tar.gz` **held by this client** and embedded in the build recipe, capped at ~700 KB compressed (the template body limit is 1 MiB). Honours `.dockerignore`. `COPY`/`ADD` (incl. `--chown`, `--chmod`, globs, `ADD` of a local tar) copy from it; `COPY --from`, URLs, `../` sources are refused. A context uploaded by another process is `SolariCompatUnsupported`. Everything else on `storage_object` (create, list, download, mounts) -> GATEWAY (objects). |
44
+ | `secret.create / from_name / from_id / update / list / delete`, `Secret.get_info / update / delete`, `client.secrets.*`, create `secrets={ENV: secret_name}` | DIFF | Solari account secrets (pinetree-desktop#583): **write-only** (no route returns a value), names `[A-Za-z][A-Za-z0-9_.-]{0,63}` double as ids, values up to 16 KiB, 200 per org, at most 20 per create, reserved env names (`LD_*`, `PATH`, `HOME`...) refused. Not exercised on staging yet: the gateway answers 501 until the org-secrets key exists, and the shim then raises `SolariCompatUnsupported`. |
45
+ | `agent`, `axon`, `network_policy`, `gateway_config`, `mcp_config` | NO | Runloop platform features. |
46
+
47
+ ## REST layer (`client.devboxes.*`)
48
+ `create`, `create_and_await_running`, `retrieve`, `list` (auto-paginating, awaitable or `async for`), `shutdown`, `suspend`,
49
+ `resume`, `keep_alive`, `await_running`, `await_suspended`, `execute`, `execute_sync`, `execute_async`,
50
+ `execute_and_await_completion`, `wait_for_command`, `read_file_contents`, `write_file_contents`, `upload_file`,
51
+ `download_file`, `snapshot_disk`, `snapshot_disk_async`, `list_disk_snapshots`, `delete_disk_snapshot`, `executions.*`
52
+ (retrieve, await_completed, kill, send_std_in), `disk_snapshots.list/delete`: **OK** (same caveats as above).
53
+ `update(name=, metadata=)`: OK on a gateway with `PATCH /sandboxes/{id}` (pinetree-desktop#566), `SolariCompatUnsupported` on one without. `retrieve_resource_usage`, tunnels, ssh, tokens -> NO. `client.blueprints` (create, retrieve, list, delete, logs,
54
+ await_build_complete, create_and_await_build_complete): OK (Dockerfile subset). `objects`, `agents`, ... -> NO. `with_raw_response` / `with_streaming_response` -> not provided.
55
+
56
+ ## Create parameters
57
+ | Runloop param | Mapping |
58
+ |---|---|
59
+ | `name` | sent to the gateway's native `name` (#566, via a PATCH right after create); also stored as metadata key `runloop.name` (hidden from `metadata`) so it works on gateways without it |
60
+ | `metadata` | `metadata` |
61
+ | `environment_variables` | `envs` |
62
+ | `blueprint_name` | `template` (custom names resolved to `tpl_` ids) |
63
+ | `snapshot_id` | `fromSnapshot` |
64
+ | `file_mounts` | files written after create |
65
+ | `entrypoint` | started in the background after create |
66
+ | `launch_parameters.launch_commands` | run in order after create; a non-zero exit fails the create (and shuts the devbox down) |
67
+ | `launch_parameters.keep_alive_time_seconds` | `timeoutMs`, `lifecycle.onTimeout = "kill"` (Runloop's default is shutdown) |
68
+ | `launch_parameters.after_idle {on_idle, idle_time_seconds}` | `timeoutMs`; `on_idle: "suspend"` -> `onTimeout: "pause"`, otherwise `"kill"` |
69
+ | `launch_parameters.resource_size_request`, `custom_cpu_cores`, `custom_gb_memory`, `custom_disk_size` | see sizes |
70
+ | `blueprint_id` | a Solari template id |
71
+ | `code_mounts`, `mounts`, `gateways`, `mcp`, `secrets`, `tunnel`, `network_policy_id`, `user_parameters` | NO |
72
+ | `architecture`, `available_ports` | ignored |
73
+
74
+ ### Sizes (James's rule: cap silently, never error)
75
+ CPU is rounded **up** to a whole vCPU; the result is capped at Solari's limits, **8 vCPU / 16 GiB**, with a `WARNING` on the
76
+ `solari_runloop` logger when a cap applies. Disk is only sent when `custom_disk_size` is given (Runloop's per-size disk
77
+ figures are not forwarded). No size at all leaves Solari's defaults (2 vCPU / 2 GiB).
78
+
79
+ | `resource_size_request` | Runloop documents | Sent to Solari |
80
+ |---|---|---|
81
+ | `X_SMALL` | 0.5 vCPU, 1 GiB | 1 vCPU, 1 GiB |
82
+ | `SMALL` | 1 vCPU, 2 GiB | 1 vCPU, 2 GiB |
83
+ | `MEDIUM` | 2 vCPU, 4 GiB | 2 vCPU, 4 GiB |
84
+ | `LARGE` | 2 vCPU, 8 GiB | 2 vCPU, 8 GiB |
85
+ | `X_LARGE` | 4 vCPU, 16 GiB | 4 vCPU, 16 GiB |
86
+ | `XX_LARGE` | 8 vCPU, 32 GiB | 8 vCPU, **16 GiB (capped)** |
87
+ | `CUSTOM_SIZE` | `custom_cpu_cores`, `custom_gb_memory` | ceil(cpu) capped at 8; memory x 1024 MiB capped at 16384 |
88
+
89
+ ### Image import limits (`FROM <image>`, BLUEPRINTS.md level 3a)
90
+ The image needs a `/bin/sh` (distroless fails the build); about 2 GB of free disk in the builder; `ENTRYPOINT`/`CMD` are recorded, **not started**;
91
+ public registries from an allowlist only, amd64 only, no credentials; the root is a chroot in your own microVM (not a security boundary); no init/systemd
92
+ inside the image; absolute symlinks inside the image are refused by the file API (use `cmd.exec`). A warning is logged whenever import mode is used.
93
+
94
+ ## Status, errors, pagination
95
+ - Status (Solari state -> `DevboxView.status`): `starting`->`initializing`, `running`->`running`, `paused`->`suspended`,
96
+ `archived`->`suspended`, `releasing`->`shutdown`, `gone`->`shutdown`; anything else `failure`.
97
+ `DevboxView` fields Solari does not have are synthesized: `create_time_ms` from `createdAt`, one `state_transitions`
98
+ entry, `launch_parameters` from the real `cpu`/`memMb`, `capabilities=[]`.
99
+ - Errors: 400 `BadRequestError`, 401 `AuthenticationError`, 402 and 403 `PermissionDeniedError` (`status_code` keeps 402),
100
+ 404 `NotFoundError`, 409 `ConflictError`, 422 `UnprocessableEntityError`, 429 `RateLimitError`, 5xx `InternalServerError`;
101
+ network -> `APIConnectionError`, timeouts -> `APITimeoutError`. `.body` is Solari's `{code,error,message,retryable}`.
102
+ Solari-specific: a **429 `ConcurrencyLimitExceeded`** is a `RateLimitError` that the client does NOT retry.
103
+ - IDs are Solari's opaque signed strings (they contain `:` and `.`), not `dbx_...`. Snapshot ids are `snap_...`.
104
+ - Pagination: `devboxes.list()` returns a page with `.devboxes`, `.has_more`, `.has_next_page()`, `.get_next_page()`;
105
+ iterating it (or `async for`) walks every page. `total_count` is always `None`. `starting_after` is sent as `startingAfter` to a gateway that supports it (#566, detected from `hasMore` in the list reply); on an older one it is treated as the opaque cursor from a previous page. `list` only returns sandboxes, never desktops.
106
+ - Not retried-for-you: Runloop's long-poll `*_and_await_*` helpers poll with `PollingConfig` (re-exported from
107
+ `solari_runloop.lib.polling`; same fields).
108
+
109
+ ## Not compatible, by design
110
+ Persistent 1:1 `dbx_` ids; `optimistic_timeout` (exec always waits); log streaming over SSE; anything under `agents`/`axons`/`gateways`/`mcp`/`secrets`.
111
+
112
+ ## Runloop's own examples (import swapped, run against staging by `tests/run_runloop_examples.sh`)
113
+ Last run 2026-10-21 against the F15 integration build `desktop-gateway:staging-190c78afc3e2`: **4 of 4 supported recipes pass (19 checks, 0 failures)**, run twice. Live suite (`pytest -m live`) on the previous build: 14 of 15, the other one a list-lag flake now retried.
114
+ | Example | Result |
115
+ |---|---|
116
+ | `devbox_snapshots` | PASS (suspend, resume, snapshot, two clones, isolation checks) |
117
+ | `devbox_snapshot_resume` | PASS |
118
+ | `devbox_from_blueprint_lifecycle` | PASS (Dockerfile blueprint, build logs, devbox logs from the in-process record) |
119
+ | `devbox_tunnel` | PASS (open tunnel: `tunnel={'auth_mode':'open'}`, `get_tunnel()`, `get_tunnel_url(port)` = Solari's per-port preview URL; the HTTP request through it succeeds) |
120
+ | `blueprint_with_build_context`, `secrets_with_devbox`, `mcp_github_tools` | not run: storage objects / secret store / MCP are not supported |
121
+
122
+ ## Test status
123
+ - `tests/` offline: mappings, config, errors, sync/async surface over a fake gateway.
124
+ - `tests/test_live_staging.py` (`pytest -m live`): quickstart, exec failure/stderr/tail, async exec streaming and kill, file round-trips,
125
+ list/retrieve/pagination, suspend/resume state, snapshot -> create from snapshot, errors, size capping. See the PR for the latest run.
@@ -0,0 +1,64 @@
1
+ # Target gateway API for Runloop compatibility (proposal, v1)
2
+
3
+ Build to these shapes and the compat packages (`solari_runloop`, `@solarisdk/runloop-compat`) will wire to them. Shapes follow
4
+ the gateway's existing conventions: camelCase JSON, opaque signed ids, `Idempotency-Key` on creates, errors as
5
+ `{code,error,message,retryable}`. If you must deviate, change this file in the same PR and tell feat1.
6
+ Everything is scoped to the caller's org like the existing `/sandboxes/*` routes. "SandboxView" is the existing view.
7
+
8
+ ## 1. Durable executions + logs (owner: feat2)
9
+ Why: Runloop's `execute_async` returns an `execution_id` that is retrievable, waitable and killable from any process.
10
+ Today `cmd.start` is a live WebSocket handle only.
11
+
12
+ | Route | Body / query | Response |
13
+ |---|---|---|
14
+ | `POST /sandboxes/{id}/executions` | `{cmd, args?, cwd?, env?, user?, shellName?, attachStdin?}` (`cmd` = argv[0]; the compat layer sends `/bin/bash -lc "<string>"`) | `201 ExecutionView` |
15
+ | `GET /sandboxes/{id}/executions/{eid}` | `?lastN=<lines>` | `ExecutionView` |
16
+ | `GET /sandboxes/{id}/executions` | `?limit&cursor&status` | `{executions: ExecutionView[], nextCursor?}` |
17
+ | `POST /sandboxes/{id}/executions/{eid}/wait` | `{timeoutMs?}` long-poll (cap 30 s per call) | `ExecutionView` (status `running` if the wait timed out) |
18
+ | `POST /sandboxes/{id}/executions/{eid}/kill` | `{signal?}` | `ExecutionView` |
19
+ | `POST /sandboxes/{id}/executions/{eid}/stdin` | `{data, eof?}` | `{ok:true}` |
20
+ | `GET /sandboxes/{id}/executions/{eid}/stream` | `?stream=stdout\|stderr\|both&fromSeq=` SSE: `data: {"seq":n,"stream":"stdout","data":"..."}` then `event: exit` | stream |
21
+ | `GET /sandboxes/{id}/logs` | `?executionId&source&lastN&cursor&limit` | `{logs:[{ts,source:"stdout"\|"stderr"\|"entrypoint"\|"system",executionId?,message}], nextCursor?}` |
22
+
23
+ ```
24
+ ExecutionView = { executionId, sandboxId, status: "queued"|"running"|"completed", exitCode?: int,
25
+ stdout: string, stderr: string, stdoutTruncated: bool, stderrTruncated: bool,
26
+ shellName?: string, startedAt, finishedAt? }
27
+ ```
28
+ Semantics: output is buffered server-side (suggest 1 MiB per stream, tail kept, `*Truncated` set) and survives the client
29
+ disconnecting; records are kept >= 24 h after exit. A paused sandbox keeps its records. `status:"completed"` with
30
+ `exitCode` absent means the sandbox died mid-run. The HTTP `POST /sandboxes/{id}/exec` fast path stays as is.
31
+
32
+ **As built (feat2, pinetree-desktop#570):** routes and `ExecutionView` as above; list `limit<=100`, logs `limit<=1000`, wait capped at 30 s, stdin <=1 MiB per call,
33
+ max 16 running executions per sandbox (429 `TooManyExecutions`, retryable); `kill` waits up to 2 s for the exit and returns the view; logs sources are
34
+ `stdout|stderr|system` (+ `entrypoint`, always empty for now), `system` lines are "execution started: <cmd>" / "execution exited with code N", the cursor is an opaque
35
+ numeric offset; shared-context sandboxes get 409 `NotSupported`; wait/kill/stdin only work on the pod that started the run, else 409 `ExecutionOnOtherInstance`
36
+ (retryable: the compat layer retries); GET/list/logs work on any pod only once `EXECUTIONS_TABLE` (Firestore) is configured, otherwise records are in pod memory;
37
+ `shellName` is accepted and echoed but is not a persistent shell yet (named shells are #569: `POST /sandboxes/{id}/shells/{name}/exec`); stdin `eof:true` needs the next guest image.
38
+
39
+ ## 2. Names, metadata, pagination, snapshots (owner: bug3)
40
+ - `PATCH /sandboxes/{id}` `{name?: string|null, metadata?: {string:string}}` -> `SandboxView` (metadata is replaced, not merged;
41
+ send `null` to clear a field). Add first-class `name?: string` and `template` to `SandboxView`/create (`name` in
42
+ `POST /sandboxes`). Until it ships the compat layer stores the name under metadata key `runloop.name`.
43
+ - `GET /sandboxes` gains `startingAfter=<sandboxId>` (exclusive; fall back to 400 if unknown) and returns
44
+ `{sandboxes, nextCursor?, hasMore: bool, totalCount?: int}` (`includeTotalCount=true` to get it). Keep `cursor`.
45
+ - Snapshots: `POST /sandboxes/{id}/snapshots` accepts `{name?, metadata?, commitMessage?}`; `SnapshotView` gains
46
+ `metadata`, `commitMessage?`, `sourceSandboxId`; `PATCH /snapshots/{id}` `{name?, metadata?, commitMessage?}`.
47
+ Optional `?wait=false` on create returns early with `status:"pending"` and `GET /snapshots/{id}` shows `status: pending|ready|failed`
48
+ (default stays synchronous).
49
+ - Named persistent shells: `shellName` on execution create = one persistent bash per `(sandboxId, shellName)`; commands queue
50
+ one at a time, cwd/env persist (Runloop `devbox.shell("name")`). First use creates the shell.
51
+
52
+ ## 3. Storage objects, mounts, tunnels (owner: bug2)
53
+ - `POST /objects` `{name, contentType?, ttlMs?}` -> `{objectId, uploadUrl, uploadHeaders?}`; client PUTs the bytes;
54
+ `POST /objects/{id}/complete`; `GET /objects/{id}` -> `{objectId,name,contentType,sizeBytes,state:"uploading"|"ready",createdAt,expiresAt?}`;
55
+ `GET /objects/{id}/download-url` -> `{url, expiresAt}`; `DELETE /objects/{id}`; `GET /objects?limit&cursor&name&contentType`.
56
+ - Mounts at create: `POST /sandboxes` accepts `mounts: [{objectId, path, extract?: bool}]` (a `.tar.gz` object with
57
+ `extract:true` is unpacked at `path`; otherwise placed as a file).
58
+ - Tunnel: `POST /sandboxes/{id}/tunnel` `{authMode?: "open"|"authenticated", wakeOnHttp?: bool, keepAlive?: bool}` ->
59
+ `{urlTemplate: "https://{port}-<key>.<domain>", authToken?, authMode}`; `GET` same shape; `DELETE`. The existing
60
+ per-port `GET /sandboxes/{id}/ports/{port}` stays.
61
+
62
+ ## 4. Blueprints from a Dockerfile (owner: feat1 to sketch, see BLUEPRINTS.md)
63
+ `POST /templates` additionally accepts `{name, dockerfile, buildContext?: objectId, systemSetupCommands?: string[], launchCommands?: string[], baseTemplate?}`;
64
+ `TemplateView`/`GET /templates/{id}/logs` unchanged (status `building|ready|failed`).
@@ -0,0 +1,25 @@
1
+ solari-runloop-compat
2
+
3
+ Not affiliated with, endorsed by, or sponsored by Runloop. "Runloop" and "Devbox" are used only to describe the
4
+ interface this package is compatible with.
5
+
6
+ Portions of solari_runloop/types/ are copied from runloop_api_client 2.0.0
7
+ (https://github.com/runloopai/api-client-python), which is licensed under the MIT License:
8
+
9
+ Copyright 2026 runloop
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
12
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation
13
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
14
+ to permit persons to whom the Software is furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of
17
+ the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
20
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
22
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
23
+ IN THE SOFTWARE.
24
+
25
+ The copied files keep their "generated by Stainless" headers.
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: solari-runloop-compat
3
+ Version: 0.1.0
4
+ Summary: Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop.
5
+ Project-URL: Homepage, https://getsolari.com
6
+ Author: Solari
7
+ License: MIT
8
+ License-File: NOTICE
9
+ Keywords: compatibility,runloop,sandbox,solari
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: pydantic<3,>=2
12
+ Requires-Dist: solari-core>=0.3.1
13
+ Requires-Dist: solari-sandbox>=0.2.4
14
+ Provides-Extra: test
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
16
+ Requires-Dist: pytest>=7; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # solari-runloop-compat (Python)
20
+
21
+ Run code written for the Runloop Python SDK on Solari Sandboxes by changing the import:
22
+
23
+ ```python
24
+ # from runloop_api_client import RunloopSDK
25
+ from solari_runloop import RunloopSDK
26
+
27
+ runloop = RunloopSDK() # SOLARI_API_KEY (or RUNLOOP_API_KEY) and, optionally, SOLARI_BASE_URL
28
+
29
+ with runloop.devbox.create(name="my-devbox") as devbox:
30
+ devbox.file.write(file_path="main.py", contents="print('hello from a sandbox')")
31
+ result = devbox.cmd.exec("python3 main.py")
32
+ print(result.stdout())
33
+ ```
34
+
35
+ `Runloop`, `AsyncRunloop`, `RunloopSDK`, `AsyncRunloopSDK`, the exception classes and the response types are exported
36
+ under Runloop's names. **COMPAT.md** lists exactly what is supported, what differs, and what raises
37
+ `SolariCompatUnsupported`. Unsupported features never fail silently.
38
+
39
+ *Not affiliated with Runloop.* Package and module names are provisional (see `solari_runloop/_branding.py`).
40
+ Do not publish: this is a draft for review.
41
+
42
+ ## Develop
43
+ ```
44
+ pip install -e ../python-core -e ../python-sandbox -e '.[test]'
45
+ pytest # offline unit tests
46
+ SOLARI_API_KEY=... SOLARI_BASE_URL=https://api-sta.getsolari.com pytest -m live # staging; deletes what it creates
47
+ ```
@@ -0,0 +1,29 @@
1
+ # solari-runloop-compat (Python)
2
+
3
+ Run code written for the Runloop Python SDK on Solari Sandboxes by changing the import:
4
+
5
+ ```python
6
+ # from runloop_api_client import RunloopSDK
7
+ from solari_runloop import RunloopSDK
8
+
9
+ runloop = RunloopSDK() # SOLARI_API_KEY (or RUNLOOP_API_KEY) and, optionally, SOLARI_BASE_URL
10
+
11
+ with runloop.devbox.create(name="my-devbox") as devbox:
12
+ devbox.file.write(file_path="main.py", contents="print('hello from a sandbox')")
13
+ result = devbox.cmd.exec("python3 main.py")
14
+ print(result.stdout())
15
+ ```
16
+
17
+ `Runloop`, `AsyncRunloop`, `RunloopSDK`, `AsyncRunloopSDK`, the exception classes and the response types are exported
18
+ under Runloop's names. **COMPAT.md** lists exactly what is supported, what differs, and what raises
19
+ `SolariCompatUnsupported`. Unsupported features never fail silently.
20
+
21
+ *Not affiliated with Runloop.* Package and module names are provisional (see `solari_runloop/_branding.py`).
22
+ Do not publish: this is a draft for review.
23
+
24
+ ## Develop
25
+ ```
26
+ pip install -e ../python-core -e ../python-sandbox -e '.[test]'
27
+ pytest # offline unit tests
28
+ SOLARI_API_KEY=... SOLARI_BASE_URL=https://api-sta.getsolari.com pytest -m live # staging; deletes what it creates
29
+ ```
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "solari-runloop-compat"
7
+ version = "0.1.0"
8
+ description = "Runloop-compatible Python interface over Solari Sandboxes. Not affiliated with Runloop."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Solari" }]
13
+ keywords = ["solari", "sandbox", "runloop", "compatibility"]
14
+ dependencies = ["solari-sandbox>=0.2.4", "solari-core>=0.3.1", "pydantic>=2,<3"]
15
+
16
+ [project.optional-dependencies]
17
+ test = ["pytest>=7", "pytest-asyncio>=0.23"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://getsolari.com"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["solari_runloop"]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+ testpaths = ["tests"]
28
+ markers = ["live: runs against a real Solari gateway (needs SOLARI_API_KEY + SOLARI_BASE_URL)"]
29
+ addopts = "-m 'not live'"
@@ -0,0 +1,42 @@
1
+ """Runloop-compatible interface over the Solari sandbox gateway.
2
+
3
+ Not affiliated with Runloop. See COMPAT.md for exactly which parts of the Runloop SDK are supported.
4
+ Change ``from runloop_api_client import RunloopSDK`` to ``from solari_runloop import RunloopSDK``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from . import types
10
+ from ._branding import TARGET_RUNLOOP_VERSION
11
+ from ._client import AsyncRunloop, Runloop
12
+ from ._exceptions import (
13
+ APIError,
14
+ APIStatusError,
15
+ ConflictError,
16
+ NotFoundError,
17
+ RateLimitError,
18
+ RunloopError,
19
+ BadRequestError,
20
+ APITimeoutError,
21
+ APIConnectionError,
22
+ AuthenticationError,
23
+ InternalServerError,
24
+ PermissionDeniedError,
25
+ SolariCompatUnsupported,
26
+ UnprocessableEntityError,
27
+ APIResponseValidationError,
28
+ )
29
+ from .lib.polling import PollingConfig, PollingTimeout
30
+ from .sdk import AsyncRunloopSDK, RunloopSDK
31
+
32
+ __version__ = "0.1.0"
33
+ Client = Runloop
34
+ AsyncClient = AsyncRunloop
35
+
36
+ __all__ = [
37
+ "Runloop", "AsyncRunloop", "RunloopSDK", "AsyncRunloopSDK", "Client", "AsyncClient",
38
+ "RunloopError", "APIError", "APIStatusError", "APIConnectionError", "APITimeoutError",
39
+ "APIResponseValidationError", "BadRequestError", "AuthenticationError", "PermissionDeniedError",
40
+ "NotFoundError", "ConflictError", "UnprocessableEntityError", "RateLimitError", "InternalServerError",
41
+ "SolariCompatUnsupported", "PollingConfig", "PollingTimeout", "types", "TARGET_RUNLOOP_VERSION",
42
+ ]
@@ -0,0 +1,10 @@
1
+ """Every name that might change after the naming/legal review lives here."""
2
+
3
+ PACKAGE_NAME = "solari-runloop-compat"
4
+ MODULE_NAME = "solari_runloop"
5
+ #: Runloop SDK release whose public surface this layer mirrors (see COMPAT.md).
6
+ TARGET_RUNLOOP_VERSION = "2.0.0"
7
+ DEFAULT_BASE_URL = "https://api.getsolari.com"
8
+ KEY_ENV_VARS = ("SOLARI_API_KEY", "RUNLOOP_API_KEY")
9
+ BASE_URL_ENV_VARS = ("SOLARI_BASE_URL", "RUNLOOP_BASE_URL")
10
+ KEY_PREFIX = "slr_"
@@ -0,0 +1,107 @@
1
+ """``Runloop`` / ``AsyncRunloop``: the REST-style client (``client.devboxes.*``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping, Optional
6
+
7
+ from . import _config
8
+ from ._gateway import Gateway
9
+ from ._loop import LoopThread
10
+ from ._resources import AsyncBlueprints, AsyncDevboxes, AsyncSecrets, _Unsupported
11
+ from ._sync import SyncMirror
12
+
13
+ DEFAULT_MAX_RETRIES = 5
14
+
15
+ _UNSUPPORTED_RESOURCES = {
16
+ "objects": "Storage objects have no Solari equivalent yet.",
17
+ "agents": "Agents are a Runloop platform feature.",
18
+ "axons": "Axons are a Runloop platform feature.",
19
+ "network_policies": "Network policies have no Solari equivalent.",
20
+ "gateway_configs": "Agent gateways are a Runloop platform feature.",
21
+ "mcp_configs": "MCP configs are a Runloop platform feature.",
22
+ "accounts": "Accounts are a Runloop platform feature.",
23
+ "apikeys": "API keys are managed in the Solari console.",
24
+ "restricted_keys": "Restricted keys are a Runloop platform feature.",
25
+ "pty": "PTY tunnels are not exposed through the compat layer.",
26
+ }
27
+
28
+
29
+ class AsyncRunloop:
30
+ """Async REST client. Accepts Runloop's constructor arguments; ``timeout``/``default_headers``/
31
+ ``http_client`` are accepted for source compatibility and not used."""
32
+
33
+ _solari_mirror = False
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ bearer_token: Optional[str] = None,
39
+ base_url: Optional[str] = None,
40
+ timeout: Any = None,
41
+ max_retries: int = DEFAULT_MAX_RETRIES,
42
+ default_headers: Optional[Mapping[str, str]] = None,
43
+ default_query: Optional[Mapping[str, object]] = None,
44
+ http_client: Any = None,
45
+ **_ignored: Any,
46
+ ) -> None:
47
+ token, url = _config.resolve(bearer_token, base_url)
48
+ self.bearer_token = token
49
+ self.base_url = url
50
+ self.max_retries = max_retries
51
+ self._gw = Gateway(token=token, base_url=url, max_retries=max_retries)
52
+ self.devboxes = AsyncDevboxes(self._gw)
53
+ self.blueprints = AsyncBlueprints(self._gw)
54
+ self.secrets = AsyncSecrets(self._gw)
55
+ for name, hint in _UNSUPPORTED_RESOURCES.items():
56
+ setattr(self, name, _Unsupported(name, hint))
57
+
58
+ def with_options(self, **_: Any) -> "AsyncRunloop":
59
+ return self
60
+
61
+ async def close(self) -> None:
62
+ await self._gw.aclose()
63
+
64
+ async def __aenter__(self) -> "AsyncRunloop":
65
+ return self
66
+
67
+ async def __aexit__(self, *_exc: Any) -> None:
68
+ await self.close()
69
+
70
+
71
+ class Runloop:
72
+ """Sync REST client: the async client driven from a background event loop (see ``_loop.py``)."""
73
+
74
+ def __init__(self, **kwargs: Any) -> None:
75
+ self._attach(AsyncRunloop(**kwargs), LoopThread())
76
+
77
+ @classmethod
78
+ def _wrapping(cls, inner: AsyncRunloop, loop: LoopThread) -> "Runloop":
79
+ """A sync view of an existing async client (used by ``RunloopSDK`` so both share one loop)."""
80
+ self = cls.__new__(cls)
81
+ self._attach(inner, loop)
82
+ return self
83
+
84
+ def _attach(self, inner: AsyncRunloop, loop: LoopThread) -> None:
85
+ self._loop = loop
86
+ self._inner = inner
87
+ self.bearer_token = self._inner.bearer_token
88
+ self.base_url = self._inner.base_url
89
+ self.max_retries = self._inner.max_retries
90
+ self.devboxes = SyncMirror(self._inner.devboxes, self._loop)
91
+ self.blueprints = SyncMirror(self._inner.blueprints, self._loop)
92
+ self.secrets = SyncMirror(self._inner.secrets, self._loop)
93
+ for name in _UNSUPPORTED_RESOURCES:
94
+ setattr(self, name, getattr(self._inner, name))
95
+
96
+ def with_options(self, **_: Any) -> "Runloop":
97
+ return self
98
+
99
+ def close(self) -> None:
100
+ self._loop.run(self._inner.close())
101
+ self._loop.close()
102
+
103
+ def __enter__(self) -> "Runloop":
104
+ return self
105
+
106
+ def __exit__(self, *_exc: Any) -> None:
107
+ self.close()