diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7ce775 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +jobs: + rust: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Run Rust tests + run: cargo test + + - name: Run Rust serde tests + run: cargo test --features serde + + ui: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web/void-control-ux + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/void-control-ux/package-lock.json + + - name: Install UI dependencies + run: npm ci + + - name: Build UI + run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..426c5b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,152 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +permissions: + contents: write + +jobs: + verify-version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + + - name: Verify manifest versions match tag + id: version + run: | + TAG="${GITHUB_REF_NAME#v}" + CARGO_VERSION=$(python3 - <<'PY' +from pathlib import Path +for line in Path("Cargo.toml").read_text().splitlines(): + if line.startswith("version = "): + print(line.split('"')[1]) + break +PY +) + UI_VERSION=$(python3 - <<'PY' +import json +from pathlib import Path +print(json.loads(Path("web/void-control-ux/package.json").read_text())["version"]) +PY +) + test "${TAG}" = "${CARGO_VERSION}" + test "${TAG}" = "${UI_VERSION}" + echo "version=${TAG}" >> "$GITHUB_OUTPUT" + + rust-release: + runs-on: ubuntu-latest + needs: verify-version + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Build release binaries + run: | + cargo test + cargo test --features serde + cargo build --release --features serde --bin voidctl --bin normalize_fixture + + - name: Package voidctl + run: | + mkdir -p dist/voidctl + cp target/release/voidctl dist/voidctl/ + cp README.md LICENSE dist/voidctl/ + tar -C dist -czf "voidctl-v${{ needs.verify-version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz" voidctl + + - name: Package normalize_fixture + run: | + mkdir -p dist/normalize_fixture + cp target/release/normalize_fixture dist/normalize_fixture/ + cp README.md LICENSE dist/normalize_fixture/ + tar -C dist -czf "normalize_fixture-v${{ needs.verify-version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz" normalize_fixture + + - name: Upload Rust release artifacts + uses: actions/upload-artifact@v4 + with: + name: rust-release-assets + path: | + voidctl-v${{ needs.verify-version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz + normalize_fixture-v${{ needs.verify-version.outputs.version }}-x86_64-unknown-linux-gnu.tar.gz + + ui-release: + runs-on: ubuntu-latest + needs: verify-version + defaults: + run: + working-directory: web/void-control-ux + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/void-control-ux/package-lock.json + + - name: Install UI dependencies + run: npm ci + + - name: Build UI + run: npm run build + + - name: Package UI bundle + run: | + tar -C dist -czf "../../void-control-ux-v${{ needs.verify-version.outputs.version }}.tar.gz" . + + - name: Upload UI release artifact + uses: actions/upload-artifact@v4 + with: + name: ui-release-asset + path: void-control-ux-v${{ needs.verify-version.outputs.version }}.tar.gz + + publish-release: + runs-on: ubuntu-latest + needs: + - verify-version + - rust-release + - ui-release + steps: + - uses: actions/checkout@v4 + + - name: Download Rust release assets + uses: actions/download-artifact@v4 + with: + name: rust-release-assets + path: release-assets + + - name: Download UI release asset + uses: actions/download-artifact@v4 + with: + name: ui-release-asset + path: release-assets + + - name: Prepare release notes header + run: | + cat > .github-release-notes.md <<'EOF' + First public `void-control` release. + + Supported `void-box` baseline for this release: + - `void-box` `v0.1.1` or equivalent validated production build + + Included assets: + - `voidctl` Linux x86_64 binary archive + - `normalize_fixture` Linux x86_64 binary archive + - `void-control-ux` production bundle archive + EOF + + - name: Publish GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: release-assets/* + body_path: .github-release-notes.md diff --git a/.github/workflows/void-box-compatibility.yml b/.github/workflows/void-box-compatibility.yml new file mode 100644 index 0000000..8d079a0 --- /dev/null +++ b/.github/workflows/void-box-compatibility.yml @@ -0,0 +1,33 @@ +name: Void Box Compatibility + +on: + workflow_dispatch: + inputs: + void_box_base_url: + description: 'Reachable void-box daemon URL' + required: true + default: 'http://127.0.0.1:43100' + supported_void_box_version: + description: 'Validated void-box version/build label' + required: true + default: 'v0.1.1' + +jobs: + compatibility: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Report compatibility target + run: | + echo "Validating against void-box baseline: ${{ inputs.supported_void_box_version }}" + echo "Daemon URL: ${{ inputs.void_box_base_url }}" + + - name: Run compatibility gate + run: scripts/release/check_void_box_compat.sh "${{ inputs.void_box_base_url }}" diff --git a/.gitignore b/.gitignore index ad67955..1433b11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,22 @@ -# Generated by Cargo -# will have compiled files and executables -debug -target +/target +/artifacts +/tmp +package-lock.json +package.json +node_modules +.claude +.idea +.local-utils -# These are backup files generated by rustfmt -**/*.rs.bk +# Frontend build output and local env files +web/void-control-ux/dist/ +web/void-control-ux/.env +web/void-control-ux/.env.local +web/void-control-ux/.env.*.local +web/void-control-ux/tmp_*.mjs -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb +.github-release-notes.md -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +# Keep the UI lockfile tracked for CI/npm ci +!web/void-control-ux/package.json +!web/void-control-ux/package-lock.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..33e1326 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,120 @@ +# Repository Guidelines + +## Project Structure & Module Organization +This repository currently contains architecture and runtime-contract documentation for Void Control. + +- `spec/`: Canonical specifications (for example, `spec/void-control-runtime-spec-v0.1.md`). +- `LICENSE`: Project license. + +When adding implementation code, keep the same separation of concerns defined in the spec: +- Control-plane orchestration logic should be separate from runtime execution logic. +- Add new specs to `spec/` and version them in the filename (for example, `*-v0.2.md`). + +## Build, Test, and Development Commands +Use Cargo for local development and validation: + +- `cargo test`: Run core unit tests (no optional JSON compatibility feature). +- `cargo test --features serde`: Run JSON compatibility tests and fixture-based checks. +- `cargo test --features serde runtime::void_box::`: Run live-daemon client contract tests (mocked transport). +- `VOID_BOX_BASE_URL=http://127.0.0.1:3000 cargo test --features serde --test void_box_contract -- --ignored --nocapture`: Run live daemon contract gate tests (tests auto-generate fallback specs under `/tmp`). +- Optional spec overrides for policy behavior checks: + - `VOID_BOX_TIMEOUT_SPEC_FILE` + - `VOID_BOX_PARALLEL_SPEC_FILE` + - `VOID_BOX_RETRY_SPEC_FILE` + - `VOID_BOX_NO_POLICY_SPEC_FILE` +- `cargo run --example normalize_void_box_run`: Run the typed normalization example. +- `cargo run --bin normalize_fixture -- fixtures/sample.vbrun`: Normalize from local fixture format. + +### Void-Box Production Image (for UI/real Claude runs) + +When validating real pipeline execution from `void-control` UI, use the production +void-box rootfs from the sibling repository: + +```bash +cd /home/diego/github/agent-infra/void-box +TMPDIR=$PWD/target/tmp scripts/build_claude_rootfs.sh +``` + +Start daemon with production kernel/initramfs: + +```bash +cd /home/diego/github/agent-infra/void-box +export ANTHROPIC_API_KEY=sk-ant-... +export VOID_BOX_KERNEL=/boot/vmlinuz-$(uname -r) +export VOID_BOX_INITRAMFS=$PWD/target/void-box-rootfs.cpio.gz +cargo run --bin voidbox -- serve --listen 127.0.0.1:43100 +``` + +Start bridge (required for Launch modal spec upload/content mode): + +```bash +cd /home/diego/github/void-control +cargo run --features serde --bin voidctl -- serve +``` + +Start UI: + +```bash +cd /home/diego/github/void-control/web/void-control-ux +npm run dev -- --host 127.0.0.1 --port 3000 +``` + +Important: +- Do not use `/tmp/void-box-test-rootfs.cpio.gz` for production/runtime UI validation. +- `target/void-box-rootfs.cpio.gz` is the expected production image path. + +### UI Debugging Requirement + +For UI work in `web/void-control-ux`, browser automation/inspection is required. +Do not rely on screenshot-only iteration when layout, DOM state, resize behavior, +or graph rendering need verification. + +Preferred order: + +- Use configured browser MCP first. +- If browser MCP is unavailable, install and use Playwright locally. +- Screenshots are a fallback only, not the primary workflow. + +Current local browser MCP: + +- `chrome-devtools` is already configured in `~/.codex/config.toml`. +- This should be the default tool for DOM inspection, layout debugging, console + errors, network checks, and viewport validation. + +Playwright install fallback: + +```bash +cd /home/diego/github/void-control/web/void-control-ux +npm install -D playwright +npx playwright install chromium +``` + +If Playwright MCP is later added, prefer that over manual screenshots for UI +inspection. No dedicated local skill currently exists in this repo for +Playwright setup; use browser MCP or direct Playwright commands. + +## Coding Style & Naming Conventions +For documentation and future code contributions: + +- Use clear, boundary-focused naming aligned with the spec (`Run`, `Stage`, `Attempt`, `Runtime`, `Controller`). +- Keep Markdown headings hierarchical and concise. +- Prefer short sections and bullet lists over long prose blocks. +- Use ASCII unless a symbol is required for technical clarity. + +## Testing Guidelines +- Keep contract tests in module `#[cfg(test)]` blocks close to conversion/runtime logic. +- Add fixture-based tests for compatibility behavior under `--features serde`. +- Validate both paths before PRs: + - `cargo test` + - `cargo test --features serde` + +## Commit & Pull Request Guidelines +Git history is minimal (`Initial commit`), so adopt a consistent imperative style now: + +- Commit format: `area: concise action` (example: `spec: clarify cancellation semantics`). +- Keep commits focused to one concern. +- PRs should include: + - A short problem statement. + - A summary of what changed. + - Any spec sections affected (file paths + headings). + - Follow-up work, if intentionally deferred. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7e941ed --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,93 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +void-control is the control-plane orchestration layer for the `void-box` runtime. It launches/manages runs, tracks run/stage/event lifecycle, and enforces runtime contract compatibility. The project has two main parts: a Rust library/CLI and a React operator dashboard. + +## Build & Test Commands + +```bash +# Core unit tests (no serde feature) +cargo test + +# JSON compatibility + fixture-based tests +cargo test --features serde + +# Mocked transport contract tests for void-box client +cargo test --features serde runtime::void_box:: + +# Live daemon contract tests (requires running void-box on port 43100) +VOID_BOX_BASE_URL=http://127.0.0.1:43100 \ + cargo test --features serde --test void_box_contract -- --ignored --nocapture + +# Run a single test +cargo test --features serde test_name_here + +# Terminal console +cargo run --features serde --bin voidctl + +# Bridge server (port 43210) +cargo run --features serde --bin voidctl -- serve +``` + +**Always validate both paths before PRs:** `cargo test` AND `cargo test --features serde`. + +### Web UI (web/void-control-ux/) + +```bash +cd web/void-control-ux +npm install +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 npm run dev # dev server on port 5174 +npm run build # production build (tsc -b && vite build) +``` + +## Architecture + +### Rust Crate (src/) + +Single crate with two main modules, feature-gated with `serde`: + +- **`contract/`** — Control-plane type definitions (no runtime dependencies) + - `api.rs` — Request/response types: `StartRequest`, `StopRequest`, `RuntimeInspection`, `ConvertedRunView` + - `state.rs` — `RunState` enum with strict lifecycle transitions: Pending→Starting→Running→{Succeeded|Failed|Canceled} + - `event.rs` — `EventEnvelope`, `EventType` enum, `EventSequenceTracker` (enforces monotonic seq ordering) + - `policy.rs` — `ExecutionPolicy` (max_parallel_microvms, stage_timeout, retry config) + - `error.rs` — `ContractError` with code, message, retryable flag + - `compat.rs` / `compat_json.rs` — Normalization from void-box raw format to canonical types + +- **`runtime/`** — Client implementations (behind `serde` feature) + - `void_box.rs` — `VoidBoxRuntimeClient` (HTTP client to void-box daemon) + - `mock.rs` — `MockRuntime` for testing + +- **`bin/`** — `voidctl` (console + bridge server), `normalize_fixture` + +### Web UI (web/void-control-ux/src/) + +React 18 + TypeScript dashboard: + +- **State:** Zustand (`store/ui.ts`) for selection state; TanStack Query for server state with tiered polling (active runs 2.5s, terminal 5s, events 1.2s) +- **Key components:** `RunsList`, `RunGraph` (Sigma/Graphology DAG), `EventTimeline`, `NodeInspector`, `LaunchRunModal` +- **API layer:** `lib/api.ts` wraps daemon endpoints (`/v1/runs/*`) and bridge endpoint (`/v1/launch`) +- **Types:** `lib/types.ts` mirrors the Rust contract types + +### API Surface + +- Daemon (void-box): `/v1/runs`, `/v1/runs/{id}`, `/v1/runs/{id}/events`, `/v1/runs/{id}/stages`, `/v1/runs/{id}/telemetry`, `/v1/runs/{id}/cancel` +- Bridge (voidctl serve): `POST /v1/launch` + +## Coding Conventions + +- **Naming:** Use boundary-focused names from the spec: `Run`, `Stage`, `Attempt`, `Runtime`, `Controller` +- **Testing:** Keep contract tests in `#[cfg(test)]` blocks near conversion/runtime logic. Fixture-based tests require `--features serde` +- **Feature gating:** JSON serialization, HTTP client, and server code live behind the `serde` feature flag +- **Commits:** Imperative style, format `area: concise action` (e.g., `spec: clarify cancellation semantics`) +- **Specs:** Add new specs to `spec/` with version in filename (e.g., `*-v0.2.md`) + +## Environment Variables + +- `VOID_BOX_BASE_URL` — void-box daemon endpoint (default: `http://127.0.0.1:43100`) +- `VITE_VOID_BOX_BASE_URL` — daemon URL for web UI +- `VITE_VOID_CONTROL_BASE_URL` — bridge URL for web UI (e.g., `http://127.0.0.1:43210`) +- `VOID_BOX_TIMEOUT_SPEC_FILE`, `VOID_BOX_PARALLEL_SPEC_FILE`, `VOID_BOX_RETRY_SPEC_FILE`, `VOID_BOX_NO_POLICY_SPEC_FILE` — Optional spec file overrides for policy behavior tests diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..29d5897 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,413 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustyline" +version = "14.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.52.0", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "void-control" +version = "0.0.1" +dependencies = [ + "rustyline", + "serde", + "serde_json", + "tiny_http", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a9eb5e8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "void-control" +version = "0.0.1" +edition = "2021" +license = "Apache-2.0" + +[features] +default = [] +serde = ["dep:serde", "dep:serde_json", "dep:rustyline", "dep:tiny_http"] + +[dependencies] +serde = { version = "1", features = ["derive"], optional = true } +serde_json = { version = "1", optional = true } +rustyline = { version = "14", optional = true } +tiny_http = { version = "0.12", optional = true } diff --git a/README.md b/README.md new file mode 100644 index 0000000..5e421e4 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# void-control + +Orchestration layer for `void-box` runtime execution. + +![void-control hero](docs/assets/void-control-hero.png) + +## Demo + + + +If the embedded player does not render in your Markdown viewer, use the direct file link: [void-control demo video](docs/assets/void-control-demo.mp4). + +## Release + +- First public release target: `v0.0.1` +- Release artifacts are published through GitHub Releases +- Supported `void-box` baseline for `v0.0.1`: `void-box` `v0.1.1` or an equivalent validated production build +- Release process and compatibility gate details: [docs/release-process.md](docs/release-process.md) + +## What It Is + +`void-control` is the control-plane side of the stack: + +- Launches and manages runs on `void-box`. +- Tracks run/stage/event lifecycle. +- Provides terminal-first and graph-first operator UX. +- Enforces runtime contract compatibility with `void-box`. + +## Project Components + +- `spec/`: Runtime and orchestration contracts. +- `src/`: Rust orchestration client/runtime normalization logic. +- `tests/`: Contract and compatibility tests. +- `web/void-control-ux/`: React operator dashboard (graph + inspector). + +## Quick Start + +### 1) Start `void-box` daemon + +```bash +cargo run --bin voidbox -- serve --listen 127.0.0.1:43100 +``` + +### 2) Run `void-control` tests + +```bash +cargo test +cargo test --features serde +``` + +### 3) Run live daemon contract gate + +```bash +VOID_BOX_BASE_URL=http://127.0.0.1:43100 \ +cargo test --features serde --test void_box_contract -- --ignored --nocapture +``` + +### 4) Start graph dashboard + +```bash +cd web/void-control-ux +npm install +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 npm run dev +``` + +### 5) Launch from YAML editor/upload (bridge) + +Run bridge mode in another terminal: + +```bash +cargo run --features serde --bin voidctl -- serve +``` + +Then start UI with bridge URL: + +```bash +cd web/void-control-ux +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 \ +VITE_VOID_CONTROL_BASE_URL=http://127.0.0.1:43210 \ +npm run dev +``` + +## Terminal Console + +```bash +cargo run --features serde --bin voidctl +``` + +## Notes + +- Dashboard uses daemon APIs (`/v1/runs`, `/v1/runs/{id}/events`, `/v1/runs/{id}/stages`, `/v1/runs/{id}/telemetry`). +- `+ Launch Box` supports: + - editor/upload launch through bridge (`POST /v1/launch`) + - path-only fallback launch (`POST /v1/runs`) when no spec text is provided. diff --git a/docs/assets/void-control-demo.mp4 b/docs/assets/void-control-demo.mp4 new file mode 100644 index 0000000..802112a Binary files /dev/null and b/docs/assets/void-control-demo.mp4 differ diff --git a/docs/assets/void-control-hero.png b/docs/assets/void-control-hero.png new file mode 100644 index 0000000..6769255 Binary files /dev/null and b/docs/assets/void-control-hero.png differ diff --git a/docs/release-process.md b/docs/release-process.md new file mode 100644 index 0000000..9736956 --- /dev/null +++ b/docs/release-process.md @@ -0,0 +1,64 @@ +# `void-control` Release Process + +This repository ships as a GitHub Release. + +## `v0.0.1` Baseline + +- Repo tag: `v0.0.1` +- Rust crate version: `0.0.1` +- UI package version: `0.0.1` +- Supported `void-box` baseline: `v0.1.1` or an equivalent validated production build + +## Release Artifacts + +- `voidctl-v-x86_64-unknown-linux-gnu.tar.gz` +- `normalize_fixture-v-x86_64-unknown-linux-gnu.tar.gz` +- `void-control-ux-v.tar.gz` + +The UI is shipped as a built artifact from `web/void-control-ux/dist/`. It is not published to npm for `v0.0.1`. + +## Required Checks + +Before cutting a release tag: + +1. Rust CI passes: + - `cargo test` + - `cargo test --features serde` +2. UI CI passes: + - `cd web/void-control-ux && npm ci && npm run build` +3. `void-box` compatibility gate passes against the supported daemon baseline: + - `scripts/release/check_void_box_compat.sh http://127.0.0.1:43100` + +## Tag-Driven Release + +Releases are created by pushing a semver tag: + +```bash +git tag v0.0.1 +git push origin v0.0.1 +``` + +The release workflow will: + +- verify that the tag matches `Cargo.toml` and `web/void-control-ux/package.json` +- run Rust and UI build jobs +- package Rust binaries +- package the UI production bundle +- publish a GitHub Release with those assets + +## `void-box` Compatibility Gate + +`void-control` is released independently from `void-box`, but releases must be validated against a pinned `void-box` version/build. + +For `v0.0.1`, use: + +- `void-box` `v0.1.1` +- production kernel/initramfs path as documented in [AGENTS.md](../AGENTS.md) + +Run compatibility manually: + +```bash +scripts/release/check_void_box_compat.sh http://127.0.0.1:43100 +``` + +Or via the dedicated GitHub Actions workflow on a self-hosted runner that can reach a real daemon. diff --git a/examples/normalize_void_box_json.rs b/examples/normalize_void_box_json.rs new file mode 100644 index 0000000..2f4b530 --- /dev/null +++ b/examples/normalize_void_box_json.rs @@ -0,0 +1,23 @@ +#[cfg(feature = "serde")] +fn main() { + let run_json = r#"{ + "id":"run-5000", + "status":"Completed", + "error":null, + "events":[ + {"ts_ms":1700000000000,"event_type":"run.started","run_id":"run-5000","seq":1}, + {"ts_ms":1700000003000,"event_type":"run.finished","run_id":"run-5000","seq":2} + ] + }"#; + + let converted = void_control::contract::from_void_box_run_json(run_json) + .expect("json normalization should succeed"); + println!("inspection: {:#?}", converted.inspection); + println!("events: {:#?}", converted.events); +} + +#[cfg(not(feature = "serde"))] +fn main() { + eprintln!("re-run with: cargo run --features serde --example normalize_void_box_json"); +} + diff --git a/examples/normalize_void_box_run.rs b/examples/normalize_void_box_run.rs new file mode 100644 index 0000000..86bbd87 --- /dev/null +++ b/examples/normalize_void_box_run.rs @@ -0,0 +1,51 @@ +use std::collections::BTreeMap; + +use void_control::contract::{ + from_void_box_run, VoidBoxPayloadValue, VoidBoxRunEventRaw, VoidBoxRunRaw, +}; + +fn main() { + let mut payload = BTreeMap::new(); + payload.insert( + "message".to_string(), + VoidBoxPayloadValue::String("run created".to_string()), + ); + + let raw = VoidBoxRunRaw { + id: "run-1700000000".to_string(), + status: "Completed".to_string(), + error: None, + events: vec![ + VoidBoxRunEventRaw { + ts_ms: 1700000000000, + event_type: "run.started".to_string(), + run_id: Some("run-1700000000".to_string()), + seq: Some(1), + payload: Some(payload), + }, + VoidBoxRunEventRaw { + ts_ms: 1700000004500, + event_type: "run.finished".to_string(), + run_id: Some("run-1700000000".to_string()), + seq: Some(2), + payload: None, + }, + ], + }; + + match from_void_box_run(&raw) { + Ok(converted) => { + println!("inspection: {:#?}", converted.inspection); + println!("diagnostics: {:#?}", converted.diagnostics); + println!("events:"); + for event in &converted.events { + println!(" - {:?}", event); + } + } + Err(err) => { + eprintln!("conversion error: {:?}", err); + std::process::exit(1); + } + } +} + diff --git a/fixtures/sample.vbrun b/fixtures/sample.vbrun new file mode 100644 index 0000000..2ba5819 --- /dev/null +++ b/fixtures/sample.vbrun @@ -0,0 +1,13 @@ +# Simple fixture format for src/bin/normalize_fixture.rs +# Keys: +# id= +# status= +# error= +# event|ts_ms=|event_type=|run_id=|seq=|payload= + +id=run-1700000000 +status=Completed +error= +event|ts_ms=1700000000000|event_type=run.started|run_id=run-1700000000|seq=1|payload=message:run_created +event|ts_ms=1700000004500|event_type=run.finished|run_id=run-1700000000|seq=2 + diff --git a/fixtures/voidbox_run_bad_seq.json b/fixtures/voidbox_run_bad_seq.json new file mode 100644 index 0000000..f871592 --- /dev/null +++ b/fixtures/voidbox_run_bad_seq.json @@ -0,0 +1,20 @@ +{ + "id": "run-4000", + "status": "Running", + "error": null, + "events": [ + { + "ts_ms": 1700000000000, + "event_type": "run.started", + "run_id": "run-4000", + "seq": 2 + }, + { + "ts_ms": 1700000001000, + "event_type": "run.finished", + "run_id": "run-4000", + "seq": 1 + } + ] +} + diff --git a/fixtures/voidbox_run_events_success.json b/fixtures/voidbox_run_events_success.json new file mode 100644 index 0000000..75d9ea8 --- /dev/null +++ b/fixtures/voidbox_run_events_success.json @@ -0,0 +1,23 @@ +[ + { + "ts_ms": 1700000000000, + "level": "info", + "event_type": "run.started", + "message": "run started", + "run_id": "run-2000", + "seq": 1, + "payload": { + "message": "run created" + } + }, + { + "ts_ms": 1700000004000, + "level": "info", + "event_type": "run.finished", + "message": "run finished", + "run_id": "run-2000", + "seq": 2, + "payload": null + } +] + diff --git a/fixtures/voidbox_run_success.json b/fixtures/voidbox_run_success.json new file mode 100644 index 0000000..5312a62 --- /dev/null +++ b/fixtures/voidbox_run_success.json @@ -0,0 +1,29 @@ +{ + "id": "run-2000", + "status": "Completed", + "error": null, + "events": [ + { + "ts_ms": 1700000000000, + "level": "info", + "event_type": "run.started", + "message": "run started", + "run_id": "run-2000", + "seq": 1, + "payload": { + "message": "run created", + "count": 1 + } + }, + { + "ts_ms": 1700000004000, + "level": "info", + "event_type": "run.finished", + "message": "run finished", + "run_id": "run-2000", + "seq": 2, + "payload": null + } + ] +} + diff --git a/fixtures/voidbox_run_unknown_event.json b/fixtures/voidbox_run_unknown_event.json new file mode 100644 index 0000000..f5bae86 --- /dev/null +++ b/fixtures/voidbox_run_unknown_event.json @@ -0,0 +1,20 @@ +{ + "id": "run-3000", + "status": "Running", + "error": null, + "events": [ + { + "ts_ms": 1700000000000, + "event_type": "run.started", + "run_id": "run-3000", + "seq": 1 + }, + { + "ts_ms": 1700000001000, + "event_type": "workflow.planned", + "run_id": "run-3000", + "seq": 2 + } + ] +} + diff --git a/scripts/release/check_void_box_compat.sh b/scripts/release/check_void_box_compat.sh new file mode 100755 index 0000000..a37155e --- /dev/null +++ b/scripts/release/check_void_box_compat.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${1:-${VOID_BOX_BASE_URL:-http://127.0.0.1:43100}}" + +echo "[void-control] checking daemon health at ${BASE_URL}" +curl -fsS "${BASE_URL}/v1/health" >/dev/null + +echo "[void-control] running live daemon contract suite against ${BASE_URL}" +VOID_BOX_BASE_URL="${BASE_URL}" cargo test --features serde --test void_box_contract -- --ignored --nocapture diff --git a/spec/void-box-execution-telemetry-observability-v0.1.md b/spec/void-box-execution-telemetry-observability-v0.1.md new file mode 100644 index 0000000..cb31357 --- /dev/null +++ b/spec/void-box-execution-telemetry-observability-v0.1.md @@ -0,0 +1,187 @@ +# Void-Box Execution + Telemetry Observability Specification + +## Version: v0.1 + +## Scope +Define required `void-box` runtime/daemon changes so `void-control` can visualize: +- real execution flow (steps/boxes, fan-out, fan-in), +- live step state transitions, +- host/guest runtime metrics (CPU, memory, I/O, network), +- resumable event and telemetry streams. + +This spec extends: +- `spec/void-control-runtime-spec-v0.2.md` +- `spec/void-box-orchestration-integration-changes-v0.1.md` + +--- + +## 1. Problem Summary + +Current UI can only display run-level events (`RunStarted`, `WorkflowPlanned`, `RunCompleted`, etc.). +It cannot show true step-level execution, parallel groups, or resource behavior during run execution. + +Required outcome: +1. step/box lifecycle is externally observable, +2. fan-out/fan-in topology is reconstructible from runtime events, +3. telemetry is available as resumable time-series data. + +--- + +## 2. Event Contract Additions (`GET /v1/runs/{id}/events`) + +### 2.1 New Event Types + +- `StepQueued` +- `StepStarted` +- `StepStdoutChunk` +- `StepStderrChunk` +- `StepSucceeded` +- `StepFailed` +- `StepSkipped` +- `TelemetrySample` + +### 2.2 Required Payload Fields for Step Events + +```json +{ + "step_name": "aggregate", + "box_name": "writer-box", + "depends_on": ["parallel_a", "parallel_b"], + "group_id": "g2", + "attempt": 1, + "started_at": "2026-03-03T18:00:00Z", + "finished_at": "2026-03-03T18:00:01Z", + "duration_ms": 1000 +} +``` + +Notes: +- `group_id` identifies steps that may execute in parallel. +- `depends_on` is required on all `Step*` events. +- `finished_at`/`duration_ms` are required on terminal step events (`StepSucceeded`, `StepFailed`, `StepSkipped`). + +### 2.3 Telemetry Event Payload + +```json +{ + "scope": "run", + "step_name": "optional", + "box_name": "optional", + "sample_seq": 42, + "host": { + "cpu_percent": 61.2, + "rss_mb": 512, + "io_read_bytes": 12345, + "io_write_bytes": 9876, + "net_rx_bytes": 1000, + "net_tx_bytes": 800 + }, + "guest": { + "cpu_percent": 48.1, + "mem_used_mb": 384, + "load_1m": 0.72 + } +} +``` + +--- + +## 3. New Runtime APIs + +### 3.1 `GET /v1/runs/{id}/stages` + +Returns current step snapshot for graph/state reconstruction. + +```json +{ + "run_id": "ux-fanout-fanin-demo", + "attempt_id": 1, + "stages": [ + { + "step_name": "parallel_a", + "box_name": "parallel_a", + "group_id": "g1", + "state": "queued", + "depends_on": ["ingest"], + "started_at": null, + "finished_at": null, + "duration_ms": null, + "retries": 0, + "last_error": null + } + ], + "updated_at": "2026-03-03T18:00:02Z" +} +``` + +### 3.2 `GET /v1/runs/{id}/telemetry?from_seq=...&scope=run|step&step_name=...` + +```json +{ + "run_id": "ux-fanout-fanin-demo", + "attempt_id": 1, + "samples": [], + "next_seq": 43 +} +``` + +`from_seq` is resumable and idempotent, equivalent to `from_event_id` semantics on events. + +--- + +## 4. Semantics (Normative) + +1. Event `seq` remains strictly increasing per `run_id + attempt_id`. +2. Telemetry `sample_seq` is strictly increasing per `run_id + attempt_id`. +3. Step transitions must obey: + - `queued -> started -> terminal` + - terminal = `succeeded | failed | skipped` +4. For fan-out/fan-in: + - parallel siblings share `group_id`, + - fan-in step must expose all upstream dependencies in `depends_on`. +5. Runtime updates `active_stage_count` and `active_microvm_count` consistently with stage snapshot. + +--- + +## 5. Collection and Cadence + +Default telemetry sampling: +- interval: `1000ms` +- enable flag: `VOIDBOX_TELEMETRY_ENABLED=true` +- cadence override: `VOIDBOX_TELEMETRY_INTERVAL_MS=` +- per-run retention: latest 5000 samples (default) + +Guest metrics are best-effort: +- if unavailable, omit `guest` object (do not fail run). + +--- + +## 6. Compatibility and Rollout + +1. Additive change only; existing run APIs remain valid. +2. Use `#[serde(default)]` for all newly added persisted fields. +3. Keep legacy event types available in compatibility mode. +4. New APIs return empty collections when telemetry/stage data is unavailable, not 500. + +--- + +## 7. Validation / Acceptance Criteria + +1. Diamond DAG (`A -> (B,C) -> D`) produces observable parallel group: + - `B.group_id == C.group_id`, + - `D.depends_on == [B,C]`. +2. `GET /stages` reflects live transitions and final terminal state per step. +3. `GET /telemetry?from_seq` returns only newer samples and stable `next_seq`. +4. Controller restart + reconciliation can resume events and telemetry without duplication. +5. UI can render: + - live node states per step, + - fan-out/fan-in graph edges, + - CPU/memory charts per run (and optionally per step). + +--- + +## 8. Non-Goals (v0.1) + +- No WebSocket requirement (HTTP polling + resumable cursors is sufficient). +- No distributed multi-node telemetry aggregation. +- No change to scheduler ownership boundaries (controller remains run-level orchestrator). diff --git a/spec/void-box-orchestration-fixes-v0.1.md b/spec/void-box-orchestration-fixes-v0.1.md new file mode 100644 index 0000000..793c7ab --- /dev/null +++ b/spec/void-box-orchestration-fixes-v0.1.md @@ -0,0 +1,96 @@ +# Void-Box Orchestration Fixes (Cancel Idempotency + Timeout Enforcement) + +## Version: v0.1 + +## Scope +This spec defines the minimum `void-box` changes required to close the remaining +controller contract gaps observed from `void-control` live integration tests. + +Target failures: +- `cancel_idempotency` +- `policy_timeout_enforced_failure` + +This document is implementation-focused and intentionally narrow. + +--- + +## 1. Problem Statements + +### 1.1 Cancel idempotency is not terminal-event stable +Observed behavior: repeated `POST /v1/runs/{id}/cancel` returns different +`terminal_event_id` values. + +Required behavior: once a run reaches terminal state through cancel, all +subsequent cancel calls must return the same `terminal_event_id`. + +### 1.2 Policy timeout is accepted but not enforced +Observed behavior: run policy includes `stage_timeout_secs`, but a long-running +step still completes successfully instead of failing on timeout. + +Required behavior: policy timeout must be applied to runtime execution, causing +terminal `failed` status on timeout. + +--- + +## 2. Required Changes + +### 2.1 Stable terminal event identity for cancel + +#### Data model +In `RunState`, add: +- `terminal_event_id: Option` (`#[serde(default)]`) + +#### API semantics +For `POST /v1/runs/{id}/cancel`: +- If run is non-terminal: + - append cancel terminal event once, + - persist its id in `run.terminal_event_id`, + - return that id in response. +- If run is already terminal: + - do not append any new event, + - return `run.terminal_event_id` unchanged. + +#### Concurrency guard +Background run completion logic must not overwrite a run already marked +terminal by cancel. If terminal, skip completion mutation and event append. + +### 2.2 Enforce `stage_timeout_secs` in runtime execution + +#### Policy threading +Thread `policy: Option` from daemon start request into: +- workflow execution path, +- pipeline execution path. + +#### Timeout rules +- When a step has explicit timeout, keep explicit timeout. +- When a step has no explicit timeout and policy provides + `stage_timeout_secs`, apply policy timeout. +- Service-mode infinite timeout semantics remain explicit and must not be + silently overridden. + +#### Failure mapping +When timeout expires: +- step result must be failure, +- run terminal status must become `failed`, +- failure event must be emitted with timeout reason. + +--- + +## 3. Acceptance Criteria + +The following must pass against a live daemon: + +1. `void-control/tests/void_box_contract.rs::cancel_idempotency` +2. `void-control/tests/void_box_contract.rs::policy_timeout_enforced_failure` + +Expected outcomes: +- repeated cancel returns identical `terminal_event_id`; +- run started with `stage_timeout_secs=1` and long sleep step ends as `failed`. + +--- + +## 4. Non-Goals + +- No controller-side behavior changes. +- No event schema redesign. +- No change to existing start/inspect/list API shape beyond fields above. diff --git a/spec/void-box-orchestration-integration-changes-v0.1.md b/spec/void-box-orchestration-integration-changes-v0.1.md new file mode 100644 index 0000000..8215b07 --- /dev/null +++ b/spec/void-box-orchestration-integration-changes-v0.1.md @@ -0,0 +1,174 @@ +# Void-Box Changes Required for Controller Orchestration Integration + +## Version: v0.1 + +## Scope +This document defines the required void-box daemon/runtime changes to +support first-class orchestration by `void-control`. + +This is a specification only. No void-box repository code changes are +made in this repo. + +--- + +# 1. Problem Summary + +Current void-box daemon endpoints are sufficient for ad hoc run control, +but not for strict controller-runtime contract compliance in +`void-control-runtime-spec-v0.2.md`. + +Main gaps: +- No runtime `attempt_id` model. +- No stable terminal event id contract. +- No run-level execution policy input. +- No resumable event stream API (snapshot-only `/events`). +- Event typing not aligned to canonical contract names. + +--- + +# 2. Required API Changes + +## 2.1 Start API (`POST /v1/runs`) + +Current: +```json +{"file":"path","input":"optional"} +``` + +Required: +```json +{ + "run_id":"optional-controller-id", + "file":"path", + "input":"optional", + "policy":{ + "max_parallel_microvms_per_run":8, + "max_stage_retries":1, + "stage_timeout_secs":900, + "cancel_grace_period_secs":20 + } +} +``` + +Response must include: +```json +{"run_id":"...","attempt_id":1,"state":"running"} +``` + +## 2.2 Inspect API (`GET /v1/runs/{id}`) + +Response must include: +- `attempt_id` +- `active_stage_count` +- `active_microvm_count` +- `started_at` and `updated_at` in RFC3339 UTC +- `terminal_reason` and `exit_code` when terminal + +## 2.3 Stop API (`POST /v1/runs/{id}/cancel`) + +Request should accept: +```json +{"reason":"user requested"} +``` + +Response should include: +```json +{"run_id":"...","state":"canceled","terminal_event_id":"evt_..."} +``` + +## 2.4 Events API + +Add resumable API: +- `GET /v1/runs/{id}/events?from_event_id=evt_123` + +Event envelope must include: +- `event_id` (stable unique) +- `event_type` (contract-aligned canonical names) +- `run_id` +- `attempt_id` +- `timestamp` (RFC3339 UTC) +- `seq` (strictly increasing per run+attempt) +- `payload` + +Canonical event names: +- `RunStarted` +- `StageStarted` +- `StageCompleted` +- `StageFailed` +- `MicroVmSpawned` +- `MicroVmExited` +- `RunCompleted` +- `RunFailed` +- `RunCanceled` + +--- + +# 3. Runtime Semantics Changes + +## 3.1 Attempt Model + +- Introduce `attempt_id` per run. +- Increment on restart/retry. +- Emit `attempt_id` in all run/event responses. + +## 3.2 Idempotency + +- `start` idempotent for active run. +- `cancel` idempotent for terminal run. +- `inspect` idempotent and safe after daemon restart. + +## 3.3 Reconciliation Support + +Add one of: +- `GET /v1/runs?state=active` +or +- `GET /v1/runs/active` + +Controller needs this to reload non-terminal runs and resume tracking. + +--- + +# 4. Error Contract + +All non-2xx responses should use: +```json +{ + "code":"INVALID_POLICY", + "message":"...", + "retryable":false +} +``` + +Minimum codes: +- `INVALID_SPEC` +- `INVALID_POLICY` +- `NOT_FOUND` +- `ALREADY_TERMINAL` +- `RESOURCE_LIMIT_EXCEEDED` +- `INTERNAL_ERROR` + +--- + +# 5. Compatibility / Migration + +## 5.1 Transitional Mode + +For backward compatibility, daemon may keep legacy event names +(`run.started`, `run.finished`, etc.) behind a compatibility flag. + +## 5.2 Recommended Rollout + +1. Add new fields/endpoints without removing legacy behavior. +2. Enable controller integration tests against new endpoints. +3. Deprecate legacy event naming after controller cutover. + +--- + +# 6. Acceptance Criteria + +Void-box is considered orchestration-ready when: +- Controller can `start`, `inspect`, `subscribe_events(from_event_id)`, + and `stop` using only run-level operations. +- All events include stable `event_id`, strict `seq`, and `attempt_id`. +- Reconciliation after controller restart requires no manual recovery. +- Policy input is accepted and enforced by runtime. diff --git a/spec/void-control-runtime-spec-v0.1.md b/spec/void-control-runtime-spec-v0.1.md new file mode 100644 index 0000000..8d36719 --- /dev/null +++ b/spec/void-control-runtime-spec-v0.1.md @@ -0,0 +1,190 @@ +# Void Control Plane ↔ Void-Box Runtime Specification + +## Version: v0.1 + +## Scope: Single-host first, distributed-ready + +------------------------------------------------------------------------ + +# 1. Architectural Principles + +## 1.1 Clear Layer Separation + +### Void-Box (Runtime) + +- Executes a Run. +- Manages workflow graph. +- Spawns microVM per stage. +- Handles fan_out / join. +- Enforces skill isolation & policies. +- Produces structured internal events. + +### Void-Controller (Control Plane) + +- Orchestrates Runs. +- Persists desired/observed state. +- Reconciles lifecycle. +- Streams logs/events. +- Enforces global concurrency limits. +- Handles restart/remove semantics. + +⚠️ Controller does NOT orchestrate stages.\ +⚠️ Runtime does NOT persist cluster-wide lifecycle state. + +------------------------------------------------------------------------ + +# 2. Execution Model + +## 2.1 Run + +A Run represents one full workflow execution. + +Run may internally produce: - N sequential microVMs - M parallel +microVMs via fan_out + +This is internal to Void-Box. + +## 2.2 Attempt + +Each restart creates a new attempt linked to the Run. + +------------------------------------------------------------------------ + +# 3. Runtime Contract (Void-Box as Executor) + +The Controller interacts only with Run-level operations. + +Required interface: + +- start(run_id, spec, policy) → RunHandle +- stop(handle) +- inspect(handle) → RuntimeInspection +- subscribe_events(handle) → EventStream + +------------------------------------------------------------------------ + +# 4. Void-Box Responsibilities + +For a given Run: + +- Parse workflow +- Execute DAG +- Spawn microVM per stage +- Manage fan_out parallelism internally +- Propagate stage failures +- Aggregate final exit result +- Emit structured events + +Example internal flow: + +Stage A → microVM #1\ +fan_out:\ +• Stage B1 → microVM #2\ +• Stage B2 → microVM #3\ +• Stage B3 → microVM #4\ +join\ +Stage C → microVM #5 + +Controller sees only Run state transitions. + +------------------------------------------------------------------------ + +# 5. Event Mapping + +Runtime emits structured events such as: + +- RunStarted +- StageStarted +- StageCompleted +- StageFailed +- MicroVmSpawned +- MicroVmExited +- RunCompleted +- RunFailed + +Controller maps these into durable RunEvents. + +------------------------------------------------------------------------ + +# 6. Cancellation Semantics + +On cancel: + +Controller: - sets desired_state = Stopped - calls runtime.stop() + +Runtime: - terminates active microVM(s) - aborts workflow - cleans +resources - emits terminal event + +------------------------------------------------------------------------ + +# 7. Concurrency & Host Protection + +Controller owns host-level limits: + +- max_active_runs +- max_total_microvms +- max_parallel_microvms_per_run + +Runtime receives execution policy hints and must respect them. + +No silent degradation. No policy bypass. + +------------------------------------------------------------------------ + +# 8. Reconciliation Contract + +On Controller restart: + +- Reload active runs +- Inspect runtime handles +- Mark orphaned runs explicitly +- Resume tracking active executions + +Runtime must support idempotent inspection. + +------------------------------------------------------------------------ + +# 9. Log & Telemetry Ownership + +Runtime: - Produces structured logs and stage events. + +Controller: - Streams logs to API clients. - Persists metadata and +references. - Exposes metrics. + +------------------------------------------------------------------------ + +# 10. Single-Host vs Distributed + +Single-host: + +Controller → Local Runtime + +Future Distributed: + +Control Plane\ +→ Node A (Void-Box runtime)\ +→ Node B (Void-Box runtime)\ +→ Node C (Void-Box runtime) + +No changes required to workflow model. + +------------------------------------------------------------------------ + +# 11. Strict Boundary Rules + +Controller MUST NOT: - Spawn stage-level microVMs - Interpret workflow +DAG - Reimplement fan_out logic + +Runtime MUST NOT: - Persist cluster-wide desired state - Schedule across +runs - Manage distributed coordination + +------------------------------------------------------------------------ + +# 12. Core Mental Model + +Run = Atomic orchestration unit\ +Stage = Atomic isolation unit\ +microVM = Isolation boundary + +Controller orchestrates Runs.\ +Runtime orchestrates Stages. diff --git a/spec/void-control-runtime-spec-v0.2.md b/spec/void-control-runtime-spec-v0.2.md new file mode 100644 index 0000000..2a6c4be --- /dev/null +++ b/spec/void-control-runtime-spec-v0.2.md @@ -0,0 +1,237 @@ +# Void Control Plane <-> Void-Box Runtime Specification + +## Version: v0.2 (Draft) + +## Scope: Single-host first, distributed-ready + +This version preserves v0.1 boundaries and adds concrete contracts for +state, events, and error behavior. + +--- + +# 1. Architectural Boundaries + +## 1.1 Layer Separation + +### Void-Box (Runtime) + +- Executes one `Run` (workflow DAG) per accepted request. +- Schedules internal `Stage` execution and `fan_out`/`join`. +- Spawns one microVM per stage execution unit. +- Emits ordered runtime events for each run. + +### Void-Controller (Control Plane) + +- Owns desired/observed run lifecycle state. +- Enforces global host policies and admission control. +- Persists run metadata and durable event history. +- Reconciles after restarts and streams logs/events to clients. + +Controller MUST NOT orchestrate stages. +Runtime MUST NOT persist cluster-wide lifecycle state. + +--- + +# 2. Core Model + +## 2.1 Identifiers + +- `run_id`: globally unique ID for one workflow execution. +- `attempt_id`: monotonic integer starting at `1` per run. +- `stage_id`: stable ID from workflow spec. +- `event_id`: unique ID per emitted event. + +## 2.2 Run States + +`Pending -> Starting -> Running -> {Succeeded | Failed | Canceled}` + +- Terminal states are immutable. +- `Failed` means runtime error or stage failure. +- `Canceled` means user/system-initiated stop. + +## 2.3 Attempt Semantics + +- Every restart creates a new `attempt_id`. +- Only one active attempt per run at a time. +- Events and logs MUST include `attempt_id`. + +--- + +# 3. Runtime Contract (Run-Level API) + +The controller interacts with runtime using run-level calls only. + +## 3.1 `start(run_id, workflow_spec, policy) -> StartResult` + +Idempotency: +- If run is already active, return existing `handle` and current state. +- If run is terminal, return `ALREADY_TERMINAL`. + +`StartResult`: +- `handle: string` +- `attempt_id: integer` +- `state: "Starting" | "Running"` + +## 3.2 `stop(handle, reason) -> StopResult` + +- Must be idempotent. +- If already terminal, return success with terminal state. + +`StopResult`: +- `state: "Canceled" | "Succeeded" | "Failed"` +- `terminal_event_id: string` + +## 3.3 `inspect(handle) -> RuntimeInspection` + +`RuntimeInspection`: +- `run_id`, `attempt_id`, `state` +- `active_stage_count` +- `active_microvm_count` +- `started_at`, `updated_at` +- `terminal_reason?`, `exit_code?` + +## 3.4 `subscribe_events(handle, from_event_id?) -> EventStream` + +- Delivers ordered events for a single run. +- Supports resume from `from_event_id`. +- At-least-once delivery; duplicates are allowed and must preserve + `event_id`. + +--- + +# 4. Event Contract + +## 4.1 Event Envelope (Required) + +```json +{ + "event_id": "evt_123", + "event_type": "RunStarted", + "run_id": "run_123", + "attempt_id": 1, + "timestamp": "2026-02-28T19:00:00Z", + "seq": 42, + "payload": {} +} +``` + +Required fields: +- `event_id`: unique, stable. +- `seq`: strictly increasing per `run_id` + `attempt_id`. +- `timestamp`: RFC3339 UTC. + +## 4.2 Standard Event Types + +- `RunStarted` +- `StageStarted` +- `StageCompleted` +- `StageFailed` +- `MicroVmSpawned` +- `MicroVmExited` +- `RunCompleted` +- `RunFailed` +- `RunCanceled` + +`RunCompleted`, `RunFailed`, and `RunCanceled` are terminal events. + +## 4.3 Ordering Rules + +- Runtime MUST emit events in causal order per run attempt. +- Controller MUST treat `seq` as source of truth for ordering. +- Missing sequence numbers during streaming MUST trigger re-sync via + `inspect` + resumed `subscribe_events`. + +--- + +# 5. Policy Contract + +Controller passes policy hints with `start`: + +```json +{ + "max_parallel_microvms_per_run": 8, + "max_stage_retries": 1, + "stage_timeout_secs": 900, + "cancel_grace_period_secs": 20 +} +``` + +Rules: +- Runtime MUST enforce provided limits. +- Runtime MUST reject invalid or unsupported policy fields. +- No silent degradation and no policy bypass. + +--- + +# 6. Error Model + +## 6.1 Error Codes + +- `INVALID_SPEC` +- `INVALID_POLICY` +- `NOT_FOUND` +- `ALREADY_TERMINAL` +- `RESOURCE_LIMIT_EXCEEDED` +- `INTERNAL_ERROR` + +## 6.2 Error Response Shape + +```json +{ + "code": "INVALID_POLICY", + "message": "max_parallel_microvms_per_run must be > 0", + "retryable": false +} +``` + +--- + +# 7. Cancellation & Reconciliation + +## 7.1 Cancellation Flow + +Controller: +- Sets desired state to stopped. +- Calls `stop(handle, reason)`. + +Runtime: +- Terminates active microVMs (graceful, then forced after timeout). +- Emits one terminal event (`RunCanceled` unless already terminal). + +## 7.2 Reconciliation After Controller Restart + +Controller must: +- Reload non-terminal runs. +- Call `inspect` for each known handle. +- Resume stream via `subscribe_events(from_event_id=last_seen)`. +- Mark unknown/missing handles as orphaned and emit a controller-side + reconciliation event. + +Runtime `inspect` and `stop` must be idempotent. + +--- + +# 8. Strict Boundary Rules (Normative) + +Controller MUST NOT: +- Spawn stage-level microVMs. +- Interpret workflow DAG for execution. +- Reimplement runtime `fan_out` scheduling. + +Runtime MUST NOT: +- Persist cluster-wide desired state. +- Perform cross-run global scheduling decisions. +- Manage distributed coordination between nodes. + +--- + +# 9. Mental Model + +`Run` = atomic orchestration unit (control plane scope) + +`Stage` = atomic isolation unit (runtime scope) + +`microVM` = execution isolation boundary + +Controller orchestrates runs. +Runtime orchestrates stages. diff --git a/spec/void-control-terminal-console-spec-v0.1.md b/spec/void-control-terminal-console-spec-v0.1.md new file mode 100644 index 0000000..70839c3 --- /dev/null +++ b/spec/void-control-terminal-console-spec-v0.1.md @@ -0,0 +1,127 @@ +# Void Control Terminal Console Specification + +## Version: v0.1 + +## Scope +Define a terminal-first user experience (no web UI) for operating +`void-control` + `void-box` runs with interactive commands and live event/log +feedback. + +This spec complements `void-control-ux-visualization-spec-v0.1.md`. + +--- + +## 1. Product Flavor (No UI) + +Single binary experience: +- interactive console (REPL style) +- command-based orchestration +- live stream of run events/logs +- recovery/resume after disconnect + +Target users: +- developers +- infra/operators +- CI/debug workflows + +--- + +## 2. Console Command Set (v0.1) + +Required commands: +- `/run [--run-id ] [--policy ]` +- `/status ` +- `/events [--from ]` +- `/logs [--follow]` +- `/cancel [--reason ]` +- `/list [--state active|terminal]` +- `/resume ` +- `/help` +- `/exit` + +Optional quality-of-life: +- `/watch ` (status + events tail combined) +- `/policy presets` + +--- + +## 3. Runtime/Controller Contract Mapping + +Console actions map directly to run-level APIs: +- start: `POST /v1/runs` +- inspect: `GET /v1/runs/{id}` +- events: `GET /v1/runs/{id}/events?from_event_id=...` +- cancel: `POST /v1/runs/{id}/cancel` +- list: `GET /v1/runs?state=...` + +No stage-level orchestration in console logic. + +--- + +## 4. Session & Persistence Rules + +Local session file (recommended path): +- `~/.void-control/session.json` + +Persist: +- last selected run +- last seen event id per run +- active watch mode settings +- recent command history + +On console restart: +1. reload session file +2. offer `/resume ` for prior active runs +3. continue stream from `last_seen_event_id` + +--- + +## 5. Output Model (Terminal UX) + +## 5.1 Status line +- run_id, attempt_id, state, active_stage_count, active_microvm_count + +## 5.2 Event line format +`[timestamp][seq][event_type][run_id] message` + +## 5.3 Log chunk format +`[timestamp][run_id][stdout|stderr] ` + +Use ANSI colors by state: +- running blue +- succeeded green +- failed red +- canceled gray + +--- + +## 6. Error Handling UX + +All non-2xx responses must render: +- `code` +- `message` +- `retryable` + +Examples: +- `NOT_FOUND` -> suggest `/list` +- `ALREADY_TERMINAL` -> suggest `/status` or `/events` +- `INVALID_POLICY` -> suggest `/policy presets` + +--- + +## 7. Acceptance Criteria + +1. User can start a run and see state transition to terminal in console. +2. User can disconnect/restart console and resume events via `from_event_id`. +3. Repeated cancel on terminal run returns stable terminal response. +4. Structured errors are shown without losing interactive session. +5. `list --state active` and `list --state terminal` work for reconciliation. + +--- + +## 8. Non-Goals (v0.1) + +- No web frontend. +- No multi-node cluster dashboard. +- No stage-level control commands (runtime internal concern). + diff --git a/spec/void-control-ux-visualization-spec-v0.1.md b/spec/void-control-ux-visualization-spec-v0.1.md new file mode 100644 index 0000000..3b73a99 --- /dev/null +++ b/spec/void-control-ux-visualization-spec-v0.1.md @@ -0,0 +1,118 @@ +# Void Control UX Visualization Specification + +## Version: v0.1 + +## Scope +Define a user-facing visualization UX for orchestration runs using: +- graph view (run/attempt/stage/microVM relationships) +- timeline view (ordered events/logs) +- operational actions (cancel/retry/resume stream) + +This spec reuses the existing runtime/control contract. It does not add new +execution semantics. + +--- + +## 1. UX Goals + +1. Let users understand run state at a glance. +2. Make cause/effect visible (dependency edges, retries, failures, timeouts). +3. Support fast operator actions from the same screen. +4. Keep reconciliation and resume behavior explicit. + +--- + +## 2. Core Information Model (UI) + +- **Run**: top-level orchestration unit. +- **Attempt**: restart/retry boundary for a run. +- **Stage**: execution step in workflow DAG. +- **microVM**: isolated execution boundary per stage unit. +- **Event**: ordered envelope (`event_id`, `event_type`, `seq`, `timestamp`). + +Status colors: +- `running`: blue +- `succeeded`: green +- `failed`: red +- `canceled`: gray + +--- + +## 3. Primary Screens + +## 3.1 Runs List +- Columns: run_id, state, started_at, updated_at, active_stage_count, active_microvm_count. +- Filters: `state=active|terminal`, search by run_id. +- Row action: open run detail. + +## 3.2 Run Detail (Graph + Timeline) +- Left: DAG/graph panel (`Run -> Attempt -> Stage -> microVM`). +- Right: timeline panel sorted by `seq` (source of truth ordering). +- Bottom: log stream panel (stdout/stderr chunks). +- Top actions: `Cancel`, `Retry Attempt`, `Resume Stream`. + +## 3.3 Reconciliation View +- Lists non-terminal and orphan-marked runs. +- Shows last seen event id and resume status. +- Action: `Reconcile now`. + +--- + +## 4. Interaction Rules + +1. Selecting graph node filters timeline/logs to node scope. +2. Timeline hover highlights related graph nodes. +3. Resume uses `from_event_id=last_seen_event_id`. +4. Duplicate events are tolerated by `event_id` dedupe in UI state. +5. Terminal event closes live stream indicator. + +--- + +## 5. API Requirements for Frontend + +Required existing endpoints: +- `GET /v1/runs?state=active|terminal` +- `GET /v1/runs/{id}` +- `GET /v1/runs/{id}/events?from_event_id=...` +- `POST /v1/runs/{id}/cancel` + +Frontend expects: +- stable `event_id` +- monotonic `seq` per run+attempt +- `attempt_id` in run and event payloads +- structured error shape `{code,message,retryable}` + +--- + +## 6. Frontend State Shape (Recommended) + +```json +{ + "runsById": {}, + "attemptsByRun": {}, + "eventsByRunAttempt": {}, + "lastSeenEventIdByRun": {}, + "orphanRuns": [] +} +``` + +Use `seq` for ordering and `event_id` for dedupe. + +--- + +## 7. Acceptance Criteria + +1. User can open a run and see graph + timeline synchronized. +2. Cancel action updates UI terminal state without page reload. +3. Stream resume after disconnect continues from last seen event id. +4. Failed stage is visually traceable to related terminal run event. +5. Reconciliation screen clearly marks orphaned/unknown handles. + +--- + +## 8. Non-Goals (v0.1) + +- No distributed scheduler UX. +- No multi-node topology map. +- No new runtime APIs beyond current contract. + diff --git a/src/bin/normalize_fixture.rs b/src/bin/normalize_fixture.rs new file mode 100644 index 0000000..e30dd86 --- /dev/null +++ b/src/bin/normalize_fixture.rs @@ -0,0 +1,189 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; + +use void_control::contract::{ + from_void_box_run, VoidBoxPayloadValue, VoidBoxRunEventRaw, VoidBoxRunRaw, +}; + +fn main() { + let path = match env::args().nth(1) { + Some(value) => value, + None => { + eprintln!("usage: cargo run --bin normalize_fixture -- "); + std::process::exit(2); + } + }; + + let text = match fs::read_to_string(&path) { + Ok(value) => value, + Err(err) => { + eprintln!("failed to read fixture '{}': {}", path, err); + std::process::exit(1); + } + }; + + let raw = match parse_fixture(&text) { + Ok(value) => value, + Err(err) => { + eprintln!("fixture parse error: {}", err); + std::process::exit(1); + } + }; + + match from_void_box_run(&raw) { + Ok(converted) => { + println!("inspection: {:#?}", converted.inspection); + println!("diagnostics: {:#?}", converted.diagnostics); + println!("events:"); + for event in converted.events { + println!(" - {:?}", event); + } + } + Err(err) => { + eprintln!("conversion error: {:?}", err); + std::process::exit(1); + } + } +} + +fn parse_fixture(input: &str) -> Result { + let mut id: Option = None; + let mut status: Option = None; + let mut error: Option = None; + let mut events = Vec::new(); + + for (idx, line) in input.lines().enumerate() { + let line_no = idx + 1; + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if let Some(value) = trimmed.strip_prefix("id=") { + id = Some(value.to_string()); + continue; + } + if let Some(value) = trimmed.strip_prefix("status=") { + status = Some(value.to_string()); + continue; + } + if let Some(value) = trimmed.strip_prefix("error=") { + error = if value.is_empty() { + None + } else { + Some(value.to_string()) + }; + continue; + } + if let Some(value) = trimmed.strip_prefix("event|") { + events.push(parse_event_line(value, line_no)?); + continue; + } + + return Err(format!("line {}: unknown directive '{}'", line_no, trimmed)); + } + + let id = id.ok_or_else(|| "missing required field 'id='".to_string())?; + let status = status.ok_or_else(|| "missing required field 'status='".to_string())?; + + Ok(VoidBoxRunRaw { + id, + status, + error, + events, + }) +} + +fn parse_event_line(value: &str, line_no: usize) -> Result { + let mut ts_ms: Option = None; + let mut event_type: Option = None; + let mut run_id: Option = None; + let mut seq: Option = None; + let mut payload: Option> = None; + + for part in value.split('|') { + let (key, raw) = part + .split_once('=') + .ok_or_else(|| format!("line {}: invalid event token '{}'", line_no, part))?; + + match key { + "ts_ms" => { + ts_ms = Some(raw.parse::().map_err(|_| { + format!("line {}: invalid ts_ms '{}'", line_no, raw) + })?); + } + "event_type" => event_type = Some(raw.to_string()), + "run_id" => { + if !raw.is_empty() { + run_id = Some(raw.to_string()); + } + } + "seq" => { + if !raw.is_empty() { + seq = Some(raw.parse::().map_err(|_| { + format!("line {}: invalid seq '{}'", line_no, raw) + })?); + } + } + "payload" => { + if !raw.is_empty() { + payload = Some(parse_payload(raw, line_no)?); + } + } + _ => { + return Err(format!( + "line {}: unsupported event field '{}'", + line_no, key + )); + } + } + } + + Ok(VoidBoxRunEventRaw { + ts_ms: ts_ms + .ok_or_else(|| format!("line {}: event missing ts_ms", line_no))?, + event_type: event_type + .ok_or_else(|| format!("line {}: event missing event_type", line_no))?, + run_id, + seq, + payload, + }) +} + +fn parse_payload( + value: &str, + line_no: usize, +) -> Result, String> { + let mut map = BTreeMap::new(); + for pair in value.split(',') { + let (key, raw) = pair.split_once(':').ok_or_else(|| { + format!("line {}: invalid payload pair '{}'", line_no, pair) + })?; + map.insert(key.to_string(), parse_payload_value(raw)); + } + Ok(map) +} + +fn parse_payload_value(raw: &str) -> VoidBoxPayloadValue { + if raw.eq_ignore_ascii_case("null") { + return VoidBoxPayloadValue::Null; + } + if raw.eq_ignore_ascii_case("true") { + return VoidBoxPayloadValue::Bool(true); + } + if raw.eq_ignore_ascii_case("false") { + return VoidBoxPayloadValue::Bool(false); + } + if let Ok(value) = raw.parse::() { + return VoidBoxPayloadValue::Integer(value); + } + if let Ok(value) = raw.parse::() { + return VoidBoxPayloadValue::Unsigned(value); + } + if let Ok(value) = raw.parse::() { + return VoidBoxPayloadValue::Float(value); + } + VoidBoxPayloadValue::String(raw.to_string()) +} + diff --git a/src/bin/voidctl.rs b/src/bin/voidctl.rs new file mode 100644 index 0000000..090333d --- /dev/null +++ b/src/bin/voidctl.rs @@ -0,0 +1,811 @@ +#[cfg(not(feature = "serde"))] +fn main() { + eprintln!("voidctl requires --features serde"); + std::process::exit(1); +} + +#[cfg(feature = "serde")] +fn main() { + if let Err(e) = run() { + eprintln!("fatal: {e}"); + std::process::exit(1); + } +} + +#[cfg(feature = "serde")] +fn run() -> Result<(), String> { + use std::collections::BTreeMap; + use std::env; + use std::fs; + use std::io::{self, Write}; + use std::path::{Path, PathBuf}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use rustyline::completion::{Completer, Pair}; + use rustyline::error::ReadlineError; + use rustyline::highlight::Highlighter; + use rustyline::hint::Hinter; + use rustyline::history::DefaultHistory; + use rustyline::validate::Validator; + use rustyline::{Context, Editor, Helper}; + use serde::{Deserialize, Serialize}; + use void_control::contract::{ + ContractError, ContractErrorCode, EventEnvelope, ExecutionPolicy, RunState, StartRequest, + StopRequest, SubscribeEventsRequest, + }; + use void_control::runtime::VoidBoxRuntimeClient; + + let mut args = env::args().skip(1); + if let Some(cmd) = args.next() { + if cmd == "serve" { + return void_control::bridge::run_bridge(); + } + if cmd == "help" || cmd == "--help" || cmd == "-h" { + println!("voidctl commands:"); + println!(" voidctl # interactive terminal console"); + println!(" voidctl serve # start launch bridge (:43210 by default)"); + println!(" voidctl help # show this help"); + return Ok(()); + } + return Err(format!( + "unknown command '{}'. supported: serve, help", + cmd + )); + } + + #[derive(Debug, Default, Serialize, Deserialize)] + struct ConsoleSession { + last_selected_run: Option, + last_seen_event_id_by_run: BTreeMap, + recent_commands: Vec, + } + + #[derive(Default)] + struct VoidCtlHelper; + + impl Helper for VoidCtlHelper {} + impl Highlighter for VoidCtlHelper {} + impl Validator for VoidCtlHelper {} + impl Hinter for VoidCtlHelper { + type Hint = String; + } + + impl Completer for VoidCtlHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> Result<(usize, Vec), ReadlineError> { + let safe_pos = pos.min(line.len()); + let head = &line[..safe_pos]; + let tokens = head.split_whitespace().collect::>(); + + let command_candidates = [ + "/run", "/status", "/events", "/logs", "/cancel", "/list", "/watch", "/resume", + "/help", "/exit", + ]; + + let mut out = Vec::new(); + + if tokens.is_empty() || (tokens.len() == 1 && !head.ends_with(' ')) { + let prefix = tokens.first().copied().unwrap_or(""); + for cmd in command_candidates { + if cmd.starts_with(prefix) { + out.push(Pair { + display: cmd.to_string(), + replacement: cmd.to_string(), + }); + } + } + return Ok((0, out)); + } + + let cmd = tokens[0]; + let current = if head.ends_with(' ') { + "" + } else { + tokens.last().copied().unwrap_or("") + }; + + let mut options: Vec<&str> = Vec::new(); + match cmd { + "/run" => { + options.extend(["--run-id", "--policy"]); + if tokens.iter().any(|t| *t == "--policy") { + options.extend(["fast", "balanced", "safe"]); + } + } + "/events" => options.push("--from"), + "/logs" => options.push("--follow"), + "/cancel" => options.push("--reason"), + "/list" => { + options.push("--state"); + if tokens.iter().any(|t| *t == "--state") { + options.extend(["active", "terminal"]); + } + } + _ => {} + } + + let start = safe_pos.saturating_sub(current.len()); + for opt in options { + if opt.starts_with(current) { + out.push(Pair { + display: opt.to_string(), + replacement: opt.to_string(), + }); + } + } + Ok((start, out)) + } + } + + #[derive(Debug)] + enum Command { + Run { + spec: String, + run_id: Option, + policy: Option, + }, + Status { + run_id: String, + }, + Events { + run_id: String, + from_event_id: Option, + }, + Logs { + run_id: String, + follow: bool, + }, + Cancel { + run_id: String, + reason: String, + }, + List { + state: Option, + }, + Watch { + run_id: String, + }, + Resume { + run_id: String, + }, + Help, + Exit, + Empty, + } + + fn default_policy() -> ExecutionPolicy { + ExecutionPolicy { + max_parallel_microvms_per_run: 2, + max_stage_retries: 1, + stage_timeout_secs: 300, + cancel_grace_period_secs: 10, + } + } + + fn policy_from_preset(name: &str) -> Option { + match name { + "fast" => Some(ExecutionPolicy { + max_parallel_microvms_per_run: 4, + max_stage_retries: 0, + stage_timeout_secs: 120, + cancel_grace_period_secs: 5, + }), + "balanced" => Some(default_policy()), + "safe" => Some(ExecutionPolicy { + max_parallel_microvms_per_run: 1, + max_stage_retries: 2, + stage_timeout_secs: 900, + cancel_grace_period_secs: 20, + }), + _ => None, + } + } + + fn parse_policy(raw: Option) -> Result { + let Some(raw) = raw else { + return Ok(default_policy()); + }; + if let Some(p) = policy_from_preset(&raw.to_ascii_lowercase()) { + return Ok(p); + } + let value: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| format!("invalid policy JSON: {e}"))?; + let policy = ExecutionPolicy { + max_parallel_microvms_per_run: value + .get("max_parallel_microvms_per_run") + .and_then(|v| v.as_u64()) + .unwrap_or(2) as u32, + max_stage_retries: value + .get("max_stage_retries") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32, + stage_timeout_secs: value + .get("stage_timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(300) as u32, + cancel_grace_period_secs: value + .get("cancel_grace_period_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as u32, + }; + Ok(policy) + } + + fn parse_command(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Ok(Command::Empty); + } + if !line.starts_with('/') { + return Err("commands must start with '/'".to_string()); + } + let mut tokens = line.split_whitespace(); + let head = tokens.next().unwrap_or_default(); + match head { + "/run" => { + let spec = tokens + .next() + .ok_or_else(|| "usage: /run [--run-id ] [--policy ]".to_string())? + .to_string(); + let mut run_id = None; + let mut policy = None; + let rest = tokens.collect::>(); + let mut idx = 0usize; + while idx < rest.len() { + match rest[idx] { + "--run-id" => { + idx += 1; + if idx >= rest.len() { + return Err("missing value for --run-id".to_string()); + } + run_id = Some(rest[idx].to_string()); + } + "--policy" => { + idx += 1; + if idx >= rest.len() { + return Err("missing value for --policy".to_string()); + } + policy = Some(rest[idx].to_string()); + } + other => { + return Err(format!("unknown /run option '{other}'")); + } + } + idx += 1; + } + Ok(Command::Run { + spec, + run_id, + policy, + }) + } + "/status" => Ok(Command::Status { + run_id: tokens + .next() + .ok_or_else(|| "usage: /status ".to_string())? + .to_string(), + }), + "/events" => { + let run_id = tokens + .next() + .ok_or_else(|| "usage: /events [--from ]".to_string())? + .to_string(); + let mut from_event_id = None; + let rest = tokens.collect::>(); + let mut idx = 0usize; + while idx < rest.len() { + match rest[idx] { + "--from" => { + idx += 1; + if idx >= rest.len() { + return Err("missing value for --from".to_string()); + } + from_event_id = Some(rest[idx].to_string()); + } + other => return Err(format!("unknown /events option '{other}'")), + } + idx += 1; + } + Ok(Command::Events { + run_id, + from_event_id, + }) + } + "/logs" => { + let run_id = tokens + .next() + .ok_or_else(|| "usage: /logs [--follow]".to_string())? + .to_string(); + let mut follow = false; + for token in tokens { + if token == "--follow" { + follow = true; + } else { + return Err(format!("unknown /logs option '{token}'")); + } + } + Ok(Command::Logs { run_id, follow }) + } + "/cancel" => { + let run_id = tokens + .next() + .ok_or_else(|| "usage: /cancel [--reason ]".to_string())? + .to_string(); + let reason = if let Some(pos) = line.find("--reason") { + line[pos + "--reason".len()..].trim().to_string() + } else { + "user requested".to_string() + }; + Ok(Command::Cancel { run_id, reason }) + } + "/list" => { + let mut state = None; + let rest = tokens.collect::>(); + let mut idx = 0usize; + while idx < rest.len() { + match rest[idx] { + "--state" => { + idx += 1; + if idx >= rest.len() { + return Err("missing value for --state".to_string()); + } + state = Some(rest[idx].to_string()); + } + other => return Err(format!("unknown /list option '{other}'")), + } + idx += 1; + } + Ok(Command::List { state }) + } + "/watch" => Ok(Command::Watch { + run_id: tokens + .next() + .ok_or_else(|| "usage: /watch ".to_string())? + .to_string(), + }), + "/resume" => Ok(Command::Resume { + run_id: tokens + .next() + .ok_or_else(|| "usage: /resume ".to_string())? + .to_string(), + }), + "/help" => Ok(Command::Help), + "/exit" | "/quit" => Ok(Command::Exit), + other => Err(format!("unknown command '{other}'")), + } + } + + fn help_text() -> &'static str { + "Commands: + /run [--run-id ] [--policy ] + /status + /events [--from ] + /logs [--follow] + /cancel [--reason ] + /list [--state active|terminal] + /watch + /resume + /help + /exit + +Policy presets: fast | balanced | safe" + } + + fn default_logo() -> &'static str { + r#" + _ __ _ __ ______ __ __ +| | / /___ (_)___/ / / ____/___ ____ / /__________ / / +| | / / __ \/ / __ / / / / __ \/ __ \/ __/ ___/ __ \/ / +| |/ / /_/ / / /_/ / / /___/ /_/ / / / / /_/ / / /_/ / / +|___/\____/_/\__,_/ \____/\____/_/ /_/\__/_/ \____/_/ +"# + } + + fn load_logo() -> String { + if let Ok(path) = env::var("VOID_CONTROL_LOGO_PATH") { + if let Ok(content) = fs::read_to_string(path) { + return content; + } + } + default_logo().to_string() + } + + fn state_color(state: RunState) -> &'static str { + match state { + RunState::Running | RunState::Starting | RunState::Pending => "\x1b[34m", + RunState::Succeeded => "\x1b[32m", + RunState::Failed => "\x1b[31m", + RunState::Canceled => "\x1b[90m", + } + } + + fn reset_color() -> &'static str { + "\x1b[0m" + } + + fn print_event(e: &EventEnvelope) { + println!( + "[{}][seq={}][{:?}][run={}] {}", + e.timestamp, + e.seq, + e.event_type, + e.run_id, + if e.payload.is_empty() { + String::new() + } else { + format!("{:?}", e.payload) + } + ); + } + + fn print_event_live(e: &EventEnvelope) { + print!("\r\x1b[2K"); + print_event(e); + } + + fn print_contract_error(err: &ContractError) { + println!( + "error: code={:?} retryable={} message={}", + err.code, err.retryable, err.message + ); + match err.code { + ContractErrorCode::NotFound => println!("hint: use /list to discover available runs"), + ContractErrorCode::AlreadyTerminal => { + println!("hint: run is terminal, use /status or /events") + } + ContractErrorCode::InvalidPolicy => { + println!("hint: use /run ... --policy balanced|fast|safe") + } + _ => {} + } + } + + fn session_path() -> PathBuf { + if let Ok(custom) = env::var("VOID_CONTROL_SESSION_FILE") { + return PathBuf::from(custom); + } + if let Ok(home) = env::var("HOME") { + return Path::new(&home).join(".void-control/session.json"); + } + PathBuf::from("./.void-control-session.json") + } + + fn load_session(path: &Path) -> ConsoleSession { + let Ok(content) = fs::read_to_string(path) else { + return ConsoleSession::default(); + }; + serde_json::from_str(&content).unwrap_or_default() + } + + fn save_session(path: &Path, session: &ConsoleSession) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("create session dir failed: {e}"))?; + } + let serialized = + serde_json::to_string_pretty(session).map_err(|e| format!("serialize session failed: {e}"))?; + fs::write(path, serialized).map_err(|e| format!("write session failed: {e}")) + } + + fn run_id_to_handle(run_id: &str) -> String { + format!("vb:{run_id}") + } + + fn generate_run_id() -> String { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("run-{millis}") + } + + fn print_status_line(inspect: &void_control::contract::RuntimeInspection) { + let color = state_color(inspect.state); + println!( + "{}run_id={} attempt={} state={:?} active_stage_count={} active_microvm_count={}{}", + color, + inspect.run_id, + inspect.attempt_id, + inspect.state, + inspect.active_stage_count, + inspect.active_microvm_count, + reset_color() + ); + } + + fn format_status_bar( + inspect: &void_control::contract::RuntimeInspection, + last_event_id: Option<&str>, + ) -> String { + format!( + "[run={} attempt={} state={:?} stages={} microvms={} last_event={}]", + inspect.run_id, + inspect.attempt_id, + inspect.state, + inspect.active_stage_count, + inspect.active_microvm_count, + last_event_id.unwrap_or("-") + ) + } + + fn render_status_bar(bar: &str) { + print!("\r\x1b[2K{bar}"); + let _ = io::stdout().flush(); + } + + fn stream_run( + client: &VoidBoxRuntimeClient, + session: &mut ConsoleSession, + run_id: &str, + logs_only: bool, + show_status: bool, + ) { + let handle = run_id_to_handle(run_id); + println!("streaming run={} (Ctrl+C to stop)", run_id); + loop { + let from = session.last_seen_event_id_by_run.get(run_id).cloned(); + match client.subscribe_events(SubscribeEventsRequest { + handle: handle.clone(), + from_event_id: from, + }) { + Ok(events) => { + for event in &events { + if logs_only && event.payload.is_empty() { + continue; + } + print_event_live(event); + } + if let Some(last) = events.last() { + session + .last_seen_event_id_by_run + .insert(run_id.to_string(), last.event_id.clone()); + } + } + Err(err) => { + print_contract_error(&err); + break; + } + } + + match client.inspect(&handle) { + Ok(inspect) => { + if show_status { + let last_event = session + .last_seen_event_id_by_run + .get(run_id) + .map(String::as_str); + let bar = format_status_bar(&inspect, last_event); + render_status_bar(&bar); + } + if inspect.state.is_terminal() { + print!("\r\x1b[2K"); + print_status_line(&inspect); + println!("terminal state reached: {:?}", inspect.state); + break; + } + } + Err(err) => { + print_contract_error(&err); + break; + } + } + + std::thread::sleep(Duration::from_millis(client.poll_interval_ms())); + } + print!("\r\x1b[2K"); + let _ = io::stdout().flush(); + } + + let base_url = + env::var("VOID_BOX_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:43100".to_string()); + let client = VoidBoxRuntimeClient::new(base_url.clone(), 250); + let session_file = session_path(); + let mut session = load_session(&session_file); + let mut rl = Editor::::new() + .map_err(|e| format!("readline init failed: {e}"))?; + rl.set_helper(Some(VoidCtlHelper)); + for cmd in &session.recent_commands { + let _ = rl.add_history_entry(cmd.as_str()); + } + + println!("{}", load_logo()); + println!("voidctl connected to {base_url}"); + println!("{}", help_text()); + + loop { + let line = match rl.readline("voidctl> ") { + Ok(line) => line, + Err(ReadlineError::Interrupted) => { + println!("^C"); + continue; + } + Err(ReadlineError::Eof) => { + println!(); + break; + } + Err(e) => return Err(format!("stdin read failed: {e}")), + }; + let trimmed = line.trim().to_string(); + if !trimmed.is_empty() { + let _ = rl.add_history_entry(trimmed.as_str()); + session.recent_commands.push(trimmed.clone()); + if session.recent_commands.len() > 200 { + let keep_from = session.recent_commands.len().saturating_sub(200); + session.recent_commands = session.recent_commands[keep_from..].to_vec(); + } + } + + let parsed = match parse_command(&trimmed) { + Ok(cmd) => cmd, + Err(e) => { + println!("{e}"); + continue; + } + }; + + match parsed { + Command::Empty => continue, + Command::Help => println!("{}", help_text()), + Command::Exit => { + save_session(&session_file, &session)?; + println!("bye"); + break; + } + Command::Run { + spec, + run_id, + policy, + } => { + let run_id = run_id.unwrap_or_else(generate_run_id); + let policy = match parse_policy(policy) { + Ok(p) => p, + Err(e) => { + println!("{e}"); + continue; + } + }; + match client.start(StartRequest { + run_id: run_id.clone(), + workflow_spec: spec, + policy, + }) { + Ok(started) => { + session.last_selected_run = Some(run_id.clone()); + println!( + "started run_id={} handle={} attempt_id={} state={:?}", + run_id, started.handle, started.attempt_id, started.state + ); + } + Err(err) => print_contract_error(&err), + } + } + Command::Status { run_id } => { + let handle = run_id_to_handle(&run_id); + match client.inspect(&handle) { + Ok(inspect) => { + session.last_selected_run = Some(run_id); + print_status_line(&inspect); + println!( + "started_at={} updated_at={}", + inspect.started_at, inspect.updated_at + ); + if let Some(reason) = inspect.terminal_reason { + println!("terminal_reason={reason}"); + } + if let Some(code) = inspect.exit_code { + println!("exit_code={code}"); + } + } + Err(err) => print_contract_error(&err), + } + } + Command::Events { + run_id, + from_event_id, + } => { + let handle = run_id_to_handle(&run_id); + match client.subscribe_events(SubscribeEventsRequest { + handle, + from_event_id, + }) { + Ok(events) => { + for event in &events { + print_event(event); + } + if let Some(last) = events.last() { + session + .last_seen_event_id_by_run + .insert(run_id.clone(), last.event_id.clone()); + } + session.last_selected_run = Some(run_id); + } + Err(err) => print_contract_error(&err), + } + } + Command::Logs { run_id, follow } => { + let handle = run_id_to_handle(&run_id); + let from = session.last_seen_event_id_by_run.get(&run_id).cloned(); + match client.subscribe_events(SubscribeEventsRequest { + handle, + from_event_id: from, + }) { + Ok(events) => { + for event in &events { + if !event.payload.is_empty() { + print_event(event); + } + } + if let Some(last) = events.last() { + session + .last_seen_event_id_by_run + .insert(run_id.clone(), last.event_id.clone()); + } + session.last_selected_run = Some(run_id.clone()); + if follow { + stream_run(&client, &mut session, &run_id, true, true); + } + } + Err(err) => print_contract_error(&err), + } + } + Command::Cancel { run_id, reason } => { + let handle = run_id_to_handle(&run_id); + match client.stop(StopRequest { handle, reason }) { + Ok(stopped) => { + println!( + "stopped run_id={} state={:?} terminal_event_id={}", + run_id, stopped.state, stopped.terminal_event_id + ); + session.last_selected_run = Some(run_id.clone()); + session.last_seen_event_id_by_run.insert( + run_id, + stopped.terminal_event_id, + ); + } + Err(err) => print_contract_error(&err), + } + } + Command::List { state } => { + let filter = state.as_deref(); + match client.list_runs(filter) { + Ok(runs) => { + println!("runs={}", runs.len()); + for r in runs { + let color = state_color(r.state); + println!( + "{}run_id={} attempt={} state={:?} active_stage_count={} active_microvm_count={}{}", + color, + r.run_id, + r.attempt_id, + r.state, + r.active_stage_count, + r.active_microvm_count, + reset_color() + ); + } + } + Err(err) => print_contract_error(&err), + } + } + Command::Watch { run_id } => { + session.last_selected_run = Some(run_id.clone()); + stream_run(&client, &mut session, &run_id, false, true); + } + Command::Resume { run_id } => { + session.last_selected_run = Some(run_id.clone()); + stream_run(&client, &mut session, &run_id, false, true); + } + } + + if let Err(e) = save_session(&session_file, &session) { + eprintln!("warn: {e}"); + } + } + + Ok(()) +} diff --git a/src/bridge.rs b/src/bridge.rs new file mode 100644 index 0000000..bb38263 --- /dev/null +++ b/src/bridge.rs @@ -0,0 +1,268 @@ +#[cfg(feature = "serde")] +pub fn run_bridge() -> Result<(), String> { + use std::env; + use std::fs::{self, OpenOptions}; + use std::io::Write; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde::{Deserialize, Serialize}; + use tiny_http::{Header, Method, Response, Server, StatusCode}; + + use crate::contract::{ExecutionPolicy, RunState, StartRequest}; + use crate::runtime::VoidBoxRuntimeClient; + + #[derive(Debug, Deserialize)] + struct LaunchRequest { + run_id: Option, + file: Option, + spec_text: Option, + spec_format: Option, + policy: Option, + } + + #[derive(Debug, Deserialize)] + struct PolicyJson { + max_parallel_microvms_per_run: Option, + max_stage_retries: Option, + stage_timeout_secs: Option, + cancel_grace_period_secs: Option, + } + + #[derive(Debug, Serialize)] + struct LaunchResponse { + run_id: String, + attempt_id: u32, + state: String, + file: String, + } + + #[derive(Debug, Serialize)] + struct ApiError { + code: &'static str, + message: String, + retryable: bool, + } + + fn default_policy() -> ExecutionPolicy { + ExecutionPolicy { + max_parallel_microvms_per_run: 2, + max_stage_retries: 1, + stage_timeout_secs: 300, + cancel_grace_period_secs: 10, + } + } + + fn policy_from_json(raw: Option) -> ExecutionPolicy { + let defaults = default_policy(); + let Some(raw) = raw else { + return defaults; + }; + ExecutionPolicy { + max_parallel_microvms_per_run: raw + .max_parallel_microvms_per_run + .unwrap_or(defaults.max_parallel_microvms_per_run), + max_stage_retries: raw + .max_stage_retries + .unwrap_or(defaults.max_stage_retries), + stage_timeout_secs: raw.stage_timeout_secs.unwrap_or(defaults.stage_timeout_secs), + cancel_grace_period_secs: raw + .cancel_grace_period_secs + .unwrap_or(defaults.cancel_grace_period_secs), + } + } + + fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + } + + fn next_run_id() -> String { + format!("ui-{}", now_ms()) + } + + fn run_id_from_handle(handle: &str) -> String { + handle + .strip_prefix("void-box:") + .or_else(|| handle.strip_prefix("vb:")) + .unwrap_or(handle) + .to_string() + } + + fn state_to_str(state: RunState) -> &'static str { + match state { + RunState::Pending => "pending", + RunState::Starting => "starting", + RunState::Running => "running", + RunState::Succeeded => "succeeded", + RunState::Failed => "failed", + RunState::Canceled => "cancelled", + } + } + + fn infer_ext(spec_format: Option<&str>, spec_text: &str) -> &'static str { + if let Some(fmt) = spec_format { + let f = fmt.to_ascii_lowercase(); + if f.contains("json") { + return "json"; + } + if f.contains("yaml") || f.contains("yml") { + return "yaml"; + } + } + if spec_text.trim_start().starts_with('{') || spec_text.trim_start().starts_with('[') { + "json" + } else { + "yaml" + } + } + + fn write_spec_file(spec_dir: &Path, spec_text: &str, spec_format: Option<&str>) -> Result { + fs::create_dir_all(spec_dir) + .map_err(|e| format!("failed to create spec dir {}: {e}", spec_dir.display()))?; + let ext = infer_ext(spec_format, spec_text); + let filename = format!("spec-{}-{}.{}", now_ms(), std::process::id(), ext); + let path = spec_dir.join(filename); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .map_err(|e| format!("failed to create spec file {}: {e}", path.display()))?; + file.write_all(spec_text.as_bytes()) + .and_then(|_| file.flush()) + .map_err(|e| format!("failed to write spec file {}: {e}", path.display()))?; + Ok(path.display().to_string()) + } + + fn make_header(name: &str, value: &str) -> Header { + Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header") + } + + fn json_response(status: u16, body: &T) -> Response>> { + let payload = serde_json::to_vec(body).unwrap_or_else(|_| b"{\"code\":\"INTERNAL_ERROR\",\"message\":\"serialization failed\",\"retryable\":true}".to_vec()); + Response::from_data(payload) + .with_status_code(StatusCode(status)) + .with_header(make_header("Content-Type", "application/json")) + .with_header(make_header("Access-Control-Allow-Origin", "*")) + .with_header(make_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")) + .with_header(make_header("Access-Control-Allow-Headers", "Content-Type")) + } + + fn json_error(status: u16, code: &'static str, message: String, retryable: bool) -> Response>> { + json_response( + status, + &ApiError { + code, + message, + retryable, + }, + ) + } + + let listen = env::var("VOID_CONTROL_BRIDGE_LISTEN").unwrap_or_else(|_| "127.0.0.1:43210".to_string()); + let base_url = env::var("VOID_BOX_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:43100".to_string()); + let spec_dir = env::var("VOID_CONTROL_SPEC_DIR").unwrap_or_else(|_| "/tmp/void-control/specs".to_string()); + let spec_dir_path = PathBuf::from(spec_dir); + + let server = Server::http(&listen).map_err(|e| format!("listen {listen} failed: {e}"))?; + let client = VoidBoxRuntimeClient::new(base_url.clone(), 250); + println!("voidctl bridge listening on http://{listen} -> {base_url}"); + + for mut req in server.incoming_requests() { + let method = req.method().clone(); + let path = req.url().to_string(); + + if method == Method::Options { + let _ = req.respond( + Response::empty(204) + .with_header(make_header("Access-Control-Allow-Origin", "*")) + .with_header(make_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")) + .with_header(make_header("Access-Control-Allow-Headers", "Content-Type")), + ); + continue; + } + + if method == Method::Get && path == "/v1/health" { + let _ = req.respond(json_response(200, &serde_json::json!({"status":"ok","service":"voidctl-bridge"}))); + continue; + } + + if method == Method::Post && path == "/v1/launch" { + let mut body = String::new(); + if let Err(e) = req.as_reader().read_to_string(&mut body) { + let _ = req.respond(json_error(400, "INVALID_SPEC", format!("failed to read request body: {e}"), false)); + continue; + } + let launch: LaunchRequest = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + let _ = req.respond(json_error(400, "INVALID_SPEC", format!("invalid JSON body: {e}"), false)); + continue; + } + }; + + let file = if let Some(spec_text) = launch.spec_text.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + match write_spec_file(&spec_dir_path, spec_text, launch.spec_format.as_deref()) { + Ok(path) => path, + Err(e) => { + let _ = req.respond(json_error(500, "INTERNAL_ERROR", e, true)); + continue; + } + } + } else if let Some(file) = launch.file.as_ref().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()) { + file + } else { + let _ = req.respond(json_error( + 400, + "INVALID_SPEC", + "provide either `spec_text` or `file`".to_string(), + false, + )); + continue; + }; + + let run_id = launch.run_id.filter(|s| !s.trim().is_empty()).unwrap_or_else(next_run_id); + let policy = policy_from_json(launch.policy); + if let Err(msg) = policy.validate() { + let _ = req.respond(json_error(400, "INVALID_POLICY", msg.to_string(), false)); + continue; + } + + match client.start(StartRequest { + run_id: run_id.clone(), + workflow_spec: file.clone(), + policy, + }) { + Ok(started) => { + let response = LaunchResponse { + run_id: run_id_from_handle(&started.handle), + attempt_id: started.attempt_id, + state: state_to_str(started.state).to_string(), + file, + }; + let _ = req.respond(json_response(200, &response)); + } + Err(e) => { + let _ = req.respond(json_error( + 500, + "INTERNAL_ERROR", + e.message, + e.retryable, + )); + } + } + continue; + } + + let _ = req.respond(json_error( + 404, + "NOT_FOUND", + format!("no route for {} {}", method.as_str(), path), + false, + )); + } + + Ok(()) +} diff --git a/src/contract/api.rs b/src/contract/api.rs new file mode 100644 index 0000000..b744305 --- /dev/null +++ b/src/contract/api.rs @@ -0,0 +1,46 @@ +use crate::contract::{ExecutionPolicy, RunState}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartRequest { + pub run_id: String, + pub workflow_spec: String, + pub policy: ExecutionPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartResult { + pub handle: String, + pub attempt_id: u32, + pub state: RunState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StopRequest { + pub handle: String, + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StopResult { + pub state: RunState, + pub terminal_event_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeInspection { + pub run_id: String, + pub attempt_id: u32, + pub state: RunState, + pub active_stage_count: u32, + pub active_microvm_count: u32, + pub started_at: String, + pub updated_at: String, + pub terminal_reason: Option, + pub exit_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscribeEventsRequest { + pub handle: String, + pub from_event_id: Option, +} diff --git a/src/contract/compat.rs b/src/contract/compat.rs new file mode 100644 index 0000000..a616a1d --- /dev/null +++ b/src/contract/compat.rs @@ -0,0 +1,429 @@ +use std::collections::BTreeMap; + +use crate::contract::{ + ContractError, ContractErrorCode, EventEnvelope, EventSequenceTracker, EventType, RunState, + RuntimeInspection, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum VoidBoxPayloadValue { + String(String), + Bool(bool), + Integer(i64), + Unsigned(u64), + Float(f64), + Null, + Unsupported(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VoidBoxRunEventRaw { + pub ts_ms: u64, + pub event_type: String, + pub run_id: Option, + pub seq: Option, + pub payload: Option>, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct VoidBoxRunRaw { + pub id: String, + pub status: String, + pub error: Option, + pub events: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConversionDiagnostics { + pub dropped_unknown_event_types: usize, + pub dropped_missing_run_id: usize, + pub seq_fallback_assigned: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConvertedRunView { + pub inspection: RuntimeInspection, + pub events: Vec, + pub diagnostics: ConversionDiagnostics, +} + +pub fn map_void_box_status(status: &str) -> Option { + match status.to_ascii_lowercase().as_str() { + "running" => Some(RunState::Running), + "completed" => Some(RunState::Succeeded), + "failed" => Some(RunState::Failed), + "cancelled" | "canceled" => Some(RunState::Canceled), + _ => None, + } +} + +pub fn map_void_box_event_type(event_type: &str) -> Option { + match event_type { + "run.started" => Some(EventType::RunStarted), + "run.finished" => Some(EventType::RunCompleted), + "run.failed" => Some(EventType::RunFailed), + "run.cancelled" | "run.canceled" => Some(EventType::RunCanceled), + _ => None, + } +} + +pub fn from_void_box_run(run: &VoidBoxRunRaw) -> Result { + let state = map_void_box_status(&run.status).ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("unknown void-box status '{}'", run.status), + false, + ) + })?; + + let mut diagnostics = ConversionDiagnostics::default(); + let mut events = Vec::new(); + let mut last_seq: Option = None; + + for raw in &run.events { + let mapped_type = match map_void_box_event_type(&raw.event_type) { + Some(value) => value, + None => { + diagnostics.dropped_unknown_event_types += 1; + continue; + } + }; + + if raw.run_id.is_none() { + diagnostics.dropped_missing_run_id += 1; + continue; + } + + let seq = match raw.seq { + Some(explicit) => explicit, + None => { + diagnostics.seq_fallback_assigned += 1; + last_seq.unwrap_or(0) + 1 + } + }; + last_seq = Some(seq); + + events.push(EventEnvelope { + event_id: format!("evt_{}_{}", run.id, seq), + event_type: mapped_type, + run_id: run.id.clone(), + attempt_id: 1, + timestamp: ts_ms_to_string(raw.ts_ms), + seq, + payload: flatten_payload(raw.payload.as_ref()), + }); + + } + + let mut tracker = EventSequenceTracker::default(); + for event in &events { + tracker.observe(event).map_err(|e| { + ContractError::new(ContractErrorCode::InvalidSpec, e, false) + })?; + } + + let (started_at, updated_at) = derive_started_updated(&events); + + let inspection = RuntimeInspection { + run_id: run.id.clone(), + attempt_id: 1, + state, + active_stage_count: 0, + active_microvm_count: 0, + started_at, + updated_at, + terminal_reason: run.error.clone(), + exit_code: None, + }; + + Ok(ConvertedRunView { + inspection, + events, + diagnostics, + }) +} + +fn flatten_payload( + payload: Option<&BTreeMap>, +) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(payload) = payload else { + return out; + }; + + for (key, value) in payload { + let maybe = match value { + VoidBoxPayloadValue::String(v) => Some(v.clone()), + VoidBoxPayloadValue::Bool(v) => Some(v.to_string()), + VoidBoxPayloadValue::Integer(v) => Some(v.to_string()), + VoidBoxPayloadValue::Unsigned(v) => Some(v.to_string()), + VoidBoxPayloadValue::Float(v) => Some(v.to_string()), + VoidBoxPayloadValue::Null => None, + VoidBoxPayloadValue::Unsupported(_) => None, + }; + if let Some(value) = maybe { + out.insert(key.clone(), value); + } + } + out +} + +fn derive_started_updated(events: &[EventEnvelope]) -> (String, String) { + if events.is_empty() { + return ("0Z".to_string(), "0Z".to_string()); + } + + let mut min = u64::MAX; + let mut max = 0u64; + for event in events { + if let Some(ms) = parse_ts_ms(&event.timestamp) { + if ms < min { + min = ms; + } + if ms > max { + max = ms; + } + } + } + + if min == u64::MAX { + return ("0Z".to_string(), "0Z".to_string()); + } + + (ts_ms_to_string(min), ts_ms_to_string(max)) +} + +fn parse_ts_ms(ts: &str) -> Option { + ts.strip_suffix("ms")?.parse::().ok() +} + +fn ts_ms_to_string(ts_ms: u64) -> String { + format!("{ts_ms}ms") +} + +#[cfg(test)] +mod tests { + use super::{ + from_void_box_run, map_void_box_event_type, map_void_box_status, VoidBoxPayloadValue, + VoidBoxRunEventRaw, VoidBoxRunRaw, + }; + use crate::contract::{ContractErrorCode, EventType, RunState}; + use std::collections::BTreeMap; + + fn make_event(ts_ms: u64, event_type: &str, seq: Option) -> VoidBoxRunEventRaw { + VoidBoxRunEventRaw { + ts_ms, + event_type: event_type.to_string(), + run_id: Some("run-1".to_string()), + seq, + payload: None, + } + } + + #[test] + fn maps_void_box_status_values() { + assert_eq!(map_void_box_status("Running"), Some(RunState::Running)); + assert_eq!(map_void_box_status("Completed"), Some(RunState::Succeeded)); + assert_eq!(map_void_box_status("Failed"), Some(RunState::Failed)); + assert_eq!(map_void_box_status("Cancelled"), Some(RunState::Canceled)); + } + + #[test] + fn maps_void_box_terminal_event_strings() { + assert_eq!( + map_void_box_event_type("run.started"), + Some(EventType::RunStarted) + ); + assert_eq!( + map_void_box_event_type("run.finished"), + Some(EventType::RunCompleted) + ); + assert_eq!( + map_void_box_event_type("run.failed"), + Some(EventType::RunFailed) + ); + assert_eq!( + map_void_box_event_type("run.cancelled"), + Some(EventType::RunCanceled) + ); + } + + #[test] + fn maps_completed_run_to_succeeded_with_terminal_event() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Completed".to_string(), + error: None, + events: vec![make_event(1000, "run.finished", Some(1))], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.inspection.state, RunState::Succeeded); + assert_eq!(converted.events.len(), 1); + assert_eq!(converted.events[0].event_type, EventType::RunCompleted); + } + + #[test] + fn maps_cancelled_run_status_to_canceled() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Cancelled".to_string(), + error: Some("stopped".to_string()), + events: vec![make_event(1000, "run.cancelled", Some(1))], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.inspection.state, RunState::Canceled); + } + + #[test] + fn drops_unknown_event_types_and_counts_them() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![ + make_event(1000, "run.started", Some(1)), + make_event(1001, "workflow.planned", Some(2)), + ], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.events.len(), 1); + assert_eq!(converted.diagnostics.dropped_unknown_event_types, 1); + } + + #[test] + fn errors_on_unknown_status() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Queued".to_string(), + error: None, + events: vec![], + }; + + let err = from_void_box_run(&run).expect_err("unknown status"); + assert_eq!(err.code, ContractErrorCode::InvalidSpec); + } + + #[test] + fn fills_attempt_id_with_one() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![make_event(1000, "run.started", Some(1))], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.inspection.attempt_id, 1); + assert_eq!(converted.events[0].attempt_id, 1); + } + + #[test] + fn uses_fallback_seq_when_missing_and_keeps_monotonicity() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![ + make_event(1000, "run.started", None), + make_event(1001, "run.finished", None), + ], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.events[0].seq, 1); + assert_eq!(converted.events[1].seq, 2); + assert_eq!(converted.diagnostics.seq_fallback_assigned, 2); + } + + #[test] + fn errors_when_explicit_seq_is_non_monotonic() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![ + make_event(1000, "run.started", Some(2)), + make_event(1001, "run.finished", Some(1)), + ], + }; + + let err = from_void_box_run(&run).expect_err("non monotonic"); + assert_eq!(err.code, ContractErrorCode::InvalidSpec); + } + + #[test] + fn inspection_timestamps_come_from_event_bounds() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![ + make_event(1200, "run.started", Some(1)), + make_event(1100, "run.finished", Some(2)), + ], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.inspection.started_at, "1100ms"); + assert_eq!(converted.inspection.updated_at, "1200ms"); + } + + #[test] + fn terminal_reason_from_run_error() { + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Failed".to_string(), + error: Some("boom".to_string()), + events: vec![make_event(1000, "run.failed", Some(1))], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.inspection.terminal_reason.as_deref(), Some("boom")); + } + + #[test] + fn drops_missing_run_id() { + let mut bad = make_event(1000, "run.started", Some(1)); + bad.run_id = None; + + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![bad], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert!(converted.events.is_empty()); + assert_eq!(converted.diagnostics.dropped_missing_run_id, 1); + } + + #[test] + fn flattens_scalar_payload_values() { + let mut payload = BTreeMap::new(); + payload.insert("a".to_string(), VoidBoxPayloadValue::String("x".to_string())); + payload.insert("b".to_string(), VoidBoxPayloadValue::Bool(true)); + payload.insert("c".to_string(), VoidBoxPayloadValue::Unsupported("{}".to_string())); + + let run = VoidBoxRunRaw { + id: "run-1".to_string(), + status: "Running".to_string(), + error: None, + events: vec![VoidBoxRunEventRaw { + ts_ms: 1000, + event_type: "run.started".to_string(), + run_id: Some("run-1".to_string()), + seq: Some(1), + payload: Some(payload), + }], + }; + + let converted = from_void_box_run(&run).expect("conversion"); + assert_eq!(converted.events[0].payload.get("a"), Some(&"x".to_string())); + assert_eq!(converted.events[0].payload.get("b"), Some(&"true".to_string())); + assert!(!converted.events[0].payload.contains_key("c")); + } +} diff --git a/src/contract/compat_json.rs b/src/contract/compat_json.rs new file mode 100644 index 0000000..445944e --- /dev/null +++ b/src/contract/compat_json.rs @@ -0,0 +1,209 @@ +#[cfg(feature = "serde")] +use std::collections::BTreeMap; + +#[cfg(feature = "serde")] +use serde::Deserialize; + +#[cfg(feature = "serde")] +use crate::contract::{ + from_void_box_run, ContractError, ContractErrorCode, ConvertedRunView, VoidBoxPayloadValue, + VoidBoxRunEventRaw, VoidBoxRunRaw, +}; + +#[cfg(feature = "serde")] +#[derive(Debug, Clone, Deserialize)] +struct DaemonRunStateJson { + id: String, + status: String, + #[serde(default)] + error: Option, + #[serde(default)] + events: Vec, +} + +#[cfg(feature = "serde")] +#[derive(Debug, Clone, Deserialize)] +struct DaemonRunEventJson { + ts_ms: u64, + #[serde(default)] + event_type: String, + #[serde(default)] + run_id: Option, + #[serde(default)] + seq: Option, + #[serde(default)] + payload: Option, +} + +#[cfg(feature = "serde")] +pub fn from_void_box_run_json(run_json: &str) -> Result { + let run: DaemonRunStateJson = serde_json::from_str(run_json).map_err(|e| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid run JSON: {e}"), + false, + ) + })?; + + from_void_box_run(&to_raw_run(run)) +} + +#[cfg(feature = "serde")] +pub fn from_void_box_run_and_events_json( + run_json: &str, + events_json: &str, +) -> Result { + let run: DaemonRunStateJson = serde_json::from_str(run_json).map_err(|e| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid run JSON: {e}"), + false, + ) + })?; + let events: Vec = serde_json::from_str(events_json).map_err(|e| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid events JSON: {e}"), + false, + ) + })?; + + from_void_box_run(&to_raw_run_with_events(run, events)) +} + +#[cfg(feature = "serde")] +fn to_raw_run(run: DaemonRunStateJson) -> VoidBoxRunRaw { + let DaemonRunStateJson { + id, + status, + error, + events, + } = run; + VoidBoxRunRaw { + id, + status, + error, + events: events.into_iter().map(to_raw_event).collect(), + } +} + +#[cfg(feature = "serde")] +fn to_raw_run_with_events( + run: DaemonRunStateJson, + events: Vec, +) -> VoidBoxRunRaw { + let DaemonRunStateJson { + id, + status, + error, + .. + } = run; + VoidBoxRunRaw { + id, + status, + error, + events: events.into_iter().map(to_raw_event).collect(), + } +} + +#[cfg(feature = "serde")] +fn to_raw_event(event: DaemonRunEventJson) -> VoidBoxRunEventRaw { + VoidBoxRunEventRaw { + ts_ms: event.ts_ms, + event_type: event.event_type, + run_id: event.run_id, + seq: event.seq, + payload: event.payload.map(payload_to_map), + } +} + +#[cfg(feature = "serde")] +fn payload_to_map(value: serde_json::Value) -> BTreeMap { + let mut out = BTreeMap::new(); + let serde_json::Value::Object(map) = value.clone() else { + out.insert("value".to_string(), json_to_payload_value(value)); + return out; + }; + + for (key, value) in map { + out.insert(key, json_to_payload_value(value)); + } + out +} + +#[cfg(feature = "serde")] +fn json_to_payload_value(value: serde_json::Value) -> VoidBoxPayloadValue { + match value { + serde_json::Value::String(v) => VoidBoxPayloadValue::String(v), + serde_json::Value::Bool(v) => VoidBoxPayloadValue::Bool(v), + serde_json::Value::Number(num) => { + if let Some(i) = num.as_i64() { + VoidBoxPayloadValue::Integer(i) + } else if let Some(u) = num.as_u64() { + VoidBoxPayloadValue::Unsigned(u) + } else if let Some(f) = num.as_f64() { + VoidBoxPayloadValue::Float(f) + } else { + VoidBoxPayloadValue::Unsupported(num.to_string()) + } + } + serde_json::Value::Null => VoidBoxPayloadValue::Null, + serde_json::Value::Array(_) | serde_json::Value::Object(_) => { + VoidBoxPayloadValue::Unsupported(value.to_string()) + } + } +} + +#[cfg(all(test, feature = "serde"))] +mod tests { + use super::{from_void_box_run_and_events_json, from_void_box_run_json}; + use crate::contract::{ContractErrorCode, EventType, RunState}; + use std::fs; + + fn fixture(path: &str) -> String { + fs::read_to_string(path).expect("read fixture") + } + + #[test] + fn parses_run_json_success() { + let run = fixture("fixtures/voidbox_run_success.json"); + let converted = from_void_box_run_json(&run).expect("conversion"); + assert_eq!(converted.inspection.state, RunState::Succeeded); + assert_eq!(converted.events.len(), 2); + assert!(converted + .events + .iter() + .any(|e| e.event_type == EventType::RunCompleted)); + } + + #[test] + fn parses_run_and_events_json_success() { + let run = fixture("fixtures/voidbox_run_success.json"); + let events = fixture("fixtures/voidbox_run_events_success.json"); + let converted = from_void_box_run_and_events_json(&run, &events).expect("conversion"); + assert_eq!(converted.events.len(), 2); + assert_eq!(converted.events[0].seq, 1); + assert_eq!(converted.events[1].seq, 2); + } + + #[test] + fn invalid_json_is_invalid_spec() { + let err = from_void_box_run_json("{bad").expect_err("expected parse error"); + assert_eq!(err.code, ContractErrorCode::InvalidSpec); + } + + #[test] + fn unknown_event_is_dropped_in_diagnostics() { + let run = fixture("fixtures/voidbox_run_unknown_event.json"); + let converted = from_void_box_run_json(&run).expect("conversion"); + assert_eq!(converted.diagnostics.dropped_unknown_event_types, 1); + assert_eq!(converted.events.len(), 1); + } + + #[test] + fn bad_seq_is_invalid_spec() { + let run = fixture("fixtures/voidbox_run_bad_seq.json"); + let err = from_void_box_run_json(&run).expect_err("expected seq failure"); + assert_eq!(err.code, ContractErrorCode::InvalidSpec); + } +} diff --git a/src/contract/error.rs b/src/contract/error.rs new file mode 100644 index 0000000..3cde186 --- /dev/null +++ b/src/contract/error.rs @@ -0,0 +1,30 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContractErrorCode { + InvalidSpec, + InvalidPolicy, + NotFound, + AlreadyTerminal, + ResourceLimitExceeded, + InternalError, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContractError { + pub code: ContractErrorCode, + pub message: String, + pub retryable: bool, +} + +impl ContractError { + pub fn new( + code: ContractErrorCode, + message: impl Into, + retryable: bool, + ) -> Self { + Self { + code, + message: message.into(), + retryable, + } + } +} diff --git a/src/contract/event.rs b/src/contract/event.rs new file mode 100644 index 0000000..96c52ef --- /dev/null +++ b/src/contract/event.rs @@ -0,0 +1,84 @@ +use std::collections::BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EventType { + RunStarted, + StageStarted, + StageCompleted, + StageFailed, + MicroVmSpawned, + MicroVmExited, + RunCompleted, + RunFailed, + RunCanceled, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EventEnvelope { + pub event_id: String, + pub event_type: EventType, + pub run_id: String, + pub attempt_id: u32, + pub timestamp: String, + pub seq: u64, + pub payload: BTreeMap, +} + +impl EventEnvelope { + pub fn is_terminal(&self) -> bool { + matches!( + self.event_type, + EventType::RunCompleted | EventType::RunFailed | EventType::RunCanceled + ) + } +} + +#[derive(Debug, Default)] +pub struct EventSequenceTracker { + last_seq: Option, +} + +impl EventSequenceTracker { + pub fn observe(&mut self, event: &EventEnvelope) -> Result<(), &'static str> { + if let Some(last_seq) = self.last_seq { + if event.seq <= last_seq { + return Err("event sequence must be strictly increasing"); + } + } + self.last_seq = Some(event.seq); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{EventEnvelope, EventSequenceTracker, EventType}; + use std::collections::BTreeMap; + + fn event(seq: u64) -> EventEnvelope { + EventEnvelope { + event_id: format!("evt_{seq}"), + event_type: EventType::StageStarted, + run_id: "run_1".to_string(), + attempt_id: 1, + timestamp: "2026-01-01T00:00:00Z".to_string(), + seq, + payload: BTreeMap::new(), + } + } + + #[test] + fn enforces_increasing_sequence() { + let mut tracker = EventSequenceTracker::default(); + assert!(tracker.observe(&event(1)).is_ok()); + assert!(tracker.observe(&event(2)).is_ok()); + assert!(tracker.observe(&event(2)).is_err()); + } + + #[test] + fn marks_terminal_types() { + let mut completed = event(1); + completed.event_type = EventType::RunCompleted; + assert!(completed.is_terminal()); + } +} diff --git a/src/contract/mod.rs b/src/contract/mod.rs new file mode 100644 index 0000000..fe2302a --- /dev/null +++ b/src/contract/mod.rs @@ -0,0 +1,22 @@ +mod api; +mod compat; +#[cfg(feature = "serde")] +mod compat_json; +mod error; +mod event; +mod policy; +mod state; + +pub use api::{ + RuntimeInspection, StartRequest, StartResult, StopRequest, StopResult, SubscribeEventsRequest, +}; +pub use compat::{ + from_void_box_run, map_void_box_event_type, map_void_box_status, ConversionDiagnostics, + ConvertedRunView, VoidBoxPayloadValue, VoidBoxRunEventRaw, VoidBoxRunRaw, +}; +#[cfg(feature = "serde")] +pub use compat_json::{from_void_box_run_and_events_json, from_void_box_run_json}; +pub use error::{ContractError, ContractErrorCode}; +pub use event::{EventEnvelope, EventSequenceTracker, EventType}; +pub use policy::ExecutionPolicy; +pub use state::RunState; diff --git a/src/contract/policy.rs b/src/contract/policy.rs new file mode 100644 index 0000000..5700f80 --- /dev/null +++ b/src/contract/policy.rs @@ -0,0 +1,49 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecutionPolicy { + pub max_parallel_microvms_per_run: u32, + pub max_stage_retries: u32, + pub stage_timeout_secs: u32, + pub cancel_grace_period_secs: u32, +} + +impl ExecutionPolicy { + pub fn validate(&self) -> Result<(), &'static str> { + if self.max_parallel_microvms_per_run == 0 { + return Err("max_parallel_microvms_per_run must be > 0"); + } + if self.stage_timeout_secs == 0 { + return Err("stage_timeout_secs must be > 0"); + } + if self.cancel_grace_period_secs == 0 { + return Err("cancel_grace_period_secs must be > 0"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::ExecutionPolicy; + + #[test] + fn validates_happy_path() { + let policy = ExecutionPolicy { + max_parallel_microvms_per_run: 8, + max_stage_retries: 1, + stage_timeout_secs: 900, + cancel_grace_period_secs: 20, + }; + assert!(policy.validate().is_ok()); + } + + #[test] + fn rejects_invalid_limits() { + let policy = ExecutionPolicy { + max_parallel_microvms_per_run: 0, + max_stage_retries: 1, + stage_timeout_secs: 900, + cancel_grace_period_secs: 20, + }; + assert!(policy.validate().is_err()); + } +} diff --git a/src/contract/state.rs b/src/contract/state.rs new file mode 100644 index 0000000..0fcafe8 --- /dev/null +++ b/src/contract/state.rs @@ -0,0 +1,54 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RunState { + Pending, + Starting, + Running, + Succeeded, + Failed, + Canceled, +} + +impl RunState { + pub fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Canceled) + } + + pub fn can_transition_to(self, next: Self) -> bool { + match (self, next) { + (Self::Pending, Self::Starting) => true, + (Self::Starting, Self::Running) => true, + (Self::Running, Self::Succeeded | Self::Failed | Self::Canceled) => true, + (a, b) if a == b => true, + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::RunState; + + #[test] + fn permits_expected_lifecycle() { + assert!(RunState::Pending.can_transition_to(RunState::Starting)); + assert!(RunState::Starting.can_transition_to(RunState::Running)); + assert!(RunState::Running.can_transition_to(RunState::Succeeded)); + assert!(RunState::Running.can_transition_to(RunState::Failed)); + assert!(RunState::Running.can_transition_to(RunState::Canceled)); + } + + #[test] + fn rejects_invalid_lifecycle_edges() { + assert!(!RunState::Pending.can_transition_to(RunState::Running)); + assert!(!RunState::Starting.can_transition_to(RunState::Succeeded)); + assert!(!RunState::Succeeded.can_transition_to(RunState::Running)); + } + + #[test] + fn marks_terminal_states() { + assert!(RunState::Succeeded.is_terminal()); + assert!(RunState::Failed.is_terminal()); + assert!(RunState::Canceled.is_terminal()); + assert!(!RunState::Running.is_terminal()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fcef45c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod contract; +pub mod runtime; +#[cfg(feature = "serde")] +pub mod bridge; diff --git a/src/runtime/mock.rs b/src/runtime/mock.rs new file mode 100644 index 0000000..81ea723 --- /dev/null +++ b/src/runtime/mock.rs @@ -0,0 +1,313 @@ +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::contract::{ + ContractError, ContractErrorCode, EventEnvelope, EventType, RunState, RuntimeInspection, + StartRequest, StartResult, StopRequest, StopResult, SubscribeEventsRequest, +}; + +#[derive(Debug, Clone)] +struct RunRecord { + run_id: String, + handle: String, + attempt_id: u32, + state: RunState, + started_at: String, + updated_at: String, + terminal_reason: Option, + exit_code: Option, + events: Vec, + next_seq: u64, +} + +impl RunRecord { + fn new(run_id: String) -> Self { + let now = now_rfc3339_like(); + Self { + handle: format!("run-handle:{run_id}"), + run_id, + attempt_id: 1, + state: RunState::Running, + started_at: now.clone(), + updated_at: now, + terminal_reason: None, + exit_code: None, + events: Vec::new(), + next_seq: 1, + } + } + + fn push_event(&mut self, event_type: EventType, payload: BTreeMap) -> String { + let event_id = format!("evt_{}_{}", self.run_id, self.next_seq); + let event = EventEnvelope { + event_id: event_id.clone(), + event_type, + run_id: self.run_id.clone(), + attempt_id: self.attempt_id, + timestamp: now_rfc3339_like(), + seq: self.next_seq, + payload, + }; + self.updated_at = event.timestamp.clone(); + self.next_seq += 1; + self.events.push(event); + event_id + } +} + +#[derive(Debug, Default)] +pub struct MockRuntime { + runs: Vec, +} + +impl MockRuntime { + pub fn new() -> Self { + Self::default() + } + + pub fn start(&mut self, request: StartRequest) -> Result { + request.policy.validate().map_err(|msg| { + ContractError::new(ContractErrorCode::InvalidPolicy, msg, false) + })?; + + if let Some(existing) = self.runs.iter_mut().find(|r| r.run_id == request.run_id) { + if existing.state.is_terminal() { + return Err(ContractError::new( + ContractErrorCode::AlreadyTerminal, + "run is already terminal", + false, + )); + } + + return Ok(StartResult { + handle: existing.handle.clone(), + attempt_id: existing.attempt_id, + state: existing.state, + }); + } + + let mut record = RunRecord::new(request.run_id); + record.push_event(EventType::RunStarted, BTreeMap::new()); + let result = StartResult { + handle: record.handle.clone(), + attempt_id: record.attempt_id, + state: record.state, + }; + self.runs.push(record); + Ok(result) + } + + pub fn stop(&mut self, request: StopRequest) -> Result { + let Some(record) = self.runs.iter_mut().find(|r| r.handle == request.handle) else { + return Err(ContractError::new( + ContractErrorCode::NotFound, + "run handle not found", + false, + )); + }; + + if record.state.is_terminal() { + let terminal_event_id = record + .events + .iter() + .rev() + .find(|e| e.is_terminal()) + .map(|e| e.event_id.clone()) + .unwrap_or_else(|| "evt_missing_terminal".to_string()); + return Ok(StopResult { + state: record.state, + terminal_event_id, + }); + } + + record.state = RunState::Canceled; + record.terminal_reason = Some(request.reason); + record.exit_code = Some(130); + let terminal_event_id = record.push_event(EventType::RunCanceled, BTreeMap::new()); + Ok(StopResult { + state: record.state, + terminal_event_id, + }) + } + + pub fn inspect(&self, handle: &str) -> Result { + let Some(record) = self.runs.iter().find(|r| r.handle == handle) else { + return Err(ContractError::new( + ContractErrorCode::NotFound, + "run handle not found", + false, + )); + }; + + Ok(RuntimeInspection { + run_id: record.run_id.clone(), + attempt_id: record.attempt_id, + state: record.state, + active_stage_count: if record.state == RunState::Running { 1 } else { 0 }, + active_microvm_count: if record.state == RunState::Running { 1 } else { 0 }, + started_at: record.started_at.clone(), + updated_at: record.updated_at.clone(), + terminal_reason: record.terminal_reason.clone(), + exit_code: record.exit_code, + }) + } + + pub fn subscribe_events( + &self, + request: SubscribeEventsRequest, + ) -> Result, ContractError> { + let Some(record) = self.runs.iter().find(|r| r.handle == request.handle) else { + return Err(ContractError::new( + ContractErrorCode::NotFound, + "run handle not found", + false, + )); + }; + + if let Some(from_event_id) = request.from_event_id { + if let Some(idx) = record + .events + .iter() + .position(|event| event.event_id == from_event_id) + { + return Ok(record.events[idx + 1..].to_vec()); + } + } + + Ok(record.events.clone()) + } +} + +fn now_rfc3339_like() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("{secs}Z") +} + +#[cfg(test)] +mod tests { + use super::MockRuntime; + use crate::contract::{ContractErrorCode, EventType, ExecutionPolicy, RunState}; + use crate::contract::{StartRequest, StopRequest, SubscribeEventsRequest}; + + fn policy() -> ExecutionPolicy { + ExecutionPolicy { + max_parallel_microvms_per_run: 4, + max_stage_retries: 1, + stage_timeout_secs: 300, + cancel_grace_period_secs: 10, + } + } + + #[test] + fn start_is_idempotent_for_active_run() { + let mut runtime = MockRuntime::new(); + let req = StartRequest { + run_id: "run-1".to_string(), + workflow_spec: "workflow".to_string(), + policy: policy(), + }; + + let first = runtime.start(req.clone()).expect("first start"); + let second = runtime.start(req).expect("second start"); + + assert_eq!(first.handle, second.handle); + assert_eq!(first.attempt_id, second.attempt_id); + } + + #[test] + fn stop_is_idempotent_for_terminal_run() { + let mut runtime = MockRuntime::new(); + let req = StartRequest { + run_id: "run-2".to_string(), + workflow_spec: "workflow".to_string(), + policy: policy(), + }; + let started = runtime.start(req).expect("start"); + + let stop_req = StopRequest { + handle: started.handle.clone(), + reason: "user requested".to_string(), + }; + let first = runtime.stop(stop_req.clone()).expect("first stop"); + let second = runtime.stop(stop_req).expect("second stop"); + + assert_eq!(first.state, RunState::Canceled); + assert_eq!(first.terminal_event_id, second.terminal_event_id); + } + + #[test] + fn subscribe_supports_resume_from_event_id() { + let mut runtime = MockRuntime::new(); + let req = StartRequest { + run_id: "run-3".to_string(), + workflow_spec: "workflow".to_string(), + policy: policy(), + }; + let started = runtime.start(req).expect("start"); + let stop = runtime + .stop(StopRequest { + handle: started.handle.clone(), + reason: "cancel".to_string(), + }) + .expect("stop"); + + let resumed = runtime + .subscribe_events(SubscribeEventsRequest { + handle: started.handle, + from_event_id: Some(stop.terminal_event_id), + }) + .expect("subscribe"); + + assert!(resumed.is_empty()); + } + + #[test] + fn rejects_invalid_policy() { + let mut runtime = MockRuntime::new(); + let err = runtime + .start(StartRequest { + run_id: "run-4".to_string(), + workflow_spec: "workflow".to_string(), + policy: ExecutionPolicy { + max_parallel_microvms_per_run: 0, + max_stage_retries: 1, + stage_timeout_secs: 100, + cancel_grace_period_secs: 5, + }, + }) + .expect_err("expected invalid policy"); + + assert_eq!(err.code, ContractErrorCode::InvalidPolicy); + } + + #[test] + fn emits_expected_terminal_event() { + let mut runtime = MockRuntime::new(); + let started = runtime + .start(StartRequest { + run_id: "run-5".to_string(), + workflow_spec: "workflow".to_string(), + policy: policy(), + }) + .expect("start"); + runtime + .stop(StopRequest { + handle: started.handle.clone(), + reason: "cancel".to_string(), + }) + .expect("stop"); + + let events = runtime + .subscribe_events(SubscribeEventsRequest { + handle: started.handle, + from_event_id: None, + }) + .expect("subscribe"); + assert!(events.iter().any(|e| e.event_type == EventType::RunStarted)); + assert!(events.iter().any(|e| e.event_type == EventType::RunCanceled)); + } +} + diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs new file mode 100644 index 0000000..48fa928 --- /dev/null +++ b/src/runtime/mod.rs @@ -0,0 +1,7 @@ +mod mock; +#[cfg(feature = "serde")] +mod void_box; + +pub use mock::MockRuntime; +#[cfg(feature = "serde")] +pub use void_box::VoidBoxRuntimeClient; diff --git a/src/runtime/void_box.rs b/src/runtime/void_box.rs new file mode 100644 index 0000000..80fea23 --- /dev/null +++ b/src/runtime/void_box.rs @@ -0,0 +1,628 @@ +use std::io::{Read, Write}; +use std::net::TcpStream; + +use crate::contract::{ + from_void_box_run_and_events_json, from_void_box_run_json, ContractError, ContractErrorCode, + map_void_box_status, ConvertedRunView, EventEnvelope, EventType, RunState, RuntimeInspection, StartRequest, + StartResult, StopRequest, StopResult, SubscribeEventsRequest, +}; + +pub struct VoidBoxRuntimeClient { + base_url: String, + poll_interval_ms: u64, + transport: Box, +} + +impl VoidBoxRuntimeClient { + pub fn new(base_url: String, poll_interval_ms: u64) -> Self { + Self { + base_url, + poll_interval_ms, + transport: Box::new(TcpHttpTransport), + } + } + + #[cfg(test)] + fn with_transport( + base_url: String, + poll_interval_ms: u64, + transport: Box, + ) -> Self { + Self { + base_url, + poll_interval_ms, + transport, + } + } + + pub fn poll_interval_ms(&self) -> u64 { + self.poll_interval_ms + } + + pub fn start(&self, request: StartRequest) -> Result { + request.policy.validate().map_err(|msg| { + ContractError::new(ContractErrorCode::InvalidPolicy, msg, false) + })?; + + let payload = serde_json::json!({ + "file": request.workflow_spec, + "input": serde_json::Value::Null + }) + .to_string(); + + let response = self.http_post("/v1/runs", &payload)?; + if response.status == 404 { + return Err(ContractError::new( + ContractErrorCode::NotFound, + "void-box endpoint not found", + false, + )); + } + if response.status >= 400 { + return Err(ContractError::new( + ContractErrorCode::InternalError, + format!("void-box start failed: HTTP {}", response.status), + response.status >= 500, + )); + } + + let body: serde_json::Value = serde_json::from_str(&response.body).map_err(|e| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid create-run response: {e}"), + false, + ) + })?; + let run_id = body + .get("run_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + "missing run_id in create-run response", + false, + ) + })? + .to_string(); + + Ok(StartResult { + handle: handle_from_run_id(&run_id), + attempt_id: 1, + state: RunState::Running, + }) + } + + pub fn stop(&self, request: StopRequest) -> Result { + let run_id = run_id_from_handle(&request.handle)?; + let cancel_path = format!("/v1/runs/{run_id}/cancel"); + let cancel_resp = self.http_post(&cancel_path, "{}")?; + + if cancel_resp.status == 404 { + return Err(ContractError::new( + ContractErrorCode::NotFound, + format!("run '{run_id}' not found"), + false, + )); + } + if cancel_resp.status >= 400 { + return Err(ContractError::new( + ContractErrorCode::InternalError, + format!("void-box cancel failed: HTTP {}", cancel_resp.status), + cancel_resp.status >= 500, + )); + } + + let converted = self.fetch_converted_run(run_id)?; + let Some(terminal) = converted + .events + .iter() + .rev() + .find(|e| { + matches!( + e.event_type, + EventType::RunCanceled | EventType::RunFailed | EventType::RunCompleted + ) + }) + .cloned() + else { + return Err(ContractError::new( + ContractErrorCode::InternalError, + "terminal event not found after cancel", + true, + )); + }; + + Ok(StopResult { + state: converted.inspection.state, + terminal_event_id: terminal.event_id, + }) + } + + pub fn inspect(&self, handle: &str) -> Result { + let run_id = run_id_from_handle(handle)?; + let run_path = format!("/v1/runs/{run_id}"); + let run_resp = self.http_get(&run_path)?; + + if run_resp.status == 404 { + return Err(ContractError::new( + ContractErrorCode::NotFound, + format!("run '{run_id}' not found"), + false, + )); + } + if run_resp.status >= 400 { + return Err(ContractError::new( + ContractErrorCode::InternalError, + format!("inspect failed: HTTP {}", run_resp.status), + run_resp.status >= 500, + )); + } + + let converted = from_void_box_run_json(&run_resp.body)?; + Ok(converted.inspection) + } + + pub fn list_runs(&self, state: Option<&str>) -> Result, ContractError> { + let path = if let Some(filter) = state.filter(|s| !s.trim().is_empty()) { + format!("/v1/runs?state={}", filter.trim()) + } else { + "/v1/runs".to_string() + }; + let response = self.http_get(&path)?; + + if response.status >= 400 { + return Err(ContractError::new( + ContractErrorCode::InternalError, + format!("list runs failed: HTTP {}", response.status), + response.status >= 500, + )); + } + + let body: serde_json::Value = serde_json::from_str(&response.body).map_err(|e| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid runs response JSON: {e}"), + false, + ) + })?; + let runs = body + .get("runs") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + "missing runs array in list response", + false, + ) + })?; + + let inspections = runs + .iter() + .filter_map(|run| { + let run_id = run + .get("id") + .or_else(|| run.get("run_id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string)?; + let status_raw = run + .get("status") + .or_else(|| run.get("state")) + .and_then(serde_json::Value::as_str)?; + let state = map_void_box_status(status_raw)?; + let attempt_id = run + .get("attempt_id") + .and_then(serde_json::Value::as_u64) + .unwrap_or(1) as u32; + let active_stage_count = run + .get("active_stage_count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u32; + let active_microvm_count = run + .get("active_microvm_count") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u32; + let started_at = run + .get("started_at") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let updated_at = run + .get("updated_at") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let terminal_reason = run + .get("terminal_reason") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let exit_code = run + .get("exit_code") + .and_then(serde_json::Value::as_i64) + .map(|v| v as i32); + Some(RuntimeInspection { + run_id, + attempt_id, + state, + active_stage_count, + active_microvm_count, + started_at, + updated_at, + terminal_reason, + exit_code, + }) + }) + .collect(); + + Ok(inspections) + } + + pub fn subscribe_events( + &self, + request: SubscribeEventsRequest, + ) -> Result, ContractError> { + let run_id = run_id_from_handle(&request.handle)?; + let converted = self.fetch_converted_run(run_id)?; + Ok(filter_events_from_id( + converted.events, + request.from_event_id.as_deref(), + )) + } + + fn fetch_converted_run(&self, run_id: &str) -> Result { + let run_path = format!("/v1/runs/{run_id}"); + let events_path = format!("/v1/runs/{run_id}/events"); + let run_resp = self.http_get(&run_path)?; + let events_resp = self.http_get(&events_path)?; + + if run_resp.status == 404 || events_resp.status == 404 { + return Err(ContractError::new( + ContractErrorCode::NotFound, + format!("run '{run_id}' not found"), + false, + )); + } + if run_resp.status >= 400 || events_resp.status >= 400 { + let status = if run_resp.status >= 400 { + run_resp.status + } else { + events_resp.status + }; + return Err(ContractError::new( + ContractErrorCode::InternalError, + format!("event poll failed: HTTP {status}"), + status >= 500, + )); + } + + from_void_box_run_and_events_json(&run_resp.body, &events_resp.body) + } + + fn http_get(&self, path: &str) -> Result { + self.transport.request(&self.base_url, "GET", path, "") + } + + fn http_post(&self, path: &str, body: &str) -> Result { + self.transport.request(&self.base_url, "POST", path, body) + } +} + +trait HttpTransport { + fn request( + &self, + base_url: &str, + method: &str, + path: &str, + body: &str, + ) -> Result; +} + +struct TcpHttpTransport; + +impl HttpTransport for TcpHttpTransport { + fn request( + &self, + base_url: &str, + method: &str, + path: &str, + body: &str, + ) -> Result { + let (host, port) = parse_host_port(base_url)?; + let addr = format!("{host}:{port}"); + let mut stream = TcpStream::connect(&addr).map_err(|e| { + ContractError::new( + ContractErrorCode::InternalError, + format!("connect to {addr} failed: {e}"), + true, + ) + })?; + + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(request.as_bytes()).map_err(|e| { + ContractError::new( + ContractErrorCode::InternalError, + format!("request write failed: {e}"), + true, + ) + })?; + + let mut response = String::new(); + stream.read_to_string(&mut response).map_err(|e| { + ContractError::new( + ContractErrorCode::InternalError, + format!("response read failed: {e}"), + true, + ) + })?; + + parse_http_response(&response) + } +} + +#[derive(Debug, Clone)] +struct HttpResponse { + status: u16, + body: String, +} + +fn parse_http_response(raw: &str) -> Result { + let (head, body) = raw.split_once("\r\n\r\n").ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + "invalid HTTP response format", + false, + ) + })?; + + let mut lines = head.lines(); + let status_line = lines.next().unwrap_or_default(); + let mut parts = status_line.split_whitespace(); + let _http = parts.next(); + let status = parts + .next() + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + "invalid HTTP status line", + false, + ) + })?; + + Ok(HttpResponse { + status, + body: body.to_string(), + }) +} + +fn parse_host_port(base_url: &str) -> Result<(String, u16), ContractError> { + let stripped = base_url.strip_prefix("http://").ok_or_else(|| { + ContractError::new( + ContractErrorCode::InvalidSpec, + "base_url must start with http://", + false, + ) + })?; + let host_port = stripped.split('/').next().unwrap_or(stripped); + let (host, port) = match host_port.split_once(':') { + Some((host, port)) => { + let parsed = port.parse::().map_err(|_| { + ContractError::new( + ContractErrorCode::InvalidSpec, + format!("invalid port in base_url '{base_url}'"), + false, + ) + })?; + (host.to_string(), parsed) + } + None => (host_port.to_string(), 80), + }; + Ok((host, port)) +} + +fn handle_from_run_id(run_id: &str) -> String { + format!("vb:{run_id}") +} + +fn run_id_from_handle(handle: &str) -> Result<&str, ContractError> { + handle.strip_prefix("vb:").ok_or_else(|| { + ContractError::new( + ContractErrorCode::NotFound, + format!("invalid run handle '{handle}'"), + false, + ) + }) +} + +fn filter_events_from_id(events: Vec, from_event_id: Option<&str>) -> Vec { + let Some(from_id) = from_event_id else { + return events; + }; + if let Some(idx) = events.iter().position(|e| e.event_id == from_id) { + return events.into_iter().skip(idx + 1).collect(); + } + events +} + +#[cfg(test)] +mod tests { + use super::{filter_events_from_id, HttpResponse, HttpTransport, VoidBoxRuntimeClient}; + use crate::contract::{ + ContractErrorCode, EventEnvelope, EventType, ExecutionPolicy, RunState, StartRequest, + StopRequest, SubscribeEventsRequest, + }; + use std::collections::{BTreeMap, HashMap}; + use std::sync::Mutex; + + struct MockTransport { + routes: Mutex>, + } + + impl MockTransport { + fn new(routes: Vec<(&str, &str, u16, &str)>) -> Self { + let map = routes + .into_iter() + .map(|(m, p, s, b)| { + ( + (m.to_string(), p.to_string()), + HttpResponse { + status: s, + body: b.to_string(), + }, + ) + }) + .collect(); + Self { + routes: Mutex::new(map), + } + } + } + + impl HttpTransport for MockTransport { + fn request( + &self, + _base_url: &str, + method: &str, + path: &str, + _body: &str, + ) -> Result { + let key = (method.to_string(), path.to_string()); + if let Some(resp) = self.routes.lock().expect("lock").get(&key) { + return Ok(resp.clone()); + } + Ok(HttpResponse { + status: 404, + body: r#"{"error":"not found"}"#.to_string(), + }) + } + } + + fn client(routes: Vec<(&str, &str, u16, &str)>) -> VoidBoxRuntimeClient { + VoidBoxRuntimeClient::with_transport( + "http://mock:3000".to_string(), + 250, + Box::new(MockTransport::new(routes)), + ) + } + + fn policy() -> ExecutionPolicy { + ExecutionPolicy { + max_parallel_microvms_per_run: 1, + max_stage_retries: 0, + stage_timeout_secs: 60, + cancel_grace_period_secs: 5, + } + } + + #[test] + fn start_returns_handle_and_running_state() { + let c = client(vec![("POST", "/v1/runs", 200, r#"{"run_id":"run-123"}"#)]); + let started = c + .start(StartRequest { + run_id: "controller-run-1".to_string(), + workflow_spec: "fixtures/sample.vbrun".to_string(), + policy: policy(), + }) + .expect("start"); + assert_eq!(started.handle, "vb:run-123"); + assert_eq!(started.attempt_id, 1); + assert_eq!(started.state, RunState::Running); + assert_eq!(c.poll_interval_ms(), 250); + } + + #[test] + fn inspect_maps_daemon_run_state() { + let c = client(vec![( + "GET", + "/v1/runs/run-123", + 200, + include_str!("../../fixtures/voidbox_run_success.json"), + )]); + let inspection = c.inspect("vb:run-123").expect("inspect"); + assert_eq!(inspection.run_id, "run-2000"); + assert_eq!(inspection.state, RunState::Succeeded); + } + + #[test] + fn subscribe_events_applies_resume_filter() { + let c = client(vec![ + ( + "GET", + "/v1/runs/run-123", + 200, + include_str!("../../fixtures/voidbox_run_success.json"), + ), + ( + "GET", + "/v1/runs/run-123/events", + 200, + include_str!("../../fixtures/voidbox_run_events_success.json"), + ), + ]); + let events = c + .subscribe_events(SubscribeEventsRequest { + handle: "vb:run-123".to_string(), + from_event_id: Some("evt_run-2000_1".to_string()), + }) + .expect("subscribe"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].event_type, EventType::RunCompleted); + } + + #[test] + fn stop_returns_terminal_event() { + let c = client(vec![ + ( + "POST", + "/v1/runs/run-123/cancel", + 200, + include_str!("../../fixtures/voidbox_run_success.json"), + ), + ( + "GET", + "/v1/runs/run-123", + 200, + include_str!("../../fixtures/voidbox_run_success.json"), + ), + ( + "GET", + "/v1/runs/run-123/events", + 200, + include_str!("../../fixtures/voidbox_run_events_success.json"), + ), + ]); + let stop = c + .stop(StopRequest { + handle: "vb:run-123".to_string(), + reason: "user".to_string(), + }) + .expect("stop"); + assert_eq!(stop.state, RunState::Succeeded); + assert_eq!(stop.terminal_event_id, "evt_run-2000_2"); + } + + #[test] + fn inspect_404_maps_to_not_found() { + let c = client(vec![("GET", "/v1/runs/run-404", 404, r#"{"error":"not found"}"#)]); + let err = c.inspect("vb:run-404").expect_err("expected not found"); + assert_eq!(err.code, ContractErrorCode::NotFound); + } + + #[test] + fn filter_events_from_id_returns_full_when_marker_missing() { + let events = vec![EventEnvelope { + event_id: "evt_1".to_string(), + event_type: EventType::RunStarted, + run_id: "run-1".to_string(), + attempt_id: 1, + timestamp: "1ms".to_string(), + seq: 1, + payload: BTreeMap::new(), + }]; + let out = filter_events_from_id(events.clone(), Some("evt_missing")); + assert_eq!(out, events); + } +} diff --git a/tests/void_box_contract.rs b/tests/void_box_contract.rs new file mode 100644 index 0000000..baf5c89 --- /dev/null +++ b/tests/void_box_contract.rs @@ -0,0 +1,749 @@ +#![cfg(feature = "serde")] + +use std::env; +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread; +use std::time::Duration; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::{json, Value}; + +fn require_env(name: &str) -> String { + env::var(name).unwrap_or_else(|_| panic!("missing required env var: {name}")) +} + +#[derive(Clone, Copy)] +enum DefaultSpecKind { + LongRunning, + Timeout, + BaselineSuccess, +} + +static FALLBACK_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn resolve_spec_path(env_name: &str, kind: DefaultSpecKind) -> String { + if let Ok(value) = env::var(env_name) { + if Path::new(&value).exists() { + return value; + } + eprintln!( + "[void_box_contract] {} points to missing path '{}'; using generated fallback fixture", + env_name, value + ); + } + + let path = fallback_spec_path(kind); + write_fallback_spec(&path, kind); + path.to_string_lossy().to_string() +} + +fn fallback_spec_path(kind: DefaultSpecKind) -> PathBuf { + let suffix = match kind { + DefaultSpecKind::LongRunning => "long_running", + DefaultSpecKind::Timeout => "timeout", + DefaultSpecKind::BaselineSuccess => "baseline_success", + }; + let nonce = FALLBACK_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let pid = std::process::id(); + env::temp_dir().join(format!( + "void-control-gate-{suffix}-{pid}-{nanos}-{nonce}.yaml" + )) +} + +fn write_fallback_spec(path: &Path, kind: DefaultSpecKind) { + let yaml = match kind { + DefaultSpecKind::LongRunning => { + r#"api_version: v1 +kind: workflow +name: long-running + +sandbox: + mode: mock + network: false + +workflow: + steps: + - name: wait + run: + program: sleep + args: ["3"] + - name: done + depends_on: [wait] + run: + program: echo + args: ["done"] + output_step: done +"# + } + DefaultSpecKind::Timeout => { + r#"api_version: v1 +kind: workflow +name: timeout-case + +sandbox: + mode: local + network: false + +workflow: + steps: + - name: slow + run: + program: sleep + args: ["5"] + output_step: slow +"# + } + DefaultSpecKind::BaselineSuccess => { + r#"api_version: v1 +kind: workflow +name: baseline-success + +sandbox: + mode: mock + network: false + +workflow: + steps: + - name: fetch + run: + program: echo + args: ["hello from workflow"] + - name: transform + depends_on: [fetch] + run: + program: tr + args: ["a-z", "A-Z"] + stdin_from: fetch + output_step: transform +"# + } + }; + + fs::write(path, yaml).unwrap_or_else(|e| { + panic!("failed to write fallback spec at '{}': {}", path.display(), e) + }); +} + +fn unique_run_id(prefix: &str) -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_millis(); + format!("{prefix}-{now}") +} + +fn parse_host_port(base_url: &str) -> (String, u16) { + let stripped = base_url + .strip_prefix("http://") + .expect("VOID_BOX_BASE_URL must start with http://"); + let host_port = stripped.split('/').next().unwrap_or(stripped); + match host_port.split_once(':') { + Some((host, port)) => (host.to_string(), port.parse::().expect("valid port")), + None => (host_port.to_string(), 80), + } +} + +fn http_request(base_url: &str, method: &str, path: &str, body: Option<&str>) -> (u16, String) { + let (host, port) = parse_host_port(base_url); + let mut stream = TcpStream::connect(format!("{host}:{port}")).expect("connect"); + let body = body.unwrap_or(""); + let request = format!( + "{method} {path} HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(request.as_bytes()).expect("write request"); + let mut response = String::new(); + stream.read_to_string(&mut response).expect("read response"); + + let (head, body) = response + .split_once("\r\n\r\n") + .expect("response has head/body"); + let status = head + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse::().ok()) + .expect("status code"); + (status, body.to_string()) +} + +fn http_get_json(base_url: &str, path: &str) -> (u16, Value) { + let (status, body) = http_request(base_url, "GET", path, None); + let json = serde_json::from_str::(&body).unwrap_or_else(|_| json!({})); + (status, json) +} + +fn http_post_json(base_url: &str, path: &str, payload: &Value) -> (u16, Value) { + let body = payload.to_string(); + let (status, body) = http_request(base_url, "POST", path, Some(&body)); + let json = serde_json::from_str::(&body).unwrap_or_else(|_| json!({})); + (status, json) +} + +fn assert_error_shape(v: &Value) { + assert!(v.get("code").and_then(Value::as_str).is_some(), "missing code"); + assert!( + v.get("message").and_then(Value::as_str).is_some(), + "missing message" + ); + assert!( + v.get("retryable").and_then(Value::as_bool).is_some(), + "missing retryable" + ); +} + +fn start_payload(run_id: &str, spec_file: &str) -> Value { + json!({ + "run_id": run_id, + "file": spec_file, + "policy": { + "max_parallel_microvms_per_run": 1, + "max_stage_retries": 1, + "stage_timeout_secs": 60, + "cancel_grace_period_secs": 5 + } + }) +} + +fn start_payload_with_policy(run_id: &str, spec_file: &str, policy: Value) -> Value { + json!({ + "run_id": run_id, + "file": spec_file, + "policy": policy + }) +} + +fn start_payload_without_policy(run_id: &str, spec_file: &str) -> Value { + json!({ + "run_id": run_id, + "file": spec_file + }) +} + +fn is_terminal_status(status: &str) -> bool { + matches!( + status.to_ascii_lowercase().as_str(), + "succeeded" | "failed" | "cancelled" | "canceled" + ) +} + +fn wait_until_terminal(base: &str, run_id: &str, timeout_secs: u64) -> Value { + let attempts = timeout_secs * 10; + for _ in 0..attempts { + let (status, run) = http_get_json(base, &format!("/v1/runs/{run_id}")); + if status == 200 { + if let Some(s) = run.get("status").and_then(Value::as_str) { + if is_terminal_status(s) { + return run; + } + } + } + thread::sleep(Duration::from_millis(100)); + } + panic!("run '{run_id}' did not reach terminal state within {timeout_secs}s"); +} + +fn assert_no_spec_parse_failure(base: &str, run_id: &str) { + let (status, events) = http_get_json(base, &format!("/v1/runs/{run_id}/events")); + assert_eq!(status, 200, "failed to fetch events for {run_id}: {events}"); + let events = events + .as_array() + .unwrap_or_else(|| panic!("events response is not an array for {run_id}: {events}")); + for event in events { + let event_type = event + .get("event_type") + .and_then(Value::as_str) + .unwrap_or_default(); + let event_type_v2 = event + .get("event_type_v2") + .and_then(Value::as_str) + .unwrap_or_default(); + let message = event + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + event_type != "spec.parse_failed" && event_type_v2 != "SpecParseFailed", + "run '{run_id}' has spec parse failure event: {event}" + ); + assert!( + !message.contains("failed to read"), + "run '{run_id}' has file-read failure message in event: {event}" + ); + } +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn health_check() { + let base = require_env("VOID_BOX_BASE_URL"); + let (status, json) = http_get_json(&base, "/v1/health"); + assert_eq!(status, 200); + assert!(json.get("status").and_then(Value::as_str).is_some()); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn start_returns_enriched_contract_fields() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-start"); + let (status, json) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status, 200, "body={json}"); + assert_eq!(json.get("run_id").and_then(Value::as_str), Some(run_id.as_str())); + assert!(json.get("attempt_id").and_then(Value::as_u64).is_some()); + assert!(json.get("state").and_then(Value::as_str).is_some()); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn start_idempotency_active_run() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-idempotent-start"); + let payload = start_payload(&run_id, &spec); + + let (status_1, json_1) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_1, 200, "body={json_1}"); + let first_attempt = json_1 + .get("attempt_id") + .and_then(Value::as_u64) + .expect("attempt_id"); + + let (status_2, json_2) = http_post_json(&base, "/v1/runs", &payload); + if status_2 == 200 { + assert_eq!(json_2.get("run_id").and_then(Value::as_str), Some(run_id.as_str())); + assert_eq!( + json_2.get("attempt_id").and_then(Value::as_u64), + Some(first_attempt) + ); + return; + } + + // Fast-completing fixtures can transition to terminal between start calls. + assert_eq!(status_2, 409, "body={json_2}"); + assert_error_shape(&json_2); + assert_eq!( + json_2.get("code").and_then(Value::as_str), + Some("ALREADY_TERMINAL") + ); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn inspect_enriched_fields() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-inspect"); + let (status_start, _) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status_start, 200); + + let (status, json) = http_get_json(&base, &format!("/v1/runs/{run_id}")); + assert_eq!(status, 200, "body={json}"); + assert!(json.get("id").and_then(Value::as_str).is_some()); + assert!(json.get("status").and_then(Value::as_str).is_some()); + assert!(json.get("attempt_id").and_then(Value::as_u64).is_some()); + assert!(json.get("started_at").and_then(Value::as_str).is_some()); + assert!(json.get("updated_at").and_then(Value::as_str).is_some()); + assert!(json.get("active_stage_count").and_then(Value::as_u64).is_some()); + assert!(json.get("active_microvm_count").and_then(Value::as_u64).is_some()); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn events_envelope_required_fields() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-events-envelope"); + let (status_start, _) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status_start, 200); + let _ = wait_until_terminal(&base, &run_id, 30); + + let (status, json) = http_get_json(&base, &format!("/v1/runs/{run_id}/events")); + assert_eq!(status, 200, "body={json}"); + let events = json.as_array().expect("events array"); + let mut seqs = Vec::with_capacity(events.len()); + let mut ids = std::collections::BTreeSet::new(); + for e in events { + let event_id = e.get("event_id").and_then(Value::as_str).expect("event_id"); + let seq = e.get("seq").and_then(Value::as_u64).expect("seq"); + assert!(e.get("event_type").and_then(Value::as_str).is_some(), "event_type"); + assert!(e.get("attempt_id").and_then(Value::as_u64).is_some(), "attempt_id"); + assert!(e.get("timestamp").and_then(Value::as_str).is_some(), "timestamp"); + assert!(e.get("run_id").and_then(Value::as_str).is_some(), "run_id"); + seqs.push(seq); + assert!(ids.insert(event_id.to_string()), "duplicate event_id"); + } + assert!(!seqs.is_empty(), "expected non-empty event list"); + let mut sorted = seqs.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + seqs.len(), + "seq values must be unique per run+attempt" + ); + let min = *sorted.first().expect("min seq"); + let max = *sorted.last().expect("max seq"); + assert_eq!( + max - min + 1, + sorted.len() as u64, + "seq values should be gapless per run+attempt" + ); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn events_resume_from_event_id() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-events-resume"); + let (status_start, _) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status_start, 200); + + let (status_all, json_all) = http_get_json(&base, &format!("/v1/runs/{run_id}/events")); + assert_eq!(status_all, 200); + let events = json_all.as_array().expect("events array"); + let first_id = events + .first() + .and_then(|e| e.get("event_id")) + .and_then(Value::as_str) + .expect("first event id"); + + let (status_resume, json_resume) = http_get_json( + &base, + &format!("/v1/runs/{run_id}/events?from_event_id={first_id}"), + ); + assert_eq!(status_resume, 200); + let resumed = json_resume.as_array().expect("resumed array"); + if !resumed.is_empty() { + let resumed_first = resumed[0] + .get("event_id") + .and_then(Value::as_str) + .expect("event id"); + assert_ne!(resumed_first, first_id); + } + + let (status_missing, json_missing) = + http_get_json(&base, &format!("/v1/runs/{run_id}/events?from_event_id=evt_missing")); + assert_eq!(status_missing, 200); + assert!(json_missing.as_array().is_some()); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn cancel_returns_terminal_response_shape() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-cancel-shape"); + let (status_start, _) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status_start, 200); + + let (status_cancel, json_cancel) = http_post_json( + &base, + &format!("/v1/runs/{run_id}/cancel"), + &json!({"reason":"test cancel"}), + ); + assert_eq!(status_cancel, 200, "body={json_cancel}"); + assert_eq!( + json_cancel.get("run_id").and_then(Value::as_str), + Some(run_id.as_str()) + ); + assert!(json_cancel.get("state").and_then(Value::as_str).is_some()); + assert!( + json_cancel + .get("terminal_event_id") + .and_then(Value::as_str) + .is_some() + ); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn cancel_idempotency() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-cancel-idempotent"); + let (status_start, _) = http_post_json(&base, "/v1/runs", &start_payload(&run_id, &spec)); + assert_eq!(status_start, 200); + + let (status_1, json_1) = http_post_json( + &base, + &format!("/v1/runs/{run_id}/cancel"), + &json!({"reason":"idempotency-1"}), + ); + assert_eq!(status_1, 200, "body={json_1}"); + let first_terminal = json_1 + .get("terminal_event_id") + .and_then(Value::as_str) + .expect("terminal_event_id") + .to_string(); + + let (status_2, json_2) = http_post_json( + &base, + &format!("/v1/runs/{run_id}/cancel"), + &json!({"reason":"idempotency-2"}), + ); + assert_eq!(status_2, 200, "body={json_2}"); + assert_eq!( + json_2.get("terminal_event_id").and_then(Value::as_str), + Some(first_terminal.as_str()) + ); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn structured_error_not_found() { + let base = require_env("VOID_BOX_BASE_URL"); + let (status, json) = http_get_json(&base, "/v1/runs/does-not-exist"); + assert!(status >= 400); + assert_error_shape(&json); + assert_eq!(json.get("code").and_then(Value::as_str), Some("NOT_FOUND")); + assert_eq!(json.get("retryable").and_then(Value::as_bool), Some(false)); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn structured_error_invalid_policy() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-bad-policy"); + let payload = json!({ + "run_id": run_id, + "file": spec, + "policy": { + "max_parallel_microvms_per_run": 0, + "max_stage_retries": 1, + "stage_timeout_secs": 60, + "cancel_grace_period_secs": 5 + } + }); + let (status, json) = http_post_json(&base, "/v1/runs", &payload); + assert!(status >= 400); + assert_error_shape(&json); + assert_eq!( + json.get("code").and_then(Value::as_str), + Some("INVALID_POLICY") + ); + assert_eq!(json.get("retryable").and_then(Value::as_bool), Some(false)); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn list_runs_for_reconciliation() { + let base = require_env("VOID_BOX_BASE_URL"); + let (status_active, active) = http_get_json(&base, "/v1/runs?state=active"); + assert_eq!(status_active, 200, "body={active}"); + assert!(active.get("runs").and_then(Value::as_array).is_some()); + + let (status_terminal, terminal) = http_get_json(&base, "/v1/runs?state=terminal"); + assert_eq!(status_terminal, 200, "body={terminal}"); + assert!(terminal.get("runs").and_then(Value::as_array).is_some()); +} + +#[test] +#[ignore = "requires live void-box daemon"] +fn already_terminal_start_behavior() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TEST_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-terminal-start"); + let payload = start_payload(&run_id, &spec); + + let (status_start, _) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_start, 200); + let (status_cancel, _) = http_post_json( + &base, + &format!("/v1/runs/{run_id}/cancel"), + &json!({"reason":"terminalize"}), + ); + assert_eq!(status_cancel, 200); + + let (status_restart, json_restart) = http_post_json(&base, "/v1/runs", &payload); + assert!(status_restart >= 400, "body={json_restart}"); + assert_error_shape(&json_restart); + assert_eq!( + json_restart.get("code").and_then(Value::as_str), + Some("ALREADY_TERMINAL") + ); + assert_eq!( + json_restart.get("retryable").and_then(Value::as_bool), + Some(false) + ); +} + +#[test] +#[ignore = "requires live void-box daemon and timeout fixture"] +fn policy_timeout_enforced_failure() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_TIMEOUT_SPEC_FILE", DefaultSpecKind::Timeout); + let run_id = unique_run_id("contract-policy-timeout"); + let payload = start_payload_with_policy( + &run_id, + &spec, + json!({ + "max_parallel_microvms_per_run": 2, + "max_stage_retries": 1, + "stage_timeout_secs": 1, + "cancel_grace_period_secs": 5 + }), + ); + let (status_start, body_start) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_start, 200, "body={body_start}"); + + let terminal = wait_until_terminal(&base, &run_id, 30); + assert_no_spec_parse_failure(&base, &run_id); + let status = terminal + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + assert_eq!(status, "failed", "terminal={terminal}"); +} + +#[test] +#[ignore = "requires live void-box daemon and parallel fixture"] +fn policy_parallel_limit_caps_active_microvms() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_PARALLEL_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-policy-parallel"); + let payload = start_payload_with_policy( + &run_id, + &spec, + json!({ + "max_parallel_microvms_per_run": 1, + "max_stage_retries": 1, + "stage_timeout_secs": 120, + "cancel_grace_period_secs": 5 + }), + ); + let (status_start, body_start) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_start, 200, "body={body_start}"); + + let mut max_seen = 0u64; + for _ in 0..300 { + let (status, run) = http_get_json(&base, &format!("/v1/runs/{run_id}")); + assert_eq!(status, 200, "body={run}"); + if let Some(active) = run.get("active_microvm_count").and_then(Value::as_u64) { + max_seen = max_seen.max(active); + } + if let Some(s) = run.get("status").and_then(Value::as_str) { + if is_terminal_status(s) { + break; + } + } + thread::sleep(Duration::from_millis(100)); + } + assert_no_spec_parse_failure(&base, &run_id); + assert!(max_seen <= 1, "max active_microvm_count was {max_seen}"); +} + +#[test] +#[ignore = "requires live void-box daemon and retry fixture"] +fn policy_retry_cap_is_persisted() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_RETRY_SPEC_FILE", DefaultSpecKind::LongRunning); + let run_id = unique_run_id("contract-policy-retry-persist"); + let payload = start_payload_with_policy( + &run_id, + &spec, + json!({ + "max_parallel_microvms_per_run": 1, + "max_stage_retries": 0, + "stage_timeout_secs": 60, + "cancel_grace_period_secs": 5 + }), + ); + let (status_start, body_start) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_start, 200, "body={body_start}"); + + let (status, run) = http_get_json(&base, &format!("/v1/runs/{run_id}")); + assert_eq!(status, 200, "body={run}"); + assert_no_spec_parse_failure(&base, &run_id); + let policy = run.get("policy").expect("policy present"); + assert_eq!( + policy.get("max_stage_retries").and_then(Value::as_u64), + Some(0) + ); +} + +#[test] +#[ignore = "requires live void-box daemon and retry fixture"] +fn policy_retry_cap_reduces_event_churn() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path("VOID_BOX_RETRY_SPEC_FILE", DefaultSpecKind::LongRunning); + + let run_a = unique_run_id("contract-policy-retry-a"); + let payload_a = start_payload_with_policy( + &run_a, + &spec, + json!({ + "max_parallel_microvms_per_run": 1, + "max_stage_retries": 0, + "stage_timeout_secs": 60, + "cancel_grace_period_secs": 5 + }), + ); + let (status_a, body_a) = http_post_json(&base, "/v1/runs", &payload_a); + assert_eq!(status_a, 200, "body={body_a}"); + let _ = wait_until_terminal(&base, &run_a, 30); + + let run_b = unique_run_id("contract-policy-retry-b"); + let payload_b = start_payload_with_policy( + &run_b, + &spec, + json!({ + "max_parallel_microvms_per_run": 1, + "max_stage_retries": 2, + "stage_timeout_secs": 60, + "cancel_grace_period_secs": 5 + }), + ); + let (status_b, body_b) = http_post_json(&base, "/v1/runs", &payload_b); + assert_eq!(status_b, 200, "body={body_b}"); + let _ = wait_until_terminal(&base, &run_b, 30); + + let (status_events_a, events_a) = http_get_json(&base, &format!("/v1/runs/{run_a}/events")); + let (status_events_b, events_b) = http_get_json(&base, &format!("/v1/runs/{run_b}/events")); + assert_eq!(status_events_a, 200, "body={events_a}"); + assert_eq!(status_events_b, 200, "body={events_b}"); + let len_a = events_a.as_array().map(|a| a.len()).unwrap_or_default(); + let len_b = events_b.as_array().map(|a| a.len()).unwrap_or_default(); + assert_no_spec_parse_failure(&base, &run_a); + assert_no_spec_parse_failure(&base, &run_b); + assert!( + len_b >= len_a, + "expected retries=2 run to have >= event count than retries=0 (a={len_a}, b={len_b})" + ); +} + +#[test] +#[ignore = "requires live void-box daemon and no-policy baseline fixture"] +fn policy_no_policy_regression_allows_completion() { + let base = require_env("VOID_BOX_BASE_URL"); + let spec = resolve_spec_path( + "VOID_BOX_NO_POLICY_SPEC_FILE", + DefaultSpecKind::BaselineSuccess, + ); + let run_id = unique_run_id("contract-policy-no-policy"); + let payload = start_payload_without_policy(&run_id, &spec); + let (status_start, body_start) = http_post_json(&base, "/v1/runs", &payload); + assert_eq!(status_start, 200, "body={body_start}"); + + let terminal = wait_until_terminal(&base, &run_id, 30); + assert_no_spec_parse_failure(&base, &run_id); + let status = terminal + .get("status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + assert_eq!(status, "succeeded", "terminal={terminal}"); +} diff --git a/web/void-control-ux/.env.example b/web/void-control-ux/.env.example new file mode 100644 index 0000000..fd340b0 --- /dev/null +++ b/web/void-control-ux/.env.example @@ -0,0 +1,5 @@ +# Direct daemon URL. In local dev you can also omit this and use Vite proxy mode (`/api`). +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 + +# Optional bridge URL for Launch modal spec upload/content mode. +VITE_VOID_CONTROL_BASE_URL=http://127.0.0.1:43210 diff --git a/web/void-control-ux/README.md b/web/void-control-ux/README.md new file mode 100644 index 0000000..00fb497 --- /dev/null +++ b/web/void-control-ux/README.md @@ -0,0 +1,76 @@ +# void-control-ux + +Graph-first operator dashboard for `void-control` and `void-box`. + +## Stack + +- React + TypeScript + Vite +- Sigma + Graphology (execution graph renderer) +- TanStack Query (polling and cache) +- Zustand (selection/pinning UI state) + +## Run + +```bash +cd web/void-control-ux +npm install +npm run dev -- --host 127.0.0.1 --port 3000 +``` + +Open: `http://127.0.0.1:3000` + +Default local env behavior: + +- If `VITE_VOID_BOX_BASE_URL` is not set, the app uses `/api` (Vite proxy mode). +- If `VITE_VOID_CONTROL_BASE_URL` is not set, launch/upload uses `http://127.0.0.1:43210`. + +Example `.env`: + +```bash +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 +VITE_VOID_CONTROL_BASE_URL=http://127.0.0.1:43210 +``` + +## Launch Bridge (for YAML editor/upload) + +The launch modal can upload/persist spec text via `voidctl serve` bridge mode. + +Start bridge: + +```bash +cargo run --features serde --bin voidctl -- serve +``` + +Run UI pointing to bridge: + +```bash +VITE_VOID_BOX_BASE_URL=http://127.0.0.1:43100 \ +VITE_VOID_CONTROL_BASE_URL=http://127.0.0.1:43210 \ +npm run dev -- --host 127.0.0.1 --port 3000 +``` + +## Requirements + +- `void-box` daemon running (`/v1/health` reachable) +- Runtime contract endpoints available: + - `GET /v1/runs?state=active|terminal` + - `GET /v1/runs/{id}` + - `GET /v1/runs/{id}/events` + - `GET /v1/runs/{id}/stages` + - `GET /v1/runs/{id}/telemetry` +- `POST /v1/runs` +- `POST /v1/launch` (bridge endpoint) + - `POST /v1/runs/{id}/cancel` + +## Current UX (MVP+) + +- Runs list (active + terminal) with test-run filter +- Sigma execution graph with stage selection and dependency highlighting +- Node inspector (state/timing/dependencies/metrics/recent events) +- Launch Run modal (`+ Launch Box`) with: + - spec path + run id + - YAML/JSON upload + - inline validation +- Run logs panel +- Cancel run action +- Live polling refresh diff --git a/web/void-control-ux/index.html b/web/void-control-ux/index.html new file mode 100644 index 0000000..ab0bc6b --- /dev/null +++ b/web/void-control-ux/index.html @@ -0,0 +1,12 @@ + + + + + + void-control ux + + +
+ + + diff --git a/web/void-control-ux/package-lock.json b/web/void-control-ux/package-lock.json new file mode 100644 index 0000000..544df7f --- /dev/null +++ b/web/void-control-ux/package-lock.json @@ -0,0 +1,2060 @@ +{ + "name": "void-control-ux", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "void-control-ux", + "version": "0.0.1", + "dependencies": { + "@tanstack/react-query": "^5.66.9", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "elkjs": "^0.11.1", + "graphology": "^0.26.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sigma": "^3.0.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.58.2", + "typescript": "^5.7.3", + "vite": "^6.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001775", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz", + "integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/sigma": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", + "integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0", + "graphology-utils": "^2.5.2" + } + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/void-control-ux/package.json b/web/void-control-ux/package.json new file mode 100644 index 0000000..d4f6241 --- /dev/null +++ b/web/void-control-ux/package.json @@ -0,0 +1,30 @@ +{ + "name": "void-control-ux", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.66.9", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "elkjs": "^0.11.1", + "graphology": "^0.26.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sigma": "^3.0.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/react": "^18.3.20", + "@types/react-dom": "^18.3.6", + "@vitejs/plugin-react": "^4.3.4", + "playwright": "^1.58.2", + "typescript": "^5.7.3", + "vite": "^6.1.0" + } +} diff --git a/web/void-control-ux/src/App.tsx b/web/void-control-ux/src/App.tsx new file mode 100644 index 0000000..6ce3c45 --- /dev/null +++ b/web/void-control-ux/src/App.tsx @@ -0,0 +1,400 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { RunsList } from './components/RunsList'; +import { RunGraph } from './components/RunGraph'; +import { NodeInspector } from './components/NodeInspector'; +import { RunLogs } from './components/RunLogs'; +import { LaunchRunModal } from './components/LaunchRunModal'; +import { baseUrl, cancelRun, getRun, getRunEvents, getRunStages, getRunTelemetrySamples, getRuns, launchRunFromSpecText, startRun } from './lib/api'; +import { useUiStore } from './store/ui'; +import { defaultStageSelection, eventNodeId, filterEventsForStage, parseNodeId, resolveSelectedStage, runNodeId } from './lib/selectors'; +import type { StageView } from './lib/types'; + +function normalizeStageStatuses(stages: StageView[], runStateRaw?: string): StageView[] { + const runState = (runStateRaw ?? '').toLowerCase(); + const isTerminal = runState === 'succeeded' || runState === 'failed' || runState === 'cancelled' || runState === 'canceled'; + if (!isTerminal) return stages; + + return stages.map((stage) => { + // If the run is already terminal, a stage stuck in queued is effectively skipped/blocked. + if (stage.status === 'queued') { + return { ...stage, status: 'skipped' }; + } + return stage; + }); +} + +function isHiddenTestRun(runIdRaw: string): boolean { + const id = runIdRaw.toLowerCase(); + return id.startsWith('contract-') || id.includes('void_box_contract'); +} + +export function App() { + const [hideTestRuns, setHideTestRuns] = useState(true); + const [runStateFilter, setRunStateFilter] = useState<'all' | 'running' | 'failed' | 'succeeded' | 'cancelled'>('all'); + const [isLaunchOpen, setIsLaunchOpen] = useState(false); + const [launchError, setLaunchError] = useState(null); + const [launchPending, setLaunchPending] = useState(false); + const selectedRunId = useUiStore((s) => s.selectedRunId); + const selectedNodeId = useUiStore((s) => s.selectedNodeId); + const selectedNodeType = useUiStore((s) => s.selectedNodeType); + const isSelectionPinned = useUiStore((s) => s.isSelectionPinned); + const setSelectedRunId = useUiStore((s) => s.setSelectedRunId); + const setSelectedNode = useUiStore((s) => s.setSelectedNode); + const clearSelectedNode = useUiStore((s) => s.clearSelectedNode); + const setSelectionPinned = useUiStore((s) => s.setSelectionPinned); + const setLastSeen = useUiStore((s) => s.setLastSeenEvent); + const prevRunRef = useRef(null); + + const activeRuns = useQuery({ + queryKey: ['runs', 'active'], + queryFn: () => getRuns('active'), + refetchInterval: 2500 + }); + + const terminalRuns = useQuery({ + queryKey: ['runs', 'terminal'], + queryFn: () => getRuns('terminal'), + refetchInterval: 5000 + }); + + const runDetail = useQuery({ + queryKey: ['run', selectedRunId], + queryFn: () => getRun(selectedRunId as string), + enabled: !!selectedRunId, + refetchInterval: 2000 + }); + + const events = useQuery({ + queryKey: ['events', selectedRunId], + queryFn: () => getRunEvents(selectedRunId as string), + enabled: !!selectedRunId, + refetchInterval: 1200 + }); + + const stages = useQuery({ + queryKey: ['stages', selectedRunId], + queryFn: () => getRunStages(selectedRunId as string), + enabled: !!selectedRunId, + refetchInterval: 1500 + }); + + const telemetry = useQuery({ + queryKey: ['telemetry', selectedRunId], + queryFn: () => getRunTelemetrySamples(selectedRunId as string), + enabled: !!selectedRunId, + refetchInterval: 1500 + }); + + const cancelMutation = useMutation({ + mutationFn: async () => cancelRun(selectedRunId as string, 'cancelled from dashboard') + }); + + const launchMutation = useMutation({ + mutationFn: async ({ file, runId }: { file: string; runId?: string }) => startRun(file, runId) + }); + + const filteredActiveRuns = useMemo( + () => + (activeRuns.data ?? []).filter((run) => { + if (!hideTestRuns) return true; + const id = (run.id ?? run.run_id ?? '').trim(); + return !isHiddenTestRun(id); + }), + [activeRuns.data, hideTestRuns] + ); + + const filteredTerminalRuns = useMemo( + () => + (terminalRuns.data ?? []).filter((run) => { + if (!hideTestRuns) return true; + const id = (run.id ?? run.run_id ?? '').trim(); + return !isHiddenTestRun(id); + }), + [terminalRuns.data, hideTestRuns] + ); + + const visibleActiveRuns = useMemo( + () => + filteredActiveRuns.filter((run) => { + if (runStateFilter === 'all') return true; + const state = (run.status ?? run.state ?? 'unknown').toString().toLowerCase(); + return state === runStateFilter; + }), + [filteredActiveRuns, runStateFilter] + ); + + const visibleTerminalRuns = useMemo( + () => + filteredTerminalRuns.filter((run) => { + if (runStateFilter === 'all') return true; + const state = (run.status ?? run.state ?? 'unknown').toString().toLowerCase(); + return state === runStateFilter; + }), + [filteredTerminalRuns, runStateFilter] + ); + + const resolvedRunId = useMemo(() => { + if (selectedRunId) return selectedRunId; + const firstActive = visibleActiveRuns[0]; + const firstTerminal = visibleTerminalRuns[0]; + return (firstActive?.id ?? firstActive?.run_id ?? firstTerminal?.id ?? firstTerminal?.run_id ?? null) as string | null; + }, [selectedRunId, visibleActiveRuns, visibleTerminalRuns]); + + const eventList = events.data ?? []; + const listError = (activeRuns.error as Error | null)?.message ?? (terminalRuns.error as Error | null)?.message; + const detailError = (runDetail.error as Error | null)?.message + ?? (events.error as Error | null)?.message + ?? (stages.error as Error | null)?.message + ?? (telemetry.error as Error | null)?.message; + + const normalizedStages = useMemo( + () => normalizeStageStatuses(stages.data ?? [], (runDetail.data?.status ?? runDetail.data?.state)?.toString()), + [stages.data, runDetail.data?.status, runDetail.data?.state] + ); + const parsedSelected = useMemo(() => parseNodeId(selectedNodeId), [selectedNodeId]); + const selectedStage = useMemo( + () => resolveSelectedStage(selectedNodeId, normalizedStages), + [selectedNodeId, normalizedStages] + ); + const selectedEventRef = parsedSelected?.type === 'event' ? parsedSelected.eventRef : null; + const scopedEvents = useMemo(() => { + if (parsedSelected?.type === 'stage' && selectedStage) { + return filterEventsForStage(eventList, selectedStage); + } + return eventList; + }, [parsedSelected?.type, selectedStage, eventList]); + + useEffect(() => { + if (!selectedRunId && resolvedRunId) { + setSelectedRunId(resolvedRunId); + } + }, [selectedRunId, resolvedRunId, setSelectedRunId]); + + useEffect(() => { + if (resolvedRunId && eventList.length > 0) { + setLastSeen(resolvedRunId, eventList[eventList.length - 1]?.event_id); + } + }, [resolvedRunId, eventList, setLastSeen]); + + useEffect(() => { + if (!resolvedRunId) { + clearSelectedNode(); + prevRunRef.current = null; + return; + } + if (isSelectionPinned) return; + + const parsed = parseNodeId(selectedNodeId); + const runChanged = prevRunRef.current !== resolvedRunId; + const nodeMatchesRun = parsed?.runId === resolvedRunId; + const stageList = normalizedStages; + const promoteRunRoot = parsed?.type === 'run' && stageList.length > 0; + const needsDefault = runChanged || !selectedNodeId || !nodeMatchesRun || promoteRunRoot; + + if (needsDefault) { + const latestEvent = eventList.length > 0 ? eventList[eventList.length - 1] : null; + setSelectedNode( + stageList.length > 0 + ? defaultStageSelection(resolvedRunId, stageList) + : latestEvent + ? eventNodeId(resolvedRunId, latestEvent.event_id || `${latestEvent.seq}-latest`) + : runNodeId(resolvedRunId), + stageList.length > 0 ? 'stage' : (latestEvent ? 'event' : 'run') + ); + } + prevRunRef.current = resolvedRunId; + }, [ + resolvedRunId, + selectedNodeId, + isSelectionPinned, + stages.data, + normalizedStages, + eventList, + setSelectedNode, + clearSelectedNode + ]); + + const onLaunchRun = async (params: { file: string; runId?: string; specText?: string }) => { + setLaunchError(null); + setLaunchPending(true); + try { + let result: { run_id?: string; runId?: string; attempt_id?: number; state?: string }; + const hasSpecText = Boolean(params.specText && params.specText.trim().length > 0); + if (hasSpecText) { + try { + result = await launchRunFromSpecText({ + specText: params.specText as string, + runId: params.runId, + file: params.file, + specFormat: (params.specText as string).trimStart().startsWith('{') ? 'json' : 'yaml' + }); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + const bridgeUnavailable = + msg.includes('Failed to fetch') || + msg.includes('ECONNREFUSED') || + msg.includes('HTTP 404') || + msg.includes('HTTP 405'); + if (!bridgeUnavailable) throw error; + result = await launchMutation.mutateAsync({ file: params.file, runId: params.runId }); + } + } else { + result = await launchMutation.mutateAsync({ file: params.file, runId: params.runId }); + } + const createdRunId = result.run_id ?? ('runId' in result ? result.runId : undefined) ?? params.runId; + await Promise.all([activeRuns.refetch(), terminalRuns.refetch()]); + if (createdRunId) { + setSelectedRunId(createdRunId); + } + setIsLaunchOpen(false); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + setLaunchError(msg); + } finally { + setLaunchPending(false); + } + }; + + const onSelectEvent = (event: { event_id: string; seq: number }) => { + if (!resolvedRunId) return; + const ref = (event.event_id ?? '').trim(); + setSelectedNode(eventNodeId(resolvedRunId, ref.length > 0 ? ref : `${event.seq}`), 'event'); + }; + + return ( +
+
+
+ + + +
+
Void Control
+
orchestration explorer
+
+
+
daemon: {baseUrl}
+
+ +
+ { + setLaunchError(null); + setIsLaunchOpen(true); + }} + hideTestRuns={hideTestRuns} + onToggleHideTestRuns={() => setHideTestRuns((v) => !v)} + stateFilter={runStateFilter} + onStateFilterChange={setRunStateFilter} + /> + +
+ {(listError || detailError) && ( +
+ Connection error: {listError ?? detailError} +
+ )} +
+
+ Run: {resolvedRunId ?? '-'} + {(runDetail.data?.status ?? runDetail.data?.state ?? 'unknown').toString()} +
+
+ +
+
+ + {!resolvedRunId ? ( +
No runs yet. Start one from terminal and refresh.
+ ) : ( +
+
+ {(eventList.length === 0 && (stages.data ?? []).length === 0) ? ( +
+
Execution Graph
+
No stages/events found for this run yet.
+
+ ) : ( + + )} + +
+ + { + clearSelectedNode(); + setSelectedNode(runNodeId(resolvedRunId), 'run'); + }} + onTogglePinned={() => setSelectionPinned(!isSelectionPinned)} + /> +
+ )} +
+
+ + setIsLaunchOpen(false)} + onSubmit={onLaunchRun} + /> +
+ ); +} diff --git a/web/void-control-ux/src/components/EventTimeline.tsx b/web/void-control-ux/src/components/EventTimeline.tsx new file mode 100644 index 0000000..55307ef --- /dev/null +++ b/web/void-control-ux/src/components/EventTimeline.tsx @@ -0,0 +1,138 @@ +import ReactECharts from 'echarts-for-react'; +import type { EChartsOption } from 'echarts'; +import type { RunEvent, TelemetrySample } from '../lib/types'; +import { rollingEventsPerSec } from '../lib/selectors'; + +interface EventTimelineProps { + events: RunEvent[]; + telemetry?: TelemetrySample[]; + selectedEventRef?: string | null; + onSelectEvent?: (event: RunEvent) => void; +} + +function chartBase(): Pick { + return { + grid: { left: 36, right: 12, top: 14, bottom: 28 }, + xAxis: { + type: 'category', + axisLine: { lineStyle: { color: '#334155' } }, + axisLabel: { color: '#94a3b8', fontSize: 11 } + }, + yAxis: { + type: 'value', + axisLine: { show: false }, + splitLine: { lineStyle: { color: '#1f2937', type: 'dashed' } }, + axisLabel: { color: '#94a3b8', fontSize: 11 } + }, + tooltip: { + trigger: 'axis', + backgroundColor: '#0b1222', + borderColor: '#334155', + textStyle: { color: '#e2e8f0' } + } + }; +} + +export function EventTimeline({ + events, + telemetry = [], + selectedEventRef = null, + onSelectEvent +}: EventTimelineProps) { + const latest = telemetry.length > 0 ? telemetry[telemetry.length - 1] : null; + const latestHostCpu = latest?.host?.cpu_percent ?? 0; + const latestHostRssMb = (latest?.host?.rss_bytes ?? 0) / (1024 * 1024); + const latestGuestCpu = latest?.guest?.cpu_percent ?? 0; + const latestGuestMemMb = (latest?.guest?.memory_used_bytes ?? 0) / (1024 * 1024); + const eventsPerSec = rollingEventsPerSec(events); + + const hasTelemetry = telemetry.length > 0; + const samples = telemetry.slice(-32); + const fallbackLen = Math.max(10, Math.min(24, events.length || 12)); + const chartLabels = hasTelemetry + ? samples.map((s) => `#${s.seq}`) + : Array.from({ length: fallbackLen }, (_, i) => `#${i + 1}`); + const chartData = hasTelemetry + ? samples.map((s) => Number((s.host?.cpu_percent ?? 0).toFixed(2))) + : Array.from({ length: fallbackLen }, () => 0); + + const telemetryOption: EChartsOption = { + ...chartBase(), + xAxis: { + ...(chartBase().xAxis as object), + data: chartLabels + }, + yAxis: { + ...(chartBase().yAxis as object), + max: 100 + }, + series: [ + { + name: 'host cpu', + type: 'line', + smooth: true, + showSymbol: false, + lineStyle: { width: 2, color: hasTelemetry ? '#22d3ee' : '#334155', type: hasTelemetry ? 'solid' : 'dashed' }, + areaStyle: hasTelemetry + ? { + color: { + type: 'linear', x: 0, y: 0, x2: 0, y2: 1, + colorStops: [ + { offset: 0, color: 'rgba(34, 211, 238, 0.35)' }, + { offset: 1, color: 'rgba(34, 211, 238, 0.03)' } + ] + } + } + : undefined, + data: chartData + } + ] + }; + + const stageCpuMap = new Map(); + for (const sample of telemetry) { + const cpu = sample.host?.cpu_percent; + if (typeof cpu !== 'number') continue; + const stage = sample.stage_name || 'unknown'; + const entry = stageCpuMap.get(stage) ?? { sum: 0, count: 0 }; + entry.sum += cpu; + entry.count += 1; + stageCpuMap.set(stage, entry); + } + + const stageCpu = [...stageCpuMap.entries()] + .map(([stage, v]) => ({ stage, avgCpu: v.count > 0 ? v.sum / v.count : 0 })) + .sort((a, b) => b.avgCpu - a.avgCpu) + .slice(0, 4); + + return ( +
+
Telemetry Timeline (Host CPU %)
+
+
host cpu{latestHostCpu.toFixed(1)}%
+
host rss{latestHostRssMb.toFixed(1)} MB
+
guest cpu{latestGuestCpu.toFixed(1)}%
+
guest mem{latestGuestMemMb.toFixed(1)} MB
+
+ events/s + {eventsPerSec.toFixed(1)} +
+
+ + {stageCpu.length > 0 && ( +
+ {stageCpu.map((s) => ( + {s.stage}: {s.avgCpu.toFixed(1)}% + ))} +
+ )} + + {!hasTelemetry &&
No telemetry samples yet for this run.
} + +
+ +
+ +
+ ); +} diff --git a/web/void-control-ux/src/components/LaunchRunModal.tsx b/web/void-control-ux/src/components/LaunchRunModal.tsx new file mode 100644 index 0000000..421827c --- /dev/null +++ b/web/void-control-ux/src/components/LaunchRunModal.tsx @@ -0,0 +1,180 @@ +import { useMemo, useRef, useState } from 'react'; + +interface LaunchRunModalProps { + open: boolean; + isSubmitting: boolean; + submitError: string | null; + onClose: () => void; + onSubmit: (params: { file: string; runId?: string; specText?: string }) => Promise; +} + +function looksLikeJson(text: string): boolean { + const t = text.trim(); + return t.startsWith('{') || t.startsWith('['); +} + +function validateSpecText(text: string): string[] { + const trimmed = text.trim(); + if (!trimmed) return []; + + if (looksLikeJson(trimmed)) { + try { + const parsed = JSON.parse(trimmed) as Record; + const errors: string[] = []; + const kind = typeof parsed.kind === 'string' ? parsed.kind : ''; + if (kind !== 'workflow' && kind !== 'pipeline') { + errors.push("`kind` must be `workflow` or `pipeline`."); + } + if (typeof parsed.name !== 'string' || parsed.name.trim().length === 0) errors.push('`name` is required.'); + if (kind === 'workflow') { + if (typeof parsed.workflow !== 'object' || parsed.workflow === null) errors.push('`workflow` section is required.'); + const workflow = parsed.workflow as Record | undefined; + if (!workflow || !Array.isArray(workflow.steps)) errors.push("Missing `steps:` list under workflow."); + } + if (kind === 'pipeline') { + if (typeof parsed.pipeline !== 'object' || parsed.pipeline === null) errors.push('`pipeline` section is required.'); + const pipeline = parsed.pipeline as Record | undefined; + if (!pipeline || !Array.isArray(pipeline.boxes)) errors.push("Missing `boxes:` list under pipeline."); + if (!pipeline || !Array.isArray(pipeline.stages)) errors.push("Missing `stages:` list under pipeline."); + } + return errors; + } catch { + return ['Invalid JSON format.']; + } + } + + const errors: string[] = []; + const kindMatch = /^\s*kind\s*:\s*([a-zA-Z0-9_-]+)\s*$/m.exec(trimmed); + const kind = kindMatch?.[1]?.toLowerCase() ?? ''; + if (!/^\s*api_version\s*:\s*/m.test(trimmed)) errors.push('Missing `api_version:`.'); + if (!kindMatch) { + errors.push('Missing `kind:`.'); + } else if (kind !== 'workflow' && kind !== 'pipeline') { + errors.push("`kind` must be `workflow` or `pipeline`."); + } + if (!/^\s*name\s*:\s*/m.test(trimmed)) errors.push('Missing `name:`.'); + if (!/^\s*sandbox\s*:\s*/m.test(trimmed)) errors.push('Missing `sandbox:` section.'); + if (kind === 'workflow') { + if (!/^\s*workflow\s*:\s*/m.test(trimmed)) errors.push('Missing `workflow:` section.'); + if (!/^\s*steps\s*:\s*/m.test(trimmed)) errors.push('Missing `steps:` list under workflow.'); + } + if (kind === 'pipeline') { + if (!/^\s*pipeline\s*:\s*/m.test(trimmed)) errors.push('Missing `pipeline:` section.'); + if (!/^\s*boxes\s*:\s*/m.test(trimmed)) errors.push('Missing `boxes:` list under pipeline.'); + if (!/^\s*stages\s*:\s*/m.test(trimmed)) errors.push('Missing `stages:` list under pipeline.'); + } + return errors; +} + +export function LaunchRunModal({ + open, + isSubmitting, + submitError, + onClose, + onSubmit +}: LaunchRunModalProps) { + const [filePath, setFilePath] = useState('/tmp/void-control-run.yaml'); + const [runId, setRunId] = useState(`ui-${Date.now()}`); + const [specText, setSpecText] = useState(''); + const [uploadedName, setUploadedName] = useState(null); + const fileRef = useRef(null); + + const validationErrors = useMemo(() => validateSpecText(specText), [specText]); + + if (!open) return null; + + const onPickFile = async (file?: File) => { + if (!file) return; + const text = await file.text(); + setSpecText(text); + setUploadedName(file.name); + if (!filePath || filePath.trim().length === 0 || filePath === '/tmp/void-control-run.yaml') { + const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + setFilePath(`/tmp/${safeName}`); + } + }; + + const submit = async () => { + const trimmedPath = filePath.trim(); + if (!trimmedPath) return; + if (validationErrors.length > 0) return; + await onSubmit({ + file: trimmedPath, + runId: runId.trim().length > 0 ? runId.trim() : undefined, + specText: specText.trim().length > 0 ? specText : undefined + }); + }; + + return ( +
+
e.stopPropagation()}> +
+

Launch Run

+ +
+ +
+ + +
+ +
+ + {uploadedName && {uploadedName}} + void onPickFile(e.target.files?.[0])} + /> +
+ +