From 3b6dd9745831a4b5cd64ca58e9c453fc56dd4804 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 10 Sep 2026 11:17:28 -0700 Subject: [PATCH] CAN-333: CLI fixes and README consolidation - Merge cli/README.md's install/command/usage docs into root README.md, correct stale onboarding (new-app command name, workflow vs dashboard port) and add the global_controller.yaml config walkthrough. - Trim cli/README.md to a short pointer at ARCHITECTURE.md. - Un-embed the accidentally nested examples/repo git repo (stale gitlink in the index, no actual .git left on disk) from a prior state -- kept out of scope here, that lives on docs/fleshing-docs-clean. - Assorted small fixes across cli/canyonos/ (constants, deploy, test, theme, init, quit, stop, verify, dashboard_stack, dashboard.compose.yml) and their tests. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 2 + README.md | 1 + cli/README.md | 64 ++++-- cli/canyonos/constants.py | 86 +++++++- cli/canyonos/dashboard.compose.yml | 22 +-- cli/canyonos/dashboard_stack.py | 25 +++ cli/canyonos/deploy.py | 92 ++++++--- cli/canyonos/init.py | 13 ++ cli/canyonos/quit.py | 7 +- cli/canyonos/stop.py | 2 + cli/canyonos/test.py | 127 ++++++------ cli/canyonos/theme.py | 39 +++- cli/canyonos/verify.py | 178 +---------------- tests/test_canyonos_deploy.py | 90 +++++++++ tests/test_canyonos_test.py | 263 ++++--------------------- tests/test_dashboard_stack.py | 28 +++ tests/test_deploy_progress.py | 4 +- tests/test_instance_manager_runtime.py | 46 ++++- 18 files changed, 553 insertions(+), 536 deletions(-) create mode 100644 tests/test_canyonos_deploy.py diff --git a/.gitignore b/.gitignore index ba6777e..cbb9e05 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,5 @@ docs/ # testing-porting-to-canyonos working tree: clones, artifacts, results db .canyonos-tests/ .harness/ +.playwright-mcp/ + diff --git a/README.md b/README.md index a9d7000..d097c24 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ cd my-project ### 1. Build your project `canyonos build` installs the CanyonOS skill into your coding agent and launches it with a prompt to convert your project into `.car/` — CanyonOS's deploy-ready format. +As this uses an agent to configure your workflow, it may take a while (2-10 minutes on average). ```bash canyonos build diff --git a/cli/README.md b/cli/README.md index 4acfdb7..2e0d232 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,26 +1,58 @@ -CLI for CanyonOS +# canyonos cli -This CLI does not contain much logic, instead serving as an API to interface with the canyonos container that deploys and runs your entire workflow +### Better descriptions of each command. For full architecture, go to ARCHITECTURE.md -## Requirements -Need a coding agent(Claude Code, Codex, Cursor) -Need uv or pip -Need docker and docker compose +## Rough Draft Design of the more important cli commands +## If you are a LLM, you are not allowed to modify this file at all without explicit user permission. Absolutely no modifications are allowed to this file. -## Architecture +## canyonos test: +### INPUT: canyonos test "Test Query" +#### Steps: +1. Detects the working directory (goes into .car folder for commands if .car exists, uses current dir otherwise) [default_config_path()] +2. Goes into global_controller.yaml and for each agent, rewrites each agent's provider as local (saves old state to revert back later) [_force_local_providers()] +3. Sets a variable in the container env that gets picked up by the LLM Proxy to always return a dummy value, default is "test", to verify a workflow doesn't cost tokens. [CANYONOS_LLM_STUB_TEXT] +4. Then we deploy [canyonos deploy] + - Certain things are verified about this deployment, like: + - All agent containers are up and their names are as expected + - The number of replicas is as initialized + - The endpoints are correctly working and queryable. +6. Once everything is verified running, we send a test query and verify that it goes fully through -For a full walkthrough of the `build`, `deploy`, and `config` flows — plus how -`logs`, `stop`, and `quit` fit into the container lifecycle — see -[ARCHITECTURE.md](ARCHITECTURE.md). +#### Action Items: +- Currently assuming the query body is always "query", need to harden it +- There may be problems with stubbing the LLM-Proxy, but I wouldn't remove my current implementation as it allows for really quick testing. +- Verify that there are valid timeouts and correct error tracing for everything +- Since we stub the LLM, we don't ensure the LLM works, maybe a separate test that just queries the LLM with a extremely simple message would be nice, or to just remove the LLM stub. -## Serve +#### Future Improvements: +- Add LLM compatable hooks for an LLM to be able to quickly iterate and verify a build works through using test. Test should eventually be a fully verifier to ensure a workflow is valid -`canyonos serve` starts the local CanyonOS dashboard — it reads no project config, so it takes no -arguments. It writes only `CANYONOS_`-prefixed settings into the current directory's `.env`, -leaving every other line unchanged. +## canyonos build: +### INPUT: canyonos build +#### Steps: +1. Asks the user which coding agent they want to use for this [Codex/Claude] +2. Asks the user if they want to download the skill locally or globally (So the skill can be viewed either only in this directory or across your entire laptop) +3. Opens said coding agent, giving it instructions to build a new .car folder with the code (Nicks skill) + 4. Periodically the coding agent should ask the user config related questions (which provider, entrypoints, OTEL location) + 5. Coding agent should also be running canyonos test to verify workflow works +6. Finishes, doesn't run deploy itself. -If you have a workflow running, and want to make a config change, canyonos config automatically would reload the project with your config. If you change the workflow files itself though and want the changes to take effect, you need to redeploy from scratch, running canyonos build for good measure +#### Action Items: +- Coding agent should be using canyonos test to verify the file working, need to add that to skill file and harden canyonos test first. +- Maybe add more skills for it to deploy itself and monitor deployments so the user literally doesn't have to do anything else. -# Use: canyonos -h +#### Future Improvements: +- Add more agent providers (Cursor, Pi, Windsurf, etc...) +- Add a preconfigured config file that can get converted into global_controller.yaml (So provider can be autofilled as AWS/Azure/etc..) + + +## canyonos deploy: +### INPUT: canyonos deploy [optional: --serve True -verbose True] + +#### Steps: + +#### Action Items: + +#### Future Improvements diff --git a/cli/canyonos/constants.py b/cli/canyonos/constants.py index f1aa9d1..05ac30f 100644 --- a/cli/canyonos/constants.py +++ b/cli/canyonos/constants.py @@ -2,7 +2,9 @@ Config/data layer. Holds shared values and parsing helpers""" +import ast import os +import socket import yaml from ruamel.yaml import YAML @@ -10,10 +12,10 @@ DEFAULT_API_PORT = 8080 DEFAULT_DASHBOARD_PORT = 8081 -# The workflow entrypoint is always exposed as POST /main with a {"query": ...} -# body, regardless of what the workflow function is called in the project. -# This should be fixed later, keeping it like this for now though +# Fallback when the real function name/params can't be determined statically +# (see workflow_entrypoint) -- canyonos_core's own examples all follow this shape. WORKFLOW_ROUTE = "main" +DEFAULT_QUERY_PARAM = "query" def default_config_path(): @@ -36,6 +38,84 @@ def workflow_api_port(config_path): return None +def _source_root(config_path): + """Directory `workflow_file` is relative to -- `.car/app` under the .car layout, else the project root.""" + car_root = os.path.dirname(os.path.dirname(config_path)) or "." + return os.path.join(car_root, "app") if os.path.basename(car_root) == ".car" else car_root + + +def _deploy_call_target(tree): + """The name passed as `deploy(, ...)`'s first argument, or None.""" + for node in ast.walk(tree): + is_deploy_call = ( + isinstance(node, ast.Call) + and isinstance(node.func, (ast.Name, ast.Attribute)) + and (node.func.id if isinstance(node.func, ast.Name) else node.func.attr) == "deploy" + ) + if is_deploy_call and node.args and isinstance(node.args[0], ast.Name): + return node.args[0].id + return None + + +def workflow_entrypoint(config_path): + """(route, [(param_name, example_default_or_None), ...]) read statically from the + workflow's own source -- the function `deploy()` is actually called with, not an + assumed name. Returns None if the file, the deploy() call, or the function can't be found.""" + try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + return None + + workflow_file = next( + (a.get("workflow_file") for a in config.get("agents") or [] if a.get("type") == "workflow"), + None, + ) + if not workflow_file: + return None + + workflow_path = os.path.join(_source_root(config_path), workflow_file) + try: + with open(workflow_path) as f: + tree = ast.parse(f.read(), filename=workflow_path) + except (OSError, SyntaxError): + return None + + fn_name = _deploy_call_target(tree) + if fn_name is None: + return None + + fn_def = next( + (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name == fn_name), + None, + ) + if fn_def is None: + return None + + args = [a.arg for a in fn_def.args.args if a.arg != "self"] + defaults = fn_def.args.defaults + first_defaulted = len(args) - len(defaults) + params = [] + for i, name in enumerate(args): + default = None + if i >= first_defaulted: + try: + default = ast.literal_eval(defaults[i - first_defaulted]) + except (ValueError, TypeError): + default = None + params.append((name, default)) + return fn_name, params + + +def port_in_use(port): + """True if something is listening on this host port already.""" + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return True + except OSError: + return False + + def dashboard_port(config_path): """Host port the local dashboard prefers to start on, falling back to the default.""" try: diff --git a/cli/canyonos/dashboard.compose.yml b/cli/canyonos/dashboard.compose.yml index e6691ab..38c3c99 100644 --- a/cli/canyonos/dashboard.compose.yml +++ b/cli/canyonos/dashboard.compose.yml @@ -1,21 +1,23 @@ services: - postgres: - image: postgres:17-alpine + # Fast-path bundled DB, hardcoded creds -- fine for local dev, not for anything real. + db: + image: postgres:16-alpine environment: - POSTGRES_DB: canyonos POSTGRES_USER: canyonos POSTGRES_PASSWORD: canyonos - volumes: - - postgres-data:/var/lib/postgresql/data + POSTGRES_DB: canyonos healthcheck: - test: ["CMD-SHELL", "pg_isready -U canyonos -d canyonos"] - interval: 3s + test: ["CMD-SHELL", "pg_isready -U canyonos"] + interval: 2s timeout: 3s retries: 20 + ports: + - "127.0.0.1:5432:5432" + api: image: ${CANYONOS_API_IMAGE} depends_on: - postgres: + db: condition: service_healthy # Published on all interfaces (not just 127.0.0.1) so a GC container can # actually reach this via host.docker.internal -- Docker's host-gateway @@ -26,7 +28,7 @@ services: ports: - "3000:3000" environment: - DATABASE_URL: postgres://canyonos:canyonos@postgres:5432/canyonos + DATABASE_URL: postgresql://canyonos:canyonos@db:5432/canyonos JWT_SECRET: ${CANYONOS_JWT_SECRET} CANYONOS_DISABLE_AUTH: "true" CANYONOS_REDIS_HOST: ${CANYONOS_REDIS_HOST} @@ -41,5 +43,3 @@ services: condition: service_healthy ports: - "127.0.0.1:${CANYONOS_WEB_PORT}:8080" -volumes: - postgres-data: diff --git a/cli/canyonos/dashboard_stack.py b/cli/canyonos/dashboard_stack.py index 38e38a2..1f307a6 100644 --- a/cli/canyonos/dashboard_stack.py +++ b/cli/canyonos/dashboard_stack.py @@ -356,6 +356,31 @@ def _cleanup(stack: DashboardStack, manifest: Path) -> None: return +def _dashboard_compose_command(*args: str) -> bool: + """Run a `docker compose` subcommand against the dashboard stack from the current project. + + False (no-op) if the dashboard was never started from here -- there's no + `.env` for `--env-file` to point at, so there's nothing to stop/tear down. + """ + stack = DashboardStack(state_dir=_state_dir(), project_dir=Path.cwd()) + if not stack.env_path.is_file(): + return False + manifest_resource = importlib.resources.files("canyonos").joinpath("dashboard.compose.yml") + with importlib.resources.as_file(manifest_resource) as manifest: + result = _run([*_compose_argv(stack, manifest), *args]) + return result.returncode == 0 + + +def stop_dashboard() -> bool: + """`docker compose stop` -- halts web/api/db, keeping them for a later `canyonos serve`.""" + return _dashboard_compose_command("stop") + + +def teardown_dashboard() -> bool: + """`docker compose down` -- removes the dashboard's web/api/db containers entirely.""" + return _dashboard_compose_command("down") + + def run_dashboard( phase_reporter: Callable[[str, str], None] | None = None, preferred_port: int = DEFAULT_DASHBOARD_PORT, diff --git a/cli/canyonos/deploy.py b/cli/canyonos/deploy.py index 8cddd81..8e61e27 100644 --- a/cli/canyonos/deploy.py +++ b/cli/canyonos/deploy.py @@ -15,6 +15,7 @@ manual step. """ +import json import queue import re import subprocess @@ -27,9 +28,12 @@ from canyonos import ui from canyonos.constants import ( + DEFAULT_QUERY_PARAM, WORKFLOW_ROUTE, default_config_path, + port_in_use, workflow_api_port, + workflow_entrypoint, workspace_relative, ) from canyonos.gc import GCError, deploy_status, post_deploy, workflow_endpoints @@ -133,30 +137,53 @@ def agents_ready_message(self): return f"{ready} agent(s) ready", True -def run_deploy(config_path=None, serve=True, verbose=False): +def run_deploy(config_path=None, serve=True, verbose=False, quiet=False, extra_env=None, banner=True): + """`quiet` skips the log-tail/dashboard UI and returns the GC state right + after the deploy is triggered -- for a caller (`canyonos test`) that wants + its own readiness check instead of this command's own output. + """ # Left as None when unset: canyonos resolves the artifact layout itself. if config_path is not None: config_path = workspace_relative(config_path) if config_path is None: - ui.fail("Config must be inside the project directory being synced.") - return + raise RuntimeError("Config must be inside the project directory being synced.") - run_init() + run_init(banner=banner, extra_env=extra_env) # Copy the current project into the container before building/deploying. if not run_sync(): - return + raise RuntimeError("Could not sync the project into the container.") state = load_state() # Read for display only -- canyonos resolves the path it actually deploys. api_port = workflow_api_port(config_path or default_config_path()) + # Checked here, after run_init() has already torn down any previous deploy, + # so a still-live prior run doesn't read as an unrelated conflict. + if api_port is not None and port_in_use(api_port): + raise RuntimeError( + f"Port {api_port} is already in use, and the workflow needs it. Free it " + f"or change `api_port` in {config_path or default_config_path()}." + ) + try: post_deploy(state["port"], config_path) - _stream_logs_and_autoserve(state, api_port, serve=serve, verbose=verbose) except GCError as e: - ui.fail(e) + raise RuntimeError(str(e)) from None + + if quiet: + # Still bring the dashboard up so anything reachable only through its + # LLM proxy (e.g. a guardrail calling the OpenAI SDK directly) works + # under `canyonos test` too -- just skip the log-tail/summary UI. + if serve: + _start_dashboard() + return state + + _stream_logs_and_autoserve( + state, api_port, config_path or default_config_path(), serve=serve, verbose=verbose + ) + return state def workflow_targets(gc_port, api_port): @@ -180,7 +207,27 @@ def workflow_targets(gc_port, api_port): return [(None, "127.0.0.1", api_port)] if api_port else [] -def _summary_body(dashboard_url, targets): +def _example_route_and_body(config_path): + """(route, body dict) for the curl example -- read from the workflow function's + own signature when possible, falling back to the historical `main`/`query` shape.""" + entrypoint = workflow_entrypoint(config_path) + if not entrypoint: + return WORKFLOW_ROUTE, {DEFAULT_QUERY_PARAM: "your question here"} + + fn_name, params = entrypoint + if not params: + return fn_name, {DEFAULT_QUERY_PARAM: "your question here"} + return fn_name, {name: default if default is not None else "" for name, default in params} + + +def _curl_example(url, body): + """A copy-pasteable `curl -X POST ...` block, indented to sit under the summary's other rows.""" + json_lines = json.dumps(body, indent=2).splitlines() + indented_body = "\n".join(line if i == 0 else f" {line}" for i, line in enumerate(json_lines)) + return f'curl -X POST {url} \\\n -H "Content-Type: application/json" \\\n -d \'{indented_body}\'' + + +def _summary_body(dashboard_url, targets, config_path): """ The contents that go inside the deploy panel""" body = Text() body.append("Dashboard ", "dim") @@ -189,15 +236,14 @@ def _summary_body(dashboard_url, targets): else: body.append("not running -- start it with `canyonos serve`", WHITE) + route, example = _example_route_and_body(config_path) for name, host, port in targets: base = f"http://{host}:{port}" body.append("\n") if name: body.append(f"\n{name}", f"bold {WHITE}") - body.append("\nPOST ", "dim") - body.append(f"{base}/{WORKFLOW_ROUTE}", f"bold {GREEN}") - body.append("\nbody ", "dim") - body.append('{"query": "your question here"}', WHITE) + body.append("\n") + body.append(_curl_example(f"{base}/{route}", example), WHITE) body.append("\npoll ", "dim") body.append(f"{base}/status/", WHITE) if host not in ("127.0.0.1", "localhost"): @@ -205,7 +251,7 @@ def _summary_body(dashboard_url, targets): return body -def print_deploy_summary(dashboard_url, targets): +def print_deploy_summary(dashboard_url, targets, config_path): """The one screen printed once everything is up: dashboard and workflow endpoints. Under `-v` it is printed again on exit, because the log tail continues @@ -215,7 +261,7 @@ def print_deploy_summary(dashboard_url, targets): ui.blank() ui.panel( Panel( - _summary_body(dashboard_url, targets), + _summary_body(dashboard_url, targets, config_path), title=f"[bold {GREEN}]Deploy is live[/]", title_align="left", border_style=GREEN, @@ -235,12 +281,14 @@ def _start_dashboard(): return None -def _deploy_summary(state, api_port, serve): +def _deploy_summary(state, api_port, config_path, serve): summary = ( _start_dashboard() if serve else None, workflow_targets(state["port"], api_port), + config_path, ) print_deploy_summary(*summary) + ui.hint("Tailing logs now, press Ctrl+C to stop. Run `canyonos stop` to stop the workflow.") return summary @@ -252,7 +300,7 @@ def _interrupted(summary=None): print_deploy_summary(*summary) -def _tail_verbose(stream, state, api_port, serve): +def _tail_verbose(stream, state, api_port, config_path, serve): """Every log line, verbatim -- what `-v` restores. Ctrl+C reprints the summary here but not in quiet mode: only this tail keeps @@ -264,12 +312,12 @@ def _tail_verbose(stream, state, api_port, serve): print(line, end="") # Logged exactly once, right after the workflow finishes coming up. if summary is None and "Global controller started, polling every" in line: - summary = _deploy_summary(state, api_port, serve) + summary = _deploy_summary(state, api_port, config_path, serve) except KeyboardInterrupt: _interrupted(summary) -def _tail_quiet(lines, state, api_port, serve): +def _tail_quiet(lines, state, api_port, config_path, serve): """Only the phase transitions, until the workflow is up or something fails. Nothing is echoed raw: the buildx transcript, canyonos' bare prints and grpc's @@ -303,7 +351,7 @@ def _tail_quiet(lines, state, api_port, serve): break if reached_up_marker: - return _deploy_summary(state, api_port, serve) + return _deploy_summary(state, api_port, config_path, serve) _reveal_failure(lines, recent, state) return None @@ -384,7 +432,7 @@ def _reveal_failure(lines, recent, state): ui.hint("Run `canyonos deploy -v` or `canyonos logs` for the full container log.") -def _stream_logs_and_autoserve(state, api_port, serve=True, verbose=False): +def _stream_logs_and_autoserve(state, api_port, config_path, serve=True, verbose=False): """Tail the GC container's logs, and once they show the workflow is up, start the dashboard (unless disabled via `serve=False`) and print where everything lives. Log tailing continues afterwards. @@ -398,10 +446,10 @@ def _stream_logs_and_autoserve(state, api_port, serve=True, verbose=False): ) try: if verbose: - _tail_verbose(process.stdout, state, api_port, serve) + _tail_verbose(process.stdout, state, api_port, config_path, serve) return lines = _queued_lines(process.stdout) - if _tail_quiet(lines, state, api_port, serve) is not None: + if _tail_quiet(lines, state, api_port, config_path, serve) is not None: # Quiet mode stays attached after the summary so Ctrl+C means the # same thing in both modes -- it just swallows what arrives. while lines.get() is not None: diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py index eac2edf..5b3823d 100644 --- a/cli/canyonos/init.py +++ b/cli/canyonos/init.py @@ -24,6 +24,12 @@ # Image Name, need to switch to CanyonCore Organization Namespace later GC_IMAGE = "saakeths/canyonos:latest" GC_CONTAINER_PORT = 8000 +GC_CONTAINER_NAME = "canyonos-global-controller" + +# Same network canyonos_core's own GlobalController creates for local-provider +# Redis/agent containers -- the GC container needs to be on it too, e.g. to +# resolve :50051 for its own cleanup gRPC calls. +LOCAL_NETWORK = "canyonos-local" # Named docker volume mounted at /workspace inside the container. Files are # copied in via `canyonos sync` (docker cp), not mounted live, so host-side @@ -129,13 +135,20 @@ def _port_reachable(port, attempts=10, delay=0.5): def run_container(image=GC_IMAGE, max_attempts=50, extra_env=None): port = GC_CONTAINER_PORT + # Idempotent: succeeds silently if the network already exists (created by + # this or a prior GC/Redis launch). + subprocess.run(["docker", "network", "create", LOCAL_NETWORK], capture_output=True) for _ in range(max_attempts): cmd = [ "docker", "run", "-d", + "--name", + GC_CONTAINER_NAME, "-p", f"127.0.0.1:{port}:{GC_CONTAINER_PORT}", + "--network", + LOCAL_NETWORK, # Docker-outside-of-Docker: GC shells out to `docker` to launch # Redis/agent containers, so it needs the host's real daemon, # not a nested one. diff --git a/cli/canyonos/quit.py b/cli/canyonos/quit.py index 9af5dcc..f7ee16c 100644 --- a/cli/canyonos/quit.py +++ b/cli/canyonos/quit.py @@ -1,14 +1,16 @@ """ Logic for `canyonos quit`: full teardown. Stops and removes the Global Controller container AND deletes the /workspace named volume, so the project -files copied into it are discarded too. (Use `canyonos stop` to only halt a -running deploy while keeping the container and files around.) +files copied into it are discarded too. Also tears down the local dashboard +stack (web/api/db), if one was started from this project. (Use `canyonos stop` +to only halt a running deploy while keeping the containers and files around.) """ import os import subprocess from canyonos import ui +from canyonos.dashboard_stack import teardown_dashboard from canyonos.gc import GCError, post_clean, require_state from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH @@ -49,6 +51,7 @@ def run_quit(): # refuses to remove a volume still in use). check=False so a missing # volume doesn't turn teardown into an error. subprocess.run(["docker", "volume", "rm", GC_WORKSPACE_VOLUME], check=False, capture_output=True) + teardown_dashboard() os.remove(STATE_PATH) if already_gone: diff --git a/cli/canyonos/stop.py b/cli/canyonos/stop.py index 519cf49..204c3b2 100644 --- a/cli/canyonos/stop.py +++ b/cli/canyonos/stop.py @@ -4,6 +4,7 @@ """ from canyonos import ui +from canyonos.dashboard_stack import stop_dashboard from canyonos.gc import GCError, post_clean, require_state @@ -15,6 +16,7 @@ def run_stop(): try: with ui.status("Stopping deploy..."): post_clean(state["port"]) + stop_dashboard() ui.ok("Deploy stopped.") except GCError as e: ui.fail(e) diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py index 895e8e0..47e589f 100644 --- a/cli/canyonos/test.py +++ b/cli/canyonos/test.py @@ -1,8 +1,7 @@ """ Logic for `canyonos test`: check a project end to end on this machine. -Four phases, each ending the run if it fails: -- The `.car/` artifact `canyonos build` produced is verified statically +Three phases, each ending the run if it fails: - The project is deployed locally (every agent's `provider` rewritten to `local` for the duration, the original file restored verbatim afterwards) - The running containers are checked against what the config declared - One prompt is sent to the workflow's `/main` endpoint. @@ -15,7 +14,6 @@ import json import os -import socket import subprocess import time import urllib.error @@ -32,12 +30,11 @@ workflow_api_port, workspace_relative, ) -from canyonos.deploy import workflow_targets -from canyonos.gc import GCError, deploy_status, post_deploy -from canyonos.init import load_state, quit_existing, run_init -from canyonos.sync import run_sync +from canyonos.deploy import run_deploy, workflow_targets +from canyonos.gc import _DEPLOY_CONFLICT, deploy_status +from canyonos.init import load_state, quit_existing from canyonos.theme import GREEN, WHITE -from canyonos.verify import ARTIFACT_DIR, verify_build_artifact, verify_runtime +from canyonos.verify import verify_runtime DEFAULT_QUERY = "hello" # `canyonos test` stubs the in-container LLM proxy by default so a smoke test @@ -68,14 +65,6 @@ def _force_local_providers(config_path): return original -def _port_in_use(port): - try: - with socket.create_connection(("127.0.0.1", port), timeout=0.5): - return True - except OSError: - return False - - def _workflow_ready(host, port): """True once the workflow's REST API answers at all. @@ -150,7 +139,6 @@ def __init__(self, query): # log worth reading; before that it holds nothing about the failure. self.deploy_started = False self.phases = [] - self.validation = None self.runtime = None self.endpoint = None self.result = None @@ -161,7 +149,7 @@ def begin(self, name, number, title): """Open a phase, recorded as failed until `done` says otherwise.""" self.phases.append({"name": name, "ok": False, "detail": None}) ui.blank() - ui.say(f"[{number}/4] {title}") + ui.say(f"[{number}/3] {title}") def done(self, detail=None): self.phases[-1].update(ok=True, detail=detail) @@ -174,49 +162,19 @@ def elapsed(self): return round(time.monotonic() - self.started, 3) -def _verify_build(run, config_path): - run.begin("verify_build", 1, "Verify build artifact") - - # A project ported before the .car layout keeps its config at the top level; - # there is no build artifact to check, so the deploy phases still run. - if not config_path.startswith(f"{ARTIFACT_DIR}{os.sep}"): - ui.warn(f"No `{ARTIFACT_DIR}/` artifact -- deploying {config_path} as it is.") - ui.hint(" -> `canyonos build` produces one, and gives this phase something to check.") - run.done("skipped: no .car/ artifact") - return - - run.validation = verify_build_artifact() - stale = len(run.validation["stale"]) - run.done(f"{run.validation['warnings']} warning(s), {stale} stale source(s)") - - def _deploy_locally(run, config_path, api_port, llm_stub=DEFAULT_LLM_STUB): - run.begin("deploy", 2, "Deploy locally") + run.begin("deploy", 1, "Deploy locally") # When stubbing, hand the flag to the GC container; the local runtime # forwards it into every agent so their LLM calls are replaced with canned # text (see canyonos_core/llm_proxy/stub.py). extra_env = {"CANYONOS_LLM_STUB_TEXT": llm_stub} if llm_stub else None if llm_stub: ui.say(f"LLM stub on: every model call returns {llm_stub!r} (no real LLM). Pass --real-llm to disable.") - run_init(banner=False, extra_env=extra_env) - - if not run_sync(): - raise RuntimeError("Could not sync the project into the container.") - - # Only the gRPC host port is bumped when a port is taken (the local runtime's - # launch retry), so an occupied api_port dies 50 attempts later as "no free - # port found". `canyonos serve` also starts looking for its web port at 8080. - if _port_in_use(api_port): - raise RuntimeError( - f"Port {api_port} is already in use, and the workflow needs it. Free it " - f"(`canyonos quit` stops a previous deploy) or change `api_port` in {config_path}." - ) - state = load_state() - try: - post_deploy(state["port"], config_path) - except GCError as e: - raise RuntimeError(str(e)) from None + # quiet=True: skip `canyonos deploy`'s own log-tail/summary UI, we do our + # own HTTP readiness check below instead. serve=True still brings the + # dashboard's LLM proxy up, quietly, for code that calls it directly. + state = run_deploy(config_path, serve=True, quiet=True, extra_env=extra_env, banner=False) run.deploy_started = True _wait_for_workflow(state["port"], api_port) @@ -225,13 +183,13 @@ def _deploy_locally(run, config_path, api_port, llm_stub=DEFAULT_LLM_STUB): def _verify_runtime(run, config_path, gc_port): - run.begin("verify_runtime", 3, "Verify runtime") + run.begin("verify_runtime", 2, "Verify runtime") run.runtime = verify_runtime(config_path, gc_port) run.done(f"{len(run.runtime['agents'])} agent(s) up") def _query(run, gc_port, api_port): - run.begin("query", 4, "Query the workflow") + run.begin("query", 3, "Query the workflow") targets = workflow_targets(gc_port, api_port) if not targets: raise RuntimeError("The deploy reported no workflow endpoint to query.") @@ -256,16 +214,29 @@ def _query(run, gc_port, api_port): run.done(f"answered in {run.elapsed()}s") +def _refuse_if_deploy_running(): + """Bail out before touching anything if a deploy is already up -- otherwise + `_deploy_locally` would tear it down via `run_init`'s own cleanup only to + fail later for an unrelated reason. + """ + try: + state = load_state() + except FileNotFoundError: + return + if (deploy_status(state["port"]) or {}).get("running", False): + raise RuntimeError(_DEPLOY_CONFLICT) + + def _run_test(run, llm_stub=DEFAULT_LLM_STUB): - """Walk the four phases, restoring the config whatever happens.""" + """Walk the three phases, restoring the config whatever happens.""" + _refuse_if_deploy_running() + config_path = workspace_relative(default_config_path()) if config_path is None: raise RuntimeError("Config must be inside the project directory being synced.") if not os.path.isfile(config_path): raise RuntimeError(f"No config at {config_path}. Run `canyonos build` first.") - _verify_build(run, config_path) - api_port = workflow_api_port(config_path) if api_port is None: raise RuntimeError(f"No agent with `type: workflow` in {config_path}; nothing to test.") @@ -327,6 +298,38 @@ def _print_summary(run): ui.blank() +def _readable_result(result): + """`result` unwrapped to its plain value when it's just one field -- the + common case (e.g. `{"reply": "..."}`) reads far better than raw JSON. + """ + if isinstance(result, dict) and len(result) == 1: + value = next(iter(result.values())) + if isinstance(value, str): + return value + return json.dumps(result, indent=2) + + +def _print_io(run): + """A short, scannable input/output pair -- the main panel's own Result field + is the full raw JSON, which gets unreadable fast for a nested result. + """ + body = Text() + body.append("Input ", "dim") + body.append(run.query, WHITE) + body.append("\nOutput ", "dim") + body.append(_readable_result(run.result), WHITE) + ui.panel( + Panel( + body, + title=f"[bold {GREEN}]Input / Output[/]", + title_align="left", + border_style=GREEN, + padding=(1, 4), + ) + ) + ui.blank() + + def _print_failure_logs(run): if run.log_tail: ui.hint(f"last {LOG_TAIL_LINES} lines of the Global Controller log:") @@ -341,7 +344,6 @@ def _payload(run): "query": run.query, "elapsed_s": run.elapsed(), "phases": run.phases, - "validation": run.validation, "runtime": run.runtime, "result": run.result, "error": run.error, @@ -376,13 +378,18 @@ def run_test(prompt=None, as_json=False, llm_stub=DEFAULT_LLM_STUB): container_live = True except (FileNotFoundError, OSError): pass - else: + elif run.error is None: quit_existing() + # else: failed before this run ever started its own deploy (e.g. bad + # config, or `_refuse_if_deploy_running` above) -- nothing of ours to + # clean up, so leave whatever was already there alone. if as_json: print(json.dumps(_payload(run), indent=2)) else: _print_summary(run) + if run.error is None: + _print_io(run) if container_live: _print_failure_logs(run) diff --git a/cli/canyonos/theme.py b/cli/canyonos/theme.py index e74a069..cd2a44e 100644 --- a/cli/canyonos/theme.py +++ b/cli/canyonos/theme.py @@ -4,17 +4,38 @@ The green->white gradient introduced by the `canyonos init` banner, reused across the CLI so everything shares one look. `GREEN` is the primary brand color; `WHITE` the secondary; `GRADIENT` the full ramp for multi-line output. +Both flip to a dark-on-light variant when the terminal background is light. """ +import os +import re +import select +import sys +import termios +import tty + GREEN = "#2BD17E" -WHITE = "#FFFFFF" + +_GRADIENT_DARK = ["#2BD17E", "#55DA98", "#80E3B2", "#AAEDCB", "#D5F6E5", "#FFFFFF"] +_GRADIENT_LIGHT = ["#2BD17E", "#1F9C61", "#177249", "#0F4E31", "#082A1A", "#000000"] + + +def _is_light_background(): + if not sys.stdin.isatty(): + return False + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + sys.stdout.write("\x1b]11;?\x07") + sys.stdout.flush() + reply = os.read(fd, 32).decode(errors="ignore") if select.select([fd], [], [], 0.1)[0] else "" + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + m = re.search(r"rgb:([0-9a-f]{2})\S*/([0-9a-f]{2})\S*/([0-9a-f]{2})", reply, re.I) + return bool(m) and (0.299 * int(m[1], 16) + 0.587 * int(m[2], 16) + 0.114 * int(m[3], 16)) > 128 + # Primary -> secondary ramp (used for the init banner, top to bottom). -GRADIENT = [ - "#2BD17E", - "#55DA98", - "#80E3B2", - "#AAEDCB", - "#D5F6E5", - "#FFFFFF", -] +GRADIENT = _GRADIENT_LIGHT if _is_light_background() else _GRADIENT_DARK +WHITE = GRADIENT[-1] diff --git a/cli/canyonos/verify.py b/cli/canyonos/verify.py index 4f9a46a..6d5be36 100644 --- a/cli/canyonos/verify.py +++ b/cli/canyonos/verify.py @@ -1,9 +1,5 @@ """ -The two verification passes behind `canyonos test`. - -`verify_build_artifact` checks the `.car/` tree a `canyonos build` produced, -before any container is started: the layout, the porting skill's own validator, -and whether the sources have moved on since the port was taken. +The verification pass behind `canyonos test`. `verify_runtime` checks a running local deploy against what the config declared -- every image built, every replica up -- because the controller logs a warning @@ -13,190 +9,18 @@ This file will also need lots of iteration based on what is needed, will expect it to change alot """ -import hashlib -import json -import os import subprocess -import sys import yaml from rich.table import Table from canyonos import gc, ui -from canyonos.build import AGENTS, install_skill from canyonos.constants import DEFAULT_API_PORT -from canyonos.init import STATE_DIR from canyonos.theme import GREEN -ARTIFACT_DIR = ".car" -SOURCE_DIR = "app" -CONFIG_REL = "config/global_controller.yaml" -PORTING_STATE_REL = "config/.porting-state.json" - -VALIDATOR_NAME = "validate.py" -SKILL_CACHE_DIR = os.path.join(STATE_DIR, "skill") - -# These two rules decide their verdict by importing `canyonos` and probing it for -# env-file injection and editable-install support. The runtime lives in the -# Global Controller image, not on the host running this CLI, so the probe always -# comes back empty here and the rules report a failure that isn't one. -CAPABILITY_GATED_CHECKS = frozenset({"V030", "V031"}) - RUNTIME_PREFIX = "canyonos-local-" -# ------------------------------------------------------------------ # -# Build artifact # -# ------------------------------------------------------------------ # - - -def _find_validator(project_root): - """Path to the porting skill's validate.py, fetching the skill if needed.""" - for spec in AGENTS.values(): - for skill_dir in spec["skill_dirs"].values(): - if not os.path.isabs(skill_dir): - skill_dir = os.path.join(project_root, skill_dir) - candidate = os.path.join(skill_dir, VALIDATOR_NAME) - if os.path.isfile(candidate): - return candidate - - cached = os.path.join(SKILL_CACHE_DIR, VALIDATOR_NAME) - if os.path.isfile(cached): - return cached - if install_skill(SKILL_CACHE_DIR) and os.path.isfile(cached): - return cached - return None - - -def _run_validator(validator, artifact_dir): - """The validator's parsed --json report, or None if it produced no report.""" - result = subprocess.run( - [sys.executable, validator, artifact_dir, "-c", CONFIG_REL, "--json"], - capture_output=True, - text=True, - ) - try: - return json.loads(result.stdout) - except ValueError: - detail = (result.stderr or result.stdout).strip().splitlines() - ui.warn(f" The porting validator did not run: {detail[-1] if detail else 'no output'}") - return None - - -def _drop_unprobeable(report): - """Remove the rules that can only be judged with `canyonos` importable. - - Their verdict without it is not merely uncertain, it is wrong: V030 reports - that the runtime never reads `env_file` when the container's runtime does. - """ - if report.get("capabilities", {}).get("canyonos_core"): - return 0 - - kept = [] - dropped = 0 - for finding in report.get("findings") or []: - if finding["check"] in CAPABILITY_GATED_CHECKS: - if finding["level"] == "ERROR": - report["errors"] = max(report.get("errors", 0) - 1, 0) - elif finding["level"] == "WARN": - report["warnings"] = max(report.get("warnings", 0) - 1, 0) - dropped += 1 - continue - kept.append(finding) - report["findings"] = kept - return dropped - - -_LEVEL_EMITTER = {"ERROR": ui.fail, "WARN": ui.warn} - - -def _report_findings(findings): - for finding in sorted(findings, key=lambda f: (f["level"] != "ERROR", f["check"])): - where = finding.get("path") or "" - if where and finding.get("line"): - where = f"{where}:{finding['line']}" - parts = [finding["check"], where, finding["summary"]] - line = " ".join(part for part in parts if part) - _LEVEL_EMITTER.get(finding["level"], ui.hint)(f" {line}") - - -def _sha256(path): - digest = hashlib.sha256() - with open(path, "rb") as f: - for block in iter(lambda: f.read(65536), b""): - digest.update(block) - return digest.hexdigest() - - -def _stale_sources(project_root, artifact_dir): - """Recorded sources that changed or vanished since the port was taken.""" - try: - with open(os.path.join(artifact_dir, PORTING_STATE_REL)) as f: - state = json.load(f) - except (OSError, ValueError): - return [] - - stale = [] - for relative, expected in (state.get("source_files") or {}).items(): - # The skill's own files are recorded alongside the project's; a newer - # skill would otherwise read as the application having changed. - if relative.startswith(".claude/"): - continue - path = os.path.join(project_root, relative) - if not os.path.isfile(path) or _sha256(path) != expected: - stale.append(relative) - return sorted(stale) - - -def verify_build_artifact(project_root="."): - """Check the `.car/` tree. Raises RuntimeError if it can't be deployed.""" - artifact_dir = os.path.join(project_root, ARTIFACT_DIR) - config_path = os.path.join(artifact_dir, CONFIG_REL) - - if not os.path.isfile(config_path) or not os.path.isdir( - os.path.join(artifact_dir, SOURCE_DIR) - ): - raise RuntimeError( - f"No `{ARTIFACT_DIR}/` artifact here (expected {CONFIG_REL} beside " - f"{SOURCE_DIR}/). Run `canyonos build` first." - ) - ui.ok(f"{ARTIFACT_DIR}/ layout (config/ + {SOURCE_DIR}/)") - - summary = {"errors": 0, "warnings": 0, "findings": [], "stale": []} - - validator = _find_validator(project_root) - if validator is None: - ui.warn("Could not fetch the porting validator; skipping artifact checks.") - ui.hint(" The deploy below still runs -- `canyonos doctor` checks the fetch path.") - else: - report = _run_validator(validator, os.path.abspath(artifact_dir)) - if report is not None: - skipped = _drop_unprobeable(report) - summary.update( - errors=report.get("errors", 0), - warnings=report.get("warnings", 0), - findings=report.get("findings", []), - ) - counts = f"{summary['errors']} error(s), {summary['warnings']} warning(s)" - (ui.fail if summary["errors"] else ui.ok)(f"porting validator: {counts}") - _report_findings(summary["findings"]) - if skipped: - ui.hint(f" {skipped} rule(s) need the canyonos runtime to judge and were skipped") - - summary["stale"] = _stale_sources(project_root, artifact_dir) - for relative in summary["stale"]: - ui.warn(f" source changed since the port: {relative}") - if summary["stale"]: - ui.hint(" -> re-run `canyonos build` to bring the artifact back in step") - - if summary["errors"]: - raise RuntimeError( - f"The build artifact has {summary['errors']} validation error(s); fix them " - "or re-run `canyonos build`." - ) - return summary - - # ------------------------------------------------------------------ # # Runtime # # ------------------------------------------------------------------ # diff --git a/tests/test_canyonos_deploy.py b/tests/test_canyonos_deploy.py new file mode 100644 index 0000000..ac62c88 --- /dev/null +++ b/tests/test_canyonos_deploy.py @@ -0,0 +1,90 @@ +import pytest + +from canyonos import deploy as deploy_cmd +from canyonos.gc import GCError + +CONFIG_PATH = "config/global_controller.yaml" +STATE = {"container_id": "abc", "port": 8000} + + +@pytest.fixture +def deployable(monkeypatch): + """Every step run_deploy drives succeeds unless overridden.""" + monkeypatch.setattr(deploy_cmd, "workspace_relative", lambda p: p) + monkeypatch.setattr(deploy_cmd, "run_init", lambda banner=True, extra_env=None: None) + monkeypatch.setattr(deploy_cmd, "run_sync", lambda: True) + monkeypatch.setattr(deploy_cmd, "load_state", lambda: dict(STATE)) + monkeypatch.setattr(deploy_cmd, "workflow_api_port", lambda _config: 8080) + monkeypatch.setattr(deploy_cmd, "port_in_use", lambda _port: False) + monkeypatch.setattr(deploy_cmd, "post_deploy", lambda *_a: None) + + +def test_a_config_path_outside_the_project_raises(monkeypatch, deployable): + monkeypatch.setattr(deploy_cmd, "workspace_relative", lambda _p: None) + + with pytest.raises(RuntimeError, match="Config must be inside the project directory"): + deploy_cmd.run_deploy(CONFIG_PATH, quiet=True) + + +def test_a_sync_failure_raises(monkeypatch, deployable): + monkeypatch.setattr(deploy_cmd, "run_sync", lambda: False) + + with pytest.raises(RuntimeError, match="Could not sync the project"): + deploy_cmd.run_deploy(CONFIG_PATH, quiet=True) + + +def test_an_occupied_api_port_raises_before_post_deploy(monkeypatch, deployable): + """Checked after run_init() (which already tore down any previous deploy), so a still-live + prior run doesn't read as an unrelated conflict -- only a genuinely occupied port does.""" + calls = [] + monkeypatch.setattr(deploy_cmd, "port_in_use", lambda _port: True) + monkeypatch.setattr(deploy_cmd, "post_deploy", lambda *a: calls.append(a)) + + with pytest.raises(RuntimeError, match="Port 8080 is already in use"): + deploy_cmd.run_deploy(CONFIG_PATH, quiet=True) + + assert calls == [] + + +def test_a_post_deploy_failure_is_reraised_as_a_runtime_error(monkeypatch, deployable): + def boom(*_a): + raise GCError("Deploy failed: conflict") + + monkeypatch.setattr(deploy_cmd, "post_deploy", boom) + + with pytest.raises(RuntimeError, match="Deploy failed: conflict"): + deploy_cmd.run_deploy(CONFIG_PATH, quiet=True) + + +def test_quiet_returns_state_without_streaming(monkeypatch, deployable): + def unexpected(*_a, **_k): + raise AssertionError("quiet=True should skip the log-tail/dashboard UI") + + monkeypatch.setattr(deploy_cmd, "_stream_logs_and_autoserve", unexpected) + + assert deploy_cmd.run_deploy(CONFIG_PATH, quiet=True) == STATE + + +def test_non_quiet_still_streams_and_returns_state(monkeypatch, deployable): + calls = [] + monkeypatch.setattr( + deploy_cmd, "_stream_logs_and_autoserve", + lambda state, api_port, config_path, serve, verbose: calls.append( + (state, api_port, config_path, serve, verbose) + ), + ) + + assert deploy_cmd.run_deploy(CONFIG_PATH, serve=False, verbose=True) == STATE + assert calls == [(STATE, 8080, CONFIG_PATH, False, True)] + + +def test_extra_env_and_banner_are_forwarded_to_run_init(monkeypatch, deployable): + seen = {} + monkeypatch.setattr( + deploy_cmd, "run_init", + lambda banner=True, extra_env=None: seen.update(banner=banner, extra_env=extra_env), + ) + + deploy_cmd.run_deploy(CONFIG_PATH, quiet=True, extra_env={"CANYONOS_LLM_STUB_TEXT": "test"}, banner=False) + + assert seen == {"banner": False, "extra_env": {"CANYONOS_LLM_STUB_TEXT": "test"}} diff --git a/tests/test_canyonos_test.py b/tests/test_canyonos_test.py index 21e0918..e88aa12 100644 --- a/tests/test_canyonos_test.py +++ b/tests/test_canyonos_test.py @@ -1,4 +1,3 @@ -import hashlib import json import subprocess @@ -41,190 +40,6 @@ def project(monkeypatch, tmp_path): return tmp_path -def report(errors=0, warnings=0, findings=(), canyonos=False): - return { - "capabilities": {"canyonos_core": canyonos}, - "errors": errors, - "warnings": warnings, - "findings": list(findings), - } - - -def finding(check, level="ERROR"): - return {"check": check, "level": level, "path": "config/x.yaml", "line": 1, "summary": "s"} - - -# ------------------------------------------------------------------ # -# Locating the porting validator # -# ------------------------------------------------------------------ # - - -def test_validator_prefers_the_project_skill(monkeypatch, project, tmp_path): - codex = tmp_path / "codex-skill" - codex.mkdir() - (codex / "validate.py").write_text("") - local = project / ".claude" / "skills" / "porting-to-canyonos" - local.mkdir(parents=True) - (local / "validate.py").write_text("") - - monkeypatch.setattr( - verify, - "AGENTS", - { - "claude": {"skill_dirs": {"local": ".claude/skills/porting-to-canyonos"}}, - "codex": {"skill_dirs": {"global": str(codex)}}, - }, - ) - assert verify._find_validator(str(project)) == str(local / "validate.py") - - -def test_validator_falls_back_to_the_codex_skill(monkeypatch, project, tmp_path): - codex = tmp_path / "codex-skill" - codex.mkdir() - (codex / "validate.py").write_text("") - - monkeypatch.setattr( - verify, - "AGENTS", - { - "claude": {"skill_dirs": {"local": ".claude/skills/porting-to-canyonos"}}, - "codex": {"skill_dirs": {"global": str(codex)}}, - }, - ) - assert verify._find_validator(str(project)) == str(codex / "validate.py") - - -def test_validator_is_fetched_when_nothing_is_installed(monkeypatch, project, tmp_path): - cache = tmp_path / "cache" - monkeypatch.setattr(verify, "AGENTS", {}) - monkeypatch.setattr(verify, "SKILL_CACHE_DIR", str(cache)) - - def fake_install(dest): - assert dest == str(cache) - cache.mkdir() - (cache / "validate.py").write_text("") - return True - - monkeypatch.setattr(verify, "install_skill", fake_install) - assert verify._find_validator(str(project)) == str(cache / "validate.py") - - -def test_a_validator_that_cannot_be_fetched_does_not_stop_the_run(monkeypatch, project, tmp_path): - monkeypatch.setattr(verify, "AGENTS", {}) - monkeypatch.setattr(verify, "SKILL_CACHE_DIR", str(tmp_path / "empty-cache")) - monkeypatch.setattr(verify, "install_skill", lambda _dest: False) - - summary = verify.verify_build_artifact(str(project)) - - assert summary == {"errors": 0, "warnings": 0, "findings": [], "stale": []} - - -# ------------------------------------------------------------------ # -# Reading the validator's report # -# ------------------------------------------------------------------ # - - -def test_validator_errors_fail_the_phase(monkeypatch, project): - monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") - monkeypatch.setattr( - verify, "_run_validator", lambda *_: report(errors=1, findings=[finding("V002")]) - ) - - with pytest.raises(RuntimeError): - verify.verify_build_artifact(str(project)) - - -def test_validator_warnings_pass(monkeypatch, project): - monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") - monkeypatch.setattr( - verify, - "_run_validator", - lambda *_: report(warnings=1, findings=[finding("V018", "WARN")]), - ) - - summary = verify.verify_build_artifact(str(project)) - - assert (summary["errors"], summary["warnings"]) == (0, 1) - - -def test_rules_needing_canyonos_are_dropped_when_it_is_not_importable(monkeypatch, project): - monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") - monkeypatch.setattr( - verify, - "_run_validator", - lambda *_: report(errors=1, findings=[finding("V030"), finding("V031", "INFO")]), - ) - - summary = verify.verify_build_artifact(str(project)) - - assert summary["errors"] == 0 - assert summary["findings"] == [] - - -def test_rules_needing_canyonos_are_kept_when_it_is_importable(monkeypatch, project): - monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") - monkeypatch.setattr( - verify, - "_run_validator", - lambda *_: report(errors=1, findings=[finding("V030")], canyonos=True), - ) - - with pytest.raises(RuntimeError): - verify.verify_build_artifact(str(project)) - - -def test_a_missing_car_directory_is_an_error(monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - with pytest.raises(RuntimeError, match="Run `canyonos build` first"): - verify.verify_build_artifact(str(tmp_path)) - - -# ------------------------------------------------------------------ # -# Source drift # -# ------------------------------------------------------------------ # - - -def write_porting_state(project, entries): - (project / ".car" / "config" / ".porting-state.json").write_text( - json.dumps({"version": 1, "source_files": entries}) - ) - - -def sha256(path): - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def test_unchanged_sources_are_not_reported_as_stale(project): - source = project / "echo_agent.py" - source.write_text("x = 1\n") - write_porting_state(project, {"echo_agent.py": sha256(source)}) - - assert verify._stale_sources(str(project), str(project / ".car")) == [] - - -def test_changed_and_deleted_sources_are_reported(project): - source = project / "echo_agent.py" - source.write_text("x = 2\n") - write_porting_state( - project, {"echo_agent.py": "0" * 64, "gone.py": "0" * 64} - ) - - assert verify._stale_sources(str(project), str(project / ".car")) == [ - "echo_agent.py", - "gone.py", - ] - - -def test_the_skills_own_files_are_not_reported_as_drift(project): - write_porting_state(project, {".claude/skills/porting-to-canyonos/SKILL.md": "0" * 64}) - - assert verify._stale_sources(str(project), str(project / ".car")) == [] - - -def test_a_hand_written_artifact_has_no_state_to_compare(project): - assert verify._stale_sources(str(project), str(project / ".car")) == [] - - # ------------------------------------------------------------------ # # Runtime verification # # ------------------------------------------------------------------ # @@ -289,14 +104,11 @@ def test_the_workflow_endpoint_falls_back_to_the_configured_port(project, runtim @pytest.fixture def deployable(monkeypatch, project): - """A project where every step past the build check succeeds unless overridden.""" - calls = {"post_deploy": 0, "quit": 0} + """A project where every step succeeds unless overridden.""" + calls = {"run_deploy": 0, "quit": 0} - monkeypatch.setattr(test_cmd, "verify_build_artifact", lambda *a: {"warnings": 0, "stale": []}) - monkeypatch.setattr(test_cmd, "run_init", lambda banner=True, extra_env=None: None) - monkeypatch.setattr(test_cmd, "run_sync", lambda: True) monkeypatch.setattr(test_cmd, "load_state", lambda: {"container_id": "abc", "port": 8000}) - monkeypatch.setattr(test_cmd, "_port_in_use", lambda _port: False) + monkeypatch.setattr(test_cmd, "deploy_status", lambda *_a: None) monkeypatch.setattr(test_cmd, "_wait_for_workflow", lambda *a: None) monkeypatch.setattr(test_cmd, "verify_runtime", lambda *a: {"agents": []}) monkeypatch.setattr(test_cmd, "workflow_targets", lambda *a: [("Workflow", "127.0.0.1", 8080)]) @@ -304,13 +116,14 @@ def deployable(monkeypatch, project): monkeypatch.setattr(test_cmd, "_await_result", lambda *a: {"status": "done", "result": {"r": 1}}) monkeypatch.setattr(test_cmd, "_log_tail", lambda _cid: "boom") - def post_deploy(*_a, **_k): - calls["post_deploy"] += 1 + def run_deploy(*_a, **_k): + calls["run_deploy"] += 1 + return {"container_id": "abc", "port": 8000} def quit_existing(): calls["quit"] += 1 - monkeypatch.setattr(test_cmd, "post_deploy", post_deploy) + monkeypatch.setattr(test_cmd, "run_deploy", run_deploy) monkeypatch.setattr(test_cmd, "quit_existing", quit_existing) return calls @@ -321,11 +134,11 @@ def test_a_passing_run_tears_everything_down(deployable): def test_llm_is_stubbed_by_default(monkeypatch, deployable): - """`canyonos test` hands the stub flag to the GC container so no real LLM is hit.""" + """`canyonos test` hands the stub flag to `canyonos deploy` so no real LLM is hit.""" seen = {} monkeypatch.setattr( - test_cmd, "run_init", - lambda banner=True, extra_env=None: seen.update(extra_env=extra_env), + test_cmd, "run_deploy", + lambda *_a, **kwargs: seen.update(extra_env=kwargs.get("extra_env")) or {"container_id": "abc", "port": 8000}, ) assert test_cmd.run_test("hi") == 0 assert seen["extra_env"] == {"CANYONOS_LLM_STUB_TEXT": "test"} @@ -334,8 +147,8 @@ def test_llm_is_stubbed_by_default(monkeypatch, deployable): def test_real_llm_flag_disables_the_stub(monkeypatch, deployable): seen = {} monkeypatch.setattr( - test_cmd, "run_init", - lambda banner=True, extra_env=None: seen.update(extra_env=extra_env), + test_cmd, "run_deploy", + lambda *_a, **kwargs: seen.update(extra_env=kwargs.get("extra_env")) or {"container_id": "abc", "port": 8000}, ) assert test_cmd.run_test("hi", llm_stub=None) == 0 assert seen["extra_env"] is None @@ -349,16 +162,6 @@ def test_the_provider_is_restored_after_the_run(project, deployable): assert config.read_text() == CONFIG -def test_an_occupied_api_port_fails_before_the_deploy(monkeypatch, deployable, capsys): - monkeypatch.setattr(test_cmd, "_port_in_use", lambda _port: True) - - assert test_cmd.run_test("hi", as_json=True) == 1 - payload = json.loads(capsys.readouterr().out) - - assert deployable["post_deploy"] == 0 - assert "8080 is already in use" in payload["error"] - - def test_a_failed_deploy_keeps_the_container_and_reads_its_log(monkeypatch, deployable, capsys): def boom(*_a): raise RuntimeError("the deploy did not come up") @@ -372,16 +175,34 @@ def boom(*_a): assert payload["log_tail"] == "boom" -def test_a_failure_before_the_deploy_leaves_nothing_running(monkeypatch, deployable, capsys): - monkeypatch.setattr(test_cmd, "run_sync", lambda: False) +def test_a_failure_before_the_deploy_leaves_existing_state_alone(monkeypatch, deployable, capsys): + """A failure that never gets as far as starting this run's own deploy must + not tear down whatever deploy was already there -- see + test_refuses_to_run_when_a_deploy_is_already_up. + """ + def boom(*_a, **_k): + raise RuntimeError("Could not sync the project into the container.") + + monkeypatch.setattr(test_cmd, "run_deploy", boom) assert test_cmd.run_test("hi", as_json=True) == 1 payload = json.loads(capsys.readouterr().out) - assert deployable["quit"] == 1 + assert deployable["quit"] == 0 assert payload["log_tail"] is None +def test_refuses_to_run_when_a_deploy_is_already_up(monkeypatch, deployable, capsys): + monkeypatch.setattr(test_cmd, "deploy_status", lambda *_a: {"running": True}) + + assert test_cmd.run_test("hi", as_json=True) == 1 + payload = json.loads(capsys.readouterr().out) + + assert deployable["run_deploy"] == 0 + assert deployable["quit"] == 0 + assert payload["error"] == test_cmd._DEPLOY_CONFLICT + + def test_json_mode_prints_one_object_and_nothing_else(deployable, capsys): assert test_cmd.run_test("a prompt", as_json=True) == 0 payload = json.loads(capsys.readouterr().out) @@ -391,7 +212,6 @@ def test_json_mode_prints_one_object_and_nothing_else(deployable, capsys): assert payload["result"] == {"r": 1} assert payload["error"] is None assert [(p["name"], p["ok"]) for p in payload["phases"]] == [ - ("verify_build", True), ("deploy", True), ("verify_runtime", True), ("query", True), @@ -408,25 +228,20 @@ def test_a_workflow_error_is_reported_as_a_failure(monkeypatch, deployable, caps assert json.loads(capsys.readouterr().out)["error"] == "agent blew up" -def test_a_flat_layout_project_skips_the_build_check(monkeypatch, tmp_path, deployable, capsys): +def test_a_flat_layout_project_deploys_fine_with_no_car_directory(monkeypatch, tmp_path, deployable, capsys): legacy = tmp_path / "legacy" / "config" legacy.mkdir(parents=True) (legacy / "global_controller.yaml").write_text(CONFIG) monkeypatch.chdir(tmp_path / "legacy") - def unexpected(*_a): - raise AssertionError("the artifact validator should not run without a .car/") - - monkeypatch.setattr(test_cmd, "verify_build_artifact", unexpected) - assert test_cmd.run_test("hi", as_json=True) == 0 payload = json.loads(capsys.readouterr().out) - assert payload["phases"][0] == { - "name": "verify_build", - "ok": True, - "detail": "skipped: no .car/ artifact", - } + assert [(p["name"], p["ok"]) for p in payload["phases"]] == [ + ("deploy", True), + ("verify_runtime", True), + ("query", True), + ] # ------------------------------------------------------------------ # diff --git a/tests/test_dashboard_stack.py b/tests/test_dashboard_stack.py index 448c1a1..77d2600 100644 --- a/tests/test_dashboard_stack.py +++ b/tests/test_dashboard_stack.py @@ -279,6 +279,34 @@ def urlopen(endpoint, timeout): ] +def test_stop_and_teardown_are_a_noop_without_an_env_file(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + + assert dashboard_stack.stop_dashboard() is False + assert dashboard_stack.teardown_dashboard() is False + assert calls == [] + + +def test_stop_dashboard_runs_compose_stop(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + Path.cwd().joinpath(".env").write_text("CANYONOS_JWT_SECRET=x\n") + + assert dashboard_stack.stop_dashboard() is True + assert calls[-1][-1] == "stop" + assert calls[-1][4:6] == ["--env-file", str(project / ".env")] + + +def test_teardown_dashboard_runs_compose_down(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + Path.cwd().joinpath(".env").write_text("CANYONOS_JWT_SECRET=x\n") + + assert dashboard_stack.teardown_dashboard() is True + assert calls[-1][-1] == "down" + + def test_existing_dashboard_container_skips_port_check(monkeypatch, project): calls = [] diff --git a/tests/test_deploy_progress.py b/tests/test_deploy_progress.py index db0bb5a..647c5c5 100644 --- a/tests/test_deploy_progress.py +++ b/tests/test_deploy_progress.py @@ -211,7 +211,7 @@ def test_the_clis_own_status_requests_are_not_shown_or_buffered(monkeypatch): ] ) ) - summary = deploy_cmd._tail_quiet(lines, {"port": 1}, 8080, serve=False) + summary = deploy_cmd._tail_quiet(lines, {"port": 1}, 8080, "config/global_controller.yaml", serve=False) assert summary == ("url", []) assert shown == ["Build complete", "Workflow ready"] @@ -229,7 +229,7 @@ def test_a_build_that_dies_silently_does_not_hang(monkeypatch, capsys): # The queue never yields None: the stream stays open, as it does in reality. lines.put = lambda *a, **k: None - summary = deploy_cmd._tail_quiet(lines, {"port": 1}, 8080, serve=False) + summary = deploy_cmd._tail_quiet(lines, {"port": 1}, 8080, "config/global_controller.yaml", serve=False) assert summary is None assert "Building 2 Docker image(s)" in capsys.readouterr().out diff --git a/tests/test_instance_manager_runtime.py b/tests/test_instance_manager_runtime.py index 41bfb50..93b2836 100644 --- a/tests/test_instance_manager_runtime.py +++ b/tests/test_instance_manager_runtime.py @@ -219,16 +219,17 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): controller = _fake_controller() manager = InstanceManager(controller, controller.redis) - manager.ensure_instances( - [ - { - "name": "Workflow", - "provider": "local", - "type": "workflow", - "resources": {"cpu": 2, "memory": 1024, "gpu": 1}, - } - ] - ) + with patch.object(local_runtime, "_port_bound", return_value=False): + manager.ensure_instances( + [ + { + "name": "Workflow", + "provider": "local", + "type": "workflow", + "resources": {"cpu": 2, "memory": 1024, "gpu": 1}, + } + ] + ) self.assertEqual( controller._run_cmd.call_args.args, @@ -273,6 +274,31 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): ), ) + def test_workflow_bootstrap_fails_fast_on_an_occupied_api_port(self): + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + with patch.object(local_runtime, "_port_bound", return_value=True): + with self.assertRaises(RuntimeError) as ctx: + manager.ensure_instances( + [{"name": "Workflow", "provider": "local", "type": "workflow"}] + ) + + self.assertIn("api_port 8080", str(ctx.exception)) + # The orphan-container `docker inspect` probe still runs -- only `docker run` is skipped. + run_calls = [c for c in controller._run_cmd.call_args_list if c.args[0][:2] == ["docker", "run"]] + self.assertEqual(run_calls, []) + + def test_plain_agent_bootstrap_ignores_api_port_conflicts(self): + """Only `type: workflow` publishes api_port -- a plain agent has nothing to conflict on.""" + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + with patch.object(local_runtime, "_port_bound", return_value=True): + manager.ensure_instances([{"name": "Alpha", "provider": "local"}]) + + controller._run_cmd.assert_called() + def test_agent_id_is_stable_across_repeated_ensure_instances_calls(self): controller = _fake_controller() manager = InstanceManager(controller, controller.redis)