From bd725704375ce34cbf9a1196b1c9911caa2aa4fc Mon Sep 17 00:00:00 2001
From: "fabro-sh-0530[bot]"
<281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Date: Mon, 25 May 2026 22:41:57 -0400
Subject: [PATCH] =?UTF-8?q?Add=20provider-backed=20sandbox=20inventory=20A?=
=?UTF-8?q?PI=20and=20rename=20SandboxProvider=20to=E2=80=A6=20(#409)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Exposes `GET /api/v1/sandboxes` and `GET /api/v1/sandboxes/{id}`
endpoints that query sandbox inventory directly from configured
providers (Docker, Daytona), independent of run projections. Also
renames the existing `SandboxProvider` enum to `SandboxProviderKind`
throughout the codebase to free the name for the new `SandboxProvider`
trait.
### Plan Summary
- **OpenAPI + types**: New `SandboxInfo`, `SandboxListResponse`,
`SandboxListMeta`, `SandboxProviderLookupError`, and
`SandboxProviderKind` schemas added to the API spec; canonical Rust DTOs
added to `fabro-types`.
- **Provider trait and registry**: `SandboxProvider` trait (`list`,
`get`, `create`, `delete`) and `SandboxProviderRegistry` introduced in
`fabro-sandbox/src/provider.rs`. Registry fans out calls across all
configured providers and implements fail-soft semantics for list and
conflict/unavailable detection for get.
- **Provider implementations**: `DockerSandboxProvider` uses Bollard
label-filtered container listing and per-inspect;
`DaytonaSandboxProvider` uses the SDK with paginated label-filtered
listing. Both verify `sh.fabro.managed=true`.
- **Shared detail mapping**: Docker and Daytona inspect-to-`SandboxInfo`
paths extracted into `docker_info_from_inspect` /
`daytona_info_from_sdk_sandbox` so run-scoped `SandboxDetails` and
inventory `SandboxInfo` share the same normalization logic.
- **Monitoring UI**: `RunsInfo` now exposes `scheduler_slots_used`; the
monitoring panel displays "slots used" instead of the raw active-run
count.
## What changed and why
**`SandboxProvider` → `SandboxProviderKind`** is a mechanical rename
across ~20 call sites so the unqualified name `SandboxProvider` can be
claimed by the new trait without collision.
**Registry lookup semantics** for `get_managed_by_native_id`:
| Outcome | HTTP |
|---|---|
| Exactly one provider matches | `200` |
| All providers succeed, none match | `404` |
| Two or more providers match the same id | `409` |
| No match + at least one provider failed | `502` |
List is always fail-soft: partial results are returned and failing
providers appear in `meta.provider_errors`.
**`DockerFields` / `DaytonaFields` structs** were introduced inside
`details.rs` to hold the shared normalization output. Both
`map_docker_inspect` (run-scoped) and `docker_info_from_inspect`
(inventory) now delegate to `docker_fields_from_inspect`, eliminating
duplicate field-extraction logic. Same pattern for Daytona.
**`futures` moved from optional to unconditional** in
`fabro-sandbox/Cargo.toml` because `join_all` / `try_join_all` are now
used in `provider.rs`, which is not feature-gated.
**`local` provider** intentionally returns an empty list and `None` for
get — it has no provider-managed inventory.
### Fabro Details
Ran 8 stages in 102m 54s for $41.81
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 6s | – | 0 |
| preflight_lint | 2m 20s | – | 0 |
| implement | 56m 40s | $10.82 | 0 |
| simplify_opus | 27m 50s | $26.24 | 0 |
| simplify_gpt | 5m 2s | $4.76 | 0 |
| verify | 8m 24s | – | 0 |
| **Total** | **102m 54s** | **$41.81** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14
edges)
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
docs/public/api-reference/fabro-api.yaml | 160 +++++++-
lib/crates/fabro-api/build.rs | 18 +-
lib/crates/fabro-api/src/lib.rs | 13 +-
.../fabro-api/tests/run_sandbox_round_trip.rs | 8 +-
.../tests/sandbox_details_round_trip.rs | 15 +-
.../tests/sandbox_inventory_round_trip.rs | 123 ++++++
.../src/commands/run/run_progress/mod.rs | 6 +-
lib/crates/fabro-dump/src/lib.rs | 8 +-
lib/crates/fabro-install/src/lib.rs | 8 +-
lib/crates/fabro-sandbox/Cargo.toml | 5 +-
lib/crates/fabro-sandbox/src/daytona/mod.rs | 2 +-
lib/crates/fabro-sandbox/src/details.rs | 227 +++++++++--
lib/crates/fabro-sandbox/src/lib.rs | 11 +-
.../fabro-sandbox/src/managed_labels.rs | 10 +-
lib/crates/fabro-sandbox/src/provider.rs | 360 ++++++++++++++++++
.../fabro-sandbox/src/provider/daytona.rs | 172 +++++++++
.../fabro-sandbox/src/provider/docker.rs | 163 ++++++++
lib/crates/fabro-sandbox/src/reconnect.rs | 12 +-
lib/crates/fabro-sandbox/src/sandbox_spec.rs | 19 +-
lib/crates/fabro-sandbox/src/terminal.rs | 14 +-
lib/crates/fabro-sandbox/src/test_support.rs | 100 +++++
lib/crates/fabro-server/src/run_manifest.rs | 61 +--
lib/crates/fabro-server/src/serve.rs | 1 +
lib/crates/fabro-server/src/server.rs | 62 ++-
.../fabro-server/src/server/handler/mod.rs | 2 +
.../src/server/handler/sandbox.rs | 50 +--
.../src/server/handler/sandboxes.rs | 247 ++++++++++++
lib/crates/fabro-server/src/server/tests.rs | 18 +-
lib/crates/fabro-server/src/test_support.rs | 12 +
lib/crates/fabro-store/src/run_state.rs | 4 +-
.../tests/serializable_projection.rs | 4 +-
lib/crates/fabro-types/src/lib.rs | 6 +-
lib/crates/fabro-types/src/run_event/infra.rs | 4 +-
lib/crates/fabro-types/src/run_sandbox.rs | 4 +-
lib/crates/fabro-types/src/sandbox_details.rs | 4 +-
.../fabro-types/src/sandbox_inventory.rs | 52 +++
.../fabro-types/src/sandbox_provider.rs | 34 +-
lib/crates/fabro-types/src/settings/run.rs | 2 +-
lib/crates/fabro-types/src/settings/server.rs | 11 +-
.../tests/sandbox_inventory_serde.rs | 119 ++++++
.../fabro-types/tests/sandbox_model_serde.rs | 20 +-
lib/crates/fabro-workflow/src/event/events.rs | 4 +-
.../fabro-workflow/src/operations/retry.rs | 2 +-
.../fabro-workflow/src/operations/start.rs | 14 +-
.../fabro-workflow/tests/it/cp_integration.rs | 6 +-
.../tests/it/daytona_integration.rs | 4 +-
.../src/.openapi-generator/FILES | 7 +-
lib/packages/fabro-api-client/src/api.ts | 1 +
.../fabro-api-client/src/api/sandboxes-api.ts | 200 ++++++++++
.../src/models/delete-run-sandbox.ts | 4 +-
.../fabro-api-client/src/models/index.ts | 6 +-
.../src/models/run-sandbox.ts | 4 +-
.../src/models/sandbox-info.ts | 77 ++++
.../src/models/sandbox-list-meta.ts | 25 ++
.../src/models/sandbox-list-response.ts | 29 ++
.../src/models/sandbox-provider-kind.ts | 27 ++
.../models/sandbox-provider-lookup-error.ts | 26 ++
57 files changed, 2379 insertions(+), 228 deletions(-)
create mode 100644 lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs
create mode 100644 lib/crates/fabro-sandbox/src/provider.rs
create mode 100644 lib/crates/fabro-sandbox/src/provider/daytona.rs
create mode 100644 lib/crates/fabro-sandbox/src/provider/docker.rs
create mode 100644 lib/crates/fabro-server/src/server/handler/sandboxes.rs
create mode 100644 lib/crates/fabro-types/src/sandbox_inventory.rs
create mode 100644 lib/crates/fabro-types/tests/sandbox_inventory_serde.rs
create mode 100644 lib/packages/fabro-api-client/src/api/sandboxes-api.ts
create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-info.ts
create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-list-meta.ts
create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-list-response.ts
create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-kind.ts
create mode 100644 lib/packages/fabro-api-client/src/models/sandbox-provider-lookup-error.ts
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index fdada6ce5..6d190e0cf 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -15,6 +15,8 @@ tags:
description: Browser authentication
- name: Runs
description: Run management operations
+ - name: Sandboxes
+ description: Provider-backed sandbox inventory
- name: Sessions
description: Ask Fabro sessions bound to runs
- name: Human-in-the-Loop
@@ -3632,6 +3634,67 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
+ /api/v1/sandboxes:
+ get:
+ operationId: listSandboxes
+ tags: [Sandboxes]
+ summary: List Sandboxes
+ description: Lists Fabro-managed sandboxes directly from configured sandbox providers.
+ responses:
+ "200":
+ description: Provider-backed sandbox inventory
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SandboxListResponse"
+
+ /api/v1/sandboxes/{id}:
+ get:
+ operationId: retrieveSandbox
+ tags: [Sandboxes]
+ summary: Retrieve Sandbox
+ description: Retrieves a Fabro-managed sandbox by provider-native id by searching all configured sandbox providers.
+ parameters:
+ - in: path
+ name: id
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Sandbox found
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/SandboxInfo"
+ "404":
+ description: No provider found a Fabro-managed sandbox with this id
+ headers:
+ x-request-id:
+ $ref: "#/components/headers/XRequestId"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "409":
+ description: More than one provider matched this sandbox id
+ headers:
+ x-request-id:
+ $ref: "#/components/headers/XRequestId"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+ "502":
+ description: Provider lookup failed before a definitive result could be determined
+ headers:
+ x-request-id:
+ $ref: "#/components/headers/XRequestId"
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorResponse"
+
/api/v1/runs/{id}/sandbox:
get:
operationId: retrieveRunSandbox
@@ -7536,7 +7599,7 @@ components:
required: [provider, id]
properties:
provider:
- $ref: "#/components/schemas/SandboxProvider"
+ $ref: "#/components/schemas/SandboxProviderKind"
id:
type: string
@@ -10177,8 +10240,8 @@ components:
description: Equals `inference_time_ms + tool_time_ms`.
example: 180000
- SandboxProvider:
- description: Sandbox execution provider.
+ SandboxProviderKind:
+ description: Sandbox provider discriminator.
type: string
enum:
- local
@@ -10195,7 +10258,7 @@ components:
- runtime
properties:
provider:
- $ref: "#/components/schemas/SandboxProvider"
+ $ref: "#/components/schemas/SandboxProviderKind"
image:
type: ["string", "null"]
snapshot:
@@ -11097,6 +11160,95 @@ components:
timestamps:
$ref: "#/components/schemas/SandboxTimestamps"
+ SandboxInfo:
+ description: Provider-backed inventory record for a Fabro-managed sandbox.
+ type: object
+ required:
+ - provider
+ - id
+ - state
+ - resources
+ - network
+ - labels
+ - timestamps
+ properties:
+ provider:
+ $ref: "#/components/schemas/SandboxProviderKind"
+ id:
+ type: string
+ description: Provider-native sandbox id.
+ display_name:
+ type: ["string", "null"]
+ description: Provider display name when distinct from the native id.
+ state:
+ $ref: "#/components/schemas/SandboxState"
+ native_state:
+ type: ["string", "null"]
+ description: Original provider state string before normalization. Display/debugging only; UI behavior keys off `state`.
+ image:
+ type: ["string", "null"]
+ description: Provider image when surfaced by the sandbox provider.
+ snapshot:
+ type: ["string", "null"]
+ description: Provider snapshot when surfaced by the sandbox provider.
+ region:
+ type: ["string", "null"]
+ description: Provider region or target. Null for local-style providers.
+ web_url:
+ type: ["string", "null"]
+ description: Provider dashboard URL for this sandbox when available.
+ working_directory:
+ type: ["string", "null"]
+ description: Provider-reported or Fabro-default working directory when available.
+ resources:
+ $ref: "#/components/schemas/SandboxResources"
+ network:
+ $ref: "#/components/schemas/SandboxNetwork"
+ labels:
+ type: object
+ additionalProperties:
+ type: string
+ description: Provider-reported labels.
+ timestamps:
+ $ref: "#/components/schemas/SandboxTimestamps"
+
+ SandboxProviderLookupError:
+ description: Provider error captured during fail-soft sandbox inventory lookup.
+ type: object
+ required:
+ - provider
+ - message
+ properties:
+ provider:
+ $ref: "#/components/schemas/SandboxProviderKind"
+ message:
+ type: string
+
+ SandboxListMeta:
+ description: Metadata for provider-backed sandbox inventory.
+ type: object
+ required:
+ - provider_errors
+ properties:
+ provider_errors:
+ type: array
+ items:
+ $ref: "#/components/schemas/SandboxProviderLookupError"
+
+ SandboxListResponse:
+ description: Non-paginated provider-backed sandbox inventory response.
+ type: object
+ required:
+ - data
+ - meta
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/SandboxInfo"
+ meta:
+ $ref: "#/components/schemas/SandboxListMeta"
+
SandboxFileEntry:
description: A directory entry in a run sandbox.
type: object
diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs
index 14d13d70e..737ae9cf9 100644
--- a/lib/crates/fabro-api/build.rs
+++ b/lib/crates/fabro-api/build.rs
@@ -530,9 +530,25 @@ fn main() {
("PreRunPushOutcome", "fabro_types::PreRunPushOutcome", &[]),
("DirtyStatus", "fabro_types::DirtyStatus", &[]),
("GitContext", "fabro_types::GitContext", &[]),
- ("SandboxProvider", "fabro_types::SandboxProvider", &[]),
+ (
+ "SandboxProviderKind",
+ "fabro_types::SandboxProviderKind",
+ &[],
+ ),
("RunSandbox", "fabro_types::RunSandbox", &[]),
("SandboxDetails", "fabro_types::SandboxDetails", &[]),
+ ("SandboxInfo", "fabro_types::SandboxInfo", &[]),
+ (
+ "SandboxProviderLookupError",
+ "fabro_types::SandboxProviderLookupError",
+ &[],
+ ),
+ ("SandboxListMeta", "fabro_types::SandboxListMeta", &[]),
+ (
+ "SandboxListResponse",
+ "fabro_types::SandboxListResponse",
+ &[],
+ ),
("SandboxNetwork", "fabro_types::SandboxNetwork", &[]),
(
"SandboxNetworkPolicy",
diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs
index 9b40a152c..0eceb6ec3 100644
--- a/lib/crates/fabro-api/src/lib.rs
+++ b/lib/crates/fabro-api/src/lib.rs
@@ -47,12 +47,13 @@ pub mod types {
QuestionType, RepositoryRef, Run, RunApproval, RunApprovalState, RunClientProvenance,
RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,
RunPairStatusResponse, RunProjection, RunProvenance, RunRunnableSource, RunSandbox,
- RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxNetwork,
- SandboxNetworkPolicy, SandboxNetworkPolicyMode, SandboxProvider, SandboxResources,
- SandboxService, SandboxServiceListResponse, SandboxState, SandboxTimestamps,
- SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId, SessionMessage,
- SessionRecord, SessionStatus, SessionSummary, SessionTurn, SkillsProjection,
- StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
+ RunSandboxRuntime, RunServerProvenance, RunSize, SandboxDetails, SandboxInfo,
+ SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
+ SandboxNetworkPolicyMode, SandboxProviderKind, SandboxProviderLookupError,
+ SandboxResources, SandboxService, SandboxServiceListResponse, SandboxState,
+ SandboxTimestamps, SecretMetadata, SecretType, ServerSettings, SessionDetail, SessionId,
+ SessionMessage, SessionRecord, SessionStatus, SessionSummary, SessionTurn,
+ SkillsProjection, StageCompletion, StageContextWindow, StageContextWindowBreakdownItem,
StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection,
StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageContextWindowWarning, StageHandler, StageModelUsage, StageOutcome, StageProjection,
diff --git a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs
index 0e3a21af9..5c7da541a 100644
--- a/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs
+++ b/lib/crates/fabro-api/tests/run_sandbox_round_trip.rs
@@ -1,19 +1,19 @@
use std::any::{TypeId, type_name};
-use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProvider as ApiSandboxProvider};
-use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProvider};
+use fabro_api::types::{RunSandbox as ApiRunSandbox, SandboxProviderKind as ApiSandboxProvider};
+use fabro_types::{RunSandbox, RunSandboxRuntime, SandboxProviderKind};
use serde_json::json;
#[test]
fn run_sandbox_reuses_domain_types() {
assert_same_type::();
- assert_same_type::();
+ assert_same_type::();
}
#[test]
fn run_sandbox_json_matches_openapi_shape() {
let sandbox = RunSandbox {
- provider: SandboxProvider::Docker,
+ provider: SandboxProviderKind::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
diff --git a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs
index 97485b4c1..a485ac4eb 100644
--- a/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs
+++ b/lib/crates/fabro-api/tests/sandbox_details_round_trip.rs
@@ -5,20 +5,21 @@ use chrono::{TimeZone, Utc};
use fabro_api::types::{
SandboxDetails as ApiSandboxDetails, SandboxNetwork as ApiSandboxNetwork,
SandboxNetworkPolicy as ApiSandboxNetworkPolicy,
- SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode, SandboxProvider as ApiSandboxProvider,
- SandboxResources as ApiSandboxResources, SandboxState as ApiSandboxState,
- SandboxTimestamps as ApiSandboxTimestamps,
+ SandboxNetworkPolicyMode as ApiSandboxNetworkPolicyMode,
+ SandboxProviderKind as ApiSandboxProvider, SandboxResources as ApiSandboxResources,
+ SandboxState as ApiSandboxState, SandboxTimestamps as ApiSandboxTimestamps,
};
use fabro_types::{
RunSandbox, RunSandboxRuntime, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy,
- SandboxNetworkPolicyMode, SandboxProvider, SandboxResources, SandboxState, SandboxTimestamps,
+ SandboxNetworkPolicyMode, SandboxProviderKind, SandboxResources, SandboxState,
+ SandboxTimestamps,
};
use serde_json::json;
#[test]
fn sandbox_details_reuses_domain_types() {
assert_same_type::();
- assert_same_type::();
+ assert_same_type::();
assert_same_type::();
assert_same_type::();
assert_same_type::();
@@ -32,7 +33,7 @@ fn sandbox_details_json_matches_openapi_shape() {
let created_at = Utc.with_ymd_and_hms(2026, 5, 9, 12, 0, 0).unwrap();
let details = SandboxDetails {
sandbox: RunSandbox {
- provider: SandboxProvider::Docker,
+ provider: SandboxProviderKind::Docker,
image: Some("ghcr.io/fabro/sandbox:latest".to_string()),
snapshot: None,
runtime: Some(RunSandboxRuntime {
@@ -129,7 +130,7 @@ fn sandbox_details_deserializes_when_optional_fields_are_absent() {
}))
.unwrap();
- assert_eq!(details.sandbox.provider, SandboxProvider::Local);
+ assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);
assert_eq!(
details
.sandbox
diff --git a/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs b/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs
new file mode 100644
index 000000000..85f6de00a
--- /dev/null
+++ b/lib/crates/fabro-api/tests/sandbox_inventory_round_trip.rs
@@ -0,0 +1,123 @@
+use std::any::{TypeId, type_name};
+use std::collections::BTreeMap;
+
+use chrono::{TimeZone, Utc};
+use fabro_api::types::{
+ SandboxInfo as ApiSandboxInfo, SandboxListMeta as ApiSandboxListMeta,
+ SandboxListResponse as ApiSandboxListResponse, SandboxProviderKind as ApiSandboxProviderKind,
+ SandboxProviderLookupError as ApiSandboxProviderLookupError,
+};
+use fabro_types::{
+ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxNetwork, SandboxNetworkPolicy,
+ SandboxProviderKind, SandboxProviderLookupError, SandboxResources, SandboxState,
+ SandboxTimestamps,
+};
+use serde_json::json;
+
+#[test]
+fn sandbox_inventory_round_trip_reuses_domain_types() {
+ assert_same_type::();
+ assert_same_type::();
+ assert_same_type::();
+ assert_same_type::();
+ assert_same_type::();
+}
+
+#[test]
+fn sandbox_inventory_round_trip_json_matches_openapi_shape() {
+ let created_at = Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
+ let response = SandboxListResponse {
+ data: vec![SandboxInfo {
+ provider: SandboxProviderKind::Daytona,
+ id: "sandbox-abc123".to_string(),
+ display_name: Some("fabro-01KSGHGMCFM8W2FHXNMJ7MVY65".to_string()),
+ state: SandboxState::Running,
+ native_state: Some("started".to_string()),
+ image: None,
+ snapshot: Some("daytona-medium".to_string()),
+ region: Some("us".to_string()),
+ web_url: Some(
+ "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123".to_string(),
+ ),
+ working_directory: Some("/home/daytona/workspace".to_string()),
+ resources: SandboxResources {
+ cpu_cores: Some(2.0),
+ memory_bytes: Some(4 * 1024 * 1024 * 1024),
+ disk_bytes: Some(20 * 1024 * 1024 * 1024),
+ },
+ network: SandboxNetwork {
+ egress: SandboxNetworkPolicy::open(),
+ ingress: SandboxNetworkPolicy::blocked(),
+ },
+ labels: BTreeMap::from([(
+ "sh.fabro.managed".to_string(),
+ "true".to_string(),
+ )]),
+ timestamps: SandboxTimestamps {
+ created_at: Some(created_at),
+ last_activity_at: Some(created_at),
+ },
+ }],
+ meta: SandboxListMeta {
+ provider_errors: vec![SandboxProviderLookupError {
+ provider: SandboxProviderKind::Docker,
+ message: "Failed to connect to Docker daemon".to_string(),
+ }],
+ },
+ };
+
+ assert_eq!(
+ serde_json::to_value(&response).unwrap(),
+ json!({
+ "data": [{
+ "provider": "daytona",
+ "id": "sandbox-abc123",
+ "display_name": "fabro-01KSGHGMCFM8W2FHXNMJ7MVY65",
+ "state": "running",
+ "native_state": "started",
+ "snapshot": "daytona-medium",
+ "region": "us",
+ "web_url": "https://app.daytona.io/dashboard/sandboxes?sandboxId=sandbox-abc123",
+ "working_directory": "/home/daytona/workspace",
+ "resources": {
+ "cpu_cores": 2.0,
+ "memory_bytes": 4_294_967_296_u64,
+ "disk_bytes": 21_474_836_480_u64
+ },
+ "network": {
+ "egress": {
+ "mode": "open",
+ "cidrs": []
+ },
+ "ingress": {
+ "mode": "blocked",
+ "cidrs": []
+ }
+ },
+ "labels": {
+ "sh.fabro.managed": "true"
+ },
+ "timestamps": {
+ "created_at": "2026-05-25T12:00:00Z",
+ "last_activity_at": "2026-05-25T12:00:00Z"
+ }
+ }],
+ "meta": {
+ "provider_errors": [{
+ "provider": "docker",
+ "message": "Failed to connect to Docker daemon"
+ }]
+ }
+ })
+ );
+}
+
+fn assert_same_type() {
+ assert_eq!(
+ TypeId::of::(),
+ TypeId::of::(),
+ "{} should be the same type as {}",
+ type_name::(),
+ type_name::()
+ );
+}
diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs
index b13f34dea..782d2badf 100644
--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs
+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs
@@ -474,7 +474,7 @@ mod tests {
use fabro_model::{Catalog, ModelRef, ProviderId};
use fabro_types::run_event::CliEnsureCompletedProps;
use fabro_types::{
- MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProvider,
+ MetadataSnapshotFailureKind, MetadataSnapshotPhase, ParallelBranchId, SandboxProviderKind,
StageId, fixtures,
};
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
@@ -737,7 +737,7 @@ mod tests {
stage_started("code", "Code"),
Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
- provider: SandboxProvider::Daytona,
+ provider: SandboxProviderKind::Daytona,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
@@ -1119,7 +1119,7 @@ mod tests {
emit(&mut ui, stage_started("code", "Code"));
emit(&mut ui, Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
- provider: SandboxProvider::Daytona,
+ provider: SandboxProviderKind::Daytona,
id: "daytona:sandbox-id".into(),
repo_cloned: None,
clone_origin_url: None,
diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs
index 3c7b5a070..d44e01215 100644
--- a/lib/crates/fabro-dump/src/lib.rs
+++ b/lib/crates/fabro-dump/src/lib.rs
@@ -473,9 +473,9 @@ mod tests {
use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::{
- Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus, SandboxProvider,
- StageCompletion, StageModelUsage, StageOutcome, StartRecord, SuccessReason,
- WorkflowSettings, first_event_seq, fixtures,
+ Checkpoint, CheckpointRecord, Conclusion, RunDiff, RunSandbox, RunStatus,
+ SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,
+ SuccessReason, WorkflowSettings, first_event_seq, fixtures,
};
use futures::executor;
@@ -558,7 +558,7 @@ mod tests {
diff: RunDiff::default(),
});
projection.sandbox = Some(RunSandbox {
- provider: SandboxProvider::Local,
+ provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
diff --git a/lib/crates/fabro-install/src/lib.rs b/lib/crates/fabro-install/src/lib.rs
index 933c161cc..7d88fe1dc 100644
--- a/lib/crates/fabro-install/src/lib.rs
+++ b/lib/crates/fabro-install/src/lib.rs
@@ -461,13 +461,13 @@ pub fn write_object_store_settings(
}
fn write_sandbox_provider_policy(server: &mut toml::Table) -> Result<()> {
- use fabro_types::SandboxProvider;
+ use fabro_types::SandboxProviderKind;
let sandbox = ensure_table(server, "sandbox")?;
let providers = ensure_table(sandbox, "providers")?;
for provider in [
- SandboxProvider::Local,
- SandboxProvider::Docker,
- SandboxProvider::Daytona,
+ SandboxProviderKind::Local,
+ SandboxProviderKind::Docker,
+ SandboxProviderKind::Daytona,
] {
let entry = ensure_table(providers, &provider.to_string())?;
entry.insert("enabled".to_string(), toml::Value::Boolean(true));
diff --git a/lib/crates/fabro-sandbox/Cargo.toml b/lib/crates/fabro-sandbox/Cargo.toml
index 0f6c3f874..cdb6aad9d 100644
--- a/lib/crates/fabro-sandbox/Cargo.toml
+++ b/lib/crates/fabro-sandbox/Cargo.toml
@@ -9,7 +9,7 @@ description = "Sandbox trait and implementations for Fabro agent execution envir
[features]
default = ["local"]
local = []
-docker = ["dep:bollard", "dep:tar", "dep:futures", "dep:fabro-github"]
+docker = ["dep:bollard", "dep:tar", "dep:fabro-github"]
daytona = ["dep:daytona-sdk", "dep:daytona-api-client", "dep:git2", "dep:fabro-github", "dep:fabro-config", "dep:fabro-http", "dep:reqwest-middleware", "dep:rand", "dep:tokio-tungstenite", "dep:futures-util", "dep:rustls"]
test-support = []
@@ -39,10 +39,11 @@ shlex = "1"
# local
glob = { version = "0.3" }
+futures = { workspace = true }
+
# docker
bollard = { workspace = true, optional = true }
tar = { workspace = true, optional = true }
-futures = { workspace = true, optional = true }
# daytona
fabro-config = { path = "../fabro-config", optional = true }
diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs
index 9609c4000..3f7faceca 100644
--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs
+++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs
@@ -128,7 +128,7 @@ async fn build_daytona_client(
build_daytona_client_with(api_key, None, None, None).await
}
-async fn build_daytona_client_with(
+pub(crate) async fn build_daytona_client_with(
api_key: Option,
api_url: Option,
organization_id: Option,
diff --git a/lib/crates/fabro-sandbox/src/details.rs b/lib/crates/fabro-sandbox/src/details.rs
index 1aa057802..e6b65bd9b 100644
--- a/lib/crates/fabro-sandbox/src/details.rs
+++ b/lib/crates/fabro-sandbox/src/details.rs
@@ -4,7 +4,7 @@ use anyhow::Result;
#[cfg(any(feature = "docker", feature = "daytona"))]
use chrono::{DateTime, Utc};
use fabro_types::{
- RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProvider, SandboxResources,
+ RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxProviderKind, SandboxResources,
SandboxState, SandboxTimestamps,
};
@@ -26,18 +26,18 @@ pub async fn sandbox_details(
run_id: Option,
) -> Result {
match record.provider {
- SandboxProvider::Local => Ok(local_details(record)),
+ SandboxProviderKind::Local => Ok(local_details(record)),
#[cfg(feature = "docker")]
- SandboxProvider::Docker => docker::docker_details(record, run_id).await,
+ SandboxProviderKind::Docker => docker::docker_details(record, run_id).await,
#[cfg(not(feature = "docker"))]
- SandboxProvider::Docker => Err(anyhow::anyhow!(
+ SandboxProviderKind::Docker => Err(anyhow::anyhow!(
"Sandbox provider '{}' has no details implementation",
record.provider
)),
#[cfg(feature = "daytona")]
- SandboxProvider::Daytona => daytona::daytona_details(record, daytona_api_key).await,
+ SandboxProviderKind::Daytona => daytona::daytona_details(record, daytona_api_key).await,
#[cfg(not(feature = "daytona"))]
- SandboxProvider::Daytona => Err(anyhow::anyhow!(
+ SandboxProviderKind::Daytona => Err(anyhow::anyhow!(
"Sandbox provider '{}' has no details implementation",
record.provider
)),
@@ -66,7 +66,7 @@ fn parse_rfc3339_utc(value: &str) -> Option> {
}
#[cfg(feature = "docker")]
-mod docker {
+pub(crate) mod docker {
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
@@ -74,11 +74,12 @@ mod docker {
use bollard::container::InspectContainerOptions;
use bollard::models::{ContainerInspectResponse, ContainerStateStatusEnum, HostConfig};
use fabro_types::{
- RunId, RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
- SandboxState, SandboxTimestamps,
+ RunId, RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,
+ SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
+ use crate::docker::WORKING_DIRECTORY;
pub(super) async fn docker_details(
record: &RunSandbox,
@@ -94,13 +95,66 @@ mod docker {
.inspect_container(&runtime.id, None::)
.await
.map_err(|err| anyhow!("Failed to inspect Docker container '{}': {err}", runtime.id))?;
- Ok(map_docker_inspect(inspect, record))
+ Ok(map_docker_inspect(&inspect, record))
}
- fn map_docker_inspect(
- inspect: ContainerInspectResponse,
+ pub(crate) fn docker_info_from_inspect(inspect: &ContainerInspectResponse) -> SandboxInfo {
+ let fields = docker_fields_from_inspect(inspect);
+ SandboxInfo {
+ provider: SandboxProviderKind::Docker,
+ id: fields.id,
+ display_name: fields.display_name,
+ state: fields.state,
+ native_state: fields.native_state,
+ image: fields.image,
+ snapshot: None,
+ region: None,
+ web_url: None,
+ working_directory: fields.working_directory,
+ resources: fields.resources,
+ network: fields.network,
+ labels: fields.labels,
+ timestamps: fields.timestamps,
+ }
+ }
+
+ pub(super) fn map_docker_inspect(
+ inspect: &ContainerInspectResponse,
record: &RunSandbox,
) -> SandboxDetails {
+ let fields = docker_fields_from_inspect(inspect);
+ let image = fields.image.clone().or_else(|| record.image.clone());
+
+ SandboxDetails {
+ sandbox: RunSandbox {
+ image,
+ ..record.clone()
+ },
+ state: fields.state,
+ native_state: fields.native_state,
+ region: None,
+ web_url: None,
+ resources: fields.resources,
+ network: fields.network,
+ labels: fields.labels,
+ timestamps: fields.timestamps,
+ }
+ }
+
+ struct DockerFields {
+ id: String,
+ display_name: Option,
+ state: SandboxState,
+ native_state: Option,
+ image: Option,
+ working_directory: Option,
+ resources: SandboxResources,
+ network: SandboxNetwork,
+ labels: BTreeMap,
+ timestamps: SandboxTimestamps,
+ }
+
+ fn docker_fields_from_inspect(inspect: &ContainerInspectResponse) -> DockerFields {
let status_enum = inspect
.state
.as_ref()
@@ -124,23 +178,44 @@ mod docker {
let labels: BTreeMap = inspect
.config
- .and_then(|config| config.labels)
+ .as_ref()
+ .and_then(|config| config.labels.clone())
.map(|map| map.into_iter().collect())
.unwrap_or_default();
- let image = inspect.image;
+ let image = inspect
+ .config
+ .as_ref()
+ .and_then(|config| config.image.clone())
+ .or_else(|| inspect.image.clone())
+ .filter(|value| !value.is_empty());
+ let working_directory = inspect
+ .config
+ .as_ref()
+ .and_then(|config| config.working_dir.clone())
+ .filter(|value| !value.is_empty())
+ .or_else(|| Some(WORKING_DIRECTORY.to_string()));
+
+ let id = inspect
+ .id
+ .clone()
+ .or_else(|| inspect.name.as_ref().map(|name| trim_container_name(name)))
+ .unwrap_or_default();
+ let display_name = inspect
+ .name
+ .as_ref()
+ .map(|name| trim_container_name(name))
+ .filter(|name| !name.is_empty());
let created_at = inspect.created.as_deref().and_then(parse_rfc3339_utc);
- SandboxDetails {
- sandbox: RunSandbox {
- image: image.or_else(|| record.image.clone()),
- ..record.clone()
- },
+ DockerFields {
+ id,
+ display_name,
state: normalized_state,
native_state,
- region: None,
- web_url: None,
+ image,
+ working_directory,
resources,
network,
labels,
@@ -151,6 +226,10 @@ mod docker {
}
}
+ fn trim_container_name(name: &str) -> String {
+ name.strip_prefix('/').unwrap_or(name).to_string()
+ }
+
fn docker_network(host_config: Option<&HostConfig>) -> SandboxNetwork {
match host_config.and_then(|host| host.network_mode.as_deref()) {
Some("none") => {
@@ -195,14 +274,15 @@ mod docker {
mod tests {
use bollard::models::HostConfig;
use fabro_types::{
- RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy, SandboxProvider,
+ RunSandbox, RunSandboxRuntime, SandboxNetwork, SandboxNetworkPolicy,
+ SandboxProviderKind,
};
use super::*;
fn record() -> RunSandbox {
RunSandbox {
- provider: SandboxProvider::Docker,
+ provider: SandboxProviderKind::Docker,
image: None,
snapshot: None,
runtime: Some(RunSandboxRuntime {
@@ -258,7 +338,7 @@ mod docker {
}),
..Default::default()
};
- let details = map_docker_inspect(inspect, &record());
+ let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, None);
}
@@ -271,7 +351,7 @@ mod docker {
}),
..Default::default()
};
- let details = map_docker_inspect(inspect, &record());
+ let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.resources.memory_bytes, Some(2_147_483_648));
}
@@ -284,7 +364,7 @@ mod docker {
}),
..Default::default()
};
- let details = map_docker_inspect(inspect, &record());
+ let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network.egress, SandboxNetworkPolicy::blocked());
assert_eq!(details.network.ingress, SandboxNetworkPolicy::blocked());
}
@@ -298,7 +378,7 @@ mod docker {
}),
..Default::default()
};
- let details = map_docker_inspect(inspect, &record());
+ let details = map_docker_inspect(&inspect, &record());
assert_eq!(details.network, SandboxNetwork::unknown());
}
@@ -308,12 +388,31 @@ mod docker {
name: Some("/fabro-run-abc".to_string()),
..Default::default()
};
- let details = map_docker_inspect(inspect, &record());
+ let details = map_docker_inspect(&inspect, &record());
let runtime = details.sandbox.runtime.expect("runtime");
assert_eq!(runtime.id, "container-abc123");
assert_eq!(runtime.working_directory, "/workspace");
}
+ #[test]
+ fn inventory_identity_uses_native_id_and_display_name() {
+ let inspect = ContainerInspectResponse {
+ id: Some("container-abc123".to_string()),
+ name: Some("/fabro-run-abc".to_string()),
+ ..Default::default()
+ };
+ let info = docker_info_from_inspect(&inspect);
+ assert_eq!(info.id, "container-abc123");
+ assert_eq!(info.display_name.as_deref(), Some("fabro-run-abc"));
+ }
+
+ #[test]
+ fn inventory_working_directory_defaults_to_fabro_workspace() {
+ let inspect = ContainerInspectResponse::default();
+ let info = docker_info_from_inspect(&inspect);
+ assert_eq!(info.working_directory.as_deref(), Some("/workspace"));
+ }
+
#[test]
fn empty_status_is_unknown() {
assert_eq!(
@@ -392,18 +491,18 @@ mod docker {
}
#[cfg(feature = "daytona")]
-mod daytona {
+pub(crate) mod daytona {
use std::collections::BTreeMap;
use anyhow::{Context, Result, anyhow};
use daytona_api_client::models::SandboxState as DaytonaState;
use fabro_types::{
- RunSandbox, SandboxDetails, SandboxNetwork, SandboxNetworkPolicy, SandboxResources,
- SandboxState, SandboxTimestamps,
+ RunSandbox, SandboxDetails, SandboxInfo, SandboxNetwork, SandboxNetworkPolicy,
+ SandboxProviderKind, SandboxResources, SandboxState, SandboxTimestamps,
};
use super::parse_rfc3339_utc;
- use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox};
+ use crate::daytona::{DAYTONA_DASHBOARD_SANDBOXES_URL, DaytonaSandbox, WORKING_DIRECTORY};
pub(super) async fn daytona_details(
record: &RunSandbox,
@@ -434,7 +533,58 @@ mod daytona {
Ok(map_daytona_sandbox(sdk_sandbox, record))
}
- fn map_daytona_sandbox(sandbox: &daytona_sdk::Sandbox, record: &RunSandbox) -> SandboxDetails {
+ pub(crate) fn daytona_info_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> SandboxInfo {
+ let fields = daytona_fields_from_sdk_sandbox(sandbox);
+ SandboxInfo {
+ provider: SandboxProviderKind::Daytona,
+ id: sandbox.id.clone(),
+ display_name: Some(sandbox.name.clone()).filter(|name| !name.is_empty()),
+ state: fields.state,
+ native_state: fields.native_state,
+ image: None,
+ snapshot: sandbox.snapshot.clone(),
+ region: fields.region,
+ web_url: Some(daytona_dashboard_url(&sandbox.id)),
+ working_directory: Some(WORKING_DIRECTORY.to_string()),
+ resources: fields.resources,
+ network: fields.network,
+ labels: fields.labels,
+ timestamps: fields.timestamps,
+ }
+ }
+
+ pub(super) fn map_daytona_sandbox(
+ sandbox: &daytona_sdk::Sandbox,
+ record: &RunSandbox,
+ ) -> SandboxDetails {
+ let fields = daytona_fields_from_sdk_sandbox(sandbox);
+ SandboxDetails {
+ sandbox: RunSandbox {
+ snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),
+ ..record.clone()
+ },
+ state: fields.state,
+ native_state: fields.native_state,
+ region: fields.region,
+ web_url: Some(daytona_dashboard_url(&sandbox.id)),
+ resources: fields.resources,
+ network: fields.network,
+ labels: fields.labels,
+ timestamps: fields.timestamps,
+ }
+ }
+
+ struct DaytonaFields {
+ state: SandboxState,
+ native_state: Option,
+ region: Option,
+ resources: SandboxResources,
+ network: SandboxNetwork,
+ labels: BTreeMap,
+ timestamps: SandboxTimestamps,
+ }
+
+ fn daytona_fields_from_sdk_sandbox(sandbox: &daytona_sdk::Sandbox) -> DaytonaFields {
let normalized_state = sandbox
.state
.map_or(SandboxState::Unknown, normalize_daytona_state);
@@ -459,15 +609,10 @@ mod daytona {
Some(target)
};
- SandboxDetails {
- sandbox: RunSandbox {
- snapshot: sandbox.snapshot.clone().or_else(|| record.snapshot.clone()),
- ..record.clone()
- },
+ DaytonaFields {
state: normalized_state,
native_state,
region,
- web_url: Some(daytona_dashboard_url(&sandbox.id)),
resources,
network: daytona_network(
sandbox.network_block_all,
@@ -673,7 +818,7 @@ mod tests {
#[test]
fn local_details_returns_running_with_no_metadata() {
let record = RunSandbox {
- provider: SandboxProvider::Local,
+ provider: SandboxProviderKind::Local,
image: None,
snapshot: None,
runtime: Some(fabro_types::RunSandboxRuntime {
@@ -689,7 +834,7 @@ mod tests {
}),
};
let details = local_details(&record);
- assert_eq!(details.sandbox.provider, SandboxProvider::Local);
+ assert_eq!(details.sandbox.provider, SandboxProviderKind::Local);
assert_eq!(details.state, SandboxState::Running);
let runtime = details.sandbox.runtime.as_ref().unwrap();
assert_eq!(runtime.id, "local:01JNQVR7M0EJ5GKAT2SC4ERS1Z");
diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs
index 3d3fe4a64..2fb0f0b4b 100644
--- a/lib/crates/fabro-sandbox/src/lib.rs
+++ b/lib/crates/fabro-sandbox/src/lib.rs
@@ -2,6 +2,7 @@ pub mod config;
pub mod error;
#[cfg(any(feature = "docker", feature = "daytona"))]
pub mod from_environment;
+pub mod provider;
pub mod sandbox;
pub mod sandbox_spec;
@@ -39,8 +40,16 @@ pub use details::sandbox_details;
#[cfg(feature = "docker")]
pub use docker::{DockerSandbox, DockerSandboxOptions};
pub use error::{Error, Result, default_redacted_output_tail, display_for_log};
-pub use fabro_types::{RunSandbox, SandboxProvider};
+pub use fabro_types::{RunSandbox, SandboxProviderKind};
pub use local::LocalSandbox;
+#[cfg(feature = "daytona")]
+pub use provider::daytona::DaytonaSandboxProvider;
+#[cfg(feature = "docker")]
+pub use provider::docker::DockerSandboxProvider;
+pub use provider::{
+ LocalSandboxProvider, SandboxCreateSpec, SandboxLookupError, SandboxProvider,
+ SandboxProviderRegistry,
+};
pub use read_guard::ReadBeforeWriteSandbox;
pub use reconnect::{reconnect, reconnect_for_run, reconnect_for_run_with_callback};
pub use sandbox::{
diff --git a/lib/crates/fabro-sandbox/src/managed_labels.rs b/lib/crates/fabro-sandbox/src/managed_labels.rs
index 2841105bc..09b2dd96f 100644
--- a/lib/crates/fabro-sandbox/src/managed_labels.rs
+++ b/lib/crates/fabro-sandbox/src/managed_labels.rs
@@ -3,8 +3,15 @@ use std::collections::HashMap;
use fabro_types::RunId;
pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed";
+pub(crate) const MANAGED_LABEL_VALUE: &str = "true";
pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id";
+/// True when the provided label map carries the Fabro managed sentinel.
+#[cfg(any(feature = "docker", feature = "daytona", test))]
+pub(crate) fn is_managed(labels: &HashMap) -> bool {
+ labels.get(MANAGED_LABEL).map(String::as_str) == Some(MANAGED_LABEL_VALUE)
+}
+
#[cfg(any(feature = "docker", test))]
pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap {
let mut labels = HashMap::new();
@@ -23,7 +30,7 @@ pub(crate) fn merge_for_run(
}
fn insert_for_run(labels: &mut HashMap, run_id: Option<&RunId>) {
- labels.insert(MANAGED_LABEL.to_string(), "true".to_string());
+ labels.insert(MANAGED_LABEL.to_string(), MANAGED_LABEL_VALUE.to_string());
if let Some(run_id) = run_id {
labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string());
}
@@ -60,6 +67,7 @@ mod tests {
labels.get(RUN_ID_LABEL).map(String::as_str),
Some("01HY0000000000000000000000")
);
+ assert!(is_managed(&labels));
}
#[test]
diff --git a/lib/crates/fabro-sandbox/src/provider.rs b/lib/crates/fabro-sandbox/src/provider.rs
new file mode 100644
index 000000000..b72d64efc
--- /dev/null
+++ b/lib/crates/fabro-sandbox/src/provider.rs
@@ -0,0 +1,360 @@
+#[cfg(feature = "daytona")]
+pub mod daytona;
+#[cfg(feature = "docker")]
+pub mod docker;
+
+use std::sync::Arc;
+
+use async_trait::async_trait;
+#[cfg(any(feature = "docker", feature = "daytona"))]
+use fabro_github::GitHubCredentials;
+#[cfg(any(feature = "docker", feature = "daytona"))]
+use fabro_types::RunId;
+use fabro_types::{
+ SandboxInfo, SandboxListMeta, SandboxListResponse, SandboxProviderKind,
+ SandboxProviderLookupError,
+};
+use fabro_util::error::collect_chain;
+use futures::future::join_all;
+
+#[cfg(feature = "daytona")]
+use crate::daytona::DaytonaConfig;
+#[cfg(feature = "docker")]
+use crate::docker::DockerSandboxOptions;
+
+pub enum SandboxCreateSpec {
+ Local,
+ #[cfg(feature = "docker")]
+ Docker {
+ config: DockerSandboxOptions,
+ github_app: Option,
+ run_id: Option,
+ clone_origin_url: Option,
+ clone_branch: Option,
+ },
+ #[cfg(feature = "daytona")]
+ Daytona {
+ config: Box,
+ github_app: Option,
+ run_id: Option,
+ clone_origin_url: Option,
+ clone_branch: Option,
+ api_key: Option,
+ },
+}
+
+#[async_trait]
+pub trait SandboxProvider: Send + Sync {
+ fn kind(&self) -> SandboxProviderKind;
+
+ async fn list(&self) -> crate::Result>;
+ async fn get(&self, id: &str) -> crate::Result