mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Merge remote-tracking branch 'origin/main' into codex/restore-configured-artifact-storage
# Conflicts: # lib/apps/fabro-cli/tests/it/scenario/artifacts.rs
This commit is contained in:
commit
d26b2324f0
62 changed files with 1326 additions and 1568 deletions
|
|
@ -7,6 +7,18 @@ leak-timeout = "500ms"
|
|||
filter = "package(fabro-cli)"
|
||||
slow-timeout = { period = "6s", terminate-after = 4 }
|
||||
|
||||
# Docker scenarios allow 60s for a cold image pull; leave time for cleanup.
|
||||
# Keep this before the general server override because the first match wins.
|
||||
[[profile.default.overrides]]
|
||||
filter = """
|
||||
package(fabro-server) & (
|
||||
test(=scenario::petri::a_runs_projection_carries_its_docker_sandbox_instance)
|
||||
| test(=scenario::petri::the_server_attaches_to_the_container_petri_created)
|
||||
| test(=scenario::petri::a_bundle_naming_a_catalog_environment_runs_on_docker_with_its_image)
|
||||
)
|
||||
"""
|
||||
slow-timeout = { period = "30s", terminate-after = 3 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-server)"
|
||||
slow-timeout = { period = "5s", terminate-after = 4 }
|
||||
|
|
@ -16,7 +28,7 @@ leak-timeout = "500ms"
|
|||
slow-timeout = { period = "2s", terminate-after = 3 }
|
||||
|
||||
# fabro-petri's adapter tests run whole workflows on the host sandbox
|
||||
# through the sandbox-driver plugin, and one of them calls the twin.
|
||||
# through the in-process provider, and one of them calls the twin.
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(fabro-petri)"
|
||||
slow-timeout = { period = "5s", terminate-after = 4 }
|
||||
|
|
|
|||
9
.github/workflows/nightly.yml
vendored
9
.github/workflows/nightly.yml
vendored
|
|
@ -61,6 +61,15 @@ jobs:
|
|||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
|
||||
- name: Pull the images the Docker tests run
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
run: |
|
||||
backend="$(dirname "$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "petri-executor-sandbox") | .manifest_path')")/src/backend.rs"
|
||||
pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")"
|
||||
test -n "$pin"
|
||||
docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin"
|
||||
docker pull ghcr.io/lithoscomputer/ubuntu-22.04:slim
|
||||
|
||||
- name: Release nightly
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
run: |
|
||||
|
|
|
|||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
|
@ -61,6 +61,14 @@ jobs:
|
|||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config libssl-dev
|
||||
|
||||
- name: Install Docker for ARM Linux tests
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get install -y docker.io acl
|
||||
sudo systemctl start docker
|
||||
sudo setfacl -m "u:$(id -un):rw" /var/run/docker.sock
|
||||
docker info
|
||||
|
||||
- name: Install musl toolchain for x86_64-musl tests
|
||||
if: matrix.target == 'x86_64-unknown-linux-musl'
|
||||
run: sudo apt-get install -y musl-tools
|
||||
|
|
@ -88,6 +96,15 @@ jobs:
|
|||
- name: Refresh embedded SPA
|
||||
run: cargo --locked dev spa refresh
|
||||
|
||||
- name: Pull the images the Docker tests run
|
||||
if: runner.os == 'Linux' && matrix.target != 'aarch64-unknown-linux-musl'
|
||||
run: |
|
||||
backend="$(dirname "$(cargo metadata --locked --format-version 1 | jq -r '.packages[] | select(.name == "petri-executor-sandbox") | .manifest_path')")/src/backend.rs"
|
||||
pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")"
|
||||
test -n "$pin"
|
||||
docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin"
|
||||
docker pull ghcr.io/lithoscomputer/ubuntu-22.04:slim
|
||||
|
||||
- name: Test (x86_64-musl)
|
||||
# nextest still shells through cargo test for this target, so
|
||||
# build.rs C code needs an explicit musl compiler/linker.
|
||||
|
|
@ -95,7 +112,7 @@ jobs:
|
|||
env:
|
||||
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||
run: cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
|
||||
run: cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci --no-fail-fast
|
||||
|
||||
- name: Test
|
||||
# aarch64-musl test runs have not been validated on the compile
|
||||
|
|
@ -106,7 +123,7 @@ jobs:
|
|||
if [[ "$RUNNER_OS" == "macOS" ]]; then
|
||||
ulimit -n 4096
|
||||
fi
|
||||
cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci
|
||||
cargo nextest run --locked --workspace --target ${{ matrix.target }} --release --status-level slow --profile ci --no-fail-fast
|
||||
|
||||
- name: Build (musl via cargo-zigbuild)
|
||||
if: matrix.musl
|
||||
|
|
|
|||
103
.github/workflows/rust.yml
vendored
103
.github/workflows/rust.yml
vendored
|
|
@ -17,6 +17,7 @@ on:
|
|||
- "docs/public/reference/user-configuration.mdx"
|
||||
- "openapi/**"
|
||||
- ".github/workflows/rust.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
|
|
@ -33,6 +34,7 @@ on:
|
|||
- "docs/public/reference/user-configuration.mdx"
|
||||
- "openapi/**"
|
||||
- ".github/workflows/rust.yml"
|
||||
- ".github/workflows/release.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -144,30 +146,7 @@ jobs:
|
|||
with:
|
||||
cache-on-failure: true
|
||||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
# Every Petri run takes its scope through a sandbox-driver plugin
|
||||
# executable that Petri finds on PATH: `sandbox-driver-host` for the
|
||||
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
|
||||
# at the rev the workspace pins, so the plugins and the in-process
|
||||
# driver are one build; a from-source build, so the two executables
|
||||
# are cached by OS and rev and only rebuilt when the pin moves.
|
||||
- name: Read the sandbox-driver rev the workspace pins
|
||||
id: sandbox-driver
|
||||
run: |
|
||||
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
|
||||
test -n "$rev"
|
||||
echo "rev=$rev" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore the sandbox-driver plugin executables
|
||||
id: sandbox-driver-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/sandbox-driver-host
|
||||
~/.cargo/bin/sandbox-driver-docker
|
||||
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
|
||||
- name: Install the sandbox-driver plugin executables
|
||||
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
|
||||
# The images the suite's Docker tests run. The plugin pulls a missing
|
||||
# The images the suite's Docker tests run. The provider pulls a missing
|
||||
# image on first use, but a 1 GiB pull inside a run's wait is a flake,
|
||||
# so pull them here, where a registry problem reads as one. Most tests
|
||||
# leave the image to Petri, whose Docker scope runs on its default
|
||||
|
|
@ -194,9 +173,9 @@ jobs:
|
|||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
# The Docker scenarios skip when the executable, the daemon or the
|
||||
# The Docker scenarios skip when the daemon or the
|
||||
# image is missing; in CI a skip is a failure.
|
||||
FABRO_REQUIRE_SANDBOX_PLUGINS: "1"
|
||||
FABRO_REQUIRE_SANDBOX_BACKENDS: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
|
@ -217,40 +196,15 @@ jobs:
|
|||
pin="$(sed -n 's/^const RUNNER_PIN: &str = "\([0-9a-f]*\)";$/\1/p' "$backend")"
|
||||
test -n "$pin"
|
||||
docker pull "ghcr.io/lithoscomputer/ubuntu-24.04:slim-$pin"
|
||||
# Every Petri run takes its scope through a sandbox-driver plugin
|
||||
# executable that Petri finds on PATH: `sandbox-driver-host` for the
|
||||
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
|
||||
# at the rev the workspace pins, so the plugins and the in-process
|
||||
# driver are one build; a from-source build, so the two executables
|
||||
# are cached by OS and rev and only rebuilt when the pin moves.
|
||||
- name: Read the sandbox-driver rev the workspace pins
|
||||
id: sandbox-driver
|
||||
run: |
|
||||
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
|
||||
test -n "$rev"
|
||||
echo "rev=$rev" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore the sandbox-driver plugin executables
|
||||
id: sandbox-driver-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/sandbox-driver-host
|
||||
~/.cargo/bin/sandbox-driver-docker
|
||||
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
|
||||
- name: Install the sandbox-driver plugin executables
|
||||
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
|
||||
# The workflow scenarios on the Docker provider. The scenarios are e2e
|
||||
# tests (ignored by default); the key-free ones run here, the
|
||||
# LLM-backed ones self-skip without credentials.
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow --run-ignored only -p fabro-cli --test it -E 'test(/::docker_/)'
|
||||
# The Petri runs (not ignored: they skip without the host plugin, which
|
||||
# the environment above forbids).
|
||||
# Petri adapter runs use the built-in providers; Docker is required here.
|
||||
- run: cargo nextest run --locked --profile ci --status-level slow -p fabro-petri
|
||||
|
||||
test-macos:
|
||||
name: Test (macOS)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-15
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -265,28 +219,27 @@ jobs:
|
|||
with:
|
||||
cache-on-failure: true
|
||||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
# Every Petri run takes its scope through a sandbox-driver plugin
|
||||
# executable that Petri finds on PATH: `sandbox-driver-host` for the
|
||||
# `local` provider, `sandbox-driver-docker` for `docker`. Installed
|
||||
# at the rev the workspace pins, so the plugins and the in-process
|
||||
# driver are one build; a from-source build, so the two executables
|
||||
# are cached by OS and rev and only rebuilt when the pin moves.
|
||||
- name: Read the sandbox-driver rev the workspace pins
|
||||
id: sandbox-driver
|
||||
run: |
|
||||
rev="$(sed -n 's/^sandbox-driver = { git = "[^"]*", rev = "\([0-9a-f]*\)" }$/\1/p' Cargo.toml)"
|
||||
test -n "$rev"
|
||||
echo "rev=$rev" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore the sandbox-driver plugin executables
|
||||
id: sandbox-driver-cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/sandbox-driver-host
|
||||
~/.cargo/bin/sandbox-driver-docker
|
||||
key: sandbox-driver-plugins-${{ runner.os }}-${{ steps.sandbox-driver.outputs.rev }}
|
||||
- name: Install the sandbox-driver plugin executables
|
||||
if: steps.sandbox-driver-cache.outputs.cache-hit != 'true'
|
||||
run: cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev "${{ steps.sandbox-driver.outputs.rev }}" sandbox-driver-host sandbox-driver-docker
|
||||
# No Docker daemon on the macOS runner: the Docker tests skip there.
|
||||
- run: cargo nextest run --locked --workspace --status-level slow --profile ci
|
||||
|
||||
process-title-musl:
|
||||
name: Process titles (musl)
|
||||
runs-on: ubuntu-24.04-x86-32-cores
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.97.1
|
||||
targets: x86_64-unknown-linux-musl
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
- uses: taiki-e/install-action@773334c0e05d7e699e4d78234494308223f3a2cf # nextest
|
||||
- name: Test real process titles under musl
|
||||
env:
|
||||
CC_x86_64_unknown_linux_musl: musl-gcc
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||
run: cargo nextest run --locked --release -p fabro-proc --target x86_64-unknown-linux-musl --profile ci
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
|
||||
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (tracking Petri `main`; see the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
|
||||
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
|
||||
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
|
||||
|
|
@ -228,7 +228,7 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
|
|||
- **fabro-workflow** — Fabro's platform half of a run: creates a run around Petri's admission (the run's display graph is read off the admitted graph), archives, forks and retries runs, and holds the run tools and the pull request pipeline. Compilation and execution are Petri's, through `fabro-petri`
|
||||
- **fabro-dot** — The workflow graph as written, read through Petri's DOT parser: its name, goal, node and edge counts, and the files it references (`import`, `stack.child_workflow`, `@file` prompts, the goal). The bundler and the workflow-version store walk references through it; `fabro-graphviz` re-emits Fabro DOT for Graphviz through it
|
||||
- **fabro-graphviz** — SVG rendering of workflow graphs through the vendored Graphviz (`graphviz-sys`)
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (pinned by rev in the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-petri** — Fabro's adapters over Petri, the workflow engine: the one crate that imports the Petri packages (tracking Petri `main`; see the workspace `Cargo.toml`), holding the run store over SQLite and the platform adapters
|
||||
- **fabro-server** — Axum HTTP server. Routes for runs, sessions, models, completions, usage. SSE event streaming. Demo mode via header
|
||||
- **fabro-llm** — Unified LLM client with providers: Anthropic, OpenAI, Gemini, OpenAI-compatible, plus retry/middleware/streaming
|
||||
- **fabro-api** — Auto-generated Rust types and reqwest HTTP client from OpenAPI spec (build.rs + progenitor)
|
||||
|
|
|
|||
166
Cargo.lock
generated
166
Cargo.lock
generated
|
|
@ -1642,7 +1642,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
|||
[[package]]
|
||||
name = "daytona-api-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?branch=main#0e69058c888a6562c70a5b16d707253914cff563"
|
||||
dependencies = [
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
|
|
@ -1656,7 +1656,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "daytona-sdk"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?branch=main#0e69058c888a6562c70a5b16d707253914cff563"
|
||||
dependencies = [
|
||||
"daytona-api-client",
|
||||
"daytona-toolbox-client",
|
||||
|
|
@ -1676,7 +1676,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "daytona-toolbox-client"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?rev=5e86990418e21f4288ce537c9852dfdf78768abc#5e86990418e21f4288ce537c9852dfdf78768abc"
|
||||
source = "git+https://github.com/brynary/daytona-sdk-rust?branch=main#0e69058c888a6562c70a5b16d707253914cff563"
|
||||
dependencies = [
|
||||
"reqwest 0.13.4",
|
||||
"reqwest-middleware",
|
||||
|
|
@ -1990,7 +1990,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-api"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-automation",
|
||||
|
|
@ -2016,7 +2016,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-auth"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2041,7 +2041,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-automation"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2062,11 +2062,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-build-support"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-checkpoint"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-types",
|
||||
|
|
@ -2074,7 +2074,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-cli"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2170,7 +2170,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-client"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2199,7 +2199,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-config"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2228,7 +2228,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-db"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2241,7 +2241,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dev"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2260,7 +2260,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dot"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-template",
|
||||
"fabro-types",
|
||||
|
|
@ -2272,7 +2272,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-dump"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bytes",
|
||||
|
|
@ -2286,7 +2286,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-environment"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2308,7 +2308,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-github"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2333,7 +2333,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-graphviz"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-dot",
|
||||
|
|
@ -2343,7 +2343,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-http"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-static",
|
||||
"http 1.4.0",
|
||||
|
|
@ -2353,7 +2353,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-install"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-config",
|
||||
|
|
@ -2370,7 +2370,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-interview"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fabro-types",
|
||||
|
|
@ -2382,7 +2382,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-llm"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
|
@ -2407,7 +2407,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-macros"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"fabro-options-metadata",
|
||||
|
|
@ -2418,7 +2418,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-manifest"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2447,7 +2447,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-server"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-client",
|
||||
|
|
@ -2467,7 +2467,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-mcp-store"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-db",
|
||||
|
|
@ -2485,7 +2485,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-oauth"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
|
@ -2507,7 +2507,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-options-metadata"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -2515,7 +2515,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-petri"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2530,6 +2530,7 @@ dependencies = [
|
|||
"fabro-interview",
|
||||
"fabro-llm",
|
||||
"fabro-petri",
|
||||
"fabro-static",
|
||||
"fabro-store",
|
||||
"fabro-test",
|
||||
"fabro-tool",
|
||||
|
|
@ -2548,6 +2549,10 @@ dependencies = [
|
|||
"petri-runtime",
|
||||
"petri-store",
|
||||
"petri-testkit",
|
||||
"sandbox-driver",
|
||||
"sandbox-driver-daytona",
|
||||
"sandbox-driver-docker",
|
||||
"sandbox-driver-host",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
|
|
@ -2560,9 +2565,8 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-proc"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
|
@ -2573,7 +2577,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-redact"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"pebble-coding-agent",
|
||||
|
|
@ -2590,7 +2594,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-server"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2655,8 +2659,6 @@ dependencies = [
|
|||
"rand 0.9.4",
|
||||
"reqwest 0.12.28",
|
||||
"sandbox-driver",
|
||||
"sandbox-driver-daytona",
|
||||
"sandbox-driver-docker",
|
||||
"sandbox-driver-host",
|
||||
"sandbox-driver-protocol",
|
||||
"sandbox-driver-testing",
|
||||
|
|
@ -2687,7 +2689,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-slack"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-http",
|
||||
"fabro-interview",
|
||||
|
|
@ -2708,18 +2710,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-spa"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"rust-embed",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fabro-static"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
|
||||
[[package]]
|
||||
name = "fabro-store"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
|
|
@ -2747,7 +2749,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-telemetry"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
|
@ -2773,7 +2775,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-template"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"fabro-types",
|
||||
|
|
@ -2787,7 +2789,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-test"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
|
|
@ -2812,7 +2814,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tool"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2836,7 +2838,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-tracker"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2850,7 +2852,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-types"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
|
|
@ -2877,7 +2879,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-util"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"console 0.15.11",
|
||||
|
|
@ -2900,7 +2902,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-variable"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2917,7 +2919,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-vault"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
@ -2936,7 +2938,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -2981,7 +2983,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "fabro-workflow-version"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"fabro-config",
|
||||
"fabro-dot",
|
||||
|
|
@ -3766,7 +3768,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4280,7 +4282,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
|
|||
[[package]]
|
||||
name = "lithos-llm"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/lithos-llm?rev=43a42ac28e9d9bcf40a91abc02be4f12ca274ebb#43a42ac28e9d9bcf40a91abc02be4f12ca274ebb"
|
||||
source = "git+https://github.com/lithoscomputer/lithos-llm?branch=main#43a42ac28e9d9bcf40a91abc02be4f12ca274ebb"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
|
|
@ -5070,7 +5072,7 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
|||
[[package]]
|
||||
name = "pebble-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=13a7798eddd8e248e0bbdb65d8e812aec3ded9ca#13a7798eddd8e248e0bbdb65d8e812aec3ded9ca"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?branch=main#72a51ea2652be619f214e331f1fde87e9acb2267"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
@ -5087,7 +5089,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pebble-cli-core"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=13a7798eddd8e248e0bbdb65d8e812aec3ded9ca#13a7798eddd8e248e0bbdb65d8e812aec3ded9ca"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?branch=main#72a51ea2652be619f214e331f1fde87e9acb2267"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -5116,7 +5118,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pebble-coding-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=13a7798eddd8e248e0bbdb65d8e812aec3ded9ca#13a7798eddd8e248e0bbdb65d8e812aec3ded9ca"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?branch=main#72a51ea2652be619f214e331f1fde87e9acb2267"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
@ -5158,7 +5160,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
|||
[[package]]
|
||||
name = "petri-attractor-steps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"globset",
|
||||
|
|
@ -5189,7 +5191,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-driver"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"getrandom 0.3.4",
|
||||
|
|
@ -5209,7 +5211,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-engine"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"petri-ir",
|
||||
"serde",
|
||||
|
|
@ -5221,7 +5223,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-execution"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"petri-driver",
|
||||
|
|
@ -5245,7 +5247,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-executor"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"libc",
|
||||
|
|
@ -5260,7 +5262,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-executor-sandbox"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"petri-executor",
|
||||
|
|
@ -5282,7 +5284,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-frontend"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"marked-yaml",
|
||||
"petri-ir",
|
||||
|
|
@ -5296,7 +5298,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-frontend-attractor"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"minijinja",
|
||||
"petri-frontend",
|
||||
|
|
@ -5313,7 +5315,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-frontend-fabro"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"petri-frontend",
|
||||
"petri-frontend-attractor",
|
||||
|
|
@ -5329,7 +5331,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-frontend-native"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"petri-frontend",
|
||||
"petri-ir",
|
||||
|
|
@ -5340,7 +5342,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-ir"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
|
|
@ -5353,7 +5355,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-runtime"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"petri-driver",
|
||||
|
|
@ -5374,7 +5376,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-steps"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"petri-executor",
|
||||
|
|
@ -5390,7 +5392,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-store"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"getrandom 0.3.4",
|
||||
|
|
@ -5405,7 +5407,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "petri-testkit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?rev=f80f68b06b3e58401b75b62d0a29c8420effd9e2#f80f68b06b3e58401b75b62d0a29c8420effd9e2"
|
||||
source = "git+https://github.com/lithoscomputer/petri.git?branch=main#153d586871824b8023a5691e49a1e99421bae282"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"petri-driver",
|
||||
|
|
@ -5416,6 +5418,7 @@ dependencies = [
|
|||
"petri-steps",
|
||||
"petri-store",
|
||||
"sandbox-driver",
|
||||
"sandbox-driver-host",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smol_str",
|
||||
|
|
@ -6284,7 +6287,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"globset",
|
||||
|
|
@ -6301,13 +6304,14 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-daytona"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"daytona-api-client",
|
||||
"daytona-sdk",
|
||||
"futures-util",
|
||||
"hmac 0.12.1",
|
||||
"rand 0.10.1",
|
||||
"reqwest 0.13.4",
|
||||
|
|
@ -6328,7 +6332,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-daytona-config"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"sandbox-driver-docker-config",
|
||||
"serde",
|
||||
|
|
@ -6338,7 +6342,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-docker"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -6359,7 +6363,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-docker-config"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -6368,7 +6372,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
|
@ -6386,7 +6390,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-protocol"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
|
|
@ -6403,7 +6407,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "sandbox-driver-testing"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?rev=583a16463320966e8f5c88bedd1d759e93fbd05f#583a16463320966e8f5c88bedd1d759e93fbd05f"
|
||||
source = "git+https://github.com/lithoscomputer/sandbox-driver?branch=main#236196ed134dd594e7678e38ebbe968097f1935c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"sandbox-driver",
|
||||
|
|
@ -7877,7 +7881,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "twin-core"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613"
|
||||
source = "git+https://github.com/lithoscomputer/twins?branch=main#19bf6ae2fbfcea7a845fe9fa0796b1d613db859a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
@ -7891,7 +7895,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "twin-github"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
|
|
@ -7911,7 +7915,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "twin-openai"
|
||||
version = "0.1.5"
|
||||
source = "git+https://github.com/lithoscomputer/twins?rev=ca45f0e50a6716d716aa2f638ca3cf767e88f613#ca45f0e50a6716d716aa2f638ca3cf767e88f613"
|
||||
source = "git+https://github.com/lithoscomputer/twins?branch=main#19bf6ae2fbfcea7a845fe9fa0796b1d613db859a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
|
|
|||
87
Cargo.toml
87
Cargo.toml
|
|
@ -10,7 +10,7 @@ resolver = "2"
|
|||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.362.0-nightly.0"
|
||||
version = "0.370.0-nightly.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
|
@ -73,62 +73,47 @@ sha2 = "0.10"
|
|||
hex = "0.4"
|
||||
insta = "1"
|
||||
fabro-test = { path = "lib/foundation/fabro-test" }
|
||||
# Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is
|
||||
# published to crates.io.
|
||||
lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "43a42ac28e9d9bcf40a91abc02be4f12ca274ebb", default-features = false }
|
||||
# Deterministic OpenAI twin used by twin-mode E2E tests; the same revision
|
||||
# lithos-llm verifies its codecs against.
|
||||
twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" }
|
||||
# Lithos libraries track `main`; this lockfile picks the commits — move one
|
||||
# with `cargo update -p <crate>`. Try unmerged library work with an
|
||||
# uncommitted `[patch]`, not a PR-head pin.
|
||||
|
||||
# Provider-neutral LLM catalog and client, from git until 0.x is published to
|
||||
# crates.io.
|
||||
lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", branch = "main", default-features = false }
|
||||
# Deterministic OpenAI twin used by twin-mode E2E tests, the twin lithos-llm
|
||||
# verifies its codecs against.
|
||||
twin-openai = { git = "https://github.com/lithoscomputer/twins", branch = "main" }
|
||||
twin-github = { path = "test/twin/github" }
|
||||
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3"
|
||||
# sandbox-driver: the sandbox provider layer. Bundled Host, Docker, and
|
||||
# Daytona providers link in-process; third-party providers run as stdio
|
||||
# plugins through sandbox-driver-protocol. Pinned by rev to sandbox-driver
|
||||
# `main` (the `section-4-driver-items` work merged in #20; #21 gave the
|
||||
# protocol crate's `PluginSupervisor` numbered generations, a health probe
|
||||
# before a generation serves, and a refusal of a replacement that reports
|
||||
# another resource namespace; #22 made the Daytona exec decoder resync on a
|
||||
# torn frame and report what it dropped as `ExecStreamingResult::output_loss`
|
||||
# instead of failing the command; #23 made an idle Host sentinel reap its
|
||||
# own process group once its owning provider pid is gone, so a provider
|
||||
# that is killed rather than stopped leaves no sentinel behind; #61 let a
|
||||
# second process attach read-only to a managed host workspace by path, so
|
||||
# the server's `petri.run` ownership check runs on the host as on Docker).
|
||||
# The CI plugin job installs the driver executables at the same rev, read
|
||||
# from this file.
|
||||
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "583a16463320966e8f5c88bedd1d759e93fbd05f" }
|
||||
# plugins through sandbox-driver-protocol.
|
||||
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-docker = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-docker-config = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-daytona = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-daytona-config = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driver", branch = "main" }
|
||||
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
|
||||
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to pebble
|
||||
# `main`. The `Environment` over a sandbox-driver handle is pebble's
|
||||
# `sandbox_driver` module (the `sandbox-driver` feature), so pebble pins the
|
||||
# same sandbox-driver rev as this file and the workspace links one copy of
|
||||
# the driver; a move of either pin moves the other. Pebble pins the same
|
||||
# lithos-llm rev as fabro, and its lockfile policy is that every shared crate
|
||||
# resolves to the version lithos-llm locks.
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "13a7798eddd8e248e0bbdb65d8e812aec3ded9ca" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "13a7798eddd8e248e0bbdb65d8e812aec3ded9ca", features = ["mcp", "search-providers", "sandbox-driver"] }
|
||||
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "13a7798eddd8e248e0bbdb65d8e812aec3ded9ca" }
|
||||
# petri: the workflow engine Fabro runs its workflows on. Pinned by rev, the
|
||||
# same way pebble and sandbox-driver are. Petri pins the same pebble,
|
||||
# lithos-llm and sandbox-driver revisions as this file, so the workspace links
|
||||
# one copy of each. Only `fabro-petri` and `fabro-dot` (the DOT parser alone)
|
||||
# may depend on these packages; the keys carry the `petri_` prefix so the crate
|
||||
# names say where they come from.
|
||||
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-runtime" }
|
||||
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-execution" }
|
||||
petri_store = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-store" }
|
||||
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-attractor-steps" }
|
||||
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-frontend-attractor" }
|
||||
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-frontend-fabro" }
|
||||
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", rev = "f80f68b06b3e58401b75b62d0a29c8420effd9e2", package = "petri-testkit" }
|
||||
# sessions, hook evaluators, and `fabro exec` on. The `Environment` over a
|
||||
# sandbox-driver handle is pebble's `sandbox_driver` module (the
|
||||
# `sandbox-driver` feature).
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", branch = "main" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", branch = "main", features = ["mcp", "search-providers", "sandbox-driver"] }
|
||||
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", branch = "main" }
|
||||
# petri: the workflow engine Fabro runs its workflows on. Only `fabro-petri`
|
||||
# and `fabro-dot` (the DOT parser alone) may depend on these packages; the
|
||||
# keys carry the `petri_` prefix so the crate names say where they come from.
|
||||
petri_runtime = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-runtime" }
|
||||
petri_execution = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-execution" }
|
||||
petri_store = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-store" }
|
||||
petri_attractor_steps = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-attractor-steps" }
|
||||
petri_frontend_attractor = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-frontend-attractor" }
|
||||
petri_frontend_fabro = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-frontend-fabro" }
|
||||
petri_testkit = { git = "https://github.com/lithoscomputer/petri.git", branch = "main", package = "petri-testkit" }
|
||||
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
|
||||
fork = "0.2"
|
||||
exec = "0.3"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ disallowed-methods = [
|
|||
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },
|
||||
{ path = "reqwest::blocking::Client::builder", reason = "Use fabro_http::BlockingHttpClientBuilder::new()", allow-invalid = true },
|
||||
{ path = "reqwest::get", reason = "Build a fabro_http client and send the request explicitly", allow-invalid = true },
|
||||
{ path = "runtime::Runtime::standard", reason = "Use fabro_petri::providers::standard_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true },
|
||||
{ path = "runtime::Runtime::bare", reason = "Use fabro_petri::providers::bare_runtime, which installs the built-in in-process sandbox providers; without them Petri launches provider plugins, which a release build cannot verify", allow-invalid = true },
|
||||
{ path = "fabro_types::settings::interp::InterpString::as_source", reason = "Returns the unresolved template source, which leaks {{ ... }} tokens as literal text downstream. Resolve via resolve()/resolve_with() or substitute via substitute_with() instead; document intentional raw-source access (serialization, error messages, deliberate source preservation) with #[expect(clippy::disallowed_methods, reason = \"...\")]", allow-invalid = true },
|
||||
]
|
||||
disallowed-types = [
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Sandboxes isolate agent execution from the host machine. When an agent runs a sh
|
|||
|
||||
Fabro bundles three sandbox providers: `local` (no isolation), `docker` (container-level), and `daytona` (cloud VM). Additional providers run as [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugins configured under `[server.sandbox.providers.<kind>]`; an environment selects one by its kind name. See [Environments](/execution/environments) for full provider-specific configuration and [Server configuration](/administration/server-configuration#serversandboxproviders-section) for plugin settings.
|
||||
|
||||
The built-in providers run in process for execution, resume, and pruning. Fabro does not need `sandbox-driver-host`, `sandbox-driver-docker`, or `sandbox-driver-daytona` executables or checksum pins for these operations.
|
||||
|
||||
Operators can enable or disable which providers the server may launch with
|
||||
`[server.sandbox.providers.<kind>]` in `settings.toml`. Missing bundled entries default to
|
||||
`enabled = true`; setting `enabled = false` rejects new runs whose effective provider is disabled,
|
||||
|
|
|
|||
|
|
@ -189,18 +189,20 @@ enabled = true
|
|||
enabled = true
|
||||
```
|
||||
|
||||
The built-in `local`, `docker`, and `daytona` providers run in process. `local` selects Petri's
|
||||
Host provider. Built-in entries reject `path`, `sha256`, `dev`, `args`, `env`, and `inherit_env`;
|
||||
no plugin executable or checksum is needed. The legacy built-in
|
||||
`PETRI_SANDBOX_{HOST,DOCKER,DAYTONA}_{PLUGIN,SHA256}` variables are ignored.
|
||||
|
||||
Any other key names a [sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) plugin:
|
||||
an executable that speaks the sandbox-driver JSON-RPC protocol on stdin and stdout. The kind must
|
||||
be lowercase ASCII letters, digits, and interior hyphens. The plugin starts with a scrubbed
|
||||
environment: only `env` and the ambient variables listed in `inherit_env` reach it. Bundled
|
||||
providers reject these plugin keys.
|
||||
|
||||
The same executable serves both sides of a run. The server launches it to reach a run's
|
||||
sandbox after the fact (the sandbox tab, files, terminal, Ask Fabro), and Petri launches it in
|
||||
the run's worker to create the sandbox. The server hands the worker `path` and `sha256` as
|
||||
`PETRI_SANDBOX_<KIND>_PLUGIN` and `PETRI_SANDBOX_<KIND>_SHA256` (the kind uppercased, hyphens
|
||||
as underscores), and `PETRI_SANDBOX_PLUGIN_DEV=1` when any configured plugin sets `dev`, so a
|
||||
plugin configured here needs no second configuration for the worker.
|
||||
The server launches the plugin to reach a sandbox after the fact (the sandbox tab, files,
|
||||
terminal, Ask Fabro). Runs execute only on the built-in providers for now, so a run's worker
|
||||
never launches a plugin and receives none of these settings.
|
||||
|
||||
```toml title="settings.toml"
|
||||
[server.sandbox.providers.e2b]
|
||||
|
|
|
|||
|
|
@ -53,97 +53,52 @@ Common flags:
|
|||
|
||||
See [Server Configuration](/administration/server-configuration) for the full `settings.toml` reference.
|
||||
|
||||
### SQLite blob storage activation
|
||||
### Upgrading from SlateDB
|
||||
|
||||
On startup, Fabro activates SQLite as the only live content-addressed blob
|
||||
store before it opens routes, schedulers, workers, webhooks, reapers, or the
|
||||
ready callback. The activation inventories the exact legacy SlateDB blob
|
||||
prefix and run history, then checks disk headroom for the rows not yet
|
||||
imported, any required blob backup, and the projected post-import database
|
||||
snapshot required by run-history activation. A warm restart with no pending
|
||||
imports or backups only needs a small fixed headroom; on filesystems whose free
|
||||
space cannot be determined the check is skipped with a warning. Fabro then
|
||||
imports in bounded transactions, compares every legacy blob byte-for-byte with
|
||||
SQLite, runs a live SQLite integrity check, and attempts a final WAL truncate
|
||||
checkpoint. A busy final truncate logs a warning and startup continues so a
|
||||
later checkpoint can finish after the blocking reader exits.
|
||||
Boots that import new rows additionally re-verify every
|
||||
legacy blob against SQLite and validate every SQLite blob row independently.
|
||||
Any failure stops startup. Warm boots that import no rows skip that full target
|
||||
scan: the import pass has already byte-compared every retained legacy row, and
|
||||
SQLite-only blobs are hash-validated when read. Rows committed by an interrupted
|
||||
import are retained so the next startup can resume, but the legacy source is
|
||||
never modified and there is no fallback or dual read/write path.
|
||||
Fabro stores current run history and content-addressed blobs in SQLite.
|
||||
Upgrading from a release that stored these in SlateDB does not automatically
|
||||
import them, so those older runs are not available in the current application.
|
||||
|
||||
For a non-empty legacy inventory, the first activation also creates the
|
||||
private sibling backup
|
||||
`fabro.sqlite3.pre-blob-activation.bak`. Fabro writes the staging database
|
||||
inside a private same-directory area, applies owner-only permissions, flushes
|
||||
and validates it, then publishes the backup without overwriting an existing file.
|
||||
A valid retained backup is revalidated on every warm restart and is preserved
|
||||
as the original pre-activation safety artifact. If any legacy row is already
|
||||
present in SQLite, a missing retained backup stops startup rather than silently
|
||||
moving that rollback boundary forward. It is not a promise that an
|
||||
older binary can safely resume after the activated server has accepted new
|
||||
work; recovery after that boundary is forward-only. Empty legacy inventories
|
||||
do not need this backup.
|
||||
The upgrade leaves existing SlateDB run history and blobs in their original
|
||||
local storage directory or object-store location. Retain that data if you may
|
||||
need to recover historical runs using a reader compatible with the older
|
||||
storage format.
|
||||
|
||||
Keep both the unchanged legacy `blobs/sha256` prefix and the private activation
|
||||
backup for at least 30 consecutive calendar days after the first successful
|
||||
production activation. Cleanup is eligible only after a successful cold
|
||||
activation, a later warm restart that revalidates the backup and byte-compares
|
||||
every retained legacy blob against SQLite, and 30 days of production observation
|
||||
with no unresolved inventory, import, verification, integrity, backup, or
|
||||
checkpoint failure. Scott must review that evidence and explicitly authorize a
|
||||
separate cleanup change. Day 30 is only the earliest eligibility date; nothing
|
||||
is deleted automatically, and incomplete evidence extends the support window.
|
||||
Artifact files use the separate local or S3 object store configured through
|
||||
`[server.artifacts]` in [Server Configuration](/administration/server-configuration).
|
||||
|
||||
### SQLite run-history activation
|
||||
#### Legacy SlateDB settings
|
||||
|
||||
Immediately after blob activation, and still before routes, schedulers,
|
||||
workers, webhooks, reapers, or readiness are exposed, Fabro activates SQLite
|
||||
as the sole authority for run existence, run events, and each run's current
|
||||
projected row. The activation strictly validates and fingerprints the exact
|
||||
legacy SlateDB run-event key/value stream, imports each complete run in its own
|
||||
transaction, verifies every legacy history as an exact SQLite prefix, replays
|
||||
and verifies every SQLite run independently, and runs a full SQLite integrity
|
||||
check. It attempts a final WAL truncate checkpoint, but a blocking reader only
|
||||
produces a warning because committed activation data remains durable in the
|
||||
WAL. A source fingerprint or count change after activation stops startup. There
|
||||
is no fallback or dual-read/write mode.
|
||||
When Fabro loads active server settings that still contain `[server.slatedb]`,
|
||||
it backs up the settings file and removes that section and its subtables.
|
||||
For `settings.toml`, the backup is
|
||||
`settings.toml.server-slatedb-migration.bak` beside the original file. Existing
|
||||
backups are preserved; further backups use numbered suffixes such as
|
||||
`settings.toml.server-slatedb-migration.1.bak`. A warning identifies the
|
||||
rewritten file and its backup. Other settings are preserved by this cleanup,
|
||||
and subsequent loads do nothing once the section is absent.
|
||||
|
||||
For a non-empty legacy run history, the first activation creates and validates
|
||||
the private sibling backup
|
||||
`fabro.sqlite3.pre-run-history-activation.bak` before importing anything. The
|
||||
backup is published without overwriting an existing file and is revalidated
|
||||
on every restart. If import progress exists but that retained backup is
|
||||
missing, startup stops. An empty legacy source is accepted without a backup
|
||||
only when SQLite also has no unmarked run data. The activation marker stores
|
||||
the source identity and first-success timestamp; retries preserve that
|
||||
timestamp and repeat source, destination, backup, and integrity checks.
|
||||
This cleanup changes configuration only. The settings backup contains no run
|
||||
history or blobs.
|
||||
|
||||
After activation, creating a run commits `run.created`, the run's current row,
|
||||
and its existence atomically. Later appends update the event log and current
|
||||
row in one transaction, and live streams advance only after commit. Deleting
|
||||
a migrated run commits a tombstone with the SQL deletion so the retained
|
||||
legacy source cannot resurrect it during a restart.
|
||||
### SQLite schema upgrades
|
||||
|
||||
Keep the unchanged legacy `runs/*/events/*` data and the private activation
|
||||
backup for at least 30 consecutive calendar days after the persisted
|
||||
first-success timestamp. Cleanup also requires successful cold and warm
|
||||
activation evidence, production observation, backup and restore validation,
|
||||
deletion/restart coverage, and explicit approval for a separate cleanup
|
||||
change. Nothing is deleted automatically. The run-history activation backup
|
||||
represents the database immediately before run-history import and can be used
|
||||
to retry or recover the activation with a binary that knows the activated
|
||||
schema. It is not a binary-downgrade artifact because it already contains the
|
||||
new SQL migrations.
|
||||
On startup, Fabro applies pending SQLite schema migrations before serving
|
||||
normal traffic. Before changing a database that has previously applied
|
||||
migrations, it writes a snapshot beside the database as
|
||||
`fabro.sqlite3.pre-migration.bak`. A fresh database or a restart with no pending
|
||||
migrations does not create a new snapshot. Each later schema upgrade replaces
|
||||
this snapshot; failure to create it stops the upgrade.
|
||||
|
||||
To return to the older binary, stop the server and restore the database's
|
||||
`.pre-migration.bak` snapshot instead, then remove any `-wal` and `-shm`
|
||||
siblings before starting the older binary. That snapshot was taken before the
|
||||
new migrations were applied. Either recovery path loses writes accepted after
|
||||
its snapshot, so make the rollback boundary explicit before restoring it.
|
||||
The Petri transition drops the former SQL `run_events` table and its activation
|
||||
bookkeeping. Those events are not converted into Petri run history. Current
|
||||
runs use Petri records and Fabro platform records in SQLite.
|
||||
|
||||
For a database rollback, stop the server and restore the `.pre-migration.bak`
|
||||
snapshot, then remove any `-wal` and `-shm` siblings before starting the
|
||||
corresponding older binary. The snapshot holds the database state immediately
|
||||
before the most recent schema upgrade. Restoring it loses database writes
|
||||
accepted after that snapshot.
|
||||
|
||||
## Submitting runs
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ use fabro_petri::hooks::HooksSpec;
|
|||
use fabro_petri::interview::{Approval, FabroInterviewer};
|
||||
use fabro_petri::petri::OwnerId;
|
||||
use fabro_petri::platform_records::{HttpPlatformRecords, PlatformRecords};
|
||||
use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig};
|
||||
use fabro_petri::runtime::{self, RuntimeSpec};
|
||||
use fabro_petri::secrets::VaultSecrets;
|
||||
use fabro_petri::{HttpRunStore, admission};
|
||||
|
|
@ -618,7 +619,14 @@ async fn runtime_spec(
|
|||
None
|
||||
}
|
||||
};
|
||||
let daytona = vault.read().await.get(EnvVars::DAYTONA_API_KEY).map(|key| {
|
||||
// The same shared client the server attaches, so the worker's
|
||||
// Daytona calls take the server's proxy and CA policy.
|
||||
DaytonaCredentials::from_api_key(key.to_owned(), crate::process_env_var)
|
||||
.with_http_client(fabro_http::http_client().ok())
|
||||
});
|
||||
Ok(RuntimeSpec {
|
||||
sandbox: SandboxProviderConfig::from_lookup(daytona, crate::process_env_var),
|
||||
settings_toml: None,
|
||||
mcp_catalog_toml: None,
|
||||
model_client,
|
||||
|
|
|
|||
|
|
@ -182,9 +182,9 @@ impl miette::Diagnostic for CliDiagnostic {
|
|||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "CLI main reads documented process-env controls before telemetry and worker dispatch."
|
||||
reason = "CLI main reads documented process-env controls before telemetry and worker dispatch, and the worker snapshots inherited sandbox provider selection."
|
||||
)]
|
||||
fn process_env_var(name: &str) -> Option<String> {
|
||||
pub(crate) fn process_env_var(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,14 +7,11 @@ use std::time::Duration;
|
|||
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
use super::petri::{RunningServer, host_plugin, run_detached, run_json, wait_for_success};
|
||||
use super::petri::{RunningServer, run_detached, run_json, wait_for_success};
|
||||
use crate::cmd::support::{read_text, text_tree};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artifact_worker_captures_large_files_in_the_configured_local_store() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start_with(
|
||||
"\n[server.artifacts]\nprovider = \"local\"\nprefix = \"selected-prefix\"\n",
|
||||
|
|
@ -162,9 +159,6 @@ fn artifact_workspace(context: &fabro_test::TestContext) -> PathBuf {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn artifact_commands_read_the_artifacts_the_hooks_collected() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let workspace = artifact_workspace(&context);
|
||||
|
|
|
|||
|
|
@ -5,18 +5,14 @@
|
|||
//!
|
||||
//! Each test starts its own foreground server on disk storage, because the
|
||||
//! session's shared daemon keeps its object store in memory and the resume
|
||||
//! scenario restarts the server. The runs take their host scope through the
|
||||
//! sandbox-driver host plugin, so the tests skip, and say why, when the
|
||||
//! executable is not found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! The plugin's path override crosses into the server and its workers the
|
||||
//! way `PATH` does.
|
||||
//! scenario restarts the server. Host scopes run in process.
|
||||
//!
|
||||
//! The harness here (the server, the detached run, the status and event
|
||||
//! reads) is shared with the run-tools scenarios in `petri_tools.rs`.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "these scenarios start a real server subprocess, locate the plugin through the process environment, and poll processes"
|
||||
reason = "these scenarios start a real server subprocess, poll processes"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::disallowed_types,
|
||||
|
|
@ -47,36 +43,9 @@ use fabro_vault::{SecretType, Vault};
|
|||
use crate::cmd::support::created_run_id;
|
||||
use crate::support::{TEST_DEV_TOKEN, TEST_SESSION_SECRET, seed_dev_token_auth};
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
pub(super) const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
pub(super) const RUN_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
pub(super) const POLL: Duration = Duration::from_millis(50);
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
pub(super) fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(EnvVars::PETRI_SANDBOX_HOST_PLUGIN)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os(EnvVars::PATH)?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {} is unset",
|
||||
EnvVars::PETRI_SANDBOX_HOST_PLUGIN
|
||||
);
|
||||
eprintln!(
|
||||
"skipping: {HOST_PLUGIN} is not on PATH and {} is unset",
|
||||
EnvVars::PETRI_SANDBOX_HOST_PLUGIN
|
||||
);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A foreground server on its own disk storage, dev-token auth, started
|
||||
/// from the compiled `fabro` binary. Dropping it kills the process.
|
||||
pub(super) struct RunningServer {
|
||||
|
|
@ -676,9 +645,6 @@ pub(super) fn wait_until_gate_is_polled(gate: &Path) {
|
|||
/// records through the HTTP store, and its lease ended with it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_petri_run_executes_in_the_server_launched_worker() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let workspace = write_petri_workspace(&context, "echo hello from petri");
|
||||
|
|
@ -730,9 +696,6 @@ async fn a_petri_run_executes_in_the_server_launched_worker() {
|
|||
/// resume mode, which finishes the run with one terminal lifecycle record.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("resume.gate");
|
||||
|
|
@ -748,7 +711,7 @@ async fn a_petri_run_resumes_in_a_new_worker_after_the_server_restarts() {
|
|||
eprintln!("stage is waiting on the gate");
|
||||
|
||||
// The crash: the server first, so it never observes the worker exit,
|
||||
// then the worker's whole process group, plugin and stage included.
|
||||
// then the worker's whole process group, stage included.
|
||||
server.kill();
|
||||
fabro_proc::sigkill_process_group(worker);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
|
|
@ -923,9 +886,6 @@ fn two_gates_dot(markers: &Path) -> String {
|
|||
/// and the run's stream records the interview.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_human_gate_in_the_worker_is_answered_through_the_api() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let markers = context.temp_dir.join("markers");
|
||||
|
|
@ -980,9 +940,6 @@ async fn a_human_gate_in_the_worker_is_answered_through_the_api() {
|
|||
/// the API in the other order, binds to its own branch.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn two_parallel_gates_in_the_worker_each_bind_their_own_answer() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let markers = context.temp_dir.join("markers");
|
||||
|
|
@ -1033,9 +990,6 @@ async fn two_parallel_gates_in_the_worker_each_bind_their_own_answer() {
|
|||
/// pending.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_unanswered_gate_in_the_worker_expires_with_its_default() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let markers = context.temp_dir.join("markers");
|
||||
|
|
@ -1136,9 +1090,6 @@ fn pretty_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> {
|
|||
/// `wait` and `runs inspect` read the projection.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_finished_petri_run_reads_back_through_the_cli() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let workspace = write_petri_workspace(&context, "echo hello from petri");
|
||||
|
|
@ -1257,9 +1208,6 @@ async fn a_finished_petri_run_reads_back_through_the_cli() {
|
|||
/// the gate and the attach exits with the run's status.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn attach_asks_a_petri_gate_at_the_terminal_and_answers_it() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let markers = context.temp_dir.join("markers");
|
||||
|
|
@ -1306,9 +1254,6 @@ async fn attach_asks_a_petri_gate_at_the_terminal_and_answers_it() {
|
|||
/// the run goes on follow, and the terminal lifecycle record ends it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn events_follow_streams_a_petri_run_live_to_its_end() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("go");
|
||||
|
|
@ -1400,7 +1345,7 @@ fn three_stage_bundle(context: &fabro_test::TestContext, gate: &Path) -> PathBuf
|
|||
/// Kill the server first, so it never observes the worker exit, then the
|
||||
/// worker's whole process group, then the stage's own process group when a
|
||||
/// stage was waiting on `gate`: a stage process runs in a group of its own
|
||||
/// under the host plugin, and a machine crash takes it with everything
|
||||
/// under the Host provider, and a machine crash takes it with everything
|
||||
/// else, where a killed worker alone would leave it writing into the
|
||||
/// workspace.
|
||||
pub(super) fn crash(server: &mut RunningServer, worker: u32, gate: Option<&Path>) {
|
||||
|
|
@ -1465,9 +1410,6 @@ fn three_stage_subjects(run_id: &str) -> Vec<String> {
|
|||
/// snapshot (its partial output gone), and the next stage sees both.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_crash_after_a_durable_finish_keeps_its_one_commit() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("two.gate");
|
||||
|
|
@ -1506,9 +1448,6 @@ async fn a_crash_after_a_durable_finish_keeps_its_one_commit() {
|
|||
/// is not durable, the stage reruns once, and one commit exists for it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_crash_before_the_commit_lands_reruns_the_stage_once() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("two.gate");
|
||||
|
|
@ -1547,9 +1486,6 @@ async fn a_crash_before_the_commit_lands_reruns_the_stage_once() {
|
|||
/// repository, the stage does not rerun, and one commit exists for it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_crash_before_the_record_reconciles_it_from_the_run_branch() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("two.gate");
|
||||
|
|
@ -1591,9 +1527,6 @@ async fn a_crash_before_the_record_reconciles_it_from_the_run_branch() {
|
|||
/// repository, and the next stage sees the checkpoint's files.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_deleted_workspace_is_restored_from_its_snapshot() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("two.gate");
|
||||
|
|
@ -1628,9 +1561,6 @@ async fn a_deleted_workspace_is_restored_from_its_snapshot() {
|
|||
/// route reruns on the same files.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failure_route_sees_the_same_committed_files_after_a_crash() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("fix.gate");
|
||||
|
|
@ -1683,9 +1613,6 @@ async fn a_failure_route_sees_the_same_committed_files_after_a_crash() {
|
|||
/// leaves it failed without launching a worker.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let workspace = write_petri_workflow(
|
||||
|
|
@ -1760,9 +1687,6 @@ async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() {
|
|||
/// exit after the delete brings nothing back.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_delete_right_after_the_run_reads_ended_is_accepted() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let workspace = write_petri_workspace(&context, "true");
|
||||
|
|
@ -1805,3 +1729,71 @@ async fn a_delete_right_after_the_run_reads_ended_is_accepted() {
|
|||
);
|
||||
server.shutdown();
|
||||
}
|
||||
|
||||
/// A Host run acquires and prunes a real scope with no plugin executable
|
||||
/// anywhere the worker or server would look. The server is also handed
|
||||
/// legacy Host plugin settings, which its prune must ignore; the worker
|
||||
/// never receives them (its environment allowlist drops them). The release
|
||||
/// workflow runs the suite in a release build, where no plugin checksum is
|
||||
/// pinned, so this is the check that a release can run a sandbox at all.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn built_in_host_runs_and_prunes_without_plugins() {
|
||||
let context = test_context!();
|
||||
let path = "/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
let binary_dir = Path::new(env!("CARGO_BIN_EXE_fabro"))
|
||||
.parent()
|
||||
.expect("binary directory");
|
||||
for kind in ["host", "docker", "daytona"] {
|
||||
let executable = format!("sandbox-driver-{kind}");
|
||||
for directory in env::split_paths(path).chain([binary_dir.to_path_buf()]) {
|
||||
assert!(
|
||||
!directory.join(&executable).exists(),
|
||||
"test requires no {executable} in {}",
|
||||
directory.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let server = RunningServer::start_with_env("", &[], &[
|
||||
(EnvVars::PATH, path),
|
||||
(
|
||||
"PETRI_SANDBOX_HOST_PLUGIN",
|
||||
"/nonexistent/sandbox-driver-host",
|
||||
),
|
||||
("PETRI_SANDBOX_HOST_SHA256", "invalid-pin"),
|
||||
(EnvVars::PETRI_SANDBOX_PLUGIN_DEV, "0"),
|
||||
])
|
||||
.await;
|
||||
let workspace = write_petri_workspace(&context, "echo built-in > built-in.txt");
|
||||
let run_id = run_detached(&context, &server, &workspace);
|
||||
wait_for_success(&server, &run_id).await;
|
||||
let run_dir = server.petri_run_dir(&run_id);
|
||||
let scopes = run_dir.join("scopes");
|
||||
let entries = std::fs::read_dir(&scopes)
|
||||
.expect("the real Host scope exists")
|
||||
.map(|entry| entry.expect("the scope entry reads").path())
|
||||
.collect::<Vec<_>>();
|
||||
let [scope] = entries.as_slice() else {
|
||||
panic!("expected exactly one Host scope, found {entries:?}");
|
||||
};
|
||||
let scope = scope.clone();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(scope.join("work/built-in.txt"))
|
||||
.expect("the worker wrote its file"),
|
||||
"built-in\n"
|
||||
);
|
||||
let response = fabro_test::test_http_client()
|
||||
.delete(format!("{}/api/v1/runs/{run_id}", server.api_base_url))
|
||||
.bearer_auth(TEST_DEV_TOKEN)
|
||||
.send()
|
||||
.await
|
||||
.expect("the delete sends");
|
||||
let status = response.status();
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
assert_eq!(
|
||||
status,
|
||||
fabro_http::StatusCode::NO_CONTENT,
|
||||
"prune failed: {detail}"
|
||||
);
|
||||
assert!(!scope.exists(), "prune removed the managed Host workspace");
|
||||
server.shutdown();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,8 @@
|
|||
//! the worker never answers (a test hook mutes the worker's answers).
|
||||
//!
|
||||
//! The harness is `petri.rs`'s: a foreground server on disk storage, the
|
||||
//! run started with `fabro run --detach`, and the host scope through the
|
||||
//! sandbox-driver host plugin, so the tests skip, and say why, when the
|
||||
//! plugin is not found.
|
||||
//! run started with `fabro run --detach`, and the Host scope running in
|
||||
//! process.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
@ -37,9 +36,9 @@ use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_o
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use super::petri::{
|
||||
RunningServer, answer, count_of, host_plugin, run_detached, run_detached_with, run_json,
|
||||
run_status, run_stream, settled_stream, stream_names, wait_for_questions, wait_for_status,
|
||||
wait_for_worker, wait_until_gate_is_polled, write_petri_workflow,
|
||||
RunningServer, answer, count_of, run_detached, run_detached_with, run_json, run_status,
|
||||
run_stream, settled_stream, stream_names, wait_for_questions, wait_for_status, wait_for_worker,
|
||||
wait_until_gate_is_polled, write_petri_workflow,
|
||||
};
|
||||
use crate::support::TEST_DEV_TOKEN;
|
||||
|
||||
|
|
@ -331,9 +330,6 @@ async fn assert_petri_succeeded(server: &RunningServer, run_id: &str) {
|
|||
/// both carry the pause and the unpause.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_pause_holds_the_next_stage_until_the_unpause() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("a.gate");
|
||||
|
|
@ -405,9 +401,6 @@ async fn a_pause_holds_the_next_stage_until_the_unpause() {
|
|||
/// no control request is recorded, since none went through the API.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_user_signals_pause_and_unpause_the_worker() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("a.gate");
|
||||
|
|
@ -482,9 +475,6 @@ async fn the_user_signals_pause_and_unpause_the_worker() {
|
|||
/// stream carries the `control.requested` record, and the run succeeds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_steer_reaches_the_agent_stage_on_the_twin() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -614,9 +604,6 @@ async fn a_steer_reaches_the_agent_stage_on_the_twin() {
|
|||
/// reaches that stage's session and no other.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn two_live_agent_stages_are_steered_apart_by_their_labels() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -776,9 +763,6 @@ async fn two_live_agent_stages_are_steered_apart_by_their_labels() {
|
|||
/// `attractor.turn.interrupted` report, and the run succeeds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_interrupt_ends_the_turn_and_its_text_is_the_next_input() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -912,9 +896,6 @@ const UNNAMED_INTERRUPT_REFUSAL: &str =
|
|||
/// answer routes the run to its end.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let marker = context.temp_dir.join("yes.marker");
|
||||
|
|
@ -993,9 +974,6 @@ async fn an_interrupt_of_a_gate_stage_is_refused_with_no_live_turn() {
|
|||
/// muted through the server's test hook, forwarded to the worker by name.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_control_the_worker_never_answers_is_pending() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server =
|
||||
RunningServer::start_with_env("", &[], &[(EnvVars::FABRO_TEST_CONTROL_ACKS_MUTED, "1")])
|
||||
|
|
@ -1048,9 +1026,6 @@ async fn a_control_the_worker_never_answers_is_pending() {
|
|||
/// without a new admission: a pause holds admission, never running work.)
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_run_paused_before_a_crash_resumes_paused() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let mut server = RunningServer::start().await;
|
||||
let gate = context.temp_dir.join("a.gate");
|
||||
|
|
|
|||
|
|
@ -9,17 +9,14 @@
|
|||
//! Petri created and reads a file the workflow wrote there, its model the
|
||||
//! twin.
|
||||
//!
|
||||
//! The runs take their scope through the sandbox-driver Docker plugin on
|
||||
//! this machine's daemon, so the tests skip, and say why, when the
|
||||
//! executable is not found or no daemon answers, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set and the plugin is missing. The
|
||||
//! server, the detached run and the crash come from `petri.rs`.
|
||||
//! The runs use the built-in Docker provider on this machine's daemon.
|
||||
//! Tests skip when no daemon answers, unless `FABRO_REQUIRE_SANDBOX_BACKENDS`
|
||||
//! requires it. The server, detached run and crash come from `petri.rs`.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "these scenarios locate the plugin through the process environment and drive the Docker daemon with its CLI"
|
||||
reason = "these scenarios inspect backend availability and drive the Docker daemon with its CLI"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -33,53 +30,16 @@ use fabro_test::{
|
|||
use serde_json::json;
|
||||
|
||||
use super::petri::{
|
||||
REQUIRE_ENV, RunningServer, crash, run_detached_in, wait_for_status, wait_for_success,
|
||||
wait_for_worker, write_petri_workflow,
|
||||
RunningServer, crash, run_detached_in, wait_for_status, wait_for_success, wait_for_worker,
|
||||
write_petri_workflow,
|
||||
};
|
||||
use crate::support::TEST_DEV_TOKEN;
|
||||
|
||||
const DOCKER_PLUGIN: &str = "sandbox-driver-docker";
|
||||
/// The server-side environment the runs select.
|
||||
const ENVIRONMENT: &str = "docker";
|
||||
/// The twin's model, for the Ask Fabro session.
|
||||
const MODEL: &str = "gpt-5.4";
|
||||
|
||||
/// The Docker plugin as Petri's lookup finds it, with a daemon that
|
||||
/// answers. `None`, after saying so, when the test should skip; a panic
|
||||
/// when the environment forbids a skip and the plugin is missing.
|
||||
fn docker_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os(EnvVars::PATH)?)
|
||||
.map(|dir| dir.join(DOCKER_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
let Some(found) = found else {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {} is unset",
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN
|
||||
);
|
||||
eprintln!(
|
||||
"skipping: {DOCKER_PLUGIN} is not on PATH and {} is unset",
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let daemon = Command::new("docker")
|
||||
.args(["version", "--format", "{{.Server.Version}}"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !daemon {
|
||||
eprintln!("skipping: no Docker daemon answers");
|
||||
return None;
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// A server with a Docker environment beside the default local one.
|
||||
async fn docker_server() -> RunningServer {
|
||||
docker_server_with("", &[]).await
|
||||
|
|
@ -294,7 +254,7 @@ fn restore_actions(server: &RunningServer, run_id: &str) -> Vec<String> {
|
|||
/// nothing of the workspace is on the host.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_docker_run_publishes_every_stages_checkpoint_from_the_container() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
|
|
@ -325,7 +285,7 @@ async fn a_docker_run_publishes_every_stages_checkpoint_from_the_container() {
|
|||
/// the second stage sees the first stage's files and nothing else.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_retained_container_whose_workspace_drifted_is_reset_on_restart() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
|
|
@ -370,7 +330,7 @@ async fn a_retained_container_whose_workspace_drifted_is_reset_on_restart() {
|
|||
/// repository, and the second stage sees the first stage's files.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_lost_container_is_replaced_and_its_workspace_restored_from_the_snapshot() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
|
|
@ -463,7 +423,7 @@ async fn question_inputs(twin: &fabro_test::TwinOpenAi, namespace: &str) -> Vec<
|
|||
/// follow-up request carries the file's content back as the tool's answer.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_ask_fabro_turn_reads_a_file_inside_the_runs_container() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Fork, rewind, retry and the timeline over Petri runs, through a real
|
||||
//! server and its worker subprocess (the integration plan's F5.1).
|
||||
//! server and its worker subprocess.
|
||||
//!
|
||||
//! The harness is `petri.rs`'s: a foreground server on disk storage, a run
|
||||
//! created and started with `fabro run --detach`, executed by the worker
|
||||
|
|
@ -20,8 +20,7 @@ use std::process::{Command, Output};
|
|||
use fabro_test::test_context;
|
||||
|
||||
use super::petri::{
|
||||
RunningServer, host_plugin, run_detached, run_json, wait_for_status, wait_for_success,
|
||||
write_petri_workflow,
|
||||
RunningServer, run_detached, run_json, wait_for_status, wait_for_success, write_petri_workflow,
|
||||
};
|
||||
|
||||
/// Three command stages that build on each other's files: `one` writes a
|
||||
|
|
@ -178,9 +177,6 @@ fn read(workspace: &Path, name: &str) -> String {
|
|||
/// new run says where it came from.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -260,9 +256,6 @@ async fn a_fork_at_the_first_stage_continues_with_the_rest_on_its_files() {
|
|||
/// and finishes the run.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_transient() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let marker = context.temp_dir.join("flaky.marker");
|
||||
|
|
@ -316,9 +309,6 @@ async fn a_retry_reruns_the_failed_stage_and_succeeds_when_the_failure_was_trans
|
|||
/// names the run that replaced it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_rewind_supersedes_its_source() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -381,9 +371,6 @@ async fn a_rewind_supersedes_its_source() {
|
|||
/// its position and commit, as the CLI prints it and as the API serves it.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn the_timeline_lists_every_checkpoint_with_its_commit() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, &three_stage_dot());
|
||||
|
|
@ -453,9 +440,6 @@ async fn the_timeline_lists_every_checkpoint_with_its_commit() {
|
|||
/// refuses it, and the refusal says why. The join, in the root, is.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fork_inside_a_parallel_branch_is_refused() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let server = RunningServer::start().await;
|
||||
let bundle = write_petri_workflow(&context, ¶llel_dot());
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Fabro's run tools inside a Petri run (integration plan item F3.4): a
|
||||
//! Fabro's run tools inside a Petri run: a
|
||||
//! workflow that enables `[run.agent] fabro_tools` runs on Petri in the
|
||||
//! worker the server launched, and the agent stage's model, the twin, calls
|
||||
//! the run tools the worker registered through Petri's host tool
|
||||
|
|
@ -9,9 +9,7 @@
|
|||
//!
|
||||
//! The harness is `petri.rs`'s: a foreground server on disk storage with
|
||||
//! the `openai` provider repointed at the twin, its key in the vault, and
|
||||
//! the run started with `fabro run --detach`. The runs take their host
|
||||
//! scope through the sandbox-driver host plugin, so the tests skip, and say
|
||||
//! why, when it is not found.
|
||||
//! the run started with `fabro run --detach`. Host scopes run in process.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
@ -33,7 +31,7 @@ use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, test_context, twin_o
|
|||
use fabro_types::{WorkflowPath, WorkflowVersion};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::petri::{RunningServer, host_plugin, run_detached_with, run_json, wait_for_status};
|
||||
use super::petri::{RunningServer, run_detached_with, run_json, wait_for_status};
|
||||
use crate::support::TEST_DEV_TOKEN;
|
||||
|
||||
const MODEL: &str = "gpt-5.4";
|
||||
|
|
@ -227,9 +225,6 @@ async fn wait_for_children(server: &RunningServer, parent_id: &str) -> Vec<Value
|
|||
/// Petri's record under the stage.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_agent_starts_a_child_run_with_a_run_tool_inside_a_petri_run() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -322,9 +317,6 @@ async fn an_agent_starts_a_child_run_with_a_run_tool_inside_a_petri_run() {
|
|||
/// call.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_run_hook_blocks_a_run_tool_inside_a_petri_run() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -391,9 +383,6 @@ async fn a_run_hook_blocks_a_run_tool_inside_a_petri_run() {
|
|||
/// parent session.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_sub_agent_calls_an_inherited_run_tool_inside_a_petri_run() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let context = test_context!();
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
|
|||
|
|
@ -1,36 +1,27 @@
|
|||
//! The Docker provider for the workflow scenarios: an environment on
|
||||
//! [`DOCKER_IMAGE`], on an isolated server.
|
||||
//!
|
||||
//! Petri serves every provider through a sandbox-driver plugin executable it
|
||||
//! finds on `PATH` (`sandbox-driver-docker` here); CI installs the
|
||||
//! executables at the rev the workspace pins, and a developer installs them
|
||||
//! with
|
||||
//! `cargo install --locked --git https://github.com/lithoscomputer/sandbox-driver --rev <rev> sandbox-driver-host sandbox-driver-docker`.
|
||||
//! Petri uses the built-in Docker provider; no plugin executable is needed.
|
||||
//! A scenario configured here runs against its own server so the environment
|
||||
//! it creates never leaks into the shared session server.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "test setup reads the process environment for its opt-in gate and probes Docker synchronously"
|
||||
reason = "a failed setup reads the isolated server's log synchronously"
|
||||
)]
|
||||
#![expect(
|
||||
clippy::print_stderr,
|
||||
reason = "a skipped scenario says why on the test's stderr"
|
||||
reason = "a failed setup prints the server log tail on the test's stderr"
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_test::{TestContext, expect_reqwest_status};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::support::server_endpoint;
|
||||
|
||||
/// Set in CI so a missing executable or daemon fails the test instead of
|
||||
/// skipping it.
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
const DOCKER_IMAGE: &str = "buildpack-deps:noble";
|
||||
const DOCKER_PLUGIN: &str = "sandbox-driver-docker";
|
||||
/// The environment id the scenario selects with `--environment`.
|
||||
pub(crate) const ENVIRONMENT: &str = "docker";
|
||||
|
||||
|
|
@ -38,24 +29,7 @@ pub(crate) const ENVIRONMENT: &str = "docker";
|
|||
/// [`DOCKER_IMAGE`]. Returns the environment id, or `None` when the
|
||||
/// prerequisites are missing and the test should skip.
|
||||
pub(crate) fn configure(context: &mut TestContext) -> Option<&'static str> {
|
||||
let required = std::env::var_os(REQUIRE_ENV).is_some();
|
||||
if plugin_executable().is_none() {
|
||||
assert!(
|
||||
!required,
|
||||
"{REQUIRE_ENV} is set but {DOCKER_PLUGIN} is not on PATH"
|
||||
);
|
||||
eprintln!(
|
||||
"skipping: {DOCKER_PLUGIN} is not on PATH; install the sandbox-driver executables at \
|
||||
the rev Cargo.toml pins"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if !docker_image_available() {
|
||||
assert!(
|
||||
!required,
|
||||
"{REQUIRE_ENV} is set but no Docker daemon with {DOCKER_IMAGE} is available"
|
||||
);
|
||||
eprintln!("skipping: no Docker daemon with {DOCKER_IMAGE}");
|
||||
if !fabro_test::docker_image_available(DOCKER_IMAGE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -75,23 +49,6 @@ methods = ["dev-token"]
|
|||
Some(ENVIRONMENT)
|
||||
}
|
||||
|
||||
/// The Docker plugin executable on `PATH`, when installed.
|
||||
fn plugin_executable() -> Option<PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|dir| dir.join(DOCKER_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn docker_image_available() -> bool {
|
||||
Command::new("docker")
|
||||
.args(["image", "inspect", DOCKER_IMAGE])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn toml_path(path: &Path) -> String {
|
||||
path.display().to_string().replace('\\', "/")
|
||||
}
|
||||
|
|
@ -133,7 +90,7 @@ fn create_environment(storage_dir: &Path) {
|
|||
}
|
||||
|
||||
/// Run a scenario; when it fails, print the isolated server's log first, since
|
||||
/// the worker's stderr (and so a plugin's launch failure) lands only there
|
||||
/// the worker's stderr (and so a sandbox provider failure) lands only there
|
||||
/// and the server root is removed when the context drops.
|
||||
pub(crate) fn run_with_server_log(context: &TestContext, scenario: impl FnOnce()) {
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(scenario));
|
||||
|
|
|
|||
|
|
@ -178,10 +178,9 @@ fn run_stream_items(run_dir: &Path) -> Vec<RunStreamItem> {
|
|||
/// - `docker`: the Docker provider, an environment on `buildpack-deps:noble`
|
||||
/// created on an isolated server.
|
||||
///
|
||||
/// Petri serves each provider through the matching sandbox-driver plugin
|
||||
/// executable on `PATH`. The `docker` variant skips without
|
||||
/// `sandbox-driver-docker` or without a Docker daemon that has the image,
|
||||
/// unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set, as CI sets it.
|
||||
/// The built-in providers run in process. The `docker` variant skips when
|
||||
/// no Docker daemon has the required image, unless CI requires the backend
|
||||
/// with `FABRO_REQUIRE_SANDBOX_BACKENDS`.
|
||||
macro_rules! sandbox_tests {
|
||||
($name:ident) => {
|
||||
sandbox_tests!($name, keys = []);
|
||||
|
|
|
|||
|
|
@ -35,8 +35,6 @@ fabro-workflow = { path = "../../components/fabro-workflow" }
|
|||
fabro-workflow-version = { path = "../../components/fabro-workflow-version" }
|
||||
sandbox-driver.workspace = true
|
||||
sandbox-driver-host.workspace = true
|
||||
sandbox-driver-docker.workspace = true
|
||||
sandbox-driver-daytona.workspace = true
|
||||
sandbox-driver-protocol.workspace = true
|
||||
fabro-github = { path = "../../components/fabro-github" }
|
||||
pebble-agent.workspace = true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use anyhow::{Result, anyhow};
|
|||
use fabro_api::types;
|
||||
use fabro_config::{RunLayer, SettingsLayer, WorkflowSettingsBuilder, project};
|
||||
use fabro_manifest::CollectedWorkflowClosure;
|
||||
use fabro_petri::providers::SandboxProviderConfig;
|
||||
use fabro_petri::run_graph;
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ fn offline_runtime(run: Option<&RunLayer>) -> RuntimeSpec {
|
|||
..SettingsLayer::default()
|
||||
};
|
||||
RuntimeSpec {
|
||||
sandbox: SandboxProviderConfig::default(),
|
||||
settings_toml: toml::to_string(&layer).ok(),
|
||||
mcp_catalog_toml: None,
|
||||
model_client: None,
|
||||
|
|
|
|||
|
|
@ -233,7 +233,12 @@ pub(crate) async fn check_prepared_manifest(
|
|||
None,
|
||||
);
|
||||
let dry_run = prepared.settings.run.execution.mode == RunMode::DryRun;
|
||||
let runtime = petri_runs::runtime_spec(state, ready_providers, dry_run);
|
||||
let runtime = petri_runs::runtime_spec(
|
||||
state,
|
||||
ready_providers,
|
||||
dry_run,
|
||||
state.sandbox_provider_config(None),
|
||||
);
|
||||
let has_ready_provider = !ready_providers.is_empty();
|
||||
let prepared = prepared.clone();
|
||||
task::spawn_blocking(move || {
|
||||
|
|
@ -1530,7 +1535,12 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
);
|
||||
let runtime = crate::server::petri_runs::runtime_spec(state, ready_providers, false);
|
||||
let runtime = crate::server::petri_runs::runtime_spec(
|
||||
state,
|
||||
ready_providers,
|
||||
false,
|
||||
state.sandbox_provider_config(None),
|
||||
);
|
||||
validate_prepared_manifest(
|
||||
prepared,
|
||||
&HashMap::new(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
//! Credentials arrive explicitly. Nothing here reads the process
|
||||
//! environment for a secret: the Daytona key comes from the vault through
|
||||
//! [`DaytonaCredentials`]. The Docker client resolves its endpoint from the
|
||||
//! same variables Petri forwards to its Docker plugin (`DOCKER_HOST` and
|
||||
//! same variables Fabro forwards to its worker (`DOCKER_HOST` and
|
||||
//! its TLS companions), so the server and the run's containers meet on one
|
||||
//! daemon.
|
||||
|
||||
|
|
@ -32,7 +32,8 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::Context as _;
|
||||
use fabro_config::Storage;
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_petri::providers;
|
||||
pub(crate) use fabro_petri::providers::DaytonaCredentials;
|
||||
use fabro_types::settings::server::{
|
||||
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
||||
};
|
||||
|
|
@ -46,8 +47,6 @@ use sandbox_driver::{
|
|||
Sandbox, SandboxFilter, SandboxId, SandboxProvider, SandboxSource, SandboxSpec, SandboxState,
|
||||
WaitOptions,
|
||||
};
|
||||
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
use sandbox_driver_protocol::{PluginConfig, PluginSupervisor};
|
||||
use tokio::sync::OnceCell;
|
||||
|
|
@ -63,78 +62,9 @@ pub(crate) const PETRI_RUN_LABEL: &str = "petri.run";
|
|||
/// it up under the same name.
|
||||
const PLUGIN_BINARY_PREFIX: &str = "sandbox-driver";
|
||||
|
||||
/// `User-Agent` Fabro presents to remote sandbox control planes.
|
||||
const USER_AGENT: &str = concat!("fabro-server/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Budget for the credential probe `fabro doctor` and the install flow run.
|
||||
pub(crate) const DAYTONA_CREDENTIAL_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Explicit Daytona credentials: the SDK's configuration with the API key
|
||||
/// always present and a `Debug` that never prints it. The process
|
||||
/// environment is never consulted.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DaytonaCredentials(DaytonaConfig);
|
||||
|
||||
impl DaytonaCredentials {
|
||||
/// Credentials for `api_key` against Daytona's public control plane,
|
||||
/// presenting Fabro's `User-Agent`.
|
||||
#[must_use]
|
||||
pub(crate) fn new(api_key: String) -> Self {
|
||||
Self(DaytonaConfig {
|
||||
api_key: Some(api_key),
|
||||
user_agent: Some(USER_AGENT.to_string()),
|
||||
..DaytonaConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Credentials for a vault API key, with the control-plane URL and
|
||||
/// organization taken from `lookup` (server configuration). Nothing is
|
||||
/// read implicitly.
|
||||
pub(crate) fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option<String>) -> Self {
|
||||
Self::new(api_key)
|
||||
.with_api_url(
|
||||
lookup(EnvVars::DAYTONA_API_URL).or_else(|| lookup(EnvVars::DAYTONA_SERVER_URL)),
|
||||
)
|
||||
.with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID))
|
||||
}
|
||||
|
||||
/// The control-plane URL; Daytona's public API when `None`.
|
||||
#[must_use]
|
||||
pub(crate) fn with_api_url(mut self, api_url: Option<String>) -> Self {
|
||||
self.0.api_url = api_url;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn with_organization_id(mut self, organization_id: Option<String>) -> Self {
|
||||
self.0.organization_id = organization_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// A shared HTTP client; tests pass a no-proxy client here.
|
||||
#[must_use]
|
||||
pub(crate) fn with_http_client(mut self, http_client: Option<fabro_http::HttpClient>) -> Self {
|
||||
self.0.http_client = http_client;
|
||||
self
|
||||
}
|
||||
|
||||
/// The SDK configuration the driver's Daytona provider connects with.
|
||||
#[must_use]
|
||||
fn config(&self) -> &DaytonaConfig {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DaytonaCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DaytonaCredentials")
|
||||
.field("api_url", &self.0.api_url)
|
||||
.field("organization_id", &self.0.organization_id)
|
||||
.field("target", &self.0.target)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server needs to reach every provider a run record can name:
|
||||
/// its provider settings (which kinds are enabled, which run as plugins),
|
||||
/// the Daytona credentials from the vault, and the storage root under
|
||||
|
|
@ -214,7 +144,7 @@ pub(crate) enum ConnectError {
|
|||
/// a run's host sandbox is reached through the run's own registry by
|
||||
/// [`attach_run_sandbox`].
|
||||
/// `docker` connects to the daemon the process environment names, the
|
||||
/// same variables Petri hands its Docker plugin, without requiring the
|
||||
/// same variables Fabro forwards to its worker, without requiring the
|
||||
/// daemon to answer: `health` reports an unreachable daemon so preflight
|
||||
/// and the doctor see the cause. `daytona` needs the vault key. Any other
|
||||
/// kind launches the plugin executable its settings name and supervises
|
||||
|
|
@ -236,19 +166,15 @@ pub(crate) async fn connect_provider(
|
|||
};
|
||||
Ok(match kind.bundled() {
|
||||
Some(BundledProvider::Local) => Arc::new(HostProvider::new()),
|
||||
Some(BundledProvider::Docker) => {
|
||||
Arc::new(DockerProvider::connect_unverified().map_err(driver)?)
|
||||
}
|
||||
Some(BundledProvider::Docker) => providers::connect_docker().map_err(driver)?,
|
||||
Some(BundledProvider::Daytona) => {
|
||||
let credentials = access
|
||||
.daytona
|
||||
.as_ref()
|
||||
.ok_or(ConnectError::MissingDaytonaCredentials)?;
|
||||
Arc::new(
|
||||
DaytonaProvider::connect_explicit(credentials.config().clone())
|
||||
.await
|
||||
.map_err(driver)?,
|
||||
)
|
||||
providers::connect_daytona(credentials)
|
||||
.await
|
||||
.map_err(driver)?
|
||||
}
|
||||
None => {
|
||||
let plugin = settings
|
||||
|
|
@ -1241,22 +1167,6 @@ mod tests {
|
|||
assert_eq!(config.inherit_env, vec!["PATH"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_credentials_debug_never_prints_the_key() {
|
||||
let credentials = DaytonaCredentials::from_api_key("dtn_secret_key".to_string(), |name| {
|
||||
(name == EnvVars::DAYTONA_ORGANIZATION_ID).then(|| "org-1".to_string())
|
||||
});
|
||||
// The rendering stays out of the assertion messages: a failure must
|
||||
// not print the key it is checking for.
|
||||
let rendered = format!("{credentials:?}");
|
||||
assert!(!rendered.contains("dtn_secret_key"));
|
||||
assert!(rendered.contains("org-1"));
|
||||
assert_eq!(
|
||||
credentials.config().api_key.as_deref(),
|
||||
Some("dtn_secret_key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_scopes_render_as_the_provider_reports_them() {
|
||||
let check = DaytonaKeyCheck {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ use fabro_llm::{ClientOptions, FabroClient};
|
|||
use fabro_mcp_store::McpServerStore;
|
||||
use fabro_petri::controls::{RunControls, SteerError};
|
||||
use fabro_petri::projector::Projector;
|
||||
use fabro_petri::providers::SandboxProviderConfig;
|
||||
use fabro_petri::prune::{self, PruneError, PruneRequest};
|
||||
use fabro_redact::redact_jsonl_line;
|
||||
use fabro_slack::client::{PostedMessage as SlackPostedMessage, SlackClient};
|
||||
|
|
@ -150,7 +151,7 @@ use crate::sandbox_access::{
|
|||
SandboxInventory,
|
||||
};
|
||||
use crate::server_secrets::ServerSecrets;
|
||||
use crate::spawn_env::{self, apply_render_graph_env};
|
||||
use crate::spawn_env::apply_render_graph_env;
|
||||
use crate::worker_control::{
|
||||
LocalWorkerControlBus, WORKER_CONTROL_ACK_WAIT, WorkerControlAcks, WorkerControlBus,
|
||||
WorkerControlBusError,
|
||||
|
|
@ -1545,6 +1546,33 @@ impl AppState {
|
|||
.with_http_client(self.http_client().ok())
|
||||
}
|
||||
|
||||
/// The same provider selection for execution, fork and prune; credentials
|
||||
/// arrive from the caller's vault read, never the process environment.
|
||||
pub(crate) fn sandbox_provider_config(
|
||||
&self,
|
||||
daytona_api_key: Option<String>,
|
||||
) -> SandboxProviderConfig {
|
||||
SandboxProviderConfig::from_lookup(
|
||||
daytona_api_key.map(|key| self.daytona_credentials(key)),
|
||||
|name| self.config_env_lookup(name),
|
||||
)
|
||||
}
|
||||
|
||||
/// [`Self::sandbox_provider_config`] for a server-side prune of
|
||||
/// `provider`'s sandboxes, with the Daytona key read from the vault only
|
||||
/// when `provider` is Daytona.
|
||||
pub(crate) async fn load_sandbox_provider_config(
|
||||
&self,
|
||||
provider: &SandboxProviderKind,
|
||||
) -> Result<SandboxProviderConfig, SecretStoreError> {
|
||||
let daytona_api_key = if *provider == SandboxProviderKind::DAYTONA {
|
||||
self.vault_secret(EnvVars::DAYTONA_API_KEY).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(self.sandbox_provider_config(daytona_api_key))
|
||||
}
|
||||
|
||||
/// Everything a reconnect needs to reach a run's provider: the server's
|
||||
/// provider settings and the Daytona credentials from the vault (`None`
|
||||
/// when no key is stored).
|
||||
|
|
@ -2875,7 +2903,29 @@ async fn delete_run_sandbox_resource(
|
|||
.run_scratch(&id)
|
||||
.root()
|
||||
.join("petri");
|
||||
let sandbox = match state.load_sandbox_provider_config(&record.provider).await {
|
||||
Ok(sandbox) => sandbox,
|
||||
// A forced or restarted delete goes on without the sandboxes, as it
|
||||
// does for any other prune failure below.
|
||||
Err(err) if force || delete_started => {
|
||||
tracing::warn!(
|
||||
run_id = %id,
|
||||
provider = %record.provider,
|
||||
error = ?err,
|
||||
"Skipping the sandbox prune after loading sandbox credentials failed during run deletion"
|
||||
);
|
||||
return Ok(SandboxDeleteOutcome::Cleaned);
|
||||
}
|
||||
Err(err) => {
|
||||
error!(error = ?err, "Loading sandbox credentials failed");
|
||||
return Err(ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"secret store operation failed",
|
||||
));
|
||||
}
|
||||
};
|
||||
let report = prune::prune(PruneRequest {
|
||||
sandbox,
|
||||
run_id: id.to_string(),
|
||||
run_dir,
|
||||
store: state.petri_runs.shared_store(),
|
||||
|
|
@ -3777,7 +3827,6 @@ fn worker_launch_spec(
|
|||
run_dir: &std::path::Path,
|
||||
agent_fabro_tools_enabled: bool,
|
||||
github_app_private_key: Option<String>,
|
||||
daytona_api_key: Option<String>,
|
||||
) -> anyhow::Result<WorkerLaunchSpec> {
|
||||
let current_exe = std::env::current_exe().context("reading current executable path")?;
|
||||
let executable =
|
||||
|
|
@ -3816,11 +3865,7 @@ fn worker_launch_spec(
|
|||
fabro_log,
|
||||
active_config_path: state.active_config_path().to_path_buf(),
|
||||
github_app_private_key,
|
||||
daytona_api_key,
|
||||
fabro_home: fabro_config::Home::from_env().root().to_path_buf(),
|
||||
sandbox_plugin_env: spawn_env::sandbox_plugin_env(
|
||||
&state.server_settings().server.sandbox.providers,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -4137,21 +4182,9 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
|||
return;
|
||||
}
|
||||
|
||||
// A Daytona run's worker hands the vault's key to Petri's Daytona
|
||||
// plugin through its own environment; any other run's worker never
|
||||
// sees it.
|
||||
let wants_daytona =
|
||||
run_state.spec.settings.run.environment.provider == SandboxProviderKind::DAYTONA;
|
||||
let secrets = async {
|
||||
let github_app_private_key = state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await?;
|
||||
let daytona_api_key = if wants_daytona {
|
||||
state.vault_secret(EnvVars::DAYTONA_API_KEY).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok::<_, SecretStoreError>((github_app_private_key, daytona_api_key))
|
||||
};
|
||||
let (github_app_private_key, daytona_api_key) = match secrets.await {
|
||||
// The worker reads the Daytona key from the vault itself; only the
|
||||
// GitHub App key crosses on its command.
|
||||
let github_app_private_key = match state.vault_secret(EnvVars::GITHUB_APP_PRIVATE_KEY).await {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
fail_run_before_execution(
|
||||
|
|
@ -4175,7 +4208,6 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
|
|||
&run_dir_for_build,
|
||||
agent_fabro_tools_enabled,
|
||||
github_app_private_key,
|
||||
daytona_api_key,
|
||||
)
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! A run's checkpoint timeline, and the runs made from it: fork, rewind and
|
||||
//! retry (the integration plan's F5.1).
|
||||
//! retry.
|
||||
//!
|
||||
//! The timeline is the run's `checkpoint` platform records, labelled with
|
||||
//! the stages the projector folded them onto. A fork resolves a target on
|
||||
|
|
|
|||
|
|
@ -47,10 +47,12 @@ use fabro_petri::hooks::HooksSpec;
|
|||
use fabro_petri::interview::{Approval, FabroInterviewer};
|
||||
use fabro_petri::petri::{Access, Digest, LogId, Record, RunKey, RunLogs, RunStore, StoreError};
|
||||
use fabro_petri::platform_records::SqlitePlatformRecords;
|
||||
use fabro_petri::providers::SandboxProviderConfig;
|
||||
use fabro_petri::recovery::{self, Recovery, RecoveryRequest};
|
||||
use fabro_petri::runtime::{self, RuntimeSpec};
|
||||
use fabro_petri::secrets::VaultSecrets;
|
||||
use fabro_petri::{SqliteRunStore, admission, projection, run_graph};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_store::platform_records::{RunLifecycleKind, RunLifecycleRecord};
|
||||
use fabro_types::settings::McpTransport;
|
||||
use fabro_types::settings::run::{ApprovalMode, McpServerSettings, RunMode};
|
||||
|
|
@ -73,11 +75,12 @@ use crate::run_compiler::{AdmittedRun, PreparedRun, RunCompilerError};
|
|||
/// The runtime Petri gets, at create and at execution: the server's run
|
||||
/// defaults and environment catalog as the settings layer, the MCP
|
||||
/// catalog, the model client over the server's catalog and credentials for
|
||||
/// the eligible providers, and the run mode.
|
||||
/// the eligible providers, the sandbox providers, and the run mode.
|
||||
pub(crate) fn runtime_spec(
|
||||
state: &AppState,
|
||||
eligible: &[ProviderId],
|
||||
dry_run: bool,
|
||||
sandbox: SandboxProviderConfig,
|
||||
) -> RuntimeSpec {
|
||||
let settings_toml = settings_layer_toml(state);
|
||||
let mcp_catalog_toml = mcp_catalog_toml(&state.mcp_server_store().catalog_settings());
|
||||
|
|
@ -95,6 +98,7 @@ pub(crate) fn runtime_spec(
|
|||
}
|
||||
};
|
||||
RuntimeSpec {
|
||||
sandbox,
|
||||
settings_toml,
|
||||
mcp_catalog_toml,
|
||||
model_client,
|
||||
|
|
@ -275,7 +279,12 @@ pub(crate) async fn admit(
|
|||
settings,
|
||||
prepared.vars(),
|
||||
launch,
|
||||
runtime_spec(state, eligible, dry_run),
|
||||
runtime_spec(
|
||||
state,
|
||||
eligible,
|
||||
dry_run,
|
||||
state.sandbox_provider_config(None),
|
||||
),
|
||||
false,
|
||||
)
|
||||
.map_err(RunCompilerError::Workflow)?;
|
||||
|
|
@ -458,6 +467,12 @@ pub(crate) async fn execute(state: Arc<AppState>, run_id: RunId) {
|
|||
&run_state.spec.settings.run,
|
||||
Arc::new(StoreArtifactWriter::new(state.artifact_store.clone())),
|
||||
);
|
||||
let runtime = runtime_spec(
|
||||
&state,
|
||||
&eligible,
|
||||
dry_run,
|
||||
state.sandbox_provider_config(vault.get(EnvVars::DAYTONA_API_KEY).map(str::to_owned)),
|
||||
);
|
||||
let request = RunRequest {
|
||||
run_id: run_id.to_string(),
|
||||
run_dir: run_dir.join("petri"),
|
||||
|
|
@ -470,7 +485,7 @@ pub(crate) async fn execute(state: Arc<AppState>, run_id: RunId) {
|
|||
.observe_store(Arc::new(SqliteRunStore::new(state.db_pool.clone()))),
|
||||
state: Arc::clone(&state),
|
||||
}),
|
||||
runtime: runtime_spec(&state, &eligible, dry_run),
|
||||
runtime,
|
||||
provider: run_state.spec.settings.run.environment.provider.clone(),
|
||||
cancel,
|
||||
// The in-process test path drives no pause: the server's transport
|
||||
|
|
|
|||
|
|
@ -225,17 +225,12 @@ fn run_json_pending_control(run: &serde_json::Value) -> &serde_json::Value {
|
|||
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/paginated")
|
||||
.query_param("page", "1")
|
||||
.query_param("limit", "1");
|
||||
when.method(GET).path("/sandbox").query_param("limit", "1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(json!({
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"totalPages": 0
|
||||
"nextCursor": null
|
||||
}));
|
||||
})
|
||||
.await
|
||||
|
|
@ -2279,7 +2274,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() {
|
|||
storage_dir.path(),
|
||||
false,
|
||||
Some("test-private-key".to_string()),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = LocalWorkerRuntime::command_for_spec(&spec);
|
||||
|
|
@ -2294,74 +2288,6 @@ fn worker_command_forwards_github_app_private_key_from_vault() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A Daytona run's worker carries the vault's key for Petri's Daytona
|
||||
/// plugin.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_forwards_daytona_api_key_from_vault() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let state = worker_command_test_state(storage_dir.path(), &["dev-token"], Some(TEST_DEV_TOKEN));
|
||||
let spec = worker_launch_spec(
|
||||
state.as_ref(),
|
||||
RunId::new(),
|
||||
RunExecutionMode::Start,
|
||||
storage_dir.path(),
|
||||
false,
|
||||
None,
|
||||
Some("dtn_test-key".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let cmd = LocalWorkerRuntime::command_for_spec(&spec);
|
||||
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, EnvVars::DAYTONA_API_KEY),
|
||||
EnvOverride::Set("dtn_test-key".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin configured under `[server.sandbox.providers.<kind>]` reaches
|
||||
/// the worker under the names Petri reads, so a run on that kind finds its
|
||||
/// plugin without a second configuration.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_forwards_configured_sandbox_plugins() {
|
||||
let storage_dir = tempfile::tempdir().unwrap();
|
||||
let state = worker_command_test_state_with_extra_config(
|
||||
storage_dir.path(),
|
||||
&["dev-token"],
|
||||
Some(TEST_DEV_TOKEN),
|
||||
r#"
|
||||
[server.sandbox.providers.e2b]
|
||||
path = "/opt/fabro/plugins/sandbox-driver-e2b"
|
||||
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
dev = true
|
||||
"#,
|
||||
);
|
||||
let cmd = worker_command(
|
||||
state.as_ref(),
|
||||
RunId::new(),
|
||||
RunExecutionMode::Start,
|
||||
storage_dir.path(),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, "PETRI_SANDBOX_E2B_PLUGIN"),
|
||||
EnvOverride::Set("/opt/fabro/plugins/sandbox-driver-e2b".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, "PETRI_SANDBOX_E2B_SHA256"),
|
||||
EnvOverride::Set(
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
command_env_value(&cmd, EnvVars::PETRI_SANDBOX_PLUGIN_DEV),
|
||||
EnvOverride::Set("1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn worker_command_omits_github_app_private_key_when_unset() {
|
||||
|
|
@ -2564,7 +2490,6 @@ fn worker_command(
|
|||
run_dir,
|
||||
agent_fabro_tools_enabled,
|
||||
None,
|
||||
None,
|
||||
)?;
|
||||
Ok(LocalWorkerRuntime::command_for_spec(&spec))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
use std::ffi::OsString;
|
||||
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::settings::server::ServerSandboxProvidersSettings;
|
||||
use tokio::process::Command;
|
||||
|
||||
const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
||||
|
|
@ -51,21 +50,11 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
EnvVars::AWS_CONTAINER_CREDENTIALS_RELATIVE_URI,
|
||||
EnvVars::AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||
EnvVars::AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE,
|
||||
// Petri's sandbox-driver plugins are resolved in the worker, where a
|
||||
// Petri run executes: the plugin path, checksum and dev-mode overrides
|
||||
// cross with `PATH`, so the worker finds the plugins the server would.
|
||||
// A plugin the server's settings configure is set on top of these by
|
||||
// `sandbox_plugin_env`, for every configured kind.
|
||||
EnvVars::PETRI_SANDBOX_HOST_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_HOST_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_DAYTONA_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_DAYTONA_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_PLUGIN_DEV,
|
||||
// Petri's sandbox settings the worker's in-process providers read. No
|
||||
// plugin settings cross: the worker never launches a provider plugin.
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS,
|
||||
EnvVars::PETRI_SANDBOX_ACTION_HOST_IMAGE,
|
||||
// The Docker daemon selection: the worker's Docker plugin reads these
|
||||
// The Docker daemon selection: the worker's Docker provider reads these
|
||||
// from its own process, so the worker's sandboxes go to the daemon the
|
||||
// server uses (a remote or TLS daemon, a named context), not the
|
||||
// default socket.
|
||||
|
|
@ -75,9 +64,8 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
EnvVars::DOCKER_API_VERSION,
|
||||
EnvVars::DOCKER_CONFIG,
|
||||
EnvVars::DOCKER_CONTEXT,
|
||||
// Daytona's control-plane selection, the non-secret half: the plugin
|
||||
// reads them from the worker. The API key comes from the vault, set on
|
||||
// the command by the launch (`WorkerLaunchSpec::daytona_api_key`).
|
||||
// Daytona's non-secret selection. The worker reads the API key from
|
||||
// the vault and supplies it explicitly to the in-process provider.
|
||||
EnvVars::DAYTONA_API_URL,
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID,
|
||||
// A test's checkpoint gates: the worker's hooks hold at a named point
|
||||
|
|
@ -90,55 +78,9 @@ const WORKER_ENV_ALLOWLIST: &[&str] = &[
|
|||
|
||||
const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[EnvVars::PATH, EnvVars::HOME, EnvVars::TMPDIR];
|
||||
|
||||
/// The worker's environment: the allowlisted ambient variables, then the
|
||||
/// plugin variables the server's settings derive, which win over an
|
||||
/// ambient variable of the same name.
|
||||
pub(crate) fn apply_worker_env(cmd: &mut Command, sandbox_plugins: &[(String, String)]) {
|
||||
apply_worker_env_with(cmd, sandbox_plugins, &process_env_var_os);
|
||||
}
|
||||
|
||||
fn apply_worker_env_with(
|
||||
cmd: &mut Command,
|
||||
sandbox_plugins: &[(String, String)],
|
||||
lookup: &dyn Fn(&str) -> Option<OsString>,
|
||||
) {
|
||||
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, lookup);
|
||||
for (name, value) in sandbox_plugins {
|
||||
cmd.env(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// The plugin variables Petri reads in the worker, derived from the
|
||||
/// server's `[server.sandbox.providers.<kind>]` settings: for every enabled
|
||||
/// kind that carries plugin settings, `PETRI_SANDBOX_<KIND>_PLUGIN` from
|
||||
/// its `path` and `PETRI_SANDBOX_<KIND>_SHA256` from its `sha256`, and
|
||||
/// `PETRI_SANDBOX_PLUGIN_DEV=1` when any of them sets `dev`. The kind is
|
||||
/// uppercased with hyphens as underscores, as Petri names the variable. A
|
||||
/// kind whose settings name no path is left to Petri's own lookup
|
||||
/// (`sandbox-driver-<kind>` beside the executable, then on `PATH`), the
|
||||
/// same lookup the server's attach uses.
|
||||
pub(crate) fn sandbox_plugin_env(
|
||||
providers: &ServerSandboxProvidersSettings,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut env = Vec::new();
|
||||
let mut dev = false;
|
||||
for (kind, plugin) in providers.enabled_plugins() {
|
||||
let upper = kind.as_str().to_ascii_uppercase().replace('-', "_");
|
||||
if let Some(path) = &plugin.path {
|
||||
env.push((format!("PETRI_SANDBOX_{upper}_PLUGIN"), path.clone()));
|
||||
}
|
||||
if let Some(sha256) = &plugin.sha256 {
|
||||
env.push((format!("PETRI_SANDBOX_{upper}_SHA256"), sha256.clone()));
|
||||
}
|
||||
dev |= plugin.dev;
|
||||
}
|
||||
if dev {
|
||||
env.push((
|
||||
EnvVars::PETRI_SANDBOX_PLUGIN_DEV.to_string(),
|
||||
"1".to_string(),
|
||||
));
|
||||
}
|
||||
env
|
||||
/// The worker's environment: the allowlisted ambient variables only.
|
||||
pub(crate) fn apply_worker_env(cmd: &mut Command) {
|
||||
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, &process_env_var_os);
|
||||
}
|
||||
|
||||
pub(crate) fn apply_render_graph_env(cmd: &mut Command) {
|
||||
|
|
@ -168,15 +110,7 @@ mod tests {
|
|||
use std::ffi::OsString;
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use fabro_types::settings::server::{
|
||||
SandboxPluginSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings,
|
||||
};
|
||||
|
||||
use super::{
|
||||
RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist, apply_worker_env_with,
|
||||
sandbox_plugin_env,
|
||||
};
|
||||
use super::{RENDER_GRAPH_ENV_ALLOWLIST, WORKER_ENV_ALLOWLIST, apply_allowlist};
|
||||
|
||||
fn env_command() -> tokio::process::Command {
|
||||
assert!(Path::new("/usr/bin/env").exists());
|
||||
|
|
@ -260,6 +194,11 @@ mod tests {
|
|||
"https://daytona.internal/api".to_string(),
|
||||
),
|
||||
("DAYTONA_ORGANIZATION_ID".to_string(), "org-1".to_string()),
|
||||
(
|
||||
"DAYTONA_SERVER_URL".to_string(),
|
||||
"https://daytona-alias.internal/api".to_string(),
|
||||
),
|
||||
("DAYTONA_TARGET".to_string(), "us".to_string()),
|
||||
("DAYTONA_API_KEY".to_string(), "leak".to_string()),
|
||||
]);
|
||||
let mut cmd = env_command();
|
||||
|
|
@ -295,18 +234,12 @@ mod tests {
|
|||
Some("xterm-256color")
|
||||
);
|
||||
assert_eq!(actual.get("NO_COLOR").map(String::as_str), Some("1"));
|
||||
// Petri's plugin overrides cross so the worker resolves the same
|
||||
// sandbox-driver plugins the server would.
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_HOST_PLUGIN").map(String::as_str),
|
||||
Some("/opt/petri/sandbox-driver-host")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str),
|
||||
Some("1")
|
||||
);
|
||||
// No plugin setting reaches the worker: it never launches a
|
||||
// provider plugin.
|
||||
assert!(!actual.contains_key("PETRI_SANDBOX_HOST_PLUGIN"));
|
||||
assert!(!actual.contains_key("PETRI_SANDBOX_PLUGIN_DEV"));
|
||||
// The Docker daemon selection crosses whole, so the worker's Docker
|
||||
// plugin drives the daemon the server uses.
|
||||
// provider drives the daemon the server uses.
|
||||
assert_eq!(
|
||||
actual.get("DOCKER_HOST").map(String::as_str),
|
||||
Some("tcp://build-daemon.internal:2376")
|
||||
|
|
@ -341,6 +274,11 @@ mod tests {
|
|||
actual.get("DAYTONA_ORGANIZATION_ID").map(String::as_str),
|
||||
Some("org-1")
|
||||
);
|
||||
// The URL alias and placement target never reached the Daytona
|
||||
// plugin, so they stay out and plugin-era leases keep their
|
||||
// fingerprint.
|
||||
assert!(!actual.contains_key("DAYTONA_SERVER_URL"));
|
||||
assert!(!actual.contains_key("DAYTONA_TARGET"));
|
||||
assert!(!actual.contains_key("DAYTONA_API_KEY"));
|
||||
assert_eq!(actual.get("CLICOLOR").map(String::as_str), Some("0"));
|
||||
assert_eq!(actual.get("CLICOLOR_FORCE").map(String::as_str), Some("1"));
|
||||
|
|
@ -380,117 +318,6 @@ mod tests {
|
|||
assert!(!actual.contains_key("MY_API_KEY"));
|
||||
}
|
||||
|
||||
fn provider(
|
||||
kind: &str,
|
||||
enabled: bool,
|
||||
plugin: SandboxPluginSettings,
|
||||
) -> (SandboxProviderKind, ServerSandboxProviderSettings) {
|
||||
(
|
||||
SandboxProviderKind::try_new(kind).expect("a valid kind"),
|
||||
ServerSandboxProviderSettings {
|
||||
enabled,
|
||||
plugin: Some(plugin),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A configured plugin reaches the worker under the names Petri reads,
|
||||
/// a configured path wins over the ambient variable of the same name,
|
||||
/// a kind the settings leave to `PATH` keeps the ambient one, and a
|
||||
/// disabled kind's plugin never crosses.
|
||||
#[tokio::test]
|
||||
async fn configured_plugins_reach_the_worker_and_win_over_ambient_variables() {
|
||||
let mut providers = ServerSandboxProvidersSettings::default();
|
||||
providers.entries.extend([
|
||||
provider("e2b", true, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-e2b".to_string()),
|
||||
sha256: Some("0123abcd".to_string()),
|
||||
dev: true,
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
provider("docker", true, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-docker".to_string()),
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
provider("daytona", true, SandboxPluginSettings::default()),
|
||||
provider("fly-io", false, SandboxPluginSettings {
|
||||
path: Some("/opt/fabro/plugins/sandbox-driver-fly-io".to_string()),
|
||||
..SandboxPluginSettings::default()
|
||||
}),
|
||||
]);
|
||||
let env = HashMap::from([
|
||||
("PATH".to_string(), "/bin".to_string()),
|
||||
(
|
||||
"PETRI_SANDBOX_HOST_PLUGIN".to_string(),
|
||||
"/ambient/sandbox-driver-host".to_string(),
|
||||
),
|
||||
(
|
||||
"PETRI_SANDBOX_DOCKER_PLUGIN".to_string(),
|
||||
"/ambient/sandbox-driver-docker".to_string(),
|
||||
),
|
||||
(
|
||||
"PETRI_SANDBOX_DAYTONA_PLUGIN".to_string(),
|
||||
"/ambient/sandbox-driver-daytona".to_string(),
|
||||
),
|
||||
]);
|
||||
let mut cmd = env_command();
|
||||
apply_worker_env_with(&mut cmd, &sandbox_plugin_env(&providers), &|name| {
|
||||
env.get(name).map(OsString::from)
|
||||
});
|
||||
|
||||
let actual = env_output(cmd).await;
|
||||
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_E2B_PLUGIN").map(String::as_str),
|
||||
Some("/opt/fabro/plugins/sandbox-driver-e2b")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_E2B_SHA256").map(String::as_str),
|
||||
Some("0123abcd")
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_PLUGIN_DEV").map(String::as_str),
|
||||
Some("1"),
|
||||
"one plugin in dev mode puts the worker's lookup in dev mode"
|
||||
);
|
||||
assert_eq!(
|
||||
actual
|
||||
.get("PETRI_SANDBOX_DOCKER_PLUGIN")
|
||||
.map(String::as_str),
|
||||
Some("/opt/fabro/plugins/sandbox-driver-docker"),
|
||||
"the settings win over the ambient variable"
|
||||
);
|
||||
assert_eq!(
|
||||
actual.get("PETRI_SANDBOX_HOST_PLUGIN").map(String::as_str),
|
||||
Some("/ambient/sandbox-driver-host"),
|
||||
"a kind without settings keeps the allowlisted ambient variable"
|
||||
);
|
||||
assert_eq!(
|
||||
actual
|
||||
.get("PETRI_SANDBOX_DAYTONA_PLUGIN")
|
||||
.map(String::as_str),
|
||||
Some("/ambient/sandbox-driver-daytona"),
|
||||
"settings without a path leave the ambient variable in place"
|
||||
);
|
||||
assert!(
|
||||
!actual.contains_key("PETRI_SANDBOX_FLY_IO_PLUGIN"),
|
||||
"a disabled kind's plugin does not cross"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_configured_plugin_derives_no_variables() {
|
||||
assert!(sandbox_plugin_env(&ServerSandboxProvidersSettings::default()).is_empty());
|
||||
let mut providers = ServerSandboxProvidersSettings::default();
|
||||
providers
|
||||
.entries
|
||||
.extend([provider("docker", true, SandboxPluginSettings::default())]);
|
||||
assert!(
|
||||
sandbox_plugin_env(&providers).is_empty(),
|
||||
"settings with neither a path nor a pin nor dev mode add nothing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_graph_allowlist_is_fail_closed() {
|
||||
let env = HashMap::from([
|
||||
|
|
|
|||
|
|
@ -48,16 +48,9 @@ pub(crate) struct WorkerLaunchSpec {
|
|||
pub(crate) fabro_log: Option<String>,
|
||||
pub(crate) active_config_path: PathBuf,
|
||||
pub(crate) github_app_private_key: Option<String>,
|
||||
/// The vault's Daytona API key, for a run on a Daytona environment:
|
||||
/// Petri's Daytona plugin reads it from the worker's process.
|
||||
pub(crate) daytona_api_key: Option<String>,
|
||||
/// The Fabro home the server resolved, so a Petri run's skills step
|
||||
/// reads the same home whatever the worker's environment says.
|
||||
pub(crate) fabro_home: PathBuf,
|
||||
/// The sandbox-driver plugin variables the server's provider settings
|
||||
/// derive (`spawn_env::sandbox_plugin_env`), so Petri in the worker
|
||||
/// launches the plugin the settings name for every configured kind.
|
||||
pub(crate) sandbox_plugin_env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub(crate) struct StartedWorker {
|
||||
|
|
@ -105,7 +98,7 @@ impl LocalWorkerRuntime {
|
|||
.stdout(worker_stdout)
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
apply_worker_env(&mut cmd, &spec.sandbox_plugin_env);
|
||||
apply_worker_env(&mut cmd);
|
||||
if let Some(level) = spec.fabro_log.as_deref() {
|
||||
cmd.env(EnvVars::FABRO_LOG, level);
|
||||
}
|
||||
|
|
@ -116,9 +109,6 @@ impl LocalWorkerRuntime {
|
|||
if let Some(pem) = spec.github_app_private_key.as_deref() {
|
||||
cmd.env(EnvVars::GITHUB_APP_PRIVATE_KEY, pem);
|
||||
}
|
||||
if let Some(key) = spec.daytona_api_key.as_deref() {
|
||||
cmd.env(EnvVars::DAYTONA_API_KEY, key);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fabro_proc::pre_exec_setpgid(cmd.as_std_mut());
|
||||
|
|
|
|||
|
|
@ -92,17 +92,12 @@ fn assert_no_legacy_environment_dir(temp_dir: &tempfile::TempDir) {
|
|||
async fn mock_daytona_auth_probe(server: &MockServer) -> httpmock::Mock<'_> {
|
||||
server
|
||||
.mock_async(|when, then| {
|
||||
when.method(GET)
|
||||
.path("/sandbox/paginated")
|
||||
.query_param("page", "1")
|
||||
.query_param("limit", "1");
|
||||
when.method(GET).path("/sandbox").query_param("limit", "1");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.json_body(serde_json::json!({
|
||||
"items": [],
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"totalPages": 0
|
||||
"nextCursor": null
|
||||
}));
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use fabro_test::{
|
|||
expect_axum_status_in, expect_axum_text,
|
||||
};
|
||||
use fabro_types::ServerSettings;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::{self, sleep};
|
||||
use tower::ServiceExt;
|
||||
|
||||
pub(crate) const MINIMAL_DOT: &str = r#"digraph Test {
|
||||
|
|
@ -307,18 +307,36 @@ pub(crate) async fn wait_for_run_status(
|
|||
run_id: &str,
|
||||
expected: &[&str],
|
||||
) -> String {
|
||||
for _ in 0..POLL_ATTEMPTS {
|
||||
let body = run_json(app, run_id).await;
|
||||
let status = body["lifecycle"]["status"]["kind"]
|
||||
.as_str()
|
||||
.expect("run response should include a tagged status kind")
|
||||
.to_string();
|
||||
if expected.iter().any(|candidate| *candidate == status) {
|
||||
return status;
|
||||
wait_for_run_status_with_timeout(app, run_id, expected, Duration::from_secs(5)).await
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_run_status_with_timeout(
|
||||
app: &axum::Router,
|
||||
run_id: &str,
|
||||
expected: &[&str],
|
||||
timeout: Duration,
|
||||
) -> String {
|
||||
let mut last_body = serde_json::Value::Null;
|
||||
time::timeout(timeout, async {
|
||||
loop {
|
||||
last_body = run_json(app, run_id).await;
|
||||
let status = last_body["lifecycle"]["status"]["kind"]
|
||||
.as_str()
|
||||
.expect("run response should include a tagged status kind");
|
||||
if expected.contains(&status) {
|
||||
return status.to_owned();
|
||||
}
|
||||
sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
panic!("run {run_id} did not reach any of {expected:?}");
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"run {run_id} did not reach any of {expected:?} within {timeout:?}; \
|
||||
last lifecycle: {}; last sandbox: {}",
|
||||
last_body["lifecycle"], last_body["sandbox"]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_run_status_not_in(
|
||||
|
|
|
|||
|
|
@ -7,22 +7,18 @@
|
|||
//! run, which the CLI's scenario tests cover with the real binary
|
||||
//! (`lib/apps/fabro-cli/tests/it/scenario/petri.rs`).
|
||||
//!
|
||||
//! The runs that execute take their host scope through the sandbox-driver
|
||||
//! host plugin, so those tests skip, and say why, when the executable is not
|
||||
//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. The create-time
|
||||
//! refusals need no plugin and always run.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment"
|
||||
reason = "the tests inspect backend availability through the process environment"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
|
|
@ -41,18 +37,15 @@ use fabro_types::{RunId, WorkflowPath, WorkflowVersion};
|
|||
use tower::ServiceExt;
|
||||
|
||||
use crate::helpers::{
|
||||
api, create_and_start_run_from_intent, minimal_manifest_json, read_repo_file, response_json,
|
||||
run_json, settings_from_toml, test_app_state_with_options, test_app_with_scheduler,
|
||||
test_settings, wait_for_run_status,
|
||||
self, api, create_and_start_run_from_intent, minimal_manifest_json, read_repo_file,
|
||||
response_json, run_json, settings_from_toml, test_app_state_with_options,
|
||||
test_app_with_scheduler, test_settings, wait_for_run_status,
|
||||
};
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const DOCKER_PLUGIN: &str = "sandbox-driver-docker";
|
||||
const DOCKER_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_DOCKER_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
const OPENAI_MODEL: &str = "gpt-5.4";
|
||||
// A cold Docker daemon may need to download the runner image before the run
|
||||
// starts.
|
||||
const DOCKER_RUN_TIMEOUT: Duration = Duration::from_mins(1);
|
||||
|
||||
/// A command-only workflow: one script stage between start and exit.
|
||||
const COMMAND_DOT: &str = r#"digraph Command {
|
||||
|
|
@ -110,59 +103,6 @@ const PARALLEL_DOT: &str = r#"digraph Parallel {
|
|||
|
||||
pub(super) const PLAIN_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n";
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
pub(super) fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The Docker plugin as Petri's lookup finds it, with a daemon that
|
||||
/// answers. `None`, after saying so, when the test should skip; a panic
|
||||
/// when the environment forbids a skip and the plugin is missing.
|
||||
fn docker_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(DOCKER_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(DOCKER_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
let Some(found) = found else {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset");
|
||||
return None;
|
||||
};
|
||||
let daemon = Command::new("docker")
|
||||
.args(["version", "--format", "{{.Server.Version}}"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !daemon {
|
||||
eprintln!("skipping: no Docker daemon answers");
|
||||
return None;
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// Register a version whose entrypoint is `workflow.fabro`, with the given
|
||||
/// files beside it.
|
||||
pub(super) async fn register_version(app: &axum::Router, files: &[(&str, &str)]) -> String {
|
||||
|
|
@ -290,9 +230,6 @@ async fn create_run_response(app: &axum::Router, intent: serde_json::Value) -> s
|
|||
/// run succeeded, and Petri's record of the run says the same.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_hello_bundle_runs_on_petri() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -367,9 +304,6 @@ async fn the_hello_bundle_runs_on_petri() {
|
|||
/// A command-only bundle runs on Petri, and Petri's record agrees.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_command_bundle_runs_on_petri() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
let state = test_app_state_with_options(settings, 5);
|
||||
|
|
@ -413,9 +347,6 @@ async fn a_command_bundle_runs_on_petri() {
|
|||
/// under the fork, and the fork carries the branch results.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_parallel_bundle_projects_its_branches_through_the_server() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
let state = test_app_state_with_options(settings, 5);
|
||||
|
|
@ -574,9 +505,6 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu
|
|||
/// interview as a legacy stage's would.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_human_gate_is_answered_through_the_questions_api() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let markers = tempfile::tempdir().expect("marker tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
|
|
@ -696,9 +624,6 @@ async fn a_human_gate_is_answered_through_the_questions_api() {
|
|||
/// `sandbox cp` reach the sandbox after the run.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_runs_projection_carries_its_host_sandbox_instance() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
let state = test_app_state_with_options(settings, 5);
|
||||
|
|
@ -764,9 +689,6 @@ async fn a_runs_projection_carries_its_host_sandbox_instance() {
|
|||
/// host workspace Petri kept along with the run.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleting_a_run_prunes_its_host_workspace_through_petri() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
let state = test_app_state_with_options(settings, 5);
|
||||
|
|
@ -884,9 +806,6 @@ fn delete(run_id: &str) -> Request<Body> {
|
|||
/// end after the delete brings nothing back.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_delete_right_after_the_run_reads_ended_is_accepted() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
let state = test_app_state_with_options(settings, 5);
|
||||
|
|
@ -955,7 +874,7 @@ async fn a_delete_right_after_the_run_reads_ended_is_accepted() {
|
|||
/// attaches to it on the daemon.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_runs_projection_carries_its_docker_sandbox_instance() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"docker\"\n");
|
||||
|
|
@ -1004,7 +923,13 @@ async fn a_runs_projection_carries_its_docker_sandbox_instance() {
|
|||
"args": {},
|
||||
});
|
||||
let run_id = create_and_start_run_from_intent(&app, intent).await;
|
||||
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
let status = helpers::wait_for_run_status_with_timeout(
|
||||
&app,
|
||||
&run_id,
|
||||
&["succeeded", "failed"],
|
||||
DOCKER_RUN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
"succeeded",
|
||||
|
|
@ -1072,7 +997,7 @@ async fn a_runs_projection_carries_its_docker_sandbox_instance() {
|
|||
/// run label.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_server_attaches_to_the_container_petri_created() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
let twin = twin_openai().await;
|
||||
|
|
@ -1107,7 +1032,13 @@ async fn the_server_attaches_to_the_container_petri_created() {
|
|||
"args": {},
|
||||
});
|
||||
let run_id = create_and_start_run_from_intent(&app, intent).await;
|
||||
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
let status = helpers::wait_for_run_status_with_timeout(
|
||||
&app,
|
||||
&run_id,
|
||||
&["succeeded", "failed"],
|
||||
DOCKER_RUN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
"succeeded",
|
||||
|
|
@ -1293,7 +1224,7 @@ fn get(path: &str) -> Request<Body> {
|
|||
|
||||
/// The image the server's `docker-small` environment names in the tests
|
||||
/// below: a runner image with `git` for the checkpoint commit, and not the
|
||||
/// plugin's default, so the container proves the catalog's image reached it.
|
||||
/// provider's default, so the container proves the catalog's image reached it.
|
||||
const CATALOG_IMAGE: &str = "ghcr.io/lithoscomputer/ubuntu-22.04:slim";
|
||||
|
||||
/// A Docker environment in the server's catalog, with the image it runs.
|
||||
|
|
@ -1397,7 +1328,7 @@ async fn admitted_root_graph(app: &axum::Router, run_id: &str) -> serde_json::Va
|
|||
/// A bundle that names a server environment it does not declare admits: the
|
||||
/// catalog's `[environments.docker-small]` reaches Petri through the
|
||||
/// settings layer, its image lands on the lowered environment, and, with
|
||||
/// the Docker plugin and a daemon, the run's container runs that image.
|
||||
/// a Docker daemon, the run's container runs that image.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_bundle_naming_a_catalog_environment_runs_on_docker_with_its_image() {
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
|
|
@ -1431,11 +1362,17 @@ async fn a_bundle_naming_a_catalog_environment_runs_on_docker_with_its_image() {
|
|||
graph["params"]["fabro.launch"]
|
||||
);
|
||||
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
start_run(&app, &run_id).await;
|
||||
let status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await;
|
||||
let status = helpers::wait_for_run_status_with_timeout(
|
||||
&app,
|
||||
&run_id,
|
||||
&["succeeded", "failed"],
|
||||
DOCKER_RUN_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
let projection = settled_state(&state, &app, &run_id).await;
|
||||
assert_eq!(status, "succeeded", "run: {projection}");
|
||||
let instance = &projection["sandbox"]["instance"];
|
||||
|
|
@ -1583,9 +1520,6 @@ const AGENT_DOT: &str = r#"digraph Agent {
|
|||
/// server's tool to the model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_bundle_naming_a_catalog_mcp_server_lists_its_tools_to_the_model() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
|
|
@ -1770,9 +1704,6 @@ async fn assert_run_goal(app: &axum::Router, namespace: &str, run_id: &str, goal
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_goal_override_is_the_goal_the_stages_execute_with() {
|
||||
const GOAL: &str = "Add a limerick to the README instead of a haiku";
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let [(workflow_path, workflow), (settings_path, settings)] = hello_files();
|
||||
let (_state, app, namespace, run_id) = run_hello_agent(
|
||||
&[(workflow_path, &workflow), (settings_path, &settings)],
|
||||
|
|
@ -1788,9 +1719,6 @@ async fn a_goal_override_is_the_goal_the_stages_execute_with() {
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_run_goal_file_layer_is_the_goal_the_stages_execute_with() {
|
||||
const GOAL: &str = "Write a limerick about workflow engines into the README";
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let [(workflow_path, workflow), _] = hello_files();
|
||||
let settings =
|
||||
"_version = 1\n[workflow]\ngraph = \"workflow.fabro\"\n[run.goal]\nfile = \"goal.md\"\n";
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@
|
|||
//! concurrent child executions' events.
|
||||
//!
|
||||
//! The runs execute in the server process under the handler-registry test
|
||||
//! override and take their host scope through the sandbox-driver host
|
||||
//! plugin, so the tests skip, and say why, when the executable is not
|
||||
//! found (see `petri.rs`).
|
||||
//! override and take their Host scope through the built-in provider.
|
||||
//!
|
||||
//! With `FABRO_CAPTURE_PETRI_FIXTURES` set, a scenario also writes its
|
||||
//! settled projection and full stream as JSON under the web app's test
|
||||
|
|
@ -17,7 +15,7 @@
|
|||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable and the capture switch through the process environment"
|
||||
reason = "the tests read the capture switch through the process environment"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
|
|
@ -35,7 +33,7 @@ use http_body_util::BodyExt;
|
|||
use tokio::time::timeout;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::petri::{PLAIN_SETTINGS, host_plugin, intent, register_version, settled_state};
|
||||
use super::petri::{PLAIN_SETTINGS, intent, register_version, settled_state};
|
||||
use crate::helpers::{
|
||||
api, create_and_start_run_from_intent, repo_root, response_json, run_json, settings_from_toml,
|
||||
test_app_state_with_options, test_app_with_scheduler, wait_for_run_status,
|
||||
|
|
@ -231,9 +229,6 @@ fn wait_for_marker(path: &std::path::Path) {
|
|||
/// gap, no duplicate, with the notice between the branches' events.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_reconnecting_client_receives_every_stream_item_once_in_order() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let workspace = tempfile::tempdir().expect("workspace tempdir");
|
||||
let markers = tempfile::tempdir().expect("marker tempdir");
|
||||
let settings = settings_from_toml("_version = 1\n\n[run.environment]\nid = \"local\"\n");
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ fabro-api = { path = "../../foundation/fabro-api" }
|
|||
fabro-client = { path = "../../foundation/fabro-client" }
|
||||
fabro-db = { path = "../../foundation/fabro-db" }
|
||||
fabro-http.workspace = true
|
||||
fabro-static = { path = "../../foundation/fabro-static" }
|
||||
sandbox-driver.workspace = true
|
||||
sandbox-driver-host.workspace = true
|
||||
sandbox-driver-docker.workspace = true
|
||||
sandbox-driver-daytona.workspace = true
|
||||
fabro-interview = { path = "../fabro-interview" }
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-types = { path = "../../foundation/fabro-types" }
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ Fabro's adapters over Petri, the workflow engine Fabro runs its workflows on.
|
|||
|
||||
## Layering rule
|
||||
|
||||
Only this crate imports Petri. The workspace `Cargo.toml` pins the Petri
|
||||
packages by revision under `petri_*` keys, and `fabro-petri` is the only
|
||||
member that lists them as dependencies. Every other Fabro crate reaches the
|
||||
engine through what this crate exports. A Petri pin move is therefore a change
|
||||
to this crate and the lockfile, nothing else.
|
||||
Only this crate imports Petri. The workspace `Cargo.toml` lists the Petri
|
||||
packages under `petri_*` keys (see its comment for how they are tracked), and
|
||||
`fabro-petri` is the only member that lists them as dependencies. Every other
|
||||
Fabro crate reaches the engine through what this crate exports.
|
||||
|
||||
## What it holds
|
||||
|
||||
|
|
@ -174,16 +173,15 @@ Integration tests live under `tests/`:
|
|||
- `runs.rs` runs the `hello` bundle in memory through `Runtime::standard()`
|
||||
with the Fabro frontend and the model-free stub registry, then a
|
||||
command-only workflow on the host sandbox through the real step registry.
|
||||
Both skip, and say why, when the `sandbox-driver-host` plugin executable
|
||||
is not on `PATH` (every run takes its scope's environment through it);
|
||||
the sandbox-plugins CI job requires them.
|
||||
Both acquire real Host scopes through the built-in in-process provider;
|
||||
no plugin executable or checksum is required.
|
||||
- `check.rs` admits the `hello` bundle and round-trips its graph through
|
||||
the blob store, binds the launch, admits a version whose `workflow.toml`
|
||||
names `engine = "petri"`, reads the project settings from the map, and
|
||||
refuses an unknown attribute, an unknown `[workflow]` key
|
||||
(`unsupported.workflow_toml.key`, named in `workflow.toml`) and, with a
|
||||
model client over the test catalog, an unknown model
|
||||
(`attractor.model.unknown`). No plugin is needed.
|
||||
(`attractor.model.unknown`). No sandbox is acquired.
|
||||
- `sqlite_store.rs` runs Petri's store conformance suite
|
||||
(`petri_testkit::run_store::conformance`) against `SqliteRunStore`, plus the
|
||||
operator release, lease exclusivity, a crash between appends, and blob
|
||||
|
|
@ -209,8 +207,7 @@ Integration tests live under `tests/`:
|
|||
client over a vault that holds the key, and checks the skills step
|
||||
searched the configured Fabro home.
|
||||
|
||||
Those four need the host plugin like `runs.rs` does, and `model.rs` also
|
||||
starts the twin.
|
||||
Those four use the in-process Host provider; `model.rs` also starts the twin.
|
||||
|
||||
- `projection.rs` builds the view live (every append signals the
|
||||
projector) for the `hello` bundle on the stub registry, a command-only
|
||||
|
|
@ -220,7 +217,7 @@ starts the twin.
|
|||
recovers a crash between the record commit and the view transaction by
|
||||
applying only the missing suffix, with the positions and `stream_seq`
|
||||
continuing; runs two projectors over one store with child executions; and
|
||||
holds the view at a torn tail. All skip without the host plugin.
|
||||
holds the view at a torn tail. These run without a Host plugin.
|
||||
|
||||
The conformance suite over `HttpRunStore` needs a server to talk to, so it
|
||||
lives with the server's integration tests
|
||||
|
|
|
|||
|
|
@ -170,9 +170,15 @@ pub enum Conclusion {
|
|||
|
||||
/// Execute the run to its end and report what the record says.
|
||||
pub async fn run(request: RunRequest) -> Result<RunOutcome, RunError> {
|
||||
let backend = backend(&request.provider).ok_or_else(|| RunError::UnsupportedProvider {
|
||||
provider: request.provider.clone(),
|
||||
})?;
|
||||
let backend = if request.runtime.dry_run {
|
||||
// The dry-run admission pass puts every scope on the host. Keep the
|
||||
// real local workspace for Fabro's checkpoint and artifact hooks.
|
||||
SandboxBackend::Host
|
||||
} else {
|
||||
backend(&request.provider).ok_or_else(|| RunError::UnsupportedProvider {
|
||||
provider: request.provider.clone(),
|
||||
})?
|
||||
};
|
||||
let key = RunKey::new(request.run_id.as_str());
|
||||
let mut options = RunOptions::new(&request.run_dir);
|
||||
options.run_key = Some(key.clone());
|
||||
|
|
@ -196,7 +202,10 @@ pub async fn run(request: RunRequest) -> Result<RunOutcome, RunError> {
|
|||
if let Some(blobs) = &request.blobs {
|
||||
runtime = runtime.capability(RunBlobs::output_store(Arc::clone(blobs)));
|
||||
}
|
||||
let fabro_hooks = request.hooks.map(|spec| {
|
||||
let fabro_hooks = request.hooks.map(|mut spec| {
|
||||
if request.runtime.dry_run {
|
||||
spec.git.host_workspaces = true;
|
||||
}
|
||||
let inner = runtime
|
||||
.installed_hooks()
|
||||
.unwrap_or_else(|| Arc::new(NoHooks));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//! Forking a Fabro run at a checkpoint: the seam over Petri's
|
||||
//! `host::fork_from` that rewind, fork and retry are built on (the
|
||||
//! integration plan's F5.1).
|
||||
//! `host::fork_from` that rewind, fork and retry are built on.
|
||||
//!
|
||||
//! Fabro's checkpoint record ties a Petri position `(execution, firing)` to
|
||||
//! a Git commit. A fork seeds a new run from the source's records up to such
|
||||
|
|
@ -48,8 +47,8 @@ use petri_execution::{
|
|||
Access, CoordinatorEvent, ExecutionId, InvocationId, RunKey, RunStore,
|
||||
StoreError as CoordinatorStoreError,
|
||||
};
|
||||
use petri_runtime::RunOptions;
|
||||
use petri_runtime::ir::FiringId;
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_store::StoreError;
|
||||
use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
|
@ -59,6 +58,7 @@ use crate::checkpoint::{CheckpointKey, RunWorkspaces};
|
|||
use crate::platform_records::{PlatformRecordError, PlatformRecords};
|
||||
use crate::projection::FoldState;
|
||||
use crate::projector::ProjectError;
|
||||
use crate::providers::{self, SandboxProviderConfig};
|
||||
|
||||
/// One fork to seed.
|
||||
pub struct ForkRequest {
|
||||
|
|
@ -177,7 +177,9 @@ pub async fn fork(request: ForkRequest) -> Result<Forked, ForkError> {
|
|||
|
||||
let mut options = RunOptions::new(&request.fork_run_dir);
|
||||
options.run_key = Some(fork_key.clone());
|
||||
let runtime = Runtime::standard()
|
||||
// A fork only copies records and acquires no sandbox, so it needs no
|
||||
// provider configuration.
|
||||
let runtime = providers::standard_runtime(&SandboxProviderConfig::default())
|
||||
.options(options)
|
||||
.store(Arc::clone(&request.store));
|
||||
let forked = host::fork_from(&runtime, &*source_logs, request.position, ForkOptions {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
//! run's records are its source of truth in Fabro's tables;
|
||||
//! - [`runtime`]: the Petri runtime Fabro assembles, at create time and at
|
||||
//! execution;
|
||||
//! - [`providers`]: lazy in-process Host, Docker and Daytona factories, sharing
|
||||
//! explicit configuration with the server's sandbox access;
|
||||
//! - [`check`]: Petri compiles a workflow version's bundle at create time, and
|
||||
//! its diagnostics come back in a shape Fabro maps onto its own;
|
||||
//! - [`admission`]: the admitted graphs in Fabro's blob store, named on the run
|
||||
|
|
@ -58,8 +60,8 @@
|
|||
//! - [`prune`]: a run's sandboxes deleted through Petri's lease ledger, as
|
||||
//! `petri sandbox prune` deletes them, when Fabro deletes the run.
|
||||
//!
|
||||
//! The Petri packages are pinned by revision in the workspace `Cargo.toml`
|
||||
//! under `petri_*` keys.
|
||||
//! The Petri packages are workspace dependencies under `petri_*` keys; see the
|
||||
//! workspace `Cargo.toml` for how they are tracked.
|
||||
|
||||
pub mod admission;
|
||||
pub mod artifacts;
|
||||
|
|
@ -77,6 +79,7 @@ pub mod petri;
|
|||
pub mod platform_records;
|
||||
pub mod projection;
|
||||
pub mod projector;
|
||||
pub mod providers;
|
||||
pub mod prune;
|
||||
pub mod recovery;
|
||||
pub mod run_graph;
|
||||
|
|
|
|||
354
lib/components/fabro-petri/src/providers.rs
Normal file
354
lib/components/fabro-petri/src/providers.rs
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
//! Built-in sandbox providers shared by Petri execution and server access.
|
||||
//!
|
||||
//! Configuration is captured by the caller, with Daytona credentials from
|
||||
//! the vault. Factories connect lazily per run; a Host-only run needs neither
|
||||
//! Docker nor Daytona. Host registry ownership stays with Petri: server
|
||||
//! attach uses an observer instead of these factories.
|
||||
//!
|
||||
//! Every Petri runtime Fabro builds starts from [`standard_runtime`] or
|
||||
//! [`bare_runtime`], so none falls back to launching a provider plugin,
|
||||
//! which a release build cannot verify.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use fabro_static::EnvVars;
|
||||
use petri_runtime::{
|
||||
InProcessProviders, ProviderContext, ProviderFactory, ProviderNetwork, Runtime, fingerprint,
|
||||
};
|
||||
use sandbox_driver::{AuthError, Error, ProviderKind, SandboxProvider};
|
||||
use sandbox_driver_daytona::{DaytonaConfig, DaytonaProvider};
|
||||
use sandbox_driver_docker::DockerProvider;
|
||||
use sandbox_driver_host::HostProvider;
|
||||
|
||||
const USER_AGENT: &str = concat!("fabro-server/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
/// Explicit Daytona credentials: the SDK's configuration with the API key
|
||||
/// always present and a `Debug` that never prints it. The process
|
||||
/// environment is never consulted.
|
||||
#[derive(Clone)]
|
||||
pub struct DaytonaCredentials(DaytonaConfig);
|
||||
|
||||
impl DaytonaCredentials {
|
||||
/// Credentials for `api_key` against Daytona's public control plane,
|
||||
/// presenting Fabro's `User-Agent`.
|
||||
#[must_use]
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self(DaytonaConfig {
|
||||
api_key: Some(api_key),
|
||||
user_agent: Some(USER_AGENT.to_string()),
|
||||
..DaytonaConfig::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Credentials for a vault API key, with the control-plane URL and
|
||||
/// organization taken from `lookup` (server configuration). Nothing is
|
||||
/// read implicitly.
|
||||
///
|
||||
/// These are the two settings the Daytona plugin read in the worker, so
|
||||
/// a lease it recorded keeps its fingerprint: no URL alias and no
|
||||
/// placement target, neither of which reached the plugin.
|
||||
pub fn from_api_key(api_key: String, lookup: impl Fn(&str) -> Option<String>) -> Self {
|
||||
Self::new(api_key)
|
||||
.with_api_url(lookup(EnvVars::DAYTONA_API_URL))
|
||||
.with_organization_id(lookup(EnvVars::DAYTONA_ORGANIZATION_ID))
|
||||
}
|
||||
|
||||
/// The control-plane URL; Daytona's public API when `None`.
|
||||
#[must_use]
|
||||
pub fn with_api_url(mut self, api_url: Option<String>) -> Self {
|
||||
self.0.api_url = api_url;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_organization_id(mut self, organization_id: Option<String>) -> Self {
|
||||
self.0.organization_id = organization_id;
|
||||
self
|
||||
}
|
||||
|
||||
/// A shared HTTP client; tests pass a no-proxy client here.
|
||||
#[must_use]
|
||||
pub fn with_http_client(mut self, http_client: Option<fabro_http::HttpClient>) -> Self {
|
||||
self.0.http_client = http_client;
|
||||
self
|
||||
}
|
||||
|
||||
/// The SDK configuration the driver's Daytona provider connects with.
|
||||
#[must_use]
|
||||
fn config(&self) -> &DaytonaConfig {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DaytonaCredentials {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DaytonaCredentials")
|
||||
.field("api_url", &self.0.api_url)
|
||||
.field("organization_id", &self.0.organization_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// The provider configuration supplied to a run or prune. Defaults are
|
||||
/// local Docker selection and no Daytona credentials; constructing this
|
||||
/// configuration never connects to a backend or requires a credential.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SandboxProviderConfig {
|
||||
docker_host: Option<String>,
|
||||
docker_host_address: Option<String>,
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
}
|
||||
|
||||
impl SandboxProviderConfig {
|
||||
/// The configuration for `daytona`'s credentials, with how a remote
|
||||
/// Docker daemon's containers reach this machine from `lookup`.
|
||||
///
|
||||
/// The Docker endpoint is read from this process's `DOCKER_HOST`, never
|
||||
/// from `lookup`: the Docker client connects to the daemon that
|
||||
/// variable names, so the lease fingerprint and network name the daemon
|
||||
/// the sandboxes are actually created on.
|
||||
pub fn from_lookup(
|
||||
daytona: Option<DaytonaCredentials>,
|
||||
lookup: impl Fn(&str) -> Option<String>,
|
||||
) -> Self {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the Docker client reads DOCKER_HOST from this process; the fingerprint must name the same daemon"
|
||||
)]
|
||||
let docker_host = std::env::var(EnvVars::DOCKER_HOST).ok();
|
||||
Self {
|
||||
docker_host,
|
||||
docker_host_address: lookup(EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS)
|
||||
.filter(|value| !value.trim().is_empty()),
|
||||
daytona,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Petri's standard runtime with Fabro's built-in providers installed.
|
||||
#[must_use]
|
||||
pub fn standard_runtime(config: &SandboxProviderConfig) -> Runtime {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the one place a standard runtime is built, with the built-in providers installed"
|
||||
)]
|
||||
let runtime = Runtime::standard();
|
||||
runtime.in_process_providers(built_in_providers(config))
|
||||
}
|
||||
|
||||
/// Petri's bare runtime with Fabro's built-in providers installed.
|
||||
#[must_use]
|
||||
pub fn bare_runtime(config: &SandboxProviderConfig) -> Runtime {
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the one place a bare runtime is built, with the built-in providers installed"
|
||||
)]
|
||||
let runtime = Runtime::bare();
|
||||
runtime.in_process_providers(built_in_providers(config))
|
||||
}
|
||||
|
||||
/// The Docker connection used by both the server and Petri. The driver reads
|
||||
/// the caller process's Docker endpoint/TLS environment; health is checked
|
||||
/// by the caller so diagnostics can report an unavailable daemon.
|
||||
#[expect(
|
||||
clippy::result_large_err,
|
||||
reason = "preserve sandbox-driver's typed error, as required by Petri's ProviderFactory contract"
|
||||
)]
|
||||
pub fn connect_docker() -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
Ok(Arc::new(DockerProvider::connect_unverified()?))
|
||||
}
|
||||
|
||||
/// Connect using only the vault credentials and explicit control-plane
|
||||
/// configuration; no ambient secret fallback is permitted.
|
||||
pub async fn connect_daytona(
|
||||
credentials: &DaytonaCredentials,
|
||||
) -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
Ok(Arc::new(
|
||||
DaytonaProvider::connect_explicit(credentials.config().clone()).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// One lazy factory per built-in kind. Missing Daytona credentials fail
|
||||
/// only when a Daytona scope is acquired, never for admission or a Host run.
|
||||
fn built_in_providers(config: &SandboxProviderConfig) -> InProcessProviders {
|
||||
InProcessProviders::new()
|
||||
.with(Arc::new(HostFactory))
|
||||
.with(Arc::new(DockerFactory {
|
||||
host: config.docker_host.clone(),
|
||||
host_address: config.docker_host_address.clone(),
|
||||
}))
|
||||
.with(Arc::new(DaytonaFactory(config.daytona.clone())))
|
||||
}
|
||||
|
||||
struct HostFactory;
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderFactory for HostFactory {
|
||||
fn kind(&self) -> &'static str {
|
||||
"host"
|
||||
}
|
||||
|
||||
/// An empty registry path when Petri supplies none, as the plugin
|
||||
/// recorded it.
|
||||
fn fingerprint_seed(&self, context: &ProviderContext) -> String {
|
||||
fingerprint::host(context.host_registry().unwrap_or(Path::new("")))
|
||||
}
|
||||
|
||||
fn network(&self) -> ProviderNetwork {
|
||||
ProviderNetwork::host()
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
&self,
|
||||
context: &ProviderContext,
|
||||
) -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
let registry = context.host_registry().ok_or_else(|| {
|
||||
Error::invalid_spec(
|
||||
"host_registry",
|
||||
"Petri supplied no Host registry for this run",
|
||||
)
|
||||
})?;
|
||||
Ok(Arc::new(HostProvider::with_registry(registry).await?))
|
||||
}
|
||||
}
|
||||
|
||||
struct DockerFactory {
|
||||
host: Option<String>,
|
||||
host_address: Option<String>,
|
||||
}
|
||||
|
||||
impl DockerFactory {
|
||||
fn seed(&self) -> String {
|
||||
fingerprint::docker(self.host.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderFactory for DockerFactory {
|
||||
fn kind(&self) -> &'static str {
|
||||
"docker"
|
||||
}
|
||||
|
||||
fn fingerprint_seed(&self, _context: &ProviderContext) -> String {
|
||||
self.seed()
|
||||
}
|
||||
|
||||
fn network(&self) -> ProviderNetwork {
|
||||
ProviderNetwork::docker(self.host.as_deref(), self.host_address.as_deref())
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
&self,
|
||||
_context: &ProviderContext,
|
||||
) -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
connect_docker()
|
||||
}
|
||||
}
|
||||
|
||||
struct DaytonaFactory(Option<DaytonaCredentials>);
|
||||
|
||||
impl DaytonaFactory {
|
||||
fn seed(&self) -> String {
|
||||
let config = self.0.as_ref().map(DaytonaCredentials::config);
|
||||
fingerprint::daytona(
|
||||
config.and_then(|config| config.api_url.as_deref()),
|
||||
config.and_then(|config| config.organization_id.as_deref()),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderFactory for DaytonaFactory {
|
||||
fn kind(&self) -> &'static str {
|
||||
"daytona"
|
||||
}
|
||||
|
||||
fn fingerprint_seed(&self, _context: &ProviderContext) -> String {
|
||||
self.seed()
|
||||
}
|
||||
|
||||
fn network(&self) -> ProviderNetwork {
|
||||
ProviderNetwork::none()
|
||||
}
|
||||
|
||||
async fn connect(
|
||||
&self,
|
||||
_context: &ProviderContext,
|
||||
) -> sandbox_driver::Result<Arc<dyn SandboxProvider>> {
|
||||
let credentials = self.0.as_ref().ok_or_else(|| Error::Auth(AuthError::new(
|
||||
ProviderKind::try_new("daytona").expect("the built-in provider kind is valid"),
|
||||
"Daytona requires DAYTONA_API_KEY in the vault; run `fabro secret set DAYTONA_API_KEY`",
|
||||
)))?;
|
||||
connect_daytona(credentials).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn docker_fingerprint_keeps_the_plugin_namespace_for_unset_and_configured_hosts() {
|
||||
for (host, expected) in [
|
||||
(None, "docker:default"),
|
||||
(Some(" "), "docker:default"),
|
||||
(Some(" tcp://daemon:2376 "), "docker:tcp://daemon:2376"),
|
||||
(
|
||||
Some("unix:///var/run/docker.sock"),
|
||||
"docker:unix:///var/run/docker.sock",
|
||||
),
|
||||
] {
|
||||
let factory = DockerFactory {
|
||||
host: host.map(str::to_owned),
|
||||
host_address: None,
|
||||
};
|
||||
assert_eq!(factory.seed(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_fingerprint_keeps_the_plugin_seed() {
|
||||
let unset = DaytonaCredentials::from_api_key("test-key".to_string(), |_| None);
|
||||
assert_eq!(DaytonaFactory(Some(unset)).seed(), "daytona:::");
|
||||
let configured =
|
||||
DaytonaCredentials::from_api_key("test-key".to_string(), |name| match name {
|
||||
EnvVars::DAYTONA_API_URL => Some("https://daytona.example".to_string()),
|
||||
EnvVars::DAYTONA_SERVER_URL => Some("https://ignored.example".to_string()),
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID => Some("org-1".to_string()),
|
||||
EnvVars::DAYTONA_TARGET => Some("us".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
let factory = DaytonaFactory(Some(configured));
|
||||
assert_eq!(factory.seed(), "daytona:https://daytona.example:org-1:");
|
||||
assert_eq!(factory.region(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_configuration_ignores_the_url_alias_and_keeps_the_http_client() {
|
||||
let credentials = DaytonaCredentials::from_api_key("test-key".to_string(), |name| {
|
||||
(name == EnvVars::DAYTONA_SERVER_URL).then(|| "https://alias.example".to_string())
|
||||
})
|
||||
.with_http_client(Some(fabro_test::test_http_client()));
|
||||
assert_eq!(credentials.config().api_url, None);
|
||||
assert!(credentials.config().http_client.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_configuration_keeps_the_key_but_never_prints_it() {
|
||||
let key = "dtn_test_sensitive_value";
|
||||
let config = SandboxProviderConfig::from_lookup(
|
||||
Some(DaytonaCredentials::from_api_key(key.to_string(), |name| {
|
||||
(name == EnvVars::DAYTONA_ORGANIZATION_ID).then(|| "org-1".to_string())
|
||||
})),
|
||||
|_| None,
|
||||
);
|
||||
let daytona = config.daytona.as_ref().expect("the credentials are kept");
|
||||
assert_eq!(daytona.config().api_key.as_deref(), Some(key));
|
||||
let rendered = format!("{config:?}");
|
||||
assert!(!rendered.contains(key));
|
||||
assert!(rendered.contains("org-1"));
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
//! sandbox prune` deletes them, over the store the run's records live in,
|
||||
//! rather than through a provider call of Fabro's own: Petri opens the run
|
||||
//! for writing, so a live worker that still holds the lease refuses the
|
||||
//! delete; it checks each lease's fingerprint against the plugin it
|
||||
//! launches, so a changed daemon or account is a problem to report, never
|
||||
//! delete; it checks each lease's fingerprint against the provider it
|
||||
//! connects, so a changed daemon or account is a problem to report, never
|
||||
//! a delete on another backend; it writes the delete intent before the
|
||||
//! provider call and the tombstone after, beside the run's other records;
|
||||
//! and each provider removes its sandbox's managed workspace, a host
|
||||
|
|
@ -26,12 +26,15 @@ use fabro_types::SandboxProviderKind;
|
|||
pub use petri_execution::prune::PruneReport;
|
||||
use petri_execution::prune::{self as petri_prune};
|
||||
use petri_execution::{RunKey, RunStore};
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_runtime::RunOptions;
|
||||
|
||||
use crate::engine;
|
||||
use crate::providers::{self, SandboxProviderConfig};
|
||||
|
||||
/// One run whose sandboxes are to be deleted.
|
||||
pub struct PruneRequest {
|
||||
/// The provider configuration used by this server-side operation.
|
||||
pub sandbox: SandboxProviderConfig,
|
||||
/// The Fabro run id, which is Petri's run key.
|
||||
pub run_id: String,
|
||||
/// Where the run's worker ran Petri: its host registry and action-host
|
||||
|
|
@ -69,7 +72,9 @@ pub async fn prune(request: PruneRequest) -> Result<PruneReport, PruneError> {
|
|||
options.run_key = Some(RunKey::new(request.run_id.as_str()));
|
||||
options.retention = engine::RETENTION;
|
||||
options.sandbox.backend = backend;
|
||||
let runtime = Runtime::bare().store(request.store).options(options);
|
||||
let runtime = providers::bare_runtime(&request.sandbox)
|
||||
.store(request.store)
|
||||
.options(options);
|
||||
petri_prune::prune(&runtime)
|
||||
.await
|
||||
.map_err(|error| match error {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
//! The Petri runtime Fabro runs its workflows on, assembled the same way at
|
||||
//! create time (for `Runtime::check`) and at execution.
|
||||
//!
|
||||
//! Built-in sandbox factories are installed for every runtime and connect
|
||||
//! only when a scope is acquired.
|
||||
//!
|
||||
//! The pieces are Petri's own: [`Runtime::standard`] with the Fabro frontend
|
||||
//! carrying the server's settings layer, the Attractor step kinds (the real
|
||||
//! ones, or the simulated registry for a dry run), the model client as the
|
||||
|
|
@ -22,14 +25,19 @@ use lithos_llm::credentials::CredentialProvider;
|
|||
use petri_attractor_steps::pebble::PebbleClient;
|
||||
use petri_attractor_steps::skills::FabroHome;
|
||||
use petri_frontend_fabro::Fabro;
|
||||
use petri_runtime::Runtime;
|
||||
use petri_runtime::ir::{Graph, RuntimeTarget};
|
||||
use petri_runtime::steps::Capabilities;
|
||||
use petri_runtime::{AdmissionPass, AdmissionProblem, Runtime};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::host_tools;
|
||||
use crate::providers::{self, SandboxProviderConfig};
|
||||
|
||||
/// What every Petri runtime Fabro builds is configured with.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct RuntimeSpec {
|
||||
/// Explicit provider configuration. Factories connect only at acquire.
|
||||
pub sandbox: SandboxProviderConfig,
|
||||
/// The operator's settings layer, as `~/.fabro/settings.toml` text: the
|
||||
/// lowest of the three layers the Fabro frontend reads (`[run.model]`
|
||||
/// defaults, `[[run.hooks]]`, `[run.agent.mcps]`, `[run.environment]`
|
||||
|
|
@ -45,8 +53,8 @@ pub struct RuntimeSpec {
|
|||
/// catalog the admission pass resolves model selectors against. `None`
|
||||
/// leaves every LLM node unpinned and every model call unconfigured.
|
||||
pub model_client: Option<Client>,
|
||||
/// Run the simulated step registry (Fabro's `--dry-run` handlers)
|
||||
/// instead of the real one.
|
||||
/// Simulate steps (Fabro's `--dry-run` handlers) in local workspaces,
|
||||
/// without acquiring the configured Docker or Daytona sandboxes.
|
||||
pub dry_run: bool,
|
||||
/// The Fabro home the skills step reads; `None` leaves it to Petri's
|
||||
/// own lookup (`FABRO_HOME`, else `$HOME/.fabro`).
|
||||
|
|
@ -64,7 +72,7 @@ impl RuntimeSpec {
|
|||
/// registry: only execution swaps in the stubs.
|
||||
#[must_use]
|
||||
pub fn runtime(&self, for_execution: bool) -> Runtime {
|
||||
let mut runtime = Runtime::standard().frontend(
|
||||
let mut runtime = providers::standard_runtime(&self.sandbox).frontend(
|
||||
Fabro::new()
|
||||
.with_settings_toml(self.settings_toml.clone())
|
||||
.with_mcp_catalog_toml(self.mcp_catalog_toml.clone()),
|
||||
|
|
@ -83,6 +91,9 @@ impl RuntimeSpec {
|
|||
if let Some(services) = &self.run_tools {
|
||||
runtime = runtime.capability(host_tools::capability(services.clone()));
|
||||
}
|
||||
if self.dry_run {
|
||||
runtime = runtime.admission(LocalDryRun);
|
||||
}
|
||||
if for_execution && self.dry_run {
|
||||
petri_attractor_steps::register_stubs(runtime)
|
||||
} else {
|
||||
|
|
@ -91,6 +102,22 @@ impl RuntimeSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/// Dry runs keep real local workspaces for checkpoint hooks, while simulated
|
||||
/// stages need neither container images nor sidecars. Do this during admission
|
||||
/// so Petri persists the effective scopes and re-digests nested graphs itself.
|
||||
struct LocalDryRun;
|
||||
|
||||
impl AdmissionPass for LocalDryRun {
|
||||
fn admit(&self, graph: &mut Graph, _caps: &Capabilities) -> Vec<AdmissionProblem> {
|
||||
for scope in &mut graph.body.scopes {
|
||||
scope.runtime.target = RuntimeTarget::HostProcess;
|
||||
scope.runtime.requirements.clear();
|
||||
scope.services.clear();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The model client Fabro hands Petri: the server's catalog, its credential
|
||||
/// provider, its HTTP client (so a test's loopback client and a server's
|
||||
/// proxy policy carry over), and only the providers whose credentials are
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
//! A large stage value leaves the run's records for Fabro's blob table
|
||||
//! under `blob://sha256/<hex>`, and comes back from the same table.
|
||||
//!
|
||||
//! The run takes its host scope through the sandbox-driver host plugin, so
|
||||
//! the test skips, and says why, when the executable is not found, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
mod support;
|
||||
|
||||
|
|
@ -17,7 +15,7 @@ use fabro_petri::runtime::RuntimeSpec;
|
|||
use fabro_store::{BlobStore, test_support};
|
||||
use fabro_types::BlobHash;
|
||||
use petri_attractor_steps::blobs::{BLOB_REF_PREFIX, OFFLOAD_THRESHOLD, parse_blob_ref};
|
||||
use support::{SETTINGS, Silent, admit, all_records, host_plugin, no_questions, run_request};
|
||||
use support::{SETTINGS, Silent, admit, all_records, no_questions, run_request};
|
||||
|
||||
/// One line of the command's output.
|
||||
const LINE: &str = "xxxxxxxx";
|
||||
|
|
@ -38,9 +36,6 @@ fn workflow(lines: usize) -> String {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_large_output_round_trips_through_the_blob_table() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let pool = test_support::in_memory_pool_with(&[
|
||||
fabro_db::BLOBS_MIGRATION_SQL,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ use fabro_petri::check::{self, Bundle, CheckError, CheckRequest, DiagnosticSever
|
|||
use fabro_petri::runtime::{self, RuntimeSpec};
|
||||
use fabro_store::{BlobStore, test_support};
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use petri_runtime::ir::RuntimeTarget;
|
||||
|
||||
const COMMAND_WORKFLOW: &str = r#"digraph Command {
|
||||
graph [goal="Run one command"]
|
||||
|
|
@ -420,3 +421,59 @@ async fn a_launch_goal_replaces_the_graphs_goal_on_the_admitted_graph_and_its_st
|
|||
// Without a launch goal, the bundle's `[run] goal` stands over the graph's.
|
||||
assert_eq!(goal_of(Launch::default()), "The bundle's goal");
|
||||
}
|
||||
|
||||
/// Dry-run admission must cover child workflows too, and its rewritten
|
||||
/// graphs must survive the same digest-checked persistence as any other run.
|
||||
#[tokio::test]
|
||||
async fn dry_run_admission_makes_nested_scopes_local_and_preserves_graph_digests() {
|
||||
let root = r#"digraph Root {
|
||||
start [shape=Mdiamond]
|
||||
exit [shape=Msquare]
|
||||
child [shape=house, stack.child_workflow="child/workflow.fabro"]
|
||||
start -> child -> exit
|
||||
}"#;
|
||||
let settings = format!(
|
||||
"{SETTINGS}\n[run.environment]\nid = \"docker\"\n\n[environments.docker]\nprovider = \"docker\"\n[environments.docker.image]\ndocker = \"invalid.example/dry-run:must-not-pull\"\n"
|
||||
);
|
||||
let files = [
|
||||
("workflow.fabro", root),
|
||||
("workflow.toml", settings.as_str()),
|
||||
("child/workflow.fabro", COMMAND_WORKFLOW),
|
||||
("child/workflow.toml", settings.as_str()),
|
||||
];
|
||||
let real = check::check(&request(bundle(&files), RuntimeSpec::default()))
|
||||
.expect("the real workflow admits");
|
||||
assert!(!real.children.is_empty());
|
||||
assert!(
|
||||
real.graph
|
||||
.body
|
||||
.scopes
|
||||
.iter()
|
||||
.any(|scope| { matches!(scope.runtime.target, RuntimeTarget::Container { .. }) })
|
||||
);
|
||||
let dry_run = check::check(&request(bundle(&files), RuntimeSpec {
|
||||
dry_run: true,
|
||||
..RuntimeSpec::default()
|
||||
}))
|
||||
.expect("the dry run admits");
|
||||
assert_eq!(dry_run.children.len(), real.children.len());
|
||||
for graph in std::iter::once(&dry_run.graph).chain(&dry_run.children) {
|
||||
assert!(!graph.body.scopes.is_empty());
|
||||
for scope in &graph.body.scopes {
|
||||
assert_eq!(scope.runtime.target, RuntimeTarget::HostProcess);
|
||||
assert!(scope.services.is_empty());
|
||||
assert!(scope.runtime.requirements.is_empty());
|
||||
}
|
||||
}
|
||||
let blobs = BlobStore::new(test_support::in_memory_pool_with(&[
|
||||
fabro_db::BLOBS_MIGRATION_SQL,
|
||||
]));
|
||||
let record = admission::persist(&blobs, &dry_run)
|
||||
.await
|
||||
.expect("the graphs persist");
|
||||
let loaded = admission::load(&blobs, &record)
|
||||
.await
|
||||
.expect("the graph digests match");
|
||||
assert_eq!(loaded.graph, dry_run.graph);
|
||||
assert_eq!(loaded.children, dry_run.children);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,16 +4,13 @@
|
|||
//! failure route, the fatal checkpoint, and the run-end hooks are checked
|
||||
//! against the workspace's Git history and the run's records.
|
||||
//!
|
||||
//! Every run acquires its scope through the sandbox-driver host plugin, so
|
||||
//! the tests skip when that executable is not found, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. The crash cases of the recovery
|
||||
//! protocol need a worker to kill and live in the CLI's scenario suite.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
//! The crash cases need a worker to kill and live in the CLI's scenario suite.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment and read the workspace's history with git"
|
||||
reason = "the tests inspect backend availability and read the workspace's history with git"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
|
|
@ -32,6 +29,7 @@ use fabro_petri::controls::RunControls;
|
|||
use fabro_petri::engine::{self, Execution, RunRequest, RunStatus};
|
||||
use fabro_petri::hooks::HooksSpec;
|
||||
use fabro_petri::platform_records::PlatformRecords;
|
||||
use fabro_petri::providers::{DaytonaCredentials, SandboxProviderConfig};
|
||||
use fabro_petri::recovery::{self, Recovery, RecoveryRequest};
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
use fabro_petri::test_support::{MemoryBlobs, MemoryPlatformRecords};
|
||||
|
|
@ -47,33 +45,6 @@ use tokio_util::sync::CancellationToken;
|
|||
|
||||
mod support;
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const DOCKER_PLUGIN: &str = "sandbox-driver-docker";
|
||||
const DOCKER_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_DOCKER_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A command-only bundle: the stage lines go between `start` and `exit`,
|
||||
/// the edge lines after them.
|
||||
fn workflow(stages: &str, edges: &str) -> String {
|
||||
|
|
@ -109,39 +80,6 @@ fn admit(workflow: &str, settings: &str) -> AdmittedGraphs {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Docker plugin as Petri's lookup finds it, with a daemon that
|
||||
/// answers. `None`, after saying so, when the test should skip; a panic
|
||||
/// when the environment forbids a skip and the plugin is missing.
|
||||
fn docker_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(DOCKER_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(DOCKER_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
let Some(found) = found else {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} \
|
||||
is unset"
|
||||
);
|
||||
eprintln!("skipping: {DOCKER_PLUGIN} is not on PATH and {DOCKER_PLUGIN_OVERRIDE} is unset");
|
||||
return None;
|
||||
};
|
||||
let daemon = std::process::Command::new("docker")
|
||||
.args(["version", "--format", "{{.Server.Version}}"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success());
|
||||
if !daemon {
|
||||
eprintln!("skipping: no Docker daemon answers");
|
||||
return None;
|
||||
}
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// One run's pieces: the store, its platform records, where it ran.
|
||||
struct Harness {
|
||||
run_id: RunId,
|
||||
|
|
@ -208,12 +146,22 @@ impl Harness {
|
|||
) -> engine::RunOutcome {
|
||||
let (interviewer, observers) = no_questions();
|
||||
let hooks = self.hooks(&provider);
|
||||
let daytona = (provider == SandboxProviderKind::DAYTONA).then(|| {
|
||||
DaytonaCredentials::from_api_key(
|
||||
env::var("DAYTONA_API_KEY").expect("live Daytona credentials"),
|
||||
|name| env::var(name).ok(),
|
||||
)
|
||||
});
|
||||
let sandbox = SandboxProviderConfig::from_lookup(daytona, |name| env::var(name).ok());
|
||||
let request = RunRequest {
|
||||
run_id: self.run_id.to_string(),
|
||||
run_dir: self.run_dir.clone(),
|
||||
execution: Execution::Start(admit(workflow, settings)),
|
||||
store: Arc::clone(&self.store) as Arc<dyn petri_store::RunStore>,
|
||||
runtime: RuntimeSpec::default(),
|
||||
runtime: RuntimeSpec {
|
||||
sandbox,
|
||||
..RuntimeSpec::default()
|
||||
},
|
||||
provider,
|
||||
cancel: CancellationToken::new(),
|
||||
controls: RunControls::new(),
|
||||
|
|
@ -368,14 +316,67 @@ fn stages(inspection: &RunInspection) -> Vec<(String, String)> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// A dry run keeps checkpointable local workspaces even when the selected
|
||||
/// environment would require Docker or Daytona. Its command is simulated.
|
||||
#[tokio::test]
|
||||
async fn dry_runs_use_local_workspaces_and_checkpoint_without_sandbox_credentials() {
|
||||
for provider in [SandboxProviderKind::DOCKER, SandboxProviderKind::DAYTONA] {
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
r#" write [shape=parallelogram, script="touch should-not-exist; exit 1"]"#,
|
||||
" start -> write -> exit",
|
||||
);
|
||||
let settings = format!(
|
||||
"{SETTINGS}\n[run.environment]\nid = \"remote\"\n\n[environments.remote]\nprovider = \"{provider}\"\n\n[environments.remote.image]\ndocker = \"invalid.example/dry-run:must-not-pull\"\n"
|
||||
);
|
||||
let runtime = RuntimeSpec {
|
||||
dry_run: true,
|
||||
..RuntimeSpec::default()
|
||||
};
|
||||
let graphs = support::admit(
|
||||
&[("workflow.fabro", &workflow), ("workflow.toml", &settings)],
|
||||
Launch::default(),
|
||||
&runtime,
|
||||
);
|
||||
let mut request = support::run_request(
|
||||
&harness.run_id.to_string(),
|
||||
&harness.run_dir,
|
||||
graphs,
|
||||
harness.store.clone(),
|
||||
runtime,
|
||||
support::no_questions(Arc::new(support::Silent)),
|
||||
);
|
||||
request.hooks = Some(harness.hooks(&provider));
|
||||
request.provider = provider.clone();
|
||||
request.blobs = Some(harness.blobs.clone());
|
||||
let outcome = engine::run(request).await.expect("the dry run executes");
|
||||
assert_eq!(
|
||||
outcome.status,
|
||||
RunStatus::Success,
|
||||
"{provider}: {outcome:?}"
|
||||
);
|
||||
assert!(outcome.complete, "{provider}: {:?}", outcome.incomplete);
|
||||
let workspace = harness.workspace().await;
|
||||
assert!(
|
||||
!harness
|
||||
.workspace_path(&workspace)
|
||||
.join("should-not-exist")
|
||||
.exists()
|
||||
);
|
||||
assert_eq!(
|
||||
harness.checkpoints().len(),
|
||||
3,
|
||||
"{provider}: every stage checkpoints"
|
||||
);
|
||||
assert_eq!(harness.snapshot_commits(&workspace).await.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every finished stage is committed on the run branch with the identity
|
||||
/// trailers, its platform record names the commit, and the run-end hooks
|
||||
/// reached Petri's local service through Fabro's wrapper.
|
||||
#[tokio::test]
|
||||
async fn every_finish_is_committed_and_recorded() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
" write [shape=parallelogram, script=\"echo one > out.txt\"]\n check \
|
||||
|
|
@ -450,9 +451,6 @@ async fn every_finish_is_committed_and_recorded() {
|
|||
/// the end.
|
||||
#[tokio::test]
|
||||
async fn artifacts_the_branch_and_the_diffs_are_recorded() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let mut harness = Harness::new();
|
||||
harness.artifacts = vec!["assets/**".to_string()];
|
||||
let workflow = workflow(
|
||||
|
|
@ -633,9 +631,6 @@ async fn checkpoint_nodes(harness: &Harness) -> Vec<(String, u64)> {
|
|||
/// and its failure route runs on the committed files.
|
||||
#[tokio::test]
|
||||
async fn a_failed_stage_is_committed_and_its_route_sees_the_files() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
" work [shape=parallelogram, script=\"echo partial > out.txt; exit 1\"]\n fix \
|
||||
|
|
@ -676,9 +671,6 @@ async fn a_failed_stage_is_committed_and_its_route_sees_the_files() {
|
|||
/// checkpoint's error, and a restart reports it failed without resuming.
|
||||
#[tokio::test]
|
||||
async fn a_failed_checkpoint_ends_the_run_with_no_route() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
" wreck [shape=parallelogram, script=\"rm -rf .git && echo garbage > .git && echo wrecked \
|
||||
|
|
@ -742,9 +734,6 @@ async fn a_failed_checkpoint_ends_the_run_with_no_route() {
|
|||
/// starts over, and a run that finished has nothing to bring back.
|
||||
#[tokio::test]
|
||||
async fn recovery_starts_an_unknown_run_and_resumes_a_finished_one() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
assert_eq!(harness.recover().await, Recovery::Start);
|
||||
|
||||
|
|
@ -782,9 +771,6 @@ async fn a_run_hook_blocks_a_tool_effect_through_the_forwarded_service() {
|
|||
use serde_json::json;
|
||||
|
||||
const MODEL: &str = "gpt-5.6-sol";
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let twin = fabro_test::twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
TwinScenarios::new(namespace.clone())
|
||||
|
|
@ -891,9 +877,6 @@ async fn the_records_name_the_root_invocations_workspace() {
|
|||
use fabro_petri::workspace::WorkspaceLookup;
|
||||
use petri_execution::InvocationId;
|
||||
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
" write [shape=parallelogram, script=\"echo one > out.txt\"]",
|
||||
|
|
@ -925,9 +908,6 @@ async fn the_records_name_the_root_invocations_workspace() {
|
|||
/// problem.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn parallel_branches_checkpoint_the_shared_workspace_in_turn() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let harness = Harness::new();
|
||||
let workflow = workflow(
|
||||
" fork [shape=component]\n a [shape=parallelogram, script=\"echo a > a.txt\"]\n b \
|
||||
|
|
@ -981,7 +961,7 @@ async fn parallel_branches_checkpoint_the_shared_workspace_in_turn() {
|
|||
/// its ref, with the platform records naming the same commits.
|
||||
#[tokio::test]
|
||||
async fn a_docker_run_commits_inside_the_container_and_publishes_every_checkpoint() {
|
||||
if docker_plugin().is_none() {
|
||||
if !fabro_test::docker_available() {
|
||||
return;
|
||||
}
|
||||
assert_sandbox_run_publishes_every_checkpoint(SandboxProviderKind::DOCKER).await;
|
||||
|
|
@ -989,8 +969,8 @@ async fn a_docker_run_commits_inside_the_container_and_publishes_every_checkpoin
|
|||
|
||||
/// The same protocol on Daytona: the sandbox-driver facets are provider
|
||||
/// neutral, so the commit, the bundle and the restore take one path. Live:
|
||||
/// it needs `DAYTONA_API_KEY` and the Daytona plugin, and provisions a
|
||||
/// sandbox.
|
||||
/// it needs `DAYTONA_API_KEY`, passed explicitly to the provider, and
|
||||
/// provisions a sandbox.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires live Daytona credentials and provisions a sandbox"]
|
||||
async fn a_daytona_run_commits_inside_the_sandbox_and_publishes_every_checkpoint() {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,14 @@
|
|||
//! Fabro's run tools on a Petri run from this crate (integration plan item
|
||||
//! F3.4): `RuntimeSpec::run_tools` installs the adapter as Petri's host
|
||||
//! tool capability, a workflow with one agent stage runs on the real step
|
||||
//! registry against a scripted model, and the stage's session gets the
|
||||
//! tools the legacy worker registers, bound to the run: the model is
|
||||
//! advertised every run tool, its `fabro_run_create` call reaches Fabro's
|
||||
//! API with the Petri run as the child's parent, the API's answer comes
|
||||
//! back to the model, and the call is in the run's record under the stage.
|
||||
//! Fabro's run tools on a Petri run from this crate: `RuntimeSpec::run_tools`
|
||||
//! installs the adapter as Petri's host tool capability, a workflow with one
|
||||
//! agent stage runs on the real step registry against a scripted model, and the
|
||||
//! stage's session gets the tools the legacy worker registers, bound to the
|
||||
//! run: the model is advertised every run tool, its `fabro_run_create` call
|
||||
//! reaches Fabro's API with the Petri run as the child's parent, the API's
|
||||
//! answer comes back to the model, and the call is in the run's record under
|
||||
//! the stage.
|
||||
//!
|
||||
//! Every run takes its scope through the sandbox-driver host plugin, so
|
||||
//! the tests skip when that executable is not found, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
|
@ -48,10 +39,6 @@ use petri_store::{Access, MemoryRunStore, RunKey, RunStore};
|
|||
use serde_json::json;
|
||||
use tokio::fs;
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
/// One agent stage on the native backend, pinned to the scripted model.
|
||||
const AGENT_WORKFLOW: &str = r#"digraph Agent {
|
||||
graph [goal="Start a child run", backend="api", default_max_retries=0]
|
||||
|
|
@ -63,27 +50,6 @@ const AGENT_WORKFLOW: &str = r#"digraph Agent {
|
|||
|
||||
const AGENT_SETTINGS: &str = "_version = 1\n\n[workflow]\ngraph = \"workflow.fabro\"\n";
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Write the agent bundle into `<root>/.fabro/workflows/agent`; the
|
||||
/// workflow file.
|
||||
async fn install_bundle(root: &Path) -> PathBuf {
|
||||
|
|
@ -187,9 +153,6 @@ fn advertised(request: &Request) -> Vec<(String, String)> {
|
|||
/// answer reaches the model; the call is in the record under the stage.
|
||||
#[tokio::test]
|
||||
async fn a_petri_stage_calls_a_run_tool_bound_to_the_run() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let workflow = install_bundle(root.path()).await;
|
||||
let run_id = RunId::new();
|
||||
|
|
@ -271,9 +234,6 @@ async fn a_petri_stage_calls_a_run_tool_bound_to_the_run() {
|
|||
/// one tool the model called marked invoked.
|
||||
#[tokio::test]
|
||||
async fn the_projection_lists_the_stages_tools_and_marks_the_one_called() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let workflow = install_bundle(root.path()).await;
|
||||
let run_id = RunId::new();
|
||||
|
|
@ -375,9 +335,6 @@ async fn the_projection_lists_the_stages_tools_and_marks_the_one_called() {
|
|||
/// rather than parenting a child run to the wrong run.
|
||||
#[tokio::test]
|
||||
async fn services_for_another_run_give_the_stage_no_run_tools() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let workflow = install_bundle(root.path()).await;
|
||||
let run_id = RunId::new();
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@
|
|||
//! timeout with the gate's default, an auto-approved run answers itself,
|
||||
//! and a cancelled run interrupts its question.
|
||||
//!
|
||||
//! Every run takes its host scope through the sandbox-driver host plugin,
|
||||
//! so the tests skip, and say why, when the executable is not found,
|
||||
//! unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
mod support;
|
||||
|
||||
|
|
@ -25,7 +23,7 @@ use fabro_petri::runtime::RuntimeSpec;
|
|||
use fabro_types::{Principal, QuestionType, SystemActorKind};
|
||||
use petri_execution::{Delivery, InterviewReceipt, RECEIPT_FILE, ReplyRecord};
|
||||
use petri_store::MemoryRunStore;
|
||||
use support::{SETTINGS, admit, all_records, host_plugin, run_request, wait_until};
|
||||
use support::{SETTINGS, admit, all_records, run_request, wait_until};
|
||||
use tokio::fs;
|
||||
|
||||
/// A board of every notice the adapter posted.
|
||||
|
|
@ -175,9 +173,6 @@ impl Gate {
|
|||
/// submitted under the posted id, as the API delivers it, routes the gate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_gate_answered_under_the_posted_id_routes_on_the_answer() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Gate::new();
|
||||
let workflow = one_gate(&gate.markers, "");
|
||||
let runtime = RuntimeSpec::default();
|
||||
|
|
@ -263,9 +258,6 @@ async fn a_gate_answered_under_the_posted_id_routes_on_the_answer() {
|
|||
/// the other order, lands on its own branch.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn two_parallel_gates_each_bind_their_own_answer() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Gate::new();
|
||||
let workflow = two_gates(&gate.markers);
|
||||
let runtime = RuntimeSpec::default();
|
||||
|
|
@ -334,9 +326,6 @@ async fn two_parallel_gates_each_bind_their_own_answer() {
|
|||
/// the default's branch runs.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_unanswered_question_expires_with_the_gates_default() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Gate::new();
|
||||
let workflow = one_gate(
|
||||
&gate.markers,
|
||||
|
|
@ -384,9 +373,6 @@ async fn an_unanswered_question_expires_with_the_gates_default() {
|
|||
/// engine, and still posts the question and its answer.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_auto_approved_run_answers_yes_at_once() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Gate::new();
|
||||
let workflow = one_gate(&gate.markers, "");
|
||||
let runtime = RuntimeSpec::default();
|
||||
|
|
@ -427,9 +413,6 @@ async fn an_auto_approved_run_answers_yes_at_once() {
|
|||
/// question is interrupted, the gate fails closed and the run is cancelled.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_cancelled_run_interrupts_its_pending_question() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Gate::new();
|
||||
let workflow = one_gate(&gate.markers, "");
|
||||
let runtime = RuntimeSpec::default();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
//! The `hello` bundle's agent stage calls the OpenAI twin through a model
|
||||
//! client built over a vault that holds the key; the twin requires a
|
||||
//! bearer token and logs requests under it, so a request logged under the
|
||||
//! vault's key proves the key came from the vault. The run takes its host
|
||||
//! scope through the sandbox-driver host plugin, so the test skips, and
|
||||
//! says why, when the executable is not found, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! vault's key proves the key came from the vault. The Host scope runs in
|
||||
//! process.
|
||||
|
||||
mod support;
|
||||
|
||||
|
|
@ -24,7 +22,7 @@ use fabro_types::SecretType;
|
|||
use fabro_vault::Vault;
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use petri_store::MemoryRunStore;
|
||||
use support::{Silent, all_records, hello_bundle, host_plugin, no_questions, run_request};
|
||||
use support::{Silent, all_records, hello_bundle, no_questions, run_request};
|
||||
use tokio::fs;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
|
||||
|
|
@ -32,9 +30,6 @@ const OPENAI_MODEL: &str = "gpt-5.4";
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_model_call_authenticates_through_the_vault_and_skills_read_the_home() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let twin = twin_openai().await;
|
||||
let namespace = format!("{}::{}", module_path!(), line!());
|
||||
TwinScenarios::new(&namespace)
|
||||
|
|
|
|||
|
|
@ -7,20 +7,11 @@
|
|||
//! a live run costs its new records, with the cache that makes it so
|
||||
//! dropped at a restart, after the idle period and at the run's finish.
|
||||
//!
|
||||
//! Every run here takes its scope's environment through the sandbox-driver
|
||||
//! host plugin, so the tests skip, and say why, when the executable is not
|
||||
//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -32,8 +23,9 @@ use fabro_petri::check::Launch;
|
|||
use fabro_petri::engine::{self, RunStatus as EngineRunStatus};
|
||||
use fabro_petri::interview::{Approval, FabroInterviewer};
|
||||
use fabro_petri::projector::{self, Projector};
|
||||
use fabro_petri::providers::SandboxProviderConfig;
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
use fabro_petri::{SqliteRunStore, test_support as petri_support};
|
||||
use fabro_petri::{SqliteRunStore, providers, test_support as petri_support};
|
||||
use fabro_store::platform_records::{
|
||||
PlatformRecord, PlatformRecordStore, RunCreatedRecord, RunLifecycleKind, RunLifecycleRecord,
|
||||
};
|
||||
|
|
@ -44,18 +36,14 @@ use fabro_types::{
|
|||
};
|
||||
use petri_execution::host::{self, HostRun};
|
||||
use petri_frontend_fabro::Fabro;
|
||||
use petri_runtime::RunOptions;
|
||||
use petri_runtime::executor::Retention;
|
||||
use petri_runtime::frontend::CompileInputs;
|
||||
use petri_runtime::ir::RunStatus as PetriRunStatus;
|
||||
use petri_runtime::{RunOptions, Runtime};
|
||||
use petri_store::{RunKey, RunStore};
|
||||
use tokio::fs;
|
||||
use tokio::time::sleep;
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
const COMMAND_WORKFLOW: &str = r#"digraph Command {
|
||||
graph [goal="Run one command"]
|
||||
start [shape=Mdiamond]
|
||||
|
|
@ -127,24 +115,6 @@ fn described_gate_workflow(markers: &Path) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// A fresh in-memory database with every table the projection touches.
|
||||
fn pool() -> DbPool {
|
||||
test_support::in_memory_pool_with(&[
|
||||
|
|
@ -236,7 +206,8 @@ async fn run_workflow(
|
|||
workflow: &Path,
|
||||
stubs: bool,
|
||||
) {
|
||||
let runtime = Runtime::standard().frontend(Fabro::new());
|
||||
let runtime =
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new());
|
||||
let runtime = if stubs {
|
||||
petri_attractor_steps::register_stubs(runtime)
|
||||
} else {
|
||||
|
|
@ -466,9 +437,6 @@ async fn stage_states(pool: &DbPool, run_id: RunId) -> Vec<(String, StageState)>
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_hello_bundle_projects_live_as_it_rebuilds() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = hello_scenario().await;
|
||||
run_live(&scenario).await;
|
||||
assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -495,9 +463,6 @@ async fn the_hello_bundle_projects_live_as_it_rebuilds() {
|
|||
/// reference, never as the bytes the live log accumulated.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_large_output_projects_as_its_blob_reference() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = large_output_scenario().await;
|
||||
run_live(&scenario).await;
|
||||
assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -519,9 +484,6 @@ async fn a_large_output_projects_as_its_blob_reference() {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_command_workflow_projects_live_as_it_rebuilds() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = command_scenario().await;
|
||||
run_live(&scenario).await;
|
||||
assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -548,9 +510,6 @@ async fn a_command_workflow_projects_live_as_it_rebuilds() {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_parallel_workflow_projects_its_branches_as_child_executions() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_live(&scenario).await;
|
||||
assert_view_equals_rebuild(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -587,9 +546,6 @@ async fn a_parallel_workflow_projects_its_branches_as_child_executions() {
|
|||
/// folds everything.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dropped_wake_ups_are_caught_up_by_the_next_signal() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = command_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
assert!(
|
||||
|
|
@ -614,9 +570,6 @@ async fn dropped_wake_ups_are_caught_up_by_the_next_signal() {
|
|||
/// The same, through the startup pass.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_startup_pass_catches_up_a_view_nobody_signalled() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = command_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone());
|
||||
|
|
@ -638,9 +591,6 @@ async fn the_startup_pass_catches_up_a_view_nobody_signalled() {
|
|||
/// a duplicate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn concurrent_passes_over_one_run_commit_one_contiguous_stream() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone());
|
||||
|
|
@ -745,9 +695,6 @@ async fn copy_run_without_records(source: &DbPool, run_id: RunId) -> DbPool {
|
|||
/// sequence continuing from where the committed view stood.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_crash_between_the_record_commit_and_the_view_applies_only_the_suffix() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let rows = petri_rows(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -863,9 +810,6 @@ async fn a_crash_between_the_record_commit_and_the_view_applies_only_the_suffix(
|
|||
/// agree with a projector that saw the run whole.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_restarted_projector_agrees_over_nested_child_executions() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let rows = petri_rows(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -928,9 +872,6 @@ async fn a_restarted_projector_agrees_over_nested_child_executions() {
|
|||
/// reported incomplete with the reason.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_torn_tail_holds_the_view_and_reports_the_run_incomplete() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = command_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let projector = Projector::new(scenario.pool.clone(), scenario.pool.clone());
|
||||
|
|
@ -1035,9 +976,6 @@ async fn committed_pass(projector: &Projector, run_id: RunId) -> projector::Pass
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_pass_over_a_live_run_costs_its_new_records_not_the_run() {
|
||||
const BATCH: usize = 7;
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let rows = petri_rows(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -1088,9 +1026,6 @@ async fn a_pass_over_a_live_run_costs_its_new_records_not_the_run() {
|
|||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_restart_and_the_idle_period_drop_the_cache_and_one_full_replay_rebuilds_it() {
|
||||
const BATCH: usize = 5;
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let scenario = parallel_scenario().await;
|
||||
run_unobserved(&scenario).await;
|
||||
let rows = petri_rows(&scenario.pool, scenario.run_id).await;
|
||||
|
|
@ -1272,9 +1207,6 @@ impl GateRun {
|
|||
/// rebuilds the same.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_expired_question_is_pending_while_the_gate_waits_and_closes_on_the_expiry() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Arc::new(gate_run(r#", timeout="1500ms", human.default_choice="no""#).await);
|
||||
let running = {
|
||||
let gate = Arc::clone(&gate);
|
||||
|
|
@ -1354,9 +1286,6 @@ async fn an_expired_question_is_pending_while_the_gate_waits_and_closes_on_the_e
|
|||
/// on a choice that has none.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_pending_question_carries_its_choice_descriptions_previews_and_context() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = Arc::new(gate_run_of(described_gate_workflow).await);
|
||||
let running = {
|
||||
let gate = Arc::clone(&gate);
|
||||
|
|
@ -1401,9 +1330,6 @@ async fn a_pending_question_carries_its_choice_descriptions_previews_and_context
|
|||
/// the view rebuilds the same.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn an_auto_approved_answer_closes_the_question_in_the_projection() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let gate = gate_run("").await;
|
||||
gate.run(Approval::Auto).await;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
//! tombstoned in the run's record, a second prune has nothing to do, and a
|
||||
//! run a live handle holds is refused.
|
||||
//!
|
||||
//! The run takes its scope through the sandbox-driver host plugin, so the
|
||||
//! test skips, and says why, when the executable is not found.
|
||||
//! The Host scope runs in process without a plugin executable.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
|
|
@ -18,12 +17,13 @@ use std::sync::Arc;
|
|||
|
||||
use fabro_petri::check::Launch;
|
||||
use fabro_petri::engine::{self, RunStatus};
|
||||
use fabro_petri::providers::SandboxProviderConfig;
|
||||
use fabro_petri::prune::{PruneError, PruneRequest, prune};
|
||||
use fabro_petri::runtime::RuntimeSpec;
|
||||
use fabro_petri::{SqliteRunStore, petri};
|
||||
use fabro_store::test_support;
|
||||
use fabro_types::SandboxProviderKind;
|
||||
use support::{Silent, admit, host_plugin, no_questions, run_request};
|
||||
use support::{Silent, admit, no_questions, run_request};
|
||||
|
||||
/// A command-only workflow whose one stage writes a file into its
|
||||
/// workspace.
|
||||
|
|
@ -96,9 +96,6 @@ async fn lease_states(store: &SqliteRunStore, run_id: &str) -> Vec<String> {
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refused() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let run_dir = root.path().join("run");
|
||||
let pool = test_support::in_memory_pool_with(&[
|
||||
|
|
@ -137,7 +134,37 @@ async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refuse
|
|||
);
|
||||
assert_eq!(lease_states(&store, "prune").await, ["stopped"]);
|
||||
|
||||
let logs = petri::RunStore::open(
|
||||
store.as_ref(),
|
||||
&petri::RunKey::new("prune"),
|
||||
petri::Access::Read,
|
||||
)
|
||||
.await
|
||||
.expect("the resources open");
|
||||
let resources = logs
|
||||
.read(&petri::LogId::Resources)
|
||||
.await
|
||||
.expect("the resources read");
|
||||
let legacy_fingerprint = format!(
|
||||
"host:{}",
|
||||
run_dir
|
||||
.join("host-registry")
|
||||
.canonicalize()
|
||||
.expect("canonical registry")
|
||||
.display()
|
||||
);
|
||||
let allocating = resources
|
||||
.iter()
|
||||
.find(|record| {
|
||||
record.record["body"]["state"] == "allocating"
|
||||
&& record.record["body"]["fingerprint"].is_string()
|
||||
})
|
||||
.expect("the allocation is recorded");
|
||||
assert_eq!(allocating.record["body"]["fingerprint"], legacy_fingerprint);
|
||||
drop(logs);
|
||||
|
||||
let request = || PruneRequest {
|
||||
sandbox: SandboxProviderConfig::default(),
|
||||
run_id: "prune".to_string(),
|
||||
run_dir: run_dir.clone(),
|
||||
store: store.clone(),
|
||||
|
|
@ -187,6 +214,7 @@ async fn a_finished_runs_host_workspace_is_deleted_once_and_a_held_run_is_refuse
|
|||
async fn a_provider_petri_does_not_serve_is_refused_before_the_store_is_opened() {
|
||||
let store = Arc::new(petri_store::MemoryRunStore::new());
|
||||
let error = prune(PruneRequest {
|
||||
sandbox: SandboxProviderConfig::default(),
|
||||
run_id: "e2b-run".to_string(),
|
||||
run_dir: std::env::temp_dir().join("fabro-petri-prune-e2b"),
|
||||
store,
|
||||
|
|
|
|||
|
|
@ -2,22 +2,13 @@
|
|||
//! memory on the stub registry, and a command-only workflow on the host
|
||||
//! sandbox through the real step registry.
|
||||
//!
|
||||
//! Every run, stubbed or real, acquires its scope's environment through the
|
||||
//! sandbox-driver host plugin, so both tests skip when that executable is not
|
||||
//! found, unless `FABRO_REQUIRE_SANDBOX_PLUGINS` is set. Fabro's CI installs
|
||||
//! the plugin on `PATH` in the sandbox-plugins job and requires it there.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment"
|
||||
)]
|
||||
#![expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_petri::providers::{self, SandboxProviderConfig};
|
||||
use petri_execution::host::{self, HostRun};
|
||||
use petri_execution::inspect::{self, RunInspection};
|
||||
use petri_frontend_fabro::Fabro;
|
||||
|
|
@ -28,10 +19,6 @@ use petri_runtime::{RunOptions, Runtime};
|
|||
use petri_store::{Access, MemoryRunStore, RunKey, RunStore as _};
|
||||
use tokio::fs;
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
/// A command-only workflow: one script stage between start and exit.
|
||||
const COMMAND_WORKFLOW: &str = r#"digraph Command {
|
||||
graph [goal="Run one command"]
|
||||
|
|
@ -48,27 +35,6 @@ fn hello_bundle() -> PathBuf {
|
|||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../.fabro/workflows/hello")
|
||||
}
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Write a bundle's files into `<root>/.fabro/workflows/<name>` so the
|
||||
/// frontend sees a bundle root of its own, with no project settings layer
|
||||
/// above it. Returns the workflow file.
|
||||
|
|
@ -131,12 +97,9 @@ async fn run_workflow(
|
|||
/// The `hello` bundle, whose one stage is a prompt, completes on the stub
|
||||
/// registry with no model, and its record in the memory store says so. The
|
||||
/// stubbed stages never run a command, but the run still takes its host
|
||||
/// scope through the plugin.
|
||||
/// scope through the in-process provider.
|
||||
#[tokio::test]
|
||||
async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let bundle = hello_bundle();
|
||||
let workflow_text = fs::read_to_string(bundle.join("workflow.fabro"))
|
||||
|
|
@ -151,9 +114,11 @@ async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() {
|
|||
])
|
||||
.await;
|
||||
let store = Arc::new(MemoryRunStore::new());
|
||||
let rt = petri_attractor_steps::register_stubs(Runtime::standard().frontend(Fabro::new()))
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "hello"));
|
||||
let rt = petri_attractor_steps::register_stubs(
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()),
|
||||
)
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "hello"));
|
||||
|
||||
let inspection = run_workflow(&rt, &store, "hello", &workflow).await;
|
||||
|
||||
|
|
@ -167,9 +132,6 @@ async fn the_hello_bundle_runs_in_memory_on_the_stub_registry() {
|
|||
/// real step registry, and its record in the memory store says so.
|
||||
#[tokio::test]
|
||||
async fn a_command_workflow_runs_on_the_host_sandbox() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let workflow = install_bundle(root.path(), "command", &[
|
||||
("workflow.fabro", COMMAND_WORKFLOW),
|
||||
|
|
@ -177,9 +139,11 @@ async fn a_command_workflow_runs_on_the_host_sandbox() {
|
|||
])
|
||||
.await;
|
||||
let store = Arc::new(MemoryRunStore::new());
|
||||
let rt = petri_attractor_steps::register(Runtime::standard().frontend(Fabro::new()))
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "command"));
|
||||
let rt = petri_attractor_steps::register(
|
||||
providers::standard_runtime(&SandboxProviderConfig::default()).frontend(Fabro::new()),
|
||||
)
|
||||
.store(store.clone())
|
||||
.options(run_options(&root.path().join("run"), "command"));
|
||||
|
||||
let inspection = run_workflow(&rt, &store, "command", &workflow).await;
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
//! command's environment, and the value never reaches `petri_records`:
|
||||
//! Petri masks every record before it is appended.
|
||||
//!
|
||||
//! The run takes its host scope through the sandbox-driver host plugin, so
|
||||
//! the test skips, and says why, when the executable is not found, unless
|
||||
//! `FABRO_REQUIRE_SANDBOX_PLUGINS` is set.
|
||||
//! Built-in Host scopes run in process without a plugin executable.
|
||||
|
||||
mod support;
|
||||
|
||||
|
|
@ -19,7 +17,7 @@ use fabro_petri::secrets::VaultSecrets;
|
|||
use fabro_store::test_support;
|
||||
use fabro_types::SecretType;
|
||||
use fabro_vault::Vault;
|
||||
use support::{Silent, admit, host_plugin, no_questions, run_request};
|
||||
use support::{Silent, admit, no_questions, run_request};
|
||||
|
||||
const TOKEN: &str = "hunter2-hunter2-hunter2";
|
||||
|
||||
|
|
@ -50,9 +48,6 @@ TOKEN = "{{ secrets.TOKEN }}"
|
|||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_secret_reaches_the_command_and_is_masked_in_every_record() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let pool = test_support::in_memory_pool_with(&[
|
||||
fabro_db::BLOBS_MIGRATION_SQL,
|
||||
|
|
@ -106,9 +101,6 @@ async fn a_secret_reaches_the_command_and_is_masked_in_every_record() {
|
|||
/// ends the way Fabro's failure policy for a command ends it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_secret_nobody_provides_fails_the_command() {
|
||||
if host_plugin().is_none() {
|
||||
return;
|
||||
}
|
||||
let root = tempfile::tempdir().expect("a temp dir");
|
||||
let store = Arc::new(petri_store::MemoryRunStore::new());
|
||||
let runtime = RuntimeSpec::default();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! What the adapter tests share: the host plugin lookup, a bundle admitted
|
||||
//! What the adapter tests share: a bundle admitted
|
||||
//! through `check`, a run request over the engine assembly, and the run's
|
||||
//! records read back from its store.
|
||||
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -25,39 +24,9 @@ use petri_store::{Access, LogId, RunKey, RunStore};
|
|||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const HOST_PLUGIN: &str = "sandbox-driver-host";
|
||||
const HOST_PLUGIN_OVERRIDE: &str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
const REQUIRE_ENV: &str = "FABRO_REQUIRE_SANDBOX_PLUGINS";
|
||||
|
||||
pub(crate) const POLL: Duration = Duration::from_millis(10);
|
||||
pub(crate) const PATIENCE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// The host plugin as Petri's lookup finds it: the override variable, else
|
||||
/// the executable on `PATH`. `None`, after saying so, when the test should
|
||||
/// skip; a panic when the environment forbids a skip.
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the tests locate the plugin executable through the process environment"
|
||||
)]
|
||||
#[expect(clippy::print_stderr, reason = "a skipped test says why on its stderr")]
|
||||
pub(crate) fn host_plugin() -> Option<PathBuf> {
|
||||
let found = env::var_os(HOST_PLUGIN_OVERRIDE)
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
env::split_paths(&env::var_os("PATH")?)
|
||||
.map(|dir| dir.join(HOST_PLUGIN))
|
||||
.find(|candidate| candidate.is_file())
|
||||
});
|
||||
if found.is_none() {
|
||||
assert!(
|
||||
env::var_os(REQUIRE_ENV).is_none(),
|
||||
"{REQUIRE_ENV} is set, but {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset"
|
||||
);
|
||||
eprintln!("skipping: {HOST_PLUGIN} is not on PATH and {HOST_PLUGIN_OVERRIDE} is unset");
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The `.fabro/workflows/hello` bundle checked into this repository.
|
||||
pub(crate) fn hello_bundle() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../.fabro/workflows/hello")
|
||||
|
|
|
|||
|
|
@ -20,6 +20,3 @@ libc = "0.2"
|
|||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
#![allow(
|
||||
clippy::disallowed_methods,
|
||||
reason = "Build scripts run at compile time and read Cargo-provided env vars."
|
||||
)]
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=c/capture_argv.c");
|
||||
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
|
||||
cc::Build::new()
|
||||
.file("c/capture_argv.c")
|
||||
.compile("capture_argv");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
#include <string.h>
|
||||
|
||||
static char *g_argv_start = 0;
|
||||
static unsigned long g_argv_len = 0;
|
||||
|
||||
__attribute__((constructor))
|
||||
static void capture_argv(int argc, char **argv, char **envp) {
|
||||
(void)envp;
|
||||
if (argc <= 0 || !argv || !argv[0]) return;
|
||||
g_argv_start = argv[0];
|
||||
char *end = argv[argc - 1] + strlen(argv[argc - 1]) + 1;
|
||||
g_argv_len = (unsigned long)(end - argv[0]);
|
||||
}
|
||||
|
||||
char *fabro_proctitle_argv_start(void) { return g_argv_start; }
|
||||
unsigned long fabro_proctitle_argv_len(void) { return g_argv_len; }
|
||||
|
|
@ -13,12 +13,6 @@ unsafe impl Sync for Buffer {}
|
|||
|
||||
static STATE: OnceLock<Mutex<Buffer>> = OnceLock::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe extern "C" {
|
||||
fn fabro_proctitle_argv_start() -> *mut libc::c_char;
|
||||
fn fabro_proctitle_argv_len() -> libc::c_ulong;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe extern "C" {
|
||||
fn _NSGetArgv() -> *mut *mut *mut libc::c_char;
|
||||
|
|
@ -73,23 +67,38 @@ fn write_title(dst: &mut [u8], title: &[u8]) {
|
|||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "process-title initialization reads this process's kernel-owned argv bounds once"
|
||||
)]
|
||||
fn platform_init() -> Option<Buffer> {
|
||||
// SAFETY: these symbols are provided by the Linux-only C object compiled in
|
||||
// build.rs.
|
||||
let start = unsafe { fabro_proctitle_argv_start() };
|
||||
// SAFETY: paired with the symbol above.
|
||||
let len = unsafe { fabro_proctitle_argv_len() };
|
||||
let len = usize::try_from(len).ok()?;
|
||||
if start.is_null() || len == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Linux exposes the original argv span independently of libc. In particular,
|
||||
// musl does not pass argc/argv to C constructors, unlike glibc.
|
||||
let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
|
||||
let (start, len) = linux_argv_span(&stat)?;
|
||||
Some(Buffer {
|
||||
start: start.cast(),
|
||||
// The kernel reports this process's writable, initial argv allocation.
|
||||
// No code in this crate relocates or frees that allocation.
|
||||
start: std::ptr::with_exposed_provenance_mut(start),
|
||||
len,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_argv_span(stat: &str) -> Option<(usize, usize)> {
|
||||
// comm (field 2) can contain spaces and ')'; the final ')' ends it.
|
||||
let (_, tail) = stat.rsplit_once(')')?;
|
||||
let mut fields = tail.split_whitespace();
|
||||
// The tail starts at field 3; arg_start and arg_end are fields 48 and 49.
|
||||
let start: usize = fields.nth(45)?.parse().ok()?;
|
||||
let end: usize = fields.next()?.parse().ok()?;
|
||||
let len = end.checked_sub(start)?;
|
||||
if start == 0 || len == 0 || len > isize::MAX as usize {
|
||||
return None;
|
||||
}
|
||||
Some((start, len))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_init() -> Option<Buffer> {
|
||||
// SAFETY: macOS exposes argc/argv through crt_externs for the current process.
|
||||
|
|
@ -140,7 +149,23 @@ fn platform_init() -> Option<Buffer> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::write_title;
|
||||
use super::{linux_argv_span, write_title};
|
||||
|
||||
#[test]
|
||||
fn linux_argv_bounds_handle_parentheses_in_the_process_name() {
|
||||
let fields = vec!["0"; 45].join(" ");
|
||||
let stat = format!("42 (a name ) with (parens)) {fields} 4096 4160 5000 5100 0");
|
||||
assert_eq!(linux_argv_span(&stat), Some((4096, 64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_argv_bounds_refuse_missing_or_invalid_addresses() {
|
||||
let fields = vec!["0"; 45].join(" ");
|
||||
for addresses in ["", "4096", "0 64", "4096 4096", "4160 4096", "x 4160"] {
|
||||
let stat = format!("42 (probe) {fields} {addresses}");
|
||||
assert_eq!(linux_argv_span(&stat), None, "{stat}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_title_zero_fills_remainder() {
|
||||
|
|
|
|||
40
lib/foundation/fabro-proc/tests/title.rs
Normal file
40
lib/foundation/fabro-proc/tests/title.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
//! Exercise the real argv allocation in a separate process, including musl.
|
||||
#![cfg(target_os = "linux")]
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "the test launches an isolated copy of itself and reads its kernel process title"
|
||||
)]
|
||||
|
||||
use std::process::Command;
|
||||
use std::{env, fs};
|
||||
|
||||
const TITLE: &str = "fabro title-probe running";
|
||||
|
||||
#[test]
|
||||
fn process_title_is_visible_in_proc() {
|
||||
let output = Command::new(env::current_exe().unwrap())
|
||||
.args(["--exact", "title_child", "--ignored", "--nocapture"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "launched by process_title_is_visible_in_proc to isolate argv mutation"]
|
||||
fn title_child() {
|
||||
let len = fabro_proc::title_init();
|
||||
assert!(len > TITLE.len(), "no usable argv buffer: {len}");
|
||||
fabro_proc::title_set(TITLE);
|
||||
let cmdline = fs::read("/proc/self/cmdline").unwrap();
|
||||
assert_eq!(
|
||||
cmdline.split(|byte| *byte == 0).next().unwrap(),
|
||||
TITLE.as_bytes()
|
||||
);
|
||||
assert_eq!(fabro_proc::title_init(), len);
|
||||
fabro_proc::title_set("fabro done");
|
||||
let cmdline = fs::read("/proc/self/cmdline").unwrap();
|
||||
assert_eq!(
|
||||
cmdline.split(|byte| *byte == 0).next().unwrap(),
|
||||
b"fabro done"
|
||||
);
|
||||
}
|
||||
|
|
@ -49,29 +49,16 @@ impl EnvVars {
|
|||
pub const FABRO_WEB_URL: &'static str = "FABRO_WEB_URL";
|
||||
pub const FABRO_WORKER_TOKEN: &'static str = "FABRO_WORKER_TOKEN";
|
||||
|
||||
// Petri's sandbox-driver plugins: where each provider's plugin executable
|
||||
// is, its checksum override, dev mode for unpinned plugins, and how a
|
||||
// remote Docker daemon's containers reach this machine. A run's worker
|
||||
// resolves the plugins, so these cross into the worker process.
|
||||
pub const PETRI_SANDBOX_HOST_PLUGIN: &'static str = "PETRI_SANDBOX_HOST_PLUGIN";
|
||||
pub const PETRI_SANDBOX_HOST_SHA256: &'static str = "PETRI_SANDBOX_HOST_SHA256";
|
||||
pub const PETRI_SANDBOX_DOCKER_PLUGIN: &'static str = "PETRI_SANDBOX_DOCKER_PLUGIN";
|
||||
pub const PETRI_SANDBOX_DOCKER_SHA256: &'static str = "PETRI_SANDBOX_DOCKER_SHA256";
|
||||
pub const PETRI_SANDBOX_DAYTONA_PLUGIN: &'static str = "PETRI_SANDBOX_DAYTONA_PLUGIN";
|
||||
pub const PETRI_SANDBOX_DAYTONA_SHA256: &'static str = "PETRI_SANDBOX_DAYTONA_SHA256";
|
||||
// Petri's sandbox settings: dev mode for unpinned third-party plugins,
|
||||
// how a remote Docker daemon's containers reach this machine, and the
|
||||
// action-host image. These cross into a run's worker process.
|
||||
pub const PETRI_SANDBOX_PLUGIN_DEV: &'static str = "PETRI_SANDBOX_PLUGIN_DEV";
|
||||
pub const PETRI_SANDBOX_DOCKER_HOST_ADDRESS: &'static str = "PETRI_SANDBOX_DOCKER_HOST_ADDRESS";
|
||||
pub const PETRI_SANDBOX_ACTION_HOST_IMAGE: &'static str = "PETRI_SANDBOX_ACTION_HOST_IMAGE";
|
||||
|
||||
/// Every Petri plugin variable, in one list for the process boundaries
|
||||
/// Every Petri sandbox variable, in one list for the process boundaries
|
||||
/// that forward them.
|
||||
pub const PETRI_SANDBOX_PLUGIN_VARS: &'static [&'static str] = &[
|
||||
Self::PETRI_SANDBOX_HOST_PLUGIN,
|
||||
Self::PETRI_SANDBOX_HOST_SHA256,
|
||||
Self::PETRI_SANDBOX_DOCKER_PLUGIN,
|
||||
Self::PETRI_SANDBOX_DOCKER_SHA256,
|
||||
Self::PETRI_SANDBOX_DAYTONA_PLUGIN,
|
||||
Self::PETRI_SANDBOX_DAYTONA_SHA256,
|
||||
Self::PETRI_SANDBOX_PLUGIN_DEV,
|
||||
Self::PETRI_SANDBOX_DOCKER_HOST_ADDRESS,
|
||||
Self::PETRI_SANDBOX_ACTION_HOST_IMAGE,
|
||||
|
|
@ -168,6 +155,7 @@ impl EnvVars {
|
|||
pub const DAYTONA_API_KEY: &'static str = "DAYTONA_API_KEY";
|
||||
pub const DAYTONA_API_URL: &'static str = "DAYTONA_API_URL";
|
||||
pub const DAYTONA_ORGANIZATION_ID: &'static str = "DAYTONA_ORGANIZATION_ID";
|
||||
pub const DAYTONA_TARGET: &'static str = "DAYTONA_TARGET";
|
||||
pub const DAYTONA_SERVER_URL: &'static str = "DAYTONA_SERVER_URL";
|
||||
pub const SESSION_SECRET: &'static str = "SESSION_SECRET";
|
||||
|
||||
|
|
@ -256,12 +244,6 @@ mod tests {
|
|||
EnvVars::FABRO_VERBOSE,
|
||||
EnvVars::FABRO_WEB_URL,
|
||||
EnvVars::FABRO_WORKER_TOKEN,
|
||||
EnvVars::PETRI_SANDBOX_HOST_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_HOST_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_DAYTONA_PLUGIN,
|
||||
EnvVars::PETRI_SANDBOX_DAYTONA_SHA256,
|
||||
EnvVars::PETRI_SANDBOX_PLUGIN_DEV,
|
||||
EnvVars::PETRI_SANDBOX_DOCKER_HOST_ADDRESS,
|
||||
EnvVars::PETRI_SANDBOX_ACTION_HOST_IMAGE,
|
||||
|
|
@ -330,6 +312,7 @@ mod tests {
|
|||
EnvVars::DAYTONA_API_URL,
|
||||
EnvVars::DAYTONA_ORGANIZATION_ID,
|
||||
EnvVars::DAYTONA_SERVER_URL,
|
||||
EnvVars::DAYTONA_TARGET,
|
||||
EnvVars::SESSION_SECRET,
|
||||
EnvVars::CARGO_BIN_EXE_FABRO,
|
||||
EnvVars::CARGO_CFG_TARGET_OS,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,56 @@ pub fn require_env(name: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Set in CI so a missing sandbox backend (a Docker daemon or image) fails
|
||||
/// the test instead of skipping it.
|
||||
pub const REQUIRE_SANDBOX_BACKENDS: &str = "FABRO_REQUIRE_SANDBOX_BACKENDS";
|
||||
|
||||
/// A reachable Docker daemon. When [`REQUIRE_SANDBOX_BACKENDS`] is set, a
|
||||
/// missing daemon fails the test instead of skipping it.
|
||||
#[must_use]
|
||||
pub fn docker_available() -> bool {
|
||||
sandbox_backend_available(
|
||||
docker_succeeds(&["version", "--format", "{{.Server.Version}}"]),
|
||||
"no Docker daemon answers",
|
||||
)
|
||||
}
|
||||
|
||||
/// A Docker daemon that already holds `image`, under the same
|
||||
/// fail-or-skip policy as [`docker_available`].
|
||||
#[must_use]
|
||||
pub fn docker_image_available(image: &str) -> bool {
|
||||
sandbox_backend_available(
|
||||
docker_succeeds(&["image", "inspect", image]),
|
||||
&format!("no Docker daemon with {image}"),
|
||||
)
|
||||
}
|
||||
|
||||
fn docker_succeeds(args: &[&str]) -> bool {
|
||||
std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
/// `available`, or a skip notice for `missing` (a failure when
|
||||
/// [`REQUIRE_SANDBOX_BACKENDS`] is set).
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Skip notices go to stderr so stdout stays assertable."
|
||||
)]
|
||||
fn sandbox_backend_available(available: bool, missing: &str) -> bool {
|
||||
if !available {
|
||||
assert!(
|
||||
std::env::var_os(REQUIRE_SANDBOX_BACKENDS).is_none(),
|
||||
"{REQUIRE_SANDBOX_BACKENDS} is set, but {missing}"
|
||||
);
|
||||
eprintln!("skipping: {missing}");
|
||||
}
|
||||
available
|
||||
}
|
||||
|
||||
/// Apply baseline environment isolation to a `Command` that spawns the
|
||||
/// `fabro` binary (or a helper that will act like it).
|
||||
///
|
||||
|
|
@ -228,10 +278,10 @@ fn apply_test_isolation_with_lookup(
|
|||
if let Some(path) = lookup(EnvVars::PATH) {
|
||||
cmd.env(EnvVars::PATH, path);
|
||||
}
|
||||
// Petri resolves its sandbox-driver plugins from these, in the server a
|
||||
// test starts and in the workers that server launches; a developer's
|
||||
// plugin override reaches them like `PATH` does, and so does the Docker
|
||||
// daemon selection the Docker plugin needs.
|
||||
// Petri reads its sandbox settings from these, in the server a test
|
||||
// starts and in the workers that server launches; a developer's
|
||||
// override reaches them like `PATH` does, and so does the Docker daemon
|
||||
// selection the Docker provider needs.
|
||||
for name in EnvVars::PETRI_SANDBOX_PLUGIN_VARS
|
||||
.iter()
|
||||
.chain(EnvVars::DOCKER_VARS)
|
||||
|
|
|
|||
|
|
@ -147,12 +147,13 @@ impl ServerSandboxProvidersSettings {
|
|||
.map(|(kind, _)| kind)
|
||||
}
|
||||
|
||||
/// Enabled kinds that are served by a plugin executable.
|
||||
/// Enabled third-party kinds that are served by a plugin executable.
|
||||
/// Bundled kinds run in process, so plugin settings on them are ignored.
|
||||
pub fn enabled_plugins(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (&SandboxProviderKind, &SandboxPluginSettings)> {
|
||||
self.entries.iter().filter_map(|(kind, entry)| {
|
||||
(entry.enabled)
|
||||
(entry.enabled && kind.bundled().is_none())
|
||||
.then_some(entry.plugin.as_ref())
|
||||
.flatten()
|
||||
.map(|plugin| (kind, plugin))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue