From 5f06ca9ea042234a3480452846baa512728918e5 Mon Sep 17 00:00:00 2001 From: brainx <12695242+brainx@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:45:28 +0200 Subject: [PATCH] Upgrade Zeus compatibility to 0.5.0.dev0 --- CHANGELOG.md | 15 +- README.md | 44 +- docs/OPERATIONS.md | 43 +- docs/PROJECT_PLAN.md | 30 +- docs/STAGING_RUNBOOK.md | 14 +- docs/THREAT_MODEL.md | 6 +- docs/zeus-openapi-v0.5.0.dev0.json | 1595 ++++++++++++++++++++++++++++ olymp/cli.py | 11 + olymp/execution.py | 46 +- olymp/plans.py | 8 +- olymp/zeus_client.py | 20 + scripts/repo_check.sh | 3 +- scripts/verify_real_zeus.sh | 19 +- tests/test_api_security.py | 8 +- tests/test_bot_creation.py | 122 ++- tests/test_cli_api.py | 62 +- tests/test_events.py | 2 +- tests/test_plans_evidence.py | 96 +- tests/test_policy.py | 69 +- tests/test_repo_contracts.py | 62 +- tests/test_rollouts.py | 14 +- tests/test_zeus_client.py | 49 +- 22 files changed, 2100 insertions(+), 238 deletions(-) create mode 100644 docs/zeus-openapi-v0.5.0.dev0.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ae15a..46ed4c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,15 @@ - Added a SQLite-backed operation event bus with CLI/API timeline readers and in-process plugin subscriber hooks for extensions. -- Updated the Zeus mutation compatibility target to v0.3.0 with an exact - vendored OpenAPI fixture, while retaining v0.1.4 as a transitional - legacy read-only contract. -- Required mutation targets to declare a supported Zeus `0.3.N` - `expected_version` at planning and execution while leaving legacy read-only - health, inventory, and history available. +- Updated the Zeus mutation compatibility target to exact `0.5.0.dev0` at + upstream contract commit `f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a`, + while retaining v0.3.0 and v0.1.4 as legacy read-only contracts. +- Added authenticated Zeus `/ready` support, fail-closed `not_ready` mutation + preflights, schema-version validation, and an exact `0.5.0.dev0` + `expected_version` gate. +- Added contract coverage for creating `kimi-k3-coding-bot` using only bot and + template metadata. Provider credentials and harness configuration remain in + Zeus; no direct harness integration was added. - Added exact-approval execution for normal `bot.create`; higher-risk `bot.replace` and `bot.replace_stop` remain default-denied policy actions. Durable idempotency evidence prevents automatic repeats after an diff --git a/README.md b/README.md index a7ab84c..ce088dd 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,18 @@ Zeus. Operators run Zeus from the upstream project on each node, keep the Zeus API loopback-only, and register those nodes in Olymp for inventory, planning, approved lifecycle execution, audit trails, and evidence export. -The current mutation compatibility target is Zeus v0.3.0, captured exactly -from the upstream tag in `docs/zeus-openapi-v0.3.0.json`. The retained -`docs/zeus-openapi-v0.1.4.json` fixture is transitional and legacy read-only. +The current mutation compatibility target is Zeus `0.5.0.dev0`, captured +exactly from upstream commit `f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a` +in `docs/zeus-openapi-v0.5.0.dev0.json`. The retained v0.3.0 and v0.1.4 +fixtures are legacy read-only contracts. ## Goals - Manage multiple Zeus instances without exposing Zeus directly to the public internet. -- Discover Zeus nodes, verify their `/health`, `/doctor`, template, bot, status, - log, inspect, and reconcile surfaces, and track compatibility against Zeus' - OpenAPI contract. +- Discover Zeus nodes, verify their `/health`, `/ready`, `/doctor`, template, + bot, status, log, inspect, and reconcile surfaces, and track compatibility + against Zeus' OpenAPI contract. - Provide a professional CLI and API first, with a dashboard once the backend contract is stable. - Coordinate bot lifecycle operations across hosts with explicit approvals, @@ -60,6 +61,8 @@ from the upstream tag in `docs/zeus-openapi-v0.3.0.json`. The retained - Olymp will not store provider credentials by default. Bot/provider secrets should stay in Zeus/Hermes host-specific configuration or an approved secret store. +- Olymp will not integrate directly with Hermes or another agent harness in + this compatibility step; Zeus remains the only bot-creation boundary. ## Planned Shape @@ -67,9 +70,9 @@ from the upstream tag in `docs/zeus-openapi-v0.3.0.json`. The retained - CLI: `olymp` - Local API: `127.0.0.1:4321` by default - State: SQLite for the local control plane, with append-only JSONL audit logs -- Integration: typed Zeus client checked against the Zeus v0.3.0 mutation - compatibility fixture in `docs/zeus-openapi-v0.3.0.json`, with v0.1.4 kept - only as a transitional legacy read-only fixture +- Integration: typed Zeus client checked against the Zeus `0.5.0.dev0` + mutation compatibility fixture in `docs/zeus-openapi-v0.5.0.dev0.json`, + with v0.3.0 and v0.1.4 kept as legacy read-only fixtures - Deployment: systemd service, optional Caddy HTTPS front door for the Olymp UI/API, Zeus nodes kept loopback-only behind local agents or SSH/mTLS tunnels @@ -84,7 +87,7 @@ The repository now includes the first CLI/API foundation: - SQLite-backed operation event bus with in-process plugin subscribers, - `olymp doctor`, - `olymp node add/list/check/remove`, -- `olymp zeus health/doctor/templates/bots/inventory/history`, +- `olymp zeus health/ready/doctor/templates/bots/inventory/history`, - dry-run plan commands for bot create/lifecycle/reconcile actions, - approval-backed execution for bot create, lifecycle, and reconcile plans, - configurable local policy loaded from `$OLYMP_POLICY_FILE` or @@ -129,9 +132,10 @@ olymp node add local-zeus \ --name "Local Zeus" \ --base-url http://127.0.0.1:4311 \ --api-key-env ZEUS_API_KEY \ - --expected-version 0.3.0 + --expected-version 0.5.0.dev0 olymp node check local-zeus +olymp zeus ready local-zeus olymp zeus inventory local-zeus olymp zeus history local-zeus coder --limit 50 --before 100 ``` @@ -139,10 +143,11 @@ olymp zeus history local-zeus coder --limit 50 --before 100 Bot history is returned newest first. Pass the previous response's `next_before` value to `--before` to fetch the next page. -Mutation plans require every target node to declare a supported `0.3.N` -`expected_version`, where `N` is a nonnegative integer. Health, inventory, and -history remain available for versionless or legacy nodes, but execution also -rechecks the current registration before any Zeus request. +Mutation plans require every target node to declare the exact reviewed +`0.5.0.dev0` `expected_version`. Health, inventory, and history remain +available for versionless or legacy nodes, but execution rechecks the current +registration and authenticated Zeus `/ready` response before any mutation. +Zeus `not_ready` responses fail closed without recording a mutation attempt. Create and exactly approve a normal bot creation plan: @@ -156,6 +161,11 @@ olymp plan create-bot local-zeus \ --template-id coding-bot \ --reason "prepare next bot" +olymp plan create-bot local-zeus \ + --bot-id kimi-coder \ + --template-id kimi-k3-coding-bot \ + --reason "prepare Kimi K3 coding bot" + olymp plan execute --approve olymp plan rollout-lifecycle coder --action restart \ @@ -165,6 +175,10 @@ olymp plan rollout-lifecycle coder --action restart \ --retry-limit 1 ``` +Configure `KIMI_API_KEY` and the optional `KIMI_BASE_URL` only on the Zeus +host. Olymp sends the bot ID and `kimi-k3-coding-bot` template ID; it does not +accept, persist, or forward provider credentials or harness configuration. + ## Local Policy Defaults Olymp applies conservative local policy before creating mutation plans: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 3047418..d298e44 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -43,8 +43,9 @@ requires the spawned instance's nonce. Olymp operates registered [brainx/zeus](https://github.com/brainx/zeus) nodes. Install and run Zeus from that upstream project on each host, keep its API bound to loopback, then register the local or tunneled endpoint in Olymp. -The current mutation compatibility target is Zeus v0.3.0. Zeus v0.1.4 remains -supported only as a transitional legacy read-only contract. +The current mutation compatibility target is Zeus `0.5.0.dev0` at contract +commit `f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a`. Zeus v0.3.0 and +v0.1.4 remain available only as legacy read-only contracts. Register a loopback Zeus node: @@ -54,13 +55,13 @@ olymp node add local-zeus \ --name "Local Zeus" \ --base-url http://127.0.0.1:4311 \ --api-key-env ZEUS_API_KEY \ - --expected-version 0.3.0 + --expected-version 0.5.0.dev0 ``` Every mutation plan target must declare `expected_version` in the supported -`0.3.N` patch line, with a nonnegative integer `N`. Planning and execution fail -closed for missing, malformed, older, or unreviewed newer versions. Read-only -health, inventory, and history remain available for legacy/versionless nodes. +exact `0.5.0.dev0` contract. Planning and execution fail closed for missing, +malformed, older, or unreviewed newer versions. Read-only health, inventory, +and history remain available for legacy/versionless nodes. Node IDs must match `^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$`; they cannot contain colons, slashes, whitespace, or commas because plan targets use @@ -77,6 +78,7 @@ Read-only checks: ```bash olymp node check local-zeus +olymp zeus ready local-zeus olymp zeus inventory local-zeus olymp zeus history local-zeus coder --limit 50 --before 100 ``` @@ -174,6 +176,11 @@ olymp plan create-bot local-zeus \ --template-id coding-bot \ --reason "prepare next bot" +olymp plan create-bot local-zeus \ + --bot-id kimi-coder \ + --template-id kimi-k3-coding-bot \ + --reason "prepare Kimi K3 coding bot" + # Higher risk and default-denied; --stop-existing requires --replace. olymp plan create-bot local-zeus \ --bot-id coder-next \ @@ -190,6 +197,11 @@ olymp plan rollout-lifecycle coder --action restart \ Every plan command writes a redacted audit event with result `dry_run`. +The Kimi template is rendered and configured by Zeus. Keep `KIMI_API_KEY` and +the optional `KIMI_BASE_URL` in Zeus host configuration; Olymp's plan and +mutation payload contain only bot metadata and never provider credentials, +provider/model selection, or direct harness configuration. + ## Approved Plan Execution Bot creation, lifecycle, and reconcile plans can be executed only with an exact @@ -205,10 +217,12 @@ olymp events list --limit 50 olymp events tail --run-id ``` -Execution preflights Zeus `/health`, executes the exact planned Zeus endpoint, -enforces the expected final state during the postcheck, records a run, and -writes a redacted audit event. If the registered node changes after the plan is -created, execution is rejected before any Zeus request is made. +Execution preflights authenticated Zeus `/ready`, requires the reviewed schema +version, executes the exact planned Zeus endpoint, enforces the expected final +state during the postcheck, records a run, and writes a redacted audit event. +A Zeus `not_ready` response fails closed before a mutation attempt is recorded. +If the registered node changes after the plan is created, execution is rejected +before any Zeus request is made. Bot creation uses one durable idempotency attempt and one inventory postcheck. An indeterminate create outcome is preserved with its Zeus request metadata and @@ -323,8 +337,9 @@ audit directories, audit files, and audit lock files under the state directory. ## Compatibility Olymp's mutation compatibility target is -[brainx/zeus](https://github.com/brainx/zeus) v0.3.0, captured as an exact, -immutable upstream copy in `docs/zeus-openapi-v0.3.0.json`. Repository and CI +[brainx/zeus](https://github.com/brainx/zeus) `0.5.0.dev0` at commit +`f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a`, captured as an exact, immutable +upstream copy in `docs/zeus-openapi-v0.5.0.dev0.json`. Repository and CI contract checks use this vendored fixture and do not require network access. -The retained `docs/zeus-openapi-v0.1.4.json` contract is transitional and -legacy read-only; it is not the mutation compatibility target. +The retained v0.3.0 and v0.1.4 contracts are legacy read-only fixtures; they +are not mutation compatibility targets. diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md index da7fc89..0c6708c 100644 --- a/docs/PROJECT_PLAN.md +++ b/docs/PROJECT_PLAN.md @@ -17,17 +17,19 @@ node connectors rather than asking operators to expose Zeus directly. Olymp should treat the upstream [brainx/zeus](https://github.com/brainx/zeus) OpenAPI file as the integration contract and test against it on every release. -Zeus v0.3.0 is the current mutation compatibility target, vendored unchanged -as `docs/zeus-openapi-v0.3.0.json`. The v0.1.4 contract remains available only -as the transitional legacy read-only fixture -`docs/zeus-openapi-v0.1.4.json`. -Mutation planning and execution require each registered target to declare an -`expected_version` in the reviewed `0.3.N` patch line; read-only access remains -available for legacy or versionless nodes. +Zeus `0.5.0.dev0` at commit +`f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a` is the current mutation +compatibility target, vendored unchanged as +`docs/zeus-openapi-v0.5.0.dev0.json`. The v0.3.0 and v0.1.4 contracts remain +available only as legacy read-only fixtures. Mutation planning and execution +require each registered target to declare the exact reviewed `0.5.0.dev0` +`expected_version`; read-only access remains available for legacy or +versionless nodes. Required Zeus surfaces: - `GET /health` +- `GET /ready` - `GET /doctor` - `GET /templates` - `GET /bots` @@ -46,6 +48,8 @@ Required Zeus security behavior: - `GET /health` may be unauthenticated. - All other endpoints must support `x-zeus-api-key`. +- `GET /ready` must report status `ready` at schema version `6`; `not_ready` + must block mutations without creating an attempt. - Diagnostic logs and inspect endpoints must remain authenticated. - Responses and logs must be treated as sensitive operational data. - Zeus mutations must support `Idempotency-Key` and every documented response @@ -142,16 +146,16 @@ Status: initial foundation implemented. ### 1. Zeus Client MVP -- Implement a typed client for the Zeus OpenAPI v0.3.0 mutation surface while - retaining v0.1.4 only for transitional legacy read-only compatibility. +- Implement a typed client for the Zeus OpenAPI `0.5.0.dev0` mutation surface + while retaining v0.3.0 and v0.1.4 as legacy read-only compatibility. - Cover auth success/failure, missing key behavior, timeouts, malformed JSON, response-size limits, and diagnostic endpoint redaction. - Add contract tests using a fake Zeus server. -Status: initial client and fake-Zeus tests are implemented. The exact Zeus -v0.3.0 contract is the mutation compatibility target; the v0.1.4 fixture is -retained as transitional legacy read-only coverage. Timeout-specific tests are -implemented. +Status: the client and fake-Zeus tests are implemented. The exact Zeus +`0.5.0.dev0` contract is the mutation compatibility target; the v0.3.0 and +v0.1.4 fixtures are retained as legacy read-only coverage. Timeout-specific +tests are implemented. ### 2. Local CLI and Registry diff --git a/docs/STAGING_RUNBOOK.md b/docs/STAGING_RUNBOOK.md index 981c615..34a8786 100644 --- a/docs/STAGING_RUNBOOK.md +++ b/docs/STAGING_RUNBOOK.md @@ -20,16 +20,18 @@ API: ZEUS_API_KEY=change-me OLYMP_API_KEY=change-me sh scripts/verify_real_zeus.sh ``` -The verifier is pinned to Zeus v0.3.0, registers the smoke node with -`expected_version` `0.3.0`, checks inventory, creates and executes an approved -lifecycle plan only when a disposable existing bot is explicitly configured, -exports evidence, and removes its temporary state by default. +The verifier is pinned to the Zeus `0.5.0.dev0` contract commit +`f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a`, registers the smoke node with +`expected_version` `0.5.0.dev0`, checks authenticated `/ready` plus inventory, +creates and executes an approved lifecycle plan only when a disposable +existing bot is explicitly configured, exports evidence, and removes its +temporary state by default. It does not create or replace bots. Set `OLYMP_REAL_ZEUS_KEEP_STATE=1` to preserve the verifier database, audit log, and evidence bundle for review. -Checkout-only mode fetches the supported Zeus tag without starting Zeus or -requiring a live Zeus API: +Checkout-only mode fetches the pinned Zeus contract commit without starting +Zeus or requiring a live Zeus API: ```bash OLYMP_REAL_ZEUS_CHECKOUT=1 sh scripts/verify_real_zeus.sh diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 5fe9d39..2b10e22 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -64,8 +64,10 @@ That makes Olymp security-sensitive even when the first release is local-only. checks for Zeus calls. - Require dry-run output, policy validation, and explicit approval before mutating fleet actions. -- Require every mutation target to declare the reviewed Zeus `0.3.N` - `expected_version`, then recheck the current registration before Zeus I/O. +- Require every mutation target to declare the exact reviewed Zeus + `0.5.0.dev0` `expected_version`, then recheck the current registration and + authenticated `/ready` response before Zeus mutation I/O. Treat + `not_ready` as a fail-closed preflight result. - Allow normal `bot.create`, keep `bot.replace` and `bot.replace_stop` default-denied, and validate the exact stored action/path/body relationship. - Persist the idempotency attempt before create, never automatically repeat an diff --git a/docs/zeus-openapi-v0.5.0.dev0.json b/docs/zeus-openapi-v0.5.0.dev0.json new file mode 100644 index 0000000..b38dc0d --- /dev/null +++ b/docs/zeus-openapi-v0.5.0.dev0.json @@ -0,0 +1,1595 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Zeus Local API", + "version": "0.5.0.dev0", + "description": "Local JSON API for managing Hermes bot profiles and gateway lifecycle." + }, + "servers": [ + { + "url": "http://127.0.0.1:4311" + }, + { + "url": "http://127.0.0.1:4311/v1" + } + ], + "components": { + "headers": { + "XRequestID": { + "description": "Locally generated request correlation identifier.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + } + }, + "IdempotencyReplayed": { + "description": "Present only when Zeus replays a stored idempotent response.", + "required": false, + "schema": { + "type": "string", + "const": "true" + } + }, + "RetryAfter": { + "description": "Seconds to wait before retrying an in-progress idempotent request.", + "required": false, + "schema": { + "type": "string", + "const": "1" + } + } + }, + "securitySchemes": { + "ZeusApiKey": { + "type": "apiKey", + "in": "header", + "name": "x-zeus-api-key" + } + }, + "schemas": { + "Error": { + "type": "object", + "required": ["error"], + "properties": { + "error": { + "type": "object", + "required": ["code", "message", "status"], + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_request", + "invalid_bot_id", + "unknown_bot", + "unknown_template", + "missing_api_key", + "invalid_api_key", + "auth_rate_limited", + "mutation_rate_limited", + "unsupported_media_type", + "method_not_allowed", + "bot_locked", + "reconcile_locked", + "bot_exists", + "bot_running", + "bot_replace_failed", + "bot_delete_failed", + "bot_archive_failed", + "idempotency_key_conflict", + "idempotency_in_progress", + "idempotency_indeterminate", + "idempotency_store_unavailable", + "idempotency_response_too_large", + "server_busy", + "server_draining", + "not_ready", + "internal_error" + ] + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + } + } + }, + "ReadinessResponse": { + "type": "object", + "required": ["schema_version", "status"], + "properties": { + "schema_version": { + "type": "integer", + "const": 6 + }, + "status": { + "type": "string", + "const": "ready" + } + } + }, + "Bot": { + "type": "object", + "required": [ + "bot_id", + "template_id", + "display_name", + "profile_path", + "status", + "pid", + "restart_policy", + "restart_backoff_seconds", + "restart_max_attempts", + "restart_attempts", + "next_restart_at", + "started_at", + "ready_at", + "stopped_at", + "last_exit_code", + "last_error", + "last_transition_reason", + "desired_state", + "converged", + "created_at", + "updated_at" + ], + "properties": { + "bot_id": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "profile_path": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["stopped", "starting", "running", "failed", "unknown"] + }, + "pid": { + "type": ["integer", "null"] + }, + "restart_policy": { + "type": "string", + "enum": ["manual", "on-failure"] + }, + "restart_backoff_seconds": { + "type": "number" + }, + "restart_max_attempts": { + "type": "integer" + }, + "restart_attempts": { + "type": "integer" + }, + "next_restart_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "started_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "ready_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "stopped_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "last_exit_code": { + "type": ["integer", "null"] + }, + "last_error": { + "type": ["string", "null"] + }, + "last_transition_reason": { + "type": ["string", "null"] + }, + "desired_state": { + "type": "string", + "enum": ["running", "stopped"], + "description": "Persisted operator intent for the gateway." + }, + "converged": { + "type": "boolean", + "description": "True only when observed status matches desired state." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, + "BotCreateRequest": { + "type": "object", + "required": ["bot_id", "template_id"], + "properties": { + "bot_id": { + "type": "string" + }, + "template_id": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "restart_policy": { + "type": "string", + "enum": ["manual", "on-failure"], + "default": "manual" + }, + "restart_backoff_seconds": { + "type": "number", + "default": 5 + }, + "restart_max_attempts": { + "type": "integer", + "default": 5 + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "BotStatusResponse": { + "type": "object", + "required": ["bot_id", "status", "pid", "profile_path", "message"], + "properties": { + "bot_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["stopped", "starting", "running", "failed", "unknown"] + }, + "pid": { + "type": ["integer", "null"] + }, + "profile_path": { + "type": "string" + }, + "message": { + "type": "string" + } + } + }, + "ReconcileResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "bot_id", + "outcome", + "desired_state", + "observed_status", + "pid", + "action", + "message", + "error_code", + "event_id", + "started_at", + "finished_at" + ], + "properties": { + "bot_id": { + "type": "string" + }, + "outcome": { + "type": "string", + "enum": [ + "healthy", + "changed", + "pending", + "action_required", + "error", + "skipped" + ] + }, + "desired_state": { + "type": ["string", "null"], + "enum": ["running", "stopped", null] + }, + "observed_status": { + "type": ["string", "null"], + "enum": ["stopped", "starting", "running", "failed", "unknown", null] + }, + "pid": { + "type": ["integer", "null"] + }, + "action": { + "type": "string" + }, + "message": { + "type": "string" + }, + "error_code": { + "type": ["string", "null"] + }, + "event_id": { + "type": ["integer", "null"] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + } + } + }, + "ReconcileRunSummary": { + "type": "object", + "additionalProperties": false, + "required": [ + "run_id", + "scope", + "started_at", + "finished_at", + "outcome", + "ok", + "counts", + "total", + "results" + ], + "properties": { + "run_id": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": ["bot", "fleet"] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "finished_at": { + "type": "string", + "format": "date-time" + }, + "outcome": { + "type": "string", + "enum": ["succeeded", "completed_with_errors"] + }, + "ok": { + "type": "boolean" + }, + "counts": { + "type": "object", + "additionalProperties": false, + "required": [ + "healthy", + "changed", + "pending", + "action_required", + "error", + "skipped" + ], + "properties": { + "healthy": {"type": "integer", "minimum": 0}, + "changed": {"type": "integer", "minimum": 0}, + "pending": {"type": "integer", "minimum": 0}, + "action_required": {"type": "integer", "minimum": 0}, + "error": {"type": "integer", "minimum": 0}, + "skipped": {"type": "integer", "minimum": 0} + } + }, + "total": { + "type": "integer", + "minimum": 0 + }, + "results": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ReconcileResult" + } + } + } + }, + "BotInspectResponse": { + "type": "object", + "required": [ + "bot", + "lifecycle", + "profile_files", + "pid_marker", + "live_cmdline_verified", + "ownership", + "recent_logs" + ], + "properties": { + "bot": { + "$ref": "#/components/schemas/Bot" + }, + "lifecycle": { + "type": "object", + "required": [ + "started_at", + "ready_at", + "stopped_at", + "last_exit_code", + "last_error", + "last_transition_reason" + ], + "properties": { + "started_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "ready_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "stopped_at": { + "type": ["string", "null"], + "format": "date-time" + }, + "last_exit_code": { + "type": ["integer", "null"] + }, + "last_error": { + "type": ["string", "null"] + }, + "last_transition_reason": { + "type": ["string", "null"] + } + } + }, + "profile_files": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "pid_marker": { + "type": "object", + "additionalProperties": true + }, + "live_cmdline_verified": { + "type": "boolean" + }, + "ownership": { + "type": "object", + "required": ["verified", "reason", "classification", "expected"], + "properties": { + "verified": { + "type": "boolean" + }, + "reason": { + "type": "string" + }, + "classification": { + "type": ["string", "null"] + }, + "expected": { + "type": "object", + "required": ["bot_id", "component", "action"], + "properties": { + "bot_id": { + "type": "string" + }, + "component": { + "type": "string" + }, + "action": { + "type": "string" + } + } + } + } + }, + "recent_logs": { + "type": "string" + } + } + }, + "LifecycleEvent": { + "type": "object", + "required": [ + "event_id", + "bot_id", + "operation_id", + "request_id", + "occurred_at", + "source", + "action", + "outcome", + "status_before", + "status_after", + "pid_before", + "pid_after", + "reason", + "error_code", + "error_message", + "details" + ], + "properties": { + "event_id": { + "type": "integer", + "minimum": 1 + }, + "bot_id": { + "type": "string" + }, + "operation_id": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "request_id": { + "type": ["string", "null"] + }, + "occurred_at": { + "type": "string", + "format": "date-time" + }, + "source": { + "type": "string" + }, + "action": { + "type": "string" + }, + "outcome": { + "type": "string" + }, + "status_before": { + "type": ["string", "null"] + }, + "status_after": { + "type": ["string", "null"] + }, + "pid_before": { + "type": ["integer", "null"] + }, + "pid_after": { + "type": ["integer", "null"] + }, + "reason": { + "type": "string" + }, + "error_code": { + "type": ["string", "null"] + }, + "error_message": { + "type": ["string", "null"] + }, + "details": { + "type": "object", + "additionalProperties": true + } + } + }, + "LifecycleHistory": { + "type": "object", + "required": ["bot_id", "events", "next_before"], + "properties": { + "bot_id": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LifecycleEvent" + } + }, + "next_before": { + "type": ["integer", "null"], + "minimum": 1 + } + } + }, + "Template": { + "type": "object", + "required": ["id", "name", "description", "version", "metadata", "delegation"], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "version": { + "type": "string" + }, + "metadata": { + "type": "object" + }, + "delegation": { + "type": "object" + } + } + }, + "DoctorReport": { + "type": "object", + "required": ["ok", "checks"], + "properties": { + "ok": { + "type": "boolean" + }, + "checks": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + }, + "security": [ + { + "ZeusApiKey": [] + } + ], + "paths": { + "/health": { + "get": { + "security": [], + "responses": { + "200": { + "description": "Health check", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "const": "ok" + } + } + } + } + } + } + } + } + }, + "/ready": { + "get": { + "summary": "Check API state-store readiness", + "description": "Authenticates the request, then opens the Zeus database read-only, requires the current schema version, and executes SELECT 1. The /v1/ready alias is provided by the versioned server URL.", + "security": [ + { + "ZeusApiKey": [] + } + ], + "responses": { + "200": { + "description": "The state store is readable at the current schema version", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadinessResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"} + }, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "401": { + "description": "Missing API key, invalid API key, or API key not configured", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"} + }, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "429": { + "description": "Authentication rate limit exceeded", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Retry-After": { + "description": "Seconds to wait before retrying authentication.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + }, + "503": { + "description": "The API key is unconfigured or the state store is not ready", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"} + }, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/Error"} + } + } + } + } + } + }, + "/doctor": { + "get": { + "responses": { + "200": { + "description": "Readiness report", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DoctorReport" + } + } + } + }, + "401": { + "description": "Invalid API key", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/templates": { + "get": { + "responses": { + "200": { + "description": "Template list", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Template" + } + } + } + } + } + } + } + }, + "/bots": { + "get": { + "responses": { + "200": { + "description": "Bot list", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Bot" + } + } + } + } + } + } + }, + "post": { + "parameters": [ + { + "name": "replace", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "stop", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Created bot record", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Bot" + } + } + } + }, + "400": { + "description": "Invalid request", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Bot/domain conflict or idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/bots/reconcile": { + "post": { + "parameters": [ + { + "name": "summary", + "in": "query", + "required": false, + "description": "Return the persisted reconciliation run summary instead of the legacy status array.", + "schema": { + "type": "string", + "const": "1" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "responses": { + "200": { + "description": "All-bot reconcile results", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/BotStatusResponse" + } + }, + { + "$ref": "#/components/schemas/ReconcileRunSummary" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request or idempotency key", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "409": { + "description": "Reconciliation lock conflict, idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "422": { + "description": "Keyed fleet response exceeds the replay budget before claim", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/bots/{bot_id}/status": { + "get": { + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Bot status", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotStatusResponse" + } + } + } + } + } + } + }, + "/bots/{bot_id}/logs": { + "get": { + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Bot logs", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["bot_id", "logs"], + "properties": { + "bot_id": { + "type": "string" + }, + "logs": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/bots/{bot_id}/history": { + "get": { + "summary": "List authoritative bot lifecycle history", + "security": [ + { + "ZeusApiKey": [] + } + ], + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 50 + } + }, + { + "name": "before", + "in": "query", + "required": false, + "description": "Exclusive lifecycle event ID cursor.", + "schema": { + "type": "integer", + "minimum": 1 + } + } + ], + "responses": { + "200": { + "description": "Lifecycle history, newest first", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LifecycleHistory" + } + } + } + }, + "400": { + "description": "Invalid bot ID or query parameter", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Missing or invalid API key", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Unknown bot", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "API key is not configured", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/bots/{bot_id}/inspect": { + "get": { + "summary": "Inspect bot profile and runtime diagnostics", + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Bot inspection payload", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotInspectResponse" + } + } + } + }, + "401": { + "description": "Invalid API key", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Unknown bot", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/bots/{bot_id}/start": { + "post": { + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "wait", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "timeout", + "in": "query", + "required": false, + "schema": { + "type": "number", + "minimum": 0.1, + "maximum": 300 + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "responses": { + "200": { + "description": "Start result", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid request or idempotency key", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "404": { + "description": "Unknown bot", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "409": { + "description": "Idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/bots/{bot_id}/stop": { + "post": { + "description": "Stops a bot only with an exact, single-link schema-v3 marker. Schema-v2 or legacy markers fail closed before signaling and remain untouched with the recorded PID and pending stop intent.", + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "kill_after_timeout", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "responses": { + "200": { + "description": "Stop result", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid request or idempotency key", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "404": { + "description": "Unknown bot", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "409": { + "description": "Idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/bots/{bot_id}/restart": { + "post": { + "description": "Restarts a bot only when the prior process has an exact, single-link schema-v3 marker. Schema-v2 or legacy markers fail closed before signaling, preserve the marker, recorded PID, and pending intent, and require manual process resolution.", + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "wait", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false + } + }, + { + "name": "timeout", + "in": "query", + "required": false, + "schema": { + "type": "number", + "minimum": 0.1, + "maximum": 300 + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "responses": { + "200": { + "description": "Restart result", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BotStatusResponse" + } + } + } + }, + "400": { + "description": "Invalid request or idempotency key", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "404": { + "description": "Unknown bot", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "409": { + "description": "Idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/bots/{bot_id}/reconcile": { + "post": { + "description": "Recovers pending lifecycle intents and reconciles one bot. Pending restarts with schema-v2 or legacy markers fail closed without signaling, launching, or changing the recorded PID projection.", + "parameters": [ + { + "name": "bot_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "summary", + "in": "query", + "required": false, + "description": "Return the persisted reconciliation run summary instead of the legacy status array.", + "schema": { + "type": "string", + "const": "1" + } + }, + { + "name": "Idempotency-Key", + "in": "header", + "required": false, + "description": "Opt-in durable replay key. The guarantee is limited to the configured retention window.", + "schema": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$" + } + } + ], + "responses": { + "200": { + "description": "Single-bot reconcile result", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/BotStatusResponse" + } + }, + { + "$ref": "#/components/schemas/ReconcileRunSummary" + } + ] + } + } + } + }, + "400": { + "description": "Invalid request or idempotency key", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "404": { + "description": "Unknown bot", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "409": { + "description": "Reconciliation lock conflict, idempotency conflict, in-progress claim, or indeterminate execution", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"}, + "Retry-After": {"$ref": "#/components/headers/RetryAfter"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "500": { + "description": "Internal mutation error", + "headers": { + "X-Request-ID": {"$ref": "#/components/headers/XRequestID"}, + "Idempotency-Replayed": {"$ref": "#/components/headers/IdempotencyReplayed"} + }, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "503": { + "description": "Idempotency storage or capacity unavailable", + "headers": {"X-Request-ID": {"$ref": "#/components/headers/XRequestID"}}, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + } + } +} diff --git a/olymp/cli.py b/olymp/cli.py index 60c1e87..9fa46c6 100644 --- a/olymp/cli.py +++ b/olymp/cli.py @@ -82,6 +82,10 @@ def build_parser() -> argparse.ArgumentParser: zeus_health.add_argument("node_id") zeus_health.set_defaults(handler=_zeus_health) + zeus_ready = zeus_subcommands.add_parser("ready") + zeus_ready.add_argument("node_id") + zeus_ready.set_defaults(handler=_zeus_ready) + zeus_doctor = zeus_subcommands.add_parser("doctor") zeus_doctor.add_argument("node_id") zeus_doctor.set_defaults(handler=_zeus_doctor) @@ -262,6 +266,13 @@ def _zeus_health(args: argparse.Namespace) -> dict[str, Any]: return result +def _zeus_ready(args: argparse.Namespace) -> dict[str, Any]: + node = _require_node(args.node_id) + result = _client_for_node(node).ready() + _audit("zeus.ready", node.node_id, "ok", {"node": node.to_dict()}) + return result + + def _zeus_doctor(args: argparse.Namespace) -> dict[str, Any]: node = _require_node(args.node_id) result = _client_for_node(node).doctor() diff --git a/olymp/execution.py b/olymp/execution.py index 40e011d..426b3a9 100644 --- a/olymp/execution.py +++ b/olymp/execution.py @@ -43,7 +43,7 @@ class ZeusClientProtocol(Protocol): - def health(self) -> dict[str, Any]: ... + def ready(self) -> dict[str, Any]: ... def list_bots(self) -> list[dict[str, Any]]: ... @@ -226,29 +226,23 @@ def fail( raise PlanExecutionError(f"plan node is stale; run_id={run['run_id']}", run=run) try: - preflight_client = client_factory(node, False) + client = client_factory(node, True) + except ZeusClientError as exc: + run = fail(exc.code or "zeus_error", str(exc)) + raise PlanExecutionError(f"plan execution failed; run_id={run['run_id']}", run=run) from exc except (ValueError, OSError) as exc: run = fail("invalid_node_config", str(exc)) raise PlanExecutionError(f"plan execution failed; run_id={run['run_id']}", run=run) from exc try: - preflight = preflight_client.health() + preflight = client.ready() except ZeusClientError as exc: - run = fail("preflight_failed", str(exc)) + run = fail(_preflight_error_code(exc), str(exc)) raise PlanExecutionError(f"plan preflight failed; run_id={run['run_id']}", run=run) from exc except OSError as exc: run = fail("preflight_failed", str(exc)) raise PlanExecutionError(f"plan preflight failed; run_id={run['run_id']}", run=run) from exc - try: - client = client_factory(node, True) - except ZeusClientError as exc: - run = fail(exc.code or "zeus_error", str(exc)) - raise PlanExecutionError(f"plan execution failed; run_id={run['run_id']}", run=run) from exc - except (ValueError, OSError) as exc: - run = fail("invalid_node_config", str(exc)) - raise PlanExecutionError(f"plan execution failed; run_id={run['run_id']}", run=run) from exc - try: result = _execute_action( store=store, @@ -509,6 +503,12 @@ def _execution_error_code( return value_error_code +def _preflight_error_code(exc: ZeusClientError) -> str: + if exc.code == "not_ready": + return "not_ready" + return "preflight_failed" + + def _mutation_metadata(attempt: dict[str, Any]) -> dict[str, Any]: return { key: attempt.get(key) @@ -663,9 +663,9 @@ def _execute_rollout_plan( failed_node_id=node_id, ) try: - preflight_client = client_factory(node, False) - except (ValueError, OSError) as exc: - code, message = "invalid_node_config", str(exc) + client = client_factory(node, True) + except ZeusClientError as exc: + code, message = exc.code or "zeus_error", str(exc) node_results.append( _rollout_failed_entry(node_id, code, message, attempt_number, prior_attempts) ) @@ -684,10 +684,8 @@ def _execute_rollout_plan( code, message, ) - try: - preflight = preflight_client.health() - except (ZeusClientError, OSError) as exc: - code, message = "preflight_failed", str(exc) + except (ValueError, OSError) as exc: + code, message = "invalid_node_config", str(exc) node_results.append( _rollout_failed_entry(node_id, code, message, attempt_number, prior_attempts) ) @@ -707,9 +705,9 @@ def _execute_rollout_plan( message, ) try: - client = client_factory(node, True) + preflight = client.ready() except ZeusClientError as exc: - code, message = exc.code or "zeus_error", str(exc) + code, message = _preflight_error_code(exc), str(exc) node_results.append( _rollout_failed_entry(node_id, code, message, attempt_number, prior_attempts) ) @@ -728,8 +726,8 @@ def _execute_rollout_plan( code, message, ) - except (ValueError, OSError) as exc: - code, message = "invalid_node_config", str(exc) + except OSError as exc: + code, message = "preflight_failed", str(exc) node_results.append( _rollout_failed_entry(node_id, code, message, attempt_number, prior_attempts) ) diff --git a/olymp/plans.py b/olymp/plans.py index 34b41c6..32439d5 100644 --- a/olymp/plans.py +++ b/olymp/plans.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re from dataclasses import dataclass, field from datetime import UTC, datetime from typing import Any @@ -14,7 +13,7 @@ from olymp.store import node_fingerprint LIFECYCLE_ACTIONS = {"start", "stop", "restart", "reconcile"} -SUPPORTED_ZEUS_MUTATION_VERSION = re.compile(r"^0\.3\.(?:0|[1-9][0-9]*)$") +SUPPORTED_ZEUS_MUTATION_VERSION = "0.5.0.dev0" # Zeus readiness waits are deliberately bounded so approved lifecycle plans have # one predictable execution and transport budget. LIFECYCLE_WAIT_TIMEOUT_SECONDS = 30 @@ -313,7 +312,8 @@ def validate_path_segment(value: str, *, label: str) -> None: def require_supported_zeus_mutation_version(node: ZeusNode) -> None: version = node.expected_version - if not isinstance(version, str) or SUPPORTED_ZEUS_MUTATION_VERSION.fullmatch(version) is None: + if version != SUPPORTED_ZEUS_MUTATION_VERSION: raise ValueError( - f"node {node.node_id} expected_version must declare supported Zeus version 0.3.N" + f"node {node.node_id} expected_version must declare supported Zeus version " + f"{SUPPORTED_ZEUS_MUTATION_VERSION}" ) diff --git a/olymp/zeus_client.py b/olymp/zeus_client.py index 5d21543..ab8147a 100644 --- a/olymp/zeus_client.py +++ b/olymp/zeus_client.py @@ -21,6 +21,7 @@ # Allow a bounded server-side readiness wait to finish and its response bytes to # arrive without turning the configured transport timeout into an unbounded one. WAIT_TRANSPORT_GRACE_SECONDS = 5.0 +ZEUS_READY_SCHEMA_VERSION = 6 LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") REQUEST_ID_PATTERN = re.compile(r"^[0-9a-f]{32}$") @@ -158,6 +159,25 @@ def health(self) -> dict[str, Any]: response = self._request("GET", "/health", authenticated=False) return _dict_response(response, "/health").body + def ready(self) -> dict[str, Any]: + response = self._request("GET", "/ready") + readiness = _dict_response(response, "/ready") + body = readiness.body + if body.get("status") != "ready": + raise _response_shape_error( + readiness, + "Zeus /ready response status must be ready", + ) + if ( + type(body.get("schema_version")) is not int + or body["schema_version"] != ZEUS_READY_SCHEMA_VERSION + ): + raise _response_shape_error( + readiness, + f"Zeus /ready response schema_version must be {ZEUS_READY_SCHEMA_VERSION}", + ) + return body + def doctor(self) -> dict[str, Any]: response = self._request("GET", "/doctor") return _dict_response(response, "/doctor").body diff --git a/scripts/repo_check.sh b/scripts/repo_check.sh index 156e71e..e1468b9 100755 --- a/scripts/repo_check.sh +++ b/scripts/repo_check.sh @@ -8,7 +8,8 @@ sh -n scripts/*.sh for json_file in \ docs/openapi.json \ docs/zeus-openapi-v0.1.4.json \ - docs/zeus-openapi-v0.3.0.json; do + docs/zeus-openapi-v0.3.0.json \ + docs/zeus-openapi-v0.5.0.dev0.json; do "$PYTHON" -m json.tool "$json_file" >/dev/null done "$PYTHON" -m compileall -q olymp tests diff --git a/scripts/verify_real_zeus.sh b/scripts/verify_real_zeus.sh index 6033f8f..3effba9 100755 --- a/scripts/verify_real_zeus.sh +++ b/scripts/verify_real_zeus.sh @@ -42,8 +42,8 @@ esac state_created=0 started_zeus=0 -install_dir="${OLYMP_REAL_ZEUS_DIR:-.tmp/zeus-v0.3.0}" -zeus_release_commit="c36d021fa25639093e7ca7e5547dd4d7146deaf1" +install_dir="${OLYMP_REAL_ZEUS_DIR:-.tmp/zeus-v0.5.0.dev0}" +zeus_contract_commit="f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a" zeus_host="${ZEUS_HOST:-127.0.0.1}" zeus_port="${ZEUS_PORT:-4311}" zeus_url="${ZEUS_BASE_URL:-http://$zeus_host:$zeus_port}" @@ -90,12 +90,12 @@ validate_zeus_checkout() { exit 1 fi actual_zeus_commit="$(git -C "$install_dir" rev-parse HEAD 2>/dev/null || true)" - if [ "$actual_zeus_commit" != "$zeus_release_commit" ]; then - echo "Zeus checkout does not match required release commit $zeus_release_commit" >&2 + if [ "$actual_zeus_commit" != "$zeus_contract_commit" ]; then + echo "Zeus checkout does not match required contract commit $zeus_contract_commit" >&2 exit 1 fi if [ -n "$(git -C "$install_dir" status --porcelain)" ]; then - echo "Zeus checkout must be clean at required release commit $zeus_release_commit" >&2 + echo "Zeus checkout must be clean at required contract commit $zeus_contract_commit" >&2 exit 1 fi } @@ -108,11 +108,11 @@ if [ "$checkout" = "1" ]; then fi git init "$install_dir" git -C "$install_dir" remote add origin https://github.com/brainx/zeus - git -C "$install_dir" fetch --depth 1 origin "$zeus_release_commit" - git -C "$install_dir" checkout --detach "$zeus_release_commit" + git -C "$install_dir" fetch --depth 1 origin "$zeus_contract_commit" + git -C "$install_dir" checkout --detach "$zeus_contract_commit" fi validate_zeus_checkout - echo "Zeus v0.3.0 checkout is available at $install_dir" + echo "Zeus 0.5.0.dev0 contract checkout is available at $install_dir" fi if [ "$start_zeus" = "1" ] && [ "$checkout" = "0" ]; then @@ -162,9 +162,10 @@ fi --name "Real Zeus Smoke" \ --base-url "$zeus_url" \ --api-key-env ZEUS_API_KEY \ - --expected-version 0.3.0 >/dev/null + --expected-version 0.5.0.dev0 >/dev/null "$PYTHON" -m olymp.cli node check "$node_id" >/dev/null +"$PYTHON" -m olymp.cli zeus ready "$node_id" >/dev/null "$PYTHON" -m olymp.cli zeus inventory "$node_id" >/dev/null if [ -n "$bot_id" ]; then diff --git a/tests/test_api_security.py b/tests/test_api_security.py index 6fe62aa..1cce8d2 100644 --- a/tests/test_api_security.py +++ b/tests/test_api_security.py @@ -78,7 +78,7 @@ def test_cli_redacts_expected_exception_message(self) -> None: node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) stdout = StringIO() @@ -114,7 +114,7 @@ def test_lifecycle_rejects_non_string_text_without_saving_plan(self) -> None: node_id="123", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) store = PlanRunStore(path) @@ -157,7 +157,7 @@ def test_reconcile_rejects_non_string_text_without_saving_plan(self) -> None: node_id="123", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) store = PlanRunStore(path) @@ -210,7 +210,7 @@ def bot_status(self, bot_id: str) -> dict[str, str]: node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( diff --git a/tests/test_bot_creation.py b/tests/test_bot_creation.py index 6f4c92c..a62cba0 100644 --- a/tests/test_bot_creation.py +++ b/tests/test_bot_creation.py @@ -31,7 +31,7 @@ def test_single_plan_rechecks_current_version_before_client_construction(self) - with tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0")) + node = registry.add(_node(expected_version="0.5.0.dev0")) plan = lifecycle_plan( node, bot_id="coder", @@ -56,7 +56,7 @@ def test_single_plan_rechecks_current_version_before_client_construction(self) - self.assertIsNotNone(run) assert run is not None self.assertEqual(run["error"]["code"], "incompatible_zeus_version") - self.assertIn("0.3.N", run["error"]["message"]) + self.assertIn("0.5.0.dev0", run["error"]["message"]) self.assertEqual(client_requests, []) self.assertEqual(run["attempts"], []) @@ -64,7 +64,7 @@ def test_rollout_rechecks_each_current_version_before_zeus_io(self) -> None: with tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0", tags=("staging",))) + node = registry.add(_node(expected_version="0.5.0.dev0", tags=("staging",))) plan = rollout_lifecycle_plan( (node,), selector_tags=("staging",), @@ -106,7 +106,7 @@ def test_rollout_validates_all_current_versions_before_any_client_construction( node_id=f"staging-{suffix}", display_name=f"Staging {suffix.upper()}", base_url=f"http://127.0.0.1:{port}", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ) ) @@ -137,9 +137,9 @@ class RolloutClient: def __init__(self, node_id: str) -> None: self.node_id = node_id - def health(self) -> dict[str, str]: - zeus_io.append(f"health:{self.node_id}") - return {"status": "ok"} + def ready(self) -> dict[str, object]: + zeus_io.append(f"ready:{self.node_id}") + return {"schema_version": 6, "status": "ready"} def start_bot_response( self, @@ -208,7 +208,7 @@ def test_create_actions_execute_once_with_exact_body_flags_and_durable_metadata( with self.subTest(action=action), tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0")) + node = registry.add(_node(expected_version="0.5.0.dev0")) plan = create_bot_plan( node, bot_id="coder", @@ -248,7 +248,7 @@ def test_create_actions_execute_once_with_exact_body_flags_and_durable_metadata( self.assertEqual( client.calls, [ - "health", + "ready", ( "create", { @@ -269,6 +269,96 @@ def test_create_actions_execute_once_with_exact_body_flags_and_durable_metadata( attempt["attempt_id"], ) + def test_kimi_k3_template_creation_keeps_provider_credentials_in_zeus(self) -> None: + with tempfile.TemporaryDirectory() as temp: + db_path = Path(temp) / "olymp.db" + registry = NodeRegistry(db_path) + node = registry.add(_node(expected_version="0.5.0.dev0")) + plan = create_bot_plan( + node, + bot_id="kimi-coder", + template_id="kimi-k3-coding-bot", + reason="provision Kimi coding bot", + ).to_dict() + store = PlanRunStore(db_path) + store.save_plan(plan) + client = _CreationClient() + client.create_record = { + **client.create_record, + "bot_id": "kimi-coder", + "template_id": "kimi-k3-coding-bot", + "display_name": "kimi-coder", + } + client.bot_projection = [dict(client.create_record)] + + run = execute_plan( + store=store, + registry=registry, + plan_id=str(plan["plan_id"]), + approve=str(plan["plan_id"]), + client_factory=lambda _node, _require_key: client, + ) + + self.assertEqual(run["status"], "succeeded") + create_call = client.calls[1] + self.assertEqual( + create_call[1], + { + "bot_id": "kimi-coder", + "template_id": "kimi-k3-coding-bot", + }, + ) + self.assertTrue( + { + "KIMI_API_KEY", + "KIMI_BASE_URL", + "provider", + "model", + "harness", + }.isdisjoint(create_call[1]) + ) + + def test_not_ready_preflight_blocks_mutation_without_attempt(self) -> None: + with tempfile.TemporaryDirectory() as temp: + db_path = Path(temp) / "olymp.db" + registry = NodeRegistry(db_path) + node = registry.add(_node(expected_version="0.5.0.dev0")) + plan = create_bot_plan( + node, + bot_id="coder", + template_id="coding-bot", + reason="provision coder", + ).to_dict() + store = PlanRunStore(db_path) + store.save_plan(plan) + + class NotReadyClient(_CreationClient): + def ready(self) -> dict[str, object]: + self.calls.append("ready") + raise ZeusClientError( + "state store is not ready", + status=503, + code="not_ready", + request_id="f" * 32, + ) + + client = NotReadyClient() + with self.assertRaises(PlanExecutionError) as raised: + execute_plan( + store=store, + registry=registry, + plan_id=str(plan["plan_id"]), + approve=str(plan["plan_id"]), + client_factory=lambda _node, _require_key: client, + ) + + run = raised.exception.run + self.assertIsNotNone(run) + assert run is not None + self.assertEqual(run["error"]["code"], "not_ready") + self.assertEqual(run["attempts"], []) + self.assertEqual(client.calls, ["ready"]) + def test_stored_create_plan_rejects_action_path_body_mismatches_before_client_io( self, ) -> None: @@ -294,7 +384,7 @@ def set_body(plan: dict[str, Any], body: dict[str, Any]) -> None: with self.subTest(case=name), tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0")) + node = registry.add(_node(expected_version="0.5.0.dev0")) plan = create_bot_plan( node, bot_id="coder", @@ -355,7 +445,7 @@ def test_create_postcheck_fails_closed_and_preserves_mutation_evidence(self) -> with self.subTest(case=name), tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0")) + node = registry.add(_node(expected_version="0.5.0.dev0")) plan = create_bot_plan( node, bot_id="coder", @@ -393,7 +483,7 @@ def test_indeterminate_create_is_recorded_once_and_never_automatically_repeated( with tempfile.TemporaryDirectory() as temp: db_path = Path(temp) / "olymp.db" registry = NodeRegistry(db_path) - node = registry.add(_node(expected_version="0.3.0")) + node = registry.add(_node(expected_version="0.5.0.dev0")) plan = create_bot_plan( node, bot_id="coder", @@ -726,9 +816,9 @@ def __init__(self) -> None: } self.bot_projection = [dict(self.create_record)] - def health(self) -> dict[str, str]: - self.calls.append("health") - return {"status": "ok"} + def ready(self) -> dict[str, object]: + self.calls.append("ready") + return {"schema_version": 6, "status": "ready"} def create_bot_response( self, @@ -762,7 +852,7 @@ def _add_cli_node() -> int: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ) diff --git a/tests/test_cli_api.py b/tests/test_cli_api.py index 1d0ada7..239c3eb 100644 --- a/tests/test_cli_api.py +++ b/tests/test_cli_api.py @@ -118,7 +118,7 @@ def test_cli_node_lifecycle_uses_workspace_state(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -131,6 +131,33 @@ def test_cli_unknown_node_returns_error(self) -> None: with tempfile.TemporaryDirectory() as temp, patched_env(OLYMP_STATE_DIR=temp): self.assertEqual(_run_cli(["zeus", "health", "missing"]), 1) + def test_cli_zeus_ready_uses_authenticated_contract(self) -> None: + with ( + tempfile.TemporaryDirectory() as temp, + patched_env(OLYMP_STATE_DIR=temp, ZEUS_API_KEY="secret"), + patch_cli_client({"status": "ok"}), + ): + self.assertEqual( + _run_cli( + [ + "node", + "add", + "local", + "--name", + "Local Zeus", + "--base-url", + "http://127.0.0.1:4311", + "--expected-version", + "0.5.0.dev0", + ] + ), + 0, + ) + code, output, error = _run_cli_with_output(["zeus", "ready", "local"]) + + self.assertEqual(code, 0, error) + self.assertEqual(json.loads(output), {"schema_version": 6, "status": "ready"}) + def test_cli_redacts_output(self) -> None: with tempfile.TemporaryDirectory() as temp, patched_env(OLYMP_STATE_DIR=temp): with patch_cli_client({"token": "super-secret-value"}): @@ -145,7 +172,7 @@ def test_cli_redacts_output(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -176,7 +203,7 @@ def test_cli_zeus_history_defaults_and_audits_only_safe_metadata(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -230,7 +257,7 @@ def test_cli_zeus_history_forwards_pagination(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -281,7 +308,7 @@ def test_cli_zeus_history_rejects_control_character_id_before_upstream_io(self) "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -518,7 +545,7 @@ def test_api_node_read_endpoints(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -568,7 +595,7 @@ def test_api_bot_history_is_authenticated_decodes_ids_and_paginates_once(self) - "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -637,7 +664,7 @@ def test_api_bot_history_defaults_without_extra_zeus_requests(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -690,7 +717,7 @@ def test_api_bot_history_rejects_invalid_query_before_zeus_request(self) -> None "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -732,7 +759,7 @@ def test_api_bot_history_rejects_invalid_encoded_ids_before_zeus_request(self) - "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -796,7 +823,7 @@ def test_api_bot_history_maps_unknown_node_and_zeus_errors(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -835,7 +862,7 @@ def test_api_bot_history_maps_unknown_node_and_zeus_errors(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -880,7 +907,7 @@ def test_api_bot_history_maps_invalid_upstream_envelope_without_success_audit(se "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -923,7 +950,7 @@ def test_cli_zeus_history_returns_error_when_upstream_request_id_is_missing(self "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -957,7 +984,7 @@ def test_api_plan_run_and_evidence_endpoints(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -1076,7 +1103,7 @@ def test_api_rollout_lifecycle_plan_endpoint(self) -> None: "--base-url", f"http://127.0.0.1:{port_number}", "--expected-version", - "0.3.0", + "0.5.0.dev0", "--tag", "staging", ] @@ -1167,6 +1194,9 @@ def __init__( def health(self) -> dict[str, str]: return {"token": "super-secret-value"} + def ready(self) -> dict[str, object]: + return {"schema_version": 6, "status": "ready"} + @contextmanager def patch_cli_client( diff --git a/tests/test_events.py b/tests/test_events.py index e511c6a..01d9701 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -57,7 +57,7 @@ def test_plan_run_and_mutation_methods_emit_timeline_events(self) -> None: node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( diff --git a/tests/test_plans_evidence.py b/tests/test_plans_evidence.py index c4c0797..801f241 100644 --- a/tests/test_plans_evidence.py +++ b/tests/test_plans_evidence.py @@ -36,7 +36,7 @@ def test_legacy_rollout_step_records_actual_wait_request_path(self) -> None: node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ) ) @@ -53,7 +53,7 @@ def test_legacy_rollout_step_records_actual_wait_request_path(self) -> None: store.save_plan(plan) class Client: - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: return {"status": "ok"} def restart_bot_response( @@ -151,7 +151,7 @@ def test_execution_persists_running_run_and_started_attempt_before_mutation(self node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( @@ -167,7 +167,7 @@ def test_execution_persists_running_run_and_started_attempt_before_mutation(self test_case = self class DurableClient: - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: return {"status": "ok"} def restart_bot_response( @@ -234,7 +234,7 @@ def test_execution_marks_transport_without_http_status_indeterminate(self) -> No node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( @@ -248,7 +248,7 @@ def test_execution_marks_transport_without_http_status_indeterminate(self) -> No store.save_plan(plan) class TransportFailureClient: - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: return {"status": "ok"} def restart_bot_response( @@ -344,7 +344,7 @@ def test_cli_lifecycle_plan_is_dry_run_and_audited(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -401,7 +401,7 @@ def test_cli_plan_rejects_invalid_bot_id(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -493,7 +493,7 @@ def test_concurrent_plan_execution_claim_allows_exactly_one_mutation(self) -> No node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( @@ -505,18 +505,18 @@ def test_concurrent_plan_execution_claim_allows_exactly_one_mutation(self) -> No store = PlanRunStore(db_path) store.save_plan(plan) start = Barrier(3) - release_health = Event() - health_started = Event() + release_ready = Event() + ready_started = Event() state_lock = Lock() - state = {"health_calls": 0, "mutations": 0} + state = {"ready_calls": 0, "mutations": 0} class ConcurrentExecutionClient: - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: with state_lock: - state["health_calls"] += 1 - health_started.set() - if not release_health.wait(timeout=2): - raise RuntimeError("health release timed out") + state["ready_calls"] += 1 + ready_started.set() + if not release_ready.wait(timeout=2): + raise RuntimeError("ready release timed out") return {"status": "ok"} def start_bot_response( @@ -561,14 +561,14 @@ def execute_once() -> str: with ThreadPoolExecutor(max_workers=2) as executor: futures = [executor.submit(execute_once) for _ in range(2)] start.wait() - self.assertTrue(health_started.wait(timeout=1)) + self.assertTrue(ready_started.wait(timeout=1)) wait(futures, timeout=0.25, return_when=FIRST_COMPLETED) - release_health.set() + release_ready.set() outcomes = [future.result(timeout=2) for future in futures] self.assertEqual(outcomes.count("succeeded"), 1) self.assertTrue(any("already been claimed" in outcome for outcome in outcomes)) - self.assertEqual(state["health_calls"], 1) + self.assertEqual(state["ready_calls"], 1) self.assertEqual(state["mutations"], 1) self.assertEqual(len(store.list_runs()), 1) @@ -588,7 +588,7 @@ def test_plan_execute_restart_records_successful_run(self) -> None: run = json.loads(output) self.assertEqual(run["status"], "succeeded") self.assertEqual(run["request_path"], "/bots/coder/restart?wait=true&timeout=30") - self.assertEqual(fake.calls, ["health", "restart:coder:wait=30", "bots"]) + self.assertEqual(fake.calls, ["ready", "restart:coder:wait=30", "bots"]) code, output, error = _run_cli_with_output(["run", "show", run["run_id"]]) self.assertEqual(code, 0, error) @@ -619,7 +619,7 @@ def test_plan_execute_reconcile_node_records_successful_run(self) -> None: run = json.loads(output) self.assertEqual(run["status"], "succeeded") self.assertEqual(run["request_path"], "/bots/reconcile?summary=1") - self.assertEqual(fake.calls, ["health", "reconcile-all-summary"]) + self.assertEqual(fake.calls, ["ready", "reconcile-all-summary"]) self.assertEqual(run["result"]["reconcile_summary"]["run_id"], "fleet-run") def test_plan_rollout_lifecycle_executes_each_selected_node(self) -> None: @@ -640,7 +640,7 @@ def test_plan_rollout_lifecycle_executes_each_selected_node(self) -> None: "--base-url", f"http://127.0.0.1:{port}", "--expected-version", - "0.3.0", + "0.5.0.dev0", "--tag", "staging", ] @@ -684,10 +684,10 @@ def test_plan_rollout_lifecycle_executes_each_selected_node(self) -> None: self.assertEqual( fake.calls, [ - "health", + "ready", "restart:coder:wait=30", "bots", - "health", + "ready", "restart:coder:wait=30", "bots", ], @@ -697,7 +697,7 @@ def test_plan_rollout_preflight_error_records_failed_run(self) -> None: with ( tempfile.TemporaryDirectory() as temp, patched_env(OLYMP_STATE_DIR=temp, ZEUS_API_KEY="secret"), - patch_cli_client_for_execution(health_error="health unavailable") as fake, + patch_cli_client_for_execution(ready_error="ready unavailable") as fake, ): self.assertEqual( _run_cli( @@ -710,7 +710,7 @@ def test_plan_rollout_preflight_error_records_failed_run(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", "--tag", "staging", ] @@ -741,7 +741,7 @@ def test_plan_rollout_preflight_error_records_failed_run(self) -> None: self.assertEqual(code, 1) self.assertIn("run_id=", error) - self.assertEqual(fake.calls, ["health"]) + self.assertEqual(fake.calls, ["ready"]) code, output, list_error = _run_cli_with_output(["run", "list"]) self.assertEqual(code, 0, list_error) run_id = json.loads(output)["runs"][0]["run_id"] @@ -780,7 +780,7 @@ def test_plan_execute_restart_requires_running_postcheck(self) -> None: self.assertEqual(code, 1) self.assertIn("run_id=", error) - self.assertEqual(fake.calls, ["health", "restart:coder:wait=30", "bots"]) + self.assertEqual(fake.calls, ["ready", "restart:coder:wait=30", "bots"]) code, output, list_error = _run_cli_with_output(["run", "list"]) self.assertEqual(code, 0, list_error) run_id = json.loads(output)["runs"][0]["run_id"] @@ -825,7 +825,7 @@ def test_plan_execute_stop_requires_stopped_postcheck(self) -> None: self.assertEqual(code, 1) self.assertIn("run_id=", error) - self.assertEqual(fake.calls, ["health", "stop:coder", "bots"]) + self.assertEqual(fake.calls, ["ready", "stop:coder", "bots"]) code, output, list_error = _run_cli_with_output(["run", "list"]) self.assertEqual(code, 0, list_error) run_id = json.loads(output)["runs"][0]["run_id"] @@ -864,7 +864,7 @@ def test_plan_execute_preflight_error_records_failed_run(self) -> None: with ( tempfile.TemporaryDirectory() as temp, patched_env(OLYMP_STATE_DIR=temp, ZEUS_API_KEY="secret"), - patch_cli_client_for_execution(health_error="health unavailable") as fake, + patch_cli_client_for_execution(ready_error="ready unavailable") as fake, ): plan_id = _create_restart_plan() @@ -874,7 +874,7 @@ def test_plan_execute_preflight_error_records_failed_run(self) -> None: self.assertEqual(code, 1) self.assertIn("run_id=", error) - self.assertEqual(fake.calls, ["health"]) + self.assertEqual(fake.calls, ["ready"]) code, output, list_error = _run_cli_with_output(["run", "list"]) self.assertEqual(code, 0, list_error) run_id = json.loads(output)["runs"][0]["run_id"] @@ -934,7 +934,7 @@ def test_plan_execute_missing_key_records_failed_run(self) -> None: self.assertEqual(code, 1) self.assertIn("run_id=", error) - self.assertEqual(fake.calls, ["health"]) + self.assertEqual(fake.calls, []) code, output, list_error = _run_cli_with_output(["run", "list"]) self.assertEqual(code, 0, list_error) run = json.loads(output)["runs"][0] @@ -958,7 +958,7 @@ def test_plan_execute_rejects_stale_node_before_zeus_request(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -1008,7 +1008,7 @@ def test_plan_execute_create_bot_records_successful_run(self) -> None: self.assertEqual(run["result"]["mutation"]["bot_id"], "coder-next") self.assertEqual( fake.calls, - ["health", "create:coder-next:replace=False:stop=False", "bots"], + ["ready", "create:coder-next:replace=False:stop=False", "bots"], ) def test_evidence_bundle_redacts_audit_and_writes_private_file(self) -> None: @@ -1027,7 +1027,7 @@ def test_evidence_bundle_redacts_audit_and_writes_private_file(self) -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ), 0, @@ -1145,7 +1145,7 @@ def test_lifecycle_postcheck_requires_exact_converged_bot_projection(self) -> No node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) plan = lifecycle_plan( @@ -1166,7 +1166,7 @@ def __init__( self._test_case = test_case self._bot_projection = bot_projection - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: return {"status": "ok"} def start_bot_response( @@ -1204,7 +1204,7 @@ def test_reconcile_summary_is_authoritative_and_never_falls_back(self) -> None: node_id="local", display_name="Local Zeus", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) ) from olymp.plans import reconcile_node_plan @@ -1221,7 +1221,7 @@ def test_reconcile_summary_is_authoritative_and_never_falls_back(self) -> None: } class Client: - def health(self) -> dict[str, str]: + def ready(self) -> dict[str, str]: return {"status": "ok"} def reconcile_bots_summary_response( @@ -1353,7 +1353,7 @@ def _add_local_node() -> None: "--base-url", "http://127.0.0.1:4311", "--expected-version", - "0.3.0", + "0.5.0.dev0", ] ) if self_code != 0: @@ -1389,7 +1389,7 @@ class FakeExecutionClient: restart_error: str = "" post_status: str = "running" init_error: str = "" - health_error: str = "" + ready_error: str = "" def __init__( self, @@ -1403,10 +1403,10 @@ def __init__( self.api_key = api_key self.allow_remote = allow_remote - def health(self) -> dict[str, str]: - self.calls.append("health") - if self.health_error: - raise ZeusClientError(self.health_error, code="health_failed") + def ready(self) -> dict[str, str]: + self.calls.append("ready") + if self.ready_error: + raise ZeusClientError(self.ready_error, code="health_failed") return {"status": "ok"} def list_bots(self) -> list[dict[str, object]]: @@ -1535,7 +1535,7 @@ def patch_cli_client_for_execution( restart_error: str = "", post_status: str = "running", init_error: str = "", - health_error: str = "", + ready_error: str = "", ): import olymp.cli @@ -1545,7 +1545,7 @@ class FakeClient(FakeExecutionClient): FakeClient.restart_error = restart_error FakeClient.post_status = post_status FakeClient.init_error = init_error - FakeClient.health_error = health_error + FakeClient.ready_error = ready_error original = olymp.cli.ZeusClient olymp.cli.ZeusClient = FakeClient diff --git a/tests/test_policy.py b/tests/test_policy.py index 6d4ca32..ecabc89 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -35,7 +35,7 @@ def test_create_bot_plan_uses_distinct_action_path_and_exact_body_variants(self) node_id="staging", display_name="Staging", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) replacement_policy = Policy( allowed_actions=(*DEFAULT_ALLOWED_ACTIONS, "bot.replace", "bot.replace_stop") @@ -78,7 +78,7 @@ def test_replacement_plans_are_default_denied_and_require_explicit_policy_opt_in node_id="staging", display_name="Staging", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) for stop, action in ((False, "bot.replace"), (True, "bot.replace_stop")): with self.subTest(action=action), self.assertRaisesRegex(PolicyViolation, action): @@ -96,7 +96,7 @@ def test_create_bot_plan_rejects_unsafe_flag_and_display_name_values(self) -> No node_id="staging", display_name="Staging", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) invalid_cases = ( ({"stop": True}, "stop requires replace"), @@ -123,7 +123,7 @@ def test_all_create_variants_require_reason_even_when_policy_disables_it(self) - node_id="staging", display_name="Staging", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) policy = Policy( require_reason_for_mutation=False, @@ -155,7 +155,7 @@ def test_create_bot_plan_rejects_control_characters_in_identifiers(self) -> None node_id="staging", display_name="Staging", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) for field in ("bot_id", "template_id"): values = {"bot_id": "coder", "template_id": "coding-bot"} @@ -189,7 +189,15 @@ def test_mutation_plans_require_declared_zeus_03_patch_version(self) -> None: reason="maintenance", ), } - for version in (None, "", "v0.3.0", "0.2.9", "0.3", "0.3.-1", "0.4.0"): + for version in ( + None, + "", + "v0.5.0.dev0", + "0.3.0", + "0.3.42", + "0.5.0", + "0.5.0.dev1", + ): node = ZeusNode( node_id="staging", display_name="Staging", @@ -200,34 +208,33 @@ def test_mutation_plans_require_declared_zeus_03_patch_version(self) -> None: for name, build in builders.items(): with ( self.subTest(version=version, builder=name), - self.assertRaisesRegex(ValueError, r"expected_version.*0\.3\.N"), + self.assertRaisesRegex(ValueError, r"expected_version.*0\.5\.0\.dev0"), ): build(node) - def test_mutation_plans_accept_nonnegative_zeus_03_patch_versions(self) -> None: - for version in ("0.3.0", "0.3.42"): - node = ZeusNode( - node_id="staging", - display_name="Staging", - base_url="http://127.0.0.1:4311", - expected_version=version, - ) + def test_mutation_plans_accept_exact_zeus_050_dev0_version(self) -> None: + node = ZeusNode( + node_id="staging", + display_name="Staging", + base_url="http://127.0.0.1:4311", + expected_version="0.5.0.dev0", + ) - plan = lifecycle_plan( - node, - bot_id="coder", - action="start", - reason="maintenance", - ) + plan = lifecycle_plan( + node, + bot_id="coder", + action="start", + reason="maintenance", + ) - self.assertEqual(plan.node.expected_version, version) + self.assertEqual(plan.node.expected_version, "0.5.0.dev0") def test_lifecycle_plan_requires_reason(self) -> None: node = ZeusNode( node_id="local", display_name="Local", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) with self.assertRaisesRegex(PolicyViolation, "reason"): @@ -238,7 +245,7 @@ def test_restart_plan_requires_rollback_note(self) -> None: node_id="local", display_name="Local", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", ) with self.assertRaisesRegex(PolicyViolation, "rollback"): @@ -254,7 +261,7 @@ def test_policy_blocks_denied_tags(self) -> None: node_id="prod", display_name="Production", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("production",), ) @@ -267,14 +274,14 @@ def test_rollout_policy_limits_parallelism(self) -> None: node_id="staging-a", display_name="Staging A", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ), ZeusNode( node_id="staging-b", display_name="Staging B", base_url="http://127.0.0.1:4312", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ), ) @@ -296,14 +303,14 @@ def test_rollout_rejects_parallel_execution_when_policy_limit_is_higher(self) -> node_id="staging-a", display_name="Staging A", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ), ZeusNode( node_id="staging-b", display_name="Staging B", base_url="http://127.0.0.1:4312", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("staging",), ), ) @@ -337,7 +344,7 @@ def test_policy_file_overrides_deny_tags(self) -> None: node_id="prod", display_name="Production", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("production",), ) @@ -373,7 +380,7 @@ def test_break_glass_requires_incident_prefix(self) -> None: node_id="prod", display_name="Production", base_url="http://127.0.0.1:4311", - expected_version="0.3.0", + expected_version="0.5.0.dev0", tags=("production",), ) policy = load_policy(path) diff --git a/tests/test_repo_contracts.py b/tests/test_repo_contracts.py index 363a6ad..bb98425 100644 --- a/tests/test_repo_contracts.py +++ b/tests/test_repo_contracts.py @@ -34,6 +34,7 @@ def test_required_publication_files_exist(self) -> None: "docs/openapi.json", "docs/zeus-openapi-v0.1.4.json", "docs/zeus-openapi-v0.3.0.json", + "docs/zeus-openapi-v0.5.0.dev0.json", "docs/assets/olymp-social-preview-chain.png", "examples/benchmark/olymp.env.example", "examples/benchmark/zeus.env.example", @@ -70,7 +71,10 @@ def test_public_surfaces_name_upstream_zeus_repository(self) -> None: pyproject = Path("pyproject.toml").read_text(encoding="utf-8") self.assertIn(f'Zeus = "{upstream}"', pyproject) - for path in ["docs/openapi.json", "docs/zeus-openapi-v0.1.4.json"]: + for path in [ + "docs/openapi.json", + "docs/zeus-openapi-v0.1.4.json", + ]: openapi = json.loads(Path(path).read_text(encoding="utf-8")) self.assertEqual(openapi["externalDocs"]["url"], upstream, path) @@ -101,22 +105,22 @@ def test_legacy_zeus_fixture_remains_read_only_compatibility_surface(self) -> No self.assertIn(path, paths) self.assertTrue(methods.issubset(paths[path]), path) - def test_current_zeus_fixture_is_exact_upstream_v030_contract(self) -> None: - fixture = Path("docs/zeus-openapi-v0.3.0.json") + def test_current_zeus_fixture_is_exact_upstream_v050_dev0_contract(self) -> None: + fixture = Path("docs/zeus-openapi-v0.5.0.dev0.json") self.assertEqual( hashlib.sha256(fixture.read_bytes()).hexdigest(), - "c274e06094d6c417eda09f1e60aed02adf1bb408160fce45873575095075f5c9", + "9ed13a758f3463308556f48fc133281db2953b069c4d8aaec5fae60c041359b1", ) zeus_contract = json.loads(fixture.read_text(encoding="utf-8")) - self.assertEqual(zeus_contract["info"]["version"], "0.3.0") + self.assertEqual(zeus_contract["info"]["version"], "0.5.0.dev0") self.assertEqual( zeus_contract["components"]["securitySchemes"]["ZeusApiKey"]["name"], "x-zeus-api-key", ) - def test_current_zeus_fixture_covers_v030_mutation_contract(self) -> None: + def test_current_zeus_fixture_covers_v050_dev0_mutation_contract(self) -> None: zeus_contract = json.loads( - Path("docs/zeus-openapi-v0.3.0.json").read_text(encoding="utf-8") + Path("docs/zeus-openapi-v0.5.0.dev0.json").read_text(encoding="utf-8") ) paths = zeus_contract["paths"] schemas = zeus_contract["components"]["schemas"] @@ -172,6 +176,25 @@ def test_current_zeus_fixture_covers_v030_mutation_contract(self) -> None: }.issubset(reconcile_summary["required"]) ) + ready = paths["/ready"]["get"] + self.assertEqual( + ready["responses"]["200"]["content"]["application/json"]["schema"], + {"$ref": "#/components/schemas/ReadinessResponse"}, + ) + self.assertEqual( + schemas["ReadinessResponse"], + { + "type": "object", + "required": ["schema_version", "status"], + "properties": { + "schema_version": {"type": "integer", "const": 6}, + "status": {"type": "string", "const": "ready"}, + }, + }, + ) + error_codes = schemas["Error"]["properties"]["error"]["properties"]["code"]["enum"] + self.assertIn("not_ready", error_codes) + for path, path_item in paths.items(): for method, operation in path_item.items(): for status, response in operation["responses"].items(): @@ -187,8 +210,9 @@ def test_current_docs_declare_zeus_compatibility_targets(self) -> None: ]: with self.subTest(path=path): text = Path(path).read_text(encoding="utf-8") - self.assertIn("v0.3.0", text) + self.assertIn("0.5.0.dev0", text) self.assertIn("mutation compatibility target", text) + self.assertIn("v0.3.0", text) self.assertIn("v0.1.4", text) self.assertIn("legacy read-only", text) @@ -425,35 +449,35 @@ def test_mutation_docs_cover_version_gate_replacement_and_ambiguity_boundaries( ): with self.subTest(path=path): text = Path(path).read_text(encoding="utf-8").lower() - self.assertIn("0.3.n", text) + self.assertIn("0.5.0.dev0", text) self.assertIn("bot.replace", text) self.assertIn("exact", text) self.assertIn("indeterminate", text) self.assertIn("no archive/delete automation", text) - def test_real_zeus_verifier_and_staging_runbook_pin_safe_v030_smoke(self) -> None: + def test_real_zeus_verifier_and_staging_runbook_pin_safe_v050_dev0_smoke(self) -> None: script = Path("scripts/verify_real_zeus.sh").read_text(encoding="utf-8") - self.assertIn(".tmp/zeus-v0.3.0", script) + self.assertIn(".tmp/zeus-v0.5.0.dev0", script) self.assertIn( - 'zeus_release_commit="c36d021fa25639093e7ca7e5547dd4d7146deaf1"', + 'zeus_contract_commit="f4a7af8a0ce802b3f9daeed0ee44958ef92abf1a"', script, ) - self.assertIn('checkout --detach "$zeus_release_commit"', script) + self.assertIn('checkout --detach "$zeus_contract_commit"', script) self.assertIn("rev-parse HEAD", script) self.assertIn( - '"$actual_zeus_commit" != "$zeus_release_commit"', + '"$actual_zeus_commit" != "$zeus_contract_commit"', script, ) - self.assertIn("does not match required release commit", script) + self.assertIn("does not match required contract commit", script) self.assertIn("status --porcelain", script) self.assertIn("must be clean", script) - self.assertNotIn("--branch v0.3.0", script) - self.assertIn("Zeus v0.3.0 checkout", script) - self.assertIn("--expected-version 0.3.0", script) + self.assertIn("Zeus 0.5.0.dev0 contract checkout", script) + self.assertIn("--expected-version 0.5.0.dev0", script) + self.assertIn('olymp.cli zeus ready "$node_id"', script) self.assertNotIn("v0.1.4", script) runbook = Path("docs/STAGING_RUNBOOK.md").read_text(encoding="utf-8").lower() - self.assertIn("v0.3.0", runbook) + self.assertIn("0.5.0.dev0", runbook) self.assertIn("expected_version", runbook) self.assertIn("does not create or replace bots", runbook) diff --git a/tests/test_rollouts.py b/tests/test_rollouts.py index c2aa6a7..eb0f0d2 100644 --- a/tests/test_rollouts.py +++ b/tests/test_rollouts.py @@ -63,12 +63,12 @@ def test_rollout_resume_skips_successful_nodes_and_retries_failed_node(self) -> self.assertEqual( fake.calls, [ - "health:staging-a", + "ready:staging-a", "restart:staging-a:coder", "bots:staging-a", - "health:staging-b", + "ready:staging-b", "restart:staging-b:coder", - "health:staging-b", + "ready:staging-b", "restart:staging-b:coder", "bots:staging-b", ], @@ -213,7 +213,7 @@ def _add_staging_nodes() -> None: "--base-url", f"http://127.0.0.1:{port}", "--expected-version", - "0.3.0", + "0.5.0.dev0", "--tag", "staging", ] @@ -279,9 +279,9 @@ def __init__( self.allow_remote = allow_remote self.node_id = "staging-a" if base_url.endswith(":4311") else "staging-b" - def health(self) -> dict[str, str]: - self.calls.append(f"health:{self.node_id}") - return {"status": "ok"} + def ready(self) -> dict[str, object]: + self.calls.append(f"ready:{self.node_id}") + return {"schema_version": 6, "status": "ready"} def list_bots(self) -> list[dict[str, object]]: self.calls.append(f"bots:{self.node_id}") diff --git a/tests/test_zeus_client.py b/tests/test_zeus_client.py index fe24f52..72da334 100644 --- a/tests/test_zeus_client.py +++ b/tests/test_zeus_client.py @@ -93,6 +93,25 @@ def _handle(self) -> None: }, ) return + if FakeZeusHandler.mode == "not_ready" and self.path == "/ready": + self._send_json( + { + "error": { + "code": "not_ready", + "message": "state store is not ready", + "status": 503, + } + }, + status=503, + headers={"X-Request-ID": "f" * 32}, + ) + return + if FakeZeusHandler.mode == "malformed_ready_status" and self.path == "/ready": + self._send_json({"schema_version": 6, "status": "starting"}) + return + if FakeZeusHandler.mode == "malformed_ready_schema" and self.path == "/ready": + self._send_json({"schema_version": True, "status": "ready"}) + return if FakeZeusHandler.mode == "malformed_body": payload: Any = [] if self.path == "/health" else {} self._send_json( @@ -124,6 +143,7 @@ def _handle(self) -> None: return routes: dict[tuple[str, str], Any] = { ("GET", "/health"): {"status": "ok"}, + ("GET", "/ready"): {"schema_version": 6, "status": "ready"}, ("GET", "/doctor"): {"ok": True, "checks": []}, ("GET", "/templates"): [{"id": "coding-bot"}], ("GET", "/bots"): [{"bot_id": "coder"}], @@ -235,6 +255,7 @@ def test_contract_paths_and_auth_headers(self) -> None: client = ZeusClient(self.base_url, api_key="secret") self.assertEqual(client.health()["status"], "ok") + self.assertEqual(client.ready(), {"schema_version": 6, "status": "ready"}) self.assertTrue(client.doctor()["ok"]) self.assertEqual(client.list_templates()[0]["id"], "coding-bot") self.assertEqual(client.list_bots()[0]["bot_id"], "coder") @@ -258,10 +279,34 @@ def test_contract_paths_and_auth_headers(self) -> None: def test_missing_key_fails_before_protected_request(self) -> None: client = ZeusClient(self.base_url) - with self.assertRaisesRegex(ZeusClientError, "API key"): - client.doctor() + for reader in (client.ready, client.doctor): + with ( + self.subTest(reader=reader.__name__), + self.assertRaisesRegex(ZeusClientError, "API key"), + ): + reader() self.assertEqual(FakeZeusHandler.seen, []) + def test_ready_maps_not_ready_and_validates_contract_shape(self) -> None: + client = ZeusClient(self.base_url, api_key="secret") + FakeZeusHandler.mode = "not_ready" + + with self.assertRaises(ZeusClientError) as raised: + client.ready() + + self.assertEqual(raised.exception.status, 503) + self.assertEqual(raised.exception.code, "not_ready") + self.assertEqual(raised.exception.request_id, "f" * 32) + + expected = { + "malformed_ready_status": "status must be ready", + "malformed_ready_schema": "schema_version must be 6", + } + for mode, message in expected.items(): + FakeZeusHandler.mode = mode + with self.subTest(mode=mode), self.assertRaisesRegex(ZeusClientError, message): + client.ready() + def test_response_envelope_is_frozen_and_old_methods_still_return_bodies(self) -> None: client = ZeusClient(self.base_url, api_key="secret") FakeZeusHandler.mode = "response_metadata"