diff --git a/.claude/skills/porting-to-canyonos/references/ec2.md b/.claude/skills/porting-to-canyonos/references/ec2.md index 6dbdfe2..d5f3390 100644 --- a/.claude/skills/porting-to-canyonos/references/ec2.md +++ b/.claude/skills/porting-to-canyonos/references/ec2.md @@ -31,8 +31,9 @@ that provisioning, SSH, image transfer, or remote container startup works. ## Networking A remote container's `host.docker.internal` names its own EC2 Docker host. It -does not name the local controller machine. Databases, model proxies, and other -services must use addresses reachable from every selected host. +does not name the local controller machine. Databases, model proxies, `otel` +destinations, and other services must use addresses reachable from every +selected host. The environment file may be copied temporarily to a remote host by runtimes that expose the `env_file` capability. Confirm behavior from the capability probe and diff --git a/.claude/skills/porting-to-canyonos/references/manifest.md b/.claude/skills/porting-to-canyonos/references/manifest.md index 0eb00ac..395fae8 100644 --- a/.claude/skills/porting-to-canyonos/references/manifest.md +++ b/.claude/skills/porting-to-canyonos/references/manifest.md @@ -45,10 +45,13 @@ column only, in one round, carrying these defaults. | `api_port` | developer | `8080` | | `redis_port`, `redis.host` / `.port` / `.db` | developer | `6379`, `localhost` / `6379` / `0` | | `poll_interval` | developer | `5` | +| `cleanup_interval` | developer | `10` | | `env_file` | developer — the file's location and whether it exists | `.env` when the survey found credential reads, else absent | +| `otel.destinations` | derived for `provider: local` (see below) | the local dashboard's OTLP ingest | +| `project_id` | derived — generated once by the controller and written back into the config file | absent on first write; a generated UUID after | | `policy.yaml` | developer | absent | -Two entries in that table are not free choices, and saying so is part of showing +Three entries in that table are not free choices, and saying so is part of showing the config rather than asking about it: - **`replicas` stops being a choice once a service holds cross-request state.** @@ -59,6 +62,31 @@ the config rather than asking about it: example environment, and a wrong AMI, subnet, or security group fails at deploy preflight or, worse, provisions something unreachable. Unanswered means the entry stays `local`. +- **`otel.destinations` defaults to the local dashboard's own OTLP ingest for + `provider: local`.** Without it, the exporter subprocess never starts and no + trace reaches the dashboard -- expected only when the developer explicitly + wants tracing off. Include: + + ```yaml + 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: local + protocol: http + endpoint: http://host.docker.internal:3000/v1/traces + headers: {} + ``` + + `host.docker.internal` names the local Docker host, not a remote one -- + read [ec2.md](ec2.md#networking) before reusing this block on an entry with + `provider: EC2`. + +Omitting `project_id` is not the same as leaving it unset: the controller +generates a UUID on first load and appends `project_id: ""` to the +config file on disk so it survives reloads and restarts. Never invent one when +reviewing a candidate manifest -- absent means "not yet assigned", not "missing". ## Configuration review @@ -183,6 +211,16 @@ redis: db: 0 env_file: .env # relative to the application root, not .car + +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: local + protocol: http + endpoint: http://host.docker.internal:3000/v1/traces + headers: {} ``` Omit `database`. Without it every metrics poll logs `Could not parse SQLAlchemy diff --git a/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md b/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md index 9f5f515..6b2e16f 100644 --- a/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md +++ b/.claude/skills/porting-to-canyonos/references/validation-and-deploy.md @@ -37,6 +37,14 @@ capability limitations: list each in the handoff and state whether it blocks this source. Confirm with `git status` that no developer-owned file outside `.car` changed. +`canyonos_core` ships only inside the built container image, so any check +still gated on importing it (currently only the full-project-file-sweep +check) reports UNAVAILABLE on every local run, on every machine, regardless +of Python or venv. That is expected -- report it as such in the handoff and +move on. Do not treat it as a code defect or an environment problem to debug +on this host; there is no local fix, and no amount of venv or `PYTHONPATH` +troubleshooting makes it importable outside a container. + Report: - that the `.car` port validated; diff --git a/.claude/skills/porting-to-canyonos/validate.py b/.claude/skills/porting-to-canyonos/validate.py index 9283dc9..868ee81 100755 --- a/.claude/skills/porting-to-canyonos/validate.py +++ b/.claude/skills/porting-to-canyonos/validate.py @@ -15,9 +15,12 @@ Exit 1 if any ERROR was reported, 0 otherwise. --strict also fails on warnings. -Runtime capabilities vary across CanyonOS Core installations. This script probes -the importable `canyonos_core` package directly. A capability-gated check reports -UNAVAILABLE when its behavior cannot be proven. +`canyonos_core` ships inside the built container image, not on the host: the +`canyonos` CLI's own venv does not install it, so this script's probe of the +importable `canyonos_core` package fails on every local run, for every source +tree, regardless of which Python or venv runs it. That is expected, not an +environment defect to chase on this machine. A capability-gated check reports +UNAVAILABLE rather than failing when its behavior cannot be proven this way. """ import argparse @@ -127,14 +130,6 @@ def validate(artifact_dir, config_path, capabilities): entrypoint_path = os.path.join(source_dir, entrypoint or "") if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): check_entrypoint_module(report, source_dir, name, entrypoint) - check_requirements_coverage( - report, - source_dir, - entry, - entrypoint_path, - config_path, - BASE_AGENT_REQUIREMENTS, - ) # Where each agent's stub is written, and therefore the only import that # reaches it over gRPC. @@ -149,6 +144,35 @@ def validate(artifact_dir, config_path, capabilities): if name in agents_by_name ] + # A second pass: every other agent's entrypoint is a stub in this image, + # but this entry's own entrypoint is the one file that is not -- it is + # the real code this image runs. Excluding it from `shadowed_paths` is + # what tells `reachable_imports` to keep walking past it instead of + # treating it as a stub and losing everything it reaches. + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": + continue + name = entry.get("name") + entrypoint = entry.get("entrypoint") + entrypoint_path = os.path.join(source_dir, entrypoint or "") + if not (isinstance(entrypoint, str) and os.path.isfile(entrypoint_path)): + continue + own_path = os.path.realpath(entrypoint_path) + shadowed_paths = [ + path + for path in stubbed_entrypoint_paths + if os.path.realpath(path) != own_path + ] + check_requirements_coverage( + report, + source_dir, + entry, + entrypoint_path, + config_path, + BASE_AGENT_REQUIREMENTS, + shadowed_paths=shadowed_paths, + ) + for entry in entries: if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": continue @@ -174,7 +198,7 @@ def validate(artifact_dir, config_path, capabilities): # These survive a green build and otherwise surface only in a container or # on its first request. check_flat_collisions(report, source_dir, entrypoints) - check_env_file(report, config, config_path, artifact_dir) + check_env_file(report, config, config_path) entrypoint_paths = [ os.path.join(source_dir, e) @@ -222,7 +246,9 @@ def _wrap(text, width, indent): def print_report(report, artifact_root): caps = report.capabilities if not caps.get("canyonos_core"): - print("canyonos_core is not importable here -- capability-gated rules are") + print("canyonos_core is not importable here -- expected on a local run,") + print("since it ships only inside the built container image. This is not") + print("something to fix on this machine. Capability-gated rules are") print("reported UNAVAILABLE rather than checked.\n") else: print("CanyonOS Core capabilities detected:") diff --git a/.claude/skills/porting-to-canyonos/validation/packaging.py b/.claude/skills/porting-to-canyonos/validation/packaging.py index a89d5a7..8d37495 100644 --- a/.claude/skills/porting-to-canyonos/validation/packaging.py +++ b/.claude/skills/porting-to-canyonos/validation/packaging.py @@ -1,6 +1,12 @@ -"""V030-V031 -- capability-gated rules about credentials and import roots.""" +"""V030-V031 -- rules about credentials and import roots. -import os +Both used to be gated on a probed capability. Neither actually varies: +`global_controller.py` calls `resolve_env_file` unconditionally on every +deployment, so env-file injection is not optional; and `canyonos_core` has no +`_install_step` or any other editable-install mechanism, in this codebase or +its history, so an editable install is never available. Treat both as fixed +facts about the current runtime instead of probing for them. +""" from validation.core import line_of from validation.python_source import ( @@ -12,53 +18,24 @@ from validation.runtime import RUNTIME_FLAT_NAMES -def check_env_file(report, config, config_path, artifact_dir): - """V030 -- gated on detected env-file injection support.""" - declared = config.get("env_file") - supported = report.capabilities.get("env_file") - - if not supported: - if declared: - report.error( - "V030", - config_path, - line_of(config, "env_file"), - f"`env_file: {declared}` is set, but this CanyonOS Core never reads it", - "No resolve_env_file in the importable canyonos_core package, so the " - "key is silently dropped and the container answers a provider " - "credential error on the first request. This port requires the " - "`env_file` runtime capability.", - ) - else: - report.unavailable( - "V030", - "env_file is not supported by the importable `canyonos_core` runtime. " - "Credentials have no declared path into a container on this tree.", - ) +def check_env_file(report, config, config_path): + """V030 -- env-file injection is mandatory; warn when the config omits it.""" + if config.get("env_file"): return - if not declared: - report.warn( - "V030", - config_path, - line_of(config), - "no `env_file:` in the config", - "Only runtime-managed CANYONOS_* variables are guaranteed without it. " - "If the source reads credentials from the environment, the first " - "request fails on a provider error.", - ) - return + report.warn( + "V030", + config_path, + line_of(config), + "no `env_file:` in the config", + "Only runtime-managed CANYONOS_* variables are guaranteed without it. " + "If the source reads credentials from the environment, the first " + "request fails on a provider error.", + ) def check_import_root(report, source_dir, entrypoint_paths): - """V031 -- gated on detected editable-install support.""" - supported = report.capabilities.get("editable_install") - has_metadata = any( - os.path.isfile(os.path.join(source_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - - non_flat = [] + """V031 -- canyonos_core runs no editable install; only /app-rooted names import.""" for path in entrypoint_paths: tree, _ = parse_python(path) if tree is None: @@ -69,40 +46,16 @@ def check_import_root(report, source_dir, entrypoint_paths): if resolves_flat(source_dir, name): continue location = resolves_nested(source_dir, name) - if location: - non_flat.append((path, lineno, name, location)) - - if not supported: - report.unavailable( - "V031", - "the editable install (`-e .`) is not supported by the importable " - "`canyonos_core` runtime. Only names rooted at /app import inside a container.", - ) - for path, lineno, name, location in non_flat: + if not location: + continue report.error( "V031", path, lineno, f"`import {name}` resolves to {location}, which is not at the " "root of the source copy", - "sys.path[0] is /app and this CanyonOS Core runs no editable install, " - "so only modules swept to the root import. The adapter raises " - "ModuleNotFoundError inside _load_agent and the first request " - "answers 'No agent loaded'.", - ) - return - - if non_flat and not has_metadata: - for path, lineno, name, location in non_flat: - report.error( - "V031", - path, - lineno, - f"`import {name}` resolves to {location}, and the source copy's " - "root has no packaging metadata", - "A pyproject.toml, setup.py or setup.cfg at the root of the " - "source copy is what adds `-e .`; metadata nested deeper in the " - "tree is ignored. Add minimal root metadata pointing at the " - "existing package directory. Without it the install is skipped " - "silently.", + "sys.path[0] is /app and canyonos_core runs no editable " + "install, so only modules swept to the root import. The " + "adapter raises ModuleNotFoundError inside _load_agent and " + "the first request answers 'No agent loaded'.", ) diff --git a/.claude/skills/porting-to-canyonos/validation/runtime.py b/.claude/skills/porting-to-canyonos/validation/runtime.py index 3c76aa8..c9cac73 100644 --- a/.claude/skills/porting-to-canyonos/validation/runtime.py +++ b/.claude/skills/porting-to-canyonos/validation/runtime.py @@ -1,6 +1,5 @@ """Runtime capabilities and dependency facts used by validation checks.""" -import importlib import os import sys @@ -43,8 +42,6 @@ NAMESPACE_DISTRIBUTIONS = {"llama_index": "llama-index"} CAPABILITY_SOURCE = { - "env_file": "runtime env-file injection", - "editable_install": "editable project installation", "sweeps_all_files": "full project-file sweep", } @@ -94,7 +91,19 @@ def _stdlib_names(): def probe_capabilities(): - """Probe the installed compatibility runtime behind the CanyonOS CLI.""" + """Probe for `canyonos_core`, which is never present on the local host. + + It ships only inside the built container image, not in the `canyonos` CLI's + own venv or system Python, so this always returns all-False when run + outside a container -- on every machine, for every source tree. That is + the expected result of a local run, not a broken install to fix. + + `env_file` and `editable_install` used to be probed here too. Neither + actually varies, so they are no longer treated as capabilities: + `resolve_env_file` is called unconditionally by every + `global_controller.py`, and `canyonos_core` has no `_install_step` or any + other editable-install mechanism, in this codebase or its history. + """ capabilities = dict.fromkeys(CAPABILITY_SOURCE, False) capabilities["canyonos_core"] = False try: @@ -103,18 +112,5 @@ def probe_capabilities(): return capabilities capabilities["canyonos_core"] = True - capabilities["editable_install"] = hasattr(stub_generator, "_install_step") capabilities["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") - - for module_name in ( - "canyonos_core.controller.utils.env_file", - "canyonos_core.utils.env_file", - ): - try: - module = importlib.import_module(module_name) - except Exception: # noqa: BLE001 - try the other supported location - continue - if hasattr(module, "resolve_env_file"): - capabilities["env_file"] = True - break return capabilities