diff --git a/.cursor/seed-agent.sh b/.cursor/seed-agent.sh index 1b1441248b..f0e3c5e8b3 100644 --- a/.cursor/seed-agent.sh +++ b/.cursor/seed-agent.sh @@ -22,7 +22,7 @@ run_seed() { } run_bootstrap() { - python -m tools.agent_seed bootstrap --base-url "${AGENT_SEED_BASE_URL:-http://127.0.0.1:8000}" + python -m tools.agent_seed bootstrap --base-url "${AGENT_SEED_BASE_URL:-http://127.0.0.1:8000}" "$@" } run_start() { @@ -35,7 +35,8 @@ case "${1:-}" in run_seed "${2:-}" ;; bootstrap) - run_bootstrap + shift + run_bootstrap "$@" ;; start) run_start @@ -49,7 +50,7 @@ case "${1:-}" in run_seed --clear ;; *) - echo "Usage: $0 {seed [--clear]|bootstrap|start|--full|--clear}" >&2 + echo "Usage: $0 {seed [--clear]|bootstrap [--scenario grid|index|completed|lifecycle|all]|start|--full|--clear}" >&2 exit 1 ;; esac diff --git a/.cursor/skills/agent-seed/SKILL.md b/.cursor/skills/agent-seed/SKILL.md index b8bebf2af6..31b6b4695f 100644 --- a/.cursor/skills/agent-seed/SKILL.md +++ b/.cursor/skills/agent-seed/SKILL.md @@ -41,6 +41,7 @@ Master tentacles on `user/reference_tentacles_config/` must exist (see `tools/ag bash .cursor/seed-agent.sh --full # seed + background start + bootstrap — stop node first; do NOT run start again bash .cursor/seed-agent.sh --clear # wipe + re-seed — stop node first bash .cursor/seed-agent.sh bootstrap # node must already listen on AGENT_SEED_BASE_URL +bash .cursor/seed-agent.sh bootstrap --scenario index --scenario completed # more automations (grid|index|completed|lifecycle|all) bash .cursor/seed-agent.sh start # foreground start only ``` diff --git a/packages/binary/AGENTS.md b/packages/binary/AGENTS.md new file mode 100644 index 0000000000..a9842e4012 --- /dev/null +++ b/packages/binary/AGENTS.md @@ -0,0 +1,43 @@ +# Agents: binary + +## Role + +Builds the single-file PyInstaller executables of OctoBot (Linux, Windows, macOS) that CI publishes as workflow artifacts and release assets. Holds the build scripts and the helpers that list modules and fetch data for PyInstaller. It does not contain application logic. + +## Owns + +- `packages/binary/build_scripts/` (per-platform build entry points) +- `packages/binary/scripts/` (`python_file_lister.py`, `fetch_nltk_data.py`) +- `packages/binary/requirements.txt` +- `packages/binary/README.md` +- `packages/binary/BINARY_TESTING_INSTRUCTIONS.md` + +## Public surface + +- CI calls `build_scripts/unix.sh` and `build_scripts/windows.ps1`, which use `bin/start.spec` at the repo root. +- Output artifacts: `OctoBot_linux_x64`, `OctoBot_windows_x64.exe`, `OctoBot_macos_arm64`. + +## May depend on + +- The repo-root `bin/` PyInstaller spec and hooks, and the packages the binary bundles (`octobot`, `packages/*`) + +## Do not + +- Put application logic here, it belongs in `octobot/` or `packages/*` +- Commit built binaries, `user/`, `logs/` or `tentacles/` data +- Bypass tentacles signature verification (`ALLOW_UNSIGNED_TENTACLES`) when testing a binary + +## Tests + +- Quick check of a built binary: `packages/binary/scripts/smoke_test.sh ` (curl only, exit code 0 or 1). +- Full testing of a built binary (download from CI, `TENTACLES_URL_TAG` rules, run, UI and API checks): follow [BINARY_TESTING_INSTRUCTIONS.md](BINARY_TESTING_INSTRUCTIONS.md). Builds from branch `bin_factory` need `TENTACLES_URL_TAG=latest`, other builds do not. +- The instructions also cover seeded QA against the binary (`tools/agent_seed`, skill **agent-seed**), debug API user actions, the scheduler DB, the node journal, the REST spec and restart recovery. Use `pkill -INT -x OctoBot_x64` to stop it, never `pkill -f`. +- No pytest suite under this package. + +## Related human doc + +- [README.md](README.md) + +## Last reviewed + +- 2026-09-29 diff --git a/packages/binary/BINARY_TESTING_INSTRUCTIONS.md b/packages/binary/BINARY_TESTING_INSTRUCTIONS.md new file mode 100644 index 0000000000..01736c51fb --- /dev/null +++ b/packages/binary/BINARY_TESTING_INSTRUCTIONS.md @@ -0,0 +1,236 @@ +# Binary testing instructions + +How to download a PyInstaller build of OctoBot from CI, run it, and check that it works. Written from a real run of the Linux x64 build on a headless Linux machine (build from branch `bin_factory`, OctoBot `3.0.0-beta3`, Python 3.13). + +## 1. Get the binary + +Every CI run of the `OctoBot-CI` workflow (`.github/workflows/main.yml`) uploads one artifact per platform: + +| Artifact name | Platform | +|---------------|----------| +| `OctoBot_linux_x64` | Linux x86_64 | +| `OctoBot_windows_x64.exe` | Windows x64 | +| `OctoBot_macos_arm64` | macOS Apple silicon | +| `octobot-wheel` | Python wheel (not a binary) | + +Find the run under the repository Actions tab. Artifacts are kept for 90 days. + +Download with a GitHub token (the artifact URL needs authentication, a browser download also works): + +```bash +RUN_ARTIFACT_ID=10993723554 # id from the artifact URL +curl -sS -L -o artifact.zip \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/Drakkar-Software/OctoBot/actions/artifacts/$RUN_ARTIFACT_ID/zip +``` + +The API answers with a redirect to Azure blob storage (`*.blob.core.windows.net`). On a machine with an egress allowlist, that host must be allowed or the download fails with a 403 on the proxy tunnel. + +Check the download against the `digest` shown for the artifact in the run page or in the API listing, then unpack: + +```bash +sha256sum artifact.zip +unzip artifact.zip # the Linux zip contains a single file: OctoBot_x64 +chmod +x OctoBot_x64 +``` + +The file is a single executable of about 150 MB. Nothing else has to be installed, Python is bundled. + +## 2. Choose the tentacles tag + +On first start the binary downloads and installs the default tentacles for its own version. Which package it asks for depends on `TENTACLES_URL_TAG`. + +- **Build from branch `bin_factory`:** always set `export TENTACLES_URL_TAG=latest` before running. These builds carry an unreleased version (for example `3.0.0-beta3`) that has no tentacles package, so the default request returns 404 and the bot cannot install its default profiles. +- **Any other build (a release, or a branch whose version has published tentacles):** do not set the variable. The default is the binary's own version. + +You can check whether a version has tentacles with `curl -I https://tentacles.octobot.online/officials/packages/full/base//any_platform.zip` (200 means yes). + +The variable is only needed while tentacles are being installed. Once the `tentacles/` folder exists, restarts work without it. + +## 3. Run it + +Always start from a new empty directory. The binary writes `user/`, `logs/` and `tentacles/` into the current directory and never asks before doing so. + +```bash +mkdir octobot-test && cd octobot-test +export TENTACLES_URL_TAG=latest # bin_factory builds only, see section 2 +./path/to/OctoBot_x64 > octobot.log 2>&1 & +``` + +Expected behavior: + +- The binary starts in **node mode** (`Using node OctoBot distribution`). The Node UI (React, `node_web_interface`) is the interface. The classic Flask web interface is disabled (`Web interface disabled`). +- The Node API and UI listen on **port 8000**, on all interfaces. The UI is at `http://127.0.0.1:8000/app`. +- Nothing listens on port 5001 in node mode. Builds that include `dev` commit `6eea4ff` log `WebInterface configured to use port 5001 if started.` Older builds, such as the `bin_factory` build this guide was first written against, log `Interface successfully initialized and accessible at: http://127.0.0.1:5001` instead, which is misleading here: ignore that line. +- Start-up takes a few seconds once tentacles are installed. The first start is longer because it downloads and installs them (about 12 MB). + +Wait for readiness instead of sleeping: + +```bash +until curl -sf -o /dev/null http://127.0.0.1:8000/app; do sleep 2; done && echo ready +``` + +Stop it with `pkill -INT -x OctoBot_x64` (or Ctrl+C in the foreground). It exits in about 2 seconds. The log line `forcing immediate process exit without stop_tasks()` is the normal handling of an interrupt, not a crash. + +### When the machine cannot reach the tentacles host directly + +The tentacles downloader uses its own HTTP client, which does not read `HTTPS_PROXY`. On a machine where outbound traffic only works through a proxy (for example a cloud sandbox), the first start fails with: + +``` +Failed to download file at url : https://tentacles.octobot.online/... (status: 403, text: Host not in allowlist ...) +Missing default profiles. OctoBot can't start without a valid default profile configuration. +``` + +`curl` does use the proxy, so fetch the package yourself and install it from the local file with the binary's own command. Keep the `.signature` file next to the zip, the install refuses unsigned packages: + +```bash +BASE=https://tentacles.octobot.online/officials/packages/full/base/latest +curl -sS -o tentacles.zip $BASE/any_platform.zip +curl -sS -o tentacles.zip.signature $BASE/any_platform.zip.signature +TENTACLES_URL_TAG=latest ./path/to/OctoBot_x64 tentacles --install --all --force --location "$PWD/tentacles.zip" +``` + +Then start the bot as above. Do not set `ALLOW_UNSIGNED_TENTACLES` to get around a signature error, that turns off the check that protects the install. + +## 4. Test it + +Run these in order on a fresh directory. Everything below was seen passing on the `bin_factory` Linux x64 build, except where a finding says otherwise. + +### 4.0 Automated smoke test (curl only) + +`scripts/smoke_test.sh` runs sections 3, 4.1, 4.2, 4.5 and the setup and debug API checks of 4.6 and 4.8 in one go. It needs only bash, curl, grep and sed, so it runs on a plain machine with the binary and nothing else. + +```bash +TENTACLES_URL_TAG=latest packages/binary/scripts/smoke_test.sh ./OctoBot_x64 # bin_factory build: keep the variable, other builds: drop it +``` + +It boots the binary in a temporary empty directory, checks the HTTP surface, creates a throwaway wallet through the setup API, checks the debug API with that wallet, scans the log for `Traceback` and unexpected `ERROR` lines, stops the node gracefully, restarts it without `TENTACLES_URL_TAG` and checks the wallet survived. It exits 0 when everything passes and 1 otherwise (the work dir is then kept and its path printed). Set `SMOKE_TENTACLES_ZIP` to a local signed tentacles package (section 3) when the machine cannot reach the tentacles host, `SMOKE_TIMEOUT` to change the 180 s start-up wait, and pass a second argument to choose the work dir. It stops the node by PID and never by process name pattern. + +The manual sections below cover what a script cannot: the UI, the wizard, and the seeded automations. + +### 4.1 Boot + +- [ ] Process stays up and the log reaches `Uvicorn running on http://0.0.0.0:8000`. +- [ ] `grep -E "ERROR|Traceback" octobot.log` shows nothing except the known message below. +- [ ] The scheduler starts: `Scheduler: initialize_scheduler completed`, and the recurring workflows (`global_view_refresh`, `portfolio_history`, `dbos_cleanup`) log success. +- [ ] Tentacles are installed: `tentacles/` exists with `Agent`, `Automation`, `Backtesting`, `Evaluator`, `Meta`, `Services`, `Trading` and `profiles/`. + +Known, harmless log line on machines where the tentacles host is only reachable through a proxy: `Error when checking ssl certificates: fetching .../metadata.yaml returned 403. Considering certificates as valid.` This is a certificate pre-check that is allowed to fail. + +### 4.2 HTTP surface + +| Request | Expected on a fresh install | +|---------|-----------------------------| +| `GET /` | 307 redirect to `/app` | +| `GET /app` | 200, HTML titled `OctoBot Node` | +| `GET /app/assets/index-*.js` and `.css` (names are in the HTML) | 200 | +| `GET /api/v1/wallets/` | 200, `[]` | +| `GET /api/v1/nodes/config` | 503, `{"detail":{"code":"auth_node_not_configured",...}}` (no wallet yet) | + +### 4.3 Node UI + +Open `http://127.0.0.1:8000/app` in a browser. On a fresh install: + +- [ ] It redirects to `/app/setup/welcome` and shows the "Welcome to OctoBot Node" card with a **Get started** button. +- [ ] `/app/debug` also redirects to setup until a wallet exists. Once a wallet exists, a new browser session must enter the passphrase before using the app or `/app/debug`. +- [ ] **Get started** opens `/app/setup`, "Step 1 / 5, Set up your wallet". Entering a passphrase and confirming it, then **Generate wallet**, moves to "Step 2 / 5, Save your seed phrase" and shows 12 words. +- [ ] `GET /api/v1/wallets/` now returns one wallet. + +For a headless check, drive it with Playwright and the Chromium already installed on the machine (`PLAYWRIGHT_BROWSERS_PATH`, do not download a browser). Collect `console` errors and failed requests. In a sandbox with a TLS-intercepting proxy, Google Fonts requests fail with `ERR_CERT_AUTHORITY_INVALID`. That is the only expected failure, everything served by the bot itself must return below 400. + +### 4.4 UI copy + +Per `.cursor/skills/end-user-ui/SKILL.md`, user-visible text must use entry-level trading vocabulary, stay short, and contain no em dash. Skim each screen you open for these. + +### 4.5 Persistence and restart + +- [ ] Stop the bot, start it again in the same directory **without** `TENTACLES_URL_TAG`. It boots (tentacles already installed) and `GET /api/v1/wallets/` still returns the wallet created earlier. + +### 4.6 Seeded QA (agent-seed) + +The demo fixtures from `tools/agent_seed` (see its [README](../../tools/agent_seed/README.md) and skill **agent-seed**) work unchanged against the binary. This is the way to test a logged-in node with accounts, strategies and a running automation, and it is the documented QA path for the Node UI. + +Only the seed and bootstrap commands need the source checkout: they are Python code that has to run from the repo root in a Python 3.13 environment that has all the repo requirement files loaded: root `requirements.txt`, `full_requirements.txt` and `extra_requirements.txt`, then every `packages/*/requirements.txt` and every `packages/*/full_requirements.txt`. The `full_requirements.txt` files matter: `jsonschema`, `aiosqlite`, `psutil` and others are only declared there. Add `pytest` and its plugins as well if you want to run `tools/tests`. The binary itself needs nothing besides the tentacles from section 3, so use a scratch environment outside the repo and do not commit anything from it. + +Use the directory where the binary already installed its tentacles (call it `$RUN`). Two paths differ from the source-tree flow: + +- `OCTOBOT_AGENT_SEED_MASTER_USER_ROOT=$RUN/user` points the seed at the reference tentacles config and profiles the binary created. +- `PYTHONPATH` must list the repo root, every `packages/*` folder except `tentacles` and `binary`, and `$RUN` itself so that `import tentacles` finds the installed tentacles. + +```bash +export OCTOBOT_AGENT_SEED_MASTER_USER_ROOT=$RUN/user +python -m tools.agent_seed seed --user-folder $RUN/user/agent-seed # run from the repo root, prints nothing on success + +cd $RUN +export EXIT_BEFORE_TENTACLES_AUTO_REINSTALL=true +export SCHEDULER_SQLITE_FILE=$RUN/user/agent-seed/tasks.db +./path/to/OctoBot_x64 --master --user-folder user/agent-seed & # same as `seed-agent.sh start`, with the binary + +python -m tools.agent_seed bootstrap --base-url http://127.0.0.1:8000 # from the repo root, once the node listens: starts the grid automation +python -m tools.agent_seed bootstrap --scenario index --scenario completed +python -m tools.agent_seed bootstrap --scenario lifecycle # see the finding in 4.10, fails on this build +``` + +`bootstrap` takes repeatable `--scenario` options, all idempotent (a scenario already in its target state does nothing): + +| Scenario | Result | +|----------|--------| +| `grid` (default) | Grid automation `...0001` on Seed kraken A is `running` | +| `index` | Index automation `...0002` (BTC, ETH, SOL) on Seed kraken B is `running` | +| `completed` | Index automation `...0003` on Seed kraken B is created, then stopped, so it shows as completed | +| `lifecycle` | Stops and restarts the grid automation, then checks it is `running` again with its name | +| `all` | The four above in that order | + +There is no seeded "errored" automation on purpose: a failing automation keeps retrying and stays `running`, and it only becomes `failed` after the scheduler exhausts its recovery attempts, so it cannot be produced quickly and reliably from user actions. + +Checks, all seen passing on the `bin_factory` Linux x64 build: + +- [ ] `GET /api/v1/wallets/` lists the demo wallet `0x70997970c51812dc3a010c7d01b50e0d17dc79c8`, name `demo`. +- [ ] `GET /api/v1/debug/` without credentials returns 401. With HTTP Basic `wallet:demodemo` it returns 200 with accounts **Seed kraken A** (1000 USDC) and **Seed kraken B** (500 USDC), one exchange config, two strategies. +- [ ] `/app` redirects to `/app/login` ("Unlock your node"). The passphrase `demodemo` opens `/app/octobots`. `/app/debug` shows the debug view with the seeded counts and no browser errors. +- [ ] `bootstrap` exits 0. The automation `a0000000-0000-4000-8000-000000000001` is `running`, the create user action is `completed`, and the log shows the simulated trader placing 3 buy and 3 sell BTC/USDC limit orders. +- [ ] `/app/octobots` shows one active OctoBot as Running. +- [ ] After `--scenario index --scenario completed`: automation `...0002` is `running` with the name `Agent seed BTC/ETH/SOL index`, and `...0003` is `completed` with the name `Agent seed stopped index`. `/app/octobots` counts 2 active and 1 completed. Running the same command again changes nothing. + +### 4.7 User actions through the debug API + +`POST /api/v1/debug/` (HTTP Basic, body is a `UserAction` as JSON) returns 204 when the action is accepted. Poll `GET /api/v1/debug/` until the action in `user_actions` is `completed` or `failed`. Build payloads with `tools.agent_seed.protocol.builders` and send them with `json.loads(user_action.to_json())`. + +- [ ] Creating a **live** (not simulated) account returns 403 with `Demo agent-seed wallet cannot create live exchange accounts or automations`. This is the demo wallet guard, not a bug. +- [ ] Creating a **simulated** account returns 204 and completes. The account list grows. +- [ ] `automation_stop` (with `cancel_orders`) returns 204, completes, and the automation status becomes `completed`. +- [ ] `automation_restart` returns 204, completes, and the automation is `running` again. + +### 4.8 Node internals (packages/node, node_journal, node_api_interface) + +- [ ] **Scheduler:** `user/agent-seed/tasks.db` (SQLite, set by `SCHEDULER_SQLITE_FILE`) has successful `execute_user_action`, `execute_automation`, `global_view_refresh`, `portfolio_history_collection` and `dbos_cleanup` workflows. +- [ ] **Journal (record-only):** `/node_journal/events.jsonl` and `onboarding_segment.jsonl` contain the events for what you did (`external_action_received`, `account_create_attempt`, `automation_stopped`, `automation_restarted`, `first_automation_started`, `node_process_startup_succeeded`). Nothing in the app should read them back. +- [ ] **REST spec:** `GET /api/v1/openapi.json` returns 200 (about 100 KB). `/docs` and `/redoc` return 200. `/openapi.json` returns 404, that is expected. +- [ ] **Encryption:** debug routes answer 404 when node-side encryption is on. The seeded demo expects it off. + +### 4.9 Restart recovery + +- [ ] With the automation running, stop the bot (`pkill -INT -x OctoBot_x64`) and start it again with the same command. The log shows `Recovering 1 workflows from application version octobot_node_v1`, the automation is `running` again with its 6 orders, accounts and strategies are unchanged, and there is no `ERROR` or `Traceback`. + +### 4.10 Known findings on this build + +These were seen on the `bin_factory` build. They do not block boot or the checks above, and none is caused by the binary packaging itself. + +- `bootstrap --scenario lifecycle` fails on this build with `AutomationNameLostError` and exit code 1. That is the finding below, the check is doing its job. It also means `grid` cannot be re-run afterwards on the same node, because the grid check finds its automation by name. +- After `automation_restart`, the automation `metadata.name` is empty, so the Node UI titles the card `OctoBot a00000` instead of `Agent seed BTC/USDC grid`. The name is correct until the restart. The restart executor rebuilds the task from the latest terminal workflow (`user_actions_executor/automation/restart_automation.py`). + +## 5. Clean up + +Stop the bot by exact process name. Do not use `pkill -f OctoBot_x64`, it also matches the shell that runs the command and kills it. + +```bash +pkill -INT -x OctoBot_x64 +cd .. && rm -rf octobot-test +``` + +The wallet created during testing is a throwaway. Never reuse a test seed phrase or passphrase for real funds. + +## 6. What to report + +State the artifact (run id, branch, commit, artifact id and sha256), whether `TENTACLES_URL_TAG` was set, the result of each check in section 4, and any `ERROR` or `Traceback` lines other than the known one. Attach the relevant part of `octobot.log` for anything that failed. diff --git a/packages/binary/scripts/smoke_test.sh b/packages/binary/scripts/smoke_test.sh new file mode 100755 index 0000000000..76de24cb0e --- /dev/null +++ b/packages/binary/scripts/smoke_test.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Smoke test for a CI-built OctoBot binary. Needs only bash, curl, grep and sed (no Python, no repo checkout). +# +# Usage: smoke_test.sh [work-dir] +# +# Boots the binary in an empty work dir, checks the HTTP surface and the setup API, creates a throwaway +# wallet, stops it gracefully, restarts it and checks the wallet is still there. +# Exit code is 0 when every check passes, 1 otherwise. See packages/binary/BINARY_TESTING_INSTRUCTIONS.md. +# +# Environment: +# TENTACLES_URL_TAG set to "latest" for builds from branch bin_factory (unreleased version), leave unset otherwise +# SMOKE_TENTACLES_ZIP local tentacles zip (with its .signature file next to it) to install before the first +# start, for machines where the binary cannot reach the tentacles host directly +# SMOKE_TIMEOUT seconds to wait for the node to listen (default 180) +# SMOKE_KEEP set to 1 to keep the work dir +set -uo pipefail + +BINARY="${1:-}" +if [[ -z "${BINARY}" || ! -x "${BINARY}" ]]; then + echo "Usage: $0 [work-dir]" >&2 + exit 2 +fi +BINARY="$(cd "$(dirname "${BINARY}")" && pwd)/$(basename "${BINARY}")" + +BASE_URL="http://127.0.0.1:8000" +SMOKE_TIMEOUT="${SMOKE_TIMEOUT:-180}" +PASSPHRASE="smoke-test-passphrase" +KNOWN_ERROR_PATTERN="Error when checking ssl certificates" +STOP_TIMEOUT=30 + +USER_WORK_DIR="${2:-}" +WORK_DIR="${USER_WORK_DIR:-$(mktemp -d -t octobot-smoke.XXXXXX)}" +mkdir -p "${WORK_DIR}" +WORK_DIR="$(cd "${WORK_DIR}" && pwd)" +BODY_FILE="${WORK_DIR}/.smoke_body" +PID="" +FAILED=0 +PASSED=0 + +pass() { PASSED=$((PASSED + 1)); echo " ok $1"; } +fail() { FAILED=$((FAILED + 1)); echo " FAIL $1"; } + +# Args: description, expected, actual +expect_equal() { + if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1 (expected '$2', got '$3')"; fi +} + +# Args: description, pattern (grep -E), file +expect_body_matches() { + if grep -Eiq "$2" "$3"; then pass "$1"; else fail "$1 (pattern '$2' not found in: $(head -c 200 "$3"))"; fi +} + +# Prints the HTTP status. Response body goes to BODY_FILE. Extra curl args follow the url. +http() { + local url="$1" + shift + local code + code="$(curl -sS --noproxy '*' -m 15 -o "${BODY_FILE}" -w '%{http_code}' "$@" "${url}" 2>/dev/null)" || true + echo "${code:-000}" +} + +start_node() { + (cd "${WORK_DIR}" && exec "${BINARY}" >>"${WORK_DIR}/octobot.log" 2>&1) & + PID=$! +} + +wait_ready() { + local waited=0 + while (( waited < SMOKE_TIMEOUT )); do + if ! kill -0 "${PID}" 2>/dev/null; then return 1; fi + if [[ "$(http "${BASE_URL}/app")" == "200" ]]; then return 0; fi + sleep 2 + waited=$((waited + 2)) + done + return 1 +} + +# Interrupt by PID (never pkill -f: that pattern also matches the shell running this script). +stop_node() { + [[ -n "${PID}" ]] || return 0 + kill -INT "${PID}" 2>/dev/null || true + local waited=0 + while kill -0 "${PID}" 2>/dev/null && (( waited < STOP_TIMEOUT )); do + sleep 1 + waited=$((waited + 1)) + done + if kill -0 "${PID}" 2>/dev/null; then + kill -KILL "${PID}" 2>/dev/null || true + PID="" + return 1 + fi + wait "${PID}" 2>/dev/null || true + PID="" + return 0 +} + +cleanup() { + stop_node >/dev/null 2>&1 || true + if [[ "${SMOKE_KEEP:-0}" != "1" && -z "${USER_WORK_DIR}" ]]; then rm -rf "${WORK_DIR}"; fi +} +trap 'cleanup' EXIT + +echo "OctoBot binary smoke test" +echo " binary: ${BINARY}" +echo " work dir: ${WORK_DIR}" +echo " tentacles tag: ${TENTACLES_URL_TAG:-}" + +if [[ "$(http "${BASE_URL}/app")" != "000" ]]; then + echo "Something already answers on ${BASE_URL}. Stop it first." >&2 + exit 2 +fi + +echo "1. Version" +version_output="$("${BINARY}" --version 2>/dev/null | tail -1)" +if [[ -n "${version_output}" ]]; then pass "--version prints '${version_output}'"; else fail "--version printed nothing"; fi + +if [[ -n "${SMOKE_TENTACLES_ZIP:-}" ]]; then + echo "2. Install tentacles from ${SMOKE_TENTACLES_ZIP}" + if (cd "${WORK_DIR}" && "${BINARY}" tentacles --install --all --force --location "${SMOKE_TENTACLES_ZIP}" >"${WORK_DIR}/tentacles_install.log" 2>&1); then + pass "tentacles installed from local package" + else + fail "tentacles install failed (see ${WORK_DIR}/tentacles_install.log)" + fi +fi + +echo "3. First start" +start_node +if wait_ready; then + pass "node listens on ${BASE_URL}" +else + fail "node did not become ready within ${SMOKE_TIMEOUT}s (or exited)" + echo "---- last log lines ----" + tail -20 "${WORK_DIR}/octobot.log" | sed 's/\x1b\[[0-9;]*m//g' + echo "Result: ${PASSED} passed, ${FAILED} failed" + SMOKE_KEEP=1 + exit 1 +fi + +echo "4. HTTP surface" +expect_equal "GET / redirects" "307" "$(http "${BASE_URL}/")" +code="$(http "${BASE_URL}/app")" +expect_equal "GET /app" "200" "${code}" +expect_body_matches "/app is the Node UI" "OctoBot Node" "${BODY_FILE}" +assets="$(grep -Eo '/app/assets/[^"]+\.(js|css)' "${BODY_FILE}" | sort -u)" +if [[ -z "${assets}" ]]; then fail "no js/css assets referenced by /app"; fi +for asset in ${assets}; do + expect_equal "GET ${asset}" "200" "$(http "${BASE_URL}${asset}")" +done +expect_equal "GET /api/v1/openapi.json" "200" "$(http "${BASE_URL}/api/v1/openapi.json")" +expect_equal "GET /docs" "200" "$(http "${BASE_URL}/docs")" + +echo "5. Setup API on a fresh node" +expect_equal "GET /api/v1/setup/status" "200" "$(http "${BASE_URL}/api/v1/setup/status")" +expect_body_matches "node is not configured yet" '"configured": ?false' "${BODY_FILE}" +expect_equal "GET /api/v1/wallets/" "200" "$(http "${BASE_URL}/api/v1/wallets/")" +expect_body_matches "no wallet yet" '^\[\]$' "${BODY_FILE}" +expect_equal "GET /api/v1/nodes/config before setup" "503" "$(http "${BASE_URL}/api/v1/nodes/config")" +expect_body_matches "typed not-configured error" "auth_node_not_configured" "${BODY_FILE}" + +echo "6. Create a throwaway wallet" +code="$(http "${BASE_URL}/api/v1/setup/init" -X POST -H 'Content-Type: application/json' \ + -d "{\"passphrase\":\"${PASSPHRASE}\",\"node_type\":\"standalone\"}")" +expect_equal "POST /api/v1/setup/init" "200" "${code}" +address="$(grep -Eo '0x[0-9a-fA-F]{40}' "${BODY_FILE}" | head -1)" +if [[ -n "${address}" ]]; then pass "wallet address returned (${address})"; else fail "no wallet address in the setup response"; fi +http "${BASE_URL}/api/v1/setup/status" >/dev/null +expect_body_matches "node is configured" '"configured": ?true' "${BODY_FILE}" +http "${BASE_URL}/api/v1/wallets/" >/dev/null +expect_body_matches "wallet is listed" "${address:-0xMISSING}" "${BODY_FILE}" + +echo "7. Debug API" +expect_equal "GET /api/v1/debug/ without credentials" "401" "$(http "${BASE_URL}/api/v1/debug/")" +code="$(http "${BASE_URL}/api/v1/debug/" -u "${address}:${PASSPHRASE}")" +expect_equal "GET /api/v1/debug/ with the wallet passphrase" "200" "${code}" +expect_body_matches "debug state has automations" '"automations"' "${BODY_FILE}" + +echo "8. Log" +clean_log="$(sed 's/\x1b\[[0-9;]*m//g' "${WORK_DIR}/octobot.log")" +if grep -q "Traceback" <<<"${clean_log}"; then fail "Traceback in the log"; else pass "no Traceback in the log"; fi +unexpected_errors="$(grep -E ' ERROR ' <<<"${clean_log}" | grep -v "${KNOWN_ERROR_PATTERN}" || true)" +if [[ -z "${unexpected_errors}" ]]; then + pass "no ERROR lines besides the known ssl pre-check" +else + fail "unexpected ERROR lines:" + echo "${unexpected_errors}" | cut -c1-220 | head -5 +fi + +echo "9. Graceful stop" +if stop_node; then pass "stopped within ${STOP_TIMEOUT}s"; else fail "did not stop within ${STOP_TIMEOUT}s, killed"; fi + +echo "10. Restart without TENTACLES_URL_TAG" +unset TENTACLES_URL_TAG +start_node +if wait_ready; then + pass "node listens again" + http "${BASE_URL}/api/v1/wallets/" >/dev/null + expect_body_matches "wallet survived the restart" "${address:-0xMISSING}" "${BODY_FILE}" +else + fail "node did not come back after restart" +fi +stop_node >/dev/null 2>&1 || true + +echo +echo "Result: ${PASSED} passed, ${FAILED} failed" +if (( FAILED > 0 )); then + echo "Work dir kept for inspection: ${WORK_DIR}" + SMOKE_KEEP=1 + exit 1 +fi +exit 0 diff --git a/packages/node/octobot_node/agent_seed/constants.py b/packages/node/octobot_node/agent_seed/constants.py index c0e87b22db..c6016a24a4 100644 --- a/packages/node/octobot_node/agent_seed/constants.py +++ b/packages/node/octobot_node/agent_seed/constants.py @@ -22,6 +22,9 @@ DEMO_AGENT_SEED_STRATEGY_INDEX_ID = "agent-seed-strategy-index-btc-eth-sol" # Stable agent-seed grid automation parent id (Node create-automation requires canonical lowercase UUID). DEMO_AGENT_SEED_AUTOMATION_GRID_ID = "a0000000-0000-4000-8000-000000000001" +# Index automation (runs on the idle account) and index automation that bootstrap stops (completed state in the UI). +DEMO_AGENT_SEED_AUTOMATION_INDEX_ID = "a0000000-0000-4000-8000-000000000002" +DEMO_AGENT_SEED_AUTOMATION_COMPLETED_ID = "a0000000-0000-4000-8000-000000000003" DEMO_AGENT_SEED_GRID_SYMBOL = "BTC/USDC" DEMO_AGENT_SEED_GRID_SPREAD = 2000.0 @@ -39,6 +42,8 @@ DEMO_AGENT_SEED_EXCHANGE_INTERNAL_NAME = "kraken" DEMO_AGENT_SEED_GRID_AUTOMATION_DISPLAY_NAME = "Agent seed BTC/USDC grid" +DEMO_AGENT_SEED_INDEX_AUTOMATION_DISPLAY_NAME = "Agent seed BTC/ETH/SOL index" +DEMO_AGENT_SEED_COMPLETED_AUTOMATION_DISPLAY_NAME = "Agent seed stopped index" DEMO_AGENT_SEED_FORBIDDEN_ACTION_DETAIL = ( "Demo agent-seed wallet cannot create live exchange accounts or automations" diff --git a/tools/agent_seed/ARCHITECTURE.md b/tools/agent_seed/ARCHITECTURE.md index fa299fad35..f41ff74c76 100644 --- a/tools/agent_seed/ARCHITECTURE.md +++ b/tools/agent_seed/ARCHITECTURE.md @@ -7,6 +7,7 @@ Demo-only fixture tooling for Cloud / local Node UI QA. Insecure committed walle - Canonical guide: [README.md](README.md) - Agent procedure: skill **agent-seed** (`.cursor/skills/agent-seed/SKILL.md`) - Bootstrap polls debug `user_actions` and fails fast on FAILED create-automation (`operations/bootstrap_grid.py`) +- Extra bootstrap scenarios (index, completed, lifecycle) live in `operations/bootstrap_scenarios.py` ## Phases @@ -18,6 +19,7 @@ flowchart TB seedSync[operations.seed_sync] clearOp[operations.clear] bootstrapGrid[operations.bootstrap_grid] + bootstrapScenarios[operations.bootstrap_scenarios] bootstrapHttp[operations.bootstrap_http] builders[protocol.builders] pathsMod[paths] @@ -25,7 +27,10 @@ flowchart TB nodeConstants[octobot_node.agent_seed.constants] cli --> seedRun - cli --> bootstrapGrid + cli --> bootstrapScenarios + bootstrapScenarios --> bootstrapGrid + bootstrapScenarios --> bootstrapHttp + bootstrapScenarios --> builders seedRun --> clearOp seedRun --> seedOctobotConfig seedRun --> seedSync @@ -51,7 +56,10 @@ flowchart TB | `operations/seed_run.py` | `run_seed`, idempotency | | `operations/seed_octobot_config.py` | `config.json` readonly overlays to master `user/` | | `operations/bootstrap_http.py` | Debug API HTTP helpers | -| `operations/bootstrap_grid.py` | Grid automation bootstrap | +| `operations/bootstrap_grid.py` | Grid automation bootstrap, shared polling helpers | +| `operations/bootstrap_scenarios.py` | `--scenario` runners: index, completed, lifecycle (uses the grid helpers) | +| `enums.py` | `BootstrapScenario` | +| `errors.py` | `AgentSeedError`, `AutomationLifecycleError`, `AutomationNameLostError` | | `cli/` | Argparse only | | `__main__.py` | Entry: `python -m tools.agent_seed` | @@ -67,7 +75,8 @@ Runtime sandbox (`is_demo_agent_seed_user`, validation in `demo_wallet.py`) live | `operations/clear.py` | node constants (message prefix) | tentacles, CLI | | `operations/seed_sync.py`, `seed_run.py` | sync, CommunityAuthentication, `protocol.builders`, `operations.clear`, `secrets`, `paths`, node constants | HTTP debug | | `operations/seed_octobot_config.py` | `octobot_commons.constants`, `paths` | tentacles, CLI | -| `operations/bootstrap_*.py` | `protocol.builders`, `secrets`, node constants, urllib | CollectionProviders | +| `operations/bootstrap_*.py` | `protocol.builders`, `secrets`, `enums`, `errors`, node constants, urllib | CollectionProviders | +| `enums.py`, `errors.py` | stdlib | `octobot_*` | | `cli/*` | `operations`, `paths` | tentacles | | `__main__.py` | `cli` | direct `operations` | @@ -75,7 +84,7 @@ Runtime sandbox (`is_demo_agent_seed_user`, validation in `demo_wallet.py`) live ## Tests -Mirror under `tools/tests/agent_seed/` (`paths/`, `protocol/`, `operations/`). Demo guard tests stay in `packages/node/tests/agent_seed/`. +Mirror under `tools/tests/agent_seed/` (`paths/`, `protocol/`, `operations/`, `cli/`). Demo guard tests stay in `packages/node/tests/agent_seed/`. **CI:** OctoBot-CI job `extended_linter` runs `PYTHONPATH=.:$PYTHONPATH pytest tools/tests` (wheel + tentacles in that job). diff --git a/tools/agent_seed/README.md b/tools/agent_seed/README.md index 933d41410c..3166d002d8 100644 --- a/tools/agent_seed/README.md +++ b/tools/agent_seed/README.md @@ -67,7 +67,7 @@ From OctoBot repo root (after sourcing env files): | `seed` with second arg `--clear` | Not used by shell; use `--clear` below | | `--clear` | Wipe user folder + sqlite, then re-seed (**node must be stopped**) | | `start` | `start.py --master --user-folder …` (foreground) | -| `bootstrap` | HTTP bootstrap grid automation (node must be listening) | +| `bootstrap [--scenario …]` | HTTP bootstrap of the seeded automations, grid by default (node must be listening). See **Bootstrap scenarios** | | `--full` | `seed` → **`start.py` in background** → `bootstrap` | ### Node process rules @@ -139,6 +139,8 @@ From `octobot_node.agent_seed.constants`: |------|--------| | Grid automation name | `Agent seed BTC/USDC grid` | | Grid automation id | `a0000000-0000-4000-8000-000000000001` | +| Index automation name / id | `Agent seed BTC/ETH/SOL index` / `a0000000-0000-4000-8000-000000000002` | +| Stopped (completed) index automation name / id | `Agent seed stopped index` / `a0000000-0000-4000-8000-000000000003` | | Account display names | **Seed kraken A** (grid), **Seed kraken B** (index idle) | ## Demo wallet restrictions @@ -151,7 +153,27 @@ The demo wallet is sandboxed in `octobot_node/agent_seed/demo_wallet.py`: it **c - Kraken **simulated** exchange config and accounts **Seed kraken A** (1000 USDC, grid) and **Seed kraken B** (500 USDC, index idle) - Grid strategy on **BTC/USDC** (3 buy / 3 sell, spread 2000, increment 500) - Index strategy on **BTC / ETH / SOL** (10% rebalance trigger) -- Bootstrap (optional/`--full`) starts the grid automation via `POST /api/v1/debug/`; CLI exits with error if debug shows that create user action **failed**. +- Bootstrap (optional/`--full`) starts the grid automation via `POST /api/v1/debug/`; CLI exits with error if debug shows that create user action **failed**. More automations on the same fixtures: see **Bootstrap scenarios**. + +## Bootstrap scenarios + +`bootstrap` (and `all`) take repeatable `--scenario` options, via `bash .cursor/seed-agent.sh bootstrap --scenario index` or `python -m tools.agent_seed bootstrap --scenario index`. Every scenario is idempotent: one already in its target state does nothing. Automations are created and controlled only through the debug API, with the same user actions the UI sends. + +| Scenario | Result | Why it exists | +|----------|--------|---------------| +| `grid` (default) | Grid automation on **Seed kraken A** is RUNNING | The default demo automation | +| `index` | Index automation on **Seed kraken B** is RUNNING | Uses the seeded index strategy and the second account | +| `completed` | Another index automation on **Seed kraken B** is created, then stopped (COMPLETED) | Gives the UI a completed automation to show | +| `lifecycle` | Stops and restarts the grid automation, then checks it is RUNNING with its display name | Regression check for the stop and restart user actions. It raises `AutomationNameLostError` (exit 1) if the name is lost | +| `all` | The four above, in that order | | + +There is no seeded errored automation. An automation that fails keeps retrying and stays RUNNING until the scheduler runs out of recovery attempts, which is neither quick nor reliable to trigger from user actions. Cover the errored state with unit tests instead. + +Do not re-run `grid` after `lifecycle` on the same node: the grid check finds its automation by display name. + +## Against a PyInstaller binary + +The same fixtures work with a CI-built binary instead of `start.py`. `seed` and `bootstrap` still run from this repo (Python), only the node process is the binary. Set `OCTOBOT_AGENT_SEED_MASTER_USER_ROOT` to the `user/` folder the binary created when it installed tentacles, put that run folder on `PYTHONPATH` so `tentacles` resolves, then start the binary with `--master --user-folder user/agent-seed` plus the same `SCHEDULER_SQLITE_FILE` and `EXIT_BEFORE_TENTACLES_AUTO_REINSTALL` variables as `seed-agent.sh start`. Full steps and the checks to run: [`packages/binary/BINARY_TESTING_INSTRUCTIONS.md`](../../packages/binary/BINARY_TESTING_INSTRUCTIONS.md) (section **Seeded QA**). ## Troubleshooting @@ -162,3 +184,4 @@ The demo wallet is sandboxed in `octobot_node/agent_seed/demo_wallet.py`: it **c | Bootstrap `RuntimeError` with user action failed | Read automation error in message; fix node/fixtures, re-seed if needed | | Debug UI/API 404 | Node-side encryption enabled — not supported for debug QA | | `--full` bootstrap flaky | Node still booting; retry `bootstrap` | +| `bootstrap --scenario lifecycle` exits 1 with `AutomationNameLostError` | The restarted automation lost its name (restart user action). Real node bug, not a seed problem | diff --git a/tools/agent_seed/cli/__init__.py b/tools/agent_seed/cli/__init__.py index b5d3b0461d..1cbb1fa29d 100644 --- a/tools/agent_seed/cli/__init__.py +++ b/tools/agent_seed/cli/__init__.py @@ -6,6 +6,10 @@ import tools.agent_seed.cli.all as agent_seed_cli_all import tools.agent_seed.cli.bootstrap as agent_seed_cli_bootstrap import tools.agent_seed.cli.seed as agent_seed_cli_seed +import tools.agent_seed.enums as agent_seed_enums + +_SCENARIO_CHOICES = [scenario.value for scenario in agent_seed_enums.BootstrapScenario] +_SCENARIO_HELP = "Bootstrap scenario, repeatable (default: grid). all = grid, index, completed, lifecycle" def build_arg_parser() -> argparse.ArgumentParser: @@ -20,11 +24,14 @@ def build_arg_parser() -> argparse.ArgumentParser: bootstrap_parser = subparsers.add_parser( "bootstrap", - help="POST automation_create and wait for RUNNING grid automation", + help="Start the seeded automations (grid by default) through the debug API", ) bootstrap_parser.add_argument("--base-url", default="http://127.0.0.1:8000") bootstrap_parser.add_argument("--poll-interval", type=float, default=None) bootstrap_parser.add_argument("--timeout", type=float, default=None) + bootstrap_parser.add_argument( + "--scenario", action="append", choices=_SCENARIO_CHOICES, default=None, help=_SCENARIO_HELP, + ) bootstrap_parser.set_defaults(handler=agent_seed_cli_bootstrap.run_from_namespace) all_parser = subparsers.add_parser("all", help="Run seed then bootstrap") @@ -34,6 +41,9 @@ def build_arg_parser() -> argparse.ArgumentParser: all_parser.add_argument("--base-url", default="http://127.0.0.1:8000") all_parser.add_argument("--poll-interval", type=float, default=None) all_parser.add_argument("--timeout", type=float, default=None) + all_parser.add_argument( + "--scenario", action="append", choices=_SCENARIO_CHOICES, default=None, help=_SCENARIO_HELP, + ) all_parser.set_defaults(handler=agent_seed_cli_all.run_from_namespace) return parser diff --git a/tools/agent_seed/cli/bootstrap.py b/tools/agent_seed/cli/bootstrap.py index 7772ec76d6..d519f3d3db 100644 --- a/tools/agent_seed/cli/bootstrap.py +++ b/tools/agent_seed/cli/bootstrap.py @@ -3,7 +3,9 @@ import argparse import typing +import tools.agent_seed.enums as agent_seed_enums import tools.agent_seed.operations.bootstrap_grid as agent_seed_bootstrap_grid +import tools.agent_seed.operations.bootstrap_scenarios as agent_seed_bootstrap_scenarios def build_arg_parser() -> argparse.ArgumentParser: @@ -23,13 +25,21 @@ def build_arg_parser() -> argparse.ArgumentParser: type=float, default=agent_seed_bootstrap_grid.DEFAULT_TIMEOUT_SECONDS, ) + parser.add_argument( + "--scenario", + action="append", + choices=[scenario.value for scenario in agent_seed_enums.BootstrapScenario], + default=None, + help="Scenario to run, repeatable (default: grid). all = grid, index, completed, lifecycle", + ) return parser def main(argv: typing.Optional[list[str]] = None) -> int: arguments = build_arg_parser().parse_args(argv) - agent_seed_bootstrap_grid.bootstrap_grid_automation( + agent_seed_bootstrap_scenarios.run_scenarios( base_url=arguments.base_url, + scenarios=[agent_seed_enums.BootstrapScenario(value) for value in arguments.scenario or []], poll_interval_seconds=arguments.poll_interval, timeout_seconds=arguments.timeout, ) @@ -42,4 +52,6 @@ def run_from_namespace(arguments: argparse.Namespace) -> int: bootstrap_argv.extend(["--poll-interval", str(arguments.poll_interval)]) if arguments.timeout is not None: bootstrap_argv.extend(["--timeout", str(arguments.timeout)]) + for scenario in arguments.scenario or []: + bootstrap_argv.extend(["--scenario", scenario]) return main(bootstrap_argv) diff --git a/tools/agent_seed/enums.py b/tools/agent_seed/enums.py new file mode 100644 index 0000000000..e6f6140a84 --- /dev/null +++ b/tools/agent_seed/enums.py @@ -0,0 +1,16 @@ +# Demo-only agent seed enums. + +import enum + + +class BootstrapScenario(str, enum.Enum): + # Start the grid automation on Seed kraken A (default). + GRID = "grid" + # Start the index automation on Seed kraken B. + INDEX = "index" + # Create an index automation on Seed kraken B, then stop it: completed state in the UI. + COMPLETED = "completed" + # Stop and restart the grid automation, then check it keeps its name. + LIFECYCLE = "lifecycle" + # Every scenario above, in that order. + ALL = "all" diff --git a/tools/agent_seed/errors.py b/tools/agent_seed/errors.py new file mode 100644 index 0000000000..8e288ec3a5 --- /dev/null +++ b/tools/agent_seed/errors.py @@ -0,0 +1,13 @@ +# Demo-only agent seed typed errors. + + +class AgentSeedError(Exception): + pass + + +class AutomationLifecycleError(AgentSeedError): + pass + + +class AutomationNameLostError(AutomationLifecycleError): + pass diff --git a/tools/agent_seed/operations/bootstrap_grid.py b/tools/agent_seed/operations/bootstrap_grid.py index 615f5eddbc..72f84daf36 100644 --- a/tools/agent_seed/operations/bootstrap_grid.py +++ b/tools/agent_seed/operations/bootstrap_grid.py @@ -29,7 +29,7 @@ def grid_automation_is_running(debug_payload: dict) -> bool: return False -def _find_user_action(debug_payload: dict, user_action_id: str) -> typing.Optional[dict]: +def find_user_action(debug_payload: dict, user_action_id: str) -> typing.Optional[dict]: debug_section = debug_payload.get("debug") or {} user_actions = debug_section.get("user_actions") or [] for user_action in user_actions: @@ -38,7 +38,7 @@ def _find_user_action(debug_payload: dict, user_action_id: str) -> typing.Option return None -def _format_automation_user_action_error(user_action: dict) -> str: +def format_automation_user_action_error(user_action: dict) -> str: user_action_id = user_action.get("id", "") status = user_action.get("status", "") result = user_action.get("result") @@ -64,13 +64,13 @@ def _format_automation_user_action_error(user_action: dict) -> str: return ": ".join(parts) -def _ensure_user_action_not_failed(debug_payload: dict, user_action_id: str) -> None: - user_action = _find_user_action(debug_payload, user_action_id) +def ensure_user_action_not_failed(debug_payload: dict, user_action_id: str) -> None: + user_action = find_user_action(debug_payload, user_action_id) if user_action is None: return if user_action.get("status") != protocol_models.UserActionStatus.FAILED.value: return - raise RuntimeError(_format_automation_user_action_error(user_action)) + raise RuntimeError(format_automation_user_action_error(user_action)) def _poll_debug_after_create( @@ -85,7 +85,7 @@ def _poll_debug_after_create( while time.monotonic() < deadline: status_code, debug_payload = agent_seed_bootstrap_http.request_json("GET", debug_url, headers) if status_code == 200 and isinstance(debug_payload, dict): - _ensure_user_action_not_failed(debug_payload, user_action_id) + ensure_user_action_not_failed(debug_payload, user_action_id) if grid_automation_is_running(debug_payload): return time.sleep(poll_interval_seconds) diff --git a/tools/agent_seed/operations/bootstrap_http.py b/tools/agent_seed/operations/bootstrap_http.py index 5e5682b545..12eccfe571 100644 --- a/tools/agent_seed/operations/bootstrap_http.py +++ b/tools/agent_seed/operations/bootstrap_http.py @@ -12,6 +12,11 @@ def basic_auth_header(wallet_address: str, passphrase: str) -> dict[str, str]: return {"Authorization": f"Basic {token}"} +def user_action_to_payload(user_action: typing.Any) -> dict: + # UserAction.to_dict() keeps datetimes, to_json() gives the wire form the debug API expects. + return json.loads(user_action.to_json()) + + def request_json( method: str, url: str, diff --git a/tools/agent_seed/operations/bootstrap_scenarios.py b/tools/agent_seed/operations/bootstrap_scenarios.py new file mode 100644 index 0000000000..ec4f5890de --- /dev/null +++ b/tools/agent_seed/operations/bootstrap_scenarios.py @@ -0,0 +1,267 @@ +# Demo-only extra bootstrap scenarios (index, completed, lifecycle) via debug HTTP API. + +import time +import typing + +import octobot_protocol.models as protocol_models + +import octobot_node.agent_seed.constants as demo_agent_seed_constants + +import tools.agent_seed.enums as agent_seed_enums +import tools.agent_seed.errors as agent_seed_errors +import tools.agent_seed.operations.bootstrap_grid as agent_seed_bootstrap_grid +import tools.agent_seed.operations.bootstrap_http as agent_seed_bootstrap_http +import tools.agent_seed.protocol.builders as agent_seed_protocol_builders +import tools.agent_seed.secrets as agent_seed_secrets + +# ALL expands to these, in order: grid first (lifecycle needs it), completed after index. +_SCENARIO_ORDER = ( + agent_seed_enums.BootstrapScenario.GRID, + agent_seed_enums.BootstrapScenario.INDEX, + agent_seed_enums.BootstrapScenario.COMPLETED, + agent_seed_enums.BootstrapScenario.LIFECYCLE, +) + + +def find_automation(debug_payload: dict, automation_id: str) -> typing.Optional[dict]: + debug_section = debug_payload.get("debug") or {} + for automation in debug_section.get("automations") or []: + if automation.get("id") == automation_id: + return automation + return None + + +def expand_scenarios( + scenarios: typing.Optional[typing.Iterable[agent_seed_enums.BootstrapScenario]], +) -> list[agent_seed_enums.BootstrapScenario]: + requested = set(scenarios or [agent_seed_enums.BootstrapScenario.GRID]) + if agent_seed_enums.BootstrapScenario.ALL in requested: + return list(_SCENARIO_ORDER) + return [scenario for scenario in _SCENARIO_ORDER if scenario in requested] + + +def _get_debug_payload(debug_url: str, headers: dict[str, str]) -> dict: + status_code, debug_payload = agent_seed_bootstrap_http.request_json("GET", debug_url, headers) + if status_code != 200 or not isinstance(debug_payload, dict): + raise RuntimeError(f"GET {debug_url} failed with HTTP {status_code}: {debug_payload}") + return debug_payload + + +def _submit_user_action( + debug_url: str, + headers: dict[str, str], + user_action: protocol_models.UserAction, +) -> str: + status_code, body = agent_seed_bootstrap_http.request_json( + "POST", + debug_url, + headers, + agent_seed_bootstrap_http.user_action_to_payload(user_action), + ) + if status_code != 204: + raise RuntimeError(f"{user_action.id} rejected with HTTP {status_code}: {body}") + return user_action.id + + +def _wait_for_automation_status( + *, + debug_url: str, + headers: dict[str, str], + automation_id: str, + expected_status: protocol_models.WorkflowStatus, + user_action_id: typing.Optional[str], + poll_interval_seconds: float, + timeout_seconds: float, +) -> dict: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + debug_payload = _get_debug_payload(debug_url, headers) + if user_action_id is not None: + agent_seed_bootstrap_grid.ensure_user_action_not_failed(debug_payload, user_action_id) + automation = find_automation(debug_payload, automation_id) + if automation is not None and automation.get("status") == expected_status.value: + return automation + time.sleep(poll_interval_seconds) + raise TimeoutError( + f"Automation {automation_id} did not reach {expected_status.value} within {timeout_seconds}s ({debug_url})", + ) + + +def _session(base_url: str) -> tuple[str, dict[str, str]]: + headers = agent_seed_bootstrap_http.basic_auth_header( + demo_agent_seed_constants.DEMO_AGENT_SEED_WALLET_EVM_ADDRESS, + agent_seed_secrets.DEMO_INSECURE_WALLET_PASSPHRASE, + ) + return f"{base_url.rstrip('/')}/api/v1/debug/", headers + + +def _current_status(debug_url: str, headers: dict[str, str], automation_id: str) -> typing.Optional[str]: + automation = find_automation(_get_debug_payload(debug_url, headers), automation_id) + return None if automation is None else automation.get("status") + + +def _create_and_wait_running( + *, + debug_url: str, + headers: dict[str, str], + user_action: protocol_models.UserAction, + automation_id: str, + poll_interval_seconds: float, + timeout_seconds: float, +) -> None: + user_action_id = _submit_user_action(debug_url, headers, user_action) + _wait_for_automation_status( + debug_url=debug_url, + headers=headers, + automation_id=automation_id, + expected_status=protocol_models.WorkflowStatus.RUNNING, + user_action_id=user_action_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + + +def _stop_and_wait_completed( + *, + debug_url: str, + headers: dict[str, str], + automation_id: str, + poll_interval_seconds: float, + timeout_seconds: float, +) -> None: + user_action_id = _submit_user_action( + debug_url, + headers, + agent_seed_protocol_builders.build_stop_automation_user_action(automation_id), + ) + _wait_for_automation_status( + debug_url=debug_url, + headers=headers, + automation_id=automation_id, + expected_status=protocol_models.WorkflowStatus.COMPLETED, + user_action_id=user_action_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + + +def bootstrap_index_automation( + *, + base_url: str, + poll_interval_seconds: float = agent_seed_bootstrap_grid.DEFAULT_POLL_INTERVAL_SECONDS, + timeout_seconds: float = agent_seed_bootstrap_grid.DEFAULT_TIMEOUT_SECONDS, +) -> None: + debug_url, headers = _session(base_url) + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_INDEX_ID + if _current_status(debug_url, headers, automation_id) == protocol_models.WorkflowStatus.RUNNING.value: + return + _create_and_wait_running( + debug_url=debug_url, + headers=headers, + user_action=agent_seed_protocol_builders.build_create_index_automation_user_action(), + automation_id=automation_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + + +def bootstrap_completed_automation( + *, + base_url: str, + poll_interval_seconds: float = agent_seed_bootstrap_grid.DEFAULT_POLL_INTERVAL_SECONDS, + timeout_seconds: float = agent_seed_bootstrap_grid.DEFAULT_TIMEOUT_SECONDS, +) -> None: + debug_url, headers = _session(base_url) + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_COMPLETED_ID + status = _current_status(debug_url, headers, automation_id) + if status == protocol_models.WorkflowStatus.COMPLETED.value: + return + if status != protocol_models.WorkflowStatus.RUNNING.value: + _create_and_wait_running( + debug_url=debug_url, + headers=headers, + user_action=agent_seed_protocol_builders.build_create_index_automation_user_action( + automation_id=automation_id, + automation_name=demo_agent_seed_constants.DEMO_AGENT_SEED_COMPLETED_AUTOMATION_DISPLAY_NAME, + ), + automation_id=automation_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + _stop_and_wait_completed( + debug_url=debug_url, + headers=headers, + automation_id=automation_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + + +def check_grid_automation_lifecycle( + *, + base_url: str, + poll_interval_seconds: float = agent_seed_bootstrap_grid.DEFAULT_POLL_INTERVAL_SECONDS, + timeout_seconds: float = agent_seed_bootstrap_grid.DEFAULT_TIMEOUT_SECONDS, +) -> None: + """ + Stop then restart the seeded grid automation and check it is RUNNING again with its display name. + Raises AutomationNameLostError when the restarted automation has lost its name. + """ + debug_url, headers = _session(base_url) + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID + if _current_status(debug_url, headers, automation_id) != protocol_models.WorkflowStatus.RUNNING.value: + agent_seed_bootstrap_grid.bootstrap_grid_automation( + base_url=base_url, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + _stop_and_wait_completed( + debug_url=debug_url, + headers=headers, + automation_id=automation_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + restart_action_id = _submit_user_action( + debug_url, + headers, + agent_seed_protocol_builders.build_restart_automation_user_action(automation_id), + ) + automation = _wait_for_automation_status( + debug_url=debug_url, + headers=headers, + automation_id=automation_id, + expected_status=protocol_models.WorkflowStatus.RUNNING, + user_action_id=restart_action_id, + poll_interval_seconds=poll_interval_seconds, + timeout_seconds=timeout_seconds, + ) + expected_name = demo_agent_seed_constants.DEMO_AGENT_SEED_GRID_AUTOMATION_DISPLAY_NAME + restarted_name = (automation.get("metadata") or {}).get("name") + if restarted_name != expected_name: + raise agent_seed_errors.AutomationNameLostError( + f"Automation {automation_id} is RUNNING after restart but its name is {restarted_name!r}, " + f"expected {expected_name!r}", + ) + + +def run_scenarios( + *, + base_url: str, + scenarios: typing.Optional[typing.Iterable[agent_seed_enums.BootstrapScenario]], + poll_interval_seconds: float = agent_seed_bootstrap_grid.DEFAULT_POLL_INTERVAL_SECONDS, + timeout_seconds: float = agent_seed_bootstrap_grid.DEFAULT_TIMEOUT_SECONDS, +) -> None: + common = { + "base_url": base_url, + "poll_interval_seconds": poll_interval_seconds, + "timeout_seconds": timeout_seconds, + } + runners = { + agent_seed_enums.BootstrapScenario.GRID: agent_seed_bootstrap_grid.bootstrap_grid_automation, + agent_seed_enums.BootstrapScenario.INDEX: bootstrap_index_automation, + agent_seed_enums.BootstrapScenario.COMPLETED: bootstrap_completed_automation, + agent_seed_enums.BootstrapScenario.LIFECYCLE: check_grid_automation_lifecycle, + } + for scenario in expand_scenarios(scenarios): + runners[scenario](**common) diff --git a/tools/agent_seed/protocol/builders.py b/tools/agent_seed/protocol/builders.py index 2e64c6bac3..4f789f409c 100644 --- a/tools/agent_seed/protocol/builders.py +++ b/tools/agent_seed/protocol/builders.py @@ -149,28 +149,78 @@ def wrap_user_action_configuration( return protocol_models.UserActionConfiguration.from_json(payload.to_json()) -def build_create_grid_automation_user_action() -> protocol_models.UserAction: +def _build_create_automation_user_action( + *, + user_action_id_prefix: str, + automation_id: str, + automation_name: str, + strategy_id: str, + account_id: str, +) -> protocol_models.UserAction: strategy_reference = protocol_models.StrategyReference( - id=demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_GRID_ID, + id=strategy_id, version=demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_VERSION, emit_signals=False, ) automation_configuration = protocol_models.AutomationConfiguration( - id=demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID, - name=demo_agent_seed_constants.DEMO_AGENT_SEED_GRID_AUTOMATION_DISPLAY_NAME, + id=automation_id, + name=automation_name, created_at=datetime.datetime(2026, 6, 1, 12, 0, 0, tzinfo=datetime.UTC), strategy=strategy_reference, - accounts=[ - protocol_models.AccountReference( - id=demo_agent_seed_constants.DEMO_AGENT_SEED_ACCOUNT_GRID_ID, - ), - ], + accounts=[protocol_models.AccountReference(id=account_id)], ) payload = protocol_models.CreateAutomationConfiguration( action_type=protocol_models.UserActionType.AUTOMATION_CREATE, configuration=automation_configuration, ) return protocol_models.UserAction( - id=f"ua-agent-seed-grid-{uuid.uuid4()}", + id=f"{user_action_id_prefix}-{uuid.uuid4()}", + configuration=wrap_user_action_configuration(payload), + ) + + +def build_create_grid_automation_user_action() -> protocol_models.UserAction: + return _build_create_automation_user_action( + user_action_id_prefix="ua-agent-seed-grid", + automation_id=demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID, + automation_name=demo_agent_seed_constants.DEMO_AGENT_SEED_GRID_AUTOMATION_DISPLAY_NAME, + strategy_id=demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_GRID_ID, + account_id=demo_agent_seed_constants.DEMO_AGENT_SEED_ACCOUNT_GRID_ID, + ) + + +def build_create_index_automation_user_action( + *, + automation_id: str = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_INDEX_ID, + automation_name: str = demo_agent_seed_constants.DEMO_AGENT_SEED_INDEX_AUTOMATION_DISPLAY_NAME, +) -> protocol_models.UserAction: + return _build_create_automation_user_action( + user_action_id_prefix="ua-agent-seed-index", + automation_id=automation_id, + automation_name=automation_name, + strategy_id=demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_INDEX_ID, + account_id=demo_agent_seed_constants.DEMO_AGENT_SEED_ACCOUNT_INDEX_IDLE_ID, + ) + + +def build_stop_automation_user_action(automation_id: str) -> protocol_models.UserAction: + payload = protocol_models.StopAutomationConfiguration( + id=automation_id, + action_type=protocol_models.UserActionType.AUTOMATION_STOP, + cancel_orders=True, + ) + return protocol_models.UserAction( + id=f"ua-agent-seed-stop-{uuid.uuid4()}", + configuration=wrap_user_action_configuration(payload), + ) + + +def build_restart_automation_user_action(automation_id: str) -> protocol_models.UserAction: + payload = protocol_models.RestartAutomationConfiguration( + id=automation_id, + action_type=protocol_models.UserActionType.AUTOMATION_RESTART, + ) + return protocol_models.UserAction( + id=f"ua-agent-seed-restart-{uuid.uuid4()}", configuration=wrap_user_action_configuration(payload), ) diff --git a/tools/tests/agent_seed/cli/test_bootstrap_cli.py b/tools/tests/agent_seed/cli/test_bootstrap_cli.py new file mode 100644 index 0000000000..b036a22755 --- /dev/null +++ b/tools/tests/agent_seed/cli/test_bootstrap_cli.py @@ -0,0 +1,50 @@ +# Unit tests for agent seed bootstrap CLI scenario options. + +import pytest + +import tools.agent_seed.cli as agent_seed_cli +import tools.agent_seed.cli.bootstrap as agent_seed_cli_bootstrap +import tools.agent_seed.enums as agent_seed_enums +import tools.agent_seed.operations.bootstrap_scenarios as agent_seed_bootstrap_scenarios + + +@pytest.fixture +def captured_run(monkeypatch): + calls = [] + + def fake_run_scenarios(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(agent_seed_bootstrap_scenarios, "run_scenarios", fake_run_scenarios) + return calls + + +class TestBootstrapCli: + def test_no_scenario_option_passes_empty_list(self, captured_run): + assert agent_seed_cli.main(["bootstrap"]) == 0 + assert captured_run[0]["scenarios"] == [] + + def test_scenario_option_is_repeatable(self, captured_run): + argv = ["bootstrap", "--scenario", "index", "--scenario", "lifecycle"] + assert agent_seed_cli.main(argv) == 0 + assert captured_run[0]["scenarios"] == [ + agent_seed_enums.BootstrapScenario.INDEX, + agent_seed_enums.BootstrapScenario.LIFECYCLE, + ] + + def test_unknown_scenario_is_rejected(self, captured_run): + with pytest.raises(SystemExit): + agent_seed_cli.main(["bootstrap", "--scenario", "errored"]) + assert captured_run == [] + + def test_run_from_namespace_forwards_scenarios_and_base_url(self, captured_run): + namespace = agent_seed_cli.build_arg_parser().parse_args( + ["bootstrap", "--base-url", "http://node:9", "--scenario", "all"], + ) + assert agent_seed_cli_bootstrap.run_from_namespace(namespace) == 0 + assert captured_run[0]["base_url"] == "http://node:9" + assert captured_run[0]["scenarios"] == [agent_seed_enums.BootstrapScenario.ALL] + + def test_all_subcommand_accepts_scenarios(self): + namespace = agent_seed_cli.build_arg_parser().parse_args(["all", "--scenario", "completed"]) + assert namespace.scenario == ["completed"] diff --git a/tools/tests/agent_seed/operations/test_bootstrap_grid.py b/tools/tests/agent_seed/operations/test_bootstrap_grid.py index 14004d99b0..c4a5218e6c 100644 --- a/tools/tests/agent_seed/operations/test_bootstrap_grid.py +++ b/tools/tests/agent_seed/operations/test_bootstrap_grid.py @@ -15,12 +15,12 @@ def test_find_user_action_returns_matching_entry(self): ], }, } - found = agent_seed_bootstrap_grid._find_user_action(debug_payload, "ua-target") + found = agent_seed_bootstrap_grid.find_user_action(debug_payload, "ua-target") assert found == {"id": "ua-target", "status": "failed"} def test_find_user_action_returns_none_when_missing(self): debug_payload = {"debug": {"user_actions": [{"id": "ua-other"}]}} - assert agent_seed_bootstrap_grid._find_user_action(debug_payload, "ua-missing") is None + assert agent_seed_bootstrap_grid.find_user_action(debug_payload, "ua-missing") is None class TestFormatAutomationUserActionError: @@ -35,7 +35,7 @@ def test_format_includes_error_message_and_details(self): }, }, } - message = agent_seed_bootstrap_grid._format_automation_user_action_error(user_action) + message = agent_seed_bootstrap_grid.format_automation_user_action_error(user_action) assert "ua-failed" in message assert "invalid_automation_id" in message assert "valid UUID" in message @@ -60,7 +60,7 @@ def test_raises_runtime_error_when_action_failed(self): }, } with pytest.raises(RuntimeError, match="invalid_automation_id"): - agent_seed_bootstrap_grid._ensure_user_action_not_failed(debug_payload, "ua-failed") + agent_seed_bootstrap_grid.ensure_user_action_not_failed(debug_payload, "ua-failed") def test_no_op_when_action_pending_or_missing(self): debug_payload = { @@ -70,8 +70,8 @@ def test_no_op_when_action_pending_or_missing(self): ], }, } - agent_seed_bootstrap_grid._ensure_user_action_not_failed(debug_payload, "ua-pending") - agent_seed_bootstrap_grid._ensure_user_action_not_failed(debug_payload, "ua-missing") + agent_seed_bootstrap_grid.ensure_user_action_not_failed(debug_payload, "ua-pending") + agent_seed_bootstrap_grid.ensure_user_action_not_failed(debug_payload, "ua-missing") def test_no_op_when_action_completed(self): debug_payload = { @@ -79,4 +79,4 @@ def test_no_op_when_action_completed(self): "user_actions": [{"id": "ua-done", "status": "completed"}], }, } - agent_seed_bootstrap_grid._ensure_user_action_not_failed(debug_payload, "ua-done") + agent_seed_bootstrap_grid.ensure_user_action_not_failed(debug_payload, "ua-done") diff --git a/tools/tests/agent_seed/operations/test_bootstrap_scenarios.py b/tools/tests/agent_seed/operations/test_bootstrap_scenarios.py new file mode 100644 index 0000000000..de64f5693e --- /dev/null +++ b/tools/tests/agent_seed/operations/test_bootstrap_scenarios.py @@ -0,0 +1,194 @@ +# Unit tests for agent seed bootstrap scenarios (index, completed, lifecycle). + +import pytest + +import octobot_node.agent_seed.constants as demo_agent_seed_constants + +import tools.agent_seed.enums as agent_seed_enums +import tools.agent_seed.errors as agent_seed_errors +import tools.agent_seed.operations.bootstrap_http as agent_seed_bootstrap_http +import tools.agent_seed.operations.bootstrap_scenarios as agent_seed_bootstrap_scenarios + +BASE_URL = "http://127.0.0.1:8000" +GRID_ID = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID +INDEX_ID = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_INDEX_ID +COMPLETED_ID = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_COMPLETED_ID +GRID_NAME = demo_agent_seed_constants.DEMO_AGENT_SEED_GRID_AUTOMATION_DISPLAY_NAME + + +class FakeNode: + """In-memory stand-in for GET/POST /api/v1/debug/ that completes every user action at once.""" + + def __init__(self, *, drop_name_on_restart: bool = False, reject_posts_with: int | None = None): + self.automations: dict[str, dict] = {} + self.user_actions: list[dict] = [] + self.posted_action_types: list[str] = [] + self.drop_name_on_restart = drop_name_on_restart + self.reject_posts_with = reject_posts_with + + def request_json(self, method, url, headers, payload=None): + assert url.endswith("/api/v1/debug/") + assert headers["Authorization"].startswith("Basic ") + if method == "GET": + return 200, { + "debug": { + "automations": list(self.automations.values()), + "user_actions": self.user_actions, + }, + } + if self.reject_posts_with is not None: + return self.reject_posts_with, {"detail": "rejected"} + self._apply(payload) + return 204, None + + def _apply(self, payload): + action = payload["configuration"] + action_type = action["action_type"] + self.posted_action_types.append(action_type) + if action_type == "automation_create": + configuration = action["configuration"] + self.automations[configuration["id"]] = { + "id": configuration["id"], + "status": "running", + "metadata": {"name": configuration["name"]}, + } + elif action_type == "automation_stop": + self.automations[action["id"]]["status"] = "completed" + elif action_type == "automation_restart": + automation = self.automations[action["id"]] + automation["status"] = "running" + if self.drop_name_on_restart: + automation["metadata"]["name"] = "" + self.user_actions.append({"id": payload["id"], "status": "completed"}) + + +@pytest.fixture +def fake_node(monkeypatch): + node = FakeNode() + monkeypatch.setattr(agent_seed_bootstrap_http, "request_json", node.request_json) + return node + + +def _run(scenarios): + agent_seed_bootstrap_scenarios.run_scenarios( + base_url=BASE_URL, + scenarios=scenarios, + poll_interval_seconds=0, + timeout_seconds=1, + ) + + +class TestExpandScenarios: + def test_defaults_to_grid(self): + assert agent_seed_bootstrap_scenarios.expand_scenarios(None) == [agent_seed_enums.BootstrapScenario.GRID] + assert agent_seed_bootstrap_scenarios.expand_scenarios([]) == [agent_seed_enums.BootstrapScenario.GRID] + + def test_all_expands_in_dependency_order(self): + assert agent_seed_bootstrap_scenarios.expand_scenarios([agent_seed_enums.BootstrapScenario.ALL]) == [ + agent_seed_enums.BootstrapScenario.GRID, + agent_seed_enums.BootstrapScenario.INDEX, + agent_seed_enums.BootstrapScenario.COMPLETED, + agent_seed_enums.BootstrapScenario.LIFECYCLE, + ] + + def test_order_is_fixed_and_duplicates_dropped(self): + requested = [ + agent_seed_enums.BootstrapScenario.LIFECYCLE, + agent_seed_enums.BootstrapScenario.INDEX, + agent_seed_enums.BootstrapScenario.INDEX, + ] + assert agent_seed_bootstrap_scenarios.expand_scenarios(requested) == [ + agent_seed_enums.BootstrapScenario.INDEX, + agent_seed_enums.BootstrapScenario.LIFECYCLE, + ] + + +class TestFindAutomation: + def test_returns_matching_automation(self): + payload = {"debug": {"automations": [{"id": "a"}, {"id": "b", "status": "running"}]}} + assert agent_seed_bootstrap_scenarios.find_automation(payload, "b") == {"id": "b", "status": "running"} + + def test_returns_none_when_missing_or_empty(self): + assert agent_seed_bootstrap_scenarios.find_automation({"debug": {"automations": []}}, "a") is None + assert agent_seed_bootstrap_scenarios.find_automation({}, "a") is None + + +class TestIndexScenario: + def test_creates_running_index_automation(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.INDEX]) + assert fake_node.posted_action_types == ["automation_create"] + assert fake_node.automations[INDEX_ID]["status"] == "running" + assert ( + fake_node.automations[INDEX_ID]["metadata"]["name"] + == demo_agent_seed_constants.DEMO_AGENT_SEED_INDEX_AUTOMATION_DISPLAY_NAME + ) + + def test_no_op_when_already_running(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.INDEX]) + _run([agent_seed_enums.BootstrapScenario.INDEX]) + assert fake_node.posted_action_types == ["automation_create"] + + def test_raises_when_node_rejects_the_action(self, monkeypatch): + node = FakeNode(reject_posts_with=403) + monkeypatch.setattr(agent_seed_bootstrap_http, "request_json", node.request_json) + with pytest.raises(RuntimeError, match="HTTP 403"): + _run([agent_seed_enums.BootstrapScenario.INDEX]) + + +class TestCompletedScenario: + def test_creates_then_stops_automation(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + assert fake_node.posted_action_types == ["automation_create", "automation_stop"] + assert fake_node.automations[COMPLETED_ID]["status"] == "completed" + + def test_no_op_when_already_completed(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + assert fake_node.posted_action_types == ["automation_create", "automation_stop"] + + def test_only_stops_when_already_running(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + fake_node.automations[COMPLETED_ID]["status"] = "running" + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + assert fake_node.posted_action_types == ["automation_create", "automation_stop", "automation_stop"] + + def test_times_out_when_automation_never_completes(self, fake_node, monkeypatch): + original_apply = fake_node._apply + + def apply_without_stopping(payload): + if payload["configuration"]["action_type"] != "automation_stop": + original_apply(payload) + + monkeypatch.setattr(fake_node, "_apply", apply_without_stopping) + with pytest.raises(TimeoutError, match="completed"): + _run([agent_seed_enums.BootstrapScenario.COMPLETED]) + + +class TestLifecycleScenario: + def test_passes_when_name_survives_restart(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.LIFECYCLE]) + assert fake_node.posted_action_types == ["automation_create", "automation_stop", "automation_restart"] + assert fake_node.automations[GRID_ID]["status"] == "running" + assert fake_node.automations[GRID_ID]["metadata"]["name"] == GRID_NAME + + def test_raises_typed_error_when_name_lost_on_restart(self, monkeypatch): + node = FakeNode(drop_name_on_restart=True) + monkeypatch.setattr(agent_seed_bootstrap_http, "request_json", node.request_json) + with pytest.raises(agent_seed_errors.AutomationNameLostError, match="name is ''"): + _run([agent_seed_enums.BootstrapScenario.LIFECYCLE]) + + def test_name_lost_error_is_an_agent_seed_error(self): + assert issubclass(agent_seed_errors.AutomationNameLostError, agent_seed_errors.AgentSeedError) + + +class TestAllScenarios: + def test_all_runs_every_scenario_in_order(self, fake_node): + _run([agent_seed_enums.BootstrapScenario.ALL]) + assert fake_node.posted_action_types == [ + "automation_create", # grid + "automation_create", # index + "automation_create", # completed + "automation_stop", + "automation_stop", # lifecycle + "automation_restart", + ] diff --git a/tools/tests/agent_seed/protocol/test_builders.py b/tools/tests/agent_seed/protocol/test_builders.py index 1e59c4e70f..84308ad49b 100644 --- a/tools/tests/agent_seed/protocol/test_builders.py +++ b/tools/tests/agent_seed/protocol/test_builders.py @@ -79,3 +79,49 @@ def test_create_automation_references_stable_ids(self): assert str(uuid.UUID(automation_id)) == automation_id assert automation.strategy.id == demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_GRID_ID assert automation.accounts[0].id == demo_agent_seed_constants.DEMO_AGENT_SEED_ACCOUNT_GRID_ID + + +class TestBuildCreateIndexAutomationUserAction: + def test_defaults_reference_index_strategy_and_idle_account(self): + user_action = agent_seed_protocol_builders.build_create_index_automation_user_action() + payload = user_action.configuration.actual_instance + assert payload.action_type == protocol_models.UserActionType.AUTOMATION_CREATE + automation = payload.configuration + assert automation.id == demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_INDEX_ID + assert str(uuid.UUID(automation.id)) == automation.id + assert automation.name == demo_agent_seed_constants.DEMO_AGENT_SEED_INDEX_AUTOMATION_DISPLAY_NAME + assert automation.strategy.id == demo_agent_seed_constants.DEMO_AGENT_SEED_STRATEGY_INDEX_ID + assert automation.accounts[0].id == demo_agent_seed_constants.DEMO_AGENT_SEED_ACCOUNT_INDEX_IDLE_ID + + def test_automation_id_and_name_can_be_overridden(self): + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_COMPLETED_ID + user_action = agent_seed_protocol_builders.build_create_index_automation_user_action( + automation_id=automation_id, + automation_name=demo_agent_seed_constants.DEMO_AGENT_SEED_COMPLETED_AUTOMATION_DISPLAY_NAME, + ) + automation = user_action.configuration.actual_instance.configuration + assert automation.id == automation_id + assert str(uuid.UUID(automation_id)) == automation_id + assert automation.name == demo_agent_seed_constants.DEMO_AGENT_SEED_COMPLETED_AUTOMATION_DISPLAY_NAME + + def test_user_action_ids_are_unique(self): + first = agent_seed_protocol_builders.build_create_index_automation_user_action() + second = agent_seed_protocol_builders.build_create_index_automation_user_action() + assert first.id != second.id + + +class TestBuildStopAndRestartAutomationUserActions: + def test_stop_targets_automation_and_cancels_orders(self): + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID + user_action = agent_seed_protocol_builders.build_stop_automation_user_action(automation_id) + payload = user_action.configuration.actual_instance + assert payload.action_type == protocol_models.UserActionType.AUTOMATION_STOP + assert payload.id == automation_id + assert payload.cancel_orders is True + + def test_restart_targets_automation(self): + automation_id = demo_agent_seed_constants.DEMO_AGENT_SEED_AUTOMATION_GRID_ID + user_action = agent_seed_protocol_builders.build_restart_automation_user_action(automation_id) + payload = user_action.configuration.actual_instance + assert payload.action_type == protocol_models.UserActionType.AUTOMATION_RESTART + assert payload.id == automation_id