diff --git a/.gitignore b/.gitignore index 085b4a3..1d7998b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ env/ Thumbs.db ._* +# Canyon artifacts. `.car` is generated from the application source by the +# porting skill and `ventis build`; it is never committed. +.car/ + # Generated stubs stubs/ grpc_stubs/ diff --git a/README.md b/README.md index 81d944f..0f1ea27 100644 --- a/README.md +++ b/README.md @@ -39,26 +39,26 @@ cd my-app ``` This command creates a new directory `my-app` with the following structure: ``` -├── agents/ # Agent implementations and YAML definitions -│ ├── example_agent.py -│ └── example_agent.yaml -├── workflows/ # Workflow scripts (deployed as REST APIs) -│ └── example_workflow.py -├── config/ -│ ├── global_controller.yaml # Deployment configuration -│ └── policy.yaml # Access control rules -├── stubs/ # Generated agent stubs (auto-generated) -├── grpc_stubs/ # Generated gRPC stubs (auto-generated) -└── README.md # Readme for the project +├── .car/ +│ ├── app/ # Source copy used for builds +│ ├── config/ +│ │ ├── global_controller.yaml +│ │ ├── example_agent.yaml # Agent declaration +│ │ └── policy.yaml +│ ├── stubs/ +│ ├── grpc_stubs/ +│ └── docker_container/ +└── README.md ``` The Readme in the newly created project directory provides a quick overview of the project and how to use it. Including how to add new files etc. We provide some overview in next few steps. #### Step 2: Define Your Agents -Place your agent logic (`.py`) and definitions (`.yaml`) in the `agents/` directory. +Agent declarations live under `.car/config/`. The source used for builds is +copied to `.car/app/` by `canyonos build`. -- **`agents/my_agent.yaml`**: Defines methods and schemas. -- **`agents/my_agent.py`**: Contains the actual Python implementation. +- **`.car/config/my_agent.yaml`**: Defines methods and schemas. +- **`.car/app/path/to/my_agent.py`**: Contains the agent implementation. We have provided an example of a finance agent and a market research agent in the `examples/` directory. To run the example, copy files into your newly created project directory from within the your my-app directory with the command - @@ -69,14 +69,14 @@ cp -r ../examples/* ./ ## Deployment Guide #### Step 1: Configure the Global Controller -Edit `config/global_controller.yaml` in your project directory to list the agents you want to deploy, their `provider`, `replicas`, and resource limits. Add a per-agent `requirements: [pkg, ...]` list for any extra pip packages that agent's code imports — only a small base list (grpc, redis, pyyaml, psutil, etc.) is installed by default. +Edit `.car/config/global_controller.yaml` in your project directory to list the agents you want to deploy, their `provider`, `replicas`, and resource limits. Add a per-agent `requirements: [pkg, ...]` list for any extra pip packages that agent's code imports — only a small base list (grpc, redis, pyyaml, psutil, etc.) is installed by default. #### Step 1.1: Passing secrets to agents (optional) Agents that need API keys read them from environment variables. Point `env_file` at a `.env` file to have Ventis inject it into every agent container: ```yaml -# config/global_controller.yaml +# .car/config/global_controller.yaml env_file: .env ``` @@ -102,7 +102,7 @@ Users can send requests to this endpoint to trigger the workflow. For this examp curl -X POST http://localhost:8080/main \ -H "Content-Type: application/json" \ -d '{ - "ticker": "AAPL" + "query": "AAPL" }' ``` The request is asynchronous. To get the result, you use the following URL- diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md new file mode 100644 index 0000000..62f869a --- /dev/null +++ b/cli/ARCHITECTURE.md @@ -0,0 +1,200 @@ +# CanyonOS CLI — Architecture + +## The one idea to keep in your head + +**The CLI does almost nothing. The container does everything.** + +`canyonos` is a thin client. It never builds, compiles, or runs your workflow +itself — it manages a **Global Controller (GC) container**, ships your project +into it, and drives it over a small HTTP API. Everything you see in your +terminal is the CLI *narrating* what the container is doing. + +If you remember only one picture, remember this: + +``` + YOU CLI (host) GLOBAL CONTROLLER (container) + │ │ │ + │ canyonos deploy │ │ + ├──────────────────────▶│ pull + run container │ + │ ├───────────────────────────────▶│ + │ │ copy project in (docker cp) │ + │ ├───────────────────────────────▶│ /workspace + │ │ POST /deploy │ + │ ├───────────────────────────────▶│ ventis build + launch + │ │◀── log stream (docker logs) ───┤ │ + │◀── readable progress ─┤ │ ▼ + │ │ spawns Redis + agents + │ │ (sibling containers) +``` + +--- + +## How the pieces connect + +``` +┌───────────────────────────── your machine ─────────────────────────────┐ +│ │ +│ ┌───────────┐ HTTP :8000 ┌──────────────────────────┐ │ +│ │ canyonos │ ───── /deploy /clean ────▶│ Global Controller │ │ +│ │ CLI │ /status /endpoints │ container │ │ +│ │ │ ───── docker cp ─────────▶│ ├─ /workspace (a copy │ │ +│ │ │ ───── docker logs -f ────▶│ │ of your project) │ │ +│ └─────┬─────┘ │ └─ runs `ventis` │ │ +│ │ └───────────┬──────────────┘ │ +│ │ docker compose │ docker.sock │ +│ ▼ ▼ (spawns siblings)│ +│ ┌───────────────────────┐ ┌───────────────────────────┐ │ +│ │ Dashboard stack │◀── traces ───│ Redis + your agent / │ │ +│ │ web · api · postgres │ (OTLP) │ workflow containers │ │ +│ └───────────────────────┘ └───────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +Three things worth internalizing about this diagram: + +1. **The container talks to the host Docker daemon.** The GC mounts the host's + `docker.sock`, so the Redis and agent/workflow containers it launches are + **siblings on your machine**, not nested inside it. (This is why teardown has + to be explicit — see `stop` vs `quit` below.) +2. **Your project is a *copy*, not a live mount.** Files are `docker cp`'d into + a named volume at `/workspace`. Editing files on the host after a deploy does + **not** reach the running build. +3. **The dashboard is separate.** It's its own compose stack that just *renders* + the OTLP traces your workflow emits — it isn't in the deploy critical path. + +State connecting the CLI to its container is a single file: +`~/.canyonos/state.json` (container id + port). Every command that needs the +container reads it. + +--- + +## The three commands that matter + +### `build` — get your code into CanyonOS shape + +``` + you ──▶ canyonos build ──▶ pick agent (Claude/Codex) + scope + └─▶ fetch the porting skill from GitHub + └─▶ launch your coding agent with it + │ + ▼ + generates .car/ ◀── canyonos-formatted project + (originals untouched) +``` + +A **host-side, agent-driven** step. The CLI installs the CanyonOS porting skill +onto your coding agent and hands it a prompt; the agent produces a `.car/` +folder — the canyonos-ready version of your project plus its config. **No +container is involved yet.** + +### `deploy` — the main path + +``` + canyonos deploy + │ + ├─ 1. start fresh → ensure Docker up, tear down any old controller, + │ pull + run the GC container, save state (previous canyonos init) + │ + ├─ 2. ship code → docker cp your project into /workspace + │ + ├─ 3. trigger → POST /deploy (container runs `ventis`: + │ build stubs/images + launch the workflow) + │ + └─ 4. narrate → tail container logs, boil them down to phases, + and when the workflow reports "up": + • auto-start the dashboard (canyonos serve) + • print where everything lives +``` + +Everything after step 3 happens *inside* the container. The CLI's real job in +step 4 is turning a very noisy log stream (a full image-build transcript, etc.) +into a short, readable progression — and, on failure, revealing the part it had +been hiding so you can see the actual cause. + +When it finishes you get a summary panel: the **dashboard URL** and each +**workflow endpoint** (`POST /main`), using the real address the container +placed the workflow at. + +``` + ┌─ Deploy is live ─────────────────────────────┐ + │ Dashboard http://127.0.0.1:8080 │ + │ POST http://127.0.0.1:8000/main │ + │ body {"query": "..."} │ + └──────────────────────────────────────────────┘ +``` + +### `config` — view or edit settings + +``` + canyonos config ──▶ View → pretty tables of agents / otel / general + └─▶ Change → interactive editor (comments & order preserved) +``` + +The important mental model isn't the editor — it's **what a change costs you**: +canyonos config only allows you to change the config file, changing the source code requires a redeploy. + +``` + change type takes effect by... + ─────────────────────── ─────────────────────────────────── + config value only reloads in place (no rebuild) + workflow *code* changes full redeploy (container holds a copy) +``` + +--- + +## Lifecycle: what stays and what goes + +Because the deploy spawns real sibling containers, "make it stop" has two levels of "stop": + +``` + deploy sibling GC project files + stops? containers? container? (volume)? + ─────────────── ─────── ─────────── ───────── ───────────── + canyonos stop ✅ ✅ keep keep + canyonos quit ✅ ✅ remove remove +``` + +- **`stop`** — pause the show, keep the stage set. Redeploy without re-pulling. +- **`quit`** — full teardown. Removes the container *and* the `/workspace` + volume (your copied files). Every `deploy` quietly does this to any previous + controller, so each deploy starts clean. + +And to observe without changing anything: + +- **`logs`** — re-attach to the same live log stream `deploy` shows. Useful + after you Ctrl+C out of a deploy: the deploy keeps running; you just stopped + *watching*. (Ctrl+C on `logs` likewise only detaches.) + +``` + deploy ──▶ (Ctrl+C) ──▶ still running in the container + │ ▲ + └── logs ─────────────────┘ re-attach anytime +``` + +--- + +## The whole loop, one screen + +``` + cd your-project + │ + ▼ + build port your code → .car/ (opens your coding agent) + │ + ▼ + deploy build + launch in the container (dashboard opens itself) + │ + ├─ status where does the workflow answer? + ├─ config tweak settings (live reload); redeploy for code changes + ├─ logs re-attach to the stream + │ + ▼ + stop halt the deploy, keep container + files + or + quit full teardown, remove everything +``` + +That's the entire system: a thin CLI, one container that does the heavy +lifting, a pile of sibling containers it spawns, and a dashboard watching the +whole thing. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..b72f186 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,34 @@ +Lightweight CLI for CanyonOS + +Serves as a thin API layer, connecting to the global controller container. + +## Architecture + +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). + +## Serve + +`canyonos serve` starts the local CanyonOS dashboard against the Postgres bundled in its compose +stack — 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. + +## Requirements +Need a coding agent(Claude Code, Codex, Cursor) +Need uv or pip +Need docker and docker compose + +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 + +# Use: canyonos -h +### To Republish to PyPi + +```Terminal +cd cli +# Go into, pyproject.toml, and increment version number +rm -rf dist/ # Removes the old distro, causes conflicts + +uv build +uv publish # Needs PyPi Auth Token, ask Saaketh +``` \ No newline at end of file diff --git a/cli/canyonos/__init__.py b/cli/canyonos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/canyonos/build.py b/cli/canyonos/build.py new file mode 100644 index 0000000..c9b56b4 --- /dev/null +++ b/cli/canyonos/build.py @@ -0,0 +1,208 @@ +""" +Logic for `canyonos build`: install the CanyonOS skill on a coding agent, +then launch that agent with a prompt to apply it to the current project. +""" + +import os +import shutil +import subprocess +import tarfile +import tempfile +import urllib.request + +from canyonos import ui +from utils.tui import select_menu + +SKILL_OWNER = "CanyonCodeCoreAI" +SKILL_REPO = "canyoncodecore" +# The .car-aware skill lives only on this branch; the copies on main and every +# other branch are the older flat-layout `porting-to-canyonos-core`. Repoint at +# main once this merges -- and rename SKILL_NAME with it, since the two +# variants declare different `name:` frontmatter. +SKILL_REF = "nickhuo/porting-skill-car-layout" +SKILL_NAME = "porting-to-canyonos" +SKILL_PATH = f".claude/skills/{SKILL_NAME}" + +REPO_URL = f"https://github.com/{SKILL_OWNER}/{SKILL_REPO}" +TREE_URL = f"{REPO_URL}/tree/{SKILL_REF}/{SKILL_PATH}" +TARBALL_URL = f"https://codeload.github.com/{SKILL_OWNER}/{SKILL_REPO}/tar.gz/refs/heads/{SKILL_REF}" + +# The porting skill emits no otel config; without this the dashboard stays empty. +OTEL_BLOCK = """otel: + destinations: + - name: local + protocol: http + endpoint: http://host.docker.internal:3000/v1/traces + headers: {}""" + +BUILD_PROMPT = ( + f"Use the CanyonOS {SKILL_NAME} skill to convert the codebase in this directory to a canyonos-compatable format. No changes should be made to the current files, but all modifications should be put into a new .car folder." + "\n\nFinally, add the following block verbatim to the generated config/global_controller.yaml," + " at the top level as a sibling of `agents:`. Copy it exactly -- `protocol` must be http, and" + " the endpoint must keep the /v1/traces path:\n\n" + OTEL_BLOCK +) + +# The leaf name of every install path must match the skill's own `name:` +# frontmatter or the agent won't resolve it. +AGENTS = { + "claude": { + "label": "Claude Code", + "cli": "claude", + "skill_dirs": { + "local": SKILL_PATH, + "global": os.path.expanduser(f"~/.claude/skills/{SKILL_NAME}"), + }, + }, + "codex": { + "label": "Codex", + "cli": "codex", + "skill_dirs": { + "local": f".codex/skills/{SKILL_NAME}", + "global": os.path.expanduser(f"~/.codex/skills/{SKILL_NAME}"), + }, + }, +} + + +def prompt_agent(): + options = [(key, spec["label"]) for key, spec in AGENTS.items()] + return select_menu(options, title="Which coding agent do you want to build on?") + + +def prompt_scope(agent): + dirs = AGENTS[agent]["skill_dirs"] + options = [ + ("local", f"This project only ({dirs['local']})"), + ("global", f"Globally ({dirs['global']})"), + ] + return select_menu(options, title="Where should the CanyonOS skill be installed?") + + +def _replace_dir(source, dest): + """Move `source` onto `dest`, replacing whatever was there.""" + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + if os.path.isdir(dest): + shutil.rmtree(dest) + shutil.move(source, dest) + + +def _fetch_with_git(dest): + """Sparse-checkout just the skill path -- no full-repo download, no Node.""" + if not shutil.which("git"): + return False + + with tempfile.TemporaryDirectory() as tmp: + clone = os.path.join(tmp, "repo") + cloned = subprocess.run( + ["git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", + "--branch", SKILL_REF, REPO_URL, clone], + capture_output=True, + ) + if cloned.returncode != 0: + return False + + sparse = subprocess.run( + ["git", "-C", clone, "sparse-checkout", "set", SKILL_PATH], + capture_output=True, + ) + skill = os.path.join(clone, SKILL_PATH) + if sparse.returncode != 0 or not os.path.isdir(skill): + return False + + _replace_dir(skill, dest) + return True + + +def _fetch_with_tarball(dest): + """Stdlib-only fallback: pull the ref's tarball and keep the skill members. + + Needs no external tool at all, at the cost of downloading the whole repo. + """ + prefix = f"{SKILL_PATH}/" + with tempfile.TemporaryDirectory() as tmp: + archive = os.path.join(tmp, "repo.tar.gz") + try: + with urllib.request.urlopen(TARBALL_URL, timeout=60) as response: + with open(archive, "wb") as out: + shutil.copyfileobj(response, out) + except OSError: + return False + + staged = os.path.join(tmp, "skill") + found = False + with tarfile.open(archive, "r:gz") as tar: + for member in tar.getmembers(): + # Drop the archive's own top-level directory, whose name + # depends on how GitHub mangles the ref. + _, _, path = member.name.partition("/") + if not path.startswith(prefix) or not member.isfile(): + continue + relative = os.path.relpath(path, SKILL_PATH) + target = os.path.join(staged, relative) + # Never let an archive entry write outside the staging dir. + if not os.path.abspath(target).startswith(os.path.abspath(staged) + os.sep): + continue + extracted = tar.extractfile(member) + if extracted is None: + continue + os.makedirs(os.path.dirname(target), exist_ok=True) + with open(target, "wb") as out: + shutil.copyfileobj(extracted, out) + found = True + + if not found: + return False + _replace_dir(staged, dest) + return True + + +FETCH_STRATEGIES = ( + ("git", _fetch_with_git), + ("tarball", _fetch_with_tarball), +) + + +def install_skill(dest): + """Fetch the skill into `dest`. Returns True on success.""" + for name, fetch in FETCH_STRATEGIES: + try: + if fetch(dest): + ui.ok(f"Fetched the CanyonOS skill via {name}.") + return True + except OSError: + pass + ui.hint(f"{name} fetch unavailable, trying the next option...") + + ui.fail(f"Could not fetch the CanyonOS skill from {TREE_URL}.") + ui.hint("Install git, or check network access, then run `canyonos doctor`.") + return False + + +def launch_agent(agent, prompt): + spec = AGENTS[agent] + if not shutil.which(spec["cli"]): + ui.fail(f"`{spec['cli']}` not found on PATH; install {spec['label']} first.") + return + # No check=True: the agent exiting non-zero (including the user quitting it) + # is an ordinary outcome, not something to raise a traceback over. + subprocess.run([spec["cli"], prompt]) + + +def run_build(): + agent = prompt_agent() + if agent is None: + ui.say("Cancelled.") + return + + scope = prompt_scope(agent) + if scope is None: + ui.say("Cancelled.") + return + + dest = AGENTS[agent]["skill_dirs"][scope] + ui.say(f"Installing CanyonOS skill for {AGENTS[agent]['label']} into {dest}...") + if not install_skill(dest): + return + + ui.say(f"Launching {AGENTS[agent]['label']}...") + launch_agent(agent, BUILD_PROMPT) diff --git a/cli/canyonos/clean.py b/cli/canyonos/clean.py new file mode 100644 index 0000000..9c974b4 --- /dev/null +++ b/cli/canyonos/clean.py @@ -0,0 +1,20 @@ +""" +Logic for `canyonos clean`: remove the generated .car artifact directory. +""" + +import os +import shutil + +from canyonos import ui + + +def run_clean(): + car_dir = os.path.join(os.getcwd(), ".car") + + if not os.path.isdir(car_dir): + ui.warn("Nothing to clean, no .car folder in root") + return + + with ui.status(f"Cleaning {car_dir}..."): + shutil.rmtree(car_dir) + ui.ok("Clean complete.") diff --git a/cli/canyonos/config.py b/cli/canyonos/config.py new file mode 100644 index 0000000..aec5b12 --- /dev/null +++ b/cli/canyonos/config.py @@ -0,0 +1,341 @@ +""" +Logic for `canyonos config`: view or change project/deploy configuration. +""" + +import os + +import yaml +from rich.table import Table + +from canyonos.constants import default_config_path, round_trip_yaml +from canyonos.theme import GREEN, WHITE +from canyonos import ui +from utils.tui import DELETE_ACTION, QUIT_ACTION, select_menu + +BACK = "__back__" + +OPTIONS = [ + ("view", "View"), + ("change", "Change"), +] + +BORDER = GREEN +HEADER = f"bold {GREEN}" + +# Rendered as their own tables (in this order); everything else scalar at the +# top level is collected into a single "General" table. +STRUCTURED_KEYS = ("agents", "otel") + + +def _fmt(value): + """Render a YAML value as a compact, single-cell string.""" + if value is None: + return "-" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, list): + return ", ".join(_fmt(v) for v in value) if value else "-" + if isinstance(value, dict): + return ", ".join(f"{k}={_fmt(v)}" for k, v in value.items()) if value else "-" + return str(value) + + +def _agents_table(agents): + table = Table(title="Agents", border_style=BORDER, header_style=HEADER, title_style=HEADER) + for col in ("Name", "Type", "Replicas", "CPU", "Mem", "Provider", "Port", "Entrypoint"): + table.add_column(col) + + for agent in agents: + resources = agent.get("resources") or {} + # Workflows carry `workflow_file` + `api_port`; plain agents carry + # `entrypoint` + `redis_port`. + entry = agent.get("entrypoint") or agent.get("workflow_file") or "-" + port = agent.get("api_port") or agent.get("redis_port") + table.add_row( + _fmt(agent.get("name")), + agent.get("type", "agent"), + _fmt(agent.get("replicas")), + _fmt(resources.get("cpu")), + _fmt(resources.get("memory")), + _fmt(agent.get("provider")), + _fmt(port), + entry, + ) + return table + + +def _otel_table(otel): + destinations = (otel or {}).get("destinations") or [] + table = Table( + title="OTel Destinations", border_style=BORDER, header_style=HEADER, title_style=HEADER + ) + for col in ("Name", "Protocol", "Endpoint", "Insecure", "Headers"): + table.add_column(col) + + for dest in destinations: + headers = dest.get("headers") or {} + table.add_row( + _fmt(dest.get("name")), + _fmt(dest.get("protocol")), + _fmt(dest.get("endpoint")), + _fmt(dest.get("insecure", False)), + ", ".join(headers.keys()) if headers else "-", + ) + return table + + +def _kv_table(title, data): + """A two-column Setting/Value table from a flat-ish dict (or single value).""" + table = Table(title=title, border_style=BORDER, header_style=HEADER, title_style=HEADER) + table.add_column("Setting", style="bold") + table.add_column("Value") + + if isinstance(data, dict): + for key, value in data.items(): + table.add_row(str(key), _fmt(value)) + else: + table.add_row(title, _fmt(data)) + return table + + +def _require_config(config_path): + """Resolved config path, or None after reporting that it's missing.""" + config_path = config_path or default_config_path() + if not os.path.isfile(config_path): + ui.fail(f"Config file not found: {config_path}") + return None + return config_path + + +def run_view_config(config_path=None): + config_path = _require_config(config_path) + if config_path is None: + return + + with open(config_path) as f: + config = yaml.safe_load(f) or {} + + ui.console.print(_agents_table(config.get("agents") or [])) + ui.blank() + + if config.get("otel"): + ui.console.print(_otel_table(config["otel"])) + ui.blank() + + # Every other top-level key: dicts get their own table, bare scalars are + # gathered into a single "General" table. + general = {} + for key, value in config.items(): + if key in STRUCTURED_KEYS: + continue + if isinstance(value, dict): + ui.console.print(_kv_table(key, value)) + ui.blank() + else: + general[key] = value + + if general: + ui.console.print(_kv_table("General", general)) + + +def _is_leaf(value): + """A value the user edits directly: any scalar, or a list of only scalars. + + Lists of mappings (agents, otel.destinations) are containers to drill into; + lists of plain scalars (requirements, security_group_ids) are edited whole + via comma-separated input. + """ + if isinstance(value, dict): + return False + if isinstance(value, list): + return all(not isinstance(item, (dict, list)) for item in value) + return True + + +def _preview(value): + if isinstance(value, dict): + return f"{{{len(value)} keys}}" + if isinstance(value, list) and not _is_leaf(value): + return f"[{len(value)} items]" + return _fmt(value) + + +def _seq_label(index, item): + if isinstance(item, dict) and item.get("name"): + return str(item["name"]) + return f"[{index}]" + + +def _cast(raw, current): + """Coerce the typed string to the current value's type. Raises ValueError.""" + # bool must precede int: bool is a subclass of int. + if isinstance(current, bool): + low = raw.strip().lower() + if low in ("true", "yes", "y", "1"): + return True + if low in ("false", "no", "n", "0"): + return False + raise ValueError("expected yes/no") + if isinstance(current, int): + return int(raw) + if isinstance(current, float): + return float(raw) + if isinstance(current, list): + return [part.strip() for part in raw.split(",") if part.strip()] + return raw + + +class _Screen: + """Owns the alt-screen: clears and redraws a persistent breadcrumb header + (plus a transient status line) before each menu/prompt, so the change + session replaces the view in place instead of scrolling. + """ + + def __init__(self, console): + self.console = console + self.status = "" + + def render(self, breadcrumb): + self.console.clear() + path = " \u203a ".join(str(part) for part in breadcrumb) if breadcrumb else "config" + self.console.print(f"[bold {GREEN}]CanyonOS[/] [{WHITE}]config[/]") + self.console.print(f"[{WHITE}]{path}[/]") + if self.status: + self.console.print(f"[{GREEN}]{self.status}[/]") + self.console.print() + + +def _edit_leaf(screen, parent, key, breadcrumb): + """Prompt for and apply a new value for parent[key]. Returns True if changed.""" + screen.render(breadcrumb) + console = screen.console + current = parent[key] + label = key if not isinstance(key, int) else f"item {key}" + console.print(f"[bold]{label}[/bold] current: {_fmt(current)}") + if isinstance(current, list): + console.print("[dim]enter comma-separated values[/dim]") + + raw = input("New value (blank to cancel): ").strip() + if raw == "": + return False + + try: + parent[key] = _cast(raw, current) + except ValueError as exc: + screen.status = f"Invalid value: {exc}" + return False + + screen.status = f"Set {label} = {_fmt(parent[key])}" + return True + + +def _confirm_delete(screen, node, key, breadcrumb): + """Yes/No confirm menu for deleting node[key]. Returns True to delete.""" + screen.render(breadcrumb) + label = key if isinstance(node, dict) else _seq_label(key, node[key]) + options = [("yes", f"Yes, delete '{label}'"), ("no", "No, keep it")] + choice = select_menu( + options, + title=f"Delete '{label}' ({_preview(node[key])}) and everything inside?", + ) + return choice == "yes" + + +def _navigate(screen, node, breadcrumb): + """Drill into a mapping/sequence. Returns True if any value was changed or + deleted, None if the user backed out of this level, or QUIT_ACTION if the + user quit (which unwinds the whole session from any depth).""" + while True: + screen.render(breadcrumb) + if isinstance(node, dict): + options = [(k, f"{k}: {_preview(v)}") for k, v in node.items()] + else: # list + options = [(i, f"{_seq_label(i, item)}: {_preview(item)}") for i, item in enumerate(node)] + options.append((BACK, "\u2190 Back")) + + choice = select_menu( + options, title="Select a field (d to delete)", deletable=True, quittable=True + ) + if choice is None: + return None + # 'q' anywhere -> unwind the entire session, not just this level. + if choice is QUIT_ACTION: + return QUIT_ACTION + + # 'd' over an item -> (DELETE_ACTION, hovered_value). + if isinstance(choice, tuple) and choice[0] is DELETE_ACTION: + target = choice[1] + if target == BACK: + continue # the Back entry isn't deletable + if _confirm_delete(screen, node, target, breadcrumb): + label = target if isinstance(node, dict) else _seq_label(target, node[target]) + del node[target] + screen.status = f"Deleted '{label}'" + return True + continue # delete cancelled: stay on this menu + + if choice == BACK: + return None + + child = node[choice] + label = choice if isinstance(node, dict) else _seq_label(choice, child) + if _is_leaf(child): + if _edit_leaf(screen, node, choice, breadcrumb + [str(label)]): + return True + # cancelled/invalid: stay on this menu + else: + result = _navigate(screen, child, breadcrumb + [str(label)]) + if result is QUIT_ACTION: + return QUIT_ACTION + if result: + return True + # backed out of the child: stay on this menu + + +def run_change_config(config_path=None): + config_path = _require_config(config_path) + if config_path is None: + return + + yaml_rt = round_trip_yaml() + with open(config_path) as f: + data = yaml_rt.load(f) + + if not data: + ui.warn("Config is empty; nothing to change.") + return + + screen = _Screen(ui.console) + saves = 0 + # Alternate screen: the whole session replaces the view, and the terminal + # scrollback is restored untouched on exit. + ui.console.set_alt_screen(True) + try: + while True: + changed = _navigate(screen, data, ["config"]) + # None = backed out at root, QUIT_ACTION = quit from any depth. + if changed is None or changed is QUIT_ACTION: + break + with open(config_path, "w") as f: + yaml_rt.dump(data, f) + saves += 1 + screen.status = f"Saved to {config_path}" + finally: + ui.console.set_alt_screen(False) + + if saves: + ui.ok(f"Saved {saves} change(s) to {config_path}") + else: + ui.say("No changes made.") + + +def run_config(): + choice = select_menu(OPTIONS, title="What do you want to do?") + if choice is None: + ui.say("Cancelled.") + return + + if choice == "view": + run_view_config() + elif choice == "change": + run_change_config() diff --git a/cli/canyonos/constants.py b/cli/canyonos/constants.py new file mode 100644 index 0000000..455e860 --- /dev/null +++ b/cli/canyonos/constants.py @@ -0,0 +1,60 @@ +"""Shared helpers for the canyonos CLI.""" + +import os + +import yaml +from ruamel.yaml import YAML + +DEFAULT_API_PORT = 8080 + +# The workflow entrypoint is always exposed as POST /main with a {"query": ...} +# body, regardless of what the workflow function is called in the project. +WORKFLOW_ROUTE = "main" + + +def default_config_path(): + """Global controller config for the current directory, preferring the .car artifact layout.""" + car = os.path.join(".car", "config", "global_controller.yaml") + return car if os.path.isfile(car) else os.path.join("config", "global_controller.yaml") + + +def workflow_api_port(config_path): + """Host port the workflow answers on, or None if there isn't one to read.""" + try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + return None + + for agent in config.get("agents") or []: + if agent.get("type") == "workflow": + return agent.get("api_port", DEFAULT_API_PORT) + return None + + +def workspace_relative(config_path): + """`config_path` relative to the cwd, or None if it falls outside it. + + The container only ever receives a copy of the current directory, and it + resolves what it's given against /workspace -- so an absolute path silently + discards that prefix and a `../` one escapes it. Both then 404 naming a + path that exists on the host, which reads as a bug in the wrong place. + """ + # realpath on both sides: a symlinked project dir (or macOS's /tmp -> + # /private/tmp) otherwise makes an in-project absolute path look external. + relative = os.path.relpath(os.path.realpath(config_path), os.path.realpath(os.getcwd())) + if relative == ".." or relative.startswith(f"..{os.sep}"): + return None + return relative + + +def round_trip_yaml(): + """Loader that preserves comments, key order, quoting and ${ENV} refs. + + The indent settings match the project's YAML style, so edits don't reflow + list indentation: block sequences stay indented under their key. + """ + yaml_rt = YAML() + yaml_rt.preserve_quotes = True + yaml_rt.indent(mapping=2, sequence=4, offset=2) + return yaml_rt diff --git a/cli/canyonos/dashboard.compose.yml b/cli/canyonos/dashboard.compose.yml new file mode 100644 index 0000000..0689749 --- /dev/null +++ b/cli/canyonos/dashboard.compose.yml @@ -0,0 +1,45 @@ +services: + # Fast-path bundled DB, hardcoded creds -- fine for local dev, not for anything real. + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: canyonos + POSTGRES_PASSWORD: canyonos + POSTGRES_DB: canyonos + healthcheck: + 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: + 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 + # route doesn't reach ports bound to loopback only, so a 127.0.0.1 bind + # here silently black-holed every OTLP span export. That route also + # renames ventis' `project_id` attribute to the `canyon.project.id` every + # dashboard query filters on. + ports: + - "3000:3000" + environment: + DATABASE_URL: postgresql://canyonos:canyonos@db:5432/canyonos + JWT_SECRET: ${CANYONOS_JWT_SECRET} + CANYONOS_DISABLE_AUTH: "true" + CANYONOS_REDIS_HOST: ${CANYONOS_REDIS_HOST} + CANYONOS_REDIS_PORT: ${CANYONOS_REDIS_PORT} + LOG_LEVEL: info + extra_hosts: + - host.docker.internal:host-gateway + web: + image: ${CANYONOS_WEB_IMAGE} + depends_on: + api: + condition: service_healthy + ports: + - "127.0.0.1:${CANYONOS_WEB_PORT}:8080" diff --git a/cli/canyonos/dashboard_stack.py b/cli/canyonos/dashboard_stack.py new file mode 100644 index 0000000..e26fbcf --- /dev/null +++ b/cli/canyonos/dashboard_stack.py @@ -0,0 +1,398 @@ +"""Manage the local CanyonOS dashboard stack.""" + +from __future__ import annotations + +import importlib.resources +import json +import os +import re +import secrets +import shutil +import socket +import subprocess +import time +import urllib.error +import urllib.request +from contextlib import ExitStack +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable + +COMPOSE_PROJECT = "canyonos-dashboard" +STACK_VERSION = "v0.1.0-rc.2" +API_IMAGE = f"ghcr.io/canyoncodecoreai/canyonos-api:{STACK_VERSION}" +WEB_IMAGE = f"ghcr.io/canyoncodecoreai/canyonos-web:{STACK_VERSION}" +HOST_GATEWAY = "host.docker.internal" +REDIS_HOST = HOST_GATEWAY +REDIS_PORT = "6379" + + +@dataclass(frozen=True) +class ServeResult: + ok: bool + phase: str + message: str + url: str | None = None + log_path: str | None = None + + +class PhaseFailure(Exception): + def __init__(self, phase: str, message: str): + super().__init__(message) + self.phase = phase + self.message = message + + +@dataclass(frozen=True) +class DashboardStack: + state_dir: Path + project_dir: Path + web_port: int = 8080 + + @property + def env_path(self) -> Path: + return self.project_dir / ".env" + + +def _run(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, capture_output=True, check=False, text=True) + + +def _state_dir() -> Path: + return Path.home() / ".canyonos" / "dashboard" + + +def _compose_argv(stack: DashboardStack, manifest: Path) -> list[str]: + return [ + "docker", + "compose", + "-p", + COMPOSE_PROJECT, + "--env-file", + str(stack.env_path), + "-f", + str(manifest), + ] + + +def _existing_dashboard_port() -> int | None: + """The host port an already-running dashboard `web` container owns, if any + -- so re-running `canyonos serve` reconnects to the same stack instead of + picking a new port out from under it.""" + try: + result = _run(["docker", "container", "inspect", "canyonos-dashboard-web-1"]) + except OSError: + return None + if result.returncode != 0: + return None + + try: + containers = json.loads(result.stdout) + bindings = containers[0]["NetworkSettings"]["Ports"].get("8080/tcp") or [] + except (IndexError, KeyError, TypeError, json.JSONDecodeError): + return None + + for binding in bindings: + if binding.get("HostIp") in {"127.0.0.1", "0.0.0.0", "::"}: + try: + return int(binding["HostPort"]) + except (KeyError, TypeError, ValueError): + continue + return None + + +def _port_is_free(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + try: + probe.bind(("127.0.0.1", port)) + except OSError: + return False + return True + + +def _find_web_port(start: int = 8080, max_attempts: int = 50) -> int: + """First free port at or after `start`, so an unrelated process or container + squatting on 8080 (e.g. a deployed Workflow's own api_port) doesn't + hard-block serve. + """ + for port in range(start, start + max_attempts): + if _port_is_free(port): + return port + raise PhaseFailure( + "validate", f"no free port found for the dashboard after {max_attempts} attempts starting at {start}" + ) + + +def validate() -> DashboardStack: + if shutil.which("docker") is None: + raise PhaseFailure("validate", "docker is not on PATH") + + try: + if _run(["docker", "info"]).returncode != 0: + raise PhaseFailure("validate", "docker daemon or socket is unavailable") + if _run(["docker", "compose", "version"]).returncode != 0: + raise PhaseFailure("validate", "docker compose is unavailable") + except OSError: + raise PhaseFailure("validate", "docker daemon or socket is unavailable") + + # The dashboard reads no project config -- it always runs against the + # bundled Postgres on this machine -- so the project root is just the cwd, + # the same assumption sync/clean/build already make. + project_root = Path.cwd() + + state_dir = _state_dir() + try: + state_dir.mkdir(parents=True, exist_ok=True) + probe_path = state_dir / ".write-probe" + with open(probe_path, "w", encoding="utf-8") as probe: + probe.write("") + probe_path.unlink() + except OSError: + raise PhaseFailure("validate", "dashboard state directory is not writable") + + web_port = _existing_dashboard_port() or _find_web_port() + + return DashboardStack(state_dir, project_root, web_port) + + +def _env_value(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def _read_existing_secret(env_path: Path) -> str | None: + try: + lines = env_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None + + for line in lines: + key, separator, value = line.partition("=") + if separator and key == "CANYONOS_JWT_SECRET" and _env_value(value): + return _env_value(value) + return None + + +def _write_private_file(path: Path, contents: str) -> None: + """Write 0600 from the start, so the contents are never briefly world-readable. + + The open mode only applies when creating, so an already-loose file (a `.env` + the user wrote by hand) is tightened explicitly rather than left as it was. + """ + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + os.fchmod(output.fileno(), 0o600) + output.write(contents) + + +def _env_line(key: str, value: str) -> str: + if " " in value or "#" in value: + return f'{key}="{value}"\n' + return f"{key}={value}\n" + + +def _write_project_env(env_path: Path, managed_env: dict[str, str]) -> None: + """Rewrite only the CANYONOS_* keys, leaving every other line of the user's .env alone.""" + try: + lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) + except FileNotFoundError: + lines = [] + + replaced: set[str] = set() + updated_lines: list[str] = [] + for line in lines: + key, separator, _ = line.partition("=") + if separator and key in managed_env: + if key not in replaced: + updated_lines.append(_env_line(key, managed_env[key])) + replaced.add(key) + continue + updated_lines.append(line) + + updated_lines.extend( + _env_line(key, value) for key, value in managed_env.items() if key not in replaced + ) + _write_private_file(env_path, "".join(updated_lines)) + + +def prepare(stack: DashboardStack) -> tuple[dict[str, str], str]: + try: + stack.state_dir.mkdir(parents=True, exist_ok=True) + os.chmod(stack.state_dir, 0o700) + managed_env = { + "CANYONOS_JWT_SECRET": _read_existing_secret(stack.env_path) or secrets.token_urlsafe(32), + "CANYONOS_REDIS_HOST": REDIS_HOST, + "CANYONOS_REDIS_PORT": REDIS_PORT, + "CANYONOS_API_IMAGE": API_IMAGE, + "CANYONOS_WEB_IMAGE": WEB_IMAGE, + "CANYONOS_WEB_PORT": str(stack.web_port), + } + _write_project_env(stack.env_path, managed_env) + (stack.state_dir / "stack.json").write_text( + json.dumps( + { + "schema_version": 1, + "stack_version": STACK_VERSION, + "compose_project": COMPOSE_PROJECT, + } + ) + + "\n", + encoding="utf-8", + ) + except (OSError, ValueError): + raise PhaseFailure("prepare", "could not prepare the dashboard state directory") + + return managed_env, "dashboard state prepared" + + +def _command_failure_message( + message: str, + result: subprocess.CompletedProcess[str], + managed_env: dict[str, str], +) -> str: + detail = next( + (line.strip() for line in reversed(result.stderr.splitlines()) if line.strip()), None + ) + if detail is None: + return message + return f"{message}: {redact_logs(detail, managed_env['CANYONOS_JWT_SECRET'])}" + + +def pull(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> str: + try: + result = _run([*_compose_argv(stack, manifest), "pull"]) + except OSError: + raise PhaseFailure("pull", "could not run docker compose pull") + if result.returncode != 0: + raise PhaseFailure( + "pull", _command_failure_message("docker compose pull failed", result, managed_env) + ) + return "dashboard images pulled" + + +def _project_has_running_containers(stack: DashboardStack, manifest: Path) -> bool: + try: + result = _run([*_compose_argv(stack, manifest), "ps", "-q"]) + except OSError: + return False + return result.returncode == 0 and bool(result.stdout.strip()) + + +def start(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> None: + # The api reads the controller's Redis identity once at startup to create + # its project row, so a surviving container keeps serving whichever project + # was deployed before it. Replace it every serve rather than reuse it. + _run([*_compose_argv(stack, manifest), "rm", "-sf", "api"]) + try: + result = _run( + [*_compose_argv(stack, manifest), "up", "-d", "--wait", "--wait-timeout", "180"] + ) + except OSError: + raise PhaseFailure("start", "could not run docker compose up") + if result.returncode != 0: + raise PhaseFailure( + "start", _command_failure_message("docker compose up failed", result, managed_env) + ) + + +def verify(port: int) -> str: + dashboard_url = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + 30 + endpoints = (f"{dashboard_url}/healthz", f"{dashboard_url}/api/healthz") + while time.monotonic() < deadline: + healthy = True + for endpoint in endpoints: + try: + response = urllib.request.urlopen(endpoint, timeout=5) + try: + status = response.status + finally: + response.close() + except (OSError, urllib.error.URLError): + healthy = False + break + if status != 200: + healthy = False + break + if healthy: + return dashboard_url + if time.monotonic() < deadline: + time.sleep(1) + raise PhaseFailure("verify", "dashboard health checks did not return 200 within 30 seconds") + + +def redact_logs(logs: str, jwt_secret: str) -> str: + redacted = logs.replace(jwt_secret, "[redacted]") + return re.sub(r"://[^/\s@]+@", "://[redacted]@", redacted) + + +def _capture_failure_logs(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> Path: + try: + result = _run([*_compose_argv(stack, manifest), "logs", "--no-color", "--tail", "200"]) + logs = f"{result.stdout}\n{result.stderr}" + except OSError: + logs = "Unable to collect docker compose logs." + + log_dir = stack.state_dir / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + log_path = log_dir / f"serve-{timestamp}.log" + _write_private_file( + log_path, + redact_logs(logs, managed_env["CANYONOS_JWT_SECRET"]), + ) + return log_path + + +def _cleanup(stack: DashboardStack, manifest: Path) -> None: + try: + _run([*_compose_argv(stack, manifest), "down"]) + except OSError: + return + + +def run_dashboard( + phase_reporter: Callable[[str, str], None] | None = None, +) -> ServeResult: + def report(result: ServeResult) -> None: + if phase_reporter is not None: + phase_reporter(result.phase, result.message) + + stack: DashboardStack | None = None + managed_env: dict[str, str] | None = None + manifest: Path | None = None + # Whether the stack predates this serve, so a failure only tears down what + # this run brought up. Read once, before anything here can change it. + had_containers = False + with ExitStack() as resources: + try: + stack = validate() + report(ServeResult(True, "validate", "dashboard prerequisites validated")) + + managed_env, prepare_message = prepare(stack) + report(ServeResult(True, "prepare", prepare_message)) + + manifest_resource = importlib.resources.files("canyonos").joinpath("dashboard.compose.yml") + manifest = resources.enter_context(importlib.resources.as_file(manifest_resource)) + had_containers = _project_has_running_containers(stack, manifest) + + report(ServeResult(True, "pull", pull(stack, manifest, managed_env))) + + start(stack, manifest, managed_env) + report(ServeResult(True, "start", "dashboard stack started")) + + url = verify(stack.web_port) + report(ServeResult(True, "verify", "dashboard health checks passed", url)) + return ServeResult(True, "verify", "dashboard health checks passed", url) + except PhaseFailure as failure: + log_path = None + if failure.phase in {"pull", "start", "verify"} and stack and managed_env and manifest: + log_path = _capture_failure_logs(stack, manifest, managed_env) + if not had_containers: + _cleanup(stack, manifest) + return ServeResult( + False, failure.phase, failure.message, None, str(log_path) if log_path else None + ) diff --git a/cli/canyonos/deploy.py b/cli/canyonos/deploy.py new file mode 100644 index 0000000..048cd3d --- /dev/null +++ b/cli/canyonos/deploy.py @@ -0,0 +1,427 @@ +""" +Logic for `canyonos deploy`: copy the project into the container's /workspace +volume (via `canyonos sync`), then tell the Global Controller container to +build and deploy it. The container's `ventis deploy` handles both the build +(stubs, protos, Docker images) and the launch -- the CLI just ships files, +triggers it, and watches the logs. + +That log stream is mostly noise the user didn't ask for (a whole `docker buildx +bake` transcript, among other things), so by default only the phase transitions +worth seeing are rendered and everything else is dropped. `-v` streams it all, +and a failure reveals the output it had been hiding. + +Once the deploy's logs report the workflow is actually up, `canyonos serve` +is kicked off automatically so the local dashboard is ready without an extra +manual step. +""" + +import queue +import re +import subprocess +import threading +import time +from collections import deque + +from rich.panel import Panel +from rich.text import Text + +from canyonos import ui +from canyonos.constants import ( + WORKFLOW_ROUTE, + default_config_path, + workflow_api_port, + workspace_relative, +) +from canyonos.gc import GCError, deploy_status, post_deploy, workflow_endpoints +from canyonos.theme import GREEN, WHITE +from canyonos.init import load_state, run_init +from canyonos.serve import serve_dashboard +from canyonos.sync import run_sync + +LOCAL_HOSTS = ("127.0.0.1", "localhost") + +# Logged exactly once by GlobalController.run(), right after `_wait_for_healthy()` +# returns -- the signal that the workflow finished coming up and entered its +# steady-state polling loop. +_WORKFLOW_UP_MARKER = "Global controller started, polling every" + +# Substrings that mean the in-container deploy hit something fatal. `WARNING:` is +# deliberately absent: the OTel-not-configured notice and stub_generator's +# "Warning:" lines are benign and fire on nearly every run. +_ERROR_MARKERS = ( + "ERROR:", + "Traceback (most recent call last):", + "ERROR: failed to solve", + "process did not complete successfully", +) + +# (substring, spinner message, completed message). A None spinner message keeps +# whatever the spinner already shows; a None completed message prints nothing. +# Matched by substring against the raw line, so a phase that never runs is simply +# never matched -- nothing here assumes a phase happens, or happens in order. +_PHASES = ( + ("Generating stub:", "Generating stubs and Docker contexts...", None), + ("Compiling gRPC proto:", "Generating stubs and Docker contexts...", None), + ("Generating Docker context", "Generating stubs and Docker contexts...", None), + ("Building Docker image:", "Building images...", None), + ("No Docker images to build.", None, "No images to build"), + ("Build complete.", None, "Build complete"), + ("Deploying from config:", "Starting deploy...", None), + ("Checking for stale containers", "Cleaning up stale containers...", None), + ("Redis launched on", None, "Redis ready"), + ("Docker container(s) across", "Starting agents...", None), +) + +_IMAGE_COUNT = re.compile(r"Building (\d+) Docker image\(s\) via") +_REPLICA_COUNT = re.compile(r"Waiting for (\d+) replica\(s\) to become healthy") +# The name repeats across replicas of one agent, so the endpoint is what makes a +# ready line unique. +_READY = re.compile(r"Controller (\S+ \([^)]+\)) is ready\.") + +# Enough to hold a buildx failure block plus a Python traceback; 40 (what +# `canyonos test` tails) truncates both. +_RECENT_LINES = 200 + +# The container logs every request the CLI makes to it, so its own polling shows +# up in the stream it is reading. +_OWN_REQUEST_MARKER = "GET /status HTTP/1.1" + +_STATUS_POLL_SECONDS = 2.0 + +# Upper bound on how long to keep collecting output after a failure is spotted. +_REVEAL_GRACE_SECONDS = 30.0 + + +class PhaseTracker: + """Turns the container's log lines into the handful of events worth showing. + + `feed()` returns (spinner_message, completed_message, is_error) -- any of + which may be None -- so the caller owns all printing. + """ + + def __init__(self): + self.spinner = None + self.replicas_total = 0 + self.replicas_ready = set() + + def _agent_progress(self): + if self.replicas_total: + return f"Starting agents ({len(self.replicas_ready)}/{self.replicas_total} ready)..." + return "Starting agents..." + + def feed(self, line): + if any(marker in line for marker in _ERROR_MARKERS): + return None, None, True + + count = _IMAGE_COUNT.search(line) + if count: + self.spinner = f"Building {count.group(1)} images..." + return self.spinner, None, False + + replicas = _REPLICA_COUNT.search(line) + if replicas: + self.replicas_total = int(replicas.group(1)) + self.spinner = self._agent_progress() + return self.spinner, None, False + + ready = _READY.search(line) + if ready: + self.replicas_ready.add(ready.group(1)) + self.spinner = self._agent_progress() + return self.spinner, None, False + + for marker, spinner, done in _PHASES: + if marker in line: + # Repeats (one `Generating stub:` per agent) collapse: the + # spinner is only re-emitted when the message actually changes. + if spinner and spinner != self.spinner: + self.spinner = spinner + return spinner, done, False + return None, done, False + + return None, None, False + + def agents_ready_message(self): + """(message, all_ready). `_wait_for_healthy` gives up after its timeout and + lets the controller start anyway, so the workflow can come up short. + """ + ready = len(self.replicas_ready) + if not self.replicas_total: + return "Workflow ready", True + if ready < self.replicas_total: + return f"Workflow up, but only {ready}/{self.replicas_total} agents reported healthy", False + return f"{ready} agent(s) ready", True + + +def run_deploy(config_path=None, serve=True, verbose=False): + # Left as None when unset: ventis 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 + + run_init() + + # Copy the current project into the container before building/deploying. + if not run_sync(): + return + + state = load_state() + + # Read for display only -- ventis resolves the path it actually deploys. + api_port = workflow_api_port(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) + + +def workflow_targets(gc_port, api_port): + """(name, host, port) for each deployed workflow. + + The container reports the address it actually placed each workflow at, so a + workflow running on another machine shows that machine's public IP. The + local port mapping is the fallback when it reports nothing. + """ + targets = [ + ( + endpoint.get("name"), + "127.0.0.1" if endpoint["host"] in LOCAL_HOSTS else endpoint["host"], + endpoint["port"], + ) + for endpoint in workflow_endpoints(gc_port) + if endpoint.get("host") and endpoint.get("port") + ] + if targets: + return targets + return [(None, "127.0.0.1", api_port)] if api_port else [] + + +def _summary_body(dashboard_url, targets): + body = Text() + body.append("Dashboard ", "dim") + if dashboard_url: + body.append(dashboard_url, f"bold {GREEN}") + else: + body.append("not running -- start it with `canyonos serve`", WHITE) + + 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("\npoll ", "dim") + body.append(f"{base}/status/", WHITE) + if host not in LOCAL_HOSTS: + body.append(f"\n needs inbound TCP {port} open on {host}", "dim") + return body + + +def print_deploy_summary(dashboard_url, targets): + """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 + afterwards and would otherwise scroll it out of sight. Quiet mode prints + nothing after it, so once is enough. + """ + ui.blank() + ui.panel( + Panel( + _summary_body(dashboard_url, targets), + title=f"[bold {GREEN}]Deploy is live[/]", + title_align="left", + border_style=GREEN, + padding=(1, 4), + ) + ) + ui.blank() + + +def _start_dashboard(): + """The dashboard's URL, or None -- a dashboard that won't start doesn't fail the deploy.""" + try: + return serve_dashboard().url + except Exception as e: + ui.fail(f"Could not start the dashboard automatically: {e}") + ui.hint("Run `canyonos serve` manually to view it.") + return None + + +def _deploy_summary(state, api_port, serve): + summary = ( + _start_dashboard() if serve else None, + workflow_targets(state["port"], api_port), + ) + print_deploy_summary(*summary) + return summary + + +def _interrupted(summary=None): + ui.blank() + ui.say("Stopped monitoring log stream. Run `canyonos stop` to stop the deploy.") + ui.hint("To resubscribe to log stream run `canyonos logs`.") + if summary is not None: + print_deploy_summary(*summary) + + +def _tail_verbose(stream, state, api_port, serve): + """Every log line, verbatim -- what `-v` restores. + + Ctrl+C reprints the summary here but not in quiet mode: only this tail keeps + printing past it, so only here has it scrolled out of sight. + """ + summary = None + try: + for line in stream: + print(line, end="") + if summary is None and _WORKFLOW_UP_MARKER in line: + summary = _deploy_summary(state, api_port, serve) + except KeyboardInterrupt: + _interrupted(summary) + + +def _tail_quiet(lines, state, api_port, serve): + """Only the phase transitions, until the workflow is up or something fails. + + Nothing is echoed raw: the buildx transcript, ventis' bare prints and grpc's + stderr have no common prefix to filter on, so anything unrecognized is + dropped rather than allow-listed. `-v` and `canyonos logs` still have it all. + """ + tracker = PhaseTracker() + recent = deque(maxlen=_RECENT_LINES) + reached_up_marker = False + + # The spinner is exited before the summary panel or the dashboard's own + # spinner is drawn, and on the way out of a Ctrl+C, so the cursor is restored. + # A nested spinner wouldn't raise, it would silently render nothing. + with ui.status("Starting build...") as spinner: + for line in _drain(lines, state): + recent.append(line) + message, done, is_error = tracker.feed(line) + if is_error: + break + if done: + ui.ok(done) + if message: + spinner.update(message) + if _WORKFLOW_UP_MARKER in line: + summary_line, all_ready = tracker.agents_ready_message() + (ui.ok if all_ready else ui.warn)(summary_line) + reached_up_marker = True + break + + if reached_up_marker: + return _deploy_summary(state, api_port, serve) + + _reveal_failure(lines, recent, state) + return None + + +def _queued_lines(stream): + """Feed `stream` into a queue, terminated by None, so reads can time out. + + A failed build leaves the log stream open and silent -- the deploy is only a + subprocess of the container being tailed -- so blocking on the next line + would wait forever with nothing left to report. + """ + lines = queue.Queue() + + def read(): + for line in stream: + lines.put(line) + lines.put(None) + + threading.Thread(target=read, daemon=True).start() + return lines + + +def _drain(lines, state, deadline=None): + """Yield log lines until the stream ends, the deploy dies, or `deadline` passes. + + The container's /status is polled on the read timeout rather than per line, + because the container logs each of those requests into the very stream being + read -- which would otherwise feed itself. + """ + misses = 0 + while deadline is None or time.monotonic() < deadline: + try: + line = lines.get(timeout=_STATUS_POLL_SECONDS) + except queue.Empty: + # Nothing for a while: check the deploy is still alive, since a + # build that died takes the output with it but not the stream. + dead, misses = _deploy_is_dead(state, misses) + if dead: + return + continue + if line is None: + return + misses = 0 + if _OWN_REQUEST_MARKER not in line: + yield line + + +def _deploy_is_dead(state, misses): + """Whether the in-container deploy has stopped, over two consecutive checks. + + An unreachable container counts as a miss rather than a verdict, so one + dropped request doesn't end a deploy that is merely busy. + """ + status = deploy_status(state["port"]) + if status is not None and status.get("running"): + return False, 0 + misses += 1 + return misses >= 2, misses + + +def _reveal_failure(lines, recent, state): + """Stop hiding: replay what was suppressed, then keep echoing. + + The cause is usually still in flight when the verdict lands, so this keeps + draining until the container confirms the deploy is gone. + """ + ui.fail("Deploy failed.") + ui.blank() + for buffered in recent: + print(buffered, end="") + + for line in _drain(lines, state, deadline=time.monotonic() + _REVEAL_GRACE_SECONDS): + print(line, end="") + + ui.blank() + 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): + """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. + """ + process = subprocess.Popen( + ["docker", "logs", "-f", state["container_id"]], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + try: + if verbose: + _tail_verbose(process.stdout, state, api_port, serve) + return + lines = _queued_lines(process.stdout) + if _tail_quiet(lines, state, api_port, 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: + pass + except KeyboardInterrupt: + _interrupted() + finally: + if process.poll() is None: + process.terminate() diff --git a/cli/canyonos/doctor.py b/cli/canyonos/doctor.py new file mode 100644 index 0000000..15c36af --- /dev/null +++ b/cli/canyonos/doctor.py @@ -0,0 +1,79 @@ +""" +Logic for `canyonos doctor`: a simple checklist of environment checks +(Docker installed/running, Compose available). Each check just reports +pass/fail plus a suggested fix -- nothing here attempts to auto-fix anything. +""" + +import shutil +import subprocess + +from canyonos import ui +from canyonos.build import AGENTS +from canyonos.init import docker_running, docker_start_command + + +def _compose_available(): + result = subprocess.run(["docker", "compose", "version"], capture_output=True) + return result.returncode == 0 + + +def _docker_daemon_fix(): + """Names the command for the active docker context, since `canyonos deploy` + would run exactly that itself.""" + command = docker_start_command() + if command: + return f"run `{' '.join(command)}` -- or just run `canyonos deploy`, which starts it for you" + return "start your Docker runtime (on Linux: `sudo systemctl start docker`)" + + +def _checks(): + return [ + ( + "Docker installed", + lambda: shutil.which("docker") is not None, + "install Docker: https://docs.docker.com/get-docker/", + ), + ( + "Docker daemon running", + docker_running, + _docker_daemon_fix(), + ), + ( + "Docker Compose available", + _compose_available, + "update Docker to a version that includes Compose v2 (needed for `canyonos serve`)", + ), + ( + "git available", + lambda: shutil.which("git") is not None, + "install git (`canyonos build` fetches the porting skill with it; " + "without git it falls back to a full-repo tarball download)", + ), + ( + "Coding agent available", + lambda: any(shutil.which(spec["cli"]) for spec in AGENTS.values()), + "install one of " + + " or ".join(spec["label"] for spec in AGENTS.values()) + + " (`canyonos build` runs the port through it)", + ), + ] + + +def run_doctor(): + """Run every check, print a pass/fail checklist, and return True iff all passed.""" + all_ok = True + for label, check, fix in _checks(): + try: + passed = bool(check()) + except OSError as e: + passed = False + fix = f"{fix} (error: {e})" + + if passed: + ui.ok(label) + else: + ui.fail(label) + ui.hint(f" -> {fix}") + all_ok = False + + return all_ok diff --git a/cli/canyonos/gc.py b/cli/canyonos/gc.py new file mode 100644 index 0000000..a8778b3 --- /dev/null +++ b/cli/canyonos/gc.py @@ -0,0 +1,97 @@ +""" +Shared request helpers for the Global Controller container, so the commands +that talk to it don't each restate the same routes, payloads and failure modes. +""" + +import json +import urllib.error +import urllib.request + +from canyonos import ui +from canyonos.init import load_state + +_DEPLOY_CONFLICT = "Run `canyonos stop` to stop the running deploy first." + + +class GCError(Exception): + """A failed Global Controller request, carrying a message fit to print.""" + + def __init__(self, message, code=None): + super().__init__(message) + self.code = code + + +def _error_detail(e): + """The server's `error` field, falling back to the raw body when it isn't JSON.""" + body = e.read().decode(errors="replace").strip() + try: + return json.loads(body).get("error", body) + except ValueError: + return body or f"HTTP {e.code}" + + +def _request(url, action, data=None, method="GET"): + headers = {"Content-Type": "application/json"} if data is not None else {} + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + raise GCError(f"{action} failed: {_error_detail(e)}", code=e.code) from None + except urllib.error.URLError as e: + raise GCError(f"Could not reach Global Controller container: {e.reason}") from None + + +def require_state(): + """Recorded container state, or None after reporting that there is none.""" + try: + return load_state() + except FileNotFoundError: + ui.warn("No Global Controller container is running. Run `canyonos deploy` first.") + return None + + +def post_deploy(port, config_path=None): + """Start a deploy inside the container. Raises GCError on failure. + + Omitting config_path lets ventis resolve it against the synced workspace. + """ + body = json.dumps({"config_path": config_path} if config_path else {}).encode() + try: + return _request(f"http://127.0.0.1:{port}/deploy", "Deploy", data=body, method="POST") + except GCError as e: + if e.code == 409: + raise GCError(f"{e}\n{_DEPLOY_CONFLICT}", code=409) from None + raise + + +def post_clean(port): + """Tear down the running deploy: SIGTERMs the in-container `ventis deploy` + process, whose handler calls GlobalController.stop() and blocks until it + returns. This is what actually removes the local controller and Redis + containers a deploy spawned via docker-outside-of-docker. + """ + return _request(f"http://127.0.0.1:{port}/clean", "Stop", method="POST") + + +def workflow_endpoints(port): + """Where the deployed workflows answer, per the container's own instance + records -- for a workflow placed on another machine that is its public IP, + not this host. Empty when the container can't say (an older image has no + /endpoints route), which leaves the caller on its local-port fallback. + """ + try: + data = _request(f"http://127.0.0.1:{port}/endpoints", "Endpoints") + except GCError: + return [] + return data.get("workflows") or [] + + +def deploy_status(port): + """Parsed /status payload, or None if the container is unreachable.""" + url = f"http://127.0.0.1:{port}/status" + try: + with urllib.request.urlopen(url, timeout=5) as resp: + return json.loads(resp.read()) + except OSError: + return None diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py new file mode 100644 index 0000000..6b07f84 --- /dev/null +++ b/cli/canyonos/init.py @@ -0,0 +1,215 @@ +""" +Logic for `canyonos init`, does the following: +1. Pull the Global Controller image +2. Start a container from it +3. Record where it's listening so cli knows where to send requests. +""" + +import json +import os +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request + +# Formatting +from pyfiglet import figlet_format + +from canyonos import ui + + + +# Image Name, need to switch to CanyonCore Organization Namespace later +GC_IMAGE = "saakeths/canyonos:latest" +GC_CONTAINER_PORT = 8000 + +# Named docker volume mounted at /workspace inside the container. Files are +# copied in via `canyonos sync` (docker cp), not mounted live, so host-side +# edits don't reach a running build. `canyonos quit` removes the volume, and +# since every deploy quits any previous controller first, each deploy starts +# from an empty workspace. +GC_WORKSPACE_VOLUME = "canyonos-workspace" +GC_WORKSPACE_PATH = "/workspace" + +STATE_DIR = os.path.expanduser("~/.canyonos") +STATE_PATH = os.path.join(STATE_DIR, "state.json") + +# How to start the daemon behind each docker context, as (CLI command, macOS +# app). Keyed off the *active context* rather than which app is installed: with +# both Docker Desktop and OrbStack present, guessing by app bundle starts the +# wrong daemon and then waits out the timeout against a socket nothing is +# listening on. +DOCKER_RUNTIMES = { + "orbstack": (["orb", "start"], "OrbStack"), + "colima": (["colima", "start"], None), + "desktop-linux": (None, "Docker"), + "default": (None, "Docker"), +} +DOCKER_START_TIMEOUT = 60 + + +def docker_running(): + try: + return subprocess.run(["docker", "info"], capture_output=True).returncode == 0 + except OSError: + return False + + +def docker_start_command(): + """The command that starts the daemon for the active context, or None.""" + try: + result = subprocess.run( + ["docker", "context", "show"], capture_output=True, text=True + ) + except OSError: + return None + + context = result.stdout.strip() if result.returncode == 0 else "default" + command, app = DOCKER_RUNTIMES.get(context, (None, "Docker")) + if command and shutil.which(command[0]): + return command + if app and sys.platform == "darwin" and os.path.isdir(f"/Applications/{app}.app"): + return ["open", "-a", app] + return None + + +def ensure_docker_running(timeout=DOCKER_START_TIMEOUT): + if docker_running(): + return + + command = docker_start_command() + if command is None: + # Linux/systemd wants root here; escalating on the user's behalf is not + # this CLI's call to make. + raise RuntimeError( + "Docker isn't running, and there's no way to start it for the current " + "docker context. Start it (on Linux: `sudo systemctl start docker`) and re-run." + ) + + ui.say(f"Docker isn't running -- starting it with `{' '.join(command)}`...") + subprocess.run(command, capture_output=True) + + deadline = time.time() + timeout + with ui.status("Waiting for the Docker daemon..."): + while time.time() < deadline: + if docker_running(): + ui.ok("Docker is running.") + return + time.sleep(1) + + raise RuntimeError( + f"Docker did not become ready within {timeout}s. Start it manually and re-run." + ) + + +def pull_image(image=GC_IMAGE): + # Capture output so the rich status spinner isn't clobbered by docker's own + # layer-progress printing -- but surface it on failure (auth, network, + # rate-limit, missing arch, etc. all otherwise look like the same opaque + # "exit status 1"). + result = subprocess.run(["docker", "pull", image], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError( + f"docker pull {image} failed: {result.stderr.strip() or result.stdout.strip()}" + ) + + +def _port_reachable(port, attempts=10, delay=0.5): + """ + A successful `docker run` only means Docker accepted the port binding -- + not that traffic actually flows. OrbStack's own port-forwarding proxy for + a given port can get stuck (heavy churn on the same port is enough to + trigger it), which looks fine at the Docker level but resets every real + connection. Confirm the container is actually reachable before trusting it. + """ + url = f"http://127.0.0.1:{port}/status" + for _ in range(attempts): + try: + urllib.request.urlopen(url, timeout=1) + return True + except (urllib.error.URLError, OSError): + time.sleep(delay) + return False + + +def run_container(image=GC_IMAGE, max_attempts=50): + port = GC_CONTAINER_PORT + for _ in range(max_attempts): + result = subprocess.run( + [ + "docker", + "run", + "-d", + "-p", + f"127.0.0.1:{port}:{GC_CONTAINER_PORT}", + # 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. + "-v", + "/var/run/docker.sock:/var/run/docker.sock", + "-v", + f"{GC_WORKSPACE_VOLUME}:{GC_WORKSPACE_PATH}", + "--add-host=host.docker.internal:host-gateway", + "-e", + "VENTIS_REDIS_HOST=host.docker.internal", + image, + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + container_id = result.stdout.strip() + if _port_reachable(port): + return container_id, port + # Port bound fine but never actually became reachable -- treat + # like a conflict, since that's effectively what it is. + subprocess.run(["docker", "rm", "-f", container_id], capture_output=True) + port += 1 + continue + if "port is already allocated" in result.stderr: + port += 1 + continue + raise RuntimeError(result.stderr) + raise RuntimeError(f"no free port found after {max_attempts} attempts starting at {GC_CONTAINER_PORT}") + + +def save_state(container_id, port): + os.makedirs(STATE_DIR, exist_ok=True) + with open(STATE_PATH, "w") as f: + json.dump({"container_id": container_id, "port": port}, f) + + +def load_state(): + with open(STATE_PATH) as f: + return json.load(f) + + +def quit_existing(): + """Tear down a previously started Global Controller, if state records one. + + Without this each run starts another container on the next free port and + orphans the last one, which then can't be reached through state.json. + """ + # Deferred: quit.py imports from this module, so a top-level import cycles. + from canyonos.quit import run_quit + + if os.path.isfile(STATE_PATH): + run_quit() + + +def run_init(banner=True): + if banner: + ui.gradient(figlet_format("CANYON OS", font="ansi_shadow", width=200)) + + # Before quit_existing(), which shells out to docker itself. + ensure_docker_running() + quit_existing() + + with ui.status("Pulling Global Controller image..."): + pull_image() + with ui.status("Starting Global Controller container..."): + container_id, port = run_container() + save_state(container_id, port) + ui.ok(f"Global Controller running in container {container_id[:12]} on port {port}") diff --git a/cli/canyonos/logs.py b/cli/canyonos/logs.py new file mode 100644 index 0000000..3969af6 --- /dev/null +++ b/cli/canyonos/logs.py @@ -0,0 +1,29 @@ +""" +Logic for `canyonos logs`: re-subscribe to the running deploy's log stream. +""" + +import subprocess + +from canyonos import ui +from canyonos.gc import deploy_status, require_state + + +def run_logs(): + state = require_state() + if state is None: + return + + status = deploy_status(state["port"]) + if status is None: + ui.fail("Could not reach Global Controller container.") + return + + if not status.get("running"): + ui.warn("No deploy running, run `canyonos deploy` to deploy project.") + return + + try: + subprocess.run(["docker", "logs", "-f", state["container_id"]]) + except KeyboardInterrupt: + ui.blank() + ui.say("Stopped monitoring log stream. Run `canyonos stop` to stop the deploy.") diff --git a/cli/canyonos/new_app.py b/cli/canyonos/new_app.py new file mode 100644 index 0000000..31aeb44 --- /dev/null +++ b/cli/canyonos/new_app.py @@ -0,0 +1,23 @@ +""" +Logic for `canyonos new-app`: scaffold a new project in the current +directory. Runs locally, no container involved. +""" + +import os + +from canyonos import ui + + +def run_new_app(): + if os.listdir("."): + ui.fail("Directory is not empty. Run `canyonos new-app` in an empty directory.") + return + + for folder in ("agents", "config", "workflow"): + os.makedirs(folder) + open(".env", "w").close() + + for filename in ("global_controller.yaml", "policy.yaml"): + open(os.path.join("config", filename), "w").close() + + ui.ok("Created new CanyonOS project.") diff --git a/cli/canyonos/quit.py b/cli/canyonos/quit.py new file mode 100644 index 0000000..9af5dcc --- /dev/null +++ b/cli/canyonos/quit.py @@ -0,0 +1,57 @@ +""" +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.) +""" + +import os +import subprocess + +from canyonos import ui +from canyonos.gc import GCError, post_clean, require_state +from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH + + +def _container_exists(container_id): + result = subprocess.run( + ["docker", "inspect", container_id], capture_output=True + ) + return result.returncode == 0 + + +def run_quit(): + state = require_state() + if state is None: + return + + container_id = state["container_id"] + with ui.status("Tearing down..."): + # Stop any running deploy first, so the local controller and Redis + # containers it spawned via docker-outside-of-docker get torn down + # too. Removing the GC container itself doesn't touch them -- they're + # sibling containers on the host, not nested inside it. + try: + post_clean(state["port"]) + except GCError: + # Nothing was running, or the GC is already unreachable/gone. + pass + + # state.json can go stale (daemon restarted, container removed by + # hand, a previous `quit` died partway through) -- don't let a + # missing container turn `quit` into a crash instead of a cleanup. + already_gone = not _container_exists(container_id) + if not already_gone: + subprocess.run(["docker", "stop", container_id], check=False, capture_output=True) + subprocess.run(["docker", "rm", container_id], check=False, capture_output=True) + + # Remove the workspace volume only after the container is gone (docker + # 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) + os.remove(STATE_PATH) + + if already_gone: + ui.warn(f"Global Controller container {container_id[:12]} was already gone; cleaned up local state.") + else: + ui.ok(f"Global Controller container {container_id[:12]} torn down (volume removed)") diff --git a/cli/canyonos/serve.py b/cli/canyonos/serve.py new file mode 100644 index 0000000..c96e336 --- /dev/null +++ b/cli/canyonos/serve.py @@ -0,0 +1,37 @@ +"""CLI output for the local dashboard stack.""" + +from canyonos import ui +from .dashboard_stack import ServeResult, run_dashboard + + +def serve_dashboard() -> ServeResult: + """Bring the dashboard up, reporting progress. Returns the stack's result.""" + # Phases drive the spinner while the stack comes up; the trace itself is + # only printed when something fails and the user needs to see how far it got. + trace = [] + + with ui.status("Starting the dashboard...") as spinner: + def report(phase: str, message: str) -> None: + trace.append((phase, message)) + spinner.update(message) + + result = run_dashboard(report) + + if result.ok: + return result + + for phase, message in trace: + ui.hint(f"{phase}: {message}") + ui.fail(f"serve failed in {result.phase}: {result.message}") + if result.log_path: + ui.hint(f"log: {result.log_path}") + return result + + +def run_serve() -> int: + result = serve_dashboard() + if not result.ok: + return 1 + + ui.ok(f"Dashboard: {result.url}") + return 0 diff --git a/cli/canyonos/status.py b/cli/canyonos/status.py new file mode 100644 index 0000000..071297e --- /dev/null +++ b/cli/canyonos/status.py @@ -0,0 +1,55 @@ +""" +Logic for `canyonos status`: reports whether a deploy is currently running, +and if so, where the workflow (and, if up, the dashboard) answer. +""" + +from canyonos import ui +from canyonos.constants import WORKFLOW_ROUTE, default_config_path, workflow_api_port +from canyonos.dashboard_stack import _existing_dashboard_port +from canyonos.deploy import workflow_targets +from canyonos.gc import deploy_status, require_state + + +def run_status(): + state = require_state() + if state is None: + return + + status = deploy_status(state["port"]) + if not status or not status.get("running"): + ui.warn("No deploy is currently running.") + return + + ui.ok("Deploy is running.") + + # Same resolution `deploy` uses, so both report the address the container + # actually placed the workflow at and fall back to the configured api_port + # rather than a guess. + targets = workflow_targets(state["port"], workflow_api_port(default_config_path())) + for name, target_host, target_port in targets: + label = f"Workflow {name}" if name else "Workflow" + ui.say(f"{label}: {target_host}:{target_port}") + if not targets: + ui.hint("No workflow endpoints reported yet.") + + dashboard_port = _existing_dashboard_port() + if dashboard_port: + ui.say(f"Dashboard: 127.0.0.1:{dashboard_port}") + else: + ui.hint("Dashboard is not running. Run `canyonos serve` to start it.") + + if not targets: + return + + # The body is splatted into the workflow entrypoint as kwargs, so its keys + # are that function's parameter names -- `query` for every bundled example, + # but swap in whatever yours actually takes. + _, host, port = targets[0] + ui.blank() + ui.hint("Query the workflow:") + ui.say(f" curl -X POST http://{host}:{port}/{WORKFLOW_ROUTE} \\") + ui.say(' -H "Content-Type: application/json" \\') + ui.say(" -d '{\"query\": \"your question here\"}'") + ui.blank() + ui.hint("Check a request's result:") + ui.say(f" curl http://{host}:{port}/status/") diff --git a/cli/canyonos/stop.py b/cli/canyonos/stop.py new file mode 100644 index 0000000..519cf49 --- /dev/null +++ b/cli/canyonos/stop.py @@ -0,0 +1,20 @@ +""" +Logic for `canyonos stop`: stop the running deploy inside the Global +Controller container (SIGTERM, same teardown as Ctrl+C would trigger). +""" + +from canyonos import ui +from canyonos.gc import GCError, post_clean, require_state + + +def run_stop(): + state = require_state() + if state is None: + return + + try: + with ui.status("Stopping deploy..."): + post_clean(state["port"]) + ui.ok("Deploy stopped.") + except GCError as e: + ui.fail(e) diff --git a/cli/canyonos/sync.py b/cli/canyonos/sync.py new file mode 100644 index 0000000..84f875c --- /dev/null +++ b/cli/canyonos/sync.py @@ -0,0 +1,45 @@ +""" +Logic for `canyonos sync`: copy the current project directory into the Global +Controller container's /workspace volume via `docker cp`. + +Files live inside the container's named volume (see `init.py`), not on a live +bind mount, so host-side edits don't reach a running build. `docker cp` is +additive -- it overwrites and adds but never deletes -- so a standalone +re-sync leaves behind anything removed from the host since the last one. +That can't accumulate across deploys: `canyonos deploy` quits any previous +controller first, which removes the volume. +""" + +import os +import subprocess + +from canyonos import ui +from canyonos.gc import require_state +from canyonos.init import GC_WORKSPACE_PATH + + +def run_sync(): + """Copy the current directory into the container. Returns True on success.""" + state = require_state() + if state is None: + return False + + container_id = state["container_id"] + # Trailing "/." copies the *contents* of the current directory into + # /workspace, rather than nesting it under /workspace/. + src = os.path.join(os.getcwd(), ".") + label = f"Syncing {os.getcwd()} -> {container_id[:12]}:{GC_WORKSPACE_PATH}" + + with ui.status(f"{label}..."): + # Captured so docker's own progress output doesn't clobber the spinner. + result = subprocess.run( + ["docker", "cp", src, f"{container_id}:{GC_WORKSPACE_PATH}"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + ui.fail(f"Sync failed: {result.stderr.strip() or result.stdout.strip()}") + return False + + ui.ok("Sync complete.") + return True diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py new file mode 100644 index 0000000..2409965 --- /dev/null +++ b/cli/canyonos/test.py @@ -0,0 +1,396 @@ +""" +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, 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, and one prompt is sent to the workflow's `/main` endpoint. + +A passing run leaves nothing behind. A failing one leaves the Global Controller +container up, with the tail of its log, so there is something left to debug. +""" + +import json +import os +import socket +import subprocess +import time +import urllib.error +import urllib.request + +from rich.panel import Panel +from rich.text import Text + +from canyonos import ui +from canyonos.constants import ( + WORKFLOW_ROUTE, + default_config_path, + round_trip_yaml, + 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.theme import GREEN, WHITE +from canyonos.verify import ( + ARTIFACT_DIR, + VerificationError, + verify_build_artifact, + verify_runtime, +) + +DEFAULT_QUERY = "hello" +# Generous: the first deploy of a project builds every agent image from scratch. +READY_TIMEOUT = 900 +REQUEST_TIMEOUT = 600 +SUBMIT_TIMEOUT = 30 +POLL_INTERVAL = 2 +LOG_TAIL_LINES = 40 + + +def _force_local_providers(config_path): + """Set every agent's provider to `local`. Returns the original file text.""" + with open(config_path) as f: + original = f.read() + + yaml_rt = round_trip_yaml() + data = yaml_rt.load(original) + + for agent in data.get("agents") or []: + agent["provider"] = "local" + + with open(config_path, "w") as f: + yaml_rt.dump(data, f) + + 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. + + Any HTTP response counts -- /status/ 404s, which still proves + the server is up and listening. + """ + url = f"http://{host}:{port}/status/canyonos-test-probe" + try: + urllib.request.urlopen(url, timeout=2) + return True + except urllib.error.HTTPError: + return True + except OSError: + return False + + +def _wait_for_workflow(gc_port, api_port): + deadline = time.time() + READY_TIMEOUT + with ui.status("Building images and starting containers..."): + while time.time() < deadline: + if _workflow_ready("127.0.0.1", api_port): + return + if not (deploy_status(gc_port) or {}).get("running", False): + raise _TestFailed("The deploy stopped before the workflow came up.") + time.sleep(POLL_INTERVAL) + raise _TestFailed(f"Timed out after {READY_TIMEOUT}s waiting for the workflow to come up.") + + +def _send_query(host, port, query): + url = f"http://{host}:{port}/{WORKFLOW_ROUTE}" + body = json.dumps({"query": query}).encode() + req = urllib.request.Request( + url, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + with urllib.request.urlopen(req, timeout=SUBMIT_TIMEOUT) as resp: + return json.loads(resp.read())["request_id"] + + +def _await_result(host, port, request_id): + url = f"http://{host}:{port}/status/{request_id}" + deadline = time.time() + REQUEST_TIMEOUT + with ui.status("Running query..."): + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=10) as resp: + data = json.loads(resp.read()) + if data.get("status") in ("done", "error"): + return data + except OSError: + # A blip while the workflow is busy; keep polling until the deadline. + pass + time.sleep(POLL_INTERVAL) + return {"status": "timeout"} + + +def _log_tail(container_id): + result = subprocess.run( + ["docker", "logs", "--tail", str(LOG_TAIL_LINES), container_id], + capture_output=True, + text=True, + ) + return (result.stdout + result.stderr).strip() or None + + +class _TestFailed(Exception): + """Ends the run early, carrying a message fit for either output mode.""" + + +class _Run: + """One `canyonos test` invocation: the phases it got through, and what they found.""" + + def __init__(self, query): + self.query = query + self.started = time.monotonic() + # Only once a deploy is under way is the container worth keeping and its + # 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 + self.error = None + self.log_tail = None + + 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}") + + def done(self, detail=None): + self.phases[-1].update(ok=True, detail=detail) + + def failed(self, detail): + if self.phases: + self.phases[-1]["detail"] = detail + + 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 + + try: + run.validation = verify_build_artifact() + except VerificationError as e: + raise _TestFailed(str(e)) from None + 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): + run.begin("deploy", 2, "Deploy locally") + run_init(banner=False) + + if not run_sync(): + raise _TestFailed("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 _TestFailed( + 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 _TestFailed(str(e)) from None + run.deploy_started = True + + _wait_for_workflow(state["port"], api_port) + run.done(f"Global Controller on port {state['port']}") + return state + + +def _verify_runtime(run, config_path, gc_port): + run.begin("verify_runtime", 3, "Verify runtime") + try: + run.runtime = verify_runtime(config_path, gc_port) + except VerificationError as e: + raise _TestFailed(str(e)) from None + run.done(f"{len(run.runtime['agents'])} agent(s) up") + + +def _query(run, gc_port, api_port): + run.begin("query", 4, "Query the workflow") + targets = workflow_targets(gc_port, api_port) + if not targets: + raise _TestFailed("The deploy reported no workflow endpoint to query.") + + _, host, port = targets[0] + run.endpoint = f"http://{host}:{port}/{WORKFLOW_ROUTE}" + ui.say(f"POST {run.endpoint} {json.dumps({'query': run.query})}") + + try: + request_id = _send_query(host, port, run.query) + except OSError as e: + raise _TestFailed(f"Could not reach the workflow at {run.endpoint}: {e}") from None + + data = _await_result(host, port, request_id) + status = data.get("status") + if status == "error": + raise _TestFailed(data.get("error") or "the workflow returned an error.") + if status != "done": + raise _TestFailed(f"The workflow did not finish within {REQUEST_TIMEOUT}s.") + + run.result = data.get("result") + run.done(f"answered in {run.elapsed()}s") + + +def _run_test(run): + """Walk the four phases, restoring the config whatever happens.""" + config_path = workspace_relative(default_config_path()) + if config_path is None: + raise _TestFailed("Config must be inside the project directory being synced.") + if not os.path.isfile(config_path): + raise _TestFailed(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 _TestFailed(f"No agent with `type: workflow` in {config_path}; nothing to test.") + + original_config = _force_local_providers(config_path) + try: + state = _deploy_locally(run, config_path, api_port) + _verify_runtime(run, config_path, state["port"]) + _query(run, state["port"], api_port) + finally: + with open(config_path, "w") as f: + f.write(original_config) + + +# ------------------------------------------------------------------ # +# Output # +# ------------------------------------------------------------------ # + + +def _summary_body(run): + body = Text() + body.append("Query ", "dim") + body.append(run.query, WHITE) + if run.endpoint: + body.append("\nEndpoint ", "dim") + body.append(run.endpoint, WHITE) + body.append("\nElapsed ", "dim") + body.append(f"{run.elapsed()}s", WHITE) + + body.append("\n") + for phase in run.phases: + body.append("\n") + body.append("✓ " if phase["ok"] else "✗ ", GREEN if phase["ok"] else "bold red") + body.append(f"{phase['name']:<16}", WHITE) + # The failing phase's detail is the error, spelled out below in full. + body.append(phase["detail"] if phase["ok"] else "", "dim") + + body.append("\n\n") + if run.error is None: + body.append("Result ", "dim") + body.append(json.dumps(run.result, indent=2), WHITE) + else: + body.append(run.error, "bold red") + return body + + +def _print_summary(run): + passed = run.error is None + ui.blank() + ui.panel( + Panel( + _summary_body(run), + title=f"[bold {GREEN}]Test passed[/]" if passed else "[bold red]Test failed[/]", + title_align="left", + border_style=GREEN if passed else "red", + 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:") + ui.say(run.log_tail) + ui.blank() + ui.hint("Containers left running for inspection: `canyonos logs` | `canyonos quit`") + + +def _payload(run): + return { + "ok": run.error is None, + "query": run.query, + "elapsed_s": run.elapsed(), + "phases": run.phases, + "validation": run.validation, + "runtime": run.runtime, + "result": run.result, + "error": run.error, + "log_tail": run.log_tail, + } + + +def run_test(prompt=None, as_json=False): + run = _Run(prompt or DEFAULT_QUERY) + ui.set_quiet(as_json) + + try: + container_live = False + try: + _run_test(run) + except _TestFailed as e: + run.error = str(e) + except KeyboardInterrupt: + run.error = "cancelled by user" + except RuntimeError as e: + # Docker unreachable, image pull failed, no free port: all carry a + # readable message, and `--json` needs it inside the payload. + run.error = str(e) + + if run.error is not None: + run.failed(run.error) + + if run.error is not None and run.deploy_started: + # Read the log before anything else touches the container, and leave + # it running -- a torn-down deploy can't be diagnosed. + try: + run.log_tail = _log_tail(load_state()["container_id"]) + container_live = True + except (FileNotFoundError, OSError): + pass + else: + quit_existing() + + if as_json: + print(json.dumps(_payload(run), indent=2)) + else: + _print_summary(run) + if container_live: + _print_failure_logs(run) + + return 0 if run.error is None else 1 + finally: + ui.set_quiet(False) diff --git a/cli/canyonos/theme.py b/cli/canyonos/theme.py new file mode 100644 index 0000000..e74a069 --- /dev/null +++ b/cli/canyonos/theme.py @@ -0,0 +1,20 @@ +""" +CanyonOS standard color palette. + +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. +""" + +GREEN = "#2BD17E" +WHITE = "#FFFFFF" + +# Primary -> secondary ramp (used for the init banner, top to bottom). +GRADIENT = [ + "#2BD17E", + "#55DA98", + "#80E3B2", + "#AAEDCB", + "#D5F6E5", + "#FFFFFF", +] diff --git a/cli/canyonos/ui.py b/cli/canyonos/ui.py new file mode 100644 index 0000000..056999a --- /dev/null +++ b/cli/canyonos/ui.py @@ -0,0 +1,67 @@ +""" +The CLI's one output surface: every user-facing line goes through here so the +whole tool speaks with the same palette, symbols and spinner. + +Messages are emitted as literal text, never as rich markup, so a path or an +error containing square brackets can't be swallowed as a style tag. +""" + +from contextlib import contextmanager + +from rich.console import Console +from rich.text import Text + +from canyonos.theme import GRADIENT, GREEN, WHITE + +console = Console() + + +def set_quiet(quiet): + """Silence every helper here, so `canyonos test --json` emits only its payload.""" + console.quiet = quiet + + +def _emit(message, style, symbol=None): + parts = [(f"{symbol} ", style)] if symbol else [] + parts.append((str(message), WHITE if symbol else style)) + console.print(Text.assemble(*parts)) + + +def say(message): + _emit(message, WHITE) + + +def ok(message): + _emit(message, GREEN, "✓") + + +def fail(message): + _emit(message, "bold red", "✗") + + +def warn(message): + _emit(message, "yellow", "!") + + +def hint(message): + _emit(message, "dim") + + +def blank(): + console.print() + + +def gradient(text): + """Print `text` line by line down the brand ramp (the `init` banner).""" + for line, color in zip(text.splitlines(), GRADIENT): + console.print(line, style=color) + + +def panel(renderable): + console.print(renderable) + + +@contextmanager +def status(message): + with console.status(message) as spinner: + yield spinner diff --git a/cli/canyonos/verify.py b/cli/canyonos/verify.py new file mode 100644 index 0000000..d047885 --- /dev/null +++ b/cli/canyonos/verify.py @@ -0,0 +1,291 @@ +""" +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. + +`verify_runtime` checks a running local deploy against what the config declared +-- every image built, every replica up -- because the controller logs a warning +and carries on when an agent never becomes healthy, so a workflow that answers +is not on its own proof that the deploy is complete. +""" + +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 `ventis` 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 = "ventis-local-" + + +class VerificationError(Exception): + """A check that should end the run, carrying a message fit to print.""" + + +# ------------------------------------------------------------------ # +# 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 `ventis` 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("ventis"): + 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 VerificationError 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 VerificationError( + 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 ventis 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 VerificationError( + f"The build artifact has {summary['errors']} validation error(s); fix them " + "or re-run `canyonos build`." + ) + return summary + + +# ------------------------------------------------------------------ # +# Runtime # +# ------------------------------------------------------------------ # + + +def _built_images(): + result = subprocess.run( + ["docker", "images", "--format", "{{.Repository}}"], capture_output=True, text=True + ) + return set(result.stdout.split()) + + +def _running_containers(): + result = subprocess.run( + ["docker", "ps", "--filter", f"name={RUNTIME_PREFIX}", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + return result.stdout.split() + + +def _runtime_table(rows): + table = Table(border_style=GREEN, header_style=f"bold {GREEN}", title_style=f"bold {GREEN}") + for column in ("Agent", "Image", "Replicas", "Endpoint"): + table.add_column(column) + for row in rows: + replicas = f"{row['running']}/{row['expected']}" + style = "" if row["ok"] else "bold red" + table.add_row( + row["name"], + row["image"] if row["image_built"] else f"{row['image']} (missing)", + replicas, + row["endpoint"] or "-", + style=style, + ) + return table + + +def verify_runtime(config_path, gc_port): + """Check the running deploy against the config. Raises VerificationError on a gap.""" + with open(config_path) as f: + config = yaml.safe_load(f) or {} + + images = _built_images() + containers = _running_containers() + endpoints = { + endpoint.get("name"): f"{endpoint['host']}:{endpoint['port']}" + for endpoint in gc.workflow_endpoints(gc_port) + if endpoint.get("host") and endpoint.get("port") + } + + rows = [] + problems = [] + for agent in config.get("agents") or []: + name = agent.get("name") + if not name: + continue + # Image and container names the local provider derives from the agent name. + image = f"ventis-{name.lower()}" + expected = int(agent.get("replicas", 1) or 1) + running = sum(1 for c in containers if c.startswith(f"{RUNTIME_PREFIX}{name.lower()}-")) + image_built = image in images + + if not image_built: + problems.append(f"{name}: image {image} was never built") + elif running < expected: + problems.append(f"{name}: {running} of {expected} replicas running") + + endpoint = endpoints.get(name) + if endpoint is None and agent.get("type") == "workflow": + # The container only reports endpoints it has instance records for; + # locally the published port is the one the config asked for. + endpoint = f"127.0.0.1:{agent.get('api_port', DEFAULT_API_PORT)}" + + rows.append( + { + "name": name, + "image": image, + "image_built": image_built, + "expected": expected, + "running": running, + "endpoint": endpoint, + "ok": image_built and running >= expected, + } + ) + + ui.panel(_runtime_table(rows)) + if problems: + raise VerificationError("The deploy is incomplete -- " + "; ".join(problems)) + return {"agents": rows} diff --git a/cli/cli.py b/cli/cli.py new file mode 100644 index 0000000..a6bd390 --- /dev/null +++ b/cli/cli.py @@ -0,0 +1,111 @@ +""" +Almost all commands will be executing on the canyonos container spawned by deploy +Commands like doctor, version, and new_app will not though +""" + +import argparse +import importlib.metadata +import sys + +from canyonos import ui +from canyonos.clean import run_clean +from canyonos.config import run_config +from canyonos.deploy import run_deploy +from canyonos.build import run_build +from canyonos.doctor import run_doctor +from canyonos.logs import run_logs +from canyonos.new_app import run_new_app +from canyonos.quit import run_quit +from canyonos.serve import run_serve +from canyonos.status import run_status +from canyonos.stop import run_stop +from canyonos.test import DEFAULT_QUERY, run_test +from utils.help_screen import DESCRIPTIONS, print_custom_help + +def _parse_bool(value): + if value.lower() in ("true", "1", "yes"): + return True + if value.lower() in ("false", "0", "no"): + return False + raise argparse.ArgumentTypeError(f"expected true/false, got: {value!r}") + + +class _RootParser(argparse.ArgumentParser): + """Routes the top-level -h/--help through the custom help screen.""" + + def print_help(self, file=None): + print_custom_help() + + +def main(): + parser = _RootParser(prog="canyonos") + # Subparsers keep the stock argparse help, so `canyonos -h` still + # describes that command instead of reprinting the top-level screen. + subparsers = parser.add_subparsers(dest="command", parser_class=argparse.ArgumentParser) + + def add(name, run): + # A KeyError here means the command has no entry on the help screen. + command = subparsers.add_parser(name, help=DESCRIPTIONS[name]) + command.set_defaults(func=run) + return command + + # Note, not tested much, keeping this in the back burner for now while we flesh out the main path + add("new-app", lambda args: run_new_app()) + deploy = add("deploy", lambda args: run_deploy(args.config, serve=args.serve, verbose=args.verbose)) + deploy.add_argument( + "-c", + "--config", + help="Path to global controller config (default: resolved by ventis inside the container)", + ) + deploy.add_argument( + "--serve", + type=_parse_bool, + default=True, + metavar="true|false", + help="Automatically launch the local dashboard (canyonos serve) once the workflow is up (default: true)", + ) + deploy.add_argument( + "-v", + "--verbose", + action="store_true", + help="Stream the container's full build and deploy logs instead of a progress summary", + ) + add("clean", lambda args: run_clean()) + add("stop", lambda args: run_stop()) + add("logs", lambda args: run_logs()) + add("quit", lambda args: run_quit()) + add("config", lambda args: run_config()) + add("build", lambda args: run_build()) + add("doctor", lambda args: sys.exit(0 if run_doctor() else 1)) + add("version", lambda args: ui.say(f"canyonos {importlib.metadata.version('canyonos')}")) + add("serve", lambda args: sys.exit(run_serve())) + add("status", lambda args: run_status()) + test = add("test", lambda args: sys.exit(run_test(args.prompt, as_json=args.json))) + test.add_argument( + "prompt", + nargs="?", + default=DEFAULT_QUERY, + help=f"Prompt sent to the workflow (default: {DEFAULT_QUERY!r})", + ) + test.add_argument( + "--json", + action="store_true", + help="Print a single JSON result object and nothing else (for CI)", + ) + + args = parser.parse_args() + if not getattr(args, "command", None): + parser.print_help() + return + + try: + args.func(args) + except RuntimeError as e: + # Docker unreachable, image pull failed, no free port -- all already + # carry a readable message, so print it rather than a traceback. + ui.fail(e) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..f11a241 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "canyonos" +version = "0.1.5" +description = "CanyonOS CLI" +requires-python = ">=3.10" +dependencies = [ + "pyfiglet", + "pyyaml", + "rich", + "ruamel.yaml", +] + +[project.scripts] +canyonos = "cli:main" + +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["canyonos*", "utils*"] + +[tool.setuptools] +py-modules = ["cli"] + +[tool.setuptools.package-data] +canyonos = ["dashboard.compose.yml"] diff --git a/cli/utils/__init__.py b/cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/utils/help_screen.py b/cli/utils/help_screen.py new file mode 100644 index 0000000..81ea3ca --- /dev/null +++ b/cli/utils/help_screen.py @@ -0,0 +1,70 @@ +"""Custom help screen for the canyonos CLI.""" + +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from canyonos import ui +from canyonos.theme import GREEN, WHITE + +# The single source of truth for command descriptions: cli.py registers every +# subparser through this table, so a command can't be added to one and missed +# in the other. +CORE_COMMANDS = ( + ("build", "Port an existing project into a CanyonOS workflow"), + ("deploy", "Build and launch the workflow, then open the dashboard"), + ("config", "Configure project settings"), +) + +# The three teardown commands differ only in what they leave behind, so each +# description says so explicitly rather than all three reading as "stop stuff". +UTIL_COMMANDS = ( + ("clean", "Delete the generated .car folder from this project"), + ("doctor", "Check Docker, git and a coding agent are all available"), + ("logs", "Follow the running deploy's logs"), + ("new-app", "Create a barebones CanyonOS project"), + ("quit", "Stop the deploy and remove the container and its files"), + ("serve", "Start local CanyonOS dashboard"), + ("status", "Check whether a deploy is running and where it answers"), + ("stop", "Stop the running deploy, keeping the container and files"), + ("test", "Deploy locally and run one prompt end to end"), + ("version", "Print canyonos version"), +) + +DESCRIPTIONS = dict(CORE_COMMANDS + UTIL_COMMANDS) + + +# Both columns are sized from the widest entry across BOTH tables, so Core and +# Utils line up with each other instead of each shrinking to fit its own rows. +_ALL_COMMANDS = CORE_COMMANDS + UTIL_COMMANDS +_NAME_WIDTH = max(len(name) for name, _ in _ALL_COMMANDS) +_DESCRIPTION_WIDTH = max(len(description) for _, description in _ALL_COMMANDS) + + +def _command_table(commands): + table = Table(show_header=False, border_style="dim", padding=(0, 2)) + table.add_column(style=f"bold {GREEN}", width=_NAME_WIDTH) + table.add_column(style=WHITE, width=_DESCRIPTION_WIDTH) + for name, description in commands: + table.add_row(name, description) + return table + + +def print_custom_help(): + """Print a custom, visually appealing help screen.""" + title = Text("CanyonOS CLI", style=f"bold {GREEN}") + subtitle = Text("\nBuild, deploy, and manage agentic workflows with ease", style="dim") + ui.console.print(Panel(title + subtitle, border_style=GREEN, padding=(1, 2))) + + ui.console.print(f"\n[bold {GREEN}]Core Commands[/]") + ui.console.print(_command_table(CORE_COMMANDS)) + + ui.console.print(f"\n[bold {GREEN}]Utils[/]") + ui.console.print(_command_table(UTIL_COMMANDS)) + + ui.console.print(f"\n[bold {GREEN}]Quick Start:[/]") + ui.console.print(f" [dim]1.[/dim] cd [{GREEN}]into-your-workflow-root-dir[/]") + ui.console.print(" [dim]2.[/dim] canyonos build (opens coding agent)") + ui.console.print(" [dim]3.[/dim] canyonos deploy (UI automatically starts)") + + ui.console.print(f"[dim]For command-specific help: [{GREEN}]canyonos --help[/][/dim]\n") diff --git a/cli/utils/tui.py b/cli/utils/tui.py new file mode 100644 index 0000000..3154062 --- /dev/null +++ b/cli/utils/tui.py @@ -0,0 +1,117 @@ +""" +Minimal arrow-key select menu, no dependency beyond the standard library. +""" + +import os +import select as select_syscall +import sys +import termios +import tty + +from canyonos.theme import GREEN + +UP_KEYS = ("\x1b[A", "\x1bOA", "k") +DOWN_KEYS = ("\x1b[B", "\x1bOB", "j") +CANCEL_KEYS = ("\x03", "\x1b") +DELETE_KEYS = ("d", "D") +QUIT_KEYS = ("q", "Q") + +# The brand green as a raw truecolor escape: this menu writes ANSI directly +# rather than going through rich, but shares the CLI's one palette. +_GREEN = "\x1b[38;2;{};{};{}m".format(*(int(GREEN[i:i + 2], 16) for i in (1, 3, 5))) + +# Sentinel returned (paired with the hovered value) when the delete key is +# pressed and `deletable=True`. Callers check `result[0] is DELETE_ACTION`. +DELETE_ACTION = object() + +# Sentinel returned when the quit key is pressed and `quittable=True`. Distinct +# from None (which callers use for a single-level cancel/back) so a caller can +# unwind an entire nested session. Callers check `result is QUIT_ACTION`. +QUIT_ACTION = object() + + +def _read_key(fd): + # Reads straight off the fd (not sys.stdin) so this stays in sync with + # the select() call below -- stdin's own buffering can silently swallow + # an arrow key's trailing bytes before select() ever sees them queued. + ch = os.read(fd, 1).decode() + if ch == "\x1b": + # An arrow key arrives as a multi-byte escape sequence; a bare Esc + # press has nothing queued right behind it. + if select_syscall.select([fd], [], [], 0.01)[0]: + ch += os.read(fd, 1).decode() + if ch[-1] in ("[", "O"): + ch += os.read(fd, 1).decode() + return ch + + +def select_menu(options, title, deletable=False, quittable=False): + """Arrow-key single-select over `options` (a list of (value, label) pairs). + + Returns the chosen value, or None if there's nothing to choose from or + the user cancelled (Esc/Ctrl-C). + + If `deletable` is True, pressing the delete key ('d') over an item returns + the tuple `(DELETE_ACTION, hovered_value)` so the caller can act on the + currently-hovered item instead of selecting it. + + If `quittable` is True, pressing the quit key ('q') returns the sentinel + `QUIT_ACTION` -- distinct from None -- so the caller can unwind an entire + nested session rather than just this one menu. + """ + if not options or not sys.stdin.isatty(): + return None + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + out = sys.stderr + idx = 0 + n = len(options) + + def frame(): + lines = [f"\x1b[1m{title}\x1b[0m", ""] + for i, (_, label) in enumerate(options): + lines.append(f"{_GREEN}❯ {label}\x1b[0m" if i == idx else f" {label}") + hint = "↑/↓ move · 1-9 jump · enter select" + if deletable: + hint += " · d delete" + if quittable: + hint += " · q quit" + hint += " · esc cancel" + lines.append(f"\x1b[2m{hint}\x1b[0m") + return "\r\n".join(lines) + + prev_frame = None + try: + tty.setraw(fd) + out.write("\x1b[?25l") + while True: + text = frame() + if prev_frame is not None: + # How far back up to move is read off the frame we actually + # wrote last time, not recomputed separately -- it can't drift + # out of sync with what's really on screen. + out.write(f"\r\x1b[{prev_frame.count(chr(10))}A\x1b[J") + out.write(text) + out.flush() + prev_frame = text + + key = _read_key(fd) + if key in ("\r", "\n"): + return options[idx][0] + if key in CANCEL_KEYS: + return None + if key in UP_KEYS: + idx = (idx - 1) % n + elif key in DOWN_KEYS: + idx = (idx + 1) % n + elif deletable and key in DELETE_KEYS: + return (DELETE_ACTION, options[idx][0]) + elif quittable and key in QUIT_KEYS: + return QUIT_ACTION + elif key.isdigit() and key != "0" and int(key) <= n: + return options[int(key) - 1][0] + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + out.write("\x1b[?25h\r\n") + out.flush() diff --git a/examples/finance/agents/finance_agent.py b/examples/finance/agents/finance_agent.py index 7c36ef5..db70b01 100644 --- a/examples/finance/agents/finance_agent.py +++ b/examples/finance/agents/finance_agent.py @@ -1,4 +1,11 @@ -from vllm_agent import VllmAgent +# `agents.vllm_agent` is where the generated VllmAgent stub actually lands +# inside this agent's own Docker container (stubs are copied to their source +# agent's own entrypoint-mirrored path -- see ventis/stub_generator.py). The +# bare `vllm_agent` fallback covers running outside that layout. +try: + from agents.vllm_agent import VllmAgent +except ImportError: + from vllm_agent import VllmAgent # Example of a simple finance agent diff --git a/examples/finance/workflow/example_workflow.py b/examples/finance/workflow/example_workflow.py index e4b6ce5..644e483 100644 --- a/examples/finance/workflow/example_workflow.py +++ b/examples/finance/workflow/example_workflow.py @@ -4,7 +4,7 @@ # Start agents first: python src/controller/global_controller.py # Then run this file: python examples/workflow.py # Test: -# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' -d '{"ticker": "AAPL"}' +# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' -d '{"query": "AAPL"}' # curl http://localhost:8080/status/ import sys @@ -22,13 +22,13 @@ from agents.market_agent import MarketResearchAgent -def main(ticker: str = "AAPL"): +def main(query: str = "AAPL"): finance = FinanceAgent() market = MarketResearchAgent() # Call finance agent functions - price = finance.get_stock_price(ticker=ticker) - company = finance.get_company_name(ticker=ticker) + price = finance.get_stock_price(ticker=query) + company = finance.get_company_name(ticker=query) # Call market agent functions trend = market.get_market_trend(sector="tech") diff --git a/examples/helloworld/README.md b/examples/helloworld/README.md index a182896..38483be 100644 --- a/examples/helloworld/README.md +++ b/examples/helloworld/README.md @@ -14,7 +14,7 @@ ventis deploy # Test with curl curl -X POST http://:8080/main \ -H 'Content-Type: application/json' \ - -d '{"name": "World"}' + -d '{"query": "World"}' # Check result curl http://:8080/status/ @@ -52,5 +52,5 @@ Pass `_context` in your curl request to set the caller identity: ```bash curl -X POST http://localhost:8080/main \ -H 'Content-Type: application/json' \ - -d '{"name": "World", "_context": {"origin": "admin"}}' + -d '{"query": "World", "_context": {"origin": "admin"}}' ``` diff --git a/examples/helloworld/config/global_controller.yaml b/examples/helloworld/config/global_controller.yaml index 5f6c0cc..0b9c194 100644 --- a/examples/helloworld/config/global_controller.yaml +++ b/examples/helloworld/config/global_controller.yaml @@ -10,7 +10,7 @@ agents: cpu: 1 memory: 512 entrypoint: agents/example_agent.py - provider: local + provider: EC2 - name: VllmAgent replicas: 1 @@ -19,7 +19,7 @@ agents: cpu: 2 memory: 2048 entrypoint: agents/vllm_agent.py - provider: local + provider: EC2 instance_type: t3.micro - name: Workflow @@ -28,7 +28,7 @@ agents: redis_port: 6379 api_port: 8080 # Only needed for workflows, defaults to 8080 if not filled workflow_file: workflow/example_workflow.py - provider: local + provider: EC2 instance_type: t3.micro poll_interval: 5 diff --git a/examples/helloworld/workflow/example_workflow.py b/examples/helloworld/workflow/example_workflow.py index 6bafff3..693590e 100644 --- a/examples/helloworld/workflow/example_workflow.py +++ b/examples/helloworld/workflow/example_workflow.py @@ -2,7 +2,7 @@ # This file demonstrates how to call agent stubs and deploy as a REST API. # # After running `ventis build` and `ventis deploy`: -# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' -d '{"name": "World"}' +# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' -d '{"query": "World"}' # curl http://localhost:8080/status/ import sys @@ -18,9 +18,9 @@ from agents.example_agent import ExampleAgent -def main(name: str = "World"): +def main(query: str = "World"): agent = ExampleAgent() - greeting = agent.hello(name=name) + greeting = agent.hello(name=query) return {"greeting": greeting.value()} diff --git a/examples/joke_writer/.env.example b/examples/joke_writer/.env.example deleted file mode 100644 index b846149..0000000 --- a/examples/joke_writer/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# Copy this to `.env` and fill in the token. `config/global_controller.yaml` -# points `env_file:` at that copy, and it reaches every container as -# `docker run --env-file`. -# -# Keep the real token out of THIS file. `.env.example` is the one exception to -# the build context's exclusion of `.env*`, so whatever is written here is baked -# into the image; `.env` itself never enters the build and never leaves the host. - -# A Bedrock API key -- the long-term kind generated in the console, or a -# short-term one. botocore matches this exact name against bedrock-runtime's -# signingName (`bedrock`) and switches the client from SigV4 to bearer auth by -# itself, which is why neither joke_writer.py nor ventis/llm/bedrock.py mentions -# it. An IAM access key works too: drop AWS_ACCESS_KEY_ID and -# AWS_SECRET_ACCESS_KEY in instead and the same client signs with SigV4. -AWS_BEARER_TOKEN_BEDROCK= - -# Neither is a secret, and both have defaults in joke_writer.py -- they are here -# to name what the source reads. -BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0 -AWS_REGION=us-east-1 diff --git a/examples/joke_writer/LICENSE b/examples/joke_writer/LICENSE deleted file mode 100644 index 5600729..0000000 --- a/examples/joke_writer/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2026 LangChain, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/examples/joke_writer/README.md b/examples/joke_writer/README.md deleted file mode 100644 index 3ba7930..0000000 --- a/examples/joke_writer/README.md +++ /dev/null @@ -1,183 +0,0 @@ -# Joke Writer - -A LangGraph map-reduce, ported to Ventis. Derived from -[langchain-ai/langchain-academy](https://github.com/langchain-ai/langchain-academy) -at `fa15bec` (`module-4/studio/map_reduce.py`, MIT — see `LICENSE`). - -Unlike the other targets in `examples/`, **the source here is not unmodified**. -`map_reduce.py` built a `ChatOpenAI` at module scope, and at the time of the port -an agent container had no way to carry an `OPENAI_API_KEY` — the port was blocked -at the credential wall until the model call was rewritten onto Bedrock. That wall -is gone now: `env_file` puts any key in the container. The Bedrock rewrite stayed -anyway, and [What the port cost](#what-the-port-cost) is honest about what that -means. - -## Overview - -Given a topic, the graph splits it into sub-topics, writes one joke per -sub-topic in parallel, then picks the best of them. - -1. `generate_topics` — one LLM call, turns the topic into three sub-topics, - validated into `Subjects`. -2. `generate_joke` — one LLM call per sub-topic. `continue_to_jokes` emits a - `Send` per subject, so this node runs N times per request with no shared - state between the runs. `jokes` is an `Annotated[list, operator.add]`, which - is how the N results merge back into one state. -3. `best_joke` — one LLM call over every joke, returns the winner by index. - -``` - START - | - generate_topics 1 call - | - continue_to_jokes Send x N - / | \ - joke joke joke N calls, no shared state - \ | / - best_joke 1 call - | - END -``` - -### Why this one - -It is the smallest project in reach whose control flow does something a single -process cannot: `Send` fans out to N independent calls per request. Everything -else about it is deliberately boring — four packages, no tools, no external -service, one API key. - -## The port - -| File | What it holds | -| --- | --- | -| `joke_writer.py` | The source. Three prompts, two schemas, three nodes, and the graph — still compiled, never executed under Ventis. | -| `agents/joke_agent.py` | `JokeAgent`. Three methods, each calling the source's node with the node's own state dict. Imports `joke_writer`; restates nothing. | -| `agents/joke_agent.yaml` | The three nodes declared as three functions on one agent. | -| `workflow/joke_workflow.py` | Where the graph went — the edges, the `Send` fan-out and the `operator.add` reducer, re-expressed as ordinary Python. | -| `config/global_controller.yaml` | `JokeAgent` at `replicas: 3`, plus the workflow. | -| `config/policy.yaml` | Default-allow for the two services. Not optional — a missing file kills `ventis deploy`. | - -Two decisions worth naming: - -**One agent, not three.** `generate_topics` and `best_joke` run once per request -and have no resource profile of their own. Splitting them out would buy two more -images and two more Redis round trips. What is hoisted is the fan-out, and that -is a workflow concern. - -**The graph is not the port.** `StateGraph`, `Send` and the `Annotated[list, -operator.add]` reducer are control flow owned by the LangGraph runtime, and -Ventis has no runtime to execute them. The workflow dispatches N -`generate_joke` calls across the three replicas and concatenates the results -itself. Every call is dispatched before any is resolved — `.value()` blocks, so -fusing the two lines into one comprehension would silently serialize the fan-out -and remove the reason to be on Ventis at all. - -## What the port cost - -This is no longer upstream's model stack. `ChatOpenAI` and -`with_structured_output` are gone; `ventis.llm.bedrock.call_bedrock` is the raw -converse API, so each node asks for JSON in its prompt and validates the reply -through the same pydantic schema upstream used. `_extract_json` exists only -because `with_structured_output` used to do that work. - -That rewrite is not something the `porting-to-canyonos-core` skill should do on a -user's project — it is the credential wall, and the skill's instruction is to -report it. It was done here deliberately, so that this example is one that -actually deploys. - -**It would not be necessary today.** The rewrite bought one thing: boto3 builds -no client at import, so the agent could be *loaded* with no secret in the -container, back when `_launch_locally` passed five `-e` flags and all five were -`VENTIS_*`. `env_file` removes that constraint — an `OPENAI_API_KEY` now reaches -a container as readily as a Bedrock one, and upstream's `ChatOpenAI` at module -scope would import fine. What the rewrite still buys is narrower: a module-scope -client turns a missing key into `"No agent loaded"`, while a per-call one turns -it into a real error on `/status`. Worth knowing, not worth a rewrite. - -The example stays on Bedrock because it is the model call that has been end-to-end -verified here, and because `ventis/llm/bedrock.py` is where Ventis writes per-call -token telemetry onto the future. - -## Running it - -Copy `.env.example` to `.env` and put a Bedrock API key in it: - -```shell -cp .env.example .env -$EDITOR .env # AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-... -``` - -> **`env_file:` needs PR #53** (`jiajunh/can-232-...`), still open against main. -> Until it merges nothing in `ventis/` reads the key, so the steps below leave -> the container without a credential and every request answers a Bedrock -> credential error. `python ../../.claude/skills/porting-to-canyonos-core/validate.py .` -> reports this as V030 and stops reporting it the day the PR lands. - -`config/global_controller.yaml` points `env_file:` at that file, and every -container gets it as `docker run --env-file`. Nothing in this project reads the -variable: botocore matches the name against `bedrock-runtime`'s signingName and -switches the client from SigV4 to bearer auth on its own, so -`ventis/llm/bedrock.py` still builds a plain `boto3.client("bedrock-runtime")`. -An IAM access key instead of the bearer token works the same way. - -`.env` is gitignored and excluded from the build context — the key is in the -container's environment and not in the image. Deploy checks the path before it -launches anything, so a missing `.env` is one error line rather than three -replicas that come up and fail every request. - -```shell -ventis build -ventis deploy -``` - -```shell -curl -X POST http://localhost:8080/main \ - -H 'Content-Type: application/json' -d '{"query": "animals"}' -curl http://localhost:8080/status/ -``` - -```json -{"request_id": "cb6cb62d...", "status": "done", "result": { - "topic": "animals", - "subjects": ["Wildlife Conservation", "Animal Behavior", "Endangered Species"], - "jokes": ["...", "...", "..."], - "best_selected_joke": "Why did the chimpanzee go to the doctor? Because it was going bananas!" -}} -``` - -`BEDROCK_MODEL_ID` and `AWS_REGION` (see `.env.example`) have defaults in -`joke_writer.py`; neither is a secret. The region has to match the one the key -was issued for. - -### Running the source outside Ventis - -`joke_writer.py` imports `ventis.llm.bedrock` first and falls back to the flat -`bedrock` copy an agent image gets, so the compiled graph still runs on its own -from a checkout of this repo: - -```shell -pip install -e ../.. # the ventis package -pip install langgraph pydantic typing_extensions boto3 -``` - -```python -from joke_writer import graph - -graph.invoke({"topic": "animals"}) -``` - -## Provenance - -Taken from `module-4/studio/`, which holds four unrelated graphs sharing one -directory. Only `map_reduce.py` and its license are here. - -| Left behind | Why | -| --- | --- | -| `parallelization.py`, `research_assistant.py`, `sub_graphs.py` | Other graphs in the same studio directory. The first two also need a Tavily key and Wikipedia. | -| `langgraph.json` | Registers all four graphs and points at `./.env`; a trimmed copy would only be useful for LangGraph Studio. | -| The module-4 notebooks | Teaching material for the same code. | -| `OPENAI_API_KEY`, `TAVILY_API_KEY` in `.env.example` | The first belongs to a model call that is no longer here; the second to the two graphs that are not here. | - -Nothing was added at the project root: there is no `pyproject.toml`, `setup.py` -or `requirements.txt`, exactly as upstream has none for module-4. That is why -`config/global_controller.yaml` has to declare `requirements:` by hand. diff --git a/examples/joke_writer/agents/joke_agent.py b/examples/joke_writer/agents/joke_agent.py deleted file mode 100644 index 8fa3b93..0000000 --- a/examples/joke_writer/agents/joke_agent.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Ventis entrypoint for the map-reduce joke writer. - -Nothing here restates the project. The three prompts, the two schemas and the -Bedrock binding all live in `joke_writer.py` and are reached with an import -- -the whole project tree is in the image. - -What could not be reused is the graph itself. `StateGraph`, the `Send` in -`continue_to_jokes` and the `Annotated[list, operator.add]` reducer are control -flow owned by the LangGraph runtime, and Ventis has no runtime to execute them. -That wiring is re-expressed as ordinary Python in workflow/joke_workflow.py, -where the fan-out becomes N dispatched calls across this agent's replicas. The -nodes those edges connected are imported, unchanged. - -The module is imported whole rather than by name so that -`joke_writer.generate_joke` inside a method named `generate_joke` reads as what -it is: the source's node. -""" - -# The source tree. Importing it reads BEDROCK_MODEL_ID and AWS_REGION, imports -# bedrock.py (which builds a RedisClient at module scope) and compiles the graph -# -- but it constructs no API client, so the import needs no credential. -# -# The credential arrives by a different road: `env_file` in -# config/global_controller.yaml hands the container a .env holding -# AWS_BEARER_TOKEN_BEDROCK, and botocore picks that name up by itself. Nothing -# here or in joke_writer.py names it. -# -# Constructing no client at import is no longer what makes this agent loadable -- -# env_file would carry a key to a module-scope client too. It only changes the -# failure: a missing key is an error on /status rather than "No agent loaded". -import joke_writer - - -class JokeAgent(object): - """The graph's nodes, exposed under the class name `agent.name` declares.""" - - # No constructor arguments -- LocalController does `JokeAgent()`. The model - # id and region are the source's own module-level constants, read from the - # environment there; there is nothing to configure here. - - def generate_topics(self, topic: str) -> dict: - """Split a topic into sub-topics. Returns {"subjects": [...]}. - - Synchronous by signature -- the executor calls this with no `await`, and - returning a coroutine would put `` into Redis. - """ - # The node's own state dict goes in, the node's own return comes out. - # Both hold nothing but str and list, so the executor's json.dumps is - # happy without a serializer -- unlike a graph that hands back messages. - return joke_writer.generate_topics({"topic": topic}) - - def generate_joke(self, subject: str) -> dict: - """Write one joke about one subject. Returns {"jokes": ["..."]}. - - The single-element list is the node's own shape: it is what - `Annotated[list, operator.add]` merged N of. The workflow does that - concatenation now. - """ - return joke_writer.generate_joke({"subject": subject}) - - def best_joke(self, topic: str, jokes: list) -> dict: - """Pick the winner. Returns {"best_selected_joke": "..."}.""" - return joke_writer.best_joke({"topic": topic, "jokes": jokes}) diff --git a/examples/joke_writer/agents/joke_agent.yaml b/examples/joke_writer/agents/joke_agent.yaml deleted file mode 100644 index fee8607..0000000 --- a/examples/joke_writer/agents/joke_agent.yaml +++ /dev/null @@ -1,53 +0,0 @@ -# The graph's three nodes, exposed as three methods on one agent. -# -# One agent, not three. `generate_topics` and `best_joke` run once per request -# and have no resource profile of their own, so splitting them out would add -# two images, two dependency trees and a Redis round trip to buy nothing. What -# is hoisted is the `Send` fan-out, and that is a workflow concern, not a -# second agent: the workflow dispatches N `generate_joke` calls and the -# routing table spreads them across this agent's replicas. -# -# This file's basename names the generated stub, not the agent. Sharing it with -# joke_agent.py is why both land at /app/joke_agent.py -- the entrypoint is -# copied last and wins it, so the agent container loads the real class while the -# stub keeps /app/agents/joke_agent.py for callers. What the basename must not -# match is a source module: a `joke_writer.yaml` would put a stub at -# /app/joke_writer.py, on top of the module the adapter imports. - -agent: - name: JokeAgent - functions: - # Node 1 of the graph. One LLM call, structured output into `Subjects`. - - name: generate_topics - description: Split a topic into three related sub-topics. - arguments: - # Must equal the Python parameter name character for character -- - # LocalController calls method(**args). - - name: topic - type: str - # dict -> the workflow must json.loads what .value() hands back - returns: - type: dict - - # Node 2. The fan-out: one call per sub-topic, no shared state between - # them. This is the only reason this project is on Ventis. - - name: generate_joke - description: Write one joke about one subject. - arguments: - - name: subject - type: str - returns: - type: dict - - # Node 3. The reduce: one call over every joke the fan-out produced. - - name: best_joke - description: Pick the best joke out of the ones written for a topic. - arguments: - - name: topic - type: str - # `list` is a builtin, so the stub's annotation resolves. `list[str]` - # would be pasted into the AST verbatim and NameError on import. - - name: jokes - type: list - returns: - type: dict diff --git a/examples/joke_writer/config/global_controller.yaml b/examples/joke_writer/config/global_controller.yaml deleted file mode 100644 index eb26090..0000000 --- a/examples/joke_writer/config/global_controller.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Deployment manifest for the map-reduce joke writer. -# -# `entrypoint` is the adapter, which imports the untouched-in-shape source tree. -# -# The source has no pyproject.toml, setup.py or setup.cfg, so the Dockerfile's -# `-e .` is skipped -- silently. It does not matter here: `joke_writer.py` sits -# at the project root, so it lands flat at /app, which is sys.path[0]. A source -# laid out under src/ would need its own packaging metadata to import at all. - -agents: - - name: JokeAgent - # The fan-out. `generate_joke` is stateless, so LocalController picks a - # replica at random per call and the workflow's N dispatched calls spread - # across these three. N is whatever the model returns (the prompt asks for - # three sub-topics); replicas bound how many run at once, not how many run. - replicas: 3 - redis_port: 6379 - resources: - cpu: 1 - memory: 1024 - entrypoint: agents/joke_agent.py - provider: local - # What the source imports beyond the runtime's own list, which the generator - # prepends. boto3 is already in it, which is the whole reason the Bedrock - # call needs nothing declared here. The graph is never executed in this - # container, but `joke_writer.py` imports langgraph at module scope, so it - # still has to be installed. - requirements: - - langgraph - - pydantic - - typing_extensions - - - name: Workflow - type: workflow - replicas: 1 - redis_port: 6379 - api_port: 8080 - workflow_file: workflow/joke_workflow.py - provider: local - -poll_interval: 5 - -redis: - host: localhost - port: 6379 - db: 0 - -# `provider` must be lowercase. InstanceManager.launch_all tests -# `provider == "local"` to decide whether to reserve a host port; `Local` fails -# that test, reserved_port stays None, and Local/_runtime.py raises -# `int() argument must be ... not 'NoneType'` before any container starts. -# -# The credential. `_launch_locally` passes exactly five `-e` flags, all VENTIS_*, -# and .env is excluded from the build context, so for a while the only model call -# that could work here was one that needed no secret in the container: boto3 -# resolving an instance role per call. `env_file` is what changed. It points at a -# local .env, unresolved paths relative to this project root, and every container -# gets it as `docker run --env-file` -- so the key is in the environment without -# ever entering the image. -# -# What lands there is AWS_BEARER_TOKEN_BEDROCK. Nothing in this project reads it: -# botocore matches the name against bedrock-runtime's signingName and switches -# the client to bearer auth on its own, so `ventis/llm/bedrock.py` still builds a -# plain `boto3.client("bedrock-runtime")`. -# -# Deploy fails here rather than in a container: resolve_env_file checks the path -# before InstanceManager launches anything, so a missing .env is one error line -# instead of three replicas that come up and then answer -# {"status": "error", "error": "Unable to locate credentials"} on every request. -# -# What it costs: this is no longer upstream's model stack. See README.md. - -# Relative to this project root, same as `entrypoint` and `workflow_file`. -# .env is gitignored and excluded from the build context; .env.example names -# what belongs in it. -# -# NOTE: this key needs PR #53 (jiajunh/can-232-...), which is still open against -# main. On main nothing reads it -- `grep -rn env_file ventis/` finds no hits -- -# so the key is inert, no credential reaches the container, and every request -# answers a Bedrock credential error. `validate.py` reports that as V030 until -# the PR lands. -env_file: .env diff --git a/examples/joke_writer/config/policy.yaml b/examples/joke_writer/config/policy.yaml deleted file mode 100644 index 2cb9cb3..0000000 --- a/examples/joke_writer/config/policy.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Policy-Based Routing Rules — map-reduce joke writer -# Each rule defines a match condition (key-value pairs checked against the -# request context) and an access list of allowed services. -# Rules are evaluated most-specific-first (most matching keys wins). -# An empty match ({}) acts as a default fallback. -# -# This file IS optional -- `_load_policy_rules` logs "No policy file found" and -# returns [], and `_check_policy` allows everything when the rule list is empty. -# What is not safe is a half-written one: past the isfile() guard the read is -# unguarded, so an empty file (`.get("rules")` on None) or a null `rules:` -# (`None.sort()`) raises inside GlobalController.__init__ and `ventis deploy` -# dies before any container starts. Delete it or fill it; do not leave it empty. - -rules: - # Default fallback: the workflow and the one agent behind it. A service left - # out of this list answers "Unauthorized: Policy denied access to service". - - match: {} - access: - - Workflow - - JokeAgent diff --git a/examples/joke_writer/joke_writer.py b/examples/joke_writer/joke_writer.py deleted file mode 100644 index 3ad49d5..0000000 --- a/examples/joke_writer/joke_writer.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Map-reduce joke writer. - -Derived from langchain-ai/langchain-academy `module-4/studio/map_reduce.py` -(MIT, see LICENSE). The graph shape, the three prompts and the two schemas are -upstream's. The model call is not: upstream builds a `ChatOpenAI` at module -scope, and when this was ported nothing could carry an OPENAI_API_KEY into an -agent container. Bedrock reaches the model through boto3, which builds no client -at import, so the same code loaded with no secret injected. - -`env_file` has since removed that constraint -- the key now travels to the -container in a .env and botocore reads AWS_BEARER_TOKEN_BEDROCK on its own. The -rewrite stayed regardless; README.md says what that costs. - -`with_structured_output` went with it. `call_bedrock` is the raw converse API, so -each node asks for JSON in the prompt and validates the reply through the same -pydantic schema upstream used. -""" - -import json -import operator -import os -import re -from typing import Annotated - -from typing_extensions import TypedDict - -from pydantic import BaseModel, ValidationError - -from langgraph.constants import Send -from langgraph.graph import END, StateGraph, START - -# Ventis copies bedrock.py flat into every agent image; the package path is for -# running this module outside a container. -try: - from ventis.llm.bedrock import call_bedrock -except ImportError: - from bedrock import call_bedrock - -# Prompts we will use. Upstream's, plus the JSON instruction that -# `with_structured_output` used to add on our behalf. -subjects_prompt = """Generate a list of 3 sub-topics that are all related to this overall topic: {topic}. -Respond with JSON only, no prose: {{"subjects": ["...", "...", "..."]}}""" -joke_prompt = """Generate a joke about {subject}. -Respond with JSON only, no prose: {{"joke": "..."}}""" -best_joke_prompt = """Below are a bunch of jokes about {topic}. Select the best one! Return the ID of the best one, starting 0 as the ID for the first joke. Jokes: \n\n {jokes} -Respond with JSON only, no prose: {{"id": 0}}""" - -# LLM. Both are read once at import; the container gets them from its -# environment, and neither is a secret. -MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0") -REGION = os.environ.get("AWS_REGION", "us-east-1") - - -def _extract_json(text): - """Pull the first JSON object out of a model reply. - - Even told to answer with JSON only, a model wraps it in a ```json fence or - prefaces it with a sentence. Upstream never needed this because - `with_structured_output` handled it; the converse API does not. - """ - text = re.sub(r"^\s*```(?:json)?|```\s*$", "", text.strip(), flags=re.MULTILINE) - try: - return json.loads(text) - except json.JSONDecodeError: - pass - # Fall back to the outermost braced span. - match = re.search(r"\{.*\}", text, flags=re.DOTALL) - if not match: - raise ValueError(f"joke_writer: no JSON in model output: {text!r}") - return json.loads(match.group(0)) - - -def _ask(prompt, schema, max_tokens): - """One converse() call, validated into `schema`. - - Raising on a bad reply is deliberate. A node that returned a default would - put a plausible-looking wrong answer into the state, and the reduce step - downstream indexes into the jokes list by an id the model chose -- a silent - default there picks the wrong joke instead of failing. - """ - response = call_bedrock( - model_id=MODEL_ID, - messages=[{"role": "user", "content": [{"text": prompt}]}], - inference_config={"maxTokens": max_tokens, "temperature": 0.0}, - region=REGION, - ) - text = response["output"]["message"]["content"][0]["text"] - if not text: - raise ValueError("joke_writer: LLM returned no output.") - try: - return schema(**_extract_json(text)) - except (ValidationError, TypeError) as exc: - raise ValueError( - f"joke_writer: {schema.__name__} not satisfied by model output: {text!r}" - ) from exc - - -# Define the state -class Subjects(BaseModel): - subjects: list[str] - -class BestJoke(BaseModel): - id: int - -class OverallState(TypedDict): - topic: str - subjects: list - jokes: Annotated[list, operator.add] - best_selected_joke: str - -def generate_topics(state: OverallState): - prompt = subjects_prompt.format(topic=state["topic"]) - response = _ask(prompt, Subjects, max_tokens=300) - return {"subjects": response.subjects} - -class JokeState(TypedDict): - subject: str - -class Joke(BaseModel): - joke: str - -def generate_joke(state: JokeState): - prompt = joke_prompt.format(subject=state["subject"]) - response = _ask(prompt, Joke, max_tokens=300) - return {"jokes": [response.joke]} - -def best_joke(state: OverallState): - jokes = "\n\n".join(state["jokes"]) - prompt = best_joke_prompt.format(topic=state["topic"], jokes=jokes) - response = _ask(prompt, BestJoke, max_tokens=100) - if not 0 <= response.id < len(state["jokes"]): - raise ValueError( - f"joke_writer: model chose joke {response.id} of {len(state['jokes'])}." - ) - return {"best_selected_joke": state["jokes"][response.id]} - -def continue_to_jokes(state: OverallState): - return [Send("generate_joke", {"subject": s}) for s in state["subjects"]] - -# Construct the graph: here we put everything together to construct our graph -graph_builder = StateGraph(OverallState) -graph_builder.add_node("generate_topics", generate_topics) -graph_builder.add_node("generate_joke", generate_joke) -graph_builder.add_node("best_joke", best_joke) -graph_builder.add_edge(START, "generate_topics") -graph_builder.add_conditional_edges("generate_topics", continue_to_jokes, ["generate_joke"]) -graph_builder.add_edge("generate_joke", "best_joke") -graph_builder.add_edge("best_joke", END) - -# Compile the graph -graph = graph_builder.compile() diff --git a/examples/joke_writer/workflow/joke_workflow.py b/examples/joke_writer/workflow/joke_workflow.py deleted file mode 100644 index 9fdf3cc..0000000 --- a/examples/joke_writer/workflow/joke_workflow.py +++ /dev/null @@ -1,59 +0,0 @@ -r"""Ventis workflow for the map-reduce joke writer. - -This file is where the graph went. `generate_topics -> continue_to_jokes -> -generate_joke x N -> best_joke` is not a compiled StateGraph any more; it is the -three statements below, and the `Send` fan-out is N calls dispatched across -JokeAgent's replicas. - -The function is `main` and its one argument is `query` because the deployment -platform's test endpoint posts to a hardcoded /main with a strictly validated -{query: string} body. Ventis would serve any name and any kwargs -- the route is -the function's __name__ and the body is splatted in -- so nothing here fails if -you rename it; it just stops being reachable through the platform. - - curl -X POST http://localhost:8080/main \ - -H 'Content-Type: application/json' -d '{"query": "animals"}' - curl http://localhost:8080/status/ -""" - -import json - -from deploy import deploy -from agents.joke_agent import JokeAgent - - -def main(query): - """Route: POST /main {"query": ""}""" - agent = JokeAgent() - - # Node 1: one call, and the fan-out width comes out of it. The agent's own - # parameter is still `topic` -- that name is bound by joke_agent.yaml and the - # source's node, and only the workflow's entry point is pinned to `query`. - subjects = json.loads(agent.generate_topics(topic=query).value())["subjects"] - - # `continue_to_jokes`, re-expressed. Every call is dispatched before any - # of them is resolved -- .value() blocks, so fusing these two lines into one - # comprehension would run the jokes one after another. It would not error; - # the fan-out would just be gone, and with it the reason to be on Ventis. - futures = [agent.generate_joke(subject=s) for s in subjects] - written = [json.loads(f.value()) for f in futures] - - # `Annotated[list, operator.add]`, re-expressed: the reducer that merged N - # single-joke lists back into one list was part of the graph, not of a node. - written_jokes = [joke for result in written for joke in result["jokes"]] - - # Node 3: the reduce. `list` in the yaml is what lets this argument through. - best = json.loads(agent.best_joke(topic=query, jokes=written_jokes).value()) - - return { - "topic": query, - "subjects": subjects, - "jokes": written_jokes, - "best_selected_joke": best["best_selected_joke"], - } - - -# This file is exec'd, not imported, so __name__ == "__main__" here and any -# `if __name__ == "__main__":` block would run in production. deploy() blocks -# on app.run(); nothing after it executes. -deploy(main, port=8080) diff --git a/examples/portfolio/agents/advisor_agent.py b/examples/portfolio/agents/advisor_agent.py index 0915eea..82d8936 100644 --- a/examples/portfolio/agents/advisor_agent.py +++ b/examples/portfolio/agents/advisor_agent.py @@ -2,7 +2,7 @@ # # Final stage. Turns the computed portfolio metrics and risk figures into a # short, plain-English briefing using a small, cheap model on AWS Bedrock -# (Converse API), called via ventis.llm.bedrock so token/cost telemetry gets +# (Converse API), called via ventis.controller.bedrock so token/cost telemetry gets # recorded onto this execution's future: hash. Configure # with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) @@ -16,7 +16,7 @@ import os try: - from ventis.llm.bedrock import call_bedrock + from ventis.controller.bedrock import call_bedrock except ImportError: from bedrock import call_bedrock diff --git a/examples/portfolio/agents/intent_agent.py b/examples/portfolio/agents/intent_agent.py index d74b27b..04124cf 100644 --- a/examples/portfolio/agents/intent_agent.py +++ b/examples/portfolio/agents/intent_agent.py @@ -7,7 +7,7 @@ # -> {"holdings": {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, # "lookback_days": 180} # -# Calls AWS Bedrock (Converse API) via ventis.llm.bedrock -- same pattern as +# Calls AWS Bedrock (Converse API) via ventis.controller.bedrock -- same pattern as # AdvisorAgent -- so token/cost telemetry gets recorded onto this execution's # future: hash. Configure with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) @@ -25,7 +25,7 @@ import json try: - from ventis.llm.bedrock import call_bedrock + from ventis.controller.bedrock import call_bedrock except ImportError: from bedrock import call_bedrock diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 28069ac..253a2d2 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -8,14 +8,18 @@ # downstream RiskAgent can build the portfolio covariance. # # Resource profile: cheap CPU, high fan-out — one compute() call per holding. -import os -import sys - import json import math -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) -from price_agent import PriceAgent +# `agents.price_agent` is where the generated PriceAgent stub actually lands +# inside this agent's own Docker container (stubs are copied to their source +# agent's own entrypoint-mirrored path -- see ventis/stub_generator.py). The +# bare `price_agent` fallback covers running outside that layout (e.g. local +# dev, where `ventis build` only emits a flat stubs/ directory). +try: + from agents.price_agent import PriceAgent +except ImportError: + from price_agent import PriceAgent TRADING_DAYS = 252 diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index 96f371c..dbff7bb 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -16,7 +16,7 @@ agents: cpu: 1 memory: 256 entrypoint: agents/intent_agent.py - provider: EC2 + provider: local instance_type: t3.micro # Stage 0b: price history fetch. Network/IO-bound, cheap CPU. Called by @@ -28,7 +28,7 @@ agents: cpu: 1 memory: 256 entrypoint: agents/price_agent.py - provider: EC2 + provider: local instance_type: t3.micro requirements: [yfinance] @@ -41,7 +41,7 @@ agents: cpu: 1 memory: 256 entrypoint: agents/metrics_agent.py - provider: EC2 + provider: local instance_type: t3.micro # Stage 2: portfolio-level risk aggregation. Single call per request; needs @@ -53,7 +53,7 @@ agents: cpu: 1 memory: 256 entrypoint: agents/risk_agent.py - provider: EC2 + provider: local instance_type: t3.micro # Stage 3: LLM briefing via Bedrock. On the critical path, one call per @@ -65,7 +65,7 @@ agents: cpu: 1 memory: 512 entrypoint: agents/advisor_agent.py - provider: EC2 + provider: local instance_type: t3.micro # The workflow, exposed as a REST API. @@ -75,27 +75,23 @@ agents: redis_port: 6379 replicas: 1 workflow_file: workflow/portfolio_workflow.py - provider: EC2 + provider: local instance_type: t3.micro otel: + # The dashboard api's own OTLP ingest. Must be the full url including the + # path: the http exporter uses an explicitly-passed endpoint verbatim and + # only appends /v1/traces when reading OTEL_EXPORTER_OTLP_ENDPOINT. destinations: - - name: railway - protocol: grpc - endpoint: ${RAILWAY_OTLP_ENDPOINT} - insecure: true - headers: {} - - name: grafana + - name: local protocol: http - endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces - headers: - Authorization: Basic ${GRAFANA_OTLP_HEADERS} + endpoint: http://host.docker.internal:3000/v1/traces + headers: {} # Polling interval in seconds -poll_interval: 5 +poll_interval: 4 -# Redis connection redis: host: localhost port: 6379 @@ -111,5 +107,3 @@ ec2: ssh_user: ${EC2_SSH_USER} ssh_private_key_path: ${EC2_SSH_PRIVATE_KEY_PATH} -database: - url: ${DATABASE_URL} diff --git a/examples/portfolio/workflow/portfolio_workflow.py b/examples/portfolio/workflow/portfolio_workflow.py index 619c4bd..8b0a76a 100644 --- a/examples/portfolio/workflow/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -46,7 +46,7 @@ def main( advisor = AdvisorAgent() # Stage 0: parse the free-text request into structured holdings + window. - intent = intent_agent.parse(query=query) + intent = json.loads(intent_agent.parse(query=query).value()) holdings = intent["holdings"] lookback_days = intent["lookback_days"] diff --git a/examples/text2sql/agents/sql_generator_agent.py b/examples/text2sql/agents/sql_generator_agent.py index 6320089..4964ce6 100644 --- a/examples/text2sql/agents/sql_generator_agent.py +++ b/examples/text2sql/agents/sql_generator_agent.py @@ -9,7 +9,14 @@ # These calls sit on the request's critical path, so the scheduler should # prioritize them over background work. -from vllm_agent import VllmAgent +# `agents.vllm_agent` is where the generated VllmAgent stub actually lands +# inside this agent's own Docker container (stubs are copied to their source +# agent's own entrypoint-mirrored path -- see ventis/stub_generator.py). The +# bare `vllm_agent` fallback covers running outside that layout. +try: + from agents.vllm_agent import VllmAgent +except ImportError: + from vllm_agent import VllmAgent class SQLGeneratorAgent(object): diff --git a/examples/text2sql/agents/vllm_agent.py b/examples/text2sql/agents/vllm_agent.py index 4a7a245..ea23616 100644 --- a/examples/text2sql/agents/vllm_agent.py +++ b/examples/text2sql/agents/vllm_agent.py @@ -1,7 +1,7 @@ # VLLM Agent # # LLM backend for SQL candidate generation, called remotely by -# SQLGeneratorAgent. Calls AWS Bedrock (Converse API) via ventis.llm.bedrock +# SQLGeneratorAgent. Calls AWS Bedrock (Converse API) via ventis.controller.bedrock # so token/cost telemetry gets recorded onto this execution's # future: hash — same pattern as # examples/portfolio/agents/advisor_agent.py. @@ -14,7 +14,7 @@ import os try: - from ventis.llm.bedrock import call_bedrock + from ventis.controller.bedrock import call_bedrock except ImportError: from bedrock import call_bedrock diff --git a/examples/text2sql/workflow/text2sql_workflow.py b/examples/text2sql/workflow/text2sql_workflow.py index ac9801d..b8ce45b 100644 --- a/examples/text2sql/workflow/text2sql_workflow.py +++ b/examples/text2sql/workflow/text2sql_workflow.py @@ -11,7 +11,7 @@ # Test: # curl -X POST http://localhost:8080/main \ # -H 'Content-Type: application/json' \ -# -d '{"question": "total order amount per customer region"}' +# -d '{"query": "total order amount per customer region"}' # curl http://localhost:8080/status/ import json @@ -32,7 +32,7 @@ from agents.production_agent import ProductionExecutorAgent -def main(question: str = "total order amount per customer region", n_candidates: int = 3): +def main(query: str = "total order amount per customer region", n_candidates: int = 3): schema_agent = SchemaRetrievalAgent() generator = SQLGeneratorAgent() validator = SQLValidatorAgent() @@ -44,12 +44,12 @@ def main(question: str = "total order amount per customer region", n_candidates: # whichever node created it -- here, this workflow's own -- so resolving # it where it was created is always safe, regardless of which node ends # up running the next stage. - schema = json.loads(schema_agent.get_relevant_schema(question=question).value()) + schema = json.loads(schema_agent.get_relevant_schema(question=query).value()) # Stage 2: fan out candidate SQL queries (LLM calls happen inside). candidates = json.loads( generator.generate_candidates( - question=question, schema=schema, n=n_candidates + question=query, schema=schema, n=n_candidates ).value() ) @@ -70,7 +70,7 @@ def main(question: str = "total order amount per customer region", n_candidates: survivors.append(sql) if not survivors: - return {"question": question, "error": "no candidate passed static validation"} + return {"question": query, "error": "no candidate passed static validation"} # Stage 4: execute survivors on the sampled replica, then vote. sample_results = [ @@ -87,7 +87,7 @@ def main(question: str = "total order amount per customer region", n_candidates: ) return { - "question": question, + "question": query, "candidates": candidates, "costs": costs, "survivors": survivors, diff --git a/pyproject.toml b/pyproject.toml index 40cb43a..41169a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ include = ["ventis*"] ventis = [ "templates/**/*", "controller/proto/*.proto", + "controller/utils/aws_pricing_chart.db", ] @@ -62,4 +63,8 @@ allowed-unresolved-imports = [ [dependency-groups] dev = [ "pytest>=9.1.1", + "canyonos", ] + +[tool.uv.sources] +canyonos = { path = "cli", editable = true } diff --git a/tests/test_canyonos_test.py b/tests/test_canyonos_test.py new file mode 100644 index 0000000..d2d64e5 --- /dev/null +++ b/tests/test_canyonos_test.py @@ -0,0 +1,426 @@ +import hashlib +import json +import subprocess + +import pytest + +from canyonos import test as test_cmd +from canyonos import ui, verify + +CONFIG = """\ +agents: + - name: EchoAgent + entrypoint: echo_agent.py + provider: EC2 + replicas: 2 + + - name: Workflow + type: workflow + workflow_file: echo_workflow.py + api_port: 8080 + provider: EC2 + replicas: 1 +""" + + +@pytest.fixture(autouse=True) +def loud(): + """Every test starts with output enabled; `--json` runs flip it and restore it.""" + ui.set_quiet(False) + yield + ui.set_quiet(False) + + +@pytest.fixture +def project(monkeypatch, tmp_path): + car = tmp_path / ".car" + (car / "config").mkdir(parents=True) + (car / "app").mkdir() + (car / "config" / "global_controller.yaml").write_text(CONFIG) + monkeypatch.chdir(tmp_path) + return tmp_path + + +def report(errors=0, warnings=0, findings=(), ventis=False): + return { + "capabilities": {"ventis": ventis}, + "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(verify.VerificationError): + 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_ventis_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_ventis_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")], ventis=True), + ) + + with pytest.raises(verify.VerificationError): + 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(verify.VerificationError, 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 # +# ------------------------------------------------------------------ # + + +@pytest.fixture +def runtime(monkeypatch): + monkeypatch.setattr(verify.gc, "workflow_endpoints", lambda _port: []) + + def install(images, containers): + monkeypatch.setattr(verify, "_built_images", lambda: set(images)) + monkeypatch.setattr(verify, "_running_containers", lambda: list(containers)) + + return install + + +ALL_UP = [ + "ventis-local-echoagent-0", + "ventis-local-echoagent-1", + "ventis-local-workflow-0", +] + + +def test_a_complete_deploy_passes(project, runtime): + runtime({"ventis-echoagent", "ventis-workflow"}, ALL_UP) + + result = verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) + + assert [(a["name"], a["running"], a["expected"]) for a in result["agents"]] == [ + ("EchoAgent", 2, 2), + ("Workflow", 1, 1), + ] + + +def test_a_short_replica_count_fails(project, runtime): + runtime({"ventis-echoagent", "ventis-workflow"}, ALL_UP[1:]) + + with pytest.raises(verify.VerificationError, match="1 of 2 replicas"): + verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) + + +def test_an_image_that_was_never_built_fails(project, runtime): + runtime({"ventis-workflow"}, ["ventis-local-workflow-0"]) + + with pytest.raises(verify.VerificationError, match="ventis-echoagent was never built"): + verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) + + +def test_the_workflow_endpoint_falls_back_to_the_configured_port(project, runtime): + runtime({"ventis-echoagent", "ventis-workflow"}, ALL_UP) + + result = verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) + + assert result["agents"][0]["endpoint"] is None + assert result["agents"][1]["endpoint"] == "127.0.0.1:8080" + + +# ------------------------------------------------------------------ # +# The command itself # +# ------------------------------------------------------------------ # + + +@pytest.fixture +def deployable(monkeypatch, project): + """A project where every step past the build check succeeds unless overridden.""" + calls = {"post_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: 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, "_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)]) + monkeypatch.setattr(test_cmd, "_send_query", lambda *a: "req-1") + 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 quit_existing(): + calls["quit"] += 1 + + monkeypatch.setattr(test_cmd, "post_deploy", post_deploy) + monkeypatch.setattr(test_cmd, "quit_existing", quit_existing) + return calls + + +def test_a_passing_run_tears_everything_down(deployable): + assert test_cmd.run_test("hi") == 0 + assert deployable["quit"] == 1 + + +def test_the_provider_is_restored_after_the_run(project, deployable): + config = project / ".car" / "config" / "global_controller.yaml" + + test_cmd.run_test("hi") + + 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 test_cmd._TestFailed("the deploy did not come up") + + monkeypatch.setattr(test_cmd, "_wait_for_workflow", boom) + + assert test_cmd.run_test("hi", as_json=True) == 1 + payload = json.loads(capsys.readouterr().out) + + assert deployable["quit"] == 0 + 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) + + assert test_cmd.run_test("hi", as_json=True) == 1 + payload = json.loads(capsys.readouterr().out) + + assert deployable["quit"] == 1 + assert payload["log_tail"] is None + + +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) + + assert payload["ok"] is True + assert payload["query"] == "a prompt" + 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), + ] + + +def test_a_workflow_error_is_reported_as_a_failure(monkeypatch, deployable, capsys): + monkeypatch.setattr( + test_cmd, "_await_result", lambda *a: {"status": "error", "error": "agent blew up"} + ) + + assert test_cmd.run_test("hi", as_json=True) == 1 + + 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): + 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", + } + + +# ------------------------------------------------------------------ # +# Docker plumbing # +# ------------------------------------------------------------------ # + + +def test_running_containers_are_filtered_to_the_local_provider(monkeypatch): + seen = [] + + def fake_run(argv, **_): + seen.append(argv) + return subprocess.CompletedProcess(argv, 0, "ventis-local-echoagent-0\n", "") + + monkeypatch.setattr(verify.subprocess, "run", fake_run) + + assert verify._running_containers() == ["ventis-local-echoagent-0"] + assert "name=ventis-local-" in seen[0] diff --git a/tests/test_dashboard_stack.py b/tests/test_dashboard_stack.py new file mode 100644 index 0000000..7be86ac --- /dev/null +++ b/tests/test_dashboard_stack.py @@ -0,0 +1,312 @@ +import json +import subprocess +from pathlib import Path + +import pytest + +from canyonos import dashboard_stack + + +def completed(argv, returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess(argv, returncode, stdout, stderr) + + +@pytest.fixture +def project(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.chdir(tmp_path) + for key in ( + "JWT_SECRET", + "CANYONOS_JWT_SECRET", + "CANYONOS_REDIS_HOST", + "CANYONOS_REDIS_PORT", + "CANYONOS_API_IMAGE", + "CANYONOS_WEB_IMAGE", + ): + monkeypatch.delenv(key, raising=False) + monkeypatch.setattr(dashboard_stack.shutil, "which", lambda _: "/usr/bin/docker") + monkeypatch.setattr(dashboard_stack, "_port_is_free", lambda _port: True) + return tmp_path + + +def install_docker(monkeypatch, calls, responses=None): + responses = responses or {} + + def fake_run(argv, **_): + calls.append(argv) + if callable(responses): + return responses(argv) + for marker, result in responses.items(): + if argv[-len(marker) :] == list(marker): + return result(argv) + if argv[:3] == ["docker", "container", "inspect"]: + return completed(argv, returncode=1) + return completed(argv) + + monkeypatch.setattr(dashboard_stack.subprocess, "run", fake_run) + + +@pytest.mark.parametrize( + ("prepare", "message"), + [ + (lambda monkeypatch, _: monkeypatch.setattr(dashboard_stack.shutil, "which", lambda _: None), "docker is not on PATH"), + ( + lambda _, responses: responses.update( + {("info",): lambda argv: completed(argv, returncode=1)} + ), + "docker daemon or socket is unavailable", + ), + ( + lambda _, responses: responses.update( + {("compose", "version"): lambda argv: completed(argv, returncode=1)} + ), + "docker compose is unavailable", + ), + ], +) +def test_docker_validation_failures_do_not_pull(monkeypatch, project, prepare, message): + calls = [] + responses = {} + prepare(monkeypatch, responses) + install_docker(monkeypatch, calls, responses) + + result = dashboard_stack.run_dashboard() + + assert result == dashboard_stack.ServeResult(False, "validate", message) + assert all(command[-1] != "pull" for command in calls) + + + + + + + +def test_user_jwt_secret_is_untouched_while_canyonos_secret_is_stable(project): + source_line = "JWT_SECRET=user-value\n" + Path.cwd().joinpath(".env").write_text(source_line) + stack = dashboard_stack.DashboardStack(dashboard_stack._state_dir(), Path.cwd()) + + first_env, _ = dashboard_stack.prepare(stack) + second_env, _ = dashboard_stack.prepare(stack) + + env_contents = stack.env_path.read_text() + assert env_contents.startswith(source_line) + assert first_env["CANYONOS_JWT_SECRET"] != "user-value" + assert first_env["CANYONOS_JWT_SECRET"] == second_env["CANYONOS_JWT_SECRET"] + + + + +def test_state_directory_and_port_validation_failures_do_not_pull(monkeypatch, project, tmp_path): + calls = [] + install_docker(monkeypatch, calls) + blocked_state_dir = tmp_path / "blocked" + blocked_state_dir.write_text("not a directory") + monkeypatch.setattr(dashboard_stack, "_state_dir", lambda: blocked_state_dir) + + state_result = dashboard_stack.run_dashboard() + + assert state_result.message == "dashboard state directory is not writable" + assert all(command[-1] != "pull" for command in calls) + + calls.clear() + monkeypatch.setattr(dashboard_stack, "_state_dir", lambda: tmp_path / "state") + monkeypatch.setattr(dashboard_stack, "_find_web_port", lambda start=8080, max_attempts=50: (_ for _ in ()).throw( + dashboard_stack.PhaseFailure("validate", "no free port found for the dashboard after 50 attempts starting at 8080") + )) + port_result = dashboard_stack.run_dashboard() + + assert port_result.message == "no free port found for the dashboard after 50 attempts starting at 8080" + assert all(command[-1] != "pull" for command in calls) + + +def test_prepare_preserves_unrelated_env_lines_and_mode(project): + Path.cwd().joinpath(".env").write_text( + "OTHER=one\n# preserved\nJWT_SECRET=kept-secret\nLAST=two\n" + ) + stack = dashboard_stack.DashboardStack(dashboard_stack._state_dir(), Path.cwd()) + + managed_env, message = dashboard_stack.prepare(stack) + + assert message == "dashboard state prepared" + env_lines = stack.env_path.read_text().splitlines() + assert env_lines[:2] == ["OTHER=one", "# preserved"] + assert env_lines[2] == "JWT_SECRET=kept-secret" + assert env_lines[3] == "LAST=two" + assert {line.split("=", 1)[0] for line in env_lines if "=" in line} == { + "OTHER", + "JWT_SECRET", + "LAST", + "CANYONOS_JWT_SECRET", + "CANYONOS_REDIS_HOST", + "CANYONOS_REDIS_PORT", + "CANYONOS_API_IMAGE", + "CANYONOS_WEB_IMAGE", + "CANYONOS_WEB_PORT", + } + assert stack.env_path.stat().st_mode & 0o777 == 0o600 + assert stack.state_dir.stat().st_mode & 0o777 == 0o700 + assert sorted(path.name for path in stack.state_dir.iterdir()) == ["stack.json"] + + + +def test_redaction_removes_urls_secrets_and_credentials(): + database_url = "postgres://user:password@db.example/canyonos" + secret = "secret-value" + logs = f"{database_url}\n{secret}\nredis://other:credential@cache:6379/0" + + redacted = dashboard_stack.redact_logs(logs, secret) + + assert database_url not in redacted # credentials portion is stripped by the generic regex + assert secret not in redacted + assert "user:password@" not in redacted + assert "other:credential@" not in redacted + + +@pytest.mark.parametrize("had_containers", [False, True]) +def test_start_failure_saves_log_and_cleans_up_only_new_stack(monkeypatch, project, had_containers): + calls = [] + + def response(argv): + if argv[-2:] == ["ps", "-q"]: + return completed(argv, stdout="existing\n" if had_containers else "") + if argv[-5:] == ["up", "-d", "--wait", "--wait-timeout", "180"]: + return completed(argv, returncode=1) + if argv[-4:] == ["logs", "--no-color", "--tail", "200"]: + return completed(argv, stdout="postgres://user:password@db.example/canyonos") + return completed(argv) + + install_docker(monkeypatch, calls, response) + result = dashboard_stack.run_dashboard() + + assert result.ok is False + assert result.phase == "start" + assert result.log_path is not None + assert Path(result.log_path).stat().st_mode & 0o777 == 0o600 + assert "postgres://user:password@db.example/canyonos" not in Path(result.log_path).read_text() + assert any("logs" in command for command in calls) + assert any(command[-1] == "down" for command in calls) is (not had_containers) + + +def test_pull_failure_includes_redacted_stderr(monkeypatch, project): + calls = [] + secret = None + + def response(argv): + nonlocal secret + if argv[-1] == "pull": + secret = next( + line.split("=", 1)[1] + for line in Path.cwd().joinpath(".env").read_text().splitlines() + if line.startswith("CANYONOS_JWT_SECRET=") + ) + return completed( + argv, + returncode=1, + stderr=f"first line\npull unauthorized postgres://user:password@db.example/canyonos {secret}\n", + ) + return completed(argv) + + install_docker(monkeypatch, calls, response) + result = dashboard_stack.run_dashboard() + + assert result.phase == "pull" + assert "pull unauthorized" in result.message + assert "postgres://user:password@db.example/canyonos" not in result.message + assert "user:password@" not in result.message + assert secret not in result.message + + +def test_verify_failure_saves_a_log(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + probes = [] + + def urlopen(*_args, **_kwargs): + probes.append(True) + raise dashboard_stack.urllib.error.URLError("down") + + monkeypatch.setattr( + dashboard_stack.urllib.request, + "urlopen", + urlopen, + ) + clock = iter([0, 0, 0, 31]) + monkeypatch.setattr(dashboard_stack.time, "monotonic", lambda: next(clock)) + monkeypatch.setattr(dashboard_stack.time, "sleep", lambda _: None) + + result = dashboard_stack.run_dashboard() + + assert result.ok is False + assert result.phase == "verify" + assert result.log_path is not None + assert Path(result.log_path).is_file() + assert probes == [True] + assert any("logs" in command for command in calls) + assert any(command[-1] == "down" for command in calls) + + +def test_success_pulls_starts_and_verifies(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + + class Response: + status = 200 + + def close(self): + pass + + endpoints = [] + + def urlopen(endpoint, timeout): + endpoints.append((endpoint, timeout)) + return Response() + + monkeypatch.setattr(dashboard_stack.urllib.request, "urlopen", urlopen) + result = dashboard_stack.run_dashboard() + + assert result == dashboard_stack.ServeResult( + True, "verify", "dashboard health checks passed", "http://127.0.0.1:8080" + ) + pull_index = next(index for index, command in enumerate(calls) if command[-1] == "pull") + up_index = next(index for index, command in enumerate(calls) if "up" in command) + assert pull_index < up_index + assert calls[pull_index][4:6] == ["--env-file", str(project / ".env")] + assert calls[up_index][-5:] == ["up", "-d", "--wait", "--wait-timeout", "180"] + assert endpoints == [ + ("http://127.0.0.1:8080/healthz", 5), + ("http://127.0.0.1:8080/api/healthz", 5), + ] + + +def test_existing_dashboard_container_skips_port_check(monkeypatch, project): + calls = [] + + def response(argv): + if argv[:3] == ["docker", "container", "inspect"]: + return completed( + argv, + stdout=json.dumps( + [{"NetworkSettings": {"Ports": {"8080/tcp": [{"HostIp": "127.0.0.1", "HostPort": "8080"}]}}}] + ), + ) + if argv[-2:] == ["ps", "-q"]: + return completed(argv, stdout="existing\n") + return completed(argv) + + install_docker(monkeypatch, calls, response) + monkeypatch.setattr( + dashboard_stack, + "_find_web_port", + lambda *a, **k: pytest.fail("the existing dashboard owns port 8080, should not search for a new one"), + ) + monkeypatch.setattr( + dashboard_stack.urllib.request, + "urlopen", + lambda *_args, **_kwargs: type("Response", (), {"status": 200, "close": lambda self: None})(), + ) + + result = dashboard_stack.run_dashboard() + + assert result.ok + assert result.url == "http://127.0.0.1:8080" diff --git a/tests/test_deploy_progress.py b/tests/test_deploy_progress.py new file mode 100644 index 0000000..d1c8cf6 --- /dev/null +++ b/tests/test_deploy_progress.py @@ -0,0 +1,235 @@ +import pytest + +from canyonos import deploy as deploy_cmd +from canyonos.deploy import PhaseTracker + + +def drive(lines): + """Feed lines to a tracker, returning (spinners, completions, errored).""" + tracker = PhaseTracker() + spinners, done = [], [] + errored = False + for line in lines: + message, completed, is_error = tracker.feed(line) + if is_error: + errored = True + if message: + spinners.append(message) + if completed: + done.append(completed) + return tracker, spinners, done, errored + + +def test_a_full_run_reports_each_phase_once(): + _, spinners, done, errored = drive( + [ + "INFO:ventis:Generating stub: a.yaml -> a_stub.py\n", + "INFO:ventis:Compiling gRPC proto: a.proto\n", + "INFO:ventis:Building 3 Docker image(s) via `docker buildx bake`.\n", + "#5 [4/7] RUN pip install -r requirements.txt\n", + "INFO:ventis:Build complete.\n", + "INFO:ventis:Deploying from config: config.yaml\n", + "INFO:ventis.controller.global_controller:Redis launched on 1 node(s).\n", + "INFO:ventis.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:ventis.controller.global_controller:Controller Intent (127.0.0.1:50051) is ready.\n", + "INFO:ventis.controller.global_controller:Controller Metrics (127.0.0.1:50052) is ready.\n", + ] + ) + assert not errored + assert done == ["Build complete", "Redis ready"] + assert "Building 3 images..." in spinners + assert spinners[-1] == "Starting agents (2/2 ready)..." + + +def test_phases_are_matched_in_the_order_the_container_emits_them(): + """Redis and stale-container cleanup are logged by GlobalController.__init__, + which runs before `Deploying from config:` -- so the matcher must not assume + the config line comes first. + """ + _, spinners, done, _ = drive( + [ + "INFO:ventis.controller.global_controller:Checking for stale containers from previous runs...\n", + "INFO:ventis.controller.global_controller:Redis launched on 1 node(s).\n", + "INFO:ventis:Deploying from config: config.yaml\n", + ] + ) + assert done == ["Redis ready"] + assert spinners == ["Cleaning up stale containers...", "Starting deploy..."] + + +def test_repeated_build_lines_collapse_to_one_spinner_update(): + _, spinners, _, _ = drive( + [ + "INFO:ventis:Generating stub: a.yaml -> a_stub.py\n", + "INFO:ventis:Generating stub: b.yaml -> b_stub.py\n", + "INFO:ventis:Generating Docker context for 'b'\n", + ] + ) + assert spinners == ["Generating stubs and Docker contexts..."] + + +def test_a_run_with_nothing_to_build_still_reports_the_phase(): + _, _, done, _ = drive( + [ + "INFO:ventis:No Docker images to build.\n", + "INFO:ventis:Build complete.\n", + ] + ) + assert done == ["No images to build", "Build complete"] + + +def test_agent_progress_counts_up_against_the_announced_total(): + tracker, spinners, _, _ = drive( + [ + "INFO:ventis.controller.global_controller:Waiting for 3 replica(s) to become healthy (timeout=300s)...\n", + "INFO:ventis.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n", + "INFO:ventis.controller.global_controller:Controller B (127.0.0.1:2) is ready.\n", + ] + ) + assert spinners[-1] == "Starting agents (2/3 ready)..." + assert tracker.replicas_total == 3 + + +def test_replicas_of_one_agent_are_counted_separately(): + """`Controller %s is ready.` logs the agent name, which repeats across that + agent's replicas -- the endpoint is what distinguishes them. + """ + tracker, spinners, _, _ = drive( + [ + "INFO:ventis.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:ventis.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", + "INFO:ventis.controller.global_controller:Controller Echo (127.0.0.1:50052) is ready.\n", + ] + ) + assert spinners[-1] == "Starting agents (2/2 ready)..." + assert tracker.agents_ready_message() == ("2 agent(s) ready", True) + + +def test_a_re_read_ready_line_does_not_double_count(): + tracker, _, _, _ = drive( + [ + "INFO:ventis.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:ventis.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", + "INFO:ventis.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", + ] + ) + assert tracker.agents_ready_message() == ( + "Workflow up, but only 1/2 agents reported healthy", + False, + ) + + +def test_coming_up_short_of_the_announced_replicas_is_not_reported_as_success(): + """`_wait_for_healthy` gives up after its timeout and the controller starts + anyway, so the up-marker can arrive with agents still unhealthy. + """ + tracker, _, _, _ = drive( + [ + "INFO:ventis.controller.global_controller:Waiting for 3 replica(s) to become healthy (timeout=300s)...\n", + "INFO:ventis.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n", + ] + ) + message, all_ready = tracker.agents_ready_message() + assert not all_ready + assert message == "Workflow up, but only 1/3 agents reported healthy" + + +def test_a_run_that_never_announced_replicas_still_reports_ready(): + tracker, _, _, _ = drive(["INFO:ventis:Build complete.\n"]) + assert tracker.agents_ready_message() == ("Workflow ready", True) + + +def test_replicas_ready_without_an_announced_total_still_reports_progress(): + _, spinners, _, _ = drive( + ["INFO:ventis.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n"] + ) + assert spinners == ["Starting agents..."] + + +@pytest.mark.parametrize( + "line", + [ + "ERROR:ventis:Config file not found: missing.yaml\n", + "Traceback (most recent call last):\n", + "ERROR: failed to solve: process \"/bin/sh -c pip install\" did not complete successfully\n", + ], +) +def test_fatal_lines_are_flagged(line): + _, _, _, errored = drive([line]) + assert errored + + +@pytest.mark.parametrize( + "line", + [ + "WARNING:ventis.controller.global_controller:otel.destinations not configured -- no OTel metrics collection will happen.\n", + " Warning: no entrypoint mapping for 'agent'\n", + ], +) +def test_benign_warnings_do_not_trip_the_error_path(line): + _, _, _, errored = drive([line]) + assert not errored + + +def test_the_deploy_is_only_declared_dead_after_two_consecutive_checks(monkeypatch): + """One dropped request shouldn't end a deploy that is merely busy.""" + replies = iter([None, {"running": True}, None, None]) + monkeypatch.setattr(deploy_cmd, "deploy_status", lambda _port: next(replies)) + state = {"port": 1} + + misses = 0 + verdicts = [] + for _ in range(4): + dead, misses = deploy_cmd._deploy_is_dead(state, misses) + verdicts.append(dead) + + # a miss, then a recovery that resets the count, then two misses in a row + assert verdicts == [False, False, False, True] + + +def test_a_running_deploy_is_never_declared_dead(monkeypatch): + monkeypatch.setattr(deploy_cmd, "deploy_status", lambda _port: {"running": True}) + dead, misses = deploy_cmd._deploy_is_dead({"port": 1}, 1) + assert not dead and misses == 0 + + +def test_the_clis_own_status_requests_are_not_shown_or_buffered(monkeypatch): + """The container logs every request the CLI makes to it, so its own polling + lands in the stream it is reading. + """ + shown = [] + monkeypatch.setattr(deploy_cmd.ui, "ok", lambda m: shown.append(m)) + monkeypatch.setattr(deploy_cmd.ui, "warn", lambda m: shown.append(m)) + monkeypatch.setattr(deploy_cmd, "_deploy_summary", lambda *a: ("url", [])) + + lines = deploy_cmd._queued_lines( + iter( + [ + '172.17.0.1 - - [04/Sep/2026 21:00:00] "GET /status HTTP/1.1" 200 -\n', + "INFO:ventis:Build complete.\n", + "INFO:ventis.controller.global_controller:Global controller started, polling every 5s...\n", + ] + ) + ) + summary = deploy_cmd._tail_quiet(lines, {"port": 1}, 8080, serve=False) + + assert summary == ("url", []) + assert shown == ["Build complete", "Workflow ready"] + + +def test_a_build_that_dies_silently_does_not_hang(monkeypatch, capsys): + """A failed build leaves `docker logs -f` open with nothing more to say, so + the wait has to end on /status rather than on the stream closing. + """ + monkeypatch.setattr(deploy_cmd, "_STATUS_POLL_SECONDS", 0.01) + monkeypatch.setattr(deploy_cmd, "_REVEAL_GRACE_SECONDS", 0.5) + monkeypatch.setattr(deploy_cmd, "deploy_status", lambda _p: {"running": False}) + + lines = deploy_cmd._queued_lines(iter(["INFO:ventis:Building 2 Docker image(s) via `x`.\n"])) + # 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) + + assert summary is None + assert "Building 2 Docker image(s)" in capsys.readouterr().out diff --git a/tests/test_integration.py b/tests/test_integration.py index a2e3747..85f0c20 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -10,7 +10,7 @@ def run_integration_test(): base_url = "http://localhost:8080" print(f"Submitting query to {base_url}/main...") - response = requests.post(f"{base_url}/main", json={"ticker": "MSFT"}) + response = requests.post(f"{base_url}/main", json={"query": "MSFT"}) if response.status_code != 202: print(f"Error submitting request: HTTP {response.status_code}") diff --git a/uv.lock b/uv.lock index 9b86805..8278401 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3d/82/6f8fbbea47b773734ba0199643d2d851e5c2f75bc3699fe99db8af344d96/botocore-1.43.58-py3-none-any.whl", hash = "sha256:f516159f0732da8249206163ccea3bd1f82ad2a9d184fe6ed447e1abdba4330e", size = 15426503, upload-time = "2026-07-28T19:34:53.508Z" }, ] +[[package]] +name = "canyonos" +version = "0.1.5" +source = { editable = "cli" } +dependencies = [ + { name = "pyfiglet" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "ruamel-yaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyfiglet" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "ruamel-yaml" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -529,6 +548,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -614,6 +645,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "opentelemetry-api" version = "1.44.0" @@ -854,6 +894,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" }, ] +[[package]] +name = "pyfiglet" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e3/0a86276ad2c383ce08d76110a8eec2fe22e7051c4b8ba3fa163a0b08c428/pyfiglet-1.0.4.tar.gz", hash = "sha256:db9c9940ed1bf3048deff534ed52ff2dafbbc2cd7610b17bb5eca1df6d4278ef", size = 1560615, upload-time = "2025-08-15T18:32:47.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/5c/fe9f95abd5eaedfa69f31e450f7e2768bef121dbdf25bcddee2cd3087a16/pyfiglet-1.0.4-py3-none-any.whl", hash = "sha256:65b57b7a8e1dff8a67dc8e940a117238661d5e14c3e49121032bd404d9b2b39f", size = 1806118, upload-time = "2025-08-15T18:32:45.556Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -984,6 +1033,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, +] + [[package]] name = "s3transfer" version = "0.19.2" @@ -1172,6 +1243,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "canyonos" }, { name = "pytest" }, ] @@ -1193,7 +1265,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "canyonos", editable = "cli" }, + { name = "pytest", specifier = ">=9.1.1" }, +] [[package]] name = "werkzeug" diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index a1cbb4d..be3bb18 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -134,12 +134,16 @@ def provision_instance(spec, replica_index, next_host_port=None): raise RuntimeError( f"EC2 instance {instance_id} does not have a reachable IP address." ) + # Kept alongside `host` (a private IP inside the VPC): callers outside the + # VPC, such as the CLI printing where to send requests, need this one. + public_host = instance.get("PublicIpAddress") if instance else None redis_port = spec.get( "redis_port", _controller.config.get("redis", {}).get("port", 6379) ) record = { "host": host, + "public_host": public_host, "runtime_id": runtime_id, "redis_host": host, "redis_port": redis_port, @@ -169,7 +173,7 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): f"{host}:{CONTAINER_PORT}", timeout=cfg.get("controller_health_timeout", 180), ) - return { + instance = { "agent_name": spec["name"], "provider": "EC2", "instance_type": spec["instance_type"], @@ -182,6 +186,11 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): "redis_port": str(redis_port), "runtime_id": runtime_id, } + if provisioned.get("public_host"): + instance["public_host"] = provisioned["public_host"] + if spec.get("type") == "workflow": + instance["api_port"] = str(spec.get("api_port", 8080)) + return instance except Exception: terminate_instance(provisioned) raise diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index dda4807..88e4ec3 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -153,6 +153,8 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): } if user: instance["user"] = user + if ctrl_type == "workflow": + instance["api_port"] = str(spec.get("api_port", 8080)) logger.info("Runtime ready: %s -> %s", runtime_id, instance["endpoint"]) return instance diff --git a/ventis/controller/instance_manager.py b/ventis/controller/instance_manager.py index e904c5c..4117fd1 100644 --- a/ventis/controller/instance_manager.py +++ b/ventis/controller/instance_manager.py @@ -128,10 +128,11 @@ def _write_instance(self, instance): "redis_port": str(instance["redis_port"]), "runtime_id": instance["runtime_id"], } - if instance.get("user"): - mapping["user"] = instance["user"] - if instance.get("instance_type"): - mapping["instance_type"] = instance["instance_type"] + # public_host: set by providers whose `host` isn't reachable from outside + # the deployment's network. api_port: workflow replicas only. + for field in ("user", "instance_type", "public_host", "api_port"): + if instance.get(field): + mapping[field] = str(instance[field]) self.redis.hset_multiple(key, mapping) node_redis = self.controller.node_redis.get(instance["host"]) or self.redis diff --git a/ventis/server.py b/ventis/server.py index 8df8b0f..31acba7 100644 --- a/ventis/server.py +++ b/ventis/server.py @@ -3,15 +3,22 @@ import subprocess import sys +import yaml from flask import Flask, jsonify, request +from ventis.cli import _artifact_prefix +from ventis.controller.utils.redis_client import RedisClient + app = Flask("ventis-server") # The project files are copied here (into a named volume) by `canyonos sync` / # `canyonos deploy`. Deploy builds and launches against this path. WORKSPACE_DIR = "/workspace" +DEFAULT_API_PORT = 8080 + _gc_process = None +_config_path = None def _gc_running(): @@ -25,13 +32,17 @@ def new_project(): @app.route("/deploy", methods=["POST"]) def deploy(): - global _gc_process + global _gc_process, _config_path if _gc_running(): return jsonify({"error": "already running"}), 409 data = request.get_json(force=True, silent=True) or {} - config_path = data.get("config_path", "config/global_controller.yaml") + # Resolved with ventis' own artifact-layout rule rather than a second copy + # of it, so a `.car` project works when the client sends no config_path. + config_path = data.get("config_path") or os.path.join( + _artifact_prefix(WORKSPACE_DIR), "config", "global_controller.yaml" + ) full_path = os.path.join(WORKSPACE_DIR, config_path) if not os.path.isfile(full_path): @@ -45,6 +56,7 @@ def deploy(): [sys.executable, "-m", "ventis.cli", "deploy", "-c", config_path], cwd=WORKSPACE_DIR, ) + _config_path = full_path return jsonify({"status": "started", "pid": _gc_process.pid}), 200 @@ -66,5 +78,69 @@ def status(): return jsonify({"running": _gc_running()}), 200 +def _primary_redis(config): + """The Redis the controller writes instance records to: the local node's. + + Mirrors GlobalController._launch_redis_containers(), where a localhost node + is reached through VENTIS_REDIS_HOST when the controller is containerized. + """ + redis_cfg = config.get("redis", {}) + host = redis_cfg.get("host", "localhost") + port = redis_cfg.get("port", 6379) + for agent in config.get("agents") or []: + if str(agent.get("provider", "local")).lower() == "local": + port = agent.get("redis_port", port) + break + if host in ("localhost", "127.0.0.1"): + host = os.environ.get("VENTIS_REDIS_HOST", host) + return RedisClient(host=host, port=int(port)) + + +def _workflow_endpoints(config): + """Address of every running workflow replica, as the caller should reach it.""" + ports = { + agent["name"]: agent.get("api_port", DEFAULT_API_PORT) + for agent in config.get("agents") or [] + if agent.get("type") == "workflow" and agent.get("name") + } + if not ports: + return [] + + redis_client = _primary_redis(config) + endpoints = [] + for key in sorted(redis_client.scan_keys("agent_instance:*")): + record = redis_client.hgetall(key) + name = record.get("agent_name") + if name not in ports: + continue + # public_host wins: `host` is the address the controller routes over, + # which for a workflow on another machine is private to that network. + host = record.get("public_host") or record.get("host") + if not host: + continue + endpoints.append( + { + "name": name, + "host": host, + "port": int(record.get("api_port") or ports[name]), + } + ) + return endpoints + + +@app.route("/endpoints", methods=["GET"]) +def endpoints(): + """Where the deployed workflows answer, so the CLI can print real addresses.""" + if _config_path is None or not os.path.isfile(_config_path): + return jsonify({"workflows": []}), 200 + + try: + with open(_config_path) as f: + config = yaml.safe_load(f) or {} + return jsonify({"workflows": _workflow_endpoints(config)}), 200 + except Exception as e: + return jsonify({"workflows": [], "error": str(e)}), 200 + + if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)