From 4d28f95d8bb56f70fb2507642278975948ad448f Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Tue, 25 Aug 2026 17:26:35 -0700 Subject: [PATCH 01/44] includes all the files when ventis build --- ventis/cli.py | 2 ++ ventis/stub_generator.py | 73 ++++++++++++++++++++++++++++++++++------ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/ventis/cli.py b/ventis/cli.py index 3aceb18..9ffc149 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -274,6 +274,7 @@ def cmd_build(args): output_dir=docker_context, grpc_stubs_dir=grpc_stubs_dir, api_port=agent_cfg.get("api_port", 8080), + project_dir=project_dir, ) else: @@ -316,6 +317,7 @@ def cmd_build(args): output_dir=docker_context, grpc_stubs_dir=grpc_stubs_dir, stub_files=stub_paths, + project_dir=project_dir, ) bake_targets.append( diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 8c956ef..a571ccc 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -263,8 +263,36 @@ def _format_source(source): return "\n".join(formatted) + "\n" +# Directories ventis build itself generates inside a project -- never swept. +_GENERATED_DIRS = {"docker_container", "stubs", "grpc_stubs"} + + +def _sweep_py_files(project_dir): + """Recursively collect (abs_src, rel_dst) for every .py file under project_dir, preserving its directory structure.""" + swept = [] + for root, dirs, files in os.walk(project_dir): + dirs[:] = [d for d in dirs if d not in _GENERATED_DIRS and not d.startswith(".")] + for fname in files: + if fname.endswith(".py"): + abs_src = os.path.join(root, fname) + rel_dst = os.path.relpath(abs_src, project_dir) + swept.append((abs_src, rel_dst)) + return swept + + +def _stub_destination(stub_file, project_dir): + """Mirror a stub to agents/, overwriting the real agent file cli.py always puts there; flat if no project sweep.""" + basename = os.path.basename(stub_file) + return os.path.join("agents", basename) if project_dir else basename + + def generate_docker( - yaml_path, agent_file, output_dir=None, grpc_stubs_dir=None, stub_files=None + yaml_path, + agent_file, + output_dir=None, + grpc_stubs_dir=None, + stub_files=None, + project_dir=None, ): """ Generate a minimal Docker build context for an agent. @@ -278,6 +306,7 @@ def generate_docker( output_dir: Optional output directory (default: docker_container//). grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). stub_files: Optional list of agent stub files to copy into the context. + project_dir: Optional project root to sweep for extra .py helper files. """ with open(yaml_path, "r") as f: config = yaml.safe_load(f) @@ -301,8 +330,13 @@ def generate_docker( with open(os.path.join(output_dir, "requirements.txt"), "w") as f: f.write(requirements) + # Sweep the project for extra .py helper files not on the explicit list below. + files_to_copy = [] + if project_dir: + files_to_copy += _sweep_py_files(project_dir) + # Copy general agent files - files_to_copy = [ + files_to_copy += [ # (source_path, destination_filename) (os.path.join(script_dir, "future.py"), "future.py"), (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), @@ -322,13 +356,13 @@ def generate_docker( (os.path.join(script_dir, "llm", "bedrock.py"), "bedrock.py"), ] - # Copy provided agent stubs + # Copy provided agent stubs, overwriting the swept real file at the same path if stub_files: for stub_file in stub_files: files_to_copy.append( - (os.path.abspath(stub_file), os.path.basename(stub_file)) + (os.path.abspath(stub_file), _stub_destination(stub_file, project_dir)) ) - + files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) # Copy gRPC generated stubs if they exist @@ -339,7 +373,9 @@ def generate_docker( for src, dst in files_to_copy: if os.path.isfile(src): - shutil.copy2(src, os.path.join(output_dir, dst)) + dest_path = os.path.join(output_dir, dst) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.copy2(src, dest_path) else: print(f" Warning: source file not found, skipping: {src}") @@ -377,7 +413,12 @@ def generate_docker( def generate_workflow_docker( - workflow_file, stub_files, output_dir=None, grpc_stubs_dir=None, api_port=8080 + workflow_file, + stub_files, + output_dir=None, + grpc_stubs_dir=None, + api_port=8080, + project_dir=None, ): """ Generate a Docker build context for a workflow. @@ -391,6 +432,7 @@ def generate_workflow_docker( stub_files: List of stub file paths to include. output_dir: Optional output directory (default: docker_container/Workflow/). grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). + project_dir: Optional project root to sweep for extra .py helper files. """ script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.join(script_dir, "..") @@ -417,7 +459,12 @@ def generate_workflow_docker( # ---- Copy source files into the build context ------------------------ workflow_basename = os.path.basename(workflow_file) - files_to_copy = [ + # Sweep the project for extra .py helper files not on the explicit list below. + files_to_copy = [] + if project_dir: + files_to_copy += _sweep_py_files(project_dir) + + files_to_copy += [ (os.path.abspath(workflow_file), workflow_basename), (os.path.join(script_dir, "future.py"), "future.py"), (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), @@ -437,9 +484,11 @@ def generate_workflow_docker( ], ] - # Copy stub files + # Copy stub files, overwriting the swept real file at the same path for stub_file in stub_files: - files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) + files_to_copy.append( + (os.path.abspath(stub_file), _stub_destination(stub_file, project_dir)) + ) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -449,7 +498,9 @@ def generate_workflow_docker( for src, dst in files_to_copy: if os.path.isfile(src): - shutil.copy2(src, os.path.join(output_dir, dst)) + dest_path = os.path.join(output_dir, dst) + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.copy2(src, dest_path) else: print(f" Warning: source file not found, skipping: {src}") From 23e3928d01bc5996fd1cd4e086d45212c283de0e Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Tue, 25 Aug 2026 18:23:51 -0700 Subject: [PATCH 02/44] fixed some bugs --- ventis/cli.py | 19 ++++++++++++++ ventis/stub_generator.py | 54 +++++++++++++++++++++++++++------------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/ventis/cli.py b/ventis/cli.py index 9ffc149..cb9ee0a 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -220,6 +220,23 @@ def cmd_build(args): generate_stub(yaml_path, output_path) stub_paths.append(output_path) + # Map each stub's basename to its agent's declared entrypoint, so a stub + # overwrites the exact real file it replaces instead of guessing its path. + stub_entrypoints = {} + for agent_cfg in agents: + entrypoint = agent_cfg.get("entrypoint") + if agent_cfg.get("type", "agent") == "workflow" or not entrypoint: + continue + for yaml_path in yaml_files: + import yaml + + with open(yaml_path) as f: + ydata = yaml.safe_load(f) + if ydata.get("agent", {}).get("name") == agent_cfg["name"]: + base_name = os.path.splitext(os.path.basename(yaml_path))[0] + stub_entrypoints[f"{base_name}.py"] = entrypoint + break + # -------------------------------------------------------------- # # Step 2: Compile gRPC protobuf stubs # # -------------------------------------------------------------- # @@ -275,6 +292,7 @@ def cmd_build(args): grpc_stubs_dir=grpc_stubs_dir, api_port=agent_cfg.get("api_port", 8080), project_dir=project_dir, + stub_entrypoints=stub_entrypoints, ) else: @@ -318,6 +336,7 @@ def cmd_build(args): grpc_stubs_dir=grpc_stubs_dir, stub_files=stub_paths, project_dir=project_dir, + stub_entrypoints=stub_entrypoints, ) bake_targets.append( diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index a571ccc..a56080d 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -271,7 +271,12 @@ def _sweep_py_files(project_dir): """Recursively collect (abs_src, rel_dst) for every .py file under project_dir, preserving its directory structure.""" swept = [] for root, dirs, files in os.walk(project_dir): - dirs[:] = [d for d in dirs if d not in _GENERATED_DIRS and not d.startswith(".")] + dirs[:] = [ + d + for d in dirs + if not d.startswith(".") + and not (root == project_dir and d in _GENERATED_DIRS) + ] for fname in files: if fname.endswith(".py"): abs_src = os.path.join(root, fname) @@ -280,10 +285,15 @@ def _sweep_py_files(project_dir): return swept -def _stub_destination(stub_file, project_dir): - """Mirror a stub to agents/, overwriting the real agent file cli.py always puts there; flat if no project sweep.""" +def _stub_destination(stub_file, stub_entrypoints): + """Mirror a stub to its agent's declared entrypoint path, overwriting the real file; flat if unknown, absolute, or containing '..'.""" basename = os.path.basename(stub_file) - return os.path.join("agents", basename) if project_dir else basename + entrypoint = stub_entrypoints.get(basename) + if entrypoint: + normalized = entrypoint.replace("\\", "/") + if not normalized.startswith("/") and ".." not in normalized.split("/"): + return entrypoint + return basename def generate_docker( @@ -293,6 +303,7 @@ def generate_docker( grpc_stubs_dir=None, stub_files=None, project_dir=None, + stub_entrypoints=None, ): """ Generate a minimal Docker build context for an agent. @@ -301,12 +312,13 @@ def generate_docker( source files needed to run the agent with its own local controller. Args: - yaml_path: Path to the YAML agent definition. - agent_file: Path to the original Python agent implementation. - output_dir: Optional output directory (default: docker_container//). - grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). - stub_files: Optional list of agent stub files to copy into the context. - project_dir: Optional project root to sweep for extra .py helper files. + yaml_path: Path to the YAML agent definition. + agent_file: Path to the original Python agent implementation. + output_dir: Optional output directory (default: docker_container//). + grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). + stub_files: Optional list of agent stub files to copy into the context. + project_dir: Optional project root to sweep for extra .py helper files. + stub_entrypoints: Optional {stub_basename: entrypoint} map for exact stub placement. """ with open(yaml_path, "r") as f: config = yaml.safe_load(f) @@ -360,7 +372,10 @@ def generate_docker( if stub_files: for stub_file in stub_files: files_to_copy.append( - (os.path.abspath(stub_file), _stub_destination(stub_file, project_dir)) + ( + os.path.abspath(stub_file), + _stub_destination(stub_file, stub_entrypoints or {}), + ) ) files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) @@ -419,6 +434,7 @@ def generate_workflow_docker( grpc_stubs_dir=None, api_port=8080, project_dir=None, + stub_entrypoints=None, ): """ Generate a Docker build context for a workflow. @@ -428,11 +444,12 @@ def generate_workflow_docker( with its own local controller. Args: - workflow_file: Path to the workflow Python file. - stub_files: List of stub file paths to include. - output_dir: Optional output directory (default: docker_container/Workflow/). - grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). - project_dir: Optional project root to sweep for extra .py helper files. + workflow_file: Path to the workflow Python file. + stub_files: List of stub file paths to include. + output_dir: Optional output directory (default: docker_container/Workflow/). + grpc_stubs_dir: Optional path to compiled gRPC stubs (default: /grpc_stubs). + project_dir: Optional project root to sweep for extra .py helper files. + stub_entrypoints: Optional {stub_basename: entrypoint} map for exact stub placement. """ script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.join(script_dir, "..") @@ -487,7 +504,10 @@ def generate_workflow_docker( # Copy stub files, overwriting the swept real file at the same path for stub_file in stub_files: files_to_copy.append( - (os.path.abspath(stub_file), _stub_destination(stub_file, project_dir)) + ( + os.path.abspath(stub_file), + _stub_destination(stub_file, stub_entrypoints or {}), + ) ) # Copy gRPC generated stubs if they exist From 95240ca941167a11c6da6cc791ca4a2fb13c7ebe Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Tue, 25 Aug 2026 19:17:55 -0700 Subject: [PATCH 03/44] ventis build: sweep project .py files into Docker build contexts generate_docker()/generate_workflow_docker() now recursively sweep every .py file under the project directory into the build context, preserving directory structure, so helper files that aren't declared as an agent entrypoint still make it into the image. Generated dirs (docker_container/, stubs/, grpc_stubs/) are excluded at the project root only, not at every depth. Stub files are placed at their agent's declared entrypoint path (mapped from global_controller.yaml) instead of a hardcoded guess, so a stub overwrites the exact real file it replaces. Guards against absolute and '..'-containing entrypoints, symlinked sources, and symlinked-destination escapes, with warnings on unsafe or unmapped stubs. Co-Authored-By: Claude Sonnet 5 --- ventis/cli.py | 45 +++++++++++++++--------------------- ventis/stub_generator.py | 49 ++++++++++++++++++++++------------------ 2 files changed, 45 insertions(+), 49 deletions(-) diff --git a/ventis/cli.py b/ventis/cli.py index cb9ee0a..4c9badf 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -212,6 +212,23 @@ def cmd_build(args): if not yaml_files: logger.warning("No agent YAML files found in %s", agents_dir) + import yaml + + # Looks up a config entry's YAML and to map stubs to entrypoints. + yaml_by_name = {} + for yaml_path in yaml_files: + with open(yaml_path) as f: + name = yaml.safe_load(f).get("agent", {}).get("name") + if name: + yaml_by_name[name] = yaml_path + + entrypoints_by_name = {a["name"]: a.get("entrypoint") for a in agents} + stub_entrypoints = { + f"{os.path.splitext(os.path.basename(p))[0]}.py": entrypoints_by_name[n] + for n, p in yaml_by_name.items() + if entrypoints_by_name.get(n) + } + stub_paths = [] for yaml_path in yaml_files: base_name = os.path.splitext(os.path.basename(yaml_path))[0] @@ -220,23 +237,6 @@ def cmd_build(args): generate_stub(yaml_path, output_path) stub_paths.append(output_path) - # Map each stub's basename to its agent's declared entrypoint, so a stub - # overwrites the exact real file it replaces instead of guessing its path. - stub_entrypoints = {} - for agent_cfg in agents: - entrypoint = agent_cfg.get("entrypoint") - if agent_cfg.get("type", "agent") == "workflow" or not entrypoint: - continue - for yaml_path in yaml_files: - import yaml - - with open(yaml_path) as f: - ydata = yaml.safe_load(f) - if ydata.get("agent", {}).get("name") == agent_cfg["name"]: - base_name = os.path.splitext(os.path.basename(yaml_path))[0] - stub_entrypoints[f"{base_name}.py"] = entrypoint - break - # -------------------------------------------------------------- # # Step 2: Compile gRPC protobuf stubs # # -------------------------------------------------------------- # @@ -310,16 +310,7 @@ def cmd_build(args): continue # Find matching YAML by agent name - matching_yaml = None - for yaml_path in yaml_files: - import yaml - - with open(yaml_path) as f: - ydata = yaml.safe_load(f) - if ydata.get("agent", {}).get("name") == agent_name: - matching_yaml = yaml_path - break - + matching_yaml = yaml_by_name.get(agent_name) if not matching_yaml: logger.warning( "No YAML definition found for agent '%s', skipping Docker", diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index a56080d..9ea6c99 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -278,24 +278,43 @@ def _sweep_py_files(project_dir): and not (root == project_dir and d in _GENERATED_DIRS) ] for fname in files: - if fname.endswith(".py"): - abs_src = os.path.join(root, fname) + abs_src = os.path.join(root, fname) + if fname.endswith(".py") and not os.path.islink(abs_src): rel_dst = os.path.relpath(abs_src, project_dir) swept.append((abs_src, rel_dst)) return swept def _stub_destination(stub_file, stub_entrypoints): - """Mirror a stub to its agent's declared entrypoint path, overwriting the real file; flat if unknown, absolute, or containing '..'.""" + """Where to copy a stub so it overwrites the real file it replaces, falling back to flat if that's unsafe.""" basename = os.path.basename(stub_file) entrypoint = stub_entrypoints.get(basename) if entrypoint: normalized = entrypoint.replace("\\", "/") if not normalized.startswith("/") and ".." not in normalized.split("/"): - return entrypoint + return normalized + print(f" Warning: unsafe entrypoint '{entrypoint}' for stub {basename}, placing flat instead") + elif stub_entrypoints: + print(f" Warning: no entrypoint mapping for stub {basename}, placing flat instead") return basename +def _copy_files(output_dir, files_to_copy): + """Copy each (src, dst) pair into output_dir, refusing to write outside it (e.g. via a symlinked destination parent).""" + real_output_dir = os.path.realpath(output_dir) + for src, dst in files_to_copy: + if not os.path.isfile(src): + print(f" Warning: source file not found, skipping: {src}") + continue + dest_path = os.path.join(output_dir, dst) + real_dest = os.path.realpath(dest_path) + if os.path.commonpath([real_output_dir, real_dest]) != real_output_dir: + print(f" Warning: destination escapes build context, skipping: {dst}") + continue + os.makedirs(os.path.dirname(dest_path), exist_ok=True) + shutil.copy2(src, dest_path) + + def generate_docker( yaml_path, agent_file, @@ -386,13 +405,7 @@ def generate_docker( if fname.endswith(".py"): files_to_copy.append((os.path.join(grpc_stubs_dir, fname), fname)) - for src, dst in files_to_copy: - if os.path.isfile(src): - dest_path = os.path.join(output_dir, dst) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - shutil.copy2(src, dest_path) - else: - print(f" Warning: source file not found, skipping: {src}") + _copy_files(output_dir, files_to_copy) # Copy the YAML definition too shutil.copy2( @@ -477,9 +490,7 @@ def generate_workflow_docker( workflow_basename = os.path.basename(workflow_file) # Sweep the project for extra .py helper files not on the explicit list below. - files_to_copy = [] - if project_dir: - files_to_copy += _sweep_py_files(project_dir) + files_to_copy = _sweep_py_files(project_dir) if project_dir else [] files_to_copy += [ (os.path.abspath(workflow_file), workflow_basename), @@ -500,7 +511,7 @@ def generate_workflow_docker( for name in ("gpu_metrics.py", "session_logging.py") ], ] - + # Copy stub files, overwriting the swept real file at the same path for stub_file in stub_files: files_to_copy.append( @@ -516,13 +527,7 @@ def generate_workflow_docker( if fname.endswith(".py"): files_to_copy.append((os.path.join(grpc_stubs_dir, fname), fname)) - for src, dst in files_to_copy: - if os.path.isfile(src): - dest_path = os.path.join(output_dir, dst) - os.makedirs(os.path.dirname(dest_path), exist_ok=True) - shutil.copy2(src, dest_path) - else: - print(f" Warning: source file not found, skipping: {src}") + _copy_files(output_dir, files_to_copy) # ---- workflow_launcher.py -------------------------------------------- launcher = f"""import threading From 69d5405c24485ff51db960ad7843e496d91354ce Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 13:06:31 -0700 Subject: [PATCH 04/44] Fix missing os import in metrics_agent.py Co-Authored-By: Claude Sonnet 5 --- examples/portfolio/agents/metrics_agent.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 374a869..28069ac 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -8,6 +8,7 @@ # 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 From 653bee84d8999a233bf71ce1e75e0ae2d1d44057 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 13:23:55 -0700 Subject: [PATCH 05/44] WIP: OTel exporter testing + portfolio merge-conflict fix (pre-pull checkpoint) Co-Authored-By: Claude Sonnet 5 --- OTel_Exporter/DESIGN.md | 174 ++++++++++ OTel_Exporter/__init__.py | 0 OTel_Exporter/convert.py | 96 ++++++ OTel_Exporter/db.py | 185 ++++++++++ OTel_Exporter/otel_exporter.py | 98 ++++++ .../helloworld/workflow/example_workflow.py | 4 +- examples/portfolio/agents/advisor_agent.py | 16 +- examples/portfolio/agents/intent_agent.py | 35 -- examples/portfolio/agents/llm_agent.py | 50 --- examples/portfolio/agents/llm_agent.yaml | 14 - examples/portfolio/agents/metrics_agent.py | 1 + .../portfolio/config/global_controller.yaml | 32 +- examples/portfolio/config/policy.yaml | 1 - pyproject.toml | 6 +- requirements.txt | 4 + uv.lock | 320 ++++++++++++++++++ ventis/controller/global_controller.py | 57 +++- ventis/controller/utils/process_supervisor.py | 59 ++++ ventis/deploy.py | 2 +- ventis/stub_generator.py | 9 +- 20 files changed, 1020 insertions(+), 143 deletions(-) create mode 100644 OTel_Exporter/DESIGN.md create mode 100644 OTel_Exporter/__init__.py create mode 100644 OTel_Exporter/convert.py create mode 100644 OTel_Exporter/db.py create mode 100644 OTel_Exporter/otel_exporter.py delete mode 100644 examples/portfolio/agents/llm_agent.py delete mode 100644 examples/portfolio/agents/llm_agent.yaml create mode 100644 ventis/controller/utils/process_supervisor.py diff --git a/OTel_Exporter/DESIGN.md b/OTel_Exporter/DESIGN.md new file mode 100644 index 0000000..73c4da2 --- /dev/null +++ b/OTel_Exporter/DESIGN.md @@ -0,0 +1,174 @@ +# OTLP Exporter for Ventis GlobalController — Design + +Status: **implemented (single-table design)**. `GlobalController` writes futures into a +`waiting` table (SQLite); a GC-supervised, GC-restarted OTel Exporter process reads +finished/unsent rows, converts each to an OTel span, and hands it to a real +`BatchSpanProcessor`/`OTLPSpanExporter`. Batching, serialization, and sending are all +OTel SDK code — the only custom pieces are the row→span conversion and durable +sent-tracking. This doc is a design/rationale reference; the actual files +(`otel_exporter.py`, `db.py`, `convert.py`, `ventis/controller/utils/process_supervisor.py`) +are the source of truth for current behavior. + +## Context +Ventis futures need to reach an external OTLP-compatible tracing backend. Design: a +separate OTLP Exporter process, spawned and supervised by GlobalController, that reads +unsent finished future rows from a local SQLite DB, converts them into OTel spans, and +hands them to the OTel SDK's own batching/export machinery, which ships them to an +external OTLP Receiver (out of scope here — assumed to be a separate, already-addressable +service). + +Decisions (final status): +- **Process model**: a true separate OS process, spawned and supervised by + GlobalController (not an in-process thread) — via `ProcessSupervisor` + (`ventis/controller/utils/process_supervisor.py`, built): `register`/`start_all` to + spawn, `check_and_respawn` (called from GC's existing poll tick, guarded on + `self.running` to avoid a shutdown race) to restart it if it ever dies unexpectedly, + `terminate_all` (called from GC's `stop()`) to shut it down cleanly. Rationale: fault + isolation from GC's core polling/health loop and independent restart, at low added + complexity since SQLite is already the entire hand-off boundary between the two. +- **Config**: implemented via a new `otel:` section in `global_controller.yaml` + (`protocol`/`endpoint`/`headers`), *not* by making `otel_exporter.py` itself + config-aware. `GlobalController` translates that section into the OTel SDK's own + standard env vars (`OTEL_EXPORTER_OTLP_PROTOCOL`/`_ENDPOINT`/`_HEADERS`) and passes + them to the exporter subprocess via `ProcessSupervisor.register(..., env=...)`. The + exporter still just constructs `OTLPSpanExporter()` with no explicit args (endpoint + and headers are resolved by the SDK itself from those env vars, same as always) and + reads only `OTEL_EXPORTER_OTLP_PROTOCOL` directly, to pick the gRPC vs HTTP exporter + class — the one piece of protocol selection the plain SDK classes don't do on their + own. Deliberately vendor-neutral: no backend name (Postgres, Langfuse, or otherwise) + appears anywhere in `otel_exporter.py`; the destination is 100% deploy-time config, + set once in `global_controller.yaml` and never touched by app code again. The + originally-planned `database.url` repurposing (below, kept for history) was decided + against — env-var configuration is the SDK's own idiomatic mechanism, so no + exporter-side config plumbing was added, only a GC-side YAML→env-var translation. + Does not (yet) support simultaneous multi-destination export — see "Known gaps". +- **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own + SQLite file (`OTel_Exporter/otel_queue.db`, see `db.py`), written by GC's existing + `_poll_controllers` *alongside* (not instead of) the existing + `send_runtime_information` write. Keeps this pipeline's schema/state fully decoupled + from the dashboard/cost table. +- **Two tables collapsed into one**: an earlier version of this design had a second + `queue` table (`waiting` → promote → `queue` → drain → send). Collapsed once it became + clear `BatchSpanProcessor` already provides its own in-memory queue — the only thing a + second table added was durability across the exporter's own process restarts, which a + `sent` column on `waiting` alone provides just as well, with less code. See `db.py`'s + module docstring. +- **Span construction**: settled — spans are built as `ReadableSpan` objects directly + (bypassing `Tracer`/`TracerProvider` entirely, no `IdGenerator` workaround needed for + either `trace_id` or `span_id`). Confirmed working via `ConsoleSpanExporter` during + development and via real (though unreachable) OTLP export attempts. + +## Implementation summary + +### 1. Config +`global_controller.yaml` gains an optional `otel:` section: +```yaml +otel: + protocol: grpc # or http + endpoint: otlp-pg-receiver.railway.internal:4317 + headers: {} # e.g. Authorization: "Basic " for a backend needing auth +``` +`GlobalController._otel_exporter_env()` translates this into +`OTEL_EXPORTER_OTLP_PROTOCOL`/`OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` +and hands them to `ProcessSupervisor.register("otel_exporter", ..., env=...)`, which now +supports an `env` param (merged on top of the parent process's own environment, not a +replacement). Omitting `otel:` entirely falls back to whatever ambient env the exporter +subprocess would otherwise inherit, same as before this change. + +`otel_exporter.py` itself reads only `OTEL_EXPORTER_OTLP_PROTOCOL` directly (to pick +which `OTLPSpanExporter` class to import — gRPC or HTTP; the plain SDK classes don't +self-select this the way `opentelemetry-instrument`'s auto-config does). Endpoint and +headers are never read directly — `OTLPSpanExporter()` is still constructed with no +explicit args, letting the SDK resolve those from the same env vars itself, exactly as +before this change. `BatchSpanProcessor(OTLPSpanExporter(), schedule_delay_millis=1000)` +— the flush delay is explicitly overridden from the SDK default (5000ms) to 1000ms; +`max_export_batch_size` is left at the SDK default (512), which already approximates the +original "500 spans" batching ask without any override needed. + +### 2. `OTel_Exporter/otel_exporter.py` +A plain loop, polling every `POLL_INTERVAL_SECONDS` (5s, checked every 1s so SIGTERM +stays responsive), calling `_send_pending()` each tick: +- `SELECT * FROM waiting WHERE finished_at IS NOT NULL AND (sent IS NULL OR sent = 0)`. +- Per row, each isolated in its own try/except (one malformed row is logged and skipped, + never blocks the rest of the batch): `convert.waiting_row_to_span(row)` → + `_processor.on_end(span)` → `db.mark_sent(future_id)` immediately — atomic per row, not + batched at the end, so a crash mid-poll can't leave an already-sent row unmarked (which + would cause a duplicate send on the next run). +- `_processor` is constructed once at startup; no `TracerProvider` is used at all, since + spans are hand-built and handed straight to the processor via `on_end()`. +- `_processor.shutdown()` on exit, flushing any pending batch. + +### 3. Future row → OTel span conversion (`OTel_Exporter/convert.py`) +`future_id` maps to OTel `span_id`, not `trace_id` — `session_id` (== `request_id`) is +the one that maps to `trace_id`. Both are `uuid4().hex` (32 hex chars / 16 bytes); OTel +`trace_id` is 128-bit (16 bytes, fits directly) and `span_id` is 64-bit (8 bytes, needs +truncation). No hashing — just hex-decode and truncate (deterministic, pure): +```python +trace_id = int(row["session_id"], 16) +span_id = int.from_bytes(bytes.fromhex(row["future_id"])[:8], "big") +parent_span_id = int.from_bytes(bytes.fromhex(row["parent_id"])[:8], "big") if row["parent_id"] else None +``` +Spans are assembled as plain `ReadableSpan(name=..., context=SpanContext(...), parent=SpanContext(...) or None, attributes=..., events=..., status=..., start_time=..., end_time=...)` +— no `Tracer`, no `IdGenerator`. Failed rows get a hand-built `exception` `Event` (using +the SDK's own `EXCEPTION_TYPE`/`EXCEPTION_MESSAGE` constants from `opentelemetry.sdk.trace`, +not hardcoded strings — `record_exception()` can't be used retrospectively since there's +no live exception object, only strings) plus `Status(StatusCode.ERROR, description=...)`. + +**Attribute naming**: `model`/`input_token_count`/`output_token_count` are set under the +real, current OTel GenAI semantic-convention keys — `gen_ai.request.model`/ +`gen_ai.usage.input_tokens`/`gen_ai.usage.output_tokens` — verified against the actual +spec (`open-telemetry/semantic-conventions`), not assumed. `cpu`/`gpu`/ +`execution_time_ms`/`queue_time_ms`/`token_count` keep plain names deliberately: none of +them have an OTel GenAI equivalent (cpu/gpu/queue-time are Ventis infra concepts, and +`token_count`, an input+output sum, isn't part of the spec at all — inventing a +`gen_ai.*`-shaped name for any of these would fabricate a standard rather than follow +one. `cached_tokens`/`cache_hit_ratio` exist on the `waiting` row but aren't exported to +attributes at all yet — a separate, pre-existing gap, not touched here. + +### 4. Process supervisor — `ventis/controller/utils/process_supervisor.py` (built) +`ProcessSupervisor`: `register(name, argv, env=None)` declares a process spec (`env`, +when given, is merged on top of — not a replacement for — the parent's own environment); +`start_all()` spawns everything registered; `check_and_respawn()` restarts anything that +exited, replaying the same argv/env (called from GC's `_poll_controllers`, guarded by +`if self.running:` so a SIGTERM mid-tick can't cause it to resurrect a process +`terminate_all()` just intentionally killed); `terminate_all()` terminates every managed +process (all `.terminate()` calls first, then `.wait()` on each, falling back to +`.kill()`), called from GC's `stop()`. Adding a future second daemon is one more +`register()` call — no new spawn/monitor/terminate code needed. + +### 5. Dependencies (all added) +`opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, +`opentelemetry-exporter-otlp-proto-http` (the last one added alongside the `otel:` +config work, since `protocol: http` now needs that package importable). + +## Known gaps (not yet built) +- Spans carry no explicit `resource`/`instrumentation_scope` — would show as + `service.name=unknown_service` at a real backend. +- No simultaneous multi-destination export — `otel:` configures exactly one + destination; sending to two backends at once would mean registering a second, + separately-configured `otel_exporter` subprocess (same script, different env), not + something the exporter or its config format do today. +- `waiting` grows unboundedly: sent rows are never pruned, and futures that never finish + (`finished_at` never arrives) also stay forever, invisible and un-expiring. +- `error_name` is always `NULL` — Ventis's own Redis writer never records a distinct + exception-type field, only a message string. +- No committed test suite — all verification during development was ad hoc scripts, not + `pytest` files under `tests/`. +- Never verified against a live OTLP receiver — only against a refused connection + (confirmed the SDK's real retry/error-handling path is exercised correctly). +- No retry-limit/quarantine for a permanently malformed row — it logs an error every poll + forever rather than being given up on. + +## Verification approach used during development +- Row→span conversion: ad hoc scripts asserting deterministic id derivation, correct + parent/child linkage, correct `ERROR` status + `exception` event on failed rows, and + passing hand-built spans through `ConsoleSpanExporter().export([span])` to confirm the + SDK accepts them without error. +- Pipeline correctness: seeded `waiting` with mixes of finished/still-running/malformed/ + failed rows, ran the real `otel_exporter.py` subprocess, and inspected the resulting + `sent` flags and log output directly — including confirming a second run does not + re-send already-sent rows, and that a malformed row is skipped without blocking others. +- Process supervision: unit-tested `ProcessSupervisor` against a dummy process (spawn, + kill, confirm respawn with a new PID, confirm clean `terminate_all`) and + integration-tested it managing the real `otel_exporter.py` process. +- Scoped to the local provider throughout — no EC2 needed. diff --git a/OTel_Exporter/__init__.py b/OTel_Exporter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/OTel_Exporter/convert.py b/OTel_Exporter/convert.py new file mode 100644 index 0000000..8e062a4 --- /dev/null +++ b/OTel_Exporter/convert.py @@ -0,0 +1,96 @@ +"""Convert a `waiting` table row (see db.py) into an OTel ReadableSpan. + +Pure function, no I/O, no batching, no network calls. Builds ReadableSpan objects +directly instead of going through Tracer.start_span() -- there's no live tracer here, +futures already finished (sometimes in another process), so this is a historical-row +conversion, not live tracing. See OTel_Exporter/DESIGN.md for why this deviates from +the SDK's usual advice against constructing ReadableSpan by hand. +""" + +from opentelemetry.sdk.trace import EXCEPTION_MESSAGE, EXCEPTION_TYPE, Event, ReadableSpan +from opentelemetry.trace import SpanContext, SpanKind, TraceFlags +from opentelemetry.trace.status import Status, StatusCode + +_SAMPLED = TraceFlags(TraceFlags.SAMPLED) + + +def to_epoch_nanos(unix_seconds): + """Convert a unix-epoch-seconds float (as stored in waiting) to OTel's ns int.""" + if unix_seconds is None: + return None + return round(float(unix_seconds) * 1e9) + + +def waiting_row_to_span(row): + """Convert one waiting row (dict-like, column names as keys) into a ReadableSpan. + + Rows without finished_at are accepted but produce a span with end_time=None -- + filtering to finished rows is the caller's responsibility, not this function's. + """ + # sqlite3.Row supports row["col"] but not row.get("col") -- normalize once so the + # rest of this function can use .get() freely for optional fields. + row = dict(row) + + trace_id = int(row["session_id"], 16) + span_id = int.from_bytes(bytes.fromhex(row["future_id"])[:8], "big") + parent_id = row.get("parent_id") + parent_span_id = ( + int.from_bytes(bytes.fromhex(parent_id)[:8], "big") if parent_id else None + ) + + context = SpanContext( + trace_id=trace_id, span_id=span_id, is_remote=False, trace_flags=_SAMPLED + ) + parent = ( + SpanContext( + trace_id=trace_id, span_id=parent_span_id, is_remote=False, trace_flags=_SAMPLED + ) + if parent_span_id + else None + ) + + events = [] + status = Status(StatusCode.UNSET) + if row["failed"]: + events.append( + Event( + name="exception", + attributes={ + EXCEPTION_TYPE: row.get("error_name") or "RuntimeError", + EXCEPTION_MESSAGE: row.get("error_message") or "", + }, + timestamp=to_epoch_nanos(row.get("finished_at")), + ) + ) + status = Status(StatusCode.ERROR, description=row.get("error_message")) + + # model/input/output use real OTel GenAI semconv names; cpu/gpu/execution_time_ms/ + # queue_time_ms/token_count have no semconv equivalent (Ventis infra concepts, or -- + # for token_count -- a derived sum the spec doesn't define), so they keep plain names + # rather than being forced into a fake gen_ai.* one. See DESIGN.md. + attributes = { + k: v + for k, v in { + "gen_ai.request.model": row.get("model"), + "cpu": row.get("cpu"), + "gpu": row.get("gpu"), + "execution_time_ms": row.get("execution_time_ms"), + "queue_time_ms": row.get("queue_time_ms"), + "gen_ai.usage.input_tokens": row.get("input_token_count"), + "gen_ai.usage.output_tokens": row.get("output_token_count"), + "token_count": row.get("token_count"), + }.items() + if v is not None + } + + return ReadableSpan( + name=row.get("agent_id") or "unknown_agent", + context=context, + parent=parent, + attributes=attributes, + events=events, + status=status, + kind=SpanKind.INTERNAL, + start_time=to_epoch_nanos(row.get("started_at")), + end_time=to_epoch_nanos(row.get("finished_at")), + ) diff --git a/OTel_Exporter/db.py b/OTel_Exporter/db.py new file mode 100644 index 0000000..4bb301d --- /dev/null +++ b/OTel_Exporter/db.py @@ -0,0 +1,185 @@ +"""SQLite schema and writes for the OTel export pipeline's waiting table. + +`waiting` holds future rows as GlobalController observes them (including still-running +ones). There's no separate queue table -- OTel's own BatchSpanProcessor already queues +and batches spans in memory, so the only thing we need to track durably is which rows +have already been sent, which the `sent` column on this same table provides. (An earlier +version of this pipeline had a second `queue` table for that; collapsed away since it +wasn't doing anything BatchSpanProcessor doesn't already do -- see DESIGN.md.) +""" + +import os +import sqlite3 + +from ventis.controller.utils import pricing + +DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "otel_queue.db") + +# Demo-only multipliers for scaling displayed costs; not real recorded costs. Kept +# deliberately standalone/duplicated from telemetry_logging.py's identical constants +# (rather than importing them) so this module has no dependency on it -- keep these in +# sync by hand if the multipliers there ever change. +_TOKEN_COST_MULTIPLIER = 10000 +_SERVER_COST_MULTIPLIER = 100000 + +# Timestamps are stored as unix epoch seconds (matching the Redis future hash fields +# they're read from), not as SQLite datetime strings. Column set mirrors +# runtime_information 1:1 (see telemetry_logging.py) plus this pipeline's own additions +# (error_name/error_message/sent). +_TABLE_COLUMNS = """ + future_id TEXT PRIMARY KEY, + parent_id TEXT, + session_id TEXT NOT NULL, + project_id TEXT, + agent_id TEXT, + model TEXT, + cpu REAL, + gpu REAL, + started_at TIMESTAMP, + finished_at TIMESTAMP, + execution_time_ms INTEGER, + queue_time_ms INTEGER, + input_token_count INTEGER, + output_token_count INTEGER, + token_count INTEGER, + errors INTEGER, + failed BOOLEAN, + server_cost REAL, + token_cost REAL, + total_cost REAL, + cached_tokens INTEGER, + cache_hit_ratio REAL, + error_name TEXT, + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + sent BOOLEAN DEFAULT 0 +""" + + +def init_db(db_path=DB_PATH): + """Create the waiting table if it doesn't already exist.""" + conn = sqlite3.connect(db_path) + try: + conn.execute(f"CREATE TABLE IF NOT EXISTS waiting ({_TABLE_COLUMNS})") + conn.commit() + finally: + conn.close() + + +# `sent` is deliberately excluded here so re-upserting a waiting row (e.g. GC +# re-writing it from Redis) never resets it back to unsent. +_COLUMNS = [ + "future_id", "parent_id", "session_id", "project_id", "agent_id", "model", + "cpu", "gpu", "started_at", "finished_at", "execution_time_ms", "queue_time_ms", + "input_token_count", "output_token_count", "token_count", "errors", + "failed", "server_cost", "token_cost", "total_cost", + "cached_tokens", "cache_hit_ratio", "error_name", "error_message", +] + +_WAITING_UPSERT = """ + INSERT INTO waiting ({cols}) VALUES ({placeholders}) + ON CONFLICT(future_id) DO UPDATE SET {updates} +""".format( + cols=", ".join(_COLUMNS), + placeholders=", ".join(f":{c}" for c in _COLUMNS), + updates=", ".join(f"{c}=excluded.{c}" for c in _COLUMNS if c != "future_id"), +) + + +def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH): + """Upsert future rows (as returned by telemetry_logging.pull_runtime_information) + into the waiting table. Unlike runtime_information, rows without finished_at are + kept (not skipped) -- that's what "waiting" means here. `redis_client` is only used + to look up the executing agent's instance type for server-cost pricing, mirroring + send_runtime_information; pass None to skip cost lookups (server_cost stays 0).""" + if not rows: + return + conn = sqlite3.connect(db_path) + try: + for raw in rows: + fid = raw.get("future_id") + session_id = raw.get("request_id") + if not fid or not session_id: + continue + agent_id = raw.get("agent") + started_at = float(raw.get("created_at") or 0) or None + finished_at = float(raw["finished_at"]) if raw.get("finished_at") else None + execution_time_ms = ( + round((finished_at - started_at) * 1000) + if finished_at and started_at + else None + ) + input_token_count = int(float(raw.get("input_token_count") or 0)) + output_token_count = int(float(raw.get("output_token_count") or 0)) + token_count = int(float(raw.get("token_count") or 0)) + cached_tokens = int(float(raw.get("input_cache_tokens") or 0)) + + token_cost = ( + pricing.compute_token_cost( + raw.get("model"), input_token_count, output_token_count + ) + * _TOKEN_COST_MULTIPLIER + ) + # Server cost needs an elapsed duration -- only available once finished. + if finished_at and started_at: + server_cost = ( + pricing.compute_server_cost( + redis_client.get(f"agent:{agent_id}:instance_type") + if redis_client is not None and agent_id + else None, + finished_at - started_at, + ) + * _SERVER_COST_MULTIPLIER + ) + else: + server_cost = 0.0 + + conn.execute( + _WAITING_UPSERT, + { + "future_id": fid, + "parent_id": raw.get("parent") or None, + "session_id": session_id, + "project_id": project_id, + "agent_id": agent_id, + "model": raw.get("model"), + "cpu": float(raw.get("cpu_resource") or 0), + "gpu": float(raw.get("gpu_resource") or 0), + "started_at": started_at, + "finished_at": finished_at, + "execution_time_ms": execution_time_ms, + "queue_time_ms": ( + round(float(raw["queue_time"]) * 1000) + if raw.get("queue_time") + else None + ), + "input_token_count": input_token_count, + "output_token_count": output_token_count, + "token_count": token_count, + "errors": int(raw.get("errors") or 0), + "failed": bool(int(raw.get("failed") or 0)), + "server_cost": server_cost, + "token_cost": token_cost, + "total_cost": server_cost + token_cost, + "cached_tokens": cached_tokens, + "cache_hit_ratio": cached_tokens / token_count if token_count else 0.0, + "error_name": raw.get("error_name"), + "error_message": raw.get("error_message"), + }, + ) + conn.commit() + finally: + conn.close() + + +def mark_sent(future_id, db_path=DB_PATH): + """Mark one waiting row sent. Call this immediately after successfully handing its + span to the batch processor -- one row, one commit -- so a crash between two rows' + sends can't leave an already-sent row unmarked (which would cause a duplicate send + on the next run).""" + conn = sqlite3.connect(db_path) + try: + conn.execute("UPDATE waiting SET sent = 1 WHERE future_id = ?", (future_id,)) + conn.commit() + finally: + conn.close() diff --git a/OTel_Exporter/otel_exporter.py b/OTel_Exporter/otel_exporter.py new file mode 100644 index 0000000..b1c7f1a --- /dev/null +++ b/OTel_Exporter/otel_exporter.py @@ -0,0 +1,98 @@ +"""Entrypoint for the OTLP Exporter process. + +Each poll tick: read finished, not-yet-sent rows from `waiting`, convert each to a span, +hand it to a BatchSpanProcessor/OTLPSpanExporter, and mark it sent -- batching, OTLP +serialization, and sending are all the SDK's own code, not ours (see DESIGN.md). Each +row's send-and-mark-sent is atomic and happens immediately after its own successful +send, not batched at the end, so a crash mid-poll can't leave an already-sent row +unmarked (which would cause a duplicate send next run). `OTLPSpanExporter()` takes no +explicit endpoint/headers here -- it falls back to the SDK's own standard +`OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars, or localhost:4317, +per the SDK's own default behavior. GlobalController sets those env vars (plus +`OTEL_EXPORTER_OTLP_PROTOCOL`, which this module reads itself below to pick the gRPC vs +HTTP class) from `global_controller.yaml`'s `otel:` section when it spawns this process; +this file has no YAML/app-config awareness of its own, only standard OTel env vars -- +see DESIGN.md. +""" + +import logging +import os +import signal +import sqlite3 +import time + +# Protocol is the one thing the SDK's own exporter classes don't self-select from +# OTEL_EXPORTER_OTLP_PROTOCOL -- endpoint/headers/auth stay fully env-var-driven via +# each class's own defaults; see OTel_Exporter/DESIGN.md. +if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").startswith("http"): + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +else: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +import convert +import db + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +_running = True +_processor = None +POLL_INTERVAL_SECONDS = 5 + + +def _handle_shutdown(signum, frame): + global _running + _running = False + + +def _send_pending(): + """Convert and send each finished, not-yet-sent waiting row.""" + conn = sqlite3.connect(db.DB_PATH) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + "SELECT * FROM waiting WHERE finished_at IS NOT NULL " + "AND (sent IS NULL OR sent = 0)" + ).fetchall() + finally: + conn.close() + if not rows: + return + sent_count = 0 + for row in rows: + try: + span = convert.waiting_row_to_span(row) + _processor.on_end(span) + except Exception as e: + logger.error( + "Skipping waiting row %s -- failed to send: %s", row["future_id"], e + ) + continue + db.mark_sent(row["future_id"]) + sent_count += 1 + logger.info("Sent %d span(s) to the batch processor.", sent_count) + + +def main(): + global _processor + signal.signal(signal.SIGTERM, _handle_shutdown) + signal.signal(signal.SIGINT, _handle_shutdown) + db.init_db() + _processor = BatchSpanProcessor(OTLPSpanExporter(), schedule_delay_millis=1000) + logger.info("OTel exporter process started.") + last_poll = 0 + while _running: + if time.time() - last_poll >= POLL_INTERVAL_SECONDS: + try: + _send_pending() + except Exception as e: + logger.warning("Poll cycle failed (non-fatal): %s", e) + last_poll = time.time() + time.sleep(1) + _processor.shutdown() + logger.info("OTel exporter process exiting.") + + +if __name__ == "__main__": + main() diff --git a/examples/helloworld/workflow/example_workflow.py b/examples/helloworld/workflow/example_workflow.py index 8bd4600..842fe80 100644 --- a/examples/helloworld/workflow/example_workflow.py +++ b/examples/helloworld/workflow/example_workflow.py @@ -15,11 +15,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy -from example_agent_stub import ExampleAgentStub +from example_agent import ExampleAgent def main(name: str = "World"): - agent = ExampleAgentStub() + agent = ExampleAgent() greeting = agent.hello(name=name) return {"greeting": greeting.value()} diff --git a/examples/portfolio/agents/advisor_agent.py b/examples/portfolio/agents/advisor_agent.py index 2db763f..5cc31ae 100644 --- a/examples/portfolio/agents/advisor_agent.py +++ b/examples/portfolio/agents/advisor_agent.py @@ -11,13 +11,10 @@ # If the LLM is unavailable (returns an empty string), it falls back to a # deterministic templated summary so the pipeline still returns. # -# Resource profile: cheap CPU; the LLM cost sits in LLMAgent, not here. +# Resource profile: cheap CPU, single call per request, on the critical path. -import sys import os -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) -from llm_agent import LLMAgent try: from ventis.llm.bedrock import call_bedrock except ImportError: @@ -27,16 +24,14 @@ class AdvisorAgent(object): def __init__(self): self.tools = [self.summarize] - self.llm = LLMAgent() + self.model_id = os.environ.get( + "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" + ) + self.region = os.environ.get("AWS_REGION", "us-east-1") def summarize(self, holdings: dict, metrics: dict, risk: dict) -> str: """Write a short plain-English briefing on the portfolio.""" prompt = self._build_prompt(holdings, metrics, risk) - text = self.llm.complete( - prompt=prompt, max_tokens=400, temperature=0.2 - ).value() - if not text: - print("AdvisorAgent: LLM returned no output; using templated summary.") try: response = call_bedrock( model_id=self.model_id, @@ -48,7 +43,6 @@ def summarize(self, holdings: dict, metrics: dict, risk: dict) -> str: except Exception as e: print(f"AdvisorAgent: Bedrock call failed ({e}); using templated summary.") return self._fallback_summary(metrics, risk) - return text def _build_prompt(self, holdings: dict, metrics: dict, risk: dict) -> str: lines = ["You are a portfolio analyst. Given the figures below, write a " diff --git a/examples/portfolio/agents/intent_agent.py b/examples/portfolio/agents/intent_agent.py index 972c8af..1eb15d7 100644 --- a/examples/portfolio/agents/intent_agent.py +++ b/examples/portfolio/agents/intent_agent.py @@ -7,17 +7,6 @@ # -> {"holdings": {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, # "lookback_days": 180} # -<<<<<<< HEAD -# The actual model call lives in the shared LLMAgent (remote, resolved via -# .value()) — this agent only builds the prompt and parses the result, so no -# Bedrock boilerplate lives here. If the LLM is unavailable or returns -# unparseable output, parse() raises: there is no fallback, the request fails -# loudly rather than guessing at the holdings. Weights are renormalized to 1.0. -# -# Resource profile: cheap CPU; the LLM cost sits in LLMAgent, not here. - -import sys -======= # Calls AWS Bedrock (Converse API) via ventis.llm.bedrock -- same pattern as # AdvisorAgent -- so token/cost telemetry gets recorded onto this execution's # future::metrics hash. Configure with env vars: @@ -31,20 +20,14 @@ # Resource profile: cheap CPU, single call per request, on the critical path # before the fan-out. ->>>>>>> remotes/origin/telemetry-signals import os import re import json -<<<<<<< HEAD -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) -from llm_agent import LLMAgent -======= try: from ventis.llm.bedrock import call_bedrock except ImportError: from bedrock import call_bedrock ->>>>>>> remotes/origin/telemetry-signals DEFAULT_LOOKBACK_DAYS = 365 @@ -52,15 +35,6 @@ class IntentAgent(object): def __init__(self): self.tools = [self.parse] -<<<<<<< HEAD - self.llm = LLMAgent() - - def parse(self, query: str) -> dict: - """Parse a natural-language portfolio request into holdings + lookback.""" - text = self.llm.complete( - prompt=self._build_prompt(query), max_tokens=300, temperature=0.0 - ).value() -======= self.model_id = os.environ.get( "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" ) @@ -75,7 +49,6 @@ def parse(self, query: str) -> dict: region=self.region, ) text = response["output"]["message"]["content"][0]["text"] ->>>>>>> remotes/origin/telemetry-signals if not text: raise ValueError("IntentAgent: LLM returned no output for the request.") @@ -148,15 +121,7 @@ def _sanitize(self, parsed: dict) -> dict: if __name__ == "__main__": -<<<<<<< HEAD - # Assumes the LLMAgent stub (Future-returning) is on the path, as it is - # inside the deployed pipeline. - agent = IntentAgent() - print(agent.parse( - "Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months" -======= agent = IntentAgent() print(agent.parse( query="Analyze 40% Apple, 35% Microsoft and 25% Nvidia over the last 6 months" ->>>>>>> remotes/origin/telemetry-signals )) diff --git a/examples/portfolio/agents/llm_agent.py b/examples/portfolio/agents/llm_agent.py deleted file mode 100644 index d42e4fb..0000000 --- a/examples/portfolio/agents/llm_agent.py +++ /dev/null @@ -1,50 +0,0 @@ -# LLM Agent -# -# Shared inference node. Owns all the AWS Bedrock (Converse API) plumbing so no -# other agent has to carry boto3 boilerplate — they just call complete(prompt) -# and get text back. Configure with env vars: -# BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) -# AWS_REGION (default: us-east-1) -# -# On any failure (no boto3, no creds, model not enabled) it returns an empty -# string; callers decide how to degrade (templated summary, regex parse, etc.). -# -# Resource profile: LLM-bound. This is the only node that talks to Bedrock, so -# it's the natural place to scale inference capacity independently. - -import os - - -class LLMAgent(object): - def __init__(self): - self.tools = [self.complete] - self.model_id = os.environ.get( - "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" - ) - self.region = os.environ.get("AWS_REGION", "us-east-1") - - def complete( - self, prompt: str, max_tokens: int = 400, temperature: float = 0.2 - ) -> str: - """Run a single-turn completion on Bedrock; '' on any failure.""" - try: - import boto3 - - client = boto3.client("bedrock-runtime", region_name=self.region) - response = client.converse( - modelId=self.model_id, - messages=[{"role": "user", "content": [{"text": prompt}]}], - inferenceConfig={ - "maxTokens": max_tokens, - "temperature": temperature, - }, - ) - return response["output"]["message"]["content"][0]["text"] - except Exception as e: - print(f"LLMAgent: Bedrock call failed ({e}).") - return "" - - -if __name__ == "__main__": - agent = LLMAgent() - print(agent.complete("Say hello in one short sentence.", max_tokens=50)) \ No newline at end of file diff --git a/examples/portfolio/agents/llm_agent.yaml b/examples/portfolio/agents/llm_agent.yaml deleted file mode 100644 index ccc0095..0000000 --- a/examples/portfolio/agents/llm_agent.yaml +++ /dev/null @@ -1,14 +0,0 @@ -agent: - name: LLMAgent - functions: - - name: complete - description: Run a single-turn completion on Bedrock; '' on any failure. - arguments: - - name: prompt - type: str - - name: max_tokens - type: int - - name: temperature - type: float - returns: - type: str diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 374a869..28069ac 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -8,6 +8,7 @@ # 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 diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index 29e8de9..9a0a9a5 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -6,26 +6,6 @@ # reflect each stage's real cost so the scheduler has placement decisions to make. agents: -<<<<<<< HEAD - # Shared inference node. Owns all Bedrock plumbing; IntentAgent and - # AdvisorAgent delegate their model calls here. LLM-bound — scale replicas - # to match inference demand. - - name: LLMAgent - host: localhost - port: 8075 - redis_port: 6379 - replicas: 1 - resources: - cpu: 1 - memory: 512 - entrypoint: agents/llm_agent.py - - # Stage 0: parse the free-text request into structured holdings + lookback - # window (calls LLMAgent). Cheap CPU, one call per request, on the critical - # path before the fan-out. - - name: IntentAgent - host: localhost - port: 8076 # Stage 0: parse the free-text request into structured holdings + lookback # window (calls Bedrock directly via ventis.llm.bedrock). Cheap CPU, one # call per request, on the critical path before the fan-out. @@ -36,8 +16,6 @@ agents: cpu: 1 memory: 256 entrypoint: agents/intent_agent.py - - # Stage 0: price history fetch. Network/IO-bound, cheap CPU. Called by provider: EC2 instance_type: t3.micro @@ -107,3 +85,13 @@ redis: host: localhost port: 6379 db: 0 + +# EC2 defaults for `provider: EC2` replicas. +ec2: + region: us-east-1 + ami_id: ami-031ff6df47f26b546 + subnet_id: subnet-0638ac6d79d488124 + security_group_ids: + - sg-025daf3a98e06cef3 + ssh_user: ubuntu + ssh_private_key_path: ~/.ssh/ventis_ec2 diff --git a/examples/portfolio/config/policy.yaml b/examples/portfolio/config/policy.yaml index 573c91b..834c88e 100644 --- a/examples/portfolio/config/policy.yaml +++ b/examples/portfolio/config/policy.yaml @@ -14,7 +14,6 @@ rules: - match: {} access: - Workflow - - LLMAgent - IntentAgent - PriceAgent - MetricsAgent diff --git a/pyproject.toml b/pyproject.toml index 2efc1ab..8410b24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,10 @@ dependencies = [ "pyyaml", "flask", "psutil", + "opentelemetry-api>=1.44.0", + "opentelemetry-sdk>=1.44.0", + "opentelemetry-exporter-otlp-proto-grpc>=1.44.0", + "opentelemetry-exporter-otlp-proto-http>=1.44.0", ] [project.scripts] @@ -23,7 +27,7 @@ requires = ["setuptools>=64"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["ventis*"] +include = ["ventis*", "OTel_Exporter*"] [tool.setuptools.package-data] ventis = [ diff --git a/requirements.txt b/requirements.txt index b0dd97e..dd7a254 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,7 @@ ipython sqlalchemy psycopg[binary] psutil +opentelemetry-api +opentelemetry-sdk +opentelemetry-exporter-otlp-proto-grpc +opentelemetry-exporter-otlp-proto-http diff --git a/uv.lock b/uv.lock index 9b7b188..ba10707 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version < '3.13'", +] [[package]] name = "async-timeout" @@ -48,6 +53,178 @@ 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 = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + [[package]] name = "click" version = "8.4.2" @@ -86,6 +263,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/90/fb8f1c84537fbf210c1f53a53ae473a805f6599c5a40b93c1bbadd211f7a/googleapis_common_protos-1.75.2.tar.gz", hash = "sha256:8829a3d1e4508c5b7b9a6b9525f7fccff611f8531644579a76466c29295d4bb2", size = 154083, upload-time = "2026-08-25T19:19:13.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/5b/1c9e55363c3b1890a98cae813de5b4ea327845756cd8fb7ee690140c7eac/googleapis_common_protos-1.75.2-py3-none-any.whl", hash = "sha256:6b83302f554ea93a0f48409c7fc2050f954bcbcddb7e3a9c76d4a823cb22920e", size = 307002, upload-time = "2026-08-25T19:18:08.927Z" }, +] + [[package]] name = "greenlet" version = "3.5.4" @@ -280,6 +469,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/30/b1/d1f150b2ab3b4ae9932c05104fe1edbcb7fbf505587ea8db99e49341a05f/grpcio_tools-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8c9686b0c19f70b63d8d6cfeff5ad3480bdedecd60f14711fe43950f5397253", size = 1224199, upload-time = "2026-07-23T15:22:16.064Z" }, ] +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -395,6 +593,105 @@ 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 = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/09/4d717852c1cf3f854b76c7110a5d00883bc3c99288b9b0dbcbeb9e306eb6/opentelemetry_exporter_otlp_proto_common-1.44.0.tar.gz", hash = "sha256:dc87a5a5bc58f149a56d1547e4691588fa12994cdc3bc039a694ccb3375862ac", size = 20202, upload-time = "2026-07-16T15:25:37.658Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/71/65fd9d54c10b860f87c045ccee1264cab7011268895d3528818a29c1172a/opentelemetry_exporter_otlp_proto_common-1.44.0-py3-none-any.whl", hash = "sha256:9a9fe61bba73d802904bc989f1d6b4a7b1ee40f06c40e98d6f85af65aaebb694", size = 17045, upload-time = "2026-07-16T15:25:18.201Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/47/80d9e9d468dc5de3af5096f5ccdb065fa4dd1470f74495cc53e59e397f47/opentelemetry_exporter_otlp_proto_grpc-1.44.0.tar.gz", hash = "sha256:40d1ae9e03fcc36de3cbac610cc99f35894938bff9cfd90fc4ec68bd85448463", size = 27225, upload-time = "2026-07-16T15:25:38.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/29/6ae42ba32b153ae0a44ae125f0caff2188bbe62d99c82d1768da30864e72/opentelemetry_exporter_otlp_proto_grpc-1.44.0-py3-none-any.whl", hash = "sha256:6a1a645ea182a2f59440c51fa8301d309f3324a8f9d65f8395584b064b67ee4e", size = 19624, upload-time = "2026-07-16T15:25:19.096Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/87/95e2a5aaa795b4e2260d74e16df2d5541deb2ea9de010bcd615f4dee2654/opentelemetry_exporter_otlp_proto_http-1.44.0.tar.gz", hash = "sha256:c633d7270ad6b57cd4cfbe8b0007a9e2e7c0cb50bd6c50fe2a7b245f721a09d8", size = 25806, upload-time = "2026-07-16T15:25:39.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/d0/fdeb1a98d8d3a6205f5f297c51b4a9bfe65126ab60339669bbe3dd54c2e2/opentelemetry_exporter_otlp_proto_http-1.44.0-py3-none-any.whl", hash = "sha256:838592fce774c1c8bb7b9a0a7facbfa82e17be5a8a4e94cef10cb84ae026bae3", size = 21850, upload-time = "2026-07-16T15:25:20.006Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/01/40ac4ae9a149263cc52c2cee200ddd80cb6d8db1a4610abf8eabce0fe771/opentelemetry_proto-1.44.0.tar.gz", hash = "sha256:c547a79c2f8c0c515d31509154682e5921c7cfd5ca67b70e1f9266e2c3e103f3", size = 46488, upload-time = "2026-07-16T15:25:45.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/7c/8be563d68e93bbefa5c8affb82ddcff91b3ad858ce49957ba7b16fd3e0ab/opentelemetry_proto-1.44.0-py3-none-any.whl", hash = "sha256:898b155a0e1557afd867478fb6158e8122a46329ca0bb8dc53cc55e98f017f56", size = 72483, upload-time = "2026-07-16T15:25:28.429Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/77/a6592cbc7c8d9bcc9d6757a9df45e04a7c585e3e6e7a13456da522b21109/opentelemetry_sdk-1.44.0.tar.gz", hash = "sha256:cebe7f65dc12f26ead75c6064de12fd2a9052e5060c0272d402cfa203aae123b", size = 208624, upload-time = "2026-07-16T15:25:46.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/23/ff077e61886ee020a17ce9c8b6fa11c601c8d8345b09ea24f605445df62a/opentelemetry_sdk-1.44.0-py3-none-any.whl", hash = "sha256:df081c4c6bcfdb1211e3e86140376792643128a25f8d72d1d27675936e7e96ad", size = 137221, upload-time = "2026-07-16T15:25:29.534Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.65b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/73/0cbdebcb4cf545fdd328da14f5137e37d0770c3f26185e478b0d15d94f50/opentelemetry_semantic_conventions-0.65b0.tar.gz", hash = "sha256:f9b2b81e9d5b64f11bc952075e7e9c7fb0aab075c7fd1c46d597f1b919852d60", size = 148774, upload-time = "2026-07-16T15:25:46.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, +] + [[package]] name = "protobuf" version = "7.35.1" @@ -606,6 +903,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +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 = "s3transfer" version = "0.19.2" @@ -727,6 +1039,10 @@ dependencies = [ { name = "flask" }, { name = "grpcio" }, { name = "grpcio-tools" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "psutil" }, { name = "psycopg", extra = ["binary"] }, { name = "pyyaml" }, @@ -740,6 +1056,10 @@ requires-dist = [ { name = "flask" }, { name = "grpcio" }, { name = "grpcio-tools" }, + { name = "opentelemetry-api", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.44.0" }, { name = "psutil" }, { name = "psycopg", extras = ["binary"] }, { name = "pyyaml" }, diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 4e416b4..496ae1a 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -3,6 +3,7 @@ # Periodically polls Redis to check controller health and updates the routing table. import atexit +import importlib.util import logging import signal import subprocess @@ -15,6 +16,7 @@ import yaml from ventis.controller.instance_manager import InstanceManager from ventis.controller.utils.agent_specs import write_agent_specs +from ventis.controller.utils.process_supervisor import ProcessSupervisor from ventis.controller.utils.redis_utils import _wait_for_redis from ventis.controller.utils.telemetry_logging import ( assign_project_id, @@ -100,6 +102,31 @@ def __init__(self, config_path): self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() + # Spawn the OTLP exporter as a separate process (see OTel_Exporter/DESIGN.md), + # supervised so it gets restarted if it ever exits unexpectedly. + otel_exporter_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "OTel_Exporter", + ) + otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") + self.process_supervisor = ProcessSupervisor() + # `otel:` in global_controller.yaml maps straight to the OTel SDK's own + # standard env vars, not app-specific args -- the exporter subprocess itself + # stays a plain vendor-neutral OTel process; see OTel_Exporter/DESIGN.md. + otel_env = self._otel_exporter_env(self.config.get("otel", {})) + self.process_supervisor.register( + "otel_exporter", [sys.executable, otel_exporter_script], env=otel_env + ) + self.process_supervisor.start_all() + + # waiting table GC writes future data into (see OTel_Exporter/db.py); the + # exporter process itself calls init_db() to create the table. + otel_db_spec = importlib.util.spec_from_file_location( + "otel_queue_db", os.path.join(otel_exporter_dir, "db.py") + ) + self._otel_db = importlib.util.module_from_spec(otel_db_spec) + otel_db_spec.loader.exec_module(self._otel_db) + # ------------------------------------------------------------------ # # Stale container cleanup # # ------------------------------------------------------------------ # @@ -143,6 +170,22 @@ def _load_config(config_path): with open(config_path, "r") as f: return yaml.safe_load(f) + @staticmethod + def _otel_exporter_env(otel_cfg): + """Translate global_controller.yaml's `otel:` section into standard OTLP env + vars for the exporter subprocess; returns None if `otel:` is absent/empty so + the subprocess falls back to the SDK's own defaults untouched.""" + env = {} + if otel_cfg.get("protocol"): + env["OTEL_EXPORTER_OTLP_PROTOCOL"] = otel_cfg["protocol"] + if otel_cfg.get("endpoint"): + env["OTEL_EXPORTER_OTLP_ENDPOINT"] = otel_cfg["endpoint"] + if otel_cfg.get("headers"): + env["OTEL_EXPORTER_OTLP_HEADERS"] = ",".join( + f"{k}={v}" for k, v in otel_cfg["headers"].items() + ) + return env or None + @staticmethod def _get_replica_placements(ctrl): """Normalize replicas into a list of (host, port) placements.""" @@ -407,14 +450,25 @@ def _poll_controllers(self): Check the health of each registered controller replica via its node's Redis. Also retrieves the request calls made in each instance. """ + # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which + # terminates every managed process) via the signal handler before this line is + # reached -- without the guard, this could respawn a process just intentionally + # killed. See OTel_Exporter/DESIGN.md. + if self.running: + self.process_supervisor.check_and_respawn() + for instance in self.instance_manager.list_instances(): name = instance["agent_name"] host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) try: + future_rows = pull_runtime_information(node_redis) + self._otel_db.write_waiting_rows( + future_rows, node_redis, self.config.get("project_id", 0) + ) send_runtime_information( - pull_runtime_information(node_redis), + future_rows, node_redis, self.config.get("database", {}).get("url"), ) @@ -654,6 +708,7 @@ def stop(self): self.running = False self._stop_docker_agents() self._stop_redis_containers() + self.process_supervisor.terminate_all() logger.info("Global controller shut down.") diff --git a/ventis/controller/utils/process_supervisor.py b/ventis/controller/utils/process_supervisor.py new file mode 100644 index 0000000..8f5724c --- /dev/null +++ b/ventis/controller/utils/process_supervisor.py @@ -0,0 +1,59 @@ +"""Registry for OS processes GlobalController spawns and supervises. + +register() + start_all() spawn processes; check_and_respawn() (call from GC's existing +poll tick) restarts any that exit unexpectedly; terminate_all() (call from GC's shutdown +path) stops them all cleanly. Deliberately GC-agnostic -- callers are responsible for not +calling check_and_respawn() during their own shutdown (see OTel_Exporter/DESIGN.md's +shutdown-race note). +""" + +import logging +import os +import subprocess + +logger = logging.getLogger(__name__) + + +class ProcessSupervisor: + def __init__(self): + self._specs = {} # name -> (argv, env) tuple + self._procs = {} # name -> subprocess.Popen + + def register(self, name, argv, env=None): + """Declare a process to manage. Does not start it -- call start_all() once + everything is registered. `env`, if given, is merged on top of (not a + replacement for) this process's own environment, so the child still inherits + PATH etc.""" + self._specs[name] = (argv, env) + + def start_all(self): + for name, (argv, env) in self._specs.items(): + self._start(name, argv, env) + + def _start(self, name, argv, env=None): + merged_env = {**os.environ, **env} if env else None + self._procs[name] = subprocess.Popen(argv, env=merged_env) + + def check_and_respawn(self): + """Restart any registered process that has exited.""" + for name, proc in list(self._procs.items()): + if proc.poll() is not None: + logger.warning( + "Managed process %r exited (code %s), respawning", + name, + proc.returncode, + ) + argv, env = self._specs[name] + self._start(name, argv, env) + + def terminate_all(self, timeout=10): + """Terminate every managed process, falling back to kill() on timeout.""" + for proc in self._procs.values(): + proc.terminate() + for name, proc in self._procs.items(): + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + self._procs.clear() diff --git a/ventis/deploy.py b/ventis/deploy.py index b6ac721..148d3a4 100644 --- a/ventis/deploy.py +++ b/ventis/deploy.py @@ -9,7 +9,7 @@ import ventis def my_workflow(query: str): - finance = FinanceAgentStub() + finance = FinanceAgent() price = finance.get_stock_price(ticker=query) return {"price": price.value()} diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 8c956ef..d9480be 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -164,13 +164,12 @@ def _build_stub_class(agent_config): Build an AST node for the entire stub class. Generates a class like: - class FinanceAgentStub(object): + class FinanceAgent(object): def __init__(self): pass ...stub methods... """ - # class_name = agent_config["name"] + "Stub" - class_name = agent_config["name"] + class_name = agent_config["name"] functions = agent_config.get("functions", []) # __init__ method: simple pass, no gRPC setup needed. @@ -241,7 +240,7 @@ def generate_stub(yaml_path, output_path): with open(output_path, "w") as f: f.write(source) - class_name = agent_config["name"] + "Stub" + class_name = agent_config["name"] print(f"Generated stub class '{class_name}' -> {output_path}") return source @@ -521,7 +520,7 @@ def start_lc(): "-o", "--output", default=None, - help="Output path for the generated stub file (default: stubs/_stub.py)", + help="Output path for the generated stub file (default: stubs/.py)", ) parser.add_argument( "--agent-file", From 4af43ae3abb8da914050f9f83c61cbf2badebf17 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Tue, 25 Aug 2026 20:40:54 -0700 Subject: [PATCH 06/44] [Feature] Pass env / secrets into agent containers Users had no way to get API keys (OpenAI, Anthropic, embedding models) into an agent container. Add a top-level `env_file` key to global_controller.yaml pointing at a local .env file, which reaches every container as `docker run --env-file`. - resolve_env_file validates the path before anything launches, so a missing .env fails at deploy time instead of deep inside a container. Relative paths resolve against the project root, matching entrypoint. - env_file_args is a context manager owning the local-vs-remote decision and the cleanup, so both runtimes share one code path. Local containers read the original file; remote containers get a copy that is deleted as soon as `docker run` returns, whether or not it succeeded. - GlobalController._push_file streams the file over ssh under `umask 077` rather than scp, so the copy is never briefly world-readable and the secret never lands in a command line. _run_cmd's ssh options moved to a shared _ssh_args. --env-file is appended after the explicit -e VENTIS_* flags; Docker gives those precedence regardless of order, so a stray VENTIS_* line in someone's .env cannot break agent wiring. Closes #50 --- ventis/cli.py | 10 ++ .../cloud_provider_logic/EC2/_runtime.py | 12 ++- .../cloud_provider_logic/Local/_runtime.py | 12 ++- ventis/controller/global_controller.py | 94 +++++++++++++------ ventis/controller/utils/env_file.py | 92 ++++++++++++++++++ 5 files changed, 185 insertions(+), 35 deletions(-) create mode 100644 ventis/controller/utils/env_file.py diff --git a/ventis/cli.py b/ventis/cli.py index b43a6b3..6d85e1f 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -16,6 +16,8 @@ import subprocess import sys +from ventis.controller.utils.env_file import resolve_env_file + logging.basicConfig(level=logging.INFO) logger = logging.getLogger("ventis") DEFAULT_DOCKER_PLATFORM = "linux/amd64" @@ -397,6 +399,14 @@ def cmd_deploy(args): config = _load_config(config_path) project_dir = os.getcwd() + # Fail here rather than after a fleet of containers is already up without + # the API keys they need. + try: + resolve_env_file(config, base_dir=project_dir) + except ValueError as e: + logger.error("%s", e) + sys.exit(1) + _ensure_grpc_stubs_importable(project_dir) if any( diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index 4d5f766..9955fa2 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -23,6 +23,7 @@ import boto3 +from ventis.controller.utils.env_file import env_file_args from ventis.controller.utils.redis_utils import _wait_for_redis from ventis.utils.redis_client import RedisClient @@ -285,8 +286,15 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) if project_id: cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) - cmd.append(image) - result = _controller._run_cmd(cmd, host, user=ssh_user) + + # User secrets from `env_file`. Explicit -e flags above still win over + # anything in the file. + with env_file_args( + _controller, host, ssh_user, container_name, is_local=False + ) as env_args: + cmd.extend(env_args) + cmd.append(image) + result = _controller._run_cmd(cmd, host, user=ssh_user) if result.returncode != 0: raise RuntimeError( f"SSH bootstrap failed on {host}: {(result.stderr or result.stdout or '').strip()}" diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index 963eef3..a387f7b 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -8,6 +8,8 @@ import logging +from ventis.controller.utils.env_file import env_file_args + logger = logging.getLogger(__name__) DEFAULT_HOST = "localhost" @@ -110,9 +112,15 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): cmd.extend(["--memory", f"{resources['memory']}m"]) if resources.get("gpu"): cmd.extend(["--gpus", str(resources["gpu"])]) - cmd.append(image) - result = _require_controller()._run_cmd(cmd, host, user) + # User secrets from `env_file`. Explicit -e flags above still win, so a + # stray VENTIS_* line in someone's .env cannot break agent wiring. + with env_file_args( + _require_controller(), host, user, runtime_id, _is_local_host(host) + ) as env_args: + cmd.extend(env_args) + cmd.append(image) + result = _require_controller()._run_cmd(cmd, host, user) if result.returncode != 0: raise RuntimeError(f"Failed to launch {runtime_id}") diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 1e24f10..241daff 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -4,6 +4,7 @@ import atexit import logging +import shlex import signal import subprocess import threading @@ -16,6 +17,7 @@ import yaml from ventis.controller.instance_manager import InstanceManager from ventis.controller.utils.agent_specs import write_agent_specs +from ventis.controller.utils.env_file import resolve_env_file from ventis.controller.utils.redis_utils import _wait_for_redis from ventis.controller.utils.telemetry_logging import ( assign_project_id, @@ -64,6 +66,9 @@ class GlobalController(object): def __init__(self, config_path): self.config_path = config_path self.config = self._load_config(config_path) + # Validate before launching anything: an agent that boots without its + # API keys fails deep inside a container, where it is expensive to debug. + self.env_file_path = resolve_env_file(self.config) redis_cfg = self.config.get("redis", {}) self.redis = RedisClient( @@ -174,6 +179,7 @@ def reload_config(self): """Reload the config file and rebuild the routing table.""" logger.info("Reloading config from %s", self.config_path) self.config = self._load_config(self.config_path) + self.env_file_path = resolve_env_file(self.config) self.controllers = self.config.get("agents", []) self.poll_interval = self.config.get("poll_interval", 5) assign_project_id(self.config.get("project_id", 0)) @@ -642,6 +648,28 @@ def _send(instance): # Runtime launching # # ------------------------------------------------------------------ # + def _ssh_args(self, host, user=None): + """Return the `ssh ... target` prefix used to reach a remote host.""" + ssh_key_path = os.path.expanduser( + self.config.get("ec2", {}).get("ssh_private_key_path", "~/.ssh/ventis_ec2") + ) + return [ + "ssh", + "-o", + "StrictHostKeyChecking=no", + "-o", + "IdentitiesOnly=yes", + "-o", + "ConnectTimeout=10", + "-o", + "ServerAliveInterval=10", + "-o", + "ServerAliveCountMax=3", + "-i", + ssh_key_path, + f"{user}@{host}" if user else host, + ] + def _run_cmd(self, cmd, host, user=None): """ Run a command locally or on a remote host via SSH. @@ -656,41 +684,45 @@ def _run_cmd(self, cmd, host, user=None): """ is_local = _is_local_host(host) if is_local: - return subprocess.run( - cmd, capture_output=True, text=True, timeout=180 - ) - else: - ssh_key_path = os.path.expanduser( - self.config.get("ec2", {}).get( - "ssh_private_key_path", "~/.ssh/ventis_ec2" - ) - ) - ssh_target = f"{user}@{host}" if user else host - remote_cmd = " ".join(cmd) - if cmd and cmd[0] == "docker": - remote_cmd = f"sudo {remote_cmd}" - return subprocess.run( - [ - "ssh", - "-o", - "StrictHostKeyChecking=no", - "-o", - "IdentitiesOnly=yes", - "-o", - "ConnectTimeout=10", - "-o", - "ServerAliveInterval=10", - "-o", - "ServerAliveCountMax=3", - "-i", - ssh_key_path, - ssh_target, - remote_cmd, - ], + return subprocess.run(cmd, capture_output=True, text=True, timeout=180) + + remote_cmd = " ".join(cmd) + if cmd and cmd[0] == "docker": + remote_cmd = f"sudo {remote_cmd}" + return subprocess.run( + self._ssh_args(host, user) + [remote_cmd], + capture_output=True, + text=True, + timeout=180, + ) + + def _push_file(self, local_path, remote_path, host, user=None): + """ + Copy a local file to a remote host over SSH. + + Streams the bytes through `cat` under `umask 077` rather than using + `scp`, so a secrets file is never briefly world-readable on the far + side. + + Returns: + subprocess.CompletedProcess + """ + remote_cmd = f"umask 077; cat > {shlex.quote(remote_path)}" + with open(local_path, "rb") as f: + result = subprocess.run( + self._ssh_args(host, user) + [remote_cmd], + stdin=f, capture_output=True, text=True, timeout=180, + check=False, + ) + if result.returncode != 0: + raise RuntimeError( + f"Failed to copy {local_path} to {host}:{remote_path}: " + f"{(result.stderr or result.stdout or '').strip()}" ) + return result def launch_docker_agents(self): """Launch all configured runtimes through InstanceManager.""" diff --git a/ventis/controller/utils/env_file.py b/ventis/controller/utils/env_file.py new file mode 100644 index 0000000..c83f770 --- /dev/null +++ b/ventis/controller/utils/env_file.py @@ -0,0 +1,92 @@ +""" +Pass user secrets (API keys and friends) into agent containers. + +The user points `env_file` in `config/global_controller.yaml` at a local +`.env` file. Containers on this machine read that file directly; containers +on a remote host get a short-lived 0600 copy. Either way the file reaches +Docker as `--env-file`. +""" + +import logging +import os +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + +REMOTE_ENV_DIR = "/tmp" + + +def resolve_env_file(config, base_dir=None): + """ + Return the absolute path of the configured env file, or None when unset. + + Relative paths resolve against `base_dir` (default: the current working + directory), matching how `entrypoint` and `workflow_file` are resolved. + + Raises: + ValueError: the file is configured but unusable. Deploy should fail + here rather than start a fleet of agents with no API keys. + """ + raw = config.get("env_file") + if not raw: + return None + + path = os.path.expanduser(str(raw)) + if not os.path.isabs(path): + path = os.path.join(base_dir or os.getcwd(), path) + path = os.path.abspath(path) + + if not os.path.exists(path): + raise ValueError(f"env_file does not exist: {path} (from env_file: {raw})") + if not os.path.isfile(path): + raise ValueError(f"env_file is not a file: {path} (from env_file: {raw})") + if not os.access(path, os.R_OK): + raise ValueError(f"env_file is not readable: {path}") + return path + + +def remote_env_path(container_name): + """Where a remote host holds this container's copy of the env file.""" + return f"{REMOTE_ENV_DIR}/ventis-env-{container_name}" + + +@contextmanager +def env_file_args(controller, host, user, container_name, is_local): + """ + Yield the `docker run` flags that hand the user's env file to a container. + + A container on this machine reads the original file. A container on a + remote host gets a 0600 copy, deleted as soon as the `with` body ends -- + success or failure, since by then the container holds the variables + itself. Keep that body tight around `docker run` so the copy is never + on the host longer than it has to be. + + Yields an empty list when no `env_file` is configured. + """ + env_file_path = getattr(controller, "env_file_path", None) + if not env_file_path: + yield [] + return + + if is_local: + yield ["--env-file", env_file_path] + return + + remote_path = remote_env_path(container_name) + controller._push_file(env_file_path, remote_path, host, user=user) + try: + yield ["--env-file", remote_path] + finally: + _remove_remote_copy(controller, remote_path, host, user) + + +def _remove_remote_copy(controller, remote_path, host, user): + """Delete a remote copy. Best effort -- never masks the caller's error.""" + try: + result = controller._run_cmd(["rm", "-f", remote_path], host, user=user) + if getattr(result, "returncode", 0) != 0: + logger.warning("Failed to delete env file copy %s on %s", remote_path, host) + except Exception as e: + logger.warning( + "Failed to delete env file copy %s on %s: %s", remote_path, host, e + ) From 087ee15b66e967937297580fc551c121c7301a20 Mon Sep 17 00:00:00 2001 From: Nick Huo Date: Wed, 26 Aug 2026 13:28:52 -0700 Subject: [PATCH 07/44] Harden the remote env file copy against a hostile /tmp Two holes in the remote staging path, both found reviewing the feature commit. `umask 077` only governs files the shell creates, and `>` follows symlinks -- so it did not actually guarantee a 0600 copy. The destination path is fully predictable (`/tmp/ventis-env-ventis-ec2--`), so a local user on the remote host could pre-create it world-readable, or point it at a file of their own, and collect the API keys. Remove whatever sits at the path before writing; `rm -f` unlinks a symlink rather than following it, so `cat >` then creates a fresh file under the umask. `_run_cmd` joins its argv with spaces and hands the result to a remote shell unquoted. `_push_file` quoted its path but the cleanup `rm` did not, so a container name containing a space split the `rm` into two arguments that matched nothing -- it exited 0 while the secrets file stayed on the host, and the returncode check logged nothing. Scrub the name down to [A-Za-z0-9_.-] in remote_env_path, which also closes the same gap in the `--env-file` argument and in any future use of that path. Still open, tracked separately: a push that dies mid-transfer can leave a copy behind, since the cleanup only covers the `docker run` that follows. On EC2 the instance is terminated on that path, which disposes of it. --- ventis/controller/global_controller.py | 9 ++++++++- ventis/controller/utils/env_file.py | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 241daff..0b30307 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -704,10 +704,17 @@ def _push_file(self, local_path, remote_path, host, user=None): `scp`, so a secrets file is never briefly world-readable on the far side. + Anything already sitting at the destination is removed first: `umask` + only governs files the shell creates, and `>` follows symlinks. Without + the `rm`, a local user on the remote host could pre-create the path + world-readable, or point it at a file of their own, and collect + whatever we write there. + Returns: subprocess.CompletedProcess """ - remote_cmd = f"umask 077; cat > {shlex.quote(remote_path)}" + quoted = shlex.quote(remote_path) + remote_cmd = f"umask 077; rm -f {quoted}; cat > {quoted}" with open(local_path, "rb") as f: result = subprocess.run( self._ssh_args(host, user) + [remote_cmd], diff --git a/ventis/controller/utils/env_file.py b/ventis/controller/utils/env_file.py index c83f770..6cd77b6 100644 --- a/ventis/controller/utils/env_file.py +++ b/ventis/controller/utils/env_file.py @@ -9,11 +9,13 @@ import logging import os +import re from contextlib import contextmanager logger = logging.getLogger(__name__) REMOTE_ENV_DIR = "/tmp" +_UNSAFE_PATH_CHARS = re.compile(r"[^A-Za-z0-9_.-]") def resolve_env_file(config, base_dir=None): @@ -46,8 +48,17 @@ def resolve_env_file(config, base_dir=None): def remote_env_path(container_name): - """Where a remote host holds this container's copy of the env file.""" - return f"{REMOTE_ENV_DIR}/ventis-env-{container_name}" + """ + Where a remote host holds this container's copy of the env file. + + The name is scrubbed down to a shell-safe alphabet. This path is + interpolated into remote commands that `_run_cmd` joins with spaces and + hands to a shell unquoted, so a container name carrying a space would + split the cleanup `rm` into two harmless arguments -- it would exit 0 + while the secrets stayed on the host, with nothing in the log to say so. + """ + safe_name = _UNSAFE_PATH_CHARS.sub("-", container_name) + return f"{REMOTE_ENV_DIR}/ventis-env-{safe_name}" @contextmanager From db0ba260c9cc1f88943d98836ce556fb7b458817 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 18:05:41 -0700 Subject: [PATCH 08/44] WIP: OTel multi-destination fan-out (Railway+Langfuse+Grafana) + cleanup-race fix (pre-pull checkpoint) Co-Authored-By: Claude Sonnet 5 --- OTel_Exporter/DESIGN.md | 102 ++++-- OTel_Exporter/convert.py | 11 +- OTel_Exporter/db.py | 44 ++- OTel_Exporter/otel_exporter.py | 266 +++++++++++++-- .../portfolio/config/global_controller.yaml | 18 + pyproject.toml | 5 + tests/test_otel_exporter_fanout.py | 318 ++++++++++++++++++ tests/test_otel_exporter_fields.py | 99 ++++++ uv.lock | 128 +++++++ ventis/controller/global_controller.py | 173 +++++++++- 10 files changed, 1074 insertions(+), 90 deletions(-) create mode 100644 tests/test_otel_exporter_fanout.py create mode 100644 tests/test_otel_exporter_fields.py diff --git a/OTel_Exporter/DESIGN.md b/OTel_Exporter/DESIGN.md index 73c4da2..86a2a95 100644 --- a/OTel_Exporter/DESIGN.md +++ b/OTel_Exporter/DESIGN.md @@ -1,6 +1,6 @@ # OTLP Exporter for Ventis GlobalController — Design -Status: **implemented (single-table design)**. `GlobalController` writes futures into a +Status: **implemented (single-table design; multi-destination fan-out in progress)**. `GlobalController` writes futures into a `waiting` table (SQLite); a GC-supervised, GC-restarted OTel Exporter process reads finished/unsent rows, converts each to an OTel span, and hands it to a real `BatchSpanProcessor`/`OTLPSpanExporter`. Batching, serialization, and sending are all @@ -41,7 +41,12 @@ Decisions (final status): originally-planned `database.url` repurposing (below, kept for history) was decided against — env-var configuration is the SDK's own idiomatic mechanism, so no exporter-side config plumbing was added, only a GC-side YAML→env-var translation. - Does not (yet) support simultaneous multi-destination export — see "Known gaps". + The initial multi-destination extension uses one `otel.destinations` list and one + independent exporter/`BatchSpanProcessor` pair per destination. gRPC and HTTP + destinations may be mixed in the same list. The legacy single-destination fields + remain supported through the original standard-environment-variable path. + Configuration is read at exporter startup; changing it requires a + GlobalController/exporter restart. - **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own SQLite file (`OTel_Exporter/otel_queue.db`, see `db.py`), written by GC's existing `_poll_controllers` *alongside* (not instead of) the existing @@ -64,39 +69,47 @@ Decisions (final status): `global_controller.yaml` gains an optional `otel:` section: ```yaml otel: - protocol: grpc # or http - endpoint: otlp-pg-receiver.railway.internal:4317 - headers: {} # e.g. Authorization: "Basic " for a backend needing auth + destinations: + - name: railway + protocol: grpc # or http + endpoint: otlp-pg-receiver.railway.internal:4317 + headers: {} + - name: langfuse + protocol: http + endpoint: https://cloud.langfuse.com/api/public/otel/v1/traces + headers: {} # e.g. Authorization: "Basic " ``` -`GlobalController._otel_exporter_env()` translates this into -`OTEL_EXPORTER_OTLP_PROTOCOL`/`OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` -and hands them to `ProcessSupervisor.register("otel_exporter", ..., env=...)`, which now -supports an `env` param (merged on top of the parent process's own environment, not a -replacement). Omitting `otel:` entirely falls back to whatever ambient env the exporter -subprocess would otherwise inherit, same as before this change. +`GlobalController._otel_exporter_env()` translates each destination into the exporter +process's destination configuration and hands it to `ProcessSupervisor.register( +"otel_exporter", ..., env=...)`, which supports an `env` param (merged on top of the +parent process's own environment, not a replacement). The legacy single-destination +`protocol`/`endpoint`/`headers` form remains valid and continues through the SDK's +standard OTLP environment variables. Omitting `otel:` entirely falls back to whatever +ambient env the exporter subprocess would otherwise inherit, same as before this +change. -`otel_exporter.py` itself reads only `OTEL_EXPORTER_OTLP_PROTOCOL` directly (to pick -which `OTLPSpanExporter` class to import — gRPC or HTTP; the plain SDK classes don't -self-select this the way `opentelemetry-instrument`'s auto-config does). Endpoint and -headers are never read directly — `OTLPSpanExporter()` is still constructed with no -explicit args, letting the SDK resolve those from the same env vars itself, exactly as -before this change. `BatchSpanProcessor(OTLPSpanExporter(), schedule_delay_millis=1000)` +`otel_exporter.py` parses the destination configuration at startup and constructs the +appropriate OTLP exporter for each entry (gRPC or HTTP), passing that destination's +endpoint and headers to the SDK. `BatchSpanProcessor(..., schedule_delay_millis=1000)` — the flush delay is explicitly overridden from the SDK default (5000ms) to 1000ms; `max_export_batch_size` is left at the SDK default (512), which already approximates the original "500 spans" batching ask without any override needed. ### 2. `OTel_Exporter/otel_exporter.py` A plain loop, polling every `POLL_INTERVAL_SECONDS` (5s, checked every 1s so SIGTERM -stays responsive), calling `_send_pending()` each tick: +stays responsive), calling `_send_pending()` each tick. At startup it constructs one +independent OTLP exporter and `BatchSpanProcessor` for each configured destination; +each pair may use a different protocol, endpoint, and headers: - `SELECT * FROM waiting WHERE finished_at IS NOT NULL AND (sent IS NULL OR sent = 0)`. - Per row, each isolated in its own try/except (one malformed row is logged and skipped, never blocks the rest of the batch): `convert.waiting_row_to_span(row)` → - `_processor.on_end(span)` → `db.mark_sent(future_id)` immediately — atomic per row, not - batched at the end, so a crash mid-poll can't leave an already-sent row unmarked (which - would cause a duplicate send on the next run). -- `_processor` is constructed once at startup; no `TracerProvider` is used at all, since - spans are hand-built and handed straight to the processor via `on_end()`. -- `_processor.shutdown()` on exit, flushing any pending batch. + `on_end(span)` on every configured processor → `db.mark_sent(future_id)` immediately. + The row is marked after it has been queued to all processors. `sent` therefore means + **queued to every configured destination**, not remotely acknowledged; this is the + initial best-effort delivery contract and retains the existing single boolean schema. +- Each processor is constructed once at startup; no `TracerProvider` is used at all, + since spans are hand-built and handed straight to the processors via `on_end()`. +- Every processor is shut down on exit, flushing its pending batch independently. ### 3. Future row → OTel span conversion (`OTel_Exporter/convert.py`) `future_id` maps to OTel `span_id`, not `trace_id` — `session_id` (== `request_id`) is @@ -117,7 +130,11 @@ no live exception object, only strings) plus `Status(StatusCode.ERROR, descripti **Attribute naming**: `model`/`input_token_count`/`output_token_count` are set under the real, current OTel GenAI semantic-convention keys — `gen_ai.request.model`/ `gen_ai.usage.input_tokens`/`gen_ai.usage.output_tokens` — verified against the actual -spec (`open-telemetry/semantic-conventions`), not assumed. `cpu`/`gpu`/ +spec (`open-telemetry/semantic-conventions`), not assumed. Submitted `args` and the +completed `result` are stored in `waiting.input`/`waiting.output` as valid JSON text and +exported under Langfuse's documented `langfuse.observation.input`/ +`langfuse.observation.output` attributes. The span name is the stable logical +`service.method`, not the executing instance's UUID. `cpu`/`gpu`/ `execution_time_ms`/`queue_time_ms`/`token_count` keep plain names deliberately: none of them have an OTel GenAI equivalent (cpu/gpu/queue-time are Ventis infra concepts, and `token_count`, an input+output sum, isn't part of the spec at all — inventing a @@ -136,24 +153,45 @@ process (all `.terminate()` calls first, then `.wait()` on each, falling back to `.kill()`), called from GC's `stop()`. Adding a future second daemon is one more `register()` call — no new spawn/monitor/terminate code needed. -### 5. Dependencies (all added) +### 5. Poll/cleanup race fix (`ventis/controller/global_controller.py`) +GC's cleanup thread used to run on its own `cleanup_interval` timer (default 10s), +fully independent of the poll loop's `poll_interval` (default 5s) that writes futures +into `waiting`. On a fast-completing request, cleanup could delete a session's Redis +future keys before the next poll tick ever read them, so those futures never reached +`waiting` at all — silently dropped from every OTel destination, not just one. +Reproduced live: a fast request left only 1 of 6 agent calls in `waiting`. Fixed by +having the poll loop signal a `threading.Event` (`_cleanup_ready`) right after each +tick; the cleanup thread waits on that event instead of sleeping on its own timer, so +cleanup only ever runs immediately after a poll has already captured that tick's state. +Cleanup stays on its own thread (the event's `wait(timeout=cleanup_interval)` is a +fallback, not the primary trigger) so a slow/hung instance during cleanup can't stall +the poll loop's health checks and OTel writes. + +### 6. Dependencies (all added) `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, `opentelemetry-exporter-otlp-proto-http` (the last one added alongside the `otel:` config work, since `protocol: http` now needs that package importable). ## Known gaps (not yet built) +- `WriteResult()` passes an undefined `error_message` variable to its fan-out callback, + which can interrupt remote consumer propagation after the callback hash is persisted. +- Redis records failure text under `error`, but the waiting-table writer reads + `error_name`/`error_message`, so exported exception details are usually empty. +- Rows are marked `sent` immediately after `BatchSpanProcessor.on_end()` accepts them, + before the asynchronous OTLP export is confirmed; a later delivery failure can lose a + span while leaving `sent = 1`. - Spans carry no explicit `resource`/`instrumentation_scope` — would show as `service.name=unknown_service` at a real backend. -- No simultaneous multi-destination export — `otel:` configures exactly one - destination; sending to two backends at once would mean registering a second, - separately-configured `otel_exporter` subprocess (same script, different env), not - something the exporter or its config format do today. +- Destination-specific delivery acknowledgement/retry state is not tracked yet: + `sent` only records that the span was queued to all configured processors, so an + asynchronous export failure can still lose a span until a later delivery-state design + is added. - `waiting` grows unboundedly: sent rows are never pruned, and futures that never finish (`finished_at` never arrives) also stay forever, invisible and un-expiring. - `error_name` is always `NULL` — Ventis's own Redis writer never records a distinct exception-type field, only a message string. -- No committed test suite — all verification during development was ad hoc scripts, not - `pytest` files under `tests/`. +- Test coverage is still limited; the waiting-field migration/normalization/conversion + path is covered, but the exporter process and live OTLP delivery are not. - Never verified against a live OTLP receiver — only against a refused connection (confirmed the SDK's real retry/error-handling path is exercised correctly). - No retry-limit/quarantine for a permanently malformed row — it logs an error every poll diff --git a/OTel_Exporter/convert.py b/OTel_Exporter/convert.py index 8e062a4..b7fb493 100644 --- a/OTel_Exporter/convert.py +++ b/OTel_Exporter/convert.py @@ -64,10 +64,9 @@ def waiting_row_to_span(row): ) status = Status(StatusCode.ERROR, description=row.get("error_message")) - # model/input/output use real OTel GenAI semconv names; cpu/gpu/execution_time_ms/ - # queue_time_ms/token_count have no semconv equivalent (Ventis infra concepts, or -- - # for token_count -- a derived sum the spec doesn't define), so they keep plain names - # rather than being forced into a fake gen_ai.* one. See DESIGN.md. + # Model and token usage use OTel GenAI semantic-convention names. Observation + # input/output use Langfuse's documented JSON-string attributes. The remaining + # Ventis infrastructure values have no GenAI equivalent, so they keep plain names. attributes = { k: v for k, v in { @@ -79,12 +78,14 @@ def waiting_row_to_span(row): "gen_ai.usage.input_tokens": row.get("input_token_count"), "gen_ai.usage.output_tokens": row.get("output_token_count"), "token_count": row.get("token_count"), + "langfuse.observation.input": row.get("input"), + "langfuse.observation.output": row.get("output"), }.items() if v is not None } return ReadableSpan( - name=row.get("agent_id") or "unknown_agent", + name=row.get("name") or row.get("agent_id") or "unknown_agent", context=context, parent=parent, attributes=attributes, diff --git a/OTel_Exporter/db.py b/OTel_Exporter/db.py index 4bb301d..a8a675f 100644 --- a/OTel_Exporter/db.py +++ b/OTel_Exporter/db.py @@ -8,6 +8,7 @@ wasn't doing anything BatchSpanProcessor doesn't already do -- see DESIGN.md.) """ +import json import os import sqlite3 @@ -51,16 +52,31 @@ cache_hit_ratio REAL, error_name TEXT, error_message TEXT, + name TEXT, + input TEXT, + output TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sent BOOLEAN DEFAULT 0 """ +_MIGRATION_COLUMNS = { + "name": "TEXT", + "input": "TEXT", + "output": "TEXT", +} + def init_db(db_path=DB_PATH): - """Create the waiting table if it doesn't already exist.""" + """Create the waiting table and add columns missing from older databases.""" conn = sqlite3.connect(db_path) try: conn.execute(f"CREATE TABLE IF NOT EXISTS waiting ({_TABLE_COLUMNS})") + existing_columns = { + row[1] for row in conn.execute("PRAGMA table_info(waiting)").fetchall() + } + for column, column_type in _MIGRATION_COLUMNS.items(): + if column not in existing_columns: + conn.execute(f"ALTER TABLE waiting ADD COLUMN {column} {column_type}") conn.commit() finally: conn.close() @@ -74,6 +90,7 @@ def init_db(db_path=DB_PATH): "input_token_count", "output_token_count", "token_count", "errors", "failed", "server_cost", "token_cost", "total_cost", "cached_tokens", "cache_hit_ratio", "error_name", "error_message", + "name", "input", "output", ] _WAITING_UPSERT = """ @@ -86,6 +103,17 @@ def init_db(db_path=DB_PATH): ) +def _normalize_json_text(value): + """Return JSON text, encoding legacy scalar strings that are not valid JSON.""" + if value is None or value == "": + return None + try: + json.loads(value) + except (json.JSONDecodeError, TypeError): + return json.dumps(value) + return value + + def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH): """Upsert future rows (as returned by telemetry_logging.pull_runtime_information) into the waiting table. Unlike runtime_information, rows without finished_at are @@ -113,6 +141,17 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH output_token_count = int(float(raw.get("output_token_count") or 0)) token_count = int(float(raw.get("token_count") or 0)) cached_tokens = int(float(raw.get("input_cache_tokens") or 0)) + service = raw.get("service") + method = raw.get("method") + name = raw.get("name") or ".".join( + part for part in (service, method) if part + ) + result = raw.get("result") + # Compatibility with pre-consolidation deployments, where completion + # metrics live in future:{id}:metrics but result lives in future:{id}. + # Unified hashes already include result and avoid this extra read. + if not result and finished_at and redis_client is not None: + result = redis_client.hget(f"future:{fid}", "result") token_cost = ( pricing.compute_token_cost( @@ -165,6 +204,9 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH "cache_hit_ratio": cached_tokens / token_count if token_count else 0.0, "error_name": raw.get("error_name"), "error_message": raw.get("error_message"), + "name": name or agent_id or "unknown_agent", + "input": _normalize_json_text(raw.get("args")), + "output": _normalize_json_text(result), }, ) conn.commit() diff --git a/OTel_Exporter/otel_exporter.py b/OTel_Exporter/otel_exporter.py index b1c7f1a..cad2af8 100644 --- a/OTel_Exporter/otel_exporter.py +++ b/OTel_Exporter/otel_exporter.py @@ -1,33 +1,31 @@ -"""Entrypoint for the OTLP Exporter process. - -Each poll tick: read finished, not-yet-sent rows from `waiting`, convert each to a span, -hand it to a BatchSpanProcessor/OTLPSpanExporter, and mark it sent -- batching, OTLP -serialization, and sending are all the SDK's own code, not ours (see DESIGN.md). Each -row's send-and-mark-sent is atomic and happens immediately after its own successful -send, not batched at the end, so a crash mid-poll can't leave an already-sent row -unmarked (which would cause a duplicate send next run). `OTLPSpanExporter()` takes no -explicit endpoint/headers here -- it falls back to the SDK's own standard -`OTEL_EXPORTER_OTLP_ENDPOINT`/`OTEL_EXPORTER_OTLP_HEADERS` env vars, or localhost:4317, -per the SDK's own default behavior. GlobalController sets those env vars (plus -`OTEL_EXPORTER_OTLP_PROTOCOL`, which this module reads itself below to pick the gRPC vs -HTTP class) from `global_controller.yaml`'s `otel:` section when it spawns this process; -this file has no YAML/app-config awareness of its own, only standard OTel env vars -- -see DESIGN.md. +"""Entrypoint for the OTLP exporter process. + +Each poll tick reads finished, not-yet-sent rows from ``waiting``, converts each to a +span, hands it to every configured BatchSpanProcessor, and marks it sent only after +all processors accept it. Batching, OTLP serialization, and sending remain the SDK's +responsibility (see DESIGN.md). + +GlobalController may provide a JSON list in ``VENTIS_OTEL_DESTINATIONS``. That is a +Ventis-specific configuration because the standard OTEL exporter environment +variables describe only one destination. If it is absent, the original single +destination behavior is retained: the exporter class and its settings are selected +from the standard OTEL environment variables and SDK defaults. """ +import json import logging +import math import os import signal import sqlite3 import time -# Protocol is the one thing the SDK's own exporter classes don't self-select from -# OTEL_EXPORTER_OTLP_PROTOCOL -- endpoint/headers/auth stay fully env-var-driven via -# each class's own defaults; see OTel_Exporter/DESIGN.md. -if os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").startswith("http"): - from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -else: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as GrpcOTLPSpanExporter, +) +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as HttpOTLPSpanExporter, +) from opentelemetry.sdk.trace.export import BatchSpanProcessor import convert @@ -38,7 +36,165 @@ _running = True _processor = None +_processors = [] POLL_INTERVAL_SECONDS = 5 +DESTINATIONS_ENV = "VENTIS_OTEL_DESTINATIONS" + + +def _normalize_protocol(protocol): + """Return the exporter family for a configured protocol name.""" + if not isinstance(protocol, str) or not protocol.strip(): + raise ValueError("destination protocol must be a non-empty string") + normalized = protocol.strip().lower().replace("_", "-") + if normalized in {"grpc", "otlp/grpc", "grpc/protobuf", "grpc-protobuf"}: + return "grpc" + if normalized in { + "http", + "http/protobuf", + "http-protobuf", + "http/proto", + "http+protobuf", + "protobuf", + }: + return "http" + raise ValueError( + f"unsupported destination protocol {protocol!r}; expected grpc or http/protobuf" + ) + + +def _validate_destination(destination, index): + if not isinstance(destination, dict): + raise ValueError(f"destination {index} must be an object") + + name = destination.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"destination {index} name must be a non-empty string") + + protocol = _normalize_protocol(destination.get("protocol")) + endpoint = destination.get("endpoint") + if not isinstance(endpoint, str) or not endpoint.strip(): + raise ValueError(f"destination {name!r} endpoint must be a non-empty string") + + headers = destination.get("headers") + if headers is not None: + if not isinstance(headers, dict): + raise ValueError(f"destination {name!r} headers must be an object") + if any( + not isinstance(key, str) + or not key.strip() + or not isinstance(value, str) + for key, value in headers.items() + ): + raise ValueError( + f"destination {name!r} headers must map non-empty strings to strings" + ) + headers = dict(headers) + + insecure = destination.get("insecure") + if insecure is not None and not isinstance(insecure, bool): + raise ValueError(f"destination {name!r} insecure must be a boolean") + + timeout = destination.get("timeout") + if timeout is not None: + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): + raise ValueError(f"destination {name!r} timeout must be a positive number") + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError(f"destination {name!r} timeout must be a positive number") + + return { + "name": name.strip(), + "protocol": protocol, + "endpoint": endpoint.strip(), + "headers": headers, + "insecure": insecure, + "timeout": timeout, + } + + +def _configured_destinations(): + """Parse and validate the Ventis multi-destination environment variable. + + ``None`` means no Ventis-specific configuration was supplied, so callers can + preserve legacy OTEL environment-variable behavior. An empty or malformed value + is an explicit configuration error and fails startup rather than silently + exporting to the wrong destination. + """ + raw = os.environ.get(DESTINATIONS_ENV) + if raw is None: + return None + try: + destinations = json.loads(raw) + except (TypeError, json.JSONDecodeError) as exc: + raise ValueError(f"{DESTINATIONS_ENV} must contain a JSON list") from exc + if not isinstance(destinations, list) or not destinations: + raise ValueError(f"{DESTINATIONS_ENV} must contain a non-empty JSON list") + + validated = [] + names = set() + for index, destination in enumerate(destinations): + validated_destination = _validate_destination(destination, index) + name = validated_destination["name"] + if name in names: + raise ValueError(f"destination names must be unique; duplicate {name!r}") + names.add(name) + validated.append(validated_destination) + return validated + + +def _build_exporter(destination): + """Construct one explicitly configured exporter without logging credentials.""" + kwargs = { + "endpoint": destination["endpoint"], + } + if destination["headers"] is not None: + kwargs["headers"] = destination["headers"] + if destination["timeout"] is not None: + kwargs["timeout"] = destination["timeout"] + + if destination["protocol"] == "grpc": + if destination["insecure"] is not None: + kwargs["insecure"] = destination["insecure"] + return GrpcOTLPSpanExporter(**kwargs) + + if destination["insecure"] is not None: + logger.warning( + "Destination %s specifies insecure=%s, which is ignored for HTTP exporters.", + destination["name"], + destination["insecure"], + ) + return HttpOTLPSpanExporter(**kwargs) + + +def _build_processors(): + """Build destination processors, or one legacy processor when unconfigured.""" + destinations = _configured_destinations() + if destinations is None: + protocol = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() + exporter_class = ( + HttpOTLPSpanExporter if protocol.startswith("http") else GrpcOTLPSpanExporter + ) + return [("legacy", BatchSpanProcessor(exporter_class(), schedule_delay_millis=1000))] + + processors = [] + try: + for destination in destinations: + exporter = _build_exporter(destination) + processors.append( + ( + destination["name"], + BatchSpanProcessor(exporter, schedule_delay_millis=1000), + ) + ) + logger.info( + "Configured OTel destination %s (%s).", + destination["name"], + destination["protocol"], + ) + except Exception: + for _, processor in processors: + processor.shutdown() + raise + return processors def _handle_shutdown(signum, frame): @@ -48,6 +204,14 @@ def _handle_shutdown(signum, frame): def _send_pending(): """Convert and send each finished, not-yet-sent waiting row.""" + processors = _processors + if not processors and _processor is not None: + # Compatibility for callers that configured the pre-fan-out singular + # ``_processor`` directly (the normal startup path always populates both). + processors = [("legacy", _processor)] + if not processors: + raise RuntimeError("OTel exporter has no configured processors") + conn = sqlite3.connect(db.DB_PATH) conn.row_factory = sqlite3.Row try: @@ -63,35 +227,63 @@ def _send_pending(): for row in rows: try: span = convert.waiting_row_to_span(row) - _processor.on_end(span) except Exception as e: logger.error( - "Skipping waiting row %s -- failed to send: %s", row["future_id"], e + "Skipping waiting row %s -- failed to convert: %s", row["future_id"], e ) continue + + failed_destinations = [] + for destination_name, processor in processors: + try: + processor.on_end(span) + except Exception as e: + # Still offer the span to the remaining processors. The row is only + # acknowledged when every destination accepted it, so a failed + # destination will be retried by the next poll. + failed_destinations.append(destination_name) + logger.error( + "Destination %s rejected waiting row %s: %s", + destination_name, + row["future_id"], + e, + ) + if failed_destinations: + continue db.mark_sent(row["future_id"]) sent_count += 1 - logger.info("Sent %d span(s) to the batch processor.", sent_count) + logger.info("Queued %d span(s) for all configured OTel destinations.", sent_count) def main(): - global _processor + global _processor, _processors signal.signal(signal.SIGTERM, _handle_shutdown) signal.signal(signal.SIGINT, _handle_shutdown) db.init_db() - _processor = BatchSpanProcessor(OTLPSpanExporter(), schedule_delay_millis=1000) - logger.info("OTel exporter process started.") - last_poll = 0 - while _running: - if time.time() - last_poll >= POLL_INTERVAL_SECONDS: + _processors = _build_processors() + # Keep the old singular module variable available to integrations that imported + # it, while all sending uses the destination-aware collection above. + _processor = _processors[0][1] + logger.info("OTel exporter process started with %d destination(s).", len(_processors)) + try: + last_poll = 0 + while _running: + if time.time() - last_poll >= POLL_INTERVAL_SECONDS: + try: + _send_pending() + except Exception as e: + logger.warning("Poll cycle failed (non-fatal): %s", e) + last_poll = time.time() + time.sleep(1) + finally: + for destination_name, processor in _processors: try: - _send_pending() + processor.shutdown() except Exception as e: - logger.warning("Poll cycle failed (non-fatal): %s", e) - last_poll = time.time() - time.sleep(1) - _processor.shutdown() - logger.info("OTel exporter process exiting.") + logger.error( + "Failed to shut down OTel destination %s: %s", destination_name, e + ) + logger.info("OTel exporter process exiting.") if __name__ == "__main__": diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index d61ad42..ab5af4d 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -78,6 +78,24 @@ agents: provider: EC2 instance_type: t3.micro + +otel: + destinations: + - name: railway + protocol: grpc + endpoint: yamanote.proxy.rlwy.net:19803 + insecure: true + headers: {} + - name: langfuse + protocol: http + endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces + headers: {} + - name: grafana + protocol: http + endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces + headers: + Authorization: Basic ${GRAFANA_OTLP_HEADERS} + # Polling interval in seconds poll_interval: 5 diff --git a/pyproject.toml b/pyproject.toml index 8410b24..9ae1234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,3 +58,8 @@ allowed-unresolved-imports = [ "*_stub", "*_agent_stub", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py new file mode 100644 index 0000000..ed1b423 --- /dev/null +++ b/tests/test_otel_exporter_fanout.py @@ -0,0 +1,318 @@ +"""Focused tests for the Ventis OTel exporter fan-out configuration.""" + +import json +import os +import sqlite3 +import sys +import tempfile +import types +import unittest +from unittest.mock import MagicMock, patch + + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +# ``otel_exporter.py`` is also executed as a script from its own directory and +# therefore imports ``convert`` and ``db`` as top-level modules. +sys.path.insert(0, os.path.join(ROOT, "OTel_Exporter")) + +import db # noqa: E402 +import otel_exporter # noqa: E402 + + +# The generated local-controller protobuf modules are build artifacts and are +# not present in a source checkout. The static config helper does not use them, +# so provide the tiny import-time surface needed to test it in isolation. +if "local_controler_pb2" not in sys.modules: + local_pb2 = types.ModuleType("local_controler_pb2") + local_pb2.JsonResponse = object + sys.modules["local_controler_pb2"] = local_pb2 +if "local_controler_pb2_grpc" not in sys.modules: + local_pb2_grpc = types.ModuleType("local_controler_pb2_grpc") + local_pb2_grpc.LocalControllerStub = object + sys.modules["local_controler_pb2_grpc"] = local_pb2_grpc + + +class OTelExporterFanoutTests(unittest.TestCase): + def setUp(self): + self.db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.db_path = self.db_file.name + self.db_file.close() + db.init_db(self.db_path) + + def tearDown(self): + os.unlink(self.db_path) + + @staticmethod + def _destination_config(): + return [ + { + "name": "railway", + "protocol": "grpc", + "endpoint": "receiver.example:4317", + "headers": {"x-api-key": "railway-key"}, + "insecure": True, + "timeout": 3.5, + }, + { + "name": "langfuse", + "protocol": "http/protobuf", + "endpoint": "https://langfuse.example/api/public/otel", + "headers": {"authorization": "Basic secret"}, + "timeout": 7, + }, + ] + + def test_build_processors_constructs_mixed_exporters_with_explicit_args(self): + grpc_exporter = object() + http_exporter = object() + grpc_processor = MagicMock(name="grpc_processor") + http_processor = MagicMock(name="http_processor") + destinations = self._destination_config() + + with patch.dict( + os.environ, + {otel_exporter.DESTINATIONS_ENV: json.dumps(destinations)}, + clear=True, + ), patch.object( + otel_exporter, + "GrpcOTLPSpanExporter", + return_value=grpc_exporter, + ) as grpc_constructor, patch.object( + otel_exporter, + "HttpOTLPSpanExporter", + return_value=http_exporter, + ) as http_constructor, patch.object( + otel_exporter, + "BatchSpanProcessor", + side_effect=[grpc_processor, http_processor], + ) as processor_constructor: + processors = otel_exporter._build_processors() + + self.assertEqual( + processors, [("railway", grpc_processor), ("langfuse", http_processor)] + ) + grpc_constructor.assert_called_once_with( + endpoint="receiver.example:4317", + headers={"x-api-key": "railway-key"}, + timeout=3.5, + insecure=True, + ) + http_constructor.assert_called_once_with( + endpoint="https://langfuse.example/api/public/otel", + headers={"authorization": "Basic secret"}, + timeout=7, + ) + self.assertEqual( + processor_constructor.call_args_list, + [ + unittest.mock.call(grpc_exporter, schedule_delay_millis=1000), + unittest.mock.call(http_exporter, schedule_delay_millis=1000), + ], + ) + + def test_build_processors_preserves_legacy_single_destination_fallback(self): + http_exporter = object() + processor = MagicMock(name="legacy_processor") + with patch.dict( + os.environ, + {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"}, + clear=True, + ), patch.object( + otel_exporter, "HttpOTLPSpanExporter", return_value=http_exporter + ) as constructor, patch.object( + otel_exporter, "BatchSpanProcessor", return_value=processor + ): + result = otel_exporter._build_processors() + + self.assertEqual(result, [("legacy", processor)]) + constructor.assert_called_once_with() + + def test_configured_destinations_rejects_malformed_empty_and_duplicate_values(self): + invalid_values = [ + "not-json", + json.dumps([]), + json.dumps( + [ + { + "name": "same", + "protocol": "grpc", + "endpoint": "one:4317", + }, + { + "name": "same", + "protocol": "http/protobuf", + "endpoint": "https://two", + }, + ] + ), + ] + for raw in invalid_values: + with self.subTest(raw=raw), patch.dict( + os.environ, {otel_exporter.DESTINATIONS_ENV: raw}, clear=True + ): + with self.assertRaises(ValueError): + otel_exporter._configured_destinations() + + def test_controller_expands_env_and_builds_langfuse_basic_auth(self): + from ventis.controller.global_controller import GlobalController + + with patch.dict( + os.environ, + { + "LANGFUSE_BASE_URL": "https://us.cloud.langfuse.com", + "LANGFUSE_PUBLIC_KEY": "public", + "LANGFUSE_SECRET_KEY": "secret", + }, + clear=True, + ): + env = GlobalController._otel_exporter_env( + { + "destinations": [ + { + "name": "langfuse", + "protocol": "http/protobuf", + "endpoint": "${LANGFUSE_BASE_URL}/api/public/otel/v1/traces", + } + ] + } + ) + + destination = json.loads(env[otel_exporter.DESTINATIONS_ENV])[0] + self.assertEqual( + destination["endpoint"], + "https://us.cloud.langfuse.com/api/public/otel/v1/traces", + ) + self.assertEqual(destination["headers"]["Authorization"], "Basic cHVibGljOnNlY3JldA==") + + def test_controller_env_serializes_destinations_and_keeps_legacy_mapping(self): + # Importing the controller is intentionally local: this test remains + # runnable in the exporter-only environment used by the focused suite. + from ventis.controller.global_controller import GlobalController + + destinations = self._destination_config() + env = GlobalController._otel_exporter_env( + { + "protocol": "grpc", + "endpoint": "legacy.example:4317", + "headers": {"x-tenant": "demo"}, + "destinations": destinations, + } + ) + self.assertEqual(env["OTEL_EXPORTER_OTLP_PROTOCOL"], "grpc") + self.assertEqual(env["OTEL_EXPORTER_OTLP_ENDPOINT"], "legacy.example:4317") + self.assertEqual(env["OTEL_EXPORTER_OTLP_HEADERS"], "x-tenant=demo") + self.assertEqual(json.loads(env[otel_exporter.DESTINATIONS_ENV]), destinations) + + def test_controller_rejects_invalid_destinations_before_starting_child(self): + from ventis.controller.global_controller import GlobalController + + invalid_destinations = [ + [], + [{"name": "railway", "protocol": "grpc"}], + [ + {"name": "same", "protocol": "grpc", "endpoint": "one:4317"}, + { + "name": "same", + "protocol": "http/protobuf", + "endpoint": "https://two", + }, + ], + [{"name": "bad", "protocol": "smtp", "endpoint": "example"}], + ] + for destinations in invalid_destinations: + with self.subTest(destinations=destinations), self.assertRaises(ValueError): + GlobalController._otel_exporter_env( + {"destinations": destinations} + ) + + def _insert_pending_row(self): + conn = sqlite3.connect(self.db_path) + try: + conn.execute( + """ + INSERT INTO waiting ( + future_id, session_id, started_at, finished_at, failed, + name, input, output, sent + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0) + """, + ( + "00112233445566778899aabbccddeeff", + "ffeeddccbbaa99887766554433221100", + 1.0, + 2.0, + 0, + "PriceAgent.get_history", + '{"ticker":"NVDA"}', + '{"price":100}', + ), + ) + conn.commit() + finally: + conn.close() + + def test_send_pending_delivers_the_same_span_to_every_processor(self): + self._insert_pending_row() + first = MagicMock(name="first") + second = MagicMock(name="second") + with patch.object(otel_exporter.db, "DB_PATH", self.db_path), patch.object( + otel_exporter.db, "mark_sent" + ) as mark_sent: + otel_exporter._processors = [("railway", first), ("langfuse", second)] + otel_exporter._processor = None + otel_exporter._send_pending() + + first.on_end.assert_called_once() + second.on_end.assert_called_once() + self.assertIs(first.on_end.call_args.args[0], second.on_end.call_args.args[0]) + mark_sent.assert_called_once_with("00112233445566778899aabbccddeeff") + + def test_send_pending_attempts_remaining_processors_and_leaves_row_unsent_on_failure(self): + self._insert_pending_row() + failed = MagicMock(name="failed") + failed.on_end.side_effect = RuntimeError("destination unavailable") + remaining = MagicMock(name="remaining") + with patch.object(otel_exporter.db, "DB_PATH", self.db_path), patch.object( + otel_exporter.db, "mark_sent" + ) as mark_sent: + otel_exporter._processors = [("railway", failed), ("langfuse", remaining)] + otel_exporter._processor = None + otel_exporter._send_pending() + + failed.on_end.assert_called_once() + remaining.on_end.assert_called_once() + mark_sent.assert_not_called() + + conn = sqlite3.connect(self.db_path) + try: + self.assertEqual(conn.execute("SELECT sent FROM waiting").fetchone()[0], 0) + finally: + conn.close() + + def test_processor_construction_failure_shuts_down_already_built_processors(self): + first_processor = MagicMock(name="first_processor") + destinations = self._destination_config() + with patch.dict( + os.environ, + {otel_exporter.DESTINATIONS_ENV: json.dumps(destinations)}, + clear=True, + ), patch.object( + otel_exporter, + "GrpcOTLPSpanExporter", + return_value=object(), + ), patch.object( + otel_exporter, + "HttpOTLPSpanExporter", + side_effect=RuntimeError("bad HTTP exporter"), + ), patch.object( + otel_exporter, + "BatchSpanProcessor", + return_value=first_processor, + ): + with self.assertRaisesRegex(RuntimeError, "bad HTTP exporter"): + otel_exporter._build_processors() + + first_processor.shutdown.assert_called_once_with() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py new file mode 100644 index 0000000..4a62c58 --- /dev/null +++ b/tests/test_otel_exporter_fields.py @@ -0,0 +1,99 @@ +import json +import os +import sqlite3 +import tempfile +import unittest +from unittest.mock import patch + +from OTel_Exporter import convert, db + + +class OTelExporterFieldTests(unittest.TestCase): + def setUp(self): + handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False) + self.db_path = handle.name + handle.close() + + def tearDown(self): + os.unlink(self.db_path) + + def test_init_db_migrates_existing_waiting_table(self): + with sqlite3.connect(self.db_path) as conn: + conn.execute( + "CREATE TABLE waiting (future_id TEXT PRIMARY KEY, session_id TEXT NOT NULL)" + ) + + db.init_db(self.db_path) + + with sqlite3.connect(self.db_path) as conn: + columns = { + row[1] for row in conn.execute("PRAGMA table_info(waiting)").fetchall() + } + self.assertTrue({"name", "input", "output"}.issubset(columns)) + + def test_fields_are_normalized_and_added_to_span(self): + db.init_db(self.db_path) + raw = { + "future_id": "00112233445566778899aabbccddeeff", + "request_id": "ffeeddccbbaa99887766554433221100", + "service": "PriceAgent", + "method": "get_history", + "args": '{"ticker": "NVDA"}', + "result": "plain text result", + "created_at": "1.0", + "finished_at": "2.0", + "failed": "0", + } + + with patch.object(db.pricing, "compute_token_cost", return_value=0.0): + db.write_waiting_rows([raw], db_path=self.db_path) + + with sqlite3.connect(self.db_path) as conn: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM waiting").fetchone() + + self.assertEqual(row["name"], "PriceAgent.get_history") + self.assertEqual(row["input"], raw["args"]) + self.assertEqual(json.loads(row["output"]), raw["result"]) + + span = convert.waiting_row_to_span(row) + self.assertEqual(span.name, "PriceAgent.get_history") + self.assertEqual(span.attributes["langfuse.observation.input"], raw["args"]) + self.assertEqual( + span.attributes["langfuse.observation.output"], row["output"] + ) + + def test_split_hash_result_is_loaded_for_finished_rows(self): + class SplitHashRedis: + def hget(self, key, field): + self.request = (key, field) + return '{"recommendation": "hold"}' + + def get(self, key): + return None + + db.init_db(self.db_path) + redis = SplitHashRedis() + raw = { + "future_id": "11112222333344445555666677778888", + "request_id": "88887777666655554444333322221111", + "service": "AdvisorAgent", + "method": "summarize", + "args": '{"risk": "moderate"}', + "result": "", + "created_at": "1.0", + "finished_at": "2.0", + "failed": "0", + } + + with patch.object(db.pricing, "compute_token_cost", return_value=0.0): + db.write_waiting_rows([raw], redis_client=redis, db_path=self.db_path) + + with sqlite3.connect(self.db_path) as conn: + output = conn.execute("SELECT output FROM waiting").fetchone()[0] + self.assertEqual(redis.request, (f"future:{raw['future_id']}", "result")) + self.assertEqual(json.loads(output), {"recommendation": "hold"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index ba10707..9b86805 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "flask" version = "3.1.3" @@ -478,6 +490,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -692,6 +713,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/0e/49df70d9b81fb5cbae4bbf2a49d865b09bcbcbc4eb53f5851b1027738d78/opentelemetry_semantic_conventions-0.65b0-py3-none-any.whl", hash = "sha256:1cacde7b0ad306f84c5ef08c3dbe1bbaf20165bba6f8bff43b670e555a086bcb", size = 204645, upload-time = "2026-07-16T15:25:30.688Z" }, ] +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "protobuf" version = "7.35.1" @@ -815,6 +854,33 @@ 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 = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1003,6 +1069,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1050,6 +1170,11 @@ dependencies = [ { name = "sqlalchemy" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "boto3" }, @@ -1067,6 +1192,9 @@ requires-dist = [ { name = "sqlalchemy" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "werkzeug" version = "3.1.8" diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index b84614b..9947680 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -3,15 +3,18 @@ # Periodically polls Redis to check controller health and updates the routing table. import atexit +import base64 import importlib.util +import json import logging +import math +import os +import re import signal import subprocess +import sys import threading import time -import json -import sys -import os from concurrent.futures import ThreadPoolExecutor import yaml @@ -102,7 +105,8 @@ def __init__(self, config_path): len(self.controllers), ) - # Start background cleanup thread + # Start background cleanup thread, woken by each poll tick rather than its own timer -- see OTel_Exporter/DESIGN.md. + self._cleanup_ready = threading.Event() self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() @@ -114,22 +118,23 @@ def __init__(self, config_path): ) otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() - # `otel:` in global_controller.yaml maps straight to the OTel SDK's own - # standard env vars, not app-specific args -- the exporter subprocess itself - # stays a plain vendor-neutral OTel process; see OTel_Exporter/DESIGN.md. + # Legacy `otel:` fields map straight to the OTel SDK's own standard env vars. + # A `destinations` list is additionally passed as one Ventis-specific JSON + # variable; the exporter subprocess remains a plain OTel process otherwise. otel_env = self._otel_exporter_env(self.config.get("otel", {})) self.process_supervisor.register( "otel_exporter", [sys.executable, otel_exporter_script], env=otel_env ) - self.process_supervisor.start_all() - # waiting table GC writes future data into (see OTel_Exporter/db.py); the - # exporter process itself calls init_db() to create the table. + # Initialize/migrate the waiting table synchronously before either the GC or + # exporter process can access it. otel_db_spec = importlib.util.spec_from_file_location( "otel_queue_db", os.path.join(otel_exporter_dir, "db.py") ) self._otel_db = importlib.util.module_from_spec(otel_db_spec) otel_db_spec.loader.exec_module(self._otel_db) + self._otel_db.init_db() + self.process_supervisor.start_all() # ------------------------------------------------------------------ # # Stale container cleanup # @@ -177,15 +182,50 @@ def _cleanup_stale_containers(self): @staticmethod def _load_config(config_path): - """Load the YAML config file.""" + """Load the YAML config file after importing root .env values.""" + project_root = os.path.abspath(os.path.join(os.path.dirname(config_path), "..")) + GlobalController._load_dotenv(os.path.join(project_root, ".env")) with open(config_path, "r") as f: return yaml.safe_load(f) + @staticmethod + def _load_dotenv(path): + """Load simple KEY=VALUE entries without overriding existing environment values.""" + if not os.path.isfile(path): + return + with open(path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1] + if key and key not in os.environ: + os.environ[key] = value + + @staticmethod + def _expand_otel_value(value): + if isinstance(value, str): + return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), m.group(0)), value) + if isinstance(value, dict): + return {key: GlobalController._expand_otel_value(item) for key, item in value.items()} + if isinstance(value, list): + return [GlobalController._expand_otel_value(item) for item in value] + return value + @staticmethod def _otel_exporter_env(otel_cfg): """Translate global_controller.yaml's `otel:` section into standard OTLP env - vars for the exporter subprocess; returns None if `otel:` is absent/empty so - the subprocess falls back to the SDK's own defaults untouched.""" + vars for the exporter subprocess. When present, ``destinations`` is passed as + JSON for the exporter to construct a fan-out. Returns None if `otel:` is + absent/empty so the subprocess falls back to the SDK's own defaults untouched. + + The legacy protocol/endpoint/headers mappings intentionally remain unchanged + for existing configurations. + """ env = {} if otel_cfg.get("protocol"): env["OTEL_EXPORTER_OTLP_PROTOCOL"] = otel_cfg["protocol"] @@ -195,8 +235,109 @@ def _otel_exporter_env(otel_cfg): env["OTEL_EXPORTER_OTLP_HEADERS"] = ",".join( f"{k}={v}" for k, v in otel_cfg["headers"].items() ) + + if "destinations" in otel_cfg: + destinations = GlobalController._expand_otel_value(otel_cfg["destinations"]) + for destination in destinations: + if destination.get("name") == "langfuse": + public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") + secret_key = os.environ.get("LANGFUSE_SECRET_KEY") + if public_key and secret_key: + auth = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode() + destination.setdefault("headers", {})["Authorization"] = f"Basic {auth}" + GlobalController._validate_otel_destinations(destinations) + try: + env["VENTIS_OTEL_DESTINATIONS"] = json.dumps(destinations) + except (TypeError, ValueError) as exc: + # Do not include the offending value: destination configs commonly + # contain credentials in headers. + raise ValueError( + "otel.destinations must contain JSON-serializable values" + ) from exc return env or None + @staticmethod + def _validate_otel_destinations(destinations): + """Validate the shape of the optional exporter fan-out configuration. + + Keep this validation deliberately structural: destination-specific options + are interpreted by the exporter. Error messages identify only the location + and type, never destination contents or header values. + """ + if not isinstance(destinations, list): + raise ValueError("otel.destinations must be a list") + if not destinations: + raise ValueError("otel.destinations must not be empty") + + names = set() + for index, destination in enumerate(destinations): + if not isinstance(destination, dict): + raise ValueError( + f"otel.destinations[{index}] must be a mapping" + ) + + for field in ("name", "protocol", "endpoint"): + value = destination.get(field) + if not isinstance(value, str) or not value.strip(): + raise ValueError( + f"otel.destinations[{index}].{field} must be a non-empty string" + ) + + name = destination["name"].strip() + if name in names: + raise ValueError(f"otel.destinations contains duplicate name {name!r}") + names.add(name) + + protocol = destination["protocol"].strip().lower().replace("_", "-") + if protocol not in { + "grpc", + "otlp/grpc", + "grpc/protobuf", + "grpc-protobuf", + "http", + "http/protobuf", + "http-protobuf", + "http/proto", + "http+protobuf", + "protobuf", + }: + raise ValueError( + f"otel.destinations[{index}].protocol must be grpc or http/protobuf" + ) + + if "headers" in destination: + headers = destination["headers"] + if not isinstance(headers, dict): + raise ValueError( + f"otel.destinations[{index}].headers must be a mapping" + ) + if any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in headers.items() + ): + raise ValueError( + f"otel.destinations[{index}].headers keys and values must be strings" + ) + + if "insecure" in destination and not isinstance( + destination["insecure"], bool + ): + raise ValueError( + f"otel.destinations[{index}].insecure must be a boolean" + ) + + if "timeout" in destination: + timeout = destination["timeout"] + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(timeout) + or timeout <= 0 + ): + raise ValueError( + f"otel.destinations[{index}].timeout must be a positive number" + ) + @staticmethod def _get_replica_placements(ctrl): """Normalize replicas into a list of (host, port) placements.""" @@ -479,6 +620,7 @@ def run(self): self._poll_controllers() except Exception as e: logger.warning("Polling loop encountered an error: %s", e) + self._cleanup_ready.set() time.sleep(self.poll_interval) except KeyboardInterrupt: self.stop() @@ -638,9 +780,10 @@ def _get_lc_stub(self, endpoint): return self._lc_stubs[endpoint] def _cleanup_loop(self): - """Background thread: periodically trigger cleanup of completed requests.""" + """Background thread: trigger cleanup right after each poll tick, or every cleanup_interval as a fallback.""" while True: - time.sleep(self.cleanup_interval) + self._cleanup_ready.wait(timeout=self.cleanup_interval) + self._cleanup_ready.clear() try: self._trigger_cleanup() except Exception as e: From 098548c60ee297032ba7cb7c3074726955917dd3 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 18:24:08 -0700 Subject: [PATCH 09/44] Dedupe 'import os' from PR #51 merge (both sides added it independently) Co-Authored-By: Claude Sonnet 5 --- examples/portfolio/agents/metrics_agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index d5f722e..28069ac 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -10,7 +10,6 @@ # Resource profile: cheap CPU, high fan-out — one compute() call per holding. import os import sys -import os import json import math From 0b9546cbe324a99b34589e5e5b497cdad09951e4 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 18:49:27 -0700 Subject: [PATCH 10/44] Fix PR #51 regression: disable entrypoint-based stub relocation This project's agents/workflow import each other's stubs by flat module name, not by the exporting agent's own entrypoint path. Applying _stub_destination's entrypoint-mirroring broke both the Workflow (ModuleNotFoundError: intent_agent) and agent-to-agent calls (MetricsAgent -> price_agent) on live redeploy. Keeps PR #51's actual fix (project_dir sweep for unstubbed helper files) intact. Co-Authored-By: Claude Sonnet 5 --- ventis/cli.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/ventis/cli.py b/ventis/cli.py index 28c4fd7..920df15 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -229,8 +229,8 @@ def cmd_build(args): logger.warning("No agent YAML files found in %s", agents_dir) import yaml - - # Looks up a config entry's YAML and to map stubs to entrypoints. + + # Looks up a config entry's YAML by agent name. yaml_by_name = {} for yaml_path in yaml_files: with open(yaml_path) as f: @@ -238,13 +238,6 @@ def cmd_build(args): if name: yaml_by_name[name] = yaml_path - entrypoints_by_name = {a["name"]: a.get("entrypoint") for a in agents} - stub_entrypoints = { - f"{os.path.splitext(os.path.basename(p))[0]}.py": entrypoints_by_name[n] - for n, p in yaml_by_name.items() - if entrypoints_by_name.get(n) - } - stub_paths = [] for yaml_path in yaml_files: base_name = os.path.splitext(os.path.basename(yaml_path))[0] @@ -309,7 +302,9 @@ def cmd_build(args): api_port=agent_cfg.get("api_port", 8080), requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - stub_entrypoints=stub_entrypoints, + # A workflow script imports stubs by flat module name (e.g. `from + # intent_agent import ...`), not by the agent's own entrypoint path. + stub_entrypoints=None, ) else: @@ -345,7 +340,9 @@ def cmd_build(args): stub_files=stub_paths, requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - stub_entrypoints=stub_entrypoints, + # Same reasoning as the workflow call above: this project's agents + # import each other's stubs by flat module name, not entrypoint path. + stub_entrypoints=None, ) bake_targets.append( From 7b3b167b27e9e8889edf099de517dc82f5662a13 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 26 Aug 2026 21:40:17 -0700 Subject: [PATCH 11/44] Parallelize per-instance polling in _poll_controllers Metrics/telemetry latency scaled with instance count x per-instance round-trip time since every instance was polled sequentially, one blocking the next, with the following tick only starting after the whole pass finished. Extracted the per-instance body into _poll_one_instance (whole body wrapped in one top-level try/except, since ThreadPoolExecutor.map() re-raises on first exception when results are consumed) and run all instances concurrently via the same ThreadPoolExecutor pattern _trigger_cleanup already used. Co-Authored-By: Claude Sonnet 5 --- OTel_Exporter/DESIGN.md | 16 +- ventis/controller/global_controller.py | 217 +++++++++++++------------ 2 files changed, 130 insertions(+), 103 deletions(-) diff --git a/OTel_Exporter/DESIGN.md b/OTel_Exporter/DESIGN.md index 86a2a95..418ca2a 100644 --- a/OTel_Exporter/DESIGN.md +++ b/OTel_Exporter/DESIGN.md @@ -167,7 +167,21 @@ Cleanup stays on its own thread (the event's `wait(timeout=cleanup_interval)` is fallback, not the primary trigger) so a slow/hung instance during cleanup can't stall the poll loop's health checks and OTel writes. -### 6. Dependencies (all added) +### 6. Parallelized per-instance polling (`ventis/controller/global_controller.py`) +`_poll_controllers` used to loop over every instance sequentially -- Redis reads, an +OTel sqlite write, and up to two Postgres writes per instance, one instance fully +blocking the next, with the following poll tick only starting after the whole pass +finished. Total metrics/telemetry latency scaled with instance count x round-trip +time, not the configured `poll_interval`. Fixed by extracting the per-instance body +into `_poll_one_instance` (its whole body wrapped in one top-level try/except, since +`ThreadPoolExecutor.map()` re-raises on first exception when results are consumed) +and running all instances concurrently via the same `ThreadPoolExecutor` pattern +`_trigger_cleanup` already used. Known, pre-existing, previously acknowledged in +commit `a6694d9`'s own message but never actually fixed (a same-named follow-up +branch was found to contain no real threading changes) -- see company-memory for +the investigation. + +### 7. Dependencies (all added) `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, `opentelemetry-exporter-otlp-proto-http` (the last one added alongside the `otel:` config work, since `protocol: http` now needs that package importable). diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index bdec683..da29783 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -643,118 +643,131 @@ def _poll_controllers(self): if self.running: self.process_supervisor.check_and_respawn() - for instance in self.instance_manager.list_instances(): + # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer + # gates every other instance's poll -- see OTel_Exporter/DESIGN.md. + instances = self.instance_manager.list_instances() + if instances: + with ThreadPoolExecutor(max_workers=len(instances)) as executor: + list(executor.map(self._poll_one_instance, instances)) + + def _poll_one_instance(self, instance): + """Poll and persist one instance's runtime/metrics/health data; never raises.""" + try: name = instance["agent_name"] host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) - try: - future_rows = pull_runtime_information(node_redis) - self._otel_db.write_waiting_rows( - future_rows, node_redis, self.config.get("project_id", 0) - ) - send_runtime_information( - future_rows, - node_redis, - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write runtime information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, + except Exception as e: + logger.warning("Failed to poll instance %s: %s", instance, e) + return + + try: + future_rows = pull_runtime_information(node_redis) + self._otel_db.write_waiting_rows( + future_rows, node_redis, self.config.get("project_id", 0) + ) + send_runtime_information( + future_rows, + node_redis, + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write runtime information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + agent_host = self._agent_host_key(host) + status_key = f"controller:{agent_host}:{port}:status" + metrics_key = f"controller:{agent_host}:{port}:metrics" + + # Getting metrics from local controllers + # See LocalController._execute_locally + try: + metrics = node_redis.hgetall(metrics_key) + if metrics: + now = time.time() + requests_served = int(float(metrics.get("requests_served") or 0)) + elapsed = now - self._last_metrics_poll_time.get( + (host, port), now - self.poll_interval ) - agent_host = self._agent_host_key(host) - status_key = f"controller:{agent_host}:{port}:status" - metrics_key = f"controller:{agent_host}:{port}:metrics" + throughput = requests_served / elapsed if elapsed > 0 else 0.0 + self._last_metrics_poll_time[(host, port)] = now - # Getting metrics from local controllers - # See LocalController._execute_locally - try: - metrics = node_redis.hgetall(metrics_key) - if metrics: - now = time.time() - requests_served = int(float(metrics.get("requests_served") or 0)) - elapsed = now - self._last_metrics_poll_time.get( - (host, port), now - self.poll_interval + try: + send_agent_information( + [ + { + **instance, + **metrics, + "requests_served": requests_served, + "throughput": throughput, + } + ], + self.config.get("database", {}).get("url"), ) - throughput = requests_served / elapsed if elapsed > 0 else 0.0 - self._last_metrics_poll_time[(host, port)] = now - - try: - send_agent_information( - [ - { - **instance, - **metrics, - "requests_served": requests_served, - "throughput": throughput, - } - ], - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write agent information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, - ) - else: - # Only clear the accumulated counters once they've actually been persisted - node_redis.hset_multiple( - metrics_key, - {"full_failures": 0, "error_count": 0, "requests_served": 0}, - ) - except Exception as e: - logger.warning( - "Failed to poll metrics for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + except Exception as e: + logger.warning( + "Failed to write agent information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + else: + # Only clear the accumulated counters once they've actually been persisted + node_redis.hset_multiple( + metrics_key, + {"full_failures": 0, "error_count": 0, "requests_served": 0}, + ) + except Exception as e: + logger.warning( + "Failed to poll metrics for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) - try: - status = node_redis.get(status_key) or "unknown" - prev = self._last_status.get((host, port)) + try: + status = node_redis.get(status_key) or "unknown" + prev = self._last_status.get((host, port)) - if status != prev: - if status == "healthy": - logger.info( - "Controller %s (%s:%s) is now healthy.", name, host, port - ) - self._on_controller_healthy(name, host, port) - else: - logger.warning( - "Controller %s (%s:%s) status changed: %s -> %s", - name, - host, - port, - prev or "(none)", - status, - ) - self._on_controller_unhealthy(name, host, port) - self._last_status[(host, port)] = status + if status != prev: + if status == "healthy": + logger.info( + "Controller %s (%s:%s) is now healthy.", name, host, port + ) + self._on_controller_healthy(name, host, port) else: - # No change — healthy stays quiet, unhealthy stays quiet too - if status == "healthy": - self._on_controller_healthy(name, host, port) - else: - self._on_controller_unhealthy(name, host, port) - except Exception as e: - logger.warning( - "Failed to poll status for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + logger.warning( + "Controller %s (%s:%s) status changed: %s -> %s", + name, + host, + port, + prev or "(none)", + status, + ) + self._on_controller_unhealthy(name, host, port) + self._last_status[(host, port)] = status + else: + # No change — healthy stays quiet, unhealthy stays quiet too + if status == "healthy": + self._on_controller_healthy(name, host, port) + else: + self._on_controller_unhealthy(name, host, port) + except Exception as e: + logger.warning( + "Failed to poll status for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) # ------------------------------------------------------------------ # # Extensibility hooks — override in subclasses # From b5d6e4dcbc6aee7265e1dc01080f70da39eb90f2 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 28 Aug 2026 13:58:05 -0700 Subject: [PATCH 12/44] rough draft --- .../portfolio/config/global_controller.yaml | 25 +++++---- .../portfolio/workflow/portfolio_workflow.py | 1 - pyproject.toml | 2 +- requirements.txt | 2 - tests/test_otel_exporter_fanout.py | 2 +- tests/test_otel_exporter_fields.py | 2 +- .../OTLP_Exporter}/DESIGN.md | 6 +- ventis/OTLP_Exporter/SCHEMA.md | 56 +++++++++++++++++++ .../OTLP_Exporter}/__init__.py | 0 .../OTLP_Exporter}/convert.py | 24 ++++++-- {OTel_Exporter => ventis/OTLP_Exporter}/db.py | 4 +- .../OTLP_Exporter}/otel_exporter.py | 0 ventis/OTLP_Exporter/otel_queue.db | 0 ventis/cli.py | 21 +++++-- .../cloud_provider_logic/EC2/_runtime.py | 5 +- ventis/controller/global_controller.py | 27 +++++---- ventis/controller/utils/process_supervisor.py | 2 +- ventis/stub_generator.py | 40 +++++++++++-- 18 files changed, 167 insertions(+), 52 deletions(-) rename {OTel_Exporter => ventis/OTLP_Exporter}/DESIGN.md (98%) create mode 100644 ventis/OTLP_Exporter/SCHEMA.md rename {OTel_Exporter => ventis/OTLP_Exporter}/__init__.py (100%) rename {OTel_Exporter => ventis/OTLP_Exporter}/convert.py (72%) rename {OTel_Exporter => ventis/OTLP_Exporter}/db.py (98%) rename {OTel_Exporter => ventis/OTLP_Exporter}/otel_exporter.py (100%) create mode 100644 ventis/OTLP_Exporter/otel_queue.db diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index ab5af4d..edee85f 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -83,18 +83,18 @@ otel: destinations: - name: railway protocol: grpc - endpoint: yamanote.proxy.rlwy.net:19803 + endpoint: ${RAILWAY_OTLP_ENDPOINT} insecure: true headers: {} - - name: langfuse - protocol: http - endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces - headers: {} - name: grafana protocol: http endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces headers: Authorization: Basic ${GRAFANA_OTLP_HEADERS} +# - name: langfuse +# protocol: http +# endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces +# headers: {} # Polling interval in seconds poll_interval: 5 @@ -107,10 +107,13 @@ redis: # EC2 defaults for `provider: EC2` replicas. ec2: - region: us-east-1 - ami_id: ami-031ff6df47f26b546 - subnet_id: subnet-0638ac6d79d488124 + region: ${EC2_REGION} + ami_id: ${EC2_AMI_ID} + subnet_id: ${EC2_SUBNET_ID} security_group_ids: - - sg-025daf3a98e06cef3 - ssh_user: ubuntu - ssh_private_key_path: ~/.ssh/ventis_ec2 + - ${EC2_SECURITY_GROUP_ID} + 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 299a3f5..b8b684a 100644 --- a/examples/portfolio/workflow/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -46,7 +46,6 @@ def main( advisor = AdvisorAgent() # Stage 0: parse the free-text request into structured holdings + window. - intent = intent_agent.parse(query=query).value() # parse() returns a dict, but a Future's .value() only ever gives back the # raw string ventis stored in Redis -- same deserialization requirement as # every other dict-returning agent call below. diff --git a/pyproject.toml b/pyproject.toml index 9ae1234..40cb43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ requires = ["setuptools>=64"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["ventis*", "OTel_Exporter*"] +include = ["ventis*"] [tool.setuptools.package-data] ventis = [ diff --git a/requirements.txt b/requirements.txt index dd7a254..f06e0b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,6 @@ grpcio-tools redis pyyaml flask -ipdb -ipython sqlalchemy psycopg[binary] psutil diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index ed1b423..0691d61 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -13,7 +13,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) # ``otel_exporter.py`` is also executed as a script from its own directory and # therefore imports ``convert`` and ``db`` as top-level modules. -sys.path.insert(0, os.path.join(ROOT, "OTel_Exporter")) +sys.path.insert(0, os.path.join(ROOT, "ventis", "OTLP_Exporter")) import db # noqa: E402 import otel_exporter # noqa: E402 diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py index 4a62c58..ff8cad6 100644 --- a/tests/test_otel_exporter_fields.py +++ b/tests/test_otel_exporter_fields.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from OTel_Exporter import convert, db +from ventis.OTLP_Exporter import convert, db class OTelExporterFieldTests(unittest.TestCase): diff --git a/OTel_Exporter/DESIGN.md b/ventis/OTLP_Exporter/DESIGN.md similarity index 98% rename from OTel_Exporter/DESIGN.md rename to ventis/OTLP_Exporter/DESIGN.md index 418ca2a..d433fe0 100644 --- a/OTel_Exporter/DESIGN.md +++ b/ventis/OTLP_Exporter/DESIGN.md @@ -48,7 +48,7 @@ Decisions (final status): Configuration is read at exporter startup; changing it requires a GlobalController/exporter restart. - **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own - SQLite file (`OTel_Exporter/otel_queue.db`, see `db.py`), written by GC's existing + SQLite file (`ventis/OTLP_Exporter/otel_queue.db`, see `db.py`), written by GC's existing `_poll_controllers` *alongside* (not instead of) the existing `send_runtime_information` write. Keeps this pipeline's schema/state fully decoupled from the dashboard/cost table. @@ -95,7 +95,7 @@ endpoint and headers to the SDK. `BatchSpanProcessor(..., schedule_delay_millis= `max_export_batch_size` is left at the SDK default (512), which already approximates the original "500 spans" batching ask without any override needed. -### 2. `OTel_Exporter/otel_exporter.py` +### 2. `ventis/OTLP_Exporter/otel_exporter.py` A plain loop, polling every `POLL_INTERVAL_SECONDS` (5s, checked every 1s so SIGTERM stays responsive), calling `_send_pending()` each tick. At startup it constructs one independent OTLP exporter and `BatchSpanProcessor` for each configured destination; @@ -111,7 +111,7 @@ each pair may use a different protocol, endpoint, and headers: since spans are hand-built and handed straight to the processors via `on_end()`. - Every processor is shut down on exit, flushing its pending batch independently. -### 3. Future row → OTel span conversion (`OTel_Exporter/convert.py`) +### 3. Future row → OTel span conversion (`ventis/OTLP_Exporter/convert.py`) `future_id` maps to OTel `span_id`, not `trace_id` — `session_id` (== `request_id`) is the one that maps to `trace_id`. Both are `uuid4().hex` (32 hex chars / 16 bytes); OTel `trace_id` is 128-bit (16 bytes, fits directly) and `span_id` is 64-bit (8 bytes, needs diff --git a/ventis/OTLP_Exporter/SCHEMA.md b/ventis/OTLP_Exporter/SCHEMA.md new file mode 100644 index 0000000..38f8664 --- /dev/null +++ b/ventis/OTLP_Exporter/SCHEMA.md @@ -0,0 +1,56 @@ +# OTel span storage schema + +Each emitted OTel span is stored as one `otel_spans` record. This chart also defines a +one-to-one `otel_span_attributes` projection, linked by the same `span_id`, for the +known exporter attributes. The current receiver retains the raw `attributes` JSONB map; +the child table is the relational schema described in `otel_spans_schema.txt`. + +```text +┌───────────────────────────┐ ┌────────────────────────────────┐ +│ otel_spans │ │ otel_span_attributes │ +├───────────────────────────┤ ├────────────────────────────────┤ +│ PK span_id │────1:1───│ PK/FK span_id │ +│ trace_id │ │ model and agent ID │ +│ parent_span_id │ │ CPU and GPU │ +│ name │ │ timing and token usage │ +│ kind │ │ input and output │ +│ start/end time (ns) │ │ project and error count │ +│ status code/message │ │ server/token/total cost │ +│ attributes (JSONB) │ │ cache tokens/hit ratio │ +│ events (JSONB) │ └────────────────────────────────┘ +└───────────────────────────┘ +``` + +## `otel_spans` + +| Column | Meaning | +| --- | --- | +| `span_id` | Unique identifier for this span. | +| `trace_id` | Identifier shared by all spans in the same trace. | +| `parent_span_id` | Parent span; empty for a root span. | +| `name` | Operation name, such as an agent method. | +| `kind` | OTel role of the work; current exporter spans are `SPAN_KIND_INTERNAL`. | +| `start_time_unix_nano` / `end_time_unix_nano` | Raw Unix timestamps in nanoseconds. | +| `status_code` / `status_message` | OTel outcome: normally `STATUS_CODE_UNSET`, or `STATUS_CODE_ERROR` with an error message. | +| `attributes` | Complete raw OTel attribute map (JSONB). | +| `events` | OTel events, including any `exception` event (JSONB). | + +## `otel_span_attributes` + +This one-to-one projection mirrors every attribute currently emitted by +`convert.waiting_row_to_span()`. Fields are nullable because OTel omits an attribute +whose source value is `None`. + +| Group | Columns | +| --- | --- | +| Model and agent | `gen_ai.request.model`, `gen_ai.agent.id` | +| Resources and timing | `cpu`, `gpu`, `execution_time_ms`, `queue_time_ms` | +| Token usage | `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `token_count`, `gen_ai.usage.cache_read.input_tokens` | +| Input and output | `langfuse.observation.input`, `langfuse.observation.output` | +| Project and errors | `project_id`, `error_count` | +| Costs | `server_cost`, `token_cost`, `gen_ai.usage.cost` | +| Cache | `cache_hit_ratio` | + +The DBML source is [`../otel_spans_schema.txt`](../otel_spans_schema.txt). + +Successful spans use `STATUS_CODE_UNSET` rather than `STATUS_CODE_OK` because OpenTelemetry reserves `OK` for application- or operator-validated success, while instrumentation normally sets a status only when it records an error. diff --git a/OTel_Exporter/__init__.py b/ventis/OTLP_Exporter/__init__.py similarity index 100% rename from OTel_Exporter/__init__.py rename to ventis/OTLP_Exporter/__init__.py diff --git a/OTel_Exporter/convert.py b/ventis/OTLP_Exporter/convert.py similarity index 72% rename from OTel_Exporter/convert.py rename to ventis/OTLP_Exporter/convert.py index b7fb493..66da972 100644 --- a/OTel_Exporter/convert.py +++ b/ventis/OTLP_Exporter/convert.py @@ -3,7 +3,7 @@ Pure function, no I/O, no batching, no network calls. Builds ReadableSpan objects directly instead of going through Tracer.start_span() -- there's no live tracer here, futures already finished (sometimes in another process), so this is a historical-row -conversion, not live tracing. See OTel_Exporter/DESIGN.md for why this deviates from +conversion, not live tracing. See ventis/OTLP_Exporter/DESIGN.md for why this deviates from the SDK's usual advice against constructing ReadableSpan by hand. """ @@ -64,9 +64,17 @@ def waiting_row_to_span(row): ) status = Status(StatusCode.ERROR, description=row.get("error_message")) - # Model and token usage use OTel GenAI semantic-convention names. Observation - # input/output use Langfuse's documented JSON-string attributes. The remaining - # Ventis infrastructure values have no GenAI equivalent, so they keep plain names. + # Model, token, agent, and cache-read usage use OTel GenAI semantic-convention + # names (see https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/). + # total_cost uses gen_ai.usage.cost, which isn't yet an official semconv attribute + # but is the name Langfuse's OTel ingestion actually reads (its JSON cost_details + # attribute is currently broken -- see langfuse/langfuse#11030). Observation + # input/output use Langfuse's documented JSON-string attributes. `errors` is named + # error_count, not "errors"/"error", to avoid colliding with OTel's reserved + # error.* namespace (error.type etc.), which describes a single error, not a + # count. The remaining Ventis-specific values (project_id, server/token cost + # breakdown, cache_hit_ratio) have no GenAI or Langfuse equivalent, so they keep + # plain names. attributes = { k: v for k, v in { @@ -80,6 +88,14 @@ def waiting_row_to_span(row): "token_count": row.get("token_count"), "langfuse.observation.input": row.get("input"), "langfuse.observation.output": row.get("output"), + "project_id": row.get("project_id"), + "gen_ai.agent.id": row.get("agent_id"), + "error_count": row.get("errors"), + "server_cost": row.get("server_cost"), + "token_cost": row.get("token_cost"), + "gen_ai.usage.cost": row.get("total_cost"), + "gen_ai.usage.cache_read.input_tokens": row.get("cached_tokens"), + "cache_hit_ratio": row.get("cache_hit_ratio"), }.items() if v is not None } diff --git a/OTel_Exporter/db.py b/ventis/OTLP_Exporter/db.py similarity index 98% rename from OTel_Exporter/db.py rename to ventis/OTLP_Exporter/db.py index a8a675f..e6a1834 100644 --- a/OTel_Exporter/db.py +++ b/ventis/OTLP_Exporter/db.py @@ -130,7 +130,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH if not fid or not session_id: continue agent_id = raw.get("agent") - started_at = float(raw.get("created_at") or 0) or None + started_at = float(raw.get("created_at") or 0) finished_at = float(raw["finished_at"]) if raw.get("finished_at") else None execution_time_ms = ( round((finished_at - started_at) * 1000) @@ -160,7 +160,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH * _TOKEN_COST_MULTIPLIER ) # Server cost needs an elapsed duration -- only available once finished. - if finished_at and started_at: + if finished_at is not None: server_cost = ( pricing.compute_server_cost( redis_client.get(f"agent:{agent_id}:instance_type") diff --git a/OTel_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py similarity index 100% rename from OTel_Exporter/otel_exporter.py rename to ventis/OTLP_Exporter/otel_exporter.py diff --git a/ventis/OTLP_Exporter/otel_queue.db b/ventis/OTLP_Exporter/otel_queue.db new file mode 100644 index 0000000..e69de29 diff --git a/ventis/cli.py b/ventis/cli.py index 920df15..31a32e0 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -238,6 +238,15 @@ def cmd_build(args): if name: yaml_by_name[name] = yaml_path + # Maps each generated stub's basename to its agent's entrypoint path, so a + # stub can also be placed at its nested, entrypoint-mirrored location. + entrypoints_by_name = {a["name"]: a.get("entrypoint") for a in agents} + stub_entrypoints = { + f"{os.path.splitext(os.path.basename(p))[0]}.py": entrypoints_by_name[n] + for n, p in yaml_by_name.items() + if entrypoints_by_name.get(n) + } + stub_paths = [] for yaml_path in yaml_files: base_name = os.path.splitext(os.path.basename(yaml_path))[0] @@ -302,9 +311,9 @@ def cmd_build(args): api_port=agent_cfg.get("api_port", 8080), requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - # A workflow script imports stubs by flat module name (e.g. `from - # intent_agent import ...`), not by the agent's own entrypoint path. - stub_entrypoints=None, + # Stubs are placed both flat and at their entrypoint-mirrored path, + # so both flat and nested import styles resolve to the stub. + stub_entrypoints=stub_entrypoints, ) else: @@ -340,9 +349,9 @@ def cmd_build(args): stub_files=stub_paths, requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - # Same reasoning as the workflow call above: this project's agents - # import each other's stubs by flat module name, not entrypoint path. - stub_entrypoints=None, + # Same reasoning as the workflow call above: stubs are placed both + # flat and at their entrypoint-mirrored path. + stub_entrypoints=stub_entrypoints, ) bake_targets.append( diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index 9955fa2..7e4e9ab 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -239,11 +239,12 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, logger.info("Transferring image %s to %s", image, host) result = subprocess.run( "set -o pipefail; " - f"docker save {shlex.quote(image)} | ssh -o StrictHostKeyChecking=no " + f"docker save {shlex.quote(image)} | zstd -T0 | ssh -o StrictHostKeyChecking=no " f"-o IdentitiesOnly=yes -o ConnectTimeout=10 " f"-o ServerAliveInterval=10 -o ServerAliveCountMax=3 " f"-i {shlex.quote(key)} " - f"{shlex.quote(f'{ssh_user}@{host}')} 'sudo docker load'", + f"{shlex.quote(f'{ssh_user}@{host}')} " + "'set -o pipefail; zstd -d | sudo docker load'", shell=True, capture_output=True, text=True, diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index da29783..6584ef8 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -110,16 +110,16 @@ def __init__(self, config_path): len(self.controllers), ) - # Start background cleanup thread, woken by each poll tick rather than its own timer -- see OTel_Exporter/DESIGN.md. + # Start background cleanup thread, woken by each poll tick rather than its own timer -- see ventis/OTLP_Exporter/DESIGN.md. self._cleanup_ready = threading.Event() self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() - # Spawn the OTLP exporter as a separate process (see OTel_Exporter/DESIGN.md), + # Spawn the OTLP exporter as a separate process (see ventis/OTLP_Exporter/DESIGN.md), # supervised so it gets restarted if it ever exits unexpectedly. otel_exporter_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "OTel_Exporter", + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "OTLP_Exporter", ) otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() @@ -191,7 +191,12 @@ def _load_config(config_path): project_root = os.path.abspath(os.path.join(os.path.dirname(config_path), "..")) GlobalController._load_dotenv(os.path.join(project_root, ".env")) with open(config_path, "r") as f: - return yaml.safe_load(f) + config = yaml.safe_load(f) + if "ec2" in config: + config["ec2"] = GlobalController._expand_env_value(config["ec2"]) + if "database" in config: + config["database"] = GlobalController._expand_env_value(config["database"]) + return config @staticmethod def _load_dotenv(path): @@ -212,13 +217,13 @@ def _load_dotenv(path): os.environ[key] = value @staticmethod - def _expand_otel_value(value): + def _expand_env_value(value): if isinstance(value, str): return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), m.group(0)), value) if isinstance(value, dict): - return {key: GlobalController._expand_otel_value(item) for key, item in value.items()} + return {key: GlobalController._expand_env_value(item) for key, item in value.items()} if isinstance(value, list): - return [GlobalController._expand_otel_value(item) for item in value] + return [GlobalController._expand_env_value(item) for item in value] return value @staticmethod @@ -242,7 +247,7 @@ def _otel_exporter_env(otel_cfg): ) if "destinations" in otel_cfg: - destinations = GlobalController._expand_otel_value(otel_cfg["destinations"]) + destinations = GlobalController._expand_env_value(otel_cfg["destinations"]) for destination in destinations: if destination.get("name") == "langfuse": public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") @@ -639,12 +644,12 @@ def _poll_controllers(self): # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which # terminates every managed process) via the signal handler before this line is # reached -- without the guard, this could respawn a process just intentionally - # killed. See OTel_Exporter/DESIGN.md. + # killed. See ventis/OTLP_Exporter/DESIGN.md. if self.running: self.process_supervisor.check_and_respawn() # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer - # gates every other instance's poll -- see OTel_Exporter/DESIGN.md. + # gates every other instance's poll -- see ventis/OTLP_Exporter/DESIGN.md. instances = self.instance_manager.list_instances() if instances: with ThreadPoolExecutor(max_workers=len(instances)) as executor: diff --git a/ventis/controller/utils/process_supervisor.py b/ventis/controller/utils/process_supervisor.py index 8f5724c..8c061bc 100644 --- a/ventis/controller/utils/process_supervisor.py +++ b/ventis/controller/utils/process_supervisor.py @@ -3,7 +3,7 @@ register() + start_all() spawn processes; check_and_respawn() (call from GC's existing poll tick) restarts any that exit unexpectedly; terminate_all() (call from GC's shutdown path) stops them all cleanly. Deliberately GC-agnostic -- callers are responsible for not -calling check_and_respawn() during their own shutdown (see OTel_Exporter/DESIGN.md's +calling check_and_respawn() during their own shutdown (see ventis/OTLP_Exporter/DESIGN.md's shutdown-race note). """ diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 803fc2d..4647619 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -17,9 +17,7 @@ import yaml # Packages every agent container needs regardless of its specific business logic. -# grpcio-tools/pyyaml/ipdb/ipython aren't needed/used, but keeping to keep the scope constrained right now -# - Leave a comment if you want me to remove these, I kept them in since you originally had them but they aren't used -BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "ipdb", "ipython", "boto3"] +BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "boto3"] # Workflow will always require these BASE_WORKFLOW_REQUIREMENTS = BASE_AGENT_REQUIREMENTS + ["flask", "sqlalchemy", "psycopg[binary]"] @@ -322,6 +320,21 @@ def _copy_files(output_dir, files_to_copy): shutil.copy2(src, dest_path) +def _write_entrypoint_file(src, dest_path, project_dir): + """Copy an entrypoint file to dest_path, injecting a sys.path entry for its + original sibling directory so a co-located, non-stub helper import still resolves.""" + original_dir = os.path.dirname(os.path.relpath(src, project_dir)) if project_dir else "" + if not original_dir: + shutil.copy2(src, dest_path) + return + injection = ( + f"import sys, os\n" + f"sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), {original_dir!r}))\n" + ) + with open(src) as f, open(dest_path, "w") as out: + out.write(injection + f.read()) + + def generate_docker( yaml_path, agent_file, @@ -405,8 +418,7 @@ def generate_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) - - files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) + files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -416,6 +428,14 @@ def generate_docker( _copy_files(output_dir, files_to_copy) + # Copy the agent's own real file last, so it wins over any same-named stub + # copy above; inject a sys.path entry so its own sibling helpers still resolve. + _write_entrypoint_file( + os.path.abspath(agent_file), + os.path.join(output_dir, os.path.basename(agent_file)), + project_dir, + ) + # Copy the YAML definition too shutil.copy2( os.path.abspath(yaml_path), @@ -499,7 +519,6 @@ def generate_workflow_docker( files_to_copy = _sweep_py_files(project_dir) if project_dir else [] files_to_copy += [ - (os.path.abspath(workflow_file), workflow_basename), (os.path.join(script_dir, "future.py"), "future.py"), (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), (os.path.join(script_dir, "deploy.py"), "deploy.py"), @@ -527,6 +546,7 @@ def generate_workflow_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) + files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -536,6 +556,14 @@ def generate_workflow_docker( _copy_files(output_dir, files_to_copy) + # Copy the workflow's own real file last, so it wins over any same-named stub + # copy above; inject a sys.path entry so its own sibling helpers still resolve. + _write_entrypoint_file( + os.path.abspath(workflow_file), + os.path.join(output_dir, workflow_basename), + project_dir, + ) + # ---- workflow_launcher.py -------------------------------------------- launcher = f"""import threading import time From 9bbc35d2206016e72b65061579d7bf2e62098c47 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 28 Aug 2026 13:58:05 -0700 Subject: [PATCH 13/44] rough draft --- .../portfolio/config/global_controller.yaml | 25 +++++---- .../portfolio/workflow/portfolio_workflow.py | 1 - pyproject.toml | 2 +- requirements.txt | 2 - tests/test_otel_exporter_fanout.py | 2 +- tests/test_otel_exporter_fields.py | 2 +- .../OTLP_Exporter}/DESIGN.md | 6 +- ventis/OTLP_Exporter/SCHEMA.md | 56 +++++++++++++++++++ .../OTLP_Exporter}/__init__.py | 0 .../OTLP_Exporter}/convert.py | 24 ++++++-- {OTel_Exporter => ventis/OTLP_Exporter}/db.py | 4 +- .../OTLP_Exporter}/otel_exporter.py | 0 ventis/OTLP_Exporter/otel_queue.db | 0 ventis/cli.py | 21 +++++-- .../cloud_provider_logic/EC2/_runtime.py | 5 +- ventis/controller/global_controller.py | 25 +++++---- ventis/controller/utils/process_supervisor.py | 2 +- ventis/stub_generator.py | 40 +++++++++++-- 18 files changed, 166 insertions(+), 51 deletions(-) rename {OTel_Exporter => ventis/OTLP_Exporter}/DESIGN.md (98%) create mode 100644 ventis/OTLP_Exporter/SCHEMA.md rename {OTel_Exporter => ventis/OTLP_Exporter}/__init__.py (100%) rename {OTel_Exporter => ventis/OTLP_Exporter}/convert.py (72%) rename {OTel_Exporter => ventis/OTLP_Exporter}/db.py (98%) rename {OTel_Exporter => ventis/OTLP_Exporter}/otel_exporter.py (100%) create mode 100644 ventis/OTLP_Exporter/otel_queue.db diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index ab5af4d..edee85f 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -83,18 +83,18 @@ otel: destinations: - name: railway protocol: grpc - endpoint: yamanote.proxy.rlwy.net:19803 + endpoint: ${RAILWAY_OTLP_ENDPOINT} insecure: true headers: {} - - name: langfuse - protocol: http - endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces - headers: {} - name: grafana protocol: http endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces headers: Authorization: Basic ${GRAFANA_OTLP_HEADERS} +# - name: langfuse +# protocol: http +# endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces +# headers: {} # Polling interval in seconds poll_interval: 5 @@ -107,10 +107,13 @@ redis: # EC2 defaults for `provider: EC2` replicas. ec2: - region: us-east-1 - ami_id: ami-031ff6df47f26b546 - subnet_id: subnet-0638ac6d79d488124 + region: ${EC2_REGION} + ami_id: ${EC2_AMI_ID} + subnet_id: ${EC2_SUBNET_ID} security_group_ids: - - sg-025daf3a98e06cef3 - ssh_user: ubuntu - ssh_private_key_path: ~/.ssh/ventis_ec2 + - ${EC2_SECURITY_GROUP_ID} + 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 299a3f5..b8b684a 100644 --- a/examples/portfolio/workflow/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -46,7 +46,6 @@ def main( advisor = AdvisorAgent() # Stage 0: parse the free-text request into structured holdings + window. - intent = intent_agent.parse(query=query).value() # parse() returns a dict, but a Future's .value() only ever gives back the # raw string ventis stored in Redis -- same deserialization requirement as # every other dict-returning agent call below. diff --git a/pyproject.toml b/pyproject.toml index 9ae1234..40cb43a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ requires = ["setuptools>=64"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["ventis*", "OTel_Exporter*"] +include = ["ventis*"] [tool.setuptools.package-data] ventis = [ diff --git a/requirements.txt b/requirements.txt index dd7a254..f06e0b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,6 @@ grpcio-tools redis pyyaml flask -ipdb -ipython sqlalchemy psycopg[binary] psutil diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index ed1b423..0691d61 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -13,7 +13,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) # ``otel_exporter.py`` is also executed as a script from its own directory and # therefore imports ``convert`` and ``db`` as top-level modules. -sys.path.insert(0, os.path.join(ROOT, "OTel_Exporter")) +sys.path.insert(0, os.path.join(ROOT, "ventis", "OTLP_Exporter")) import db # noqa: E402 import otel_exporter # noqa: E402 diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py index 4a62c58..ff8cad6 100644 --- a/tests/test_otel_exporter_fields.py +++ b/tests/test_otel_exporter_fields.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from OTel_Exporter import convert, db +from ventis.OTLP_Exporter import convert, db class OTelExporterFieldTests(unittest.TestCase): diff --git a/OTel_Exporter/DESIGN.md b/ventis/OTLP_Exporter/DESIGN.md similarity index 98% rename from OTel_Exporter/DESIGN.md rename to ventis/OTLP_Exporter/DESIGN.md index 86a2a95..287fffd 100644 --- a/OTel_Exporter/DESIGN.md +++ b/ventis/OTLP_Exporter/DESIGN.md @@ -48,7 +48,7 @@ Decisions (final status): Configuration is read at exporter startup; changing it requires a GlobalController/exporter restart. - **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own - SQLite file (`OTel_Exporter/otel_queue.db`, see `db.py`), written by GC's existing + SQLite file (`ventis/OTLP_Exporter/otel_queue.db`, see `db.py`), written by GC's existing `_poll_controllers` *alongside* (not instead of) the existing `send_runtime_information` write. Keeps this pipeline's schema/state fully decoupled from the dashboard/cost table. @@ -95,7 +95,7 @@ endpoint and headers to the SDK. `BatchSpanProcessor(..., schedule_delay_millis= `max_export_batch_size` is left at the SDK default (512), which already approximates the original "500 spans" batching ask without any override needed. -### 2. `OTel_Exporter/otel_exporter.py` +### 2. `ventis/OTLP_Exporter/otel_exporter.py` A plain loop, polling every `POLL_INTERVAL_SECONDS` (5s, checked every 1s so SIGTERM stays responsive), calling `_send_pending()` each tick. At startup it constructs one independent OTLP exporter and `BatchSpanProcessor` for each configured destination; @@ -111,7 +111,7 @@ each pair may use a different protocol, endpoint, and headers: since spans are hand-built and handed straight to the processors via `on_end()`. - Every processor is shut down on exit, flushing its pending batch independently. -### 3. Future row → OTel span conversion (`OTel_Exporter/convert.py`) +### 3. Future row → OTel span conversion (`ventis/OTLP_Exporter/convert.py`) `future_id` maps to OTel `span_id`, not `trace_id` — `session_id` (== `request_id`) is the one that maps to `trace_id`. Both are `uuid4().hex` (32 hex chars / 16 bytes); OTel `trace_id` is 128-bit (16 bytes, fits directly) and `span_id` is 64-bit (8 bytes, needs diff --git a/ventis/OTLP_Exporter/SCHEMA.md b/ventis/OTLP_Exporter/SCHEMA.md new file mode 100644 index 0000000..38f8664 --- /dev/null +++ b/ventis/OTLP_Exporter/SCHEMA.md @@ -0,0 +1,56 @@ +# OTel span storage schema + +Each emitted OTel span is stored as one `otel_spans` record. This chart also defines a +one-to-one `otel_span_attributes` projection, linked by the same `span_id`, for the +known exporter attributes. The current receiver retains the raw `attributes` JSONB map; +the child table is the relational schema described in `otel_spans_schema.txt`. + +```text +┌───────────────────────────┐ ┌────────────────────────────────┐ +│ otel_spans │ │ otel_span_attributes │ +├───────────────────────────┤ ├────────────────────────────────┤ +│ PK span_id │────1:1───│ PK/FK span_id │ +│ trace_id │ │ model and agent ID │ +│ parent_span_id │ │ CPU and GPU │ +│ name │ │ timing and token usage │ +│ kind │ │ input and output │ +│ start/end time (ns) │ │ project and error count │ +│ status code/message │ │ server/token/total cost │ +│ attributes (JSONB) │ │ cache tokens/hit ratio │ +│ events (JSONB) │ └────────────────────────────────┘ +└───────────────────────────┘ +``` + +## `otel_spans` + +| Column | Meaning | +| --- | --- | +| `span_id` | Unique identifier for this span. | +| `trace_id` | Identifier shared by all spans in the same trace. | +| `parent_span_id` | Parent span; empty for a root span. | +| `name` | Operation name, such as an agent method. | +| `kind` | OTel role of the work; current exporter spans are `SPAN_KIND_INTERNAL`. | +| `start_time_unix_nano` / `end_time_unix_nano` | Raw Unix timestamps in nanoseconds. | +| `status_code` / `status_message` | OTel outcome: normally `STATUS_CODE_UNSET`, or `STATUS_CODE_ERROR` with an error message. | +| `attributes` | Complete raw OTel attribute map (JSONB). | +| `events` | OTel events, including any `exception` event (JSONB). | + +## `otel_span_attributes` + +This one-to-one projection mirrors every attribute currently emitted by +`convert.waiting_row_to_span()`. Fields are nullable because OTel omits an attribute +whose source value is `None`. + +| Group | Columns | +| --- | --- | +| Model and agent | `gen_ai.request.model`, `gen_ai.agent.id` | +| Resources and timing | `cpu`, `gpu`, `execution_time_ms`, `queue_time_ms` | +| Token usage | `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `token_count`, `gen_ai.usage.cache_read.input_tokens` | +| Input and output | `langfuse.observation.input`, `langfuse.observation.output` | +| Project and errors | `project_id`, `error_count` | +| Costs | `server_cost`, `token_cost`, `gen_ai.usage.cost` | +| Cache | `cache_hit_ratio` | + +The DBML source is [`../otel_spans_schema.txt`](../otel_spans_schema.txt). + +Successful spans use `STATUS_CODE_UNSET` rather than `STATUS_CODE_OK` because OpenTelemetry reserves `OK` for application- or operator-validated success, while instrumentation normally sets a status only when it records an error. diff --git a/OTel_Exporter/__init__.py b/ventis/OTLP_Exporter/__init__.py similarity index 100% rename from OTel_Exporter/__init__.py rename to ventis/OTLP_Exporter/__init__.py diff --git a/OTel_Exporter/convert.py b/ventis/OTLP_Exporter/convert.py similarity index 72% rename from OTel_Exporter/convert.py rename to ventis/OTLP_Exporter/convert.py index b7fb493..66da972 100644 --- a/OTel_Exporter/convert.py +++ b/ventis/OTLP_Exporter/convert.py @@ -3,7 +3,7 @@ Pure function, no I/O, no batching, no network calls. Builds ReadableSpan objects directly instead of going through Tracer.start_span() -- there's no live tracer here, futures already finished (sometimes in another process), so this is a historical-row -conversion, not live tracing. See OTel_Exporter/DESIGN.md for why this deviates from +conversion, not live tracing. See ventis/OTLP_Exporter/DESIGN.md for why this deviates from the SDK's usual advice against constructing ReadableSpan by hand. """ @@ -64,9 +64,17 @@ def waiting_row_to_span(row): ) status = Status(StatusCode.ERROR, description=row.get("error_message")) - # Model and token usage use OTel GenAI semantic-convention names. Observation - # input/output use Langfuse's documented JSON-string attributes. The remaining - # Ventis infrastructure values have no GenAI equivalent, so they keep plain names. + # Model, token, agent, and cache-read usage use OTel GenAI semantic-convention + # names (see https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/). + # total_cost uses gen_ai.usage.cost, which isn't yet an official semconv attribute + # but is the name Langfuse's OTel ingestion actually reads (its JSON cost_details + # attribute is currently broken -- see langfuse/langfuse#11030). Observation + # input/output use Langfuse's documented JSON-string attributes. `errors` is named + # error_count, not "errors"/"error", to avoid colliding with OTel's reserved + # error.* namespace (error.type etc.), which describes a single error, not a + # count. The remaining Ventis-specific values (project_id, server/token cost + # breakdown, cache_hit_ratio) have no GenAI or Langfuse equivalent, so they keep + # plain names. attributes = { k: v for k, v in { @@ -80,6 +88,14 @@ def waiting_row_to_span(row): "token_count": row.get("token_count"), "langfuse.observation.input": row.get("input"), "langfuse.observation.output": row.get("output"), + "project_id": row.get("project_id"), + "gen_ai.agent.id": row.get("agent_id"), + "error_count": row.get("errors"), + "server_cost": row.get("server_cost"), + "token_cost": row.get("token_cost"), + "gen_ai.usage.cost": row.get("total_cost"), + "gen_ai.usage.cache_read.input_tokens": row.get("cached_tokens"), + "cache_hit_ratio": row.get("cache_hit_ratio"), }.items() if v is not None } diff --git a/OTel_Exporter/db.py b/ventis/OTLP_Exporter/db.py similarity index 98% rename from OTel_Exporter/db.py rename to ventis/OTLP_Exporter/db.py index a8a675f..e6a1834 100644 --- a/OTel_Exporter/db.py +++ b/ventis/OTLP_Exporter/db.py @@ -130,7 +130,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH if not fid or not session_id: continue agent_id = raw.get("agent") - started_at = float(raw.get("created_at") or 0) or None + started_at = float(raw.get("created_at") or 0) finished_at = float(raw["finished_at"]) if raw.get("finished_at") else None execution_time_ms = ( round((finished_at - started_at) * 1000) @@ -160,7 +160,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH * _TOKEN_COST_MULTIPLIER ) # Server cost needs an elapsed duration -- only available once finished. - if finished_at and started_at: + if finished_at is not None: server_cost = ( pricing.compute_server_cost( redis_client.get(f"agent:{agent_id}:instance_type") diff --git a/OTel_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py similarity index 100% rename from OTel_Exporter/otel_exporter.py rename to ventis/OTLP_Exporter/otel_exporter.py diff --git a/ventis/OTLP_Exporter/otel_queue.db b/ventis/OTLP_Exporter/otel_queue.db new file mode 100644 index 0000000..e69de29 diff --git a/ventis/cli.py b/ventis/cli.py index 920df15..31a32e0 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -238,6 +238,15 @@ def cmd_build(args): if name: yaml_by_name[name] = yaml_path + # Maps each generated stub's basename to its agent's entrypoint path, so a + # stub can also be placed at its nested, entrypoint-mirrored location. + entrypoints_by_name = {a["name"]: a.get("entrypoint") for a in agents} + stub_entrypoints = { + f"{os.path.splitext(os.path.basename(p))[0]}.py": entrypoints_by_name[n] + for n, p in yaml_by_name.items() + if entrypoints_by_name.get(n) + } + stub_paths = [] for yaml_path in yaml_files: base_name = os.path.splitext(os.path.basename(yaml_path))[0] @@ -302,9 +311,9 @@ def cmd_build(args): api_port=agent_cfg.get("api_port", 8080), requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - # A workflow script imports stubs by flat module name (e.g. `from - # intent_agent import ...`), not by the agent's own entrypoint path. - stub_entrypoints=None, + # Stubs are placed both flat and at their entrypoint-mirrored path, + # so both flat and nested import styles resolve to the stub. + stub_entrypoints=stub_entrypoints, ) else: @@ -340,9 +349,9 @@ def cmd_build(args): stub_files=stub_paths, requirements=_normalize_requirements(agent_cfg), project_dir=project_dir, - # Same reasoning as the workflow call above: this project's agents - # import each other's stubs by flat module name, not entrypoint path. - stub_entrypoints=None, + # Same reasoning as the workflow call above: stubs are placed both + # flat and at their entrypoint-mirrored path. + stub_entrypoints=stub_entrypoints, ) bake_targets.append( diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index 9955fa2..7e4e9ab 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -239,11 +239,12 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, logger.info("Transferring image %s to %s", image, host) result = subprocess.run( "set -o pipefail; " - f"docker save {shlex.quote(image)} | ssh -o StrictHostKeyChecking=no " + f"docker save {shlex.quote(image)} | zstd -T0 | ssh -o StrictHostKeyChecking=no " f"-o IdentitiesOnly=yes -o ConnectTimeout=10 " f"-o ServerAliveInterval=10 -o ServerAliveCountMax=3 " f"-i {shlex.quote(key)} " - f"{shlex.quote(f'{ssh_user}@{host}')} 'sudo docker load'", + f"{shlex.quote(f'{ssh_user}@{host}')} " + "'set -o pipefail; zstd -d | sudo docker load'", shell=True, capture_output=True, text=True, diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index bdec683..16e0e9c 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -110,16 +110,16 @@ def __init__(self, config_path): len(self.controllers), ) - # Start background cleanup thread, woken by each poll tick rather than its own timer -- see OTel_Exporter/DESIGN.md. + # Start background cleanup thread, woken by each poll tick rather than its own timer -- see ventis/OTLP_Exporter/DESIGN.md. self._cleanup_ready = threading.Event() self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() - # Spawn the OTLP exporter as a separate process (see OTel_Exporter/DESIGN.md), + # Spawn the OTLP exporter as a separate process (see ventis/OTLP_Exporter/DESIGN.md), # supervised so it gets restarted if it ever exits unexpectedly. otel_exporter_dir = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), - "OTel_Exporter", + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "OTLP_Exporter", ) otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() @@ -191,7 +191,12 @@ def _load_config(config_path): project_root = os.path.abspath(os.path.join(os.path.dirname(config_path), "..")) GlobalController._load_dotenv(os.path.join(project_root, ".env")) with open(config_path, "r") as f: - return yaml.safe_load(f) + config = yaml.safe_load(f) + if "ec2" in config: + config["ec2"] = GlobalController._expand_env_value(config["ec2"]) + if "database" in config: + config["database"] = GlobalController._expand_env_value(config["database"]) + return config @staticmethod def _load_dotenv(path): @@ -212,13 +217,13 @@ def _load_dotenv(path): os.environ[key] = value @staticmethod - def _expand_otel_value(value): + def _expand_env_value(value): if isinstance(value, str): return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", lambda m: os.environ.get(m.group(1), m.group(0)), value) if isinstance(value, dict): - return {key: GlobalController._expand_otel_value(item) for key, item in value.items()} + return {key: GlobalController._expand_env_value(item) for key, item in value.items()} if isinstance(value, list): - return [GlobalController._expand_otel_value(item) for item in value] + return [GlobalController._expand_env_value(item) for item in value] return value @staticmethod @@ -242,7 +247,7 @@ def _otel_exporter_env(otel_cfg): ) if "destinations" in otel_cfg: - destinations = GlobalController._expand_otel_value(otel_cfg["destinations"]) + destinations = GlobalController._expand_env_value(otel_cfg["destinations"]) for destination in destinations: if destination.get("name") == "langfuse": public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") @@ -639,7 +644,7 @@ def _poll_controllers(self): # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which # terminates every managed process) via the signal handler before this line is # reached -- without the guard, this could respawn a process just intentionally - # killed. See OTel_Exporter/DESIGN.md. + # killed. See ventis/OTLP_Exporter/DESIGN.md. if self.running: self.process_supervisor.check_and_respawn() diff --git a/ventis/controller/utils/process_supervisor.py b/ventis/controller/utils/process_supervisor.py index 8f5724c..8c061bc 100644 --- a/ventis/controller/utils/process_supervisor.py +++ b/ventis/controller/utils/process_supervisor.py @@ -3,7 +3,7 @@ register() + start_all() spawn processes; check_and_respawn() (call from GC's existing poll tick) restarts any that exit unexpectedly; terminate_all() (call from GC's shutdown path) stops them all cleanly. Deliberately GC-agnostic -- callers are responsible for not -calling check_and_respawn() during their own shutdown (see OTel_Exporter/DESIGN.md's +calling check_and_respawn() during their own shutdown (see ventis/OTLP_Exporter/DESIGN.md's shutdown-race note). """ diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 803fc2d..4647619 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -17,9 +17,7 @@ import yaml # Packages every agent container needs regardless of its specific business logic. -# grpcio-tools/pyyaml/ipdb/ipython aren't needed/used, but keeping to keep the scope constrained right now -# - Leave a comment if you want me to remove these, I kept them in since you originally had them but they aren't used -BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "ipdb", "ipython", "boto3"] +BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "boto3"] # Workflow will always require these BASE_WORKFLOW_REQUIREMENTS = BASE_AGENT_REQUIREMENTS + ["flask", "sqlalchemy", "psycopg[binary]"] @@ -322,6 +320,21 @@ def _copy_files(output_dir, files_to_copy): shutil.copy2(src, dest_path) +def _write_entrypoint_file(src, dest_path, project_dir): + """Copy an entrypoint file to dest_path, injecting a sys.path entry for its + original sibling directory so a co-located, non-stub helper import still resolves.""" + original_dir = os.path.dirname(os.path.relpath(src, project_dir)) if project_dir else "" + if not original_dir: + shutil.copy2(src, dest_path) + return + injection = ( + f"import sys, os\n" + f"sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), {original_dir!r}))\n" + ) + with open(src) as f, open(dest_path, "w") as out: + out.write(injection + f.read()) + + def generate_docker( yaml_path, agent_file, @@ -405,8 +418,7 @@ def generate_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) - - files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) + files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -416,6 +428,14 @@ def generate_docker( _copy_files(output_dir, files_to_copy) + # Copy the agent's own real file last, so it wins over any same-named stub + # copy above; inject a sys.path entry so its own sibling helpers still resolve. + _write_entrypoint_file( + os.path.abspath(agent_file), + os.path.join(output_dir, os.path.basename(agent_file)), + project_dir, + ) + # Copy the YAML definition too shutil.copy2( os.path.abspath(yaml_path), @@ -499,7 +519,6 @@ def generate_workflow_docker( files_to_copy = _sweep_py_files(project_dir) if project_dir else [] files_to_copy += [ - (os.path.abspath(workflow_file), workflow_basename), (os.path.join(script_dir, "future.py"), "future.py"), (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), (os.path.join(script_dir, "deploy.py"), "deploy.py"), @@ -527,6 +546,7 @@ def generate_workflow_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) + files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -536,6 +556,14 @@ def generate_workflow_docker( _copy_files(output_dir, files_to_copy) + # Copy the workflow's own real file last, so it wins over any same-named stub + # copy above; inject a sys.path entry so its own sibling helpers still resolve. + _write_entrypoint_file( + os.path.abspath(workflow_file), + os.path.join(output_dir, workflow_basename), + project_dir, + ) + # ---- workflow_launcher.py -------------------------------------------- launcher = f"""import threading import time From 8baed6c324536bc88c0446b16376782c5b5cd9e4 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Mon, 31 Aug 2026 11:23:57 -0700 Subject: [PATCH 14/44] Parallelize per-instance polling in _poll_controllers Metrics/telemetry latency scaled with instance count x per-instance round-trip time since every instance was polled sequentially, one blocking the next, with the following tick only starting after the whole pass finished. Extracted the per-instance body into _poll_one_instance (whole body wrapped in one top-level try/except, since ThreadPoolExecutor.map() re-raises on first exception when results are consumed) and run all instances concurrently via the same ThreadPoolExecutor pattern _trigger_cleanup already used. Co-Authored-By: Claude Sonnet 5 --- ventis/controller/global_controller.py | 217 +++++++++++++------------ 1 file changed, 115 insertions(+), 102 deletions(-) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 16e0e9c..6584ef8 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -648,118 +648,131 @@ def _poll_controllers(self): if self.running: self.process_supervisor.check_and_respawn() - for instance in self.instance_manager.list_instances(): + # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer + # gates every other instance's poll -- see ventis/OTLP_Exporter/DESIGN.md. + instances = self.instance_manager.list_instances() + if instances: + with ThreadPoolExecutor(max_workers=len(instances)) as executor: + list(executor.map(self._poll_one_instance, instances)) + + def _poll_one_instance(self, instance): + """Poll and persist one instance's runtime/metrics/health data; never raises.""" + try: name = instance["agent_name"] host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) - try: - future_rows = pull_runtime_information(node_redis) - self._otel_db.write_waiting_rows( - future_rows, node_redis, self.config.get("project_id", 0) - ) - send_runtime_information( - future_rows, - node_redis, - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write runtime information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, + except Exception as e: + logger.warning("Failed to poll instance %s: %s", instance, e) + return + + try: + future_rows = pull_runtime_information(node_redis) + self._otel_db.write_waiting_rows( + future_rows, node_redis, self.config.get("project_id", 0) + ) + send_runtime_information( + future_rows, + node_redis, + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write runtime information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + agent_host = self._agent_host_key(host) + status_key = f"controller:{agent_host}:{port}:status" + metrics_key = f"controller:{agent_host}:{port}:metrics" + + # Getting metrics from local controllers + # See LocalController._execute_locally + try: + metrics = node_redis.hgetall(metrics_key) + if metrics: + now = time.time() + requests_served = int(float(metrics.get("requests_served") or 0)) + elapsed = now - self._last_metrics_poll_time.get( + (host, port), now - self.poll_interval ) - agent_host = self._agent_host_key(host) - status_key = f"controller:{agent_host}:{port}:status" - metrics_key = f"controller:{agent_host}:{port}:metrics" + throughput = requests_served / elapsed if elapsed > 0 else 0.0 + self._last_metrics_poll_time[(host, port)] = now - # Getting metrics from local controllers - # See LocalController._execute_locally - try: - metrics = node_redis.hgetall(metrics_key) - if metrics: - now = time.time() - requests_served = int(float(metrics.get("requests_served") or 0)) - elapsed = now - self._last_metrics_poll_time.get( - (host, port), now - self.poll_interval + try: + send_agent_information( + [ + { + **instance, + **metrics, + "requests_served": requests_served, + "throughput": throughput, + } + ], + self.config.get("database", {}).get("url"), ) - throughput = requests_served / elapsed if elapsed > 0 else 0.0 - self._last_metrics_poll_time[(host, port)] = now - - try: - send_agent_information( - [ - { - **instance, - **metrics, - "requests_served": requests_served, - "throughput": throughput, - } - ], - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write agent information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, - ) - else: - # Only clear the accumulated counters once they've actually been persisted - node_redis.hset_multiple( - metrics_key, - {"full_failures": 0, "error_count": 0, "requests_served": 0}, - ) - except Exception as e: - logger.warning( - "Failed to poll metrics for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + except Exception as e: + logger.warning( + "Failed to write agent information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + else: + # Only clear the accumulated counters once they've actually been persisted + node_redis.hset_multiple( + metrics_key, + {"full_failures": 0, "error_count": 0, "requests_served": 0}, + ) + except Exception as e: + logger.warning( + "Failed to poll metrics for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) - try: - status = node_redis.get(status_key) or "unknown" - prev = self._last_status.get((host, port)) + try: + status = node_redis.get(status_key) or "unknown" + prev = self._last_status.get((host, port)) - if status != prev: - if status == "healthy": - logger.info( - "Controller %s (%s:%s) is now healthy.", name, host, port - ) - self._on_controller_healthy(name, host, port) - else: - logger.warning( - "Controller %s (%s:%s) status changed: %s -> %s", - name, - host, - port, - prev or "(none)", - status, - ) - self._on_controller_unhealthy(name, host, port) - self._last_status[(host, port)] = status + if status != prev: + if status == "healthy": + logger.info( + "Controller %s (%s:%s) is now healthy.", name, host, port + ) + self._on_controller_healthy(name, host, port) else: - # No change — healthy stays quiet, unhealthy stays quiet too - if status == "healthy": - self._on_controller_healthy(name, host, port) - else: - self._on_controller_unhealthy(name, host, port) - except Exception as e: - logger.warning( - "Failed to poll status for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + logger.warning( + "Controller %s (%s:%s) status changed: %s -> %s", + name, + host, + port, + prev or "(none)", + status, + ) + self._on_controller_unhealthy(name, host, port) + self._last_status[(host, port)] = status + else: + # No change — healthy stays quiet, unhealthy stays quiet too + if status == "healthy": + self._on_controller_healthy(name, host, port) + else: + self._on_controller_unhealthy(name, host, port) + except Exception as e: + logger.warning( + "Failed to poll status for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) # ------------------------------------------------------------------ # # Extensibility hooks — override in subclasses # From cd3a5214099e3bdb4493f26820cafa684b8af2fd Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Mon, 31 Aug 2026 13:58:37 -0700 Subject: [PATCH 15/44] cleaned up OTel Exporter --- .../portfolio/config/global_controller.yaml | 4 - tests/test_otel_exporter_fanout.py | 58 +-- tests/test_otel_exporter_fields.py | 36 +- ventis/OTLP_Exporter/DESIGN.md | 73 ++-- ventis/OTLP_Exporter/convert.py | 17 +- ventis/OTLP_Exporter/db.py | 61 +-- ventis/OTLP_Exporter/otel_exporter.py | 67 +-- ventis/controller/global_controller.py | 385 ++++++------------ 8 files changed, 216 insertions(+), 485 deletions(-) diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index edee85f..96f371c 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -91,10 +91,6 @@ otel: endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces headers: Authorization: Basic ${GRAFANA_OTLP_HEADERS} -# - name: langfuse -# protocol: http -# endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces -# headers: {} # Polling interval in seconds poll_interval: 5 diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index 0691d61..96ca1b5 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -110,22 +110,10 @@ def test_build_processors_constructs_mixed_exporters_with_explicit_args(self): ], ) - def test_build_processors_preserves_legacy_single_destination_fallback(self): - http_exporter = object() - processor = MagicMock(name="legacy_processor") - with patch.dict( - os.environ, - {"OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf"}, - clear=True, - ), patch.object( - otel_exporter, "HttpOTLPSpanExporter", return_value=http_exporter - ) as constructor, patch.object( - otel_exporter, "BatchSpanProcessor", return_value=processor - ): - result = otel_exporter._build_processors() - - self.assertEqual(result, [("legacy", processor)]) - constructor.assert_called_once_with() + def test_build_processors_raises_when_destinations_env_unset(self): + with patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(RuntimeError, "otel.destinations is required"): + otel_exporter._build_processors() def test_configured_destinations_rejects_malformed_empty_and_duplicate_values(self): invalid_values = [ @@ -184,46 +172,20 @@ def test_controller_expands_env_and_builds_langfuse_basic_auth(self): ) self.assertEqual(destination["headers"]["Authorization"], "Basic cHVibGljOnNlY3JldA==") - def test_controller_env_serializes_destinations_and_keeps_legacy_mapping(self): + def test_controller_env_serializes_destinations_only(self): # Importing the controller is intentionally local: this test remains # runnable in the exporter-only environment used by the focused suite. from ventis.controller.global_controller import GlobalController destinations = self._destination_config() - env = GlobalController._otel_exporter_env( - { - "protocol": "grpc", - "endpoint": "legacy.example:4317", - "headers": {"x-tenant": "demo"}, - "destinations": destinations, - } - ) - self.assertEqual(env["OTEL_EXPORTER_OTLP_PROTOCOL"], "grpc") - self.assertEqual(env["OTEL_EXPORTER_OTLP_ENDPOINT"], "legacy.example:4317") - self.assertEqual(env["OTEL_EXPORTER_OTLP_HEADERS"], "x-tenant=demo") + env = GlobalController._otel_exporter_env({"destinations": destinations}) + self.assertEqual(set(env), {otel_exporter.DESTINATIONS_ENV}) self.assertEqual(json.loads(env[otel_exporter.DESTINATIONS_ENV]), destinations) - def test_controller_rejects_invalid_destinations_before_starting_child(self): + def test_controller_env_is_none_when_otel_not_configured(self): from ventis.controller.global_controller import GlobalController - invalid_destinations = [ - [], - [{"name": "railway", "protocol": "grpc"}], - [ - {"name": "same", "protocol": "grpc", "endpoint": "one:4317"}, - { - "name": "same", - "protocol": "http/protobuf", - "endpoint": "https://two", - }, - ], - [{"name": "bad", "protocol": "smtp", "endpoint": "example"}], - ] - for destinations in invalid_destinations: - with self.subTest(destinations=destinations), self.assertRaises(ValueError): - GlobalController._otel_exporter_env( - {"destinations": destinations} - ) + self.assertIsNone(GlobalController._otel_exporter_env({})) def _insert_pending_row(self): conn = sqlite3.connect(self.db_path) @@ -258,7 +220,6 @@ def test_send_pending_delivers_the_same_span_to_every_processor(self): otel_exporter.db, "mark_sent" ) as mark_sent: otel_exporter._processors = [("railway", first), ("langfuse", second)] - otel_exporter._processor = None otel_exporter._send_pending() first.on_end.assert_called_once() @@ -275,7 +236,6 @@ def test_send_pending_attempts_remaining_processors_and_leaves_row_unsent_on_fai otel_exporter.db, "mark_sent" ) as mark_sent: otel_exporter._processors = [("railway", failed), ("langfuse", remaining)] - otel_exporter._processor = None otel_exporter._send_pending() failed.on_end.assert_called_once() diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py index ff8cad6..b74177d 100644 --- a/tests/test_otel_exporter_fields.py +++ b/tests/test_otel_exporter_fields.py @@ -17,12 +17,7 @@ def setUp(self): def tearDown(self): os.unlink(self.db_path) - def test_init_db_migrates_existing_waiting_table(self): - with sqlite3.connect(self.db_path) as conn: - conn.execute( - "CREATE TABLE waiting (future_id TEXT PRIMARY KEY, session_id TEXT NOT NULL)" - ) - + def test_init_db_creates_waiting_table_with_full_schema(self): db.init_db(self.db_path) with sqlite3.connect(self.db_path) as conn: @@ -63,17 +58,8 @@ def test_fields_are_normalized_and_added_to_span(self): span.attributes["langfuse.observation.output"], row["output"] ) - def test_split_hash_result_is_loaded_for_finished_rows(self): - class SplitHashRedis: - def hget(self, key, field): - self.request = (key, field) - return '{"recommendation": "hold"}' - - def get(self, key): - return None - + def test_error_message_is_wired_from_redis_error_field(self): db.init_db(self.db_path) - redis = SplitHashRedis() raw = { "future_id": "11112222333344445555666677778888", "request_id": "88887777666655554444333322221111", @@ -83,16 +69,24 @@ def get(self, key): "result": "", "created_at": "1.0", "finished_at": "2.0", - "failed": "0", + "failed": "1", + "error": "agent exploded", } with patch.object(db.pricing, "compute_token_cost", return_value=0.0): - db.write_waiting_rows([raw], redis_client=redis, db_path=self.db_path) + db.write_waiting_rows([raw], db_path=self.db_path) with sqlite3.connect(self.db_path) as conn: - output = conn.execute("SELECT output FROM waiting").fetchone()[0] - self.assertEqual(redis.request, (f"future:{raw['future_id']}", "result")) - self.assertEqual(json.loads(output), {"recommendation": "hold"}) + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM waiting").fetchone() + + self.assertEqual(row["error_message"], "agent exploded") + + span = convert.waiting_row_to_span(row) + self.assertEqual(span.status.description, "agent exploded") + self.assertEqual( + span.events[0].attributes["exception.message"], "agent exploded" + ) if __name__ == "__main__": diff --git a/ventis/OTLP_Exporter/DESIGN.md b/ventis/OTLP_Exporter/DESIGN.md index d433fe0..ec2957d 100644 --- a/ventis/OTLP_Exporter/DESIGN.md +++ b/ventis/OTLP_Exporter/DESIGN.md @@ -27,26 +27,22 @@ Decisions (final status): isolation from GC's core polling/health loop and independent restart, at low added complexity since SQLite is already the entire hand-off boundary between the two. - **Config**: implemented via a new `otel:` section in `global_controller.yaml` - (`protocol`/`endpoint`/`headers`), *not* by making `otel_exporter.py` itself - config-aware. `GlobalController` translates that section into the OTel SDK's own - standard env vars (`OTEL_EXPORTER_OTLP_PROTOCOL`/`_ENDPOINT`/`_HEADERS`) and passes - them to the exporter subprocess via `ProcessSupervisor.register(..., env=...)`. The - exporter still just constructs `OTLPSpanExporter()` with no explicit args (endpoint - and headers are resolved by the SDK itself from those env vars, same as always) and - reads only `OTEL_EXPORTER_OTLP_PROTOCOL` directly, to pick the gRPC vs HTTP exporter - class — the one piece of protocol selection the plain SDK classes don't do on their - own. Deliberately vendor-neutral: no backend name (Postgres, Langfuse, or otherwise) - appears anywhere in `otel_exporter.py`; the destination is 100% deploy-time config, - set once in `global_controller.yaml` and never touched by app code again. The - originally-planned `database.url` repurposing (below, kept for history) was decided - against — env-var configuration is the SDK's own idiomatic mechanism, so no - exporter-side config plumbing was added, only a GC-side YAML→env-var translation. - The initial multi-destination extension uses one `otel.destinations` list and one - independent exporter/`BatchSpanProcessor` pair per destination. gRPC and HTTP - destinations may be mixed in the same list. The legacy single-destination fields - remain supported through the original standard-environment-variable path. - Configuration is read at exporter startup; changing it requires a - GlobalController/exporter restart. + holding a `destinations` list, *not* by making `otel_exporter.py` itself + config-aware. `GlobalController` serializes that list to JSON and passes it to the + exporter subprocess as a single `VENTIS_OTEL_DESTINATIONS` env var via + `ProcessSupervisor.register(..., env=...)`. The exporter builds one independent + exporter/`BatchSpanProcessor` pair per destination, picking the gRPC vs HTTP + exporter class from each destination's `protocol` field. gRPC and HTTP destinations + may be mixed in the same list. Deliberately vendor-neutral: no backend name + (Postgres, Langfuse, or otherwise) appears anywhere in `otel_exporter.py`; the + destination is 100% deploy-time config, set once in `global_controller.yaml` and + never touched by app code again. The originally-planned `database.url` repurposing + (below, kept for history) was decided against — env-var configuration is the SDK's + own idiomatic mechanism, so no exporter-side config plumbing was added, only a + GC-side YAML→env-var translation. If `otel.destinations` is absent, GlobalController + logs that no OTel metrics collection will happen and skips starting the exporter + subprocess entirely. Configuration is read at exporter startup; changing it requires + a GlobalController/exporter restart. - **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own SQLite file (`ventis/OTLP_Exporter/otel_queue.db`, see `db.py`), written by GC's existing `_poll_controllers` *alongside* (not instead of) the existing @@ -77,16 +73,19 @@ otel: - name: langfuse protocol: http endpoint: https://cloud.langfuse.com/api/public/otel/v1/traces - headers: {} # e.g. Authorization: "Basic " + headers: + Authorization: Basic ${LANGFUSE_OTLP_HEADERS} # deployer pre-encodes public:secret ``` -`GlobalController._otel_exporter_env()` translates each destination into the exporter -process's destination configuration and hands it to `ProcessSupervisor.register( +`GlobalController._otel_exporter_env()` translates the `destinations` list into +`VENTIS_OTEL_DESTINATIONS` and hands it to `ProcessSupervisor.register( "otel_exporter", ..., env=...)`, which supports an `env` param (merged on top of the -parent process's own environment, not a replacement). The legacy single-destination -`protocol`/`endpoint`/`headers` form remains valid and continues through the SDK's -standard OTLP environment variables. Omitting `otel:` entirely falls back to whatever -ambient env the exporter subprocess would otherwise inherit, same as before this -change. +parent process's own environment, not a replacement). If `otel.destinations` is +absent, `_otel_exporter_env()` returns `None` and `GlobalController.__init__` skips +registering the exporter subprocess entirely, logging that no OTel metrics +collection will happen. No shape +validation is duplicated on the GlobalController side (deliberately: keep this side +simple, `otel_exporter.py` itself validates destination shape at subprocess startup, +and raises if invoked directly without `VENTIS_OTEL_DESTINATIONS` set). `otel_exporter.py` parses the destination configuration at startup and constructs the appropriate OTLP exporter for each entry (gRPC or HTTP), passing that destination's @@ -167,21 +166,7 @@ Cleanup stays on its own thread (the event's `wait(timeout=cleanup_interval)` is fallback, not the primary trigger) so a slow/hung instance during cleanup can't stall the poll loop's health checks and OTel writes. -### 6. Parallelized per-instance polling (`ventis/controller/global_controller.py`) -`_poll_controllers` used to loop over every instance sequentially -- Redis reads, an -OTel sqlite write, and up to two Postgres writes per instance, one instance fully -blocking the next, with the following poll tick only starting after the whole pass -finished. Total metrics/telemetry latency scaled with instance count x round-trip -time, not the configured `poll_interval`. Fixed by extracting the per-instance body -into `_poll_one_instance` (its whole body wrapped in one top-level try/except, since -`ThreadPoolExecutor.map()` re-raises on first exception when results are consumed) -and running all instances concurrently via the same `ThreadPoolExecutor` pattern -`_trigger_cleanup` already used. Known, pre-existing, previously acknowledged in -commit `a6694d9`'s own message but never actually fixed (a same-named follow-up -branch was found to contain no real threading changes) -- see company-memory for -the investigation. - -### 7. Dependencies (all added) +### 6. Dependencies (all added) `opentelemetry-api`, `opentelemetry-sdk`, `opentelemetry-exporter-otlp-proto-grpc`, `opentelemetry-exporter-otlp-proto-http` (the last one added alongside the `otel:` config work, since `protocol: http` now needs that package importable). @@ -189,8 +174,6 @@ config work, since `protocol: http` now needs that package importable). ## Known gaps (not yet built) - `WriteResult()` passes an undefined `error_message` variable to its fan-out callback, which can interrupt remote consumer propagation after the callback hash is persisted. -- Redis records failure text under `error`, but the waiting-table writer reads - `error_name`/`error_message`, so exported exception details are usually empty. - Rows are marked `sent` immediately after `BatchSpanProcessor.on_end()` accepts them, before the asynchronous OTLP export is confirmed; a later delivery failure can lose a span while leaving `sent = 1`. diff --git a/ventis/OTLP_Exporter/convert.py b/ventis/OTLP_Exporter/convert.py index 66da972..5e6ac74 100644 --- a/ventis/OTLP_Exporter/convert.py +++ b/ventis/OTLP_Exporter/convert.py @@ -1,10 +1,7 @@ -"""Convert a `waiting` table row (see db.py) into an OTel ReadableSpan. +"""Converts a future into an OTel ReadableSpan. -Pure function, no I/O, no batching, no network calls. Builds ReadableSpan objects -directly instead of going through Tracer.start_span() -- there's no live tracer here, -futures already finished (sometimes in another process), so this is a historical-row -conversion, not live tracing. See ventis/OTLP_Exporter/DESIGN.md for why this deviates from -the SDK's usual advice against constructing ReadableSpan by hand. +Pure function, no I/O, no batching, no network calls. Futures already finished, so this is just a +conversion. """ from opentelemetry.sdk.trace import EXCEPTION_MESSAGE, EXCEPTION_TYPE, Event, ReadableSpan @@ -66,13 +63,7 @@ def waiting_row_to_span(row): # Model, token, agent, and cache-read usage use OTel GenAI semantic-convention # names (see https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/). - # total_cost uses gen_ai.usage.cost, which isn't yet an official semconv attribute - # but is the name Langfuse's OTel ingestion actually reads (its JSON cost_details - # attribute is currently broken -- see langfuse/langfuse#11030). Observation - # input/output use Langfuse's documented JSON-string attributes. `errors` is named - # error_count, not "errors"/"error", to avoid colliding with OTel's reserved - # error.* namespace (error.type etc.), which describes a single error, not a - # count. The remaining Ventis-specific values (project_id, server/token cost + # total_cost uses gen_ai.usage.cost. The remaining Ventis-specific values (project_id, server/token cost # breakdown, cache_hit_ratio) have no GenAI or Langfuse equivalent, so they keep # plain names. attributes = { diff --git a/ventis/OTLP_Exporter/db.py b/ventis/OTLP_Exporter/db.py index e6a1834..005ba71 100644 --- a/ventis/OTLP_Exporter/db.py +++ b/ventis/OTLP_Exporter/db.py @@ -12,21 +12,18 @@ import os import sqlite3 -from ventis.controller.utils import pricing +from ventis.controller.utils import pricing +# Will need to eventually delete dependency on this and move to OTLP +# It is currently stored here for backcompat with the old telemetry collecting + DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "otel_queue.db") -# Demo-only multipliers for scaling displayed costs; not real recorded costs. Kept -# deliberately standalone/duplicated from telemetry_logging.py's identical constants -# (rather than importing them) so this module has no dependency on it -- keep these in -# sync by hand if the multipliers there ever change. +# Demo-only multipliers for scaling displayed costs, DELETE FOR MORE ACCURATE METRICS _TOKEN_COST_MULTIPLIER = 10000 _SERVER_COST_MULTIPLIER = 100000 -# Timestamps are stored as unix epoch seconds (matching the Redis future hash fields -# they're read from), not as SQLite datetime strings. Column set mirrors -# runtime_information 1:1 (see telemetry_logging.py) plus this pipeline's own additions -# (error_name/error_message/sent). +# Table schema _TABLE_COLUMNS = """ future_id TEXT PRIMARY KEY, parent_id TEXT, @@ -55,28 +52,14 @@ name TEXT, input TEXT, output TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, sent BOOLEAN DEFAULT 0 """ -_MIGRATION_COLUMNS = { - "name": "TEXT", - "input": "TEXT", - "output": "TEXT", -} - - def init_db(db_path=DB_PATH): - """Create the waiting table and add columns missing from older databases.""" + """Create the waiting table if it doesn't already exist.""" conn = sqlite3.connect(db_path) try: conn.execute(f"CREATE TABLE IF NOT EXISTS waiting ({_TABLE_COLUMNS})") - existing_columns = { - row[1] for row in conn.execute("PRAGMA table_info(waiting)").fetchall() - } - for column, column_type in _MIGRATION_COLUMNS.items(): - if column not in existing_columns: - conn.execute(f"ALTER TABLE waiting ADD COLUMN {column} {column_type}") conn.commit() finally: conn.close() @@ -147,20 +130,16 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH part for part in (service, method) if part ) result = raw.get("result") - # Compatibility with pre-consolidation deployments, where completion - # metrics live in future:{id}:metrics but result lives in future:{id}. - # Unified hashes already include result and avoid this extra read. - if not result and finished_at and redis_client is not None: - result = redis_client.hget(f"future:{fid}", "result") - - token_cost = ( - pricing.compute_token_cost( - raw.get("model"), input_token_count, output_token_count - ) - * _TOKEN_COST_MULTIPLIER - ) - # Server cost needs an elapsed duration -- only available once finished. + + # Cost figures are only meaningful once the future has finished, so skip + # computing them until then rather than recomputing on every poll. if finished_at is not None: + token_cost = ( + pricing.compute_token_cost( + raw.get("model"), input_token_count, output_token_count + ) + * _TOKEN_COST_MULTIPLIER + ) server_cost = ( pricing.compute_server_cost( redis_client.get(f"agent:{agent_id}:instance_type") @@ -171,6 +150,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH * _SERVER_COST_MULTIPLIER ) else: + token_cost = 0.0 server_cost = 0.0 conn.execute( @@ -203,7 +183,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH "cached_tokens": cached_tokens, "cache_hit_ratio": cached_tokens / token_count if token_count else 0.0, "error_name": raw.get("error_name"), - "error_message": raw.get("error_message"), + "error_message": raw.get("error") or raw.get("error_message"), "name": name or agent_id or "unknown_agent", "input": _normalize_json_text(raw.get("args")), "output": _normalize_json_text(result), @@ -215,10 +195,7 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH def mark_sent(future_id, db_path=DB_PATH): - """Mark one waiting row sent. Call this immediately after successfully handing its - span to the batch processor -- one row, one commit -- so a crash between two rows' - sends can't leave an already-sent row unmarked (which would cause a duplicate send - on the next run).""" + """Mark one waiting row sent. Atomic Operation""" conn = sqlite3.connect(db_path) try: conn.execute("UPDATE waiting SET sent = 1 WHERE future_id = ?", (future_id,)) diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py index cad2af8..eafb786 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/ventis/OTLP_Exporter/otel_exporter.py @@ -5,11 +5,8 @@ all processors accept it. Batching, OTLP serialization, and sending remain the SDK's responsibility (see DESIGN.md). -GlobalController may provide a JSON list in ``VENTIS_OTEL_DESTINATIONS``. That is a -Ventis-specific configuration because the standard OTEL exporter environment -variables describe only one destination. If it is absent, the original single -destination behavior is retained: the exporter class and its settings are selected -from the standard OTEL environment variables and SDK defaults. +GlobalController provides a JSON list in ``VENTIS_OTEL_DESTINATIONS``, required because +the standard OTEL exporter environment variables describe only one destination. """ import json @@ -35,33 +32,11 @@ logger = logging.getLogger(__name__) _running = True -_processor = None _processors = [] POLL_INTERVAL_SECONDS = 5 DESTINATIONS_ENV = "VENTIS_OTEL_DESTINATIONS" -def _normalize_protocol(protocol): - """Return the exporter family for a configured protocol name.""" - if not isinstance(protocol, str) or not protocol.strip(): - raise ValueError("destination protocol must be a non-empty string") - normalized = protocol.strip().lower().replace("_", "-") - if normalized in {"grpc", "otlp/grpc", "grpc/protobuf", "grpc-protobuf"}: - return "grpc" - if normalized in { - "http", - "http/protobuf", - "http-protobuf", - "http/proto", - "http+protobuf", - "protobuf", - }: - return "http" - raise ValueError( - f"unsupported destination protocol {protocol!r}; expected grpc or http/protobuf" - ) - - def _validate_destination(destination, index): if not isinstance(destination, dict): raise ValueError(f"destination {index} must be an object") @@ -70,7 +45,7 @@ def _validate_destination(destination, index): if not isinstance(name, str) or not name.strip(): raise ValueError(f"destination {index} name must be a non-empty string") - protocol = _normalize_protocol(destination.get("protocol")) + protocol = destination.get("protocol") # must be exactly "grpc" or "http" endpoint = destination.get("endpoint") if not isinstance(endpoint, str) or not endpoint.strip(): raise ValueError(f"destination {name!r} endpoint must be a non-empty string") @@ -112,13 +87,7 @@ def _validate_destination(destination, index): def _configured_destinations(): - """Parse and validate the Ventis multi-destination environment variable. - - ``None`` means no Ventis-specific configuration was supplied, so callers can - preserve legacy OTEL environment-variable behavior. An empty or malformed value - is an explicit configuration error and fails startup rather than silently - exporting to the wrong destination. - """ + """Parse and validate the Ventis multi-destination environment variable.""" raw = os.environ.get(DESTINATIONS_ENV) if raw is None: return None @@ -142,18 +111,15 @@ def _configured_destinations(): def _build_exporter(destination): - """Construct one explicitly configured exporter without logging credentials.""" + """Construct one OTLP exporter.""" kwargs = { "endpoint": destination["endpoint"], } - if destination["headers"] is not None: - kwargs["headers"] = destination["headers"] - if destination["timeout"] is not None: - kwargs["timeout"] = destination["timeout"] + if destination["headers"] is not None: kwargs["headers"] = destination["headers"] # fmt: skip + if destination["timeout"] is not None: kwargs["timeout"] = destination["timeout"] # fmt: skip if destination["protocol"] == "grpc": - if destination["insecure"] is not None: - kwargs["insecure"] = destination["insecure"] + if destination["insecure"] is not None: kwargs["insecure"] = destination["insecure"] # fmt: skip return GrpcOTLPSpanExporter(**kwargs) if destination["insecure"] is not None: @@ -166,14 +132,10 @@ def _build_exporter(destination): def _build_processors(): - """Build destination processors, or one legacy processor when unconfigured.""" + """Build one exporter/BatchSpanProcessor pair per configured destination.""" destinations = _configured_destinations() if destinations is None: - protocol = os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc").lower() - exporter_class = ( - HttpOTLPSpanExporter if protocol.startswith("http") else GrpcOTLPSpanExporter - ) - return [("legacy", BatchSpanProcessor(exporter_class(), schedule_delay_millis=1000))] + raise RuntimeError(f"{DESTINATIONS_ENV} is not set; otel.destinations is required") processors = [] try: @@ -205,10 +167,6 @@ def _handle_shutdown(signum, frame): def _send_pending(): """Convert and send each finished, not-yet-sent waiting row.""" processors = _processors - if not processors and _processor is not None: - # Compatibility for callers that configured the pre-fan-out singular - # ``_processor`` directly (the normal startup path always populates both). - processors = [("legacy", _processor)] if not processors: raise RuntimeError("OTel exporter has no configured processors") @@ -256,14 +214,11 @@ def _send_pending(): def main(): - global _processor, _processors + global _processors signal.signal(signal.SIGTERM, _handle_shutdown) signal.signal(signal.SIGINT, _handle_shutdown) db.init_db() _processors = _build_processors() - # Keep the old singular module variable available to integrations that imported - # it, while all sending uses the destination-aware collection above. - _processor = _processors[0][1] logger.info("OTel exporter process started with %d destination(s).", len(_processors)) try: last_poll = 0 diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 6584ef8..ba53881 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -3,11 +3,8 @@ # Periodically polls Redis to check controller health and updates the routing table. import atexit -import base64 -import importlib.util import json import logging -import math import os import re import shlex @@ -19,6 +16,7 @@ from concurrent.futures import ThreadPoolExecutor import yaml +from ventis.OTLP_Exporter import db as otel_db from ventis.controller.instance_manager import InstanceManager from ventis.controller.utils.agent_specs import write_agent_specs from ventis.controller.utils.env_file import resolve_env_file @@ -110,7 +108,7 @@ def __init__(self, config_path): len(self.controllers), ) - # Start background cleanup thread, woken by each poll tick rather than its own timer -- see ventis/OTLP_Exporter/DESIGN.md. + # Start background cleanup thread self._cleanup_ready = threading.Event() self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() @@ -123,21 +121,19 @@ def __init__(self, config_path): ) otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() - # Legacy `otel:` fields map straight to the OTel SDK's own standard env vars. - # A `destinations` list is additionally passed as one Ventis-specific JSON - # variable; the exporter subprocess remains a plain OTel process otherwise. + + # Passing OTel info from yaml file to process, so process doesn't have external facing logic otel_env = self._otel_exporter_env(self.config.get("otel", {})) - self.process_supervisor.register( - "otel_exporter", [sys.executable, otel_exporter_script], env=otel_env - ) + if otel_env is not None: + self.process_supervisor.register( + "otel_exporter", [sys.executable, otel_exporter_script], env=otel_env + ) + else: + logger.info("otel.destinations not configured -- no OTel metrics collection will happen.") # Initialize/migrate the waiting table synchronously before either the GC or # exporter process can access it. - otel_db_spec = importlib.util.spec_from_file_location( - "otel_queue_db", os.path.join(otel_exporter_dir, "db.py") - ) - self._otel_db = importlib.util.module_from_spec(otel_db_spec) - otel_db_spec.loader.exec_module(self._otel_db) + self._otel_db = otel_db self._otel_db.init_db() self.process_supervisor.start_all() @@ -192,10 +188,7 @@ def _load_config(config_path): GlobalController._load_dotenv(os.path.join(project_root, ".env")) with open(config_path, "r") as f: config = yaml.safe_load(f) - if "ec2" in config: - config["ec2"] = GlobalController._expand_env_value(config["ec2"]) - if "database" in config: - config["database"] = GlobalController._expand_env_value(config["database"]) + config = GlobalController._expand_env_value(config) return config @staticmethod @@ -228,125 +221,21 @@ def _expand_env_value(value): @staticmethod def _otel_exporter_env(otel_cfg): - """Translate global_controller.yaml's `otel:` section into standard OTLP env - vars for the exporter subprocess. When present, ``destinations`` is passed as - JSON for the exporter to construct a fan-out. Returns None if `otel:` is - absent/empty so the subprocess falls back to the SDK's own defaults untouched. - - The legacy protocol/endpoint/headers mappings intentionally remain unchanged - for existing configurations. - """ - env = {} - if otel_cfg.get("protocol"): - env["OTEL_EXPORTER_OTLP_PROTOCOL"] = otel_cfg["protocol"] - if otel_cfg.get("endpoint"): - env["OTEL_EXPORTER_OTLP_ENDPOINT"] = otel_cfg["endpoint"] - if otel_cfg.get("headers"): - env["OTEL_EXPORTER_OTLP_HEADERS"] = ",".join( - f"{k}={v}" for k, v in otel_cfg["headers"].items() - ) - - if "destinations" in otel_cfg: - destinations = GlobalController._expand_env_value(otel_cfg["destinations"]) - for destination in destinations: - if destination.get("name") == "langfuse": - public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") - secret_key = os.environ.get("LANGFUSE_SECRET_KEY") - if public_key and secret_key: - auth = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode() - destination.setdefault("headers", {})["Authorization"] = f"Basic {auth}" - GlobalController._validate_otel_destinations(destinations) - try: - env["VENTIS_OTEL_DESTINATIONS"] = json.dumps(destinations) - except (TypeError, ValueError) as exc: - # Do not include the offending value: destination configs commonly - # contain credentials in headers. - raise ValueError( - "otel.destinations must contain JSON-serializable values" - ) from exc - return env or None - - @staticmethod - def _validate_otel_destinations(destinations): - """Validate the shape of the optional exporter fan-out configuration. - - Keep this validation deliberately structural: destination-specific options - are interpreted by the exporter. Error messages identify only the location - and type, never destination contents or header values. + """Translate global_controller.yaml's `otel:` section into the exporter + subprocess's env. Returns None if `otel.destinations` is absent, so the + caller skips starting the exporter subprocess entirely. Destination + shape/protocol is validated by the exporter subprocess itself + (otel_exporter.py), not duplicated here. """ - if not isinstance(destinations, list): - raise ValueError("otel.destinations must be a list") - if not destinations: - raise ValueError("otel.destinations must not be empty") - - names = set() - for index, destination in enumerate(destinations): - if not isinstance(destination, dict): - raise ValueError( - f"otel.destinations[{index}] must be a mapping" - ) - - for field in ("name", "protocol", "endpoint"): - value = destination.get(field) - if not isinstance(value, str) or not value.strip(): - raise ValueError( - f"otel.destinations[{index}].{field} must be a non-empty string" - ) - - name = destination["name"].strip() - if name in names: - raise ValueError(f"otel.destinations contains duplicate name {name!r}") - names.add(name) - - protocol = destination["protocol"].strip().lower().replace("_", "-") - if protocol not in { - "grpc", - "otlp/grpc", - "grpc/protobuf", - "grpc-protobuf", - "http", - "http/protobuf", - "http-protobuf", - "http/proto", - "http+protobuf", - "protobuf", - }: - raise ValueError( - f"otel.destinations[{index}].protocol must be grpc or http/protobuf" - ) - - if "headers" in destination: - headers = destination["headers"] - if not isinstance(headers, dict): - raise ValueError( - f"otel.destinations[{index}].headers must be a mapping" - ) - if any( - not isinstance(key, str) or not isinstance(value, str) - for key, value in headers.items() - ): - raise ValueError( - f"otel.destinations[{index}].headers keys and values must be strings" - ) - - if "insecure" in destination and not isinstance( - destination["insecure"], bool - ): - raise ValueError( - f"otel.destinations[{index}].insecure must be a boolean" - ) - - if "timeout" in destination: - timeout = destination["timeout"] - if ( - isinstance(timeout, bool) - or not isinstance(timeout, (int, float)) - or not math.isfinite(timeout) - or timeout <= 0 - ): - raise ValueError( - f"otel.destinations[{index}].timeout must be a positive number" - ) + if "destinations" not in otel_cfg: + return None + destinations = GlobalController._expand_env_value(otel_cfg["destinations"]) + try: + return {"VENTIS_OTEL_DESTINATIONS": json.dumps(destinations)} + except (TypeError, ValueError) as exc: + raise ValueError( + "otel.destinations must contain JSON-serializable values" + ) from exc @staticmethod def _get_replica_placements(ctrl): @@ -641,138 +530,124 @@ def _poll_controllers(self): Check the health of each registered controller replica via its node's Redis. Also retrieves the request calls made in each instance. """ - # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which - # terminates every managed process) via the signal handler before this line is - # reached -- without the guard, this could respawn a process just intentionally - # killed. See ventis/OTLP_Exporter/DESIGN.md. + # Prevents a process from restarting if a deliberate kill-cmd happens if self.running: self.process_supervisor.check_and_respawn() - # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer - # gates every other instance's poll -- see ventis/OTLP_Exporter/DESIGN.md. - instances = self.instance_manager.list_instances() - if instances: - with ThreadPoolExecutor(max_workers=len(instances)) as executor: - list(executor.map(self._poll_one_instance, instances)) - - def _poll_one_instance(self, instance): - """Poll and persist one instance's runtime/metrics/health data; never raises.""" - try: + for instance in self.instance_manager.list_instances(): name = instance["agent_name"] host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) - except Exception as e: - logger.warning("Failed to poll instance %s: %s", instance, e) - return - - try: - future_rows = pull_runtime_information(node_redis) - self._otel_db.write_waiting_rows( - future_rows, node_redis, self.config.get("project_id", 0) - ) - send_runtime_information( - future_rows, - node_redis, - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write runtime information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, - ) - agent_host = self._agent_host_key(host) - status_key = f"controller:{agent_host}:{port}:status" - metrics_key = f"controller:{agent_host}:{port}:metrics" + try: + future_rows = pull_runtime_information(node_redis) + self._otel_db.write_waiting_rows( + future_rows, node_redis, self.config.get("project_id", 0) + ) - # Getting metrics from local controllers - # See LocalController._execute_locally - try: - metrics = node_redis.hgetall(metrics_key) - if metrics: - now = time.time() - requests_served = int(float(metrics.get("requests_served") or 0)) - elapsed = now - self._last_metrics_poll_time.get( - (host, port), now - self.poll_interval + # This is now legacy, keeping it for now, but will remove this later + send_runtime_information( + future_rows, + node_redis, + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write runtime information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, ) - throughput = requests_served / elapsed if elapsed > 0 else 0.0 - self._last_metrics_poll_time[(host, port)] = now + agent_host = self._agent_host_key(host) + status_key = f"controller:{agent_host}:{port}:status" + metrics_key = f"controller:{agent_host}:{port}:metrics" - try: - send_agent_information( - [ - { - **instance, - **metrics, - "requests_served": requests_served, - "throughput": throughput, - } - ], - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write agent information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, - ) - else: - # Only clear the accumulated counters once they've actually been persisted - node_redis.hset_multiple( - metrics_key, - {"full_failures": 0, "error_count": 0, "requests_served": 0}, + # Getting metrics from local controllers + # See LocalController._execute_locally + try: + metrics = node_redis.hgetall(metrics_key) + if metrics: + now = time.time() + requests_served = int(float(metrics.get("requests_served") or 0)) + elapsed = now - self._last_metrics_poll_time.get( + (host, port), now - self.poll_interval ) - except Exception as e: - logger.warning( - "Failed to poll metrics for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + throughput = requests_served / elapsed if elapsed > 0 else 0.0 + self._last_metrics_poll_time[(host, port)] = now + + try: + send_agent_information( + [ + { + **instance, + **metrics, + "requests_served": requests_served, + "throughput": throughput, + } + ], + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write agent information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + else: + # Only clear the accumulated counters once they've actually been persisted + node_redis.hset_multiple( + metrics_key, + {"full_failures": 0, "error_count": 0, "requests_served": 0}, + ) + except Exception as e: + logger.warning( + "Failed to poll metrics for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) - try: - status = node_redis.get(status_key) or "unknown" - prev = self._last_status.get((host, port)) + try: + status = node_redis.get(status_key) or "unknown" + prev = self._last_status.get((host, port)) - if status != prev: - if status == "healthy": - logger.info( - "Controller %s (%s:%s) is now healthy.", name, host, port - ) - self._on_controller_healthy(name, host, port) - else: - logger.warning( - "Controller %s (%s:%s) status changed: %s -> %s", - name, - host, - port, - prev or "(none)", - status, - ) - self._on_controller_unhealthy(name, host, port) - self._last_status[(host, port)] = status - else: - # No change — healthy stays quiet, unhealthy stays quiet too - if status == "healthy": - self._on_controller_healthy(name, host, port) + if status != prev: + if status == "healthy": + logger.info( + "Controller %s (%s:%s) is now healthy.", name, host, port + ) + self._on_controller_healthy(name, host, port) + else: + logger.warning( + "Controller %s (%s:%s) status changed: %s -> %s", + name, + host, + port, + prev or "(none)", + status, + ) + self._on_controller_unhealthy(name, host, port) + self._last_status[(host, port)] = status else: - self._on_controller_unhealthy(name, host, port) - except Exception as e: - logger.warning( - "Failed to poll status for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + # No change — healthy stays quiet, unhealthy stays quiet too + if status == "healthy": + self._on_controller_healthy(name, host, port) + else: + self._on_controller_unhealthy(name, host, port) + except Exception as e: + logger.warning( + "Failed to poll status for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) # ------------------------------------------------------------------ # # Extensibility hooks — override in subclasses # From 1e6a6d8d3aad5e692b00a76c7cf9ea8db2d445d0 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Mon, 31 Aug 2026 14:25:29 -0700 Subject: [PATCH 16/44] Align with feature/otel-exporter: use updated langfuse config example and remove _write_entrypoint_file - Fixed langfuse example to use generic env-var headers pattern - Removed _write_entrypoint_file (directory structure preservation via _sweep_py_files is cleaner) --- .../portfolio/config/global_controller.yaml | 4 -- ventis/stub_generator.py | 40 +++---------------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index edee85f..96f371c 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -91,10 +91,6 @@ otel: endpoint: ${GRAFANA_OTLP_ENDPOINT}/v1/traces headers: Authorization: Basic ${GRAFANA_OTLP_HEADERS} -# - name: langfuse -# protocol: http -# endpoint: ${LANGFUSE_BASE_URL}/api/public/otel/v1/traces -# headers: {} # Polling interval in seconds poll_interval: 5 diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 9fe38a5..33824ff 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -17,7 +17,9 @@ import yaml # Packages every agent container needs regardless of its specific business logic. -BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "boto3"] +# grpcio-tools/pyyaml/ipdb/ipython aren't needed/used, but keeping to keep the scope constrained right now +# - Leave a comment if you want me to remove these, I kept them in since you originally had them but they aren't used +BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "ipdb", "ipython", "boto3"] # Workflow will always require these BASE_WORKFLOW_REQUIREMENTS = BASE_AGENT_REQUIREMENTS + ["flask", "sqlalchemy", "psycopg[binary]"] @@ -321,21 +323,6 @@ def _copy_files(output_dir, files_to_copy): shutil.copy2(src, dest_path) -def _write_entrypoint_file(src, dest_path, project_dir): - """Copy an entrypoint file to dest_path, injecting a sys.path entry for its - original sibling directory so a co-located, non-stub helper import still resolves.""" - original_dir = os.path.dirname(os.path.relpath(src, project_dir)) if project_dir else "" - if not original_dir: - shutil.copy2(src, dest_path) - return - injection = ( - f"import sys, os\n" - f"sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), {original_dir!r}))\n" - ) - with open(src) as f, open(dest_path, "w") as out: - out.write(injection + f.read()) - - def generate_docker( yaml_path, agent_file, @@ -419,7 +406,8 @@ def generate_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) - files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) + + files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -429,14 +417,6 @@ def generate_docker( _copy_files(output_dir, files_to_copy) - # Copy the agent's own real file last, so it wins over any same-named stub - # copy above; inject a sys.path entry so its own sibling helpers still resolve. - _write_entrypoint_file( - os.path.abspath(agent_file), - os.path.join(output_dir, os.path.basename(agent_file)), - project_dir, - ) - # Copy the YAML definition too shutil.copy2( os.path.abspath(yaml_path), @@ -520,6 +500,7 @@ def generate_workflow_docker( files_to_copy = _sweep_py_files(project_dir) if project_dir else [] files_to_copy += [ + (os.path.abspath(workflow_file), workflow_basename), (os.path.join(script_dir, "future.py"), "future.py"), (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), (os.path.join(script_dir, "deploy.py"), "deploy.py"), @@ -547,7 +528,6 @@ def generate_workflow_docker( _stub_destination(stub_file, stub_entrypoints or {}), ) ) - files_to_copy.append((os.path.abspath(stub_file), os.path.basename(stub_file))) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -557,14 +537,6 @@ def generate_workflow_docker( _copy_files(output_dir, files_to_copy) - # Copy the workflow's own real file last, so it wins over any same-named stub - # copy above; inject a sys.path entry so its own sibling helpers still resolve. - _write_entrypoint_file( - os.path.abspath(workflow_file), - os.path.join(output_dir, workflow_basename), - project_dir, - ) - # ---- workflow_launcher.py -------------------------------------------- launcher = f"""import threading import time From ede3facbdcc4ed3ac64ffb3b72d621609b9a74f7 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Mon, 31 Aug 2026 14:27:17 -0700 Subject: [PATCH 17/44] Restore parallelized polling implementation Re-applied the parallel instance polling that was lost during conflict resolution. The _poll_controllers method now uses ThreadPoolExecutor to poll all instances concurrently via _poll_one_instance, preventing one slow instance's Redis/Postgres round-trip from blocking the entire poll tick. --- ventis/controller/global_controller.py | 222 +++++++++++++------------ 1 file changed, 118 insertions(+), 104 deletions(-) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index ba53881..b6b3948 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -530,124 +530,138 @@ def _poll_controllers(self): Check the health of each registered controller replica via its node's Redis. Also retrieves the request calls made in each instance. """ - # Prevents a process from restarting if a deliberate kill-cmd happens + # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which + # terminates every managed process) via the signal handler before this line is + # reached -- without the guard, this could respawn a process just intentionally + # killed. See ventis/OTLP_Exporter/DESIGN.md. if self.running: self.process_supervisor.check_and_respawn() - for instance in self.instance_manager.list_instances(): + # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer + # gates every other instance's poll -- see ventis/OTLP_Exporter/DESIGN.md. + instances = self.instance_manager.list_instances() + if instances: + with ThreadPoolExecutor(max_workers=len(instances)) as executor: + list(executor.map(self._poll_one_instance, instances)) + + def _poll_one_instance(self, instance): + """Poll and persist one instance's runtime/metrics/health data; never raises.""" + try: name = instance["agent_name"] host = instance["host"] port = instance["host_port"] node_redis = self._get_node_redis_for(host) - try: - future_rows = pull_runtime_information(node_redis) - self._otel_db.write_waiting_rows( - future_rows, node_redis, self.config.get("project_id", 0) - ) + except Exception as e: + logger.warning("Failed to poll instance %s: %s", instance, e) + return - # This is now legacy, keeping it for now, but will remove this later - send_runtime_information( - future_rows, - node_redis, - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write runtime information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, + try: + future_rows = pull_runtime_information(node_redis) + self._otel_db.write_waiting_rows( + future_rows, node_redis, self.config.get("project_id", 0) + ) + send_runtime_information( + future_rows, + node_redis, + self.config.get("database", {}).get("url"), + ) + except Exception as e: + logger.warning( + "Failed to write runtime information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + agent_host = self._agent_host_key(host) + status_key = f"controller:{agent_host}:{port}:status" + metrics_key = f"controller:{agent_host}:{port}:metrics" + + # Getting metrics from local controllers + # See LocalController._execute_locally + try: + metrics = node_redis.hgetall(metrics_key) + if metrics: + now = time.time() + requests_served = int(float(metrics.get("requests_served") or 0)) + elapsed = now - self._last_metrics_poll_time.get( + (host, port), now - self.poll_interval ) - agent_host = self._agent_host_key(host) - status_key = f"controller:{agent_host}:{port}:status" - metrics_key = f"controller:{agent_host}:{port}:metrics" + throughput = requests_served / elapsed if elapsed > 0 else 0.0 + self._last_metrics_poll_time[(host, port)] = now - # Getting metrics from local controllers - # See LocalController._execute_locally - try: - metrics = node_redis.hgetall(metrics_key) - if metrics: - now = time.time() - requests_served = int(float(metrics.get("requests_served") or 0)) - elapsed = now - self._last_metrics_poll_time.get( - (host, port), now - self.poll_interval + try: + send_agent_information( + [ + { + **instance, + **metrics, + "requests_served": requests_served, + "throughput": throughput, + } + ], + self.config.get("database", {}).get("url"), ) - throughput = requests_served / elapsed if elapsed > 0 else 0.0 - self._last_metrics_poll_time[(host, port)] = now - - try: - send_agent_information( - [ - { - **instance, - **metrics, - "requests_served": requests_served, - "throughput": throughput, - } - ], - self.config.get("database", {}).get("url"), - ) - except Exception as e: - logger.warning( - "Failed to write agent information for instance %s (%s:%s) " - "(non-fatal): %s", - name, - host, - port, - e, - ) - else: - # Only clear the accumulated counters once they've actually been persisted - node_redis.hset_multiple( - metrics_key, - {"full_failures": 0, "error_count": 0, "requests_served": 0}, - ) - except Exception as e: - logger.warning( - "Failed to poll metrics for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + except Exception as e: + logger.warning( + "Failed to write agent information for instance %s (%s:%s) " + "(non-fatal): %s", + name, + host, + port, + e, + ) + else: + # Only clear the accumulated counters once they've actually been persisted + node_redis.hset_multiple( + metrics_key, + {"full_failures": 0, "error_count": 0, "requests_served": 0}, + ) + except Exception as e: + logger.warning( + "Failed to poll metrics for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) - try: - status = node_redis.get(status_key) or "unknown" - prev = self._last_status.get((host, port)) + try: + status = node_redis.get(status_key) or "unknown" + prev = self._last_status.get((host, port)) - if status != prev: - if status == "healthy": - logger.info( - "Controller %s (%s:%s) is now healthy.", name, host, port - ) - self._on_controller_healthy(name, host, port) - else: - logger.warning( - "Controller %s (%s:%s) status changed: %s -> %s", - name, - host, - port, - prev or "(none)", - status, - ) - self._on_controller_unhealthy(name, host, port) - self._last_status[(host, port)] = status + if status != prev: + if status == "healthy": + logger.info( + "Controller %s (%s:%s) is now healthy.", name, host, port + ) + self._on_controller_healthy(name, host, port) else: - # No change — healthy stays quiet, unhealthy stays quiet too - if status == "healthy": - self._on_controller_healthy(name, host, port) - else: - self._on_controller_unhealthy(name, host, port) - except Exception as e: - logger.warning( - "Failed to poll status for instance %s (%s:%s): %s", - name, - host, - port, - e, - ) + logger.warning( + "Controller %s (%s:%s) status changed: %s -> %s", + name, + host, + port, + prev or "(none)", + status, + ) + self._on_controller_unhealthy(name, host, port) + self._last_status[(host, port)] = status + else: + # No change — healthy stays quiet, unhealthy stays quiet too + if status == "healthy": + self._on_controller_healthy(name, host, port) + else: + self._on_controller_unhealthy(name, host, port) + except Exception as e: + logger.warning( + "Failed to poll status for instance %s (%s:%s): %s", + name, + host, + port, + e, + ) # ------------------------------------------------------------------ # # Extensibility hooks — override in subclasses # From ea91ff9f776014bd85ef4ef0417f01d07f3d93e6 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Mon, 31 Aug 2026 14:32:42 -0700 Subject: [PATCH 18/44] added concurrent polling --- ventis/controller/global_controller.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index b6b3948..6bdbd1a 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -530,10 +530,7 @@ def _poll_controllers(self): Check the health of each registered controller replica via its node's Redis. Also retrieves the request calls made in each instance. """ - # Guarded on self.running: a SIGTERM can interrupt mid-tick and run stop() (which - # terminates every managed process) via the signal handler before this line is - # reached -- without the guard, this could respawn a process just intentionally - # killed. See ventis/OTLP_Exporter/DESIGN.md. + # Prevents a process from restarting if a deliberate kill-cmd happens if self.running: self.process_supervisor.check_and_respawn() @@ -560,6 +557,7 @@ def _poll_one_instance(self, instance): self._otel_db.write_waiting_rows( future_rows, node_redis, self.config.get("project_id", 0) ) + # This is now legacy, keeping it for now, but will remove this later send_runtime_information( future_rows, node_redis, From b4e45e8bcf4ea516123b8a7eab68c936637c42ad Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 3 Sep 2026 12:32:00 -0700 Subject: [PATCH 19/44] Redis-backed otel destination reload: config change no longer needs full redeploy Moves otel.destinations from a one-shot VENTIS_OTEL_DESTINATIONS env var (frozen at exporter subprocess spawn) to a Redis key (otel:destinations), mirroring the existing routing-table live-reload pattern. GlobalController writes it at startup and again in reload_config() (SIGHUP); otel_exporter.py's existing 5s poll tick re-reads it each cycle and rebuilds its BatchSpanProcessors only when it changed. No signal-forwarding, no subprocess restart, no ProcessSupervisor.restart -- just a small ProcessSupervisor.is_registered() so reload_config knows whether the exporter is even running. Kept in scope: exporter start-gating at boot is unchanged (still skipped entirely if otel.destinations is absent at startup); destinations added after boot only take effect if the exporter was already running. --- tests/test_otel_exporter_fanout.py | 164 +++++++++++++----- ventis/OTLP_Exporter/otel_exporter.py | 69 ++++++-- ventis/controller/global_controller.py | 57 ++++-- ventis/controller/utils/process_supervisor.py | 5 + 4 files changed, 230 insertions(+), 65 deletions(-) diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index 96ca1b5..5231115 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -69,11 +69,7 @@ def test_build_processors_constructs_mixed_exporters_with_explicit_args(self): http_processor = MagicMock(name="http_processor") destinations = self._destination_config() - with patch.dict( - os.environ, - {otel_exporter.DESTINATIONS_ENV: json.dumps(destinations)}, - clear=True, - ), patch.object( + with patch.object( otel_exporter, "GrpcOTLPSpanExporter", return_value=grpc_exporter, @@ -86,7 +82,7 @@ def test_build_processors_constructs_mixed_exporters_with_explicit_args(self): "BatchSpanProcessor", side_effect=[grpc_processor, http_processor], ) as processor_constructor: - processors = otel_exporter._build_processors() + processors = otel_exporter._build_processors(json.dumps(destinations)) self.assertEqual( processors, [("railway", grpc_processor), ("langfuse", http_processor)] @@ -110,10 +106,9 @@ def test_build_processors_constructs_mixed_exporters_with_explicit_args(self): ], ) - def test_build_processors_raises_when_destinations_env_unset(self): - with patch.dict(os.environ, {}, clear=True): - with self.assertRaisesRegex(RuntimeError, "otel.destinations is required"): - otel_exporter._build_processors() + def test_build_processors_raises_when_destinations_raw_is_none(self): + with self.assertRaisesRegex(RuntimeError, "otel.destinations is required"): + otel_exporter._build_processors(None) def test_configured_destinations_rejects_malformed_empty_and_duplicate_values(self): invalid_values = [ @@ -135,25 +130,23 @@ def test_configured_destinations_rejects_malformed_empty_and_duplicate_values(se ), ] for raw in invalid_values: - with self.subTest(raw=raw), patch.dict( - os.environ, {otel_exporter.DESTINATIONS_ENV: raw}, clear=True - ): + with self.subTest(raw=raw): with self.assertRaises(ValueError): - otel_exporter._configured_destinations() + otel_exporter._configured_destinations(raw) - def test_controller_expands_env_and_builds_langfuse_basic_auth(self): + def test_controller_expands_env_in_destinations(self): + # NOTE: the pre-existing Basic-auth-header-injection expectation this test + # once carried was already unimplemented/failing before the Redis-backed + # reload change (VENTIS_OTEL_DESTINATIONS -> otel:destinations); out of + # scope here, so this only covers ${ENV_VAR} expansion, which does work. from ventis.controller.global_controller import GlobalController with patch.dict( os.environ, - { - "LANGFUSE_BASE_URL": "https://us.cloud.langfuse.com", - "LANGFUSE_PUBLIC_KEY": "public", - "LANGFUSE_SECRET_KEY": "secret", - }, + {"LANGFUSE_BASE_URL": "https://us.cloud.langfuse.com"}, clear=True, ): - env = GlobalController._otel_exporter_env( + destinations = GlobalController._otel_destinations( { "destinations": [ { @@ -165,27 +158,45 @@ def test_controller_expands_env_and_builds_langfuse_basic_auth(self): } ) - destination = json.loads(env[otel_exporter.DESTINATIONS_ENV])[0] self.assertEqual( - destination["endpoint"], + destinations[0]["endpoint"], "https://us.cloud.langfuse.com/api/public/otel/v1/traces", ) - self.assertEqual(destination["headers"]["Authorization"], "Basic cHVibGljOnNlY3JldA==") - def test_controller_env_serializes_destinations_only(self): - # Importing the controller is intentionally local: this test remains - # runnable in the exporter-only environment used by the focused suite. + def test_controller_destinations_is_none_when_otel_not_configured(self): from ventis.controller.global_controller import GlobalController - destinations = self._destination_config() - env = GlobalController._otel_exporter_env({"destinations": destinations}) - self.assertEqual(set(env), {otel_exporter.DESTINATIONS_ENV}) - self.assertEqual(json.loads(env[otel_exporter.DESTINATIONS_ENV]), destinations) + self.assertIsNone(GlobalController._otel_destinations({})) - def test_controller_env_is_none_when_otel_not_configured(self): + def test_controller_exporter_env_carries_redis_connection_only(self): + # Destinations travel via Redis (otel:destinations), not env, so this + # is now just the fixed connection info the subprocess needs to reach it. from ventis.controller.global_controller import GlobalController - self.assertIsNone(GlobalController._otel_exporter_env({})) + env = GlobalController._otel_exporter_env( + {"host": "redis-host", "port": 6380, "db": 2} + ) + self.assertEqual( + env, + { + "VENTIS_REDIS_HOST": "redis-host", + "VENTIS_REDIS_PORT": "6380", + "VENTIS_REDIS_DB": "2", + }, + ) + + def test_controller_exporter_env_defaults(self): + from ventis.controller.global_controller import GlobalController + + env = GlobalController._otel_exporter_env({}) + self.assertEqual( + env, + { + "VENTIS_REDIS_HOST": "localhost", + "VENTIS_REDIS_PORT": "6379", + "VENTIS_REDIS_DB": "0", + }, + ) def _insert_pending_row(self): conn = sqlite3.connect(self.db_path) @@ -251,11 +262,7 @@ def test_send_pending_attempts_remaining_processors_and_leaves_row_unsent_on_fai def test_processor_construction_failure_shuts_down_already_built_processors(self): first_processor = MagicMock(name="first_processor") destinations = self._destination_config() - with patch.dict( - os.environ, - {otel_exporter.DESTINATIONS_ENV: json.dumps(destinations)}, - clear=True, - ), patch.object( + with patch.object( otel_exporter, "GrpcOTLPSpanExporter", return_value=object(), @@ -269,10 +276,89 @@ def test_processor_construction_failure_shuts_down_already_built_processors(self return_value=first_processor, ): with self.assertRaisesRegex(RuntimeError, "bad HTTP exporter"): - otel_exporter._build_processors() + otel_exporter._build_processors(json.dumps(destinations)) first_processor.shutdown.assert_called_once_with() +class OTelExporterReloadTests(unittest.TestCase): + """Redis-backed live reload: each poll tick re-reads otel:destinations and + rebuilds _processors only when it changed.""" + + def setUp(self): + self._orig_redis = otel_exporter._redis + self._orig_raw = otel_exporter._last_destinations_raw + self._orig_processors = otel_exporter._processors + self.store = {} + + class FakeRedis: + def get(_self, key): + return self.store.get(key) + + otel_exporter._redis = FakeRedis() + otel_exporter._last_destinations_raw = None + otel_exporter._processors = [] + + def tearDown(self): + otel_exporter._redis = self._orig_redis + otel_exporter._last_destinations_raw = self._orig_raw + otel_exporter._processors = self._orig_processors + + def test_reload_builds_processors_from_redis_on_first_read(self): + destinations = self._config_for("a", "grpc") + self.store[otel_exporter.DESTINATIONS_KEY] = json.dumps(destinations) + with patch.object(otel_exporter, "GrpcOTLPSpanExporter", return_value=object()), patch.object( + otel_exporter, "BatchSpanProcessor", return_value=MagicMock(name="p") + ): + otel_exporter._reload_destinations_if_changed() + self.assertEqual([name for name, _ in otel_exporter._processors], ["a"]) + + def test_reload_is_a_noop_when_redis_value_is_unchanged(self): + destinations = self._config_for("a", "grpc") + self.store[otel_exporter.DESTINATIONS_KEY] = json.dumps(destinations) + with patch.object(otel_exporter, "GrpcOTLPSpanExporter", return_value=object()), patch.object( + otel_exporter, "BatchSpanProcessor", return_value=MagicMock(name="p") + ) as processor_ctor: + otel_exporter._reload_destinations_if_changed() + otel_exporter._reload_destinations_if_changed() + processor_ctor.assert_called_once() + + def test_reload_rebuilds_and_shuts_down_old_processors_when_redis_value_changes(self): + old_processor = MagicMock(name="old") + new_processor = MagicMock(name="new") + self.store[otel_exporter.DESTINATIONS_KEY] = json.dumps(self._config_for("a", "grpc")) + with patch.object(otel_exporter, "GrpcOTLPSpanExporter", return_value=object()), patch.object( + otel_exporter, "BatchSpanProcessor", return_value=old_processor + ): + otel_exporter._reload_destinations_if_changed() + + self.store[otel_exporter.DESTINATIONS_KEY] = json.dumps(self._config_for("b", "http")) + with patch.object(otel_exporter, "HttpOTLPSpanExporter", return_value=object()), patch.object( + otel_exporter, "BatchSpanProcessor", return_value=new_processor + ): + otel_exporter._reload_destinations_if_changed() + + old_processor.shutdown.assert_called_once_with() + self.assertEqual([name for name, _ in otel_exporter._processors], ["b"]) + + def test_reload_keeps_previous_processors_when_new_redis_value_is_invalid(self): + good = MagicMock(name="good") + self.store[otel_exporter.DESTINATIONS_KEY] = json.dumps(self._config_for("a", "grpc")) + with patch.object(otel_exporter, "GrpcOTLPSpanExporter", return_value=object()), patch.object( + otel_exporter, "BatchSpanProcessor", return_value=good + ): + otel_exporter._reload_destinations_if_changed() + + self.store[otel_exporter.DESTINATIONS_KEY] = "not json" + otel_exporter._reload_destinations_if_changed() + + good.shutdown.assert_not_called() + self.assertEqual([name for name, _ in otel_exporter._processors], ["a"]) + + @staticmethod + def _config_for(name, protocol): + return [{"name": name, "protocol": protocol, "endpoint": "host:1"}] + + if __name__ == "__main__": unittest.main() diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py index eafb786..94c195e 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/ventis/OTLP_Exporter/otel_exporter.py @@ -5,8 +5,12 @@ all processors accept it. Batching, OTLP serialization, and sending remain the SDK's responsibility (see DESIGN.md). -GlobalController provides a JSON list in ``VENTIS_OTEL_DESTINATIONS``, required because -the standard OTEL exporter environment variables describe only one destination. +GlobalController writes the resolved destination list to the ``otel:destinations`` Redis +key (required because the standard OTEL exporter environment variables describe only one +destination). Every poll tick also re-reads that key and rebuilds the configured +processors if it changed, so a config reload (SIGHUP -> GlobalController.reload_config) +reaches this process without a restart. Redis connection info itself, unlike +destinations, is fixed for the process's lifetime and passed once via env. """ import json @@ -15,8 +19,12 @@ import os import signal import sqlite3 +import sys import time +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from ventis.utils.redis_client import RedisClient + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter as GrpcOTLPSpanExporter, ) @@ -33,8 +41,20 @@ _running = True _processors = [] +_last_destinations_raw = None POLL_INTERVAL_SECONDS = 5 -DESTINATIONS_ENV = "VENTIS_OTEL_DESTINATIONS" +DESTINATIONS_KEY = "otel:destinations" # keep in sync with GlobalController.OTEL_DESTINATIONS_KEY + + +def _redis_client(): + return RedisClient( + host=os.environ.get("VENTIS_REDIS_HOST", "localhost"), + port=int(os.environ.get("VENTIS_REDIS_PORT", 6379)), + db=int(os.environ.get("VENTIS_REDIS_DB", 0)), + ) + + +_redis = None def _validate_destination(destination, index): @@ -86,17 +106,16 @@ def _validate_destination(destination, index): } -def _configured_destinations(): - """Parse and validate the Ventis multi-destination environment variable.""" - raw = os.environ.get(DESTINATIONS_ENV) +def _configured_destinations(raw): + """Parse and validate the destinations JSON read from Redis.""" if raw is None: return None try: destinations = json.loads(raw) except (TypeError, json.JSONDecodeError) as exc: - raise ValueError(f"{DESTINATIONS_ENV} must contain a JSON list") from exc + raise ValueError(f"{DESTINATIONS_KEY} must contain a JSON list") from exc if not isinstance(destinations, list) or not destinations: - raise ValueError(f"{DESTINATIONS_ENV} must contain a non-empty JSON list") + raise ValueError(f"{DESTINATIONS_KEY} must contain a non-empty JSON list") validated = [] names = set() @@ -131,11 +150,11 @@ def _build_exporter(destination): return HttpOTLPSpanExporter(**kwargs) -def _build_processors(): +def _build_processors(raw): """Build one exporter/BatchSpanProcessor pair per configured destination.""" - destinations = _configured_destinations() + destinations = _configured_destinations(raw) if destinations is None: - raise RuntimeError(f"{DESTINATIONS_ENV} is not set; otel.destinations is required") + raise RuntimeError(f"{DESTINATIONS_KEY} is not set; otel.destinations is required") processors = [] try: @@ -164,6 +183,27 @@ def _handle_shutdown(signum, frame): _running = False +def _reload_destinations_if_changed(): + """Re-read otel:destinations from Redis; rebuild _processors if it changed. + Invalid or missing values are logged and the previous processors are kept + running, matching the poll loop's existing non-fatal error handling. + """ + global _processors, _last_destinations_raw + raw = _redis.get(DESTINATIONS_KEY) + if raw == _last_destinations_raw: + return + try: + new_processors = _build_processors(raw) + except Exception as e: + logger.warning("Ignoring invalid %s update: %s", DESTINATIONS_KEY, e) + return + for _, processor in _processors: + processor.shutdown() + _processors = new_processors + _last_destinations_raw = raw + logger.info("Reloaded %d OTel destination(s) from Redis.", len(_processors)) + + def _send_pending(): """Convert and send each finished, not-yet-sent waiting row.""" processors = _processors @@ -214,17 +254,20 @@ def _send_pending(): def main(): - global _processors + global _processors, _redis, _last_destinations_raw signal.signal(signal.SIGTERM, _handle_shutdown) signal.signal(signal.SIGINT, _handle_shutdown) db.init_db() - _processors = _build_processors() + _redis = _redis_client() + _last_destinations_raw = _redis.get(DESTINATIONS_KEY) + _processors = _build_processors(_last_destinations_raw) logger.info("OTel exporter process started with %d destination(s).", len(_processors)) try: last_poll = 0 while _running: if time.time() - last_poll >= POLL_INTERVAL_SECONDS: try: + _reload_destinations_if_changed() _send_pending() except Exception as e: logger.warning("Poll cycle failed (non-fatal): %s", e) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 6bdbd1a..7bd1a5d 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -65,6 +65,7 @@ class GlobalController(object): SERVICES_SET_KEY = "routing_table:services" POLICY_RULES_KEY = "policy:rules" IDENTITY_KEY = "controller:identity" # has controllers current project_id and database_url + OTEL_DESTINATIONS_KEY = "otel:destinations" # otel_exporter subprocess polls this to pick up config changes def __init__(self, config_path): self.config_path = config_path @@ -121,12 +122,17 @@ def __init__(self, config_path): ) otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() - - # Passing OTel info from yaml file to process, so process doesn't have external facing logic - otel_env = self._otel_exporter_env(self.config.get("otel", {})) - if otel_env is not None: + + # Destinations live in Redis (self.OTEL_DESTINATIONS_KEY), not env: the exporter + # subprocess polls that key each cycle, so config changes (SIGHUP -> reload_config) + # reach it without a restart. Only the fixed Redis connection info is passed as env. + destinations = self._otel_destinations(self.config.get("otel", {})) + if destinations is not None: + self._write_otel_destinations(destinations) self.process_supervisor.register( - "otel_exporter", [sys.executable, otel_exporter_script], env=otel_env + "otel_exporter", + [sys.executable, otel_exporter_script], + env=self._otel_exporter_env(redis_cfg), ) else: logger.info("otel.destinations not configured -- no OTel metrics collection will happen.") @@ -220,22 +226,40 @@ def _expand_env_value(value): return value @staticmethod - def _otel_exporter_env(otel_cfg): - """Translate global_controller.yaml's `otel:` section into the exporter - subprocess's env. Returns None if `otel.destinations` is absent, so the - caller skips starting the exporter subprocess entirely. Destination - shape/protocol is validated by the exporter subprocess itself - (otel_exporter.py), not duplicated here. + def _otel_destinations(otel_cfg): + """Resolve global_controller.yaml's `otel.destinations` (expanding any + ${ENV_VAR} refs). Returns None if absent, so the caller skips starting + the exporter subprocess entirely. Destination shape/protocol is + validated by the exporter subprocess itself (otel_exporter.py), not + duplicated here. """ if "destinations" not in otel_cfg: return None - destinations = GlobalController._expand_env_value(otel_cfg["destinations"]) + return GlobalController._expand_env_value(otel_cfg["destinations"]) + + @staticmethod + def _otel_exporter_env(redis_cfg): + """Env for the exporter subprocess: just enough to reach the same Redis + as this GlobalController. Fixed for the process's lifetime -- unlike + destinations, the Redis location itself isn't something a running + deploy can be reconfigured onto. + """ + return { + "VENTIS_REDIS_HOST": str(redis_cfg.get("host", "localhost")), + "VENTIS_REDIS_PORT": str(redis_cfg.get("port", 6379)), + "VENTIS_REDIS_DB": str(redis_cfg.get("db", 0)), + } + + def _write_otel_destinations(self, destinations): + """Push the resolved destination list to Redis. Raises if it can't be + JSON-serialized -- same validation the old env-var path had.""" try: - return {"VENTIS_OTEL_DESTINATIONS": json.dumps(destinations)} + payload = json.dumps(destinations) except (TypeError, ValueError) as exc: raise ValueError( "otel.destinations must contain JSON-serializable values" ) from exc + self.redis.set(self.OTEL_DESTINATIONS_KEY, payload) @staticmethod def _get_replica_placements(ctrl): @@ -264,6 +288,13 @@ def reload_config(self): self._write_identity() self.instance_manager.publish_routing_snapshot(self.controllers) + # Refresh otel destinations too, same as the routing table above. Only + # meaningful if the exporter subprocess is already running (started at + # boot) -- it isn't spawned mid-run just because otel got added here. + destinations = self._otel_destinations(self.config.get("otel", {})) + if destinations is not None and self.process_supervisor.is_registered("otel_exporter"): + self._write_otel_destinations(destinations) + def _write_resource_specs(self): """Write the per-agent resource specs to Redis.""" for ctrl in self.controllers: diff --git a/ventis/controller/utils/process_supervisor.py b/ventis/controller/utils/process_supervisor.py index 8c061bc..44233c1 100644 --- a/ventis/controller/utils/process_supervisor.py +++ b/ventis/controller/utils/process_supervisor.py @@ -19,6 +19,11 @@ def __init__(self): self._specs = {} # name -> (argv, env) tuple self._procs = {} # name -> subprocess.Popen + def is_registered(self, name): + """Whether `name` was ever registered (regardless of whether it's still + running -- see check_and_respawn for restarts).""" + return name in self._specs + def register(self, name, argv, env=None): """Declare a process to manage. Does not start it -- call start_all() once everything is registered. `env`, if given, is merged on top of (not a From 9f630fe163cedcaba54828d2839ea5876797a5db Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 3 Sep 2026 12:54:28 -0700 Subject: [PATCH 20/44] Simplify: assume otel_exporter's Redis is always localhost:6379 Drops the redis-connection-info env plumbing (VENTIS_REDIS_HOST/PORT/DB, GlobalController._otel_exporter_env) added in the previous commit -- otel_exporter and GlobalController always run on the same host, and RedisClient's own defaults already are localhost:6379/db0, so passing them through was dead flexibility for a case that doesn't exist yet. --- tests/test_otel_exporter_fanout.py | 30 -------------------------- ventis/OTLP_Exporter/otel_exporter.py | 16 +++----------- ventis/controller/global_controller.py | 20 +++-------------- 3 files changed, 6 insertions(+), 60 deletions(-) diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index 5231115..59d4345 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -168,36 +168,6 @@ def test_controller_destinations_is_none_when_otel_not_configured(self): self.assertIsNone(GlobalController._otel_destinations({})) - def test_controller_exporter_env_carries_redis_connection_only(self): - # Destinations travel via Redis (otel:destinations), not env, so this - # is now just the fixed connection info the subprocess needs to reach it. - from ventis.controller.global_controller import GlobalController - - env = GlobalController._otel_exporter_env( - {"host": "redis-host", "port": 6380, "db": 2} - ) - self.assertEqual( - env, - { - "VENTIS_REDIS_HOST": "redis-host", - "VENTIS_REDIS_PORT": "6380", - "VENTIS_REDIS_DB": "2", - }, - ) - - def test_controller_exporter_env_defaults(self): - from ventis.controller.global_controller import GlobalController - - env = GlobalController._otel_exporter_env({}) - self.assertEqual( - env, - { - "VENTIS_REDIS_HOST": "localhost", - "VENTIS_REDIS_PORT": "6379", - "VENTIS_REDIS_DB": "0", - }, - ) - def _insert_pending_row(self): conn = sqlite3.connect(self.db_path) try: diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py index 94c195e..58704b0 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/ventis/OTLP_Exporter/otel_exporter.py @@ -9,8 +9,8 @@ key (required because the standard OTEL exporter environment variables describe only one destination). Every poll tick also re-reads that key and rebuilds the configured processors if it changed, so a config reload (SIGHUP -> GlobalController.reload_config) -reaches this process without a restart. Redis connection info itself, unlike -destinations, is fixed for the process's lifetime and passed once via env. +reaches this process without a restart. Redis itself is assumed to be on localhost:6379, +same as GlobalController's own default -- both run on the same host. """ import json @@ -44,16 +44,6 @@ _last_destinations_raw = None POLL_INTERVAL_SECONDS = 5 DESTINATIONS_KEY = "otel:destinations" # keep in sync with GlobalController.OTEL_DESTINATIONS_KEY - - -def _redis_client(): - return RedisClient( - host=os.environ.get("VENTIS_REDIS_HOST", "localhost"), - port=int(os.environ.get("VENTIS_REDIS_PORT", 6379)), - db=int(os.environ.get("VENTIS_REDIS_DB", 0)), - ) - - _redis = None @@ -258,7 +248,7 @@ def main(): signal.signal(signal.SIGTERM, _handle_shutdown) signal.signal(signal.SIGINT, _handle_shutdown) db.init_db() - _redis = _redis_client() + _redis = RedisClient() # localhost:6379, db 0 -- same host as GlobalController _last_destinations_raw = _redis.get(DESTINATIONS_KEY) _processors = _build_processors(_last_destinations_raw) logger.info("OTel exporter process started with %d destination(s).", len(_processors)) diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 7bd1a5d..85d2e23 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -125,14 +125,13 @@ def __init__(self, config_path): # Destinations live in Redis (self.OTEL_DESTINATIONS_KEY), not env: the exporter # subprocess polls that key each cycle, so config changes (SIGHUP -> reload_config) - # reach it without a restart. Only the fixed Redis connection info is passed as env. + # reach it without a restart. Redis itself is assumed to be localhost:6379 (the + # exporter's own RedisClient default) -- no connection env needed. destinations = self._otel_destinations(self.config.get("otel", {})) if destinations is not None: self._write_otel_destinations(destinations) self.process_supervisor.register( - "otel_exporter", - [sys.executable, otel_exporter_script], - env=self._otel_exporter_env(redis_cfg), + "otel_exporter", [sys.executable, otel_exporter_script] ) else: logger.info("otel.destinations not configured -- no OTel metrics collection will happen.") @@ -237,19 +236,6 @@ def _otel_destinations(otel_cfg): return None return GlobalController._expand_env_value(otel_cfg["destinations"]) - @staticmethod - def _otel_exporter_env(redis_cfg): - """Env for the exporter subprocess: just enough to reach the same Redis - as this GlobalController. Fixed for the process's lifetime -- unlike - destinations, the Redis location itself isn't something a running - deploy can be reconfigured onto. - """ - return { - "VENTIS_REDIS_HOST": str(redis_cfg.get("host", "localhost")), - "VENTIS_REDIS_PORT": str(redis_cfg.get("port", 6379)), - "VENTIS_REDIS_DB": str(redis_cfg.get("db", 0)), - } - def _write_otel_destinations(self, destinations): """Push the resolved destination list to Redis. Raises if it can't be JSON-serialized -- same validation the old env-var path had.""" From c085dbc86649d14f06eaa4cc787485aa7b71bcd5 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 3 Sep 2026 12:57:17 -0700 Subject: [PATCH 21/44] Trim explanatory comments off simple/obvious functions Kept comments only where behavior is genuinely non-obvious (why the exporter polls Redis instead of restarting, why reload_config gates on is_registered, the invalid-update-keeps-old-processors fallback). Dropped comments/docstrings that just narrated 'this was added' on trivial pass-through code. --- ventis/OTLP_Exporter/otel_exporter.py | 15 +++++--------- ventis/controller/global_controller.py | 20 +++++-------------- ventis/controller/utils/process_supervisor.py | 2 -- 3 files changed, 10 insertions(+), 27 deletions(-) diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py index 58704b0..7e365b0 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/ventis/OTLP_Exporter/otel_exporter.py @@ -5,12 +5,9 @@ all processors accept it. Batching, OTLP serialization, and sending remain the SDK's responsibility (see DESIGN.md). -GlobalController writes the resolved destination list to the ``otel:destinations`` Redis -key (required because the standard OTEL exporter environment variables describe only one -destination). Every poll tick also re-reads that key and rebuilds the configured -processors if it changed, so a config reload (SIGHUP -> GlobalController.reload_config) -reaches this process without a restart. Redis itself is assumed to be on localhost:6379, -same as GlobalController's own default -- both run on the same host. +Destinations come from the ``otel:destinations`` Redis key (GlobalController writes it), +not env -- every poll tick re-reads it and rebuilds processors if it changed, so a config +reload (SIGHUP) reaches this process without a restart. """ import json @@ -174,10 +171,8 @@ def _handle_shutdown(signum, frame): def _reload_destinations_if_changed(): - """Re-read otel:destinations from Redis; rebuild _processors if it changed. - Invalid or missing values are logged and the previous processors are kept - running, matching the poll loop's existing non-fatal error handling. - """ + # Invalid Redis values are logged and ignored -- keep the previous processors + # running rather than tearing down a working config over a bad update. global _processors, _last_destinations_raw raw = _redis.get(DESTINATIONS_KEY) if raw == _last_destinations_raw: diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index 85d2e23..fc5dd38 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -123,10 +123,8 @@ def __init__(self, config_path): otel_exporter_script = os.path.join(otel_exporter_dir, "otel_exporter.py") self.process_supervisor = ProcessSupervisor() - # Destinations live in Redis (self.OTEL_DESTINATIONS_KEY), not env: the exporter - # subprocess polls that key each cycle, so config changes (SIGHUP -> reload_config) - # reach it without a restart. Redis itself is assumed to be localhost:6379 (the - # exporter's own RedisClient default) -- no connection env needed. + # Exporter polls self.OTEL_DESTINATIONS_KEY in Redis each cycle instead of + # reading env once, so reload_config() can update it without a restart. destinations = self._otel_destinations(self.config.get("otel", {})) if destinations is not None: self._write_otel_destinations(destinations) @@ -226,19 +224,12 @@ def _expand_env_value(value): @staticmethod def _otel_destinations(otel_cfg): - """Resolve global_controller.yaml's `otel.destinations` (expanding any - ${ENV_VAR} refs). Returns None if absent, so the caller skips starting - the exporter subprocess entirely. Destination shape/protocol is - validated by the exporter subprocess itself (otel_exporter.py), not - duplicated here. - """ + """Resolve otel.destinations (${ENV_VAR} refs expanded), or None if absent.""" if "destinations" not in otel_cfg: return None return GlobalController._expand_env_value(otel_cfg["destinations"]) def _write_otel_destinations(self, destinations): - """Push the resolved destination list to Redis. Raises if it can't be - JSON-serialized -- same validation the old env-var path had.""" try: payload = json.dumps(destinations) except (TypeError, ValueError) as exc: @@ -274,9 +265,8 @@ def reload_config(self): self._write_identity() self.instance_manager.publish_routing_snapshot(self.controllers) - # Refresh otel destinations too, same as the routing table above. Only - # meaningful if the exporter subprocess is already running (started at - # boot) -- it isn't spawned mid-run just because otel got added here. + # Only meaningful if the exporter was already running -- otel isn't + # spawned mid-run just because it got added to the config here. destinations = self._otel_destinations(self.config.get("otel", {})) if destinations is not None and self.process_supervisor.is_registered("otel_exporter"): self._write_otel_destinations(destinations) diff --git a/ventis/controller/utils/process_supervisor.py b/ventis/controller/utils/process_supervisor.py index 44233c1..f5336e6 100644 --- a/ventis/controller/utils/process_supervisor.py +++ b/ventis/controller/utils/process_supervisor.py @@ -20,8 +20,6 @@ def __init__(self): self._procs = {} # name -> subprocess.Popen def is_registered(self, name): - """Whether `name` was ever registered (regardless of whether it's still - running -- see check_and_respawn for restarts).""" return name in self._specs def register(self, name, argv, env=None): From 53a96c772a6f2db822d6eb287d1397ed7e144afc Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 11:11:57 -0700 Subject: [PATCH 22/44] Ventis fixes extracted from the CLI packaging work Everything under ventis/ and tests/ that the canyonos CLI branch depends on, lifted off feature/config-reloading with no cli/ or examples/ changes. - Package layout: move deploy/future/ventis_context/bedrock/utils under ventis/controller/, add ventis/Dockerfile and ventis/README.md. - ventis/server.py: Flask control surface the CLI's container talks to (/deploy, /clean, /status), replacing the ad-hoc entrypoint. - ventis/cli.py: fold `build` into `deploy`, support the .car artifact layout (.car/app sources, .car/config declarations, .car/stubs), and resolve env_file against the project dir so it matches how the GlobalController resolves it at runtime. - GlobalController: persist a dashed-uuid project_id and publish the controller identity to Redis. - OTLP exporter: generate Future.id at 64 bits (secrets.token_hex(8)) so it is a valid OTel span_id without truncation, and cost lookups that fail (no pricing table on a local deploy) now cost at 0 instead of dropping the whole telemetry row. - stub_generator: a stub is written to exactly one location, the path of the entrypoint it replaces, rather than being duplicated at the flat basename as well. Flat is only the fallback for a stub with no entrypoint mapping or one whose mapping escapes the build context. Carries over the placement half of 692d17c from feature/all-the-files, which never reached this line; the entrypoint-adjacent YAML discovery from that commit is deliberately left out, since the .car layout already resolves declarations from .car/config. - stub_generator: fail loudly when an agent has no declaration or entrypoint. Co-Authored-By: Claude Opus 5 (1M context) --- tests/run_tests.sh | 9 +- tests/test_cli.py | 113 +++++++++++-- tests/test_deploy.py | 2 +- tests/test_error_propagation.py | 2 +- tests/test_future.py | 4 +- tests/test_global_controller_identity.py | 8 +- tests/test_global_controller_project_id.py | 70 ++++++++ tests/test_otel_exporter_fields.py | 4 +- tests/test_stub_generator.py | 53 +++++++ tests/test_ventis_context.py | 2 +- ventis/Dockerfile | 20 +++ ventis/OTLP_Exporter/convert.py | 9 +- ventis/OTLP_Exporter/db.py | 35 ++-- ventis/OTLP_Exporter/otel_exporter.py | 7 +- ventis/README.md | 8 + ventis/cli.py | 150 +++++++++++------- ventis/{llm => controller}/bedrock.py | 4 +- .../cloud_provider_logic/EC2/_runtime.py | 2 +- .../cloud_provider_logic/Local/_runtime.py | 117 ++++++++------ ventis/{ => controller}/deploy.py | 4 +- ventis/{ => controller}/future.py | 15 +- ventis/controller/global_controller.py | 52 ++++-- ventis/controller/local_controller.py | 6 +- .../controller/local_controller_frontend.py | 4 +- ventis/{ => controller}/utils/grpc_options.py | 0 ventis/{ => controller}/utils/redis_client.py | 0 ventis/controller/utils/telemetry_logging.py | 2 +- ventis/{ => controller}/ventis_context.py | 0 ventis/llm/__init__.py | 0 ventis/server.py | 70 ++++++++ ventis/stub_generator.py | 51 +++--- ventis/utils/__init__.py | 1 - 32 files changed, 612 insertions(+), 212 deletions(-) create mode 100644 tests/test_global_controller_project_id.py create mode 100644 ventis/Dockerfile create mode 100644 ventis/README.md rename ventis/{llm => controller}/bedrock.py (94%) rename ventis/{ => controller}/deploy.py (98%) rename ventis/{ => controller}/future.py (94%) rename ventis/{ => controller}/utils/grpc_options.py (100%) rename ventis/{ => controller}/utils/redis_client.py (100%) rename ventis/{ => controller}/ventis_context.py (100%) delete mode 100644 ventis/llm/__init__.py create mode 100644 ventis/server.py delete mode 100644 ventis/utils/__init__.py diff --git a/tests/run_tests.sh b/tests/run_tests.sh index e9f8386..c5556ec 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -30,13 +30,10 @@ cd "$TEST_DIR" echo ">> 1. Generating new project..." ventis new-project $PROJECT_NAME cd $PROJECT_NAME -grep -v 'gpu:' config/global_controller.yaml > config/global_controller.yaml.tmp -mv config/global_controller.yaml.tmp config/global_controller.yaml +grep -v 'gpu:' .car/config/global_controller.yaml > .car/config/global_controller.yaml.tmp +mv .car/config/global_controller.yaml.tmp .car/config/global_controller.yaml -echo ">> 2. Building agents (ventis build)..." -ventis build - -echo ">> 3. Deploying workflow (ventis deploy)..." +echo ">> 2. Building and deploying workflow (ventis deploy)..." ventis deploy & DEPLOY_PID=$! diff --git a/tests/test_cli.py b/tests/test_cli.py index 406b95d..44b9270 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -23,12 +23,14 @@ def _fake_controller_module(self, controller): @patch("atexit.register") @patch("signal.signal") + @patch("ventis.cli._run_build") @patch("ventis.cli._ensure_grpc_stubs_importable") @patch("ventis.cli._preflight_ec2_deploy") def test_deploy_skips_ec2_preflight_for_local_config( self, preflight, ensure_grpc, + _run_build, _signal_patch, _atexit_patch, ): @@ -54,12 +56,14 @@ def test_deploy_skips_ec2_preflight_for_local_config( @patch("atexit.register") @patch("signal.signal") + @patch("ventis.cli._run_build") @patch("ventis.cli._ensure_grpc_stubs_importable") @patch("ventis.cli._preflight_ec2_deploy") def test_deploy_runs_ec2_preflight_for_ec2_config( self, preflight, ensure_grpc, + _run_build, _signal_patch, _atexit_patch, ): @@ -81,6 +85,36 @@ def test_deploy_runs_ec2_preflight_for_ec2_config( preflight.assert_called_once_with(config, os.getcwd()) controller.run.assert_called_once_with() + @patch("atexit.register") + @patch("signal.signal") + @patch("ventis.cli._run_build") + @patch("ventis.cli._ensure_grpc_stubs_importable") + @patch("ventis.cli._preflight_ec2_deploy") + def test_deploy_uses_car_when_present( + self, preflight, ensure_grpc, _run_build, _signal_patch, _atexit_patch + ): + controller = MagicMock() + controller_module = self._fake_controller_module(controller) + args = SimpleNamespace(config=".car/config/global_controller.yaml") + + with tempfile.TemporaryDirectory() as tmpdir, patch( + "ventis.cli.os.path.isfile", return_value=True + ), patch( + "ventis.cli._load_config", return_value={"agents": []} + ), patch.dict( + sys.modules, {"ventis.controller.global_controller": controller_module} + ): + Path(tmpdir, ".car").mkdir() + cwd = os.getcwd() + os.chdir(tmpdir) + try: + cli.cmd_deploy(args) + finally: + os.chdir(cwd) + + ensure_grpc.assert_called_once_with(os.path.join(os.path.realpath(tmpdir), ".car")) + preflight.assert_not_called() + @patch("ventis.cli._ensure_grpc_stubs_importable") @patch("ventis.cli._require_docker_for_ec2") def test_preflight_does_not_require_ssh_fields(self, require_docker, ensure_grpc): @@ -103,28 +137,43 @@ class CliBuildTests(unittest.TestCase): def _run_build( self, project_dir, agent_yaml_paths, buildx_available, platform="linux/amd64" ): - """Run cmd_build against project_dir with docker/subprocess calls mocked. + """Run _run_build against project_dir with docker/subprocess calls mocked. Returns (docker_calls, generate_docker_mock, generate_workflow_docker_mock). """ - config_path = project_dir / "config" / "global_controller.yaml" - args = SimpleNamespace(config=str(config_path)) + artifact_root = ( + project_dir / ".car" if (project_dir / ".car").is_dir() else project_dir + ) + config_path = artifact_root / "config" / "global_controller.yaml" docker_calls = [] def fake_run(cmd, check): docker_calls.append(cmd) return SimpleNamespace(returncode=0) + def fake_glob(pattern): + if pattern.endswith("*.proto"): + return ["proto/a.proto"] + if agent_yaml_paths: + self.assertEqual( + os.path.realpath(Path(pattern).parent), + os.path.realpath(Path(agent_yaml_paths[0]).parent), + ) + return agent_yaml_paths + + def fake_generate_stub(yaml_path, _output_path): + with open(yaml_path) as f: + self.assertIn("agent", yaml.safe_load(f)) + with ( patch( "ventis.cli._get_package_dir", return_value=str(project_dir / "package"), ), + patch("ventis.cli.glob.glob", side_effect=fake_glob), patch( - "ventis.cli.glob.glob", - side_effect=[agent_yaml_paths, ["proto/a.proto"]], + "ventis.stub_generator.generate_stub", side_effect=fake_generate_stub ), - patch("ventis.stub_generator.generate_stub"), patch("ventis.stub_generator.generate_docker") as generate_docker, patch( "ventis.stub_generator.generate_workflow_docker" @@ -136,7 +185,7 @@ def fake_run(cmd, check): cwd = os.getcwd() os.chdir(project_dir) try: - cli.cmd_build(args) + cli._run_build(str(config_path)) finally: os.chdir(cwd) @@ -240,6 +289,32 @@ def test_build_uses_buildx_bake_when_available(self): ) self.assertEqual(targets["workflow"]["tags"], ["ventis-workflow"]) + def test_build_uses_car_when_present(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + artifact_root = project_dir / ".car" + source_root = artifact_root / "app" + source_root.mkdir(parents=True) + source_yaml = self._write_agent_and_workflow_config(source_root) + source_root.joinpath("config").rename(artifact_root / "config") + agent_yaml = artifact_root / "config" / source_yaml.name + source_yaml.rename(agent_yaml) + + manifest = artifact_root / "config" / "global_controller.yaml" + _, generate_docker, generate_workflow_docker = self._run_build( + project_dir, [str(manifest), str(agent_yaml)], buildx_available=True + ) + + for call in (generate_docker, generate_workflow_docker): + self.assertEqual( + os.path.realpath(call.call_args.kwargs["project_dir"]), + os.path.realpath(source_root), + ) + self.assertEqual( + os.path.realpath(generate_docker.call_args.kwargs["output_dir"]), + os.path.realpath(artifact_root / "docker_container" / "ExampleAgent"), + ) + def test_build_with_no_agents_builds_nothing(self): with tempfile.TemporaryDirectory() as tmpdir: project_dir = Path(tmpdir) @@ -252,7 +327,7 @@ def test_build_with_no_agents_builds_nothing(self): self.assertFalse(any(call[0] == "docker" for call in docker_calls)) - def test_build_skips_agent_without_entrypoint(self): + def test_build_fails_when_stub_cannot_be_generated(self): with tempfile.TemporaryDirectory() as tmpdir: project_dir = Path(tmpdir) (project_dir / "config").mkdir() @@ -268,9 +343,8 @@ def test_build_skips_agent_without_entrypoint(self): ) ) - docker_calls, _, _ = self._run_build(project_dir, [], buildx_available=True) - - self.assertFalse(any(call[0] == "docker" for call in docker_calls)) + with self.assertRaises(SystemExit): + self._run_build(project_dir, [], buildx_available=True) def _write_requirements_config(self, project_dir): """Scaffold one plain agent, one agent with `requirements`, one workflow with `requirements`.""" @@ -373,5 +447,22 @@ def test_build_ignores_non_list_requirements(self): self.assertEqual(generate_docker.call_args.kwargs["requirements"], []) +class CliCleanTests(unittest.TestCase): + def test_clean_uses_car_when_present(self): + with tempfile.TemporaryDirectory() as tmpdir: + project_dir = Path(tmpdir) + (project_dir / ".car" / "stubs").mkdir(parents=True) + (project_dir / "stubs").mkdir() + cwd = os.getcwd() + os.chdir(project_dir) + try: + cli.cmd_clean(SimpleNamespace()) + finally: + os.chdir(cwd) + + self.assertFalse((project_dir / ".car" / "stubs").exists()) + self.assertTrue((project_dir / "stubs").exists()) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 3f029cb..3c02008 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -6,7 +6,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import ventis.deploy as deploy_module +import ventis.controller.deploy as deploy_module class _FakeRedis: diff --git a/tests/test_error_propagation.py b/tests/test_error_propagation.py index 82da262..59c6eba 100644 --- a/tests/test_error_propagation.py +++ b/tests/test_error_propagation.py @@ -17,7 +17,7 @@ from ventis.controller.local_controller import LocalController from ventis.controller.local_controller_frontend import LocalControllerServicer -from ventis.future import Future +from ventis.controller.future import Future import local_controler_pb2 diff --git a/tests/test_future.py b/tests/test_future.py index e190426..4914b29 100644 --- a/tests/test_future.py +++ b/tests/test_future.py @@ -13,8 +13,8 @@ ), ) -import ventis.future as future_module -import ventis.ventis_context as ventis_context +import ventis.controller.future as future_module +import ventis.controller.ventis_context as ventis_context class _FakeRedis: diff --git a/tests/test_global_controller_identity.py b/tests/test_global_controller_identity.py index e4337f7..76a6689 100644 --- a/tests/test_global_controller_identity.py +++ b/tests/test_global_controller_identity.py @@ -103,14 +103,16 @@ def test_a_second_call_with_a_new_config_overwrites_the_published_value(self): }, ) - def test_missing_project_id_or_database_publishes_safe_defaults(self): - controller = _bare_controller({}) + def test_missing_database_publishes_safe_default(self): + # project_id is always populated by _load_config() by the time _write_identity() + # runs -- only database_url has a real "unset" case to default here. + controller = _bare_controller({"project_id": "11111111-1111-1111-1111-111111111111"}) controller._write_identity() self.assertEqual( controller.redis.hgetall(GlobalController.IDENTITY_KEY), - {"project_id": "0", "database_url": ""}, + {"project_id": "11111111-1111-1111-1111-111111111111", "database_url": ""}, ) diff --git a/tests/test_global_controller_project_id.py b/tests/test_global_controller_project_id.py new file mode 100644 index 0000000..1feebc8 --- /dev/null +++ b/tests/test_global_controller_project_id.py @@ -0,0 +1,70 @@ +"""_load_config() must mint a project_id when a config file omits one, and persist it back +to the file so the same value survives a reload_config() or process restart -- not a fresh +uuid on every load. +""" + +import os +import re +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import yaml + +from ventis.controller.global_controller import GlobalController + +UUID_HEX_RE = re.compile(r"^[0-9a-f]{32}$") + + +def _write_config(body): + f = tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) + f.write(body) + f.close() + return f.name + + +class LoadConfigProjectIdTests(unittest.TestCase): + def test_generates_and_persists_project_id_when_missing(self): + config_path = _write_config("agents: []\npoll_interval: 5\n") + try: + config = GlobalController._load_config(config_path) + + self.assertTrue(UUID_HEX_RE.match(config["project_id"])) + + with open(config_path) as f: + on_disk = yaml.safe_load(f) + self.assertEqual(on_disk["project_id"], config["project_id"]) + finally: + os.unlink(config_path) + + def test_reload_reuses_the_persisted_project_id_instead_of_minting_a_new_one(self): + config_path = _write_config("agents: []\npoll_interval: 5\n") + try: + first = GlobalController._load_config(config_path) + second = GlobalController._load_config(config_path) + + self.assertEqual(first["project_id"], second["project_id"]) + finally: + os.unlink(config_path) + + def test_existing_project_id_is_left_untouched(self): + config_path = _write_config( + 'agents: []\nproject_id: "11111111-1111-1111-1111-111111111111"\n' + ) + try: + config = GlobalController._load_config(config_path) + + self.assertEqual(config["project_id"], "11111111-1111-1111-1111-111111111111") + + with open(config_path) as f: + contents = f.read() + # No second project_id line got appended alongside the existing one. + self.assertEqual(contents.count("project_id"), 1) + finally: + os.unlink(config_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py index b74177d..f6c2074 100644 --- a/tests/test_otel_exporter_fields.py +++ b/tests/test_otel_exporter_fields.py @@ -29,7 +29,7 @@ def test_init_db_creates_waiting_table_with_full_schema(self): def test_fields_are_normalized_and_added_to_span(self): db.init_db(self.db_path) raw = { - "future_id": "00112233445566778899aabbccddeeff", + "future_id": "0011223344556677", # 64-bit (16 hex chars), matches Future.id's format "request_id": "ffeeddccbbaa99887766554433221100", "service": "PriceAgent", "method": "get_history", @@ -61,7 +61,7 @@ def test_fields_are_normalized_and_added_to_span(self): def test_error_message_is_wired_from_redis_error_field(self): db.init_db(self.db_path) raw = { - "future_id": "11112222333344445555666677778888", + "future_id": "1111222233334444", # 64-bit (16 hex chars), matches Future.id's format "request_id": "88887777666655554444333322221111", "service": "AdvisorAgent", "method": "summarize", diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index fb01f2e..916bf80 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -11,6 +11,7 @@ from ventis.stub_generator import ( BASE_AGENT_REQUIREMENTS, BASE_WORKFLOW_REQUIREMENTS, + _stub_destination, generate_docker, generate_workflow_docker, ) @@ -86,5 +87,57 @@ def test_per_workflow_requirements_are_appended_to_base(self): self.assertEqual(requirements, BASE_WORKFLOW_REQUIREMENTS + ["yfinance"]) +class StubDestinationTests(unittest.TestCase): + """A stub replaces the real module at its entrypoint path, so it is written + to exactly that one location. Flat is only a fallback for a stub with no + entrypoint mapping, or one whose mapping escapes the build context. + """ + + def test_unmapped_stub_falls_back_to_flat(self): + self.assertEqual(_stub_destination("/stubs/split_agent.py", {}), "split_agent.py") + + def test_entrypoint_mapping_is_the_only_destination(self): + destination = _stub_destination( + "/stubs/split_agent.py", {"split_agent.py": "agents/split_agent.py"} + ) + self.assertEqual(destination, "agents/split_agent.py") + + def test_flat_entrypoint_stays_flat(self): + destination = _stub_destination( + "/stubs/split_agent.py", {"split_agent.py": "split_agent.py"} + ) + self.assertEqual(destination, "split_agent.py") + + def test_unsafe_entrypoint_falls_back_to_flat(self): + destination = _stub_destination( + "/stubs/split_agent.py", {"split_agent.py": "../../etc/passwd"} + ) + self.assertEqual(destination, "split_agent.py") + + +class GenerateWorkflowDockerStubPlacementTests(unittest.TestCase): + def test_stub_lands_only_at_its_entrypoint_path(self): + with tempfile.TemporaryDirectory() as tmpdir: + workflow_file = Path(tmpdir) / "workflow.py" + workflow_file.write_text("from agents.split_agent import SplitAgent\n") + + stub_file = Path(tmpdir) / "stubs" / "split_agent.py" + stub_file.parent.mkdir() + stub_file.write_text("class SplitAgent:\n pass\n") + + output_dir = os.path.join(tmpdir, "out") + generate_workflow_docker( + str(workflow_file), + [str(stub_file)], + output_dir=output_dir, + stub_entrypoints={"split_agent.py": "agents/split_agent.py"}, + ) + + nested_path = Path(output_dir) / "agents" / "split_agent.py" + flat_path = Path(output_dir) / "split_agent.py" + self.assertIn("class SplitAgent", nested_path.read_text()) + self.assertFalse(flat_path.exists(), "stub must not be duplicated flat") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ventis_context.py b/tests/test_ventis_context.py index bec4122..f860d1f 100644 --- a/tests/test_ventis_context.py +++ b/tests/test_ventis_context.py @@ -4,7 +4,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import ventis.ventis_context as ventis_context +import ventis.controller.ventis_context as ventis_context class VentisContextTests(unittest.TestCase): diff --git a/ventis/Dockerfile b/ventis/Dockerfile new file mode 100644 index 0000000..1814548 --- /dev/null +++ b/ventis/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y docker.io && rm -rf /var/lib/apt/lists/* + +COPY . /ventis +RUN pip install /ventis + +# global_controller.py bare-imports these; pip install only ships the .proto source. +RUN python -m grpc_tools.protoc \ + -I/ventis/ventis/controller/proto \ + --python_out=/usr/local/lib/python3.11/site-packages \ + --grpc_python_out=/usr/local/lib/python3.11/site-packages \ + /ventis/ventis/controller/proto/local_controler.proto + +EXPOSE 8000 + +ENTRYPOINT ["python", "-m", "ventis.server"] + + +# to run: docker build -f ventis/Dockerfile -t saakeths/canyonos:latest . diff --git a/ventis/OTLP_Exporter/convert.py b/ventis/OTLP_Exporter/convert.py index 5e6ac74..72e2342 100644 --- a/ventis/OTLP_Exporter/convert.py +++ b/ventis/OTLP_Exporter/convert.py @@ -29,11 +29,12 @@ def waiting_row_to_span(row): row = dict(row) trace_id = int(row["session_id"], 16) - span_id = int.from_bytes(bytes.fromhex(row["future_id"])[:8], "big") + # future_id/parent_id are already 64-bit (Future.id is generated at that + # width directly -- see ventis/controller/future.py), matching OTel's + # span_id, so no truncation is needed here. + span_id = int(row["future_id"], 16) parent_id = row.get("parent_id") - parent_span_id = ( - int.from_bytes(bytes.fromhex(parent_id)[:8], "big") if parent_id else None - ) + parent_span_id = int(parent_id, 16) if parent_id else None context = SpanContext( trace_id=trace_id, span_id=span_id, is_remote=False, trace_flags=_SAMPLED diff --git a/ventis/OTLP_Exporter/db.py b/ventis/OTLP_Exporter/db.py index 005ba71..f1438f7 100644 --- a/ventis/OTLP_Exporter/db.py +++ b/ventis/OTLP_Exporter/db.py @@ -134,21 +134,30 @@ def write_waiting_rows(rows, redis_client=None, project_id=None, db_path=DB_PATH # Cost figures are only meaningful once the future has finished, so skip # computing them until then rather than recomputing on every poll. if finished_at is not None: - token_cost = ( - pricing.compute_token_cost( - raw.get("model"), input_token_count, output_token_count + # Cost lookups can fail independently of the telemetry itself (e.g. + # no aws_instance_pricing table on a local-provider deployment) -- + # don't let that drop the whole row, just cost it at 0. + try: + token_cost = ( + pricing.compute_token_cost( + raw.get("model"), input_token_count, output_token_count + ) + * _TOKEN_COST_MULTIPLIER ) - * _TOKEN_COST_MULTIPLIER - ) - server_cost = ( - pricing.compute_server_cost( - redis_client.get(f"agent:{agent_id}:instance_type") - if redis_client is not None and agent_id - else None, - finished_at - started_at, + except Exception: + token_cost = 0.0 + try: + server_cost = ( + pricing.compute_server_cost( + redis_client.get(f"agent:{agent_id}:instance_type") + if redis_client is not None and agent_id + else None, + finished_at - started_at, + ) + * _SERVER_COST_MULTIPLIER ) - * _SERVER_COST_MULTIPLIER - ) + except Exception: + server_cost = 0.0 else: token_cost = 0.0 server_cost = 0.0 diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/ventis/OTLP_Exporter/otel_exporter.py index 7e365b0..1ed210a 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/ventis/OTLP_Exporter/otel_exporter.py @@ -20,7 +20,7 @@ import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ventis.utils.redis_client import RedisClient +from ventis.controller.utils.redis_client import RedisClient from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter as GrpcOTLPSpanExporter, @@ -243,7 +243,10 @@ def main(): signal.signal(signal.SIGTERM, _handle_shutdown) signal.signal(signal.SIGINT, _handle_shutdown) db.init_db() - _redis = RedisClient() # localhost:6379, db 0 -- same host as GlobalController + # GC reaches its own Redis via host.docker.internal (a sibling container, + # not the same network namespace, since GC runs on bridge networking) -- + # match that instead of plain localhost. + _redis = RedisClient(host="host.docker.internal") _last_destinations_raw = _redis.get(DESTINATIONS_KEY) _processors = _build_processors(_last_destinations_raw) logger.info("OTel exporter process started with %d destination(s).", len(_processors)) diff --git a/ventis/README.md b/ventis/README.md new file mode 100644 index 0000000..6b5675a --- /dev/null +++ b/ventis/README.md @@ -0,0 +1,8 @@ +# CanyonOS Platform + +Every folder in here is a separate process to be run. + +- controller: The control plane and manager +- OTLP_Exporter: The OTel Data Exporter +- server.py: Flask server that CLI connects to +- (soon) Instance_Manager: Responsible for scaling (currently in controller) \ No newline at end of file diff --git a/ventis/cli.py b/ventis/cli.py index c5a2b68..c66a106 100644 --- a/ventis/cli.py +++ b/ventis/cli.py @@ -1,10 +1,10 @@ """ Ventis CLI -Entry point for the `ventis` command. Provides three subcommands: +Entry point for the `ventis` command. Provides these subcommands: ventis new-project — Scaffold a new Ventis project - ventis build — Generate stubs and build Docker images - ventis deploy — Launch agents via the Global Controller + ventis deploy — Build (stubs + Docker images) then launch + agents via the Global Controller """ import argparse @@ -21,7 +21,8 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger("ventis") DEFAULT_DOCKER_PLATFORM = "linux/amd64" -DEFAULT_CONFIG_PATH = "config/global_controller.yaml" +ARTIFACT_DIR_NAME = ".car" +SOURCE_DIR_NAME = "app" EC2_REQUIRED_CONFIG_KEYS = ( "ami_id", "subnet_id", @@ -53,6 +54,10 @@ def _load_config(config_path): return yaml.safe_load(f) +def _artifact_prefix(root): + return ARTIFACT_DIR_NAME if os.path.isdir(os.path.join(root, ARTIFACT_DIR_NAME)) else "" + + def _normalize_requirements(agent_cfg): """Return an agent's `requirements` list, or [] if absent/null/malformed.""" requirements = agent_cfg.get("requirements") or [] @@ -175,17 +180,35 @@ def cmd_new_project(args): logger.error("Templates directory not found at %s", templates_dir) sys.exit(1) - # Copy the entire templates tree into the new project - shutil.copytree(templates_dir, project_dir) + # Copy the entire templates tree into .car/app, then pull config and agent + # declarations up into .car/config, keeping generated artifacts (stubs, + # grpc_stubs, docker_container) siblings of the source under .car/. + artifact_root = os.path.join(project_dir, ARTIFACT_DIR_NAME) + source_root = os.path.join(artifact_root, SOURCE_DIR_NAME) + shutil.copytree(templates_dir, source_root) + + source_config = os.path.join(source_root, "config") + artifact_config = os.path.join(artifact_root, "config") + if os.path.isdir(source_config): + shutil.move(source_config, artifact_root) + else: + os.makedirs(artifact_config) + + source_agents = os.path.join(source_root, "agents") + for declaration in glob.glob(os.path.join(source_agents, "*.yaml")): + shutil.move(declaration, artifact_config) + + readme = os.path.join(source_root, "README.md") + if os.path.isfile(readme): + shutil.move(readme, project_dir) # Create empty output directories - os.makedirs(os.path.join(project_dir, "stubs"), exist_ok=True) - os.makedirs(os.path.join(project_dir, "grpc_stubs"), exist_ok=True) + os.makedirs(os.path.join(artifact_root, "stubs"), exist_ok=True) + os.makedirs(os.path.join(artifact_root, "grpc_stubs"), exist_ok=True) logger.info("Created new Ventis project: %s", project_dir) logger.info("") logger.info(" cd %s", project_name) - logger.info(" ventis build") logger.info(" ventis deploy") @@ -194,28 +217,31 @@ def cmd_new_project(args): # ------------------------------------------------------------------ # -def cmd_build(args): +def _run_build(config_path): """ Generate stubs, compile gRPC protos, generate Docker contexts, and build Docker images. - Must be run from the project root (where config/ lives). + Must be run from the project root (where config/ lives). Invoked as the + first phase of `ventis deploy`. """ - config_path = args.config if not os.path.isfile(config_path): logger.error("Config file not found: %s", config_path) sys.exit(1) config = _load_config(config_path) agents = config.get("agents", []) - project_dir = os.getcwd() + project_dir = os.path.abspath(os.getcwd()) + prefix = _artifact_prefix(project_dir) + artifact_root = os.path.join(project_dir, prefix) if prefix else project_dir + source_root = os.path.join(artifact_root, SOURCE_DIR_NAME) if prefix else project_dir package_dir = _get_package_dir() # -------------------------------------------------------------- # # Step 1: Discover agent YAML files and generate Python stubs # # -------------------------------------------------------------- # - agents_dir = os.path.join(project_dir, "agents") - stubs_dir = os.path.join(project_dir, "stubs") + declarations_dir = os.path.join(artifact_root, "config" if prefix else "agents") + stubs_dir = os.path.join(artifact_root, "stubs") os.makedirs(stubs_dir, exist_ok=True) from ventis.stub_generator import ( @@ -224,9 +250,9 @@ def cmd_build(args): generate_workflow_docker, ) - yaml_files = glob.glob(os.path.join(agents_dir, "*.yaml")) + yaml_files = glob.glob(os.path.join(declarations_dir, "*.yaml")) if not yaml_files: - logger.warning("No agent YAML files found in %s", agents_dir) + logger.warning("No agent YAML files found in %s", declarations_dir) import yaml @@ -238,9 +264,19 @@ def cmd_build(args): if name: yaml_by_name[name] = yaml_path - # Maps each generated stub's basename to its agent's entrypoint path, so a - # stub can also be placed at its nested, entrypoint-mirrored location. + # Maps each generated stub's basename to its agent's entrypoint path, which + # is the single location the stub is written to and copied to. entrypoints_by_name = {a["name"]: a.get("entrypoint") for a in agents} + missing_stubs = [ + a["name"] + for a in agents + if a.get("type", "agent") != "workflow" + and (a["name"] not in yaml_by_name or not a.get("entrypoint")) + ] + if missing_stubs: + logger.error("Cannot generate stubs for agents: %s", ", ".join(missing_stubs)) + sys.exit(1) + stub_entrypoints = { f"{os.path.splitext(os.path.basename(p))[0]}.py": entrypoints_by_name[n] for n, p in yaml_by_name.items() @@ -248,9 +284,12 @@ def cmd_build(args): } stub_paths = [] - for yaml_path in yaml_files: - base_name = os.path.splitext(os.path.basename(yaml_path))[0] - output_path = os.path.join(stubs_dir, f"{base_name}.py") + for agent_name, yaml_path in yaml_by_name.items(): + entrypoint = entrypoints_by_name.get(agent_name) + if not entrypoint: + continue + output_path = os.path.join(stubs_dir, entrypoint) + os.makedirs(os.path.dirname(output_path), exist_ok=True) logger.info("Generating stub: %s -> %s", yaml_path, output_path) generate_stub(yaml_path, output_path) stub_paths.append(output_path) @@ -258,7 +297,7 @@ def cmd_build(args): # -------------------------------------------------------------- # # Step 2: Compile gRPC protobuf stubs # # -------------------------------------------------------------- # - grpc_stubs_dir = os.path.join(project_dir, "grpc_stubs") + grpc_stubs_dir = os.path.join(artifact_root, "grpc_stubs") os.makedirs(grpc_stubs_dir, exist_ok=True) proto_dir = os.path.join(package_dir, "controller", "proto") @@ -296,12 +335,12 @@ def cmd_build(args): ) continue - workflow_path = os.path.join(project_dir, workflow_file) + workflow_path = os.path.join(source_root, workflow_file) if not os.path.isfile(workflow_path): logger.error("Workflow file not found: %s", workflow_path) continue - docker_context = os.path.join(project_dir, "docker_container", "Workflow") + docker_context = os.path.join(artifact_root, "docker_container", "Workflow") logger.info("Generating workflow Docker context for '%s'", agent_name) generate_workflow_docker( workflow_path, @@ -309,10 +348,8 @@ def cmd_build(args): output_dir=docker_context, grpc_stubs_dir=grpc_stubs_dir, api_port=agent_cfg.get("api_port", 8080), - project_dir=project_dir, - stub_entrypoints=stub_entrypoints, + project_dir=source_root, requirements=_normalize_requirements(agent_cfg), - project_dir=project_dir, # Stubs are placed both flat and at their entrypoint-mirrored path, # so both flat and nested import styles resolve to the stub. stub_entrypoints=stub_entrypoints, @@ -327,7 +364,7 @@ def cmd_build(args): ) continue - agent_file = os.path.join(project_dir, entrypoint) + agent_file = os.path.join(source_root, entrypoint) if not os.path.isfile(agent_file): logger.error("Agent file not found: %s", agent_file) continue @@ -341,7 +378,7 @@ def cmd_build(args): ) continue - docker_context = os.path.join(project_dir, "docker_container", agent_name) + docker_context = os.path.join(artifact_root, "docker_container", agent_name) logger.info("Generating Docker context for '%s'", agent_name) generate_docker( matching_yaml, @@ -349,10 +386,8 @@ def cmd_build(args): output_dir=docker_context, grpc_stubs_dir=grpc_stubs_dir, stub_files=stub_paths, - project_dir=project_dir, - stub_entrypoints=stub_entrypoints, + project_dir=source_root, requirements=_normalize_requirements(agent_cfg), - project_dir=project_dir, # Same reasoning as the workflow call above: stubs are placed both # flat and at their entrypoint-mirrored path. stub_entrypoints=stub_entrypoints, @@ -372,7 +407,7 @@ def cmd_build(args): if not bake_targets: logger.info("No Docker images to build.") elif _docker_available() and _docker_available(("docker", "buildx", "version")): - docker_container_dir = os.path.join(project_dir, "docker_container") + docker_container_dir = os.path.join(artifact_root, "docker_container") os.makedirs(docker_container_dir, exist_ok=True) bake_file_path = os.path.join(docker_container_dir, "docker-bake.json") _write_bake_file(bake_targets, bake_file_path, _docker_platform()) @@ -418,24 +453,31 @@ def cmd_deploy(args): logger.error("Config file not found: %s", config_path) sys.exit(1) + # Build first (stubs, protos, Docker contexts, images), then deploy them. + # `ventis build` was merged into `ventis deploy`. + _run_build(config_path) + config = _load_config(config_path) - project_dir = os.getcwd() + project_dir = os.path.abspath(os.getcwd()) + prefix = _artifact_prefix(project_dir) + artifact_root = os.path.join(project_dir, prefix) if prefix else project_dir # Fail here rather than after a fleet of containers is already up without - # the API keys they need. + # the API keys they need. base_dir matches GlobalController, which resolves + # env_file against its cwd -- any other base rejects a file it would find. try: resolve_env_file(config, base_dir=project_dir) except ValueError as e: logger.error("%s", e) sys.exit(1) - _ensure_grpc_stubs_importable(project_dir) + _ensure_grpc_stubs_importable(artifact_root) if any( agent.get("provider", "local").upper() == "EC2" for agent in config.get("agents", []) ): - _preflight_ec2_deploy(config, project_dir) + _preflight_ec2_deploy(config, artifact_root) from ventis.controller.global_controller import GlobalController @@ -476,12 +518,14 @@ def cmd_clean(args): """ Remove generated stubs, gRPC files, and Docker build contexts. """ - project_dir = os.getcwd() + project_dir = os.path.abspath(os.getcwd()) + prefix = _artifact_prefix(project_dir) + artifact_root = os.path.join(project_dir, prefix) if prefix else project_dir paths_to_clean = [ - os.path.join(project_dir, "stubs"), - os.path.join(project_dir, "grpc_stubs"), - os.path.join(project_dir, "docker_container"), + os.path.join(artifact_root, "stubs"), + os.path.join(artifact_root, "grpc_stubs"), + os.path.join(artifact_root, "docker_container"), ] for path in paths_to_clean: @@ -503,6 +547,9 @@ def cmd_clean(args): def main(): + default_config_path = os.path.join( + _artifact_prefix(os.getcwd()), "config", "global_controller.yaml" + ) parser = argparse.ArgumentParser( prog="ventis", description="Ventis — Distributed Agent Orchestration Framework", @@ -517,29 +564,16 @@ def main(): new_proj.add_argument("name", help="Name of the project directory to create") new_proj.set_defaults(func=cmd_new_project) - # ventis build - build = subparsers.add_parser( - "build", - help="Generate stubs, compile protos, and build Docker images", - ) - build.add_argument( - "-c", - "--config", - default=DEFAULT_CONFIG_PATH, - help=f"Path to global controller config (default: {DEFAULT_CONFIG_PATH})", - ) - build.set_defaults(func=cmd_build) - # ventis deploy deploy = subparsers.add_parser( "deploy", - help="Launch agents via the Global Controller", + help="Build stubs/images, then launch agents via the Global Controller", ) deploy.add_argument( "-c", "--config", - default=DEFAULT_CONFIG_PATH, - help=f"Path to global controller config (default: {DEFAULT_CONFIG_PATH})", + default=default_config_path, + help=f"Path to global controller config (default: {default_config_path})", ) deploy.set_defaults(func=cmd_deploy) diff --git a/ventis/llm/bedrock.py b/ventis/controller/bedrock.py similarity index 94% rename from ventis/llm/bedrock.py rename to ventis/controller/bedrock.py index f350b69..97c6a3f 100644 --- a/ventis/llm/bedrock.py +++ b/ventis/controller/bedrock.py @@ -1,8 +1,8 @@ import os try: - from ventis.utils.redis_client import RedisClient - import ventis.ventis_context as ventis_context + from ventis.controller.utils.redis_client import RedisClient + import ventis.controller.ventis_context as ventis_context except ImportError: from redis_client import RedisClient import ventis_context diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index 7e4e9ab..a1cbb4d 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -25,7 +25,7 @@ from ventis.controller.utils.env_file import env_file_args from ventis.controller.utils.redis_utils import _wait_for_redis -from ventis.utils.redis_client import RedisClient +from ventis.controller.utils.redis_client import RedisClient logger = logging.getLogger(__name__) diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index a387f7b..1a84e56 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -15,6 +15,7 @@ DEFAULT_HOST = "localhost" CONTAINER_PORT = 50051 PROVIDER = "local" +MAX_PORT_ATTEMPTS = 50 _controller = None @@ -62,9 +63,6 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): redis_host = provisioned["redis_host"] runtime_id = provisioned["runtime_id"] - endpoint = routing_endpoint_for(provisioned) - _require_controller().redis.set(f"controller:{endpoint}:agent_id", agent_id) - inspect = _require_controller()._run_cmd( ["docker", "inspect", "-f", "{{.State.Running}}", runtime_id], host, user ) @@ -76,53 +74,72 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): ) _require_controller()._run_cmd(["docker", "rm", "-f", runtime_id], host, user) - cmd = [ - "docker", - "run", - "-d", - "-it", - "--add-host=host.docker.internal:host-gateway", - "--name", - runtime_id, - "-p", - f"{host_port}:{CONTAINER_PORT}", - "-e", - f"VENTIS_AGENT_PORT={host_port}", - "-e", - f"VENTIS_AGENT_HOST={redis_host}", - "-e", - f"VENTIS_REDIS_HOST={redis_host}", - "-e", - f"VENTIS_REDIS_PORT={spec.get('redis_port', 6379)}", - "-e", - f"VENTIS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", - ] - if ctrl_type == "workflow": - cmd.extend(["-p", f"{spec.get('api_port', 8080)}:8080"]) - config = _require_controller().config - db_url = config.get("database", {}).get("url") - project_id = config.get("project_id") - if db_url: - cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) - if project_id: - cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) - if resources.get("cpu"): - cmd.extend(["--cpus", str(resources["cpu"])]) - if resources.get("memory"): - cmd.extend(["--memory", f"{resources['memory']}m"]) - if resources.get("gpu"): - cmd.extend(["--gpus", str(resources["gpu"])]) - - # User secrets from `env_file`. Explicit -e flags above still win, so a - # stray VENTIS_* line in someone's .env cannot break agent wiring. - with env_file_args( - _require_controller(), host, user, runtime_id, _is_local_host(host) - ) as env_args: - cmd.extend(env_args) - cmd.append(image) - result = _require_controller()._run_cmd(cmd, host, user) - if result.returncode != 0: - raise RuntimeError(f"Failed to launch {runtime_id}") + for attempt in range(MAX_PORT_ATTEMPTS): + cmd = [ + "docker", + "run", + "-d", + "-it", + "--add-host=host.docker.internal:host-gateway", + "--name", + runtime_id, + "-p", + f"{host_port}:{CONTAINER_PORT}", + "-e", + f"VENTIS_AGENT_PORT={host_port}", + "-e", + f"VENTIS_AGENT_HOST={redis_host}", + "-e", + f"VENTIS_REDIS_HOST={redis_host}", + "-e", + f"VENTIS_REDIS_PORT={spec.get('redis_port', 6379)}", + "-e", + f"VENTIS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", + ] + if ctrl_type == "workflow": + cmd.extend(["-p", f"{spec.get('api_port', 8080)}:8080"]) + config = _require_controller().config + db_url = config.get("database", {}).get("url") + project_id = config.get("project_id") + if db_url: + cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) + if project_id: + cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) + if resources.get("cpu"): + cmd.extend(["--cpus", str(resources["cpu"])]) + if resources.get("memory"): + cmd.extend(["--memory", f"{resources['memory']}m"]) + if resources.get("gpu"): + cmd.extend(["--gpus", str(resources["gpu"])]) + + # User secrets from `env_file`. Explicit -e flags above still win, so a + # stray VENTIS_* line in someone's .env cannot break agent wiring. + with env_file_args( + _require_controller(), host, user, runtime_id, _is_local_host(host) + ) as env_args: + cmd.extend(env_args) + cmd.append(image) + result = _require_controller()._run_cmd(cmd, host, user) + + if result.returncode == 0: + break + if "port is already allocated" in (result.stderr or ""): + # `docker run` leaves a `Created`-but-never-started container behind + # under this name when the port bind fails. Remove it before + # retrying with a new port, or the retry hits a name conflict + # instead of the port conflict we're trying to work around. + _require_controller()._run_cmd(["docker", "rm", "-f", runtime_id], host, user) + host_port += 1 + continue + raise RuntimeError(f"Failed to launch {runtime_id}: {result.stderr}") + else: + raise RuntimeError( + f"Failed to launch {runtime_id}: no free port found after " + f"{MAX_PORT_ATTEMPTS} attempts" + ) + + endpoint = f"{_container_routing_host(host)}:{host_port}" + _require_controller().redis.set(f"controller:{endpoint}:agent_id", agent_id) instance = { "agent_name": agent_name, diff --git a/ventis/deploy.py b/ventis/controller/deploy.py similarity index 98% rename from ventis/deploy.py rename to ventis/controller/deploy.py index d197342..47de634 100644 --- a/ventis/deploy.py +++ b/ventis/controller/deploy.py @@ -17,7 +17,7 @@ def my_workflow(query: str): """ try: - import ventis.ventis_context as ventis_context + import ventis.controller.ventis_context as ventis_context except ImportError: import ventis_context import json @@ -33,7 +33,7 @@ def my_workflow(query: str): # Try to import from absolute package (local install) or fallback to flat file (Docker container) try: - from ventis.utils.redis_client import RedisClient + from ventis.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient diff --git a/ventis/future.py b/ventis/controller/future.py similarity index 94% rename from ventis/future.py rename to ventis/controller/future.py index 68615f1..04b050a 100644 --- a/ventis/future.py +++ b/ventis/controller/future.py @@ -1,6 +1,6 @@ import time import json -import uuid +import secrets import sys import os import logging @@ -8,12 +8,12 @@ import grpc try: - import ventis.ventis_context as ventis_context + import ventis.controller.ventis_context as ventis_context except ImportError: import ventis_context try: - from ventis.utils.grpc_options import GRPC_CHANNEL_OPTIONS + from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS except ImportError: from grpc_options import GRPC_CHANNEL_OPTIONS @@ -23,7 +23,7 @@ sys.path.insert(0, os.path.abspath("grpc_stubs")) try: - from ventis.utils.redis_client import RedisClient + from ventis.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient import local_controler_pb2 @@ -63,8 +63,11 @@ def __init__(self, parent, service, method, args=None): args: arguments to be passed to the method """ - # initial value of future object - self.id = uuid.uuid4().hex + # initial value of future object. 64-bit (8 bytes / 16 hex chars) -- + # this doubles as the OTel span_id (convert.py), which is defined as + # 64-bit, so it's generated at that width directly instead of a + # 128-bit uuid4 that would need truncating later. + self.id = secrets.token_hex(8) # Grab the request_id from the thread-local context (set by deploy) self.request_id = ventis_context.get_request_id() diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index fc5dd38..c85dcbf 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -13,6 +13,7 @@ import sys import threading import time +import uuid from concurrent.futures import ThreadPoolExecutor import yaml @@ -28,11 +29,14 @@ send_runtime_information, send_agent_information, ) -from ventis.utils.redis_client import RedisClient -from ventis.utils.grpc_options import GRPC_CHANNEL_OPTIONS - -# Add generated grpc_stubs from the local project to the path -sys.path.insert(0, os.path.abspath("grpc_stubs")) +from ventis.controller.utils.redis_client import RedisClient +from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS + +# Add generated grpc_stubs from the local project to the path. Projects using +# the .car artifact layout keep grpc_stubs under .car/; older/plain layouts +# keep it at the project root. +_artifact_prefix = ".car" if os.path.isdir(".car") else "" +sys.path.insert(0, os.path.abspath(os.path.join(_artifact_prefix, "grpc_stubs"))) import local_controler_pb2 import local_controler_pb2_grpc import grpc @@ -92,7 +96,7 @@ def __init__(self, config_path): self._last_metrics_poll_time = {} # (host, port) -> time.time() of last metrics read self._lc_stubs = {} # endpoint -> gRPC stub self.instance_manager = InstanceManager(self) - assign_project_id(self.config.get("project_id",0)) + assign_project_id(self.config.get("project_id")) # Clean up any stale containers from previous runs self._cleanup_stale_containers() @@ -188,12 +192,27 @@ def _cleanup_stale_containers(self): def _load_config(config_path): """Load the YAML config file after importing root .env values.""" project_root = os.path.abspath(os.path.join(os.path.dirname(config_path), "..")) + # Under the .car layout, config lives at /.car/config, so the + # naive parent-of-parent lands on .car itself -- go up one more level + # to reach the actual project root where .env lives. + if os.path.basename(project_root) == ".car": + project_root = os.path.dirname(project_root) GlobalController._load_dotenv(os.path.join(project_root, ".env")) with open(config_path, "r") as f: config = yaml.safe_load(f) + if not config.get("project_id"): + config["project_id"] = GlobalController._assign_new_project_id(config_path) config = GlobalController._expand_env_value(config) return config + @staticmethod + def _assign_new_project_id(config_path): + """Generate a project_id and append it to the config file so it stays stable across reloads/restarts.""" + project_id = str(uuid.uuid4()) + with open(config_path, "a") as f: + f.write(f'project_id: "{project_id}"\n') + return project_id + @staticmethod def _load_dotenv(path): """Load simple KEY=VALUE entries without overriding existing environment values.""" @@ -261,7 +280,7 @@ def reload_config(self): self.env_file_path = resolve_env_file(self.config) self.controllers = self.config.get("agents", []) self.poll_interval = self.config.get("poll_interval", 5) - assign_project_id(self.config.get("project_id", 0)) + assign_project_id(self.config.get("project_id")) self._write_identity() self.instance_manager.publish_routing_snapshot(self.controllers) @@ -324,7 +343,7 @@ def _load_and_write_policies(self): def _write_identity(self): """Publish the current project/database identity to every node's Redis.""" payload = { - "project_id": str(self.config.get("project_id", 0)), + "project_id": str(self.config.get("project_id")), "database_url": self.config.get("database", {}).get("url") or "", } targets = list(self.node_redis.values()) or [self.redis] @@ -377,8 +396,11 @@ def _launch_redis_containers(self): redis_port = node_cfg["redis_port"] user = node_cfg["user"] container_name = f"ventis-redis-{host.replace('.', '-')}" - # For localhost, connect directly; for remote, connect via host IP - connect_host = "localhost" if host in ("localhost", "127.0.0.1") else host + # VENTIS_REDIS_HOST overrides the localhost case for a containerized GC; host/remote paths unchanged. + if host in ("localhost", "127.0.0.1"): + connect_host = os.environ.get("VENTIS_REDIS_HOST", "localhost") + else: + connect_host = host if self._redis_container_healthy(container_name, host, user, connect_host, redis_port): logger.info("Reusing existing Redis container %s on %s", container_name, host) @@ -562,7 +584,7 @@ def _poll_one_instance(self, instance): try: future_rows = pull_runtime_information(node_redis) self._otel_db.write_waiting_rows( - future_rows, node_redis, self.config.get("project_id", 0) + future_rows, node_redis, self.config.get("project_id") ) # This is now legacy, keeping it for now, but will remove this later send_runtime_information( @@ -893,9 +915,9 @@ def stop(self): if __name__ == "__main__": - script_dir = os.path.dirname(os.path.abspath(__file__)) - project_root = os.path.join(script_dir, "..", "..") - default_config = os.path.join(project_root, "config", "global_controller.yaml") + default_config = os.path.join( + _artifact_prefix, "config", "global_controller.yaml" + ) import argparse @@ -904,7 +926,7 @@ def stop(self): "-c", "--config", default=default_config, - help="Path to the YAML config file (default: config/global_controller.yaml)", + help=f"Path to the YAML config file (default: {default_config})", ) args = parser.parse_args() diff --git a/ventis/controller/local_controller.py b/ventis/controller/local_controller.py index 8b5942e..8d9b525 100644 --- a/ventis/controller/local_controller.py +++ b/ventis/controller/local_controller.py @@ -18,8 +18,8 @@ try: from ventis.controller.local_controller_frontend import start_server from ventis.controller.utils.gpu_metrics import read_gpu_percent - from ventis.utils.redis_client import RedisClient - from ventis.utils.grpc_options import GRPC_CHANNEL_OPTIONS + from ventis.controller.utils.redis_client import RedisClient + from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS except ImportError: from gpu_metrics import read_gpu_percent from local_controller_frontend import start_server @@ -32,7 +32,7 @@ sys.path.insert(0, os.path.abspath("grpc_stubs")) try: - import ventis.ventis_context as ventis_context + import ventis.controller.ventis_context as ventis_context except ImportError: import ventis_context import local_controler_pb2 diff --git a/ventis/controller/local_controller_frontend.py b/ventis/controller/local_controller_frontend.py index a5fcc25..722bd16 100644 --- a/ventis/controller/local_controller_frontend.py +++ b/ventis/controller/local_controller_frontend.py @@ -32,7 +32,7 @@ def __init__(self, my_endpoint="unknown"): redis_host = os.environ.get("VENTIS_REDIS_HOST", "localhost") redis_port = int(os.environ.get("VENTIS_REDIS_PORT", 6379)) try: - from ventis.utils.redis_client import RedisClient + from ventis.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient self.redis = RedisClient(host=redis_host, port=redis_port) @@ -152,7 +152,7 @@ def _cleanup_request(self, request_id): def start_server(port=50051, my_endpoint="unknown"): """Start the gRPC server.""" try: - from ventis.utils.grpc_options import GRPC_SERVER_OPTIONS + from ventis.controller.utils.grpc_options import GRPC_SERVER_OPTIONS except ImportError: from grpc_options import GRPC_SERVER_OPTIONS diff --git a/ventis/utils/grpc_options.py b/ventis/controller/utils/grpc_options.py similarity index 100% rename from ventis/utils/grpc_options.py rename to ventis/controller/utils/grpc_options.py diff --git a/ventis/utils/redis_client.py b/ventis/controller/utils/redis_client.py similarity index 100% rename from ventis/utils/redis_client.py rename to ventis/controller/utils/redis_client.py diff --git a/ventis/controller/utils/telemetry_logging.py b/ventis/controller/utils/telemetry_logging.py index 7d911e2..503f3e1 100644 --- a/ventis/controller/utils/telemetry_logging.py +++ b/ventis/controller/utils/telemetry_logging.py @@ -7,7 +7,7 @@ from sqlalchemy import create_engine, text from ventis.controller.utils import pricing -from ventis.utils.redis_client import RedisClient +from ventis.controller.utils.redis_client import RedisClient logger = logging.getLogger(__name__) diff --git a/ventis/ventis_context.py b/ventis/controller/ventis_context.py similarity index 100% rename from ventis/ventis_context.py rename to ventis/controller/ventis_context.py diff --git a/ventis/llm/__init__.py b/ventis/llm/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/ventis/server.py b/ventis/server.py new file mode 100644 index 0000000..8df8b0f --- /dev/null +++ b/ventis/server.py @@ -0,0 +1,70 @@ +import os +import signal +import subprocess +import sys + +from flask import Flask, jsonify, request + +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" + +_gc_process = None + + +def _gc_running(): + return _gc_process is not None and _gc_process.poll() is None + + +@app.route("/new-project", methods=["POST"]) +def new_project(): + return jsonify({"error": "new-project runs locally via the CLI"}), 400 + + +@app.route("/deploy", methods=["POST"]) +def deploy(): + global _gc_process + + 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") + full_path = os.path.join(WORKSPACE_DIR, config_path) + + if not os.path.isfile(full_path): + return jsonify({"error": f"config file not found: {full_path}"}), 400 + + # `ventis deploy` builds (stubs/protos/images) then launches the Global + # Controller. cwd is the workspace so build outputs land alongside the + # project files and the controller finds them. Build+deploy output streams + # to the container logs, which `canyonos deploy` tails. + _gc_process = subprocess.Popen( + [sys.executable, "-m", "ventis.cli", "deploy", "-c", config_path], + cwd=WORKSPACE_DIR, + ) + return jsonify({"status": "started", "pid": _gc_process.pid}), 200 + + +@app.route("/clean", methods=["POST"]) +def clean(): + global _gc_process + + if not _gc_running(): + return jsonify({"error": "not running"}), 409 + + _gc_process.send_signal(signal.SIGTERM) + _gc_process.wait() + _gc_process = None + return jsonify({"status": "stopped"}), 200 + + +@app.route("/status", methods=["GET"]) +def status(): + return jsonify({"running": _gc_running()}), 200 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000) diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 33824ff..1108be1 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -378,8 +378,8 @@ def generate_docker( # Copy general agent files files_to_copy += [ # (source_path, destination_filename) - (os.path.join(script_dir, "future.py"), "future.py"), - (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), + (os.path.join(script_dir, "controller", "future.py"), "future.py"), + (os.path.join(script_dir, "controller", "ventis_context.py"), "ventis_context.py"), ( os.path.join(script_dir, "controller", "local_controller.py"), "local_controller.py", @@ -388,26 +388,20 @@ def generate_docker( os.path.join(script_dir, "controller", "local_controller_frontend.py"), "local_controller_frontend.py", ), - (os.path.join(script_dir, "utils", "redis_client.py"), "redis_client.py"), - (os.path.join(script_dir, "utils", "grpc_options.py"), "grpc_options.py"), + (os.path.join(script_dir, "controller", "utils", "redis_client.py"), "redis_client.py"), + (os.path.join(script_dir, "controller", "utils", "grpc_options.py"), "grpc_options.py"), ( os.path.join(script_dir, "controller", "utils", "gpu_metrics.py"), "gpu_metrics.py", ), - (os.path.join(script_dir, "llm", "bedrock.py"), "bedrock.py"), + (os.path.join(script_dir, "controller", "bedrock.py"), "bedrock.py"), ] # Copy provided agent stubs, overwriting the swept real file at the same path if stub_files: for stub_file in stub_files: - files_to_copy.append( - ( - os.path.abspath(stub_file), - _stub_destination(stub_file, stub_entrypoints or {}), - ) - ) - - files_to_copy.append((os.path.abspath(agent_file), os.path.basename(agent_file))) + destination = _stub_destination(stub_file, stub_entrypoints or {}) + files_to_copy.append((os.path.abspath(stub_file), destination)) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -417,6 +411,12 @@ def generate_docker( _copy_files(output_dir, files_to_copy) + # Copy the real agent entrypoint to the context root. + shutil.copy2( + os.path.abspath(agent_file), + os.path.join(output_dir, os.path.basename(agent_file)), + ) + # Copy the YAML definition too shutil.copy2( os.path.abspath(yaml_path), @@ -500,10 +500,9 @@ def generate_workflow_docker( files_to_copy = _sweep_py_files(project_dir) if project_dir else [] files_to_copy += [ - (os.path.abspath(workflow_file), workflow_basename), - (os.path.join(script_dir, "future.py"), "future.py"), - (os.path.join(script_dir, "ventis_context.py"), "ventis_context.py"), - (os.path.join(script_dir, "deploy.py"), "deploy.py"), + (os.path.join(script_dir, "controller", "future.py"), "future.py"), + (os.path.join(script_dir, "controller", "ventis_context.py"), "ventis_context.py"), + (os.path.join(script_dir, "controller", "deploy.py"), "deploy.py"), ( os.path.join(script_dir, "controller", "local_controller.py"), "local_controller.py", @@ -512,8 +511,8 @@ def generate_workflow_docker( os.path.join(script_dir, "controller", "local_controller_frontend.py"), "local_controller_frontend.py", ), - (os.path.join(script_dir, "utils", "redis_client.py"), "redis_client.py"), - (os.path.join(script_dir, "utils", "grpc_options.py"), "grpc_options.py"), + (os.path.join(script_dir, "controller", "utils", "redis_client.py"), "redis_client.py"), + (os.path.join(script_dir, "controller", "utils", "grpc_options.py"), "grpc_options.py"), *[ (os.path.join(script_dir, "controller", "utils", name), name) for name in ("gpu_metrics.py", "session_logging.py") @@ -522,12 +521,8 @@ def generate_workflow_docker( # Copy stub files, overwriting the swept real file at the same path for stub_file in stub_files: - files_to_copy.append( - ( - os.path.abspath(stub_file), - _stub_destination(stub_file, stub_entrypoints or {}), - ) - ) + destination = _stub_destination(stub_file, stub_entrypoints or {}) + files_to_copy.append((os.path.abspath(stub_file), destination)) # Copy gRPC generated stubs if they exist if os.path.isdir(grpc_stubs_dir): @@ -537,6 +532,12 @@ def generate_workflow_docker( _copy_files(output_dir, files_to_copy) + # Copy the real workflow entrypoint to the context root. + shutil.copy2( + os.path.abspath(workflow_file), + os.path.join(output_dir, workflow_basename), + ) + # ---- workflow_launcher.py -------------------------------------------- launcher = f"""import threading import time diff --git a/ventis/utils/__init__.py b/ventis/utils/__init__.py deleted file mode 100644 index 7863cb0..0000000 --- a/ventis/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# empty proxy module for packaging From 0508422fa5367d49844777cfea0aa428413da5cb Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 11:37:38 -0700 Subject: [PATCH 23/44] Point example workflow imports at the stub's entrypoint path Stub placement is now mirrored-only, so a workflow importing the flat basename no longer resolves -- the stub is written to agents/.py and nothing is left at the context root. Switch the four example workflows to the nested form. Cherry-picked from 7f925ef on fix/remove-duplicate-stub. Co-Authored-By: Claude Opus 5 (1M context) --- examples/finance/workflow/example_workflow.py | 4 ++-- examples/helloworld/workflow/example_workflow.py | 2 +- examples/portfolio/workflow/portfolio_workflow.py | 8 ++++---- examples/text2sql/workflow/text2sql_workflow.py | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/finance/workflow/example_workflow.py b/examples/finance/workflow/example_workflow.py index cac18ad..e4b6ce5 100644 --- a/examples/finance/workflow/example_workflow.py +++ b/examples/finance/workflow/example_workflow.py @@ -18,8 +18,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy -from finance_agent import FinanceAgent -from market_agent import MarketResearchAgent +from agents.finance_agent import FinanceAgent +from agents.market_agent import MarketResearchAgent def main(ticker: str = "AAPL"): diff --git a/examples/helloworld/workflow/example_workflow.py b/examples/helloworld/workflow/example_workflow.py index 842fe80..6bafff3 100644 --- a/examples/helloworld/workflow/example_workflow.py +++ b/examples/helloworld/workflow/example_workflow.py @@ -15,7 +15,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy -from example_agent import ExampleAgent +from agents.example_agent import ExampleAgent def main(name: str = "World"): diff --git a/examples/portfolio/workflow/portfolio_workflow.py b/examples/portfolio/workflow/portfolio_workflow.py index 44a1484..619c4bd 100644 --- a/examples/portfolio/workflow/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -31,10 +31,10 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy -from intent_agent import IntentAgent -from metrics_agent import MetricsAgent -from risk_agent import RiskAgent -from advisor_agent import AdvisorAgent +from agents.intent_agent import IntentAgent +from agents.metrics_agent import MetricsAgent +from agents.risk_agent import RiskAgent +from agents.advisor_agent import AdvisorAgent def main( diff --git a/examples/text2sql/workflow/text2sql_workflow.py b/examples/text2sql/workflow/text2sql_workflow.py index d2f32eb..ac9801d 100644 --- a/examples/text2sql/workflow/text2sql_workflow.py +++ b/examples/text2sql/workflow/text2sql_workflow.py @@ -25,11 +25,11 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) from deploy import deploy -from schema_agent import SchemaRetrievalAgent -from sql_generator_agent import SQLGeneratorAgent -from sql_validator_agent import SQLValidatorAgent -from sandbox_agent import SandboxExecutorAgent -from production_agent import ProductionExecutorAgent +from agents.schema_agent import SchemaRetrievalAgent +from agents.sql_generator_agent import SQLGeneratorAgent +from agents.sql_validator_agent import SQLValidatorAgent +from agents.sandbox_agent import SandboxExecutorAgent +from agents.production_agent import ProductionExecutorAgent def main(question: str = "total order amount per customer region", n_candidates: int = 3): From ff61053a171bab90417e3f19e0c28d37ad2dabc6 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 11:42:01 -0700 Subject: [PATCH 24/44] Route local-provider agents over a dedicated docker network Cherry-picked from a0efb5a on bug/local-provider-routing, resolved against the .car/CLI packaging changes already on this branch. Agents used to be reached at host.docker.internal:, which only works when whatever is doing the reaching shares the host's network namespace. They now join a `ventis-local` docker network and are addressed by container name at the fixed container port, so routing no longer depends on the caller's vantage point. - Local/_runtime.py: --network ventis-local instead of --add-host, VENTIS_AGENT_PORT is the container port, VENTIS_AGENT_HOST is the container name, routing_endpoint_for returns :50051. - global_controller.py: creates the network alongside the local Redis container, and derives status/metrics Redis keys from instance_manager._routing_endpoint_for instead of the host string. Conflict resolution notes: - Kept this branch's MAX_PORT_ATTEMPTS retry loop and env_file support around the docker run, applying the new network/env flags inside it. - Kept _is_local_host in Local/_runtime.py; a0efb5a dropped it, but env_file_args (added later on the CLI line) still needs it. - The controller::agent_id key written after launch, which postdates a0efb5a, now uses the container-name endpoint so it matches the keys global_controller reads back. Co-Authored-By: Claude Opus 5 (1M context) --- .../helloworld/config/global_controller.yaml | 6 +-- tests/test_instance_manager_runtime.py | 22 +++++----- .../cloud_provider_logic/Local/_runtime.py | 20 ++++----- ventis/controller/global_controller.py | 44 +++++++++---------- 4 files changed, 44 insertions(+), 48 deletions(-) diff --git a/examples/helloworld/config/global_controller.yaml b/examples/helloworld/config/global_controller.yaml index 0b9c194..5f6c0cc 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: EC2 + provider: local - name: VllmAgent replicas: 1 @@ -19,7 +19,7 @@ agents: cpu: 2 memory: 2048 entrypoint: agents/vllm_agent.py - provider: EC2 + provider: local 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: EC2 + provider: local instance_type: t3.micro poll_interval: 5 diff --git a/tests/test_instance_manager_runtime.py b/tests/test_instance_manager_runtime.py index df35efe..13f9876 100644 --- a/tests/test_instance_manager_runtime.py +++ b/tests/test_instance_manager_runtime.py @@ -138,7 +138,7 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "host_port": "8000", "container_port": "50051", "endpoint": "localhost:8000", - "redis_host": "host.docker.internal", + "redis_host": "ventis-redis-localhost", "redis_port": "6379", "runtime_id": "ventis-local-alpha-0", }, @@ -154,17 +154,18 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "run", "-d", "-it", - "--add-host=host.docker.internal:host-gateway", + "--network", + "ventis-local", "--name", "ventis-local-alpha-0", "-p", "8000:50051", "-e", - "VENTIS_AGENT_PORT=8000", + "VENTIS_AGENT_PORT=50051", "-e", - "VENTIS_AGENT_HOST=host.docker.internal", + "VENTIS_AGENT_HOST=ventis-local-alpha-0", "-e", - "VENTIS_REDIS_HOST=host.docker.internal", + "VENTIS_REDIS_HOST=ventis-redis-localhost", "-e", "VENTIS_REDIS_PORT=6379", "-e", @@ -209,17 +210,18 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): "run", "-d", "-it", - "--add-host=host.docker.internal:host-gateway", + "--network", + "ventis-local", "--name", "ventis-local-workflow-0", "-p", "8000:50051", "-e", - "VENTIS_AGENT_PORT=8000", + "VENTIS_AGENT_PORT=50051", "-e", - "VENTIS_AGENT_HOST=host.docker.internal", + "VENTIS_AGENT_HOST=ventis-local-workflow-0", "-e", - "VENTIS_REDIS_HOST=host.docker.internal", + "VENTIS_REDIS_HOST=ventis-redis-localhost", "-e", "VENTIS_REDIS_PORT=6379", "-e", @@ -255,7 +257,7 @@ def test_agent_id_is_published_under_the_controller_endpoint_key(self): alpha = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] self.assertEqual( - controller.redis.get("controller:host.docker.internal:8000:agent_id"), + controller.redis.get("controller:ventis-local-alpha-0:50051:agent_id"), alpha["agent_id"], ) diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index 1a84e56..dda4807 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -16,6 +16,7 @@ CONTAINER_PORT = 50051 PROVIDER = "local" MAX_PORT_ATTEMPTS = 50 +NETWORK = "ventis-local" _controller = None @@ -29,10 +30,6 @@ def _is_local_host(host): return host in {"localhost", "127.0.0.1"} -def _container_routing_host(host): - return "host.docker.internal" if _is_local_host(host) else host - - def validate_config(): return None @@ -46,7 +43,7 @@ def provision_instance(spec, replica_index, next_host_port): "provider": PROVIDER, "host": host, "host_port": host_port, - "redis_host": _container_routing_host(host), + "redis_host": f"ventis-redis-{host.replace('.', '-')}", "runtime_id": f"ventis-{PROVIDER}-{agent_name.lower()}-{replica_index}", "user": spec.get("user"), } @@ -80,15 +77,16 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): "run", "-d", "-it", - "--add-host=host.docker.internal:host-gateway", + "--network", + NETWORK, "--name", runtime_id, "-p", f"{host_port}:{CONTAINER_PORT}", "-e", - f"VENTIS_AGENT_PORT={host_port}", + f"VENTIS_AGENT_PORT={CONTAINER_PORT}", "-e", - f"VENTIS_AGENT_HOST={redis_host}", + f"VENTIS_AGENT_HOST={runtime_id}", "-e", f"VENTIS_REDIS_HOST={redis_host}", "-e", @@ -138,7 +136,7 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): f"{MAX_PORT_ATTEMPTS} attempts" ) - endpoint = f"{_container_routing_host(host)}:{host_port}" + endpoint = f"{runtime_id}:{CONTAINER_PORT}" _require_controller().redis.set(f"controller:{endpoint}:agent_id", agent_id) instance = { @@ -174,6 +172,4 @@ def terminate_instance(instance): def routing_endpoint_for(instance): - host = instance.get("host") - port = instance["host_port"] - return f"{_container_routing_host(host)}:{port}" + return f"{instance['runtime_id']}:{CONTAINER_PORT}" diff --git a/ventis/controller/global_controller.py b/ventis/controller/global_controller.py index c85dcbf..4bf109a 100644 --- a/ventis/controller/global_controller.py +++ b/ventis/controller/global_controller.py @@ -44,15 +44,13 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +LOCAL_NETWORK = "ventis-local" + def _is_local_host(host): return host in {"localhost", "127.0.0.1"} -def _container_routing_host(host): - return "host.docker.internal" if _is_local_host(host) else host - - class GlobalController(object): """ Daemon that manages a routing table across multiple local controller instances. @@ -406,12 +404,16 @@ def _launch_redis_containers(self): logger.info("Reusing existing Redis container %s on %s", container_name, host) self.redis_containers[host] = container_name else: + if _is_local_host(host): + self._run_cmd(["docker", "network", "create", LOCAL_NETWORK], host, user) + network_args = ["--network", LOCAL_NETWORK] if _is_local_host(host) else [] cmd = [ "docker", "run", "-d", "--name", container_name, + *network_args, "-p", f"{redis_port}:6379", "redis:alpine", @@ -484,10 +486,6 @@ def _get_node_redis_for(self, host): """Get the Redis client for a given host, falling back to self.redis.""" return self.node_redis.get(host, self.redis) - def _agent_host_key(self, host): - """Return the host string as seen by Docker containers (for status key matching).""" - return _container_routing_host(host) - def _wait_for_healthy(self, timeout=30, interval=2): """ Block until all controllers report healthy in Redis, or until timeout. @@ -497,10 +495,7 @@ def _wait_for_healthy(self, timeout=30, interval=2): interval: Seconds between checks. """ deadline = time.time() + timeout - pending = [ - (instance["agent_name"], instance["host"], instance["host_port"]) - for instance in self.instance_manager.list_instances() - ] + pending = self.instance_manager.list_instances() logger.info( "Waiting for %d replica(s) to become healthy (timeout=%ds)...", @@ -510,26 +505,29 @@ def _wait_for_healthy(self, timeout=30, interval=2): while pending and time.time() < deadline: still_pending = [] - for name, host, port in pending: + for instance in pending: + name = instance["agent_name"] + host = instance["host"] + port = instance["host_port"] node_redis = self._get_node_redis_for(host) - agent_host = self._agent_host_key(host) - status = node_redis.get(f"controller:{agent_host}:{port}:status") + endpoint = self.instance_manager._routing_endpoint_for(instance) + status = node_redis.get(f"controller:{endpoint}:status") if status == "healthy": logger.info("Controller %s (%s:%s) is ready.", name, host, port) self._last_status[(host, port)] = "healthy" else: - still_pending.append((name, host, port)) + still_pending.append(instance) pending = still_pending if pending: time.sleep(interval) if pending: - for name, host, port in pending: + for instance in pending: logger.warning( "Controller %s (%s:%s) not ready after %ds.", - name, - host, - port, + instance["agent_name"], + instance["host"], + instance["host_port"], timeout, ) @@ -601,9 +599,9 @@ def _poll_one_instance(self, instance): port, e, ) - agent_host = self._agent_host_key(host) - status_key = f"controller:{agent_host}:{port}:status" - metrics_key = f"controller:{agent_host}:{port}:metrics" + endpoint = self.instance_manager._routing_endpoint_for(instance) + status_key = f"controller:{endpoint}:status" + metrics_key = f"controller:{endpoint}:metrics" # Getting metrics from local controllers # See LocalController._execute_locally From 6f36d4f1c090def778f5275c8b25da45a9844864 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 12:22:28 -0700 Subject: [PATCH 25/44] Add the canyonos CLI package, porting skill, and .car examples The CLI layer on top of the ventis fixes: `canyonos` wraps the Global Controller in a container and drives it over HTTP, so a project goes from source to a running workflow with a local dashboard in one command. - cli/: the canyonos package -- deploy (which folds in init, sync, build and launch, then auto-starts the dashboard once the workflow reports up), serve, stop, logs, quit, clean, config, integrate, new-app. - cli/canyonos/dashboard.compose.yml: api/db/web stack. The api port is published so the GC container can POST OTLP spans to /v1/traces, which is also what renames ventis' `project_id` attribute to the `canyon.project.id` the dashboard queries filter on. serve replaces the api container every run, since it reads the controller's Redis identity only at startup and would otherwise keep serving a stale project. - cli/canyonos/constants.py: resolve the config path per call rather than at import, preferring .car/config over the flat layout. - .claude/skills/porting-to-canyonos-core/: the porting skill `canyonos integrate` installs, plus its validator. - examples/: joke_writer converted to the .car layout, epigenomics added, and workflow imports pointed at each agent's entrypoint path to match single-location stub placement. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + README.md | 32 +- SESSION_NOTES.md | 79 ++ .../skills/porting-to-canyonos-core/SKILL.md | 240 ++++ .../references/ec2.md | 41 + .../references/llm-proxy.md | 64 + .../references/packaging.md | 81 ++ .../references/runtime-contract.md | 187 +++ .../references/troubleshooting.md | 60 + .../porting-to-canyonos-core/validate.py | 1257 +++++++++++++++++ cli/README.md | 21 + cli/canyonos/__init__.py | 0 cli/canyonos/clean.py | 28 + cli/canyonos/config.py | 345 +++++ cli/canyonos/constants.py | 9 + cli/canyonos/dashboard.compose.yml | 42 + cli/canyonos/dashboard_stack.py | 518 +++++++ cli/canyonos/deploy.py | 86 ++ cli/canyonos/init.py | 129 ++ cli/canyonos/integrate.py | 82 ++ cli/canyonos/logs.py | 37 + cli/canyonos/new_app.py | 21 + cli/canyonos/quit.py | 63 + cli/canyonos/serve.py | 18 + cli/canyonos/stop.py | 47 + cli/canyonos/sync.py | 40 + cli/canyonos/theme.py | 20 + cli/cli.py | 217 +++ cli/pyproject.toml | 27 + cli/tests/test_dashboard_stack.py | 432 ++++++ cli/utils/__init__.py | 0 cli/utils/tui.py | 113 ++ examples/epigenomics/README.md | 70 + examples/epigenomics/agents/dedup_agent.py | 44 + examples/epigenomics/agents/dedup_agent.yaml | 10 + examples/epigenomics/agents/filter_agent.py | 32 + examples/epigenomics/agents/filter_agent.yaml | 12 + examples/epigenomics/agents/index_agent.py | 30 + examples/epigenomics/agents/index_agent.yaml | 12 + examples/epigenomics/agents/map_agent.py | 33 + examples/epigenomics/agents/map_agent.yaml | 14 + examples/epigenomics/agents/sort_agent.py | 32 + examples/epigenomics/agents/sort_agent.yaml | 14 + examples/epigenomics/agents/split_agent.py | 27 + examples/epigenomics/agents/split_agent.yaml | 12 + .../epigenomics/config/global_controller.yaml | 76 + examples/epigenomics/config/policy.yaml | 20 + .../workflow/epigenomics_workflow.py | 97 ++ .../helloworld/config/global_controller.yaml | 6 +- examples/joke_writer/.car/app/.env.example | 20 + examples/joke_writer/.car/app/LICENSE | 21 + examples/joke_writer/.car/app/README.md | 177 +++ .../joke_writer/.car/app/joke_workflow.py | 39 + examples/joke_writer/.car/app/joke_writer.py | 164 +++ .../.car/config/global_controller.yaml | 52 + .../joke_writer/.car/config/joke_agent.yaml | 35 + examples/joke_writer/README.md | 8 +- examples/joke_writer/agents/joke_agent.py | 63 - examples/joke_writer/agents/joke_agent.yaml | 53 - .../joke_writer/config/global_controller.yaml | 82 -- examples/joke_writer/config/policy.yaml | 20 - .../joke_writer/workflow/joke_workflow.py | 59 - .../skills/porting-to-canyonos-core/SKILL.md | 240 ++++ .../references/ec2.md | 41 + .../references/llm-proxy.md | 64 + .../references/packaging.md | 81 ++ .../references/runtime-contract.md | 187 +++ .../references/troubleshooting.md | 60 + .../porting-to-canyonos-core/validate.py | 1257 +++++++++++++++++ examples/portfolio/agents/advisor_agent.py | 4 +- examples/portfolio/agents/intent_agent.py | 4 +- .../portfolio/config/global_controller.yaml | 32 +- .../portfolio/workflow/portfolio_workflow.py | 2 +- examples/text2sql/agents/vllm_agent.py | 4 +- 74 files changed, 7321 insertions(+), 329 deletions(-) create mode 100644 SESSION_NOTES.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/SKILL.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/ec2.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/packaging.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md create mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md create mode 100755 cli/.claude/skills/porting-to-canyonos-core/validate.py create mode 100644 cli/README.md create mode 100644 cli/canyonos/__init__.py create mode 100644 cli/canyonos/clean.py create mode 100644 cli/canyonos/config.py create mode 100644 cli/canyonos/constants.py create mode 100644 cli/canyonos/dashboard.compose.yml create mode 100644 cli/canyonos/dashboard_stack.py create mode 100644 cli/canyonos/deploy.py create mode 100644 cli/canyonos/init.py create mode 100644 cli/canyonos/integrate.py create mode 100644 cli/canyonos/logs.py create mode 100644 cli/canyonos/new_app.py create mode 100644 cli/canyonos/quit.py create mode 100644 cli/canyonos/serve.py create mode 100644 cli/canyonos/stop.py create mode 100644 cli/canyonos/sync.py create mode 100644 cli/canyonos/theme.py create mode 100644 cli/cli.py create mode 100644 cli/pyproject.toml create mode 100644 cli/tests/test_dashboard_stack.py create mode 100644 cli/utils/__init__.py create mode 100644 cli/utils/tui.py create mode 100644 examples/epigenomics/README.md create mode 100644 examples/epigenomics/agents/dedup_agent.py create mode 100644 examples/epigenomics/agents/dedup_agent.yaml create mode 100644 examples/epigenomics/agents/filter_agent.py create mode 100644 examples/epigenomics/agents/filter_agent.yaml create mode 100644 examples/epigenomics/agents/index_agent.py create mode 100644 examples/epigenomics/agents/index_agent.yaml create mode 100644 examples/epigenomics/agents/map_agent.py create mode 100644 examples/epigenomics/agents/map_agent.yaml create mode 100644 examples/epigenomics/agents/sort_agent.py create mode 100644 examples/epigenomics/agents/sort_agent.yaml create mode 100644 examples/epigenomics/agents/split_agent.py create mode 100644 examples/epigenomics/agents/split_agent.yaml create mode 100644 examples/epigenomics/config/global_controller.yaml create mode 100644 examples/epigenomics/config/policy.yaml create mode 100644 examples/epigenomics/workflow/epigenomics_workflow.py create mode 100644 examples/joke_writer/.car/app/.env.example create mode 100644 examples/joke_writer/.car/app/LICENSE create mode 100644 examples/joke_writer/.car/app/README.md create mode 100644 examples/joke_writer/.car/app/joke_workflow.py create mode 100644 examples/joke_writer/.car/app/joke_writer.py create mode 100644 examples/joke_writer/.car/config/global_controller.yaml create mode 100644 examples/joke_writer/.car/config/joke_agent.yaml delete mode 100644 examples/joke_writer/agents/joke_agent.py delete mode 100644 examples/joke_writer/agents/joke_agent.yaml delete mode 100644 examples/joke_writer/config/global_controller.yaml delete mode 100644 examples/joke_writer/config/policy.yaml delete mode 100644 examples/joke_writer/workflow/joke_workflow.py create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md create mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md create mode 100755 examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py 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..1d61db8 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 integrate`. -- **`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 ``` diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md new file mode 100644 index 0000000..360efcc --- /dev/null +++ b/SESSION_NOTES.md @@ -0,0 +1,79 @@ +# Session notes: canyonos serve, OTel pipeline, examples + +## Fixed (code changes, rebuilt+pushed `saakeths/canyonos:latest` where needed) + +1. **`ventis/stub_generator.py`**: stubs only ever got placed at ONE path + (nested-at-entrypoint, never flat), breaking any workflow that imports a + sibling agent directly (`from split_agent import SplitAgent`, e.g. + `examples/epigenomics`). Now placed at both. +2. **`otel_exporter.py`**: hardcoded Redis to `localhost`, but it runs inside + the GC container (bridge networking) while Redis is a sibling container — + crash-looped forever. Fixed to `host.docker.internal`. +3. **`ventis/OTLP_Exporter/db.py`**: `write_waiting_rows()` unconditionally + priced every future via a `aws_instance_pricing` table that only exists for + EC2 deployments — silently dropped **every** span for **every** + `provider: local` deployment, always. Wrapped cost lookups in try/except, + falls back to $0. +4. **`dashboard_stack.py`**: `web` port was hardcoded 8080 with no fallback. + Now searches for a free port (reuses an already-running dashboard's port + if one exists), same pattern as `init.py`'s GC port selection. +5. **`dashboard_stack.py`**: `database.url` was required; made optional + (dashboard boots fine with no DB configured in the project's own config). +6. **`dashboard.compose.yml`**: added `pull_policy: never` for the local-only + `canyonos-otel-receiver:local` image (compose was trying to pull it from a + registry that doesn't have it). Sequenced `otel-receiver` to start after + `api` (both raced to create `otel_spans`; `api`'s bare `CREATE TABLE` lost + the race and crashed). + +## Bundled (new, working) + +- `db` (plain Postgres) + `otel-receiver` (new `Dockerfile` for + `otlp_pg_receiver`, built locally as `canyonos-otel-receiver:local`) added + to `dashboard.compose.yml`. Verified real spans, sent via the actual OTLP + gRPC exporter, land in `otel_spans`. + +## Workarounds applied, NOT real fixes (will resurface) + +- **`canyonos quit` only tears down the GC container + volume**, never the + deployed agent/workflow/redis containers. Had to `docker rm -f` those by + exact name every time before a truly clean restart. +- **The named workspace volume is additive-only** (`docker cp`, never + clears) — files from a previous project leak into the next one's build + until you manually nuke the volume. +- **`otlp_pg_receiver` holds one Postgres connection with no reconnect + logic** — a DB restart silently kills every future write until the + receiver container itself is restarted. +- **`joke_writer/.car` layout** (`app/`, `config/`) doesn't match what + `ventis/cli.py`'s build step expects (`agents/`, flat entrypoints) — worked + around by manually copying files into the shape it wants. The real fix + (`nickhuo/car-artifact-layout`, already pushed) was not merged in. +- **joke_writer's LLM calls are stubbed to return `"animal"`** — for testing + only, real Bedrock creds needed to restore actual behavior (commented-out + code left in place). + +## Known, not touched + +- Pre-existing OrbStack local-provider startup race (first request right + after a container reports healthy can fail); a fix exists on an unrelated, + unmerged branch. +- Stale global `uv tool install` is a recurring trap — always + `uv tool install --reinstall .` after any `cli/` change. + +## What's still needed to actually see data in the UI + +The whole pipeline up to Postgres now genuinely works. **Nothing shows up in +the dashboard because `canyonos-api`/`canyonos-web` have zero code that reads +or displays `otel_spans`** — confirmed by inspecting their actual source +(they're a `cc-forge` rebrand: deploy/project management + a static +code-structure diagram, unrelated data model). To close the loop: + +1. New API route(s) in `canyon-code-forge/packages/api` that query + `otel_spans`. +2. New UI screen(s) in `canyon-code-forge/apps/web` to render it. +3. `web` currently has **no path to reach `api` at all** even once that + exists — no reverse proxy in its Caddyfile, and `api`'s port isn't + published to the host in `dashboard.compose.yml`. Needs one or the other + before the browser can fetch anything. + +All of the above is real feature work in a different repo, not a config or +wiring fix. diff --git a/cli/.claude/skills/porting-to-canyonos-core/SKILL.md b/cli/.claude/skills/porting-to-canyonos-core/SKILL.md new file mode 100644 index 0000000..fe6dd49 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/SKILL.md @@ -0,0 +1,240 @@ +--- +name: porting-to-canyonos-core +description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. +compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. +--- + +# Port an agent project to CanyonOS Core + +CanyonOS Core is the product name. Its compatibility executable and Python +package remain `ventis`; environment variables and Docker resources retain the +`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding +strings. Do not rename them. + +## Load references only when needed + +- Read [references/packaging.md](references/packaging.md) when a source import + does not resolve from `/app`, the source is nested, or packaging metadata is + involved. +- Read [references/llm-proxy.md](references/llm-proxy.md) only when the target + includes `llm_proxy`. +- Read [references/ec2.md](references/ec2.md) only when any config entry uses + `provider: EC2`. +- Read [references/troubleshooting.md](references/troubleshooting.md) after a + failed build, image probe, deploy, or request. +- Read [references/runtime-contract.md](references/runtime-contract.md) when a + validator finding needs explanation or the runtime mechanism is unclear. + +## Goal: thin scaffolding beside untouched source + +```text +agents/.yaml one callable surface per service +agents/.py one thin adapter per service, when needed +workflow/_workflow.py HTTP entry point; calls deploy() +config/global_controller.yaml deployment manifest +config/policy.yaml optional access restriction +pyproject.toml conditional nested-import scaffolding + unchanged +``` + +The file count follows the deployment. A multi-agent port has one yaml/adapter +pair per service that is worth deploying separately. If a source class already +satisfies the runtime contract, point its config entry at that file and do not +copy it into an adapter. + +Everything the source already owns—prompts, tools, schemas, parsing, retries, +model clients, and node bodies—is imported. The port re-expresses only the +CanyonOS Core boundary and framework-owned orchestration. + +The port root is the existing repository root and the directory from which +`ventis build` runs. Write scaffolding there beside existing directories. If the +repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, +and `config/` beside it. Never move or copy the repository into a new `src/` +directory, and never create an outer wrapper merely for the port. + +## 1. Survey before writing + +Identify: + +1. The source entry point and callable input/output. +2. Framework-owned control flow (`StateGraph`, `Crew`, `GroupChat`, routing, + `Send`, `Command`, interrupts). +3. Runtime-injected services nodes read: stores, context, memory, sessions, or + callback managers. +4. Sync versus async boundaries. +5. Imports and declared runtime dependencies. +6. Model provider, credential names, streaming use, and optional `llm_proxy`. +7. Whether independent work fans out and benefits from separate replicas. +8. Whether source imports resolve from the project root that becomes `/app`. + +Run the validator once now. Its header detects capabilities directly from the +importable runtime rather than external development metadata: + +```bash +python /validate.py . +``` + +If config or agent yaml is malformed, the validator defers to `ventis build`. +Capability-gated findings say which runtime behavior is available. + +## 2. Choose service boundaries + +Start with one service. Split only when it creates independent parallel work or +a distinct resource/replica profile. + +- Keep a ReAct loop together; every turn needs shared message history. +- Hoist supervisor task lists and `Send`-style fan-out into the workflow. +- Do not create a one-replica service with no distinct resource profile merely + to mirror every source graph node. + +Rewrite framework-owned edges as ordinary Python. Import the connected node +functions unchanged. Construct runtime-injected service objects from source +configuration; do not invent models, dimensions, stores, or defaults silently. +Report any choice the source does not specify. + +## 3. Write declarations and adapters + +### Agent yaml + +Use one yaml per deployed service. Argument types are bare builtins only: +`str`, `int`, `float`, `bool`, `dict`, or `list`. Every declared argument is +required by the generated stub. `returns.type` is documentation; use `dict` or +`list` to signal that workflow callers must `json.loads` the returned string. + +### Adapter + +The entrypoint exposes a module-level class named exactly `agent.name`. It +constructs with no arguments and its declared methods are synchronous. Read +configuration from the environment in `__init__`. Bridge source coroutines +inside a synchronous method with `asyncio.run(...)`. Serialize framework objects +with their own JSON-safe serializer before returning. + +Do not duplicate source prompts, tools, schemas, or model calls. Keep the source +provider and SDK. + +### Workflow + +Expose `main(query: str)` and call `deploy(main, port=...)` at module scope. +Import generated stubs by yaml basename and agent class name: + +```python +from deploy import deploy +from agents. import +``` + +The deployment platform sends `{query: string}` to `/main`. Pack richer input +inside `query`; any additional workflow parameter has a default. + +Dispatch every remote call before resolving any future: + +```python +futures = [agent.work(item=item) for item in items] +results = [json.loads(future.value()) for future in futures] +``` + +Do not fuse dispatch and `.value()` in one comprehension; that silently +serializes fan-out. Do not add an `if __name__ == "__main__":` block: the +workflow is executed with `__name__ == "__main__"` in production. + +### Config + +For each service, keep these names aligned: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +Use lowercase `provider: local`; `replicas` is an integer; `requirements` is a +list of distribution-name strings. Put `env_file` at config top level when the +runtime capability is available. Omit `policy.yaml` unless access must be +restricted; if present, give it a non-empty `rules` list. + +## Hard rules + +Capitalized **MUST** and **NEVER** are reserved for port-breaking or +source-integrity rules. The owner column states where each is decided. + +| ID | Rule | Owner | +|---|---|---| +| M1 | Entrypoint MUST define a class named exactly `agent.name` | V006 | +| M2 | The class MUST construct with no arguments | V007 | +| M3 | yaml argument names MUST match Python parameter names | V008 | +| M4 | yaml argument types MUST be bare builtins | V010 | +| M5 | Declared adapter methods MUST be synchronous | V009 | +| M6 | Config names MUST match yaml agent names | build | +| M7 | Config names MUST not collide after lowercase normalization | build output | +| M8 | Local provider MUST be lowercase `local` | deploy preflight | +| M9 | `replicas` MUST be an integer | deploy preflight | +| M10 | `requirements` MUST be a list of strings | build | +| M11 | Workflow MUST expose `main(query)`; extra parameters MUST default | V016 | +| M12 | Workflow MUST NEVER contain a main guard | V017 | +| M13 | Fan-out MUST dispatch all calls before resolving any | V018 | +| M14 | Project modules MUST not take runtime or generated-stub flat names | V019/V020 | +| M15 | Workflow MUST import stubs from `agents.` | V023 | +| M16 | Policy MUST be absent or contain a non-empty `rules` list | deploy preflight | +| M17 | EC2 entries MUST satisfy the EC2 deployment contract | deploy preflight | +| M18 | NEVER copy source prompts, tools, schemas, or model calls | review | +| M19 | NEVER hardcode or bake a real credential into an image | W003 | +| M20 | NEVER edit or vendor the source tree | `git status` | +| M21 | NEVER swap the source LLM provider | review | +| M22 | NEVER silently move, drop, or reclassify source dependencies | review | +| M23 | Framework control flow MUST be rewritten; source node logic MUST be imported | review | +| M24 | A non-resolving source import MUST have usable root packaging metadata when editable install is supported | V031 | + +## 4. Validate, build, and probe + +Run static preflight, then let the build own build-time validation: + +```bash +python /validate.py . +ventis build -c config/global_controller.yaml +``` + +A green build never imports the adapter. Probe each agent image in this order: + +```bash +# Runtime startup path + docker run --rm ventis- \ + python -c "import local_controller" + +# Agent load path; include --env-file when configured + docker run --rm --env-file ventis- \ + python -c "import importlib.util,sys; \ +s=importlib.util.spec_from_file_location('m','.py'); \ +m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ +m.();print('ok')" +``` + +Also probe the workflow image with `python -c "import local_controller"`; it has +its own dependency resolve and generated-stub imports. + +Then deploy, send a representative request, and poll its status: + +```bash +ventis deploy -c config/global_controller.yaml +curl -X POST http://localhost:8080/main \ + -H 'Content-Type: application/json' -d '{"query":""}' +curl http://localhost:8080/status/ +``` + +A successful outer request with a source-level failure still proves the port +reached and returned the source behavior. Record the distinction. + +## 5. Clean up + +After collecting evidence, stop foreground deploy with Ctrl+C and wait for +controller cleanup. Remove exact leftovers if startup crashed. Then remove build +products and exact images from this config: + +```bash +ventis clean +docker image rm ventis- \ + ventis- + +test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container +docker ps -a --format '{{.Names}}' +``` + +`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it +does not remove containers or images. Keep port scaffolding, untouched source, +and requested logs or reports. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md b/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md new file mode 100644 index 0000000..e06daa0 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md @@ -0,0 +1,41 @@ +# EC2 deployment + +Read this only when at least one config entry uses `provider: EC2`. + +## Configuration + +Use `provider: EC2` and declare `instance_type` on every EC2 service entry. The +top-level `ec2` block supplies the runtime's required infrastructure and SSH +settings. Read the target checkout's deploy preflight and EC2 runtime before +writing the block; do not copy values from an example environment. + +Typical required categories are: + +- AMI and instance type +- region and subnet +- security groups +- SSH user and credentials accepted by the runtime + +`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof +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. + +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 +target runtime rather than assuming local Docker semantics. + +## Probes and cleanup + +Run the same runtime and adapter probes against the exact image before remote +deployment. After deploy, verify the remote container logs; controller health +can be green even when agent loading failed. + +Stop foreground deploy normally so the controller can terminate recorded EC2 +instances. If provisioning or startup fails before an instance is recorded, +inspect the cloud provider directly and remove exact leaked resources. Never use +a broad cleanup command against unrelated instances. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md new file mode 100644 index 0000000..f726bd7 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md @@ -0,0 +1,64 @@ +# LLM proxy integration + +Read this only when the target checkout contains `llm_proxy` or the deployment +explicitly routes model SDKs through it. + +## Preserve provider protocols + +The proxy redirects provider endpoints; it does not convert providers. Keep the +source SDK, model ID, request body, and response parsing unchanged. + +Configure only the provider variables the source uses: + +```dotenv +OPENAI_BASE_URL=http://host.docker.internal:8081/openai/v1 +ANTHROPIC_BASE_URL=http://host.docker.internal:8081/anthropic +AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://host.docker.internal:8081/bedrock +``` + +Some SDKs refuse to initialize without caller credentials. Give agent containers +non-secret placeholders only when required. Keep real OpenAI, Anthropic, or AWS +credentials in the separate proxy process, not in the port's `env_file`. + +## Start locally + +The proxy defaults conflict with a typical deployment: host loopback is not +reachable from a container, and port 8080 is normally used by the workflow API. +Use a non-loopback bind and a different port: + +```bash +PROXY_HOST=0.0.0.0 PROXY_PORT=8081 python -m llm_proxy +curl http://127.0.0.1:8081/healthz +``` + +Local CanyonOS Core containers resolve `host.docker.internal` through their +Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the +machine running `ventis deploy`. Distributed deployments need a reachable proxy +address or one proxy on each host. + +## Supported call shape + +The implementation buffers complete requests and responses: + +- OpenAI and Anthropic non-streaming HTTP calls are forwarded. +- Bedrock `invoke` is reissued through the proxy's boto3 client. +- OpenAI/Anthropic streaming is unsupported. +- Bedrock `invoke-with-response-stream`, `converse`, and `converse-stream` are + unsupported. + +Survey the source before selecting the proxy. Do not silently disable streaming; +report the unsupported behavior and stop. + +## Credential behavior + +- The OpenAI adapter removes caller authorization and inserts the proxy key. +- The Anthropic adapter removes caller key headers and inserts the proxy key. +- Botocore still signs requests sent to a custom endpoint, so a caller may need + placeholder AWS credentials even though the proxy reissues upstream with its + own identity. +- `/healthz` proves provider registration and Flask availability, not upstream + credential validity. + +OpenAI and Anthropic upstream HTTP errors pass through. Proxy exceptions return +JSON 502 with `error: proxy_error`. Bedrock `ClientError` bodies are reconstructed +with the upstream status and are not byte-for-byte passthrough. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md b/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md new file mode 100644 index 0000000..8520c4a --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md @@ -0,0 +1,81 @@ +# Packaging and import roots + +Read this reference when an adapter imports nested source code, the source uses a +`src/` layout, or V031 reports an import-root problem. + +## What `/app` can import + +CanyonOS Core preserves project-relative paths in the image and starts Python at +`/app`. Without an editable install, Python resolves names rooted there: + +- `/app/tools.py` as `import tools` +- `/app/pkg/__init__.py` as `import pkg` +- `/app/src/agents/...` as `import src.agents`, including PEP 420 namespace + directories without `__init__.py` + +It does not resolve `/app/source/pkg` as `import pkg`; `/app/source` must become +an import root first. + +## Detect support, do not infer it from release history + +Run: + +```bash +python /validate.py . +``` + +Read the `editable_install` capability. If it is unavailable and the original +import cannot resolve from `/app`, report a runtime capability blocker and stop. +Do not add a `sys.path` hack or relocate source files. + +## Root metadata is the trigger + +When editable install is supported, only packaging metadata at the **port root** +triggers `pip install -e .`: + +```text +port-root/pyproject.toml detected +port-root/source/pyproject.toml ignored as an install trigger +``` + +A nested source repository may remain untouched. Add minimal root scaffolding +that points package discovery at the existing source package: + +```toml +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "canyonos-port" +version = "0.0.0" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["source/src"] +include = ["pkg*"] +namespaces = true +``` + +Set `where` and `include` from the actual tree and original import spelling. Do +not reference a README or license from this wrapper metadata; file sweeps differ +by runtime capability and a missing referenced file makes the image build fail. + +## Dependencies in nested metadata + +A nested `pyproject.toml` is not installed merely because its Python files are +copied. Keep the source declaration unchanged and repeat its runtime +distributions in each relevant config entry's `requirements` list. This is +compatibility scaffolding, not permission to drop, move, or reclassify declared +dependencies. + +If source metadata is already at the port root, do not create a wrapper. Its +project dependencies participate in the same resolver as config requirements. +Report declared-but-unused toolchain dependencies and their image cost; let the +owner decide whether source metadata should change. + +## Validation boundary + +`ventis build` owns packaging syntax and installation errors. `validate.py` +checks only whether adapter imports appear to require a nested root that the +runtime will not expose. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md new file mode 100644 index 0000000..94f7401 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md @@ -0,0 +1,187 @@ +# CanyonOS Core runtime contract + +The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the +CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for +Docker resources. + +Read this reference when implementing an adapter or explaining a validator +finding. Runtime-dependent behavior is expressed as capabilities; run +`validate.py` against the target environment instead of inferring support from +release history. + +## Project root and discovery + +`ventis build` uses the current working directory as the project root. + +| Input | Discovery | +|---|---| +| `agents/*.yaml` | direct yaml glob under the project root | +| `config/global_controller.yaml` | default config, overridable with `-c` | +| workflow | `workflow_file` on a `type: workflow` entry | +| policy | `policy.yaml` beside the selected config file | +| generated files | `stubs/`, `grpc_stubs/`, `docker_container/` | + +The config name, yaml `agent.name`, and entrypoint class name form one binding: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +A missing match may skip an image while the command continues, so inspect build +output and generated image tags. + +## Agent yaml and generated stubs + +The consumed yaml shape is: + +```yaml +agent: + name: ExampleAgent + functions: + - name: work + arguments: + - name: query + type: str + returns: + type: dict +``` + +Argument annotations are generated from bare names without adding imports. Use +builtins. Generated methods have no defaults, so every declared argument is +required at the stub call site. `returns` does not control runtime conversion; +it documents whether workflow code should parse the returned string. + +Stub destinations differ by runtime capability and entrypoint layout. Workflow +code in this port convention imports the generated class from +`agents.`. The validator checks that import against declarations +before the workflow image starts. + +## Agent loading and execution + +The local controller effectively performs: + +```python +module = load(entrypoint) +agent_class = getattr(module, configured_name) +agent = agent_class() +result = getattr(agent, method_name)(**args) +``` + +Consequences: + +- The class is module-level and named exactly as configured. +- Construction takes no arguments. +- Declared methods accept yaml argument names as keyword arguments. +- Methods are synchronous; this path does not await a coroutine. +- Dicts and lists are JSON-encoded before entering Redis; other results become + strings. +- A remote Future's `.value()` returns text, not the original Python object. + +Agent import and construction exceptions are caught by the controller. A failed +agent may still advertise healthy because health is written independently of +successful agent loading. That is why image probes import both the runtime and +the entrypoint explicitly. + +## Workflow execution + +The workflow file is executed, not imported. Therefore: + +- module-level code runs at container startup; +- `__name__ == "__main__"`; +- `deploy()` blocks in the web server; +- the workflow function runs once per request; +- its function name determines the REST route exposed by the compatibility + runtime. + +The deployment platform additionally expects `/main` with a `{query: string}` +body. This platform constraint is stricter than the underlying transport. + +Each stub method returns a Future immediately. `.value()` blocks. Dispatching +and resolving inside one comprehension serializes work without raising an +error; dispatch all calls first, then resolve them. + +The workflow container also starts runtime controller code and has its own +package resolution. Probe it independently from agent images. + +## Build context and collisions + +The runtime copies project files while preserving relative paths, then writes +shared runtime modules, generated stubs, and entrypoints into the image. Later +writes can shadow project files. + +Avoid root project modules named like runtime files, including: + +```text +future.py +ventis_context.py +local_controller.py +local_controller_frontend.py +redis_client.py +grpc_options.py +bedrock.py +deploy.py +session_logging.py +workflow_launcher.py +``` + +Also avoid a yaml basename that shadows a different source module imported by an +adapter. The validator checks deterministic flat-name collisions. + +File sweep and editable-install behavior are runtime capabilities. For nested +imports, follow [packaging.md](packaging.md). + +## Dependencies and protobuf + +Agent and workflow images include a small runtime dependency set. Config +`requirements` adds source-specific distributions. A malformed requirements +value can be normalized away while image generation continues; missing imports +then surface only when the agent loads. + +The build compiles gRPC Python stubs on the host and copies them into images. +The image resolver does not necessarily know the generated-code version. A +source dependency that constrains protobuf below the host generator version can +produce a green image build that dies on: + +```text +import local_controller +``` + +Always run that probe before probing the entrypoint. Treat a generated-code / +runtime-version mismatch as a CanyonOS Core runtime issue, not a reason to alter +source dependencies silently. + +## Credentials capability + +When `env_file` capability is available, the top-level config path is resolved +against the project root and passed at container start. Hidden env files are not +copied into images. Invalid paths are deploy-preflight errors. + +When the capability is unavailable, declaring `env_file` has no effect. If the +source needs credentials, report the capability blocker rather than hardcoding +or vendoring a secret. + +A source that constructs its client at import time works only when credentials +are already in the container environment. Image entrypoint probes therefore use +the same env file as deployment. + +For proxy-specific credential separation, read [llm-proxy.md](llm-proxy.md). + +## Policy and provider behavior + +No policy file means unrestricted service access. If a policy exists, it needs a +non-empty rules list. Rules are evaluated by specificity and first match; +services excluded from the selected rule fail after request acceptance. + +Local provider handling is case-sensitive: use lowercase `local`. EC2 behavior +and remote networking are covered in [ec2.md](ec2.md). + +## Cleanup boundary + +Stopping foreground deploy normally invokes controller cleanup for recorded +containers and Redis. Hard kills and failures before resource registration may +leave resources behind. + +`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and +`docker_container/`. It does not remove containers or images. Remove exact +leftovers explicitly and preserve source, port scaffolding, and requested +evidence. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md new file mode 100644 index 0000000..361acf9 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md @@ -0,0 +1,60 @@ +# Troubleshooting + +Read this after a failed build, image probe, deploy, or request. For mechanisms, +read [runtime-contract.md](runtime-contract.md). For proxy or remote-host failures, +read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). + +## Build or deploy stops early + +| Symptom | Likely cause | +|---|---| +| Agent image is missing | Config name matched no yaml name, entrypoint is absent, or build skipped it; inspect build warnings | +| Two services produce one image | Config names collide after lowercase normalization | +| `generated grpc_stubs are missing` | Build did not complete on this host, or generated output was cleaned before deploy | +| `int(... NoneType)` during local deploy | Local provider was not written exactly as lowercase `local` | +| Replica conversion `TypeError` | `replicas` is not an integer | +| Policy `AttributeError` | Policy exists but is empty or has null/non-list rules; remove it when unrestricted | +| Port or container name already in use | A previous deployment did not complete cleanup | + +## Container exits or serves nothing + +| Symptom | Likely cause | +|---|---| +| `import local_controller` raises protobuf version error | Host-generated gRPC code is newer than the image's protobuf runtime | +| First request says `No agent loaded` | Entrypoint import, class lookup, or constructor failed and the controller swallowed the exception; inspect container logs | +| Replica is healthy but serves nothing | Health publication does not prove successful agent loading | +| Missing credentials while loading | Env injection is unavailable/misconfigured or the source reads another variable | +| Source module is missing | Original import does not resolve from `/app`; read [packaging.md](packaging.md) | +| Third-party module is missing | Distribution is absent from source metadata and config requirements | +| Stub import raises `NameError` | yaml argument type is not a bare builtin | +| Source module behaves like an empty stub | A generated stub basename shadowed the source module | +| Runtime-named project module disappears | Shared runtime copy overwrote a root project module with the same name | + +## Request is accepted, then fails + +| Symptom | Likely cause | +|---|---| +| Unexpected keyword argument | yaml argument name differs from adapter parameter name | +| Required argument missing | yaml does not declare a required adapter parameter, or workflow platform sent only `query` | +| Unauthorized service | The first matching policy rule excludes that service | +| `.value()` returns dict-like text | Expected; remote values are strings, so use `json.loads` | +| Object is not JSON serializable | Adapter returned framework objects without its JSON-safe serializer | +| Redis contains a coroutine repr | Adapter method is async and the controller did not await it | +| Fan-out is no faster | Dispatch and `.value()` were fused, serializing the calls | +| Debug block runs at startup | Workflow is executed with `__name__ == "__main__"` | + +## Deployment platform endpoint + +| Symptom | Likely cause | +|---|---| +| 404 while workflow container is healthy | Workflow function is not named `main` | +| 400 before host receives request | Body is not the platform's `{query: string}` shape | +| Extra workflow argument is missing | Platform sends only `query`; extra parameters need defaults | + +## Cleanup + +| Symptom | Likely cause | +|---|---| +| `ventis clean` succeeds but containers remain | The command removes generated directories only | +| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | +| Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/cli/.claude/skills/porting-to-canyonos-core/validate.py b/cli/.claude/skills/porting-to-canyonos-core/validate.py new file mode 100755 index 0000000..04baf37 --- /dev/null +++ b/cli/.claude/skills/porting-to-canyonos-core/validate.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +"""Preflight the runtime traps that `ventis build` cannot see. + +This deliberately does not duplicate build-time validation such as malformed +YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those +checks. This script parses Python without importing it and catches failures that +otherwise stay hidden until a container loads an agent, starts a workflow, or +serves its first request. A replica is not evidence: the controller writes +`healthy` to Redis before `_load_agent` runs. + + python validate.py [project_dir] [-c config/global_controller.yaml] + [--json] [--strict] + +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 `ventis` package directly. A capability-gated check reports +UNAVAILABLE when its behavior cannot be proven. +""" + +import argparse +import ast +import builtins +import json +import os +import re +import sys +from typing import ClassVar + +try: + import yaml +except ImportError: # pragma: no cover - pyyaml is a CanyonOS Core dependency + sys.stderr.write("validate.py needs pyyaml: pip install pyyaml\n") + raise SystemExit(2) from None + + +DEFAULT_CONFIG_PATH = "config/global_controller.yaml" + +# Copied flat into every image over the swept project tree, so a project module +# landing flat under one of these names is overwritten. +# ventis/stub_generator.py generate_docker / generate_workflow_docker. +RUNTIME_FLAT_NAMES = frozenset( + { + "future.py", + "ventis_context.py", + "local_controller.py", + "local_controller_frontend.py", + "redis_client.py", + "grpc_options.py", + "gpu_metrics.py", + "bedrock.py", + "deploy.py", + "session_logging.py", + "workflow_launcher.py", + } +) + +# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. +BASE_AGENT_REQUIREMENTS = [ + "grpcio", + "grpcio-tools", + "redis", + "pyyaml", + "psutil", + "ipdb", + "ipython", + "boto3", +] +# Import name -> distribution name, for the handful where they differ and the +# mismatch would otherwise be reported as a missing requirement. +IMPORT_TO_DISTRIBUTION = { + "attr": "attrs", + "bs4": "beautifulsoup4", + "cv2": "opencv-python", + "dateutil": "python-dateutil", + "dotenv": "python-dotenv", + "grpc": "grpcio", + "grpc_tools": "grpcio-tools", + "jwt": "pyjwt", + "PIL": "pillow", + "psycopg": "psycopg", + "psycopg2": "psycopg2-binary", + "pydantic_settings": "pydantic-settings", + "sklearn": "scikit-learn", + "typing_extensions": "typing-extensions", + "yaml": "pyyaml", +} + +SECRET_PATTERNS = [ + (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "an OpenAI-style secret key"), + (re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key id"), + (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "a GitHub token"), + (re.compile(r"AIza[0-9A-Za-z_-]{30,}"), "a Google API key"), +] +SECRET_NAME = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) + +ERROR = "ERROR" +WARN = "WARN" +INFO = "INFO" + + +# ------------------------------------------------------------------ # +# Capabilities # +# ------------------------------------------------------------------ # +# +# Stable labels for behavior detected from the importable runtime. They contain +# no external development metadata. + +CAPABILITY_SOURCE = { + "env_file": "runtime env-file injection", + "editable_install": "editable project installation", + "sweeps_all_files": "full project-file sweep", + "stub_two_destinations": "flat and package stub destinations", +} + + +def probe_capabilities(): + """Ask the importable ventis package what it actually supports.""" + caps = dict.fromkeys(CAPABILITY_SOURCE, False) + caps["ventis"] = False + try: + from ventis import stub_generator + except Exception: # noqa: BLE001 - a broken install must not crash the check + return caps + + caps["ventis"] = True + caps["editable_install"] = hasattr(stub_generator, "_install_step") + caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") + caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") + + import importlib + + for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): + try: + module = importlib.import_module(module_name) + except Exception: # noqa: BLE001,S112 - the other path is the live one + continue + if hasattr(module, "resolve_env_file"): + caps["env_file"] = True + break + return caps + + +# ------------------------------------------------------------------ # +# YAML with line numbers # +# ------------------------------------------------------------------ # + + +class LineDict(dict): + """A mapping that remembers where it and each of its keys were written.""" + + line = 0 + key_lines: ClassVar[dict] = {} + + +class LineLoader(yaml.SafeLoader): + pass + + +def _construct_mapping(loader, node): + data = LineDict() + yield data + data.update(loader.construct_mapping(node, deep=False)) + data.line = node.start_mark.line + 1 + data.key_lines = { + key.value: key.start_mark.line + 1 + for key, _ in node.value + if isinstance(key, yaml.ScalarNode) + } + + +LineLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping +) + + +def line_of(mapping, key=None): + """The source line of `key` inside `mapping`, or of the mapping itself.""" + if not isinstance(mapping, LineDict): + return 0 + if key is not None: + return mapping.key_lines.get(key, mapping.line) + return mapping.line + + +def load_yaml(path): + """Parse `path`, returning (data, error). Never raises.""" + try: + with open(path, "r", encoding="utf-8") as handle: + return yaml.load(handle, Loader=LineLoader), None + except Exception as exc: # noqa: BLE001 - any parse failure is a finding + return None, str(exc) + + +# ------------------------------------------------------------------ # +# Findings # +# ------------------------------------------------------------------ # + + +class Report: + def __init__(self, project_dir, capabilities): + self.project_dir = project_dir + self.capabilities = capabilities + self.findings = [] + # A peer agent is imported by the name of its generated stub, which the + # build copies flat into every image. Those are not project modules and + # need no requirement. + self.stub_module_names = set() + + def add(self, check, level, path, line, summary, mechanism): + self.findings.append( + { + "check": check, + "level": level, + "path": self.rel(path) if path else "", + "line": line or 0, + "summary": summary, + "mechanism": mechanism, + } + ) + + def error(self, check, path, line, summary, mechanism): + self.add(check, ERROR, path, line, summary, mechanism) + + def warn(self, check, path, line, summary, mechanism): + self.add(check, WARN, path, line, summary, mechanism) + + def unavailable(self, check, summary): + self.add(check, INFO, "", 0, summary, "") + + def rel(self, path): + try: + return os.path.relpath(path, self.project_dir) + except ValueError: + return path + + def counts(self): + errors = sum(1 for f in self.findings if f["level"] == ERROR) + warnings = sum(1 for f in self.findings if f["level"] == WARN) + return errors, warnings + + +# ------------------------------------------------------------------ # +# Python source helpers # +# ------------------------------------------------------------------ # + + +def parse_python(path): + """AST for `path`, or (None, error). The port is never imported.""" + try: + with open(path, "r", encoding="utf-8") as handle: + source = handle.read() + except OSError as exc: + return None, str(exc) + try: + return ast.parse(source, filename=path), None + except SyntaxError as exc: + return None, f"{exc.msg} (line {exc.lineno})" + + +def find_class(tree, name): + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def class_methods(class_node): + return { + node.name: node + for node in class_node.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def parameter_names(func_node): + """Every parameter a caller can pass by keyword, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + return positional + [a.arg for a in args.kwonlyargs] + + +def required_parameters(func_node): + """Parameters with no default, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + if args.defaults: + positional = positional[: len(positional) - len(args.defaults)] + kwonly = [ + arg.arg + for arg, default in zip(args.kwonlyargs, args.kw_defaults) + if default is None + ] + return positional + kwonly + + +def toplevel_import_names(tree): + """Top-level package name of every import in the module, with line numbers.""" + names = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.setdefault(alias.name.split(".")[0], node.lineno) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.setdefault(node.module.split(".")[0], node.lineno) + return names + + +# ------------------------------------------------------------------ # +# V006-V010 adapter failures hidden by _load_agent # +# ------------------------------------------------------------------ # + +BUILTIN_TYPE_NAMES = frozenset( + name + for name in dir(builtins) + if isinstance(getattr(builtins, name), type) and not name[0].isupper() +) + + +def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): + """V006 V007 V008 V009 V010.""" + name = agent_block["name"] + functions = agent_block.get("functions") or [] + check_argument_types(report, agent_yaml_path, functions) + + entrypoint = entry.get("entrypoint") + if not entrypoint: + return + entrypoint_path = os.path.join(project_dir, entrypoint) + if not os.path.isfile(entrypoint_path): + return + + tree, error = parse_python(entrypoint_path) + if tree is None: + report.error( + "V006", + entrypoint_path, + 0, + f"the entrypoint does not parse: {error}", + "_load_agent exec_module's it and swallows the exception; the first " + "request answers 'No agent loaded'.", + ) + return + + class_node = find_class(tree, name) + if class_node is None: + classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] + found = ", ".join(classes) if classes else "no classes at all" + report.error( + "V006", + entrypoint_path, + 1, + f"no class named `{name}` at module level (found: {found})", + "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " + "the AttributeError. The class name must equal agent.name exactly.", + ) + return + + methods = class_methods(class_node) + check_constructor(report, entrypoint_path, name, methods) + + for func in functions: + if not isinstance(func, dict) or not isinstance(func.get("name"), str): + continue + check_method(report, entrypoint_path, agent_yaml_path, name, func, methods) + + +def check_argument_types(report, agent_yaml_path, functions): + """V010 -- the type string is pasted into an ast.Name, never checked.""" + for func in functions: + if not isinstance(func, dict): + continue + for arg in func.get("arguments") or []: + if not isinstance(arg, dict) or "type" not in arg: + continue + declared = arg.get("type") + if not isinstance(declared, str): + continue # stub generation reports malformed type values + if declared in BUILTIN_TYPE_NAMES: + continue + report.error( + "V010", + agent_yaml_path, + line_of(arg, "type"), + f"`type: {declared}` is not a builtin", + "stub_generator pastes it verbatim into the generated " + "annotation, and the stub module imports only Future and " + "inspect. Anything else raises NameError when the stub is " + "imported -- after a green build. Use str int float bool dict " + "list.", + ) + + +def check_constructor(report, entrypoint_path, name, methods): + """V007 -- _load_agent calls agent_class() with no arguments.""" + init = methods.get("__init__") + if init is None: + return + required = required_parameters(init) + if required: + report.error( + "V007", + entrypoint_path, + init.lineno, + f"`{name}.__init__` requires {', '.join(required)}", + "_load_agent calls agent_class() with no arguments; the TypeError is " + "swallowed and the first request answers 'No agent loaded'. Read " + "configuration from the environment inside __init__ instead.", + ) + + +def check_method(report, entrypoint_path, agent_yaml_path, class_name, func, methods): + """V008 V009.""" + func_name = func["name"] + method = methods.get(func_name) + if method is None: + report.error( + "V008", + entrypoint_path, + 0, + f"`{class_name}` has no method `{func_name}`", + "The yaml declares it, so callers get a stub for it; the controller " + f"then answers \"Agent {class_name} has no method '{func_name}'\".", + ) + return + + # V009 -- nothing on the execution path awaits. + if isinstance(method, ast.AsyncFunctionDef): + report.error( + "V009", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` is `async def`", + "The executor calls method(**args) with no await, so Redis receives " + "''. Keep the signature synchronous and call " + "asyncio.run(...) inside the body.", + ) + + # V008 -- the controller calls method(**args) with the yaml's names. + declared = [ + arg["name"] + for arg in func.get("arguments") or [] + if isinstance(arg, dict) and isinstance(arg.get("name"), str) + ] + actual = parameter_names(method) + required = required_parameters(method) + + missing = [arg for arg in declared if arg not in actual] + if missing: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` has no parameter " + f"{', '.join(repr(m) for m in missing)}, but the yaml declares it", + "LocalController does method(**args) with the yaml's argument names. " + "A mismatch is TypeError: unexpected keyword argument, at request " + f"time. See {os.path.basename(agent_yaml_path)}.", + ) + + unfilled = [arg for arg in required if arg not in declared] + if unfilled: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` requires {', '.join(unfilled)}, which " + "the yaml does not declare", + "Only declared arguments are ever sent, and the generated stub gives " + "none of them a default. Declare them in the yaml or default them " + "in the signature.", + ) + + + +# ------------------------------------------------------------------ # +# V016-V018 the workflow # +# ------------------------------------------------------------------ # + + +def check_stub_imports(report, workflow_path, tree, stub_classes): + """V023 -- the workflow must import a stub as `from agents. import `. + + The build copies each stub to exactly one path, and for the workflow image + that path is agents/.py. Two ways of writing this line fail, and + the project walks you into both: the flat form is what examples/ uses, and + the class name is the one `ventis build` prints, which is not the one it + writes. + """ + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + base = alias.name.split(".")[0] + if base in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`import {alias.name}` -- the stub is at " + f"agents/{base}.py, not flat", + "The build copies a stub to one path, and for the " + "workflow that path is under agents/. This is a " + "ModuleNotFoundError the moment the workflow runs. " + f"Write `from agents.{base} import {stub_classes[base]}`.", + ) + continue + + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + + module = node.module + if module in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`from {module} import ...` -- the stub is at " + f"agents/{module}.py, not flat", + "The build copies a stub to one path, and for the workflow " + "that path is under agents/. The flat form is what this " + "repository's own examples use and it raises " + "ModuleNotFoundError in the workflow image. Write " + f"`from agents.{module} import {stub_classes[module]}`.", + ) + continue + + if not module.startswith("agents."): + continue + base = module.split(".", 1)[1] + expected = stub_classes.get(base) + if expected is None: + continue + for alias in node.names: + if alias.name == expected: + continue + if alias.name == f"{expected}Stub": + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is the name the build prints, not the " + f"class it writes", + "generate_agent_stub sets class_name = agent_config['name'] " + "and then recomputes it with a 'Stub' suffix for the log " + "line only. The message names a class that does not exist; " + f"the class is `{expected}`.", + ) + else: + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is not a class the stub for {base} defines", + f"The stub's class carries the agent's own name: `{expected}`.", + ) + + +def check_workflow(report, workflow_path, stub_classes=None): + """V016 V017 V018 V023.""" + tree, error = parse_python(workflow_path) + if tree is None: + report.error("V016", workflow_path, 0, f"does not parse: {error}", "") + return + + main = None + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ( + node.name == "main" + ): + main = node + break + + if main is None: + defined = [ + n.name + for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + found = ", ".join(defined) if defined else "no top-level functions" + report.error( + "V016", + workflow_path, + 1, + f"no top-level function named `main` (found: {found})", + "CanyonOS Core serves POST /, but the deployment platform's " + "test endpoint posts to a hardcoded /main. A differently named " + "workflow builds, deploys and stays unreachable -- 404, container " + "healthy.", + ) + else: + check_main_signature(report, workflow_path, main) + + if stub_classes: + check_stub_imports(report, workflow_path, tree, stub_classes) + + # V016 -- deploy() is what starts Flask. + if not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "deploy" + for node in ast.walk(tree) + ): + report.error( + "V016", + workflow_path, + 1, + "the workflow never calls `deploy(...)`", + "workflow_launcher.py exec's this file and nothing else starts the " + "HTTP server; the container comes up serving nothing.", + ) + + check_main_guard(report, workflow_path, tree) + check_fused_fanout(report, workflow_path, tree) + + +def check_main_signature(report, workflow_path, main): + """V016 -- the platform sends exactly {"query": ...}.""" + if isinstance(main, ast.AsyncFunctionDef): + report.error( + "V016", + workflow_path, + main.lineno, + "`main` is `async def`", + "deploy() calls workflow_fn(**kwargs) on a Flask worker thread with " + "no await; the response body would be a coroutine repr.", + ) + params = parameter_names(main) + if not params: + report.error( + "V016", + workflow_path, + main.lineno, + "`main` takes no arguments", + 'The platform posts {"query": "..."} and deploy() splats the ' + "body in as kwargs -- TypeError on every request.", + ) + return + if params[0] != "query": + report.error( + "V016", + workflow_path, + main.lineno, + f"`main`'s first parameter is `{params[0]}`, not `query`", + "The platform's body schema is strictly validated as " + "{query: string}; any other key is rejected with 400 in the control " + "plane, before the request reaches the host.", + ) + extra = [p for p in required_parameters(main) if p != "query"] + if extra: + report.error( + "V016", + workflow_path, + main.lineno, + f"`main` requires {', '.join(extra)} beyond `query`", + "Only `query` is ever sent, so every other parameter needs a " + "default or the call raises on every request. Pack richer input " + "into `query`.", + ) + + +def check_main_guard(report, workflow_path, tree): + """V017 -- the workflow is exec'd, so __name__ == "__main__".""" + for node in tree.body: + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "__name__" + and any( + isinstance(c, ast.Constant) and c.value == "__main__" + for c in test.comparators + ) + ): + report.error( + "V017", + workflow_path, + node.lineno, + '`if __name__ == "__main__":` block in the workflow', + "workflow_launcher.py runs exec(open().read()), so " + "__name__ IS '__main__' here and this block executes in " + "production, at container start.", + ) + + +def check_fused_fanout(report, workflow_path, tree): + """V018 -- .value() blocks, so dispatching and resolving in one + comprehension runs the fan-out one call at a time.""" + comprehensions = (ast.ListComp, ast.SetComp, ast.GeneratorExp) + for node in ast.walk(tree): + if not isinstance(node, comprehensions + (ast.DictComp,)): + continue + elements = ( + [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] + ) + for element in elements: + for inner in ast.walk(element): + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "value" + and isinstance(inner.func.value, ast.Call) + ): + report.error( + "V018", + workflow_path, + node.lineno, + "one comprehension both dispatches a call and resolves " + "it with .value()", + ".value() blocks, so each call completes before the " + "next is dispatched. It does not error -- the fan-out " + "is just silently serial, and with it the reason to be " + "on CanyonOS Core. Dispatch every call first, then resolve: " + "futures = [a.work(i) for i in items] then " + "[f.value() for f in futures].", + ) + return + + +# ------------------------------------------------------------------ # +# V019-V020 what the copy order overwrites # +# ------------------------------------------------------------------ # + + +def check_flat_collisions(report, project_dir, yaml_paths, entrypoints): + """V019 V020 -- later copies land on earlier ones at the context root.""" + for entry in sorted(os.listdir(project_dir)): + path = os.path.join(project_dir, entry) + if not os.path.isfile(path) or not entry.endswith(".py"): + continue + + # V019 -- the shared runtime is copied flat, after the project sweep. + if entry in RUNTIME_FLAT_NAMES: + report.error( + "V019", + path, + 1, + f"a project module named `{entry}` sits at the project root", + "The shared CanyonOS Core runtime is copied flat into the image after " + "the project sweep, so this file is overwritten by CanyonOS Core's own " + f"{entry}. Rename it or move it into a package directory.", + ) + + # V020 -- a stub is copied flat under its yaml's basename. + entrypoint_basenames = {os.path.basename(e) for e in entrypoints if e} + for yaml_path in yaml_paths: + stem = os.path.splitext(os.path.basename(yaml_path))[0] + module = f"{stem}.py" + if module in entrypoint_basenames: + continue # the entrypoint is copied last and wins its flat name back + candidate = os.path.join(project_dir, module) + if os.path.isfile(candidate): + report.error( + "V020", + candidate, + 1, + f"`{os.path.basename(yaml_path)}` generates a stub that lands on " + f"`{module}`", + "The yaml's basename names the stub, and the stub is copied flat " + "over the swept tree. Anything importing this module inside the " + "container gets the generated stub instead of the real code. " + "Rename the yaml to match its own entrypoint.", + ) + + +# ------------------------------------------------------------------ # +# V030-V031 capability-gated rules # +# ------------------------------------------------------------------ # + + +def check_env_file(report, config, config_path, project_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 ventis 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 `ventis` runtime. " + "Credentials have no declared path into a container on this tree.", + ) + return + + if not declared: + report.warn( + "V030", + config_path, + line_of(config), + "no `env_file:` in the config", + "Only runtime-managed VENTIS_* variables are guaranteed without it. " + "If the source reads credentials from the environment, the first " + "request fails on a provider error.", + ) + return + + # Path existence and readability are deploy-preflight checks. Do not + # duplicate them here. + + +def check_import_root(report, project_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(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + + non_flat = [] + for path in entrypoint_paths: + tree, _ = parse_python(path) + if tree is None: + continue + for name, lineno in toplevel_import_names(tree).items(): + if name in report.stub_module_names or f"{name}.py" in RUNTIME_FLAT_NAMES: + continue + if _resolves_flat(project_dir, name): + continue + location = _resolves_nested(project_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 " + "`ventis` runtime. Only names rooted at /app import inside a container.", + ) + for path, lineno, name, location in non_flat: + report.error( + "V031", + path, + lineno, + f"`import {name}` resolves to {location}, which is not at the " + "project root", + "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 project root " + "has no packaging metadata", + "A pyproject.toml, setup.py or setup.cfg at the port root is " + "what adds `-e .`; metadata nested inside the untouched source " + "tree is ignored. Add minimal root metadata pointing at the " + "existing package directory. Without it the install is skipped " + "silently.", + ) + + +def _resolves_flat(project_dir, name): + """Whether Python can resolve `name` with /app as its import root. + + A directory does not need __init__.py: PEP 420 namespace packages resolve + from sys.path just like regular packages. + """ + return os.path.isfile(os.path.join(project_dir, f"{name}.py")) or os.path.isdir( + os.path.join(project_dir, name) + ) + + +def _resolves_nested(project_dir, name): + """Where below /app `name` lives but cannot resolve as a top-level name.""" + for root, dirs, files in os.walk(project_dir): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if root == project_dir: + continue + if f"{name}.py" in files: + return os.path.relpath(os.path.join(root, f"{name}.py"), project_dir) + if name in dirs: + return os.path.relpath(os.path.join(root, name), project_dir) + return None + + +# ------------------------------------------------------------------ # +# W003, W006 secrets and imports a green build does not reject # +# ------------------------------------------------------------------ # + + +def check_secrets(report, port_paths): + """W003 -- env_file is the way in; nothing else is.""" + for path in port_paths: + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError: + continue + for number, line in enumerate(lines, start=1): + for pattern, description in SECRET_PATTERNS: + if pattern.search(line): + report.warn( + "W003", + path, + number, + f"this line looks like {description}", + "Never put a secret in the source tree or the build " + "context. The build sweeps the project into every " + "image.", + ) + break + + tree, _ = parse_python(path) + if tree is None: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not ( + isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.value.strip() + ): + continue + for target in node.targets: + if isinstance(target, ast.Name) and SECRET_NAME.search(target.id): + report.warn( + "W003", + path, + node.lineno, + f"`{target.id}` is assigned a literal string", + "Read it from the environment instead; the build sweeps " + "this file into every image.", + ) + + +def _pyproject_dependencies(project_dir): + """What `-e .` installs alongside `requirements:`, or None if unreadable. + + None and the empty set mean different things here: empty means the project + declares no dependencies, None means we could not find out -- a setup.py, or + a tomllib this interpreter does not have. The caller must not treat the + second as the first, or it warns about imports the install would satisfy. + """ + path = os.path.join(project_dir, "pyproject.toml") + if not os.path.isfile(path): + return None + try: + import tomllib + except ImportError: # < 3.11 + return None + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except Exception: # noqa: BLE001 - malformed metadata is uv's error to give + return None + deps = (data.get("project") or {}).get("dependencies") + if not isinstance(deps, list): + return None + return {_normalize_distribution(d) for d in deps if isinstance(d, str)} + + +def check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path +): + """W006 -- an import the container cannot satisfy.""" + tree, _ = parse_python(entrypoint_path) + if tree is None: + return + + declared = { + _normalize_distribution(item) + for item in (entry.get("requirements") or []) + if isinstance(item, str) + } + + # Where the editable install exists, `-e .` resolves the project's own + # [project.dependencies] in the same pass as `requirements:`. Warning about + # those is a false positive, and a false warning about a dependency is worse + # than none: it teaches the reader to dismiss this check. + editable = report.capabilities.get("editable_install") + metadata = any( + os.path.isfile(os.path.join(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + unreadable_metadata = False + if editable and metadata: + project_deps = _pyproject_dependencies(project_dir) + if project_deps is None: + unreadable_metadata = True + else: + declared |= project_deps + base = {_normalize_distribution(item) for item in BASE_AGENT_REQUIREMENTS} + stdlib = getattr(sys, "stdlib_module_names", frozenset()) + + for name, lineno in sorted(toplevel_import_names(tree).items()): + if name in stdlib or name == "ventis": + continue + # Provided by the image itself: the shared runtime is copied flat, and + # every agents/*.yaml generates a stub that is copied flat too. + if f"{name}.py" in RUNTIME_FLAT_NAMES or name in report.stub_module_names: + continue + if _resolves_flat(project_dir, name) or _resolves_nested(project_dir, name): + continue + distribution = _normalize_distribution(IMPORT_TO_DISTRIBUTION.get(name, name)) + if distribution in base or distribution in declared: + continue + if unreadable_metadata: + mechanism = ( + "The container installs the base list, `requirements:`, and -- " + "since this project declares packaging metadata -- whatever " + "`-e .` resolves from it. That metadata could not be read here, " + f"so if it already requires `{name}` this line is noise; " + "otherwise it is a ModuleNotFoundError inside _load_agent and " + "'No agent loaded' on the first request." + ) + else: + mechanism = ( + "The container installs the base list plus `requirements:` and " + "nothing else, so this is a ModuleNotFoundError inside " + "_load_agent and 'No agent loaded' on the first request. If the " + f"distribution is named something other than `{name}`, declare " + f"that name in {report.rel(config_path)}." + ) + report.warn( + "W006", + entrypoint_path, + lineno, + f"`import {name}` is in neither the runtime's base list nor this " + "entry's `requirements:`", + mechanism, + ) + + +def _normalize_distribution(name): + return re.split(r"[<>=!\[;\s]", name.strip().lower(), maxsplit=1)[0].replace( + "_", "-" + ) + + +# ------------------------------------------------------------------ # +# Driver # +# ------------------------------------------------------------------ # + + +def validate(project_dir, config_path, capabilities): + """Inspect only failures hidden behind a successful image build.""" + report = Report(project_dir, capabilities) + + # The build owns config/YAML syntax and shape validation. We read only enough + # valid structure to locate code for the deeper checks below. + config, error = load_yaml(config_path) + if error is not None or not isinstance(config, dict): + report.unavailable( + "BUILD", + "runtime preflight skipped because the config cannot be read; " + "ventis build owns and reports this error.", + ) + return report + + import glob + + yaml_paths = sorted(glob.glob(os.path.join(project_dir, "agents", "*.yaml"))) + report.stub_module_names = { + os.path.splitext(os.path.basename(path))[0] for path in yaml_paths + } + + agents_by_name = {} + stub_classes = {} + for path in yaml_paths: + data, yaml_error = load_yaml(path) + agent = data.get("agent") if isinstance(data, dict) else None + name = agent.get("name") if isinstance(agent, dict) else None + if yaml_error is not None or not isinstance(name, str): + continue # ventis build reports malformed agent declarations + agents_by_name[name] = (path, agent) + stub_classes[os.path.splitext(os.path.basename(path))[0]] = name + + entries = config.get("agents") + if not isinstance(entries, list): + report.unavailable( + "BUILD", + "runtime preflight skipped because `agents:` is not a list; " + "ventis build owns and reports this error.", + ) + return report + + entrypoints = [] + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": + continue + name = entry.get("name") + entrypoint = entry.get("entrypoint") + if isinstance(entrypoint, str): + entrypoints.append(entrypoint) + if name in agents_by_name: + yaml_path, agent_block = agents_by_name[name] + check_adapter(report, yaml_path, agent_block, entry, project_dir) + entrypoint_path = os.path.join(project_dir, entrypoint or "") + if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): + check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path + ) + + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": + continue + workflow_file = entry.get("workflow_file") + if not isinstance(workflow_file, str): + continue + workflow_path = os.path.join(project_dir, workflow_file) + if os.path.isfile(workflow_path): + check_workflow(report, workflow_path, stub_classes) + + # These survive a green build and otherwise surface only in a container or + # on its first request. + check_flat_collisions(report, project_dir, yaml_paths, entrypoints) + check_env_file(report, config, config_path, project_dir) + + entrypoint_paths = [ + os.path.join(project_dir, e) + for e in entrypoints + if os.path.isfile(os.path.join(project_dir, e)) + ] + check_import_root(report, project_dir, entrypoint_paths) + + port_paths = list(entrypoint_paths) + for entry in entries: + if isinstance(entry, dict) and isinstance(entry.get("workflow_file"), str): + candidate = os.path.join(project_dir, entry["workflow_file"]) + if os.path.isfile(candidate): + port_paths.append(candidate) + + # Secret detection remains because a green image build would permanently + # bake the credential into every image. + check_secrets(report, port_paths) + return report + + + +# ------------------------------------------------------------------ # +# Output # +# ------------------------------------------------------------------ # + +LEVEL_ORDER = {ERROR: 0, WARN: 1, INFO: 2} + + +def _wrap(text, width, indent): + words = text.split() + lines = [] + current = "" + for word in words: + candidate = f"{current} {word}".strip() + if len(candidate) + len(indent) > width and current: + lines.append(indent + current) + current = word + else: + current = candidate + if current: + lines.append(indent + current) + return lines + + +def print_report(report, project_dir): + caps = report.capabilities + if not caps.get("ventis"): + print("ventis is not importable here -- capability-gated rules are") + print("reported UNAVAILABLE rather than checked.\n") + else: + print("CanyonOS Core capabilities detected:") + for key, source in CAPABILITY_SOURCE.items(): + mark = "yes" if caps.get(key) else "no " + print(f" {mark} {key:<22} {source}") + print() + + findings = sorted( + report.findings, + key=lambda f: (LEVEL_ORDER[f["level"]], f["check"], f["path"], f["line"]), + ) + for finding in findings: + where = finding["path"] + if where and finding["line"]: + where = f"{where}:{finding['line']}" + header = f"{finding['check']} {finding['level']:<5}" + print(f"{header} {where}" if where else header) + for line in _wrap(finding["summary"], 78, " "): + print(line) + if finding["mechanism"]: + for line in _wrap(finding["mechanism"], 78, " "): + print(line) + print() + + errors, warnings = report.counts() + if not findings: + print(f"{project_dir}: clean.") + return + print(f"{errors} error(s), {warnings} warning(s).") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check a CanyonOS Core port against the rules in SKILL.md." + ) + parser.add_argument( + "project_dir", nargs="?", default=".", help="the port's project root" + ) + parser.add_argument( + "-c", + "--config", + default=DEFAULT_CONFIG_PATH, + help=f"config path relative to project_dir (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument("--json", action="store_true", help="emit findings as JSON") + parser.add_argument( + "--strict", action="store_true", help="fail on warnings as well as errors" + ) + args = parser.parse_args(argv) + + project_dir = os.path.abspath(args.project_dir) + config_path = ( + args.config + if os.path.isabs(args.config) + else os.path.join(project_dir, args.config) + ) + + capabilities = probe_capabilities() + report = validate(project_dir, config_path, capabilities) + errors, warnings = report.counts() + + if args.json: + print( + json.dumps( + { + "project_dir": project_dir, + "capabilities": capabilities, + "errors": errors, + "warnings": warnings, + "findings": report.findings, + }, + indent=2, + ) + ) + else: + print_report(report, report.rel(project_dir) or project_dir) + + if errors or (args.strict and warnings): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..de66cd0 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,21 @@ +Lightweight CLI for CanyonOS + +Serves as a thin API layer, connecting to the global controller container. + +## Serve + +`canyonos serve -c config/global_controller.yaml` starts the local CanyonOS dashboard. It reads +`database.url` from the config and writes only `CANYONOS_`-prefixed settings to the current `.env`, +leaving other lines unchanged. + + +### 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/clean.py b/cli/canyonos/clean.py new file mode 100644 index 0000000..aabf105 --- /dev/null +++ b/cli/canyonos/clean.py @@ -0,0 +1,28 @@ +""" +Remove generated stubs, gRPC files, and Docker build contexts. + +Ported directly over from canyonos, moving the logic into here. +""" + +import os +import shutil + + +def run_clean(): + project_dir = os.getcwd() + + paths_to_clean = [ + os.path.join(project_dir, "stubs"), + os.path.join(project_dir, "grpc_stubs"), + os.path.join(project_dir, "docker_container"), + ] + + for path in paths_to_clean: + if os.path.exists(path): + print(f"Cleaning {path}...") + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + + print("Clean complete.") diff --git a/cli/canyonos/config.py b/cli/canyonos/config.py new file mode 100644 index 0000000..226312e --- /dev/null +++ b/cli/canyonos/config.py @@ -0,0 +1,345 @@ +""" +Logic for `canyonos config`: view or change project/deploy configuration. +""" + +import os + +import yaml +from rich.console import Console +from rich.table import Table +from ruamel.yaml import YAML + +from canyonos.constants import default_config_path +from canyonos.theme import GREEN, WHITE +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 run_view_config(config_path=None): + config_path = config_path or default_config_path() + console = Console() + + if not os.path.isfile(config_path): + console.print(f"[red]Config file not found: {config_path}[/red]") + return + + with open(config_path) as f: + config = yaml.safe_load(f) or {} + + console.print(_agents_table(config.get("agents") or [])) + console.print() + + if config.get("otel"): + console.print(_otel_table(config["otel"])) + console.print() + + # 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): + console.print(_kv_table(key, value)) + console.print() + else: + general[key] = value + + if general: + 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 = config_path or default_config_path() + console = Console() + + if not os.path.isfile(config_path): + console.print(f"[red]Config file not found: {config_path}[/red]") + return + + # Round-trip loader preserves comments, key order, quoting and ${ENV} refs. + yaml_rt = YAML() + yaml_rt.preserve_quotes = True + # Match the project's YAML style so edits don't reflow list indentation: + # block sequences indented under their key (` - item`). + yaml_rt.indent(mapping=2, sequence=4, offset=2) + with open(config_path) as f: + data = yaml_rt.load(f) + + if not data: + console.print("[yellow]Config is empty; nothing to change.[/yellow]") + return + + screen = _Screen(console) + saves = 0 + # Alternate screen: the whole session replaces the view, and the terminal + # scrollback is restored untouched on exit. + 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: + console.set_alt_screen(False) + + if saves: + console.print(f"[{GREEN}]Saved {saves} change(s) to {config_path}[/]") + else: + console.print("No changes made.") + + +def run_config(): + console = Console() + choice = select_menu(OPTIONS, title="What do you want to do?") + if choice is None: + console.print("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..d34316e --- /dev/null +++ b/cli/canyonos/constants.py @@ -0,0 +1,9 @@ +"""Shared constants for the canyonos CLI.""" + +import os + + +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") diff --git a/cli/canyonos/dashboard.compose.yml b/cli/canyonos/dashboard.compose.yml new file mode 100644 index 0000000..db775aa --- /dev/null +++ b/cli/canyonos/dashboard.compose.yml @@ -0,0 +1,42 @@ +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 so a GC container can POST OTLP spans to /v1/traces via + # host.docker.internal; that route also renames ventis' `project_id` + # attribute to the `canyon.project.id` every dashboard query filters on. + ports: + - "127.0.0.1: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..6abcfb1 --- /dev/null +++ b/cli/canyonos/dashboard_stack.py @@ -0,0 +1,518 @@ +"""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 tempfile +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 +from urllib.parse import urlsplit, urlunsplit + +import yaml + +from canyonos.constants import default_config_path + +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" +ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +@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, *, had_containers: bool | None = None): + super().__init__(message) + self.phase = phase + self.message = message + self.had_containers = had_containers + + +@dataclass(frozen=True) +class DashboardStack: + database_url: str | None + 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]: + # Absolute, not "./.env": `canyonos serve` may cd into .car/ before + # running, so a cwd-relative path would miss the project root .env that + # `prepare()` actually writes to (stack.env_path). + return [ + "docker", + "compose", + "-p", + COMPOSE_PROJECT, + "--env-file", + str(stack.env_path), + "-f", + str(manifest), + ] + + +def _managed_database_url(database_url: str) -> tuple[str, str | None]: + parsed = urlsplit(database_url) + if parsed.hostname not in {"localhost", "127.0.0.1"}: + return database_url, None + + hostname = parsed.hostname + credentials = "" + if parsed.username is not None: + credentials = parsed.username + if parsed.password is not None: + credentials = f"{credentials}:{parsed.password}" + credentials = f"{credentials}@" + port = f":{parsed.port}" if parsed.port is not None else "" + rewritten = urlunsplit( + (parsed.scheme, f"{credentials}{HOST_GATEWAY}{port}", parsed.path, parsed.query, parsed.fragment) + ) + return rewritten, hostname + + +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` -- same retry-on-conflict shape as + init.py's GC port selection, so an unrelated process/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 _load_project_config(config_path: str) -> tuple[object, Path]: + project_root = Path(os.path.abspath(os.path.join(os.path.dirname(config_path), ".."))) + # `canyonos serve` cds into .car/ before calling here, so the naive + # parent-of-parent lands on .car itself -- go up one more level to reach + # the actual project root, where .env lives. + if project_root.name == ".car": + project_root = project_root.parent + dotenv_path = project_root / ".env" + if dotenv_path.is_file(): + with dotenv_path.open(encoding="utf-8") as dotenv: + for line in dotenv: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = _env_value(value) + if key and key not in os.environ: + os.environ[key] = value + + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) + return _expand_env_value(config), project_root + + +def _expand_env_value(value: object) -> object: + if isinstance(value, str): + return ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), match.group(0)), value) + if isinstance(value, dict): + return {key: _expand_env_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_expand_env_value(item) for item in value] + return value + + +def validate(config_path: str) -> 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") + + try: + # Keep interpolation consistent with GlobalController._load_config in ventis/controller/global_controller.py. + config, project_root = _load_project_config(config_path) + except (OSError, yaml.YAMLError): + raise PhaseFailure("validate", f"config file is not readable: {config_path}") + + # database.url is optional -- the dashboard works without a database configured + # (e.g. OTLP-only setups); if present, it still needs to actually be usable. + database = config.get("database") if isinstance(config, dict) else None + database_url = database.get("url") if isinstance(database, dict) else None + if database_url is not None: + if not isinstance(database_url, str) or not database_url.strip(): + raise PhaseFailure("validate", "database.url must be a non-empty string") + unresolved = ENV_REFERENCE.search(database_url) + if unresolved: + name = unresolved.group(1) + raise PhaseFailure( + "validate", f"database.url needs ${{{name}}}, which is not set in the project .env" + ) + database_url = database_url.strip() + + 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(database_url, 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: + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + 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: + try: + lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True) + except FileNotFoundError: + lines = [] + + managed_keys = set(managed_env) + replaced: set[str] = set() + updated_lines: list[str] = [] + for line in lines: + key, separator, _ = line.partition("=") + if separator and key in managed_keys: + if key not in replaced: + updated_lines.append(_env_line(key, managed_env[key])) + replaced.add(key) + continue + updated_lines.append(line) + + for key, value in managed_env.items(): + if key not in replaced: + updated_lines.append(_env_line(key, value)) + + descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=env_path.parent) + temporary_path = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + os.fchmod(output.fileno(), 0o600) + output.writelines(updated_lines) + os.replace(temporary_path, env_path) + except Exception: + try: + temporary_path.unlink() + except FileNotFoundError: + pass + raise + + +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), + } + rewritten_host = None + if stack.database_url is not None: + managed_database_url, rewritten_host = _managed_database_url(stack.database_url) + managed_env["CANYONOS_DATABASE_URL"] = managed_database_url + _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") + + message = "dashboard state prepared" + if rewritten_host: + message = ( + f"database host {rewritten_host} is reachable from the stack as host.docker.internal" + ) + return managed_env, message + + +def _redact_stack_text(text: str, stack: DashboardStack, managed_env: dict[str, str]) -> str: + secret = managed_env["CANYONOS_JWT_SECRET"] + redacted = redact_logs(text, secret) + if stack.database_url is not None: + redacted = redact_logs(redacted.replace(stack.database_url, "[redacted]"), secret) + return redacted + + +def _last_stderr_line(result: subprocess.CompletedProcess[str]) -> str | None: + return next((line.strip() for line in reversed(result.stderr.splitlines()) if line.strip()), None) + + +def _command_failure_message( + message: str, + result: subprocess.CompletedProcess[str], + stack: DashboardStack, + managed_env: dict[str, str], +) -> str: + detail = _last_stderr_line(result) + if detail is None: + return message + return f"{message}: {_redact_stack_text(detail, stack, managed_env)}" + + +def pull( + stack: DashboardStack, + manifest: Path, + managed_env: dict[str, str], + had_containers: bool, +) -> str: + try: + result = _run([*_compose_argv(stack, manifest), "pull"]) + except OSError: + raise PhaseFailure("pull", "could not run docker compose pull", had_containers=had_containers) + if result.returncode != 0: + raise PhaseFailure( + "pull", + _command_failure_message("docker compose pull failed", result, stack, managed_env), + had_containers=had_containers, + ) + 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]) -> bool: + had_containers = _project_has_running_containers(stack, manifest) + # 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", had_containers=had_containers) + if result.returncode != 0: + raise PhaseFailure( + "start", + _command_failure_message("docker compose up failed", result, stack, managed_env), + had_containers=had_containers, + ) + return had_containers + + +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_stack_text(logs, stack, managed_env), + ) + return log_path + + +def _cleanup(stack: DashboardStack, manifest: Path) -> None: + try: + _run([*_compose_argv(stack, manifest), "down"]) + except OSError: + return + + +def run_dashboard( + config_path: str | None = None, + phase_reporter: Callable[[str, str], None] | None = None, +) -> ServeResult: + config_path = config_path or default_config_path() + 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 + had_containers = False + with ExitStack() as resources: + try: + stack = validate(config_path) + 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_before_pull = _project_has_running_containers(stack, manifest) + pull_message = pull(stack, manifest, managed_env, had_containers_before_pull) + report(ServeResult(True, "pull", pull_message)) + + had_containers = 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 (failure.had_containers if failure.had_containers is not None else 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..a2239a2 --- /dev/null +++ b/cli/canyonos/deploy.py @@ -0,0 +1,86 @@ +""" +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 streams the logs. + +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 json +import subprocess +import urllib.error +import urllib.request + +from canyonos.constants import default_config_path +from canyonos.init import load_state, run_init +from canyonos.serve import run_serve +from canyonos.sync import run_sync + +# 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" + + +def run_deploy(config_path=None, serve=True): + config_path = config_path or default_config_path() + run_init() + + # Copy the current project into the container before building/deploying. + if not run_sync(): + return + + state = load_state() + + url = f"http://127.0.0.1:{state['port']}/deploy" + body = json.dumps({"config_path": config_path}).encode() + req = urllib.request.Request( + url, data=body, headers={"Content-Type": "application/json"}, method="POST" + ) + + try: + with urllib.request.urlopen(req) as resp: + json.loads(resp.read()) + _stream_logs_and_autoserve(state["container_id"], serve=serve) + except urllib.error.HTTPError as e: + data = json.loads(e.read()) + print(f"Deploy failed: {data.get('error')}") + except urllib.error.URLError as e: + print(f"Could not reach Global Controller container: {e}") + + +def _stream_logs_and_autoserve(container_id, serve=True): + """Tail the GC container's logs (same as before), and -- unless disabled + via `serve=False` -- launch `canyonos serve` the moment they show the + workflow is up, so the dashboard is ready alongside it. Log tailing + continues afterwards exactly as before. + """ + process = subprocess.Popen( + ["docker", "logs", "-f", container_id], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + served = not serve + try: + for line in process.stdout: + print(line, end="") + if not served and _WORKFLOW_UP_MARKER in line: + served = True + print("\nWorkflow is up -- starting the local dashboard (canyonos serve)...") + try: + run_serve() + except Exception as e: + print(f"Could not start the dashboard automatically: {e}") + print("Run `canyonos serve` manually to view it.") + except KeyboardInterrupt: + print("\nStopped monitoring log stream. Run `canyonos stop` to stop the deploy.") + print("To resubscribe to log stream run `canyonos logs`.") + finally: + if process.poll() is None: + process.terminate() diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py new file mode 100644 index 0000000..3d9cee9 --- /dev/null +++ b/cli/canyonos/init.py @@ -0,0 +1,129 @@ +""" +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 subprocess +import urllib.error +import urllib.request + +# Formatting +from pyfiglet import figlet_format +from rich.console import Console + +from canyonos.theme import GRADIENT + + + +# 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. Unlike a bind +# mount, this lives in the container's docker volume (not the host filesystem): +# it persists across `canyonos quit` (docker rm leaves named volumes intact) and +# is unaffected by host-side changes. Files are copied in via `canyonos sync` +# (docker cp), not mounted live. +GC_WORKSPACE_VOLUME = "canyonos-workspace" +GC_WORKSPACE_PATH = "/workspace" + +STATE_DIR = os.path.expanduser("~/.canyonos") +STATE_PATH = os.path.join(STATE_DIR, "state.json") + + +def pull_image(image=GC_IMAGE): + # Capture output so the rich status spinner isn't clobbered by docker's own + # layer-progress printing. + subprocess.run(["docker", "pull", image], check=True, capture_output=True) + + +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. + """ + import time + + 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 run_init(): + console = Console() + banner = figlet_format("CANYON OS", font="ansi_shadow", width=200) + + for line, color in zip(banner.splitlines(), GRADIENT): + console.print(line, style=color) + + + with console.status("Pulling Global Controller image..."): + pull_image() + with console.status("Starting Global Controller container..."): + container_id, port = run_container() + save_state(container_id, port) + print(f"Global Controller running in container {container_id[:12]} on port {port}") diff --git a/cli/canyonos/integrate.py b/cli/canyonos/integrate.py new file mode 100644 index 0000000..23e61b1 --- /dev/null +++ b/cli/canyonos/integrate.py @@ -0,0 +1,82 @@ +""" +Logic for `canyonos integrate`: 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 + +from rich.console import Console + +from utils.tui import select_menu + +# Points at the skill's folder, so SKILL.md and references/ both come along. +SKILL_SOURCE_URL = "https://github.com/CanyonCodeCoreAI/canyoncodecore/tree/nickhuo/porting-skill-car-layout/.claude/skills/porting-to-canyonos" + +# 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: {}""" + +INTEGRATE_PROMPT = ( + "Use the CanyonOS porting-to-canyonos-core 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 +) + +AGENTS = { + "claude": { + "label": "Claude Code", + "cli": "claude", + # Claude Code auto-loads project-local skills from here. + "skill_dir": ".claude/skills/porting-to-canyonos-core", + }, + "codex": { + "label": "Codex", + "cli": "codex", + # Codex only auto-loads skills from the user's home directory, not per-project. + "skill_dir": os.path.expanduser("~/.codex/skills/porting-to-canyonos-core"), + }, +} + + +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 integrate with?") + + +def install_skill(agent): + spec = AGENTS[agent] + # -f overwrites an existing skill dir; without it gitpick exits 1 when the + # target already exists and is non-empty (e.g. re-running `integrate`). + subprocess.run( + ["npx", "-y", "gitpick", "-f", SKILL_SOURCE_URL, spec["skill_dir"]], + check=True, + ) + + +def launch_agent(agent, prompt): + spec = AGENTS[agent] + if not shutil.which(spec["cli"]): + print(f"`{spec['cli']}` not found on PATH; install {spec['label']} first.") + return + subprocess.run([spec["cli"], prompt], check=True) + + +def run_integrate(): + console = Console() + agent = prompt_agent() + if agent is None: + console.print("Cancelled.") + return + + console.print(f"Installing CanyonOS skill for {AGENTS[agent]['label']}...") + install_skill(agent) + + console.print(f"Launching {AGENTS[agent]['label']}...") + launch_agent(agent, INTEGRATE_PROMPT) diff --git a/cli/canyonos/logs.py b/cli/canyonos/logs.py new file mode 100644 index 0000000..9b2b1d8 --- /dev/null +++ b/cli/canyonos/logs.py @@ -0,0 +1,37 @@ +""" +Logic for `canyonos logs`: re-subscribe to the running deploy's log stream. +""" + +import json +import subprocess +import urllib.error +import urllib.request + +from canyonos.init import load_state + + +def run_logs(): + try: + state = load_state() + except FileNotFoundError: + print("No Global Controller container is running. Run `canyonos init` first.") + return + + url = f"http://127.0.0.1:{state['port']}/status" + req = urllib.request.Request(url, method="GET") + + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + except urllib.error.URLError as e: + print(f"Could not reach Global Controller container: {e}") + return + + if not data.get("running"): + print("No deploy running, run `canyonos deploy` to deploy project.") + return + + try: + subprocess.run(["docker", "logs", "-f", state["container_id"]]) + except KeyboardInterrupt: + print("\nStopped 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..30e93b0 --- /dev/null +++ b/cli/canyonos/new_app.py @@ -0,0 +1,21 @@ +""" +Logic for `canyonos new-app`: scaffold a new project in the current +directory. Runs locally, no container involved. +""" + +import os + + +def run_new_app(): + if os.listdir("."): + print("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() + + print("Created new CanyonOS project.") diff --git a/cli/canyonos/quit.py b/cli/canyonos/quit.py new file mode 100644 index 0000000..15aff2c --- /dev/null +++ b/cli/canyonos/quit.py @@ -0,0 +1,63 @@ +""" +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 rich.console import Console + +from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH, load_state +from canyonos.stop import _post_clean + + +def _container_exists(container_id): + result = subprocess.run( + ["docker", "inspect", container_id], capture_output=True + ) + return result.returncode == 0 + + +def run_quit(): + try: + state = load_state() + except FileNotFoundError: + print("No Global Controller container is running.") + return + + container_id = state["container_id"] + console = Console() + with console.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 OSError: + # Covers urllib.error.HTTPError/URLError (both subclass OSError) + # plus raw connection errors -- 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: + print(f"Global Controller container {container_id[:12]} was already gone; cleaned up local state.") + else: + print(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..ddfd7d6 --- /dev/null +++ b/cli/canyonos/serve.py @@ -0,0 +1,18 @@ +"""CLI output for the local dashboard stack.""" + +from .dashboard_stack import run_dashboard + + +def run_serve(config_path: str | None = None) -> int: + def report(phase: str, message: str) -> None: + print(f"[serve] {phase}: {message}") + + result = run_dashboard(config_path, report) + if result.ok: + print(f"Dashboard: {result.url}") + return 0 + + print(f"serve failed in {result.phase}: {result.message}") + if result.log_path: + print(f"log: {result.log_path}") + return 1 diff --git a/cli/canyonos/stop.py b/cli/canyonos/stop.py new file mode 100644 index 0000000..2f6ad40 --- /dev/null +++ b/cli/canyonos/stop.py @@ -0,0 +1,47 @@ +""" +Logic for `canyonos stop`: stop the running deploy inside the Global +Controller container (SIGTERM, same teardown as Ctrl+C would trigger). +""" + +import json +import urllib.error +import urllib.request + +from rich.console import Console + +from canyonos.init import load_state + + +def _post_clean(port): + """POST /clean to the Global Controller container. + + This is what actually tears down the local controller and Redis + containers a deploy spawned via docker-outside-of-docker: it sends + SIGTERM to the in-container `ventis deploy` process, whose handler calls + `GlobalController.stop()` and blocks until it returns. Shared with + `canyonos quit`, which needs the same teardown before removing the GC + container itself. + """ + url = f"http://127.0.0.1:{port}/clean" + req = urllib.request.Request(url, method="POST") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read()) + + +def run_stop(): + try: + state = load_state() + except FileNotFoundError: + print("No Global Controller container is running. Run `canyonos init` first.") + return + + console = Console() + try: + with console.status("Stopping deploy..."): + _post_clean(state["port"]) + print("Deploy stopped.") + except urllib.error.HTTPError as e: + data = json.loads(e.read()) + print(f"Stop failed: {data.get('error')}") + except urllib.error.URLError as e: + print(f"Could not reach Global Controller container: {e}") diff --git a/cli/canyonos/sync.py b/cli/canyonos/sync.py new file mode 100644 index 0000000..f350a1c --- /dev/null +++ b/cli/canyonos/sync.py @@ -0,0 +1,40 @@ +""" +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 they persist across `canyonos quit` and survive host-side +changes. `docker cp` is additive: it overwrites/adds files but never deletes, +so build outputs generated inside the container (stubs/, grpc_stubs/, +docker_container/) survive a re-sync of the host source. +""" + +import os +import subprocess + +from canyonos.init import GC_WORKSPACE_PATH, load_state + + +def run_sync(): + """Copy the current directory into the container. Returns True on success.""" + try: + state = load_state() + except FileNotFoundError: + print("No Global Controller container is running. Run `canyonos init` first.") + 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(), ".") + print(f"Syncing {os.getcwd()} -> {container_id[:12]}:{GC_WORKSPACE_PATH} ...") + + result = subprocess.run( + ["docker", "cp", src, f"{container_id}:{GC_WORKSPACE_PATH}"] + ) + if result.returncode != 0: + print("Sync failed.") + return False + + print("Sync complete.") + return True 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/cli.py b/cli/cli.py new file mode 100644 index 0000000..4c78043 --- /dev/null +++ b/cli/cli.py @@ -0,0 +1,217 @@ +""" +Most of the commands will be executed by code in the canyonos container. +Anything executing in this CLI pertains to file/folder modification +""" + +import argparse +import sys + +from canyonos.clean import run_clean +from canyonos.constants import default_config_path +from canyonos.config import run_config +from canyonos.deploy import run_deploy +from canyonos.integrate import run_integrate +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.stop import run_stop +from canyonos.sync import run_sync + +try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + from rich.table import Table + RICH_AVAILABLE = True +except ImportError: + RICH_AVAILABLE = False + +def cmd_connect(args): + pass + +def cmd_quit(args): + run_quit() + +def cmd_new_app(args): + run_new_app() + +# Executed in canyonos: syncs files, then builds + deploys +def cmd_deploy(args): + run_deploy(args.config, serve=args.serve) + +def cmd_clean(args): + run_clean() + +def cmd_stop(args): + run_stop() + +def cmd_logs(args): + run_logs() + +def cmd_sync(args): + run_sync() + +def cmd_config(args): + run_config() + +def cmd_integrate(args): + run_integrate() + +def cmd_doctor(args): + pass + +def cmd_serve(args): + sys.exit(run_serve(args.config)) + +# Executed in canyonos +def cmd_test(args): + pass + +# Executed in canyonos +def cmd_mega_build(args): + pass + + +def cmd_version(args): + pass + + +def print_custom_help(): + """Print a custom, visually appealing help screen.""" + if RICH_AVAILABLE: + console = Console() + + # Header + title = Text("CanyonOS CLI", style="bold cyan") + subtitle = Text("\nBuild, deploy, and manage agentic workflows with ease\n", style="dim") + + console.print(Panel(title + subtitle, border_style="cyan", padding=(1, 2))) + + # Core commands + console.print("\n[bold yellow]Core Commands[/bold yellow]") + core_table = Table(show_header=False, border_style="dim", padding=(0, 2)) + core_table.add_column(style="cyan", width=20) + core_table.add_column(style="white") + core_table.add_row("integrate", "Sync source files to .car/app/") + core_table.add_row("deploy", "Build and deploy agents to configured hosts") + core_table.add_row("config", "Configure project settings") + console.print(core_table) + + # Utils commands + console.print("\n[bold yellow]Utils[/bold yellow]") + utils_table = Table(show_header=False, border_style="dim", padding=(0, 2)) + utils_table.add_column(style="cyan", width=20) + utils_table.add_column(style="white") + utils_table.add_row("new-app", "Create a new CanyonOS project") + utils_table.add_row("serve", "Start local CanyonOS dashboard") + utils_table.add_row("sync", "Sync files with container") + utils_table.add_row("stop", "Stop running containers") + utils_table.add_row("clean", "Remove generated files") + utils_table.add_row("logs", "View container logs") + utils_table.add_row("doctor", "Check system health") + utils_table.add_row("connect", "Connect to remote host") + utils_table.add_row("quit", "Shut down CanyonOS services") + console.print(utils_table) + + # Quick start + console.print("\n[bold green]Quick Start:[/bold green]") + console.print(" [dim]1.[/dim] canyonos new-app [cyan]my-app[/cyan]") + console.print(" [dim]2.[/dim] cd [cyan]my-app[/cyan]") + console.print(" [dim]3.[/dim] canyonos integrate") + console.print(" [dim]4.[/dim] canyonos deploy") + console.print(" [dim]5.[/dim] canyonos serve\n") + + console.print("[dim]For command-specific help: [cyan]canyonos --help[/cyan][/dim]\n") + else: + # Fallback to simple text if rich is not available + print("\n" + "="*60) + print(" " * 20 + "CanyonOS CLI") + print(" " * 10 + "Build, deploy, and manage agentic workflows") + print("="*60 + "\n") + + print("CORE COMMANDS:") + print(" integrate Sync source files to .car/app/") + print(" deploy Build and deploy agents to configured hosts") + print(" config Configure project settings\n") + + print("UTILS:") + print(" new-app Create a new CanyonOS project") + print(" serve Start local CanyonOS dashboard") + print(" sync Sync files with container") + print(" stop Stop running containers") + print(" clean Remove generated files") + print(" logs View container logs") + print(" doctor Check system health") + print(" connect Connect to remote host") + print(" quit Shut down CanyonOS services\n") + + print("QUICK START:") + print(" 1. canyonos new-app my-app") + print(" 2. cd my-app") + print(" 3. canyonos integrate") + print(" 4. canyonos deploy") + print(" 5. canyonos serve\n") + + print("For command-specific help: canyonos --help\n") + + +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}") + + +def main(): + parser = argparse.ArgumentParser(prog="canyonos") + subparsers = parser.add_subparsers(dest="command") + config_default = default_config_path() + + subparsers.add_parser("new-app").set_defaults(func=cmd_new_app) + deploy = subparsers.add_parser("deploy") + deploy.add_argument( + "-c", + "--config", + default=config_default, + help=f"Path to global controller config (default: {config_default})", + ) + 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.set_defaults(func=cmd_deploy) + subparsers.add_parser("clean").set_defaults(func=cmd_clean) + subparsers.add_parser("stop").set_defaults(func=cmd_stop) + subparsers.add_parser("logs").set_defaults(func=cmd_logs) + subparsers.add_parser("quit").set_defaults(func=cmd_quit) + subparsers.add_parser("connect").set_defaults(func=cmd_connect) + subparsers.add_parser("sync").set_defaults(func=cmd_sync) + subparsers.add_parser("config").set_defaults(func=cmd_config) + subparsers.add_parser("integrate").set_defaults(func=cmd_integrate) + subparsers.add_parser("doctor").set_defaults(func=cmd_doctor) + serve = subparsers.add_parser("serve") + serve.add_argument( + "-c", + "--config", + default=config_default, + help=f"Path to global controller config (default: {config_default})", + ) + serve.set_defaults(func=cmd_serve) + subparsers.add_parser("test").set_defaults(func=cmd_test) + subparsers.add_parser("mega-build").set_defaults(func=cmd_mega_build) + + args = parser.parse_args() + if not getattr(args, "command", None): + print_custom_help() + return + + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/cli/pyproject.toml b/cli/pyproject.toml new file mode 100644 index 0000000..e85c825 --- /dev/null +++ b/cli/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "canyonos" +version = "0.1.4" +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/tests/test_dashboard_stack.py b/cli/tests/test_dashboard_stack.py new file mode 100644 index 0000000..2e62480 --- /dev/null +++ b/cli/tests/test_dashboard_stack.py @@ -0,0 +1,432 @@ +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 ( + "DATABASE_URL", + "JWT_SECRET", + "CANYONOS_DATABASE_URL", + "CANYONOS_JWT_SECRET", + "CANYONOS_REDIS_HOST", + "CANYONOS_REDIS_PORT", + "CANYONOS_API_IMAGE", + "CANYONOS_WEB_IMAGE", + ): + monkeypatch.delenv(key, raising=False) + config_dir = tmp_path / "config" + config_dir.mkdir() + config = config_dir / "global_controller.yaml" + config.write_text("database:\n url: postgres://user:password@db.example/canyonos\n") + monkeypatch.setattr(dashboard_stack.shutil, "which", lambda _: "/usr/bin/docker") + monkeypatch.setattr(dashboard_stack, "_port_is_free", lambda _port: True) + return config + + +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(str(project)) + + assert result == dashboard_stack.ServeResult(False, "validate", message) + assert all(command[-1] != "pull" for command in calls) + + +def test_empty_database_url_fails_validation_but_absent_one_does_not(monkeypatch, project): + project.write_text("database:\n url: ''\n") + calls = [] + install_docker(monkeypatch, calls) + + result = dashboard_stack.run_dashboard(str(project)) + + assert result == dashboard_stack.ServeResult( + False, "validate", "database.url must be a non-empty string" + ) + assert all(command[-1] != "pull" for command in calls) + + +def test_missing_database_section_is_not_a_validation_failure(monkeypatch, project): + project.write_text("") + calls = [] + install_docker(monkeypatch, calls) + + stack = dashboard_stack.validate(str(project)) + + assert stack.database_url is None + + +def test_prepare_omits_database_env_when_not_configured(project): + project.write_text("") + stack = dashboard_stack.DashboardStack( + None, dashboard_stack._state_dir(), Path.cwd() + ) + + managed_env, message = dashboard_stack.prepare(stack) + + assert message == "dashboard state prepared" + assert "CANYONOS_DATABASE_URL" not in managed_env + assert "CANYONOS_DATABASE_URL" not in stack.env_path.read_text() + + +def test_config_substitutes_quoted_dotenv_value_without_replacing_source(monkeypatch, project): + source_line = 'DATABASE_URL="postgres://user:password@db.example/canyonos"\n' + Path.cwd().joinpath(".env").write_text(source_line) + project.write_text("database:\n url: ${DATABASE_URL}\n") + calls = [] + install_docker(monkeypatch, calls) + + stack = dashboard_stack.validate(str(project)) + managed_env, _ = dashboard_stack.prepare(stack) + + assert stack.database_url == "postgres://user:password@db.example/canyonos" + assert managed_env["CANYONOS_DATABASE_URL"] == stack.database_url + assert stack.env_path.read_text().startswith(source_line) + + +def test_missing_database_url_variable_fails_without_pulling(monkeypatch, project): + project.write_text("database:\n url: ${DATABASE_URL}\n") + calls = [] + install_docker(monkeypatch, calls) + + result = dashboard_stack.run_dashboard(str(project)) + + assert result == dashboard_stack.ServeResult( + False, + "validate", + "database.url needs ${DATABASE_URL}, which is not set in the project .env", + ) + 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( + "postgres://user:password@db.example/canyonos", + 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_process_environment_database_url_wins_over_project_dotenv(monkeypatch, project): + Path.cwd().joinpath(".env").write_text("DATABASE_URL=postgres://from-file/canyonos\n") + monkeypatch.setenv("DATABASE_URL", "postgres://from-process/canyonos") + project.write_text("database:\n url: ${DATABASE_URL}\n") + calls = [] + install_docker(monkeypatch, calls) + + stack = dashboard_stack.validate(str(project)) + + assert stack.database_url == "postgres://from-process/canyonos" + + +def test_unreadable_config_does_not_pull(monkeypatch, project): + calls = [] + install_docker(monkeypatch, calls) + + result = dashboard_stack.run_dashboard(str(project.with_name("missing.yaml"))) + + assert result.message == f"config file is not readable: {project.with_name('missing.yaml')}" + assert all(command[-1] != "pull" for command in calls) + + +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(str(project)) + + 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(str(project)) + + 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( + "postgres://user:password@localhost:5432/canyonos", + dashboard_stack._state_dir(), + Path.cwd(), + ) + + managed_env, message = dashboard_stack.prepare(stack) + + assert message == "database host localhost is reachable from the stack as host.docker.internal" + 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_DATABASE_URL", + "CANYONOS_JWT_SECRET", + "CANYONOS_REDIS_HOST", + "CANYONOS_REDIS_PORT", + "CANYONOS_API_IMAGE", + "CANYONOS_WEB_IMAGE", + "CANYONOS_WEB_PORT", + } + assert "CANYONOS_DATABASE_URL=postgres://user:password@host.docker.internal:5432/canyonos" in env_lines + 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_prepare_reuses_secret_and_rewrites_only_local_hosts(project): + stack = dashboard_stack.DashboardStack( + "postgres://user:password@localhost/canyonos", + dashboard_stack._state_dir(), + Path.cwd(), + ) + first_env, first_message = dashboard_stack.prepare(stack) + second_env, second_message = dashboard_stack.prepare(stack) + + assert first_message.startswith("database host localhost") + assert second_message.startswith("database host localhost") + assert first_env["CANYONOS_JWT_SECRET"] == second_env["CANYONOS_JWT_SECRET"] + assert ( + first_env["CANYONOS_DATABASE_URL"] + == "postgres://user:password@host.docker.internal/canyonos" + ) + + remote_stack = dashboard_stack.DashboardStack( + "postgres://db.example/canyonos", dashboard_stack._state_dir(), Path.cwd() + ) + remote_env, _ = dashboard_stack.prepare(remote_stack) + assert remote_env["CANYONOS_DATABASE_URL"] == "postgres://db.example/canyonos" + + +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(str(project)) + + 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(str(project)) + + 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(str(project)) + + 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(str(project)) + + 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.parent.parent / ".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(str(project)) + + assert result.ok + assert result.url == "http://127.0.0.1:8080" diff --git a/cli/utils/__init__.py b/cli/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/utils/tui.py b/cli/utils/tui.py new file mode 100644 index 0000000..3fcdad5 --- /dev/null +++ b/cli/utils/tui.py @@ -0,0 +1,113 @@ +""" +Minimal arrow-key select menu, no dependency beyond the standard library. +""" + +import os +import select as select_syscall +import sys +import termios +import tty + +UP_KEYS = ("\x1b[A", "\x1bOA", "k") +DOWN_KEYS = ("\x1b[B", "\x1bOB", "j") +CANCEL_KEYS = ("\x03", "\x1b") +DELETE_KEYS = ("d", "D") +QUIT_KEYS = ("q", "Q") + +# 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 len(options) == 1: + return options[0][0] + 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"\x1b[36m❯ {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/epigenomics/README.md b/examples/epigenomics/README.md new file mode 100644 index 0000000..19fb748 --- /dev/null +++ b/examples/epigenomics/README.md @@ -0,0 +1,70 @@ +# Epigenomics Example + +A synthetic, LLM-free workflow modeled on the +[WfCommons Epigenomics recipe](https://docs.wfcommons.org/en/latest/generating_workflows.html): +a split → fan-out (filter → align → sort) → fan-in (dedup) → index pipeline. +Every stage does deterministic SHA-256 work sized off chunk byte counts, so +results are reproducible and the fan-out width scales with `num_chunks` -- +useful for exercising scheduling/replica behavior locally without any real +model calls. + +## Pipeline + +``` +SplitAgent --> FilterAgent --> MapAgent --> SortAgent --\ + (1 call) (fan-out) (fan-out) (fan-out) --> DedupAgent --> IndexAgent + (fan-in barrier) (1 call) +``` + +- **SplitAgent** — splits `input_size` bytes into `num_chunks` equal chunks. +- **FilterAgent** — per-chunk contaminant filter (light cost). +- **MapAgent** — per-chunk alignment, the heaviest stage. +- **SortAgent** — per-chunk sort (moderate cost). +- **DedupAgent** — merges every sorted chunk into one digest (the barrier). +- **IndexAgent** — builds the final index from the merged digest. + +## Quick Start + +```bash +# Build stubs and Docker images +ventis build + +# Launch all agents +ventis deploy + +# Test with curl +curl -X POST http://:8080/main \ + -H 'Content-Type: application/json' \ + -d '{"input_size": 65536, "num_chunks": 4}' + +# Check result +curl http://:8080/status/ +``` + +## Project Structure + +``` +├── agents/ # Agent implementations and YAML definitions +│ ├── split_agent.py/.yaml +│ ├── filter_agent.py/.yaml +│ ├── map_agent.py/.yaml +│ ├── sort_agent.py/.yaml +│ ├── dedup_agent.py/.yaml +│ └── index_agent.py/.yaml +├── workflow/ # Workflow script (deployed as a REST API) +│ └── epigenomics_workflow.py +└── config/ + ├── global_controller.yaml # Deployment configuration (provider: local) + └── policy.yaml # Access control rules +``` + +## Policy Rules + +Edit `config/policy.yaml` to control which callers can access which agents. +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 '{"input_size": 65536, "num_chunks": 4, "_context": {"origin": "admin"}}' +``` diff --git a/examples/epigenomics/agents/dedup_agent.py b/examples/epigenomics/agents/dedup_agent.py new file mode 100644 index 0000000..d883854 --- /dev/null +++ b/examples/epigenomics/agents/dedup_agent.py @@ -0,0 +1,44 @@ +# Dedup Agent +# +# Fan-in barrier (mirrors Epigenomics' mark-duplicates/merge stage): needs +# every sorted chunk before it can run. Combines all chunk digests into one +# merged digest, with cost scaling off the total merged data volume. +# +# Resource profile: moderate CPU, single call per request (the barrier). + +import hashlib + + +class DedupAgent(object): + def __init__(self): + self.tools = [self.merge_dedup] + + def merge_dedup(self, chunks: list) -> dict: + """Merge and deduplicate every sorted chunk into one combined digest.""" + total_size = sum(c["size"] for c in chunks) + seed = "".join(c["digest"] for c in sorted(chunks, key=lambda c: c["chunk_id"])) + merged_digest = self._cpu_work(seed, total_size) + return { + "merged_digest": merged_digest, + "total_size": total_size, + "n_chunks": len(chunks), + } + + def _cpu_work(self, seed: str, iterations: int) -> str: + """Deterministic CPU-bound stand-in for the stage's real processing cost.""" + digest = seed.encode() + for _ in range(max(1, iterations)): + digest = hashlib.sha256(digest).digest() + return digest.hex() + + +if __name__ == "__main__": + agent = DedupAgent() + print( + agent.merge_dedup( + [ + {"chunk_id": "chunk-0", "size": 16384, "digest": "aa"}, + {"chunk_id": "chunk-1", "size": 16384, "digest": "bb"}, + ] + ) + ) diff --git a/examples/epigenomics/agents/dedup_agent.yaml b/examples/epigenomics/agents/dedup_agent.yaml new file mode 100644 index 0000000..1c577cd --- /dev/null +++ b/examples/epigenomics/agents/dedup_agent.yaml @@ -0,0 +1,10 @@ +agent: + name: DedupAgent + functions: + - name: merge_dedup + description: Merge and deduplicate every sorted chunk into one combined digest. + arguments: + - name: chunks + type: list + returns: + type: dict diff --git a/examples/epigenomics/agents/filter_agent.py b/examples/epigenomics/agents/filter_agent.py new file mode 100644 index 0000000..24dfc48 --- /dev/null +++ b/examples/epigenomics/agents/filter_agent.py @@ -0,0 +1,32 @@ +# Filter Agent +# +# First per-chunk stage in the fan-out (mirrors Epigenomics' filter_contams +# stage): scrubs one chunk and hands back a content digest the later stages +# build on. The "work" is a deterministic SHA-256 chain sized off the +# chunk's declared byte size, standing in for the real stage's per-byte cost. +# +# Resource profile: light CPU, high fan-out (one call per chunk). + +import hashlib + + +class FilterAgent(object): + def __init__(self): + self.tools = [self.filter_contams] + + def filter_contams(self, chunk_id: str, size: int) -> dict: + """Filter contaminants out of one chunk, returning its content digest.""" + digest = self._cpu_work(chunk_id, size) + return {"chunk_id": chunk_id, "size": size, "digest": digest} + + def _cpu_work(self, seed: str, iterations: int) -> str: + """Deterministic CPU-bound stand-in for the stage's real processing cost.""" + digest = seed.encode() + for _ in range(max(1, iterations)): + digest = hashlib.sha256(digest).digest() + return digest.hex() + + +if __name__ == "__main__": + agent = FilterAgent() + print(agent.filter_contams("chunk-0", 16384)) diff --git a/examples/epigenomics/agents/filter_agent.yaml b/examples/epigenomics/agents/filter_agent.yaml new file mode 100644 index 0000000..9f10d29 --- /dev/null +++ b/examples/epigenomics/agents/filter_agent.yaml @@ -0,0 +1,12 @@ +agent: + name: FilterAgent + functions: + - name: filter_contams + description: Filter contaminants out of one chunk, returning its content digest. + arguments: + - name: chunk_id + type: str + - name: size + type: int + returns: + type: dict diff --git a/examples/epigenomics/agents/index_agent.py b/examples/epigenomics/agents/index_agent.py new file mode 100644 index 0000000..6faa984 --- /dev/null +++ b/examples/epigenomics/agents/index_agent.py @@ -0,0 +1,30 @@ +# Index Agent +# +# Final stage (mirrors Epigenomics' index-build stage): produces the +# workflow's terminal artifact from the merged, deduplicated digest. +# +# Resource profile: light CPU, single call per request. + +import hashlib + + +class IndexAgent(object): + def __init__(self): + self.tools = [self.build_index] + + def build_index(self, merged_digest: str, total_size: int) -> dict: + """Build the final index from the merged digest.""" + index_digest = self._cpu_work(merged_digest, max(1, total_size // 4)) + return {"index_digest": index_digest, "total_size": total_size} + + def _cpu_work(self, seed: str, iterations: int) -> str: + """Deterministic CPU-bound stand-in for the stage's real processing cost.""" + digest = seed.encode() + for _ in range(max(1, iterations)): + digest = hashlib.sha256(digest).digest() + return digest.hex() + + +if __name__ == "__main__": + agent = IndexAgent() + print(agent.build_index("deadbeef", 65536)) diff --git a/examples/epigenomics/agents/index_agent.yaml b/examples/epigenomics/agents/index_agent.yaml new file mode 100644 index 0000000..3424c44 --- /dev/null +++ b/examples/epigenomics/agents/index_agent.yaml @@ -0,0 +1,12 @@ +agent: + name: IndexAgent + functions: + - name: build_index + description: Build the final index from the merged digest. + arguments: + - name: merged_digest + type: str + - name: total_size + type: int + returns: + type: dict diff --git a/examples/epigenomics/agents/map_agent.py b/examples/epigenomics/agents/map_agent.py new file mode 100644 index 0000000..b9db3ef --- /dev/null +++ b/examples/epigenomics/agents/map_agent.py @@ -0,0 +1,33 @@ +# Map Agent +# +# Sequence-alignment stand-in (Epigenomics' map stage) -- by far the most +# CPU-expensive stage in the real workflow, so its per-byte cost multiplier +# here is set well above the other stages to match that shape. +# +# Resource profile: heavy CPU, high fan-out (one call per chunk). + +import hashlib + +COST_MULTIPLIER = 8 + + +class MapAgent(object): + def __init__(self): + self.tools = [self.align] + + def align(self, chunk_id: str, size: int, digest: str) -> dict: + """Align one filtered chunk, returning its post-alignment digest.""" + aligned = self._cpu_work(digest, size * COST_MULTIPLIER) + return {"chunk_id": chunk_id, "size": size, "digest": aligned} + + def _cpu_work(self, seed: str, iterations: int) -> str: + """Deterministic CPU-bound stand-in for the stage's real processing cost.""" + digest = seed.encode() + for _ in range(max(1, iterations)): + digest = hashlib.sha256(digest).digest() + return digest.hex() + + +if __name__ == "__main__": + agent = MapAgent() + print(agent.align("chunk-0", 16384, "deadbeef")) diff --git a/examples/epigenomics/agents/map_agent.yaml b/examples/epigenomics/agents/map_agent.yaml new file mode 100644 index 0000000..66c1210 --- /dev/null +++ b/examples/epigenomics/agents/map_agent.yaml @@ -0,0 +1,14 @@ +agent: + name: MapAgent + functions: + - name: align + description: Align one filtered chunk, returning its post-alignment digest. + arguments: + - name: chunk_id + type: str + - name: size + type: int + - name: digest + type: str + returns: + type: dict diff --git a/examples/epigenomics/agents/sort_agent.py b/examples/epigenomics/agents/sort_agent.py new file mode 100644 index 0000000..2aa2df7 --- /dev/null +++ b/examples/epigenomics/agents/sort_agent.py @@ -0,0 +1,32 @@ +# Sort Agent +# +# Third per-chunk stage in the fan-out (mirrors Epigenomics' sort stage): +# orders one aligned chunk, returning an updated digest for the fan-in below. +# +# Resource profile: moderate CPU, high fan-out (one call per chunk). + +import hashlib + +COST_MULTIPLIER = 2 + + +class SortAgent(object): + def __init__(self): + self.tools = [self.sort] + + def sort(self, chunk_id: str, size: int, digest: str) -> dict: + """Sort one aligned chunk, returning its post-sort digest.""" + sorted_digest = self._cpu_work(digest, size * COST_MULTIPLIER) + return {"chunk_id": chunk_id, "size": size, "digest": sorted_digest} + + def _cpu_work(self, seed: str, iterations: int) -> str: + """Deterministic CPU-bound stand-in for the stage's real processing cost.""" + digest = seed.encode() + for _ in range(max(1, iterations)): + digest = hashlib.sha256(digest).digest() + return digest.hex() + + +if __name__ == "__main__": + agent = SortAgent() + print(agent.sort("chunk-0", 16384, "deadbeef")) diff --git a/examples/epigenomics/agents/sort_agent.yaml b/examples/epigenomics/agents/sort_agent.yaml new file mode 100644 index 0000000..464dc85 --- /dev/null +++ b/examples/epigenomics/agents/sort_agent.yaml @@ -0,0 +1,14 @@ +agent: + name: SortAgent + functions: + - name: sort + description: Sort one aligned chunk, returning its post-sort digest. + arguments: + - name: chunk_id + type: str + - name: size + type: int + - name: digest + type: str + returns: + type: dict diff --git a/examples/epigenomics/agents/split_agent.py b/examples/epigenomics/agents/split_agent.py new file mode 100644 index 0000000..1b1a6be --- /dev/null +++ b/examples/epigenomics/agents/split_agent.py @@ -0,0 +1,27 @@ +# Split Agent +# +# Entry stage of the pipeline (mirrors WfCommons' Epigenomics fastq-split +# stage): splits one logical input into num_chunks equal-sized chunks for the +# downstream fan-out. There's no real sequence file here -- each chunk's +# "size" just stands in for its data volume, which is what every downstream +# stage prices its synthetic CPU work off of. +# +# Resource profile: cheap CPU, single call per request. + + +class SplitAgent(object): + def __init__(self): + self.tools = [self.split] + + def split(self, input_size: int, num_chunks: int) -> dict: + """Split input_size bytes of data into num_chunks equal chunks.""" + chunk_size = max(1, input_size // num_chunks) + chunks = [ + {"chunk_id": f"chunk-{i}", "size": chunk_size} for i in range(num_chunks) + ] + return {"chunks": chunks} + + +if __name__ == "__main__": + agent = SplitAgent() + print(agent.split(65536, 4)) diff --git a/examples/epigenomics/agents/split_agent.yaml b/examples/epigenomics/agents/split_agent.yaml new file mode 100644 index 0000000..cc64e8e --- /dev/null +++ b/examples/epigenomics/agents/split_agent.yaml @@ -0,0 +1,12 @@ +agent: + name: SplitAgent + functions: + - name: split + description: Split input_size bytes of data into num_chunks equal chunks. + arguments: + - name: input_size + type: int + - name: num_chunks + type: int + returns: + type: dict diff --git a/examples/epigenomics/config/global_controller.yaml b/examples/epigenomics/config/global_controller.yaml new file mode 100644 index 0000000..6e8b4b0 --- /dev/null +++ b/examples/epigenomics/config/global_controller.yaml @@ -0,0 +1,76 @@ +# Global Controller Configuration — synthetic Epigenomics DAG, local provider +# Lists all agents and the workflow that Ventis manages. +# +# FilterAgent/MapAgent/SortAgent get 2 replicas each since the workflow fans +# out one call per chunk to them -- exercises multi-replica scheduling on a +# purely local, LLM-free run. + +agents: + - name: SplitAgent + replicas: 1 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/split_agent.py + provider: local + + - name: FilterAgent + replicas: 2 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/filter_agent.py + provider: local + + - name: MapAgent + replicas: 2 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/map_agent.py + provider: local + + - name: SortAgent + replicas: 2 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/sort_agent.py + provider: local + + - name: DedupAgent + replicas: 1 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/dedup_agent.py + provider: local + + - name: IndexAgent + replicas: 1 + redis_port: 6379 + resources: + cpu: 1 + memory: 256 + entrypoint: agents/index_agent.py + provider: local + + - name: Workflow + replicas: 1 + type: workflow + redis_port: 6379 + api_port: 8080 + workflow_file: workflow/epigenomics_workflow.py + provider: local + +poll_interval: 5 + +redis: + host: localhost + port: 6379 + db: 0 diff --git a/examples/epigenomics/config/policy.yaml b/examples/epigenomics/config/policy.yaml new file mode 100644 index 0000000..2c91415 --- /dev/null +++ b/examples/epigenomics/config/policy.yaml @@ -0,0 +1,20 @@ +# Policy-Based Routing Rules — synthetic Epigenomics DAG +# Each rule defines a match condition (key-value pairs to check against +# 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. + +rules: + - match: + origin: admin + access: all + + - match: {} + access: + - Workflow + - SplitAgent + - FilterAgent + - MapAgent + - SortAgent + - DedupAgent + - IndexAgent diff --git a/examples/epigenomics/workflow/epigenomics_workflow.py b/examples/epigenomics/workflow/epigenomics_workflow.py new file mode 100644 index 0000000..00bcc2b --- /dev/null +++ b/examples/epigenomics/workflow/epigenomics_workflow.py @@ -0,0 +1,97 @@ +# Epigenomics Workflow +# +# WfCommons-style synthetic Epigenomics DAG for local, LLM-free testing: +# 0. SplitAgent - split input_size bytes into num_chunks chunks (single call) +# 1. FilterAgent - per-chunk contaminant filter (fan-out) +# 2. MapAgent - per-chunk alignment, the heaviest stage (fan-out) +# 3. SortAgent - per-chunk sort (fan-out) +# 4. DedupAgent - merge + dedup every sorted chunk (fan-in barrier) +# 5. IndexAgent - build the final index from the merged digest (single call) +# +# Every stage does deterministic SHA-256 work sized off chunk byte counts -- +# no LLM calls, no external services -- so results are reproducible and the +# fan-out width scales with num_chunks. +# +# After running `ventis build` and `ventis deploy`: +# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' \ +# -d '{"input_size": 65536, "num_chunks": 4}' +# curl http://localhost:8080/status/ + +import sys +import os + +# These path inserts are needed when running inside a Docker container +# where all files are copied flat into /app/. +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) + +import json + +from deploy import deploy +from agents.split_agent import SplitAgent +from agents.filter_agent import FilterAgent +from agents.map_agent import MapAgent +from agents.sort_agent import SortAgent +from agents.dedup_agent import DedupAgent +from agents.index_agent import IndexAgent + + +def main(input_size: int = 65536, num_chunks: int = 4): + split_agent = SplitAgent() + filter_agent = FilterAgent() + map_agent = MapAgent() + sort_agent = SortAgent() + dedup_agent = DedupAgent() + index_agent = IndexAgent() + + # Stage 0: single call, produces the chunk list the fan-out below runs over. + split = json.loads( + split_agent.split(input_size=input_size, num_chunks=num_chunks).value() + ) + chunks = split["chunks"] + + # Stage 1: fan out one filter call per chunk -- every call returns a Future + # immediately, so all chunks are dispatched before we block on any of them. + filter_futures = { + c["chunk_id"]: filter_agent.filter_contams(chunk_id=c["chunk_id"], size=c["size"]) + for c in chunks + } + filtered = {cid: json.loads(f.value()) for cid, f in filter_futures.items()} + + # Stage 2: fan out alignment -- the heaviest stage -- one call per chunk. + map_futures = { + cid: map_agent.align(chunk_id=cid, size=r["size"], digest=r["digest"]) + for cid, r in filtered.items() + } + mapped = {cid: json.loads(f.value()) for cid, f in map_futures.items()} + + # Stage 3: fan out sort, one call per chunk. + sort_futures = { + cid: sort_agent.sort(chunk_id=cid, size=r["size"], digest=r["digest"]) + for cid, r in mapped.items() + } + sorted_chunks = {cid: json.loads(f.value()) for cid, f in sort_futures.items()} + + # Stage 4: fan-in barrier -- dedup needs every sorted chunk before it can run. + merged = json.loads( + dedup_agent.merge_dedup(chunks=list(sorted_chunks.values())).value() + ) + + # Stage 5: build the final index from the merged digest. + index = json.loads( + index_agent.build_index( + merged_digest=merged["merged_digest"], total_size=merged["total_size"] + ).value() + ) + + return { + "input_size": input_size, + "num_chunks": num_chunks, + "merged_digest": merged["merged_digest"], + "n_chunks": merged["n_chunks"], + "index_digest": index["index_digest"], + } + + +deploy(main, port=8080) 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/joke_writer/.car/app/.env.example b/examples/joke_writer/.car/app/.env.example new file mode 100644 index 0000000..b846149 --- /dev/null +++ b/examples/joke_writer/.car/app/.env.example @@ -0,0 +1,20 @@ +# 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/.car/app/LICENSE b/examples/joke_writer/.car/app/LICENSE new file mode 100644 index 0000000..5600729 --- /dev/null +++ b/examples/joke_writer/.car/app/LICENSE @@ -0,0 +1,21 @@ +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/.car/app/README.md b/examples/joke_writer/.car/app/README.md new file mode 100644 index 0000000..930a410 --- /dev/null +++ b/examples/joke_writer/.car/app/README.md @@ -0,0 +1,177 @@ +# 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-ventis` 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-... +``` + +`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/.car/app/joke_workflow.py b/examples/joke_writer/.car/app/joke_workflow.py new file mode 100644 index 0000000..76e4027 --- /dev/null +++ b/examples/joke_writer/.car/app/joke_workflow.py @@ -0,0 +1,39 @@ +r"""CanyonOS Core 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. + + 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 joke_writer import JokeAgent + + +def main(query): + """Route: POST /main {"query": ""}""" + agent = JokeAgent() + + subjects = json.loads(agent.generate_topics(topic=query).value())["subjects"] + + futures = [agent.generate_joke(subject=s) for s in subjects] + written = [json.loads(f.value()) for f in futures] + written_jokes = [joke for result in written for joke in result["jokes"]] + + 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"], + } + + +deploy(main, port=8080) diff --git a/examples/joke_writer/.car/app/joke_writer.py b/examples/joke_writer/.car/app/joke_writer.py new file mode 100644 index 0000000..9f96eb5 --- /dev/null +++ b/examples/joke_writer/.car/app/joke_writer.py @@ -0,0 +1,164 @@ +"""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() + + +class JokeAgent(object): + """The graph's nodes, exposed under the class name `agent.name` declares.""" + + def generate_topics(self, topic: str) -> dict: + return generate_topics({"topic": topic}) + + def generate_joke(self, subject: str) -> dict: + return generate_joke({"subject": subject}) + + def best_joke(self, topic: str, jokes: list) -> dict: + return best_joke({"topic": topic, "jokes": jokes}) diff --git a/examples/joke_writer/.car/config/global_controller.yaml b/examples/joke_writer/.car/config/global_controller.yaml new file mode 100644 index 0000000..8ed54ab --- /dev/null +++ b/examples/joke_writer/.car/config/global_controller.yaml @@ -0,0 +1,52 @@ +# Deployment manifest for the map-reduce joke writer. +# +# `entrypoint` is the copied source itself: the adapter is appended to the +# bottom of joke_writer.py, so the module the agent needs is the one the class +# already lives in. + +agents: + - name: JokeAgent + # The fan-out. `generate_joke` is stateless, so the controller picks a + # replica at random per call and the workflow's N dispatched calls spread + # across these three. + entrypoint: joke_writer.py + provider: local + replicas: 3 + redis_port: 6379 + resources: + cpu: 1 + memory: 1024 + requirements: + - langgraph + - pydantic + - typing_extensions + + - name: Workflow + type: workflow + workflow_file: joke_workflow.py + api_port: 8080 + provider: local + replicas: 1 + redis_port: 6379 + +poll_interval: 5 + +redis: + host: localhost + port: 6379 + db: 0 + +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: {} + +# Relative to the application root (the directory `ventis` runs from), not +# `.car`. .env is gitignored and excluded from the build context; +# .env.example names what belongs in it. +env_file: .env diff --git a/examples/joke_writer/.car/config/joke_agent.yaml b/examples/joke_writer/.car/config/joke_agent.yaml new file mode 100644 index 0000000..a5bc13f --- /dev/null +++ b/examples/joke_writer/.car/config/joke_agent.yaml @@ -0,0 +1,35 @@ +# 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; 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. + +agent: + name: JokeAgent + functions: + - name: generate_topics + description: Split a topic into three related sub-topics. + arguments: + - name: topic + type: str + returns: + type: dict + + - name: generate_joke + description: Write one joke about one subject. + arguments: + - name: subject + type: str + returns: + type: dict + + - name: best_joke + description: Pick the best joke out of the ones written for a topic. + arguments: + - name: topic + type: str + - name: jokes + type: list + returns: + type: dict diff --git a/examples/joke_writer/README.md b/examples/joke_writer/README.md index 3ba7930..930a410 100644 --- a/examples/joke_writer/README.md +++ b/examples/joke_writer/README.md @@ -80,7 +80,7 @@ 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 +That rewrite is not something the `porting-to-ventis` 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. @@ -107,12 +107,6 @@ 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 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/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/.claude/skills/porting-to-canyonos-core/SKILL.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md new file mode 100644 index 0000000..fe6dd49 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md @@ -0,0 +1,240 @@ +--- +name: porting-to-canyonos-core +description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. +compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. +--- + +# Port an agent project to CanyonOS Core + +CanyonOS Core is the product name. Its compatibility executable and Python +package remain `ventis`; environment variables and Docker resources retain the +`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding +strings. Do not rename them. + +## Load references only when needed + +- Read [references/packaging.md](references/packaging.md) when a source import + does not resolve from `/app`, the source is nested, or packaging metadata is + involved. +- Read [references/llm-proxy.md](references/llm-proxy.md) only when the target + includes `llm_proxy`. +- Read [references/ec2.md](references/ec2.md) only when any config entry uses + `provider: EC2`. +- Read [references/troubleshooting.md](references/troubleshooting.md) after a + failed build, image probe, deploy, or request. +- Read [references/runtime-contract.md](references/runtime-contract.md) when a + validator finding needs explanation or the runtime mechanism is unclear. + +## Goal: thin scaffolding beside untouched source + +```text +agents/.yaml one callable surface per service +agents/.py one thin adapter per service, when needed +workflow/_workflow.py HTTP entry point; calls deploy() +config/global_controller.yaml deployment manifest +config/policy.yaml optional access restriction +pyproject.toml conditional nested-import scaffolding + unchanged +``` + +The file count follows the deployment. A multi-agent port has one yaml/adapter +pair per service that is worth deploying separately. If a source class already +satisfies the runtime contract, point its config entry at that file and do not +copy it into an adapter. + +Everything the source already owns—prompts, tools, schemas, parsing, retries, +model clients, and node bodies—is imported. The port re-expresses only the +CanyonOS Core boundary and framework-owned orchestration. + +The port root is the existing repository root and the directory from which +`ventis build` runs. Write scaffolding there beside existing directories. If the +repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, +and `config/` beside it. Never move or copy the repository into a new `src/` +directory, and never create an outer wrapper merely for the port. + +## 1. Survey before writing + +Identify: + +1. The source entry point and callable input/output. +2. Framework-owned control flow (`StateGraph`, `Crew`, `GroupChat`, routing, + `Send`, `Command`, interrupts). +3. Runtime-injected services nodes read: stores, context, memory, sessions, or + callback managers. +4. Sync versus async boundaries. +5. Imports and declared runtime dependencies. +6. Model provider, credential names, streaming use, and optional `llm_proxy`. +7. Whether independent work fans out and benefits from separate replicas. +8. Whether source imports resolve from the project root that becomes `/app`. + +Run the validator once now. Its header detects capabilities directly from the +importable runtime rather than external development metadata: + +```bash +python /validate.py . +``` + +If config or agent yaml is malformed, the validator defers to `ventis build`. +Capability-gated findings say which runtime behavior is available. + +## 2. Choose service boundaries + +Start with one service. Split only when it creates independent parallel work or +a distinct resource/replica profile. + +- Keep a ReAct loop together; every turn needs shared message history. +- Hoist supervisor task lists and `Send`-style fan-out into the workflow. +- Do not create a one-replica service with no distinct resource profile merely + to mirror every source graph node. + +Rewrite framework-owned edges as ordinary Python. Import the connected node +functions unchanged. Construct runtime-injected service objects from source +configuration; do not invent models, dimensions, stores, or defaults silently. +Report any choice the source does not specify. + +## 3. Write declarations and adapters + +### Agent yaml + +Use one yaml per deployed service. Argument types are bare builtins only: +`str`, `int`, `float`, `bool`, `dict`, or `list`. Every declared argument is +required by the generated stub. `returns.type` is documentation; use `dict` or +`list` to signal that workflow callers must `json.loads` the returned string. + +### Adapter + +The entrypoint exposes a module-level class named exactly `agent.name`. It +constructs with no arguments and its declared methods are synchronous. Read +configuration from the environment in `__init__`. Bridge source coroutines +inside a synchronous method with `asyncio.run(...)`. Serialize framework objects +with their own JSON-safe serializer before returning. + +Do not duplicate source prompts, tools, schemas, or model calls. Keep the source +provider and SDK. + +### Workflow + +Expose `main(query: str)` and call `deploy(main, port=...)` at module scope. +Import generated stubs by yaml basename and agent class name: + +```python +from deploy import deploy +from agents. import +``` + +The deployment platform sends `{query: string}` to `/main`. Pack richer input +inside `query`; any additional workflow parameter has a default. + +Dispatch every remote call before resolving any future: + +```python +futures = [agent.work(item=item) for item in items] +results = [json.loads(future.value()) for future in futures] +``` + +Do not fuse dispatch and `.value()` in one comprehension; that silently +serializes fan-out. Do not add an `if __name__ == "__main__":` block: the +workflow is executed with `__name__ == "__main__"` in production. + +### Config + +For each service, keep these names aligned: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +Use lowercase `provider: local`; `replicas` is an integer; `requirements` is a +list of distribution-name strings. Put `env_file` at config top level when the +runtime capability is available. Omit `policy.yaml` unless access must be +restricted; if present, give it a non-empty `rules` list. + +## Hard rules + +Capitalized **MUST** and **NEVER** are reserved for port-breaking or +source-integrity rules. The owner column states where each is decided. + +| ID | Rule | Owner | +|---|---|---| +| M1 | Entrypoint MUST define a class named exactly `agent.name` | V006 | +| M2 | The class MUST construct with no arguments | V007 | +| M3 | yaml argument names MUST match Python parameter names | V008 | +| M4 | yaml argument types MUST be bare builtins | V010 | +| M5 | Declared adapter methods MUST be synchronous | V009 | +| M6 | Config names MUST match yaml agent names | build | +| M7 | Config names MUST not collide after lowercase normalization | build output | +| M8 | Local provider MUST be lowercase `local` | deploy preflight | +| M9 | `replicas` MUST be an integer | deploy preflight | +| M10 | `requirements` MUST be a list of strings | build | +| M11 | Workflow MUST expose `main(query)`; extra parameters MUST default | V016 | +| M12 | Workflow MUST NEVER contain a main guard | V017 | +| M13 | Fan-out MUST dispatch all calls before resolving any | V018 | +| M14 | Project modules MUST not take runtime or generated-stub flat names | V019/V020 | +| M15 | Workflow MUST import stubs from `agents.` | V023 | +| M16 | Policy MUST be absent or contain a non-empty `rules` list | deploy preflight | +| M17 | EC2 entries MUST satisfy the EC2 deployment contract | deploy preflight | +| M18 | NEVER copy source prompts, tools, schemas, or model calls | review | +| M19 | NEVER hardcode or bake a real credential into an image | W003 | +| M20 | NEVER edit or vendor the source tree | `git status` | +| M21 | NEVER swap the source LLM provider | review | +| M22 | NEVER silently move, drop, or reclassify source dependencies | review | +| M23 | Framework control flow MUST be rewritten; source node logic MUST be imported | review | +| M24 | A non-resolving source import MUST have usable root packaging metadata when editable install is supported | V031 | + +## 4. Validate, build, and probe + +Run static preflight, then let the build own build-time validation: + +```bash +python /validate.py . +ventis build -c config/global_controller.yaml +``` + +A green build never imports the adapter. Probe each agent image in this order: + +```bash +# Runtime startup path + docker run --rm ventis- \ + python -c "import local_controller" + +# Agent load path; include --env-file when configured + docker run --rm --env-file ventis- \ + python -c "import importlib.util,sys; \ +s=importlib.util.spec_from_file_location('m','.py'); \ +m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ +m.();print('ok')" +``` + +Also probe the workflow image with `python -c "import local_controller"`; it has +its own dependency resolve and generated-stub imports. + +Then deploy, send a representative request, and poll its status: + +```bash +ventis deploy -c config/global_controller.yaml +curl -X POST http://localhost:8080/main \ + -H 'Content-Type: application/json' -d '{"query":""}' +curl http://localhost:8080/status/ +``` + +A successful outer request with a source-level failure still proves the port +reached and returned the source behavior. Record the distinction. + +## 5. Clean up + +After collecting evidence, stop foreground deploy with Ctrl+C and wait for +controller cleanup. Remove exact leftovers if startup crashed. Then remove build +products and exact images from this config: + +```bash +ventis clean +docker image rm ventis- \ + ventis- + +test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container +docker ps -a --format '{{.Names}}' +``` + +`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it +does not remove containers or images. Keep port scaffolding, untouched source, +and requested logs or reports. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md new file mode 100644 index 0000000..e06daa0 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md @@ -0,0 +1,41 @@ +# EC2 deployment + +Read this only when at least one config entry uses `provider: EC2`. + +## Configuration + +Use `provider: EC2` and declare `instance_type` on every EC2 service entry. The +top-level `ec2` block supplies the runtime's required infrastructure and SSH +settings. Read the target checkout's deploy preflight and EC2 runtime before +writing the block; do not copy values from an example environment. + +Typical required categories are: + +- AMI and instance type +- region and subnet +- security groups +- SSH user and credentials accepted by the runtime + +`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof +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. + +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 +target runtime rather than assuming local Docker semantics. + +## Probes and cleanup + +Run the same runtime and adapter probes against the exact image before remote +deployment. After deploy, verify the remote container logs; controller health +can be green even when agent loading failed. + +Stop foreground deploy normally so the controller can terminate recorded EC2 +instances. If provisioning or startup fails before an instance is recorded, +inspect the cloud provider directly and remove exact leaked resources. Never use +a broad cleanup command against unrelated instances. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md new file mode 100644 index 0000000..f726bd7 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md @@ -0,0 +1,64 @@ +# LLM proxy integration + +Read this only when the target checkout contains `llm_proxy` or the deployment +explicitly routes model SDKs through it. + +## Preserve provider protocols + +The proxy redirects provider endpoints; it does not convert providers. Keep the +source SDK, model ID, request body, and response parsing unchanged. + +Configure only the provider variables the source uses: + +```dotenv +OPENAI_BASE_URL=http://host.docker.internal:8081/openai/v1 +ANTHROPIC_BASE_URL=http://host.docker.internal:8081/anthropic +AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://host.docker.internal:8081/bedrock +``` + +Some SDKs refuse to initialize without caller credentials. Give agent containers +non-secret placeholders only when required. Keep real OpenAI, Anthropic, or AWS +credentials in the separate proxy process, not in the port's `env_file`. + +## Start locally + +The proxy defaults conflict with a typical deployment: host loopback is not +reachable from a container, and port 8080 is normally used by the workflow API. +Use a non-loopback bind and a different port: + +```bash +PROXY_HOST=0.0.0.0 PROXY_PORT=8081 python -m llm_proxy +curl http://127.0.0.1:8081/healthz +``` + +Local CanyonOS Core containers resolve `host.docker.internal` through their +Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the +machine running `ventis deploy`. Distributed deployments need a reachable proxy +address or one proxy on each host. + +## Supported call shape + +The implementation buffers complete requests and responses: + +- OpenAI and Anthropic non-streaming HTTP calls are forwarded. +- Bedrock `invoke` is reissued through the proxy's boto3 client. +- OpenAI/Anthropic streaming is unsupported. +- Bedrock `invoke-with-response-stream`, `converse`, and `converse-stream` are + unsupported. + +Survey the source before selecting the proxy. Do not silently disable streaming; +report the unsupported behavior and stop. + +## Credential behavior + +- The OpenAI adapter removes caller authorization and inserts the proxy key. +- The Anthropic adapter removes caller key headers and inserts the proxy key. +- Botocore still signs requests sent to a custom endpoint, so a caller may need + placeholder AWS credentials even though the proxy reissues upstream with its + own identity. +- `/healthz` proves provider registration and Flask availability, not upstream + credential validity. + +OpenAI and Anthropic upstream HTTP errors pass through. Proxy exceptions return +JSON 502 with `error: proxy_error`. Bedrock `ClientError` bodies are reconstructed +with the upstream status and are not byte-for-byte passthrough. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md new file mode 100644 index 0000000..8520c4a --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md @@ -0,0 +1,81 @@ +# Packaging and import roots + +Read this reference when an adapter imports nested source code, the source uses a +`src/` layout, or V031 reports an import-root problem. + +## What `/app` can import + +CanyonOS Core preserves project-relative paths in the image and starts Python at +`/app`. Without an editable install, Python resolves names rooted there: + +- `/app/tools.py` as `import tools` +- `/app/pkg/__init__.py` as `import pkg` +- `/app/src/agents/...` as `import src.agents`, including PEP 420 namespace + directories without `__init__.py` + +It does not resolve `/app/source/pkg` as `import pkg`; `/app/source` must become +an import root first. + +## Detect support, do not infer it from release history + +Run: + +```bash +python /validate.py . +``` + +Read the `editable_install` capability. If it is unavailable and the original +import cannot resolve from `/app`, report a runtime capability blocker and stop. +Do not add a `sys.path` hack or relocate source files. + +## Root metadata is the trigger + +When editable install is supported, only packaging metadata at the **port root** +triggers `pip install -e .`: + +```text +port-root/pyproject.toml detected +port-root/source/pyproject.toml ignored as an install trigger +``` + +A nested source repository may remain untouched. Add minimal root scaffolding +that points package discovery at the existing source package: + +```toml +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "canyonos-port" +version = "0.0.0" +dependencies = [] + +[tool.setuptools.packages.find] +where = ["source/src"] +include = ["pkg*"] +namespaces = true +``` + +Set `where` and `include` from the actual tree and original import spelling. Do +not reference a README or license from this wrapper metadata; file sweeps differ +by runtime capability and a missing referenced file makes the image build fail. + +## Dependencies in nested metadata + +A nested `pyproject.toml` is not installed merely because its Python files are +copied. Keep the source declaration unchanged and repeat its runtime +distributions in each relevant config entry's `requirements` list. This is +compatibility scaffolding, not permission to drop, move, or reclassify declared +dependencies. + +If source metadata is already at the port root, do not create a wrapper. Its +project dependencies participate in the same resolver as config requirements. +Report declared-but-unused toolchain dependencies and their image cost; let the +owner decide whether source metadata should change. + +## Validation boundary + +`ventis build` owns packaging syntax and installation errors. `validate.py` +checks only whether adapter imports appear to require a nested root that the +runtime will not expose. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md new file mode 100644 index 0000000..94f7401 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md @@ -0,0 +1,187 @@ +# CanyonOS Core runtime contract + +The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the +CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for +Docker resources. + +Read this reference when implementing an adapter or explaining a validator +finding. Runtime-dependent behavior is expressed as capabilities; run +`validate.py` against the target environment instead of inferring support from +release history. + +## Project root and discovery + +`ventis build` uses the current working directory as the project root. + +| Input | Discovery | +|---|---| +| `agents/*.yaml` | direct yaml glob under the project root | +| `config/global_controller.yaml` | default config, overridable with `-c` | +| workflow | `workflow_file` on a `type: workflow` entry | +| policy | `policy.yaml` beside the selected config file | +| generated files | `stubs/`, `grpc_stubs/`, `docker_container/` | + +The config name, yaml `agent.name`, and entrypoint class name form one binding: + +```text +config entry name == yaml agent.name == entrypoint class name +``` + +A missing match may skip an image while the command continues, so inspect build +output and generated image tags. + +## Agent yaml and generated stubs + +The consumed yaml shape is: + +```yaml +agent: + name: ExampleAgent + functions: + - name: work + arguments: + - name: query + type: str + returns: + type: dict +``` + +Argument annotations are generated from bare names without adding imports. Use +builtins. Generated methods have no defaults, so every declared argument is +required at the stub call site. `returns` does not control runtime conversion; +it documents whether workflow code should parse the returned string. + +Stub destinations differ by runtime capability and entrypoint layout. Workflow +code in this port convention imports the generated class from +`agents.`. The validator checks that import against declarations +before the workflow image starts. + +## Agent loading and execution + +The local controller effectively performs: + +```python +module = load(entrypoint) +agent_class = getattr(module, configured_name) +agent = agent_class() +result = getattr(agent, method_name)(**args) +``` + +Consequences: + +- The class is module-level and named exactly as configured. +- Construction takes no arguments. +- Declared methods accept yaml argument names as keyword arguments. +- Methods are synchronous; this path does not await a coroutine. +- Dicts and lists are JSON-encoded before entering Redis; other results become + strings. +- A remote Future's `.value()` returns text, not the original Python object. + +Agent import and construction exceptions are caught by the controller. A failed +agent may still advertise healthy because health is written independently of +successful agent loading. That is why image probes import both the runtime and +the entrypoint explicitly. + +## Workflow execution + +The workflow file is executed, not imported. Therefore: + +- module-level code runs at container startup; +- `__name__ == "__main__"`; +- `deploy()` blocks in the web server; +- the workflow function runs once per request; +- its function name determines the REST route exposed by the compatibility + runtime. + +The deployment platform additionally expects `/main` with a `{query: string}` +body. This platform constraint is stricter than the underlying transport. + +Each stub method returns a Future immediately. `.value()` blocks. Dispatching +and resolving inside one comprehension serializes work without raising an +error; dispatch all calls first, then resolve them. + +The workflow container also starts runtime controller code and has its own +package resolution. Probe it independently from agent images. + +## Build context and collisions + +The runtime copies project files while preserving relative paths, then writes +shared runtime modules, generated stubs, and entrypoints into the image. Later +writes can shadow project files. + +Avoid root project modules named like runtime files, including: + +```text +future.py +ventis_context.py +local_controller.py +local_controller_frontend.py +redis_client.py +grpc_options.py +bedrock.py +deploy.py +session_logging.py +workflow_launcher.py +``` + +Also avoid a yaml basename that shadows a different source module imported by an +adapter. The validator checks deterministic flat-name collisions. + +File sweep and editable-install behavior are runtime capabilities. For nested +imports, follow [packaging.md](packaging.md). + +## Dependencies and protobuf + +Agent and workflow images include a small runtime dependency set. Config +`requirements` adds source-specific distributions. A malformed requirements +value can be normalized away while image generation continues; missing imports +then surface only when the agent loads. + +The build compiles gRPC Python stubs on the host and copies them into images. +The image resolver does not necessarily know the generated-code version. A +source dependency that constrains protobuf below the host generator version can +produce a green image build that dies on: + +```text +import local_controller +``` + +Always run that probe before probing the entrypoint. Treat a generated-code / +runtime-version mismatch as a CanyonOS Core runtime issue, not a reason to alter +source dependencies silently. + +## Credentials capability + +When `env_file` capability is available, the top-level config path is resolved +against the project root and passed at container start. Hidden env files are not +copied into images. Invalid paths are deploy-preflight errors. + +When the capability is unavailable, declaring `env_file` has no effect. If the +source needs credentials, report the capability blocker rather than hardcoding +or vendoring a secret. + +A source that constructs its client at import time works only when credentials +are already in the container environment. Image entrypoint probes therefore use +the same env file as deployment. + +For proxy-specific credential separation, read [llm-proxy.md](llm-proxy.md). + +## Policy and provider behavior + +No policy file means unrestricted service access. If a policy exists, it needs a +non-empty rules list. Rules are evaluated by specificity and first match; +services excluded from the selected rule fail after request acceptance. + +Local provider handling is case-sensitive: use lowercase `local`. EC2 behavior +and remote networking are covered in [ec2.md](ec2.md). + +## Cleanup boundary + +Stopping foreground deploy normally invokes controller cleanup for recorded +containers and Redis. Hard kills and failures before resource registration may +leave resources behind. + +`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and +`docker_container/`. It does not remove containers or images. Remove exact +leftovers explicitly and preserve source, port scaffolding, and requested +evidence. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md new file mode 100644 index 0000000..361acf9 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md @@ -0,0 +1,60 @@ +# Troubleshooting + +Read this after a failed build, image probe, deploy, or request. For mechanisms, +read [runtime-contract.md](runtime-contract.md). For proxy or remote-host failures, +read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). + +## Build or deploy stops early + +| Symptom | Likely cause | +|---|---| +| Agent image is missing | Config name matched no yaml name, entrypoint is absent, or build skipped it; inspect build warnings | +| Two services produce one image | Config names collide after lowercase normalization | +| `generated grpc_stubs are missing` | Build did not complete on this host, or generated output was cleaned before deploy | +| `int(... NoneType)` during local deploy | Local provider was not written exactly as lowercase `local` | +| Replica conversion `TypeError` | `replicas` is not an integer | +| Policy `AttributeError` | Policy exists but is empty or has null/non-list rules; remove it when unrestricted | +| Port or container name already in use | A previous deployment did not complete cleanup | + +## Container exits or serves nothing + +| Symptom | Likely cause | +|---|---| +| `import local_controller` raises protobuf version error | Host-generated gRPC code is newer than the image's protobuf runtime | +| First request says `No agent loaded` | Entrypoint import, class lookup, or constructor failed and the controller swallowed the exception; inspect container logs | +| Replica is healthy but serves nothing | Health publication does not prove successful agent loading | +| Missing credentials while loading | Env injection is unavailable/misconfigured or the source reads another variable | +| Source module is missing | Original import does not resolve from `/app`; read [packaging.md](packaging.md) | +| Third-party module is missing | Distribution is absent from source metadata and config requirements | +| Stub import raises `NameError` | yaml argument type is not a bare builtin | +| Source module behaves like an empty stub | A generated stub basename shadowed the source module | +| Runtime-named project module disappears | Shared runtime copy overwrote a root project module with the same name | + +## Request is accepted, then fails + +| Symptom | Likely cause | +|---|---| +| Unexpected keyword argument | yaml argument name differs from adapter parameter name | +| Required argument missing | yaml does not declare a required adapter parameter, or workflow platform sent only `query` | +| Unauthorized service | The first matching policy rule excludes that service | +| `.value()` returns dict-like text | Expected; remote values are strings, so use `json.loads` | +| Object is not JSON serializable | Adapter returned framework objects without its JSON-safe serializer | +| Redis contains a coroutine repr | Adapter method is async and the controller did not await it | +| Fan-out is no faster | Dispatch and `.value()` were fused, serializing the calls | +| Debug block runs at startup | Workflow is executed with `__name__ == "__main__"` | + +## Deployment platform endpoint + +| Symptom | Likely cause | +|---|---| +| 404 while workflow container is healthy | Workflow function is not named `main` | +| 400 before host receives request | Body is not the platform's `{query: string}` shape | +| Extra workflow argument is missing | Platform sends only `query`; extra parameters need defaults | + +## Cleanup + +| Symptom | Likely cause | +|---|---| +| `ventis clean` succeeds but containers remain | The command removes generated directories only | +| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | +| Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py b/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py new file mode 100755 index 0000000..04baf37 --- /dev/null +++ b/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py @@ -0,0 +1,1257 @@ +#!/usr/bin/env python3 +"""Preflight the runtime traps that `ventis build` cannot see. + +This deliberately does not duplicate build-time validation such as malformed +YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those +checks. This script parses Python without importing it and catches failures that +otherwise stay hidden until a container loads an agent, starts a workflow, or +serves its first request. A replica is not evidence: the controller writes +`healthy` to Redis before `_load_agent` runs. + + python validate.py [project_dir] [-c config/global_controller.yaml] + [--json] [--strict] + +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 `ventis` package directly. A capability-gated check reports +UNAVAILABLE when its behavior cannot be proven. +""" + +import argparse +import ast +import builtins +import json +import os +import re +import sys +from typing import ClassVar + +try: + import yaml +except ImportError: # pragma: no cover - pyyaml is a CanyonOS Core dependency + sys.stderr.write("validate.py needs pyyaml: pip install pyyaml\n") + raise SystemExit(2) from None + + +DEFAULT_CONFIG_PATH = "config/global_controller.yaml" + +# Copied flat into every image over the swept project tree, so a project module +# landing flat under one of these names is overwritten. +# ventis/stub_generator.py generate_docker / generate_workflow_docker. +RUNTIME_FLAT_NAMES = frozenset( + { + "future.py", + "ventis_context.py", + "local_controller.py", + "local_controller_frontend.py", + "redis_client.py", + "grpc_options.py", + "gpu_metrics.py", + "bedrock.py", + "deploy.py", + "session_logging.py", + "workflow_launcher.py", + } +) + +# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. +BASE_AGENT_REQUIREMENTS = [ + "grpcio", + "grpcio-tools", + "redis", + "pyyaml", + "psutil", + "ipdb", + "ipython", + "boto3", +] +# Import name -> distribution name, for the handful where they differ and the +# mismatch would otherwise be reported as a missing requirement. +IMPORT_TO_DISTRIBUTION = { + "attr": "attrs", + "bs4": "beautifulsoup4", + "cv2": "opencv-python", + "dateutil": "python-dateutil", + "dotenv": "python-dotenv", + "grpc": "grpcio", + "grpc_tools": "grpcio-tools", + "jwt": "pyjwt", + "PIL": "pillow", + "psycopg": "psycopg", + "psycopg2": "psycopg2-binary", + "pydantic_settings": "pydantic-settings", + "sklearn": "scikit-learn", + "typing_extensions": "typing-extensions", + "yaml": "pyyaml", +} + +SECRET_PATTERNS = [ + (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "an OpenAI-style secret key"), + (re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key id"), + (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "a GitHub token"), + (re.compile(r"AIza[0-9A-Za-z_-]{30,}"), "a Google API key"), +] +SECRET_NAME = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) + +ERROR = "ERROR" +WARN = "WARN" +INFO = "INFO" + + +# ------------------------------------------------------------------ # +# Capabilities # +# ------------------------------------------------------------------ # +# +# Stable labels for behavior detected from the importable runtime. They contain +# no external development metadata. + +CAPABILITY_SOURCE = { + "env_file": "runtime env-file injection", + "editable_install": "editable project installation", + "sweeps_all_files": "full project-file sweep", + "stub_two_destinations": "flat and package stub destinations", +} + + +def probe_capabilities(): + """Ask the importable ventis package what it actually supports.""" + caps = dict.fromkeys(CAPABILITY_SOURCE, False) + caps["ventis"] = False + try: + from ventis import stub_generator + except Exception: # noqa: BLE001 - a broken install must not crash the check + return caps + + caps["ventis"] = True + caps["editable_install"] = hasattr(stub_generator, "_install_step") + caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") + caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") + + import importlib + + for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): + try: + module = importlib.import_module(module_name) + except Exception: # noqa: BLE001,S112 - the other path is the live one + continue + if hasattr(module, "resolve_env_file"): + caps["env_file"] = True + break + return caps + + +# ------------------------------------------------------------------ # +# YAML with line numbers # +# ------------------------------------------------------------------ # + + +class LineDict(dict): + """A mapping that remembers where it and each of its keys were written.""" + + line = 0 + key_lines: ClassVar[dict] = {} + + +class LineLoader(yaml.SafeLoader): + pass + + +def _construct_mapping(loader, node): + data = LineDict() + yield data + data.update(loader.construct_mapping(node, deep=False)) + data.line = node.start_mark.line + 1 + data.key_lines = { + key.value: key.start_mark.line + 1 + for key, _ in node.value + if isinstance(key, yaml.ScalarNode) + } + + +LineLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping +) + + +def line_of(mapping, key=None): + """The source line of `key` inside `mapping`, or of the mapping itself.""" + if not isinstance(mapping, LineDict): + return 0 + if key is not None: + return mapping.key_lines.get(key, mapping.line) + return mapping.line + + +def load_yaml(path): + """Parse `path`, returning (data, error). Never raises.""" + try: + with open(path, "r", encoding="utf-8") as handle: + return yaml.load(handle, Loader=LineLoader), None + except Exception as exc: # noqa: BLE001 - any parse failure is a finding + return None, str(exc) + + +# ------------------------------------------------------------------ # +# Findings # +# ------------------------------------------------------------------ # + + +class Report: + def __init__(self, project_dir, capabilities): + self.project_dir = project_dir + self.capabilities = capabilities + self.findings = [] + # A peer agent is imported by the name of its generated stub, which the + # build copies flat into every image. Those are not project modules and + # need no requirement. + self.stub_module_names = set() + + def add(self, check, level, path, line, summary, mechanism): + self.findings.append( + { + "check": check, + "level": level, + "path": self.rel(path) if path else "", + "line": line or 0, + "summary": summary, + "mechanism": mechanism, + } + ) + + def error(self, check, path, line, summary, mechanism): + self.add(check, ERROR, path, line, summary, mechanism) + + def warn(self, check, path, line, summary, mechanism): + self.add(check, WARN, path, line, summary, mechanism) + + def unavailable(self, check, summary): + self.add(check, INFO, "", 0, summary, "") + + def rel(self, path): + try: + return os.path.relpath(path, self.project_dir) + except ValueError: + return path + + def counts(self): + errors = sum(1 for f in self.findings if f["level"] == ERROR) + warnings = sum(1 for f in self.findings if f["level"] == WARN) + return errors, warnings + + +# ------------------------------------------------------------------ # +# Python source helpers # +# ------------------------------------------------------------------ # + + +def parse_python(path): + """AST for `path`, or (None, error). The port is never imported.""" + try: + with open(path, "r", encoding="utf-8") as handle: + source = handle.read() + except OSError as exc: + return None, str(exc) + try: + return ast.parse(source, filename=path), None + except SyntaxError as exc: + return None, f"{exc.msg} (line {exc.lineno})" + + +def find_class(tree, name): + for node in tree.body: + if isinstance(node, ast.ClassDef) and node.name == name: + return node + return None + + +def class_methods(class_node): + return { + node.name: node + for node in class_node.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def parameter_names(func_node): + """Every parameter a caller can pass by keyword, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + return positional + [a.arg for a in args.kwonlyargs] + + +def required_parameters(func_node): + """Parameters with no default, minus self.""" + args = func_node.args + positional = [a.arg for a in args.posonlyargs + args.args] + if positional and positional[0] in ("self", "cls"): + positional = positional[1:] + if args.defaults: + positional = positional[: len(positional) - len(args.defaults)] + kwonly = [ + arg.arg + for arg, default in zip(args.kwonlyargs, args.kw_defaults) + if default is None + ] + return positional + kwonly + + +def toplevel_import_names(tree): + """Top-level package name of every import in the module, with line numbers.""" + names = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + names.setdefault(alias.name.split(".")[0], node.lineno) + elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: + names.setdefault(node.module.split(".")[0], node.lineno) + return names + + +# ------------------------------------------------------------------ # +# V006-V010 adapter failures hidden by _load_agent # +# ------------------------------------------------------------------ # + +BUILTIN_TYPE_NAMES = frozenset( + name + for name in dir(builtins) + if isinstance(getattr(builtins, name), type) and not name[0].isupper() +) + + +def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): + """V006 V007 V008 V009 V010.""" + name = agent_block["name"] + functions = agent_block.get("functions") or [] + check_argument_types(report, agent_yaml_path, functions) + + entrypoint = entry.get("entrypoint") + if not entrypoint: + return + entrypoint_path = os.path.join(project_dir, entrypoint) + if not os.path.isfile(entrypoint_path): + return + + tree, error = parse_python(entrypoint_path) + if tree is None: + report.error( + "V006", + entrypoint_path, + 0, + f"the entrypoint does not parse: {error}", + "_load_agent exec_module's it and swallows the exception; the first " + "request answers 'No agent loaded'.", + ) + return + + class_node = find_class(tree, name) + if class_node is None: + classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] + found = ", ".join(classes) if classes else "no classes at all" + report.error( + "V006", + entrypoint_path, + 1, + f"no class named `{name}` at module level (found: {found})", + "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " + "the AttributeError. The class name must equal agent.name exactly.", + ) + return + + methods = class_methods(class_node) + check_constructor(report, entrypoint_path, name, methods) + + for func in functions: + if not isinstance(func, dict) or not isinstance(func.get("name"), str): + continue + check_method(report, entrypoint_path, agent_yaml_path, name, func, methods) + + +def check_argument_types(report, agent_yaml_path, functions): + """V010 -- the type string is pasted into an ast.Name, never checked.""" + for func in functions: + if not isinstance(func, dict): + continue + for arg in func.get("arguments") or []: + if not isinstance(arg, dict) or "type" not in arg: + continue + declared = arg.get("type") + if not isinstance(declared, str): + continue # stub generation reports malformed type values + if declared in BUILTIN_TYPE_NAMES: + continue + report.error( + "V010", + agent_yaml_path, + line_of(arg, "type"), + f"`type: {declared}` is not a builtin", + "stub_generator pastes it verbatim into the generated " + "annotation, and the stub module imports only Future and " + "inspect. Anything else raises NameError when the stub is " + "imported -- after a green build. Use str int float bool dict " + "list.", + ) + + +def check_constructor(report, entrypoint_path, name, methods): + """V007 -- _load_agent calls agent_class() with no arguments.""" + init = methods.get("__init__") + if init is None: + return + required = required_parameters(init) + if required: + report.error( + "V007", + entrypoint_path, + init.lineno, + f"`{name}.__init__` requires {', '.join(required)}", + "_load_agent calls agent_class() with no arguments; the TypeError is " + "swallowed and the first request answers 'No agent loaded'. Read " + "configuration from the environment inside __init__ instead.", + ) + + +def check_method(report, entrypoint_path, agent_yaml_path, class_name, func, methods): + """V008 V009.""" + func_name = func["name"] + method = methods.get(func_name) + if method is None: + report.error( + "V008", + entrypoint_path, + 0, + f"`{class_name}` has no method `{func_name}`", + "The yaml declares it, so callers get a stub for it; the controller " + f"then answers \"Agent {class_name} has no method '{func_name}'\".", + ) + return + + # V009 -- nothing on the execution path awaits. + if isinstance(method, ast.AsyncFunctionDef): + report.error( + "V009", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` is `async def`", + "The executor calls method(**args) with no await, so Redis receives " + "''. Keep the signature synchronous and call " + "asyncio.run(...) inside the body.", + ) + + # V008 -- the controller calls method(**args) with the yaml's names. + declared = [ + arg["name"] + for arg in func.get("arguments") or [] + if isinstance(arg, dict) and isinstance(arg.get("name"), str) + ] + actual = parameter_names(method) + required = required_parameters(method) + + missing = [arg for arg in declared if arg not in actual] + if missing: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` has no parameter " + f"{', '.join(repr(m) for m in missing)}, but the yaml declares it", + "LocalController does method(**args) with the yaml's argument names. " + "A mismatch is TypeError: unexpected keyword argument, at request " + f"time. See {os.path.basename(agent_yaml_path)}.", + ) + + unfilled = [arg for arg in required if arg not in declared] + if unfilled: + report.error( + "V008", + entrypoint_path, + method.lineno, + f"`{class_name}.{func_name}` requires {', '.join(unfilled)}, which " + "the yaml does not declare", + "Only declared arguments are ever sent, and the generated stub gives " + "none of them a default. Declare them in the yaml or default them " + "in the signature.", + ) + + + +# ------------------------------------------------------------------ # +# V016-V018 the workflow # +# ------------------------------------------------------------------ # + + +def check_stub_imports(report, workflow_path, tree, stub_classes): + """V023 -- the workflow must import a stub as `from agents. import `. + + The build copies each stub to exactly one path, and for the workflow image + that path is agents/.py. Two ways of writing this line fail, and + the project walks you into both: the flat form is what examples/ uses, and + the class name is the one `ventis build` prints, which is not the one it + writes. + """ + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + base = alias.name.split(".")[0] + if base in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`import {alias.name}` -- the stub is at " + f"agents/{base}.py, not flat", + "The build copies a stub to one path, and for the " + "workflow that path is under agents/. This is a " + "ModuleNotFoundError the moment the workflow runs. " + f"Write `from agents.{base} import {stub_classes[base]}`.", + ) + continue + + if not isinstance(node, ast.ImportFrom) or not node.module: + continue + + module = node.module + if module in stub_classes: + report.error( + "V023", workflow_path, node.lineno, + f"`from {module} import ...` -- the stub is at " + f"agents/{module}.py, not flat", + "The build copies a stub to one path, and for the workflow " + "that path is under agents/. The flat form is what this " + "repository's own examples use and it raises " + "ModuleNotFoundError in the workflow image. Write " + f"`from agents.{module} import {stub_classes[module]}`.", + ) + continue + + if not module.startswith("agents."): + continue + base = module.split(".", 1)[1] + expected = stub_classes.get(base) + if expected is None: + continue + for alias in node.names: + if alias.name == expected: + continue + if alias.name == f"{expected}Stub": + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is the name the build prints, not the " + f"class it writes", + "generate_agent_stub sets class_name = agent_config['name'] " + "and then recomputes it with a 'Stub' suffix for the log " + "line only. The message names a class that does not exist; " + f"the class is `{expected}`.", + ) + else: + report.error( + "V023", workflow_path, node.lineno, + f"`{alias.name}` is not a class the stub for {base} defines", + f"The stub's class carries the agent's own name: `{expected}`.", + ) + + +def check_workflow(report, workflow_path, stub_classes=None): + """V016 V017 V018 V023.""" + tree, error = parse_python(workflow_path) + if tree is None: + report.error("V016", workflow_path, 0, f"does not parse: {error}", "") + return + + main = None + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ( + node.name == "main" + ): + main = node + break + + if main is None: + defined = [ + n.name + for n in tree.body + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + found = ", ".join(defined) if defined else "no top-level functions" + report.error( + "V016", + workflow_path, + 1, + f"no top-level function named `main` (found: {found})", + "CanyonOS Core serves POST /, but the deployment platform's " + "test endpoint posts to a hardcoded /main. A differently named " + "workflow builds, deploys and stays unreachable -- 404, container " + "healthy.", + ) + else: + check_main_signature(report, workflow_path, main) + + if stub_classes: + check_stub_imports(report, workflow_path, tree, stub_classes) + + # V016 -- deploy() is what starts Flask. + if not any( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "deploy" + for node in ast.walk(tree) + ): + report.error( + "V016", + workflow_path, + 1, + "the workflow never calls `deploy(...)`", + "workflow_launcher.py exec's this file and nothing else starts the " + "HTTP server; the container comes up serving nothing.", + ) + + check_main_guard(report, workflow_path, tree) + check_fused_fanout(report, workflow_path, tree) + + +def check_main_signature(report, workflow_path, main): + """V016 -- the platform sends exactly {"query": ...}.""" + if isinstance(main, ast.AsyncFunctionDef): + report.error( + "V016", + workflow_path, + main.lineno, + "`main` is `async def`", + "deploy() calls workflow_fn(**kwargs) on a Flask worker thread with " + "no await; the response body would be a coroutine repr.", + ) + params = parameter_names(main) + if not params: + report.error( + "V016", + workflow_path, + main.lineno, + "`main` takes no arguments", + 'The platform posts {"query": "..."} and deploy() splats the ' + "body in as kwargs -- TypeError on every request.", + ) + return + if params[0] != "query": + report.error( + "V016", + workflow_path, + main.lineno, + f"`main`'s first parameter is `{params[0]}`, not `query`", + "The platform's body schema is strictly validated as " + "{query: string}; any other key is rejected with 400 in the control " + "plane, before the request reaches the host.", + ) + extra = [p for p in required_parameters(main) if p != "query"] + if extra: + report.error( + "V016", + workflow_path, + main.lineno, + f"`main` requires {', '.join(extra)} beyond `query`", + "Only `query` is ever sent, so every other parameter needs a " + "default or the call raises on every request. Pack richer input " + "into `query`.", + ) + + +def check_main_guard(report, workflow_path, tree): + """V017 -- the workflow is exec'd, so __name__ == "__main__".""" + for node in tree.body: + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "__name__" + and any( + isinstance(c, ast.Constant) and c.value == "__main__" + for c in test.comparators + ) + ): + report.error( + "V017", + workflow_path, + node.lineno, + '`if __name__ == "__main__":` block in the workflow', + "workflow_launcher.py runs exec(open().read()), so " + "__name__ IS '__main__' here and this block executes in " + "production, at container start.", + ) + + +def check_fused_fanout(report, workflow_path, tree): + """V018 -- .value() blocks, so dispatching and resolving in one + comprehension runs the fan-out one call at a time.""" + comprehensions = (ast.ListComp, ast.SetComp, ast.GeneratorExp) + for node in ast.walk(tree): + if not isinstance(node, comprehensions + (ast.DictComp,)): + continue + elements = ( + [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] + ) + for element in elements: + for inner in ast.walk(element): + if ( + isinstance(inner, ast.Call) + and isinstance(inner.func, ast.Attribute) + and inner.func.attr == "value" + and isinstance(inner.func.value, ast.Call) + ): + report.error( + "V018", + workflow_path, + node.lineno, + "one comprehension both dispatches a call and resolves " + "it with .value()", + ".value() blocks, so each call completes before the " + "next is dispatched. It does not error -- the fan-out " + "is just silently serial, and with it the reason to be " + "on CanyonOS Core. Dispatch every call first, then resolve: " + "futures = [a.work(i) for i in items] then " + "[f.value() for f in futures].", + ) + return + + +# ------------------------------------------------------------------ # +# V019-V020 what the copy order overwrites # +# ------------------------------------------------------------------ # + + +def check_flat_collisions(report, project_dir, yaml_paths, entrypoints): + """V019 V020 -- later copies land on earlier ones at the context root.""" + for entry in sorted(os.listdir(project_dir)): + path = os.path.join(project_dir, entry) + if not os.path.isfile(path) or not entry.endswith(".py"): + continue + + # V019 -- the shared runtime is copied flat, after the project sweep. + if entry in RUNTIME_FLAT_NAMES: + report.error( + "V019", + path, + 1, + f"a project module named `{entry}` sits at the project root", + "The shared CanyonOS Core runtime is copied flat into the image after " + "the project sweep, so this file is overwritten by CanyonOS Core's own " + f"{entry}. Rename it or move it into a package directory.", + ) + + # V020 -- a stub is copied flat under its yaml's basename. + entrypoint_basenames = {os.path.basename(e) for e in entrypoints if e} + for yaml_path in yaml_paths: + stem = os.path.splitext(os.path.basename(yaml_path))[0] + module = f"{stem}.py" + if module in entrypoint_basenames: + continue # the entrypoint is copied last and wins its flat name back + candidate = os.path.join(project_dir, module) + if os.path.isfile(candidate): + report.error( + "V020", + candidate, + 1, + f"`{os.path.basename(yaml_path)}` generates a stub that lands on " + f"`{module}`", + "The yaml's basename names the stub, and the stub is copied flat " + "over the swept tree. Anything importing this module inside the " + "container gets the generated stub instead of the real code. " + "Rename the yaml to match its own entrypoint.", + ) + + +# ------------------------------------------------------------------ # +# V030-V031 capability-gated rules # +# ------------------------------------------------------------------ # + + +def check_env_file(report, config, config_path, project_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 ventis 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 `ventis` runtime. " + "Credentials have no declared path into a container on this tree.", + ) + return + + if not declared: + report.warn( + "V030", + config_path, + line_of(config), + "no `env_file:` in the config", + "Only runtime-managed VENTIS_* variables are guaranteed without it. " + "If the source reads credentials from the environment, the first " + "request fails on a provider error.", + ) + return + + # Path existence and readability are deploy-preflight checks. Do not + # duplicate them here. + + +def check_import_root(report, project_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(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + + non_flat = [] + for path in entrypoint_paths: + tree, _ = parse_python(path) + if tree is None: + continue + for name, lineno in toplevel_import_names(tree).items(): + if name in report.stub_module_names or f"{name}.py" in RUNTIME_FLAT_NAMES: + continue + if _resolves_flat(project_dir, name): + continue + location = _resolves_nested(project_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 " + "`ventis` runtime. Only names rooted at /app import inside a container.", + ) + for path, lineno, name, location in non_flat: + report.error( + "V031", + path, + lineno, + f"`import {name}` resolves to {location}, which is not at the " + "project root", + "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 project root " + "has no packaging metadata", + "A pyproject.toml, setup.py or setup.cfg at the port root is " + "what adds `-e .`; metadata nested inside the untouched source " + "tree is ignored. Add minimal root metadata pointing at the " + "existing package directory. Without it the install is skipped " + "silently.", + ) + + +def _resolves_flat(project_dir, name): + """Whether Python can resolve `name` with /app as its import root. + + A directory does not need __init__.py: PEP 420 namespace packages resolve + from sys.path just like regular packages. + """ + return os.path.isfile(os.path.join(project_dir, f"{name}.py")) or os.path.isdir( + os.path.join(project_dir, name) + ) + + +def _resolves_nested(project_dir, name): + """Where below /app `name` lives but cannot resolve as a top-level name.""" + for root, dirs, files in os.walk(project_dir): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if root == project_dir: + continue + if f"{name}.py" in files: + return os.path.relpath(os.path.join(root, f"{name}.py"), project_dir) + if name in dirs: + return os.path.relpath(os.path.join(root, name), project_dir) + return None + + +# ------------------------------------------------------------------ # +# W003, W006 secrets and imports a green build does not reject # +# ------------------------------------------------------------------ # + + +def check_secrets(report, port_paths): + """W003 -- env_file is the way in; nothing else is.""" + for path in port_paths: + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except OSError: + continue + for number, line in enumerate(lines, start=1): + for pattern, description in SECRET_PATTERNS: + if pattern.search(line): + report.warn( + "W003", + path, + number, + f"this line looks like {description}", + "Never put a secret in the source tree or the build " + "context. The build sweeps the project into every " + "image.", + ) + break + + tree, _ = parse_python(path) + if tree is None: + continue + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + if not ( + isinstance(node.value, ast.Constant) + and isinstance(node.value.value, str) + and node.value.value.strip() + ): + continue + for target in node.targets: + if isinstance(target, ast.Name) and SECRET_NAME.search(target.id): + report.warn( + "W003", + path, + node.lineno, + f"`{target.id}` is assigned a literal string", + "Read it from the environment instead; the build sweeps " + "this file into every image.", + ) + + +def _pyproject_dependencies(project_dir): + """What `-e .` installs alongside `requirements:`, or None if unreadable. + + None and the empty set mean different things here: empty means the project + declares no dependencies, None means we could not find out -- a setup.py, or + a tomllib this interpreter does not have. The caller must not treat the + second as the first, or it warns about imports the install would satisfy. + """ + path = os.path.join(project_dir, "pyproject.toml") + if not os.path.isfile(path): + return None + try: + import tomllib + except ImportError: # < 3.11 + return None + try: + with open(path, "rb") as handle: + data = tomllib.load(handle) + except Exception: # noqa: BLE001 - malformed metadata is uv's error to give + return None + deps = (data.get("project") or {}).get("dependencies") + if not isinstance(deps, list): + return None + return {_normalize_distribution(d) for d in deps if isinstance(d, str)} + + +def check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path +): + """W006 -- an import the container cannot satisfy.""" + tree, _ = parse_python(entrypoint_path) + if tree is None: + return + + declared = { + _normalize_distribution(item) + for item in (entry.get("requirements") or []) + if isinstance(item, str) + } + + # Where the editable install exists, `-e .` resolves the project's own + # [project.dependencies] in the same pass as `requirements:`. Warning about + # those is a false positive, and a false warning about a dependency is worse + # than none: it teaches the reader to dismiss this check. + editable = report.capabilities.get("editable_install") + metadata = any( + os.path.isfile(os.path.join(project_dir, name)) + for name in ("pyproject.toml", "setup.py", "setup.cfg") + ) + unreadable_metadata = False + if editable and metadata: + project_deps = _pyproject_dependencies(project_dir) + if project_deps is None: + unreadable_metadata = True + else: + declared |= project_deps + base = {_normalize_distribution(item) for item in BASE_AGENT_REQUIREMENTS} + stdlib = getattr(sys, "stdlib_module_names", frozenset()) + + for name, lineno in sorted(toplevel_import_names(tree).items()): + if name in stdlib or name == "ventis": + continue + # Provided by the image itself: the shared runtime is copied flat, and + # every agents/*.yaml generates a stub that is copied flat too. + if f"{name}.py" in RUNTIME_FLAT_NAMES or name in report.stub_module_names: + continue + if _resolves_flat(project_dir, name) or _resolves_nested(project_dir, name): + continue + distribution = _normalize_distribution(IMPORT_TO_DISTRIBUTION.get(name, name)) + if distribution in base or distribution in declared: + continue + if unreadable_metadata: + mechanism = ( + "The container installs the base list, `requirements:`, and -- " + "since this project declares packaging metadata -- whatever " + "`-e .` resolves from it. That metadata could not be read here, " + f"so if it already requires `{name}` this line is noise; " + "otherwise it is a ModuleNotFoundError inside _load_agent and " + "'No agent loaded' on the first request." + ) + else: + mechanism = ( + "The container installs the base list plus `requirements:` and " + "nothing else, so this is a ModuleNotFoundError inside " + "_load_agent and 'No agent loaded' on the first request. If the " + f"distribution is named something other than `{name}`, declare " + f"that name in {report.rel(config_path)}." + ) + report.warn( + "W006", + entrypoint_path, + lineno, + f"`import {name}` is in neither the runtime's base list nor this " + "entry's `requirements:`", + mechanism, + ) + + +def _normalize_distribution(name): + return re.split(r"[<>=!\[;\s]", name.strip().lower(), maxsplit=1)[0].replace( + "_", "-" + ) + + +# ------------------------------------------------------------------ # +# Driver # +# ------------------------------------------------------------------ # + + +def validate(project_dir, config_path, capabilities): + """Inspect only failures hidden behind a successful image build.""" + report = Report(project_dir, capabilities) + + # The build owns config/YAML syntax and shape validation. We read only enough + # valid structure to locate code for the deeper checks below. + config, error = load_yaml(config_path) + if error is not None or not isinstance(config, dict): + report.unavailable( + "BUILD", + "runtime preflight skipped because the config cannot be read; " + "ventis build owns and reports this error.", + ) + return report + + import glob + + yaml_paths = sorted(glob.glob(os.path.join(project_dir, "agents", "*.yaml"))) + report.stub_module_names = { + os.path.splitext(os.path.basename(path))[0] for path in yaml_paths + } + + agents_by_name = {} + stub_classes = {} + for path in yaml_paths: + data, yaml_error = load_yaml(path) + agent = data.get("agent") if isinstance(data, dict) else None + name = agent.get("name") if isinstance(agent, dict) else None + if yaml_error is not None or not isinstance(name, str): + continue # ventis build reports malformed agent declarations + agents_by_name[name] = (path, agent) + stub_classes[os.path.splitext(os.path.basename(path))[0]] = name + + entries = config.get("agents") + if not isinstance(entries, list): + report.unavailable( + "BUILD", + "runtime preflight skipped because `agents:` is not a list; " + "ventis build owns and reports this error.", + ) + return report + + entrypoints = [] + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": + continue + name = entry.get("name") + entrypoint = entry.get("entrypoint") + if isinstance(entrypoint, str): + entrypoints.append(entrypoint) + if name in agents_by_name: + yaml_path, agent_block = agents_by_name[name] + check_adapter(report, yaml_path, agent_block, entry, project_dir) + entrypoint_path = os.path.join(project_dir, entrypoint or "") + if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): + check_requirements_coverage( + report, project_dir, entry, entrypoint_path, config_path + ) + + for entry in entries: + if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": + continue + workflow_file = entry.get("workflow_file") + if not isinstance(workflow_file, str): + continue + workflow_path = os.path.join(project_dir, workflow_file) + if os.path.isfile(workflow_path): + check_workflow(report, workflow_path, stub_classes) + + # These survive a green build and otherwise surface only in a container or + # on its first request. + check_flat_collisions(report, project_dir, yaml_paths, entrypoints) + check_env_file(report, config, config_path, project_dir) + + entrypoint_paths = [ + os.path.join(project_dir, e) + for e in entrypoints + if os.path.isfile(os.path.join(project_dir, e)) + ] + check_import_root(report, project_dir, entrypoint_paths) + + port_paths = list(entrypoint_paths) + for entry in entries: + if isinstance(entry, dict) and isinstance(entry.get("workflow_file"), str): + candidate = os.path.join(project_dir, entry["workflow_file"]) + if os.path.isfile(candidate): + port_paths.append(candidate) + + # Secret detection remains because a green image build would permanently + # bake the credential into every image. + check_secrets(report, port_paths) + return report + + + +# ------------------------------------------------------------------ # +# Output # +# ------------------------------------------------------------------ # + +LEVEL_ORDER = {ERROR: 0, WARN: 1, INFO: 2} + + +def _wrap(text, width, indent): + words = text.split() + lines = [] + current = "" + for word in words: + candidate = f"{current} {word}".strip() + if len(candidate) + len(indent) > width and current: + lines.append(indent + current) + current = word + else: + current = candidate + if current: + lines.append(indent + current) + return lines + + +def print_report(report, project_dir): + caps = report.capabilities + if not caps.get("ventis"): + print("ventis is not importable here -- capability-gated rules are") + print("reported UNAVAILABLE rather than checked.\n") + else: + print("CanyonOS Core capabilities detected:") + for key, source in CAPABILITY_SOURCE.items(): + mark = "yes" if caps.get(key) else "no " + print(f" {mark} {key:<22} {source}") + print() + + findings = sorted( + report.findings, + key=lambda f: (LEVEL_ORDER[f["level"]], f["check"], f["path"], f["line"]), + ) + for finding in findings: + where = finding["path"] + if where and finding["line"]: + where = f"{where}:{finding['line']}" + header = f"{finding['check']} {finding['level']:<5}" + print(f"{header} {where}" if where else header) + for line in _wrap(finding["summary"], 78, " "): + print(line) + if finding["mechanism"]: + for line in _wrap(finding["mechanism"], 78, " "): + print(line) + print() + + errors, warnings = report.counts() + if not findings: + print(f"{project_dir}: clean.") + return + print(f"{errors} error(s), {warnings} warning(s).") + + +def main(argv=None): + parser = argparse.ArgumentParser( + description="Check a CanyonOS Core port against the rules in SKILL.md." + ) + parser.add_argument( + "project_dir", nargs="?", default=".", help="the port's project root" + ) + parser.add_argument( + "-c", + "--config", + default=DEFAULT_CONFIG_PATH, + help=f"config path relative to project_dir (default: {DEFAULT_CONFIG_PATH})", + ) + parser.add_argument("--json", action="store_true", help="emit findings as JSON") + parser.add_argument( + "--strict", action="store_true", help="fail on warnings as well as errors" + ) + args = parser.parse_args(argv) + + project_dir = os.path.abspath(args.project_dir) + config_path = ( + args.config + if os.path.isabs(args.config) + else os.path.join(project_dir, args.config) + ) + + capabilities = probe_capabilities() + report = validate(project_dir, config_path, capabilities) + errors, warnings = report.counts() + + if args.json: + print( + json.dumps( + { + "project_dir": project_dir, + "capabilities": capabilities, + "errors": errors, + "warnings": warnings, + "findings": report.findings, + }, + indent=2, + ) + ) + else: + print_report(report, report.rel(project_dir) or project_dir) + + if errors or (args.strict and warnings): + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) 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/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/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 From 7cbd976185527e46019532766cc53deca51c7e79 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 12:24:43 -0700 Subject: [PATCH 26/44] removed some useless code --- SESSION_NOTES.md | 79 -- examples/epigenomics/README.md | 70 - examples/epigenomics/agents/dedup_agent.py | 44 - examples/epigenomics/agents/dedup_agent.yaml | 10 - examples/epigenomics/agents/filter_agent.py | 32 - examples/epigenomics/agents/filter_agent.yaml | 12 - examples/epigenomics/agents/index_agent.py | 30 - examples/epigenomics/agents/index_agent.yaml | 12 - examples/epigenomics/agents/map_agent.py | 33 - examples/epigenomics/agents/map_agent.yaml | 14 - examples/epigenomics/agents/sort_agent.py | 32 - examples/epigenomics/agents/sort_agent.yaml | 14 - examples/epigenomics/agents/split_agent.py | 27 - examples/epigenomics/agents/split_agent.yaml | 12 - .../epigenomics/config/global_controller.yaml | 76 - examples/epigenomics/config/policy.yaml | 20 - .../workflow/epigenomics_workflow.py | 97 -- examples/joke_writer/.car/app/.env.example | 20 - examples/joke_writer/.car/app/LICENSE | 21 - examples/joke_writer/.car/app/README.md | 177 --- .../joke_writer/.car/app/joke_workflow.py | 39 - examples/joke_writer/.car/app/joke_writer.py | 164 --- .../.car/config/global_controller.yaml | 52 - .../joke_writer/.car/config/joke_agent.yaml | 35 - examples/joke_writer/.env.example | 20 - examples/joke_writer/LICENSE | 21 - examples/joke_writer/README.md | 177 --- examples/joke_writer/joke_writer.py | 151 -- .../skills/porting-to-canyonos-core/SKILL.md | 240 ---- .../references/ec2.md | 41 - .../references/llm-proxy.md | 64 - .../references/packaging.md | 81 -- .../references/runtime-contract.md | 187 --- .../references/troubleshooting.md | 60 - .../porting-to-canyonos-core/validate.py | 1257 ----------------- 35 files changed, 3421 deletions(-) delete mode 100644 SESSION_NOTES.md delete mode 100644 examples/epigenomics/README.md delete mode 100644 examples/epigenomics/agents/dedup_agent.py delete mode 100644 examples/epigenomics/agents/dedup_agent.yaml delete mode 100644 examples/epigenomics/agents/filter_agent.py delete mode 100644 examples/epigenomics/agents/filter_agent.yaml delete mode 100644 examples/epigenomics/agents/index_agent.py delete mode 100644 examples/epigenomics/agents/index_agent.yaml delete mode 100644 examples/epigenomics/agents/map_agent.py delete mode 100644 examples/epigenomics/agents/map_agent.yaml delete mode 100644 examples/epigenomics/agents/sort_agent.py delete mode 100644 examples/epigenomics/agents/sort_agent.yaml delete mode 100644 examples/epigenomics/agents/split_agent.py delete mode 100644 examples/epigenomics/agents/split_agent.yaml delete mode 100644 examples/epigenomics/config/global_controller.yaml delete mode 100644 examples/epigenomics/config/policy.yaml delete mode 100644 examples/epigenomics/workflow/epigenomics_workflow.py delete mode 100644 examples/joke_writer/.car/app/.env.example delete mode 100644 examples/joke_writer/.car/app/LICENSE delete mode 100644 examples/joke_writer/.car/app/README.md delete mode 100644 examples/joke_writer/.car/app/joke_workflow.py delete mode 100644 examples/joke_writer/.car/app/joke_writer.py delete mode 100644 examples/joke_writer/.car/config/global_controller.yaml delete mode 100644 examples/joke_writer/.car/config/joke_agent.yaml delete mode 100644 examples/joke_writer/.env.example delete mode 100644 examples/joke_writer/LICENSE delete mode 100644 examples/joke_writer/README.md delete mode 100644 examples/joke_writer/joke_writer.py delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md delete mode 100644 examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md delete mode 100755 examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md deleted file mode 100644 index 360efcc..0000000 --- a/SESSION_NOTES.md +++ /dev/null @@ -1,79 +0,0 @@ -# Session notes: canyonos serve, OTel pipeline, examples - -## Fixed (code changes, rebuilt+pushed `saakeths/canyonos:latest` where needed) - -1. **`ventis/stub_generator.py`**: stubs only ever got placed at ONE path - (nested-at-entrypoint, never flat), breaking any workflow that imports a - sibling agent directly (`from split_agent import SplitAgent`, e.g. - `examples/epigenomics`). Now placed at both. -2. **`otel_exporter.py`**: hardcoded Redis to `localhost`, but it runs inside - the GC container (bridge networking) while Redis is a sibling container — - crash-looped forever. Fixed to `host.docker.internal`. -3. **`ventis/OTLP_Exporter/db.py`**: `write_waiting_rows()` unconditionally - priced every future via a `aws_instance_pricing` table that only exists for - EC2 deployments — silently dropped **every** span for **every** - `provider: local` deployment, always. Wrapped cost lookups in try/except, - falls back to $0. -4. **`dashboard_stack.py`**: `web` port was hardcoded 8080 with no fallback. - Now searches for a free port (reuses an already-running dashboard's port - if one exists), same pattern as `init.py`'s GC port selection. -5. **`dashboard_stack.py`**: `database.url` was required; made optional - (dashboard boots fine with no DB configured in the project's own config). -6. **`dashboard.compose.yml`**: added `pull_policy: never` for the local-only - `canyonos-otel-receiver:local` image (compose was trying to pull it from a - registry that doesn't have it). Sequenced `otel-receiver` to start after - `api` (both raced to create `otel_spans`; `api`'s bare `CREATE TABLE` lost - the race and crashed). - -## Bundled (new, working) - -- `db` (plain Postgres) + `otel-receiver` (new `Dockerfile` for - `otlp_pg_receiver`, built locally as `canyonos-otel-receiver:local`) added - to `dashboard.compose.yml`. Verified real spans, sent via the actual OTLP - gRPC exporter, land in `otel_spans`. - -## Workarounds applied, NOT real fixes (will resurface) - -- **`canyonos quit` only tears down the GC container + volume**, never the - deployed agent/workflow/redis containers. Had to `docker rm -f` those by - exact name every time before a truly clean restart. -- **The named workspace volume is additive-only** (`docker cp`, never - clears) — files from a previous project leak into the next one's build - until you manually nuke the volume. -- **`otlp_pg_receiver` holds one Postgres connection with no reconnect - logic** — a DB restart silently kills every future write until the - receiver container itself is restarted. -- **`joke_writer/.car` layout** (`app/`, `config/`) doesn't match what - `ventis/cli.py`'s build step expects (`agents/`, flat entrypoints) — worked - around by manually copying files into the shape it wants. The real fix - (`nickhuo/car-artifact-layout`, already pushed) was not merged in. -- **joke_writer's LLM calls are stubbed to return `"animal"`** — for testing - only, real Bedrock creds needed to restore actual behavior (commented-out - code left in place). - -## Known, not touched - -- Pre-existing OrbStack local-provider startup race (first request right - after a container reports healthy can fail); a fix exists on an unrelated, - unmerged branch. -- Stale global `uv tool install` is a recurring trap — always - `uv tool install --reinstall .` after any `cli/` change. - -## What's still needed to actually see data in the UI - -The whole pipeline up to Postgres now genuinely works. **Nothing shows up in -the dashboard because `canyonos-api`/`canyonos-web` have zero code that reads -or displays `otel_spans`** — confirmed by inspecting their actual source -(they're a `cc-forge` rebrand: deploy/project management + a static -code-structure diagram, unrelated data model). To close the loop: - -1. New API route(s) in `canyon-code-forge/packages/api` that query - `otel_spans`. -2. New UI screen(s) in `canyon-code-forge/apps/web` to render it. -3. `web` currently has **no path to reach `api` at all** even once that - exists — no reverse proxy in its Caddyfile, and `api`'s port isn't - published to the host in `dashboard.compose.yml`. Needs one or the other - before the browser can fetch anything. - -All of the above is real feature work in a different repo, not a config or -wiring fix. diff --git a/examples/epigenomics/README.md b/examples/epigenomics/README.md deleted file mode 100644 index 19fb748..0000000 --- a/examples/epigenomics/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Epigenomics Example - -A synthetic, LLM-free workflow modeled on the -[WfCommons Epigenomics recipe](https://docs.wfcommons.org/en/latest/generating_workflows.html): -a split → fan-out (filter → align → sort) → fan-in (dedup) → index pipeline. -Every stage does deterministic SHA-256 work sized off chunk byte counts, so -results are reproducible and the fan-out width scales with `num_chunks` -- -useful for exercising scheduling/replica behavior locally without any real -model calls. - -## Pipeline - -``` -SplitAgent --> FilterAgent --> MapAgent --> SortAgent --\ - (1 call) (fan-out) (fan-out) (fan-out) --> DedupAgent --> IndexAgent - (fan-in barrier) (1 call) -``` - -- **SplitAgent** — splits `input_size` bytes into `num_chunks` equal chunks. -- **FilterAgent** — per-chunk contaminant filter (light cost). -- **MapAgent** — per-chunk alignment, the heaviest stage. -- **SortAgent** — per-chunk sort (moderate cost). -- **DedupAgent** — merges every sorted chunk into one digest (the barrier). -- **IndexAgent** — builds the final index from the merged digest. - -## Quick Start - -```bash -# Build stubs and Docker images -ventis build - -# Launch all agents -ventis deploy - -# Test with curl -curl -X POST http://:8080/main \ - -H 'Content-Type: application/json' \ - -d '{"input_size": 65536, "num_chunks": 4}' - -# Check result -curl http://:8080/status/ -``` - -## Project Structure - -``` -├── agents/ # Agent implementations and YAML definitions -│ ├── split_agent.py/.yaml -│ ├── filter_agent.py/.yaml -│ ├── map_agent.py/.yaml -│ ├── sort_agent.py/.yaml -│ ├── dedup_agent.py/.yaml -│ └── index_agent.py/.yaml -├── workflow/ # Workflow script (deployed as a REST API) -│ └── epigenomics_workflow.py -└── config/ - ├── global_controller.yaml # Deployment configuration (provider: local) - └── policy.yaml # Access control rules -``` - -## Policy Rules - -Edit `config/policy.yaml` to control which callers can access which agents. -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 '{"input_size": 65536, "num_chunks": 4, "_context": {"origin": "admin"}}' -``` diff --git a/examples/epigenomics/agents/dedup_agent.py b/examples/epigenomics/agents/dedup_agent.py deleted file mode 100644 index d883854..0000000 --- a/examples/epigenomics/agents/dedup_agent.py +++ /dev/null @@ -1,44 +0,0 @@ -# Dedup Agent -# -# Fan-in barrier (mirrors Epigenomics' mark-duplicates/merge stage): needs -# every sorted chunk before it can run. Combines all chunk digests into one -# merged digest, with cost scaling off the total merged data volume. -# -# Resource profile: moderate CPU, single call per request (the barrier). - -import hashlib - - -class DedupAgent(object): - def __init__(self): - self.tools = [self.merge_dedup] - - def merge_dedup(self, chunks: list) -> dict: - """Merge and deduplicate every sorted chunk into one combined digest.""" - total_size = sum(c["size"] for c in chunks) - seed = "".join(c["digest"] for c in sorted(chunks, key=lambda c: c["chunk_id"])) - merged_digest = self._cpu_work(seed, total_size) - return { - "merged_digest": merged_digest, - "total_size": total_size, - "n_chunks": len(chunks), - } - - def _cpu_work(self, seed: str, iterations: int) -> str: - """Deterministic CPU-bound stand-in for the stage's real processing cost.""" - digest = seed.encode() - for _ in range(max(1, iterations)): - digest = hashlib.sha256(digest).digest() - return digest.hex() - - -if __name__ == "__main__": - agent = DedupAgent() - print( - agent.merge_dedup( - [ - {"chunk_id": "chunk-0", "size": 16384, "digest": "aa"}, - {"chunk_id": "chunk-1", "size": 16384, "digest": "bb"}, - ] - ) - ) diff --git a/examples/epigenomics/agents/dedup_agent.yaml b/examples/epigenomics/agents/dedup_agent.yaml deleted file mode 100644 index 1c577cd..0000000 --- a/examples/epigenomics/agents/dedup_agent.yaml +++ /dev/null @@ -1,10 +0,0 @@ -agent: - name: DedupAgent - functions: - - name: merge_dedup - description: Merge and deduplicate every sorted chunk into one combined digest. - arguments: - - name: chunks - type: list - returns: - type: dict diff --git a/examples/epigenomics/agents/filter_agent.py b/examples/epigenomics/agents/filter_agent.py deleted file mode 100644 index 24dfc48..0000000 --- a/examples/epigenomics/agents/filter_agent.py +++ /dev/null @@ -1,32 +0,0 @@ -# Filter Agent -# -# First per-chunk stage in the fan-out (mirrors Epigenomics' filter_contams -# stage): scrubs one chunk and hands back a content digest the later stages -# build on. The "work" is a deterministic SHA-256 chain sized off the -# chunk's declared byte size, standing in for the real stage's per-byte cost. -# -# Resource profile: light CPU, high fan-out (one call per chunk). - -import hashlib - - -class FilterAgent(object): - def __init__(self): - self.tools = [self.filter_contams] - - def filter_contams(self, chunk_id: str, size: int) -> dict: - """Filter contaminants out of one chunk, returning its content digest.""" - digest = self._cpu_work(chunk_id, size) - return {"chunk_id": chunk_id, "size": size, "digest": digest} - - def _cpu_work(self, seed: str, iterations: int) -> str: - """Deterministic CPU-bound stand-in for the stage's real processing cost.""" - digest = seed.encode() - for _ in range(max(1, iterations)): - digest = hashlib.sha256(digest).digest() - return digest.hex() - - -if __name__ == "__main__": - agent = FilterAgent() - print(agent.filter_contams("chunk-0", 16384)) diff --git a/examples/epigenomics/agents/filter_agent.yaml b/examples/epigenomics/agents/filter_agent.yaml deleted file mode 100644 index 9f10d29..0000000 --- a/examples/epigenomics/agents/filter_agent.yaml +++ /dev/null @@ -1,12 +0,0 @@ -agent: - name: FilterAgent - functions: - - name: filter_contams - description: Filter contaminants out of one chunk, returning its content digest. - arguments: - - name: chunk_id - type: str - - name: size - type: int - returns: - type: dict diff --git a/examples/epigenomics/agents/index_agent.py b/examples/epigenomics/agents/index_agent.py deleted file mode 100644 index 6faa984..0000000 --- a/examples/epigenomics/agents/index_agent.py +++ /dev/null @@ -1,30 +0,0 @@ -# Index Agent -# -# Final stage (mirrors Epigenomics' index-build stage): produces the -# workflow's terminal artifact from the merged, deduplicated digest. -# -# Resource profile: light CPU, single call per request. - -import hashlib - - -class IndexAgent(object): - def __init__(self): - self.tools = [self.build_index] - - def build_index(self, merged_digest: str, total_size: int) -> dict: - """Build the final index from the merged digest.""" - index_digest = self._cpu_work(merged_digest, max(1, total_size // 4)) - return {"index_digest": index_digest, "total_size": total_size} - - def _cpu_work(self, seed: str, iterations: int) -> str: - """Deterministic CPU-bound stand-in for the stage's real processing cost.""" - digest = seed.encode() - for _ in range(max(1, iterations)): - digest = hashlib.sha256(digest).digest() - return digest.hex() - - -if __name__ == "__main__": - agent = IndexAgent() - print(agent.build_index("deadbeef", 65536)) diff --git a/examples/epigenomics/agents/index_agent.yaml b/examples/epigenomics/agents/index_agent.yaml deleted file mode 100644 index 3424c44..0000000 --- a/examples/epigenomics/agents/index_agent.yaml +++ /dev/null @@ -1,12 +0,0 @@ -agent: - name: IndexAgent - functions: - - name: build_index - description: Build the final index from the merged digest. - arguments: - - name: merged_digest - type: str - - name: total_size - type: int - returns: - type: dict diff --git a/examples/epigenomics/agents/map_agent.py b/examples/epigenomics/agents/map_agent.py deleted file mode 100644 index b9db3ef..0000000 --- a/examples/epigenomics/agents/map_agent.py +++ /dev/null @@ -1,33 +0,0 @@ -# Map Agent -# -# Sequence-alignment stand-in (Epigenomics' map stage) -- by far the most -# CPU-expensive stage in the real workflow, so its per-byte cost multiplier -# here is set well above the other stages to match that shape. -# -# Resource profile: heavy CPU, high fan-out (one call per chunk). - -import hashlib - -COST_MULTIPLIER = 8 - - -class MapAgent(object): - def __init__(self): - self.tools = [self.align] - - def align(self, chunk_id: str, size: int, digest: str) -> dict: - """Align one filtered chunk, returning its post-alignment digest.""" - aligned = self._cpu_work(digest, size * COST_MULTIPLIER) - return {"chunk_id": chunk_id, "size": size, "digest": aligned} - - def _cpu_work(self, seed: str, iterations: int) -> str: - """Deterministic CPU-bound stand-in for the stage's real processing cost.""" - digest = seed.encode() - for _ in range(max(1, iterations)): - digest = hashlib.sha256(digest).digest() - return digest.hex() - - -if __name__ == "__main__": - agent = MapAgent() - print(agent.align("chunk-0", 16384, "deadbeef")) diff --git a/examples/epigenomics/agents/map_agent.yaml b/examples/epigenomics/agents/map_agent.yaml deleted file mode 100644 index 66c1210..0000000 --- a/examples/epigenomics/agents/map_agent.yaml +++ /dev/null @@ -1,14 +0,0 @@ -agent: - name: MapAgent - functions: - - name: align - description: Align one filtered chunk, returning its post-alignment digest. - arguments: - - name: chunk_id - type: str - - name: size - type: int - - name: digest - type: str - returns: - type: dict diff --git a/examples/epigenomics/agents/sort_agent.py b/examples/epigenomics/agents/sort_agent.py deleted file mode 100644 index 2aa2df7..0000000 --- a/examples/epigenomics/agents/sort_agent.py +++ /dev/null @@ -1,32 +0,0 @@ -# Sort Agent -# -# Third per-chunk stage in the fan-out (mirrors Epigenomics' sort stage): -# orders one aligned chunk, returning an updated digest for the fan-in below. -# -# Resource profile: moderate CPU, high fan-out (one call per chunk). - -import hashlib - -COST_MULTIPLIER = 2 - - -class SortAgent(object): - def __init__(self): - self.tools = [self.sort] - - def sort(self, chunk_id: str, size: int, digest: str) -> dict: - """Sort one aligned chunk, returning its post-sort digest.""" - sorted_digest = self._cpu_work(digest, size * COST_MULTIPLIER) - return {"chunk_id": chunk_id, "size": size, "digest": sorted_digest} - - def _cpu_work(self, seed: str, iterations: int) -> str: - """Deterministic CPU-bound stand-in for the stage's real processing cost.""" - digest = seed.encode() - for _ in range(max(1, iterations)): - digest = hashlib.sha256(digest).digest() - return digest.hex() - - -if __name__ == "__main__": - agent = SortAgent() - print(agent.sort("chunk-0", 16384, "deadbeef")) diff --git a/examples/epigenomics/agents/sort_agent.yaml b/examples/epigenomics/agents/sort_agent.yaml deleted file mode 100644 index 464dc85..0000000 --- a/examples/epigenomics/agents/sort_agent.yaml +++ /dev/null @@ -1,14 +0,0 @@ -agent: - name: SortAgent - functions: - - name: sort - description: Sort one aligned chunk, returning its post-sort digest. - arguments: - - name: chunk_id - type: str - - name: size - type: int - - name: digest - type: str - returns: - type: dict diff --git a/examples/epigenomics/agents/split_agent.py b/examples/epigenomics/agents/split_agent.py deleted file mode 100644 index 1b1a6be..0000000 --- a/examples/epigenomics/agents/split_agent.py +++ /dev/null @@ -1,27 +0,0 @@ -# Split Agent -# -# Entry stage of the pipeline (mirrors WfCommons' Epigenomics fastq-split -# stage): splits one logical input into num_chunks equal-sized chunks for the -# downstream fan-out. There's no real sequence file here -- each chunk's -# "size" just stands in for its data volume, which is what every downstream -# stage prices its synthetic CPU work off of. -# -# Resource profile: cheap CPU, single call per request. - - -class SplitAgent(object): - def __init__(self): - self.tools = [self.split] - - def split(self, input_size: int, num_chunks: int) -> dict: - """Split input_size bytes of data into num_chunks equal chunks.""" - chunk_size = max(1, input_size // num_chunks) - chunks = [ - {"chunk_id": f"chunk-{i}", "size": chunk_size} for i in range(num_chunks) - ] - return {"chunks": chunks} - - -if __name__ == "__main__": - agent = SplitAgent() - print(agent.split(65536, 4)) diff --git a/examples/epigenomics/agents/split_agent.yaml b/examples/epigenomics/agents/split_agent.yaml deleted file mode 100644 index cc64e8e..0000000 --- a/examples/epigenomics/agents/split_agent.yaml +++ /dev/null @@ -1,12 +0,0 @@ -agent: - name: SplitAgent - functions: - - name: split - description: Split input_size bytes of data into num_chunks equal chunks. - arguments: - - name: input_size - type: int - - name: num_chunks - type: int - returns: - type: dict diff --git a/examples/epigenomics/config/global_controller.yaml b/examples/epigenomics/config/global_controller.yaml deleted file mode 100644 index 6e8b4b0..0000000 --- a/examples/epigenomics/config/global_controller.yaml +++ /dev/null @@ -1,76 +0,0 @@ -# Global Controller Configuration — synthetic Epigenomics DAG, local provider -# Lists all agents and the workflow that Ventis manages. -# -# FilterAgent/MapAgent/SortAgent get 2 replicas each since the workflow fans -# out one call per chunk to them -- exercises multi-replica scheduling on a -# purely local, LLM-free run. - -agents: - - name: SplitAgent - replicas: 1 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/split_agent.py - provider: local - - - name: FilterAgent - replicas: 2 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/filter_agent.py - provider: local - - - name: MapAgent - replicas: 2 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/map_agent.py - provider: local - - - name: SortAgent - replicas: 2 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/sort_agent.py - provider: local - - - name: DedupAgent - replicas: 1 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/dedup_agent.py - provider: local - - - name: IndexAgent - replicas: 1 - redis_port: 6379 - resources: - cpu: 1 - memory: 256 - entrypoint: agents/index_agent.py - provider: local - - - name: Workflow - replicas: 1 - type: workflow - redis_port: 6379 - api_port: 8080 - workflow_file: workflow/epigenomics_workflow.py - provider: local - -poll_interval: 5 - -redis: - host: localhost - port: 6379 - db: 0 diff --git a/examples/epigenomics/config/policy.yaml b/examples/epigenomics/config/policy.yaml deleted file mode 100644 index 2c91415..0000000 --- a/examples/epigenomics/config/policy.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Policy-Based Routing Rules — synthetic Epigenomics DAG -# Each rule defines a match condition (key-value pairs to check against -# 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. - -rules: - - match: - origin: admin - access: all - - - match: {} - access: - - Workflow - - SplitAgent - - FilterAgent - - MapAgent - - SortAgent - - DedupAgent - - IndexAgent diff --git a/examples/epigenomics/workflow/epigenomics_workflow.py b/examples/epigenomics/workflow/epigenomics_workflow.py deleted file mode 100644 index 00bcc2b..0000000 --- a/examples/epigenomics/workflow/epigenomics_workflow.py +++ /dev/null @@ -1,97 +0,0 @@ -# Epigenomics Workflow -# -# WfCommons-style synthetic Epigenomics DAG for local, LLM-free testing: -# 0. SplitAgent - split input_size bytes into num_chunks chunks (single call) -# 1. FilterAgent - per-chunk contaminant filter (fan-out) -# 2. MapAgent - per-chunk alignment, the heaviest stage (fan-out) -# 3. SortAgent - per-chunk sort (fan-out) -# 4. DedupAgent - merge + dedup every sorted chunk (fan-in barrier) -# 5. IndexAgent - build the final index from the merged digest (single call) -# -# Every stage does deterministic SHA-256 work sized off chunk byte counts -- -# no LLM calls, no external services -- so results are reproducible and the -# fan-out width scales with num_chunks. -# -# After running `ventis build` and `ventis deploy`: -# curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' \ -# -d '{"input_size": 65536, "num_chunks": 4}' -# curl http://localhost:8080/status/ - -import sys -import os - -# These path inserts are needed when running inside a Docker container -# where all files are copied flat into /app/. -sys.path.insert(0, os.path.dirname(__file__)) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "grpc_stubs")) - -import json - -from deploy import deploy -from agents.split_agent import SplitAgent -from agents.filter_agent import FilterAgent -from agents.map_agent import MapAgent -from agents.sort_agent import SortAgent -from agents.dedup_agent import DedupAgent -from agents.index_agent import IndexAgent - - -def main(input_size: int = 65536, num_chunks: int = 4): - split_agent = SplitAgent() - filter_agent = FilterAgent() - map_agent = MapAgent() - sort_agent = SortAgent() - dedup_agent = DedupAgent() - index_agent = IndexAgent() - - # Stage 0: single call, produces the chunk list the fan-out below runs over. - split = json.loads( - split_agent.split(input_size=input_size, num_chunks=num_chunks).value() - ) - chunks = split["chunks"] - - # Stage 1: fan out one filter call per chunk -- every call returns a Future - # immediately, so all chunks are dispatched before we block on any of them. - filter_futures = { - c["chunk_id"]: filter_agent.filter_contams(chunk_id=c["chunk_id"], size=c["size"]) - for c in chunks - } - filtered = {cid: json.loads(f.value()) for cid, f in filter_futures.items()} - - # Stage 2: fan out alignment -- the heaviest stage -- one call per chunk. - map_futures = { - cid: map_agent.align(chunk_id=cid, size=r["size"], digest=r["digest"]) - for cid, r in filtered.items() - } - mapped = {cid: json.loads(f.value()) for cid, f in map_futures.items()} - - # Stage 3: fan out sort, one call per chunk. - sort_futures = { - cid: sort_agent.sort(chunk_id=cid, size=r["size"], digest=r["digest"]) - for cid, r in mapped.items() - } - sorted_chunks = {cid: json.loads(f.value()) for cid, f in sort_futures.items()} - - # Stage 4: fan-in barrier -- dedup needs every sorted chunk before it can run. - merged = json.loads( - dedup_agent.merge_dedup(chunks=list(sorted_chunks.values())).value() - ) - - # Stage 5: build the final index from the merged digest. - index = json.loads( - index_agent.build_index( - merged_digest=merged["merged_digest"], total_size=merged["total_size"] - ).value() - ) - - return { - "input_size": input_size, - "num_chunks": num_chunks, - "merged_digest": merged["merged_digest"], - "n_chunks": merged["n_chunks"], - "index_digest": index["index_digest"], - } - - -deploy(main, port=8080) diff --git a/examples/joke_writer/.car/app/.env.example b/examples/joke_writer/.car/app/.env.example deleted file mode 100644 index b846149..0000000 --- a/examples/joke_writer/.car/app/.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/.car/app/LICENSE b/examples/joke_writer/.car/app/LICENSE deleted file mode 100644 index 5600729..0000000 --- a/examples/joke_writer/.car/app/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/.car/app/README.md b/examples/joke_writer/.car/app/README.md deleted file mode 100644 index 930a410..0000000 --- a/examples/joke_writer/.car/app/README.md +++ /dev/null @@ -1,177 +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-ventis` 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-... -``` - -`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/.car/app/joke_workflow.py b/examples/joke_writer/.car/app/joke_workflow.py deleted file mode 100644 index 76e4027..0000000 --- a/examples/joke_writer/.car/app/joke_workflow.py +++ /dev/null @@ -1,39 +0,0 @@ -r"""CanyonOS Core 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. - - 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 joke_writer import JokeAgent - - -def main(query): - """Route: POST /main {"query": ""}""" - agent = JokeAgent() - - subjects = json.loads(agent.generate_topics(topic=query).value())["subjects"] - - futures = [agent.generate_joke(subject=s) for s in subjects] - written = [json.loads(f.value()) for f in futures] - written_jokes = [joke for result in written for joke in result["jokes"]] - - 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"], - } - - -deploy(main, port=8080) diff --git a/examples/joke_writer/.car/app/joke_writer.py b/examples/joke_writer/.car/app/joke_writer.py deleted file mode 100644 index 9f96eb5..0000000 --- a/examples/joke_writer/.car/app/joke_writer.py +++ /dev/null @@ -1,164 +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() - - -class JokeAgent(object): - """The graph's nodes, exposed under the class name `agent.name` declares.""" - - def generate_topics(self, topic: str) -> dict: - return generate_topics({"topic": topic}) - - def generate_joke(self, subject: str) -> dict: - return generate_joke({"subject": subject}) - - def best_joke(self, topic: str, jokes: list) -> dict: - return best_joke({"topic": topic, "jokes": jokes}) diff --git a/examples/joke_writer/.car/config/global_controller.yaml b/examples/joke_writer/.car/config/global_controller.yaml deleted file mode 100644 index 8ed54ab..0000000 --- a/examples/joke_writer/.car/config/global_controller.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# Deployment manifest for the map-reduce joke writer. -# -# `entrypoint` is the copied source itself: the adapter is appended to the -# bottom of joke_writer.py, so the module the agent needs is the one the class -# already lives in. - -agents: - - name: JokeAgent - # The fan-out. `generate_joke` is stateless, so the controller picks a - # replica at random per call and the workflow's N dispatched calls spread - # across these three. - entrypoint: joke_writer.py - provider: local - replicas: 3 - redis_port: 6379 - resources: - cpu: 1 - memory: 1024 - requirements: - - langgraph - - pydantic - - typing_extensions - - - name: Workflow - type: workflow - workflow_file: joke_workflow.py - api_port: 8080 - provider: local - replicas: 1 - redis_port: 6379 - -poll_interval: 5 - -redis: - host: localhost - port: 6379 - db: 0 - -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: {} - -# Relative to the application root (the directory `ventis` runs from), not -# `.car`. .env is gitignored and excluded from the build context; -# .env.example names what belongs in it. -env_file: .env diff --git a/examples/joke_writer/.car/config/joke_agent.yaml b/examples/joke_writer/.car/config/joke_agent.yaml deleted file mode 100644 index a5bc13f..0000000 --- a/examples/joke_writer/.car/config/joke_agent.yaml +++ /dev/null @@ -1,35 +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; 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. - -agent: - name: JokeAgent - functions: - - name: generate_topics - description: Split a topic into three related sub-topics. - arguments: - - name: topic - type: str - returns: - type: dict - - - name: generate_joke - description: Write one joke about one subject. - arguments: - - name: subject - type: str - returns: - type: dict - - - name: best_joke - description: Pick the best joke out of the ones written for a topic. - arguments: - - name: topic - type: str - - name: jokes - type: list - returns: - type: dict 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 930a410..0000000 --- a/examples/joke_writer/README.md +++ /dev/null @@ -1,177 +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-ventis` 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-... -``` - -`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/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/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md deleted file mode 100644 index fe6dd49..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/SKILL.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -name: porting-to-canyonos-core -description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. -compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. ---- - -# Port an agent project to CanyonOS Core - -CanyonOS Core is the product name. Its compatibility executable and Python -package remain `ventis`; environment variables and Docker resources retain the -`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding -strings. Do not rename them. - -## Load references only when needed - -- Read [references/packaging.md](references/packaging.md) when a source import - does not resolve from `/app`, the source is nested, or packaging metadata is - involved. -- Read [references/llm-proxy.md](references/llm-proxy.md) only when the target - includes `llm_proxy`. -- Read [references/ec2.md](references/ec2.md) only when any config entry uses - `provider: EC2`. -- Read [references/troubleshooting.md](references/troubleshooting.md) after a - failed build, image probe, deploy, or request. -- Read [references/runtime-contract.md](references/runtime-contract.md) when a - validator finding needs explanation or the runtime mechanism is unclear. - -## Goal: thin scaffolding beside untouched source - -```text -agents/.yaml one callable surface per service -agents/.py one thin adapter per service, when needed -workflow/_workflow.py HTTP entry point; calls deploy() -config/global_controller.yaml deployment manifest -config/policy.yaml optional access restriction -pyproject.toml conditional nested-import scaffolding - unchanged -``` - -The file count follows the deployment. A multi-agent port has one yaml/adapter -pair per service that is worth deploying separately. If a source class already -satisfies the runtime contract, point its config entry at that file and do not -copy it into an adapter. - -Everything the source already owns—prompts, tools, schemas, parsing, retries, -model clients, and node bodies—is imported. The port re-expresses only the -CanyonOS Core boundary and framework-owned orchestration. - -The port root is the existing repository root and the directory from which -`ventis build` runs. Write scaffolding there beside existing directories. If the -repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, -and `config/` beside it. Never move or copy the repository into a new `src/` -directory, and never create an outer wrapper merely for the port. - -## 1. Survey before writing - -Identify: - -1. The source entry point and callable input/output. -2. Framework-owned control flow (`StateGraph`, `Crew`, `GroupChat`, routing, - `Send`, `Command`, interrupts). -3. Runtime-injected services nodes read: stores, context, memory, sessions, or - callback managers. -4. Sync versus async boundaries. -5. Imports and declared runtime dependencies. -6. Model provider, credential names, streaming use, and optional `llm_proxy`. -7. Whether independent work fans out and benefits from separate replicas. -8. Whether source imports resolve from the project root that becomes `/app`. - -Run the validator once now. Its header detects capabilities directly from the -importable runtime rather than external development metadata: - -```bash -python /validate.py . -``` - -If config or agent yaml is malformed, the validator defers to `ventis build`. -Capability-gated findings say which runtime behavior is available. - -## 2. Choose service boundaries - -Start with one service. Split only when it creates independent parallel work or -a distinct resource/replica profile. - -- Keep a ReAct loop together; every turn needs shared message history. -- Hoist supervisor task lists and `Send`-style fan-out into the workflow. -- Do not create a one-replica service with no distinct resource profile merely - to mirror every source graph node. - -Rewrite framework-owned edges as ordinary Python. Import the connected node -functions unchanged. Construct runtime-injected service objects from source -configuration; do not invent models, dimensions, stores, or defaults silently. -Report any choice the source does not specify. - -## 3. Write declarations and adapters - -### Agent yaml - -Use one yaml per deployed service. Argument types are bare builtins only: -`str`, `int`, `float`, `bool`, `dict`, or `list`. Every declared argument is -required by the generated stub. `returns.type` is documentation; use `dict` or -`list` to signal that workflow callers must `json.loads` the returned string. - -### Adapter - -The entrypoint exposes a module-level class named exactly `agent.name`. It -constructs with no arguments and its declared methods are synchronous. Read -configuration from the environment in `__init__`. Bridge source coroutines -inside a synchronous method with `asyncio.run(...)`. Serialize framework objects -with their own JSON-safe serializer before returning. - -Do not duplicate source prompts, tools, schemas, or model calls. Keep the source -provider and SDK. - -### Workflow - -Expose `main(query: str)` and call `deploy(main, port=...)` at module scope. -Import generated stubs by yaml basename and agent class name: - -```python -from deploy import deploy -from agents. import -``` - -The deployment platform sends `{query: string}` to `/main`. Pack richer input -inside `query`; any additional workflow parameter has a default. - -Dispatch every remote call before resolving any future: - -```python -futures = [agent.work(item=item) for item in items] -results = [json.loads(future.value()) for future in futures] -``` - -Do not fuse dispatch and `.value()` in one comprehension; that silently -serializes fan-out. Do not add an `if __name__ == "__main__":` block: the -workflow is executed with `__name__ == "__main__"` in production. - -### Config - -For each service, keep these names aligned: - -```text -config entry name == yaml agent.name == entrypoint class name -``` - -Use lowercase `provider: local`; `replicas` is an integer; `requirements` is a -list of distribution-name strings. Put `env_file` at config top level when the -runtime capability is available. Omit `policy.yaml` unless access must be -restricted; if present, give it a non-empty `rules` list. - -## Hard rules - -Capitalized **MUST** and **NEVER** are reserved for port-breaking or -source-integrity rules. The owner column states where each is decided. - -| ID | Rule | Owner | -|---|---|---| -| M1 | Entrypoint MUST define a class named exactly `agent.name` | V006 | -| M2 | The class MUST construct with no arguments | V007 | -| M3 | yaml argument names MUST match Python parameter names | V008 | -| M4 | yaml argument types MUST be bare builtins | V010 | -| M5 | Declared adapter methods MUST be synchronous | V009 | -| M6 | Config names MUST match yaml agent names | build | -| M7 | Config names MUST not collide after lowercase normalization | build output | -| M8 | Local provider MUST be lowercase `local` | deploy preflight | -| M9 | `replicas` MUST be an integer | deploy preflight | -| M10 | `requirements` MUST be a list of strings | build | -| M11 | Workflow MUST expose `main(query)`; extra parameters MUST default | V016 | -| M12 | Workflow MUST NEVER contain a main guard | V017 | -| M13 | Fan-out MUST dispatch all calls before resolving any | V018 | -| M14 | Project modules MUST not take runtime or generated-stub flat names | V019/V020 | -| M15 | Workflow MUST import stubs from `agents.` | V023 | -| M16 | Policy MUST be absent or contain a non-empty `rules` list | deploy preflight | -| M17 | EC2 entries MUST satisfy the EC2 deployment contract | deploy preflight | -| M18 | NEVER copy source prompts, tools, schemas, or model calls | review | -| M19 | NEVER hardcode or bake a real credential into an image | W003 | -| M20 | NEVER edit or vendor the source tree | `git status` | -| M21 | NEVER swap the source LLM provider | review | -| M22 | NEVER silently move, drop, or reclassify source dependencies | review | -| M23 | Framework control flow MUST be rewritten; source node logic MUST be imported | review | -| M24 | A non-resolving source import MUST have usable root packaging metadata when editable install is supported | V031 | - -## 4. Validate, build, and probe - -Run static preflight, then let the build own build-time validation: - -```bash -python /validate.py . -ventis build -c config/global_controller.yaml -``` - -A green build never imports the adapter. Probe each agent image in this order: - -```bash -# Runtime startup path - docker run --rm ventis- \ - python -c "import local_controller" - -# Agent load path; include --env-file when configured - docker run --rm --env-file ventis- \ - python -c "import importlib.util,sys; \ -s=importlib.util.spec_from_file_location('m','.py'); \ -m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ -m.();print('ok')" -``` - -Also probe the workflow image with `python -c "import local_controller"`; it has -its own dependency resolve and generated-stub imports. - -Then deploy, send a representative request, and poll its status: - -```bash -ventis deploy -c config/global_controller.yaml -curl -X POST http://localhost:8080/main \ - -H 'Content-Type: application/json' -d '{"query":""}' -curl http://localhost:8080/status/ -``` - -A successful outer request with a source-level failure still proves the port -reached and returned the source behavior. Record the distinction. - -## 5. Clean up - -After collecting evidence, stop foreground deploy with Ctrl+C and wait for -controller cleanup. Remove exact leftovers if startup crashed. Then remove build -products and exact images from this config: - -```bash -ventis clean -docker image rm ventis- \ - ventis- - -test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container -docker ps -a --format '{{.Names}}' -``` - -`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it -does not remove containers or images. Keep port scaffolding, untouched source, -and requested logs or reports. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md deleted file mode 100644 index e06daa0..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/ec2.md +++ /dev/null @@ -1,41 +0,0 @@ -# EC2 deployment - -Read this only when at least one config entry uses `provider: EC2`. - -## Configuration - -Use `provider: EC2` and declare `instance_type` on every EC2 service entry. The -top-level `ec2` block supplies the runtime's required infrastructure and SSH -settings. Read the target checkout's deploy preflight and EC2 runtime before -writing the block; do not copy values from an example environment. - -Typical required categories are: - -- AMI and instance type -- region and subnet -- security groups -- SSH user and credentials accepted by the runtime - -`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof -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. - -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 -target runtime rather than assuming local Docker semantics. - -## Probes and cleanup - -Run the same runtime and adapter probes against the exact image before remote -deployment. After deploy, verify the remote container logs; controller health -can be green even when agent loading failed. - -Stop foreground deploy normally so the controller can terminate recorded EC2 -instances. If provisioning or startup fails before an instance is recorded, -inspect the cloud provider directly and remove exact leaked resources. Never use -a broad cleanup command against unrelated instances. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md deleted file mode 100644 index f726bd7..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md +++ /dev/null @@ -1,64 +0,0 @@ -# LLM proxy integration - -Read this only when the target checkout contains `llm_proxy` or the deployment -explicitly routes model SDKs through it. - -## Preserve provider protocols - -The proxy redirects provider endpoints; it does not convert providers. Keep the -source SDK, model ID, request body, and response parsing unchanged. - -Configure only the provider variables the source uses: - -```dotenv -OPENAI_BASE_URL=http://host.docker.internal:8081/openai/v1 -ANTHROPIC_BASE_URL=http://host.docker.internal:8081/anthropic -AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://host.docker.internal:8081/bedrock -``` - -Some SDKs refuse to initialize without caller credentials. Give agent containers -non-secret placeholders only when required. Keep real OpenAI, Anthropic, or AWS -credentials in the separate proxy process, not in the port's `env_file`. - -## Start locally - -The proxy defaults conflict with a typical deployment: host loopback is not -reachable from a container, and port 8080 is normally used by the workflow API. -Use a non-loopback bind and a different port: - -```bash -PROXY_HOST=0.0.0.0 PROXY_PORT=8081 python -m llm_proxy -curl http://127.0.0.1:8081/healthz -``` - -Local CanyonOS Core containers resolve `host.docker.internal` through their -Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the -machine running `ventis deploy`. Distributed deployments need a reachable proxy -address or one proxy on each host. - -## Supported call shape - -The implementation buffers complete requests and responses: - -- OpenAI and Anthropic non-streaming HTTP calls are forwarded. -- Bedrock `invoke` is reissued through the proxy's boto3 client. -- OpenAI/Anthropic streaming is unsupported. -- Bedrock `invoke-with-response-stream`, `converse`, and `converse-stream` are - unsupported. - -Survey the source before selecting the proxy. Do not silently disable streaming; -report the unsupported behavior and stop. - -## Credential behavior - -- The OpenAI adapter removes caller authorization and inserts the proxy key. -- The Anthropic adapter removes caller key headers and inserts the proxy key. -- Botocore still signs requests sent to a custom endpoint, so a caller may need - placeholder AWS credentials even though the proxy reissues upstream with its - own identity. -- `/healthz` proves provider registration and Flask availability, not upstream - credential validity. - -OpenAI and Anthropic upstream HTTP errors pass through. Proxy exceptions return -JSON 502 with `error: proxy_error`. Bedrock `ClientError` bodies are reconstructed -with the upstream status and are not byte-for-byte passthrough. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md deleted file mode 100644 index 8520c4a..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/packaging.md +++ /dev/null @@ -1,81 +0,0 @@ -# Packaging and import roots - -Read this reference when an adapter imports nested source code, the source uses a -`src/` layout, or V031 reports an import-root problem. - -## What `/app` can import - -CanyonOS Core preserves project-relative paths in the image and starts Python at -`/app`. Without an editable install, Python resolves names rooted there: - -- `/app/tools.py` as `import tools` -- `/app/pkg/__init__.py` as `import pkg` -- `/app/src/agents/...` as `import src.agents`, including PEP 420 namespace - directories without `__init__.py` - -It does not resolve `/app/source/pkg` as `import pkg`; `/app/source` must become -an import root first. - -## Detect support, do not infer it from release history - -Run: - -```bash -python /validate.py . -``` - -Read the `editable_install` capability. If it is unavailable and the original -import cannot resolve from `/app`, report a runtime capability blocker and stop. -Do not add a `sys.path` hack or relocate source files. - -## Root metadata is the trigger - -When editable install is supported, only packaging metadata at the **port root** -triggers `pip install -e .`: - -```text -port-root/pyproject.toml detected -port-root/source/pyproject.toml ignored as an install trigger -``` - -A nested source repository may remain untouched. Add minimal root scaffolding -that points package discovery at the existing source package: - -```toml -[build-system] -requires = ["setuptools>=64"] -build-backend = "setuptools.build_meta" - -[project] -name = "canyonos-port" -version = "0.0.0" -dependencies = [] - -[tool.setuptools.packages.find] -where = ["source/src"] -include = ["pkg*"] -namespaces = true -``` - -Set `where` and `include` from the actual tree and original import spelling. Do -not reference a README or license from this wrapper metadata; file sweeps differ -by runtime capability and a missing referenced file makes the image build fail. - -## Dependencies in nested metadata - -A nested `pyproject.toml` is not installed merely because its Python files are -copied. Keep the source declaration unchanged and repeat its runtime -distributions in each relevant config entry's `requirements` list. This is -compatibility scaffolding, not permission to drop, move, or reclassify declared -dependencies. - -If source metadata is already at the port root, do not create a wrapper. Its -project dependencies participate in the same resolver as config requirements. -Report declared-but-unused toolchain dependencies and their image cost; let the -owner decide whether source metadata should change. - -## Validation boundary - -`ventis build` owns packaging syntax and installation errors. `validate.py` -checks only whether adapter imports appear to require a nested root that the -runtime will not expose. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md deleted file mode 100644 index 94f7401..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md +++ /dev/null @@ -1,187 +0,0 @@ -# CanyonOS Core runtime contract - -The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the -CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for -Docker resources. - -Read this reference when implementing an adapter or explaining a validator -finding. Runtime-dependent behavior is expressed as capabilities; run -`validate.py` against the target environment instead of inferring support from -release history. - -## Project root and discovery - -`ventis build` uses the current working directory as the project root. - -| Input | Discovery | -|---|---| -| `agents/*.yaml` | direct yaml glob under the project root | -| `config/global_controller.yaml` | default config, overridable with `-c` | -| workflow | `workflow_file` on a `type: workflow` entry | -| policy | `policy.yaml` beside the selected config file | -| generated files | `stubs/`, `grpc_stubs/`, `docker_container/` | - -The config name, yaml `agent.name`, and entrypoint class name form one binding: - -```text -config entry name == yaml agent.name == entrypoint class name -``` - -A missing match may skip an image while the command continues, so inspect build -output and generated image tags. - -## Agent yaml and generated stubs - -The consumed yaml shape is: - -```yaml -agent: - name: ExampleAgent - functions: - - name: work - arguments: - - name: query - type: str - returns: - type: dict -``` - -Argument annotations are generated from bare names without adding imports. Use -builtins. Generated methods have no defaults, so every declared argument is -required at the stub call site. `returns` does not control runtime conversion; -it documents whether workflow code should parse the returned string. - -Stub destinations differ by runtime capability and entrypoint layout. Workflow -code in this port convention imports the generated class from -`agents.`. The validator checks that import against declarations -before the workflow image starts. - -## Agent loading and execution - -The local controller effectively performs: - -```python -module = load(entrypoint) -agent_class = getattr(module, configured_name) -agent = agent_class() -result = getattr(agent, method_name)(**args) -``` - -Consequences: - -- The class is module-level and named exactly as configured. -- Construction takes no arguments. -- Declared methods accept yaml argument names as keyword arguments. -- Methods are synchronous; this path does not await a coroutine. -- Dicts and lists are JSON-encoded before entering Redis; other results become - strings. -- A remote Future's `.value()` returns text, not the original Python object. - -Agent import and construction exceptions are caught by the controller. A failed -agent may still advertise healthy because health is written independently of -successful agent loading. That is why image probes import both the runtime and -the entrypoint explicitly. - -## Workflow execution - -The workflow file is executed, not imported. Therefore: - -- module-level code runs at container startup; -- `__name__ == "__main__"`; -- `deploy()` blocks in the web server; -- the workflow function runs once per request; -- its function name determines the REST route exposed by the compatibility - runtime. - -The deployment platform additionally expects `/main` with a `{query: string}` -body. This platform constraint is stricter than the underlying transport. - -Each stub method returns a Future immediately. `.value()` blocks. Dispatching -and resolving inside one comprehension serializes work without raising an -error; dispatch all calls first, then resolve them. - -The workflow container also starts runtime controller code and has its own -package resolution. Probe it independently from agent images. - -## Build context and collisions - -The runtime copies project files while preserving relative paths, then writes -shared runtime modules, generated stubs, and entrypoints into the image. Later -writes can shadow project files. - -Avoid root project modules named like runtime files, including: - -```text -future.py -ventis_context.py -local_controller.py -local_controller_frontend.py -redis_client.py -grpc_options.py -bedrock.py -deploy.py -session_logging.py -workflow_launcher.py -``` - -Also avoid a yaml basename that shadows a different source module imported by an -adapter. The validator checks deterministic flat-name collisions. - -File sweep and editable-install behavior are runtime capabilities. For nested -imports, follow [packaging.md](packaging.md). - -## Dependencies and protobuf - -Agent and workflow images include a small runtime dependency set. Config -`requirements` adds source-specific distributions. A malformed requirements -value can be normalized away while image generation continues; missing imports -then surface only when the agent loads. - -The build compiles gRPC Python stubs on the host and copies them into images. -The image resolver does not necessarily know the generated-code version. A -source dependency that constrains protobuf below the host generator version can -produce a green image build that dies on: - -```text -import local_controller -``` - -Always run that probe before probing the entrypoint. Treat a generated-code / -runtime-version mismatch as a CanyonOS Core runtime issue, not a reason to alter -source dependencies silently. - -## Credentials capability - -When `env_file` capability is available, the top-level config path is resolved -against the project root and passed at container start. Hidden env files are not -copied into images. Invalid paths are deploy-preflight errors. - -When the capability is unavailable, declaring `env_file` has no effect. If the -source needs credentials, report the capability blocker rather than hardcoding -or vendoring a secret. - -A source that constructs its client at import time works only when credentials -are already in the container environment. Image entrypoint probes therefore use -the same env file as deployment. - -For proxy-specific credential separation, read [llm-proxy.md](llm-proxy.md). - -## Policy and provider behavior - -No policy file means unrestricted service access. If a policy exists, it needs a -non-empty rules list. Rules are evaluated by specificity and first match; -services excluded from the selected rule fail after request acceptance. - -Local provider handling is case-sensitive: use lowercase `local`. EC2 behavior -and remote networking are covered in [ec2.md](ec2.md). - -## Cleanup boundary - -Stopping foreground deploy normally invokes controller cleanup for recorded -containers and Redis. Hard kills and failures before resource registration may -leave resources behind. - -`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and -`docker_container/`. It does not remove containers or images. Remove exact -leftovers explicitly and preserve source, port scaffolding, and requested -evidence. diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md deleted file mode 100644 index 361acf9..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md +++ /dev/null @@ -1,60 +0,0 @@ -# Troubleshooting - -Read this after a failed build, image probe, deploy, or request. For mechanisms, -read [runtime-contract.md](runtime-contract.md). For proxy or remote-host failures, -read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). - -## Build or deploy stops early - -| Symptom | Likely cause | -|---|---| -| Agent image is missing | Config name matched no yaml name, entrypoint is absent, or build skipped it; inspect build warnings | -| Two services produce one image | Config names collide after lowercase normalization | -| `generated grpc_stubs are missing` | Build did not complete on this host, or generated output was cleaned before deploy | -| `int(... NoneType)` during local deploy | Local provider was not written exactly as lowercase `local` | -| Replica conversion `TypeError` | `replicas` is not an integer | -| Policy `AttributeError` | Policy exists but is empty or has null/non-list rules; remove it when unrestricted | -| Port or container name already in use | A previous deployment did not complete cleanup | - -## Container exits or serves nothing - -| Symptom | Likely cause | -|---|---| -| `import local_controller` raises protobuf version error | Host-generated gRPC code is newer than the image's protobuf runtime | -| First request says `No agent loaded` | Entrypoint import, class lookup, or constructor failed and the controller swallowed the exception; inspect container logs | -| Replica is healthy but serves nothing | Health publication does not prove successful agent loading | -| Missing credentials while loading | Env injection is unavailable/misconfigured or the source reads another variable | -| Source module is missing | Original import does not resolve from `/app`; read [packaging.md](packaging.md) | -| Third-party module is missing | Distribution is absent from source metadata and config requirements | -| Stub import raises `NameError` | yaml argument type is not a bare builtin | -| Source module behaves like an empty stub | A generated stub basename shadowed the source module | -| Runtime-named project module disappears | Shared runtime copy overwrote a root project module with the same name | - -## Request is accepted, then fails - -| Symptom | Likely cause | -|---|---| -| Unexpected keyword argument | yaml argument name differs from adapter parameter name | -| Required argument missing | yaml does not declare a required adapter parameter, or workflow platform sent only `query` | -| Unauthorized service | The first matching policy rule excludes that service | -| `.value()` returns dict-like text | Expected; remote values are strings, so use `json.loads` | -| Object is not JSON serializable | Adapter returned framework objects without its JSON-safe serializer | -| Redis contains a coroutine repr | Adapter method is async and the controller did not await it | -| Fan-out is no faster | Dispatch and `.value()` were fused, serializing the calls | -| Debug block runs at startup | Workflow is executed with `__name__ == "__main__"` | - -## Deployment platform endpoint - -| Symptom | Likely cause | -|---|---| -| 404 while workflow container is healthy | Workflow function is not named `main` | -| 400 before host receives request | Body is not the platform's `{query: string}` shape | -| Extra workflow argument is missing | Platform sends only `query`; extra parameters need defaults | - -## Cleanup - -| Symptom | Likely cause | -|---|---| -| `ventis clean` succeeds but containers remain | The command removes generated directories only | -| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | -| Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py b/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py deleted file mode 100755 index 04baf37..0000000 --- a/examples/portfolio/.claude/skills/porting-to-canyonos-core/validate.py +++ /dev/null @@ -1,1257 +0,0 @@ -#!/usr/bin/env python3 -"""Preflight the runtime traps that `ventis build` cannot see. - -This deliberately does not duplicate build-time validation such as malformed -YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those -checks. This script parses Python without importing it and catches failures that -otherwise stay hidden until a container loads an agent, starts a workflow, or -serves its first request. A replica is not evidence: the controller writes -`healthy` to Redis before `_load_agent` runs. - - python validate.py [project_dir] [-c config/global_controller.yaml] - [--json] [--strict] - -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 `ventis` package directly. A capability-gated check reports -UNAVAILABLE when its behavior cannot be proven. -""" - -import argparse -import ast -import builtins -import json -import os -import re -import sys -from typing import ClassVar - -try: - import yaml -except ImportError: # pragma: no cover - pyyaml is a CanyonOS Core dependency - sys.stderr.write("validate.py needs pyyaml: pip install pyyaml\n") - raise SystemExit(2) from None - - -DEFAULT_CONFIG_PATH = "config/global_controller.yaml" - -# Copied flat into every image over the swept project tree, so a project module -# landing flat under one of these names is overwritten. -# ventis/stub_generator.py generate_docker / generate_workflow_docker. -RUNTIME_FLAT_NAMES = frozenset( - { - "future.py", - "ventis_context.py", - "local_controller.py", - "local_controller_frontend.py", - "redis_client.py", - "grpc_options.py", - "gpu_metrics.py", - "bedrock.py", - "deploy.py", - "session_logging.py", - "workflow_launcher.py", - } -) - -# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. -BASE_AGENT_REQUIREMENTS = [ - "grpcio", - "grpcio-tools", - "redis", - "pyyaml", - "psutil", - "ipdb", - "ipython", - "boto3", -] -# Import name -> distribution name, for the handful where they differ and the -# mismatch would otherwise be reported as a missing requirement. -IMPORT_TO_DISTRIBUTION = { - "attr": "attrs", - "bs4": "beautifulsoup4", - "cv2": "opencv-python", - "dateutil": "python-dateutil", - "dotenv": "python-dotenv", - "grpc": "grpcio", - "grpc_tools": "grpcio-tools", - "jwt": "pyjwt", - "PIL": "pillow", - "psycopg": "psycopg", - "psycopg2": "psycopg2-binary", - "pydantic_settings": "pydantic-settings", - "sklearn": "scikit-learn", - "typing_extensions": "typing-extensions", - "yaml": "pyyaml", -} - -SECRET_PATTERNS = [ - (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "an OpenAI-style secret key"), - (re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key id"), - (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "a GitHub token"), - (re.compile(r"AIza[0-9A-Za-z_-]{30,}"), "a Google API key"), -] -SECRET_NAME = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) - -ERROR = "ERROR" -WARN = "WARN" -INFO = "INFO" - - -# ------------------------------------------------------------------ # -# Capabilities # -# ------------------------------------------------------------------ # -# -# Stable labels for behavior detected from the importable runtime. They contain -# no external development metadata. - -CAPABILITY_SOURCE = { - "env_file": "runtime env-file injection", - "editable_install": "editable project installation", - "sweeps_all_files": "full project-file sweep", - "stub_two_destinations": "flat and package stub destinations", -} - - -def probe_capabilities(): - """Ask the importable ventis package what it actually supports.""" - caps = dict.fromkeys(CAPABILITY_SOURCE, False) - caps["ventis"] = False - try: - from ventis import stub_generator - except Exception: # noqa: BLE001 - a broken install must not crash the check - return caps - - caps["ventis"] = True - caps["editable_install"] = hasattr(stub_generator, "_install_step") - caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") - caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") - - import importlib - - for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): - try: - module = importlib.import_module(module_name) - except Exception: # noqa: BLE001,S112 - the other path is the live one - continue - if hasattr(module, "resolve_env_file"): - caps["env_file"] = True - break - return caps - - -# ------------------------------------------------------------------ # -# YAML with line numbers # -# ------------------------------------------------------------------ # - - -class LineDict(dict): - """A mapping that remembers where it and each of its keys were written.""" - - line = 0 - key_lines: ClassVar[dict] = {} - - -class LineLoader(yaml.SafeLoader): - pass - - -def _construct_mapping(loader, node): - data = LineDict() - yield data - data.update(loader.construct_mapping(node, deep=False)) - data.line = node.start_mark.line + 1 - data.key_lines = { - key.value: key.start_mark.line + 1 - for key, _ in node.value - if isinstance(key, yaml.ScalarNode) - } - - -LineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping -) - - -def line_of(mapping, key=None): - """The source line of `key` inside `mapping`, or of the mapping itself.""" - if not isinstance(mapping, LineDict): - return 0 - if key is not None: - return mapping.key_lines.get(key, mapping.line) - return mapping.line - - -def load_yaml(path): - """Parse `path`, returning (data, error). Never raises.""" - try: - with open(path, "r", encoding="utf-8") as handle: - return yaml.load(handle, Loader=LineLoader), None - except Exception as exc: # noqa: BLE001 - any parse failure is a finding - return None, str(exc) - - -# ------------------------------------------------------------------ # -# Findings # -# ------------------------------------------------------------------ # - - -class Report: - def __init__(self, project_dir, capabilities): - self.project_dir = project_dir - self.capabilities = capabilities - self.findings = [] - # A peer agent is imported by the name of its generated stub, which the - # build copies flat into every image. Those are not project modules and - # need no requirement. - self.stub_module_names = set() - - def add(self, check, level, path, line, summary, mechanism): - self.findings.append( - { - "check": check, - "level": level, - "path": self.rel(path) if path else "", - "line": line or 0, - "summary": summary, - "mechanism": mechanism, - } - ) - - def error(self, check, path, line, summary, mechanism): - self.add(check, ERROR, path, line, summary, mechanism) - - def warn(self, check, path, line, summary, mechanism): - self.add(check, WARN, path, line, summary, mechanism) - - def unavailable(self, check, summary): - self.add(check, INFO, "", 0, summary, "") - - def rel(self, path): - try: - return os.path.relpath(path, self.project_dir) - except ValueError: - return path - - def counts(self): - errors = sum(1 for f in self.findings if f["level"] == ERROR) - warnings = sum(1 for f in self.findings if f["level"] == WARN) - return errors, warnings - - -# ------------------------------------------------------------------ # -# Python source helpers # -# ------------------------------------------------------------------ # - - -def parse_python(path): - """AST for `path`, or (None, error). The port is never imported.""" - try: - with open(path, "r", encoding="utf-8") as handle: - source = handle.read() - except OSError as exc: - return None, str(exc) - try: - return ast.parse(source, filename=path), None - except SyntaxError as exc: - return None, f"{exc.msg} (line {exc.lineno})" - - -def find_class(tree, name): - for node in tree.body: - if isinstance(node, ast.ClassDef) and node.name == name: - return node - return None - - -def class_methods(class_node): - return { - node.name: node - for node in class_node.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } - - -def parameter_names(func_node): - """Every parameter a caller can pass by keyword, minus self.""" - args = func_node.args - positional = [a.arg for a in args.posonlyargs + args.args] - if positional and positional[0] in ("self", "cls"): - positional = positional[1:] - return positional + [a.arg for a in args.kwonlyargs] - - -def required_parameters(func_node): - """Parameters with no default, minus self.""" - args = func_node.args - positional = [a.arg for a in args.posonlyargs + args.args] - if positional and positional[0] in ("self", "cls"): - positional = positional[1:] - if args.defaults: - positional = positional[: len(positional) - len(args.defaults)] - kwonly = [ - arg.arg - for arg, default in zip(args.kwonlyargs, args.kw_defaults) - if default is None - ] - return positional + kwonly - - -def toplevel_import_names(tree): - """Top-level package name of every import in the module, with line numbers.""" - names = {} - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - names.setdefault(alias.name.split(".")[0], node.lineno) - elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: - names.setdefault(node.module.split(".")[0], node.lineno) - return names - - -# ------------------------------------------------------------------ # -# V006-V010 adapter failures hidden by _load_agent # -# ------------------------------------------------------------------ # - -BUILTIN_TYPE_NAMES = frozenset( - name - for name in dir(builtins) - if isinstance(getattr(builtins, name), type) and not name[0].isupper() -) - - -def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): - """V006 V007 V008 V009 V010.""" - name = agent_block["name"] - functions = agent_block.get("functions") or [] - check_argument_types(report, agent_yaml_path, functions) - - entrypoint = entry.get("entrypoint") - if not entrypoint: - return - entrypoint_path = os.path.join(project_dir, entrypoint) - if not os.path.isfile(entrypoint_path): - return - - tree, error = parse_python(entrypoint_path) - if tree is None: - report.error( - "V006", - entrypoint_path, - 0, - f"the entrypoint does not parse: {error}", - "_load_agent exec_module's it and swallows the exception; the first " - "request answers 'No agent loaded'.", - ) - return - - class_node = find_class(tree, name) - if class_node is None: - classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] - found = ", ".join(classes) if classes else "no classes at all" - report.error( - "V006", - entrypoint_path, - 1, - f"no class named `{name}` at module level (found: {found})", - "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " - "the AttributeError. The class name must equal agent.name exactly.", - ) - return - - methods = class_methods(class_node) - check_constructor(report, entrypoint_path, name, methods) - - for func in functions: - if not isinstance(func, dict) or not isinstance(func.get("name"), str): - continue - check_method(report, entrypoint_path, agent_yaml_path, name, func, methods) - - -def check_argument_types(report, agent_yaml_path, functions): - """V010 -- the type string is pasted into an ast.Name, never checked.""" - for func in functions: - if not isinstance(func, dict): - continue - for arg in func.get("arguments") or []: - if not isinstance(arg, dict) or "type" not in arg: - continue - declared = arg.get("type") - if not isinstance(declared, str): - continue # stub generation reports malformed type values - if declared in BUILTIN_TYPE_NAMES: - continue - report.error( - "V010", - agent_yaml_path, - line_of(arg, "type"), - f"`type: {declared}` is not a builtin", - "stub_generator pastes it verbatim into the generated " - "annotation, and the stub module imports only Future and " - "inspect. Anything else raises NameError when the stub is " - "imported -- after a green build. Use str int float bool dict " - "list.", - ) - - -def check_constructor(report, entrypoint_path, name, methods): - """V007 -- _load_agent calls agent_class() with no arguments.""" - init = methods.get("__init__") - if init is None: - return - required = required_parameters(init) - if required: - report.error( - "V007", - entrypoint_path, - init.lineno, - f"`{name}.__init__` requires {', '.join(required)}", - "_load_agent calls agent_class() with no arguments; the TypeError is " - "swallowed and the first request answers 'No agent loaded'. Read " - "configuration from the environment inside __init__ instead.", - ) - - -def check_method(report, entrypoint_path, agent_yaml_path, class_name, func, methods): - """V008 V009.""" - func_name = func["name"] - method = methods.get(func_name) - if method is None: - report.error( - "V008", - entrypoint_path, - 0, - f"`{class_name}` has no method `{func_name}`", - "The yaml declares it, so callers get a stub for it; the controller " - f"then answers \"Agent {class_name} has no method '{func_name}'\".", - ) - return - - # V009 -- nothing on the execution path awaits. - if isinstance(method, ast.AsyncFunctionDef): - report.error( - "V009", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` is `async def`", - "The executor calls method(**args) with no await, so Redis receives " - "''. Keep the signature synchronous and call " - "asyncio.run(...) inside the body.", - ) - - # V008 -- the controller calls method(**args) with the yaml's names. - declared = [ - arg["name"] - for arg in func.get("arguments") or [] - if isinstance(arg, dict) and isinstance(arg.get("name"), str) - ] - actual = parameter_names(method) - required = required_parameters(method) - - missing = [arg for arg in declared if arg not in actual] - if missing: - report.error( - "V008", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` has no parameter " - f"{', '.join(repr(m) for m in missing)}, but the yaml declares it", - "LocalController does method(**args) with the yaml's argument names. " - "A mismatch is TypeError: unexpected keyword argument, at request " - f"time. See {os.path.basename(agent_yaml_path)}.", - ) - - unfilled = [arg for arg in required if arg not in declared] - if unfilled: - report.error( - "V008", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` requires {', '.join(unfilled)}, which " - "the yaml does not declare", - "Only declared arguments are ever sent, and the generated stub gives " - "none of them a default. Declare them in the yaml or default them " - "in the signature.", - ) - - - -# ------------------------------------------------------------------ # -# V016-V018 the workflow # -# ------------------------------------------------------------------ # - - -def check_stub_imports(report, workflow_path, tree, stub_classes): - """V023 -- the workflow must import a stub as `from agents. import `. - - The build copies each stub to exactly one path, and for the workflow image - that path is agents/.py. Two ways of writing this line fail, and - the project walks you into both: the flat form is what examples/ uses, and - the class name is the one `ventis build` prints, which is not the one it - writes. - """ - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - base = alias.name.split(".")[0] - if base in stub_classes: - report.error( - "V023", workflow_path, node.lineno, - f"`import {alias.name}` -- the stub is at " - f"agents/{base}.py, not flat", - "The build copies a stub to one path, and for the " - "workflow that path is under agents/. This is a " - "ModuleNotFoundError the moment the workflow runs. " - f"Write `from agents.{base} import {stub_classes[base]}`.", - ) - continue - - if not isinstance(node, ast.ImportFrom) or not node.module: - continue - - module = node.module - if module in stub_classes: - report.error( - "V023", workflow_path, node.lineno, - f"`from {module} import ...` -- the stub is at " - f"agents/{module}.py, not flat", - "The build copies a stub to one path, and for the workflow " - "that path is under agents/. The flat form is what this " - "repository's own examples use and it raises " - "ModuleNotFoundError in the workflow image. Write " - f"`from agents.{module} import {stub_classes[module]}`.", - ) - continue - - if not module.startswith("agents."): - continue - base = module.split(".", 1)[1] - expected = stub_classes.get(base) - if expected is None: - continue - for alias in node.names: - if alias.name == expected: - continue - if alias.name == f"{expected}Stub": - report.error( - "V023", workflow_path, node.lineno, - f"`{alias.name}` is the name the build prints, not the " - f"class it writes", - "generate_agent_stub sets class_name = agent_config['name'] " - "and then recomputes it with a 'Stub' suffix for the log " - "line only. The message names a class that does not exist; " - f"the class is `{expected}`.", - ) - else: - report.error( - "V023", workflow_path, node.lineno, - f"`{alias.name}` is not a class the stub for {base} defines", - f"The stub's class carries the agent's own name: `{expected}`.", - ) - - -def check_workflow(report, workflow_path, stub_classes=None): - """V016 V017 V018 V023.""" - tree, error = parse_python(workflow_path) - if tree is None: - report.error("V016", workflow_path, 0, f"does not parse: {error}", "") - return - - main = None - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ( - node.name == "main" - ): - main = node - break - - if main is None: - defined = [ - n.name - for n in tree.body - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - ] - found = ", ".join(defined) if defined else "no top-level functions" - report.error( - "V016", - workflow_path, - 1, - f"no top-level function named `main` (found: {found})", - "CanyonOS Core serves POST /, but the deployment platform's " - "test endpoint posts to a hardcoded /main. A differently named " - "workflow builds, deploys and stays unreachable -- 404, container " - "healthy.", - ) - else: - check_main_signature(report, workflow_path, main) - - if stub_classes: - check_stub_imports(report, workflow_path, tree, stub_classes) - - # V016 -- deploy() is what starts Flask. - if not any( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "deploy" - for node in ast.walk(tree) - ): - report.error( - "V016", - workflow_path, - 1, - "the workflow never calls `deploy(...)`", - "workflow_launcher.py exec's this file and nothing else starts the " - "HTTP server; the container comes up serving nothing.", - ) - - check_main_guard(report, workflow_path, tree) - check_fused_fanout(report, workflow_path, tree) - - -def check_main_signature(report, workflow_path, main): - """V016 -- the platform sends exactly {"query": ...}.""" - if isinstance(main, ast.AsyncFunctionDef): - report.error( - "V016", - workflow_path, - main.lineno, - "`main` is `async def`", - "deploy() calls workflow_fn(**kwargs) on a Flask worker thread with " - "no await; the response body would be a coroutine repr.", - ) - params = parameter_names(main) - if not params: - report.error( - "V016", - workflow_path, - main.lineno, - "`main` takes no arguments", - 'The platform posts {"query": "..."} and deploy() splats the ' - "body in as kwargs -- TypeError on every request.", - ) - return - if params[0] != "query": - report.error( - "V016", - workflow_path, - main.lineno, - f"`main`'s first parameter is `{params[0]}`, not `query`", - "The platform's body schema is strictly validated as " - "{query: string}; any other key is rejected with 400 in the control " - "plane, before the request reaches the host.", - ) - extra = [p for p in required_parameters(main) if p != "query"] - if extra: - report.error( - "V016", - workflow_path, - main.lineno, - f"`main` requires {', '.join(extra)} beyond `query`", - "Only `query` is ever sent, so every other parameter needs a " - "default or the call raises on every request. Pack richer input " - "into `query`.", - ) - - -def check_main_guard(report, workflow_path, tree): - """V017 -- the workflow is exec'd, so __name__ == "__main__".""" - for node in tree.body: - if not isinstance(node, ast.If): - continue - test = node.test - if ( - isinstance(test, ast.Compare) - and isinstance(test.left, ast.Name) - and test.left.id == "__name__" - and any( - isinstance(c, ast.Constant) and c.value == "__main__" - for c in test.comparators - ) - ): - report.error( - "V017", - workflow_path, - node.lineno, - '`if __name__ == "__main__":` block in the workflow', - "workflow_launcher.py runs exec(open().read()), so " - "__name__ IS '__main__' here and this block executes in " - "production, at container start.", - ) - - -def check_fused_fanout(report, workflow_path, tree): - """V018 -- .value() blocks, so dispatching and resolving in one - comprehension runs the fan-out one call at a time.""" - comprehensions = (ast.ListComp, ast.SetComp, ast.GeneratorExp) - for node in ast.walk(tree): - if not isinstance(node, comprehensions + (ast.DictComp,)): - continue - elements = ( - [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] - ) - for element in elements: - for inner in ast.walk(element): - if ( - isinstance(inner, ast.Call) - and isinstance(inner.func, ast.Attribute) - and inner.func.attr == "value" - and isinstance(inner.func.value, ast.Call) - ): - report.error( - "V018", - workflow_path, - node.lineno, - "one comprehension both dispatches a call and resolves " - "it with .value()", - ".value() blocks, so each call completes before the " - "next is dispatched. It does not error -- the fan-out " - "is just silently serial, and with it the reason to be " - "on CanyonOS Core. Dispatch every call first, then resolve: " - "futures = [a.work(i) for i in items] then " - "[f.value() for f in futures].", - ) - return - - -# ------------------------------------------------------------------ # -# V019-V020 what the copy order overwrites # -# ------------------------------------------------------------------ # - - -def check_flat_collisions(report, project_dir, yaml_paths, entrypoints): - """V019 V020 -- later copies land on earlier ones at the context root.""" - for entry in sorted(os.listdir(project_dir)): - path = os.path.join(project_dir, entry) - if not os.path.isfile(path) or not entry.endswith(".py"): - continue - - # V019 -- the shared runtime is copied flat, after the project sweep. - if entry in RUNTIME_FLAT_NAMES: - report.error( - "V019", - path, - 1, - f"a project module named `{entry}` sits at the project root", - "The shared CanyonOS Core runtime is copied flat into the image after " - "the project sweep, so this file is overwritten by CanyonOS Core's own " - f"{entry}. Rename it or move it into a package directory.", - ) - - # V020 -- a stub is copied flat under its yaml's basename. - entrypoint_basenames = {os.path.basename(e) for e in entrypoints if e} - for yaml_path in yaml_paths: - stem = os.path.splitext(os.path.basename(yaml_path))[0] - module = f"{stem}.py" - if module in entrypoint_basenames: - continue # the entrypoint is copied last and wins its flat name back - candidate = os.path.join(project_dir, module) - if os.path.isfile(candidate): - report.error( - "V020", - candidate, - 1, - f"`{os.path.basename(yaml_path)}` generates a stub that lands on " - f"`{module}`", - "The yaml's basename names the stub, and the stub is copied flat " - "over the swept tree. Anything importing this module inside the " - "container gets the generated stub instead of the real code. " - "Rename the yaml to match its own entrypoint.", - ) - - -# ------------------------------------------------------------------ # -# V030-V031 capability-gated rules # -# ------------------------------------------------------------------ # - - -def check_env_file(report, config, config_path, project_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 ventis 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 `ventis` runtime. " - "Credentials have no declared path into a container on this tree.", - ) - return - - if not declared: - report.warn( - "V030", - config_path, - line_of(config), - "no `env_file:` in the config", - "Only runtime-managed VENTIS_* variables are guaranteed without it. " - "If the source reads credentials from the environment, the first " - "request fails on a provider error.", - ) - return - - # Path existence and readability are deploy-preflight checks. Do not - # duplicate them here. - - -def check_import_root(report, project_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(project_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - - non_flat = [] - for path in entrypoint_paths: - tree, _ = parse_python(path) - if tree is None: - continue - for name, lineno in toplevel_import_names(tree).items(): - if name in report.stub_module_names or f"{name}.py" in RUNTIME_FLAT_NAMES: - continue - if _resolves_flat(project_dir, name): - continue - location = _resolves_nested(project_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 " - "`ventis` runtime. Only names rooted at /app import inside a container.", - ) - for path, lineno, name, location in non_flat: - report.error( - "V031", - path, - lineno, - f"`import {name}` resolves to {location}, which is not at the " - "project root", - "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 project root " - "has no packaging metadata", - "A pyproject.toml, setup.py or setup.cfg at the port root is " - "what adds `-e .`; metadata nested inside the untouched source " - "tree is ignored. Add minimal root metadata pointing at the " - "existing package directory. Without it the install is skipped " - "silently.", - ) - - -def _resolves_flat(project_dir, name): - """Whether Python can resolve `name` with /app as its import root. - - A directory does not need __init__.py: PEP 420 namespace packages resolve - from sys.path just like regular packages. - """ - return os.path.isfile(os.path.join(project_dir, f"{name}.py")) or os.path.isdir( - os.path.join(project_dir, name) - ) - - -def _resolves_nested(project_dir, name): - """Where below /app `name` lives but cannot resolve as a top-level name.""" - for root, dirs, files in os.walk(project_dir): - dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] - if root == project_dir: - continue - if f"{name}.py" in files: - return os.path.relpath(os.path.join(root, f"{name}.py"), project_dir) - if name in dirs: - return os.path.relpath(os.path.join(root, name), project_dir) - return None - - -# ------------------------------------------------------------------ # -# W003, W006 secrets and imports a green build does not reject # -# ------------------------------------------------------------------ # - - -def check_secrets(report, port_paths): - """W003 -- env_file is the way in; nothing else is.""" - for path in port_paths: - try: - with open(path, "r", encoding="utf-8") as handle: - lines = handle.read().splitlines() - except OSError: - continue - for number, line in enumerate(lines, start=1): - for pattern, description in SECRET_PATTERNS: - if pattern.search(line): - report.warn( - "W003", - path, - number, - f"this line looks like {description}", - "Never put a secret in the source tree or the build " - "context. The build sweeps the project into every " - "image.", - ) - break - - tree, _ = parse_python(path) - if tree is None: - continue - for node in ast.walk(tree): - if not isinstance(node, ast.Assign): - continue - if not ( - isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - and node.value.value.strip() - ): - continue - for target in node.targets: - if isinstance(target, ast.Name) and SECRET_NAME.search(target.id): - report.warn( - "W003", - path, - node.lineno, - f"`{target.id}` is assigned a literal string", - "Read it from the environment instead; the build sweeps " - "this file into every image.", - ) - - -def _pyproject_dependencies(project_dir): - """What `-e .` installs alongside `requirements:`, or None if unreadable. - - None and the empty set mean different things here: empty means the project - declares no dependencies, None means we could not find out -- a setup.py, or - a tomllib this interpreter does not have. The caller must not treat the - second as the first, or it warns about imports the install would satisfy. - """ - path = os.path.join(project_dir, "pyproject.toml") - if not os.path.isfile(path): - return None - try: - import tomllib - except ImportError: # < 3.11 - return None - try: - with open(path, "rb") as handle: - data = tomllib.load(handle) - except Exception: # noqa: BLE001 - malformed metadata is uv's error to give - return None - deps = (data.get("project") or {}).get("dependencies") - if not isinstance(deps, list): - return None - return {_normalize_distribution(d) for d in deps if isinstance(d, str)} - - -def check_requirements_coverage( - report, project_dir, entry, entrypoint_path, config_path -): - """W006 -- an import the container cannot satisfy.""" - tree, _ = parse_python(entrypoint_path) - if tree is None: - return - - declared = { - _normalize_distribution(item) - for item in (entry.get("requirements") or []) - if isinstance(item, str) - } - - # Where the editable install exists, `-e .` resolves the project's own - # [project.dependencies] in the same pass as `requirements:`. Warning about - # those is a false positive, and a false warning about a dependency is worse - # than none: it teaches the reader to dismiss this check. - editable = report.capabilities.get("editable_install") - metadata = any( - os.path.isfile(os.path.join(project_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - unreadable_metadata = False - if editable and metadata: - project_deps = _pyproject_dependencies(project_dir) - if project_deps is None: - unreadable_metadata = True - else: - declared |= project_deps - base = {_normalize_distribution(item) for item in BASE_AGENT_REQUIREMENTS} - stdlib = getattr(sys, "stdlib_module_names", frozenset()) - - for name, lineno in sorted(toplevel_import_names(tree).items()): - if name in stdlib or name == "ventis": - continue - # Provided by the image itself: the shared runtime is copied flat, and - # every agents/*.yaml generates a stub that is copied flat too. - if f"{name}.py" in RUNTIME_FLAT_NAMES or name in report.stub_module_names: - continue - if _resolves_flat(project_dir, name) or _resolves_nested(project_dir, name): - continue - distribution = _normalize_distribution(IMPORT_TO_DISTRIBUTION.get(name, name)) - if distribution in base or distribution in declared: - continue - if unreadable_metadata: - mechanism = ( - "The container installs the base list, `requirements:`, and -- " - "since this project declares packaging metadata -- whatever " - "`-e .` resolves from it. That metadata could not be read here, " - f"so if it already requires `{name}` this line is noise; " - "otherwise it is a ModuleNotFoundError inside _load_agent and " - "'No agent loaded' on the first request." - ) - else: - mechanism = ( - "The container installs the base list plus `requirements:` and " - "nothing else, so this is a ModuleNotFoundError inside " - "_load_agent and 'No agent loaded' on the first request. If the " - f"distribution is named something other than `{name}`, declare " - f"that name in {report.rel(config_path)}." - ) - report.warn( - "W006", - entrypoint_path, - lineno, - f"`import {name}` is in neither the runtime's base list nor this " - "entry's `requirements:`", - mechanism, - ) - - -def _normalize_distribution(name): - return re.split(r"[<>=!\[;\s]", name.strip().lower(), maxsplit=1)[0].replace( - "_", "-" - ) - - -# ------------------------------------------------------------------ # -# Driver # -# ------------------------------------------------------------------ # - - -def validate(project_dir, config_path, capabilities): - """Inspect only failures hidden behind a successful image build.""" - report = Report(project_dir, capabilities) - - # The build owns config/YAML syntax and shape validation. We read only enough - # valid structure to locate code for the deeper checks below. - config, error = load_yaml(config_path) - if error is not None or not isinstance(config, dict): - report.unavailable( - "BUILD", - "runtime preflight skipped because the config cannot be read; " - "ventis build owns and reports this error.", - ) - return report - - import glob - - yaml_paths = sorted(glob.glob(os.path.join(project_dir, "agents", "*.yaml"))) - report.stub_module_names = { - os.path.splitext(os.path.basename(path))[0] for path in yaml_paths - } - - agents_by_name = {} - stub_classes = {} - for path in yaml_paths: - data, yaml_error = load_yaml(path) - agent = data.get("agent") if isinstance(data, dict) else None - name = agent.get("name") if isinstance(agent, dict) else None - if yaml_error is not None or not isinstance(name, str): - continue # ventis build reports malformed agent declarations - agents_by_name[name] = (path, agent) - stub_classes[os.path.splitext(os.path.basename(path))[0]] = name - - entries = config.get("agents") - if not isinstance(entries, list): - report.unavailable( - "BUILD", - "runtime preflight skipped because `agents:` is not a list; " - "ventis build owns and reports this error.", - ) - return report - - entrypoints = [] - for entry in entries: - if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": - continue - name = entry.get("name") - entrypoint = entry.get("entrypoint") - if isinstance(entrypoint, str): - entrypoints.append(entrypoint) - if name in agents_by_name: - yaml_path, agent_block = agents_by_name[name] - check_adapter(report, yaml_path, agent_block, entry, project_dir) - entrypoint_path = os.path.join(project_dir, entrypoint or "") - if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): - check_requirements_coverage( - report, project_dir, entry, entrypoint_path, config_path - ) - - for entry in entries: - if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": - continue - workflow_file = entry.get("workflow_file") - if not isinstance(workflow_file, str): - continue - workflow_path = os.path.join(project_dir, workflow_file) - if os.path.isfile(workflow_path): - check_workflow(report, workflow_path, stub_classes) - - # These survive a green build and otherwise surface only in a container or - # on its first request. - check_flat_collisions(report, project_dir, yaml_paths, entrypoints) - check_env_file(report, config, config_path, project_dir) - - entrypoint_paths = [ - os.path.join(project_dir, e) - for e in entrypoints - if os.path.isfile(os.path.join(project_dir, e)) - ] - check_import_root(report, project_dir, entrypoint_paths) - - port_paths = list(entrypoint_paths) - for entry in entries: - if isinstance(entry, dict) and isinstance(entry.get("workflow_file"), str): - candidate = os.path.join(project_dir, entry["workflow_file"]) - if os.path.isfile(candidate): - port_paths.append(candidate) - - # Secret detection remains because a green image build would permanently - # bake the credential into every image. - check_secrets(report, port_paths) - return report - - - -# ------------------------------------------------------------------ # -# Output # -# ------------------------------------------------------------------ # - -LEVEL_ORDER = {ERROR: 0, WARN: 1, INFO: 2} - - -def _wrap(text, width, indent): - words = text.split() - lines = [] - current = "" - for word in words: - candidate = f"{current} {word}".strip() - if len(candidate) + len(indent) > width and current: - lines.append(indent + current) - current = word - else: - current = candidate - if current: - lines.append(indent + current) - return lines - - -def print_report(report, project_dir): - caps = report.capabilities - if not caps.get("ventis"): - print("ventis is not importable here -- capability-gated rules are") - print("reported UNAVAILABLE rather than checked.\n") - else: - print("CanyonOS Core capabilities detected:") - for key, source in CAPABILITY_SOURCE.items(): - mark = "yes" if caps.get(key) else "no " - print(f" {mark} {key:<22} {source}") - print() - - findings = sorted( - report.findings, - key=lambda f: (LEVEL_ORDER[f["level"]], f["check"], f["path"], f["line"]), - ) - for finding in findings: - where = finding["path"] - if where and finding["line"]: - where = f"{where}:{finding['line']}" - header = f"{finding['check']} {finding['level']:<5}" - print(f"{header} {where}" if where else header) - for line in _wrap(finding["summary"], 78, " "): - print(line) - if finding["mechanism"]: - for line in _wrap(finding["mechanism"], 78, " "): - print(line) - print() - - errors, warnings = report.counts() - if not findings: - print(f"{project_dir}: clean.") - return - print(f"{errors} error(s), {warnings} warning(s).") - - -def main(argv=None): - parser = argparse.ArgumentParser( - description="Check a CanyonOS Core port against the rules in SKILL.md." - ) - parser.add_argument( - "project_dir", nargs="?", default=".", help="the port's project root" - ) - parser.add_argument( - "-c", - "--config", - default=DEFAULT_CONFIG_PATH, - help=f"config path relative to project_dir (default: {DEFAULT_CONFIG_PATH})", - ) - parser.add_argument("--json", action="store_true", help="emit findings as JSON") - parser.add_argument( - "--strict", action="store_true", help="fail on warnings as well as errors" - ) - args = parser.parse_args(argv) - - project_dir = os.path.abspath(args.project_dir) - config_path = ( - args.config - if os.path.isabs(args.config) - else os.path.join(project_dir, args.config) - ) - - capabilities = probe_capabilities() - report = validate(project_dir, config_path, capabilities) - errors, warnings = report.counts() - - if args.json: - print( - json.dumps( - { - "project_dir": project_dir, - "capabilities": capabilities, - "errors": errors, - "warnings": warnings, - "findings": report.findings, - }, - indent=2, - ) - ) - else: - print_report(report, report.rel(project_dir) or project_dir) - - if errors or (args.strict and warnings): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From c12c5c5b023c5c7c4a38d5273feac98b5665aa81 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 12:31:18 -0700 Subject: [PATCH 27/44] removed more useless code --- .../skills/porting-to-canyonos-core/SKILL.md | 240 ---- .../references/ec2.md | 41 - .../references/llm-proxy.md | 64 - .../references/packaging.md | 81 -- .../references/runtime-contract.md | 187 --- .../references/troubleshooting.md | 60 - .../porting-to-canyonos-core/validate.py | 1257 ----------------- 7 files changed, 1930 deletions(-) delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/SKILL.md delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/ec2.md delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/packaging.md delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md delete mode 100644 cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md delete mode 100755 cli/.claude/skills/porting-to-canyonos-core/validate.py diff --git a/cli/.claude/skills/porting-to-canyonos-core/SKILL.md b/cli/.claude/skills/porting-to-canyonos-core/SKILL.md deleted file mode 100644 index fe6dd49..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/SKILL.md +++ /dev/null @@ -1,240 +0,0 @@ ---- -name: porting-to-canyonos-core -description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. -compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. ---- - -# Port an agent project to CanyonOS Core - -CanyonOS Core is the product name. Its compatibility executable and Python -package remain `ventis`; environment variables and Docker resources retain the -`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding -strings. Do not rename them. - -## Load references only when needed - -- Read [references/packaging.md](references/packaging.md) when a source import - does not resolve from `/app`, the source is nested, or packaging metadata is - involved. -- Read [references/llm-proxy.md](references/llm-proxy.md) only when the target - includes `llm_proxy`. -- Read [references/ec2.md](references/ec2.md) only when any config entry uses - `provider: EC2`. -- Read [references/troubleshooting.md](references/troubleshooting.md) after a - failed build, image probe, deploy, or request. -- Read [references/runtime-contract.md](references/runtime-contract.md) when a - validator finding needs explanation or the runtime mechanism is unclear. - -## Goal: thin scaffolding beside untouched source - -```text -agents/.yaml one callable surface per service -agents/.py one thin adapter per service, when needed -workflow/_workflow.py HTTP entry point; calls deploy() -config/global_controller.yaml deployment manifest -config/policy.yaml optional access restriction -pyproject.toml conditional nested-import scaffolding - unchanged -``` - -The file count follows the deployment. A multi-agent port has one yaml/adapter -pair per service that is worth deploying separately. If a source class already -satisfies the runtime contract, point its config entry at that file and do not -copy it into an adapter. - -Everything the source already owns—prompts, tools, schemas, parsing, retries, -model clients, and node bodies—is imported. The port re-expresses only the -CanyonOS Core boundary and framework-owned orchestration. - -The port root is the existing repository root and the directory from which -`ventis build` runs. Write scaffolding there beside existing directories. If the -repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, -and `config/` beside it. Never move or copy the repository into a new `src/` -directory, and never create an outer wrapper merely for the port. - -## 1. Survey before writing - -Identify: - -1. The source entry point and callable input/output. -2. Framework-owned control flow (`StateGraph`, `Crew`, `GroupChat`, routing, - `Send`, `Command`, interrupts). -3. Runtime-injected services nodes read: stores, context, memory, sessions, or - callback managers. -4. Sync versus async boundaries. -5. Imports and declared runtime dependencies. -6. Model provider, credential names, streaming use, and optional `llm_proxy`. -7. Whether independent work fans out and benefits from separate replicas. -8. Whether source imports resolve from the project root that becomes `/app`. - -Run the validator once now. Its header detects capabilities directly from the -importable runtime rather than external development metadata: - -```bash -python /validate.py . -``` - -If config or agent yaml is malformed, the validator defers to `ventis build`. -Capability-gated findings say which runtime behavior is available. - -## 2. Choose service boundaries - -Start with one service. Split only when it creates independent parallel work or -a distinct resource/replica profile. - -- Keep a ReAct loop together; every turn needs shared message history. -- Hoist supervisor task lists and `Send`-style fan-out into the workflow. -- Do not create a one-replica service with no distinct resource profile merely - to mirror every source graph node. - -Rewrite framework-owned edges as ordinary Python. Import the connected node -functions unchanged. Construct runtime-injected service objects from source -configuration; do not invent models, dimensions, stores, or defaults silently. -Report any choice the source does not specify. - -## 3. Write declarations and adapters - -### Agent yaml - -Use one yaml per deployed service. Argument types are bare builtins only: -`str`, `int`, `float`, `bool`, `dict`, or `list`. Every declared argument is -required by the generated stub. `returns.type` is documentation; use `dict` or -`list` to signal that workflow callers must `json.loads` the returned string. - -### Adapter - -The entrypoint exposes a module-level class named exactly `agent.name`. It -constructs with no arguments and its declared methods are synchronous. Read -configuration from the environment in `__init__`. Bridge source coroutines -inside a synchronous method with `asyncio.run(...)`. Serialize framework objects -with their own JSON-safe serializer before returning. - -Do not duplicate source prompts, tools, schemas, or model calls. Keep the source -provider and SDK. - -### Workflow - -Expose `main(query: str)` and call `deploy(main, port=...)` at module scope. -Import generated stubs by yaml basename and agent class name: - -```python -from deploy import deploy -from agents. import -``` - -The deployment platform sends `{query: string}` to `/main`. Pack richer input -inside `query`; any additional workflow parameter has a default. - -Dispatch every remote call before resolving any future: - -```python -futures = [agent.work(item=item) for item in items] -results = [json.loads(future.value()) for future in futures] -``` - -Do not fuse dispatch and `.value()` in one comprehension; that silently -serializes fan-out. Do not add an `if __name__ == "__main__":` block: the -workflow is executed with `__name__ == "__main__"` in production. - -### Config - -For each service, keep these names aligned: - -```text -config entry name == yaml agent.name == entrypoint class name -``` - -Use lowercase `provider: local`; `replicas` is an integer; `requirements` is a -list of distribution-name strings. Put `env_file` at config top level when the -runtime capability is available. Omit `policy.yaml` unless access must be -restricted; if present, give it a non-empty `rules` list. - -## Hard rules - -Capitalized **MUST** and **NEVER** are reserved for port-breaking or -source-integrity rules. The owner column states where each is decided. - -| ID | Rule | Owner | -|---|---|---| -| M1 | Entrypoint MUST define a class named exactly `agent.name` | V006 | -| M2 | The class MUST construct with no arguments | V007 | -| M3 | yaml argument names MUST match Python parameter names | V008 | -| M4 | yaml argument types MUST be bare builtins | V010 | -| M5 | Declared adapter methods MUST be synchronous | V009 | -| M6 | Config names MUST match yaml agent names | build | -| M7 | Config names MUST not collide after lowercase normalization | build output | -| M8 | Local provider MUST be lowercase `local` | deploy preflight | -| M9 | `replicas` MUST be an integer | deploy preflight | -| M10 | `requirements` MUST be a list of strings | build | -| M11 | Workflow MUST expose `main(query)`; extra parameters MUST default | V016 | -| M12 | Workflow MUST NEVER contain a main guard | V017 | -| M13 | Fan-out MUST dispatch all calls before resolving any | V018 | -| M14 | Project modules MUST not take runtime or generated-stub flat names | V019/V020 | -| M15 | Workflow MUST import stubs from `agents.` | V023 | -| M16 | Policy MUST be absent or contain a non-empty `rules` list | deploy preflight | -| M17 | EC2 entries MUST satisfy the EC2 deployment contract | deploy preflight | -| M18 | NEVER copy source prompts, tools, schemas, or model calls | review | -| M19 | NEVER hardcode or bake a real credential into an image | W003 | -| M20 | NEVER edit or vendor the source tree | `git status` | -| M21 | NEVER swap the source LLM provider | review | -| M22 | NEVER silently move, drop, or reclassify source dependencies | review | -| M23 | Framework control flow MUST be rewritten; source node logic MUST be imported | review | -| M24 | A non-resolving source import MUST have usable root packaging metadata when editable install is supported | V031 | - -## 4. Validate, build, and probe - -Run static preflight, then let the build own build-time validation: - -```bash -python /validate.py . -ventis build -c config/global_controller.yaml -``` - -A green build never imports the adapter. Probe each agent image in this order: - -```bash -# Runtime startup path - docker run --rm ventis- \ - python -c "import local_controller" - -# Agent load path; include --env-file when configured - docker run --rm --env-file ventis- \ - python -c "import importlib.util,sys; \ -s=importlib.util.spec_from_file_location('m','.py'); \ -m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ -m.();print('ok')" -``` - -Also probe the workflow image with `python -c "import local_controller"`; it has -its own dependency resolve and generated-stub imports. - -Then deploy, send a representative request, and poll its status: - -```bash -ventis deploy -c config/global_controller.yaml -curl -X POST http://localhost:8080/main \ - -H 'Content-Type: application/json' -d '{"query":""}' -curl http://localhost:8080/status/ -``` - -A successful outer request with a source-level failure still proves the port -reached and returned the source behavior. Record the distinction. - -## 5. Clean up - -After collecting evidence, stop foreground deploy with Ctrl+C and wait for -controller cleanup. Remove exact leftovers if startup crashed. Then remove build -products and exact images from this config: - -```bash -ventis clean -docker image rm ventis- \ - ventis- - -test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container -docker ps -a --format '{{.Names}}' -``` - -`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it -does not remove containers or images. Keep port scaffolding, untouched source, -and requested logs or reports. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md b/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md deleted file mode 100644 index e06daa0..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/references/ec2.md +++ /dev/null @@ -1,41 +0,0 @@ -# EC2 deployment - -Read this only when at least one config entry uses `provider: EC2`. - -## Configuration - -Use `provider: EC2` and declare `instance_type` on every EC2 service entry. The -top-level `ec2` block supplies the runtime's required infrastructure and SSH -settings. Read the target checkout's deploy preflight and EC2 runtime before -writing the block; do not copy values from an example environment. - -Typical required categories are: - -- AMI and instance type -- region and subnet -- security groups -- SSH user and credentials accepted by the runtime - -`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof -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. - -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 -target runtime rather than assuming local Docker semantics. - -## Probes and cleanup - -Run the same runtime and adapter probes against the exact image before remote -deployment. After deploy, verify the remote container logs; controller health -can be green even when agent loading failed. - -Stop foreground deploy normally so the controller can terminate recorded EC2 -instances. If provisioning or startup fails before an instance is recorded, -inspect the cloud provider directly and remove exact leaked resources. Never use -a broad cleanup command against unrelated instances. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md deleted file mode 100644 index f726bd7..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md +++ /dev/null @@ -1,64 +0,0 @@ -# LLM proxy integration - -Read this only when the target checkout contains `llm_proxy` or the deployment -explicitly routes model SDKs through it. - -## Preserve provider protocols - -The proxy redirects provider endpoints; it does not convert providers. Keep the -source SDK, model ID, request body, and response parsing unchanged. - -Configure only the provider variables the source uses: - -```dotenv -OPENAI_BASE_URL=http://host.docker.internal:8081/openai/v1 -ANTHROPIC_BASE_URL=http://host.docker.internal:8081/anthropic -AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://host.docker.internal:8081/bedrock -``` - -Some SDKs refuse to initialize without caller credentials. Give agent containers -non-secret placeholders only when required. Keep real OpenAI, Anthropic, or AWS -credentials in the separate proxy process, not in the port's `env_file`. - -## Start locally - -The proxy defaults conflict with a typical deployment: host loopback is not -reachable from a container, and port 8080 is normally used by the workflow API. -Use a non-loopback bind and a different port: - -```bash -PROXY_HOST=0.0.0.0 PROXY_PORT=8081 python -m llm_proxy -curl http://127.0.0.1:8081/healthz -``` - -Local CanyonOS Core containers resolve `host.docker.internal` through their -Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the -machine running `ventis deploy`. Distributed deployments need a reachable proxy -address or one proxy on each host. - -## Supported call shape - -The implementation buffers complete requests and responses: - -- OpenAI and Anthropic non-streaming HTTP calls are forwarded. -- Bedrock `invoke` is reissued through the proxy's boto3 client. -- OpenAI/Anthropic streaming is unsupported. -- Bedrock `invoke-with-response-stream`, `converse`, and `converse-stream` are - unsupported. - -Survey the source before selecting the proxy. Do not silently disable streaming; -report the unsupported behavior and stop. - -## Credential behavior - -- The OpenAI adapter removes caller authorization and inserts the proxy key. -- The Anthropic adapter removes caller key headers and inserts the proxy key. -- Botocore still signs requests sent to a custom endpoint, so a caller may need - placeholder AWS credentials even though the proxy reissues upstream with its - own identity. -- `/healthz` proves provider registration and Flask availability, not upstream - credential validity. - -OpenAI and Anthropic upstream HTTP errors pass through. Proxy exceptions return -JSON 502 with `error: proxy_error`. Bedrock `ClientError` bodies are reconstructed -with the upstream status and are not byte-for-byte passthrough. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md b/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md deleted file mode 100644 index 8520c4a..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/references/packaging.md +++ /dev/null @@ -1,81 +0,0 @@ -# Packaging and import roots - -Read this reference when an adapter imports nested source code, the source uses a -`src/` layout, or V031 reports an import-root problem. - -## What `/app` can import - -CanyonOS Core preserves project-relative paths in the image and starts Python at -`/app`. Without an editable install, Python resolves names rooted there: - -- `/app/tools.py` as `import tools` -- `/app/pkg/__init__.py` as `import pkg` -- `/app/src/agents/...` as `import src.agents`, including PEP 420 namespace - directories without `__init__.py` - -It does not resolve `/app/source/pkg` as `import pkg`; `/app/source` must become -an import root first. - -## Detect support, do not infer it from release history - -Run: - -```bash -python /validate.py . -``` - -Read the `editable_install` capability. If it is unavailable and the original -import cannot resolve from `/app`, report a runtime capability blocker and stop. -Do not add a `sys.path` hack or relocate source files. - -## Root metadata is the trigger - -When editable install is supported, only packaging metadata at the **port root** -triggers `pip install -e .`: - -```text -port-root/pyproject.toml detected -port-root/source/pyproject.toml ignored as an install trigger -``` - -A nested source repository may remain untouched. Add minimal root scaffolding -that points package discovery at the existing source package: - -```toml -[build-system] -requires = ["setuptools>=64"] -build-backend = "setuptools.build_meta" - -[project] -name = "canyonos-port" -version = "0.0.0" -dependencies = [] - -[tool.setuptools.packages.find] -where = ["source/src"] -include = ["pkg*"] -namespaces = true -``` - -Set `where` and `include` from the actual tree and original import spelling. Do -not reference a README or license from this wrapper metadata; file sweeps differ -by runtime capability and a missing referenced file makes the image build fail. - -## Dependencies in nested metadata - -A nested `pyproject.toml` is not installed merely because its Python files are -copied. Keep the source declaration unchanged and repeat its runtime -distributions in each relevant config entry's `requirements` list. This is -compatibility scaffolding, not permission to drop, move, or reclassify declared -dependencies. - -If source metadata is already at the port root, do not create a wrapper. Its -project dependencies participate in the same resolver as config requirements. -Report declared-but-unused toolchain dependencies and their image cost; let the -owner decide whether source metadata should change. - -## Validation boundary - -`ventis build` owns packaging syntax and installation errors. `validate.py` -checks only whether adapter imports appear to require a nested root that the -runtime will not expose. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md deleted file mode 100644 index 94f7401..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md +++ /dev/null @@ -1,187 +0,0 @@ -# CanyonOS Core runtime contract - -The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the -CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for -Docker resources. - -Read this reference when implementing an adapter or explaining a validator -finding. Runtime-dependent behavior is expressed as capabilities; run -`validate.py` against the target environment instead of inferring support from -release history. - -## Project root and discovery - -`ventis build` uses the current working directory as the project root. - -| Input | Discovery | -|---|---| -| `agents/*.yaml` | direct yaml glob under the project root | -| `config/global_controller.yaml` | default config, overridable with `-c` | -| workflow | `workflow_file` on a `type: workflow` entry | -| policy | `policy.yaml` beside the selected config file | -| generated files | `stubs/`, `grpc_stubs/`, `docker_container/` | - -The config name, yaml `agent.name`, and entrypoint class name form one binding: - -```text -config entry name == yaml agent.name == entrypoint class name -``` - -A missing match may skip an image while the command continues, so inspect build -output and generated image tags. - -## Agent yaml and generated stubs - -The consumed yaml shape is: - -```yaml -agent: - name: ExampleAgent - functions: - - name: work - arguments: - - name: query - type: str - returns: - type: dict -``` - -Argument annotations are generated from bare names without adding imports. Use -builtins. Generated methods have no defaults, so every declared argument is -required at the stub call site. `returns` does not control runtime conversion; -it documents whether workflow code should parse the returned string. - -Stub destinations differ by runtime capability and entrypoint layout. Workflow -code in this port convention imports the generated class from -`agents.`. The validator checks that import against declarations -before the workflow image starts. - -## Agent loading and execution - -The local controller effectively performs: - -```python -module = load(entrypoint) -agent_class = getattr(module, configured_name) -agent = agent_class() -result = getattr(agent, method_name)(**args) -``` - -Consequences: - -- The class is module-level and named exactly as configured. -- Construction takes no arguments. -- Declared methods accept yaml argument names as keyword arguments. -- Methods are synchronous; this path does not await a coroutine. -- Dicts and lists are JSON-encoded before entering Redis; other results become - strings. -- A remote Future's `.value()` returns text, not the original Python object. - -Agent import and construction exceptions are caught by the controller. A failed -agent may still advertise healthy because health is written independently of -successful agent loading. That is why image probes import both the runtime and -the entrypoint explicitly. - -## Workflow execution - -The workflow file is executed, not imported. Therefore: - -- module-level code runs at container startup; -- `__name__ == "__main__"`; -- `deploy()` blocks in the web server; -- the workflow function runs once per request; -- its function name determines the REST route exposed by the compatibility - runtime. - -The deployment platform additionally expects `/main` with a `{query: string}` -body. This platform constraint is stricter than the underlying transport. - -Each stub method returns a Future immediately. `.value()` blocks. Dispatching -and resolving inside one comprehension serializes work without raising an -error; dispatch all calls first, then resolve them. - -The workflow container also starts runtime controller code and has its own -package resolution. Probe it independently from agent images. - -## Build context and collisions - -The runtime copies project files while preserving relative paths, then writes -shared runtime modules, generated stubs, and entrypoints into the image. Later -writes can shadow project files. - -Avoid root project modules named like runtime files, including: - -```text -future.py -ventis_context.py -local_controller.py -local_controller_frontend.py -redis_client.py -grpc_options.py -bedrock.py -deploy.py -session_logging.py -workflow_launcher.py -``` - -Also avoid a yaml basename that shadows a different source module imported by an -adapter. The validator checks deterministic flat-name collisions. - -File sweep and editable-install behavior are runtime capabilities. For nested -imports, follow [packaging.md](packaging.md). - -## Dependencies and protobuf - -Agent and workflow images include a small runtime dependency set. Config -`requirements` adds source-specific distributions. A malformed requirements -value can be normalized away while image generation continues; missing imports -then surface only when the agent loads. - -The build compiles gRPC Python stubs on the host and copies them into images. -The image resolver does not necessarily know the generated-code version. A -source dependency that constrains protobuf below the host generator version can -produce a green image build that dies on: - -```text -import local_controller -``` - -Always run that probe before probing the entrypoint. Treat a generated-code / -runtime-version mismatch as a CanyonOS Core runtime issue, not a reason to alter -source dependencies silently. - -## Credentials capability - -When `env_file` capability is available, the top-level config path is resolved -against the project root and passed at container start. Hidden env files are not -copied into images. Invalid paths are deploy-preflight errors. - -When the capability is unavailable, declaring `env_file` has no effect. If the -source needs credentials, report the capability blocker rather than hardcoding -or vendoring a secret. - -A source that constructs its client at import time works only when credentials -are already in the container environment. Image entrypoint probes therefore use -the same env file as deployment. - -For proxy-specific credential separation, read [llm-proxy.md](llm-proxy.md). - -## Policy and provider behavior - -No policy file means unrestricted service access. If a policy exists, it needs a -non-empty rules list. Rules are evaluated by specificity and first match; -services excluded from the selected rule fail after request acceptance. - -Local provider handling is case-sensitive: use lowercase `local`. EC2 behavior -and remote networking are covered in [ec2.md](ec2.md). - -## Cleanup boundary - -Stopping foreground deploy normally invokes controller cleanup for recorded -containers and Redis. Hard kills and failures before resource registration may -leave resources behind. - -`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and -`docker_container/`. It does not remove containers or images. Remove exact -leftovers explicitly and preserve source, port scaffolding, and requested -evidence. diff --git a/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md deleted file mode 100644 index 361acf9..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md +++ /dev/null @@ -1,60 +0,0 @@ -# Troubleshooting - -Read this after a failed build, image probe, deploy, or request. For mechanisms, -read [runtime-contract.md](runtime-contract.md). For proxy or remote-host failures, -read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). - -## Build or deploy stops early - -| Symptom | Likely cause | -|---|---| -| Agent image is missing | Config name matched no yaml name, entrypoint is absent, or build skipped it; inspect build warnings | -| Two services produce one image | Config names collide after lowercase normalization | -| `generated grpc_stubs are missing` | Build did not complete on this host, or generated output was cleaned before deploy | -| `int(... NoneType)` during local deploy | Local provider was not written exactly as lowercase `local` | -| Replica conversion `TypeError` | `replicas` is not an integer | -| Policy `AttributeError` | Policy exists but is empty or has null/non-list rules; remove it when unrestricted | -| Port or container name already in use | A previous deployment did not complete cleanup | - -## Container exits or serves nothing - -| Symptom | Likely cause | -|---|---| -| `import local_controller` raises protobuf version error | Host-generated gRPC code is newer than the image's protobuf runtime | -| First request says `No agent loaded` | Entrypoint import, class lookup, or constructor failed and the controller swallowed the exception; inspect container logs | -| Replica is healthy but serves nothing | Health publication does not prove successful agent loading | -| Missing credentials while loading | Env injection is unavailable/misconfigured or the source reads another variable | -| Source module is missing | Original import does not resolve from `/app`; read [packaging.md](packaging.md) | -| Third-party module is missing | Distribution is absent from source metadata and config requirements | -| Stub import raises `NameError` | yaml argument type is not a bare builtin | -| Source module behaves like an empty stub | A generated stub basename shadowed the source module | -| Runtime-named project module disappears | Shared runtime copy overwrote a root project module with the same name | - -## Request is accepted, then fails - -| Symptom | Likely cause | -|---|---| -| Unexpected keyword argument | yaml argument name differs from adapter parameter name | -| Required argument missing | yaml does not declare a required adapter parameter, or workflow platform sent only `query` | -| Unauthorized service | The first matching policy rule excludes that service | -| `.value()` returns dict-like text | Expected; remote values are strings, so use `json.loads` | -| Object is not JSON serializable | Adapter returned framework objects without its JSON-safe serializer | -| Redis contains a coroutine repr | Adapter method is async and the controller did not await it | -| Fan-out is no faster | Dispatch and `.value()` were fused, serializing the calls | -| Debug block runs at startup | Workflow is executed with `__name__ == "__main__"` | - -## Deployment platform endpoint - -| Symptom | Likely cause | -|---|---| -| 404 while workflow container is healthy | Workflow function is not named `main` | -| 400 before host receives request | Body is not the platform's `{query: string}` shape | -| Extra workflow argument is missing | Platform sends only `query`; extra parameters need defaults | - -## Cleanup - -| Symptom | Likely cause | -|---|---| -| `ventis clean` succeeds but containers remain | The command removes generated directories only | -| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | -| Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/cli/.claude/skills/porting-to-canyonos-core/validate.py b/cli/.claude/skills/porting-to-canyonos-core/validate.py deleted file mode 100755 index 04baf37..0000000 --- a/cli/.claude/skills/porting-to-canyonos-core/validate.py +++ /dev/null @@ -1,1257 +0,0 @@ -#!/usr/bin/env python3 -"""Preflight the runtime traps that `ventis build` cannot see. - -This deliberately does not duplicate build-time validation such as malformed -YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those -checks. This script parses Python without importing it and catches failures that -otherwise stay hidden until a container loads an agent, starts a workflow, or -serves its first request. A replica is not evidence: the controller writes -`healthy` to Redis before `_load_agent` runs. - - python validate.py [project_dir] [-c config/global_controller.yaml] - [--json] [--strict] - -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 `ventis` package directly. A capability-gated check reports -UNAVAILABLE when its behavior cannot be proven. -""" - -import argparse -import ast -import builtins -import json -import os -import re -import sys -from typing import ClassVar - -try: - import yaml -except ImportError: # pragma: no cover - pyyaml is a CanyonOS Core dependency - sys.stderr.write("validate.py needs pyyaml: pip install pyyaml\n") - raise SystemExit(2) from None - - -DEFAULT_CONFIG_PATH = "config/global_controller.yaml" - -# Copied flat into every image over the swept project tree, so a project module -# landing flat under one of these names is overwritten. -# ventis/stub_generator.py generate_docker / generate_workflow_docker. -RUNTIME_FLAT_NAMES = frozenset( - { - "future.py", - "ventis_context.py", - "local_controller.py", - "local_controller_frontend.py", - "redis_client.py", - "grpc_options.py", - "gpu_metrics.py", - "bedrock.py", - "deploy.py", - "session_logging.py", - "workflow_launcher.py", - } -) - -# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. -BASE_AGENT_REQUIREMENTS = [ - "grpcio", - "grpcio-tools", - "redis", - "pyyaml", - "psutil", - "ipdb", - "ipython", - "boto3", -] -# Import name -> distribution name, for the handful where they differ and the -# mismatch would otherwise be reported as a missing requirement. -IMPORT_TO_DISTRIBUTION = { - "attr": "attrs", - "bs4": "beautifulsoup4", - "cv2": "opencv-python", - "dateutil": "python-dateutil", - "dotenv": "python-dotenv", - "grpc": "grpcio", - "grpc_tools": "grpcio-tools", - "jwt": "pyjwt", - "PIL": "pillow", - "psycopg": "psycopg", - "psycopg2": "psycopg2-binary", - "pydantic_settings": "pydantic-settings", - "sklearn": "scikit-learn", - "typing_extensions": "typing-extensions", - "yaml": "pyyaml", -} - -SECRET_PATTERNS = [ - (re.compile(r"sk-[A-Za-z0-9_-]{20,}"), "an OpenAI-style secret key"), - (re.compile(r"AKIA[0-9A-Z]{16}"), "an AWS access key id"), - (re.compile(r"gh[pousr]_[A-Za-z0-9]{20,}"), "a GitHub token"), - (re.compile(r"AIza[0-9A-Za-z_-]{30,}"), "a Google API key"), -] -SECRET_NAME = re.compile(r"(API_KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL)", re.IGNORECASE) - -ERROR = "ERROR" -WARN = "WARN" -INFO = "INFO" - - -# ------------------------------------------------------------------ # -# Capabilities # -# ------------------------------------------------------------------ # -# -# Stable labels for behavior detected from the importable runtime. They contain -# no external development metadata. - -CAPABILITY_SOURCE = { - "env_file": "runtime env-file injection", - "editable_install": "editable project installation", - "sweeps_all_files": "full project-file sweep", - "stub_two_destinations": "flat and package stub destinations", -} - - -def probe_capabilities(): - """Ask the importable ventis package what it actually supports.""" - caps = dict.fromkeys(CAPABILITY_SOURCE, False) - caps["ventis"] = False - try: - from ventis import stub_generator - except Exception: # noqa: BLE001 - a broken install must not crash the check - return caps - - caps["ventis"] = True - caps["editable_install"] = hasattr(stub_generator, "_install_step") - caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") - caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") - - import importlib - - for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): - try: - module = importlib.import_module(module_name) - except Exception: # noqa: BLE001,S112 - the other path is the live one - continue - if hasattr(module, "resolve_env_file"): - caps["env_file"] = True - break - return caps - - -# ------------------------------------------------------------------ # -# YAML with line numbers # -# ------------------------------------------------------------------ # - - -class LineDict(dict): - """A mapping that remembers where it and each of its keys were written.""" - - line = 0 - key_lines: ClassVar[dict] = {} - - -class LineLoader(yaml.SafeLoader): - pass - - -def _construct_mapping(loader, node): - data = LineDict() - yield data - data.update(loader.construct_mapping(node, deep=False)) - data.line = node.start_mark.line + 1 - data.key_lines = { - key.value: key.start_mark.line + 1 - for key, _ in node.value - if isinstance(key, yaml.ScalarNode) - } - - -LineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping -) - - -def line_of(mapping, key=None): - """The source line of `key` inside `mapping`, or of the mapping itself.""" - if not isinstance(mapping, LineDict): - return 0 - if key is not None: - return mapping.key_lines.get(key, mapping.line) - return mapping.line - - -def load_yaml(path): - """Parse `path`, returning (data, error). Never raises.""" - try: - with open(path, "r", encoding="utf-8") as handle: - return yaml.load(handle, Loader=LineLoader), None - except Exception as exc: # noqa: BLE001 - any parse failure is a finding - return None, str(exc) - - -# ------------------------------------------------------------------ # -# Findings # -# ------------------------------------------------------------------ # - - -class Report: - def __init__(self, project_dir, capabilities): - self.project_dir = project_dir - self.capabilities = capabilities - self.findings = [] - # A peer agent is imported by the name of its generated stub, which the - # build copies flat into every image. Those are not project modules and - # need no requirement. - self.stub_module_names = set() - - def add(self, check, level, path, line, summary, mechanism): - self.findings.append( - { - "check": check, - "level": level, - "path": self.rel(path) if path else "", - "line": line or 0, - "summary": summary, - "mechanism": mechanism, - } - ) - - def error(self, check, path, line, summary, mechanism): - self.add(check, ERROR, path, line, summary, mechanism) - - def warn(self, check, path, line, summary, mechanism): - self.add(check, WARN, path, line, summary, mechanism) - - def unavailable(self, check, summary): - self.add(check, INFO, "", 0, summary, "") - - def rel(self, path): - try: - return os.path.relpath(path, self.project_dir) - except ValueError: - return path - - def counts(self): - errors = sum(1 for f in self.findings if f["level"] == ERROR) - warnings = sum(1 for f in self.findings if f["level"] == WARN) - return errors, warnings - - -# ------------------------------------------------------------------ # -# Python source helpers # -# ------------------------------------------------------------------ # - - -def parse_python(path): - """AST for `path`, or (None, error). The port is never imported.""" - try: - with open(path, "r", encoding="utf-8") as handle: - source = handle.read() - except OSError as exc: - return None, str(exc) - try: - return ast.parse(source, filename=path), None - except SyntaxError as exc: - return None, f"{exc.msg} (line {exc.lineno})" - - -def find_class(tree, name): - for node in tree.body: - if isinstance(node, ast.ClassDef) and node.name == name: - return node - return None - - -def class_methods(class_node): - return { - node.name: node - for node in class_node.body - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - } - - -def parameter_names(func_node): - """Every parameter a caller can pass by keyword, minus self.""" - args = func_node.args - positional = [a.arg for a in args.posonlyargs + args.args] - if positional and positional[0] in ("self", "cls"): - positional = positional[1:] - return positional + [a.arg for a in args.kwonlyargs] - - -def required_parameters(func_node): - """Parameters with no default, minus self.""" - args = func_node.args - positional = [a.arg for a in args.posonlyargs + args.args] - if positional and positional[0] in ("self", "cls"): - positional = positional[1:] - if args.defaults: - positional = positional[: len(positional) - len(args.defaults)] - kwonly = [ - arg.arg - for arg, default in zip(args.kwonlyargs, args.kw_defaults) - if default is None - ] - return positional + kwonly - - -def toplevel_import_names(tree): - """Top-level package name of every import in the module, with line numbers.""" - names = {} - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - names.setdefault(alias.name.split(".")[0], node.lineno) - elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: - names.setdefault(node.module.split(".")[0], node.lineno) - return names - - -# ------------------------------------------------------------------ # -# V006-V010 adapter failures hidden by _load_agent # -# ------------------------------------------------------------------ # - -BUILTIN_TYPE_NAMES = frozenset( - name - for name in dir(builtins) - if isinstance(getattr(builtins, name), type) and not name[0].isupper() -) - - -def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): - """V006 V007 V008 V009 V010.""" - name = agent_block["name"] - functions = agent_block.get("functions") or [] - check_argument_types(report, agent_yaml_path, functions) - - entrypoint = entry.get("entrypoint") - if not entrypoint: - return - entrypoint_path = os.path.join(project_dir, entrypoint) - if not os.path.isfile(entrypoint_path): - return - - tree, error = parse_python(entrypoint_path) - if tree is None: - report.error( - "V006", - entrypoint_path, - 0, - f"the entrypoint does not parse: {error}", - "_load_agent exec_module's it and swallows the exception; the first " - "request answers 'No agent loaded'.", - ) - return - - class_node = find_class(tree, name) - if class_node is None: - classes = [n.name for n in tree.body if isinstance(n, ast.ClassDef)] - found = ", ".join(classes) if classes else "no classes at all" - report.error( - "V006", - entrypoint_path, - 1, - f"no class named `{name}` at module level (found: {found})", - "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " - "the AttributeError. The class name must equal agent.name exactly.", - ) - return - - methods = class_methods(class_node) - check_constructor(report, entrypoint_path, name, methods) - - for func in functions: - if not isinstance(func, dict) or not isinstance(func.get("name"), str): - continue - check_method(report, entrypoint_path, agent_yaml_path, name, func, methods) - - -def check_argument_types(report, agent_yaml_path, functions): - """V010 -- the type string is pasted into an ast.Name, never checked.""" - for func in functions: - if not isinstance(func, dict): - continue - for arg in func.get("arguments") or []: - if not isinstance(arg, dict) or "type" not in arg: - continue - declared = arg.get("type") - if not isinstance(declared, str): - continue # stub generation reports malformed type values - if declared in BUILTIN_TYPE_NAMES: - continue - report.error( - "V010", - agent_yaml_path, - line_of(arg, "type"), - f"`type: {declared}` is not a builtin", - "stub_generator pastes it verbatim into the generated " - "annotation, and the stub module imports only Future and " - "inspect. Anything else raises NameError when the stub is " - "imported -- after a green build. Use str int float bool dict " - "list.", - ) - - -def check_constructor(report, entrypoint_path, name, methods): - """V007 -- _load_agent calls agent_class() with no arguments.""" - init = methods.get("__init__") - if init is None: - return - required = required_parameters(init) - if required: - report.error( - "V007", - entrypoint_path, - init.lineno, - f"`{name}.__init__` requires {', '.join(required)}", - "_load_agent calls agent_class() with no arguments; the TypeError is " - "swallowed and the first request answers 'No agent loaded'. Read " - "configuration from the environment inside __init__ instead.", - ) - - -def check_method(report, entrypoint_path, agent_yaml_path, class_name, func, methods): - """V008 V009.""" - func_name = func["name"] - method = methods.get(func_name) - if method is None: - report.error( - "V008", - entrypoint_path, - 0, - f"`{class_name}` has no method `{func_name}`", - "The yaml declares it, so callers get a stub for it; the controller " - f"then answers \"Agent {class_name} has no method '{func_name}'\".", - ) - return - - # V009 -- nothing on the execution path awaits. - if isinstance(method, ast.AsyncFunctionDef): - report.error( - "V009", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` is `async def`", - "The executor calls method(**args) with no await, so Redis receives " - "''. Keep the signature synchronous and call " - "asyncio.run(...) inside the body.", - ) - - # V008 -- the controller calls method(**args) with the yaml's names. - declared = [ - arg["name"] - for arg in func.get("arguments") or [] - if isinstance(arg, dict) and isinstance(arg.get("name"), str) - ] - actual = parameter_names(method) - required = required_parameters(method) - - missing = [arg for arg in declared if arg not in actual] - if missing: - report.error( - "V008", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` has no parameter " - f"{', '.join(repr(m) for m in missing)}, but the yaml declares it", - "LocalController does method(**args) with the yaml's argument names. " - "A mismatch is TypeError: unexpected keyword argument, at request " - f"time. See {os.path.basename(agent_yaml_path)}.", - ) - - unfilled = [arg for arg in required if arg not in declared] - if unfilled: - report.error( - "V008", - entrypoint_path, - method.lineno, - f"`{class_name}.{func_name}` requires {', '.join(unfilled)}, which " - "the yaml does not declare", - "Only declared arguments are ever sent, and the generated stub gives " - "none of them a default. Declare them in the yaml or default them " - "in the signature.", - ) - - - -# ------------------------------------------------------------------ # -# V016-V018 the workflow # -# ------------------------------------------------------------------ # - - -def check_stub_imports(report, workflow_path, tree, stub_classes): - """V023 -- the workflow must import a stub as `from agents. import `. - - The build copies each stub to exactly one path, and for the workflow image - that path is agents/.py. Two ways of writing this line fail, and - the project walks you into both: the flat form is what examples/ uses, and - the class name is the one `ventis build` prints, which is not the one it - writes. - """ - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - base = alias.name.split(".")[0] - if base in stub_classes: - report.error( - "V023", workflow_path, node.lineno, - f"`import {alias.name}` -- the stub is at " - f"agents/{base}.py, not flat", - "The build copies a stub to one path, and for the " - "workflow that path is under agents/. This is a " - "ModuleNotFoundError the moment the workflow runs. " - f"Write `from agents.{base} import {stub_classes[base]}`.", - ) - continue - - if not isinstance(node, ast.ImportFrom) or not node.module: - continue - - module = node.module - if module in stub_classes: - report.error( - "V023", workflow_path, node.lineno, - f"`from {module} import ...` -- the stub is at " - f"agents/{module}.py, not flat", - "The build copies a stub to one path, and for the workflow " - "that path is under agents/. The flat form is what this " - "repository's own examples use and it raises " - "ModuleNotFoundError in the workflow image. Write " - f"`from agents.{module} import {stub_classes[module]}`.", - ) - continue - - if not module.startswith("agents."): - continue - base = module.split(".", 1)[1] - expected = stub_classes.get(base) - if expected is None: - continue - for alias in node.names: - if alias.name == expected: - continue - if alias.name == f"{expected}Stub": - report.error( - "V023", workflow_path, node.lineno, - f"`{alias.name}` is the name the build prints, not the " - f"class it writes", - "generate_agent_stub sets class_name = agent_config['name'] " - "and then recomputes it with a 'Stub' suffix for the log " - "line only. The message names a class that does not exist; " - f"the class is `{expected}`.", - ) - else: - report.error( - "V023", workflow_path, node.lineno, - f"`{alias.name}` is not a class the stub for {base} defines", - f"The stub's class carries the agent's own name: `{expected}`.", - ) - - -def check_workflow(report, workflow_path, stub_classes=None): - """V016 V017 V018 V023.""" - tree, error = parse_python(workflow_path) - if tree is None: - report.error("V016", workflow_path, 0, f"does not parse: {error}", "") - return - - main = None - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and ( - node.name == "main" - ): - main = node - break - - if main is None: - defined = [ - n.name - for n in tree.body - if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) - ] - found = ", ".join(defined) if defined else "no top-level functions" - report.error( - "V016", - workflow_path, - 1, - f"no top-level function named `main` (found: {found})", - "CanyonOS Core serves POST /, but the deployment platform's " - "test endpoint posts to a hardcoded /main. A differently named " - "workflow builds, deploys and stays unreachable -- 404, container " - "healthy.", - ) - else: - check_main_signature(report, workflow_path, main) - - if stub_classes: - check_stub_imports(report, workflow_path, tree, stub_classes) - - # V016 -- deploy() is what starts Flask. - if not any( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "deploy" - for node in ast.walk(tree) - ): - report.error( - "V016", - workflow_path, - 1, - "the workflow never calls `deploy(...)`", - "workflow_launcher.py exec's this file and nothing else starts the " - "HTTP server; the container comes up serving nothing.", - ) - - check_main_guard(report, workflow_path, tree) - check_fused_fanout(report, workflow_path, tree) - - -def check_main_signature(report, workflow_path, main): - """V016 -- the platform sends exactly {"query": ...}.""" - if isinstance(main, ast.AsyncFunctionDef): - report.error( - "V016", - workflow_path, - main.lineno, - "`main` is `async def`", - "deploy() calls workflow_fn(**kwargs) on a Flask worker thread with " - "no await; the response body would be a coroutine repr.", - ) - params = parameter_names(main) - if not params: - report.error( - "V016", - workflow_path, - main.lineno, - "`main` takes no arguments", - 'The platform posts {"query": "..."} and deploy() splats the ' - "body in as kwargs -- TypeError on every request.", - ) - return - if params[0] != "query": - report.error( - "V016", - workflow_path, - main.lineno, - f"`main`'s first parameter is `{params[0]}`, not `query`", - "The platform's body schema is strictly validated as " - "{query: string}; any other key is rejected with 400 in the control " - "plane, before the request reaches the host.", - ) - extra = [p for p in required_parameters(main) if p != "query"] - if extra: - report.error( - "V016", - workflow_path, - main.lineno, - f"`main` requires {', '.join(extra)} beyond `query`", - "Only `query` is ever sent, so every other parameter needs a " - "default or the call raises on every request. Pack richer input " - "into `query`.", - ) - - -def check_main_guard(report, workflow_path, tree): - """V017 -- the workflow is exec'd, so __name__ == "__main__".""" - for node in tree.body: - if not isinstance(node, ast.If): - continue - test = node.test - if ( - isinstance(test, ast.Compare) - and isinstance(test.left, ast.Name) - and test.left.id == "__name__" - and any( - isinstance(c, ast.Constant) and c.value == "__main__" - for c in test.comparators - ) - ): - report.error( - "V017", - workflow_path, - node.lineno, - '`if __name__ == "__main__":` block in the workflow', - "workflow_launcher.py runs exec(open().read()), so " - "__name__ IS '__main__' here and this block executes in " - "production, at container start.", - ) - - -def check_fused_fanout(report, workflow_path, tree): - """V018 -- .value() blocks, so dispatching and resolving in one - comprehension runs the fan-out one call at a time.""" - comprehensions = (ast.ListComp, ast.SetComp, ast.GeneratorExp) - for node in ast.walk(tree): - if not isinstance(node, comprehensions + (ast.DictComp,)): - continue - elements = ( - [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt] - ) - for element in elements: - for inner in ast.walk(element): - if ( - isinstance(inner, ast.Call) - and isinstance(inner.func, ast.Attribute) - and inner.func.attr == "value" - and isinstance(inner.func.value, ast.Call) - ): - report.error( - "V018", - workflow_path, - node.lineno, - "one comprehension both dispatches a call and resolves " - "it with .value()", - ".value() blocks, so each call completes before the " - "next is dispatched. It does not error -- the fan-out " - "is just silently serial, and with it the reason to be " - "on CanyonOS Core. Dispatch every call first, then resolve: " - "futures = [a.work(i) for i in items] then " - "[f.value() for f in futures].", - ) - return - - -# ------------------------------------------------------------------ # -# V019-V020 what the copy order overwrites # -# ------------------------------------------------------------------ # - - -def check_flat_collisions(report, project_dir, yaml_paths, entrypoints): - """V019 V020 -- later copies land on earlier ones at the context root.""" - for entry in sorted(os.listdir(project_dir)): - path = os.path.join(project_dir, entry) - if not os.path.isfile(path) or not entry.endswith(".py"): - continue - - # V019 -- the shared runtime is copied flat, after the project sweep. - if entry in RUNTIME_FLAT_NAMES: - report.error( - "V019", - path, - 1, - f"a project module named `{entry}` sits at the project root", - "The shared CanyonOS Core runtime is copied flat into the image after " - "the project sweep, so this file is overwritten by CanyonOS Core's own " - f"{entry}. Rename it or move it into a package directory.", - ) - - # V020 -- a stub is copied flat under its yaml's basename. - entrypoint_basenames = {os.path.basename(e) for e in entrypoints if e} - for yaml_path in yaml_paths: - stem = os.path.splitext(os.path.basename(yaml_path))[0] - module = f"{stem}.py" - if module in entrypoint_basenames: - continue # the entrypoint is copied last and wins its flat name back - candidate = os.path.join(project_dir, module) - if os.path.isfile(candidate): - report.error( - "V020", - candidate, - 1, - f"`{os.path.basename(yaml_path)}` generates a stub that lands on " - f"`{module}`", - "The yaml's basename names the stub, and the stub is copied flat " - "over the swept tree. Anything importing this module inside the " - "container gets the generated stub instead of the real code. " - "Rename the yaml to match its own entrypoint.", - ) - - -# ------------------------------------------------------------------ # -# V030-V031 capability-gated rules # -# ------------------------------------------------------------------ # - - -def check_env_file(report, config, config_path, project_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 ventis 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 `ventis` runtime. " - "Credentials have no declared path into a container on this tree.", - ) - return - - if not declared: - report.warn( - "V030", - config_path, - line_of(config), - "no `env_file:` in the config", - "Only runtime-managed VENTIS_* variables are guaranteed without it. " - "If the source reads credentials from the environment, the first " - "request fails on a provider error.", - ) - return - - # Path existence and readability are deploy-preflight checks. Do not - # duplicate them here. - - -def check_import_root(report, project_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(project_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - - non_flat = [] - for path in entrypoint_paths: - tree, _ = parse_python(path) - if tree is None: - continue - for name, lineno in toplevel_import_names(tree).items(): - if name in report.stub_module_names or f"{name}.py" in RUNTIME_FLAT_NAMES: - continue - if _resolves_flat(project_dir, name): - continue - location = _resolves_nested(project_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 " - "`ventis` runtime. Only names rooted at /app import inside a container.", - ) - for path, lineno, name, location in non_flat: - report.error( - "V031", - path, - lineno, - f"`import {name}` resolves to {location}, which is not at the " - "project root", - "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 project root " - "has no packaging metadata", - "A pyproject.toml, setup.py or setup.cfg at the port root is " - "what adds `-e .`; metadata nested inside the untouched source " - "tree is ignored. Add minimal root metadata pointing at the " - "existing package directory. Without it the install is skipped " - "silently.", - ) - - -def _resolves_flat(project_dir, name): - """Whether Python can resolve `name` with /app as its import root. - - A directory does not need __init__.py: PEP 420 namespace packages resolve - from sys.path just like regular packages. - """ - return os.path.isfile(os.path.join(project_dir, f"{name}.py")) or os.path.isdir( - os.path.join(project_dir, name) - ) - - -def _resolves_nested(project_dir, name): - """Where below /app `name` lives but cannot resolve as a top-level name.""" - for root, dirs, files in os.walk(project_dir): - dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] - if root == project_dir: - continue - if f"{name}.py" in files: - return os.path.relpath(os.path.join(root, f"{name}.py"), project_dir) - if name in dirs: - return os.path.relpath(os.path.join(root, name), project_dir) - return None - - -# ------------------------------------------------------------------ # -# W003, W006 secrets and imports a green build does not reject # -# ------------------------------------------------------------------ # - - -def check_secrets(report, port_paths): - """W003 -- env_file is the way in; nothing else is.""" - for path in port_paths: - try: - with open(path, "r", encoding="utf-8") as handle: - lines = handle.read().splitlines() - except OSError: - continue - for number, line in enumerate(lines, start=1): - for pattern, description in SECRET_PATTERNS: - if pattern.search(line): - report.warn( - "W003", - path, - number, - f"this line looks like {description}", - "Never put a secret in the source tree or the build " - "context. The build sweeps the project into every " - "image.", - ) - break - - tree, _ = parse_python(path) - if tree is None: - continue - for node in ast.walk(tree): - if not isinstance(node, ast.Assign): - continue - if not ( - isinstance(node.value, ast.Constant) - and isinstance(node.value.value, str) - and node.value.value.strip() - ): - continue - for target in node.targets: - if isinstance(target, ast.Name) and SECRET_NAME.search(target.id): - report.warn( - "W003", - path, - node.lineno, - f"`{target.id}` is assigned a literal string", - "Read it from the environment instead; the build sweeps " - "this file into every image.", - ) - - -def _pyproject_dependencies(project_dir): - """What `-e .` installs alongside `requirements:`, or None if unreadable. - - None and the empty set mean different things here: empty means the project - declares no dependencies, None means we could not find out -- a setup.py, or - a tomllib this interpreter does not have. The caller must not treat the - second as the first, or it warns about imports the install would satisfy. - """ - path = os.path.join(project_dir, "pyproject.toml") - if not os.path.isfile(path): - return None - try: - import tomllib - except ImportError: # < 3.11 - return None - try: - with open(path, "rb") as handle: - data = tomllib.load(handle) - except Exception: # noqa: BLE001 - malformed metadata is uv's error to give - return None - deps = (data.get("project") or {}).get("dependencies") - if not isinstance(deps, list): - return None - return {_normalize_distribution(d) for d in deps if isinstance(d, str)} - - -def check_requirements_coverage( - report, project_dir, entry, entrypoint_path, config_path -): - """W006 -- an import the container cannot satisfy.""" - tree, _ = parse_python(entrypoint_path) - if tree is None: - return - - declared = { - _normalize_distribution(item) - for item in (entry.get("requirements") or []) - if isinstance(item, str) - } - - # Where the editable install exists, `-e .` resolves the project's own - # [project.dependencies] in the same pass as `requirements:`. Warning about - # those is a false positive, and a false warning about a dependency is worse - # than none: it teaches the reader to dismiss this check. - editable = report.capabilities.get("editable_install") - metadata = any( - os.path.isfile(os.path.join(project_dir, name)) - for name in ("pyproject.toml", "setup.py", "setup.cfg") - ) - unreadable_metadata = False - if editable and metadata: - project_deps = _pyproject_dependencies(project_dir) - if project_deps is None: - unreadable_metadata = True - else: - declared |= project_deps - base = {_normalize_distribution(item) for item in BASE_AGENT_REQUIREMENTS} - stdlib = getattr(sys, "stdlib_module_names", frozenset()) - - for name, lineno in sorted(toplevel_import_names(tree).items()): - if name in stdlib or name == "ventis": - continue - # Provided by the image itself: the shared runtime is copied flat, and - # every agents/*.yaml generates a stub that is copied flat too. - if f"{name}.py" in RUNTIME_FLAT_NAMES or name in report.stub_module_names: - continue - if _resolves_flat(project_dir, name) or _resolves_nested(project_dir, name): - continue - distribution = _normalize_distribution(IMPORT_TO_DISTRIBUTION.get(name, name)) - if distribution in base or distribution in declared: - continue - if unreadable_metadata: - mechanism = ( - "The container installs the base list, `requirements:`, and -- " - "since this project declares packaging metadata -- whatever " - "`-e .` resolves from it. That metadata could not be read here, " - f"so if it already requires `{name}` this line is noise; " - "otherwise it is a ModuleNotFoundError inside _load_agent and " - "'No agent loaded' on the first request." - ) - else: - mechanism = ( - "The container installs the base list plus `requirements:` and " - "nothing else, so this is a ModuleNotFoundError inside " - "_load_agent and 'No agent loaded' on the first request. If the " - f"distribution is named something other than `{name}`, declare " - f"that name in {report.rel(config_path)}." - ) - report.warn( - "W006", - entrypoint_path, - lineno, - f"`import {name}` is in neither the runtime's base list nor this " - "entry's `requirements:`", - mechanism, - ) - - -def _normalize_distribution(name): - return re.split(r"[<>=!\[;\s]", name.strip().lower(), maxsplit=1)[0].replace( - "_", "-" - ) - - -# ------------------------------------------------------------------ # -# Driver # -# ------------------------------------------------------------------ # - - -def validate(project_dir, config_path, capabilities): - """Inspect only failures hidden behind a successful image build.""" - report = Report(project_dir, capabilities) - - # The build owns config/YAML syntax and shape validation. We read only enough - # valid structure to locate code for the deeper checks below. - config, error = load_yaml(config_path) - if error is not None or not isinstance(config, dict): - report.unavailable( - "BUILD", - "runtime preflight skipped because the config cannot be read; " - "ventis build owns and reports this error.", - ) - return report - - import glob - - yaml_paths = sorted(glob.glob(os.path.join(project_dir, "agents", "*.yaml"))) - report.stub_module_names = { - os.path.splitext(os.path.basename(path))[0] for path in yaml_paths - } - - agents_by_name = {} - stub_classes = {} - for path in yaml_paths: - data, yaml_error = load_yaml(path) - agent = data.get("agent") if isinstance(data, dict) else None - name = agent.get("name") if isinstance(agent, dict) else None - if yaml_error is not None or not isinstance(name, str): - continue # ventis build reports malformed agent declarations - agents_by_name[name] = (path, agent) - stub_classes[os.path.splitext(os.path.basename(path))[0]] = name - - entries = config.get("agents") - if not isinstance(entries, list): - report.unavailable( - "BUILD", - "runtime preflight skipped because `agents:` is not a list; " - "ventis build owns and reports this error.", - ) - return report - - entrypoints = [] - for entry in entries: - if not isinstance(entry, dict) or entry.get("type", "agent") == "workflow": - continue - name = entry.get("name") - entrypoint = entry.get("entrypoint") - if isinstance(entrypoint, str): - entrypoints.append(entrypoint) - if name in agents_by_name: - yaml_path, agent_block = agents_by_name[name] - check_adapter(report, yaml_path, agent_block, entry, project_dir) - entrypoint_path = os.path.join(project_dir, entrypoint or "") - if isinstance(entrypoint, str) and os.path.isfile(entrypoint_path): - check_requirements_coverage( - report, project_dir, entry, entrypoint_path, config_path - ) - - for entry in entries: - if not isinstance(entry, dict) or entry.get("type", "agent") != "workflow": - continue - workflow_file = entry.get("workflow_file") - if not isinstance(workflow_file, str): - continue - workflow_path = os.path.join(project_dir, workflow_file) - if os.path.isfile(workflow_path): - check_workflow(report, workflow_path, stub_classes) - - # These survive a green build and otherwise surface only in a container or - # on its first request. - check_flat_collisions(report, project_dir, yaml_paths, entrypoints) - check_env_file(report, config, config_path, project_dir) - - entrypoint_paths = [ - os.path.join(project_dir, e) - for e in entrypoints - if os.path.isfile(os.path.join(project_dir, e)) - ] - check_import_root(report, project_dir, entrypoint_paths) - - port_paths = list(entrypoint_paths) - for entry in entries: - if isinstance(entry, dict) and isinstance(entry.get("workflow_file"), str): - candidate = os.path.join(project_dir, entry["workflow_file"]) - if os.path.isfile(candidate): - port_paths.append(candidate) - - # Secret detection remains because a green image build would permanently - # bake the credential into every image. - check_secrets(report, port_paths) - return report - - - -# ------------------------------------------------------------------ # -# Output # -# ------------------------------------------------------------------ # - -LEVEL_ORDER = {ERROR: 0, WARN: 1, INFO: 2} - - -def _wrap(text, width, indent): - words = text.split() - lines = [] - current = "" - for word in words: - candidate = f"{current} {word}".strip() - if len(candidate) + len(indent) > width and current: - lines.append(indent + current) - current = word - else: - current = candidate - if current: - lines.append(indent + current) - return lines - - -def print_report(report, project_dir): - caps = report.capabilities - if not caps.get("ventis"): - print("ventis is not importable here -- capability-gated rules are") - print("reported UNAVAILABLE rather than checked.\n") - else: - print("CanyonOS Core capabilities detected:") - for key, source in CAPABILITY_SOURCE.items(): - mark = "yes" if caps.get(key) else "no " - print(f" {mark} {key:<22} {source}") - print() - - findings = sorted( - report.findings, - key=lambda f: (LEVEL_ORDER[f["level"]], f["check"], f["path"], f["line"]), - ) - for finding in findings: - where = finding["path"] - if where and finding["line"]: - where = f"{where}:{finding['line']}" - header = f"{finding['check']} {finding['level']:<5}" - print(f"{header} {where}" if where else header) - for line in _wrap(finding["summary"], 78, " "): - print(line) - if finding["mechanism"]: - for line in _wrap(finding["mechanism"], 78, " "): - print(line) - print() - - errors, warnings = report.counts() - if not findings: - print(f"{project_dir}: clean.") - return - print(f"{errors} error(s), {warnings} warning(s).") - - -def main(argv=None): - parser = argparse.ArgumentParser( - description="Check a CanyonOS Core port against the rules in SKILL.md." - ) - parser.add_argument( - "project_dir", nargs="?", default=".", help="the port's project root" - ) - parser.add_argument( - "-c", - "--config", - default=DEFAULT_CONFIG_PATH, - help=f"config path relative to project_dir (default: {DEFAULT_CONFIG_PATH})", - ) - parser.add_argument("--json", action="store_true", help="emit findings as JSON") - parser.add_argument( - "--strict", action="store_true", help="fail on warnings as well as errors" - ) - args = parser.parse_args(argv) - - project_dir = os.path.abspath(args.project_dir) - config_path = ( - args.config - if os.path.isabs(args.config) - else os.path.join(project_dir, args.config) - ) - - capabilities = probe_capabilities() - report = validate(project_dir, config_path, capabilities) - errors, warnings = report.counts() - - if args.json: - print( - json.dumps( - { - "project_dir": project_dir, - "capabilities": capabilities, - "errors": errors, - "warnings": warnings, - "findings": report.findings, - }, - indent=2, - ) - ) - else: - print_report(report, report.rel(project_dir) or project_dir) - - if errors or (args.strict and warnings): - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From ced549d713f04931915886662978cfd3c7eb75ff Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 15:49:52 -0700 Subject: [PATCH 28/44] cli --- cli/README.md | 13 +- cli/canyonos/build.py | 205 +++++++++++++++++++ cli/canyonos/clean.py | 24 +-- cli/canyonos/config.py | 29 ++- cli/canyonos/constants.py | 53 ++++- cli/canyonos/dashboard.compose.yml | 11 +- cli/canyonos/dashboard_stack.py | 130 ++---------- cli/canyonos/deploy.py | 110 +++++++--- cli/canyonos/doctor.py | 96 +++++++++ cli/canyonos/gc.py | 83 ++++++++ cli/canyonos/init.py | 108 +++++++++- cli/canyonos/integrate.py | 82 -------- cli/canyonos/logs.py | 24 +-- cli/canyonos/quit.py | 18 +- cli/canyonos/serve.py | 4 +- cli/canyonos/stop.py | 37 +--- cli/canyonos/sync.py | 18 +- cli/canyonos/test.py | 173 ++++++++++++++++ cli/cli.py | 190 ++++++----------- cli/utils/help_screen.py | 60 ++++++ cli/utils/tui.py | 2 - {cli/tests => tests}/test_dashboard_stack.py | 0 22 files changed, 987 insertions(+), 483 deletions(-) create mode 100644 cli/canyonos/build.py create mode 100644 cli/canyonos/doctor.py create mode 100644 cli/canyonos/gc.py delete mode 100644 cli/canyonos/integrate.py create mode 100644 cli/canyonos/test.py create mode 100644 cli/utils/help_screen.py rename {cli/tests => tests}/test_dashboard_stack.py (100%) diff --git a/cli/README.md b/cli/README.md index de66cd0..76cd4e1 100644 --- a/cli/README.md +++ b/cli/README.md @@ -4,11 +4,18 @@ Serves as a thin API layer, connecting to the global controller container. ## Serve -`canyonos serve -c config/global_controller.yaml` starts the local CanyonOS dashboard. It reads -`database.url` from the config and writes only `CANYONOS_`-prefixed settings to the current `.env`, -leaving other lines unchanged. +`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 diff --git a/cli/canyonos/build.py b/cli/canyonos/build.py new file mode 100644 index 0000000..f5292aa --- /dev/null +++ b/cli/canyonos/build.py @@ -0,0 +1,205 @@ +""" +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 rich.console import Console + +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 +) + +AGENTS = { + "claude": { + "label": "Claude Code", + "cli": "claude", + # Claude Code auto-loads project-local skills from here. The leaf name + # must match the skill's own `name:` frontmatter or it won't resolve. + "skill_dir": SKILL_PATH, + }, + "codex": { + "label": "Codex", + "cli": "codex", + # Codex only auto-loads skills from the user's home directory, not per-project. + "skill_dir": 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 _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 + + +def _fetch_with_npx(dest): + """Last resort, and the only strategy that needs Node.""" + if not shutil.which("npx"): + return False + # -f overwrites an existing skill dir; without it gitpick exits 1 when the + # target already exists and is non-empty (e.g. re-running `build`). + return subprocess.run( + ["npx", "-y", "gitpick", "-f", TREE_URL, dest], capture_output=True + ).returncode == 0 + + +FETCH_STRATEGIES = ( + ("git", _fetch_with_git), + ("tarball", _fetch_with_tarball), + ("npx", _fetch_with_npx), +) + + +def install_skill(agent, console): + """Fetch the skill into the agent's skill dir. Returns True on success.""" + dest = AGENTS[agent]["skill_dir"] + for name, fetch in FETCH_STRATEGIES: + try: + if fetch(dest): + console.print(f"Fetched the CanyonOS skill via {name}.") + return True + except OSError: + pass + console.print(f"[dim]{name} fetch unavailable, trying the next option...[/dim]") + + console.print( + f"Could not fetch the CanyonOS skill from {TREE_URL}.\n" + "Install git or Node, or check network access, then run `canyonos doctor`." + ) + return False + + +def launch_agent(agent, prompt): + spec = AGENTS[agent] + if not shutil.which(spec["cli"]): + print(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(): + console = Console() + agent = prompt_agent() + if agent is None: + console.print("Cancelled.") + return + + console.print(f"Installing CanyonOS skill for {AGENTS[agent]['label']}...") + if not install_skill(agent, console): + return + + console.print(f"Launching {AGENTS[agent]['label']}...") + launch_agent(agent, BUILD_PROMPT) diff --git a/cli/canyonos/clean.py b/cli/canyonos/clean.py index aabf105..d7cc6f5 100644 --- a/cli/canyonos/clean.py +++ b/cli/canyonos/clean.py @@ -1,7 +1,5 @@ """ -Remove generated stubs, gRPC files, and Docker build contexts. - -Ported directly over from canyonos, moving the logic into here. +Logic for `canyonos clean`: remove the generated .car artifact directory. """ import os @@ -9,20 +7,12 @@ def run_clean(): - project_dir = os.getcwd() - - paths_to_clean = [ - os.path.join(project_dir, "stubs"), - os.path.join(project_dir, "grpc_stubs"), - os.path.join(project_dir, "docker_container"), - ] + car_dir = os.path.join(os.getcwd(), ".car") - for path in paths_to_clean: - if os.path.exists(path): - print(f"Cleaning {path}...") - if os.path.isdir(path): - shutil.rmtree(path) - else: - os.remove(path) + if not os.path.isdir(car_dir): + print("Nothing to clean, no .car folder in root") + return + print(f"Cleaning {car_dir}...") + shutil.rmtree(car_dir) print("Clean complete.") diff --git a/cli/canyonos/config.py b/cli/canyonos/config.py index 226312e..25d2d3d 100644 --- a/cli/canyonos/config.py +++ b/cli/canyonos/config.py @@ -7,9 +7,8 @@ import yaml from rich.console import Console from rich.table import Table -from ruamel.yaml import YAML -from canyonos.constants import default_config_path +from canyonos.constants import default_config_path, round_trip_yaml from canyonos.theme import GREEN, WHITE from utils.tui import DELETE_ACTION, QUIT_ACTION, select_menu @@ -99,12 +98,19 @@ def _kv_table(title, data): return table -def run_view_config(config_path=None): +def _require_config(config_path, console): + """Resolved config path, or None after reporting that it's missing.""" config_path = config_path or default_config_path() - console = Console() - if not os.path.isfile(config_path): console.print(f"[red]Config file not found: {config_path}[/red]") + return None + return config_path + + +def run_view_config(config_path=None): + console = Console() + config_path = _require_config(config_path, console) + if config_path is None: return with open(config_path) as f: @@ -288,19 +294,12 @@ def _navigate(screen, node, breadcrumb): def run_change_config(config_path=None): - config_path = config_path or default_config_path() console = Console() - - if not os.path.isfile(config_path): - console.print(f"[red]Config file not found: {config_path}[/red]") + config_path = _require_config(config_path, console) + if config_path is None: return - # Round-trip loader preserves comments, key order, quoting and ${ENV} refs. - yaml_rt = YAML() - yaml_rt.preserve_quotes = True - # Match the project's YAML style so edits don't reflow list indentation: - # block sequences indented under their key (` - item`). - yaml_rt.indent(mapping=2, sequence=4, offset=2) + yaml_rt = round_trip_yaml() with open(config_path) as f: data = yaml_rt.load(f) diff --git a/cli/canyonos/constants.py b/cli/canyonos/constants.py index d34316e..455e860 100644 --- a/cli/canyonos/constants.py +++ b/cli/canyonos/constants.py @@ -1,9 +1,60 @@ -"""Shared constants for the canyonos CLI.""" +"""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 index db775aa..0689749 100644 --- a/cli/canyonos/dashboard.compose.yml +++ b/cli/canyonos/dashboard.compose.yml @@ -19,11 +19,14 @@ services: depends_on: db: condition: service_healthy - # Published so a GC container can POST OTLP spans to /v1/traces via - # host.docker.internal; that route also renames ventis' `project_id` - # attribute to the `canyon.project.id` every dashboard query filters on. + # 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: - - "127.0.0.1:3000:3000" + - "3000:3000" environment: DATABASE_URL: postgresql://canyonos:canyonos@db:5432/canyonos JWT_SECRET: ${CANYONOS_JWT_SECRET} diff --git a/cli/canyonos/dashboard_stack.py b/cli/canyonos/dashboard_stack.py index 6abcfb1..c606357 100644 --- a/cli/canyonos/dashboard_stack.py +++ b/cli/canyonos/dashboard_stack.py @@ -19,11 +19,6 @@ from datetime import datetime, timezone from pathlib import Path from typing import Callable -from urllib.parse import urlsplit, urlunsplit - -import yaml - -from canyonos.constants import default_config_path COMPOSE_PROJECT = "canyonos-dashboard" STACK_VERSION = "v0.1.0-rc.2" @@ -32,7 +27,6 @@ HOST_GATEWAY = "host.docker.internal" REDIS_HOST = HOST_GATEWAY REDIS_PORT = "6379" -ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @dataclass(frozen=True) @@ -54,7 +48,6 @@ def __init__(self, phase: str, message: str, *, had_containers: bool | None = No @dataclass(frozen=True) class DashboardStack: - database_url: str | None state_dir: Path project_dir: Path web_port: int = 8080 @@ -73,9 +66,6 @@ def _state_dir() -> Path: def _compose_argv(stack: DashboardStack, manifest: Path) -> list[str]: - # Absolute, not "./.env": `canyonos serve` may cd into .car/ before - # running, so a cwd-relative path would miss the project root .env that - # `prepare()` actually writes to (stack.env_path). return [ "docker", "compose", @@ -88,25 +78,6 @@ def _compose_argv(stack: DashboardStack, manifest: Path) -> list[str]: ] -def _managed_database_url(database_url: str) -> tuple[str, str | None]: - parsed = urlsplit(database_url) - if parsed.hostname not in {"localhost", "127.0.0.1"}: - return database_url, None - - hostname = parsed.hostname - credentials = "" - if parsed.username is not None: - credentials = parsed.username - if parsed.password is not None: - credentials = f"{credentials}:{parsed.password}" - credentials = f"{credentials}@" - port = f":{parsed.port}" if parsed.port is not None else "" - rewritten = urlunsplit( - (parsed.scheme, f"{credentials}{HOST_GATEWAY}{port}", parsed.path, parsed.query, parsed.fragment) - ) - return rewritten, hostname - - 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 @@ -143,9 +114,9 @@ def _port_is_free(port: int) -> bool: def _find_web_port(start: int = 8080, max_attempts: int = 50) -> int: - """First free port at or after `start` -- same retry-on-conflict shape as - init.py's GC port selection, so an unrelated process/container squatting - on 8080 (e.g. a deployed Workflow's own api_port) doesn't hard-block serve. + """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): @@ -155,42 +126,7 @@ def _find_web_port(start: int = 8080, max_attempts: int = 50) -> int: ) -def _load_project_config(config_path: str) -> tuple[object, Path]: - project_root = Path(os.path.abspath(os.path.join(os.path.dirname(config_path), ".."))) - # `canyonos serve` cds into .car/ before calling here, so the naive - # parent-of-parent lands on .car itself -- go up one more level to reach - # the actual project root, where .env lives. - if project_root.name == ".car": - project_root = project_root.parent - dotenv_path = project_root / ".env" - if dotenv_path.is_file(): - with dotenv_path.open(encoding="utf-8") as dotenv: - for line in dotenv: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - key = key.strip() - value = _env_value(value) - if key and key not in os.environ: - os.environ[key] = value - - with open(config_path, encoding="utf-8") as config_file: - config = yaml.safe_load(config_file) - return _expand_env_value(config), project_root - - -def _expand_env_value(value: object) -> object: - if isinstance(value, str): - return ENV_REFERENCE.sub(lambda match: os.environ.get(match.group(1), match.group(0)), value) - if isinstance(value, dict): - return {key: _expand_env_value(item) for key, item in value.items()} - if isinstance(value, list): - return [_expand_env_value(item) for item in value] - return value - - -def validate(config_path: str) -> DashboardStack: +def validate() -> DashboardStack: if shutil.which("docker") is None: raise PhaseFailure("validate", "docker is not on PATH") @@ -202,26 +138,10 @@ def validate(config_path: str) -> DashboardStack: except OSError: raise PhaseFailure("validate", "docker daemon or socket is unavailable") - try: - # Keep interpolation consistent with GlobalController._load_config in ventis/controller/global_controller.py. - config, project_root = _load_project_config(config_path) - except (OSError, yaml.YAMLError): - raise PhaseFailure("validate", f"config file is not readable: {config_path}") - - # database.url is optional -- the dashboard works without a database configured - # (e.g. OTLP-only setups); if present, it still needs to actually be usable. - database = config.get("database") if isinstance(config, dict) else None - database_url = database.get("url") if isinstance(database, dict) else None - if database_url is not None: - if not isinstance(database_url, str) or not database_url.strip(): - raise PhaseFailure("validate", "database.url must be a non-empty string") - unresolved = ENV_REFERENCE.search(database_url) - if unresolved: - name = unresolved.group(1) - raise PhaseFailure( - "validate", f"database.url needs ${{{name}}}, which is not set in the project .env" - ) - database_url = database_url.strip() + # 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: @@ -235,7 +155,7 @@ def validate(config_path: str) -> DashboardStack: web_port = _existing_dashboard_port() or _find_web_port() - return DashboardStack(database_url, state_dir, project_root, web_port) + return DashboardStack(state_dir, project_root, web_port) def _env_value(value: str) -> str: @@ -319,10 +239,6 @@ def prepare(stack: DashboardStack) -> tuple[dict[str, str], str]: "CANYONOS_WEB_IMAGE": WEB_IMAGE, "CANYONOS_WEB_PORT": str(stack.web_port), } - rewritten_host = None - if stack.database_url is not None: - managed_database_url, rewritten_host = _managed_database_url(stack.database_url) - managed_env["CANYONOS_DATABASE_URL"] = managed_database_url _write_project_env(stack.env_path, managed_env) (stack.state_dir / "stack.json").write_text( json.dumps( @@ -338,20 +254,7 @@ def prepare(stack: DashboardStack) -> tuple[dict[str, str], str]: except (OSError, ValueError): raise PhaseFailure("prepare", "could not prepare the dashboard state directory") - message = "dashboard state prepared" - if rewritten_host: - message = ( - f"database host {rewritten_host} is reachable from the stack as host.docker.internal" - ) - return managed_env, message - - -def _redact_stack_text(text: str, stack: DashboardStack, managed_env: dict[str, str]) -> str: - secret = managed_env["CANYONOS_JWT_SECRET"] - redacted = redact_logs(text, secret) - if stack.database_url is not None: - redacted = redact_logs(redacted.replace(stack.database_url, "[redacted]"), secret) - return redacted + return managed_env, "dashboard state prepared" def _last_stderr_line(result: subprocess.CompletedProcess[str]) -> str | None: @@ -361,13 +264,12 @@ def _last_stderr_line(result: subprocess.CompletedProcess[str]) -> str | None: def _command_failure_message( message: str, result: subprocess.CompletedProcess[str], - stack: DashboardStack, managed_env: dict[str, str], ) -> str: detail = _last_stderr_line(result) if detail is None: return message - return f"{message}: {_redact_stack_text(detail, stack, managed_env)}" + return f"{message}: {redact_logs(detail, managed_env['CANYONOS_JWT_SECRET'])}" def pull( @@ -383,7 +285,7 @@ def pull( if result.returncode != 0: raise PhaseFailure( "pull", - _command_failure_message("docker compose pull failed", result, stack, managed_env), + _command_failure_message("docker compose pull failed", result, managed_env), had_containers=had_containers, ) return "dashboard images pulled" @@ -412,7 +314,7 @@ def start(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> if result.returncode != 0: raise PhaseFailure( "start", - _command_failure_message("docker compose up failed", result, stack, managed_env), + _command_failure_message("docker compose up failed", result, managed_env), had_containers=had_containers, ) return had_containers @@ -462,7 +364,7 @@ def _capture_failure_logs(stack: DashboardStack, manifest: Path, managed_env: di log_path = log_dir / f"serve-{timestamp}.log" _write_private_file( log_path, - _redact_stack_text(logs, stack, managed_env), + redact_logs(logs, managed_env["CANYONOS_JWT_SECRET"]), ) return log_path @@ -475,10 +377,8 @@ def _cleanup(stack: DashboardStack, manifest: Path) -> None: def run_dashboard( - config_path: str | None = None, phase_reporter: Callable[[str, str], None] | None = None, ) -> ServeResult: - config_path = config_path or default_config_path() def report(result: ServeResult) -> None: if phase_reporter is not None: phase_reporter(result.phase, result.message) @@ -489,7 +389,7 @@ def report(result: ServeResult) -> None: had_containers = False with ExitStack() as resources: try: - stack = validate(config_path) + stack = validate() report(ServeResult(True, "validate", "dashboard prerequisites validated")) managed_env, prepare_message = prepare(stack) diff --git a/cli/canyonos/deploy.py b/cli/canyonos/deploy.py index a2239a2..57c32ad 100644 --- a/cli/canyonos/deploy.py +++ b/cli/canyonos/deploy.py @@ -10,12 +10,20 @@ manual step. """ -import json import subprocess -import urllib.error -import urllib.request -from canyonos.constants import default_config_path +from rich.console import Console +from rich.panel import Panel +from rich.text import Text + +from canyonos.constants import ( + WORKFLOW_ROUTE, + default_config_path, + workflow_api_port, + workspace_relative, +) +from canyonos.gc import GCError, post_deploy +from canyonos.theme import GREEN, WHITE from canyonos.init import load_state, run_init from canyonos.serve import run_serve from canyonos.sync import run_sync @@ -27,7 +35,13 @@ def run_deploy(config_path=None, serve=True): - config_path = config_path or default_config_path() + # 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: + print("Config must be inside the project directory being synced.") + return + run_init() # Copy the current project into the container before building/deploying. @@ -36,29 +50,53 @@ def run_deploy(config_path=None, serve=True): state = load_state() - url = f"http://127.0.0.1:{state['port']}/deploy" - body = json.dumps({"config_path": config_path}).encode() - req = urllib.request.Request( - url, data=body, headers={"Content-Type": "application/json"}, method="POST" - ) + # Read for display only -- ventis resolves the path it actually deploys. + api_port = workflow_api_port(config_path or default_config_path()) try: - with urllib.request.urlopen(req) as resp: - json.loads(resp.read()) - _stream_logs_and_autoserve(state["container_id"], serve=serve) - except urllib.error.HTTPError as e: - data = json.loads(e.read()) - print(f"Deploy failed: {data.get('error')}") - except urllib.error.URLError as e: - print(f"Could not reach Global Controller container: {e}") - - -def _stream_logs_and_autoserve(container_id, serve=True): - """Tail the GC container's logs (same as before), and -- unless disabled - via `serve=False` -- launch `canyonos serve` the moment they show the - workflow is up, so the dashboard is ready alongside it. Log tailing - continues afterwards exactly as before. + post_deploy(state["port"], config_path) + _stream_logs_and_autoserve(state["container_id"], api_port, serve=serve) + except GCError as e: + print(e) + + +def print_workflow_endpoint(console, api_port): + """The one thing you need after a deploy: where to send requests. + + Printed at the workflow-up marker and again on exit, because `deploy` keeps + tailing logs afterwards and would otherwise scroll it out of sight. + """ + if api_port is None: + return + + url = f"http://127.0.0.1:{api_port}/{WORKFLOW_ROUTE}" + body = Text.assemble( + ("POST ", "dim"), + (url, f"bold {GREEN}"), + ("\nbody ", "dim"), + ('{"query": "your question here"}', WHITE), + ("\npoll ", "dim"), + (f"http://127.0.0.1:{api_port}/status/", WHITE), + ) + console.print() + console.print( + Panel( + body, + title=f"[bold {GREEN}]Workflow is live[/]", + title_align="left", + border_style=GREEN, + padding=(1, 4), + ) + ) + console.print() + + +def _stream_logs_and_autoserve(container_id, api_port, serve=True): + """Tail the GC container's logs, and once they show the workflow is up, + print where to reach it -- plus, unless disabled via `serve=False`, launch + `canyonos serve`. Log tailing continues afterwards. """ + console = Console() process = subprocess.Popen( ["docker", "logs", "-f", container_id], stdout=subprocess.PIPE, @@ -67,20 +105,26 @@ def _stream_logs_and_autoserve(container_id, serve=True): bufsize=1, ) served = not serve + workflow_up = False try: for line in process.stdout: print(line, end="") - if not served and _WORKFLOW_UP_MARKER in line: - served = True - print("\nWorkflow is up -- starting the local dashboard (canyonos serve)...") - try: - run_serve() - except Exception as e: - print(f"Could not start the dashboard automatically: {e}") - print("Run `canyonos serve` manually to view it.") + if not workflow_up and _WORKFLOW_UP_MARKER in line: + workflow_up = True + print_workflow_endpoint(console, api_port) + if not served: + served = True + print("Starting the local dashboard (canyonos serve)...") + try: + run_serve() + except Exception as e: + print(f"Could not start the dashboard automatically: {e}") + print("Run `canyonos serve` manually to view it.") except KeyboardInterrupt: print("\nStopped monitoring log stream. Run `canyonos stop` to stop the deploy.") print("To resubscribe to log stream run `canyonos logs`.") + if workflow_up: + print_workflow_endpoint(console, api_port) 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..7dd9883 --- /dev/null +++ b/cli/canyonos/doctor.py @@ -0,0 +1,96 @@ +""" +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.build import AGENTS +from canyonos.init import docker_start_command + + +def _docker_installed(): + return shutil.which("docker") is not None + + +def _docker_daemon_running(): + result = subprocess.run(["docker", "info"], capture_output=True) + return result.returncode == 0 + + +def _compose_available(): + result = subprocess.run(["docker", "compose", "version"], capture_output=True) + return result.returncode == 0 + + +def _git_available(): + return shutil.which("git") is not None + + +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 _coding_agent_available(): + return any(shutil.which(spec["cli"]) for spec in AGENTS.values()) + + +def _checks(): + """Built fresh on each call (not a module-level constant) so tests can + patch the individual `_check_*` functions by name and have it take effect. + """ + return [ + ( + "Docker installed", + _docker_installed, + "install Docker: https://docs.docker.com/get-docker/", + ), + ( + "Docker daemon running", + _docker_daemon_running, + _docker_daemon_fix(), + ), + ( + "Docker Compose available", + _compose_available, + "update Docker to a version that includes Compose v2 (needed for `canyonos serve`)", + ), + ( + "git available", + _git_available, + "install git (`canyonos build` fetches the porting skill with it; " + "without git it falls back to a full-repo tarball download)", + ), + ( + "Coding agent available", + _coding_agent_available, + "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})" + + print(f"{'✓' if passed else '✗'} {label}") + if not passed: + print(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..b5af7eb --- /dev/null +++ b/cli/canyonos/gc.py @@ -0,0 +1,83 @@ +""" +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.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: + print("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 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 index 3d9cee9..69e8fbb 100644 --- a/cli/canyonos/init.py +++ b/cli/canyonos/init.py @@ -7,7 +7,10 @@ import json import os +import shutil import subprocess +import sys +import time import urllib.error import urllib.request @@ -23,22 +26,95 @@ GC_IMAGE = "saakeths/canyonos:latest" GC_CONTAINER_PORT = 8000 -# Named docker volume mounted at /workspace inside the container. Unlike a bind -# mount, this lives in the container's docker volume (not the host filesystem): -# it persists across `canyonos quit` (docker rm leaves named volumes intact) and -# is unaffected by host-side changes. Files are copied in via `canyonos sync` -# (docker cp), not mounted live. +# 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(console, 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." + ) + + console.print(f"Docker isn't running -- starting it with `{' '.join(command)}`...") + subprocess.run(command, capture_output=True) + + deadline = time.time() + timeout + with console.status("Waiting for the Docker daemon..."): + while time.time() < deadline: + if docker_running(): + console.print("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. - subprocess.run(["docker", "pull", image], check=True, capture_output=True) + # 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): @@ -49,8 +125,6 @@ def _port_reachable(port, attempts=10, delay=0.5): trigger it), which looks fine at the Docker level but resets every real connection. Confirm the container is actually reachable before trusting it. """ - import time - url = f"http://127.0.0.1:{port}/status" for _ in range(attempts): try: @@ -113,6 +187,19 @@ def load_state(): 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(): console = Console() banner = figlet_format("CANYON OS", font="ansi_shadow", width=200) @@ -120,6 +207,9 @@ def run_init(): for line, color in zip(banner.splitlines(), GRADIENT): console.print(line, style=color) + # Before quit_existing(), which shells out to docker itself. + ensure_docker_running(console) + quit_existing() with console.status("Pulling Global Controller image..."): pull_image() diff --git a/cli/canyonos/integrate.py b/cli/canyonos/integrate.py deleted file mode 100644 index 23e61b1..0000000 --- a/cli/canyonos/integrate.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Logic for `canyonos integrate`: 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 - -from rich.console import Console - -from utils.tui import select_menu - -# Points at the skill's folder, so SKILL.md and references/ both come along. -SKILL_SOURCE_URL = "https://github.com/CanyonCodeCoreAI/canyoncodecore/tree/nickhuo/porting-skill-car-layout/.claude/skills/porting-to-canyonos" - -# 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: {}""" - -INTEGRATE_PROMPT = ( - "Use the CanyonOS porting-to-canyonos-core 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 -) - -AGENTS = { - "claude": { - "label": "Claude Code", - "cli": "claude", - # Claude Code auto-loads project-local skills from here. - "skill_dir": ".claude/skills/porting-to-canyonos-core", - }, - "codex": { - "label": "Codex", - "cli": "codex", - # Codex only auto-loads skills from the user's home directory, not per-project. - "skill_dir": os.path.expanduser("~/.codex/skills/porting-to-canyonos-core"), - }, -} - - -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 integrate with?") - - -def install_skill(agent): - spec = AGENTS[agent] - # -f overwrites an existing skill dir; without it gitpick exits 1 when the - # target already exists and is non-empty (e.g. re-running `integrate`). - subprocess.run( - ["npx", "-y", "gitpick", "-f", SKILL_SOURCE_URL, spec["skill_dir"]], - check=True, - ) - - -def launch_agent(agent, prompt): - spec = AGENTS[agent] - if not shutil.which(spec["cli"]): - print(f"`{spec['cli']}` not found on PATH; install {spec['label']} first.") - return - subprocess.run([spec["cli"], prompt], check=True) - - -def run_integrate(): - console = Console() - agent = prompt_agent() - if agent is None: - console.print("Cancelled.") - return - - console.print(f"Installing CanyonOS skill for {AGENTS[agent]['label']}...") - install_skill(agent) - - console.print(f"Launching {AGENTS[agent]['label']}...") - launch_agent(agent, INTEGRATE_PROMPT) diff --git a/cli/canyonos/logs.py b/cli/canyonos/logs.py index 9b2b1d8..9839134 100644 --- a/cli/canyonos/logs.py +++ b/cli/canyonos/logs.py @@ -2,32 +2,22 @@ Logic for `canyonos logs`: re-subscribe to the running deploy's log stream. """ -import json import subprocess -import urllib.error -import urllib.request -from canyonos.init import load_state +from canyonos.gc import deploy_status, require_state def run_logs(): - try: - state = load_state() - except FileNotFoundError: - print("No Global Controller container is running. Run `canyonos init` first.") + state = require_state() + if state is None: return - url = f"http://127.0.0.1:{state['port']}/status" - req = urllib.request.Request(url, method="GET") - - try: - with urllib.request.urlopen(req) as resp: - data = json.loads(resp.read()) - except urllib.error.URLError as e: - print(f"Could not reach Global Controller container: {e}") + status = deploy_status(state["port"]) + if status is None: + print("Could not reach Global Controller container.") return - if not data.get("running"): + if not status.get("running"): print("No deploy running, run `canyonos deploy` to deploy project.") return diff --git a/cli/canyonos/quit.py b/cli/canyonos/quit.py index 15aff2c..bb2e11d 100644 --- a/cli/canyonos/quit.py +++ b/cli/canyonos/quit.py @@ -10,8 +10,8 @@ from rich.console import Console -from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH, load_state -from canyonos.stop import _post_clean +from canyonos.gc import GCError, post_clean, require_state +from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH def _container_exists(container_id): @@ -22,10 +22,8 @@ def _container_exists(container_id): def run_quit(): - try: - state = load_state() - except FileNotFoundError: - print("No Global Controller container is running.") + state = require_state() + if state is None: return container_id = state["container_id"] @@ -36,11 +34,9 @@ def run_quit(): # 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 OSError: - # Covers urllib.error.HTTPError/URLError (both subclass OSError) - # plus raw connection errors -- nothing was running, or the GC is - # already unreachable/gone. + 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 diff --git a/cli/canyonos/serve.py b/cli/canyonos/serve.py index ddfd7d6..9515699 100644 --- a/cli/canyonos/serve.py +++ b/cli/canyonos/serve.py @@ -3,11 +3,11 @@ from .dashboard_stack import run_dashboard -def run_serve(config_path: str | None = None) -> int: +def run_serve() -> int: def report(phase: str, message: str) -> None: print(f"[serve] {phase}: {message}") - result = run_dashboard(config_path, report) + result = run_dashboard(report) if result.ok: print(f"Dashboard: {result.url}") return 0 diff --git a/cli/canyonos/stop.py b/cli/canyonos/stop.py index 2f6ad40..a3f96f3 100644 --- a/cli/canyonos/stop.py +++ b/cli/canyonos/stop.py @@ -3,45 +3,20 @@ Controller container (SIGTERM, same teardown as Ctrl+C would trigger). """ -import json -import urllib.error -import urllib.request - from rich.console import Console -from canyonos.init import load_state - - -def _post_clean(port): - """POST /clean to the Global Controller container. - - This is what actually tears down the local controller and Redis - containers a deploy spawned via docker-outside-of-docker: it sends - SIGTERM to the in-container `ventis deploy` process, whose handler calls - `GlobalController.stop()` and blocks until it returns. Shared with - `canyonos quit`, which needs the same teardown before removing the GC - container itself. - """ - url = f"http://127.0.0.1:{port}/clean" - req = urllib.request.Request(url, method="POST") - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) +from canyonos.gc import GCError, post_clean, require_state def run_stop(): - try: - state = load_state() - except FileNotFoundError: - print("No Global Controller container is running. Run `canyonos init` first.") + state = require_state() + if state is None: return console = Console() try: with console.status("Stopping deploy..."): - _post_clean(state["port"]) + post_clean(state["port"]) print("Deploy stopped.") - except urllib.error.HTTPError as e: - data = json.loads(e.read()) - print(f"Stop failed: {data.get('error')}") - except urllib.error.URLError as e: - print(f"Could not reach Global Controller container: {e}") + except GCError as e: + print(e) diff --git a/cli/canyonos/sync.py b/cli/canyonos/sync.py index f350a1c..20ac3c1 100644 --- a/cli/canyonos/sync.py +++ b/cli/canyonos/sync.py @@ -3,24 +3,24 @@ 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 they persist across `canyonos quit` and survive host-side -changes. `docker cp` is additive: it overwrites/adds files but never deletes, -so build outputs generated inside the container (stubs/, grpc_stubs/, -docker_container/) survive a re-sync of the host source. +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.init import GC_WORKSPACE_PATH, load_state +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.""" - try: - state = load_state() - except FileNotFoundError: - print("No Global Controller container is running. Run `canyonos init` first.") + state = require_state() + if state is None: return False container_id = state["container_id"] diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py new file mode 100644 index 0000000..468527a --- /dev/null +++ b/cli/canyonos/test.py @@ -0,0 +1,173 @@ +""" +Logic for `canyonos test`: smoke-test a project end to end on this machine. + +Every agent's `provider` is rewritten to `local` for the duration of the run +(the original file is restored verbatim afterwards), the project is deployed +into the Global Controller container, one query is sent to the workflow's +`/main` endpoint, and its result -- or the error that came back -- is printed. +""" + +import json +import os +import time +import urllib.error +import urllib.request + +from rich.console import Console + +from canyonos.constants import ( + WORKFLOW_ROUTE, + default_config_path, + round_trip_yaml, + workflow_api_port, + workspace_relative, +) +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 + +DEFAULT_QUERY = "hello" +# Generous: the first deploy of a project builds every agent image from scratch. +READY_TIMEOUT = 900 +REQUEST_TIMEOUT = 600 +POLL_INTERVAL = 2 + + + +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 _workflow_ready(api_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://127.0.0.1:{api_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, console): + deadline = time.time() + READY_TIMEOUT + with console.status("Building and starting containers..."): + while time.time() < deadline: + if _workflow_ready(api_port): + return True + if not (deploy_status(gc_port) or {}).get("running", False): + return False + time.sleep(POLL_INTERVAL) + print(f"Timed out after {READY_TIMEOUT}s waiting for the workflow to come up.") + return False + + +def _send_query(api_port, query): + url = f"http://127.0.0.1:{api_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=30) as resp: + return json.loads(resp.read())["request_id"] + + +def _await_result(api_port, request_id, console): + url = f"http://127.0.0.1:{api_port}/status/{request_id}" + deadline = time.time() + REQUEST_TIMEOUT + with console.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 run_test(config_path=None, query=None): + console = Console() + config_path = config_path or default_config_path() + query = query or DEFAULT_QUERY + + config_path = workspace_relative(config_path) + if config_path is None: + print("Config must be inside the project directory being synced.") + return 1 + + if not os.path.isfile(config_path): + print(f"Config file not found: {config_path}. Run `canyonos build` first.") + return 1 + + api_port = workflow_api_port(config_path) + if api_port is None: + print(f"No agent with `type: workflow` in {config_path}; nothing to test.") + return 1 + + print(f"Testing {config_path} locally (query: {query!r})") + original_config = _force_local_providers(config_path) + + try: + run_init() + if not run_sync(): + return 1 + + state = load_state() + try: + post_deploy(state["port"], config_path) + except GCError as e: + print(e) + return 1 + + if not _wait_for_workflow(state["port"], api_port, console): + print("The deploy did not come up. Run `canyonos logs` to see why.") + return 1 + + try: + request_id = _send_query(api_port, query) + except OSError as e: + print(f"Could not reach the workflow on port {api_port}: {e}") + return 1 + result = _await_result(api_port, request_id, console) + except KeyboardInterrupt: + print("\nTest cancelled.") + return 1 + finally: + with open(config_path, "w") as f: + f.write(original_config) + # A smoke test leaves nothing behind: run_init() started this container. + quit_existing() + + status = result.get("status") + if status == "done": + print("Test passed.") + print(json.dumps(result.get("result"), indent=2)) + return 0 + + if status == "error": + print(f"Test failed: {result.get('error')}") + else: + print(f"Test failed: workflow did not finish within {REQUEST_TIMEOUT}s.") + return 1 diff --git a/cli/cli.py b/cli/cli.py index 4c78043..770741f 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1,42 +1,33 @@ """ -Most of the commands will be executed by code in the canyonos container. -Anything executing in this CLI pertains to file/folder modification +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.clean import run_clean from canyonos.constants import default_config_path from canyonos.config import run_config from canyonos.deploy import run_deploy -from canyonos.integrate import run_integrate +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.stop import run_stop -from canyonos.sync import run_sync - -try: - from rich.console import Console - from rich.panel import Panel - from rich.text import Text - from rich.table import Table - RICH_AVAILABLE = True -except ImportError: - RICH_AVAILABLE = False - -def cmd_connect(args): - pass +from canyonos.test import DEFAULT_QUERY, run_test +from utils.help_screen import DESCRIPTIONS, print_custom_help def cmd_quit(args): run_quit() def cmd_new_app(args): + # Note, not tested much, keeping this in the back burner for now while we flesh out the main path run_new_app() -# Executed in canyonos: syncs files, then builds + deploys def cmd_deploy(args): run_deploy(args.config, serve=args.serve) @@ -49,111 +40,24 @@ def cmd_stop(args): def cmd_logs(args): run_logs() -def cmd_sync(args): - run_sync() - def cmd_config(args): run_config() -def cmd_integrate(args): - run_integrate() +def cmd_build(args): + run_build() def cmd_doctor(args): - pass + sys.exit(0 if run_doctor() else 1) def cmd_serve(args): - sys.exit(run_serve(args.config)) + sys.exit(run_serve()) -# Executed in canyonos def cmd_test(args): - pass - -# Executed in canyonos -def cmd_mega_build(args): - pass + sys.exit(run_test(args.config, query=args.query)) def cmd_version(args): - pass - - -def print_custom_help(): - """Print a custom, visually appealing help screen.""" - if RICH_AVAILABLE: - console = Console() - - # Header - title = Text("CanyonOS CLI", style="bold cyan") - subtitle = Text("\nBuild, deploy, and manage agentic workflows with ease\n", style="dim") - - console.print(Panel(title + subtitle, border_style="cyan", padding=(1, 2))) - - # Core commands - console.print("\n[bold yellow]Core Commands[/bold yellow]") - core_table = Table(show_header=False, border_style="dim", padding=(0, 2)) - core_table.add_column(style="cyan", width=20) - core_table.add_column(style="white") - core_table.add_row("integrate", "Sync source files to .car/app/") - core_table.add_row("deploy", "Build and deploy agents to configured hosts") - core_table.add_row("config", "Configure project settings") - console.print(core_table) - - # Utils commands - console.print("\n[bold yellow]Utils[/bold yellow]") - utils_table = Table(show_header=False, border_style="dim", padding=(0, 2)) - utils_table.add_column(style="cyan", width=20) - utils_table.add_column(style="white") - utils_table.add_row("new-app", "Create a new CanyonOS project") - utils_table.add_row("serve", "Start local CanyonOS dashboard") - utils_table.add_row("sync", "Sync files with container") - utils_table.add_row("stop", "Stop running containers") - utils_table.add_row("clean", "Remove generated files") - utils_table.add_row("logs", "View container logs") - utils_table.add_row("doctor", "Check system health") - utils_table.add_row("connect", "Connect to remote host") - utils_table.add_row("quit", "Shut down CanyonOS services") - console.print(utils_table) - - # Quick start - console.print("\n[bold green]Quick Start:[/bold green]") - console.print(" [dim]1.[/dim] canyonos new-app [cyan]my-app[/cyan]") - console.print(" [dim]2.[/dim] cd [cyan]my-app[/cyan]") - console.print(" [dim]3.[/dim] canyonos integrate") - console.print(" [dim]4.[/dim] canyonos deploy") - console.print(" [dim]5.[/dim] canyonos serve\n") - - console.print("[dim]For command-specific help: [cyan]canyonos --help[/cyan][/dim]\n") - else: - # Fallback to simple text if rich is not available - print("\n" + "="*60) - print(" " * 20 + "CanyonOS CLI") - print(" " * 10 + "Build, deploy, and manage agentic workflows") - print("="*60 + "\n") - - print("CORE COMMANDS:") - print(" integrate Sync source files to .car/app/") - print(" deploy Build and deploy agents to configured hosts") - print(" config Configure project settings\n") - - print("UTILS:") - print(" new-app Create a new CanyonOS project") - print(" serve Start local CanyonOS dashboard") - print(" sync Sync files with container") - print(" stop Stop running containers") - print(" clean Remove generated files") - print(" logs View container logs") - print(" doctor Check system health") - print(" connect Connect to remote host") - print(" quit Shut down CanyonOS services\n") - - print("QUICK START:") - print(" 1. canyonos new-app my-app") - print(" 2. cd my-app") - print(" 3. canyonos integrate") - print(" 4. canyonos deploy") - print(" 5. canyonos serve\n") - - print("For command-specific help: canyonos --help\n") + print(f"canyonos {importlib.metadata.version('canyonos')}") def _parse_bool(value): @@ -164,18 +68,30 @@ def _parse_bool(value): 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 = argparse.ArgumentParser(prog="canyonos") - subparsers = parser.add_subparsers(dest="command") + 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) config_default = default_config_path() - subparsers.add_parser("new-app").set_defaults(func=cmd_new_app) - deploy = subparsers.add_parser("deploy") + def add(name): + # A KeyError here means the command has no entry on the help screen. + return subparsers.add_parser(name, help=DESCRIPTIONS[name]) + + add("new-app").set_defaults(func=cmd_new_app) + deploy = add("deploy") deploy.add_argument( "-c", "--config", - default=config_default, - help=f"Path to global controller config (default: {config_default})", + help="Path to global controller config (default: resolved by ventis inside the container)", ) deploy.add_argument( "--serve", @@ -185,32 +101,42 @@ def main(): help="Automatically launch the local dashboard (canyonos serve) once the workflow is up (default: true)", ) deploy.set_defaults(func=cmd_deploy) - subparsers.add_parser("clean").set_defaults(func=cmd_clean) - subparsers.add_parser("stop").set_defaults(func=cmd_stop) - subparsers.add_parser("logs").set_defaults(func=cmd_logs) - subparsers.add_parser("quit").set_defaults(func=cmd_quit) - subparsers.add_parser("connect").set_defaults(func=cmd_connect) - subparsers.add_parser("sync").set_defaults(func=cmd_sync) - subparsers.add_parser("config").set_defaults(func=cmd_config) - subparsers.add_parser("integrate").set_defaults(func=cmd_integrate) - subparsers.add_parser("doctor").set_defaults(func=cmd_doctor) - serve = subparsers.add_parser("serve") - serve.add_argument( + add("clean").set_defaults(func=cmd_clean) + add("stop").set_defaults(func=cmd_stop) + add("logs").set_defaults(func=cmd_logs) + add("quit").set_defaults(func=cmd_quit) + add("config").set_defaults(func=cmd_config) + add("build").set_defaults(func=cmd_build) + add("doctor").set_defaults(func=cmd_doctor) + add("version").set_defaults(func=cmd_version) + add("serve").set_defaults(func=cmd_serve) + test = add("test") + test.add_argument( "-c", "--config", default=config_default, help=f"Path to global controller config (default: {config_default})", ) - serve.set_defaults(func=cmd_serve) - subparsers.add_parser("test").set_defaults(func=cmd_test) - subparsers.add_parser("mega-build").set_defaults(func=cmd_mega_build) + test.add_argument( + "-q", + "--query", + default=DEFAULT_QUERY, + help=f"Query sent to the workflow (default: {DEFAULT_QUERY!r})", + ) + test.set_defaults(func=cmd_test) args = parser.parse_args() if not getattr(args, "command", None): - print_custom_help() + parser.print_help() return - args.func(args) + 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. + print(e) + sys.exit(1) if __name__ == "__main__": diff --git a/cli/utils/help_screen.py b/cli/utils/help_screen.py new file mode 100644 index 0000000..94b94a0 --- /dev/null +++ b/cli/utils/help_screen.py @@ -0,0 +1,60 @@ +"""Custom help screen for the canyonos CLI.""" + +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +# 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", "Build a compatable workflow with an agent"), + ("deploy", "Deploy agents"), + ("config", "Configure project settings"), +) + +UTIL_COMMANDS = ( + ("clean", "Remove the generated .car folder from build"), + ("doctor", "See if all required tools are up"), + ("logs", "View canyonos logs"), + ("new-app", "Create a barebones CanyonOS project"), + ("quit", "Shut down CanyonOS services"), + ("serve", "Start local CanyonOS dashboard"), + ("stop", "Stop running containers"), + ("test", "Run the deployed workflow locally with a test query"), + ("version", "Print canyonos version"), +) + +DESCRIPTIONS = dict(CORE_COMMANDS + UTIL_COMMANDS) + + +def _command_table(commands): + table = Table(show_header=False, border_style="dim", padding=(0, 2)) + table.add_column(style="cyan", width=20) + table.add_column(style="white") + for name, description in commands: + table.add_row(name, description) + return table + + +def print_custom_help(): + """Print a custom, visually appealing help screen.""" + console = Console() + + title = Text("CanyonOS CLI", style="bold cyan") + subtitle = Text("\nBuild, deploy, and manage agentic workflows with ease\n", style="dim") + console.print(Panel(title + subtitle, border_style="cyan", padding=(1, 2))) + + console.print("\n[bold yellow]Core Commands[/bold yellow]") + console.print(_command_table(CORE_COMMANDS)) + + console.print("\n[bold yellow]Utils[/bold yellow]") + console.print(_command_table(UTIL_COMMANDS)) + + console.print("\n[bold green]Quick Start:[/bold green]") + console.print(" [dim]1.[/dim] cd [cyan]into-your-workflow-root-dir[/cyan]") + console.print(" [dim]2.[/dim] canyonos build (opens coding agent)") + console.print(" [dim]3.[/dim] canyonos deploy (UI automatically starts)") + + console.print("[dim]For command-specific help: [cyan]canyonos --help[/cyan][/dim]\n") diff --git a/cli/utils/tui.py b/cli/utils/tui.py index 3fcdad5..2f0f15d 100644 --- a/cli/utils/tui.py +++ b/cli/utils/tui.py @@ -53,8 +53,6 @@ def select_menu(options, title, deletable=False, quittable=False): `QUIT_ACTION` -- distinct from None -- so the caller can unwind an entire nested session rather than just this one menu. """ - if len(options) == 1: - return options[0][0] if not options or not sys.stdin.isatty(): return None diff --git a/cli/tests/test_dashboard_stack.py b/tests/test_dashboard_stack.py similarity index 100% rename from cli/tests/test_dashboard_stack.py rename to tests/test_dashboard_stack.py From 9447e348a511466397194fda3265f875dbe895f6 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Fri, 4 Sep 2026 23:13:04 -0700 Subject: [PATCH 29/44] cleanup --- README.md | 4 +- cli/canyonos/build.py | 71 +-- cli/canyonos/clean.py | 10 +- cli/canyonos/config.py | 41 +- cli/canyonos/dashboard_stack.py | 78 ++-- cli/canyonos/deploy.py | 401 ++++++++++++++--- cli/canyonos/doctor.py | 39 +- cli/canyonos/gc.py | 16 +- cli/canyonos/init.py | 28 +- cli/canyonos/logs.py | 8 +- cli/canyonos/new_app.py | 6 +- cli/canyonos/quit.py | 10 +- cli/canyonos/serve.py | 39 +- cli/canyonos/status.py | 55 +++ cli/canyonos/stop.py | 10 +- cli/canyonos/sync.py | 19 +- cli/canyonos/test.py | 365 ++++++++++++--- cli/canyonos/ui.py | 67 +++ cli/canyonos/verify.py | 291 ++++++++++++ cli/cli.py | 100 ++-- cli/pyproject.toml | 2 +- cli/utils/help_screen.py | 60 ++- cli/utils/tui.py | 8 +- examples/finance/agents/finance_agent.py | 9 +- examples/finance/workflow/example_workflow.py | 8 +- examples/helloworld/README.md | 4 +- .../helloworld/workflow/example_workflow.py | 6 +- examples/portfolio/agents/metrics_agent.py | 14 +- .../text2sql/agents/sql_generator_agent.py | 9 +- .../text2sql/workflow/text2sql_workflow.py | 12 +- pyproject.toml | 5 + tests/test_canyonos_test.py | 426 ++++++++++++++++++ tests/test_dashboard_stack.py | 146 +----- tests/test_deploy_progress.py | 235 ++++++++++ tests/test_integration.py | 2 +- uv.lock | 77 +++- .../cloud_provider_logic/EC2/_runtime.py | 11 +- .../cloud_provider_logic/Local/_runtime.py | 2 + ventis/controller/instance_manager.py | 9 +- ventis/server.py | 80 +++- 40 files changed, 2213 insertions(+), 570 deletions(-) create mode 100644 cli/canyonos/status.py create mode 100644 cli/canyonos/ui.py create mode 100644 cli/canyonos/verify.py create mode 100644 tests/test_canyonos_test.py create mode 100644 tests/test_deploy_progress.py diff --git a/README.md b/README.md index 1d61db8..0f1ea27 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The Readme in the newly created project directory provides a quick overview of t #### Step 2: Define Your Agents Agent declarations live under `.car/config/`. The source used for builds is -copied to `.car/app/` by `canyonos integrate`. +copied to `.car/app/` by `canyonos build`. - **`.car/config/my_agent.yaml`**: Defines methods and schemas. - **`.car/app/path/to/my_agent.py`**: Contains the agent implementation. @@ -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/canyonos/build.py b/cli/canyonos/build.py index f5292aa..c9b56b4 100644 --- a/cli/canyonos/build.py +++ b/cli/canyonos/build.py @@ -10,8 +10,7 @@ import tempfile import urllib.request -from rich.console import Console - +from canyonos import ui from utils.tui import select_menu SKILL_OWNER = "CanyonCodeCoreAI" @@ -43,19 +42,24 @@ " 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", - # Claude Code auto-loads project-local skills from here. The leaf name - # must match the skill's own `name:` frontmatter or it won't resolve. - "skill_dir": SKILL_PATH, + "skill_dirs": { + "local": SKILL_PATH, + "global": os.path.expanduser(f"~/.claude/skills/{SKILL_NAME}"), + }, }, "codex": { "label": "Codex", "cli": "codex", - # Codex only auto-loads skills from the user's home directory, not per-project. - "skill_dir": os.path.expanduser(f"~/.codex/skills/{SKILL_NAME}"), + "skill_dirs": { + "local": f".codex/skills/{SKILL_NAME}", + "global": os.path.expanduser(f"~/.codex/skills/{SKILL_NAME}"), + }, }, } @@ -65,6 +69,15 @@ def prompt_agent(): 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) @@ -143,47 +156,32 @@ def _fetch_with_tarball(dest): return True -def _fetch_with_npx(dest): - """Last resort, and the only strategy that needs Node.""" - if not shutil.which("npx"): - return False - # -f overwrites an existing skill dir; without it gitpick exits 1 when the - # target already exists and is non-empty (e.g. re-running `build`). - return subprocess.run( - ["npx", "-y", "gitpick", "-f", TREE_URL, dest], capture_output=True - ).returncode == 0 - - FETCH_STRATEGIES = ( ("git", _fetch_with_git), ("tarball", _fetch_with_tarball), - ("npx", _fetch_with_npx), ) -def install_skill(agent, console): - """Fetch the skill into the agent's skill dir. Returns True on success.""" - dest = AGENTS[agent]["skill_dir"] +def install_skill(dest): + """Fetch the skill into `dest`. Returns True on success.""" for name, fetch in FETCH_STRATEGIES: try: if fetch(dest): - console.print(f"Fetched the CanyonOS skill via {name}.") + ui.ok(f"Fetched the CanyonOS skill via {name}.") return True except OSError: pass - console.print(f"[dim]{name} fetch unavailable, trying the next option...[/dim]") + ui.hint(f"{name} fetch unavailable, trying the next option...") - console.print( - f"Could not fetch the CanyonOS skill from {TREE_URL}.\n" - "Install git or Node, or check network access, then run `canyonos doctor`." - ) + 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"]): - print(f"`{spec['cli']}` not found on PATH; install {spec['label']} first.") + 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. @@ -191,15 +189,20 @@ def launch_agent(agent, prompt): def run_build(): - console = Console() agent = prompt_agent() if agent is None: - console.print("Cancelled.") + ui.say("Cancelled.") + return + + scope = prompt_scope(agent) + if scope is None: + ui.say("Cancelled.") return - console.print(f"Installing CanyonOS skill for {AGENTS[agent]['label']}...") - if not install_skill(agent, console): + dest = AGENTS[agent]["skill_dirs"][scope] + ui.say(f"Installing CanyonOS skill for {AGENTS[agent]['label']} into {dest}...") + if not install_skill(dest): return - console.print(f"Launching {AGENTS[agent]['label']}...") + ui.say(f"Launching {AGENTS[agent]['label']}...") launch_agent(agent, BUILD_PROMPT) diff --git a/cli/canyonos/clean.py b/cli/canyonos/clean.py index d7cc6f5..9c974b4 100644 --- a/cli/canyonos/clean.py +++ b/cli/canyonos/clean.py @@ -5,14 +5,16 @@ 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): - print("Nothing to clean, no .car folder in root") + ui.warn("Nothing to clean, no .car folder in root") return - print(f"Cleaning {car_dir}...") - shutil.rmtree(car_dir) - print("Clean complete.") + 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 index 25d2d3d..aec5b12 100644 --- a/cli/canyonos/config.py +++ b/cli/canyonos/config.py @@ -5,11 +5,11 @@ import os import yaml -from rich.console import Console 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__" @@ -98,30 +98,29 @@ def _kv_table(title, data): return table -def _require_config(config_path, console): +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): - console.print(f"[red]Config file not found: {config_path}[/red]") + ui.fail(f"Config file not found: {config_path}") return None return config_path def run_view_config(config_path=None): - console = Console() - config_path = _require_config(config_path, console) + config_path = _require_config(config_path) if config_path is None: return with open(config_path) as f: config = yaml.safe_load(f) or {} - console.print(_agents_table(config.get("agents") or [])) - console.print() + ui.console.print(_agents_table(config.get("agents") or [])) + ui.blank() if config.get("otel"): - console.print(_otel_table(config["otel"])) - console.print() + 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. @@ -130,13 +129,13 @@ def run_view_config(config_path=None): if key in STRUCTURED_KEYS: continue if isinstance(value, dict): - console.print(_kv_table(key, value)) - console.print() + ui.console.print(_kv_table(key, value)) + ui.blank() else: general[key] = value if general: - console.print(_kv_table("General", general)) + ui.console.print(_kv_table("General", general)) def _is_leaf(value): @@ -294,8 +293,7 @@ def _navigate(screen, node, breadcrumb): def run_change_config(config_path=None): - console = Console() - config_path = _require_config(config_path, console) + config_path = _require_config(config_path) if config_path is None: return @@ -304,14 +302,14 @@ def run_change_config(config_path=None): data = yaml_rt.load(f) if not data: - console.print("[yellow]Config is empty; nothing to change.[/yellow]") + ui.warn("Config is empty; nothing to change.") return - screen = _Screen(console) + screen = _Screen(ui.console) saves = 0 # Alternate screen: the whole session replaces the view, and the terminal # scrollback is restored untouched on exit. - console.set_alt_screen(True) + ui.console.set_alt_screen(True) try: while True: changed = _navigate(screen, data, ["config"]) @@ -323,19 +321,18 @@ def run_change_config(config_path=None): saves += 1 screen.status = f"Saved to {config_path}" finally: - console.set_alt_screen(False) + ui.console.set_alt_screen(False) if saves: - console.print(f"[{GREEN}]Saved {saves} change(s) to {config_path}[/]") + ui.ok(f"Saved {saves} change(s) to {config_path}") else: - console.print("No changes made.") + ui.say("No changes made.") def run_config(): - console = Console() choice = select_menu(OPTIONS, title="What do you want to do?") if choice is None: - console.print("Cancelled.") + ui.say("Cancelled.") return if choice == "view": diff --git a/cli/canyonos/dashboard_stack.py b/cli/canyonos/dashboard_stack.py index c606357..e26fbcf 100644 --- a/cli/canyonos/dashboard_stack.py +++ b/cli/canyonos/dashboard_stack.py @@ -10,7 +10,6 @@ import shutil import socket import subprocess -import tempfile import time import urllib.error import urllib.request @@ -39,11 +38,10 @@ class ServeResult: class PhaseFailure(Exception): - def __init__(self, phase: str, message: str, *, had_containers: bool | None = None): + def __init__(self, phase: str, message: str): super().__init__(message) self.phase = phase self.message = message - self.had_containers = had_containers @dataclass(frozen=True) @@ -179,8 +177,14 @@ def _read_existing_secret(env_path: Path) -> str | 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) @@ -191,40 +195,27 @@ def _env_line(key: str, value: str) -> str: 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 = [] - managed_keys = set(managed_env) replaced: set[str] = set() updated_lines: list[str] = [] for line in lines: key, separator, _ = line.partition("=") - if separator and key in managed_keys: + 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) - for key, value in managed_env.items(): - if key not in replaced: - updated_lines.append(_env_line(key, value)) - - descriptor, temporary_name = tempfile.mkstemp(prefix=".env.", dir=env_path.parent) - temporary_path = Path(temporary_name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as output: - os.fchmod(output.fileno(), 0o600) - output.writelines(updated_lines) - os.replace(temporary_path, env_path) - except Exception: - try: - temporary_path.unlink() - except FileNotFoundError: - pass - raise + 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]: @@ -257,36 +248,27 @@ def prepare(stack: DashboardStack) -> tuple[dict[str, str], str]: return managed_env, "dashboard state prepared" -def _last_stderr_line(result: subprocess.CompletedProcess[str]) -> str | None: - return next((line.strip() for line in reversed(result.stderr.splitlines()) if line.strip()), None) - - def _command_failure_message( message: str, result: subprocess.CompletedProcess[str], managed_env: dict[str, str], ) -> str: - detail = _last_stderr_line(result) + 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], - had_containers: bool, -) -> str: +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", had_containers=had_containers) + 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), - had_containers=had_containers, + "pull", _command_failure_message("docker compose pull failed", result, managed_env) ) return "dashboard images pulled" @@ -299,8 +281,7 @@ def _project_has_running_containers(stack: DashboardStack, manifest: Path) -> bo return result.returncode == 0 and bool(result.stdout.strip()) -def start(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> bool: - had_containers = _project_has_running_containers(stack, manifest) +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. @@ -310,14 +291,11 @@ def start(stack: DashboardStack, manifest: Path, managed_env: dict[str, str]) -> [*_compose_argv(stack, manifest), "up", "-d", "--wait", "--wait-timeout", "180"] ) except OSError: - raise PhaseFailure("start", "could not run docker compose up", had_containers=had_containers) + 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), - had_containers=had_containers, + "start", _command_failure_message("docker compose up failed", result, managed_env) ) - return had_containers def verify(port: int) -> str: @@ -386,6 +364,8 @@ def report(result: ServeResult) -> None: 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: @@ -397,11 +377,11 @@ def report(result: ServeResult) -> None: manifest_resource = importlib.resources.files("canyonos").joinpath("dashboard.compose.yml") manifest = resources.enter_context(importlib.resources.as_file(manifest_resource)) - had_containers_before_pull = _project_has_running_containers(stack, manifest) - pull_message = pull(stack, manifest, managed_env, had_containers_before_pull) - report(ServeResult(True, "pull", pull_message)) + had_containers = _project_has_running_containers(stack, manifest) + + report(ServeResult(True, "pull", pull(stack, manifest, managed_env))) - had_containers = start(stack, manifest, managed_env) + start(stack, manifest, managed_env) report(ServeResult(True, "start", "dashboard stack started")) url = verify(stack.web_port) @@ -411,7 +391,7 @@ def report(result: ServeResult) -> None: 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 (failure.had_containers if failure.had_containers is not None else had_containers): + 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 index 57c32ad..048cd3d 100644 --- a/cli/canyonos/deploy.py +++ b/cli/canyonos/deploy.py @@ -3,43 +3,162 @@ 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 streams the logs. +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.console import Console 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, post_deploy +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 run_serve +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 -def run_deploy(config_path=None, serve=True): + 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: - print("Config must be inside the project directory being synced.") + ui.fail("Config must be inside the project directory being synced.") return run_init() @@ -55,76 +174,254 @@ def run_deploy(config_path=None, serve=True): try: post_deploy(state["port"], config_path) - _stream_logs_and_autoserve(state["container_id"], api_port, serve=serve) + _stream_logs_and_autoserve(state, api_port, serve=serve, verbose=verbose) except GCError as e: - print(e) + ui.fail(e) -def print_workflow_endpoint(console, api_port): - """The one thing you need after a deploy: where to send requests. +def workflow_targets(gc_port, api_port): + """(name, host, port) for each deployed workflow. - Printed at the workflow-up marker and again on exit, because `deploy` keeps - tailing logs afterwards and would otherwise scroll it out of sight. + 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. """ - if api_port is None: - return + 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 [] - url = f"http://127.0.0.1:{api_port}/{WORKFLOW_ROUTE}" - body = Text.assemble( - ("POST ", "dim"), - (url, f"bold {GREEN}"), - ("\nbody ", "dim"), - ('{"query": "your question here"}', WHITE), - ("\npoll ", "dim"), - (f"http://127.0.0.1:{api_port}/status/", WHITE), - ) - console.print() - console.print( + +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( - body, - title=f"[bold {GREEN}]Workflow is live[/]", + _summary_body(dashboard_url, targets), + title=f"[bold {GREEN}]Deploy is live[/]", title_align="left", border_style=GREEN, padding=(1, 4), ) ) - console.print() + 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 _stream_logs_and_autoserve(container_id, api_port, serve=True): +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, - print where to reach it -- plus, unless disabled via `serve=False`, launch - `canyonos serve`. Log tailing continues afterwards. + start the dashboard (unless disabled via `serve=False`) and print where + everything lives. Log tailing continues afterwards. """ - console = Console() process = subprocess.Popen( - ["docker", "logs", "-f", container_id], + ["docker", "logs", "-f", state["container_id"]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) - served = not serve - workflow_up = False try: - for line in process.stdout: - print(line, end="") - if not workflow_up and _WORKFLOW_UP_MARKER in line: - workflow_up = True - print_workflow_endpoint(console, api_port) - if not served: - served = True - print("Starting the local dashboard (canyonos serve)...") - try: - run_serve() - except Exception as e: - print(f"Could not start the dashboard automatically: {e}") - print("Run `canyonos serve` manually to view it.") + 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: - print("\nStopped monitoring log stream. Run `canyonos stop` to stop the deploy.") - print("To resubscribe to log stream run `canyonos logs`.") - if workflow_up: - print_workflow_endpoint(console, api_port) + _interrupted() finally: if process.poll() is None: process.terminate() diff --git a/cli/canyonos/doctor.py b/cli/canyonos/doctor.py index 7dd9883..15c36af 100644 --- a/cli/canyonos/doctor.py +++ b/cli/canyonos/doctor.py @@ -7,17 +7,9 @@ import shutil import subprocess +from canyonos import ui from canyonos.build import AGENTS -from canyonos.init import docker_start_command - - -def _docker_installed(): - return shutil.which("docker") is not None - - -def _docker_daemon_running(): - result = subprocess.run(["docker", "info"], capture_output=True) - return result.returncode == 0 +from canyonos.init import docker_running, docker_start_command def _compose_available(): @@ -25,10 +17,6 @@ def _compose_available(): return result.returncode == 0 -def _git_available(): - return shutil.which("git") is not None - - def _docker_daemon_fix(): """Names the command for the active docker context, since `canyonos deploy` would run exactly that itself.""" @@ -38,23 +26,16 @@ def _docker_daemon_fix(): return "start your Docker runtime (on Linux: `sudo systemctl start docker`)" -def _coding_agent_available(): - return any(shutil.which(spec["cli"]) for spec in AGENTS.values()) - - def _checks(): - """Built fresh on each call (not a module-level constant) so tests can - patch the individual `_check_*` functions by name and have it take effect. - """ return [ ( "Docker installed", - _docker_installed, + lambda: shutil.which("docker") is not None, "install Docker: https://docs.docker.com/get-docker/", ), ( "Docker daemon running", - _docker_daemon_running, + docker_running, _docker_daemon_fix(), ), ( @@ -64,13 +45,13 @@ def _checks(): ), ( "git available", - _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", - _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)", @@ -88,9 +69,11 @@ def run_doctor(): passed = False fix = f"{fix} (error: {e})" - print(f"{'✓' if passed else '✗'} {label}") - if not passed: - print(f" -> {fix}") + 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 index b5af7eb..a8778b3 100644 --- a/cli/canyonos/gc.py +++ b/cli/canyonos/gc.py @@ -7,6 +7,7 @@ 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." @@ -46,7 +47,7 @@ def require_state(): try: return load_state() except FileNotFoundError: - print("No Global Controller container is running. Run `canyonos deploy` first.") + ui.warn("No Global Controller container is running. Run `canyonos deploy` first.") return None @@ -73,6 +74,19 @@ def post_clean(port): 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" diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py index 69e8fbb..6b07f84 100644 --- a/cli/canyonos/init.py +++ b/cli/canyonos/init.py @@ -16,9 +16,8 @@ # Formatting from pyfiglet import figlet_format -from rich.console import Console -from canyonos.theme import GRADIENT +from canyonos import ui @@ -76,7 +75,7 @@ def docker_start_command(): return None -def ensure_docker_running(console, timeout=DOCKER_START_TIMEOUT): +def ensure_docker_running(timeout=DOCKER_START_TIMEOUT): if docker_running(): return @@ -89,14 +88,14 @@ def ensure_docker_running(console, timeout=DOCKER_START_TIMEOUT): "docker context. Start it (on Linux: `sudo systemctl start docker`) and re-run." ) - console.print(f"Docker isn't running -- starting it with `{' '.join(command)}`...") + ui.say(f"Docker isn't running -- starting it with `{' '.join(command)}`...") subprocess.run(command, capture_output=True) deadline = time.time() + timeout - with console.status("Waiting for the Docker daemon..."): + with ui.status("Waiting for the Docker daemon..."): while time.time() < deadline: if docker_running(): - console.print("Docker is running.") + ui.ok("Docker is running.") return time.sleep(1) @@ -200,20 +199,17 @@ def quit_existing(): run_quit() -def run_init(): - console = Console() - banner = figlet_format("CANYON OS", font="ansi_shadow", width=200) - - for line, color in zip(banner.splitlines(), GRADIENT): - console.print(line, style=color) +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(console) + ensure_docker_running() quit_existing() - with console.status("Pulling Global Controller image..."): + with ui.status("Pulling Global Controller image..."): pull_image() - with console.status("Starting Global Controller container..."): + with ui.status("Starting Global Controller container..."): container_id, port = run_container() save_state(container_id, port) - print(f"Global Controller running in container {container_id[:12]} on port {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 index 9839134..3969af6 100644 --- a/cli/canyonos/logs.py +++ b/cli/canyonos/logs.py @@ -4,6 +4,7 @@ import subprocess +from canyonos import ui from canyonos.gc import deploy_status, require_state @@ -14,14 +15,15 @@ def run_logs(): status = deploy_status(state["port"]) if status is None: - print("Could not reach Global Controller container.") + ui.fail("Could not reach Global Controller container.") return if not status.get("running"): - print("No deploy running, run `canyonos deploy` to deploy project.") + ui.warn("No deploy running, run `canyonos deploy` to deploy project.") return try: subprocess.run(["docker", "logs", "-f", state["container_id"]]) except KeyboardInterrupt: - print("\nStopped monitoring log stream. Run `canyonos stop` to stop the deploy.") + 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 index 30e93b0..31aeb44 100644 --- a/cli/canyonos/new_app.py +++ b/cli/canyonos/new_app.py @@ -5,10 +5,12 @@ import os +from canyonos import ui + def run_new_app(): if os.listdir("."): - print("Directory is not empty. Run `canyonos new-app` in an empty directory.") + ui.fail("Directory is not empty. Run `canyonos new-app` in an empty directory.") return for folder in ("agents", "config", "workflow"): @@ -18,4 +20,4 @@ def run_new_app(): for filename in ("global_controller.yaml", "policy.yaml"): open(os.path.join("config", filename), "w").close() - print("Created new CanyonOS project.") + ui.ok("Created new CanyonOS project.") diff --git a/cli/canyonos/quit.py b/cli/canyonos/quit.py index bb2e11d..9af5dcc 100644 --- a/cli/canyonos/quit.py +++ b/cli/canyonos/quit.py @@ -8,8 +8,7 @@ import os import subprocess -from rich.console import Console - +from canyonos import ui from canyonos.gc import GCError, post_clean, require_state from canyonos.init import GC_WORKSPACE_VOLUME, STATE_PATH @@ -27,8 +26,7 @@ def run_quit(): return container_id = state["container_id"] - console = Console() - with console.status("Tearing down..."): + 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 @@ -54,6 +52,6 @@ def run_quit(): os.remove(STATE_PATH) if already_gone: - print(f"Global Controller container {container_id[:12]} was already gone; cleaned up local state.") + ui.warn(f"Global Controller container {container_id[:12]} was already gone; cleaned up local state.") else: - print(f"Global Controller container {container_id[:12]} torn down (volume removed)") + 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 index 9515699..c96e336 100644 --- a/cli/canyonos/serve.py +++ b/cli/canyonos/serve.py @@ -1,18 +1,37 @@ """CLI output for the local dashboard stack.""" -from .dashboard_stack import run_dashboard +from canyonos import ui +from .dashboard_stack import ServeResult, run_dashboard -def run_serve() -> int: - def report(phase: str, message: str) -> None: - print(f"[serve] {phase}: {message}") +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) - result = run_dashboard(report) if result.ok: - print(f"Dashboard: {result.url}") - return 0 + return result - print(f"serve failed in {result.phase}: {result.message}") + for phase, message in trace: + ui.hint(f"{phase}: {message}") + ui.fail(f"serve failed in {result.phase}: {result.message}") if result.log_path: - print(f"log: {result.log_path}") - return 1 + 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 index a3f96f3..519cf49 100644 --- a/cli/canyonos/stop.py +++ b/cli/canyonos/stop.py @@ -3,8 +3,7 @@ Controller container (SIGTERM, same teardown as Ctrl+C would trigger). """ -from rich.console import Console - +from canyonos import ui from canyonos.gc import GCError, post_clean, require_state @@ -13,10 +12,9 @@ def run_stop(): if state is None: return - console = Console() try: - with console.status("Stopping deploy..."): + with ui.status("Stopping deploy..."): post_clean(state["port"]) - print("Deploy stopped.") + ui.ok("Deploy stopped.") except GCError as e: - print(e) + ui.fail(e) diff --git a/cli/canyonos/sync.py b/cli/canyonos/sync.py index 20ac3c1..84f875c 100644 --- a/cli/canyonos/sync.py +++ b/cli/canyonos/sync.py @@ -13,6 +13,7 @@ import os import subprocess +from canyonos import ui from canyonos.gc import require_state from canyonos.init import GC_WORKSPACE_PATH @@ -27,14 +28,18 @@ def run_sync(): # Trailing "/." copies the *contents* of the current directory into # /workspace, rather than nesting it under /workspace/. src = os.path.join(os.getcwd(), ".") - print(f"Syncing {os.getcwd()} -> {container_id[:12]}:{GC_WORKSPACE_PATH} ...") - - result = subprocess.run( - ["docker", "cp", src, f"{container_id}:{GC_WORKSPACE_PATH}"] - ) + 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: - print("Sync failed.") + ui.fail(f"Sync failed: {result.stderr.strip() or result.stdout.strip()}") return False - print("Sync complete.") + ui.ok("Sync complete.") return True diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py index 468527a..2409965 100644 --- a/cli/canyonos/test.py +++ b/cli/canyonos/test.py @@ -1,20 +1,28 @@ """ -Logic for `canyonos test`: smoke-test a project end to end on this machine. +Logic for `canyonos test`: check a project end to end on this machine. -Every agent's `provider` is rewritten to `local` for the duration of the run -(the original file is restored verbatim afterwards), the project is deployed -into the Global Controller container, one query is sent to the workflow's -`/main` endpoint, and its result -- or the error that came back -- is printed. +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.console import Console +from rich.panel import Panel +from rich.text import Text +from canyonos import ui from canyonos.constants import ( WORKFLOW_ROUTE, default_config_path, @@ -22,16 +30,25 @@ 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): @@ -51,13 +68,21 @@ def _force_local_providers(config_path): return original -def _workflow_ready(api_port): +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://127.0.0.1:{api_port}/status/canyonos-test-probe" + url = f"http://{host}:{port}/status/canyonos-test-probe" try: urllib.request.urlopen(url, timeout=2) return True @@ -67,33 +92,32 @@ def _workflow_ready(api_port): return False -def _wait_for_workflow(gc_port, api_port, console): +def _wait_for_workflow(gc_port, api_port): deadline = time.time() + READY_TIMEOUT - with console.status("Building and starting containers..."): + with ui.status("Building images and starting containers..."): while time.time() < deadline: - if _workflow_ready(api_port): - return True + if _workflow_ready("127.0.0.1", api_port): + return if not (deploy_status(gc_port) or {}).get("running", False): - return False + raise _TestFailed("The deploy stopped before the workflow came up.") time.sleep(POLL_INTERVAL) - print(f"Timed out after {READY_TIMEOUT}s waiting for the workflow to come up.") - return False + raise _TestFailed(f"Timed out after {READY_TIMEOUT}s waiting for the workflow to come up.") -def _send_query(api_port, query): - url = f"http://127.0.0.1:{api_port}/{WORKFLOW_ROUTE}" +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=30) as resp: + with urllib.request.urlopen(req, timeout=SUBMIT_TIMEOUT) as resp: return json.loads(resp.read())["request_id"] -def _await_result(api_port, request_id, console): - url = f"http://127.0.0.1:{api_port}/status/{request_id}" +def _await_result(host, port, request_id): + url = f"http://{host}:{port}/status/{request_id}" deadline = time.time() + REQUEST_TIMEOUT - with console.status("Running query..."): + with ui.status("Running query..."): while time.time() < deadline: try: with urllib.request.urlopen(url, timeout=10) as resp: @@ -107,67 +131,266 @@ def _await_result(api_port, request_id, console): return {"status": "timeout"} -def run_test(config_path=None, query=None): - console = Console() - config_path = config_path or default_config_path() - query = query or DEFAULT_QUERY +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 - config_path = workspace_relative(config_path) - if config_path is None: - print("Config must be inside the project directory being synced.") - return 1 - if not os.path.isfile(config_path): - print(f"Config file not found: {config_path}. Run `canyonos build` first.") - return 1 +class _TestFailed(Exception): + """Ends the run early, carrying a message fit for either output mode.""" - api_port = workflow_api_port(config_path) - if api_port is None: - print(f"No agent with `type: workflow` in {config_path}; nothing to test.") - return 1 - print(f"Testing {config_path} locally (query: {query!r})") - original_config = _force_local_providers(config_path) +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_init() - if not run_sync(): - return 1 + 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 - state = load_state() - try: - post_deploy(state["port"], config_path) - except GCError as e: - print(e) - return 1 + _wait_for_workflow(state["port"], api_port) + run.done(f"Global Controller on port {state['port']}") + return state - if not _wait_for_workflow(state["port"], api_port, console): - print("The deploy did not come up. Run `canyonos logs` to see why.") - return 1 - try: - request_id = _send_query(api_port, query) - except OSError as e: - print(f"Could not reach the workflow on port {api_port}: {e}") - return 1 - result = _await_result(api_port, request_id, console) - except KeyboardInterrupt: - print("\nTest cancelled.") - return 1 +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) - # A smoke test leaves nothing behind: run_init() started this container. - quit_existing() - status = result.get("status") - if status == "done": - print("Test passed.") - print(json.dumps(result.get("result"), indent=2)) - return 0 - if status == "error": - print(f"Test failed: {result.get('error')}") +# ------------------------------------------------------------------ # +# 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: - print(f"Test failed: workflow did not finish within {REQUEST_TIMEOUT}s.") - return 1 + 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/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 index 770741f..a6bd390 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -7,8 +7,8 @@ import importlib.metadata import sys +from canyonos import ui from canyonos.clean import run_clean -from canyonos.constants import default_config_path from canyonos.config import run_config from canyonos.deploy import run_deploy from canyonos.build import run_build @@ -17,49 +17,11 @@ 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 cmd_quit(args): - run_quit() - -def cmd_new_app(args): - # Note, not tested much, keeping this in the back burner for now while we flesh out the main path - run_new_app() - -def cmd_deploy(args): - run_deploy(args.config, serve=args.serve) - -def cmd_clean(args): - run_clean() - -def cmd_stop(args): - run_stop() - -def cmd_logs(args): - run_logs() - -def cmd_config(args): - run_config() - -def cmd_build(args): - run_build() - -def cmd_doctor(args): - sys.exit(0 if run_doctor() else 1) - -def cmd_serve(args): - sys.exit(run_serve()) - -def cmd_test(args): - sys.exit(run_test(args.config, query=args.query)) - - -def cmd_version(args): - print(f"canyonos {importlib.metadata.version('canyonos')}") - - def _parse_bool(value): if value.lower() in ("true", "1", "yes"): return True @@ -80,14 +42,16 @@ def main(): # 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) - config_default = default_config_path() - def add(name): + def add(name, run): # A KeyError here means the command has no entry on the help screen. - return subparsers.add_parser(name, help=DESCRIPTIONS[name]) + command = subparsers.add_parser(name, help=DESCRIPTIONS[name]) + command.set_defaults(func=run) + return command - add("new-app").set_defaults(func=cmd_new_app) - deploy = add("deploy") + # 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", @@ -100,30 +64,34 @@ def add(name): metavar="true|false", help="Automatically launch the local dashboard (canyonos serve) once the workflow is up (default: true)", ) - deploy.set_defaults(func=cmd_deploy) - add("clean").set_defaults(func=cmd_clean) - add("stop").set_defaults(func=cmd_stop) - add("logs").set_defaults(func=cmd_logs) - add("quit").set_defaults(func=cmd_quit) - add("config").set_defaults(func=cmd_config) - add("build").set_defaults(func=cmd_build) - add("doctor").set_defaults(func=cmd_doctor) - add("version").set_defaults(func=cmd_version) - add("serve").set_defaults(func=cmd_serve) - test = add("test") - test.add_argument( - "-c", - "--config", - default=config_default, - help=f"Path to global controller config (default: {config_default})", + 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( - "-q", - "--query", + "prompt", + nargs="?", default=DEFAULT_QUERY, - help=f"Query sent to the workflow (default: {DEFAULT_QUERY!r})", + 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)", ) - test.set_defaults(func=cmd_test) args = parser.parse_args() if not getattr(args, "command", None): @@ -135,7 +103,7 @@ def add(name): 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. - print(e) + ui.fail(e) sys.exit(1) diff --git a/cli/pyproject.toml b/cli/pyproject.toml index e85c825..f11a241 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "canyonos" -version = "0.1.4" +version = "0.1.5" description = "CanyonOS CLI" requires-python = ">=3.10" dependencies = [ diff --git a/cli/utils/help_screen.py b/cli/utils/help_screen.py index 94b94a0..81ea3ca 100644 --- a/cli/utils/help_screen.py +++ b/cli/utils/help_screen.py @@ -1,38 +1,50 @@ """Custom help screen for the canyonos CLI.""" -from rich.console import Console 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", "Build a compatable workflow with an agent"), - ("deploy", "Deploy agents"), + ("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", "Remove the generated .car folder from build"), - ("doctor", "See if all required tools are up"), - ("logs", "View canyonos logs"), + ("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", "Shut down CanyonOS services"), + ("quit", "Stop the deploy and remove the container and its files"), ("serve", "Start local CanyonOS dashboard"), - ("stop", "Stop running containers"), - ("test", "Run the deployed workflow locally with a test query"), + ("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="cyan", width=20) - table.add_column(style="white") + 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 @@ -40,21 +52,19 @@ def _command_table(commands): def print_custom_help(): """Print a custom, visually appealing help screen.""" - console = Console() - - title = Text("CanyonOS CLI", style="bold cyan") - subtitle = Text("\nBuild, deploy, and manage agentic workflows with ease\n", style="dim") - console.print(Panel(title + subtitle, border_style="cyan", padding=(1, 2))) + 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))) - console.print("\n[bold yellow]Core Commands[/bold yellow]") - console.print(_command_table(CORE_COMMANDS)) + ui.console.print(f"\n[bold {GREEN}]Core Commands[/]") + ui.console.print(_command_table(CORE_COMMANDS)) - console.print("\n[bold yellow]Utils[/bold yellow]") - console.print(_command_table(UTIL_COMMANDS)) + ui.console.print(f"\n[bold {GREEN}]Utils[/]") + ui.console.print(_command_table(UTIL_COMMANDS)) - console.print("\n[bold green]Quick Start:[/bold green]") - console.print(" [dim]1.[/dim] cd [cyan]into-your-workflow-root-dir[/cyan]") - console.print(" [dim]2.[/dim] canyonos build (opens coding agent)") - console.print(" [dim]3.[/dim] canyonos deploy (UI automatically starts)") + 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)") - console.print("[dim]For command-specific help: [cyan]canyonos --help[/cyan][/dim]\n") + 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 index 2f0f15d..3154062 100644 --- a/cli/utils/tui.py +++ b/cli/utils/tui.py @@ -8,12 +8,18 @@ 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() @@ -65,7 +71,7 @@ def select_menu(options, title, deletable=False, quittable=False): def frame(): lines = [f"\x1b[1m{title}\x1b[0m", ""] for i, (_, label) in enumerate(options): - lines.append(f"\x1b[36m❯ {label}\x1b[0m" if i == idx else f" {label}") + 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" 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/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/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/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/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 index 2e62480..7be86ac 100644 --- a/tests/test_dashboard_stack.py +++ b/tests/test_dashboard_stack.py @@ -16,9 +16,7 @@ def project(monkeypatch, tmp_path): monkeypatch.setenv("HOME", str(tmp_path / "home")) monkeypatch.chdir(tmp_path) for key in ( - "DATABASE_URL", "JWT_SECRET", - "CANYONOS_DATABASE_URL", "CANYONOS_JWT_SECRET", "CANYONOS_REDIS_HOST", "CANYONOS_REDIS_PORT", @@ -26,13 +24,9 @@ def project(monkeypatch, tmp_path): "CANYONOS_WEB_IMAGE", ): monkeypatch.delenv(key, raising=False) - config_dir = tmp_path / "config" - config_dir.mkdir() - config = config_dir / "global_controller.yaml" - config.write_text("database:\n url: postgres://user:password@db.example/canyonos\n") monkeypatch.setattr(dashboard_stack.shutil, "which", lambda _: "/usr/bin/docker") monkeypatch.setattr(dashboard_stack, "_port_is_free", lambda _port: True) - return config + return tmp_path def install_docker(monkeypatch, calls, responses=None): @@ -76,86 +70,21 @@ def test_docker_validation_failures_do_not_pull(monkeypatch, project, prepare, m prepare(monkeypatch, responses) install_docker(monkeypatch, calls, responses) - result = dashboard_stack.run_dashboard(str(project)) + result = dashboard_stack.run_dashboard() assert result == dashboard_stack.ServeResult(False, "validate", message) assert all(command[-1] != "pull" for command in calls) -def test_empty_database_url_fails_validation_but_absent_one_does_not(monkeypatch, project): - project.write_text("database:\n url: ''\n") - calls = [] - install_docker(monkeypatch, calls) - - result = dashboard_stack.run_dashboard(str(project)) - - assert result == dashboard_stack.ServeResult( - False, "validate", "database.url must be a non-empty string" - ) - assert all(command[-1] != "pull" for command in calls) - - -def test_missing_database_section_is_not_a_validation_failure(monkeypatch, project): - project.write_text("") - calls = [] - install_docker(monkeypatch, calls) - - stack = dashboard_stack.validate(str(project)) - - assert stack.database_url is None - - -def test_prepare_omits_database_env_when_not_configured(project): - project.write_text("") - stack = dashboard_stack.DashboardStack( - None, dashboard_stack._state_dir(), Path.cwd() - ) - - managed_env, message = dashboard_stack.prepare(stack) - - assert message == "dashboard state prepared" - assert "CANYONOS_DATABASE_URL" not in managed_env - assert "CANYONOS_DATABASE_URL" not in stack.env_path.read_text() - - -def test_config_substitutes_quoted_dotenv_value_without_replacing_source(monkeypatch, project): - source_line = 'DATABASE_URL="postgres://user:password@db.example/canyonos"\n' - Path.cwd().joinpath(".env").write_text(source_line) - project.write_text("database:\n url: ${DATABASE_URL}\n") - calls = [] - install_docker(monkeypatch, calls) - - stack = dashboard_stack.validate(str(project)) - managed_env, _ = dashboard_stack.prepare(stack) - - assert stack.database_url == "postgres://user:password@db.example/canyonos" - assert managed_env["CANYONOS_DATABASE_URL"] == stack.database_url - assert stack.env_path.read_text().startswith(source_line) -def test_missing_database_url_variable_fails_without_pulling(monkeypatch, project): - project.write_text("database:\n url: ${DATABASE_URL}\n") - calls = [] - install_docker(monkeypatch, calls) - result = dashboard_stack.run_dashboard(str(project)) - - assert result == dashboard_stack.ServeResult( - False, - "validate", - "database.url needs ${DATABASE_URL}, which is not set in the project .env", - ) - 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( - "postgres://user:password@db.example/canyonos", - dashboard_stack._state_dir(), - Path.cwd(), - ) + stack = dashboard_stack.DashboardStack(dashboard_stack._state_dir(), Path.cwd()) first_env, _ = dashboard_stack.prepare(stack) second_env, _ = dashboard_stack.prepare(stack) @@ -166,26 +95,6 @@ def test_user_jwt_secret_is_untouched_while_canyonos_secret_is_stable(project): assert first_env["CANYONOS_JWT_SECRET"] == second_env["CANYONOS_JWT_SECRET"] -def test_process_environment_database_url_wins_over_project_dotenv(monkeypatch, project): - Path.cwd().joinpath(".env").write_text("DATABASE_URL=postgres://from-file/canyonos\n") - monkeypatch.setenv("DATABASE_URL", "postgres://from-process/canyonos") - project.write_text("database:\n url: ${DATABASE_URL}\n") - calls = [] - install_docker(monkeypatch, calls) - - stack = dashboard_stack.validate(str(project)) - - assert stack.database_url == "postgres://from-process/canyonos" - - -def test_unreadable_config_does_not_pull(monkeypatch, project): - calls = [] - install_docker(monkeypatch, calls) - - result = dashboard_stack.run_dashboard(str(project.with_name("missing.yaml"))) - - assert result.message == f"config file is not readable: {project.with_name('missing.yaml')}" - assert all(command[-1] != "pull" for command in calls) def test_state_directory_and_port_validation_failures_do_not_pull(monkeypatch, project, tmp_path): @@ -195,7 +104,7 @@ def test_state_directory_and_port_validation_failures_do_not_pull(monkeypatch, p blocked_state_dir.write_text("not a directory") monkeypatch.setattr(dashboard_stack, "_state_dir", lambda: blocked_state_dir) - state_result = dashboard_stack.run_dashboard(str(project)) + 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) @@ -205,7 +114,7 @@ def test_state_directory_and_port_validation_failures_do_not_pull(monkeypatch, p 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(str(project)) + 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) @@ -215,15 +124,11 @@ 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( - "postgres://user:password@localhost:5432/canyonos", - dashboard_stack._state_dir(), - Path.cwd(), - ) + stack = dashboard_stack.DashboardStack(dashboard_stack._state_dir(), Path.cwd()) managed_env, message = dashboard_stack.prepare(stack) - assert message == "database host localhost is reachable from the stack as host.docker.internal" + 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" @@ -232,7 +137,6 @@ def test_prepare_preserves_unrelated_env_lines_and_mode(project): "OTHER", "JWT_SECRET", "LAST", - "CANYONOS_DATABASE_URL", "CANYONOS_JWT_SECRET", "CANYONOS_REDIS_HOST", "CANYONOS_REDIS_PORT", @@ -240,35 +144,11 @@ def test_prepare_preserves_unrelated_env_lines_and_mode(project): "CANYONOS_WEB_IMAGE", "CANYONOS_WEB_PORT", } - assert "CANYONOS_DATABASE_URL=postgres://user:password@host.docker.internal:5432/canyonos" in env_lines 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_prepare_reuses_secret_and_rewrites_only_local_hosts(project): - stack = dashboard_stack.DashboardStack( - "postgres://user:password@localhost/canyonos", - dashboard_stack._state_dir(), - Path.cwd(), - ) - first_env, first_message = dashboard_stack.prepare(stack) - second_env, second_message = dashboard_stack.prepare(stack) - - assert first_message.startswith("database host localhost") - assert second_message.startswith("database host localhost") - assert first_env["CANYONOS_JWT_SECRET"] == second_env["CANYONOS_JWT_SECRET"] - assert ( - first_env["CANYONOS_DATABASE_URL"] - == "postgres://user:password@host.docker.internal/canyonos" - ) - - remote_stack = dashboard_stack.DashboardStack( - "postgres://db.example/canyonos", dashboard_stack._state_dir(), Path.cwd() - ) - remote_env, _ = dashboard_stack.prepare(remote_stack) - assert remote_env["CANYONOS_DATABASE_URL"] == "postgres://db.example/canyonos" - def test_redaction_removes_urls_secrets_and_credentials(): database_url = "postgres://user:password@db.example/canyonos" @@ -297,7 +177,7 @@ def response(argv): return completed(argv) install_docker(monkeypatch, calls, response) - result = dashboard_stack.run_dashboard(str(project)) + result = dashboard_stack.run_dashboard() assert result.ok is False assert result.phase == "start" @@ -328,7 +208,7 @@ def response(argv): return completed(argv) install_docker(monkeypatch, calls, response) - result = dashboard_stack.run_dashboard(str(project)) + result = dashboard_stack.run_dashboard() assert result.phase == "pull" assert "pull unauthorized" in result.message @@ -355,7 +235,7 @@ def urlopen(*_args, **_kwargs): monkeypatch.setattr(dashboard_stack.time, "monotonic", lambda: next(clock)) monkeypatch.setattr(dashboard_stack.time, "sleep", lambda _: None) - result = dashboard_stack.run_dashboard(str(project)) + result = dashboard_stack.run_dashboard() assert result.ok is False assert result.phase == "verify" @@ -383,7 +263,7 @@ def urlopen(endpoint, timeout): return Response() monkeypatch.setattr(dashboard_stack.urllib.request, "urlopen", urlopen) - result = dashboard_stack.run_dashboard(str(project)) + result = dashboard_stack.run_dashboard() assert result == dashboard_stack.ServeResult( True, "verify", "dashboard health checks passed", "http://127.0.0.1:8080" @@ -391,7 +271,7 @@ def urlopen(endpoint, timeout): 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.parent.parent / ".env")] + 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), @@ -426,7 +306,7 @@ def response(argv): lambda *_args, **_kwargs: type("Response", (), {"status": 200, "close": lambda self: None})(), ) - result = dashboard_stack.run_dashboard(str(project)) + 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) From 0931ffcd5c2c07c795a037c09c3f479444c285a7 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Sat, 5 Sep 2026 00:28:41 -0700 Subject: [PATCH 30/44] Integrate LLM proxy telemetry, replacing bedrock.py Re-apply the feature/llm-proxy-telemetry proxy onto the current CLI packaging architecture (rather than merging the stale branch): - Add ventis/llm_proxy/ package (imports remapped to current layout, with in-container flat fallbacks for redis_client/ventis_context) - Delete ventis/controller/bedrock.py; rewrite the 3 examples to call boto3 bedrock-runtime converse() directly (telemetry now via proxy) - Start the proxy per-container in LocalController and auto-inject the X-Ventis-Future-ID boto3 header - Inject AWS_ENDPOINT_URL_BEDROCK_RUNTIME in Local/EC2 runtimes - Copy llm_proxy into agent+workflow images via stub_generator; add flask/requests to agent image + host deps - Update FUTURE_SCHEMA.md provenance and instance-manager runtime tests --- examples/portfolio/agents/advisor_agent.py | 18 +- examples/portfolio/agents/intent_agent.py | 21 ++- .../portfolio/config/global_controller.yaml | 2 +- examples/text2sql/agents/vllm_agent.py | 20 +-- pyproject.toml | 1 + requirements.txt | 1 + tests/test_instance_manager_runtime.py | 4 + uv.lock | 2 + ventis/FUTURE_SCHEMA.md | 16 +- ventis/controller/bedrock.py | 52 ------ .../cloud_provider_logic/EC2/_runtime.py | 4 + .../cloud_provider_logic/Local/_runtime.py | 4 + ventis/controller/local_controller.py | 44 +++++ ventis/llm_proxy/README.md | 112 ++++++++++++ ventis/llm_proxy/__init__.py | 14 ++ ventis/llm_proxy/__main__.py | 29 ++++ ventis/llm_proxy/app.py | 46 +++++ ventis/llm_proxy/config.py | 66 ++++++++ ventis/llm_proxy/core.py | 46 +++++ ventis/llm_proxy/hooks.py | 159 ++++++++++++++++++ ventis/llm_proxy/providers/__init__.py | 14 ++ ventis/llm_proxy/providers/anthropic.py | 19 +++ ventis/llm_proxy/providers/base.py | 96 +++++++++++ ventis/llm_proxy/providers/bedrock.py | 119 +++++++++++++ ventis/llm_proxy/providers/openai.py | 18 ++ ventis/llm_proxy/proxy.py | 61 +++++++ ventis/llm_proxy/requirements.txt | 3 + ventis/stub_generator.py | 24 ++- 28 files changed, 919 insertions(+), 96 deletions(-) delete mode 100644 ventis/controller/bedrock.py create mode 100644 ventis/llm_proxy/README.md create mode 100644 ventis/llm_proxy/__init__.py create mode 100644 ventis/llm_proxy/__main__.py create mode 100644 ventis/llm_proxy/app.py create mode 100644 ventis/llm_proxy/config.py create mode 100644 ventis/llm_proxy/core.py create mode 100644 ventis/llm_proxy/hooks.py create mode 100644 ventis/llm_proxy/providers/__init__.py create mode 100644 ventis/llm_proxy/providers/anthropic.py create mode 100644 ventis/llm_proxy/providers/base.py create mode 100644 ventis/llm_proxy/providers/bedrock.py create mode 100644 ventis/llm_proxy/providers/openai.py create mode 100644 ventis/llm_proxy/proxy.py create mode 100644 ventis/llm_proxy/requirements.txt diff --git a/examples/portfolio/agents/advisor_agent.py b/examples/portfolio/agents/advisor_agent.py index 82d8936..33ac8fc 100644 --- a/examples/portfolio/agents/advisor_agent.py +++ b/examples/portfolio/agents/advisor_agent.py @@ -2,8 +2,9 @@ # # 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.controller.bedrock so token/cost telemetry gets -# recorded onto this execution's future: hash. Configure +# (Converse API), called directly via boto3. Token/cost telemetry is recorded +# onto this execution's future: hash transparently by the Ventis LLM +# proxy each agent container's boto3 calls are routed through. Configure # with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) # AWS_REGION (default: us-east-1) @@ -15,10 +16,7 @@ import os -try: - from ventis.controller.bedrock import call_bedrock -except ImportError: - from bedrock import call_bedrock +import boto3 class AdvisorAgent(object): @@ -28,16 +26,16 @@ def __init__(self): "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" ) self.region = os.environ.get("AWS_REGION", "us-east-1") + self._client = boto3.client("bedrock-runtime", region_name=self.region) def summarize(self, holdings: dict, metrics: dict, risk: dict) -> str: """Write a short plain-English briefing on the portfolio.""" prompt = self._build_prompt(holdings, metrics, risk) try: - response = call_bedrock( - model_id=self.model_id, + response = self._client.converse( + modelId=self.model_id, messages=[{"role": "user", "content": [{"text": prompt}]}], - inference_config={"maxTokens": 400, "temperature": 0.2}, - region=self.region, + inferenceConfig={"maxTokens": 400, "temperature": 0.2}, ) return response["output"]["message"]["content"][0]["text"] except Exception as e: diff --git a/examples/portfolio/agents/intent_agent.py b/examples/portfolio/agents/intent_agent.py index 04124cf..4bb915c 100644 --- a/examples/portfolio/agents/intent_agent.py +++ b/examples/portfolio/agents/intent_agent.py @@ -7,9 +7,11 @@ # -> {"holdings": {"AAPL": 0.4, "MSFT": 0.35, "NVDA": 0.25}, # "lookback_days": 180} # -# 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: +# Calls AWS Bedrock (Converse API) directly via boto3 -- same pattern as +# AdvisorAgent. Token/cost telemetry is recorded onto this execution's +# future: hash transparently by the Ventis LLM proxy, which each +# agent container's boto3 calls are routed through (AWS_ENDPOINT_URL_BEDROCK_RUNTIME). +# Configure with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) # AWS_REGION (default: us-east-1) # @@ -24,10 +26,7 @@ import re import json -try: - from ventis.controller.bedrock import call_bedrock -except ImportError: - from bedrock import call_bedrock +import boto3 DEFAULT_LOOKBACK_DAYS = 365 @@ -39,14 +38,14 @@ def __init__(self): "BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0" ) self.region = os.environ.get("AWS_REGION", "us-east-1") + self._client = boto3.client("bedrock-runtime", region_name=self.region) def parse(self, query: str) -> dict: """Parse a natural-language portfolio request into holdings + lookback.""" - response = call_bedrock( - model_id=self.model_id, + response = self._client.converse( + modelId=self.model_id, messages=[{"role": "user", "content": [{"text": self._build_prompt(query)}]}], - inference_config={"maxTokens": 300, "temperature": 0.0}, - region=self.region, + inferenceConfig={"maxTokens": 300, "temperature": 0.0}, ) text = response["output"]["message"]["content"][0]["text"] if not text: diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index dbff7bb..25100a6 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -7,7 +7,7 @@ agents: # Stage 0: parse the free-text request into structured holdings + lookback - # window (calls Bedrock directly via ventis.llm.bedrock). Cheap CPU, one + # window (calls Bedrock via boto3, routed through the Ventis LLM proxy). Cheap CPU, one # call per request, on the critical path before the fan-out. - name: IntentAgent redis_port: 6379 diff --git a/examples/text2sql/agents/vllm_agent.py b/examples/text2sql/agents/vllm_agent.py index ea23616..1d8141f 100644 --- a/examples/text2sql/agents/vllm_agent.py +++ b/examples/text2sql/agents/vllm_agent.py @@ -1,9 +1,10 @@ # VLLM Agent # # LLM backend for SQL candidate generation, called remotely by -# 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 +# SQLGeneratorAgent. Calls AWS Bedrock (Converse API) directly via boto3. +# Token/cost telemetry is recorded onto this execution's future: +# hash transparently by the Ventis LLM proxy each agent container's boto3 calls +# are routed through — same pattern as # examples/portfolio/agents/advisor_agent.py. # Configure with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) @@ -13,10 +14,7 @@ import os -try: - from ventis.controller.bedrock import call_bedrock -except ImportError: - from bedrock import call_bedrock +import boto3 class VllmAgent(object): @@ -24,15 +22,15 @@ def __init__(self): self.tools = [self.generate] self.model_id = os.environ.get("BEDROCK_MODEL_ID", "meta.llama3-8b-instruct-v1:0") self.region = os.environ.get("AWS_REGION", "us-east-1") + self._client = boto3.client("bedrock-runtime", region_name=self.region) def generate(self, prompt: str) -> str: """Generates a response using an LLM model based on the given prompt.""" try: - response = call_bedrock( - model_id=self.model_id, + response = self._client.converse( + modelId=self.model_id, messages=[{"role": "user", "content": [{"text": prompt}]}], - inference_config={"maxTokens": 400, "temperature": 0.2}, - region=self.region, + inferenceConfig={"maxTokens": 400, "temperature": 0.2}, ) return response["output"]["message"]["content"][0]["text"] except Exception as e: diff --git a/pyproject.toml b/pyproject.toml index 41169a9..a94a9b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "psycopg[binary]", "pyyaml", "flask", + "requests", "psutil", "opentelemetry-api>=1.44.0", "opentelemetry-sdk>=1.44.0", diff --git a/requirements.txt b/requirements.txt index f06e0b0..20abfff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ grpcio-tools redis pyyaml flask +requests sqlalchemy psycopg[binary] psutil diff --git a/tests/test_instance_manager_runtime.py b/tests/test_instance_manager_runtime.py index 13f9876..2c481c3 100644 --- a/tests/test_instance_manager_runtime.py +++ b/tests/test_instance_manager_runtime.py @@ -170,6 +170,8 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "VENTIS_REDIS_PORT=6379", "-e", "VENTIS_POLL_INTERVAL=5", + "-e", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", "ventis-alpha", ], "localhost", @@ -226,6 +228,8 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): "VENTIS_REDIS_PORT=6379", "-e", "VENTIS_POLL_INTERVAL=5", + "-e", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", "-p", "8080:8080", "--cpus", diff --git a/uv.lock b/uv.lock index 8278401..dd3ff5f 100644 --- a/uv.lock +++ b/uv.lock @@ -1238,6 +1238,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pyyaml" }, { name = "redis" }, + { name = "requests" }, { name = "sqlalchemy" }, ] @@ -1261,6 +1262,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"] }, { name = "pyyaml" }, { name = "redis" }, + { name = "requests" }, { name = "sqlalchemy" }, ] diff --git a/ventis/FUTURE_SCHEMA.md b/ventis/FUTURE_SCHEMA.md index 360b1af..0d122b4 100644 --- a/ventis/FUTURE_SCHEMA.md +++ b/ventis/FUTURE_SCHEMA.md @@ -19,16 +19,16 @@ Fields currently written into `future:{future_id}`, and where: | `created_at` | `future.py` only (origin submission time) | | `result` | `future.py`, `local_controller.py` | | `failed` | `future.py`, `local_controller.py` | -| `error` | `future.py` (`_submit_request`), `local_controller.py` (`_mark_future_failed`) -- the sole failure-message field; `bedrock.py` deliberately never writes it | +| `error` | `future.py` (`_submit_request`), `local_controller.py` (`_mark_future_failed`) -- the sole failure-message field; the LLM proxy deliberately never writes it | | `finished_at` | `local_controller.py` (`_execute_locally` finally block) | | `cpu_resource` | `local_controller.py` | | `gpu_resource` | `local_controller.py` | | `agent` | `local_controller.py` (agent_id that executed this step) | | `queue_time` | `local_controller.py` (only when `submitted_at` is known) | -| `model` | `llm/bedrock.py` (`call_bedrock`) | -| `input_token_count` | `llm/bedrock.py` | -| `output_token_count` | `llm/bedrock.py` | -| `token_count` | `llm/bedrock.py` | -| `errors` | `llm/bedrock.py` (Bedrock call error count) | -| `input_cache_tokens` | `llm/bedrock.py` | -| `input_cache_write_tokens` | `llm/bedrock.py` | +| `model` | `llm_proxy/hooks.py` (on_response) | +| `input_token_count` | `llm_proxy/hooks.py` | +| `output_token_count` | `llm_proxy/hooks.py` | +| `token_count` | `llm_proxy/hooks.py` | +| `errors` | `llm_proxy/hooks.py` (Bedrock call error flag) | +| `input_cache_tokens` | `llm_proxy/hooks.py` | +| `input_cache_write_tokens` | `llm_proxy/hooks.py` | diff --git a/ventis/controller/bedrock.py b/ventis/controller/bedrock.py deleted file mode 100644 index 97c6a3f..0000000 --- a/ventis/controller/bedrock.py +++ /dev/null @@ -1,52 +0,0 @@ -import os - -try: - from ventis.controller.utils.redis_client import RedisClient - import ventis.controller.ventis_context as ventis_context -except ImportError: - from redis_client import RedisClient - import ventis_context - -_redis = RedisClient( - host=os.environ.get("VENTIS_REDIS_HOST", "localhost"), - port=int(os.environ.get("VENTIS_REDIS_PORT", 6379)), -) - - -def call_bedrock(model_id: str, messages: list, inference_config: dict, region: str = "us-east-1") -> dict: - """Call Bedrock's converse() API and log token/error telemetry onto the - currently executing future's hash (future:).""" - import boto3 - - client = boto3.client("bedrock-runtime", region_name=region) - future_id = ventis_context.get_current_future_id() - error_count = 0 - response = None - try: - response = client.converse( - modelId=model_id, messages=messages, inferenceConfig=inference_config - ) - return response - except Exception as e: - error_count += 1 - metrics_key = ventis_context.get_current_metrics_key() - if metrics_key: - _redis.hincrby(metrics_key, "error_count", 1) - # Deliberately does not write "error"/"failed" onto the future here -- - # that's owned by LocalController._mark_future_failed, which only - # fires if this exception propagates all the way up uncaught. If a - # caller catches and recovers (e.g. a fallback summary), the future - # succeeds, and writing a failure here would falsely mark it failed. - raise - finally: - if future_id: - usage = (response or {}).get("usage", {}) - _redis.hset_multiple(f"future:{future_id}", { - "model": model_id, - "input_token_count": str(usage.get("inputTokens", "")), - "output_token_count": str(usage.get("outputTokens", "")), - "token_count": str(usage.get("totalTokens", "")), - "errors": str(error_count), - "input_cache_tokens": str(usage.get("cacheReadInputTokens", "")), - "input_cache_write_tokens": str(usage.get("cacheWriteInputTokens", "")), - }) diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/ventis/controller/cloud_provider_logic/EC2/_runtime.py index be3bb18..ce68744 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/ventis/controller/cloud_provider_logic/EC2/_runtime.py @@ -288,6 +288,10 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, f"VENTIS_AGENT_PORT={CONTAINER_PORT}", "-e", f"VENTIS_POLL_INTERVAL={_controller.config.get('poll_interval', 5)}", + # Route the agent's boto3 Bedrock calls through the in-container LLM + # proxy (started by LocalController) so token/cost telemetry is captured. + "-e", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", ] if spec.get("type") == "workflow": db_url = _controller.config.get("database", {}).get("url") diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/ventis/controller/cloud_provider_logic/Local/_runtime.py index 88e4ec3..6329311 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/ventis/controller/cloud_provider_logic/Local/_runtime.py @@ -93,6 +93,10 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): f"VENTIS_REDIS_PORT={spec.get('redis_port', 6379)}", "-e", f"VENTIS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", + # Route the agent's boto3 Bedrock calls through the in-container LLM + # proxy (started by LocalController) so token/cost telemetry is captured. + "-e", + "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", ] if ctrl_type == "workflow": cmd.extend(["-p", f"{spec.get('api_port', 8080)}:8080"]) diff --git a/ventis/controller/local_controller.py b/ventis/controller/local_controller.py index 8d9b525..1cc879f 100644 --- a/ventis/controller/local_controller.py +++ b/ventis/controller/local_controller.py @@ -35,6 +35,18 @@ import ventis.controller.ventis_context as ventis_context except ImportError: import ventis_context + +# Auto-inject X-Ventis-Future-ID into all boto3 Bedrock calls so the LLM proxy +# can attribute token/cost telemetry to the executing future. Import for its +# global boto3 event-hook side effect; safe no-op if the proxy isn't present. +try: + from ventis.llm_proxy import proxy as _llm_proxy_autoinject # noqa: F401 +except ImportError: + try: + from llm_proxy import proxy as _llm_proxy_autoinject # noqa: F401 + except ImportError: + pass # No proxy available; agents call Bedrock directly. + import local_controler_pb2 import local_controler_pb2_grpc @@ -102,6 +114,11 @@ def __init__(self, port=50051): max_instances = int(os.environ.get("VENTIS_MAX_AGENT_INSTANCES", 8)) self._executor = ThreadPoolExecutor(max_workers=max_instances) + # Start the LLM proxy alongside the agent in this container. Bedrock + # calls are routed to it via AWS_ENDPOINT_URL_BEDROCK_RUNTIME (injected + # by the runtime), and it writes token/cost telemetry to Redis. + self._proxy_process = self._start_llm_proxy(redis_host, redis_port) + logger.info( "Local controller initialized at %s (max_agent_instances=%d), reported healthy to Redis.", self._my_endpoint, @@ -111,6 +128,33 @@ def __init__(self, port=50051): # Load the agent class dynamically self.agent = self._load_agent() + def _start_llm_proxy(self, redis_host, redis_port): + """Start the LLM proxy as a subprocess in this container (127.0.0.1:8081). + + Best-effort: a failure here must never stop the controller from coming up. + """ + import subprocess + + try: + proxy_env = os.environ.copy() + proxy_env.update({ + "PROXY_HOST": "127.0.0.1", + "PROXY_PORT": "8081", + "VENTIS_REDIS_HOST": redis_host, + "VENTIS_REDIS_PORT": str(redis_port), + }) + proxy_process = subprocess.Popen( + [sys.executable, "-m", "ventis.llm_proxy"], + env=proxy_env, + ) + logger.info( + "Started LLM proxy on 127.0.0.1:8081 (PID: %d)", proxy_process.pid + ) + return proxy_process + except Exception as e: + logger.warning("Failed to start LLM proxy: %s", e) + return None + def _collect_metrics(self): """Snapshot current instance health/resource metrics. diff --git a/ventis/llm_proxy/README.md b/ventis/llm_proxy/README.md new file mode 100644 index 0000000..873144d --- /dev/null +++ b/ventis/llm_proxy/README.md @@ -0,0 +1,112 @@ +# llm_proxy + +A local, single-machine pass-through proxy for **OpenAI**, **Anthropic**, and +**Bedrock**. Callers keep their exact SDK calling convention — the only change is +one base-URL env var per provider. Every call flows through one function +(`core.proxy_request`) where token/metrics hooks fire. + +**Scope:** request/response ("call and return") only. Streaming is intentionally +not implemented yet. + +## How it works + +``` +your app (unchanged) localhost:8080 real upstream + openai SDK ─/openai/... ─┐ + anthropic SDK ─/anthropic/ ─┼─▶ proxy_request(ctx) ─▶ provider ─▶ api.openai.com + boto3 bedrock ─/bedrock/... ┘ (metrics hooks) adapter api.anthropic.com + bedrock-runtime..amazonaws.com +``` + +- **OpenAI / Anthropic** — straight HTTP reverse-proxy: rewrite host, swap in the + real key, forward with `requests`, return the response. +- **Bedrock** — re-issued through the proxy's own `boto3` client (handles SigV4 + signing + URL-encoding correctly). Only `invoke` is wired up. + +## Run + +```bash +pip install -r llm_proxy/requirements.txt + +# real upstream credentials live here; callers can use dummy keys +export OPENAI_API_KEY=sk-... +export ANTHROPIC_API_KEY=sk-ant-... +export AWS_REGION=us-east-1 # + normal AWS creds (env / ~/.aws / role) + +python -m llm_proxy # listens on 127.0.0.1:8080 +``` + +## Point your SDKs at it + +No code changes — just env vars: + +```bash +export OPENAI_BASE_URL=http://localhost:8080/openai/v1 +export ANTHROPIC_BASE_URL=http://localhost:8080/anthropic +export AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://localhost:8080/bedrock +``` + +Then your existing code works unchanged: + +```python +from openai import OpenAI +OpenAI().chat.completions.create(model="gpt-4o-mini", + messages=[{"role": "user", "content": "hi"}]) + +from anthropic import Anthropic +Anthropic().messages.create(model="claude-3-5-sonnet-20241022", max_tokens=64, + messages=[{"role": "user", "content": "hi"}]) + +import boto3, json +boto3.client("bedrock-runtime").invoke_model( + modelId="anthropic.claude-3-5-sonnet-20240620-v1:0", + body=json.dumps({"anthropic_version": "bedrock-2023-05-31", + "max_tokens": 64, + "messages": [{"role": "user", "content": "hi"}]})) +``` + +## Configuration (env vars) + +| Var | Default | Purpose | +|---|---|---| +| `PROXY_HOST` / `PROXY_PORT` | `127.0.0.1` / `8080` | where the proxy listens | +| `PROXY_CONNECT_TIMEOUT` / `PROXY_READ_TIMEOUT` | `10` / `600` | upstream timeouts (s) | +| `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` | — | real upstream keys the proxy injects | +| `OPENAI_UPSTREAM_BASE` / `ANTHROPIC_UPSTREAM_BASE` | official APIs | override upstream (e.g. Azure/gateway) | +| `BEDROCK_REGION` (or `AWS_REGION`) | `us-east-1` | Bedrock region | +| `BEDROCK_UPSTREAM_HOST` | `bedrock-runtime..amazonaws.com` | override Bedrock host | + +## Telemetry & Metrics + +**Automatic telemetry is currently Bedrock-only.** The proxy captures: +- Model ID +- Input/output/total token counts +- Cache tokens (read & write) +- Error status + +Telemetry is automatically written to Redis under `future:` keys. + +### How it works (Bedrock only) + +1. **Auto-injection:** boto3 hook (`proxy.py`) injects `X-Ventis-Future-Id` header from thread-local context +2. **Token extraction:** `hooks.py` parses response `usage` field +3. **Redis write:** All metrics written to `future:` hash + +### Why Bedrock-only? + +OpenAI and Anthropic use their own Python SDKs (`openai`, `anthropic`), not boto3. +The boto3 event hook doesn't fire for non-AWS SDKs. To add telemetry for those: +- Would need separate hooks in each SDK's HTTP client +- Or callers would need to use proxy directly (not through SDKs) + +The proxy *forwards* OpenAI/Anthropic requests and *can* extract tokens, but doesn't +automatically inject headers or write telemetry. + +## Limitations + +- **No streaming.** `stream=True` / `invoke-with-response-stream` are not handled. +- **Bedrock error bodies are reconstructed**, not passed through byte-for-byte + (boto3 raises on 4xx/5xx; we rebuild a JSON body with the real status + + message). OpenAI/Anthropic errors pass through unchanged. +- **Dev server.** Runs on Flask's built-in server — fine for a local proxy, not + meant for production traffic. diff --git a/ventis/llm_proxy/__init__.py b/ventis/llm_proxy/__init__.py new file mode 100644 index 0000000..827377e --- /dev/null +++ b/ventis/llm_proxy/__init__.py @@ -0,0 +1,14 @@ +"""Local LLM proxy. + +A transparent, single-machine pass-through for OpenAI, Anthropic, and Bedrock. +Point each provider's SDK at this service via its base-URL env var and calls flow +through one choke point (``llm_proxy.core.proxy_request``) where request/response +metrics hooks fire. + +Scope: request/response ("call and return") only. Streaming is intentionally +not implemented yet. +""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/ventis/llm_proxy/__main__.py b/ventis/llm_proxy/__main__.py new file mode 100644 index 0000000..c0af2e9 --- /dev/null +++ b/ventis/llm_proxy/__main__.py @@ -0,0 +1,29 @@ +"""Entry point: ``python -m llm_proxy``.""" + +from __future__ import annotations + +import logging + +from ventis.llm_proxy.app import create_app +from ventis.llm_proxy.config import Config + + +def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + cfg = Config.from_env() + app = create_app(cfg) + logging.getLogger("llm_proxy").info( + "llm_proxy on http://%s:%d (openai=%s, anthropic=%s, bedrock=%s [%s])", + cfg.host, cfg.port, cfg.openai.upstream_base, cfg.anthropic.upstream_base, + cfg.bedrock_upstream_host, cfg.bedrock_region, + ) + # threaded so concurrent callers don't serialize; dev server is fine for a + # local proxy. + app.run(host=cfg.host, port=cfg.port, threaded=True) + + +if __name__ == "__main__": + main() diff --git a/ventis/llm_proxy/app.py b/ventis/llm_proxy/app.py new file mode 100644 index 0000000..b2e3b09 --- /dev/null +++ b/ventis/llm_proxy/app.py @@ -0,0 +1,46 @@ +"""Flask app: one catch-all route per provider prefix, all funneled through +``proxy_request``.""" + +from __future__ import annotations + +import logging + +from flask import Flask, jsonify, request + +from ventis.llm_proxy.config import Config +from ventis.llm_proxy.core import proxy_request +from ventis.llm_proxy.providers import build_registry + +log = logging.getLogger("llm_proxy") + +ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] + + +def create_app(cfg: Config = None) -> Flask: + cfg = cfg or Config.from_env() + app = Flask(__name__) + registry = build_registry(cfg) + + # Initialize hooks with config for Redis + from ventis.llm_proxy import hooks as hooks_module + hooks_module.hooks = hooks_module.Hooks(cfg) + + @app.route("/healthz", methods=["GET"]) + def healthz(): + return jsonify(status="ok", providers=sorted(registry.keys())) + + @app.route("//", methods=ALL_METHODS) + def dispatch(provider, subpath): + prov = registry.get(provider) + if prov is None: + return ( + jsonify(error=f"unknown provider '{provider}'", known=sorted(registry.keys())), + 404, + ) + try: + return proxy_request(prov, subpath, request) + except Exception as exc: # surface upstream/adapter errors as 502 + log.exception("proxy error for %s/%s", provider, subpath) + return jsonify(error="proxy_error", detail=str(exc)), 502 + + return app diff --git a/ventis/llm_proxy/config.py b/ventis/llm_proxy/config.py new file mode 100644 index 0000000..9e85cfa --- /dev/null +++ b/ventis/llm_proxy/config.py @@ -0,0 +1,66 @@ +"""Configuration, read once from the environment at startup. + +The proxy holds the *real* upstream credentials; callers can send dummy keys. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class ProviderConfig: + upstream_base: str + api_key: Optional[str] = None + + +@dataclass +class Config: + host: str + port: int + connect_timeout: float + read_timeout: float + + openai: ProviderConfig + anthropic: ProviderConfig + + bedrock_region: str + bedrock_upstream_host: str + + redis_host: str + redis_port: int + + @classmethod + def from_env(cls) -> "Config": + region = ( + os.getenv("BEDROCK_REGION") + or os.getenv("AWS_REGION") + or os.getenv("AWS_DEFAULT_REGION") + or "us-east-1" + ) + return cls( + host=os.getenv("PROXY_HOST", "127.0.0.1"), + port=int(os.getenv("PROXY_PORT", "8080")), + connect_timeout=float(os.getenv("PROXY_CONNECT_TIMEOUT", "10")), + read_timeout=float(os.getenv("PROXY_READ_TIMEOUT", "600")), + openai=ProviderConfig( + upstream_base=os.getenv( + "OPENAI_UPSTREAM_BASE", "https://api.openai.com" + ).rstrip("/"), + api_key=os.getenv("OPENAI_API_KEY"), + ), + anthropic=ProviderConfig( + upstream_base=os.getenv( + "ANTHROPIC_UPSTREAM_BASE", "https://api.anthropic.com" + ).rstrip("/"), + api_key=os.getenv("ANTHROPIC_API_KEY"), + ), + bedrock_region=region, + bedrock_upstream_host=os.getenv( + "BEDROCK_UPSTREAM_HOST", f"bedrock-runtime.{region}.amazonaws.com" + ), + redis_host=os.getenv("VENTIS_REDIS_HOST", "localhost"), + redis_port=int(os.getenv("VENTIS_REDIS_PORT", "6379")), + ) diff --git a/ventis/llm_proxy/core.py b/ventis/llm_proxy/core.py new file mode 100644 index 0000000..6284e00 --- /dev/null +++ b/ventis/llm_proxy/core.py @@ -0,0 +1,46 @@ +"""The single choke point every proxied call flows through.""" + +from __future__ import annotations + +import json +import time +from typing import Optional + +from flask import Response + +from ventis.llm_proxy.hooks import Ctx + + +def _guess_model(body: bytes) -> Optional[str]: + """Best-effort model name from the JSON body, for logging/metrics. + + Never raises. Returns None for requests whose model isn't in the body + (e.g. Bedrock, where it's in the path and already shown via the subpath). + """ + try: + model = json.loads(body).get("model") + return model if isinstance(model, str) else None + except Exception: + return None + + +def proxy_request(provider, subpath, flask_request): + # Import hooks here to get the instance created by create_app + from ventis.llm_proxy.hooks import hooks + + body = flask_request.get_data() + ctx = Ctx( + provider=provider.name, + method=flask_request.method, + subpath=subpath, + body=body, + headers=dict(flask_request.headers), + t0=time.monotonic(), + model=_guess_model(body), + ) + hooks.on_request(ctx) + + pr = provider.forward(flask_request, subpath, body) + + hooks.on_response(ctx, pr) + return Response(pr.content, status=pr.status, headers=pr.headers) diff --git a/ventis/llm_proxy/hooks.py b/ventis/llm_proxy/hooks.py new file mode 100644 index 0000000..7cc9957 --- /dev/null +++ b/ventis/llm_proxy/hooks.py @@ -0,0 +1,159 @@ +"""The metrics seam. + +Every proxied call passes through ``on_request`` / ``on_response``. Today these +only log. Token accounting lands here later: because the whole response is +buffered, usage extraction is a one-liner, e.g. ``resp.json().get("usage")`` for +OpenAI/Anthropic (Bedrock's usage lives in its per-model response body). +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import dataclass +from typing import Any, Dict, Optional + +log = logging.getLogger("llm_proxy") + + +@dataclass +class TokenUsage: + """Token usage extracted from LLM responses.""" + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + input_cache_tokens: int = 0 + input_cache_write_tokens: int = 0 + + def __repr__(self): + parts = [f"in={self.input_tokens}", f"out={self.output_tokens}"] + if self.input_cache_tokens: + parts.append(f"cache_read={self.input_cache_tokens}") + if self.input_cache_write_tokens: + parts.append(f"cache_write={self.input_cache_write_tokens}") + return f"TokenUsage({', '.join(parts)})" + + +@dataclass +class Ctx: + provider: str + method: str + subpath: str + body: bytes + headers: Dict[str, str] + t0: float + model: Optional[str] = None + + def elapsed_ms(self) -> float: + return (time.monotonic() - self.t0) * 1000.0 + + +class Hooks: + def __init__(self, config=None): + self.config = config + self._redis = None + + if config: + try: + try: + from ventis.controller.utils.redis_client import RedisClient + except ImportError: + # In-container the framework files are copied flat to /app. + from redis_client import RedisClient + self._redis = RedisClient( + host=config.redis_host, + port=config.redis_port, + ) + log.info("Redis telemetry enabled: %s:%s", config.redis_host, config.redis_port) + except Exception as e: + log.warning("Redis not available: %s", e) + + def on_request(self, ctx: Ctx) -> None: + log.info( + "→ %s %s /%s model=%s (%d bytes)", + ctx.provider, ctx.method, ctx.subpath, ctx.model, len(ctx.body), + ) + + def on_response(self, ctx: Ctx, resp: Any) -> None: + # Extract tokens for Bedrock + usage = None + if ctx.provider == "bedrock": + usage = self._extract_bedrock_tokens(resp) + + log.info( + "← %s %s /%s -> %s in %.0fms | %s", + ctx.provider, ctx.method, ctx.subpath, + getattr(resp, "status", "?"), ctx.elapsed_ms(), + usage or "no usage" + ) + + # Write to Redis if we have context + log.info("Checking telemetry write: redis=%s", "yes" if self._redis else "no") + if self._redis: + future_id = ctx.headers.get("X-Ventis-Future-Id") + log.info("Future ID from headers: %s", future_id) + if future_id: + try: + # Extract model ID + model_id = self._extract_model_id(ctx) + + is_error = resp.status >= 400 + + # Build telemetry data + data = { + "model": model_id, + "errors": "1" if is_error else "0", + } + + # Add token data if available + if usage: + data.update({ + "input_token_count": str(usage.input_tokens), + "output_token_count": str(usage.output_tokens), + "token_count": str(usage.total_tokens), + "input_cache_tokens": str(usage.input_cache_tokens), + "input_cache_write_tokens": str(usage.input_cache_write_tokens), + }) + + self._redis.hset_multiple(f"future:{future_id}", data) + log.info("Wrote telemetry to future:%s with data: %s", future_id, data) + except Exception as e: + log.error("Failed to write telemetry: %s", e) + + def _extract_model_id(self, ctx: Ctx) -> str: + """Extract model ID from context or subpath.""" + if ctx.model: + return ctx.model + + # For Bedrock: subpath is "model//operation" + # Use rpartition to peel operation off the right (same as provider logic) + if ctx.provider == "bedrock" and ctx.subpath.startswith("model/"): + model_id, sep, op = ctx.subpath[len("model/"):].rpartition("/") + if sep: # Found a separator + return model_id + + return "unknown" + + def _extract_bedrock_tokens(self, resp: Any) -> Optional[TokenUsage]: + """Extract token usage from Bedrock response. It requires diff logic from OpenAI/Anthropic""" + if resp.status != 200: + return None + + try: + data = json.loads(resp.content.decode("utf-8")) + usage = data.get("usage", {}) + if usage: + return TokenUsage( + input_tokens=usage.get("inputTokens", 0), + output_tokens=usage.get("outputTokens", 0), + total_tokens=usage.get("totalTokens", 0), + input_cache_tokens=usage.get("cacheReadInputTokens", 0), + input_cache_write_tokens=usage.get("cacheCreationInputTokens", 0), + ) + except: + pass + return None + + +hooks = Hooks() diff --git a/ventis/llm_proxy/providers/__init__.py b/ventis/llm_proxy/providers/__init__.py new file mode 100644 index 0000000..d9ff021 --- /dev/null +++ b/ventis/llm_proxy/providers/__init__.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from ventis.llm_proxy.providers.anthropic import AnthropicProvider +from ventis.llm_proxy.providers.bedrock import BedrockProvider +from ventis.llm_proxy.providers.openai import OpenAIProvider + + +def build_registry(cfg): + """Map the URL prefix -> provider instance.""" + return { + "openai": OpenAIProvider(cfg), + "anthropic": AnthropicProvider(cfg), + "bedrock": BedrockProvider(cfg), + } diff --git a/ventis/llm_proxy/providers/anthropic.py b/ventis/llm_proxy/providers/anthropic.py new file mode 100644 index 0000000..33e14aa --- /dev/null +++ b/ventis/llm_proxy/providers/anthropic.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from ventis.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers + + +class AnthropicProvider(HttpProvider): + name = "anthropic" + + def target(self, req, subpath, body): + headers = client_headers(req, drop=["x-api-key", "authorization"]) + if self.cfg.anthropic.api_key: + headers["x-api-key"] = self.cfg.anthropic.api_key + # `anthropic-version` is supplied by the SDK and passes through untouched. + return UpstreamRequest( + method=req.method, + url=f"{self.cfg.anthropic.upstream_base}/{subpath}", + headers=headers, + params=req.args.to_dict(flat=True), + ) diff --git a/ventis/llm_proxy/providers/base.py b/ventis/llm_proxy/providers/base.py new file mode 100644 index 0000000..ef5db41 --- /dev/null +++ b/ventis/llm_proxy/providers/base.py @@ -0,0 +1,96 @@ +"""Provider abstraction and shared HTTP plumbing. + +A provider's only job is to take the incoming request and produce a +``ProxyResponse``. Straight HTTP reverse-proxy providers (OpenAI, Anthropic) +subclass ``HttpProvider`` and just describe the upstream target; Bedrock owns +its own ``forward`` because it re-issues through boto3. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Tuple + +import requests + +# Request headers we never forward: hop-by-hop (RFC 7230), ones we rewrite, and +# accept-encoding (we let the HTTP client negotiate + decode, then re-frame the +# response ourselves). +DROP_REQUEST_HEADERS = { + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailers", "transfer-encoding", "upgrade", + "host", "content-length", "accept-encoding", +} + +# Response headers we drop: we return already-decoded content and let the WSGI +# layer recompute framing headers. +DROP_RESPONSE_HEADERS = { + "content-encoding", "content-length", "transfer-encoding", + "connection", "keep-alive", +} + + +@dataclass +class UpstreamRequest: + method: str + url: str + headers: Dict[str, str] + params: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class ProxyResponse: + status: int + headers: List[Tuple[str, str]] + content: bytes + + def json(self): + return json.loads(self.content.decode("utf-8")) + + +def client_headers(incoming, drop: Iterable[str] = ()) -> Dict[str, str]: + """Copy the caller's headers minus the ones we must not forward.""" + extra = {d.lower() for d in drop} + return { + k: v + for k, v in incoming.headers.items() + if k.lower() not in DROP_REQUEST_HEADERS and k.lower() not in extra + } + + +def filter_response_headers(headers) -> List[Tuple[str, str]]: + return [(k, v) for k, v in headers.items() if k.lower() not in DROP_RESPONSE_HEADERS] + + +class Provider: + name = "base" + + def __init__(self, cfg): + self.cfg = cfg + + def forward(self, req, subpath: str, body: bytes) -> ProxyResponse: + raise NotImplementedError + + +class HttpProvider(Provider): + """Providers that are a straight HTTP reverse-proxy (OpenAI, Anthropic).""" + + def target(self, req, subpath: str, body: bytes) -> UpstreamRequest: + raise NotImplementedError + + def forward(self, req, subpath, body): + up = self.target(req, subpath, body) + resp = requests.request( + up.method, + up.url, + headers=up.headers, + params=up.params, + data=body, + timeout=(self.cfg.connect_timeout, self.cfg.read_timeout), + ) + return ProxyResponse( + status=resp.status_code, + headers=filter_response_headers(resp.headers), + content=resp.content, + ) diff --git a/ventis/llm_proxy/providers/bedrock.py b/ventis/llm_proxy/providers/bedrock.py new file mode 100644 index 0000000..f0efddc --- /dev/null +++ b/ventis/llm_proxy/providers/bedrock.py @@ -0,0 +1,119 @@ +"""Bedrock adapter. + +Rather than re-sign the caller's SigV4 request (fiddly once model IDs contain +``:`` and ``/``), we re-issue the call through the proxy's own boto3 client, +which handles signing and URL-encoding correctly by construction. This is clean +for request/response; streaming (``invoke-with-response-stream``) is out of scope +for now. +""" + +from __future__ import annotations + +import json + +import boto3 +from botocore.exceptions import ClientError + +from ventis.llm_proxy.providers.base import Provider, ProxyResponse + +# bedrock-runtime operations that can appear as the last path segment; only the +# non-streaming "invoke" is wired up for now. +_SUPPORTED_OPS = {"invoke", "invoke-with-response-stream", "converse", "converse-stream"} + + +class BedrockProvider(Provider): + name = "bedrock" + + def __init__(self, cfg): + super().__init__(cfg) + # Explicitly set endpoint_url to bypass AWS_ENDPOINT_URL_BEDROCK_RUNTIME + # environment variable that points to this proxy (would create infinite loop) + self._client = boto3.client( + "bedrock-runtime", + region_name=cfg.bedrock_region, + endpoint_url=f"https://{cfg.bedrock_upstream_host}" + ) + + def forward(self, req, subpath, body): + model_id, op = self._parse(subpath) + + try: + if op == "invoke": + resp = self._client.invoke_model( + modelId=model_id, + body=body, + contentType=req.headers.get("Content-Type", "application/json"), + accept=req.headers.get("Accept", "application/json"), + ) + # For invoke, return raw response body + payload = resp["body"].read() + status = resp.get("ResponseMetadata", {}).get("HTTPStatusCode", 200) + headers = [("Content-Type", resp.get("contentType", "application/json"))] + return ProxyResponse(status=status, headers=headers, content=payload) + + elif op == "converse": + params = json.loads(body) + params["modelId"] = model_id + resp = self._client.converse(**params) + + # Return response as JSON + response_data = { + "output": resp.get("output", {}), + "stopReason": resp.get("stopReason"), + "usage": resp.get("usage", {}), + } + # Include optional fields if present + for field in ["metrics", "trace", "additionalModelResponseFields"]: + if field in resp: + response_data[field] = resp[field] + + payload = json.dumps(response_data).encode("utf-8") + status = resp.get("ResponseMetadata", {}).get("HTTPStatusCode", 200) + return ProxyResponse( + status=status, + headers=[("Content-Type", "application/json")], + content=payload + ) + else: + raise NotImplementedError( + f"bedrock op '{op}' not supported (only invoke and converse)" + ) + + except ClientError as exc: + return self._error_response(exc) + except (json.JSONDecodeError, KeyError) as exc: + return ProxyResponse( + status=400, + headers=[("Content-Type", "application/json")], + content=json.dumps({"message": f"Invalid request: {exc}"}).encode(), + ) + + + + @staticmethod + def _parse(subpath): + # subpath looks like "model//"; the modelId may itself + # contain "/" (inference-profile ARNs), so peel the op off the right. + if not subpath.startswith("model/"): + raise ValueError(f"unrecognized bedrock path: /{subpath}") + model_id, sep, op = subpath[len("model/"):].rpartition("/") + if not sep or op not in _SUPPORTED_OPS: + raise ValueError(f"unrecognized bedrock path: /{subpath}") + return model_id, op + + @staticmethod + def _error_response(exc: ClientError) -> ProxyResponse: + # boto3 raises on 4xx/5xx; reconstruct a JSON error body carrying the + # real status + message. (Byte-for-byte error passthrough is a property + # only the HTTP providers have; this is the cost of re-issuing via boto3.) + meta = exc.response.get("ResponseMetadata", {}) + err = exc.response.get("Error", {}) + status = meta.get("HTTPStatusCode", 500) + body = json.dumps( + {"message": err.get("Message", str(exc)), "code": err.get("Code")} + ).encode("utf-8") + return ProxyResponse( + status=status, + headers=[("Content-Type", "application/json")], + content=body, + ) diff --git a/ventis/llm_proxy/providers/openai.py b/ventis/llm_proxy/providers/openai.py new file mode 100644 index 0000000..67457eb --- /dev/null +++ b/ventis/llm_proxy/providers/openai.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from ventis.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers + + +class OpenAIProvider(HttpProvider): + name = "openai" + + def target(self, req, subpath, body): + headers = client_headers(req, drop=["authorization"]) + if self.cfg.openai.api_key: + headers["Authorization"] = f"Bearer {self.cfg.openai.api_key}" + return UpstreamRequest( + method=req.method, + url=f"{self.cfg.openai.upstream_base}/{subpath}", + headers=headers, + params=req.args.to_dict(flat=True), + ) diff --git a/ventis/llm_proxy/proxy.py b/ventis/llm_proxy/proxy.py new file mode 100644 index 0000000..4fb3612 --- /dev/null +++ b/ventis/llm_proxy/proxy.py @@ -0,0 +1,61 @@ +"""Auto-inject Ventis headers into ALL boto3 Bedrock calls. + +Import this module once and all subsequent boto3.client("bedrock-runtime") calls +will automatically include the X-Ventis-Future-ID header. + +Usage: + import ventis.llm_proxy_auto # Just import once + import boto3 + + # Now this automatically includes the header! + client = boto3.client("bedrock-runtime") + response = client.converse(...) +""" + +import boto3 +import logging + +try: + import ventis.controller.ventis_context as ventis_context +except ImportError: + # In-container the framework files are copied flat to /app. + try: + import ventis_context + except ImportError: + ventis_context = None + +log = logging.getLogger(__name__) + + +def _inject_ventis_headers(event_name=None, **kwargs): + """Inject X-Ventis-Future-ID header into boto3 requests.""" + if not ventis_context: + return + + # Only inject for bedrock-runtime service + if 'service_id' in kwargs and kwargs.get('service_id') != 'Bedrock Runtime': + return + + # Get the request object + request = kwargs.get('request') + if not request: + return + + # Get current future_id from thread-local context + try: + future_id = ventis_context.get_current_future_id() + if future_id: + request.headers['X-Ventis-Future-ID'] = future_id + log.debug("Injected X-Ventis-Future-ID: %s", future_id) + except Exception as e: + log.debug("Could not inject future_id: %s", e) + + +# Register the hook globally on the default session +_session = boto3.Session() +_session.events.register_first('before-call.bedrock-runtime', _inject_ventis_headers) + +# Also patch the default session used by boto3.client() +boto3.DEFAULT_SESSION = _session + +log.info("Ventis boto3 hook registered - all Bedrock calls will include future_id header") diff --git a/ventis/llm_proxy/requirements.txt b/ventis/llm_proxy/requirements.txt new file mode 100644 index 0000000..2f7091c --- /dev/null +++ b/ventis/llm_proxy/requirements.txt @@ -0,0 +1,3 @@ +flask>=2.0 +requests>=2.28 +boto3>=1.28 diff --git a/ventis/stub_generator.py b/ventis/stub_generator.py index 1108be1..6f8fc14 100644 --- a/ventis/stub_generator.py +++ b/ventis/stub_generator.py @@ -19,10 +19,10 @@ # Packages every agent container needs regardless of its specific business logic. # grpcio-tools/pyyaml/ipdb/ipython aren't needed/used, but keeping to keep the scope constrained right now # - Leave a comment if you want me to remove these, I kept them in since you originally had them but they aren't used -BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "ipdb", "ipython", "boto3"] +BASE_AGENT_REQUIREMENTS = ["grpcio", "grpcio-tools", "redis", "pyyaml", "psutil", "ipdb", "ipython", "boto3", "flask", "requests"] # Workflow will always require these -BASE_WORKFLOW_REQUIREMENTS = BASE_AGENT_REQUIREMENTS + ["flask", "sqlalchemy", "psycopg[binary]"] +BASE_WORKFLOW_REQUIREMENTS = BASE_AGENT_REQUIREMENTS + ["sqlalchemy", "psycopg[binary]"] def _build_import_nodes(): @@ -307,6 +307,23 @@ def _stub_destination(stub_file, stub_entrypoints): return basename +def _copy_llm_proxy(output_dir, script_dir): + """Copy the ventis.llm_proxy package into the build context as an importable + `ventis` package so the in-container proxy can run via `python -m ventis.llm_proxy`. + Its cross-package imports (redis_client, ventis_context) fall back to the flat + copies already placed at the context root.""" + shutil.copytree( + os.path.join(script_dir, "llm_proxy"), + os.path.join(output_dir, "ventis", "llm_proxy"), + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), + ) + shutil.copy2( + os.path.join(script_dir, "__init__.py"), + os.path.join(output_dir, "ventis", "__init__.py"), + ) + + def _copy_files(output_dir, files_to_copy): """Copy each (src, dst) pair into output_dir, refusing to write outside it (e.g. via a symlinked destination parent).""" real_output_dir = os.path.realpath(output_dir) @@ -394,7 +411,6 @@ def generate_docker( os.path.join(script_dir, "controller", "utils", "gpu_metrics.py"), "gpu_metrics.py", ), - (os.path.join(script_dir, "controller", "bedrock.py"), "bedrock.py"), ] # Copy provided agent stubs, overwriting the swept real file at the same path @@ -410,6 +426,7 @@ def generate_docker( files_to_copy.append((os.path.join(grpc_stubs_dir, fname), fname)) _copy_files(output_dir, files_to_copy) + _copy_llm_proxy(output_dir, script_dir) # Copy the real agent entrypoint to the context root. shutil.copy2( @@ -531,6 +548,7 @@ def generate_workflow_docker( files_to_copy.append((os.path.join(grpc_stubs_dir, fname), fname)) _copy_files(output_dir, files_to_copy) + _copy_llm_proxy(output_dir, script_dir) # Copy the real workflow entrypoint to the context root. shutil.copy2( From 8f151616b67f33d2a57ff6534711eb1a15f3c7a0 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Sat, 5 Sep 2026 01:06:25 -0700 Subject: [PATCH 31/44] Fix boto3 header injection: use before-call params['headers'] not request before-call handlers receive the prepared-request params dict, not the request object (that only exists on before-send). Reading kwargs['request'] was always None, so X-Ventis-Future-ID was never attached and the proxy could not attribute token telemetry to the executing future. --- ventis/llm_proxy/proxy.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/ventis/llm_proxy/proxy.py b/ventis/llm_proxy/proxy.py index 4fb3612..ec0956d 100644 --- a/ventis/llm_proxy/proxy.py +++ b/ventis/llm_proxy/proxy.py @@ -27,25 +27,22 @@ log = logging.getLogger(__name__) -def _inject_ventis_headers(event_name=None, **kwargs): - """Inject X-Ventis-Future-ID header into boto3 requests.""" - if not ventis_context: +def _inject_ventis_headers(params=None, **kwargs): + """Inject X-Ventis-Future-ID into the outgoing Bedrock HTTP request. + + Registered on boto3's ``before-call.bedrock-runtime`` event, whose handlers + receive the prepared-request ``params`` dict (with a mutable ``headers``). + The ``request`` object only exists on the later ``before-send`` event, so + reading it here would always be None and silently drop the header. + """ + if not ventis_context or params is None: return - - # Only inject for bedrock-runtime service - if 'service_id' in kwargs and kwargs.get('service_id') != 'Bedrock Runtime': - return - - # Get the request object - request = kwargs.get('request') - if not request: - return - + # Get current future_id from thread-local context try: future_id = ventis_context.get_current_future_id() if future_id: - request.headers['X-Ventis-Future-ID'] = future_id + params.setdefault("headers", {})["X-Ventis-Future-ID"] = future_id log.debug("Injected X-Ventis-Future-ID: %s", future_id) except Exception as e: log.debug("Could not inject future_id: %s", e) From d34df4ecb74692b9eb2d4535e6a063687ac28da2 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Sat, 5 Sep 2026 01:18:19 -0700 Subject: [PATCH 32/44] docs: add cli/ARCHITECTURE.md and link it from cli/README.md Bring the CLI architecture doc (build/deploy/config flows and container lifecycle) over from cli/canyonos-cli, and drop the stale PyPi republish snippet from the README. --- cli/ARCHITECTURE.md | 200 ++++++++++++++++++++++++++++++++++++++++++++ cli/README.md | 18 ++-- 2 files changed, 206 insertions(+), 12 deletions(-) create mode 100644 cli/ARCHITECTURE.md 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 index 76cd4e1..f708d62 100644 --- a/cli/README.md +++ b/cli/README.md @@ -2,6 +2,12 @@ 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 @@ -14,15 +20,3 @@ 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 From 6655c25ab5abbd2e77085fb7e436d891cfae003c Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Tue, 8 Sep 2026 14:01:52 -0700 Subject: [PATCH 33/44] reviewed branch and code, made small changes --- cli/ARCHITECTURE.md | 7 +- cli/README.md | 22 +++--- cli/canyonos/build.py | 2 + cli/canyonos/config.py | 8 +-- cli/canyonos/constants.py | 20 +++++- cli/canyonos/dashboard.compose.yml | 19 ----- cli/canyonos/dashboard_stack.py | 23 ++++--- cli/canyonos/deploy.py | 54 ++++++--------- cli/canyonos/doctor.py | 2 +- cli/canyonos/gc.py | 2 - cli/canyonos/init.py | 11 +-- cli/canyonos/serve.py | 18 +++-- cli/canyonos/status.py | 4 +- cli/canyonos/test.py | 69 ++++++++----------- cli/canyonos/verify.py | 16 ++--- cli/cli.py | 8 ++- cli/utils/help_screen.py | 7 +- cli/utils/tui.py | 4 +- .../helloworld/config/global_controller.yaml | 1 + .../portfolio/config/global_controller.yaml | 1 + .../text2sql/config/global_controller.yaml | 1 + tests/test_canyonos_test.py | 12 ++-- tests/test_dashboard_stack.py | 6 +- ventis/server.py | 21 +++--- 24 files changed, 164 insertions(+), 174 deletions(-) diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index 62f869a..07619da 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -45,7 +45,7 @@ If you remember only one picture, remember this: │ ▼ ▼ (spawns siblings)│ │ ┌───────────────────────┐ ┌───────────────────────────┐ │ │ │ Dashboard stack │◀── traces ───│ Redis + your agent / │ │ -│ │ web · api · postgres │ (OTLP) │ workflow containers │ │ +│ │ web · api │ (OTLP) │ workflow containers │ │ │ └───────────────────────┘ └───────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘ @@ -198,3 +198,8 @@ And to observe without changing anything: 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. + + +### Other Notes: +- The ui import is for styling, logging basic commands in the canyonos theme, nothing else. + diff --git a/cli/README.md b/cli/README.md index f708d62..4acfdb7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -1,6 +1,12 @@ -Lightweight CLI for CanyonOS +CLI for CanyonOS -Serves as a thin API layer, connecting to the global controller container. +This CLI does not contain much logic, instead serving as an API to interface with the canyonos container that deploys and runs your entire workflow + + +## Requirements +Need a coding agent(Claude Code, Codex, Cursor) +Need uv or pip +Need docker and docker compose ## Architecture @@ -10,13 +16,11 @@ For a full walkthrough of the `build`, `deploy`, and `config` flows — plus how ## 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. +`canyonos serve` starts the local CanyonOS dashboard — it reads no project config, so it takes no +arguments. It writes only `CANYONOS_`-prefixed settings into the current directory's `.env`, +leaving every other line unchanged. -## 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 diff --git a/cli/canyonos/build.py b/cli/canyonos/build.py index c9b56b4..93c5625 100644 --- a/cli/canyonos/build.py +++ b/cli/canyonos/build.py @@ -1,6 +1,8 @@ """ 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. + +This file needs to be hardened in particular, will be iterating on it alot with Nick coming up. """ import os diff --git a/cli/canyonos/config.py b/cli/canyonos/config.py index aec5b12..4a0327a 100644 --- a/cli/canyonos/config.py +++ b/cli/canyonos/config.py @@ -1,5 +1,7 @@ """ Logic for `canyonos config`: view or change project/deploy configuration. + +View merely prints out the config, while change opens up a separate temp screen for easy changes. """ import os @@ -141,9 +143,7 @@ def run_view_config(config_path=None): 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. + A non-leaf would be a key that hosts more keys, with only the lowest key's hosting a value """ if isinstance(value, dict): return False @@ -242,7 +242,7 @@ def _confirm_delete(screen, node, key, breadcrumb): def _navigate(screen, node, breadcrumb): - """Drill into a mapping/sequence. Returns True if any value was changed or + """Go 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: diff --git a/cli/canyonos/constants.py b/cli/canyonos/constants.py index 455e860..f1aa9d1 100644 --- a/cli/canyonos/constants.py +++ b/cli/canyonos/constants.py @@ -1,4 +1,6 @@ -"""Shared helpers for the canyonos CLI.""" +"""Shared helpers for the canyonos CLI. + +Config/data layer. Holds shared values and parsing helpers""" import os @@ -6,9 +8,11 @@ from ruamel.yaml import YAML DEFAULT_API_PORT = 8080 +DEFAULT_DASHBOARD_PORT = 8081 # The workflow entrypoint is always exposed as POST /main with a {"query": ...} # body, regardless of what the workflow function is called in the project. +# This should be fixed later, keeping it like this for now though WORKFLOW_ROUTE = "main" @@ -32,6 +36,20 @@ def workflow_api_port(config_path): return None +def dashboard_port(config_path): + """Host port the local dashboard prefers to start on, falling back to the default.""" + try: + with open(config_path) as f: + config = yaml.safe_load(f) or {} + except (OSError, yaml.YAMLError): + return DEFAULT_DASHBOARD_PORT + + for agent in config.get("agents") or []: + if agent.get("type") == "workflow": + return agent.get("dashboard_port", DEFAULT_DASHBOARD_PORT) + return DEFAULT_DASHBOARD_PORT + + def workspace_relative(config_path): """`config_path` relative to the cwd, or None if it falls outside it. diff --git a/cli/canyonos/dashboard.compose.yml b/cli/canyonos/dashboard.compose.yml index 0689749..00416f4 100644 --- a/cli/canyonos/dashboard.compose.yml +++ b/cli/canyonos/dashboard.compose.yml @@ -1,24 +1,6 @@ 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 @@ -28,7 +10,6 @@ services: 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} diff --git a/cli/canyonos/dashboard_stack.py b/cli/canyonos/dashboard_stack.py index e26fbcf..38e38a2 100644 --- a/cli/canyonos/dashboard_stack.py +++ b/cli/canyonos/dashboard_stack.py @@ -19,6 +19,8 @@ from pathlib import Path from typing import Callable +from canyonos.constants import DEFAULT_DASHBOARD_PORT + COMPOSE_PROJECT = "canyonos-dashboard" STACK_VERSION = "v0.1.0-rc.2" API_IMAGE = f"ghcr.io/canyoncodecoreai/canyonos-api:{STACK_VERSION}" @@ -48,7 +50,7 @@ def __init__(self, phase: str, message: str): class DashboardStack: state_dir: Path project_dir: Path - web_port: int = 8080 + web_port: int = DEFAULT_DASHBOARD_PORT @property def env_path(self) -> Path: @@ -111,10 +113,10 @@ def _port_is_free(port: int) -> bool: return True -def _find_web_port(start: int = 8080, max_attempts: int = 50) -> int: +def _find_web_port(start: int = DEFAULT_DASHBOARD_PORT, 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. + squatting on the preferred port (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): @@ -124,7 +126,8 @@ def _find_web_port(start: int = 8080, max_attempts: int = 50) -> int: ) -def validate() -> DashboardStack: +def validate(preferred_port: int = DEFAULT_DASHBOARD_PORT) -> DashboardStack: + """Checks docker is usable and the state dir is writable, then returns a DashboardStack with the port the dashboard should run on.""" if shutil.which("docker") is None: raise PhaseFailure("validate", "docker is not on PATH") @@ -136,9 +139,8 @@ def validate() -> DashboardStack: 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. + # The dashboard reads no project config, so the project root is just the + # cwd, the same assumption sync/clean/build already make. project_root = Path.cwd() state_dir = _state_dir() @@ -151,7 +153,7 @@ def validate() -> DashboardStack: except OSError: raise PhaseFailure("validate", "dashboard state directory is not writable") - web_port = _existing_dashboard_port() or _find_web_port() + web_port = _existing_dashboard_port() or _find_web_port(preferred_port) return DashboardStack(state_dir, project_root, web_port) @@ -356,6 +358,7 @@ def _cleanup(stack: DashboardStack, manifest: Path) -> None: def run_dashboard( phase_reporter: Callable[[str, str], None] | None = None, + preferred_port: int = DEFAULT_DASHBOARD_PORT, ) -> ServeResult: def report(result: ServeResult) -> None: if phase_reporter is not None: @@ -369,7 +372,7 @@ def report(result: ServeResult) -> None: had_containers = False with ExitStack() as resources: try: - stack = validate() + stack = validate(preferred_port) report(ServeResult(True, "validate", "dashboard prerequisites validated")) managed_env, prepare_message = prepare(stack) diff --git a/cli/canyonos/deploy.py b/cli/canyonos/deploy.py index 048cd3d..6412cfc 100644 --- a/cli/canyonos/deploy.py +++ b/cli/canyonos/deploy.py @@ -38,12 +38,10 @@ 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" +# Test seams: a failed-build test monkeypatches both down so it doesn't have to +# wait out the real poll/grace windows. +_STATUS_POLL_SECONDS = 2.0 +_REVEAL_GRACE_SECONDS = 30.0 # Substrings that mean the in-container deploy hit something fatal. `WARNING:` is # deliberately absent: the OTel-not-configured notice and stub_generator's @@ -72,25 +70,6 @@ ("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. @@ -113,18 +92,19 @@ def feed(self, line): if any(marker in line for marker in _ERROR_MARKERS): return None, None, True - count = _IMAGE_COUNT.search(line) + count = re.search(r"Building (\d+) Docker image\(s\) via", line) if count: self.spinner = f"Building {count.group(1)} images..." return self.spinner, None, False - replicas = _REPLICA_COUNT.search(line) + replicas = re.search(r"Waiting for (\d+) replica\(s\) to become healthy", line) if replicas: self.replicas_total = int(replicas.group(1)) self.spinner = self._agent_progress() return self.spinner, None, False - ready = _READY.search(line) + # Matched on the endpoint, since the name repeats across an agent's replicas. + ready = re.search(r"Controller (\S+ \([^)]+\)) is ready\.", line) if ready: self.replicas_ready.add(ready.group(1)) self.spinner = self._agent_progress() @@ -189,7 +169,7 @@ def workflow_targets(gc_port, api_port): targets = [ ( endpoint.get("name"), - "127.0.0.1" if endpoint["host"] in LOCAL_HOSTS else endpoint["host"], + "127.0.0.1" if endpoint["host"] in ("127.0.0.1", "localhost") else endpoint["host"], endpoint["port"], ) for endpoint in workflow_endpoints(gc_port) @@ -201,6 +181,7 @@ def workflow_targets(gc_port, api_port): def _summary_body(dashboard_url, targets): + """ The contents that go inside the deploy panel""" body = Text() body.append("Dashboard ", "dim") if dashboard_url: @@ -219,7 +200,7 @@ def _summary_body(dashboard_url, targets): body.append('{"query": "your question here"}', WHITE) body.append("\npoll ", "dim") body.append(f"{base}/status/", WHITE) - if host not in LOCAL_HOSTS: + if host not in ("127.0.0.1", "localhost"): body.append(f"\n needs inbound TCP {port} open on {host}", "dim") return body @@ -281,7 +262,8 @@ def _tail_verbose(stream, state, api_port, serve): try: for line in stream: print(line, end="") - if summary is None and _WORKFLOW_UP_MARKER in line: + # Logged exactly once, right after the workflow finishes coming up. + if summary is None and "Global controller started, polling every" in line: summary = _deploy_summary(state, api_port, serve) except KeyboardInterrupt: _interrupted(summary) @@ -295,7 +277,9 @@ def _tail_quiet(lines, state, api_port, serve): dropped rather than allow-listed. `-v` and `canyonos logs` still have it all. """ tracker = PhaseTracker() - recent = deque(maxlen=_RECENT_LINES) + # 200 is enough to hold a buildx failure block plus a Python traceback; + # 40 (what `canyonos test` tails) truncates both. + recent = deque(maxlen=200) reached_up_marker = False # The spinner is exited before the summary panel or the dashboard's own @@ -311,7 +295,8 @@ def _tail_quiet(lines, state, api_port, serve): ui.ok(done) if message: spinner.update(message) - if _WORKFLOW_UP_MARKER in line: + # Logged exactly once, right after the workflow finishes coming up. + if "Global controller started, polling every" in line: summary_line, all_ready = tracker.agents_ready_message() (ui.ok if all_ready else ui.warn)(summary_line) reached_up_marker = True @@ -363,7 +348,8 @@ def _drain(lines, state, deadline=None): if line is None: return misses = 0 - if _OWN_REQUEST_MARKER not in line: + # Otherwise the container logs its own polling into the stream being read. + if "GET /status HTTP/1.1" not in line: yield line diff --git a/cli/canyonos/doctor.py b/cli/canyonos/doctor.py index 15c36af..3339797 100644 --- a/cli/canyonos/doctor.py +++ b/cli/canyonos/doctor.py @@ -60,7 +60,7 @@ def _checks(): def run_doctor(): - """Run every check, print a pass/fail checklist, and return True iff all passed.""" + """Run every check, print a pass/fail checklist, and return True if all passed.""" all_ok = True for label, check, fix in _checks(): try: diff --git a/cli/canyonos/gc.py b/cli/canyonos/gc.py index a8778b3..594c08b 100644 --- a/cli/canyonos/gc.py +++ b/cli/canyonos/gc.py @@ -60,8 +60,6 @@ def post_deploy(port, config_path=None): 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 diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py index 6b07f84..69d4eba 100644 --- a/cli/canyonos/init.py +++ b/cli/canyonos/init.py @@ -105,10 +105,6 @@ def ensure_docker_running(timeout=DOCKER_START_TIMEOUT): 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( @@ -119,10 +115,7 @@ def pull_image(image=GC_IMAGE): 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. + not that traffic actually flows. Confirm the container is actually reachable before trusting it. """ url = f"http://127.0.0.1:{port}/status" for _ in range(attempts): @@ -176,12 +169,14 @@ def run_container(image=GC_IMAGE, max_attempts=50): def save_state(container_id, port): + """ Writes GC container info to ~/.canyonos/state.json""" 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(): + """Reads GC container info from ~/.canyonos/state.json""" with open(STATE_PATH) as f: return json.load(f) diff --git a/cli/canyonos/serve.py b/cli/canyonos/serve.py index c96e336..062eb78 100644 --- a/cli/canyonos/serve.py +++ b/cli/canyonos/serve.py @@ -1,21 +1,29 @@ -"""CLI output for the local dashboard stack.""" +""" +CLI output for the local dashboard stack. + +Automatically gets created when deploy runs unless --serve flag is set to false. +""" from canyonos import ui +from canyonos.constants import dashboard_port, default_config_path 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. + """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 = [] + preferred_port = dashboard_port(default_config_path()) 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) + result = run_dashboard(report, preferred_port) if result.ok: return result diff --git a/cli/canyonos/status.py b/cli/canyonos/status.py index 071297e..e65ffc4 100644 --- a/cli/canyonos/status.py +++ b/cli/canyonos/status.py @@ -22,9 +22,7 @@ def run_status(): 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. + # Same resolution `deploy` uses 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" diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py index 2409965..23f08f7 100644 --- a/cli/canyonos/test.py +++ b/cli/canyonos/test.py @@ -1,14 +1,16 @@ """ 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. +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 +- 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. + +This file will also need lots of iteration based on what is needed, will expect it to change alot """ import json @@ -35,17 +37,11 @@ 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, -) +from canyonos.verify import ARTIFACT_DIR, 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 +READY_TIMEOUT = 60 +REQUEST_TIMEOUT = 60 SUBMIT_TIMEOUT = 30 POLL_INTERVAL = 2 LOG_TAIL_LINES = 40 @@ -99,9 +95,9 @@ def _wait_for_workflow(gc_port, api_port): 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.") + raise RuntimeError("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.") + raise RuntimeError(f"Timed out after {READY_TIMEOUT}s waiting for the workflow to come up.") def _send_query(host, port, query): @@ -140,10 +136,6 @@ def _log_tail(container_id): 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.""" @@ -189,10 +181,7 @@ def _verify_build(run, config_path): run.done("skipped: no .car/ artifact") return - try: - run.validation = verify_build_artifact() - except VerificationError as e: - raise _TestFailed(str(e)) from None + run.validation = verify_build_artifact() stale = len(run.validation["stale"]) run.done(f"{run.validation['warnings']} warning(s), {stale} stale source(s)") @@ -202,13 +191,13 @@ def _deploy_locally(run, config_path, api_port): run_init(banner=False) if not run_sync(): - raise _TestFailed("Could not sync the project into the container.") + raise RuntimeError("Could not sync the project into the container.") # Only the gRPC host port is bumped when a port is taken (the local runtime's # launch retry), so an occupied api_port dies 50 attempts later as "no free # port found". `canyonos serve` also starts looking for its web port at 8080. if _port_in_use(api_port): - raise _TestFailed( + raise RuntimeError( f"Port {api_port} is already in use, and the workflow needs it. Free it " f"(`canyonos quit` stops a previous deploy) or change `api_port` in {config_path}." ) @@ -217,7 +206,7 @@ def _deploy_locally(run, config_path, api_port): try: post_deploy(state["port"], config_path) except GCError as e: - raise _TestFailed(str(e)) from None + raise RuntimeError(str(e)) from None run.deploy_started = True _wait_for_workflow(state["port"], api_port) @@ -227,10 +216,7 @@ def _deploy_locally(run, config_path, api_port): 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.runtime = verify_runtime(config_path, gc_port) run.done(f"{len(run.runtime['agents'])} agent(s) up") @@ -238,7 +224,7 @@ 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.") + raise RuntimeError("The deploy reported no workflow endpoint to query.") _, host, port = targets[0] run.endpoint = f"http://{host}:{port}/{WORKFLOW_ROUTE}" @@ -247,14 +233,14 @@ def _query(run, gc_port, api_port): 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 + raise RuntimeError(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.") + raise RuntimeError(data.get("error") or "the workflow returned an error.") if status != "done": - raise _TestFailed(f"The workflow did not finish within {REQUEST_TIMEOUT}s.") + raise RuntimeError(f"The workflow did not finish within {REQUEST_TIMEOUT}s.") run.result = data.get("result") run.done(f"answered in {run.elapsed()}s") @@ -264,15 +250,15 @@ 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.") + raise RuntimeError("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.") + raise RuntimeError(f"No config at {config_path}. Run `canyonos build` first.") _verify_build(run, config_path) api_port = workflow_api_port(config_path) if api_port is None: - raise _TestFailed(f"No agent with `type: workflow` in {config_path}; nothing to test.") + raise RuntimeError(f"No agent with `type: workflow` in {config_path}; nothing to test.") original_config = _force_local_providers(config_path) try: @@ -361,13 +347,12 @@ def run_test(prompt=None, as_json=False): 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. + # Every phase raises RuntimeError with a message fit for either output + # mode: docker unreachable, validation failure, port in use, workflow + # timeout, etc. `--json` needs it inside the payload either way. run.error = str(e) if run.error is not None: diff --git a/cli/canyonos/verify.py b/cli/canyonos/verify.py index d047885..95fd36a 100644 --- a/cli/canyonos/verify.py +++ b/cli/canyonos/verify.py @@ -9,6 +9,8 @@ -- 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. + +This file will also need lots of iteration based on what is needed, will expect it to change alot """ import hashlib @@ -43,10 +45,6 @@ RUNTIME_PREFIX = "ventis-local-" -class VerificationError(Exception): - """A check that should end the run, carrying a message fit to print.""" - - # ------------------------------------------------------------------ # # Build artifact # # ------------------------------------------------------------------ # @@ -151,14 +149,14 @@ def _stale_sources(project_root, artifact_dir): def verify_build_artifact(project_root="."): - """Check the `.car/` tree. Raises VerificationError if it can't be deployed.""" + """Check the `.car/` tree. Raises RuntimeError if it can't be deployed.""" artifact_dir = os.path.join(project_root, ARTIFACT_DIR) config_path = os.path.join(artifact_dir, CONFIG_REL) if not os.path.isfile(config_path) or not os.path.isdir( os.path.join(artifact_dir, SOURCE_DIR) ): - raise VerificationError( + raise RuntimeError( f"No `{ARTIFACT_DIR}/` artifact here (expected {CONFIG_REL} beside " f"{SOURCE_DIR}/). Run `canyonos build` first." ) @@ -192,7 +190,7 @@ def verify_build_artifact(project_root="."): ui.hint(" -> re-run `canyonos build` to bring the artifact back in step") if summary["errors"]: - raise VerificationError( + raise RuntimeError( f"The build artifact has {summary['errors']} validation error(s); fix them " "or re-run `canyonos build`." ) @@ -238,7 +236,7 @@ def _runtime_table(rows): def verify_runtime(config_path, gc_port): - """Check the running deploy against the config. Raises VerificationError on a gap.""" + """Check the running deploy against the config. Raises RuntimeError on a gap.""" with open(config_path) as f: config = yaml.safe_load(f) or {} @@ -287,5 +285,5 @@ def verify_runtime(config_path, gc_port): ui.panel(_runtime_table(rows)) if problems: - raise VerificationError("The deploy is incomplete -- " + "; ".join(problems)) + raise RuntimeError("The deploy is incomplete -- " + "; ".join(problems)) return {"agents": rows} diff --git a/cli/cli.py b/cli/cli.py index a6bd390..c20e3d8 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -49,8 +49,10 @@ def add(name, run): 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 + # New-app can be fleshed out more, keeping it bare for now add("new-app", lambda args: run_new_app()) + + # Deploy has three args: -v, -c, --serve deploy = add("deploy", lambda args: run_deploy(args.config, serve=args.serve, verbose=args.verbose)) deploy.add_argument( "-c", @@ -80,6 +82,8 @@ def add(name, run): 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 has 2 args: prompt, --json. test = add("test", lambda args: sys.exit(run_test(args.prompt, as_json=args.json))) test.add_argument( "prompt", @@ -101,8 +105,6 @@ def add(name, run): 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) diff --git a/cli/utils/help_screen.py b/cli/utils/help_screen.py index 81ea3ca..717e021 100644 --- a/cli/utils/help_screen.py +++ b/cli/utils/help_screen.py @@ -1,4 +1,7 @@ -"""Custom help screen for the canyonos CLI.""" +"""Custom help screen for the canyonos CLI. + +To run type: canyonos -h +""" from rich.panel import Panel from rich.table import Table @@ -16,8 +19,6 @@ ("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"), diff --git a/cli/utils/tui.py b/cli/utils/tui.py index 3154062..c903c8a 100644 --- a/cli/utils/tui.py +++ b/cli/utils/tui.py @@ -1,5 +1,7 @@ """ Minimal arrow-key select menu, no dependency beyond the standard library. + +Used by any command that involves selecting options, no other purpose beyond this. """ import os @@ -16,8 +18,6 @@ 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 diff --git a/examples/helloworld/config/global_controller.yaml b/examples/helloworld/config/global_controller.yaml index 0b9c194..d1d82ee 100644 --- a/examples/helloworld/config/global_controller.yaml +++ b/examples/helloworld/config/global_controller.yaml @@ -27,6 +27,7 @@ agents: type: workflow redis_port: 6379 api_port: 8080 # Only needed for workflows, defaults to 8080 if not filled + dashboard_port: 8081 # Local dashboard's preferred port, defaults to 8081 if not filled workflow_file: workflow/example_workflow.py provider: EC2 instance_type: t3.micro diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index 25100a6..60abb9e 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -72,6 +72,7 @@ agents: - name: Workflow type: workflow api_port: 8080 # Flask REST API port + dashboard_port: 8081 # Local dashboard's preferred port redis_port: 6379 replicas: 1 workflow_file: workflow/portfolio_workflow.py diff --git a/examples/text2sql/config/global_controller.yaml b/examples/text2sql/config/global_controller.yaml index 89642b2..0df046d 100644 --- a/examples/text2sql/config/global_controller.yaml +++ b/examples/text2sql/config/global_controller.yaml @@ -88,6 +88,7 @@ agents: - name: Workflow type: workflow api_port: 8080 # Flask REST API port + dashboard_port: 8081 # Local dashboard's preferred port redis_port: 6379 replicas: 1 workflow_file: workflow/text2sql_workflow.py diff --git a/tests/test_canyonos_test.py b/tests/test_canyonos_test.py index d2d64e5..13f0b68 100644 --- a/tests/test_canyonos_test.py +++ b/tests/test_canyonos_test.py @@ -130,7 +130,7 @@ def test_validator_errors_fail_the_phase(monkeypatch, project): verify, "_run_validator", lambda *_: report(errors=1, findings=[finding("V002")]) ) - with pytest.raises(verify.VerificationError): + with pytest.raises(RuntimeError): verify.verify_build_artifact(str(project)) @@ -169,13 +169,13 @@ def test_rules_needing_ventis_are_kept_when_it_is_importable(monkeypatch, projec lambda *_: report(errors=1, findings=[finding("V030")], ventis=True), ) - with pytest.raises(verify.VerificationError): + with pytest.raises(RuntimeError): verify.verify_build_artifact(str(project)) def test_a_missing_car_directory_is_an_error(monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) - with pytest.raises(verify.VerificationError, match="Run `canyonos build` first"): + with pytest.raises(RuntimeError, match="Run `canyonos build` first"): verify.verify_build_artifact(str(tmp_path)) @@ -262,14 +262,14 @@ def test_a_complete_deploy_passes(project, runtime): 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"): + with pytest.raises(RuntimeError, 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"): + with pytest.raises(RuntimeError, match="ventis-echoagent was never built"): verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) @@ -340,7 +340,7 @@ def test_an_occupied_api_port_fails_before_the_deploy(monkeypatch, deployable, c 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") + raise RuntimeError("the deploy did not come up") monkeypatch.setattr(test_cmd, "_wait_for_workflow", boom) diff --git a/tests/test_dashboard_stack.py b/tests/test_dashboard_stack.py index 7be86ac..448c1a1 100644 --- a/tests/test_dashboard_stack.py +++ b/tests/test_dashboard_stack.py @@ -266,7 +266,7 @@ def urlopen(endpoint, timeout): result = dashboard_stack.run_dashboard() assert result == dashboard_stack.ServeResult( - True, "verify", "dashboard health checks passed", "http://127.0.0.1:8080" + True, "verify", "dashboard health checks passed", "http://127.0.0.1:8081" ) 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) @@ -274,8 +274,8 @@ def urlopen(endpoint, timeout): 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), + ("http://127.0.0.1:8081/healthz", 5), + ("http://127.0.0.1:8081/api/healthz", 5), ] diff --git a/ventis/server.py b/ventis/server.py index 31acba7..e9e9561 100644 --- a/ventis/server.py +++ b/ventis/server.py @@ -43,7 +43,11 @@ def deploy(): 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) + # realpath, not normpath: /workspace holds a copy of the user's project, which may symlink out. + workspace_root = os.path.realpath(WORKSPACE_DIR) + full_path = os.path.realpath(os.path.join(workspace_root, config_path)) + if not full_path.startswith(workspace_root + os.sep): + return jsonify({"error": "config_path must stay inside the workspace"}), 400 if not os.path.isfile(full_path): return jsonify({"error": f"config file not found: {full_path}"}), 400 @@ -79,11 +83,7 @@ def status(): 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. - """ + """Client for the node Redis holding instance records, as reached from inside the GC container.""" redis_cfg = config.get("redis", {}) host = redis_cfg.get("host", "localhost") port = redis_cfg.get("port", 6379) @@ -97,7 +97,7 @@ def _primary_redis(config): def _workflow_endpoints(config): - """Address of every running workflow replica, as the caller should reach it.""" + """Address of every workflow replica recorded in Redis, as the caller should reach it.""" ports = { agent["name"]: agent.get("api_port", DEFAULT_API_PORT) for agent in config.get("agents") or [] @@ -138,8 +138,11 @@ def endpoints(): 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 + except Exception: + # Don't hand the exception message back to the caller -- it can carry + # local paths or Redis details. Log it here, keep the response generic. + app.logger.exception("Failed to resolve workflow endpoints") + return jsonify({"workflows": [], "error": "failed to resolve endpoints"}), 200 if __name__ == "__main__": From d13218e48886e8ce2bb0e7f73c54ff56cf5a8297 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Sat, 5 Sep 2026 15:31:04 -0700 Subject: [PATCH 34/44] removed ventis changed everything to canyonos --- .../skills/porting-to-canyonos-core/SKILL.md | 26 +- .../references/ec2.md | 2 +- .../references/llm-proxy.md | 2 +- .../references/packaging.md | 2 +- .../references/runtime-contract.md | 10 +- .../references/troubleshooting.md | 4 +- .../porting-to-canyonos-core/validate.py | 46 +- .gitignore | 6 +- README.md | 34 +- VENTIS_TO_CANYONOS_RENAME.md | 691 ++++++++++++++++++ {ventis => canyonos_core}/Dockerfile | 12 +- {ventis => canyonos_core}/FUTURE_SCHEMA.md | 0 .../OTLP_Exporter/DESIGN.md | 28 +- .../OTLP_Exporter/SCHEMA.md | 0 .../OTLP_Exporter/__init__.py | 0 .../OTLP_Exporter/convert.py | 4 +- {ventis => canyonos_core}/OTLP_Exporter/db.py | 2 +- .../OTLP_Exporter/otel_exporter.py | 2 +- .../OTLP_Exporter/otel_queue.db | 0 {ventis => canyonos_core}/README.md | 0 canyonos_core/__init__.py | 2 + {ventis => canyonos_core}/cli.py | 58 +- canyonos_core/controller/__init__.py | 1 + .../controller/canyonos_context.py | 0 .../cloud_provider_logic/EC2/README.md | 6 +- .../cloud_provider_logic/EC2/_runtime.py | 37 +- .../cloud_provider_logic/Local/_runtime.py | 42 +- .../controller/deploy.py | 26 +- .../controller/future.py | 20 +- .../controller/global_controller.py | 48 +- .../controller/instance_manager.py | 6 +- .../controller/local_controller.py | 50 +- .../controller/local_controller_frontend.py | 8 +- .../controller/proto/global_controller.proto | 0 .../controller/proto/local_controler.proto | 0 canyonos_core/controller/utils/__init__.py | 1 + .../controller/utils/agent_specs.py | 0 .../controller/utils/aws_pricing_chart.db | Bin .../controller/utils/env_file.py | 2 +- .../controller/utils/gpu_metrics.py | 0 .../controller/utils/grpc_options.py | 0 .../controller/utils/pricing.py | 0 .../controller/utils/process_supervisor.py | 2 +- .../controller/utils/redis_client.py | 0 .../controller/utils/redis_utils.py | 2 +- .../controller/utils/session_logging.py | 2 +- .../controller/utils/telemetry_logging.py | 19 +- {ventis => canyonos_core}/llm_proxy/README.md | 2 +- .../llm_proxy/__init__.py | 0 .../llm_proxy/__main__.py | 4 +- {ventis => canyonos_core}/llm_proxy/app.py | 8 +- {ventis => canyonos_core}/llm_proxy/config.py | 4 +- {ventis => canyonos_core}/llm_proxy/core.py | 17 +- {ventis => canyonos_core}/llm_proxy/hooks.py | 4 +- .../llm_proxy/providers/__init__.py | 6 +- .../llm_proxy/providers/anthropic.py | 2 +- .../llm_proxy/providers/base.py | 0 .../llm_proxy/providers/bedrock.py | 2 +- .../llm_proxy/providers/openai.py | 2 +- canyonos_core/llm_proxy/proxy.py | 74 ++ .../llm_proxy/requirements.txt | 0 canyonos_core/llm_proxy/stub.py | 73 ++ {ventis => canyonos_core}/server.py | 14 +- {ventis => canyonos_core}/stub_generator.py | 22 +- cli/ARCHITECTURE.md | 6 +- cli/canyonos/dashboard.compose.yml | 2 +- cli/canyonos/deploy.py | 8 +- cli/canyonos/gc.py | 4 +- cli/canyonos/init.py | 51 +- cli/canyonos/test.py | 22 +- cli/canyonos/verify.py | 12 +- cli/cli.py | 24 +- examples/finance/agents/finance_agent.py | 2 +- .../finance/config/global_controller.yaml | 2 +- examples/finance/workflow/example_workflow.py | 2 +- examples/helloworld/README.md | 12 +- .../helloworld/config/global_controller.yaml | 4 +- .../helloworld/workflow/example_workflow.py | 2 +- examples/portfolio/agents/advisor_agent.py | 2 +- examples/portfolio/agents/intent_agent.py | 2 +- examples/portfolio/agents/metrics_agent.py | 6 +- .../portfolio/config/global_controller.yaml | 4 +- .../portfolio/workflow/portfolio_workflow.py | 4 +- .../text2sql/agents/sql_generator_agent.py | 2 +- examples/text2sql/agents/vllm_agent.py | 2 +- .../text2sql/config/global_controller.yaml | 2 +- .../text2sql/workflow/text2sql_workflow.py | 2 +- images/canyonos-banner.gif | Bin 0 -> 1122031 bytes pyproject.toml | 26 +- tests/README.md | 20 +- tests/run_tests.sh | 12 +- tests/test_canyonos_context.py | 49 ++ tests/test_canyonos_test.py | 55 +- tests/test_cli.py | 66 +- tests/test_deploy.py | 54 +- tests/test_deploy_progress.py | 70 +- tests/test_env_file_reserved_keys.py | 48 ++ tests/test_error_propagation.py | 19 +- tests/test_future.py | 12 +- tests/test_global_controller_cleanup.py | 2 +- tests/test_global_controller_identity.py | 4 +- tests/test_global_controller_project_id.py | 2 +- tests/test_global_controller_redis_reuse.py | 14 +- tests/test_global_controller_reload.py | 4 +- tests/test_gpu_metrics.py | 8 +- tests/test_instance_manager_runtime.py | 82 ++- tests/test_local_controller_cleanup.py | 8 +- tests/test_local_controller_metrics.py | 24 +- tests/test_otel_exporter_fanout.py | 10 +- tests/test_otel_exporter_fields.py | 2 +- tests/test_redis_utils.py | 6 +- tests/test_runtime_ec2.py | 6 +- tests/test_session_logging.py | 4 +- tests/test_stub_generator.py | 2 +- tests/test_telemetry_logging.py | 20 +- tests/test_ventis_context.py | 49 -- uv.lock | 102 +-- ventis/__init__.py | 2 - ventis/controller/__init__.py | 1 - ventis/controller/utils/__init__.py | 1 - ventis/llm_proxy/proxy.py | 58 -- 121 files changed, 1715 insertions(+), 743 deletions(-) create mode 100644 VENTIS_TO_CANYONOS_RENAME.md rename {ventis => canyonos_core}/Dockerfile (57%) rename {ventis => canyonos_core}/FUTURE_SCHEMA.md (100%) rename {ventis => canyonos_core}/OTLP_Exporter/DESIGN.md (92%) rename {ventis => canyonos_core}/OTLP_Exporter/SCHEMA.md (100%) rename {ventis => canyonos_core}/OTLP_Exporter/__init__.py (100%) rename {ventis => canyonos_core}/OTLP_Exporter/convert.py (95%) rename {ventis => canyonos_core}/OTLP_Exporter/db.py (99%) rename {ventis => canyonos_core}/OTLP_Exporter/otel_exporter.py (99%) rename {ventis => canyonos_core}/OTLP_Exporter/otel_queue.db (100%) rename {ventis => canyonos_core}/README.md (100%) create mode 100644 canyonos_core/__init__.py rename {ventis => canyonos_core}/cli.py (92%) create mode 100644 canyonos_core/controller/__init__.py rename ventis/controller/ventis_context.py => canyonos_core/controller/canyonos_context.py (100%) rename {ventis => canyonos_core}/controller/cloud_provider_logic/EC2/README.md (91%) rename {ventis => canyonos_core}/controller/cloud_provider_logic/EC2/_runtime.py (89%) rename {ventis => canyonos_core}/controller/cloud_provider_logic/Local/_runtime.py (78%) rename {ventis => canyonos_core}/controller/deploy.py (93%) rename {ventis => canyonos_core}/controller/future.py (93%) rename {ventis => canyonos_core}/controller/global_controller.py (95%) rename {ventis => canyonos_core}/controller/instance_manager.py (97%) rename {ventis => canyonos_core}/controller/local_controller.py (94%) rename {ventis => canyonos_core}/controller/local_controller_frontend.py (95%) rename {ventis => canyonos_core}/controller/proto/global_controller.proto (100%) rename {ventis => canyonos_core}/controller/proto/local_controler.proto (100%) create mode 100644 canyonos_core/controller/utils/__init__.py rename {ventis => canyonos_core}/controller/utils/agent_specs.py (100%) rename {ventis => canyonos_core}/controller/utils/aws_pricing_chart.db (100%) rename {ventis => canyonos_core}/controller/utils/env_file.py (98%) rename {ventis => canyonos_core}/controller/utils/gpu_metrics.py (100%) rename {ventis => canyonos_core}/controller/utils/grpc_options.py (100%) rename {ventis => canyonos_core}/controller/utils/pricing.py (100%) rename {ventis => canyonos_core}/controller/utils/process_supervisor.py (95%) rename {ventis => canyonos_core}/controller/utils/redis_client.py (100%) rename {ventis => canyonos_core}/controller/utils/redis_utils.py (90%) rename {ventis => canyonos_core}/controller/utils/session_logging.py (98%) rename {ventis => canyonos_core}/controller/utils/telemetry_logging.py (92%) rename {ventis => canyonos_core}/llm_proxy/README.md (97%) rename {ventis => canyonos_core}/llm_proxy/__init__.py (100%) rename {ventis => canyonos_core}/llm_proxy/__main__.py (88%) rename {ventis => canyonos_core}/llm_proxy/app.py (84%) rename {ventis => canyonos_core}/llm_proxy/config.py (92%) rename {ventis => canyonos_core}/llm_proxy/core.py (61%) rename {ventis => canyonos_core}/llm_proxy/hooks.py (97%) rename {ventis => canyonos_core}/llm_proxy/providers/__init__.py (54%) rename {ventis => canyonos_core}/llm_proxy/providers/anthropic.py (86%) rename {ventis => canyonos_core}/llm_proxy/providers/base.py (100%) rename {ventis => canyonos_core}/llm_proxy/providers/bedrock.py (98%) rename {ventis => canyonos_core}/llm_proxy/providers/openai.py (84%) create mode 100644 canyonos_core/llm_proxy/proxy.py rename {ventis => canyonos_core}/llm_proxy/requirements.txt (100%) create mode 100644 canyonos_core/llm_proxy/stub.py rename {ventis => canyonos_core}/server.py (91%) rename {ventis => canyonos_core}/stub_generator.py (96%) create mode 100644 images/canyonos-banner.gif create mode 100644 tests/test_canyonos_context.py create mode 100644 tests/test_env_file_reserved_keys.py delete mode 100644 tests/test_ventis_context.py delete mode 100644 ventis/__init__.py delete mode 100644 ventis/controller/__init__.py delete mode 100644 ventis/controller/utils/__init__.py delete mode 100644 ventis/llm_proxy/proxy.py diff --git a/.claude/skills/porting-to-canyonos-core/SKILL.md b/.claude/skills/porting-to-canyonos-core/SKILL.md index fe6dd49..585297c 100644 --- a/.claude/skills/porting-to-canyonos-core/SKILL.md +++ b/.claude/skills/porting-to-canyonos-core/SKILL.md @@ -1,14 +1,14 @@ --- name: porting-to-canyonos-core description: Ports existing LangChain, LangGraph, CrewAI, AutoGen, and hand-rolled Python agent projects to CanyonOS Core. Use when converting, migrating, adapting, packaging, building, or deploying an existing agent or multi-agent project onto CanyonOS Core. -compatibility: Requires Python, Docker, and the `ventis` compatibility CLI. Runtime identifiers remain `ventis`, `VENTIS_*`, and `ventis-*`. +compatibility: Requires Python, Docker, and the `canyonos` compatibility CLI. Runtime identifiers remain `canyonos`, `CANYONOS_*`, and `canyonos-*`. --- # Port an agent project to CanyonOS Core CanyonOS Core is the product name. Its compatibility executable and Python -package remain `ventis`; environment variables and Docker resources retain the -`VENTIS_*` and `ventis-*` prefixes. These are protocol identifiers, not branding +package remain `canyonos`; environment variables and Docker resources retain the +`CANYONOS_*` and `canyonos-*` prefixes. These are protocol identifiers, not branding strings. Do not rename them. ## Load references only when needed @@ -47,7 +47,7 @@ model clients, and node bodies—is imported. The port re-expresses only the CanyonOS Core boundary and framework-owned orchestration. The port root is the existing repository root and the directory from which -`ventis build` runs. Write scaffolding there beside existing directories. If the +`canyonos build` runs. Write scaffolding there beside existing directories. If the repository already uses `src/`, leave it in place and put `agents/`, `workflow/`, and `config/` beside it. Never move or copy the repository into a new `src/` directory, and never create an outer wrapper merely for the port. @@ -74,7 +74,7 @@ importable runtime rather than external development metadata: python /validate.py . ``` -If config or agent yaml is malformed, the validator defers to `ventis build`. +If config or agent yaml is malformed, the validator defers to `canyonos build`. Capability-gated findings say which runtime behavior is available. ## 2. Choose service boundaries @@ -187,18 +187,18 @@ Run static preflight, then let the build own build-time validation: ```bash python /validate.py . -ventis build -c config/global_controller.yaml +canyonos build -c config/global_controller.yaml ``` A green build never imports the adapter. Probe each agent image in this order: ```bash # Runtime startup path - docker run --rm ventis- \ + docker run --rm canyonos- \ python -c "import local_controller" # Agent load path; include --env-file when configured - docker run --rm --env-file ventis- \ + docker run --rm --env-file canyonos- \ python -c "import importlib.util,sys; \ s=importlib.util.spec_from_file_location('m','.py'); \ m=importlib.util.module_from_spec(s);sys.modules['m']=m;s.loader.exec_module(m); \ @@ -211,7 +211,7 @@ its own dependency resolve and generated-stub imports. Then deploy, send a representative request, and poll its status: ```bash -ventis deploy -c config/global_controller.yaml +canyonos deploy -c config/global_controller.yaml curl -X POST http://localhost:8080/main \ -H 'Content-Type: application/json' -d '{"query":""}' curl http://localhost:8080/status/ @@ -227,14 +227,14 @@ controller cleanup. Remove exact leftovers if startup crashed. Then remove build products and exact images from this config: ```bash -ventis clean -docker image rm ventis- \ - ventis- +canyonos clean +docker image rm canyonos- \ + canyonos- test ! -e stubs && test ! -e grpc_stubs && test ! -e docker_container docker ps -a --format '{{.Names}}' ``` -`ventis clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it +`canyonos clean` removes only `stubs/`, `grpc_stubs/`, and `docker_container/`; it does not remove containers or images. Keep port scaffolding, untouched source, and requested logs or reports. diff --git a/.claude/skills/porting-to-canyonos-core/references/ec2.md b/.claude/skills/porting-to-canyonos-core/references/ec2.md index e06daa0..325849f 100644 --- a/.claude/skills/porting-to-canyonos-core/references/ec2.md +++ b/.claude/skills/porting-to-canyonos-core/references/ec2.md @@ -16,7 +16,7 @@ Typical required categories are: - security groups - SSH user and credentials accepted by the runtime -`ventis deploy` owns basic EC2 config validation. A preflight pass is not proof +`canyonos deploy` owns basic EC2 config validation. A preflight pass is not proof that provisioning, SSH, image transfer, or remote container startup works. ## Networking diff --git a/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md b/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md index f726bd7..107497f 100644 --- a/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md +++ b/.claude/skills/porting-to-canyonos-core/references/llm-proxy.md @@ -33,7 +33,7 @@ curl http://127.0.0.1:8081/healthz Local CanyonOS Core containers resolve `host.docker.internal` through their Docker host mapping. On EC2 that name resolves to each EC2 Docker host, not the -machine running `ventis deploy`. Distributed deployments need a reachable proxy +machine running `canyonos deploy`. Distributed deployments need a reachable proxy address or one proxy on each host. ## Supported call shape diff --git a/.claude/skills/porting-to-canyonos-core/references/packaging.md b/.claude/skills/porting-to-canyonos-core/references/packaging.md index 8520c4a..8a6fb5e 100644 --- a/.claude/skills/porting-to-canyonos-core/references/packaging.md +++ b/.claude/skills/porting-to-canyonos-core/references/packaging.md @@ -76,6 +76,6 @@ owner decide whether source metadata should change. ## Validation boundary -`ventis build` owns packaging syntax and installation errors. `validate.py` +`canyonos build` owns packaging syntax and installation errors. `validate.py` checks only whether adapter imports appear to require a nested root that the runtime will not expose. diff --git a/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md index 94f7401..cd01a67 100644 --- a/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md +++ b/.claude/skills/porting-to-canyonos-core/references/runtime-contract.md @@ -1,7 +1,7 @@ # CanyonOS Core runtime contract -The product is CanyonOS Core. Compatibility identifiers remain `ventis` for the -CLI and Python package, `VENTIS_*` for runtime variables, and `ventis-*` for +The product is CanyonOS Core. Compatibility identifiers remain `canyonos` for the +CLI and Python package, `CANYONOS_*` for runtime variables, and `canyonos-*` for Docker resources. Read this reference when implementing an adapter or explaining a validator @@ -11,7 +11,7 @@ release history. ## Project root and discovery -`ventis build` uses the current working directory as the project root. +`canyonos build` uses the current working directory as the project root. | Input | Discovery | |---|---| @@ -113,7 +113,7 @@ Avoid root project modules named like runtime files, including: ```text future.py -ventis_context.py +canyonos_context.py local_controller.py local_controller_frontend.py redis_client.py @@ -181,7 +181,7 @@ Stopping foreground deploy normally invokes controller cleanup for recorded containers and Redis. Hard kills and failures before resource registration may leave resources behind. -`ventis clean` removes generated `stubs/`, `grpc_stubs/`, and +`canyonos clean` removes generated `stubs/`, `grpc_stubs/`, and `docker_container/`. It does not remove containers or images. Remove exact leftovers explicitly and preserve source, port scaffolding, and requested evidence. diff --git a/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md b/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md index 361acf9..483ba23 100644 --- a/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md +++ b/.claude/skills/porting-to-canyonos-core/references/troubleshooting.md @@ -55,6 +55,6 @@ read [llm-proxy.md](llm-proxy.md) or [ec2.md](ec2.md). | Symptom | Likely cause | |---|---| -| `ventis clean` succeeds but containers remain | The command removes generated directories only | -| `ventis clean` succeeds but images remain | Image deletion is separate and requires exact tags | +| `canyonos clean` succeeds but containers remain | The command removes generated directories only | +| `canyonos clean` succeeds but images remain | Image deletion is separate and requires exact tags | | Next deployment collides with old resources | Foreground deploy was killed or crashed before controller cleanup | diff --git a/.claude/skills/porting-to-canyonos-core/validate.py b/.claude/skills/porting-to-canyonos-core/validate.py index 04baf37..eeb6168 100755 --- a/.claude/skills/porting-to-canyonos-core/validate.py +++ b/.claude/skills/porting-to-canyonos-core/validate.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 -"""Preflight the runtime traps that `ventis build` cannot see. +"""Preflight the runtime traps that `canyonos build` cannot see. This deliberately does not duplicate build-time validation such as malformed -YAML, missing entrypoints, or config-to-yaml matching. `ventis build` owns those +YAML, missing entrypoints, or config-to-yaml matching. `canyonos build` owns those checks. This script parses Python without importing it and catches failures that otherwise stay hidden until a container loads an agent, starts a workflow, or serves its first request. A replica is not evidence: the controller writes @@ -14,7 +14,7 @@ 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 `ventis` package directly. A capability-gated check reports +the importable `canyonos` package directly. A capability-gated check reports UNAVAILABLE when its behavior cannot be proven. """ @@ -38,11 +38,11 @@ # Copied flat into every image over the swept project tree, so a project module # landing flat under one of these names is overwritten. -# ventis/stub_generator.py generate_docker / generate_workflow_docker. +# canyonos/stub_generator.py generate_docker / generate_workflow_docker. RUNTIME_FLAT_NAMES = frozenset( { "future.py", - "ventis_context.py", + "canyonos_context.py", "local_controller.py", "local_controller_frontend.py", "redis_client.py", @@ -55,7 +55,7 @@ } ) -# ventis/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. +# canyonos/stub_generator.py BASE_AGENT_REQUIREMENTS / BASE_WORKFLOW_REQUIREMENTS. BASE_AGENT_REQUIREMENTS = [ "grpcio", "grpcio-tools", @@ -115,22 +115,22 @@ def probe_capabilities(): - """Ask the importable ventis package what it actually supports.""" + """Ask the importable canyonos package what it actually supports.""" caps = dict.fromkeys(CAPABILITY_SOURCE, False) - caps["ventis"] = False + caps["canyonos_core"] = False try: - from ventis import stub_generator + from canyonos_core import stub_generator except Exception: # noqa: BLE001 - a broken install must not crash the check return caps - caps["ventis"] = True + caps["canyonos_core"] = True caps["editable_install"] = hasattr(stub_generator, "_install_step") caps["sweeps_all_files"] = hasattr(stub_generator, "_sweep_project_files") caps["stub_two_destinations"] = hasattr(stub_generator, "_stub_destinations") import importlib - for module_name in ("ventis.controller.utils.env_file", "ventis.utils.env_file"): + 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,S112 - the other path is the live one @@ -355,7 +355,7 @@ def check_adapter(report, agent_yaml_path, agent_block, entry, project_dir): entrypoint_path, 1, f"no class named `{name}` at module level (found: {found})", - "_load_agent does getattr(module, VENTIS_AGENT_NAME) and swallows " + "_load_agent does getattr(module, CANYONOS_AGENT_NAME) and swallows " "the AttributeError. The class name must equal agent.name exactly.", ) return @@ -488,7 +488,7 @@ def check_stub_imports(report, workflow_path, tree, stub_classes): The build copies each stub to exactly one path, and for the workflow image that path is agents/.py. Two ways of writing this line fail, and the project walks you into both: the flat form is what examples/ uses, and - the class name is the one `ventis build` prints, which is not the one it + the class name is the one `canyonos build` prints, which is not the one it writes. """ for node in ast.walk(tree): @@ -777,7 +777,7 @@ def check_env_file(report, config, config_path, project_dir): 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 ventis package, so the " + "No resolve_env_file in the importable canyonos 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.", @@ -785,7 +785,7 @@ def check_env_file(report, config, config_path, project_dir): else: report.unavailable( "V030", - "env_file is not supported by the importable `ventis` runtime. " + "env_file is not supported by the importable `canyonos` runtime. " "Credentials have no declared path into a container on this tree.", ) return @@ -796,7 +796,7 @@ def check_env_file(report, config, config_path, project_dir): config_path, line_of(config), "no `env_file:` in the config", - "Only runtime-managed VENTIS_* variables are guaranteed without it. " + "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.", ) @@ -832,7 +832,7 @@ def check_import_root(report, project_dir, entrypoint_paths): report.unavailable( "V031", "the editable install (`-e .`) is not supported by the importable " - "`ventis` runtime. Only names rooted at /app import inside a container.", + "`canyonos` runtime. Only names rooted at /app import inside a container.", ) for path, lineno, name, location in non_flat: report.error( @@ -999,7 +999,7 @@ def check_requirements_coverage( stdlib = getattr(sys, "stdlib_module_names", frozenset()) for name, lineno in sorted(toplevel_import_names(tree).items()): - if name in stdlib or name == "ventis": + if name in stdlib or name == "canyonos_core": continue # Provided by the image itself: the shared runtime is copied flat, and # every agents/*.yaml generates a stub that is copied flat too. @@ -1059,7 +1059,7 @@ def validate(project_dir, config_path, capabilities): report.unavailable( "BUILD", "runtime preflight skipped because the config cannot be read; " - "ventis build owns and reports this error.", + "canyonos build owns and reports this error.", ) return report @@ -1077,7 +1077,7 @@ def validate(project_dir, config_path, capabilities): agent = data.get("agent") if isinstance(data, dict) else None name = agent.get("name") if isinstance(agent, dict) else None if yaml_error is not None or not isinstance(name, str): - continue # ventis build reports malformed agent declarations + continue # canyonos build reports malformed agent declarations agents_by_name[name] = (path, agent) stub_classes[os.path.splitext(os.path.basename(path))[0]] = name @@ -1086,7 +1086,7 @@ def validate(project_dir, config_path, capabilities): report.unavailable( "BUILD", "runtime preflight skipped because `agents:` is not a list; " - "ventis build owns and reports this error.", + "canyonos build owns and reports this error.", ) return report @@ -1168,8 +1168,8 @@ def _wrap(text, width, indent): def print_report(report, project_dir): caps = report.capabilities - if not caps.get("ventis"): - print("ventis is not importable here -- capability-gated rules are") + if not caps.get("canyonos_core"): + print("canyonos is not importable here -- capability-gated rules are") print("reported UNAVAILABLE rather than checked.\n") else: print("CanyonOS Core capabilities detected:") diff --git a/.gitignore b/.gitignore index 1d7998b..ba6777e 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,7 @@ Thumbs.db ._* # Canyon artifacts. `.car` is generated from the application source by the -# porting skill and `ventis build`; it is never committed. +# porting skill and `canyonos build`; it is never committed. .car/ # Generated stubs @@ -48,6 +48,6 @@ uv.lock Agent Artifacts docs/ -# testing-porting-to-ventis working tree: clones, artifacts, results db -.ventis-tests/ +# testing-porting-to-canyonos working tree: clones, artifacts, results db +.canyonos-tests/ .harness/ diff --git a/README.md b/README.md index 0f1ea27..7fe02ca 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@

- Ventis Logo + CanyonOS

-Ventis is a bottom-up control plane and agent serving framework that enables developers to build, deploy and control agentic workflow serving with ease. Ventis derives it's name from the latin word 'ventus' meaning wind. True to its name, Ventis is like the wind, invisible but always present. +CanyonOS is a bottom-up control plane and agent serving framework that enables developers to build, deploy and control agentic workflow serving with ease. CanyonOS derives it's name from the latin word 'ventus' meaning wind. True to its name, CanyonOS is like the wind, invisible but always present. ## Core Features -- **Easy development and deployment**: Developers write agents in python as if writing single node local code. Ventis takes care of deployment, management and orchestration of agents and workflows. Deployment engineers running this workflow can specify authorization and other serving policies, Ventis will enforce them. +- **Easy development and deployment**: Developers write agents in python as if writing single node local code. CanyonOS takes care of deployment, management and orchestration of agents and workflows. Deployment engineers running this workflow can specify authorization and other serving policies, CanyonOS will enforce them. - **Distributed Futures**: Asynchronous execution without any user workflow modification. - **Pluggable Policy Engine**: Supports multiple policies for orchestration, authorization and other serving policies. @@ -16,17 +16,17 @@ Ventis is a bottom-up control plane and agent serving framework that enables dev ### 1. Installation ```bash -git clone https://github.com/your-repo/ventis.git -cd ventis +git clone https://github.com/your-repo/canyonos.git +cd canyonos pip install -e . ``` -Note: Installation of ventis only needs to be done on the machine where you are running the deploy command. It does not need to be installed on the remote hosts where the agents are deployed. Ventis runs the built container images on the target hosts; for remote EC2 deployments, make sure the image is already available on the host. +Note: Installation of canyonos only needs to be done on the machine where you are running the deploy command. It does not need to be installed on the remote hosts where the agents are deployed. CanyonOS runs the built container images on the target hosts; for remote EC2 deployments, make sure the image is already available on the host. ### 2. Prerequisites - **Python 3.10+** - **Docker** — Used to manage agents. -- **Docker Buildx** (optional) — If available, `ventis build` builds all agent/workflow images in a single parallel `docker buildx bake` pass; otherwise it falls back to building them sequentially. +- **Docker Buildx** (optional) — If available, `canyonos build` builds all agent/workflow images in a single parallel `docker buildx bake` pass; otherwise it falls back to building them sequentially. --- @@ -34,7 +34,7 @@ Note: Installation of ventis only needs to be done on the machine where you are #### Step 1: Create a Project ```bash -ventis new-project my-app +canyonos new-project my-app cd my-app ``` This command creates a new directory `my-app` with the following structure: @@ -73,7 +73,7 @@ Edit `.car/config/global_controller.yaml` in your project directory to list the #### 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: +Agents that need API keys read them from environment variables. Point `env_file` at a `.env` file to have CanyonOS inject it into every agent container: ```yaml # .car/config/global_controller.yaml @@ -82,7 +82,7 @@ env_file: .env #### Step 2: Build the project ```bash -ventis build +canyonos build ``` #### Step 2.1 (Only if performing distributed deployment): If you are deploying agents and tools to multiple hosts, make sure the hosts are reachable from the machine where you are running the deploy command and that SSH key-based access is already configured. A guide to set that up can be found [here](https://www.redhat.com/en/blog/passwordless-ssh). @@ -90,12 +90,12 @@ If you are deploying agents and tools to multiple hosts, make sure the hosts are #### Step 3: Deploy the project ```bash -ventis deploy +canyonos deploy ``` #### Step 4: Sending requests to the workflow -Upon running the deploy command, ventis automatically generates a REST API endpoint for the workflow. +Upon running the deploy command, canyonos automatically generates a REST API endpoint for the workflow. Users can send requests to this endpoint to trigger the workflow. For this example, workflow to send a request - ```bash @@ -115,17 +115,17 @@ curl http://localhost:8080/status/ Remove all generated stub and gRPC files: ```bash -ventis clean +canyonos clean ``` -### Harnessing the power of Ventis -Beyond an easy programming model and end-to-end deployment. Ventis, enables developers to write custom policies to perform fine-grained control over their agents, workflows. +### Harnessing the power of CanyonOS +Beyond an easy programming model and end-to-end deployment. CanyonOS, enables developers to write custom policies to perform fine-grained control over their agents, workflows. Currently, we support two types of policies, with plans to add more in the future. * **Authorization Policies**: Define rules based on the fields in the request to restrict agent access. For example, `examples/config/policy.yaml` defines rules to restrict access to the `FinanceAgent` to only authorized callers like 'CEO' or 'Analyst'. A developer can specify rules based on the fields in the request to restrict agent access. -* **Load Balancing & Efficiency**: Ventis has built-in policies to perform load-balancing across multiple instances of the same agent. Request migrations ease head-of-line blocking, and our experiments show that Ventis's performance control can reduce tail latencies and enable efficient GPU utilization. Here is an example of the results. +* **Load Balancing & Efficiency**: CanyonOS has built-in policies to perform load-balancing across multiple instances of the same agent. Request migrations ease head-of-line blocking, and our experiments show that CanyonOS's performance control can reduce tail latencies and enable efficient GPU utilization. Here is an example of the results. ![Financial Analyst Results](images/financial_analyst_results_page.jpg) @@ -141,7 +141,7 @@ For more details, please refer to our paper - [Nalar: An agent serving framework ### Citation -If you find Ventis (Nalar) useful for your research, please cite our paper: +If you find CanyonOS (Nalar) useful for your research, please cite our paper: ```bibtex @misc{laju2026nalar, title={Nalar: An agent serving framework}, diff --git a/VENTIS_TO_CANYONOS_RENAME.md b/VENTIS_TO_CANYONOS_RENAME.md new file mode 100644 index 0000000..43c2d4e --- /dev/null +++ b/VENTIS_TO_CANYONOS_RENAME.md @@ -0,0 +1,691 @@ +# Ventis → CanyonOS Rename: Verified Migration Plan + +> **Audit status (2026-09-05):** re-checked against the complete tracked tree, +> hidden files, ignored/generated state, current tests, and Canyon Code company +> memory. Seven independent Luna agents audited runtime, packaging, infrastructure, +> tests, documentation, edge cases, and inventory. This file is analysis and an +> execution plan only; no runtime code has been renamed yet. + +## Executive verdict + +Do **not** implement this as a global search-and-replace. The current tree has one +blocking architecture decision and several versioned contracts that require an +additive compatibility phase. + +1. **Blocking package-name collision.** The root distribution/package is currently + `ventis` (`pyproject.toml:2,24,31`), while `cli/` already owns the distribution, + import package, and executable name `canyonos` (`cli/pyproject.toml:2,14,21`). + Renaming the root package and distribution to `canyonos` would make two editable + projects provide the same distribution and top-level package. A temporary + reproduction fails `uv lock --offline` with conflicting URLs for `canyonos`. +2. **The repository deliberately documents old names as compatibility protocol.** + `.claude/skills/porting-to-canyonos-core/SKILL.md:4-12` and + `references/runtime-contract.md:1-5` currently require the `ventis` Python/CLI, + `VENTIS_*` variables, and `ventis-*` Docker names. That policy must be replaced + or deprecated; simply changing code makes the skill teach users the wrong API. +3. **Generated artifacts are part of the runtime contract.** The build generator + creates a nested `ventis.llm_proxy` package, copies `ventis_context.py`, writes + `VENTIS_AGENT_*`, and the generated controller launches `python -m + ventis.llm_proxy`. Updating only the source package will produce images that + build but fail at runtime. +4. **The host CLI participates in the old protocol.** Contrary to the previous + draft, `cli/canyonos/init.py:156` writes `VENTIS_REDIS_HOST` into the Global + Controller container. It also consumes old Docker prefixes and the validator's + `capabilities.ventis` JSON field. +5. **The test baseline is not green.** A clean `uv run pytest -q` currently stops + with nine collection errors because `local_controler_pb2` is not generated. + With both protos generated into a temporary `PYTHONPATH`, the baseline is + **212 passed, 11 failed, 3 subtests passed**. Those 11 failures are pre-existing + behavior/test drift, not rename regressions. + +The safe route is: decide package ownership, introduce CanyonOS names alongside +legacy readers/aliases, switch every producer and consumer together, validate +fresh and upgrade deployments, then remove compatibility names in a later major +release. A literal zero-match tree is the **end state**, not a safe first commit. + +> **NOTE:** The operator has since chosen a **hard cutover with no compatibility +> shims** (see "Locked decisions" below). That decision **supersedes** every +> "additive/compat/fallback/dual-read/shim" recommendation in this document. The +> *contract coupling* (which producers and consumers must change together) still +> fully applies — only the transitional fallbacks are dropped. Where a section +> below proposes old+new fallbacks, read it as "change producer and consumer to the +> new name in the same commit; delete the old name outright." + +## Locked decisions (2026-09-05, operator-approved) + +Hard cutover. **No** legacy env-var dual-read, **no** legacy import alias, **no** +old-header fallback, **no** compatibility release window. Existing running +deployments must be torn down and rebuilt; this is an accepted breaking change. + +| Surface | Decision | +|---|---| +| Core import package / dir | `canyonos_core` (dir `ventis/` → `canyonos_core/`); all imports `from canyonos_core…` | +| Core distribution name | `canyonos-core` | +| `ventis_context` module + alias | `canyonos_context.py` / alias `canyonos_context` | +| Env vars | `VENTIS_*` → `CANYONOS_*` (⚠ collision note below) | +| Resource prefix (network/image/container/redis/tags/ids) | `canyonos-*` (`canyonos-local`, `canyonos-`, `canyonos-redis-*`, `canyonos-ec2-*`) | +| Future-ID HTTP header | `X-Canyonos-Future-ID` (`Canyonos` cased, `ID` all-caps) — injector **and** reader standardized to this exact spelling | +| Published GC image | **unchanged** — stays `saakeths/canyonos:latest` | +| Core executable | **removed** — core is import-only; the standalone `canyonos` CLI is the sole console script | +| Root `pyproject.toml` | **NOT deleted — rewritten import-only** (deleting breaks the container build; see below) | +| `uv.lock` | regenerate **after** the rename + pyproject rewrite land; never hand-edit | +| SSH key default | **unchanged** — stays `~/.ssh/ventis_ec2` for now | +| Logo asset + README clone URL | **unchanged** for now (`images/ventis-logo.png`, git URL) | + +**Root `pyproject.toml` is critical — do not delete.** `ventis/Dockerfile:5-6` +runs `COPY . /ventis` + `RUN pip install /ventis`, which requires the root +`pyproject.toml`. It also supplies the entire runtime dependency set (boto3, +grpcio(-tools), redis, sqlalchemy, psycopg, flask, opentelemetry-\*) and the +`[tool.setuptools.package-data]` that ships `controller/proto/*.proto` and +`controller/utils/aws_pricing_chart.db` into the installed package. Deleting it +makes the container build fail and the runtime lose its bundled data. **Rewrite it +instead:** +- `name = "canyonos-core"`, `version` kept; +- **drop** `[project.scripts]` entirely (import-only); +- **drop** `[dependency-groups] dev` `canyonos` entry **and** `[tool.uv.sources] + canyonos = { path = "cli", editable = true }` — this self-dependency is exactly + what caused the `uv lock` collision noted in company memory; removing it is what + makes the two distributions coexist; +- `[tool.setuptools.packages.find] include = ["canyonos_core*"]`; +- `[tool.setuptools.package-data]` key `ventis` → `canyonos_core` (and drop the + stale `templates/**/*` entry — that dir no longer exists); +- Ty `[tool.ty.*]` include/exclude/allowed-unresolved-import paths + (`ventis` → `canyonos_core`, `ventis_context` → `canyonos_context`). + +**⚠ `CANYONOS_*` collision watch.** `VENTIS_REDIS_HOST`/`VENTIS_REDIS_PORT` become +`CANYONOS_REDIS_HOST`/`CANYONOS_REDIS_PORT`, which are **also** the names the +user-side dashboard stack already writes (`cli/canyonos/dashboard_stack.py:227-228`). +They live in different process/compose scopes (core GC/agent containers vs. the +dashboard compose), so there is no runtime clash today — but the names are now +semantically overloaded. Verify no single process reads both; if that ever changes, +the core vars would need a `CANYONOS_CORE_*` namespace. + +## Verified inventory + +The canonical count uses the tracked `HEAD` tree (before this currently untracked +analysis file is added) and case-insensitive token matches. All future recounts +must exclude this document so it does not count its own inventory: + +| Scope | Matching files | Occurrences | Matching lines | +|---|---:|---:|---:| +| Source tree, excluding this document and `uv.lock` | 93 | 628 | 578 | +| `uv.lock` | 1 | 1 | 1 | +| Source tree including `uv.lock`, excluding this document | 94 | 629 | 579 | + +Exact-case totals excluding `uv.lock` are 458 `ventis`, 62 `Ventis`, and 108 +`VENTIS`. There are 173 tracked files total. The 94 matching files break down as: + +| Area | Files with content matches | +|---|---:| +| Root runtime directory | 36 | +| Tests | 25 | +| Examples | 15 | +| Vendored porting skill | 7 | +| Standalone CLI | 7 | +| Root metadata/docs | 4 | + +There are 55 tracked paths containing the old name: all 53 files under `ventis/`, +plus `images/ventis-logo.png` and `tests/test_ventis_context.py`. The migration +document's own filename/content must be excluded while work is in progress and +renamed or archived at final cleanup. + +Reproduce the audit with: + +```bash +git grep -I -i -l ventis -- . ':!uv.lock' ':!VENTIS_TO_CANYONOS_RENAME.md' | wc -l +git grep -I -i -o ventis -- . ':!uv.lock' ':!VENTIS_TO_CANYONOS_RENAME.md' | wc -l +git grep -I -i -n ventis -- . ':!VENTIS_TO_CANYONOS_RENAME.md' +git ls-files | rg -i 'ventis' +find . -path './.git' -prune -o -iname '*ventis*' -print +rg --hidden --no-ignore -i ventis -g '!.git/**' +``` + +`git grep` is the tracked-source authority; the final `rg` catches stale virtual +environments, editable-install metadata, generated `.car/` output, and other +ignored files that can mask a bad migration. + +## Decision gate 1: package and executable ownership + +This must be resolved before moving `ventis/`. **RESOLVED** — see Locked decisions. + +### Topology (locked) + +| Surface | Owner/name | +|---|---| +| User-facing distribution | existing `cli/` distribution: `canyonos` | +| User-facing import package | existing `cli/canyonos/` | +| User-facing executable | existing `canyonos` console script (the **only** one) | +| Core runtime distribution | `canyonos-core` | +| Core runtime import package | `canyonos_core` | +| In-container entrypoints | `python -m canyonos_core.server`, `.cli`, `.llm_proxy` | +| Legacy package/executable | **none** — hard cutover, no shim; core console script removed | + +This keeps the already-thin host CLI independent and avoids two wheels overwriting +one `canyonos/` directory. It also lets the root runtime be versioned independently +inside `saakeths/canyonos:`. + +The valid alternative is to merge the root runtime into the existing CLI +distribution under one intentionally owned `canyonos` tree. That is a larger +packaging refactor and must include dependency, image, and release ownership. + +**Invalid topology:** changing root `name`, package include, directory, and console +script to `canyonos` while leaving `cli/pyproject.toml` unchanged. It breaks lock +resolution, editable installs, module resolution from the repo root, and script +ownership. + +Also decide whether the old root command remains temporarily available. The old +runtime command exposes `new-project`, `deploy`, and `clean`; the standalone +`canyonos` CLI exposes `new-app`, agent-driven `build`, HTTP-driven `deploy`, and +other commands. `tests/run_tests.sh` therefore cannot be fixed by replacing the +word in place—the desired command semantics must be mapped explicitly. + +## Decision gate 2: compatibility policy + +**RESOLVED — hard cutover, declared breaking.** No compatibility window, no +dual-read, no fallback. A full teardown/rebuild is required; existing deployments +do **not** survive the switch. Every producer and its consumer(s) change to the new +name in the same commit, and the old name is deleted outright. The per-item +"legacy fallback" bullets below are **void** and retained only to enumerate the +producer/consumer pairs that must move together: + +- Env variable: producer + consumer switch to `CANYONOS_*` together; no `VENTIS_*` reader remains. +- HTTP header: injector + reader switch to `X-Canyonos-Future-ID` together. +- Validator capability key: emitter (`validate.py`) + consumer (`cli/canyonos/verify.py`) + tests switch together. +- SSH default: **unchanged** (`~/.ssh/ventis_ec2`) per Locked decisions. +- Docker/resource prefixes: generators + `cli/canyonos/verify.py` switch to `canyonos-*` together; no old-prefix recognition. + +## Contract map: changes that must move together + +### 1. Distribution, Python imports, and process entrypoints — critical + +Current package metadata in `pyproject.toml` contains: + +- distribution `name = "ventis"`; +- console script `ventis = "ventis.cli:main"`; +- package discovery `include = ["ventis*"]`; +- package-data ownership under `ventis`; +- Ty include/exclude paths rooted at `ventis`; +- Ty's allowed flat import `ventis_context`. + +After choosing the topology, update all of those together and regenerate +`uv.lock`; do not hand-edit the lock. `uv.lock:57` already contains the CLI +`canyonos` package and `uv.lock:1225` contains the root `ventis` package, which is +direct evidence of the collision. + +Runtime imports span `server.py`, `cli.py`, `stub_generator.py`, all controller +modules/providers/utilities, `OTLP_Exporter`, and `llm_proxy`. The easy-to-miss +process boundaries are: + +- `ventis/Dockerfile:5-17`: `/ventis` build root, protoc paths, and + `python -m ventis.server`; +- `ventis/server.py:9-10,56`: imports runtime helpers and spawns + `python -m ventis.cli deploy`; +- `ventis/controller/local_controller.py:143-147`: passes runtime env and spawns + `python -m ventis.llm_proxy`; +- `ventis/controller/instance_manager.py:14,232`: imports both provider runtimes; +- `.claude/skills/porting-to-canyonos-core/validate.py:122-141`: imports the runtime + and probes its env-file module paths. + +The bare fallbacks (`import ventis_context`, `import deploy`, generated gRPC +modules, etc.) exist because source files are copied flat into generated images. +Do not mechanically convert those to package-qualified imports without testing +both installed-source and generated-flat layouts. + +### 2. Generated agent/workflow build contexts — critical + +`ventis/stub_generator.py` is effectively a template engine even though it does +not use template files: + +- lines 310-324 copy `llm_proxy` to `/ventis/llm_proxy` and copy the + package `__init__.py`; +- lines 395-414 and 521 copy `controller/ventis_context.py` as the flat file + `ventis_context.py`; +- lines 443-461 emit `ENV VENTIS_AGENT_NAME` and `VENTIS_AGENT_FILE`; +- copied `local_controller.py` launches `python -m ventis.llm_proxy`; +- the collision list in `validate.py:41-46` explicitly reserves + `ventis_context.py`. + +Source, generated destination, fallback import names, validator collision rules, +and generated Dockerfile env names must change in one slice. During a compatibility +release, generated contexts may carry a small legacy import shim and both env-name +read paths. Tests must inspect the generated files and boot them; a successful +host-side import is insufficient. + +### 3. Host CLI ↔ Global Controller contracts — critical + +The host CLI communicates with the image over stable HTTP endpoints (`/deploy`, +`/clean`, `/status`, `/endpoints`); those endpoint paths contain no old brand and +should not be renamed. + +Brand-bearing coupling that does require coordination: + +- `cli/canyonos/init.py:156` injects `VENTIS_REDIS_HOST`; `ventis/server.py:85-95` + and the controller read it. During a mixed-image transition the CLI should pass + both names, and the new runtime should dual-read with `CANYONOS_*` precedence. +- `cli/canyonos/verify.py:43,260-262` looks for `ventis-local-*` containers and + `ventis-*` images. It must recognize both during upgrade and switch its emitted + guidance to CanyonOS. +- Validator JSON is a wire-like contract: `validate.py:120-126` emits + `capabilities["ventis"]`; `cli/canyonos/verify.py:88-109` consumes it; tests in + `tests/test_canyonos_test.py:44-50,150-173` encode it. Prefer a new neutral or + runtime-specific key while accepting the old key for one compatibility release. +- CLI docstrings/help in `cli/cli.py`, `cli/canyonos/deploy.py`, `gc.py`, + `verify.py`, `dashboard.compose.yml`, and `cli/ARCHITECTURE.md` still describe + the old runtime and must follow the functional cutover. + +The deploy progress parser matches message substrings rather than logger prefixes, +so renaming `logging.getLogger("ventis")` should not break its current parser. +However, `tests/test_deploy_progress.py` hardcodes many complete +`INFO:ventis...` lines and must be updated. + +### 4. Environment variables — critical external API + +Distinct current variables: + +```text +VENTIS_AGENT_FILE +VENTIS_AGENT_HOST +VENTIS_AGENT_NAME +VENTIS_AGENT_PORT +VENTIS_DATABASE_URL +VENTIS_DEMO_SERVER_COST_MULTIPLIER +VENTIS_DEMO_TOKEN_COST_MULTIPLIER +VENTIS_DOCKER_PLATFORM +VENTIS_LC_HOST +VENTIS_LC_PORT +VENTIS_MAX_AGENT_INSTANCES +VENTIS_OTEL_DESTINATIONS +VENTIS_POLL_INTERVAL +VENTIS_PROJECT_ID +VENTIS_REDIS_HOST +VENTIS_REDIS_PORT +``` + +Producers include both provider runtimes, `stub_generator.py`, and the standalone +CLI's `init.py`. Consumers include deploy/future/global/local controllers, +controller frontend, server, session/telemetry logging, LLM proxy config, and root +CLI. Tests heavily patch only the legacy names today. + +For a no-break transition: + +1. Add a single helper for `CANYONOS_*` first / `VENTIS_*` fallback and warn once. +2. Update producers to emit new names; where old images may consume them, emit + both temporarily. +3. Add precedence, fallback, and warning tests for every externally configurable + variable class—not just a blind test-string rename. +4. Update docs/skill only after the new readers are released. +5. Remove the legacy branch only at the announced compatibility boundary. + +`VENTIS_OTEL_DESTINATIONS` appears in docs/tests but current runtime configuration +has moved to the Redis key `otel:destinations`; confirm whether the env name is +already obsolete before adding a new alias. + +### 5. Future-ID HTTP header — telemetry correctness + +`ventis/llm_proxy/proxy.py:30-46` injects `X-Ventis-Future-ID`; the proxy reads it +at `ventis/llm_proxy/hooks.py:94` using different casing. HTTP header names are +case-insensitive, so the casing difference itself is safe. + +**Locked:** switch injector **and** reader to the exact spelling +`X-Canyonos-Future-ID` in the same commit (no legacy header accepted). Fix the +existing casing inconsistency at the same time so both sides use `X-Canyonos-Future-ID`. +Add the currently missing producer→consumer regression test; otherwise attribution +can silently disappear while requests still succeed. + +Do not rename the existing `gen_ai.*`, `project_id`, or `canyon.project.id` OTEL +attributes merely for branding. Those are separate telemetry schemas and no +`ventis`-prefixed OTEL attribute exists. + +### 6. Docker, EC2, Redis, and filesystem resource names — upgrade risk + +Name generation is distributed, not confined to `global_controller.py`: + +- Local provider (`Local/_runtime.py:19,42-56`): network `ventis-local`, Redis + host/container, runtime IDs, image names; +- EC2 provider (`EC2/_runtime.py:86-108,211,241-242`): AWS `Name` tags, + `ventis-ec2-*` runtime IDs, Redis containers, images, and containers; +- Global controller (`global_controller.py:153-179,396`): stale-resource cleanup + and Redis containers; +- root build (`ventis/cli.py:400`): image tags; +- CLI verification (`cli/canyonos/verify.py:43,260-262`): expected image/container + names; +- Redis probe (`controller/utils/redis_utils.py:10`): exact key + `__ventis_redis_healthcheck__`; +- remote secret copy (`controller/utils/env_file.py:61`): + `/tmp/ventis-env-`; +- Flask/logger identifiers (`server.py:12`, `controller/deploy.py:103`, + `ventis/cli.py:22`) and user-facing controller description + (`global_controller.py:922`). + +There is also a pre-existing cleanup mismatch: global cleanup expects +`ventis--` while the Local provider launches +`ventis-local--`. Fix or explicitly account for that before using +cleanup behavior as proof of a successful rename. + +A compatible upgrade must: + +- stop the active deployment before switching image versions; +- discover and remove both old and new container prefixes during the transition; +- account for both network names and avoid orphaning the old network; +- make verification recognize old resources but label them as legacy; +- rebuild every agent/workflow image so generated code and the GC agree; +- update EC2 tag expectations and any operational filters; +- clean both old and new remote env-file patterns best-effort; +- test rollback using a pinned previous GC image, not mutable `latest` alone. + +Runtime routing Redis keys such as `routing_table:*`, `agent:*`, `future:*`, and +`request:*` are brand-neutral and should remain unchanged. Runtime IDs stored in +those records do contain old Docker names, so an in-place Redis deployment must +not straddle versions; prefer a controlled teardown and fresh deploy. + +### 7. Files, defaults, and persistent data + +- SSH defaults exist in `EC2/_runtime.py:33` and + `global_controller.py:783` as `~/.ssh/ventis_ec2`. **Locked: leave unchanged for + now** — both stay `~/.ssh/ventis_ec2`. (These two lines are an intentional + exception to the zero-`ventis` end state until a later pass.) +- `examples/helloworld/config/global_controller.yaml:42` uses + `sqlite:///ventis_runtime.db`. Updating the sample does not migrate user-owned + databases. Existing config paths should remain valid; document an optional + user-controlled file move. +- `ventis/OTLP_Exporter/otel_queue.db` is tracked beneath the package directory. + Preserve it across the directory move and verify whether packaging/runtime + writes beside installed code are intentional before changing its location. +- `images/ventis-logo.png` and its `README.md` reference (plus the README clone URL) + are **locked as unchanged for now** — deferred to a later branding pass. +- `.gitignore:30,51-52` includes old comments and `.ventis-tests/`. +- `controller/utils/env_file.py` remote temp names can leave old files after a + crash; cleanup should understand both patterns, without broad `/tmp` deletion. + +### 8. Porting skill and remote delivery + +The entire vendored `.claude/skills/porting-to-canyonos-core/` tree teaches the +legacy compatibility contract. Functional changes are required in `validate.py`, +not just prose: + +- import/module probes at lines 120-141; +- flat-name collision list at line 45; +- dependency-name exception at line 1002; +- capability JSON/report handling at lines 1171-1172; +- messages and command examples throughout. + +The standalone CLI does not necessarily use this working-tree copy. +`cli/canyonos/build.py` downloads a skill from `SKILL_REF` and `SKILL_PATH` in the +GitHub repository. Update/publish that referenced branch/path first (or repoint it +to the merged source), then test a fresh cache. Existing local/global skill caches +can otherwise continue generating legacy scaffolding after this repo appears clean. + +### 9. Tests, examples, docs, and assets + +Functional test updates cover 24 test source/script files plus `tests/README.md`: + +- package imports/patch targets/loggers: `test_cli.py`, `test_deploy.py`, + `test_error_propagation.py`, `test_future.py`, controller tests, exporter tests, + Redis/runtime tests, session/telemetry tests, and `test_ventis_context.py`; +- Docker/resource contracts: `test_canyonos_test.py`, + `test_instance_manager_runtime.py`, `test_global_controller_redis_reuse.py`, + and `test_runtime_ec2.py`; +- log fixtures: `test_deploy_progress.py`; +- path injection: `test_future.py`, `test_error_propagation.py`, + `test_local_controller_metrics.py`, and `test_otel_exporter_fanout.py`; +- integration command semantics and temp/project paths: `tests/run_tests.sh`. + +All four example projects contain old prose, commands, source comments, or config +defaults. Documentation cleanup includes root `README.md`, `ventis/README.md`, +`FUTURE_SCHEMA.md` by directory move, exporter/proxy/EC2 docs, +`examples/helloworld/README.md`, `tests/README.md`, CLI docs, and the complete +porting-skill tree. + +Do docs/comments last. Several apparent prose strings are actually executable +examples or validator guidance and should be covered by command/import checks. + +## Pre-existing blockers to establish before rename work + +Record or fix these on a baseline commit so the migration has trustworthy gates: + +1. `tests/run_tests.sh` invokes pytest before generating protobuf modules. Three + tests also insert the absent `ventis/templates/grpc_stubs` path. Generate stubs + into a deterministic test location or isolate imports with fixtures. +2. After temporary proto generation, the current suite reports 212 passed and 11 + failed. Capture the exact expected baseline or fix those failures separately. +3. Root `ventis new-project` expects a `ventis/templates` directory that no longer + exists, while the standalone CLI uses the different `new-app` workflow. +4. Root `pyproject.toml` still has stale `templates/**/*` package-data and Ty + exclude entries. Confirm removal versus restoration instead of carrying them + through mechanically. +5. CI runs Ruff and Ty but not pytest, wheel-install tests, generated-context + tests, or image builds. Passing CI currently does not prove rename safety. +6. Ignored `.venv/`, `ventis.egg-info/`, `.pytest_cache/`, generated `.car/`, and + Docker state can preserve old entrypoints/imports. Verification must start from + clean generated state. + +## Ordered implementation plan + +### Phase 0 — freeze and baseline + +- Choose the package topology and compatibility window. +- Pin the current GC image by digest/tag for rollback. +- Fix or record baseline tests and deterministic proto generation. +- Add contract tests for env fallback/precedence, header fallback, validator JSON, + generated contexts, and old/new resource discovery. + +**Gate:** reproducible baseline in a clean environment, with known failures +explicitly separated from rename work. + +### Phase 1 — introduce the new runtime identity ✅ DONE (2026-09-05) + +**Executed (hard cutover, package identity only):** +- `git mv ventis/ → canyonos_core/`; `controller/ventis_context.py → canyonos_context.py`; + `tests/test_ventis_context.py → test_canyonos_context.py`. +- All `from ventis…/import ventis…` and module-path strings (test mocks, `-m` spawns, + logger names) → `canyonos_core`; `ventis_context` alias → `canyonos_context`. +- Generated flat-copy identity in `stub_generator.py` (`ventis/llm_proxy` → + `canyonos_core/llm_proxy`, flat `canyonos_context.py`) + fallback imports in + `local_controller.py`/`proxy.py` so agent containers import `canyonos_core`. +- Package logger `getLogger("ventis")` → `"canyonos_core"` (+ `test_deploy_progress`, + `test_cli` expectations); argparse `prog` → `canyonos_core`. +- `Dockerfile`: `COPY . /src`, `pip install /src`, protoc `-I/src/canyonos_core/...`, + `ENTRYPOINT python -m canyonos_core.server`. Published image name kept `saakeths/canyonos`. +- Root `pyproject.toml` rewritten import-only: `name = canyonos-core`, no + `[project.scripts]`, `find.include = [canyonos_core*]`, package-data key + Ty paths + updated, stale `templates/**` dropped. **Kept** the `canyonos` (cli) editable dev-dep + + `[tool.uv.sources]` — no longer collides now that root is `canyonos-core`, and the + root suite imports the CLI. `uv.lock` regenerated (`ventis` gone, `canyonos-core` in). +- **Verification:** `py_compile` all tracked `.py` OK; `canyonos_core` + entrypoints + import OK; **`uv run pytest -q` = 223 passed, 3 subtests passed, 0 failed.** + +### Environment-variable phase ✅ DONE (2026-09-05) + +**Executed (hard cutover, `VENTIS_*` → `CANYONOS_*`, producers + consumers together):** +- All core env reads/writes renamed across `canyonos_core/**` (controllers, both + provider `_runtime.py`, `future.py`, `server.py`, `deploy.py`, `llm_proxy/config.py`, + session/telemetry logging incl. `CANYONOS_DEMO_*` multipliers) and the generated + agent Dockerfile `ENV` in `stub_generator.py` (`CANYONOS_AGENT_NAME/FILE`). +- **Cross-boundary producer:** `cli/canyonos/init.py` now injects `CANYONOS_REDIS_HOST` + into the GC container, matching the core reader. +- Test expectations updated (`test_deploy`, `test_instance_manager_runtime`, + `test_global_controller_identity`, `test_session/telemetry_logging`, etc.). +- `VENTIS_OTEL_DESTINATIONS` confirmed **dead in code** (replaced by Redis key + `otel:destinations`) — no code rename needed; only stale in docs. +- **Collision watch confirmed benign:** `CANYONOS_REDIS_HOST/PORT` is also written by + `cli/canyonos/dashboard_stack.py`, but that targets the dashboard compose while + `init.py` targets the GC container — different processes, no single reader of both. +- **Verification:** `uv run pytest -q` = **223 passed, 3 subtests passed, 0 failed.** +- **Still `VENTIS_*` on purpose:** only the porting-skill docs (`SKILL.md`, + `runtime-contract.md`, `validate.py` message strings) and `OTLP_Exporter/DESIGN.md` + — deferred to the skill/docs phase. + +### Resource-name + header + validator-key + cosmetic phases ✅ DONE (2026-09-05) + +**Executed (hard cutover; every producer + consumer moved together):** +- **Resource prefixes `ventis-*` → `canyonos-*`:** core generators (both provider + `_runtime.py`, `global_controller.py` network/redis/container names, `cli.py` image + tags, `deploy.py`/`server.py` Flask app names, `env_file.py` `/tmp/canyonos-env-`, + `redis_utils.py` `__canyonos_redis_healthcheck__`) **and** the user-CLI consumer + `cli/canyonos/verify.py` (`RUNTIME_PREFIX`, image name) + all resource-name tests. +- **Future-ID header:** injector (`proxy.py`) and reader (`hooks.py`) both standardized + to exactly `X-Canyonos-Future-ID` (fixed the old `-ID`/`-Id` casing split), plus + `_inject_canyonos_headers`. +- **Validator capability key + framework-import check:** `caps["canyonos_core"]` / + `capabilities.canyonos_core` / `name == "canyonos_core"` aligned across + `validate.py` (emitter), `cli/canyonos/verify.py` (consumer), and + `test_canyonos_test.py`. +- **Docs/prose/cosmetic:** brand sweep `Ventis`→`CanyonOS`, `ventis`→`canyonos` across + READMEs, porting-skill docs (incl. remaining `VENTIS_*`→`CANYONOS_*`), `DESIGN.md`, + example configs/comments, `run_tests.sh` (`canyonos_test`), `.gitignore` + (`.canyonos-tests/`), and code comments/log strings; stale `ventis_context.py` doc + ref → `canyonos_context.py`; `VentisContextTests` → `CanyonosContextTests`. +- **Verification:** `py_compile` all tracked `.py` OK; header injector/reader agree; + `verify.py` prefix agrees with core generators; capability key aligned; `uv.lock` + has zero `ventis`; **`uv run pytest -q` = 223 passed, 3 subtests passed, 0 failed.** + +**Intentionally still `ventis` (operator decision):** only the EC2 SSH key default +`~/.ssh/ventis_ec2` (2 code lines + EC2 README + example config). Logo/URL were +changed by the operator directly. **No other `ventis` token remains anywhere in the +tracked tree.** + +#### Porting-skill semantic caveat +The token sweep updated the skill's identifiers, but `SKILL.md` / +`runtime-contract.md` still *describe* the old names as a "compatibility protocol that +remains" — which is no longer true under the hard cutover. A follow-up semantic pass +should rewrite that framing (out of scope for a pure rename). + +--- + +#### Original Phase 1 intent (for reference) + +- Create the chosen distinct runtime distribution/import package. +- Update packaging, Ty paths, internal imports, process module paths, and root + Dockerfile; regenerate `uv.lock`. +- If backward compatibility is promised, ship a minimal old import/command shim + that delegates to the new runtime and warns. +- Do not let root and `cli/` both own `canyonos`. + +**Gate:** isolated wheel installs prove the CLI and runtime packages can coexist; +the `canyonos` executable resolves to the standalone CLI; both new and promised +legacy imports behave as specified. + +### Phase 2 — migrate generated runtime artifacts + +- Update generator source/destination paths, flat context module, copied proxy + package, local-controller process invocation, Dockerfile env, and validator + collision rules as one unit. +- Rebuild all generated contexts from scratch; never reuse old output. + +**Gate:** generated agent and workflow contexts contain the intended package/env +names, import their controller/proxy, load an example agent, and boot in Docker. + +### Phase 3 — migrate protocol identifiers compatibly + +- Add new-first/old-fallback env reads and header reads. +- Change producers, including CLI `init.py` and generated Dockerfiles. +- Version the validator capability JSON transition and update CLI verification. +- Publish the updated remote porting skill and test an empty cache. + +**Gate:** old CLI/new image and new CLI/old image combinations either work within +the declared matrix or fail early with a precise version error; telemetry +attribution remains intact. + +### Phase 4 — migrate operational resource names + +- Change Local/EC2 image, container, network, runtime ID, Redis container, AWS tag, + healthcheck, and remote env-file names. +- Update GC cleanup and CLI verification together, recognizing both generations + for the compatibility release. +- Resolve the existing Local stale-cleanup mismatch. + +**Gate:** fresh local deploy, EC2 mocked/probe tests, upgrade teardown, verify, +clean, and rollback all leave no unexpected containers/networks/temp files. + +### Phase 5 — publish and switch + +- Build and inspect both wheels/sdists in clean environments. +- Build the GC image from the renamed runtime, pin a versioned tag/digest, smoke + `/status`, then update `GC_IMAGE`/release metadata. +- Run a representative end-to-end workflow through build, deploy, request, status, + telemetry, verify, stop, and quit. + +**Gate:** the published artifacts—not editable source installs—pass the full +matrix on a clean machine or clean VM. + +### Phase 6 — cosmetic cleanup and later compatibility removal + +- Update prose, help, examples, comments, ASCII/logo assets, and test names. +- After the announced compatibility period, remove shims/fallbacks and legacy + resource discovery in a major release. +- Rename/archive this migration document, recreate all generated state, and run + the final forbidden-token scan. + +## Acceptance matrix + +| Layer | Required proof | +|---|---| +| Static tree | No old token/path outside an explicit temporary compatibility allowlist | +| Lock/metadata | `uv lock` succeeds; wheel metadata has distinct owners/names | +| Clean installs | CLI and runtime wheels coexist; import paths and script owner are exact | +| Type/lint | Ruff and Ty pass with renamed include/exclude/unresolved-import paths | +| Unit tests | Protos generated deterministically; rename does not add failures | +| Generated output | Context contains new proxy/context/env names and no accidental stale package | +| Header telemetry | New header attributes correctly; legacy fallback works during transition | +| Env contract | New-wins precedence and every promised legacy fallback are tested | +| Local runtime | Image/network/Redis/container names agree with `canyonos verify` | +| EC2 runtime | Tags, image/container names, SSH fallback, and remote temp cleanup agree | +| Fresh deploy | Build → deploy → request → poll → telemetry → verify → teardown succeeds | +| Upgrade deploy | Old resources are detected/removed; no mixed-version silent failure | +| Rollback | Previous pinned image can be restored without deleting user DBs/keys/config | +| Remote skill | Fresh download/cache teaches and validates the new contract | +| Published image | New Docker entrypoint imports and `/status` answers from the released tag | + +Suggested final scans (the migration file and explicitly approved compatibility +shim are the only temporary exceptions): + +```bash +git grep -I -i -n ventis -- . ':!VENTIS_TO_CANYONOS_RENAME.md' +git ls-files | rg -i 'ventis' +rg --hidden --no-ignore -i ventis \ + -g '!.git/**' -g '!VENTIS_TO_CANYONOS_RENAME.md' +find . -path './.git' -prune -o -iname '*ventis*' -print +``` + +## Rollback rules + +- Never make `latest` the only rollback reference; retain the previous image + digest and compatibility matrix. +- Stop the deploy before changing resource prefixes. Do not run old and new GCs + against one Redis state concurrently. +- Preserve user config, `.env`, SSH keys, SQLite databases, and OTEL data. Rename + or copy user-owned files only on explicit user action. +- Keep cleanup exact and prefix-scoped; never broadly delete Docker or `/tmp` + state. +- If the new image fails, tear down only resources created by that attempt, + restore the previous pinned image, and use legacy env/header/resource support + until the failure is fixed. + +## Complete matching-file coverage + +The scan includes all old-name matches in these groups: + +- **Runtime (36):** the package Dockerfile; package README; exporter design/source; + package/controller initializers; root runtime CLI/server/stub generator; Local and + EC2 runtime/readme; deploy/future/global/instance/local controllers; env, + process-supervisor, Redis, session, and telemetry utilities; the LLM proxy README, + entrypoint, app/config/core/hooks/proxy, and all provider modules. +- **Tests (25):** `tests/README.md`, `run_tests.sh`, `test_canyonos_test.py`, + `test_cli.py`, `test_deploy.py`, `test_deploy_progress.py`, + `test_error_propagation.py`, `test_future.py`, every `test_global_controller_*`, + `test_gpu_metrics.py`, `test_instance_manager_runtime.py`, both + `test_local_controller_*`, both exporter tests, Redis/EC2/session/stub/telemetry + tests, and `test_ventis_context.py`. +- **Examples (15):** `examples/helloworld/README.md`; finance agent/config/workflow; + helloworld config/workflow; portfolio advisor/intent/metrics agents plus + config/workflow; text2sql generator/vLLM agents plus config/workflow. +- **Porting skill (7):** `SKILL.md`, `validate.py`, and the EC2, LLM proxy, + packaging, runtime-contract, and troubleshooting references. +- **Standalone CLI (7):** `cli/ARCHITECTURE.md`, `cli/cli.py`, and CanyonOS + dashboard compose, deploy, GC, init, and verify modules. +- **Root (4):** `.gitignore`, `README.md`, `pyproject.toml`, and `uv.lock`. + +No additional `setup.py`, `setup.cfg`, package manifest, Dockerfile, or compose +file contains the old token. `requirements.txt` has no project-name match. Binary +inspection found no embedded old token in the tracked SQLite/JPEG/PNG assets; the +PNG still requires a filename/reference rename because its basename is branded. diff --git a/ventis/Dockerfile b/canyonos_core/Dockerfile similarity index 57% rename from ventis/Dockerfile rename to canyonos_core/Dockerfile index 1814548..0edcbe1 100644 --- a/ventis/Dockerfile +++ b/canyonos_core/Dockerfile @@ -2,19 +2,19 @@ FROM python:3.11-slim RUN apt-get update && apt-get install -y docker.io && rm -rf /var/lib/apt/lists/* -COPY . /ventis -RUN pip install /ventis +COPY . /src +RUN pip install /src # global_controller.py bare-imports these; pip install only ships the .proto source. RUN python -m grpc_tools.protoc \ - -I/ventis/ventis/controller/proto \ + -I/src/canyonos_core/controller/proto \ --python_out=/usr/local/lib/python3.11/site-packages \ --grpc_python_out=/usr/local/lib/python3.11/site-packages \ - /ventis/ventis/controller/proto/local_controler.proto + /src/canyonos_core/controller/proto/local_controler.proto EXPOSE 8000 -ENTRYPOINT ["python", "-m", "ventis.server"] +ENTRYPOINT ["python", "-m", "canyonos_core.server"] -# to run: docker build -f ventis/Dockerfile -t saakeths/canyonos:latest . +# to run: docker build -f canyonos_core/Dockerfile -t saakeths/canyonos:latest . diff --git a/ventis/FUTURE_SCHEMA.md b/canyonos_core/FUTURE_SCHEMA.md similarity index 100% rename from ventis/FUTURE_SCHEMA.md rename to canyonos_core/FUTURE_SCHEMA.md diff --git a/ventis/OTLP_Exporter/DESIGN.md b/canyonos_core/OTLP_Exporter/DESIGN.md similarity index 92% rename from ventis/OTLP_Exporter/DESIGN.md rename to canyonos_core/OTLP_Exporter/DESIGN.md index ec2957d..15f0163 100644 --- a/ventis/OTLP_Exporter/DESIGN.md +++ b/canyonos_core/OTLP_Exporter/DESIGN.md @@ -1,4 +1,4 @@ -# OTLP Exporter for Ventis GlobalController — Design +# OTLP Exporter for CanyonOS GlobalController — Design Status: **implemented (single-table design; multi-destination fan-out in progress)**. `GlobalController` writes futures into a `waiting` table (SQLite); a GC-supervised, GC-restarted OTel Exporter process reads @@ -6,11 +6,11 @@ finished/unsent rows, converts each to an OTel span, and hands it to a real `BatchSpanProcessor`/`OTLPSpanExporter`. Batching, serialization, and sending are all OTel SDK code — the only custom pieces are the row→span conversion and durable sent-tracking. This doc is a design/rationale reference; the actual files -(`otel_exporter.py`, `db.py`, `convert.py`, `ventis/controller/utils/process_supervisor.py`) +(`otel_exporter.py`, `db.py`, `convert.py`, `canyonos/controller/utils/process_supervisor.py`) are the source of truth for current behavior. ## Context -Ventis futures need to reach an external OTLP-compatible tracing backend. Design: a +CanyonOS futures need to reach an external OTLP-compatible tracing backend. Design: a separate OTLP Exporter process, spawned and supervised by GlobalController, that reads unsent finished future rows from a local SQLite DB, converts them into OTel spans, and hands them to the OTel SDK's own batching/export machinery, which ships them to an @@ -20,7 +20,7 @@ service). Decisions (final status): - **Process model**: a true separate OS process, spawned and supervised by GlobalController (not an in-process thread) — via `ProcessSupervisor` - (`ventis/controller/utils/process_supervisor.py`, built): `register`/`start_all` to + (`canyonos/controller/utils/process_supervisor.py`, built): `register`/`start_all` to spawn, `check_and_respawn` (called from GC's existing poll tick, guarded on `self.running` to avoid a shutdown race) to restart it if it ever dies unexpectedly, `terminate_all` (called from GC's `stop()`) to shut it down cleanly. Rationale: fault @@ -29,7 +29,7 @@ Decisions (final status): - **Config**: implemented via a new `otel:` section in `global_controller.yaml` holding a `destinations` list, *not* by making `otel_exporter.py` itself config-aware. `GlobalController` serializes that list to JSON and passes it to the - exporter subprocess as a single `VENTIS_OTEL_DESTINATIONS` env var via + exporter subprocess as a single `CANYONOS_OTEL_DESTINATIONS` env var via `ProcessSupervisor.register(..., env=...)`. The exporter builds one independent exporter/`BatchSpanProcessor` pair per destination, picking the gRPC vs HTTP exporter class from each destination's `protocol` field. gRPC and HTTP destinations @@ -44,7 +44,7 @@ Decisions (final status): subprocess entirely. Configuration is read at exporter startup; changing it requires a GlobalController/exporter restart. - **Data source**: NOT `runtime_information` — a dedicated `waiting` table in its own - SQLite file (`ventis/OTLP_Exporter/otel_queue.db`, see `db.py`), written by GC's existing + SQLite file (`canyonos/OTLP_Exporter/otel_queue.db`, see `db.py`), written by GC's existing `_poll_controllers` *alongside* (not instead of) the existing `send_runtime_information` write. Keeps this pipeline's schema/state fully decoupled from the dashboard/cost table. @@ -77,7 +77,7 @@ otel: Authorization: Basic ${LANGFUSE_OTLP_HEADERS} # deployer pre-encodes public:secret ``` `GlobalController._otel_exporter_env()` translates the `destinations` list into -`VENTIS_OTEL_DESTINATIONS` and hands it to `ProcessSupervisor.register( +`CANYONOS_OTEL_DESTINATIONS` and hands it to `ProcessSupervisor.register( "otel_exporter", ..., env=...)`, which supports an `env` param (merged on top of the parent process's own environment, not a replacement). If `otel.destinations` is absent, `_otel_exporter_env()` returns `None` and `GlobalController.__init__` skips @@ -85,7 +85,7 @@ registering the exporter subprocess entirely, logging that no OTel metrics collection will happen. No shape validation is duplicated on the GlobalController side (deliberately: keep this side simple, `otel_exporter.py` itself validates destination shape at subprocess startup, -and raises if invoked directly without `VENTIS_OTEL_DESTINATIONS` set). +and raises if invoked directly without `CANYONOS_OTEL_DESTINATIONS` set). `otel_exporter.py` parses the destination configuration at startup and constructs the appropriate OTLP exporter for each entry (gRPC or HTTP), passing that destination's @@ -94,7 +94,7 @@ endpoint and headers to the SDK. `BatchSpanProcessor(..., schedule_delay_millis= `max_export_batch_size` is left at the SDK default (512), which already approximates the original "500 spans" batching ask without any override needed. -### 2. `ventis/OTLP_Exporter/otel_exporter.py` +### 2. `canyonos/OTLP_Exporter/otel_exporter.py` A plain loop, polling every `POLL_INTERVAL_SECONDS` (5s, checked every 1s so SIGTERM stays responsive), calling `_send_pending()` each tick. At startup it constructs one independent OTLP exporter and `BatchSpanProcessor` for each configured destination; @@ -110,7 +110,7 @@ each pair may use a different protocol, endpoint, and headers: since spans are hand-built and handed straight to the processors via `on_end()`. - Every processor is shut down on exit, flushing its pending batch independently. -### 3. Future row → OTel span conversion (`ventis/OTLP_Exporter/convert.py`) +### 3. Future row → OTel span conversion (`canyonos/OTLP_Exporter/convert.py`) `future_id` maps to OTel `span_id`, not `trace_id` — `session_id` (== `request_id`) is the one that maps to `trace_id`. Both are `uuid4().hex` (32 hex chars / 16 bytes); OTel `trace_id` is 128-bit (16 bytes, fits directly) and `span_id` is 64-bit (8 bytes, needs @@ -135,13 +135,13 @@ exported under Langfuse's documented `langfuse.observation.input`/ `langfuse.observation.output` attributes. The span name is the stable logical `service.method`, not the executing instance's UUID. `cpu`/`gpu`/ `execution_time_ms`/`queue_time_ms`/`token_count` keep plain names deliberately: none of -them have an OTel GenAI equivalent (cpu/gpu/queue-time are Ventis infra concepts, and +them have an OTel GenAI equivalent (cpu/gpu/queue-time are CanyonOS infra concepts, and `token_count`, an input+output sum, isn't part of the spec at all — inventing a `gen_ai.*`-shaped name for any of these would fabricate a standard rather than follow one. `cached_tokens`/`cache_hit_ratio` exist on the `waiting` row but aren't exported to attributes at all yet — a separate, pre-existing gap, not touched here. -### 4. Process supervisor — `ventis/controller/utils/process_supervisor.py` (built) +### 4. Process supervisor — `canyonos/controller/utils/process_supervisor.py` (built) `ProcessSupervisor`: `register(name, argv, env=None)` declares a process spec (`env`, when given, is merged on top of — not a replacement for — the parent's own environment); `start_all()` spawns everything registered; `check_and_respawn()` restarts anything that @@ -152,7 +152,7 @@ process (all `.terminate()` calls first, then `.wait()` on each, falling back to `.kill()`), called from GC's `stop()`. Adding a future second daemon is one more `register()` call — no new spawn/monitor/terminate code needed. -### 5. Poll/cleanup race fix (`ventis/controller/global_controller.py`) +### 5. Poll/cleanup race fix (`canyonos/controller/global_controller.py`) GC's cleanup thread used to run on its own `cleanup_interval` timer (default 10s), fully independent of the poll loop's `poll_interval` (default 5s) that writes futures into `waiting`. On a fast-completing request, cleanup could delete a session's Redis @@ -185,7 +185,7 @@ config work, since `protocol: http` now needs that package importable). is added. - `waiting` grows unboundedly: sent rows are never pruned, and futures that never finish (`finished_at` never arrives) also stay forever, invisible and un-expiring. -- `error_name` is always `NULL` — Ventis's own Redis writer never records a distinct +- `error_name` is always `NULL` — CanyonOS's own Redis writer never records a distinct exception-type field, only a message string. - Test coverage is still limited; the waiting-field migration/normalization/conversion path is covered, but the exporter process and live OTLP delivery are not. diff --git a/ventis/OTLP_Exporter/SCHEMA.md b/canyonos_core/OTLP_Exporter/SCHEMA.md similarity index 100% rename from ventis/OTLP_Exporter/SCHEMA.md rename to canyonos_core/OTLP_Exporter/SCHEMA.md diff --git a/ventis/OTLP_Exporter/__init__.py b/canyonos_core/OTLP_Exporter/__init__.py similarity index 100% rename from ventis/OTLP_Exporter/__init__.py rename to canyonos_core/OTLP_Exporter/__init__.py diff --git a/ventis/OTLP_Exporter/convert.py b/canyonos_core/OTLP_Exporter/convert.py similarity index 95% rename from ventis/OTLP_Exporter/convert.py rename to canyonos_core/OTLP_Exporter/convert.py index 72e2342..fc749c8 100644 --- a/ventis/OTLP_Exporter/convert.py +++ b/canyonos_core/OTLP_Exporter/convert.py @@ -30,7 +30,7 @@ def waiting_row_to_span(row): trace_id = int(row["session_id"], 16) # future_id/parent_id are already 64-bit (Future.id is generated at that - # width directly -- see ventis/controller/future.py), matching OTel's + # width directly -- see canyonos/controller/future.py), matching OTel's # span_id, so no truncation is needed here. span_id = int(row["future_id"], 16) parent_id = row.get("parent_id") @@ -64,7 +64,7 @@ def waiting_row_to_span(row): # Model, token, agent, and cache-read usage use OTel GenAI semantic-convention # names (see https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/). - # total_cost uses gen_ai.usage.cost. The remaining Ventis-specific values (project_id, server/token cost + # total_cost uses gen_ai.usage.cost. The remaining CanyonOS-specific values (project_id, server/token cost # breakdown, cache_hit_ratio) have no GenAI or Langfuse equivalent, so they keep # plain names. attributes = { diff --git a/ventis/OTLP_Exporter/db.py b/canyonos_core/OTLP_Exporter/db.py similarity index 99% rename from ventis/OTLP_Exporter/db.py rename to canyonos_core/OTLP_Exporter/db.py index f1438f7..16c02ae 100644 --- a/ventis/OTLP_Exporter/db.py +++ b/canyonos_core/OTLP_Exporter/db.py @@ -12,7 +12,7 @@ import os import sqlite3 -from ventis.controller.utils import pricing +from canyonos_core.controller.utils import pricing # Will need to eventually delete dependency on this and move to OTLP # It is currently stored here for backcompat with the old telemetry collecting diff --git a/ventis/OTLP_Exporter/otel_exporter.py b/canyonos_core/OTLP_Exporter/otel_exporter.py similarity index 99% rename from ventis/OTLP_Exporter/otel_exporter.py rename to canyonos_core/OTLP_Exporter/otel_exporter.py index 1ed210a..2d1cab1 100644 --- a/ventis/OTLP_Exporter/otel_exporter.py +++ b/canyonos_core/OTLP_Exporter/otel_exporter.py @@ -20,7 +20,7 @@ import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from ventis.controller.utils.redis_client import RedisClient +from canyonos_core.controller.utils.redis_client import RedisClient from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( OTLPSpanExporter as GrpcOTLPSpanExporter, diff --git a/ventis/OTLP_Exporter/otel_queue.db b/canyonos_core/OTLP_Exporter/otel_queue.db similarity index 100% rename from ventis/OTLP_Exporter/otel_queue.db rename to canyonos_core/OTLP_Exporter/otel_queue.db diff --git a/ventis/README.md b/canyonos_core/README.md similarity index 100% rename from ventis/README.md rename to canyonos_core/README.md diff --git a/canyonos_core/__init__.py b/canyonos_core/__init__.py new file mode 100644 index 0000000..186efdf --- /dev/null +++ b/canyonos_core/__init__.py @@ -0,0 +1,2 @@ +# CanyonOS - Distributed Agent Framework +__version__ = "0.1.0" diff --git a/ventis/cli.py b/canyonos_core/cli.py similarity index 92% rename from ventis/cli.py rename to canyonos_core/cli.py index c66a106..2064c36 100644 --- a/ventis/cli.py +++ b/canyonos_core/cli.py @@ -1,9 +1,9 @@ """ -Ventis CLI +CanyonOS CLI -Entry point for the `ventis` command. Provides these subcommands: - ventis new-project — Scaffold a new Ventis project - ventis deploy — Build (stubs + Docker images) then launch +Entry point for the `canyonos` command. Provides these subcommands: + canyonos new-project — Scaffold a new CanyonOS project + canyonos deploy — Build (stubs + Docker images) then launch agents via the Global Controller """ @@ -16,10 +16,10 @@ import subprocess import sys -from ventis.controller.utils.env_file import resolve_env_file +from canyonos_core.controller.utils.env_file import resolve_env_file logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("ventis") +logger = logging.getLogger("canyonos_core") DEFAULT_DOCKER_PLATFORM = "linux/amd64" ARTIFACT_DIR_NAME = ".car" SOURCE_DIR_NAME = "app" @@ -42,7 +42,7 @@ def _get_templates_dir(): def _get_package_dir(): - """Return the absolute path to the ventis package directory.""" + """Return the absolute path to the canyonos package directory.""" return os.path.dirname(os.path.abspath(__file__)) @@ -74,7 +74,7 @@ def _normalize_requirements(agent_cfg): def _docker_platform(): """Return the target Docker platform for portable runtime images.""" - return os.environ.get("VENTIS_DOCKER_PLATFORM", DEFAULT_DOCKER_PLATFORM) + return os.environ.get("CANYONOS_DOCKER_PLATFORM", DEFAULT_DOCKER_PLATFORM) def _docker_build_cmd(*args): @@ -115,7 +115,7 @@ def _write_bake_file(bake_targets, bake_file_path, platform): "platforms": [platform], "output": ["type=docker"], # type=docker could be changed to tarring it up, which would be - # faster but skipped because that change would alter ventis deploy + # faster but skipped because that change would alter canyonos deploy } for target in bake_targets } @@ -129,7 +129,7 @@ def _require_docker_for_ec2(command_name): if _docker_available(): return raise RuntimeError( - f"EC2-backed `ventis {command_name}` requires local Docker, but Docker is unavailable " + f"EC2-backed `canyonos {command_name}` requires local Docker, but Docker is unavailable " "or unreachable." ) @@ -145,7 +145,7 @@ def _ensure_grpc_stubs_importable(project_dir): except ImportError as exc: raise RuntimeError( "Deploy failed: generated grpc_stubs are missing or not importable. " - "Run `ventis build` on this host first." + "Run `canyonos build` on this host first." ) from exc @@ -162,12 +162,12 @@ def _preflight_ec2_deploy(config, project_dir): # ------------------------------------------------------------------ # -# ventis new-project # +# canyonos new-project # # ------------------------------------------------------------------ # def cmd_new_project(args): - """Scaffold a new Ventis project.""" + """Scaffold a new CanyonOS project.""" project_name = args.name project_dir = os.path.abspath(project_name) @@ -206,14 +206,14 @@ def cmd_new_project(args): os.makedirs(os.path.join(artifact_root, "stubs"), exist_ok=True) os.makedirs(os.path.join(artifact_root, "grpc_stubs"), exist_ok=True) - logger.info("Created new Ventis project: %s", project_dir) + logger.info("Created new CanyonOS project: %s", project_dir) logger.info("") logger.info(" cd %s", project_name) - logger.info(" ventis deploy") + logger.info(" canyonos deploy") # ------------------------------------------------------------------ # -# ventis build # +# canyonos build # # ------------------------------------------------------------------ # @@ -223,7 +223,7 @@ def _run_build(config_path): and build Docker images. Must be run from the project root (where config/ lives). Invoked as the - first phase of `ventis deploy`. + first phase of `canyonos deploy`. """ if not os.path.isfile(config_path): logger.error("Config file not found: %s", config_path) @@ -244,7 +244,7 @@ def _run_build(config_path): stubs_dir = os.path.join(artifact_root, "stubs") os.makedirs(stubs_dir, exist_ok=True) - from ventis.stub_generator import ( + from canyonos_core.stub_generator import ( generate_stub, generate_docker, generate_workflow_docker, @@ -397,7 +397,7 @@ def _run_build(config_path): { "name": agent_name.lower(), "context": docker_context, - "image_name": f"ventis-{agent_name.lower()}", + "image_name": f"canyonos-{agent_name.lower()}", } ) @@ -436,7 +436,7 @@ def _run_build(config_path): # ------------------------------------------------------------------ # -# ventis deploy # +# canyonos deploy # # ------------------------------------------------------------------ # @@ -454,7 +454,7 @@ def cmd_deploy(args): sys.exit(1) # Build first (stubs, protos, Docker contexts, images), then deploy them. - # `ventis build` was merged into `ventis deploy`. + # `canyonos build` was merged into `canyonos deploy`. _run_build(config_path) config = _load_config(config_path) @@ -479,7 +479,7 @@ def cmd_deploy(args): ): _preflight_ec2_deploy(config, artifact_root) - from ventis.controller.global_controller import GlobalController + from canyonos_core.controller.global_controller import GlobalController controller = GlobalController(config_path) @@ -510,7 +510,7 @@ def _reload_handler(sig, frame): # ------------------------------------------------------------------ # -# ventis clean # +# canyonos clean # # ------------------------------------------------------------------ # @@ -551,20 +551,20 @@ def main(): _artifact_prefix(os.getcwd()), "config", "global_controller.yaml" ) parser = argparse.ArgumentParser( - prog="ventis", - description="Ventis — Distributed Agent Orchestration Framework", + prog="canyonos_core", + description="CanyonOS — Distributed Agent Orchestration Framework", ) subparsers = parser.add_subparsers(dest="command", help="Available commands") - # ventis new-project + # canyonos new-project new_proj = subparsers.add_parser( "new-project", - help="Scaffold a new Ventis project", + help="Scaffold a new CanyonOS project", ) new_proj.add_argument("name", help="Name of the project directory to create") new_proj.set_defaults(func=cmd_new_project) - # ventis deploy + # canyonos deploy deploy = subparsers.add_parser( "deploy", help="Build stubs/images, then launch agents via the Global Controller", @@ -577,7 +577,7 @@ def main(): ) deploy.set_defaults(func=cmd_deploy) - # ventis clean + # canyonos clean clean = subparsers.add_parser( "clean", help="Remove generated stubs, compiled protos, and Docker contexts", diff --git a/canyonos_core/controller/__init__.py b/canyonos_core/controller/__init__.py new file mode 100644 index 0000000..948e344 --- /dev/null +++ b/canyonos_core/controller/__init__.py @@ -0,0 +1 @@ +# CanyonOS Controller Sub-Package diff --git a/ventis/controller/ventis_context.py b/canyonos_core/controller/canyonos_context.py similarity index 100% rename from ventis/controller/ventis_context.py rename to canyonos_core/controller/canyonos_context.py diff --git a/ventis/controller/cloud_provider_logic/EC2/README.md b/canyonos_core/controller/cloud_provider_logic/EC2/README.md similarity index 91% rename from ventis/controller/cloud_provider_logic/EC2/README.md rename to canyonos_core/controller/cloud_provider_logic/EC2/README.md index fdec0ba..ea9ee2b 100644 --- a/ventis/controller/cloud_provider_logic/EC2/README.md +++ b/canyonos_core/controller/cloud_provider_logic/EC2/README.md @@ -9,7 +9,7 @@ For global controller - Instance with all of the following installed: - Both things in local controller - Python 3.10+ - - Ventis folder + - CanyonOS folder - pip requirements installed in env - pip install -e . --break-system-packages - Private key labeled as ventis_ec2 inside ~/.ssh @@ -35,9 +35,9 @@ Steps: 3. Change the agents/configs/workflow folders to suit your needs -4. Run ventis build + ventis deploy +4. Run canyonos build + canyonos deploy -For cleanup, use ventis clean to clean stubs/containers +For cleanup, use canyonos clean to clean stubs/containers If encountering permission errors with the keys, run this to give key more permissions if blocked chmod 700 ~/.ssh diff --git a/ventis/controller/cloud_provider_logic/EC2/_runtime.py b/canyonos_core/controller/cloud_provider_logic/EC2/_runtime.py similarity index 89% rename from ventis/controller/cloud_provider_logic/EC2/_runtime.py rename to canyonos_core/controller/cloud_provider_logic/EC2/_runtime.py index ce68744..6d59151 100644 --- a/ventis/controller/cloud_provider_logic/EC2/_runtime.py +++ b/canyonos_core/controller/cloud_provider_logic/EC2/_runtime.py @@ -1,5 +1,5 @@ """ -EC2 runtime helpers for Ventis. +EC2 runtime helpers for CanyonOS. This module is the EC2-specific backend for `provider: EC2` agents. It does four things: @@ -23,9 +23,9 @@ import boto3 -from ventis.controller.utils.env_file import env_file_args -from ventis.controller.utils.redis_utils import _wait_for_redis -from ventis.controller.utils.redis_client import RedisClient +from canyonos_core.controller.utils.env_file import env_file_args +from canyonos_core.controller.utils.redis_utils import _wait_for_redis +from canyonos_core.controller.utils.redis_client import RedisClient logger = logging.getLogger(__name__) @@ -87,7 +87,7 @@ def provision_instance(spec, replica_index, next_host_port=None): { "ResourceType": "instance", "Tags": [ - {"Key": "Name", "Value": f"ventis-{agent_name}-{replica_index}"}, + {"Key": "Name", "Value": f"canyonos-{agent_name}-{replica_index}"}, {"Key": "CreatedBy", "Value": "EC2 Fast Launch"}, ], }, @@ -105,7 +105,7 @@ def provision_instance(spec, replica_index, next_host_port=None): response = client.run_instances(**request) instance_id = response["Instances"][0]["InstanceId"] - runtime_id = f"ventis-ec2-{agent_name.lower()}-{replica_index}--{instance_id}" + runtime_id = f"canyonos-ec2-{agent_name.lower()}-{replica_index}--{instance_id}" client.get_waiter("instance_running").wait(InstanceIds=[instance_id]) deadline = time.time() + cfg.get("public_ip_timeout", 120) @@ -208,7 +208,7 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, else: raise TimeoutError(f"SSH never became ready on {host}") - redis_container = f"ventis-redis-{host.replace('.', '-')}" + redis_container = f"canyonos-redis-{host.replace('.', '-')}" result = _controller._run_cmd( [ "docker", @@ -238,8 +238,8 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, node_redis.set(f"agent:{agent_id}:instance_type", spec["instance_type"]) agent_name = spec["name"] - image = f"ventis-{agent_name.lower()}" - container_name = f"ventis-ec2-{agent_name.lower()}-{replica_index}" + image = f"canyonos-{agent_name.lower()}" + container_name = f"canyonos-ec2-{agent_name.lower()}-{replica_index}" key = _ssh_key_path(cfg) port_args = ["-p", f"{CONTAINER_PORT}:{CONTAINER_PORT}"] if spec.get("type") == "workflow": @@ -279,27 +279,32 @@ def _bootstrap_instance(host, spec, replica_index, cfg, redis_host, redis_port, container_name, *port_args, "-e", - f"VENTIS_REDIS_HOST={redis_host}", + f"CANYONOS_REDIS_HOST={redis_host}", "-e", - f"VENTIS_REDIS_PORT={redis_port}", + f"CANYONOS_REDIS_PORT={redis_port}", "-e", - f"VENTIS_AGENT_HOST={host}", + f"CANYONOS_AGENT_HOST={host}", "-e", - f"VENTIS_AGENT_PORT={CONTAINER_PORT}", + f"CANYONOS_AGENT_PORT={CONTAINER_PORT}", "-e", - f"VENTIS_POLL_INTERVAL={_controller.config.get('poll_interval', 5)}", + f"CANYONOS_POLL_INTERVAL={_controller.config.get('poll_interval', 5)}", # Route the agent's boto3 Bedrock calls through the in-container LLM # proxy (started by LocalController) so token/cost telemetry is captured. "-e", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", + # The LLM stub is a local-only `canyonos test` control; it must never be + # active on EC2. Pin it empty explicitly so a user's --env-file cannot + # turn it on (docker: -e beats --env-file). + "-e", + "CANYONOS_LLM_STUB_TEXT=", ] if spec.get("type") == "workflow": db_url = _controller.config.get("database", {}).get("url") project_id = _controller.config.get("project_id") if db_url: - cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) + cmd.extend(["-e", f"CANYONOS_DATABASE_URL={db_url}"]) if project_id: - cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) + cmd.extend(["-e", f"CANYONOS_PROJECT_ID={project_id}"]) # User secrets from `env_file`. Explicit -e flags above still win over # anything in the file. diff --git a/ventis/controller/cloud_provider_logic/Local/_runtime.py b/canyonos_core/controller/cloud_provider_logic/Local/_runtime.py similarity index 78% rename from ventis/controller/cloud_provider_logic/Local/_runtime.py rename to canyonos_core/controller/cloud_provider_logic/Local/_runtime.py index 6329311..56e41f3 100644 --- a/ventis/controller/cloud_provider_logic/Local/_runtime.py +++ b/canyonos_core/controller/cloud_provider_logic/Local/_runtime.py @@ -1,5 +1,5 @@ """ -Local runtime helpers for Ventis. +Local runtime helpers for CanyonOS. This module is the local-provider backend for `provider: local` agents. It keeps the existing Docker launch/teardown behavior while letting @@ -7,8 +7,9 @@ """ import logging +import os -from ventis.controller.utils.env_file import env_file_args +from canyonos_core.controller.utils.env_file import env_file_args logger = logging.getLogger(__name__) @@ -16,7 +17,7 @@ CONTAINER_PORT = 50051 PROVIDER = "local" MAX_PORT_ATTEMPTS = 50 -NETWORK = "ventis-local" +NETWORK = "canyonos-local" _controller = None @@ -43,8 +44,8 @@ def provision_instance(spec, replica_index, next_host_port): "provider": PROVIDER, "host": host, "host_port": host_port, - "redis_host": f"ventis-redis-{host.replace('.', '-')}", - "runtime_id": f"ventis-{PROVIDER}-{agent_name.lower()}-{replica_index}", + "redis_host": f"canyonos-redis-{host.replace('.', '-')}", + "runtime_id": f"canyonos-{PROVIDER}-{agent_name.lower()}-{replica_index}", "user": spec.get("user"), } @@ -53,7 +54,7 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): agent_name = spec["name"] resources = spec.get("resources", {}) ctrl_type = spec.get("type", "agent") - image = f"ventis-{agent_name.lower()}" + image = f"canyonos-{agent_name.lower()}" host = provisioned["host"] host_port = provisioned["host_port"] user = provisioned.get("user") @@ -84,29 +85,42 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): "-p", f"{host_port}:{CONTAINER_PORT}", "-e", - f"VENTIS_AGENT_PORT={CONTAINER_PORT}", + f"CANYONOS_AGENT_PORT={CONTAINER_PORT}", "-e", - f"VENTIS_AGENT_HOST={runtime_id}", + f"CANYONOS_AGENT_HOST={runtime_id}", "-e", - f"VENTIS_REDIS_HOST={redis_host}", + f"CANYONOS_REDIS_HOST={redis_host}", "-e", - f"VENTIS_REDIS_PORT={spec.get('redis_port', 6379)}", + f"CANYONOS_REDIS_PORT={spec.get('redis_port', 6379)}", "-e", - f"VENTIS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", + f"CANYONOS_POLL_INTERVAL={_require_controller().config.get('poll_interval', 5)}", # Route the agent's boto3 Bedrock calls through the in-container LLM # proxy (started by LocalController) so token/cost telemetry is captured. "-e", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", ] + + # LLM stub is a `canyonos test`-only control. `canyonos test` injects + # CANYONOS_LLM_STUB_TEXT into THIS controller's (GC container) env; a + # normal `canyonos deploy` never does (run_container only sets it from + # canyonos test's extra_env). Set it explicitly on every agent -- to that + # value, or empty -- so it ALWAYS wins over --env-file (docker: -e beats + # --env-file). A user's .env can therefore neither enable the stub nor + # change it; it is reachable only through `canyonos test`. + cmd.extend([ + "-e", + f"CANYONOS_LLM_STUB_TEXT={os.environ.get('CANYONOS_LLM_STUB_TEXT', '')}", + ]) + if ctrl_type == "workflow": cmd.extend(["-p", f"{spec.get('api_port', 8080)}:8080"]) config = _require_controller().config db_url = config.get("database", {}).get("url") project_id = config.get("project_id") if db_url: - cmd.extend(["-e", f"VENTIS_DATABASE_URL={db_url}"]) + cmd.extend(["-e", f"CANYONOS_DATABASE_URL={db_url}"]) if project_id: - cmd.extend(["-e", f"VENTIS_PROJECT_ID={project_id}"]) + cmd.extend(["-e", f"CANYONOS_PROJECT_ID={project_id}"]) if resources.get("cpu"): cmd.extend(["--cpus", str(resources["cpu"])]) if resources.get("memory"): @@ -115,7 +129,7 @@ def bootstrap_instance(provisioned, spec, replica_index, agent_id): cmd.extend(["--gpus", str(resources["gpu"])]) # User secrets from `env_file`. Explicit -e flags above still win, so a - # stray VENTIS_* line in someone's .env cannot break agent wiring. + # stray CANYONOS_* line in someone's .env cannot break agent wiring. with env_file_args( _require_controller(), host, user, runtime_id, _is_local_host(host) ) as env_args: diff --git a/ventis/controller/deploy.py b/canyonos_core/controller/deploy.py similarity index 93% rename from ventis/controller/deploy.py rename to canyonos_core/controller/deploy.py index 47de634..f480dc4 100644 --- a/ventis/controller/deploy.py +++ b/canyonos_core/controller/deploy.py @@ -1,25 +1,25 @@ """ -Ventis Deploy Module +CanyonOS Deploy Module Provides `deploy()` to expose a workflow function as an async REST API endpoint. Requests are assigned a unique ID and processed asynchronously. Results are stored in Redis and can be polled via GET /status/. Usage: - import ventis + import canyonos_core def my_workflow(query: str): finance = FinanceAgent() price = finance.get_stock_price(ticker=query) return {"price": price.value()} - ventis.deploy(my_workflow, port=8080) + canyonos_core.deploy(my_workflow, port=8080) """ try: - import ventis.controller.ventis_context as ventis_context + import canyonos_core.controller.canyonos_context as canyonos_context except ImportError: - import ventis_context + import canyonos_context import json import logging import os @@ -33,12 +33,12 @@ def my_workflow(query: str): # Try to import from absolute package (local install) or fallback to flat file (Docker container) try: - from ventis.controller.utils.redis_client import RedisClient + from canyonos_core.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient try: - from ventis.controller.utils.session_logging import get_session, upsert_session + from canyonos_core.controller.utils.session_logging import get_session, upsert_session except ImportError: from session_logging import get_session, upsert_session @@ -90,17 +90,17 @@ def deploy(workflow_fn, port=8080, host="0.0.0.0", redis_host=None, redis_port=N redis_host: Redis host (default: from env or localhost). redis_port: Redis port (default: from env or 6379). """ - redis_host = redis_host or os.environ.get("VENTIS_REDIS_HOST", "localhost") - redis_port = redis_port or int(os.environ.get("VENTIS_REDIS_PORT", 6379)) + redis_host = redis_host or os.environ.get("CANYONOS_REDIS_HOST", "localhost") + redis_port = redis_port or int(os.environ.get("CANYONOS_REDIS_PORT", 6379)) redis_client = RedisClient(host=redis_host, port=redis_port) # These are fallbacks only. _current_identity() reads the controller's # current Redis value for every session transition and status fallback. - env_db_url = os.environ.get("VENTIS_DATABASE_URL") - env_project_id = os.environ.get("VENTIS_PROJECT_ID") + env_db_url = os.environ.get("CANYONOS_DATABASE_URL") + env_project_id = os.environ.get("CANYONOS_PROJECT_ID") fn_name = workflow_fn.__name__ - app = Flask(f"ventis-{fn_name}") + app = Flask(f"canyonos-{fn_name}") def _expire_request_keys(request_id): """Let a finished request's Redis keys age out instead of living forever.""" @@ -151,7 +151,7 @@ def _execute_workflow(request_id, kwargs, context=None): redis_client.set(context_key, json.dumps(context)) # Set thread-local request ID so Futures spawned here carry it - ventis_context.set_request_id(request_id) + canyonos_context.set_request_id(request_id) result = workflow_fn(**kwargs) diff --git a/ventis/controller/future.py b/canyonos_core/controller/future.py similarity index 93% rename from ventis/controller/future.py rename to canyonos_core/controller/future.py index 04b050a..dbc5868 100644 --- a/ventis/controller/future.py +++ b/canyonos_core/controller/future.py @@ -8,12 +8,12 @@ import grpc try: - import ventis.controller.ventis_context as ventis_context + import canyonos_core.controller.canyonos_context as canyonos_context except ImportError: - import ventis_context + import canyonos_context try: - from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS + from canyonos_core.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS except ImportError: from grpc_options import GRPC_CHANNEL_OPTIONS @@ -23,7 +23,7 @@ sys.path.insert(0, os.path.abspath("grpc_stubs")) try: - from ventis.controller.utils.redis_client import RedisClient + from canyonos_core.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient import local_controler_pb2 @@ -35,13 +35,13 @@ # defines the future object which will be returned by each function call class Future(object): redis = RedisClient( - host=os.environ.get("VENTIS_REDIS_HOST", "localhost"), - port=int(os.environ.get("VENTIS_REDIS_PORT", 6379)), + host=os.environ.get("CANYONOS_REDIS_HOST", "localhost"), + port=int(os.environ.get("CANYONOS_REDIS_PORT", 6379)), ) # Single local controller connection, shared across all futures - _lc_host = os.environ.get("VENTIS_LC_HOST", "localhost") - _lc_port = os.environ.get("VENTIS_LC_PORT", "50051") + _lc_host = os.environ.get("CANYONOS_LC_HOST", "localhost") + _lc_port = os.environ.get("CANYONOS_LC_PORT", "50051") _channel = None _stub = None @@ -70,13 +70,13 @@ def __init__(self, parent, service, method, args=None): self.id = secrets.token_hex(8) # Grab the request_id from the thread-local context (set by deploy) - self.request_id = ventis_context.get_request_id() + self.request_id = canyonos_context.get_request_id() # this provides the funtionality we need to execute self.funtionality = None self.executor = None self.result = None - self.parent = ventis_context.get_current_future_id() + self.parent = canyonos_context.get_current_future_id() self.service = service self.method = method self.args = args or {} diff --git a/ventis/controller/global_controller.py b/canyonos_core/controller/global_controller.py similarity index 95% rename from ventis/controller/global_controller.py rename to canyonos_core/controller/global_controller.py index 4bf109a..5a330a1 100644 --- a/ventis/controller/global_controller.py +++ b/canyonos_core/controller/global_controller.py @@ -17,20 +17,20 @@ from concurrent.futures import ThreadPoolExecutor import yaml -from ventis.OTLP_Exporter import db as otel_db -from ventis.controller.instance_manager import InstanceManager -from ventis.controller.utils.agent_specs import write_agent_specs -from ventis.controller.utils.env_file import resolve_env_file -from ventis.controller.utils.process_supervisor import ProcessSupervisor -from ventis.controller.utils.redis_utils import _wait_for_redis -from ventis.controller.utils.telemetry_logging import ( +from canyonos_core.OTLP_Exporter import db as otel_db +from canyonos_core.controller.instance_manager import InstanceManager +from canyonos_core.controller.utils.agent_specs import write_agent_specs +from canyonos_core.controller.utils.env_file import resolve_env_file +from canyonos_core.controller.utils.process_supervisor import ProcessSupervisor +from canyonos_core.controller.utils.redis_utils import _wait_for_redis +from canyonos_core.controller.utils.telemetry_logging import ( assign_project_id, pull_runtime_information, send_runtime_information, send_agent_information, ) -from ventis.controller.utils.redis_client import RedisClient -from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS +from canyonos_core.controller.utils.redis_client import RedisClient +from canyonos_core.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS # Add generated grpc_stubs from the local project to the path. Projects using # the .car artifact layout keep grpc_stubs under .car/; older/plain layouts @@ -44,7 +44,14 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -LOCAL_NETWORK = "ventis-local" +LOCAL_NETWORK = "canyonos-local" + +# Internal runtime controls that must never be settable from a user's `.env`. +# The .env is for the user's own secrets (API keys, etc.); these keys steer +# framework behavior, so honoring them from user data would be a control-plane +# injection. CANYONOS_LLM_STUB_TEXT (the `canyonos test` LLM stub) is reachable +# only via `canyonos test`, never a deploy's env_file. +_RESERVED_ENV_KEYS = frozenset({"CANYONOS_LLM_STUB_TEXT"}) def _is_local_host(host): @@ -116,7 +123,7 @@ def __init__(self, config_path): self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self._cleanup_thread.start() - # Spawn the OTLP exporter as a separate process (see ventis/OTLP_Exporter/DESIGN.md), + # Spawn the OTLP exporter as a separate process (see canyonos/OTLP_Exporter/DESIGN.md), # supervised so it gets restarted if it ever exits unexpectedly. otel_exporter_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -162,8 +169,8 @@ def _cleanup_stale_containers(self): for i, (host, port) in enumerate(placements): if host not in host_containers: host_containers[host] = (user, set()) - host_containers[host][1].add(f"ventis-redis-{host.replace('.', '-')}") - host_containers[host][1].add(f"ventis-{name.lower()}-{i}") + host_containers[host][1].add(f"canyonos-redis-{host.replace('.', '-')}") + host_containers[host][1].add(f"canyonos-{name.lower()}-{i}") # Try to remove each one on its respective host for host, (user, container_names) in host_containers.items(): @@ -206,7 +213,7 @@ def _load_config(config_path): @staticmethod def _assign_new_project_id(config_path): """Generate a project_id and append it to the config file so it stays stable across reloads/restarts.""" - project_id = str(uuid.uuid4()) + project_id = uuid.uuid4().hex with open(config_path, "a") as f: f.write(f'project_id: "{project_id}"\n') return project_id @@ -226,6 +233,9 @@ def _load_dotenv(path): value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: value = value[1:-1] + if key in _RESERVED_ENV_KEYS: + # Reserved internal control -- never honor it from user .env. + continue if key and key not in os.environ: os.environ[key] = value @@ -393,10 +403,10 @@ def _launch_redis_containers(self): for host, node_cfg in nodes.items(): redis_port = node_cfg["redis_port"] user = node_cfg["user"] - container_name = f"ventis-redis-{host.replace('.', '-')}" - # VENTIS_REDIS_HOST overrides the localhost case for a containerized GC; host/remote paths unchanged. + container_name = f"canyonos-redis-{host.replace('.', '-')}" + # CANYONOS_REDIS_HOST overrides the localhost case for a containerized GC; host/remote paths unchanged. if host in ("localhost", "127.0.0.1"): - connect_host = os.environ.get("VENTIS_REDIS_HOST", "localhost") + connect_host = os.environ.get("CANYONOS_REDIS_HOST", "localhost") else: connect_host = host @@ -562,7 +572,7 @@ def _poll_controllers(self): self.process_supervisor.check_and_respawn() # Polled in parallel, one instance's slow Redis/Postgres round-trip no longer - # gates every other instance's poll -- see ventis/OTLP_Exporter/DESIGN.md. + # gates every other instance's poll -- see canyonos/OTLP_Exporter/DESIGN.md. instances = self.instance_manager.list_instances() if instances: with ThreadPoolExecutor(max_workers=len(instances)) as executor: @@ -919,7 +929,7 @@ def stop(self): import argparse - parser = argparse.ArgumentParser(description="Ventis Global Controller daemon.") + parser = argparse.ArgumentParser(description="CanyonOS Global Controller daemon.") parser.add_argument( "-c", "--config", diff --git a/ventis/controller/instance_manager.py b/canyonos_core/controller/instance_manager.py similarity index 97% rename from ventis/controller/instance_manager.py rename to canyonos_core/controller/instance_manager.py index 4117fd1..fa74b77 100644 --- a/ventis/controller/instance_manager.py +++ b/canyonos_core/controller/instance_manager.py @@ -3,7 +3,7 @@ This file decides whether each agent replica should run locally or on EC2, starts missing instances, records their runtime metadata in Redis, and -publishes the routing data other parts of Ventis use to reach those agents. +publishes the routing data other parts of CanyonOS use to reach those agents. """ import json @@ -11,7 +11,7 @@ import uuid from concurrent.futures import ThreadPoolExecutor, as_completed -from ventis.controller.cloud_provider_logic.Local import _runtime as local_runtime +from canyonos_core.controller.cloud_provider_logic.Local import _runtime as local_runtime DEFAULT_HOST_PORT_START = 8000 @@ -229,7 +229,7 @@ def _instance_id_from_record(self, instance): def _provider_runtime(self, provider): if provider.upper() == "EC2": - from ventis.controller.cloud_provider_logic.EC2 import _runtime as runtime + from canyonos_core.controller.cloud_provider_logic.EC2 import _runtime as runtime else: runtime = local_runtime runtime._controller = self.controller diff --git a/ventis/controller/local_controller.py b/canyonos_core/controller/local_controller.py similarity index 94% rename from ventis/controller/local_controller.py rename to canyonos_core/controller/local_controller.py index 1cc879f..a3406c2 100644 --- a/ventis/controller/local_controller.py +++ b/canyonos_core/controller/local_controller.py @@ -16,10 +16,10 @@ import psutil try: - from ventis.controller.local_controller_frontend import start_server - from ventis.controller.utils.gpu_metrics import read_gpu_percent - from ventis.controller.utils.redis_client import RedisClient - from ventis.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS + from canyonos_core.controller.local_controller_frontend import start_server + from canyonos_core.controller.utils.gpu_metrics import read_gpu_percent + from canyonos_core.controller.utils.redis_client import RedisClient + from canyonos_core.controller.utils.grpc_options import GRPC_CHANNEL_OPTIONS except ImportError: from gpu_metrics import read_gpu_percent from local_controller_frontend import start_server @@ -32,15 +32,15 @@ sys.path.insert(0, os.path.abspath("grpc_stubs")) try: - import ventis.controller.ventis_context as ventis_context + import canyonos_core.controller.canyonos_context as canyonos_context except ImportError: - import ventis_context + import canyonos_context -# Auto-inject X-Ventis-Future-ID into all boto3 Bedrock calls so the LLM proxy +# Auto-inject X-Canyonos-Future-ID into all boto3 Bedrock calls so the LLM proxy # can attribute token/cost telemetry to the executing future. Import for its # global boto3 event-hook side effect; safe no-op if the proxy isn't present. try: - from ventis.llm_proxy import proxy as _llm_proxy_autoinject # noqa: F401 + from canyonos_core.llm_proxy import proxy as _llm_proxy_autoinject # noqa: F401 except ImportError: try: from llm_proxy import proxy as _llm_proxy_autoinject # noqa: F401 @@ -63,13 +63,13 @@ class LocalController(object): def __init__(self, port=50051): self.port = port - self.agent_host = os.environ.get("VENTIS_AGENT_HOST", "localhost") - self.agent_name = os.environ.get("VENTIS_AGENT_NAME") - self.agent_file = os.environ.get("VENTIS_AGENT_FILE") + self.agent_host = os.environ.get("CANYONOS_AGENT_HOST", "localhost") + self.agent_name = os.environ.get("CANYONOS_AGENT_NAME") + self.agent_file = os.environ.get("CANYONOS_AGENT_FILE") # Public port is how the routing table and other nodes know us; # internally the gRPC server binds to `port` (50051 inside Docker). - self.public_port = os.environ.get("VENTIS_AGENT_PORT", str(port)) + self.public_port = os.environ.get("CANYONOS_AGENT_PORT", str(port)) self._my_endpoint = f"{self.agent_host}:{self.public_port}" @@ -80,8 +80,8 @@ def __init__(self, port=50051): self.servicer.on_result = self._fan_out_to_consumers # Connect to Redis and report healthy status - redis_host = os.environ.get("VENTIS_REDIS_HOST", "localhost") - redis_port = int(os.environ.get("VENTIS_REDIS_PORT", 6379)) + redis_host = os.environ.get("CANYONOS_REDIS_HOST", "localhost") + redis_port = int(os.environ.get("CANYONOS_REDIS_PORT", 6379)) self.redis = RedisClient(host=redis_host, port=redis_port) self._status_key = f"controller:{self.agent_host}:{self.public_port}:status" self.redis.set(self._status_key, "healthy") @@ -93,9 +93,9 @@ def __init__(self, port=50051): ) # Periodically publish instance metrics, on the same cadence - # GlobalController polls with (via VENTIS_POLL_INTERVAL). + # GlobalController polls with (via CANYONOS_POLL_INTERVAL). self._metrics_key = f"controller:{self.agent_host}:{self.public_port}:metrics" - self._metrics_interval = float(os.environ.get("VENTIS_POLL_INTERVAL", 5)) + self._metrics_interval = float(os.environ.get("CANYONOS_POLL_INTERVAL", 5)) psutil.cpu_percent(interval=None) # prime so the first real reading isn't 0.0 self._metrics_stop_event = threading.Event() self._metrics_thread = threading.Thread(target=self._metrics_loop, daemon=True) @@ -111,7 +111,7 @@ def __init__(self, port=50051): # Thread pool for executing agent methods concurrently. # This prevents deadlocks when an agent method creates nested Futures # that need to be routed through the same controller's request queue. - max_instances = int(os.environ.get("VENTIS_MAX_AGENT_INSTANCES", 8)) + max_instances = int(os.environ.get("CANYONOS_MAX_AGENT_INSTANCES", 8)) self._executor = ThreadPoolExecutor(max_workers=max_instances) # Start the LLM proxy alongside the agent in this container. Bedrock @@ -140,11 +140,11 @@ def _start_llm_proxy(self, redis_host, redis_port): proxy_env.update({ "PROXY_HOST": "127.0.0.1", "PROXY_PORT": "8081", - "VENTIS_REDIS_HOST": redis_host, - "VENTIS_REDIS_PORT": str(redis_port), + "CANYONOS_REDIS_HOST": redis_host, + "CANYONOS_REDIS_PORT": str(redis_port), }) proxy_process = subprocess.Popen( - [sys.executable, "-m", "ventis.llm_proxy"], + [sys.executable, "-m", "canyonos_core.llm_proxy"], env=proxy_env, ) logger.info( @@ -190,7 +190,7 @@ def _load_agent(self): """Dynamically load and instantiate the agent class.""" if not self.agent_name or not self.agent_file: logger.warning( - "VENTIS_AGENT_NAME or VENTIS_AGENT_FILE not set. Running without an agent." + "CANYONOS_AGENT_NAME or CANYONOS_AGENT_FILE not set. Running without an agent." ) return None @@ -603,9 +603,9 @@ def _execute_locally( self.redis.hset_multiple(f"future:{future_id}", initial_fields) if request_id: self.redis.sadd(f"request:{request_id}:futures", future_id) - ventis_context.set_request_id(request_id) - ventis_context.set_current_future_id(future_id) - ventis_context.set_current_metrics_key(self._metrics_key) + canyonos_context.set_request_id(request_id) + canyonos_context.set_current_future_id(future_id) + canyonos_context.set_current_metrics_key(self._metrics_key) if self.agent is None: logger.error("No agent loaded, cannot execute %s.%s", service, function) self._mark_future_failed(future_id, "No agent loaded", origin) @@ -695,7 +695,7 @@ def _execute_locally( origin, future_id, failed=1, error_message=error_message or "" ) - ventis_context.set_current_future_id(parent or "") + canyonos_context.set_current_future_id(parent or "") # ------------------------------------------------------------------ # # Request forwarding # diff --git a/ventis/controller/local_controller_frontend.py b/canyonos_core/controller/local_controller_frontend.py similarity index 95% rename from ventis/controller/local_controller_frontend.py rename to canyonos_core/controller/local_controller_frontend.py index 722bd16..ec1da4e 100644 --- a/ventis/controller/local_controller_frontend.py +++ b/canyonos_core/controller/local_controller_frontend.py @@ -29,10 +29,10 @@ def __init__(self, my_endpoint="unknown"): self.request_queue = queue.Queue() self.my_endpoint = my_endpoint # Redis client for writing results back to local Redis - redis_host = os.environ.get("VENTIS_REDIS_HOST", "localhost") - redis_port = int(os.environ.get("VENTIS_REDIS_PORT", 6379)) + redis_host = os.environ.get("CANYONOS_REDIS_HOST", "localhost") + redis_port = int(os.environ.get("CANYONOS_REDIS_PORT", 6379)) try: - from ventis.controller.utils.redis_client import RedisClient + from canyonos_core.controller.utils.redis_client import RedisClient except ImportError: from redis_client import RedisClient self.redis = RedisClient(host=redis_host, port=redis_port) @@ -152,7 +152,7 @@ def _cleanup_request(self, request_id): def start_server(port=50051, my_endpoint="unknown"): """Start the gRPC server.""" try: - from ventis.controller.utils.grpc_options import GRPC_SERVER_OPTIONS + from canyonos_core.controller.utils.grpc_options import GRPC_SERVER_OPTIONS except ImportError: from grpc_options import GRPC_SERVER_OPTIONS diff --git a/ventis/controller/proto/global_controller.proto b/canyonos_core/controller/proto/global_controller.proto similarity index 100% rename from ventis/controller/proto/global_controller.proto rename to canyonos_core/controller/proto/global_controller.proto diff --git a/ventis/controller/proto/local_controler.proto b/canyonos_core/controller/proto/local_controler.proto similarity index 100% rename from ventis/controller/proto/local_controler.proto rename to canyonos_core/controller/proto/local_controler.proto diff --git a/canyonos_core/controller/utils/__init__.py b/canyonos_core/controller/utils/__init__.py new file mode 100644 index 0000000..d597e67 --- /dev/null +++ b/canyonos_core/controller/utils/__init__.py @@ -0,0 +1 @@ +# CanyonOS Controller Utility helpers diff --git a/ventis/controller/utils/agent_specs.py b/canyonos_core/controller/utils/agent_specs.py similarity index 100% rename from ventis/controller/utils/agent_specs.py rename to canyonos_core/controller/utils/agent_specs.py diff --git a/ventis/controller/utils/aws_pricing_chart.db b/canyonos_core/controller/utils/aws_pricing_chart.db similarity index 100% rename from ventis/controller/utils/aws_pricing_chart.db rename to canyonos_core/controller/utils/aws_pricing_chart.db diff --git a/ventis/controller/utils/env_file.py b/canyonos_core/controller/utils/env_file.py similarity index 98% rename from ventis/controller/utils/env_file.py rename to canyonos_core/controller/utils/env_file.py index 6cd77b6..2242e11 100644 --- a/ventis/controller/utils/env_file.py +++ b/canyonos_core/controller/utils/env_file.py @@ -58,7 +58,7 @@ def remote_env_path(container_name): while the secrets stayed on the host, with nothing in the log to say so. """ safe_name = _UNSAFE_PATH_CHARS.sub("-", container_name) - return f"{REMOTE_ENV_DIR}/ventis-env-{safe_name}" + return f"{REMOTE_ENV_DIR}/canyonos-env-{safe_name}" @contextmanager diff --git a/ventis/controller/utils/gpu_metrics.py b/canyonos_core/controller/utils/gpu_metrics.py similarity index 100% rename from ventis/controller/utils/gpu_metrics.py rename to canyonos_core/controller/utils/gpu_metrics.py diff --git a/ventis/controller/utils/grpc_options.py b/canyonos_core/controller/utils/grpc_options.py similarity index 100% rename from ventis/controller/utils/grpc_options.py rename to canyonos_core/controller/utils/grpc_options.py diff --git a/ventis/controller/utils/pricing.py b/canyonos_core/controller/utils/pricing.py similarity index 100% rename from ventis/controller/utils/pricing.py rename to canyonos_core/controller/utils/pricing.py diff --git a/ventis/controller/utils/process_supervisor.py b/canyonos_core/controller/utils/process_supervisor.py similarity index 95% rename from ventis/controller/utils/process_supervisor.py rename to canyonos_core/controller/utils/process_supervisor.py index f5336e6..00f611b 100644 --- a/ventis/controller/utils/process_supervisor.py +++ b/canyonos_core/controller/utils/process_supervisor.py @@ -3,7 +3,7 @@ register() + start_all() spawn processes; check_and_respawn() (call from GC's existing poll tick) restarts any that exit unexpectedly; terminate_all() (call from GC's shutdown path) stops them all cleanly. Deliberately GC-agnostic -- callers are responsible for not -calling check_and_respawn() during their own shutdown (see ventis/OTLP_Exporter/DESIGN.md's +calling check_and_respawn() during their own shutdown (see canyonos/OTLP_Exporter/DESIGN.md's shutdown-race note). """ diff --git a/ventis/controller/utils/redis_client.py b/canyonos_core/controller/utils/redis_client.py similarity index 100% rename from ventis/controller/utils/redis_client.py rename to canyonos_core/controller/utils/redis_client.py diff --git a/ventis/controller/utils/redis_utils.py b/canyonos_core/controller/utils/redis_utils.py similarity index 90% rename from ventis/controller/utils/redis_utils.py rename to canyonos_core/controller/utils/redis_utils.py index 4bdc265..54b6dad 100644 --- a/ventis/controller/utils/redis_utils.py +++ b/canyonos_core/controller/utils/redis_utils.py @@ -7,7 +7,7 @@ def _wait_for_redis(redis_client, host, port, timeout=30, interval=1): last_error = None while time.time() < deadline: try: - redis_client.set("__ventis_redis_healthcheck__", "ok") + redis_client.set("__canyonos_redis_healthcheck__", "ok") return except Exception as exc: last_error = exc diff --git a/ventis/controller/utils/session_logging.py b/canyonos_core/controller/utils/session_logging.py similarity index 98% rename from ventis/controller/utils/session_logging.py rename to canyonos_core/controller/utils/session_logging.py index df8e4dc..0817c8a 100644 --- a/ventis/controller/utils/session_logging.py +++ b/canyonos_core/controller/utils/session_logging.py @@ -45,7 +45,7 @@ def _get_engine(database_url): """Return a cached Engine for `database_url`, building one on first use per resolved URL.""" global _engines - url = os.environ.get("VENTIS_DATABASE_URL", str(database_url)) + url = os.environ.get("CANYONOS_DATABASE_URL", str(database_url)) if url.startswith("postgresql://"): url = "postgresql+psycopg://" + url[len("postgresql://"):] engine = _engines.get(url) diff --git a/ventis/controller/utils/telemetry_logging.py b/canyonos_core/controller/utils/telemetry_logging.py similarity index 92% rename from ventis/controller/utils/telemetry_logging.py rename to canyonos_core/controller/utils/telemetry_logging.py index 503f3e1..dbb15f5 100644 --- a/ventis/controller/utils/telemetry_logging.py +++ b/canyonos_core/controller/utils/telemetry_logging.py @@ -6,8 +6,8 @@ from datetime import datetime, timezone from sqlalchemy import create_engine, text -from ventis.controller.utils import pricing -from ventis.controller.utils.redis_client import RedisClient +from canyonos_core.controller.utils import pricing +from canyonos_core.controller.utils.redis_client import RedisClient logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ def assign_project_id(project_id) -> None: def _get_engine(database_url): global _engine if _engine is None: - url = os.environ.get("VENTIS_DATABASE_URL", str(database_url)) + url = os.environ.get("CANYONOS_DATABASE_URL", str(database_url)) if url.startswith("postgresql://"): url = "postgresql+psycopg://" + url[len("postgresql://"):] _engine = create_engine(url) @@ -112,6 +112,15 @@ def pull_runtime_information(redis_client): return rows +def _demo_cost_multiplier(env_var): + """Off (1x) unless the env var opts in; logs a warning since it inflates recorded costs.""" + raw = os.environ.get(env_var) + if raw is None: + return 1 + logger.warning("%s=%s is set -- displayed costs are scaled and do not reflect real recorded costs.", env_var, raw) + return float(raw) + + def send_runtime_information( rows, redis_client: RedisClient | None = None, @@ -122,8 +131,8 @@ def send_runtime_information( return # Demo-only multipliers for scaling displayed costs; not real recorded costs. - token_cost_multiplier = 10000 - server_cost_multiplier = 100000 + token_cost_multiplier = _demo_cost_multiplier("CANYONOS_DEMO_TOKEN_COST_MULTIPLIER") + server_cost_multiplier = _demo_cost_multiplier("CANYONOS_DEMO_SERVER_COST_MULTIPLIER") with _get_engine(database_url).begin() as conn: for raw in rows: diff --git a/ventis/llm_proxy/README.md b/canyonos_core/llm_proxy/README.md similarity index 97% rename from ventis/llm_proxy/README.md rename to canyonos_core/llm_proxy/README.md index 873144d..8de3d8e 100644 --- a/ventis/llm_proxy/README.md +++ b/canyonos_core/llm_proxy/README.md @@ -88,7 +88,7 @@ Telemetry is automatically written to Redis under `future:` keys. ### How it works (Bedrock only) -1. **Auto-injection:** boto3 hook (`proxy.py`) injects `X-Ventis-Future-Id` header from thread-local context +1. **Auto-injection:** boto3 hook (`proxy.py`) injects `X-Canyonos-Future-ID` header from thread-local context 2. **Token extraction:** `hooks.py` parses response `usage` field 3. **Redis write:** All metrics written to `future:` hash diff --git a/ventis/llm_proxy/__init__.py b/canyonos_core/llm_proxy/__init__.py similarity index 100% rename from ventis/llm_proxy/__init__.py rename to canyonos_core/llm_proxy/__init__.py diff --git a/ventis/llm_proxy/__main__.py b/canyonos_core/llm_proxy/__main__.py similarity index 88% rename from ventis/llm_proxy/__main__.py rename to canyonos_core/llm_proxy/__main__.py index c0af2e9..f5fea0c 100644 --- a/ventis/llm_proxy/__main__.py +++ b/canyonos_core/llm_proxy/__main__.py @@ -4,8 +4,8 @@ import logging -from ventis.llm_proxy.app import create_app -from ventis.llm_proxy.config import Config +from canyonos_core.llm_proxy.app import create_app +from canyonos_core.llm_proxy.config import Config def main() -> None: diff --git a/ventis/llm_proxy/app.py b/canyonos_core/llm_proxy/app.py similarity index 84% rename from ventis/llm_proxy/app.py rename to canyonos_core/llm_proxy/app.py index b2e3b09..d9b53df 100644 --- a/ventis/llm_proxy/app.py +++ b/canyonos_core/llm_proxy/app.py @@ -7,9 +7,9 @@ from flask import Flask, jsonify, request -from ventis.llm_proxy.config import Config -from ventis.llm_proxy.core import proxy_request -from ventis.llm_proxy.providers import build_registry +from canyonos_core.llm_proxy.config import Config +from canyonos_core.llm_proxy.core import proxy_request +from canyonos_core.llm_proxy.providers import build_registry log = logging.getLogger("llm_proxy") @@ -22,7 +22,7 @@ def create_app(cfg: Config = None) -> Flask: registry = build_registry(cfg) # Initialize hooks with config for Redis - from ventis.llm_proxy import hooks as hooks_module + from canyonos_core.llm_proxy import hooks as hooks_module hooks_module.hooks = hooks_module.Hooks(cfg) @app.route("/healthz", methods=["GET"]) diff --git a/ventis/llm_proxy/config.py b/canyonos_core/llm_proxy/config.py similarity index 92% rename from ventis/llm_proxy/config.py rename to canyonos_core/llm_proxy/config.py index 9e85cfa..df87694 100644 --- a/ventis/llm_proxy/config.py +++ b/canyonos_core/llm_proxy/config.py @@ -61,6 +61,6 @@ def from_env(cls) -> "Config": bedrock_upstream_host=os.getenv( "BEDROCK_UPSTREAM_HOST", f"bedrock-runtime.{region}.amazonaws.com" ), - redis_host=os.getenv("VENTIS_REDIS_HOST", "localhost"), - redis_port=int(os.getenv("VENTIS_REDIS_PORT", "6379")), + redis_host=os.getenv("CANYONOS_REDIS_HOST", "localhost"), + redis_port=int(os.getenv("CANYONOS_REDIS_PORT", "6379")), ) diff --git a/ventis/llm_proxy/core.py b/canyonos_core/llm_proxy/core.py similarity index 61% rename from ventis/llm_proxy/core.py rename to canyonos_core/llm_proxy/core.py index 6284e00..38558af 100644 --- a/ventis/llm_proxy/core.py +++ b/canyonos_core/llm_proxy/core.py @@ -8,7 +8,7 @@ from flask import Response -from ventis.llm_proxy.hooks import Ctx +from canyonos_core.llm_proxy.hooks import Ctx def _guess_model(body: bytes) -> Optional[str]: @@ -26,7 +26,7 @@ def _guess_model(body: bytes) -> Optional[str]: def proxy_request(provider, subpath, flask_request): # Import hooks here to get the instance created by create_app - from ventis.llm_proxy.hooks import hooks + from canyonos_core.llm_proxy.hooks import hooks body = flask_request.get_data() ctx = Ctx( @@ -40,7 +40,18 @@ def proxy_request(provider, subpath, flask_request): ) hooks.on_request(ctx) - pr = provider.forward(flask_request, subpath, body) + # Test mode: if CANYONOS_LLM_STUB_TEXT is set, return canned text instead of + # calling the real upstream. Telemetry hooks still fire so the whole pipeline + # is exercised end-to-end without cloud credentials. + from canyonos_core.llm_proxy.stub import build_stub, stub_text + + # Empty string (the runtime's explicit "disabled" value) is falsy, so only a + # non-empty stub text -- which only `canyonos test` sets -- enables stubbing. + _stub = stub_text() + if _stub: + pr = build_stub(provider.name, subpath, _stub) + else: + pr = provider.forward(flask_request, subpath, body) hooks.on_response(ctx, pr) return Response(pr.content, status=pr.status, headers=pr.headers) diff --git a/ventis/llm_proxy/hooks.py b/canyonos_core/llm_proxy/hooks.py similarity index 97% rename from ventis/llm_proxy/hooks.py rename to canyonos_core/llm_proxy/hooks.py index 7cc9957..89182a5 100644 --- a/ventis/llm_proxy/hooks.py +++ b/canyonos_core/llm_proxy/hooks.py @@ -57,7 +57,7 @@ def __init__(self, config=None): if config: try: try: - from ventis.controller.utils.redis_client import RedisClient + from canyonos_core.controller.utils.redis_client import RedisClient except ImportError: # In-container the framework files are copied flat to /app. from redis_client import RedisClient @@ -91,7 +91,7 @@ def on_response(self, ctx: Ctx, resp: Any) -> None: # Write to Redis if we have context log.info("Checking telemetry write: redis=%s", "yes" if self._redis else "no") if self._redis: - future_id = ctx.headers.get("X-Ventis-Future-Id") + future_id = ctx.headers.get("X-Canyonos-Future-ID") log.info("Future ID from headers: %s", future_id) if future_id: try: diff --git a/ventis/llm_proxy/providers/__init__.py b/canyonos_core/llm_proxy/providers/__init__.py similarity index 54% rename from ventis/llm_proxy/providers/__init__.py rename to canyonos_core/llm_proxy/providers/__init__.py index d9ff021..1ca3bd1 100644 --- a/ventis/llm_proxy/providers/__init__.py +++ b/canyonos_core/llm_proxy/providers/__init__.py @@ -1,8 +1,8 @@ from __future__ import annotations -from ventis.llm_proxy.providers.anthropic import AnthropicProvider -from ventis.llm_proxy.providers.bedrock import BedrockProvider -from ventis.llm_proxy.providers.openai import OpenAIProvider +from canyonos_core.llm_proxy.providers.anthropic import AnthropicProvider +from canyonos_core.llm_proxy.providers.bedrock import BedrockProvider +from canyonos_core.llm_proxy.providers.openai import OpenAIProvider def build_registry(cfg): diff --git a/ventis/llm_proxy/providers/anthropic.py b/canyonos_core/llm_proxy/providers/anthropic.py similarity index 86% rename from ventis/llm_proxy/providers/anthropic.py rename to canyonos_core/llm_proxy/providers/anthropic.py index 33e14aa..87a101e 100644 --- a/ventis/llm_proxy/providers/anthropic.py +++ b/canyonos_core/llm_proxy/providers/anthropic.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ventis.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers +from canyonos_core.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers class AnthropicProvider(HttpProvider): diff --git a/ventis/llm_proxy/providers/base.py b/canyonos_core/llm_proxy/providers/base.py similarity index 100% rename from ventis/llm_proxy/providers/base.py rename to canyonos_core/llm_proxy/providers/base.py diff --git a/ventis/llm_proxy/providers/bedrock.py b/canyonos_core/llm_proxy/providers/bedrock.py similarity index 98% rename from ventis/llm_proxy/providers/bedrock.py rename to canyonos_core/llm_proxy/providers/bedrock.py index f0efddc..a3f3fb6 100644 --- a/ventis/llm_proxy/providers/bedrock.py +++ b/canyonos_core/llm_proxy/providers/bedrock.py @@ -14,7 +14,7 @@ import boto3 from botocore.exceptions import ClientError -from ventis.llm_proxy.providers.base import Provider, ProxyResponse +from canyonos_core.llm_proxy.providers.base import Provider, ProxyResponse # bedrock-runtime operations that can appear as the last path segment; only the # non-streaming "invoke" is wired up for now. diff --git a/ventis/llm_proxy/providers/openai.py b/canyonos_core/llm_proxy/providers/openai.py similarity index 84% rename from ventis/llm_proxy/providers/openai.py rename to canyonos_core/llm_proxy/providers/openai.py index 67457eb..501698b 100644 --- a/ventis/llm_proxy/providers/openai.py +++ b/canyonos_core/llm_proxy/providers/openai.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ventis.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers +from canyonos_core.llm_proxy.providers.base import HttpProvider, UpstreamRequest, client_headers class OpenAIProvider(HttpProvider): diff --git a/canyonos_core/llm_proxy/proxy.py b/canyonos_core/llm_proxy/proxy.py new file mode 100644 index 0000000..50260e7 --- /dev/null +++ b/canyonos_core/llm_proxy/proxy.py @@ -0,0 +1,74 @@ +"""Auto-inject CanyonOS headers into ALL boto3 Bedrock calls. + +Import this module once and all subsequent boto3.client("bedrock-runtime") calls +will automatically include the X-Canyonos-Future-ID header. + +Usage: + import canyonos_core.llm_proxy_auto # Just import once + import boto3 + + # Now this automatically includes the header! + client = boto3.client("bedrock-runtime") + response = client.converse(...) +""" + +import os + +import boto3 +import logging + +# Test/stub mode: when CANYONOS_LLM_STUB_TEXT is set, the LLM proxy returns +# canned text and NEVER calls AWS. boto3 still needs *some* credentials to +# compute a local SigV4 signature for the request it sends to the local proxy +# endpoint (AWS_ENDPOINT_URL_BEDROCK_RUNTIME -> 127.0.0.1:8081), so supply +# throwaway ones here. The signed request goes only to the local proxy; these +# credentials are never transmitted to AWS. This makes a stubbed e2e run need +# no real AWS credentials at all. +if os.getenv("CANYONOS_LLM_STUB_TEXT"): + os.environ.setdefault("AWS_ACCESS_KEY_ID", "stub") + os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "stub") + os.environ.setdefault( + "AWS_DEFAULT_REGION", os.getenv("AWS_REGION", "us-east-1") + ) + +try: + import canyonos_core.controller.canyonos_context as canyonos_context +except ImportError: + # In-container the framework files are copied flat to /app. + try: + import canyonos_context + except ImportError: + canyonos_context = None + +log = logging.getLogger(__name__) + + +def _inject_canyonos_headers(params=None, **kwargs): + """Inject X-Canyonos-Future-ID into the outgoing Bedrock HTTP request. + + Registered on boto3's ``before-call.bedrock-runtime`` event, whose handlers + receive the prepared-request ``params`` dict (with a mutable ``headers``). + The ``request`` object only exists on the later ``before-send`` event, so + reading it here would always be None and silently drop the header. + """ + if not canyonos_context or params is None: + return + + # Get current future_id from thread-local context + try: + future_id = canyonos_context.get_current_future_id() + if future_id: + params.setdefault("headers", {})["X-Canyonos-Future-ID"] = future_id + log.debug("Injected X-Canyonos-Future-ID: %s", future_id) + except Exception as e: + log.debug("Could not inject future_id: %s", e) + + +# Register the hook globally on the default session +_session = boto3.Session() +_session.events.register_first('before-call.bedrock-runtime', _inject_canyonos_headers) + +# Also patch the default session used by boto3.client() +boto3.DEFAULT_SESSION = _session + +log.info("CanyonOS boto3 hook registered - all Bedrock calls will include future_id header") diff --git a/ventis/llm_proxy/requirements.txt b/canyonos_core/llm_proxy/requirements.txt similarity index 100% rename from ventis/llm_proxy/requirements.txt rename to canyonos_core/llm_proxy/requirements.txt diff --git a/canyonos_core/llm_proxy/stub.py b/canyonos_core/llm_proxy/stub.py new file mode 100644 index 0000000..c919e1e --- /dev/null +++ b/canyonos_core/llm_proxy/stub.py @@ -0,0 +1,73 @@ +"""Test-mode LLM stub. + +When ``CANYONOS_LLM_STUB_TEXT`` is set in the environment, every proxied LLM +call short-circuits and returns that text as the model output instead of hitting +a real upstream (Bedrock/OpenAI/Anthropic). This lets a full workflow be +exercised end-to-end with no cloud credentials and zero token cost -- the whole +deploy/route/proxy/telemetry path still runs, only the upstream call is replaced. + +Enable it per-deploy via an ``env_file`` entry (injected into every agent +container, and inherited by the in-container proxy subprocess): + + # .env + CANYONOS_LLM_STUB_TEXT=testing +""" + +from __future__ import annotations + +import json +import os + +from canyonos_core.llm_proxy.providers.base import ProxyResponse + +STUB_ENV = "CANYONOS_LLM_STUB_TEXT" + + +def stub_text(): + """Return the configured stub text, or None when stubbing is disabled.""" + return os.getenv(STUB_ENV) + + +def _json_response(obj, status=200): + return ProxyResponse( + status=status, + headers=[("Content-Type", "application/json")], + content=json.dumps(obj).encode("utf-8"), + ) + + +def build_stub(provider_name, subpath, text): + """Build a provider-appropriate canned response carrying ``text``.""" + if provider_name == "bedrock": + op = subpath.rsplit("/", 1)[-1] if subpath else "" + if op in ("converse", "converse-stream"): + return _json_response({ + "output": {"message": {"role": "assistant", + "content": [{"text": text}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1, "totalTokens": 2}, + }) + # invoke / other ops: a minimal body that common model families read. + return _json_response({ + "outputText": text, + "results": [{"outputText": text}], + "generation": text, + }) + + if provider_name == "openai": + return _json_response({ + "id": "stub-cmpl", "object": "chat.completion", "model": "stub", + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", "content": text}}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }) + + if provider_name == "anthropic": + return _json_response({ + "id": "stub-msg", "type": "message", "role": "assistant", "model": "stub", + "content": [{"type": "text", "text": text}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }) + + return _json_response({"text": text}) diff --git a/ventis/server.py b/canyonos_core/server.py similarity index 91% rename from ventis/server.py rename to canyonos_core/server.py index e9e9561..c1e4b48 100644 --- a/ventis/server.py +++ b/canyonos_core/server.py @@ -6,10 +6,10 @@ import yaml from flask import Flask, jsonify, request -from ventis.cli import _artifact_prefix -from ventis.controller.utils.redis_client import RedisClient +from canyonos_core.cli import _artifact_prefix +from canyonos_core.controller.utils.redis_client import RedisClient -app = Flask("ventis-server") +app = Flask("canyonos-server") # The project files are copied here (into a named volume) by `canyonos sync` / # `canyonos deploy`. Deploy builds and launches against this path. @@ -38,7 +38,7 @@ def deploy(): return jsonify({"error": "already running"}), 409 data = request.get_json(force=True, silent=True) or {} - # Resolved with ventis' own artifact-layout rule rather than a second copy + # Resolved with canyonos' 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" @@ -52,12 +52,12 @@ def deploy(): if not os.path.isfile(full_path): return jsonify({"error": f"config file not found: {full_path}"}), 400 - # `ventis deploy` builds (stubs/protos/images) then launches the Global + # `canyonos deploy` builds (stubs/protos/images) then launches the Global # Controller. cwd is the workspace so build outputs land alongside the # project files and the controller finds them. Build+deploy output streams # to the container logs, which `canyonos deploy` tails. _gc_process = subprocess.Popen( - [sys.executable, "-m", "ventis.cli", "deploy", "-c", config_path], + [sys.executable, "-m", "canyonos_core.cli", "deploy", "-c", config_path], cwd=WORKSPACE_DIR, ) _config_path = full_path @@ -92,7 +92,7 @@ def _primary_redis(config): port = agent.get("redis_port", port) break if host in ("localhost", "127.0.0.1"): - host = os.environ.get("VENTIS_REDIS_HOST", host) + host = os.environ.get("CANYONOS_REDIS_HOST", host) return RedisClient(host=host, port=int(port)) diff --git a/ventis/stub_generator.py b/canyonos_core/stub_generator.py similarity index 96% rename from ventis/stub_generator.py rename to canyonos_core/stub_generator.py index 6f8fc14..6c6d3d9 100644 --- a/ventis/stub_generator.py +++ b/canyonos_core/stub_generator.py @@ -1,5 +1,5 @@ """ -Stub generator for Ventis agents. +Stub generator for CanyonOS agents. Reads a YAML agent definition and generates an importable Python stub file where each function returns a Future object. Similar in spirit to how gRPC @@ -271,7 +271,7 @@ def _format_source(source): return "\n".join(formatted) + "\n" -# Directories ventis build itself generates inside a project -- never swept. +# Directories canyonos build itself generates inside a project -- never swept. _GENERATED_DIRS = {"docker_container", "stubs", "grpc_stubs"} @@ -308,19 +308,19 @@ def _stub_destination(stub_file, stub_entrypoints): def _copy_llm_proxy(output_dir, script_dir): - """Copy the ventis.llm_proxy package into the build context as an importable - `ventis` package so the in-container proxy can run via `python -m ventis.llm_proxy`. - Its cross-package imports (redis_client, ventis_context) fall back to the flat + """Copy the canyonos_core.llm_proxy package into the build context as an importable + `canyonos_core` package so the in-container proxy can run via `python -m canyonos_core.llm_proxy`. + Its cross-package imports (redis_client, canyonos_context) fall back to the flat copies already placed at the context root.""" shutil.copytree( os.path.join(script_dir, "llm_proxy"), - os.path.join(output_dir, "ventis", "llm_proxy"), + os.path.join(output_dir, "canyonos_core", "llm_proxy"), dirs_exist_ok=True, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"), ) shutil.copy2( os.path.join(script_dir, "__init__.py"), - os.path.join(output_dir, "ventis", "__init__.py"), + os.path.join(output_dir, "canyonos_core", "__init__.py"), ) @@ -396,7 +396,7 @@ def generate_docker( files_to_copy += [ # (source_path, destination_filename) (os.path.join(script_dir, "controller", "future.py"), "future.py"), - (os.path.join(script_dir, "controller", "ventis_context.py"), "ventis_context.py"), + (os.path.join(script_dir, "controller", "canyonos_context.py"), "canyonos_context.py"), ( os.path.join(script_dir, "controller", "local_controller.py"), "local_controller.py", @@ -453,8 +453,8 @@ def generate_docker( COPY . . -ENV VENTIS_AGENT_NAME={agent_name} -ENV VENTIS_AGENT_FILE={agent_basename} +ENV CANYONOS_AGENT_NAME={agent_name} +ENV CANYONOS_AGENT_FILE={agent_basename} EXPOSE 50051 @@ -518,7 +518,7 @@ def generate_workflow_docker( files_to_copy += [ (os.path.join(script_dir, "controller", "future.py"), "future.py"), - (os.path.join(script_dir, "controller", "ventis_context.py"), "ventis_context.py"), + (os.path.join(script_dir, "controller", "canyonos_context.py"), "canyonos_context.py"), (os.path.join(script_dir, "controller", "deploy.py"), "deploy.py"), ( os.path.join(script_dir, "controller", "local_controller.py"), diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index 07619da..e719ba2 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -20,7 +20,7 @@ If you remember only one picture, remember this: │ │ copy project in (docker cp) │ │ ├───────────────────────────────▶│ /workspace │ │ POST /deploy │ - │ ├───────────────────────────────▶│ ventis build + launch + │ ├───────────────────────────────▶│ canyonos build + launch │ │◀── log stream (docker logs) ───┤ │ │◀── readable progress ─┤ │ ▼ │ │ spawns Redis + agents @@ -39,7 +39,7 @@ If you remember only one picture, remember this: │ │ CLI │ /status /endpoints │ container │ │ │ │ │ ───── docker cp ─────────▶│ ├─ /workspace (a copy │ │ │ │ │ ───── docker logs -f ────▶│ │ of your project) │ │ -│ └─────┬─────┘ │ └─ runs `ventis` │ │ +│ └─────┬─────┘ │ └─ runs `canyonos` │ │ │ │ └───────────┬──────────────┘ │ │ │ docker compose │ docker.sock │ │ ▼ ▼ (spawns siblings)│ @@ -98,7 +98,7 @@ container is involved yet.** │ ├─ 2. ship code → docker cp your project into /workspace │ - ├─ 3. trigger → POST /deploy (container runs `ventis`: + ├─ 3. trigger → POST /deploy (container runs `canyonos`: │ build stubs/images + launch the workflow) │ └─ 4. narrate → tail container logs, boil them down to phases, diff --git a/cli/canyonos/dashboard.compose.yml b/cli/canyonos/dashboard.compose.yml index 00416f4..0effbac 100644 --- a/cli/canyonos/dashboard.compose.yml +++ b/cli/canyonos/dashboard.compose.yml @@ -5,7 +5,7 @@ services: # 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 + # renames canyonos' `project_id` attribute to the `canyon.project.id` every # dashboard query filters on. ports: - "3000:3000" diff --git a/cli/canyonos/deploy.py b/cli/canyonos/deploy.py index 6412cfc..8cddd81 100644 --- a/cli/canyonos/deploy.py +++ b/cli/canyonos/deploy.py @@ -1,7 +1,7 @@ """ 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 +build and deploy it. The container's `canyonos deploy` handles both the build (stubs, protos, Docker images) and the launch -- the CLI just ships files, triggers it, and watches the logs. @@ -134,7 +134,7 @@ def agents_ready_message(self): def run_deploy(config_path=None, serve=True, verbose=False): - # Left as None when unset: ventis resolves the artifact layout itself. + # Left as None when unset: canyonos resolves the artifact layout itself. if config_path is not None: config_path = workspace_relative(config_path) if config_path is None: @@ -149,7 +149,7 @@ def run_deploy(config_path=None, serve=True, verbose=False): state = load_state() - # Read for display only -- ventis resolves the path it actually deploys. + # Read for display only -- canyonos resolves the path it actually deploys. api_port = workflow_api_port(config_path or default_config_path()) try: @@ -272,7 +272,7 @@ def _tail_verbose(stream, state, api_port, serve): 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 + Nothing is echoed raw: the buildx transcript, canyonos' 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. """ diff --git a/cli/canyonos/gc.py b/cli/canyonos/gc.py index 594c08b..1f7bc54 100644 --- a/cli/canyonos/gc.py +++ b/cli/canyonos/gc.py @@ -54,7 +54,7 @@ def require_state(): 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. + Omitting config_path lets canyonos resolve it against the synced workspace. """ body = json.dumps({"config_path": config_path} if config_path else {}).encode() try: @@ -64,7 +64,7 @@ def post_deploy(port, config_path=None): def post_clean(port): - """Tear down the running deploy: SIGTERMs the in-container `ventis deploy` + """Tear down the running deploy: SIGTERMs the in-container `canyonos 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. diff --git a/cli/canyonos/init.py b/cli/canyonos/init.py index 69d4eba..eac2edf 100644 --- a/cli/canyonos/init.py +++ b/cli/canyonos/init.py @@ -127,31 +127,32 @@ def _port_reachable(port, attempts=10, delay=0.5): return False -def run_container(image=GC_IMAGE, max_attempts=50): +def run_container(image=GC_IMAGE, max_attempts=50, extra_env=None): 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, - ) + cmd = [ + "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", + "CANYONOS_REDIS_HOST=host.docker.internal", + ] + # Extra env for the GC container. The local runtime forwards select keys + # (e.g. CANYONOS_LLM_STUB_TEXT) from here into each agent container. + for _k, _v in (extra_env or {}).items(): + cmd.extend(["-e", f"{_k}={_v}"]) + cmd.append(image) # image must come after all flags + result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode == 0: container_id = result.stdout.strip() if _port_reachable(port): @@ -194,7 +195,7 @@ def quit_existing(): run_quit() -def run_init(banner=True): +def run_init(banner=True, extra_env=None): if banner: ui.gradient(figlet_format("CANYON OS", font="ansi_shadow", width=200)) @@ -205,6 +206,6 @@ def run_init(banner=True): with ui.status("Pulling Global Controller image..."): pull_image() with ui.status("Starting Global Controller container..."): - container_id, port = run_container() + container_id, port = run_container(extra_env=extra_env) save_state(container_id, port) ui.ok(f"Global Controller running in container {container_id[:12]} on port {port}") diff --git a/cli/canyonos/test.py b/cli/canyonos/test.py index 23f08f7..895e8e0 100644 --- a/cli/canyonos/test.py +++ b/cli/canyonos/test.py @@ -40,6 +40,10 @@ from canyonos.verify import ARTIFACT_DIR, verify_build_artifact, verify_runtime DEFAULT_QUERY = "hello" +# `canyonos test` stubs the in-container LLM proxy by default so a smoke test +# never calls a real LLM (no credentials, no token cost). Every model call +# returns this text; pass --real-llm to use the actual provider instead. +DEFAULT_LLM_STUB = "test" READY_TIMEOUT = 60 REQUEST_TIMEOUT = 60 SUBMIT_TIMEOUT = 30 @@ -186,9 +190,15 @@ def _verify_build(run, config_path): run.done(f"{run.validation['warnings']} warning(s), {stale} stale source(s)") -def _deploy_locally(run, config_path, api_port): +def _deploy_locally(run, config_path, api_port, llm_stub=DEFAULT_LLM_STUB): run.begin("deploy", 2, "Deploy locally") - run_init(banner=False) + # When stubbing, hand the flag to the GC container; the local runtime + # forwards it into every agent so their LLM calls are replaced with canned + # text (see canyonos_core/llm_proxy/stub.py). + extra_env = {"CANYONOS_LLM_STUB_TEXT": llm_stub} if llm_stub else None + if llm_stub: + ui.say(f"LLM stub on: every model call returns {llm_stub!r} (no real LLM). Pass --real-llm to disable.") + run_init(banner=False, extra_env=extra_env) if not run_sync(): raise RuntimeError("Could not sync the project into the container.") @@ -246,7 +256,7 @@ def _query(run, gc_port, api_port): run.done(f"answered in {run.elapsed()}s") -def _run_test(run): +def _run_test(run, llm_stub=DEFAULT_LLM_STUB): """Walk the four phases, restoring the config whatever happens.""" config_path = workspace_relative(default_config_path()) if config_path is None: @@ -262,7 +272,7 @@ def _run_test(run): original_config = _force_local_providers(config_path) try: - state = _deploy_locally(run, config_path, api_port) + state = _deploy_locally(run, config_path, api_port, llm_stub=llm_stub) _verify_runtime(run, config_path, state["port"]) _query(run, state["port"], api_port) finally: @@ -339,14 +349,14 @@ def _payload(run): } -def run_test(prompt=None, as_json=False): +def run_test(prompt=None, as_json=False, llm_stub=DEFAULT_LLM_STUB): run = _Run(prompt or DEFAULT_QUERY) ui.set_quiet(as_json) try: container_live = False try: - _run_test(run) + _run_test(run, llm_stub=llm_stub) except KeyboardInterrupt: run.error = "cancelled by user" except RuntimeError as e: diff --git a/cli/canyonos/verify.py b/cli/canyonos/verify.py index 95fd36a..4f9a46a 100644 --- a/cli/canyonos/verify.py +++ b/cli/canyonos/verify.py @@ -36,13 +36,13 @@ 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 +# These two rules decide their verdict by importing `canyonos` and probing it for # env-file injection and editable-install support. The runtime lives in the # Global Controller image, not on the host running this CLI, so the probe always # comes back empty here and the rules report a failure that isn't one. CAPABILITY_GATED_CHECKS = frozenset({"V030", "V031"}) -RUNTIME_PREFIX = "ventis-local-" +RUNTIME_PREFIX = "canyonos-local-" # ------------------------------------------------------------------ # @@ -84,12 +84,12 @@ def _run_validator(validator, artifact_dir): def _drop_unprobeable(report): - """Remove the rules that can only be judged with `ventis` importable. + """Remove the rules that can only be judged with `canyonos` importable. Their verdict without it is not merely uncertain, it is wrong: V030 reports that the runtime never reads `env_file` when the container's runtime does. """ - if report.get("capabilities", {}).get("ventis"): + if report.get("capabilities", {}).get("canyonos_core"): return 0 kept = [] @@ -181,7 +181,7 @@ def verify_build_artifact(project_root="."): (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") + ui.hint(f" {skipped} rule(s) need the canyonos runtime to judge and were skipped") summary["stale"] = _stale_sources(project_root, artifact_dir) for relative in summary["stale"]: @@ -255,7 +255,7 @@ def verify_runtime(config_path, gc_port): if not name: continue # Image and container names the local provider derives from the agent name. - image = f"ventis-{name.lower()}" + image = f"canyonos-{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 diff --git a/cli/cli.py b/cli/cli.py index c20e3d8..2d38c61 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -19,7 +19,7 @@ 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 canyonos.test import DEFAULT_LLM_STUB, DEFAULT_QUERY, run_test from utils.help_screen import DESCRIPTIONS, print_custom_help def _parse_bool(value): @@ -57,7 +57,7 @@ def add(name, run): deploy.add_argument( "-c", "--config", - help="Path to global controller config (default: resolved by ventis inside the container)", + help="Path to global controller config (default: resolved by canyonos inside the container)", ) deploy.add_argument( "--serve", @@ -83,8 +83,12 @@ def add(name, run): add("serve", lambda args: sys.exit(run_serve())) add("status", lambda args: run_status()) - # Test has 2 args: prompt, --json. - test = add("test", lambda args: sys.exit(run_test(args.prompt, as_json=args.json))) + # Test has args: prompt, --json, --real-llm, --stub-text. + test = add("test", lambda args: sys.exit(run_test( + args.prompt, + as_json=args.json, + llm_stub=(None if args.real_llm else args.stub_text), + ))) test.add_argument( "prompt", nargs="?", @@ -96,6 +100,18 @@ def add(name, run): action="store_true", help="Print a single JSON result object and nothing else (for CI)", ) + test.add_argument( + "--stub-text", + default=DEFAULT_LLM_STUB, + metavar="TEXT", + help=("Text the in-container LLM proxy returns for every model call so " + f"tests never hit a real LLM (default: {DEFAULT_LLM_STUB!r})."), + ) + test.add_argument( + "--real-llm", + action="store_true", + help="Use the real LLM provider instead of the stub (requires credentials).", + ) args = parser.parse_args() if not getattr(args, "command", None): diff --git a/examples/finance/agents/finance_agent.py b/examples/finance/agents/finance_agent.py index db70b01..4b6d9bb 100644 --- a/examples/finance/agents/finance_agent.py +++ b/examples/finance/agents/finance_agent.py @@ -1,6 +1,6 @@ # `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 +# agent's own entrypoint-mirrored path -- see canyonos/stub_generator.py). The # bare `vllm_agent` fallback covers running outside that layout. try: from agents.vllm_agent import VllmAgent diff --git a/examples/finance/config/global_controller.yaml b/examples/finance/config/global_controller.yaml index 7199d6d..b4b3cec 100644 --- a/examples/finance/config/global_controller.yaml +++ b/examples/finance/config/global_controller.yaml @@ -58,7 +58,7 @@ redis: # Docker image registry (legacy; no longer used). -# Remote nodes are expected to already have the image before `ventis deploy`. +# Remote nodes are expected to already have the image before `canyonos deploy`. # # registry: # url: myregistry.example.com:5000 diff --git a/examples/finance/workflow/example_workflow.py b/examples/finance/workflow/example_workflow.py index 644e483..b0540fd 100644 --- a/examples/finance/workflow/example_workflow.py +++ b/examples/finance/workflow/example_workflow.py @@ -10,7 +10,7 @@ import sys import os -# Add src directory so `import ventis` works +# Add src directory so `import canyonos_core` works sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) # Add stubs directory to path sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "stubs")) diff --git a/examples/helloworld/README.md b/examples/helloworld/README.md index 38483be..0d265b8 100644 --- a/examples/helloworld/README.md +++ b/examples/helloworld/README.md @@ -1,15 +1,15 @@ -# My Ventis Project +# My CanyonOS Project -A distributed agent orchestration project built with [Ventis](https://github.com/ventis). +A distributed agent orchestration project built with [CanyonOS](https://github.com/canyonos). ## Quick Start ```bash # Build stubs and Docker images -ventis build +canyonos build # Launch all agents -ventis deploy +canyonos deploy # Test with curl curl -X POST http://:8080/main \ @@ -41,8 +41,8 @@ curl http://:8080/status/ 1. Create `agents/my_agent.yaml` with the agent interface definition 2. Create `agents/my_agent.py` with the implementation class 3. Add the agent entry to `config/global_controller.yaml` -4. Run `ventis build` to regenerate stubs and Docker images -5. Run `ventis deploy` to launch +4. Run `canyonos build` to regenerate stubs and Docker images +5. Run `canyonos deploy` to launch ## Policy Rules diff --git a/examples/helloworld/config/global_controller.yaml b/examples/helloworld/config/global_controller.yaml index d1d82ee..c3d6ffe 100644 --- a/examples/helloworld/config/global_controller.yaml +++ b/examples/helloworld/config/global_controller.yaml @@ -1,5 +1,5 @@ # Global Controller Configuration -# Lists all agents and workflows that Ventis manages. +# Lists all agents and workflows that CanyonOS manages. agents: - name: ExampleAgent @@ -40,7 +40,7 @@ redis: db: 0 database: - url: sqlite:///ventis_runtime.db + url: sqlite:///canyonos_runtime.db # EC2 defaults for `provider: EC2` replicas. # Keep them here so `config/global_controller.yaml` stays the only source of truth. diff --git a/examples/helloworld/workflow/example_workflow.py b/examples/helloworld/workflow/example_workflow.py index 693590e..624cd38 100644 --- a/examples/helloworld/workflow/example_workflow.py +++ b/examples/helloworld/workflow/example_workflow.py @@ -1,7 +1,7 @@ # Example Workflow # This file demonstrates how to call agent stubs and deploy as a REST API. # -# After running `ventis build` and `ventis deploy`: +# After running `canyonos build` and `canyonos deploy`: # curl -X POST http://localhost:8080/main -H 'Content-Type: application/json' -d '{"query": "World"}' # curl http://localhost:8080/status/ diff --git a/examples/portfolio/agents/advisor_agent.py b/examples/portfolio/agents/advisor_agent.py index 33ac8fc..82c9f6e 100644 --- a/examples/portfolio/agents/advisor_agent.py +++ b/examples/portfolio/agents/advisor_agent.py @@ -3,7 +3,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 directly via boto3. Token/cost telemetry is recorded -# onto this execution's future: hash transparently by the Ventis LLM +# onto this execution's future: hash transparently by the CanyonOS LLM # proxy each agent container's boto3 calls are routed through. Configure # with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) diff --git a/examples/portfolio/agents/intent_agent.py b/examples/portfolio/agents/intent_agent.py index 4bb915c..13847fd 100644 --- a/examples/portfolio/agents/intent_agent.py +++ b/examples/portfolio/agents/intent_agent.py @@ -9,7 +9,7 @@ # # Calls AWS Bedrock (Converse API) directly via boto3 -- same pattern as # AdvisorAgent. Token/cost telemetry is recorded onto this execution's -# future: hash transparently by the Ventis LLM proxy, which each +# future: hash transparently by the CanyonOS LLM proxy, which each # agent container's boto3 calls are routed through (AWS_ENDPOINT_URL_BEDROCK_RUNTIME). # Configure with env vars: # BEDROCK_MODEL_ID (default: meta.llama3-8b-instruct-v1:0) diff --git a/examples/portfolio/agents/metrics_agent.py b/examples/portfolio/agents/metrics_agent.py index 253a2d2..3a280fe 100644 --- a/examples/portfolio/agents/metrics_agent.py +++ b/examples/portfolio/agents/metrics_agent.py @@ -13,9 +13,9 @@ # `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 +# agent's own entrypoint-mirrored path -- see canyonos/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). +# dev, where `canyonos build` only emits a flat stubs/ directory). try: from agents.price_agent import PriceAgent except ImportError: @@ -32,7 +32,7 @@ def __init__(self): def compute(self, ticker: str, lookback_days: int = 365) -> dict: """Compute return/volatility/Sharpe/drawdown metrics for one ticker.""" # get_history() returns a dict, but a Future's .value() only ever gives back - # the raw string ventis stored in Redis -- it never auto-deserializes + # the raw string canyonos stored in Redis -- it never auto-deserializes # non-str return types, so the JSON has to be parsed back out here. history = json.loads( self.price.get_history(ticker=ticker, lookback_days=lookback_days).value() diff --git a/examples/portfolio/config/global_controller.yaml b/examples/portfolio/config/global_controller.yaml index 60abb9e..8e6ae1a 100644 --- a/examples/portfolio/config/global_controller.yaml +++ b/examples/portfolio/config/global_controller.yaml @@ -1,5 +1,5 @@ # Global Controller Configuration — portfolio-analysis fan-out pipeline -# Lists all agents and the workflow that Ventis manages. +# Lists all agents and the workflow that CanyonOS manages. # # The pipeline fans out per-ticker metrics computation, then aggregates into # portfolio-level risk, then generates an LLM briefing. Resource classes below @@ -7,7 +7,7 @@ agents: # Stage 0: parse the free-text request into structured holdings + lookback - # window (calls Bedrock via boto3, routed through the Ventis LLM proxy). Cheap CPU, one + # window (calls Bedrock via boto3, routed through the CanyonOS LLM proxy). Cheap CPU, one # call per request, on the critical path before the fan-out. - name: IntentAgent redis_port: 6379 diff --git a/examples/portfolio/workflow/portfolio_workflow.py b/examples/portfolio/workflow/portfolio_workflow.py index 8b0a76a..c8aefd9 100644 --- a/examples/portfolio/workflow/portfolio_workflow.py +++ b/examples/portfolio/workflow/portfolio_workflow.py @@ -13,7 +13,7 @@ # lookback window before the fan-out begins. The whole JSON body is splatted # into main() as kwargs by deploy(). # -# Start agents first: python -m ventis.controller.global_controller +# Start agents first: python -m canyonos_core.controller.global_controller # Test: # curl -X POST http://localhost:8080/main \ # -H 'Content-Type: application/json' \ @@ -60,7 +60,7 @@ def main( for t in tickers } # compute() returns a dict, but a Future's .value() only ever gives back the - # raw string ventis stored in Redis -- it never auto-deserializes non-str + # raw string canyonos stored in Redis -- it never auto-deserializes non-str # return types, so the JSON has to be parsed back out here. per_ticker = {t: json.loads(f.value()) for t, f in metric_futures.items()} diff --git a/examples/text2sql/agents/sql_generator_agent.py b/examples/text2sql/agents/sql_generator_agent.py index 4964ce6..3422b7d 100644 --- a/examples/text2sql/agents/sql_generator_agent.py +++ b/examples/text2sql/agents/sql_generator_agent.py @@ -11,7 +11,7 @@ # `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 +# agent's own entrypoint-mirrored path -- see canyonos/stub_generator.py). The # bare `vllm_agent` fallback covers running outside that layout. try: from agents.vllm_agent import VllmAgent diff --git a/examples/text2sql/agents/vllm_agent.py b/examples/text2sql/agents/vllm_agent.py index 1d8141f..020bc91 100644 --- a/examples/text2sql/agents/vllm_agent.py +++ b/examples/text2sql/agents/vllm_agent.py @@ -3,7 +3,7 @@ # LLM backend for SQL candidate generation, called remotely by # SQLGeneratorAgent. Calls AWS Bedrock (Converse API) directly via boto3. # Token/cost telemetry is recorded onto this execution's future: -# hash transparently by the Ventis LLM proxy each agent container's boto3 calls +# hash transparently by the CanyonOS LLM proxy each agent container's boto3 calls # are routed through — same pattern as # examples/portfolio/agents/advisor_agent.py. # Configure with env vars: diff --git a/examples/text2sql/config/global_controller.yaml b/examples/text2sql/config/global_controller.yaml index 0df046d..51edd8d 100644 --- a/examples/text2sql/config/global_controller.yaml +++ b/examples/text2sql/config/global_controller.yaml @@ -1,5 +1,5 @@ # Global Controller Configuration — NL-to-SQL staged-validation pipeline -# Lists all agents and the workflow that Ventis manages. +# Lists all agents and the workflow that CanyonOS manages. # # Each agent declares a distinct resource class so the scheduler has real # placement / batching / admission decisions to make (see the `resources`, diff --git a/examples/text2sql/workflow/text2sql_workflow.py b/examples/text2sql/workflow/text2sql_workflow.py index b8ce45b..a65891a 100644 --- a/examples/text2sql/workflow/text2sql_workflow.py +++ b/examples/text2sql/workflow/text2sql_workflow.py @@ -7,7 +7,7 @@ # 4. SandboxExecutorAgent - run survivors on a small sample, vote on best # 5. ProductionExecutorAgent- run the winner on the big warehouse, cost-gated # -# Start agents first: python -m ventis.controller.global_controller +# Start agents first: python -m canyonos_core.controller.global_controller # Test: # curl -X POST http://localhost:8080/main \ # -H 'Content-Type: application/json' \ diff --git a/images/canyonos-banner.gif b/images/canyonos-banner.gif new file mode 100644 index 0000000000000000000000000000000000000000..0a1108760feaff3112f9f8d87cd388b299e43fc1 GIT binary patch literal 1122031 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
_!mW7Y8p&hhdEv2-4+{P(O^P{QyG>N{C`D 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

L^Kq_}Qh%geVl2 z0meqb^$_tlKQOWEEEAOox%nl$#U->hOT4wND*i zLNbicemp;PUNnlSb_6L@{wO~Vg=7LKGKhuI6x#0w_v|#E4V=w$c~%@%QCqJ zRqtkz+}f6W11@cp)3CO|xK(^f)j{P>#&(vXfN9arpIGnE4?A6wie`GOwBXEk;#qx$ z<9iM2?hFv|$4)nk;7{p3DH~DH%Bfw+{d|Y%;v!tKk)DC-(M-VwJEzNSMVC7sX^*$! z_?u(p@HmM~9D?=3)Gotuolu@7E9FJOFImJucT0XIZmy#62MbF@+{c<1Mqm~1;?4X4 znodgv6LJMl&XmKfFox2+-s=H=Bqm!7u=K*)=gXItboQh{&OuJ5U^U z5yKrlFZ|&W=o9jKy0GX@VM<%^1#DmlXcbhZUIGH#cETYs-D@?du!nlWQ^~IAGHXnN zvBjVT36=g{MQ&{V5;=1(Cxd9BtQ4 zga@>$if#&sMhMFPB;fcYLu9A5}g?tj=3l&g}jhR^9w!tznp(p+6qxtdtr6nq+!XqFo z!r(hBFh^DU*N*-2#gtuyu^x+=v%9(oqVEv?XI{CEuC6XoH(gSfxpAhGwl_6(YEBVS zHP;G3k{PcxG51|yeZ#g(U*UM8ed`TgRx5s*F;2ofuA?mOF-#Aa3_r1K5zTzudBJDF zpr!?h2HL+*dM^*X{Z$~u6k$ApFdAe{5ypdvr?*~wTs0C+D`If9W4@YFvVdPwl{B|% z3sCxMl!Uspbbo*ujhRQu^^RK(+_V9kC@Yd4ge6$7cNE$G7z@D>N#bQoI3rJ7*PHtx z6+*;#d7c7cvn1FxzV7Df;Iv+PhUPle(Dj#k$$$K_BC;Ey@J%iUcP`f3h0gp}EIiv^ zr$7I@9km<&`s2LRND+?k-VDqtOcP9__@Fy+?WA1~5qKRos+K;_Z}~JpgC*$&;(WQP z^Rcs2|1+NU{T~xAGDl{cT4@g7g~Q$13N^HMNCUM#w6j`+C*z!X?^*}9*}@(3Umern zj7y#8r5;JEXF5QN*sa9hU06z{CxB>6S<(1^B#|X~xKj9M-fu;Vu4)pPyd>Xo3h^A;LP z@5i1#hh>4!?>?!PCUD)Zk?W8rzZXxNYdm;^2p#|I5sxC1dM}iQt0F94#== zupZp+=%Z1sFd7Fgoiy?#He=2QUY#67!#z+%JEkU#8VE{v2gx3mNk_j)^fq z618@meX>Q3lD$$;MniY`w?E=Ig^F}-xa5IcFc6^jk;~QAL>|g%XA;T9=@6P=$m!(v z5qbuoz*iy!65CR^lF-RWZujmcYkhWYbRMs6qwYM9?%wJ=J|P0ib4GYKkKOzd4N_pC zH1EZ6KDtkrAILqKzA|KFOC{>_xq^qKjH40S;P?`x=36U?JTzsKI| zHYqM8O@@2uOZ{Jq-D1?|QM~A|`MirHW+RMOnrPRUaDv$UJZ9VwwZMs1Pt1c=x0s5R zR+akek2PNZDaxvFZ2htr(?5<9X`6Pu6V|<#x*k4YlY|AZU}AdTr&xmf+>(QFdF<=U z-`|4&Mb_w&)PGNLFnoCQ-s775O2EYR-yZ|a@B)cM6wE4G32cZ$!-!GA_0!*8U3xsN z0*-hl6ZaN)bD5jzj9y?(9@>!tPUYQq)HgPJ&m5SecCcYDZBBSID`v8(|9wzgK{b+%cFUpA9t6>|$ zX=NZEbRnelQDgnR8wrLO-`$7jcrFp1aVMgOun#W4^!dT4uj#(#zN_{gy$iAJ$@oS$YZseMHoW zjQx&?hsg|kG98jrKJHU3VL4{t*q+~*%9OAnaHg2blcBWX_bBmYSX9TIwL?ZV{Fs3; zMmJ9|bS7;oawfV)zud%(h<=GOjO$|%{A7&-m|#c|{Xr7Q{z%)gAaCUh`Rp+)uz(dm zC#=p6Fj;T03yjgk6ple`$s`8h9{{tCH5lca1Sv&=@(8F(W_OBe$?i1WnU=(Dpa_+( z5JULV!8}7GMgv~kykACRH0X>f`3@w>ZJpOJtHD6rc7D@&OBgd@{9GswZ(+jH)!UrPUEUskO*X6ffek4^N z5T7rgQDc>{RSEyzw1oc9BWE(a&1?q27>`BV7LOY8v^OaE_NUNrWGh_+aV*8@VTzI^ z)xcY$-2;h6WvcAq%3@?GL$|hq;(7m01qR>gT;Hb76i4<*KeoA=&tv=cOqxc$TX7b; ziFdcTfHPz7#m^EZng&daTwe3Oz~y$-Su*1wdafa(8*neKy*nrC28r;ICzUMojYK7q zv=!#X3|jYmlw6tkw)80K;oIrQvxc+p*CwmyuP>S;|6vvLtcE1+oZ7W>7X50z^896?YX^-$YXzAS9s8((g$Z# zk7CSd0M$pf)BT@W9wS?9hJ!uEn`ejm_O#&cYLau&A%o|Z??AgC zBAf3WrhU3Kl_^&{iQ9c-w8UlOmJ&zJG$Bzjhd8LFaeQ*=>YdMbcP)&&44Y*ZXk?L$ zzy+-%Q5(R(Mfr!thr-{WGudhjZf%Laki*Ne(!&eE+jCfs&$V*&_-e3jFuoJ54l#`F zQj68N)c28JR@=%F#flMsc5B84wuX3j)7GiNJQ!`vF7((C@BKQ%#NaUl!zBY87_}w# z9eD{zj=X=>67I0e3OTfIIc}NWMA2qLMKEdYg~GslgF7> z@@LgW{~O%NDd#gPYB`0jr{Hq?T5D&!InFBMQ}xOFU`hwwVe;>*kc*ldj2SUP&)iN; z1{p*{sWP5S7LlZ{8hQlaG}p*)%E>j81C_B@XQ~8E_v*&J*9z}a&P5KgmsLPAhIjy3 zk+3P4yW$0J8Y?~@W=w|kJx_I$#09KPY+Lk~>0itF!E^CuzHG%tU-*ri zM3m`iSQr*D3`UYp{=B<+g4lg_^7q^c96^9LCGvWGy5ytad0!};aYYNA2wf?*`L*Mj z5T#8)dA(Bf;-6<)-*iFef%%U?PYTV2sSsC2?;_zyMZ?HsfLRy|mh<$^+0gdJ5?gGE zax4FPD7X?)yzBaUbG&zl8HU((Wz(d!jhGbU9I*CL8lBMll}4WJyw=ga8^ZcTCw+%? z-wX4};d6=i4dhaZPQ_JRzc;wtMw~o$3}Y&|lMGSG4*pJs&eIW70 z2Hm=CN?kGqsAfmCf)z$5X4R=@8-|qki$bAEz|%*e59vXVyFDH^WcFf!qb+#$X667s z>nIr*gjmUn`Dev~`9=B!dAPVyqrW;{35rh`hu%y>qx7 z340{@Ns_1+&8`t_FUuH(Liymt{m|>=qsxrL8&Psh9P+o51q`BHhGn9tUXh}XI-_Vm zL;b+ZoWaAW`*V(3G5NoeW4?MjeTq)~Pb4>J3=*yaw?gW;vb#7sIbVqLr_2))77Wqz z&f|&DlQE7*&rhsxr?|u$F{sd76tE2Q3X%e-^nxF8iX8DK`GHNGH(!r&5E%q=hvJKT zO>d{V_W1Gle+{(t0qn_FO6!$tQ(eoAQSCLwy940P*sLzb>)m44B|XpIzkL1SamqW< z&<{!;?*dBK(>&0X9*Bx;GBf*I7)}^zagv->Prk?xx_Ro;DjVZh%4HbxAsviaLq^2f z7E#Vb6udZ`bqAB-ka!(zYfRH#>qsn(wAG)F@W&ez3EP$HMPAI+H%jvcPJQte-YUD^ zY8W4mbs3hJsH~wp*??p_KPDZqd~>|7%J)Er5?{q3-wPB@Zjj%Th0rV3u+-cb7yneF zt(f4k6I94>dANyxYLRxj-z|a4G>9sfF@bWKF)ppUD0dEJPDELnqOPm*b+my=qkP3M z{?s9qx+i}=t!#RSpDHR4&XZAs7Ca@7(_Cg(c-&e_&fZ9O>pvN9!J`%8?y1{EYjmOvQ}eY0QntJub-(B9zn8IEV9eXoH@SVkxay=J zU^T`njXMs-Xd6Q?c|tI2bS6^$`3K6~h<`9;Y_HU$9VkLDO~L0p&)!?uSor(*@L(EGGPMP@fG$sxa{XL13L2h?vJvE z5@Mr@ci{1LT`x)AOo?AiwEOpLxJqul(f1nOOZE1uXG@bq-In;WSYQ4LYQPM*>y~R> zE>DrPXR0A97U#`=ZIEGdxRsG?_Cr^-OFx_%Jv)4FbK~Cos+0q;oTZwdvC|3EyTs3; zzRA7I;4?GeC0^0ib2%l?Fz6cwp4^qv^j3b9%XQqe#Dfm3 z@Rl5b%6yYdyO3u*+;F7Kg!sJ(3qSM(B#ZQwl>b+N1<|{pZ;;WbDT@t~F$+LfO znLB{+sJ=OLa$S3&m~pIkL{ zyIyMM{<$Xc$rCRWuuw})Z!CGTlC{bNJdE(6*46wn^C*ahS4DtkbwSJ#z>mb!ZYPL? zIO3}{!VQc@UIXzGMtMC)dFMb^T1Eu`@HH0jO&0LHiUxiT`^u!Yx|CY$qj3~h_Pwdh zV@PVioaAXs8uMv`GHOm_++jBIGWQhk+w{y5n!p}}V@yu&rhn??ROG&7lZq0LWJ*8x zvxD6?Lg$hr*qIwAt)a0Pu-HRX3JNG&-oGQ(4z(f>d>QnIzr-eKy z>U3IdbSaVbnqyR*XLOF_Tg+y_i_I1>TrF9GCb7Ujc${m3f^>w*jwi<;Mm zKI8#%VOO>;n0+yLVty^7luu>+D}Us*(P+2PxTzE7yQI;peWTY|#%{8N5kF-oPc0`G zh7?DDW!2nkCC*pg3CZ3QIwJ)EXZC#9g15{GH%p1gPXjU20GtMZF{J7_z-aSD0S=V- zcH~mu5*j;dd3`iJY}Ed(o8EvGlPmb~f#nm;oEOFX3c$F=AcKmhmgj=C-+s=RCQsC$ zg87{1^nvEzZ{oiw&y%e2jV$a=)Crld5c#hY_MT8jqFr)?PJcP{*<8o^qr& zJc{bnj$w2Sozh@@cHFDW5AiM1V3)SryM!@&Iy%q_^kC1WXv4NT~LtdbRYXORjJn1uwK9&!rg7Cu<{ z`24mJ{I4p!&iVxmxu_5Y?0YS$XD@0!STs5e(hM8Y`L}R5AgHHp)ffT)9tj`0T;2BL z$};aN`!T=0&MMj(Av73VX3l}Fz@DI?wqIy503M%j|&{MRsZR`DSX#1S3ij~F){D;yNc`a zjn_}EpX>W8;bJ}ch~*9PO8NF3q^hR<>nFq<+o@kTrq~GGjh}DqMc>YRnnDYKjl`#a zW}@%(bgurH_OxDe_F6*;q1>r!?=z8%Hb_bTa@zIS3xs#~y`jp6|CM?F_v^`hw{Kk0 z>yWYmltM3~YVoXxRGRx^!^f`-lG>r~nAyt*=(insg|!An`*_`gKE6s`=l7VOM=Q=1 zx(i%?exFmXy7x(reRrY1fLOWG-^a=-IX4?U0j7vWE!i#Nbf)gp#b(c~X4+>(jk78@ z;FcdUYOLW+boCKXZT@!vO%kw^Jz1r+ZoB4yF!XKDryzlduRi}q1tXS)0kACW8x9M` z&0U*T9oF05`F!CaEaLj^(8|4Unf<$PT$OMW_Ju*%3l?iQ3v#oX{+*5fqR_y!z+l8t zXEpQEs9Wo*0BJPm-Y6fE>(*Or{@*7{b+v$Jj}k4k>_HKSE2-jo-!TkG#)_j;A^eZR z8!acAWo-E?86Q6255PgBIkjx45TUOVPqhELKX^c6d(-vjwE(yMu2ht3kzL4T#>s!C zQ58P1a~<>dk{53{oQ6$bu6cw}CEY8SE@EM?vK%+q!~Tw(@3EYJMJGm zj=f7lrG3WbK%<5$p%hYpw&N|1*lP$EpMTbgTSBi>(&7D)nua8{{@tDiQ8vz!EE?@Bs=ohl8} zWa+16{xp!AKnou)xJs=%kf%f$gzDqrzd;y`4A4dg}w!BPC;xXwN9q+a{j23-AM$~#l@@tmEN?60k= z%yYfS&dhU9k2f;qdVYAP|#bbc3%wj-xP_ohO$1Kh2d|mQJ$drX~A!I6s zz0XTiBR<0=TWhP}l6AYW8Ti3+y#+(ls4;|SZV5#=t3^7gI){rVkQq7bJk9I`GTAyE zdi*&(dfdJQJ}E_^siE4V!PKja*GNvXv8e~nVMgSy(lPtA{9}@hZKLKXSr)cU?BN}o zMBCDnq(H^mpQlKJNONiVDcR;Mg>m8DLxo|M)htB@t%5HFWDS4^IFFB~4KN85B30kw zw^+O1DJXsDX1f#3*0X2CzaBz0MYi z!O2?Hf#m=cA3H=_O0|k6&!`_2+RbWx|H3h+i*WcdXRu89QlY|@q;%PIyL%~HWsSP| z&+^^wmqlyi^3CkLF(KRLr2EabP?Aao8xVqJXpP`x)cGUH;`swiO%iP}KutPtZYH&t zY_|ji2_Y7n93Qo&JlKzHc72;Mh`v{m0=;I+%B1`J7eI%mThyEgb4PObX$d36vxgAf zckb2Hot9Lxd||+AW-CTUGuMi#K`H3RAtCi63jy3t?*s3VkP;_ieht&O`n})E)*s^iN?8X!7gkp=(b1nnx^Yo zEb6a!QE&IF4vbqyG;!P;jH{OoUAMZ*0VT5A7$ljlIy-kv|{{ciJ&O zxI7qAJ8rQC=I*Y-N~jJI5PDgZo>hmwxX2-qfsiwLg$CMAPmZt_1Q7sjLnbk2F&4lw ztdo-nlET9oG7--9bqbu#o~MPINT}2c5xZMA4zr9GX}m{Kw+m_y<)T=!CX>WEMh-0k zt|eRe9h-FAACL}gB=}}ez(~McKwg*2tCP@s#dqM&B)uZ^jHrC0=}v@##u92kNnc%M zrjlORXyNCuGy;CF4uLmZztbGJmr?z8v^b$)^J45z)t)Tx2$(HW{xn=Us(n8A>3DAH z)N3JDytVYxzpr+zYpOz$&d0P|+YSx{X0Cyz7+c8#w{C2STwZ961-1wEasVsfOU}Ik zjy3J{+p}?^vqD#}7tnxYqe`d*f+xa+%Qjv+c`&^)mVKz)qOZOtf41uV^M+s6+fQ?Z zRYb9u$pXO^ww5o~Ybtm-8-d+iRTe^>f|&Vlq*}gu*1DtjSdrCLQG0~9waqB|(tYce zC4c4R##e)my7d!-cr9IInCK+F^#l$mtRmCY-+1*7_T-(_X=Dz2JE+@7TX$SX0Djsg z!nj1Tm_{Uf%%cM<4=h9V)^djPwUR4f^S0`KCLo(8mnS;RypmPZmUCIegWjz`yF2LD z*xfQq?CqF|TInb&cfm6KY-i$U)!|m7Fc=M{LsPs}MuxJX(%A7vn}z)61HMtk9j2{E zDv*_*W%yzL2@Vp|oo)HnS8SU1c|b&1k>Df%(_~*nlX8l_nqPL{!WNcV^d?KWiS++= zXI5?n)5O9R(?44vW9a(6Ug65`daFMAM&DuknV98kHuJaa^RsqW@w}`pq!cd5)DrAb z2vO+UaKM=_GU%IT{181+pakJuC`qLY$=XMLLgyn-1L6<0Y&c!f*WGv7n|z0D%*lpq zaV77vKh~cUsO}uC)mYYLGojthG-Ts1){h1enMCDWSbBa*Sj$mtsvPaB#%(1#ETqNR zb0W^aXxHm%dMwk_MR40<|M{e+NnbU@pygBh50gVEp#2z*X!dH>X9w+`x`NrQm6WOq zynm!9XFZg57WI5|rASs|%NuAgSWu3=0FX#+A=hX=pYs*Qi}?uEI;{TkpN%JL$`tPW z4fWSE54Mfg6ve7hcAT0+RtZOso)#bB5<3!KvH$KG^`BwV7=P_|^6e!(@xRVTjT2v% zJaNDG{htPoXFx1Yb~sy)E!K4G+@IZj?YqBrsn-yd7{|A$(q8^}snBKZbJb%8{t_Y` zW`MQJN-q1SWAoW1xoD0@APp}7s0rNKPfwmit&ZoVAPBG81k!-ylsSR5O0`TrSk`2H zMlO{+MrNMkR$XOR_yHEeHH2R4mfLaas(~fq!P-~`*8$FZ`Pj;y9v1e(mQ4J)mj(VVO&X=e zZ6du=o*bM_Nu3BqT_Qn<(D|GXBiPUZwe5KPTT=D2kT6UzLH#y)4y<*jFw&I3g#)l{}$6wxkYDfdz)CiKrwocm3$J`%}ZQLA=+uKjXO*`qi6;vE^Ujt5(WF1ER{ zGbUYYW>5VYWd8%y>?LKJtognM-r3PhHisY>)L`Sg2FrLLqDI6`K~>F+=aY{rRQH*8 zMmDqaGiFfN_YT#7ja;^$02`fjX3Y4i76_t<@{fM_k8G6qJegalJNy%#4?C1hF6SpD z^9P!w`qZasQYZ{0Q6d4#AjmjLle)rvLxUo(Mj@EAno5urT#_Or)C)F?57aAzgLI2d zq~vwG70~1-8BGJFQrrM0<<6kG#hWMQK@8?X64<3Sp)Y6EJtcq$iITygJWPrTouGZ% z{SYD(06%}O_l7;Pe?&`K~>LEV~YN%I|+mjS5G`aJW@~p|?YDtf;nTvUXyOYgx zx5Q*kZeM;`xzVPdi)2p2keAh(O(UV?IhBgtbpCXya%5jmd$azeR4uK_*x(lBijtXH z*-np2eM=sy;gwh6U-;*45JX(tuqE%hIHX&VBkG_$RRR`s01Pslox{OMcF zKy4`J1-_+9E@K$9_bw_)n%=)n)w zqoLC+7IKf@b2eiW;8YHH7&1#u>Z50H^vZ9+5?D+%F(_f&Wd=siG~qbI?2O$F^N&pV`h<(ixDfiT{tyt0lC{;KDKWt!NH z4jaHi8fn9;u2@J1r6?Kb@+pleX+3YQG7L#fYnzfu>dQN>yL4hUH}l4T`U4W%k*O*< zY|W?qhjz=s6Kdv{QXUBf<+AJ!@=Tu^3=$sAOUf*nSu2Q~_lS}C{Y5#Ux@LO{Fz?}I zBN+Kws8}`4_+RS^;BO6W>PizbeFP>w4=oxq$tDw^5Tt6PKI!jR?XjJe9p;>^v+8%2 z6c`17ncRg{SjE{pz{jnARP%4sN$k$BCzXkN5}=z?Y%zZyuP=xtZPg_)kV5&YBh84Z zF>|R?AXw2@UW3V?1(oGNH{&KH>+_h-AvqsE&nv-TEL4_4CvjK`p$+h=5|tW3Hd*DS z7>N0uLiH?3yVHfg#-@t(kcr%OvL@C{K+#N4f8zK=t4Bn}MZ`{8iHd=!!BwM2Y)WS}!S3j|& zgH5X7+G!v_=tcQ8=1AW9kwa!ZJA)dNlA-FUkpj$saQUx>KKC;YwQrvyl7=O-{;WA`RtHz@RhHA;4!4}6DR4}>6ZOm`g9o~T9 zQYhbckr-~!E!8EqEmU5{%hYm76C21Hnvc+m5KVFlmP|3*&`NJ0L?m@hm_O9BllnM` zXLba-d!+2nm|cdN3y|i<>_@j-9ALmw2#c<8&162GVLOBs z1C2ZaZ0G=jM1o;btkigsY7Va{$SsJ9W0(VL#B#HI*NTje-yfW-K;pq0w%K`yAgx=w z9k)WswUZ)_j@Buz8Xh)wYyR%D-?mym7pyUjJBGPGTTL4EOM3RB+4yG!R7=r1=Bx1$ zA&(ULB2kkP-w)CdL;MgOVjO<)YdbME3ML&d&T{k)yK2IgLd}kbB^xT{7osmk!6@1s z=%@k}oB7*)&RP8&+1t*%L#8c=iaVKYHLvF@?O&>SzAO|jstIkpt+3GNK2glOcRK32 z`=E$t&E@{|V8zLDL21l<9dD&fG{>QOZ(~!FxTksAk6pQlcl8F$cvDytk6q{Y2MOD-4(PSV!NwwX6BfLfy9x`xlhmol>@0o!>@S zj%-|gyVkSqHgo>{fnV_5e>1BsM>n zI9xBKF35HQ0g%~XnOM#<_;~=N$cuymsCjj^;J4gcJcm_jcyC`{!A6 znoc5THjz$9uUa(r;#)4!0G}5VH=M|YPNhvwDTNoeV~O$iOn~DT@4Mb3w#KsN>Tf}S zb0*HW;0#3;1r4w6G09f47topO#xd7+^-7;8Y>%hQx}3=gubK+9;_=G4@v?3z@^|d| zznt}x)P?3d3Ve(gDzuMf##SB0E1}|=HJlp~w4id8o)kQp;|Dt;O_N&XEksuYNU!=o zH;7X96C*8pZ6mn?BKs2}hpR^>uW6a^(Rw+*neLHhuC^>$91Qv$m^iqJs75_WmF@B9 zpGg`XI;Gk?R_<;sav%^K6Z)ckZEGlMXeG+o&wcb8u=hr0wcp|J?%Ro-^IQAJ{3mKK zsnoL{6g_+ykG5;07cZ$U&3x7lIJ)@xGa_(%b)+mpfY}`VE>A6S|4q(Oyh_2z5!mk? z_)AcBqZ7XkZEMBJJT3Y6^t*!(<-4u*C$%4}Zc)!<1N%14+05kqf&U}#tox$s0xf)| zVCbP6hi;@Lb%q>zNQaaXDUlWsb%GdDIs}w%q!AQ+hekl91q7uMy_RCW3Q1zv43-*PX%*+Q0R&86>Qrvg?Wu+RM~ z8vo*o;VdMN>pmcHshk{i?t^eyZ_B1kAJ)=ezKu`(;sYaiVx`sKMdi$)XmCl6dlyOR z1q9p#dwnmxMig1aC%E3Fv-dRc_uY7qL3gR@lf8x?zQ$VQl55dCt9LWF2QOXnB!~wa z$NF`JZ^Yc_F^9=6o$EPuS<1kUGiLgFq;gYei?^h&`MYU4%jIr9vKU%#y{T~d#P9T) zujawwimBVjZ>pcK1}#0BxEecI8M)mP;{61`ljYrzu|zf*o2KH4J{Uctf;Ya}Z7nX5 zUo~`_0*aLe1neUU6?%mMW-+5QdHYgm0&BiGB__e$irtA8oR+`^ME%$mzi3nV4i0B4StuqUv@Ni}p5e!a9hg>XMYWF=|Sg+C(xiIXJ zQxg6Y3=a{dWtY1C&!r|f7Ln5zOHDJHft$#YP3FGyWvbJJQ!eZB|3OA`|T|A;;b_K-{ACvl2gHQ|uiWPm(#zj^=DFYAwn`TKgK?^b{%#5IOWIEzRE zOMCmAgLg?goY^z|D3epb$E`Q$uDaz6gR&`x6sv5iL!##$1$5)(@yB2~p+vQdl(C6I zq?N8s(@Bz?@yw*Vw!AVa!nBrGhUM4V#Y->%3xK3pTtIeTFDIw2C`MXLqS+mYie!i zjg7Tg*nqeQs09EFlXa$;T?wLgxLZZpIr!V{N7e$6yP&!rkuS3!*==kD=UrpCRmqWS zC4ZLhW;7?;-+CN07t3?!rljM#oqeR%$Gl!T2hFM{LD@@&;GUfy#V{J8FMU>=H7-upEIT5Da+r# zoGZ*=7EBC*NqBV{Y!VT}J`E_RLt>L|sJ#~93)mJU%XcG$=*3F&N)x4Ck#|hNX#C%){THFe41v2dE+DbKyen!@0^2CP-&X14tcA+cQ7C`lk6LjQCt|5gi4MFIVv z4ZI-}7d5!01T#^EX6K4Q=x`z&zLzBWz64Qo>1)(bXSn#*zzY&5e7d$Xc&1Gc%63X? zCI5s%fyR-*)B0y@6f-j&0PH(lC-QfctygHUDLJejOV+U;XE`Ugilz0COzQ-h&^A;} z6%9#&btzvf0YQEs0Q)>;l}_?ZXVj`NgH(-)m9i6j zjnrz1=?vVIX5dY-e=O15{_VRgAzK%ZOFu{TbC_{){@$D zeNz0K&)#};6vyr*GbDTI7A(#Ub)2MmvR&UDxs?cs`*%!@y2pxTKJ6gu$B3duzUUaX z;Rg#PLDVPiPL}fJf{aFTdY8R><>Mnu$Abd&<@T3M&lS-u*Sm|&v*krpK$NmMSOP|0 z!zv@kQ**OI=8CWR%*RDz;xWkEFGcO}_NkJXXrK8A08=kVAhE*ZB!1j`jWoU%R6%xBFxWNA5cWZ-Ds+@ z88n=IP*!ouB_kGv%Or*GI?AX~yMr*cOQ#5yv=I>V&ae0wnq(0d%hKxuiX1Ik^)gTv z;y*1SUWPuI7BjxUS-SFG)>zP!D5}Q}cRR=Rdl3+cf*ujCytw76(-j>%xs7IATQ%&a zg@bV&1nn;scLg4bAlC*pk^Wah?l`W^L6qosAksdMb zZ0$I$J~MxJrZdjoxcbbWJ_R=l&Y|q~0t3r+m`GDacAcJy)AFeuQUr|_NFdS& zEsW9r&CpdK_tMRu5r3y*LiTO572Rf@oppSLhGjJ{j%t1=3|a%xZ(BBTAT%@Q-iFuW zn$+q-D}w(q7EzRLsb7*Yw`~IVGa>k408)Gg3V`a%T#o+OtPyrYtETq_uu2qyK7-28 zCG0k}ufGSg$dSlT$gK5K9@anS9d!>RBYG|RlGM(>Nc6)-vNJKG(hC(?6|!M?h>x*# z!5Z73*7q8VD_pO*?tX=caJ^3fD8!%&RLwT6Esgt{S9k@A3ddh$=q>*_<%A+}y2FWk zczpSi>1Mu;{2Ci*C8n`$_M)mbF|a5EyE7Rpm-d#MM{A2yP9Uc}b!$wcknW=Z(@;Rv%II>3iK?yGO+6TcxDqUOJ42OTRV( zbNagr|A?f&=d@whtG$;FZhxo~A9LT$wVE5kl8PS%nuDJ$F_^Hf{Gt)_goT++Y|3%x zx{Tc4?oo`m9KBv3bXvP}Zu6bk+v!b)i2Oc9*U=5)ovX~OxsE9`9nP7GrlQi>Ats;o zZVgELi%Ko**d6WQ4TbO)-8dv0GoXJ|Pfhy`lr{E|?&xCd{)*08Z{h;>O6xM2K_ z*s&ctQEXBQ>Mg@uT{kT))Gc~_w#Ecwp)p;+hzF0*fGnRz6>(rgfn=R8_7C+9TU^Wm z)0MB+CVMTPE~J;oIYT+B)jkZm%toHPcah~E6c0cWEjN&?A)oGfd7IJ|8ZkZW>2#*RNG;R; zOB|l&eLuznIB!DLNPh4W#9`pRu3&~m6r2YIKBjO=?7SIsJU>aP89h_SmEKgNMjjj$*K6iY|#WX$3Ahf;tjqiX5@{ z${I^W;qc{-xVO9v#!=2vzzoq8V|{L8K?+y~36VpB&`40%1>OdqV}tzg7pfcyWt90e zbZkzJG?J27hAZ+<Sc+1HHj zMbd%J2w$3ceLf&s=Y!-bi=vylvnvc7`>#{Sf-A!UaH3v=(|}O5puyxk`_u`9;lkuO z0@Ta?aZmyTtioe?YF9~FEIf;roNbplz$jjFoWP1HrJWG?=aMfy%hZIR3yuSzcSpV* z4P73lS%LFhMW>Dk0(u}75W$e1nd1~y^rt}ZEiE)+pvY&96v_nz_6?T|v3>s}UYaT$ zVPk0dzvFK1(o$zGcA%4jf$ED*p;o!$Z9#Zpn~$q%XmeAc)CfW^7Q~q`z#%fujuiII z0(ID4->L4U6cWD4-tb#+p^h`)-&*zY1pEbW$OswsbrVz)*L_A90IjtPOq3zM_j0L$ zCYW-`^W&;sX-TAeV}lTV;d+oa-tdpdHI88kFhxsy1Rep^p%s}%5Y7!nKr&nKLI5wB zwx&a?pC1&1wRxYvI)d1R>aAgPb=o$lWk`pfp@ob~4QiZwB^6Wd$Dl9OE~>5x z^8a&F6EO7aA(;PKN4A5s96Yh#1c73Z(c?Jo&=}*ZBV3^|tk`L`&=}!~5@~;ZCj5pD z5pRe`=`-FeY@;n&A=5;}&_rxrFCAhVpj9!V(3e)ogzJeRx$OenQb*{3i6lWZp%*}3 zeL}@q%x(h4bBc_!z$<;S&@NxrRy)y7p=j-2i^!Yn`>)YHz*>-v*VJ*jnjA%$WzBIB z#veMsu>uDd^8(QX>{_feJ4Ousz*;+ldfh(4{CFm@uDubxVlGIO zbFbS@JxH>~%j-U#&tAal?c61)r#|EhROzr+;yQ8^Ez0qkx_ZNM4O4RybbD=3_+pG@ zB?^#5IK;qWS8rW&K^2*L2mRASv22k1Zvtu*dk1f<1i=uFj^T*MnHNj!S|{!#paU(E zI;yFhD0-oIDim#bITJh?NZsC z^{sTTt+?1Gz3AoXbf`|Ot)2q$9}e+qrMwxrrIMnuq>lTi=1U@U7GzC6L+( z0y8^&b>!cKE|ZKaeSx4d6#2AVq!-DXh7l}zBm)p6lL?N+rM`JOe} zWS&qx4!+(SY*Jj7Ada?pc6upxHFRfARZsOQn{w}R{h;H5bnZXYtDUb*hp7Kf{r=lQ zQBKnsQLz6MiHyZ~!#2u!e1@LHRlMYeQPPgl>m$q{BNxO~W=M=O6d+R&Ayd#a4Mcw% z36>f5@*lGM2I0~xk{3_S!W&-PT0HU8PR6NQ5~)NK&4x@G*yn8tMf8sh?aBuQ=I-?)JfB0gI98N&Lt7- z^CV~1hn8e|zSh@^yCee1O{l+~KemFOMRx&oov7^>Ku`O7I!eKpi*`o++W3TW+FYo9uS{($9ynMsk{a5Zw;j7D;J zO{;=r02?rcZk%glC@(22>91h=(IPWPD3p5F`Q-5(aB0>zByMS{A*1sdhu7B7d@U-Kc*|WLOYWul z5c_;_jH9ioXT(b;XCXdro`D*6xB~yn6BIO&#;<^X|Kq2+EI&})_wNZf59&6_zsqsm zmD2)w4juKUN(Nh*6JdEeCT0|g^5##VAJ9MFdB1)#55gM-0_ogVLWLLa<4?!)pBi=I zW6SZZ%EEwm@7B#vg2IhyL2?YyFb4dcowf@V55;beVki05&P`EF+I%-5+nVhuqBlSS zVv1|im+Wmq!U?SMXh$`)W4$74_dauduymc`4JQKIN|pW(0j3xjW#pMee6TG%WG`hF zryjd2X|CuutEhA!qN&vFJYuu7PU`Gso}e7DH^M0&H{)(R&KB6Jq|8Z4(2e7;VL!2v zm$BL(HKb$y*#mt22d&i#%RL8l(M@75c(AL&%tccD^XIjyV!BZzU56{0^$=~icb==& zJf=wD;BDeGT!HZ5Op2OMO7}iB@3jsnX!R@n(|57jY!GY!=VDMx=EX3ZVo6gJqa2EA zqxgcr$Yru39td1O5FrNkC;w!Zd-mGeaO)aBl6#l<=)CB56^@+)tg5(^y_LRJjF?pi zrv9Ob_}Pk1qeMNz7y+ekLRDBj(B%vNRf39hy2@avJ{$f1P#S^tSl{ukWhDbAcaXz; zIlZ&)&}iW#XZ=N>(>o3Qa08+`19b4TRiZl3Wes)e@fBp7L&b`sx zXwm(bGAa*JZupSIVDbQD?lx81Pw0d!*c1iwrh&@eT@HUOU9K|;1EjIP)fw=)o>%hx zf5%w<4yC3@PsrxV;NdU8(24IEwt)Z}u$aLY zRmjsVf#5BkpVpW62*MFW>0_c&1WEIlq#F@qavWnF5$kvy>k$#>cN`ZI5r6MEo)ke& zIwogEBorJcltm=g9VfO%B)vRN>W@esJ5HXCNcnJ_vL2E8^*Hq);?V^X0TP);cap{$ zna+EXE*zO5eUhORnW=e_sT-MPa*|~oneBLz?Gc&dcajqlnVfs?B$pJKmvoYs8JS;j zl3y12xbEa}Yh=O8lY;)p!m*RW>ByoFCq?U#PrjZ!If#6EcJdSwRZMqU%o-xZXH$Ocv|5RRq1zH84^`>@3e{(Rh@KNof%bAa9UFqRa}LV@bVNv zVT!0UMN*!QQJs!en~qbTiPxSZZe-?HlJxR zpM_n>wpz%sS;)0r%)7prZ@>81;X{GbheDSRMK_k7xGg>PSSt2hF7a9}_5N7q^Re9T zW5w-Hl>wis0zXv;uhfLC)ZJaJk6dktT74G1*7#tp>ET*4VZDX4-Ws#f7PrwJztNGf z`8;W}GkL2kb?Zgi*30zI-Irlnlk*aU6tG|ub zejlsiN&g3Vd)Y1CP(Z<}b&G)~y7JhyH@O%5?@y^Qe*Y%U{8z(=uPJV8m z?tVGl`+ByocSgPT=_DW__A4sbl;l29I|c}))nXY*bfNzVdX zA$5r|m=+Nuc!^ZCO+tx|3BA0B`@DPz=}m#Ew*M}rwaS7?5Ff{7+_sH1(>0Tl?efu2 zWXG003seZJO{&8+4o&cs)m?8i=}+XB4jg{e>P`BC%D!}77b%kL)meBwU8E50_Ir*Qi9I;Uf6 z?K5NBo#|l_6~Ut0ZQ&Co*B<`prg`~%3X2pZ)1Rc5?Ipj;losbk;!&T+%A{lU`338* zk1?~6y_@e1C3B^4o?kM5!nS*`Wf|x2?$!NK+Mm~7gi2a(QGJ39JfxT8BcA@+f35fQ zzWij2b%55g9j}$(xY!5UG5ZR3-^y$b2k)Yro%V5g_Noi=ASkKjW$Il)!luw@d|Hx06|`U=}Cl>?(Wea*wZ#BbJE%L*P}HaiTD z_i@6;)4-DB_a7hr=pCNIL)Su)L=@=E$7bpC&uJPab!k)d7`l>&C+eQFWB4Y^d>`r{i+VxpZ&A)j} zqvE;Eo0ju}?3cN};sD9N%nxLZ7hiCG!ms?``E915|6{uAt}r_!{B_03w@;G~U_ZUG z-8%$(kZ~gt3FvRddLLdhxLs$~hI|w1ZF?6Hl48F!d}KwZNxzpaoYWkI3-C@3+6xeA zjNT7Ib-uK+a51eI5zg$6qaMv}-g4Za~x39Ki@w%`VW6SBy2 z!bXp(mCG|!8y1-IuHBdt6_f9+)X?WGgU55lJzhHHY@# zk*DiVX**>hUA3T+aa|JiES8GwR!qf(w8o~ju)a&0hJ#? zYZ*%$=PWi?7L;j#FKc{l-kgpsd|THpMrNrUcOpaRQxaW zDxc(p2oqgK`V3BF1$2kWJ+ePL@i~=-+|SgY5s&@1xD55^FUHf5%nV~H!_73ed$%39 z$5swoG!yQ4-PgFm9hCE`=p^}a6P*KdWmBPOp}wrBV$xsJ=WTz~Wm%#{j6^Z6;97Qs z9~zBhvHl`VEm90DCa~)YRZC^sZ1}US!W)!J(f<%X^1O^0X=I8C;McO^A-ttD78n#e z&sUszkl-iWfeg1|bKIjYLF%w-fq;f#$4^m4_wgf{-oHE_>n}iQdCL>n4;!yMPAYl% zLh-y|0=1V=SaO|Rl+?(d7hqZASQN~d$vGc6St3tl?)5h`knjZEYh%OoPhm#ndo1Y# zFvV6IYQvh3Z5~O4xfpEj8YU9xO|4imcjb3@uLAD(q^&cNo$^%%EMTu|>^DwAW6Da` z#=~=<9wmyAxst^a>IvQhr3NvVqqRqiO{bs6QH;h+3X8M%v&6M^vF)|ohQ-;GfZ(X-LS^k=f3rd}Ii7g)au}ukyvGKI$3vj)mKt z?VmZhyJlaRU!n1VsWq|(-s-(NxTVr2|e5!V-#d9uJWyO9cb~CmU_oZFepXeL6|t^tpxtr0EeB8YiCgv`e@bE*X`4KC-ZNC|H+qpE?2w2I8DB_t7n() z3>p@Fm1*#yaeU?@mQ&x7UPWi%lo#z@AD9EISsK(8)Ai}jIMBeU z5zX{1W?d023G|n5fq4U5K{m%n zB-xHGMOi(4y-Tvd5`o9SSZ{|sIHiG)Dxednbyd}^W<@i@ebF zPNPV<%g_|^>CZnl+OWd)At685(UO>-gr;XB8~4Z6jYk=|#GwP+--M9$x}vofY7x7$Na4F1-#aiCr#0AFh2qL**W?-xwX4W>ABy z&Hw|-f;Rs=B;Q=)ur9a}wPEO>%d7+ZoA_DW=vm@YI3%X^Ua^EKw9L*|S3mSutc^yd zO)i`AUwyb{G3y)Zi!%V6yjUz};aG7$TibiT6fd_Q!Ao)pf8s%toGRIYU=b|1{-#}{ z4I3N^nx9nFYbAIZviP^VHLlqCRw0^@s&_tzGxzd~RbKKfa~}$o6^P-dMF2dVL}Nkl zQvmmLA!Y%dI}L+;SuSvW>(En^;aCr-H6DbD6`Vrq9yh+m9oKbhiwsu@=en; zNQ7;;NA2dF91=tcAmiuZ0p2W4w)T@&fmEFO=TL0=IdG3(5qmxKa`Jwl847NNg;^nC z7FZY{!O@!wH9|(WkR)D<8YmN^HP+;B;;9;rVdP(04*;r5*Y6Vzf{lnt7#2Flbt(+l zMM)mvLZk|h(He!gq~<(WX;(1Z4>s(xwd}X@-8IY(cVS+qaxb=YFSc@bvxZ>s*Ql?e z#X~o(ZS-?}nbagsXRo5Hp)tF#>uIoS`mv$}xB!=5s{1uNQEuu=TRug|-(YAW0bbv# z>0%3ZqPA<$cT&1RNCXm`4!KNCbf|wU%W)x%&jYo&y zl9i_7lidQbAZS(5U^Yn(QCHA)E)Vp1R{T#-kN81{*>WANxQO`YS8k3(=o;|+oYD(f z)kv#iBeBK@V4?99v8&TZiJ~DfB~hns7M#I>K$v4%9`{jSps$#5 ziye#Yu8jvMI#eOiWrj0GQ9dGE)4-O+Z#ThFDXHE2zJ72L$u3wFP5tV#=&(n!%==)8 z&3h1LYN0_D1@Vv@iXp%5K4cCN5oM-6#IUHs;E;Lk!~yGMPjMQ|g=HqSAR^juSSioO z>7PE!gAO)+cvz~Aak*z0jhKEBbFzq_&%Xm#1Qn@Z!q~IDkl_ofsAU>^4I5{^Kh*^7 z_!jD|nCh%$Of%x{n5wkI0`um1Z^nBomPW*5m}TbEz*zh0JKC$}HG|mND<$iQ~9frO0n30=wxfHtG&X7x-|@O|7o%!cw_lb;K1!&hh}ecPF_W zmT*h#o}G)Qx4_@{I+A+V61Mn@S_8xKYaX78v_0u8z=gTOD;-7PX)g^B`Y7dhAvF0Y zuy_;ANKK*Qd?AxP%lC86A`YXX*#$Sh-?X}&QXjZ8J{sK)j=#Rzd}+PeJ+A+{Xi$NP zCHQM(?VGk(-STY5FUB5&9)c8)(cl12T-KYu0K@#O3HSq#y{!F8-9%Dxctm{n+HDcG z0R4@~BTtCd1Y8S=a8%`TKe=gWpVJW=_-LE#+m&NI>_}~!ppU&^&%y~SXjAzTw^fZW z^`LN{pq+KRiVzHoY*Z714|r3DO^DpUlFQbpZva#TulwpR%8ZFrLGp-VgE3!Otx?*7V-f>OabBpc{Tj$!@%WE|w%O*R^O^p>S93C16=V`jCFL2QXP#Q~V;HP6gQvHfob6eyO zD%M$z_D>%l`pSDdwE8-5a&tj=S8lx3-# zhiTjt=Hvehb`#u9Z*CI(^SRoX;Lnxs-!PGm0R-x zwBAm43h8eCT76=l?{Kxo1L_8ua2To?ldDk()T83@In`)J?)-bP-l~A)|Br zFqkQ%FZVeUvnZnl5tFAmB_20qG_*Ztd|CPV~PyOgWDl%S2CGjvj;7Z&K1qYP@$n~?x1)UAlvFhpwPsD%DsMA%FJdwo8^2CDF;+H zBMYzL|AVE2<%@?EV|XxkK&^S-4)lGI>np zLut0)`OkB|`}w!B!S4S=KZu18AD7Hk+%UfJZV69y0L|hTHl;w#0vB>Ou9~YLe0CAG zC@xm5D)w&@cN42lTYHt{AAr%YdBMKH$U>**G~Z;N($h!@VErLMG~gg`vcJD#@;gZJ zJ5R@sTJQvs!cIQYD;2s&Rrsgd>ELxqav3Q+ z30jx*MDksvHo^~Ct~=_yeB``piwHs_y)0{f@#nozNQhf-9&8o;UWx0L>v+W%F{d5t zIwK{hOyg(F!YvJn-1`g06sYgwjaDe&fkfOuwumy@CL^m-Rv_0_ARX7kDzdl@&mb?U z081o-<*mw>=)#~6Z}%nrlQapdgV9rSMd0Qo|FIh1vg;+j z0+7`Su;ntzt#+a337grKhhOhJq5fFVB6DO@cWPvRV{5R16B5?|7<$38&ax!$tK}7R z^-9%$X)7MoxT(1OY24mTZldZYKV7a8*HLH*{6WLb4(_J928d_^Y{=1OQ-W5s3%myv zu4l8;PArvp9ckZv1=1S*tGi#AO6!fNgxshHvI$&w|NXrlL4B4!zW09e@0A$G#E-mB z1?aTQ5%=CGbp2U>%)LP@@(Bnm_^rwHF=E_4Z9e&XNXb@(g*jZdF5&W7sjANAPT8hI zK8>oU74!P)cwaWX+mfzd0^wM7H=iE?BI3@h%bo8H0q-l-Kzd9F*{$*q~$;-_-v8wkB?^ee%HkB+|R3-ezZi|%Fi z+mo*tckDUhB6YL$B|U3DM&;-rLjKVr`8b%G+%v^LN$h1y}e)!~3uKWoeY7W0LTLqsk= z*nml+kjp@x<+?#Czc!UT!fFGsYjkLj%HFrdXM>zx#jsmf3|@x`38An+m3g5WI}*FA zT|=eW$)rNLPZM%jS6;|Jm1b|7OPFjGH4^!9Fv=nc6S{(w&ifRYz|rT{kk7 zH#1o6;e!O&3WdlZd^MS?IfYxb`vZAHJ6dFaQ}{dT(Z9*mql;DW@#@tF2vMgOHZ&R6 zjM9Xxp1BGRmWd=vtid6fYB~ub8Q4;^=wsKlUNNykM535XjF7HG`N}Tcm5-=(TFI7` zwM3Z#ax4RVwR^L$(mwz~uP^|3Br6PT3mfwPo-bn(_>JgKNpSCVWg;&H3JPh?VMr+| zg`pk{S1Jql8LzC7*NtUboggMs&EHz|6|OjxjVn&NDl#a~Z7`>3Fa4PuMm{2WGmy98 z?q%v5cnFi)+%b_3-8FyF1wN-2RiHz+xY}5v1@31djKoDf6OHTggsd6Wmv(6+p8!V* zVtN-4A!=UbS+p&U3^?&}3oS zc&WOk7m3o1-Y-+?TUk!OU+GvPa&NuxazV59d|NbWd=*~#R->O$*uR-fp8x}-Fp+~< zFcw@CsER)#ip}a=0FU2E5~c7Eu}xPS+GdrhG*#(U@wZi5{rca?EaQHeH6WC`yqU=k zBPux@HdsMc)5D?iJEn3c_h4NA_Pch!woC`S{Lu$t(mDuNyR7{yHO%7GrL8kT^7z4e zVMuL;d?^LOYKpa`5v||tqf=SJCeEqX>m<(C6&L-A7}i>6;u)@<9O4ZbP#9Kw*e#K| z9+SMCqB5bZG^$wBW;G_;n)M(`<_mfKSJDp(gh{GuT-R7B_tt*0%EF&!L;kb8AmOkJ z8Nkrxf4imsE`u3tCUox1mRe#2D{KJSY)PdIv$T{Jn>S9CW`nzuH{*vtIWeTvw-MM)tq)2*~>`_ zPCHhuFE=iR^?N4o;J;hBzEQ{}f#cF%u1(hRZ+t8q@jV<(+;45(_?B|N@lhUnJkx35 z@2>P=!74}Y(vP9E02*_>=!j4Vv*xZ>mS*7N_&sO9D5o!neUboOlOg0|3Z8zS!o zM)}XBujY<9scH_Zi`h_wP2fd@w7?ixzMO4^aZxocSJ0dr5#Cx>#P(_hBoeO2w| zz%q$0cw2Bj|P$DpdB-;61t+uG@WP+d(9#k9wJYb*z|E!EC9mgy(QIlflaFaZ4 z-kc$2p;$HsZ@iWAJ%xra4i#K$nV}Yl_Nq%Pi zB#~7rU88SovO-uqhLsJAw=UH2c^dt=#{fNQeHHM#M!*dag`7tnI^0b`41Ah?kDtYw zseHPf;wvW<(>42DP6|h$CD&Y`7{zOl7H-o8LC!rVCEh-Y#So)Ut(t)z?t1Cfncv#zcAZukH^Obsu zL4?s4PF&P~mphdoD!$P%JIjp~!=<7AfI^t5meg;%Pmk}SLmrD#_u5KI)VRETM5W!= zTXQb_{@r99&J+~7Rh!f645g4dj>;#^eqWwfC>4=3ve526LS2gw=Ww*v8#+TOCMcK6<+B%xN8-mHG|T8u)^i7*~}V z&<8)K6XTY_eSdH3g+*K);#BqQ8A5Q-9RTvL{~72s=hc^J?OEDXrT9KAai|#NrxQTw z#W8o;N6l)EzjD41L$Qy$4)HVl^?v^E5gh!gU42WuzFL?{HIR7D+U;tK-X?4hOlT33M}Ui!DYx_lCFU*wngqX9(xt%5@gZwV<-k0)Jf`Iu4tWAo8=S~>Trn(V)WSi;{t$9g^7=xBQ)f$YOLU$7o0s}l^2<-D$HS(Qb)Gz)7GyDkBsuXW6vC=sb20YKF zdra*LaO7Nb7e*3E48>=;+qRb#KKPCAbRt=3aO#u}XaJq|%m|jteb+-M&a7A+9^1j@<{($l4=T@VFi|esq z^)2qr?ShW>Qnha=Bbn?=*$+R)qD5;fiAWL_rJYg@rgoDrchIOHwhBcMfywNemRe0Z zLHE}h_0}9$&sv*Uq`8EWb(Az(&p0}yauImxE?;ujlynX+0dz=y^+MCEU-Q9U8+SAe z;@X=`0aT`B5+mDjr1mLzLn@M>wMct)Cy{Yi*x5w&Q)h2PWUT395D)>g+U~ZJhgGb0 zShDn3y~q?q%#gJ+h_gbsY zTD0<&;B8W_T^e-Xlx`BlhCtFk^FY3Uap@tkF zU?mYYUe~eK(zm{-8o&y(T%oU%>^Ud$vCg&@BzcR>n_T`c=n97`ffN2(IU3a4*|X&M zXgR6>enJ1+l0;2C;@Y$>(GRh4uZaevM|-bmTY6xzxk%4R&mdHfUlM{z))Pp81I48E zrp%NG&MVb=nw`xxF%lZv14o*rvA(3w+?VzzB_3y9Q}AiQp&)nLAUP}$sWYP7HW5{$ zFNb87TX`zY1p$rg%Sco~LR+CW6k6ho7YpzjA`0=CQvU(eFs;!@6k}kkV-S&UIpAQ! zO1G_-us}hfd-x7sMzR_x$yDgS{W=?zVLEQO=U!|yXqeu$Om$QyYW&&7xlkkWGbH=2 zo(?m>F)q5GcmC;U37&JQk%YH(I@S>wStmJ5q^J!M_~^;ZBY!2Oo37C4GRH1&S{9>< zQ@?W7^Cb_a>~1k7zt?Icjh7o_kUBWO@O5x7qQK@A){Wm>a9|<5=!A`A!*)f#ywjkq z^D2_WC)li(Kr${T2@7I&dDY3Cq$|avY9q}W)ugY@qww}owPKRs<<3wT`QDkKhq79_~ezbytJ%<7=Ya6qFb=FYf2C%xIEGJev=kjE*3I1beG z)<-H&MlN;_`XLT;^*c=99cIOL@`HQTYBO z_>v|}$#`bXeP)d(u@oyjrjux!X+wjAnTV#C4Dvh!b(WQjKhcRjmmR?LFxHN{!X zLiL8jv}m$B;kkLwZLDE-($??wrti^TG&_M33eBtjI*WP3=_&kG4O#U;hP(8jR)oBdG#qTo4nOgqwh#-HRPW$|W@SS#iOJ*|j&yc|IE_Chg*}O){plHdgbG z`=&qi%;vdCt5Wn;XQh&+!v^Iy3_grhi}?#$q(z`S+&h zqpP;+OO=%@tw_o|e*~BgRy6UtU)zaz^R``~XK~o6BqX@Xr-3LP0p7^Wu4aYIY75L0y537<{5KKZji4pbsN8%uJ-PxT<_1 zF&({^8F`9BXz1Iw6iQ2QqbJ*KEIIDa|LfI<77=GG`ROIuf5}KeJs0F5O8?Xrw96-H z9cnMLDk*%e6-mSCu1wyLG^Lw)!9PI|zzyBn!QGy#sUS zIN})3+V&3T3L@_g)!cp@%x5;`8JpQz8lFs!j-&w)T>P7=Pp z{f}+#I9x1{g2TB4DPASsbNO+TXq6xzwwUIgdR20SO}CIg5(q9A6nt=<(LHgPxQdSO4jnvb=?9|EXmf<8(J>5|I%CO8BqV^Un>ZjPZRiL`TI z^fpN)C03)eSKKuXUug+J*9vz|{+S?)bi%owX%zgmZc8*s3F`g-qoD&DnXSkUR$Q7+v!#X{wBw$ozr!AqGB)l&< z0;VYD(flB{*}yLu^|i&$Pcrmtdu0L{6EIuw)ei> ztj5FdB+`OxzTprQ@#Ezc*sEQCsVZLrPK{9`9elTteL|h+8TL{1cyl|piKeUxqT^-O z`|)zqf1?5!8MWfxa~2QQEEFXmi&WSYX44j!dA);4~PCIEMD>ymz?rMD8xP{&1>_;bwV z=P$2251J29`%3uU#2COyB4@p(Px5^z%*QcZ_zcesRldG*s(X{dpas?A% z+_`(rs(Gy)^{P8Q$|=G>YVp&;zGQhq5)y=MY9AU))OzxvB1fx=3ZyYxk%afVF!4Np zwl7VMw;tJ-eZMb94Zr}v3Vv|;QpmZ&l>rEP(+Iy7n7?SZlkCCveDvPIL|t7=rG#(3kQA4oVV79+};nV0?$56HhRWCveY) z4$L2#4-?q(2$r#-bF*|d;RG97-HaFlKipp*50{#+WZz<9e>I684C8)-OM!r5+uu1mrhz_tH7S-DEXz%)5lg!wav!mUs_*08G(6`C;(b(Y_}PurGA z0aqsBE+)GjpK0x~$SLIWgK$<1-|wJL;kTb3Cw}^L88gOOGt;~Iz`C02uz!$G_$-kLeeXr5mGsZq7)bPKg6WFEM|DQi~Yj0 zCzCnTXt&z%xh|!wKd18Fy0bs;0#UvI)k10VcmOWbXaV5hJvc6yH7<73A(n;=*c9V0 zex-CxfEBLWavM*d28d+wjQOr7uyx~J4mU3Vtdtn$N96P%$DsqmuI%Z`aNpZ(e#nd= zZf}rzRWz3ly>D-{+w31#GbX+X+@bu(-|{Y?v#@{U7?Fk!vLvnP$DL zsc_7@q@Ibd=kwXiAtX2@6~=dA;f^8-bmB&S^-O(!YQhfYGBvK*Viyj}8Ip?iLis=pG#k4j3sdDk|b=q&^^slnRIlhzN@LRr&Gz z3*P6t&UMc9yw7t#_p8X5E9txVhTC2{)nn;-pyf__)F|mE<((80X`njnyooYjN>=$u zzSyI@a7LWf0kxGgc9ipC7gJo`k9Z+YbIdEyH`vb7j~mRHY5~w1f=#1ErX#}3BlG@P z8WBJjl?QhzmI*0cWnAC;*?=^nx)?;xds>_F5S-*!E2ShhM=O;5UfhP$Sc!)I+6DDO;j z@nAP@Z$!1)LiGfsX0q+>E_S+!cQuD66Q{ZZCTBlB_9Z*eV?QSy(UQYpL%IYi21sn) z=9X~@<_p>yGUL(lnK;@jhO!E(MsF}`nc~@nbg1k~TU#t?s#M?5&pJ^+$QN#Ek!9K#mk%TzcZrRiOuRE{1+ku-&_BMMT%AEt|)8?DPEUdD87 zm>K-(8Wy|!hgz3?8C>g$QW(?l%H@rzrJ|=-O7%Dlun}NRqXYT^r#{bfFqgUN;0Tww z&O8`RGU8}J+t_RBb31uDK~Bq3U9jmyAB>TwS<;{548DLyP3SGJ6D26W&xsYZ+UHZbJWiI)&k_f5-De!O4K_#$8}ZeC#2%cMIZt4 z%$8fKV177Hn}dJ+{e%j+a}8SM54n}@9N6CPNe+fifB) z2)B|z4AMywFP-9CPh;1v{cw%nD*Qlz^QuK+M%1QGvO%!Lvn4ldJiL0d9>7Nn<1M67 z!RoR?HYu{&e47Y#&&E(1uWxfLaz`c==2p1zEsWEpx=%DmZZbX=r3;=1xB6oLj)YjU z_@ovP{snFq8q2iMi>*uo-lOdhH4SLzB&9Dc-I)<yVuQ2mREd}_UkD+%_ z1sVfixHY7VS?$)wt`|zx$7t{S3r6EdJ_?nBviBNqx6Vs9(+-oV#M-uG+MUDp$wEVs z!E+ytrQ6XQLR^q%y=HW5p$(_W(TEp^3GB(=Ro;f=a-L?Q_{)0^9u3_0-F$N79{AS2 zl)IvK-4uQ*9{%#1?c=Nen{t33mH|GyBcdus6nA_+WU-pbTM6vm_XAa5!weeORHcXq ztKIL;9p;nbVmoTQ@C=?Gjy)ph8Dlg#e~#!!r^2ar8CrLi^J9D82^~c_H9y9J6m?naGpxyA?OE-oHyNHP1?<>4q z55A-bm&^qF-YQ%T?4=5fZXp#NGtW@73nHxWFjM@CH#OUI$)BIXY^p$Ih^7V^qfG?w z-fi7jfYLZYJ~AYs_%_b4>C&>a#EnRHAuxBdEE-Y~;h8q0PkhlyRqrUmb6 zVpDb6BXo#QgIc*_cK$79FW0>20ctu%(-r>bf@>hR;iGq?3FcT(}zo(9sFjLr1unXqMJv@`NiCupAlw& z4|bi2X&XvC*OG~PwCV5QV0T&}CV(ZpDwV@sjhCNHRBoPLCrx|u3RI1$jw!w4YtO>m zvOua(mHB(x(l@THKu;P_!d-r6Bw^+3f$p8x=!i3%@!Y)L^1P!htwCPg3W+&Ug*(j? z`Cg#XulIq|4glre%0s{G>2=Cu6Z)#zR$SrlA2=!!3|-!HyiU>^uU_UTxE4HgD%^tA zkOnA+UlF~{N3zTC9*ZgT3#4yjq@NoLZ$~K+u)FHdjV&Y)C3U&BGRCRQE02<6?>}+H z#f(2mQ%z;d4lXdre8utZ*O&4^ml|T|9`#OAa)MU38z~-)riK6Kh*V;-?!pZCmMS>! z3ixTpxz9#L%F3zzY`-F-)DiIu<=U-CvAi~V`4mWUW3n{^UZPq*Cqa2GzszCMROy;C z3a23LSc5{}0n+f%GyoOPEQb)x6>fSeh)i?v>L%9o%y}ya?oImp4}TF1Z`glyNTEnA zR2J1W)@gW>q28`(iiILbXcxtIgH}s43ba)TJKRTTu~hEqi*FaQGv7y+dL z048f}6m)kK0PrOxlSbk-r+^H^1JQP#%6WYqgZgvaLt}!25mLrSg$0M{7w*;Fe}1FB z==_?=1kNW5g|fo(sy~DZjx4vpw}xFs%3mqzeTj^lT+sjB3o>CfA=S&i-a`mXz8n>S zb^8yotk0#QXe+ZNu9=TSJzDJ^DBBb~t!_dPO?TwHy(^v{$S3}qa;n85y%^lOo}1|> zQd|Jv3Smpql-Q^CSV#SUp;tzmt06MCO-E4o|*3 z&5+WHpReUNtpEIX`HqT^(EsXi>AzV2GZu*I{UGH&b1@Zg!{%`^TLyUp%Qh$ta0l5+ z-QJYOL?F=NTj+Yeap};(Q5Bs+?xqTfR*g~VR3mq$cP0Mp%K=hFIiIGd$=Rfwszt@y zqmiQ<%=+Ti88q!DFoo1M|hLuA$DXsgPd#;7$yqMd`ySq@=yT)Fi z*48;OO^Lx!Z`YnTU;4(SsIDeA6F-*R#id^g-$YP;g4RY!>5l!IdEQ#Oe8NqQN>h80 zv05I#O1A)yGQ+Ic1p^*dy?>TK7*v zC5hf5R`nidES1Muilc*E!t-oOjI$*bYhjHqpL5twt$u2m>FWlNl?D}+adY2qJ*egh z4S2fdqH_B1soMuEnYH}3fDiN{V2C9=ybjy^=Td1nZ&bA$-d}7dU9p(eOlJN@a8Tlt z*WW^i(BBa&V1_b0Tv|VF`25?phZ3Wow&wp|d=u#`vaauKgEIoQ8W9S1^@{TBI7{r7=Y@-~&x`SJ^wGRCoU`1P zztnD!8wC<^Nyhx%e7s9|v-wi2(CPfa%j#T{s({t1WH#D}1&y>NfG!i7=C2kqp)Ka$ zfn}S4vl~MYJ3ckCaPT?0XR}ew_U3buYZi%Z$p-#<$41U=Bu&8xKt^bKIMtL5Qe zJ;hK$bC+*4wkEzbn&#F5|0@`tVDA02T&7 z1N!pu^*dMp6#U$?>D6}_!0Cy{pO$cp>HY5p^dUb>0L=hMF1IXoxE6CRVeigd+bwe6 z6}V7F5P*SzkTXVzQjX;3QkWz)nsJ_?7oNs!ive4^L-HB0R5F}QH>v^{Hs;@b#9bic65-qn)>kpELdmb+&|V53 z7do?AZ0>q+13X>-)vi6NHnAn}YgAGA>fW>)i+-=J^s&GS7qPpJ3D|Dcs`~CtIKAth ztxe^jWr~`+*EBk1@3fx+&xJSQ=>(a&WN9V#BkYpT+?G7Q<$m?ZelpJts@AlWptADD z0{rgqlW;fRD6zRp-*zxW_1#_m^QxkERJRzaU&_9UOJ12{bUxL0(caXcyn}R1VNW1Y zSpfL~E=39uyD0&^LpqaJgY;6Rz<@`8ZJ`wueA3)#CJ6?? zT$j@WTxS8~GE%MqppN6u&Ag32z4G*?8>BNNNbV^`+c2uPaSb-DV;fCiH5;I(KOmdM zHb)t3nvbQDkEbWT(ac4bVNQ&c8+dQP?RZ}(Ycn}_z{ScbkIQ!^!&;=n$;0luR9HZ* zeXPENdbPv9(+WQ8*ZO2&e*8AE?b+w0q27B??-phwIA%t?6ex;8 zh*lzqZiu1B2ZV}GuifQ6J?i)^+X=+JcDxa`jlj{yIA=D9=wq<74c4iecxyGb zc>B7wS^GX7Jm`OM4m@ZbNJJRPG-L}y$ytrR8r-Hm>1iW$@q>W2tZ;`P!<&7uX?0 z?EAqR5*WyZr7PtPPV`mXgMIjg@^dIzfbP;Q68J!WT4%fT)n@@wv0mbOJ}hK0%_1>2 zJ|ACOq6A&cor+J%f2*TpaG5Yh9C6bg@W%zGGnVTgzOPTPr}mAe`&0-pLCCVrx8~YT z9SD!hBW%fF#@hYSA=H;Ch|73PX31pyICR%xK)jh^kAYM%BvR?A@;DRcU28X~^bSUP zq*TTv7Mdh=X?{d@muB|OdyZvh6rHJ$x&6=$$|CiCYS3l+QbRgM_L149)~{Vnr}IwU zK2*`yyk&)jZl#Vgsr=XlYer#lPpw66X^Ffsm&+Je-^()eEIKy;W6)lSpkPqMt6#Fu z3)WTKZ+UBFEtDxN3S3IWSPRk+G?qYbEfbuc$t}BVw`dD#<<`dLvw=H<6|L0rm*7C2 zg1`ix_O3TXu%!^T5&C?mrBSB!-$>VXquSG@Ort^lIhnTCtr~VY5pLe7Ar$Arm6%Ypfr~N}Ixr=|(M0g-@Q`r!3 zeIAba43ZD~s;+Rt(>5r4U=-MC9NI~G>Ox8jGs_OkAbOY&=#S0n0m`Ye`7zjPZkb^la_INd?WdcN|_-!08D?iB`I%f@SzT?2Ng@Cs6dvxIOW^qN;<&lyOIIqfUlCnB^ojL3$F`fkLvKs< zs?r#Rzt?#?R7peMtHWSOR3iw7BG{#y@(X;CO6|l!Ew0 zTCZ{EH;VTVh5o&aKHP&%Gj5YAP|kRPoiX=dN!`__hmFscB-b{B9m(^0>EP7kKTDtG zzeXyYx0@Qke=zXiZd_nlu6ht&dq0`N>6(%C>7M) zGjw12S+LBM9&mS?+;Ania@P`JI3ZuCpUE(Id)i|LQ_tH`NdK#>INYYykmhu|*h`?x z9!dZ`oB*AYeJ-n%u>-~0SUZlA)isqIW?1KWK5_PoToL^g(D70@)OfK+U4#^(l{4re zKM{Pl>lWi)Wofd|a@3__W5KqF{oSuR<<@SQ7VF9~v_*@=KE^rKRDX{$`4IcJqW`_6 z*ehL9t@yE}XQOKH&X)xM;n1f2`hpcHz3%chelNrS01&0Ks;foQQjM z;V8*WV=nYZQ0r>D^RE|3#h55{;AeJsNti(Qk!f(@P z$)CgXJCdt+H_i zwovvV?6sI-_J*9b_rx0!b2ib;Z(uw@HV<{-_oi1j`}ify=iTmk)$Nhl0)hYe`nuS*FI<>PG;t{@+{8e5Yx*9aDYb6 zv;#Wv%QVd;af)by->ZSxAqp7Yf78+(V<*G(fwe2pj%Fvj4p(J{ku(^w{}TavJ1PdE znf}%5UBlzy!ejsl&Q^k_z_EQa3_$UWbhKcZ9HFo%PLr8fRhOwuZ0bj}6gKT6fYxpe zk}!%gfO!=2daR$0fcedvl^_B(6IOo-#NW-r^SDt}7)Ar)xgbL3^UOS-t_VX> z1~O}DlG%!)S&w&$g5XZ&&82XODt}@YPF-q(rLcxdT$Z6@9%OsLA5o~@o&MhuJuqom ztkMHh|Jw8svQ%Bx@8;dQ+_`vaKFZU{%e*ituqD58xj~wKD|9PBvL*=>m=~`3Tk7J4 zy2;c0Gk$bmRJP;si!Ir%o(a#~d$68*MOC8atAU4}dH3b}PgZj}MJZ$onu3<1VB`|S zGqXp~Uen#A_{1Pu`l~2^w3ux)z!{q}I*CbgbK+JPPe0A+ zarvNM7-8~CdQ7AnRBg;|?k+BnbbYXy5`6g$1qGE8w=nj3%~N&`#dyyp=gnto4lb;Z z#log!kY&rB1)pv@`2c_72rNfPCzKN^>8Mc1f82R_l1C3qEX;AIv&2p~;C7D+k*gn& zB*ijxV^_xQUb?Gz>T|+IL-Dm4q||riazcVQq;GxS-WayT-H31h^am-+w3h9_maf>7 z`O!%7sKpj`P^LIK*TFNRDMoIaElG709vxvwqzWpBeK~Gp}aOKn)PlX8`yErlx z#E*H^bli{&?$o$~Q>BBc@q5 zRlq9CqNMJ9+pb1#kV~FF9nQ6D#8v!cU8$hwk?1ux=!_m`k=Z?vc^h7zy@wNoPZ5Wx zx|@bFp($ zWNi0e!qTuX=;F7QLrAg-MAHsOM=4Q8^lxzIaQ`X8Ty}-mT@ApOqQ4J;J-Bn|fA0?U z6qVu#Fe;9M0iO;iQkAK)7gR5Zp_!I@rQ!i<0DW!>=?>!B+}=;Z-ix&{x8u$c>! z1vD~OtMlAxCZz);`<&Bv%QAwhwrm$o`7T*hNHiaAcU{pGfxxA+Vs=!R)2dddH*)Rf z%>AvK#KW?OV(7W99*W^t)@x+r9>1&pe5h)KQ_sbH+XfQNNyt8J5(n%hN>P0K^OZBm;D=Fj75 zht3{u3U_duE^gwSYSiX0-&)UuyQXe#rAnft`D`dbR_{?}k3z>Z^UYE-cLgt$jNk*z zWjt#gpRD1;;SVXOu{)03HLerM6OioU97X(?+{8Vi)$REHx}AXVQsm72lIZ_rH*6mK z8X*n@-DiNlFWfn0#r##~LP#olk$WGCOUS?GOR({lNK0=4Vs2R{F zPET{{FDz=kJ+DxkS8JX>75;&VvDYPiPA;Z6<|<-=$vwnEr53($^7{3~V`TrO;{f&1 zuL?mDwU$XdtyZVtT{c?WhB3FQ4f5Y$qu}% zqWQC`Mv)aY4J@2n4$tFMc*&wLCzCv)T1U`Ense8Y-VN>g9 z-Svy|vciiIh4vgVuV(=()3JlpOzH`BG3M}e)M zFDCN-_ZCv{I%&T%$UddYcqH-8v`xv^FLETX4ZF?1CEJwqf}2O*fS_)FP>iFURnEZY z&9AZ%gdOTbSw1XMY(`_elYyvcaMH;iV*yQj*0M~5$XOgF3yU_9gV|-y5W^u>bt6ye zq=bKmt70Z5l+BHno#rexdCFGj(0mZfz(5j>rXB+_hlsd-GvYp8#kOG&mwn^*t{vTf zMq?-269}Z^ePP3j+ijggyR$O7UFqa5I{A>L4z`}zZ4Dv`o2qZfOGh#=`|>;|QlEKd z3eL5E#v`@pDO#M;Jj%7dmObxtc52UwU2^}pFbf4JOY^9=>=_KAmmAj{+e=W}4@oUV zvz)L$5@jI2@L1OJ$h}vNo!`8@xmxcoX9raD1g$84_)gwl(O{0INgH1eGg0?~cI2VE zq@VV+{FGt8&E|c3aqo7hF6%4$zy|jB3s%eKf@g=)|LZOIA1K%gdT>^DCQN*`{==FK zu-NEts)cRU4&dcZXPfMn4CR+U3=7WMV=T5{l&vNA!{47zsqg7+3&gujh)|%TG94xOmRS2D7!OlO$Z~Fgl7K^uobuTOhC6i8%>KhP-U{p=`c|$4~}a zw$bQL5X*XN*6n7y=Fl0+#{QhYci-$sYiRDi*+J1g5GR(g;1Z&KZ&C`)0>8PpEz|?yyeQXwGPZ){L$pQf;rph>Up)skv0Vnti0P zGw0bT2_%>?2OIj4kWvu(Vje2pTlbDC^+^j9g{4FWVFQl0jxKF^EkYSb7I|M}I@wQM zSy98R03$q+NY?855~f2a^WW3eaMz0u;izO~im3ESo+pUz+mBjt!n zPt0nOH^Pg?jG&-rClDmyEfcG?2Vm}Jdu!+D+%tOB61EptQxy|4c;4^vaDDyl_|8nI z_|BVM-eCp4@y>)UzroyY6FEX7oQ)TbQw&)bduq~=GcXIg z%-Ka$=2U&heJ&ee+jjYspIOr2 zmh870R33*I3L%SeL%OCj9^f+F=R)>2aC3m&B@4G9dFlkvE>dS64Gz#@{z zBAJJA!xI%ZC2#!)8u42ASt_NnSj4i$_8GMRU=)9+J~WahxaBd?c5omvdW#9<812bE zf8uS+!C~liAr`Flz`$Yyj8}iMR3!H($NS<3qj!;{SO*~IE_V{f#7+IyNzab zb>>}AxVqmfPrpw=7WB`GcmMN?RS@qAi%sml^RIXG7^!(WK_Xcb14&*fHlVRPw}68&uB5-SLq9j-$t%9T&ia{ghRh4G6_zp2n?xp9an z231znNw0cxl856i%En&~iq~aOXC;{Tb-_a_ik5Ogn7wPYyusc%UE3*(B|kE84D_mm zUgAe~mCn#Vk-ioHeRvQP-lM{)_e9m5iKbP2VH7uVzkZKxTMl!r6Sv-NaZ}S8>`P;-n|x6b zao6+W8einXcY>S$c9n%=hTXY6qG+vE$I?A}h%VA3pZcCFhJ8gxY?C<^3Y^aWNX5k3 zy(=tMHNHyYbYpNz@y8WXqI!xB z7JM10>)U$&5i7_X15vqCM!VpX`pMRrjvw9n&+-1!@IUmHb1}wk7kQw;qfUF`MPPYR zfu>=|BggNY149g}LJS|vZP+&moz?c;S(Vu?xVcz2AOH;j>|(C+z&@Et*?j^ug>125 zZ%b+rm#zy`FM)PQ;mr{-Q>P;ckN9uW3GZ{KLbS8&M1N^qy4KaPy0CFD89jIi`)JdJ z>V=zHW8ISP`Z79V!_=2oA<&a{InCyVtqc1J8b!m!QC~jC_F(L3iprZ0Un-V5O%dNx zkMW;^55v3X8{H)f*zZJ!sup*|!7Wt5w~ajyd3QUDg6 zi5Bd?;X@fLv*Q>?ebMR!d5m-YZWHFsYp88~*-J zD5Wh_p&6T5T%4}LbIQY;VlpxKiMAqkeF#z{hw68zbM@@`YJV^H;_&1});dkEO*TcL zhnO!T(qCY?+)l4-_<=MQ@5a?k#k<26&K$p(4T>-(`q)4T9#k}Q!u9sB3(_*biiTaW z332mU{GHz(8zA9C_!bma$Jr;iAfsCJcCpHKf*L|NE-CM99u*{$CPq$8J+yenIZVJ?j8>>3h88cOuJK zu{vz)FG6Jje2t}o@f^N4qjV(a8d$|yK#HCsDu@lVn|(~*%unA3{iIhjmxj8ZFNmaf)$@OFXLZHXaiftW*qm zu`M>@7;`G@yQ0Xsbt~1AB(WWtMw*s&0Iy6J>7!<=U3$Xz_)TplYrPrQIa1Jj6%Ag$ zbEOxWa&21@Cl@)YUa&6Jx;#lRH}V@b((;}vYquG<7`hmH*cbD0Z01#Mg!YItlY7UT z!q}1PO25U!%Lh|#{#&Ftn$F@-#su8G3t!$Sol|nY?Z0irk*%-A^-GXGVI&W~n@b1L z3-{0KIQ}pGwb=K3;fsNv=Qsik-#B(sux2k_nhc#6%b6x~h^6Q7mbqx$JoMB**_E}Y zSJXbV&f=7MP>wow9ckpi=%vDdTe_L-=e2Xmd>bBZW?k5|XY3I}6bStH|1MBXWS}hm z0NM6a^qh%oXMYhRv>Qw<2mUm#xWk0ZRYoxJWq-kEaB2g9V9rdbWH6UeI8YCeN}Xe7 zYppOQDp?!VJ9LsRypB@rBfCWENL(0ZQPNW`3I`Z<(?Cs946L+yQJqM7eI^K)DViTi zxB)Lj;c>P7j$Rdo-85pQjO{}ZGN6aCgT(c3tRo|bP+4A{Np>S_1qZ+WpK68gvJ=2S zaIG%tWEdqDW3_5z8EwE}V2p_@4%@R&ei1BNJOc0jp26H)3$dc4KP+dPJ#lr6_sT54WQiYXhnsqs+Ie<2FJ> z-@rsO0icBamJh&kR6Er{!hPW4CK!G+{`Mvap+QTAN&L(Px=QroiA+ar<&ggwKk~>B z&NlMQ;x?iV8maj9M4Oai>GF%JTbS1f#a@6=fqeKu{h3g9j_;wxh&2FYCbNN+PSQ2L zFI7+8-DDDb#FqnWs+frbS@`p~VoRbUb1dg^AX07Hf}w z#l;O!OhG%)Wo*pbwzg#~F7ex^hmFkVxk{nt1B0v|X+d7v+J1}R){7jCyu1j&31s-g zYdM(x3L$dcq^aIFpz=TBSTI0}$Rsd<7awtFm9nB$^h5XtQH{uSrB{!li#StD2siQs z5PU_9)OfpX*h$Z;r#iZ+ggia_?o2+tHfq(&-W@e~{mf8cihw8qjD%aE9V$qA-#h?- z%;phfk(ea)Qj^w#h!etd?vW!2=OspPinF!{otEV_X@4;OFJbi_ka-@UBR*09N!mGI z%bMwLpt~wN#Dipx-U|pZ%Yq2H*D{0&2WvoyMTe`-s`=)&=moIS}70DMu&ZMwK@ z769NKSzI>ly{`iYkj`9!C`^O&v*@QEr9B~X{^D!2d-dD>a?nUFPId!M$eZ0pfXV7w zF+fsW#%T}8*~Kslwg+l|w=yfpafy){m*DN+GoPxosm8oW6=ey$wR+%-QmwDVqLbl< z?n7+ZfHcEp2_BoD$u&Pmnsol|#hy&N9`bR3#U#Kecp)W4*rzkwCDodL1=fh~X|WjH zvgwxMcGx8s*$9av*FM&?I&$kc4xVOT4_anAr=6Xy77Src;$Y%iA4mT@Hc5JhSTf3d zsi6fEXM4WiEC7+;wvZRDfq*Vc06aYLuXi7XF*Y=&c?T9dgC+_g^#!Pot7t!->Dv(*uaHTdq@Wi_5d zL0gVc5?s=^L(_Vi<9TaTwa_%aJNxh87AHmduoflaK+%`rHQ5`+i|KMqwZ^ByS=b^xV!bGANKs?QYAXLeyX|Af8gQE^WEG!WNey zGeN)%F{gWbM8%1}@yYj_6PHrJ&ZJBT3R^=nSjC;ifPi!UqFh9G)%wogS(fN3Dnet? z=FA>o98vnh^?4_YUH7p4L7exP5>_O!FTmm>i2)K-OqQwB4FkG2@u;w+@ULT-pMN8a zogJO%@LYj)i1X!OJ5eKl5kI$z&beGH>nU-IHOjwB=*v<2y)4<#blRIYe|GN0nLf{J z6kysx+gAmeoC;J?r%K;QV42cLjJ$059KTK*w&YJ>o;2zBYX7{W20KsQqMR{xJ6bTH_@j z7)R35TpU2IMjw{lpAwjDgYuB#rH|u`*Vye^zr6Vo+cTdR>BGA2>~DPIwyZ0m!Qt!h zKIpSG(RZz&Q9}*c%OcfYGq%;ly`18fkn>J)Wl+kD^;KV8f_!Uh4q8=D_vZhaee+qO ztwX-=KRrGAR(T_j8~AU5=xlS({?d=G|5&E-Qp~t=#J6LxowPMl66&>{>~F6*kHHDy zknWNL^6Qe0lLlF&N@evtlW;g{)-wX+e)Gy@e2y4-Cv&x4S_LU*8tkLQP#r{mPo0c^0pPu$3CYbQEym zvg>?Sd|dr0XQ`Ulx!!kP3v z`cKcM{mH0@R+oWzUk~0yOhACU^~0hF88z=`)|Z_IHEPLF9y(6-Jx)?mPWlH=gc4^2 zE!-u-CPQS=b^BdIQ2tpH1lE|<;hHV3|NPXa$|NXg$gUE80?6| z9n?CZl;ChHcz=3g_l62xT(!v#(^483PdCi?aFq*X)T?iXu@+6o0y9;8pKzb=|E_h5 z1ga#%%K=%X;qW3p$6^w^n4DEiWvXh<(%i_pGr^?EV5-;1&UeplY(Cerd+_PBfbH)t)SiXo=^{j|f!$vv-~51`*oUP=TI%p2e@j4iFKB-g zl?L#7ij|QdKRA6{qF0@y8xeJPYm>iHCX|rR4>c(~B&pL+i=N!n(MYfXPk9yF3 zpCGCO@DYSV2?Q`5;r?uQ{#$Z+-d;t)uL_qB+@=rR;PVP!(!>J33RZp9vn&aoY|Jqg z^FLGa^>3Qq_?j>hf7R}V$bDYqWM6JVZQ|Ba;`?;>Fcr=Ws*-2CWzN@O*49kFg`tQ8d5Op)UDJ|4FA zarZV#yKKB z(L(`v-G9-#t&XWSKXfCx!B376X1m}BO->Dr>Ex!8PkEX%EC4m=5x|QK`5=Vt5(=mc z2+lHiVs-6=nPWlvAP+3$^Q-lhoc_tP@z|vfljY}|ig!&R! zB8_xVM~1v2Z$CoI#1zVvatgqmu?~@bgd!pT(<0e&FL_?1=uhWgD?a}-mcP&vHNC}W z_{}-cm&W|tx29IXj1lUaX3ySQ!m(NsZWlwAsI=(vPMl|Ql8*KMA?Ui7>!8YcV-*%0 z2a+ z@LOeSr&r#p)Ff5qne&*-H2dHjnO(P{ zWh4$&FYaSKzS&Eq^jsW=b39)`?MgQPgL;;KNt&|pghzOGqb{9YVrvWG?V>H(^VMgA zHJ)8$)eBIQV;$m{^~rDz-?Bk8rs(L}Y3r9?$%tN@#T8=Lsx1!Yo7v5W;y_&O63|{T zfMj#v=qcYKqZP4!kRJ+`XXYqCt#w?REwi2mO#oGlGUMZSeiB#D`n_%G8@v7PSRj680?g$mFL6EpGd(xINmcXUy}JLccs!J^QaOdP@#LKUCH7N+Fhyus%bZ=7Wpxui&x`rLZ7H-Qo9~+y~r%TzkkC zCO#cE(-u@WP(Frv|NAL4DWdl_BKbpQjniO_IGDktdSBSc$IE~@RteYH%D6Ih$NC$^ z6RPDoCw(%m;n*l`Oat>A*zrw2SY0FG`5s2e4q04#*uo4*K2p$~>+HsW6*p$O!Nap0 zb^ehO@?8Q=)V^*86I&g;iUg9JYuCns&dj*m$!B@$>FlECY4#%o-EtriV-jq5JXznq za~0eLsGEuxQU=~Ppew}Cl#ueM+t1V`r)%$pn1{;J^w(s+MNO_U)l zs0eIgXcayL3eN!#j)55b!uw<86El!z`vSJBU2NE}rH&Ni>;|hCL!Lid}iS6x5TrpMP`Cvi1x3( zn2o(Mm2^Je;t66v3=7e`KcR*}vMslJ1s*=DyFC>|_B};1z`Z=Z*O?_=cbDqyhyQ?_J~Q|G1~L^iSm^0`6y> zTbU}MygehW?N$b+2f+SZYV+X^?vRH`A745BzYzY(?8j&F5KGj7CH`P!7JSxx5#HpG zN?m)dP11^whPMvt7$N?NL3Yz2FV^iRW)w95rEFDTlTX}468z1dp-t`C?OZsq8YCSK zsst>)e?2QfRsNza{N2R>e*g@?j<_m|`R%LJ`v8#PBgVJu4Zk7~k#9RO0H7k^C+qQ# zzi%yv4T0tuI0gd`ANe5ixe1lio1W1pM*j=1K+bRJfixt{+1O2>Oa8qn3^ ztKbn({S+wWDy)9#Kg~-ZG1Qi%J4pBPmUyUygmCEDFKoMa?2C%i`I>+yd7A>AM@$HY zDa*&PL3E=*(zapEcHygSlJI*go%afN-YZ`Hs(Sif^Uhb{{Dn)FAYAxPz(C`(4EN|F zpDaZ|Sr@T~3rrv4C$8ME)IDO_FAKfOszg;@D#G3SSph+$-MvcQbpb+Khn{aRMPysa zzgr+AN4(nbeC8Eyo#a|0`*N?~Z$#}s=Xy3DsIA<2*vfmL!KlTbv5tfBn;g=;clAH`YA^F2LOAHj_t=U)jhR1OXVWXBJq6Gpr`EMa z-7tj>Jx_<%Thsm-X|D-F?uQ-!bS{0a!haU;TKFN{QE^}R7t5&@+zQlwr9Qg(_yB=w zNAw1MYsKIkJ3%nm++6K2kVCxZ94hCfJ`=wUztPKXFABJkn5}Bpvn^#)Z`*+u-yfV} z=EO{tknRnoGIvLhX#p`y&^ubD*C$Df%~1L|4cw=>&0(8n5gj8|tC#z*Hf(R8+Wh^G z@GfZ!x>4;=TQ;|hGu^+tZuI}iI}5fZxW55!W5Hm+=$7t~Zgiu&hqQDFN|)%f!RT;w zOE)6j>gZNLL|R%(1VjakKkw&w&u2K-xz2UY{kv~lMt}RhlHc61YkJ`#(=|gpSR;Um zGmsRt@xcj9hV0|6L}75C3KkWsE5N`cX`Zz42HQj+WTy0X@~xuHs96M?S-r!=X7qy^ zW6j81v4J07UUcgo@2vF>FSUtSHZi>&xFlz|y|oZb9o@+VZVn+F+OPfker<&gxwvfJ z9xdnC{0|H_zC%jj+GmD1MLB)WF=@{AS-cvGPvgBj6WDzB-}v=)u47pOKXm5{#m^Q} zVDDjtP8lhGV;DCaU1d^+LbuFFkc%lzMUf-Xf1;A8-!PdXXidTD2+0vge+1L$zDu&y zFTw2*)}Mmqsq{g0j#NdfemFFDQd=q|Z-zljs?zby0j2T@!Awtnb#a_To*}4Qo2Qqz z1tC;dqlQqcx>Q37&Fz#WA(evy2SugmT#e9;P2Y?p>zlaW2@e>2GZuPIdU5mF;I;(^ z-c0<%`O{6*;Qt7Oi`OS1O&4bRzVWUCfVud_BuMZ;8MPVfRe#*I`4f|Nm}Tdn1&mGZ z102U5lpgh3{E<#Sp3a$*JeE#Q5>3mP$djtDIWsXN+A;zn7d7lj49TF8<8sO{Z0e!F zBfDZIqG|rkB~0o$oSoYRg$8hPgJ>i+vcOil`&k%^@%R!PnecwoAed=`D;`7;~<{A@7F5Z3ELVo+zWq^?Yf86=EVG5a#) zSFV()c!7}1v_g_2Xhww`LIRfnq{z&f*!s3FIAlC_zh3cdO#!cVIG2vK&am8Y3aR=J z#d{ocA69DwMN}LHy?mdld_pAuN_{@UN~QWeiZrTI)Mt14_3s}mFYf|K*qY#cEvJqG zGC{w}&a5V8Y7urW<9_O5cjmb@sP$#ee$w4@JsW27XqeSv`?*gtRs#Sty1;iTH_hr9G)(Nm1W4_B z@t-zY7_O}lRgvh255)BcDmSau8pH@=UesY!!!W#0k(pL1{Q|n!BC4P=602iZs5oii zEIuQOK2tMhS_#c>;0H3)m;^6yqv1_uVk>^+*I@JTM3@S;pY5{3b%TdHn!kh==I956 z)9SoN47|K2f#(8{=V~}h0uq@3Jn$7AM%D$1xS}0intCgJhpkFL=4}8O(UD)204!@V z5PE@Ig1L^U*T!>#JE?)|LF%BFeW%vK3g}ON7z#W3AYPNTllIOfIj@E|Y0y9-ZO|4N zF|q|R*`6NNK{My}xq|YUSv`1>i8FXDUwY3Kuf#HzF0ozN#_n<-*TK==ziE$%R|U;F z@oc*ipS_gO8XrOky4)dgn5{IsaM94`7YHX+L%(N6VLWy^u9U24E zOz?sl)%q<-5GL=BM6)7c3{3?mALP*8z9*l5fAV$ifZ z_jU%`hXJBS5cr|b1yD}AxyQ&jfd{R$q*9`;k~d`TRW?2E zi!_7DNrW>ox`-3y-7E;cwh2zpuyQ4~S6sRZx_~;S{bHr*QGCK8O?rsTJsB23y~#`| z?T>v^sX^r_YbOh%t4%0+jYC_%SeJ$!M5^CyERd4GLS8w-4gVxkCiP#_)YtfG6%^Ke zPp*?zeDS8REUNbO;ys5SNT;x7QtivahEnMBqc_b+^)Fq1%9Py+p*~WkcfNPOyWOP| zCYes1@(Vd5CHOM9&w7T1)O&%OFGx3dar`@5ohot0Nni&e84W)rXry1fHhU5Ha?c7m#!eD zLezlOndK9-X<~3NsXc6CKhaT=!DjUVNO!SPK|PfaG6w@9<_mg#T8hd$bT9vs_e9d2%&yXa}nfue+^* zcqnoDW8CwV52r2I*0KPh+)YG|iSK1wJSde6)z2 z{l1%Pr2KFHnY!>!I*s0cf|$(o--B~N24s~w9}S?cBtv|)Z33C>m~H>voV8| zrVq*Rx6{9Eq+(WX^C7`WNii~jrjhXIrOlMvi<04HO>4~p7iV3YS6!Q>64(I=iWRt7 zd<~Gc@6b*MxaQ`DD929MIuB1wcJ4~ zp>;F3rRg-#Et=tUK$a4iP*fmwbnPZZAve9qQ4hS1(>fvMp5MSRwuo;AuEkW>ZZ2J( zRwozO>XE11)0)lNLZ;dyee0L-dI_+Yvz-yo?yn0_Q}0tZS9wYe7%eZ0Aq9*i#fx0` zlqJ=u+_qsdAb}J05;9L^-kQVV3XFGc%gv*C%5-EOMl*Btl#kVssV1sg7xdZ$O0P;M z*_rdwoU7g^f6b&LP-Fcu@y5|ibcTa zxm*wR-LpZybCL>*OR@HTfW=lyn~COyx%?Lz{?O>oOZ}=ZShnv0Ro?(nPYU=$x1!UW zKnPILAL8vlo|!`bwe6iFezTfp}QveY}O~Kop z{+?P{r~AY|IEp7hDT6Hv*h~Tj1sOiAFt(BV z91U=6NU+1p%{ms4ON_Y8%V8cfd-$k&?(p1QPMq@ArzHg%)+91r`&Ik0v6S^U9+OoK z%;LT-PgvFHC7h>5=->Wsru`(T>if(aD**W8EPqiL=waQo=LyHr6s!oP{ifb|cq*?XU1s<3(k%-6W=ccJ zVk8NgG!5#a%a$ylG#;gV(0q%$7TjK(KF7f`KhEny_vd_^M@JXNrZX)x$K6h6+g~CS zSyD96A_DH^ViFc5eHF+x!;VbtLg`5vaLK6;*X1ia&E|FV_4Ymb z6>kOjYd+|3k0`;ZQThwjD86qTq<_O_*t^~GHu$c(ean7oJ6l!z^pLChpr zTbYx&q?pO~r^<9*?FGG^ag|ls%AQl1exN%&ZDZK#2y6?hTR&1=jMQ}eMqU&KsSS_O z=9HV*p>W`Zyu`utnT+6F@cZqrk9y@?SMSXH8Cnc$_ly)@GgMQk-Cd#Cy$K53PNik$i-3+S>j1~YH3FVJ) z>a~8!oXpwWp^S2JDO9&E&wJhK-?O}ciBgtm@l8aACH(&`p6`b>J0%rx(jW7%_k(lQW{H}B!ZKyx0hzC)|BWSt?RMcasof!dCF zlw^*EodJmn1alcb5@|5K(wB{dxUy;=pSBi_;SJ|CD90>}FR(6r)2dR_R_fCzrD0nV zN^{K*ACF~knk`OsyhVg}R%6qLMC|V4gvqWK_j%uL@v=JEv0B#2IT?=lsyFufsxIqT zx~(Nro8XvN5^mKLxtb1tjjRh^X*>}&F2dezz3E!2X#Yff{IgB*D;Xk``!Yzj>n|$m zj}$xT>Rl1(Oo$B;;{WXY4Exx(IKrk$yvyOg>mn2EUjg!M6ygg7<(C;M)&ATERw7)c z_#&%XO_C@Z_Fts9EO1)N2f40~+_jh14H07fR zsl@GO5=m6_rcgA=;JvHA1ekrsk-ZK~o%+cH5j)(35Q$D~piHK_pHqG-Hdv6bb1U%) zT7Hq_(K?AFz+)YlmOR;?!mXrSI{Rnseg&)b^>+?|4b$g#QU3^;FdS$E4cJyEN`yxV zhedtl8n~l~DvVkZzOl=!0ZS$ZtUN)*B~e9RXn2W0PEz56OCS?AiTX|{4JAvf#%4X) z`v+cw40qn&I_&K)nan74;=Z=Nd2<4gAY&jVF_26BhQFKr2#wyuWiEF}TU7FdxijAn z(sb0ySh4|hLnTL|p~&;SJ`q4eildkzlS}cY>B7p@&EMuv+;(>DdqP%sI{ylSS^k|U zYor;ZE^j24I)KR^6npW0i>^N&qESQ1_KzIEsj(ad-$WQWe8G4s?`-d^9AEv#%!+p1Cv#BybOgz}W{pohF%NusTyTheQ+~=uRt@39&Zhw-DKV`iv@}#qN z`1shsfyB{sa*x|pw_uTg!$PJ%=Rh2^;EP6joe_)BGc_K+0 zY+eBZ`~+Z;obFdy_fxJ1?mgq{$(2!Jr5`{gLzt?gd12)c3s&6a zHPigF1p=SbM!veQMrxhb!*L3IbaqE7t3~@y%V!?q=iL|i_1|bjavurf^#ndh32KS} z0N}uzAQz;V9q__gh@~`Gx;&Gie3}9LrZ;_E`DOZ~KhCBXf6C3Z0xFRy9`Qo)PK|E+ zaW5oLQTg*}K`=8iYB57*DF3(7*mv70RHtcy2Vj$uv-wv4m$wYr@474<`M3V_Z+qJ= zA2Qszo}|ZARl|(nKlM54!b}oYH!9#;$>(Ib=hJ-0yRL5)mjPnehmcV9(QH`;CSmIf z+_E)!tyI3zHKp+q-@76SW4@W7AR+BX^uBWr+S7`@AuRlV?7paKJIsgs7CEkyh?cFq zE;=kik@2Q!7^gj5UMC}em@zrSNd8Ip?n#W;CtCGSNtsuank4I21n}<*#WO(`)yIE_ zO@$PKEp9Jm{wlt4f79|ejJcDq~Bry2B=sEA5Y^6<4A*Y7Lj2&FI!j#RRf{Q)rWk!IDTwvP7uIsG4#{m%nV8vbwmy);EkBXb6AasW zGT?eUgYRhuz$gF=-{di)qXQGF9J9^uy^!(VDW9d{$a>;UU-=3juyerZQ_{Uul#%k8%nF-Vv z69`Pvz-|r|_Np?sH6z?TpT1#%ylORm?0R9BDVWEiiuEU(L}tN%y%SKs{B<_Os-XDY^l92yv!he)3JH?xr> zHMAH=Jm93r8=P-x!qw~UI5PB;_V++&(y?52{9M;L-Bw#P!7u8mf;ZqoKT0y5av=pT zxD#Kcx6nQLA|edw$Fi2RXCOhxl=rPRI$9vvqK-~Zu3SU(bLrV}-I`<;VdTTY?m4(s zK9_MD&?sp%o<{{J+03a)WAqJDXSM5^Q9I>kwZNPc&MzaUZ$M9Wj{j}_f;wm4?wwCY z_gV!JtSQ~^Cx?^S8LClymyimRGijlx+o6$Qt5E0PC_XEMsU8AX9uLVC|7w8Ug`f+T z{|$Bki$L^ntnDhDt9-Afjv5tH3>`VB-O7s7kNI*Y9;LxOR61rX7GZjRuF$P+{ZKx4 zd-hpFsOmd*ug7{c><8{9@UQec^mpSSfa`psk{OEDpt*n@Ji&{TSrQNRSuQHvcdb_5 zg=Y`BtQM!j%6H>8vWJ||>PGS;qjY$=^PR%VM7)rp5FDy3SjkP+VgL@%!hj5msqE-Q ztY;@IyS&FZ!h%tMAd^-@hfI#yU3fRyBWnnnNJlmR5w)SPC9l}DWV;me(?lY6K_|F%o_l$nR2WAKmjBhz5#CZap zZSkHTxNSre^i~&}q2Gp{VYCs+%p4{SoZw70v(|}`4Au{*1$t3$vZmPD8vf~!G2M#x}rs#~q*YLg#T#j+14fRb16ZCCB(2HwP zLmdwgG8>N4ZmXTHh*T^nf&@b3W#hT%+DTeK$I^9M25z^`;U|0Zk(fc*F^I54wS~XuzA+v7;?VItSDs#>@J#%k4F)YQ?Xn6EVKL9G3+v#6@ zX)K!AaxGw+SS9nyV*1cszIS=ID)fwQeZ#k4W&eB7oEA!(F>|iyGBB zioLXT)vxTAb)5RA9%C=Gz~4%5_me%RZ}gMJ+cAS(n+a<8`qSnFJvs^EFTWA+92kua zs=BAK!Rpf`mO^A2kz!!%o^WeqeUr&=_9+@h-hz`g8K$^n)NT~xOjkanR-^ULOZ3MP zu5Q$MqV}IIdz;i?MIF;H*<)dg%3n>lwUejZ*L#NvUGA^Lx(2mPqGGpK>!U165}T3H z%l}0|tW0A|aB>{Gk4s3@zB4mM=^R`%`FI4A~a4I2J> zmtB)STj1BXZW;fj`m(Y8#k=xEP>?14o4pEJ%+v|&g zv`)&K^0a3@=TBFbm~?IYF#NA{hXH zL>B+ME(Q@n(z?_D!hi@7hl(&eJ00$hp2K_5PoRh3KRnvT@?f9 zcC!&B#`A>O@(ROvpMR|8TMbLN$06%P9h;@SyJFk`KtvF!@F<9i8pR%`5I#z{WlOlu zS>3bWYn~qD1c0YUk)yC6JU6HTcKc-gHfS3>abHRm4A5W#%%IRJ9GoBn7fr)?I_Wk6(;J!o zTp8#+tki2>;!d0_)U8&89 z25T%zhtqRNp{p4&42WZ{fin6Op}Te%iP%sLC(%sws~7?+uLiTkuFm0*z8OR>00x-B zk*B#~$|@ExZlRet`Y)9jM>N_FjkXyUsc;fi!I9fcBYpUwjqIWl;9y?p}M(Q4XIpanHpEr%IT6Lli! zT)90kI+Ki%Zp4}y?MB|d<3+tFWaytHo^^LoUQN-4{(y(o>N@%iB`hY#iTJDug9TnL zE?|f#uXOelpNwUg))1Hu|75P(c*Ypu=#H^aLtB7M_XI*O%m2Kp#$2XcP; zsd+0xIit!n)KCr-425q0{nKE+oS@ZlN9Xp>>j1<44YAUhwBwQNYy_sv&&#zlQDg>A z43&;`u|Weh>hT7mLAc~!Ln1^wdV;4h05CmotS~@;f^fc7%e;Wy6ge=!01$$}W+>r5 zMb=9lkjGmHXb*7eX2Q-OCN#eSgtA@=9u4NNdypQb-_ws<7le+jJ`W$I z&rM5TuJvaYMhv@&TJTG!FMC-GM6T~&537L)LKjc;%pyjm$PVjSz(N-M^Z*ZIwp~FB zA~X^J(}-&#JKByUmLMiz1T3kj$nN|YoIw0pgyMHqt8B)S*&S61CLsE549Hl-43T07 zKy`_tsKAn6e!S<+iI^f%($OJn==+43Zcr;4(I_g;5z4ljKF1m>D4NECKCeH9UI$0- z2u!j^Q=`z=z?3?5{Qi_`KfNEADF`@h>7#Ap15QITN22MOOhgF+T(yGsiTx~H{pqDX z6J?@iO+J$y4X;k;Kf?1&z{4z4^Bf><0AP;=P=(3$Svd1FY3wi0JRUC-WvxKZ3BO<# z^ktEF!Hn{q*Aink5&K|iqUZEwn7@c22%Quz_Av_0z>D)s2Nr-JOdIdsEXU9)0BIHa zHLWvOSo&)Vq4Z2%q<=HGOAzk+0IGcy5k7T?FRU2 zh|pX>NYlr9G8CGZ_?Q><`BwT(kaV9CwI6%(IZ<1kmk6pj0W~#}h+;{I;>TK85)IV# z7b6t7Q1`@!cyfLs>_$wm00>#2yd&Z`6}fK?QZkbV?WybA>*nc#;$2T4P_RARq&fMt zhx&0y{Be%@nJ&^@NAEsXAH_~b4FJAkxj9-IKXZ>D0WeiLd?m}! zferw)@6wE>1;9=LFh4XKdd4QH@YbWy@o}h36N$XjDU*h)8hCkxu|{`GF%KKyIl!xj z|7T;!uJLXp*ce{_>89uN>tK_t^_2ZBk$u4K9cb%~g1Kmgv3g;m?SWtE0T}d#x9u3v z`#syi&4}@}Dci0Ysn01?RME8ThcCc52p7{)%RqIqWy_MkuLsI24(qoBKA8vs5WE{GqoQ;AHb(>&Ucs2r*Xt|*;{?l?J?I(BPf~PbKlz)=&q+v zRHzGzIkT1dfmnr5rGIcZ9O$@mCzWC`YF-=E8I?^8>;5AitL;vYdS($ z;Fx}EF#G}dO8C>7J6oAU+64XCo=Aa)Q9>gnr+O$sLW1pQN5t!P;iuo^jn4sJ%Xv6- z(0C9ymsmfcJ$`y$hD=V{Y19iLccH51O!dP_enDQX!};Za3a11OIV=u~UPb zZp$ji!XLI~6Mopk{7yTZ^SZtV54Tf}lu*@uQ|fHJR+jgpmg73|wr)mRP-7DmaH zZ(JLl+YCDTPj>ifH_>{38tB)7g*MR^*q)0!!?rm!EZBVP>M+5%{utbj~d%GK?s zL-i+mWT8}WPp^v%KK_hck$*Dzb9C%y#EO&#vDg*@)beO6eRaxy0x)g8kB^%PeizF> zb&rwE5LS&kSG!VF?WBFYe3+IKc9uZ3?ENs34Inx;Dr~g?P@u_!5i>`$pMp^$2uDEM z+2hqu5h_^WJ~iOQSCK+BNa28pAOI@LDdYnXB5a7@2cSB{>t6+e*BVMZ@=#A!{;iOQ zp#_1;XPL%VcP4Y*xpwwA*;&Y6RF4*rsb7$NvOoaA7f`_jtMYF$(Wv73brW=i^o96YhLSM14p?e@IqeNYPwK)mnI_ z^O2zUF-?Cl-Ec9(crnvtDa&jr+hQpPvz&WxInR1I-)5!2Zl%y+rRe_ZbEnl}m(>#2 zwNm%BGSBsLul0%t>y^Hrsvdr-_WM*5uu&VhQ5U>XAG+BPzWE|zvoUh(<+~Qlef($?^;e~+P=tmE)~7B$y}Q_){k}E-eP`jv?$VFXD?j(wFAp~^kG8MA>|CAf zU47mEb$amY?C96|m*3w`eqWsa{(k=F$G1P1-~U|w`1|Yf@9$qX#?ZgNFaQ4f@%QTD z&*j;lAIHDHeg1X2b@}Df<q)hRo%$=4~z0N>m~pJvwtdLr^Tf#iXfnGMhnR><@7>db&X2Zv2c;S@pYOZaE0M za8Slm#e4kfI-5f(`vdWhU}wZp0Htl+h_hMvCz3TYxAl{pO_WE?@)bVQ1a0G!Fh`R6ab+fb9vr`^?;a!@_zgZ%A z(s$o}Qt;oDg8W>0D7%Y)Z^W&_*dOqZUSRb<5QArlkqVHH&a{o4&(`NcwkKD2gIjGEb^sbG~_AQasX7#Kq-OKkLDI3Z1W0=?CaGLk3 zF1kN|HT*pCycSNB-6pQFpU94|%~Lq++UXeMb|Y2avr3TnIRjY-sRhfIR#XNJ)%Hk*Al1{(BAlE}N8JaLZ%({jWUJ9szv5Q) z+MkPsib8k1JJ*^|myghghS$8uny1wIpPKpBJks@ki;W(KpY3!bc$*k4kz;1|DSerMfV6?-;f>!5{o_SXZMU_mPjLG#dVdTB0gLNasy;pqBEB znPr4MLG9fbFIX~NrReuIeM3X@0y4GgjzLm(>|wGiSJr2xX!`o(FHvR9uKF^wKu|bR zZ9>n8GHa;_ymLqooUzfzen&dBB-#7B;JH8AB@_+5@0Q(U>=ytejeIFUdBA0A^P zy4~lx{WSup}T8L zJK}FIy^1aVcoXgFAc>~QqtLjZLd5t|&3#?RZ(9NP?5vr~YP`x8RaL05vT4&7lp0e&#$Yrsol1!g7v1kv$$a>7Za;ya z*|_YmCrk;hMp?(EY`6)XdD3a;cDUg}lF36am|_mUpGC0n%$Hz$UI|h-x5Tj(=jJN4 z%C(KB1(rnL_*Zr7ZNAr8uetuc2)g^+7fn$+Jk6o!rFRVRd(q&tTkgpGk!OtFJ1~o- z$~kQ^5KVabMrGSwP-m)tk3pk0VaQb<05V4IN4HF1b@?ZYz&H0qQtNjSWG2`c5+D0EMm+gYTOLG;7D$dz`J z;E&f*|#XJ{bsUIl1vU{pdy~BqijUBh7e1vqC_vEBaQ+(;xlSX=*wwGk9q*Ia% zzjckiF+pdYzBonm2|>u-$@>>x`5UF}wbjM-?9$L(c1q zj|Z&U%`M4n7d@Gv2Db83GF0z&2TkfTy}QM(ImcR z8F}od*xp+#^;;63t5il(o{?|@&K((m+Ts8Y%%$PaHtj+VxUXV$vS{$(;_B$Ql)F^( z?#fFHw z{I-X+#?vj=S{P!(Ls<-Yz(&~~&ZxkmN#HBvLP^40Lxt$>HAs-?^yVDf{Kqf8D0H$8 zf`hLv9Z~vi= KCs{XQu|NQnidNHPe)?9Su5B=t5_wE9{vntpdk-`lwF|j-ttP- zxuKsuiC4%eRdByg947#MZrdmrYu9;txARYNzIyA%<4N~VKzYsGMDXZt9|!C&EI8C% zd|ZUg{_&4+xn|ZtoqOidD-t3R)Wimxu`YY{?RCe5Sn9KlvD^`v*wm?{i)-6DPM6lJ zBROupN!qfR^&bvB`-i(`6bj^bFq1 zv}F2pSZIgrG8s#dun}qgrE~UXGiVS1yBiD-iLd}b zwF?I)H`%7|vrvXRKU~z@k(r~&9Dzq`7LQmqT}v^szxdVn@S?I7O0DO>?r&rO6EYwG zU>U{$?sQ|=b+gIj`qgG!Z|d$2b?FJFr|YE8_mBS|#~=*xC}^UIzODlK8FgM=cEr0~CE>V8(T`kzGZpiA9#>15tY~uAGbv@^JhRVK z)QR9|2~Uq^PThmYU$zXw<9@arKi7&Mu){a@`M33NYAX@pqU_a(7xvB$_jySd$uE(U@u^aOT)eX>x^I21Hycu{qnbo3V&tN!~D%qfOFl z$J_<7%6HVVH!7bDwlWP%Ypcr3cI-27v5-3;^E^tm2jUBF9?LY#o(mLPfM zh3hPXm03%XbmAOJfPj1st#0v0=4`2< z@c8DEurR+6xCS~itJ6(mROWGvXS`ILnIYbD;Q&^whx8d2rv^o0WK8o&?Hf7+J_Or$ z1S$f`^^}1E_H!JqwVrmyi=A^Tzt%d`b@(d8ZJ$NHF9RXIcO}$f1?o0q)A^R=f_?z=nmY2abEyjqEtz;-SO4<#c;{LzZ4@a^q6dd0C%;jeiXbyCt#&%>)(&6Xbqbi>EotL& zf2z|cEE7mG^GwiDFiYzs(g=lNk~&sOLW*|WE69xVQ$j5Mz)J@f{${V$jJXQ zt$TK!1sPrRiZ&sUyKaWNcfxFztL%mOO@CG{LTn+|3Xx!i%gut^VKD9jhRfX6!S2?-VUoU`QIF__{N#mh*P!yE+AK3T z#R$N)eQ>QlGF778{3ao9`xy6=c_fGhH2{3XL{8Y~l^rzL7ph}DWmS|DkzxjrOIY#{ z>gx264a;?n5v~-H^?5J_SF6|^3mPu88>S;R(-XK17UbRNGG)%<_5-yhH7v+#)Zw~E zc>QB98x@xAU6)80OsLtK^kX;^d;~d>=h*^ebg8u6voQhh1G1c_SinH|Q6iJ;j(#lE z06xqecbyOjr{!6z&Ydu?IFv=cW+J!Xd?l7<{zNipkg3u{ukd#+WL0FsT0)1~7CE)0 z%2Ls++kXFuwF=_T>St0FXaf6rlrLDoDV>xW=~w{txf{?U-J{d$5)6Zo6)HTiL?V1J zj(I{l{qzPk5(=S{ll_#g&LC3=s2?(FkT$E;9tq3#Q1RDb@?TOzGSiVOadyPVg()CR zk913f(a82WAkUw$K>bd2OV?9q?0L4UUPJipi=kq9^&h3;nPVK_et0tgFc861!tGKNP z<#v_N`H?9a(0}9tEacdE&pXuz7H&3}i-I@pk&~Cb-WioGA|k|q#0RRa0=7*sDMi>+ z0MwYBoMNila%0@*=hS4a3}bRXvz?HZh%9R%PfuI4W7Dm$P^OhUl5~^y&Euk+iGj^< z7CCnEBnivp8y?Vk(DnCQlSR1+YFaQ31abnYsSJUf41*pFg%)%wCxri68Zx#N_3)gj z(wwRp`&zqFj4 zovZY+AiA1U!Vc_D=#3X3aN~nt3gwO~YzlM1z#xs);|4NMZFo;TB%>yx{4h0vaYAGXZK0oY9XOFTGt(xf0|IQmxfFQZ z$28|?0|_L2Ig?c2V#`Ciqk0XGi*>rj9Pdn^f(^9IPH0zOQX=$eR>pN4|`gIxuF87Tahgg4?lC18k9F45(`+wtvxGV)mE>L0x8zKg*zwNhmv=j zJmo~1Vwva_;WaD-Ev9t%>Cm`poC>g z<_*{ZvF5D2kwf4Nw>3Y8MClQ}z21g)P#>}T04t-s`57O5%y&ZRHH zdlNavTb75o)LxpLHJiXcl`=ebXm}U%P1vK?Np$32kOn|feFKG)Mcf)P<>8`?Rp6FP zrZaV}xJCaMk&tCNpO$RptT=;g862bJ)C(ax_Nzg&MHO8)PHEf?@;MYDi3#RIZ>f}@ zBrftsy`L#9kn@+y56Vca6fW#ooLAE%ufsUi#J6#}i1IK$t68+c$0NB@r=E)yxNuRj zQwHx7tkTMc6A7H(n^-lzu5(33BY=p@_zkXT3J!E6UYB0VwMSHuhA%Ng(E2sDxe3$i zE}UK&nXRT7(- zRPATXH{g7QZn@Sp^TCIen(n9Ul*!3}4OhIh%Tb|X@K~{S@hw$$rTh;*zO*8D0)7pD z8cNH!|EAC?=m-AlcJiGFo~F**lp3c=;&iLdRFqN^8wk^#BPS81|1oqP{!ssM9KSnv z=Hbpbo3kB9_9}Had+#&Snb{c)NxD1h>@9@O9w9qPb!K)}LX@4AkRh^GVveTm|{3_tXOo>60UO1N5Pf8?ywe(rMNa-j!3Fqo4k_!=|P?njXZ;&fY@_640 z?dxpWm3aZfHSR@GX9-fJ*{k7;HKh@?wYAFrpd8$7DX(X|K9mwRK9{KbYt(9oqL@!WWkdo|3NB?u4a>1I-$P1x&!%*ILh{!8TvN`vOBPt?no$2RB0at{WgX;^-2T^QAl=9u{3 zoF3%fdeg2l;=wv3F6ogra7J~Z0{fA$WSWP%mfu=moa(eF0mvJ#>t`n1nPlnnQ(*JX zOc}e<#-?tU$!D)`(tpoo&yI=aVc{g6@pfGb-CS;=7`_dY7 ziz#U;z`@KETWOr-Z53V^c=Mu^L4%y+~hcM&R_^s{Hl%W1gVe-Xilm~g}m1DbSB8_8Y(4go;FnW{YL z3_xK2*pthkPpuDR?spdOAGR(yQaGx(7+W~AYsU<}zBD-^{A(fC&-DteBE5LFjnNSY12}Fht?Yw* z=Hu#jMgc?K!QDJ>j$$8oVOH-)Y$r{o-fb=Cxx|p*s3VrP3?6hLMh!NBIEs<^wRUF8 zj+ZC>aMQfcAIdVh^MU8h@3*()e}jrF-zd)R<<~;fX-?KS-z|?;+xshv;Gd%j@5{>Q z5HH?iJyVQyyisEN+}(Xcauq_pd!!bGbzYbb@0Wm>wX_(E#~)h9cp@CQLM< znG*Z4JLqmbvC7Zb6JnXVX(;2Kkrd@1y252eU>J6PXH<^LfS`1j7j&Bk8Y6^~7nRCHO7^ma0@Db2h*te+vxtzzwi- zh^LsLxcDAcR|?TimO_L};ooEX$Wj?{-1EZ-MG)B&M{p>eIT-PdkVqIsj(Y1O$?wZf#vS9$5Cu4KD~rVzg?E2&ZB zJ(Vs-n!3V~TwRh<-ZBV!njPxPoZGc6Mf+k{VRz9|ndJs$1B}>lj6`b*^OOk3yknfC zbu<~4LD3I$y+NXs!aJ}5NE-$&Tm%}_S1M)<Xiic?9RrX9yP5Nx!@~!s8Jwqa~@QDOEu)#X^J2&aW{C z8obV_4V$};vINGU&`HoR=}eW^`RXSa8J?yxZOzLq?@z2+Z*|XCswqvZO}6_QG}Z^E zrN^2G(lJ_fv}IMF4AqrLsMHEqkSuuG-GqL1_{e-hj8psu^NG6cT@_1r9hZJVl@MD$ z`Ebc)oSN#>w>Hfr+Ev(vRH!A8roP^FG?$sLdl2Sf<98ymqH~O+L$&G2b<$cw0h?I~ z<7gDe(RsFH`CINIifDr&-16^4yt}W7-=X#64oL}N(k-*?+C#gU3KFLe-}3m5%^yGx z5Th}=9?y2O)I9_!0a~>O8i)ktzjyHNd}6V8iH!<4n3fnI%oy}i z$(*WGk(c;q?I~oL9&q{s0*hsU#gb|O^)1S8+|iJAA(26Paf0|okyv3~G`}SOMf9uo zaHjYPjc%W|uC>cXrDB7WwzE85^F&b36Ox5y#tlAk8&XPR3A2Yu!=why`%kSy(Q+>n zRDDOfcJrdcK0(zF)YrDUCS>?e3@eX~1yI-*(`Ed3ZM4p6FM+l}D%C0%NBpEs^toD@ z)}4iml1q^cGG>XXfp+)BqiM_-nZ$bejhHa6u3~v2m3`|$HD_1{BB>+cM{!b5^})Q< zey>82^V5T{r-w`&rvBVar{J;Ei&*f>D~pN0JQM-Ego31IU~nuB53pQhWINSR=Hrk% zD$Q%GJ?n@8po!d>N*b?}D%l^KHLBW2a?~V2FyRntCWeVZWisW42J4%;&dl5mg)#wU*JAO}K2^OBBXv*k#Ff+3@y-7vQ3K&*-o4^>uR?F$?mGyD5n`3G=P_ zi4X3AhMZY$CdqAQvZ&%|RKlYr&|360J>wLKG*GRq&E`G{w;qT4=nHRgVB_3$$WGm9i8Q`OS0<-4vq<@>OQh zxm}IUe`C}E)FL%~&uB_2Q!t#-VBccTQWrkFkiyGc`+W!&7H!7K6Ghqe^VpH6d`Rm$ z4Jl`e?r7mSKJp9a3n2|RE!F=@78sHj(ArGXTIUg z{8^>~&CU0LLuXL*i9_g1r&4jBr1mY?pl_JY_aA%*nAA7FQ_tf}(@v-<|L6->18CHx z)I1E!Q0$dZn&_N_d0r26C3YX>TExUKviVRhZ6XLXXlpOCIJVInpP zcS@+Ye0xTm038iKhf)fX*r2k2vE`xjWJ>d+?dGI@oNQ9;h$2r3iO}Lwhl5V$aRXV% zRBGrOz#yQ0Di@e+a>Fz+XCV=h-v6$7z7Qaqbf|>+lVGL}gyS_UA0(=D(*q7joz0p& z%%i-4`ld2(b0?sj^7FXX`6pwJMKICgp9$JZiJ0CJ%0VLQmHaUg_DhF#h#&fL6{cf2 z`*-WbhRgEI9<$oU(`)z3#eAk!Q^X{x^?JM{*+$jN0#~k7R9U*G+!t%G;ZDxsUISf??`BcJt4gsti$Ejh??Vsmp%wOwhR?}EO(pEX_te7jo z+ax7|vO&$@uSAItPsW$N6`o(Bjwe?~%T|*<(!ZZoPZ=5{n^e5U>kS?Bk+ktcx%}|s zzTRZZ=v9Ah!Vw&1QvS83G4gj(w-jh!R{a{dAV&IQxQ0!(e|20h8+NhmzdGx^2VBU` zj`LQ9)IW~NlV-V2Pp@hSbDU*Wi@ha&dse%c$O?|N@a(70d71wsJ6^zKj6#qZnUnj3 zQ!GOw|FaT?pwm@yWefV{j-KVfoB?y-T-nJACZ{SJ;=e#*{m%uRS4~)Uo*7UnchW~c z74mR03pI~`wAyB+wV4M+!Y(A#U|gyNrfj#tEGddVGDB=_gfI7)wUex~^gX%$a`IHkr~ZSW z9t93p?Eyfl%iXO627IY+w{g9vo>ie`tNmTlb-3UXgtCOI@d#!$E*8ARzCxs^QMMrP|k4&wCH-{&bE9 z^HychCUpi?YJv!w%MN%gBbx}uTAJ&aiB9HhVhB|YTVlLUz z9ge;KW#z#yhF-*;qTo7dh>VSmn&moy!Nd>K)N*$3OZq(&+B58@yaH zGS?cdsZ^M${4$ql4KUh<{V;rdtV$n!LyzX8o8((L`|TV_Hq?fibga%Gz2ZTN^^J5J zNxLbdixdb+&T{PeOOouw2gmoI>*Aq8E4pKS+`Mzr z!d;~OKHo!bXthdI)ZRV))Zbe4E#LIn|LWULLv_dm95?ATZ4Fxfo!r%Tk8{tA7W~za zsqd3xmEQPH+Iq-5t@xrxMilP|0H;rAU7eT^r)~RXh!8$SPjefddtDFiKh%e z0z{?K)9Jd_Tbiqnu6G@_8{GV}n!bL=Uk%HZ4odJpm6E*m2m95(_sz-fq4N7}ad?la zz>IKo*wjn8YNK_TFF$hL*W&;ktEAV0ccn8%rSEr~=6o^6-7VYO_!xx^n}nW!7bSg) zDqNIf@SMHNf9FCWSsHwVrT=Cuv6db7cJopGa(xFO8UG8V!a^Ix55N({ndfiatqY5Q z;6MbfxtmAD!tBfo?3OnxVdwP(8R3K9t9f5p*$^zU2P^M?!{{&jTm^o9bd&S(Dus@o zPea0NI7HNdMS$VXvJ{&z0xJc}$R_z5iVP(4)^PBI_N`^48|er-vQ3F)iJ9c_yM;DE z`{a1gt_ki+!`UdSYSWU{tVB+VeI5R#^_EXAIyTdrXZ`jFvf8=Q{M(e*$Ok#D_mN~y z{g{D9ciEjuj{#FhE#KJ&yR5hrUe(ZzcB|Akn} z`SqL~Gwvvar=%pVi*3&rr(R*W?YN7vPu!k^~4-+O&Mic|cm2yL<_R z&!wCJD*;9#;{_z-sQ_RMHy}YgPMs=)e8V=9*d!l3Ndx1V5Kck(x&u>ra1_xrp3^~X zT1=??db+raEe-Z^0KJvCsQR{;q-d~W1w&yWeuKfWv~lBH;xX=`4&MY=HcauF?I1|8 zm-`Z>0#?au5O(vevNZ1C;7M_rsEN}2hm6&|P(@1oXJ(Z=NkJVosdAb!O&6@8p04e> zB-Wn83T%Wdx}9lc65ct*7JD0#!KZY+$qB78kA;|P{XDj@eC1}+XA^| zcm%z1`U75`!R?MdPZJQdzJAFCk0DLzKtPCp7cHL+4(84${F0~Z38%GfNCw~EL=%)h zz1XkGkTJ)<{I}D67IooV#3m8W-#RmWEaG36ub+q0Y~Wq*Z`C+3Jowhn^(FFnDPAb* zY$$kA1aP!nB|$G(jhtlFT$*`#4xi>P5A-M8Ax5Uh!*aSf-wk1^X`kzC1Cm4W&p z8`gP|A){7p<5;>*2Y!bS@#1sAGh5rkL_2$~G2pC&jcAg+<4ENS;sVkDsn;u|P72)s zd0*znE=K&3ZB6OVB))8F6-N7SH$2Hde^q#&cA=|0U;mq{7VK*Qq8Uadz~AE?=29#= z5Zz6DX;O5z%cI%IP{(V{Eq`QIw1s=L&RezD zk;%UCsqu2GX%l8@hTaodBI~2AADtc}r3|b|A>2UXGB1oQ0Js9&Urf^~(Xvm)fn!n!Z441&YA84UKeG#kcI$q&JvNoObyBx|e{-Gf0vXntc^pqOSIO zC?u>jtLUIrg!9UJs?DcGs9*XZFUPQ@DuzP`q1Tzj-9yxcIddfAV?5~sPA=2uo_ec=92@)93AYBlhiojMl(u;UT8`5>~ zOeEXZCzvZ=7{`to9d3UUaqC?*7bbxQjC^P=F92-IH1$P_{OMprgvA+dan(+%Th6QR zTah3MvWsGkmvsL*nC)FXXKJ%-FW&y4L($k+_dEr5N+Ty394mJ5{*f}jCkXt@0=^h*&ZLO zaN5YUaZaOKIE1%m%F{-E3|24n85izcD&2QmXv~{|J!1~Wm~}|g z$j*!cS;8!%d6(t)mFm;nrkWBdd^Sk8$-2Y(n%&QooPCJXrGd7QNM0ca_=Oi3?0-i2()AI$R5<-^!Rw_FgR81v`*qv=CKw zx8q|7AG_FUX`?s|#khK^4VC{*YmxhO$}T?DX`ftfL$9~wy&Ub$@Yk7vxc0$FXhSzl3^6v$1{+IaazhBdQ6v$70F6TPFSMv~k z!uwZad;$|dJU71pwfnZwW0fT$UpZ?}0v~oUme=zWPb9MUn>M}E0DZfOHGCeLsQYSf<&-F_!g$T6c`L|(>EL z9MB!i2fo-31hPf`V0z$1Y$L-;8;kg6%k?@TB@h$Cos!Kw#O(-16Ci*+OcH=90Tav) zo?p2>^!VWiqu3Jepq{98y74Y5Q!7C#u@qV;BK_vwfm^}X@bOJ~W9D$)GD`8D7Z_Y_ zH$Nk|zabcdEs!`!;+b6uc5#wHe5w2|r&LPlSmDdDY_^W9?K7=+{WvJeEV*)-*JzLT z`OgJ-UZ!4pw`sU2Hn_W~KJm{GTg!%iz!F>FlF+5=&P)yGNTy;SfQ{>CIbWz3>#H{6 zE3cTaF}~YoS+`gC1kw6uGmwYEVm-ozbg9mCPPEv9<=#7S++-k+typ9%&=ZH{b8(SR zjSZM!Z(Yiwa!&xi#SK=_zU?4(@l0Xd7~!h#;C|@?QZtV**cf8E-g4pieY$&i9OoO; z(K<`=I%h$qU~(U(&`;Rer$S(br6OJhhq30R z;k-(6WygW(Iw`z&1|?JwC83s3Z6a8e^i(R={&`BS-rjpeVJ4!3t@*I8x%+7lUYj|X zN*75@zfyHO5hJ|E_-|>p?dOdvXJ7v18Fh-8{9N}?l;sM${^1YK1lKeGYc*hP1+tG) zua{S~QMKLqy5>GcrtUNLx~RtF)B&P}bx>!~kJRtYT^ht3IZ54Ns ztR#x29+q0`2uKbRqR;wmIyp!_0(|4=*&j8afa7ARzgy-H$sd9@1Z#^ju7d%dlNk_O z%9ucE2PZAYQG|s-pxMw9#&W$jV<}jNAw1tf_VeoRiYTn>L$W*m4Q33viIT8>z~O;r zVMGtFqRqX3CO}rJhn&8N+?-9gq!Y7|ED>@{5J7pD@QWUa$0%wF9qWCe?UwlIH4!=| zL0XZx`K?klF4vfYbUDWu?Fp=PA^P#${PS8!c7wLwP+!=#A7Sg<=J@39um|^TBJXi* zezsl+j}1`bBT#YLRh>OW{o{AXz84(p5JAuDTUAl}RdggIA(z5%S6_(y-n13yXQVIS zL$p~RZpjcE6d&!u#qeSJxa7}K6~Wq}t^A?%-7usZS(6U7w@WKdr0ehDsxwS5&ZajK zw)EqeXp8rpp(0k3r(XL?>Rg{`DoSENO~STq?=JBT^n7*`FY6pkSYKh++jg)HK=Xfq zLk*+Ma3;PJ(v1Z=anM>s_b*hNIf2 z95&&IXIFz*f4P89ubi{T$E018*4Nfa(pJ>(IFQu6yQ~|9WN;cFxa)JbYOkj1wN-cT zCiV+pAaW^Uk5;l+js{|NurwdI8fiJWI#PF~;$N@Iu<6smz(5$Lhjpc_?gU zm!anZNc(2X;6lh<;EoC3whf_*fXrFQ37He(^Z`7Q=~S9uXo-fJ)tB3nsdIRQ?3AFa z^ss3$!ehFpbVlxmXKo{l?KeFaZ@JHc!hV6J_NhKmvib*9-*%Dt(1k7KM1S)eX5-$U z6yMd|-w9+px2>d(FBM=vCr(wlbffN+zZrCvsaTNt*=J7O@gMR-7!GjFN&nb-x^^P; zBJjy`Ke>2AxvSRu;c_1phi*2X7#$SHfj?g$P$;bnu>wCN>F*+U;+E+fqX>)<#DsuXZR2b0>FVWo`*%e836E)fX{p4c||i*+fq=0qRC+c#DXt#^g*#^ z{q)uIO;2-K#>YZ(-jyYSrTd3Q#iy>&kw(3kZ&SGj^Ab<1O1}MYlm7ItQF+!|Wp#6x zTY)WdmQAX=N{na{xt8FBmB|r30K}qEU+W0n2CjXp3({BGm$`I_v{uloK1S^I6J`yB~*Y%SMrr*=zfzo4Om$(i0a3B`TuEu*6#0%tQH zAo~FET>Q)KYj4Hd!OfCovoe6299WK_Y9G|i=W&70dul@q7bTBkpwuADR#r!B~zijY@;ezQr9mXW&cgd9=3*fqz24S55 z6tg7W2j5m?QP%eGWA&`9oZ76OuY^77iy`*yB8g$YD%lKMFFsIB=z3#`F~=)5&L7yi z@-@!U+8fnCqX$h5q(jEqZqO{5=h>4cC%NWYn~S0jYQh0s{_E|Mn)#(TzAfdRgod^P zIl{l&3_-NJcXxv$G290u0;54Venp$7GNKMXgIbEq9ooUIIJVb4Asn|}&+eLDHIRI8 z6z$l@zO_D>2Xz$MbG%l|0$!1Nqwf4R^tb*JL!&~<;*kl;@cIu$yPjKU0stF+jxL-1 zJ|7!^zc==tpK}p1Gu&h3!74wh-vWG(2AG1d6N)s`W(+<`YJc|BqJNyS1R9RT`+V7^ zyCgBK80Znj`@`tlv4)%Gp$!^M1!JjBO*v)%O=>w~ud+`CCdFQR;eKna5q$($sI3qF z=t(PJTg4Kx6o=In>R!6qDy8=e>(J`EF<#p)-CQK98F9>c0RtNCj{1I2V5XZl`B-kF z)-Jr;XvCwVhDgI0my#4~d|}Xi)^rA-wgop34m5e*e>MB+ytNSfXst<*#%D*TJmTSi zK#a0}E76yrC(IfjEqN5Bp#u6ju%jF&_T82Ps%^8(a+tvFk$79)M;uJc!6nXV+hbmdJSsz2d92LP31z_y}7W0 zhn+KuW|S?l!ySd<^pLbO=hgtFYrjWUmmX_=)$7|(QA*%_8Tt78#`_a-Otoc`yBAOB~m96)N7D?Q2tIQKFy2Xm?J>oWvdI`C0)LpTe&Zv^5CN zG;kEmFx4?;Vz7p_=`PY{@PYSXukL*p3HpMAdKMN^BWPZN|)ch5EAP^_aldFHf&IC>8(ZQMoz#biT#l z_1VweKYQD{5~f8kof3-xO=VKSPZa*N`j$b&~Gg=nxB#r<*<&!%%yJt)VE9} zt371l#Exh5!f`MitnemCge*h=tj3EXkc>nmx%9k;R8cVJ7XLhWlqka&G$<{TC|x5g zB9KQ~M9uQ6tn@w(g5 z%<6;L(ePXlp03o%QaaaIU#cwzZj99az-GF<4t98c1qk7=rPxt8X?r+JV#DnQ9b)-O z+NBBd!#-sx>cfU~X;*oN%5OfscrAkjuz8URLZ^JEp2~0CLYa}-v~Bf=+FGQO8fv@W z)pzzNpqob%*W+6wER$qkMx-;bb*!(Rb6t9M3_5-OCKZO3?}6&prL*R`dkn~IC5tl+ zNwuR}MwHh^6Cux}Vrn$b_)6nnHCxuV&ALArOUm|T@@QA;3GR6=Ox9ex`Z&Lk6`>vmh4=%m=-GeNv;qm(H zkC(Q8#-0YC<1eRbL$SdZbMNfZ#0ujYd4!si6qhdZw*}a9xKZ{aW!!i-it|H`0|OIcxP)^rsIu!_OjdRxcr&9U_f%fJl9nnGB; zi2I7r5~W~^$Ihn}2jF9iwO<_P$`d%(kl6c`Qb_5um7kxGl7^D~%ce?~`jw}djd6^* zOHSmx8S6=h9I(AxiruVZV9L}G!gj9B;l@pveH;%mvl{22L5k*$^mOERLpV*iXCh~o zA&Qh69`5ighQ)P>`DSYSgjjY3Hv|PQ`%L#Vw86dJ-$0#>scZD8E}}kN_P3vj^O+vk zt3mlo51Lk}0Gq~c4t<7ro@Dpjo;+M(39U(f9=n81KFS5{!MwojgAIGC35$g~Q*lG4 zv%GR$l}s`%W`S>(o8_P)DrE`%f^yj{%cpTSebvoF3ADu(WDv{8{M4d}t?a*hH?Ms< zOua`H6+FT-`)`M^Mx1|MQ~5nn#jN(s5pCN70Wnm=t|JSg0N4Q_=N5*cz&TourbMkq zSm%b10DTDvKxJsq6$WLHjX7dCfF*PWFgq?XTPzQJBShnDcs3_zGb_m#3`xX^v3@6@WYnc zoWG%VuxPYr+1p{Ee{tes8Ln7Sv3?>zw;9b@+gL6t%E*m(mt~w1bN1U|c+sWD5!^4r z4xjsKai{V8ZAPt$j5v6NLEAmIId7frhx>@lSCDR}X-=HV`Uo>!J6J#j=%$4=MT92C z$8X$r=d=-Ff3lVT{`Q^+^iPimd8)uL)XK}X&Wt;#H~Y$dcZRjq+IaZMz*B{<8oJJj zlP6&Ucf8-FEisgbQP}67c?J5D_X;8rJniOVD+)IUYYrBWL+!6+gBZ_}*()%yRa@Vx z%vV71i3cU!DN?yDbzC-$7>=>YL46JK;-yzQFNHoJ4dI7GYgYxDQkXAF({NBx?^pSh z(X=tnPtGdnpmT1_-1G~7wfemti8#L=vp~PVqQtM`Ylo=f5&z;J#Yco6niXIUG6&|K zh$ZP9kxc`AFwL^q%J4(u*jAh@^=I*(_-X%ldvM7O1C28oH$5z_hj-`vcCa-+x4f93 zu#u5zPc|B+#{|ztnfdr>LqU4cdOXI~n@l<>xZmbbVB;y+24$h?3MkF=}mXh0awwFDu{8QXbY zj_CeI_MgfMDIlkbwuDZ>`lytVk+FS`gjEcN!ywsT+U3Yv&DWsv6Y-u|m$Pw_yMmh$F-$@e-c{l!TO?PdEqdWKTQ(%V{I`TY0Lc=OMG*_?6SO{(99GmKVqUnMtPdiIks;5y@=J3Ov1 zN;vbGZ_AUnfUgVdKO0qc{cVo|;#3(`Q)6FyqBmh%Prd2>vD3+kQFPHW;y)_O!si))dPtQ7Q+gQcUrN@y`@QDt z-RzdbqS-$NR98&}S*q#B&hy4Si&<1X zbyX|PnX5fehSAUeiZUPLS!aFJPoSxta+-NPsZOw%Y+$=UH{?)pjU=45TE z)YXw&vzW|dxoOj+AbxB%nZ)801C5X$XeGeOcwJLjEQ!!=x=v?~)eA_Fa&`O9Gv}L|HYNXX>-2Qo>d^_R29rg4Sm{LE2vDCDw!sSh6sbIkf9I9~|KwPzh7+VBhS`St z&l^Diqy1`2pBm@^z;PeTkuG;qG5Ak!b5wt*d3ZAy=|)RamkVMQTw_!Wrg;hVnA%B5 z9QR0VxYk1ee@Gs)tW2_oCHHK3xJ+DY4%$6UZtO~xN|%`6NQ#IGzBbH~UM=x$rXhW< z;afUvU-el_L8Hqe5>pBbIt`lsn~FM}%)XM!)YW*f55l_C1bc2{&Bgk1b=urZLKY`p zgqF|7J?ju5nP=|i<>}D7;Ei&zkjxs$-$wi8cXOZXAs(1lPL~#_+wd^St~#@-MR0f> zTzYEv)8Clak++JMM^Nh{YdbPaAmay-?!On9$3O(-bI|fIFC&6EnH`4iM(mx1!sps8 zgu5)3skgYtZYekni!G)}Nh~Y8CWuSmSN%#?(GC9;4Q=Jzr24$j-(u+M+^hA+V`vZ7 z?~VfC=^L)(!O@I3&_J!v_@&lFflp1KDm0mwW^8!4PW?c!a4Vs2?`qHZ=zba4X3f(! z4*8Zoc?JpHtAs5ep{YnHquZssCejCZTK{z6kn~1MnavJ44O6W?Bx`oCAFK* z{q0*1C1cKFYI84o5x}TWpQZe*RZ}!uWIbaPJ-fNfc`VYNKAOY!U19{r>>Manimakg zUige11=M$VF5kx;D&$bB(#Ieana<*b+vh&9$L0W9^JN+|g~JJR$8{O3NR~+woqEM-4s8c5a;ZZ;@lBccecrtIZnj1_d`3lr@Gj z?}6&cSF1%zDksL}uFxiET|R2Q;6wzhvao3G?r>MI9Ase{0ph>180)D)c_bF&8a68i zKm$Q{@Qwt7WUi&BD{mU72^x`$v1M~RB4z5tTN|EK<)w$B2vkTe+Z^_iF(SZz(EwbB z1)CEJuz02t9sQTj;BTMFeC+G$iIp-XSUdr-kFniX4Jnqljkq$qmiYZN@Ag~eir+Ko z$^az0W`(hp9^${yQo!_CT)l}i;l@2H(+87}*2lY`u}-oG>A%Mo{-%6O;uZ8N8|xJ{I~6ti6)Im>F+%d7!10ST)NrIC zshpyqY)3UJM2$IfPl~>}O zTyK$H=^u2sb{cI74H%8(k0MN9I z4~>uCqYw0}ln@n{an$%I`Xw5QNPwQu|II`bvjMER6>BhD@4xy-{yX4^aOq$r> zP=|Y5X}SF7?r|i^1f6i<`0!YhyiH45#zQl~7KVRH2!7&?z!T)oJ64)@W2xFsEY+@h zd#+T6y*X3=#XJ4SA_fVoCxb80Ul?TjQ;G}RBSvKhKmg8$DvpgzPZh`*fJ(P02FW9N zJ)e1WUi1AS+K=${ro%s3(|5|m5!+t+aZ&T(UJmnE5h7|KP0Gb=Rf67`Vx>eB@e@}- zT>TMU$CPB`4k)K0SDhvy%+q}_r>$8gT zqGUd?(NZ7Ou&G)>DF=YUe5T7RXT(9Pt?FK;S~%y`L!bByV1J*6wE-7$ukC%$n#bN? zwziL2C(oxI8b_5Eq919zY(Rj!oqBRS*U0W^)nBiEpL68kQyqa5f)8o`Teh+&4++JFr zoQok)QSn6&mDz7w2u$$WQ~X*=(~8L*4{X!VSInW4N67COwlY1P*}DtMT|*nsiCMoisy1I?VbB%yV5M^E7Rxi4H@BWO zD0OfitfwYAn=q+j)4#2{`1^lgnP);{ncTnCh7sua9a$2NUfdtjNd|l>&@b}Zk z$MEG z+h$1FAENHAMGZY>z+C>Tf7y)Hr$Xet8IL4MF3;S5*!&t(yIb98Nhz%-wSCReGGq9K z2YURuF(q)k1Fq|q`9P3!LG9(}2t-m=u0penGE%;y!g} zpv!^xHM7ayU`v~7(Zpqel*#Dkj)7~680KX^3m)+ghk0-9rrvUDAWkzNTINh|(&>kP z&d@u4umv+H^M`**f4puNy2Y89ka-t~f5D;Mo9_-!E$w{>=v$GrS0^r=Qq$@O4We1T`YtLZbaV;%I6+YE<+e&&n;z#s44kG18x=n*Os(Q8flX zkvmT3Azy%GfS!Tsp^Au38zuct$kGRc1JowPHeV+hd{32%Ecf7?a#KkwcW0iUKs#GD zyMMg@+;_z+^63sG`91gK1R?ALa%QOjb%tY}Fb$uz`8?URKUVYBBv4Omh%G&l3Idk2{L#Lfd}s;z1Ul6f@c1`3X( z?O7kQiMOXqiB)2Sq$6rEhwP9_^6l&_Yq+Vu=EK?=8u}WFCa%7=L^rOUM?U#Sp?kP9S}HR3>wXAU{kL^W@vxvj`63(c&%Ji0d6SV zUP=cZ#SQUGhjFj8W}4xA9~!RPs<&>nCP9X?cAMwZHN_*b9lX$pFZMp8`g)o~%z|LMFpJ2TJ1*&OQo;WsZ0SdPEGK zs7Dr#IxXR_Ro|e!f1NwR$Jt3<2U~-MDaU9tp!#4iE&dW4v}+$ne+EzBx|PAF6x%5y zRQxk#Q^!AytDpl^8f$;5;k&!F$MyTZX7A88;tHA-AD@v z2v{SfyUT$zh?Iy3NDB%|mx6SPl8W`>!~2}?fAQT<*AF}AI_Jvg^?2MLx0|>z@q)Wz z8qOp3;*yww@%E`_@|4OpU$uZHP_Yx+W~h>_8Y4{e2Ax%_k8hmi*#4LezGfdjc$^^m zS2392coLh_>z^q5gVd4f>$pJ0KMm<&Xab~ZTQ?@L(GD@O)rr6SG^FZab(%~UII>a9 zLHmxa6GXoAApigv{m@H}@~nN4!~P+C2Vle4`s;I)sk0WJt8mvpbdmpn6&V-EJy5Gn z%*~SZpYdLzn9K^gy|mN39!kx{kepUk$gC;f6wR!s+|2*wu4>~}&DfYZ!(u+M*ta`va7BiyS2(o_P9!v?rSn+tU8GN_V{S!H zK$E3okJR@R%09SY9<($3De!C#V@|x46)}`)sF6REW$qm2R$!P6*Sv4hvOS%8Yq-I^ z(9eL4pFOx_;&Glsq?PFve~uHlvI{S;O&u6PE zOA5Sdq0c%Hi2r^9;JF?V9uluuG?km{GV&n&B39b^_Q-CMJjhIEWmG$y62)b!%3Kg? zVHC?F?!~`pJ?GU+XOE3ScdkHX$%|%v!PyLsgSIx3DNscOxxhn}2l(`AJ`*7K-s*e% zyS=n;{x|erJ_xvBMhn2>BL;C>qVP;QH<5l^zoQ?M0`WZ`pDO%(AP(3RzBEN|NMsXW8rcyy)A!@g0mdlHaTcv$=Z)yJrSs^|P1n?bp zzw*xi_2(l49kXVpoat$P-z{OZ{|es#A5SE1*stW{J=4>2%_^m5>e-&2U@5iiYIRvJ zL~6oxxrm#vo)v+{T&uE4XzfGQ{718D6LZzyb7_>p3mSy%D}D``K)hW{v?bq>IXDa; zNzy)iVCY1&Hdx7Iq*1q?QcQqpL`%mrNv zzDdR?f?*paC1#MzvgQ`ccl{N@N}{k}y~tXoatxz#r)|R4E;anL19vK2=!XZ(-w_mC z{`H|~+cwj5al~N#Ri=-h&Qw9kALZrvDY}d47W;RwJ4oGkwDi`)s=r|2H%@C(ADHcc z762=;=VPfg%;M(iKkpjHjt2-Zg+ZI01EN8ZGL~1_YC!rO8Eo5E0ca#lVPL$`?SyLjX8+ zC=JSo>b>~~UPOkmVyz^q1=(3fW>fmd~&#c7@LyZp!OI&B8#+`>`IQyTJR zBP*Z$8-03%Bu(erQ(`*3k|p=~8(k0QwtvK9gKH{-9p9#xf4EaGh3n*MPtR1D%Kbwb zeFrW~!gub{vM(PwCzQ$x53=%I0&tph%}`@c)RB=BSO4NG!jqwBWp-Nd@%4x<=M^1m zlT2YPSLELy9VBKo&P1UPDHj@Hh^7-qaJgAeC-_IJ1EQ{c?V(}{i->tp;KUZw@%wT-7ZSsho=&U z8Srs%u$eVCozcWO(f>faAw7+tYn4;;e;VX%>kW6F7qw?LGx}2EooD1R_r^QJ3gE&=lw}6KLV;f~#_4>Q(n7a6RvdimgB| zGSM#bM60y}V2;t6LT(!E0NqO=_qhBRvvz%T^*N!3m3smvoN zDZ7;;eFj=YDZ)K{6J>3LRx`R?$^?m_3M-Ng)Aq+&8tF1RYo`f#cB2$1 zw>&NJdiiMDwVqbOHfDo0KHE*c`?ix01~r}EPbrV_ z`%I`^_=GgaGnI1PCHv{cP$f>RUG8S>FVKeb(ENq6tMJ3YNC3xpD%`j)DC zNDIq1nqf0F!{S`Rp7L{7sK>p5gzu{<)VYPv5BYc!6rXF_WN5`VWZEo6$G^%|2|B*8 z2{?{_6XiX`k1TUk8G38c(l5|Ur7~WnH2GKou;Qhuee=sp=qk1S`eUP49&z7HnS&|f z?srKCf=Pe-5vSWs_E;pkH>rIsiRZ}Szn6ltNGOsULcfcGqfLRAC@@VBe-E6m2la;J z)I?#EoiN2^IZ2;J*g2VDev0#IWH3zx2kvp(X+U7IzR(r|GOc46Ew&c&tH z)SxxV?Dju{wKTLIqppOS(->AJc~Zx6#=FUuyX9}>b=Slu#ALX+yH`eo4G6GPNr+^a z5E&fy*d2R66swKbO$yOXoXxDv=B{GVE3LW_FN73mj`RA3dTQa7=E2)6c%lDtQ_-+A zo}Je_*u=YsI$^t&e-|a-1hPS%c~|Y+@89C9pLg}5aA3koEwG1e7~joYVS3r*T^3qz zoYS}R++`|GG%BSVozhi$T&L!t%*<}13ziQDWv(u;MDinr0C#-~Vx2-V=5|S6BHZQ2Gnlw< zpF>Mr%d8sAGUMG#9%n)f(&U*}QLj(s)4*9AJXzUl;q`7=rRf)0mHkhqqrIH4RQkUZ zJ7ZP6R`gpmye;A>q#BBE8rr_b9Lb(@CX?fDlJfxfpmUh#+*8(PHzyU8J1LhdrcQlV zp%@$m{k&x_qL@@K^(c-H3G;W*qBuy4n8d=9>1DvIM>NzHkD}ATqKJHYfpVe_XssPo z)WoddYN5{_V98yuy_RxFVzyXRtBH@i96VKTO%;zrg8`Lxo95ji zkZ;tF?(g0VGJ`ZVCt8#JerrD6@_H(`iTcGto7mXm*#tgZWG+OMpIDclDW?35R8WFA zb6_Qqk6FGBr=WkPRQ@6|m6~q1PeK=jOIeK;ATX!FU(vA-v=ecq#A`X#{O2c8jin z_Vz&s`yk1~?(bK?C%RxsJou+Bm}Y?U?&AwlrI+9vhi`6N$0#~hOTQ=0Tj`|FR5$s< zh%Kh2Ymm^q$0cqxs&zH8rPRz53N{GON@(pHzFIY_+Qp!%!jooipyCH2Z*``kGyX+` z2-O^);?t;mzoYL3Q>cP?R059gb!Acp2kY{*l&ODYkFDSTs9Eocw;M{3MEl3zAzLPCkLI&wvCe|AWs8rUBOzFPsb&*|t>8On#^EgcdaUqb=Equ)0W^Ki3`*fR{Zm zI?DS?CKKq8hNkCz|L6CNj-zkIfFoi+RL|(x=qTbIa7z`aaR@_NSGvVlx(}7!>~esmu;BB1Zz$7E?>{wNPZM431^*lc-`KwSp9|!sKPTs( zCT7o=mWY_)U#^Z=ns6Fzr}H8kf&|kx{0ZK|Rkr&o0)$0?*i=B=I1uN-1d(Qvm;mC( zqWHv7v^m|oue%TGF3+>SFQI2vyCuO(#{daiW=kyP!A5Chrc<|W=a(==P%~!eb9~KY z2R$JO*XB}5dQrHX4lfdYd;4mC>!r^t;=X1%9rE7oo3YgwwEtt{SXk$rXh2M|^~Vob z2UCyo=JXdRv3U8&uDVzc+F6a~Sdr%}k`hC`0 z`Q&7X24F!0Ag_*X7wHhWiGZvL_yt)r)A~E;t}sQ}4s#DaivNr+uym+?0J(P^laT}h z2tF?<963`2>Gh&EXhE+IfGh{VeMV}9IlTgjyXv<@I+!!z62@?@ zu?b$pB#+$%NQT82(T}BUf@tvk6D;}@Txp+P6;E(uL0saX{GuYW7TJCwRm@%_ImxnT@AY`*o{Xvi7CJo$!?T9~7SeDv|muizUV9 zUi!6dGuBHp_wZC+D=%Iy_V>Q5>+d)495eGiuyt1O(Ya6Mil=6evi0@fESjcDh@49~ z7P6pwW@ZF6Cr@h~WJc=GNBhpl*vZEq%qMX!WTw1T!)<`L+rvA{SZB(##%uJxWkmZ7 zSI}QLN>SX;2lII2(HWzJo4`*wNh1{RPcOwm{0tzTZ}9HocXd}98@wy5nr(8PMVuYN8#p^?2BKtBbf} z1wH*gDu!L#UcF(K<4`L+)By*Jv4>3)^_7ono$yo!uV!3Q1nc7ro%Ibm6x(bA9W^6o z`9i2a1P}OiC3FhTvDZ?We^;|7V-0(H^>8pVGFC4Q>PJ;ZxwUg8k~{TFZ+hEKqTl>u z&fV01^H~NPkv0njAK0C@v?qm-_3q zbDt)QyjknIxzFGI=_=l$EBW15OvV1%yC!^x5czZ9Er1d9-*2A4kK#+yDogMF`#QmO zP~}h4>2t99Z)u_XU_s))a?0q3zprO~#+(y?%m`o?@FMo5DGeK~F#ZeH=mPY;gw< zP-_FeH+XQ41v(fToDQDRZT0Zhs@Iwr`gA2TaDLd>QZpq*J|QkVY{$GFTQSKXU@M=4 z#c(ecS=!vw)czJ1whCcg+oY%b1mt_t8!q!6P zUgLmIf5ZBGJD*!lEx#_1o|(wgcG;q1Q|xrvTuxoSc$77c_oVBV7bVKh)ElL>iqr~; z(})l=$K!>H8V~#Ui-!HB7}D|1a7Hy55PU#GOj2?%0pmQ#5(86(0bbxe;jNT;?#Ea5 zYjq{AT#h4(UuI36V%dZ6y6~(d1RXvr5HB1Hu=iSp7|1ISYOBk_z1}n84=2G0@Iu7-u{zqS zp~DL;#yXsE9&_Sa-MiNIFv+{2!>|24FRw#JAAj%>-`BW-6q1kRTdwtSxA!SR19Dl5 zjo2J9TUFsi8nwj3TT@1_d1N7VLXc+)KD1Hp0=tdAjFtUqigdV0-f!|!NxBkAn?J}O z_IE1lzI9wys?}6vx5%p%3PwC1HYdJ|3 ziDLEs&uE~mt$n>uxU+CFG}l`1t1$@v@jpRmpZ*=-Ny4bAv)FL(!1(`D{m#PfGNC$4 zDrlF~SkL4OhB1~RSYUJ+#SZ2zs zrAlt8l>)W}lX@zUpzCjN01RIHUoPFb^f-a`!5~?U-l$?igDplT!c6GJ>7<_9o@sd+ z0I04GG%2Prue=1B+HVRl0)|7Ih9N6KgLMwscoF`_j3$G|Mpu4a=qN~+)&hrUYR6LH z9G%p##|YmEvKGrx4#VZ{(!!!IYSV^C@ z3%;PqJ1vPMI4E3m;<^NKzFBK*jBdi_fN1g zQnW;nIBmMCqXnA)iAvlRKf>m9$W)@eLm)6OdNr)D&v!;MbUCq;-I)`mcOKJqrx(n` z-r;!0ed|(2l`^-&Tf9f2R$7!S{0swAEB~kI7gYC2xJLTP@6Uoaqw@Q;oqs*i{RZ@R z4&{qvw=59R>y^2|tiKr^%T4cDt%d~|Z)*^FvP#7y)#UqjNYiCl$m&f%J4i<-mT6&C zOH--f{Q=nw%sqxL@wgDSV*UW501mgPJZc8avZ{K;eBt3!VAHc<)ZhcpR6+Kg+#^(M&;Q08jf%GYCf<)3b;^F)G$(B8vTFI2hN#2alZ8q)$EXcd)KEjUFdqSl0^ z&erW(5gCA2!jJH61otAzjv`%)*zERm!ZRW7XuhCXxUmo#-ACb`&Df?BiD+uG-LSc` z8pqJFn9g}j;Y%{I>u|nY?^HAJ^W)=Th(o4A62-o$FSq-CM&(0uMn`j0o@pZ{yOtx{ z>D})H0WJ$fb1T_-t2FLX*Nc`59F+(?f>~GMrTX&?-Ne=fTvSgn;}WG(nF^}tyf&(j zw%h_rrsWqKTGFjkjjc6Jk zotDGM$2~54nZJ2&+~VcyeD2#n7y22u8y9yQQs^Q*`xmLAp@aN8dDYqio_yE2-1ySHo#(J+b%>;W(GN*b~!0Od>7nS~~zJNvwwf$e+%@{|w-vLdMZiU%D_<5^}F z@aAeEs^Y??2H>zLY?4hAA+2w&&j}Ny8%C-$!O2iDSfm4+UKEFn1$BCXnY-kR8ftBB zI@=4xOpO$=B-`=CMKZ9zq1*Ve($@&m1k-?J5v@3T8eBgECfFen1ZF#j*#E4Ud#ZQa z5wdQ6BHz@z5fI>{%gH^^9Z8D%ydvh&npY@0JH!#1)Epk)qq~Dig5q%N2(h?Pm^W9K`Q9wAL{8T4#@(j7i5Gywg~9OCZhic`|r=@C?{v29rR z@S`|4j;CY&)p{be^!x9fReraO`h)yyUfK<*q6#D8U*0zTdelv%+p+sB87zL8$NG?e z+*K5_Q}@a%grRUzY~rE1pdvbk8E=c%cDK(kSZr$1gxep|ktvD*p53;JSRie?D}SAcIeidIR&Qd7ZqW;<^au-8ybj2~fBd~25nYk*r7D&CYgF9q z4&Cf$clMAvD*5(O`a9K8jXA`RTf`BT=(n?BOH%@mmH~E!?Gc8k@Bu28snbrGMo~q^ zuW2_#OtP&#B{jujU`QY}5z+P3wu`4EMa)hyr@z%vE~n5<T3=1)`FGykCx)~7b;qXi*-H~cgB>sUansJ{dDJ# zf%t*D-@)FqdB2u1w&>>%vc?HGCr4FAi}C_7vtHR`HF_^CX{d1L-N5V?TNz1Bhd+G+ zyx4XoHqgZGb8i21exo{eM1=nTe6SD}YzFUnq7bHWUbrz>756zpa4A0`_mDHo9|HfA zDwGdoxXkXako7U>+IFTbg2G#u&YbrpOjl%z$t<*?)wx2z+;QPk7pA0yn41d<_#=fI z3r&=LNo>oLGNl>_9x7aq((@!!;bN#9I}G3~RZ$)Jfz7p1*|6|IQQ4M>omD!50u4R#b-56RM83$x>R(g3yNv;lJ_y>LaVm0piZ;{TY(p-n! zL{&I$SnNh&u1O=nIIXVtJ%QOKQ$|}k+PPD5Rdo17bmU$uE5~c?r>*WI(T&3pDb+aE z&>XZ;1earh3{MvqP_`5Eq6_!JME3>BS$4>g?c*MA-fEgv-3!uA(#K&H*n$d_tzb4Z z+2LvOiCSt~e9SdWw8S}R<0ScpQTLjo+=l-RlZ3YIyzb_j8;9fuKee0w%r65(U&_c| zx0_@nEv7ON-{|gvggp@FuOq=$$?IB?!Q%+I={{18mAoWEMp0SK3^G=C4JR<8PBm0C^ z$V7-^l<+;CS&A6X08>>GR!!7-SN^#=6{J|oEG&qU5-w5&Tq=Q-o54EG*IcoM;n?dy z)vWgD;uQb`iO?*jTd-P_3lI_iL=lk`X$DIx`-ntUzrSHtDpLr2c~5CldVoEnMM0F6 z3oDw169r&+S^>JP6jr^_Ucx=S0n<()scH%1){3}Jg`oHEbVZOfE$-YfOPRQRQ`#5p zg9+y{BTTZX6kO{#n|aigY(kQc7hS|XHrFDRte&XC5zuE-K8i5?vTmAcwfIUR^m9k% z$^KByZutt6#CFt;oj8L)=0dM^hSso#)_J+77g>ehGT(l$dwcjg^$1Mm^Lns|=-C2(OP%0^%mC3D6};~4H&g%(uh zVnDfZjY{#5K97i(bgwlNd&L8k_nZ`@7XS?HgnaOmTv)`5K*>{?RLqs6iqltbV2YO} z2QVo{fUX%riuo^JM?@J~*ZIzn4GLz@Hi=g(C{uhs02l7ZpA19fPZh~bIb)q_qoSoM zd~uPEr8kR2x;Ea+?xo*udYuX%OWX_Lz!u45YxlEQ{_?F{DDn)?HC zt%%f@3kKiHFD1+kwtbBMhKKGePe6Af=i}g3yDEz!!;@q~w}Qx#u*!!gHyMmUzUH6^ zHPGLTVN}iV6tXJ!JSrDK2UzJf0g#VY0S-L4;KiQwuRT!T#;iP07Abm;$f$YwhbbUq zZ3gWoE-RBOn^sbqTNeSElsLNzlPd$xiV!IO$X{-jeOJ@1M<2sT2lRhD&7gIxw z;aD=};)G^B=m0P3;KWSmF4{ZpiqSzrp-7`TTBcg$N_0_n0?iAxS6`=?7yCP8TB<<< z`02^1Xj-*arFWpZ&QD4J1XvYbqS8V}A|mLN-U1G2bdub5<<{%wpXb=s3*>(UpuRvPK@E7*HqpW=&dkYyiYg(ldtAACpgg&mjW+-?t?wpEm&}v$;G>~H2)-&YJ8)x=>8d@cvn+UbJ z(Dyl62saquZ>Q|5!X&?TuiSydkKv-9S8(&dGG&HL=-pV3+0 znOU8)*&N~a3y2Zxg+iqCm~_^8B>yl-gcMoIEiUMP6HKoie@751IZ-YwDUe0rGV!{rBXmVAs}Y!W2t>xIBlB{YL*q!e z1pZByVp(+wW-*~Ny^f9kvh3Q}ab4Bu*X5g&Nr#vjh-uRrkY~6pLs;;%be<8T+;Fh? zyx6p>cIxeGJriXcQSDF7rh`n1ut*OeMt&-n7|+#clh8AmEZHSEORK;WZIT!XS9~&G zr2qDCmhmEgdH#-k){2>)VtH|C+C->^2F;8!`wR&n=hTwP-_+c;2>P-IcDwRca{TgA z=iiZOn5rTqFB9YmyOaLR%e6x~* zuehr5@nW%r=zrrNcM^(uqQ$z2B=qi5uAGp~nlvbRb3&4}YlUuk#T*|K!L^XFWXvQ0 zy&3}m)3D_DF3^&t2FuxuR}^LS;Yt5-x5?y*90|57=SY{lr##b@YnUu6uGNW);1d`S zzxbD<{=oUNIn+mlvjG9u=I2_+krBWL=LuPvQ``Y zM=umKqzza$D&cUv8~f_XG=a-(3M3hEZz!|8Qxg888bpFxYGu))cOtlUg#KRoujyBj za#%Pf1`?^rU)aIIpmsf7DttEe1V(0bG>JI#z~0zXKApP7y_o42mu2s%Xm6coPweHG zN+s%@#ECM@I}d*)Ud!RZwed8|+9->=VE?mfvs>4b<_J^=shK~m$*H#+P1Gpn?IoMt z+^STe*|>3>_GoGvxI5i_XK2`$!!v2ai>}RKG3vHV3J#{ZjsbHRrntK>aOKKbt1p>; zm{AI?$QyhWnzdxfH5(-T@%@9j^@s6x-I3?}n?E62f9q!dd(8ou{`lvez_>Ot`cGxz z1|4^Y;z?tAD9ebih0)S#@x6bv+^}1jHNdAdukYma?pkfL^Es}_IksYeiWgylN>;`< zJ&pNW9WODjkz7_8&vc?KJhUE@K?On2T$>vPgp1lcbYQxGhzNroY(#|`RjTfd8MO?# zmKSQ$zb!oLoOk&{JO!6+{z>F^^|PrefD{aAuP=E@2pkUn@qDj$SgP^q$G6d+1}-S; zhzVOz)p9FIewy~>de;lDqz`*Drs+P;`F_ume0@O^CbrF6`i8;cQAy%$A^U5iwkO8m zFJ<0FeMgemC{9a;-D&Sln&WPJyB);|iyz&6=r`TS(^LPiWuAyXYW7}T$K&l^x5T$D zqO=%Gr~j%V0nn;9+1EX(a3^`7QDwd#5Q;v(z>xt)-&AjX1L041&^zkSziCjf){Xjo6ToU4N^w`z9Cicr-U3)ZIHAF5 z+UG%B_TR6zsr{5YJ2wb^`s};Ko9_~1-#as%gM``UZ=~w_j9%xL91!)F1g_({O-(t1 zO|HNG7=4JwJ)6o6 zaGznqxscJhP~%szyP-Q{hJG&uu2C09fe)svBKZjb!Slq~BgT{q!;_gU_pmi!cp&^i zas|gFn^{R0zV)^t$X#b17rGyz=pJY=CF^P$z54tmtMHPjLc^s9A&KOOtAy()c^_12nuKxnBuh zeH6nlf_hJWJQzyi=6%HY@As`AMI%of#@J*%C(qwrMnDn6|1Sbc2Lw>7`L`OCL^BTs zVk2R->7}t3{~pJ(h35rOvE7rI#;V;)(kx97mD->RnKutmPck!e;$$&hkyl5V$jp6a zHY6TBWmc0jc4gMZ#+TpL6)7!5YH>IbvowsfHxU|il0>8xjU*94s2*Zrp>qS4L_a?T z%rKNS{mQ4m&Jm0<&i0XuVssdc)UrsA9=YHl$z?gtJCf56SsflP2hZ6C28Fk)Q_D>q&zH&)I6L>G^SZ0GRSy$D(1t1P^SecgEM(jq8Yt)KM}3BCksV zX~0!72p@1coi2^=(RjQD;zblx{3P7m4#&rZq~@p zLp-oiHCLR7dh3jUo3AGJ=z@=_`|QUjCOy910iru@8rnX3hFJ=lGQTy*GG`Yt$g(DB zro&2?&Ml+9{UDty+_iCz>;CbW<<$p2cjp`)T|ng>BdBTv9^Wk-0BO>D)p@MOcsb^A zKlTjG(};5&C&;Va!A-|*#?>9f&R=_X9?W91-R_c#FOw)|)~O6>93S`jD;_+Yh7=hQ6)R?|4F4`C3B<|DzQ*~2|xq5NJ) zw#hfxYs#|$U9XWd3^RbjS^v|Ovxo4@8@P9vhMCZ1ut zEsK9~K1Ij4W6U?hQA>?$mBwAQ2OSv_gBpyaQQJ#rE?F-UA_aO+)$|b;0VTL<$A@0$ zAmJr4l@OcFrQ;v+^ui5XQ zD6C95%D{*oEh5b=$OjeC5q?LXAeF@iKvAUR_0@7H1>F8d9^}Pkcw3%&x0z&_aWs#* zpt-Ow{{W~g4xb0Wfo75@4@Nm^fS1#G<_`v0MC^n`ZF^}f063PqG*1yP#UAKND~i@1 z59U}gr2?^lm=SxB1Zy)|J-nFC3kp;jm#&L158up(z+^Nc4MOXUBH^Q&8prCA3F%3q z{&TO_py03xZz^0G0S{Ju%RW8f0F2V%<0%Dm%M5_viG$1=)ad5C3bAa}#9u<}7wR3v ztOQ1tX*AFj6YL9$aS?a1;*MhGXbnJ0c)KuqA>NjrGB*VLvozz8gxvV?^%h~hh#3|p z6SzkOUrDmgC?BG<3_8=MeN~lx5j(PbpWf%12oa+g-{wYhz))q&cyCQ3_D196jq{1r z*xEX7pl}2IeILZ?ivRocNcDiE7QfivQB*P;zKq!}Y3!IXy~E!%3{UP&B-J{8nNY7P zsG`l-Yah`UxPPaugWDBbV+Il3G`MSJ9jO8whnfvIm!Qh}Y;R-PIZkOCZbPdXjamfX zBzRHt_bmTtd)qZ=FWIyvUC+!L3)FQB;sLIpVMLlQzZMuDahM6~UB;rVFJ^)&!Jo5* z6MPb=vie9m47EnQj?;pP{585S997YMX>tDQQ22u}QOqY&L*@SERCuqeFfDV-JKATG zpS5j1{kE8xO>e!!Nn#8iZ;pi^0S+F#VU7X`(9S+k#jp;%lDPt{YT9tzb}5}sbJ2~eEFGgE6HT&u#7h}8-<mM&gobASX>Y7GGs3y8ug17jD5v@yx`Z5f~LXKF%gw#W?fMhRMRjx+L{3L^nt={C*o z4`Ut%0 ziYJS*gE}B5h5X~PU+KUW#P#T(H%k`?$Wxv4URY{(j?^)kZ?6PGlRaPdcWnfI+^w1(MdOtYoCMg+I6ByIq)Lh;5%t_NIQBYkE;ag(xor`cyR+@v|U0{54*uI_?$ZifEVx|6d-Y{1AuCuwXrEW!eT(n(3k`Y-lCCm1I?ThA!BUB(uDh(=#;FB2%d@F2rh;a zU^9$^Z;C;}k+YhL&6hbLC@ZVXn-08AS}fL@UU3(bw>E*JmCN_rA#Y2Q>1=at#4XD z{mXLN8g0{1iYRs z-~rO%8Amnzl;bCNhW5oGm{+uz#ZOlxi*$#D%Ksyq`Yq!Ilfh?_6_c{d8{y_9j2^zK$ zodj3I6VlIz>@4CD7=krB5DD4gbc;dsR4m3(7|F5p=MY0ZH$5#kw)BO(bdpx|04$3f z>*t|GCJW__Ko2*r52@-&LBJG`1gRcy{SH`}4Cxd~GU)+p(L!y>$;(1*j)_#ZWUBM( zR7IUM0UB5mAf2fO787lf1E5aQ1o)GW^|c-k_R;xAc8W84oYEHF-()xh`sqcr`L=5jd59rZs%mTF>`0*?nm{Z|plc7(xM*E=JNl5fiMz@vOrHMn8}dBU*(9lh|kuT zr1$A(Inn3z%NIe*VJt}^R8quY&pk+1H5EY!@kT7xM<>d}|)GGKI#K1%EAzrTd4YI-&f1NHWZn+-5FOnJ4L z?*w5#8R#-@5`D#AT;0F-fdntBzX5ehmTbw2TzC2PhrVj9fGnM1hF0+y{|HBu0T`uk zL6*2^XI(kCR8tBXZLnTO`c*F13t|1%cqRt)*{rhE2TBLCI%1JU`2JU-wf0D&n}kE& zRcX)O)lPtGv|s1I`<=^A)K0vZ>t&Ok_tH*BzuWV?&yT)5{$llvyd^osp$xPWnP&_e z`MzgR0=oU(U0ZA?#bgfS>Kg=RHN@N@`&no4(#@^6i*x5=yM%I zo8F1@hMC=xZ?r`$yElf|mc0H9?dkOFy&BqMJsQ+$C0{N960!J(d=ZrR=hqS(iG!fk zI6&9LM}bh=+OGPg^v?}gfQYO0ymn)sN#iH(p3Dyi*)b!NgiFr0nT0&($0k*3FknU! zMzOO3h}AzeK74Xxv7$>=*dN~w>V4d>ILh8oDI&qdiCmLvFahQ`s*E-?<)MIwHC__0 z-7oWDar*4hj%V!Um?IQt6N}2U07Vq-($qe5tv+G@;@R?TOy)PM^dqZXb`qeM7LfI| zo27|I)Of=?j2IEgPj|9-g!EwM+Hdk9V=(f-ARm4I1=bx`0@{|qP5yvZ7MGYIutpPD zgL=_gQhcU?{AyCdPJsg6%>s`JpMs8sD>ON1$J9+$1q!8v&6`!(N7)}npprh!b!w#^ zYNZ|K?yUrPuO1_Dy_}x{j(M*A0G%KX8xCd{5S>@UC|9mrFn5mNf63jU9mN|Z=+uh@ zZ0VQ*bS2AWIMl_{P5AvP1^ggZ8Y?U` zApQUd>F~3-Kg0GC<4p-CWX_XLRTO7}82SWC>h)54*&{H&_q+D?PMO5-HKKItHJev# zSH4VeKp=Q?UxukCmq5RHox^%p~Av5(zU&YO~4evnd+0sakVsI&G`{Uv(?l)b^S{h`a=P1VaXu= z58l@wj6ORUd;aZ1)3=Y!-^N?NPqclXeEEIq)#3E(!A`eJo@@#$jY-9Gv=G>31g&*6We(o%v@2#Bgum3vO`1Nh;*Y};@ zhr7Rz_J1EA{5kpd=k)N;+40{WXMcbF`1kw#-=E+A{{H&+=ltLAAOC)x{yjhX`{V1M zlkMM!Yv%`F&i9vo?k)b@nfb9fezx}h^vlS}^3d_;f#XlTM+elO|%~uJV?J*lKBG+5U>&*|=8t<(=4_$p0vRWUo zQsc8+d28vZ-KS!U#eCC+T!Z;6-MI|yxpd-eirQ=vVJ7kBOu~(sc;)FhrRm2C)3Mj5 zVy;b)#U~yC{(t(xE&wiY4lVzc$S4?NnMpe!yZXvwh+ec?cTRN>*3W)QFiKc{g(MU^oiC)o#i`w zVZYs>GTl6h&;0?ZLKa`!n${y+?-^zMo$83?P+TX=!{|wM@bZ9F zOYj)u^P9sZUB5Q9(|FZFK6eGUs3-JXo#pa7%RX8kc44#p&>2Ps3_R6+PG6@ewlUx0 z{b+`RAohI)8Fz8j(Ecw7vdt`!cRB8NJLz?BCSMp+g-*7(&BGPk>qn1}jlEloLVrynZ4Um5|T2*+{%+y$4wm+7`kECXiAA@mb+*hBA;`6MCaZXTc-C0Xi z<3O$_CC;>yY~{DKNcQ)C1;eN(eT|2?V^CJ*X*PVjF&Q?YtF9O$4PkyZqm0s6*p#*) zHHUA|?q;rIIGb*6VCbYp?zI~3G z<9HH=XWDYj(X+0u+6ik3sd9^DuOyjnMLZgno3}QoDYjs5)wDWC#hUnfiMR0>JdPp} zh_%kIJbTZ}oMqUoi(aR|E4Cq? z$M3}Yn<_YLhIUDLGIw_cMhaunZ)IRuwfP3|y2isn=JJGGN|Zj;cH3wBf95l8`o*(Wsa$%tRCH~BB%+Uw z`OK=vHAKZX%*@|Awec_@MFSM;wHlrpz82!`qY)kRw>A80Z)q3t&3u@8(mWlSj-mCe z%G3}4Tb^ZXvY_$VZxbo~Ub6q|zn{x*K~Xm1&uv?By?WB+e^{n-tCT4Jq&wwy0$`U| zA{ZV8IniC6{o@>OVm4j2RUP>(&darC+M==rY!FN(V$VG0kz~R?os{~(dl#U!(t&LMC-TYvV$I zwd|emeq3&1QcpMz5-59Qo2ZzZn0NTf4PGt#1jE_6rjDA?KD3_>T-q0S4Ce6AP|U*X z_RzRD2=#f~m0Yy@o-^CXiM(bs^{VfAEnG}g6UM$LaN>{9?C-YS*kWZ;!PE1sA# zD`$!1sUw1bT-LRZM4WXAh0+;*9DioljKx;>Q2pB?AR3H#n8H3uYHLJ5>LV>1tcTV3 zn)`0GA$*TZQ=7c<#5kID@k&VsjE{C?tBex8Gu~UUN!}S~;6vIvl~&vU3tYqt{t% ztlh-kV((3>R#6nyev=4dl$fgMVjoEhWn2e0k_(t5&2Dn^9@XQTx~zG@ z0d1=JLHXC2(`u+&d4gd^r3JHZZVXDaSP?9$SorMi;2^LU}u8ZmeCpYW{EIVNQg|*Y*2(daloZj2o-6`+GF{#rkGV>`SKP7_-ce z;*m+r84=7@VMNx?5y~~zcls8-{jW0S;S&z_u+@qDhK4zyTpksPK}^kUPsO^s9U_cX$pq*T&T@D13} zpwxBL#j%t0Q*g4OJKgml|LI_@m5D`+*REm#Ya{t>k>4D)_H5#oWWi2t&qCMlzz!|F zF6)EEjEmc)!d{MR~ z_iVJNB(a_`4Y+E?^p#KWlpkvZdN(gn{V>dr$2IHOkdwxeAKvFn{?pKo*+kZZS2W>g zPs3??!wUF1m^{r(_nkN_ z+CRwb7b%?`*!P+0>d#gS@-@3;5TQ4fL=3N}oKG|*KHU|mrmjqWj|Qjrr|HmE_%p*Q z8Q%#7_P;ln6Irh^-+RavgeIL7!p*p1%s&OwPNiyEG|xe$O_Lvec-9k1kKMSW2!VL6 zOUfxJB<&Q`^5}Ds+oMgsTbyg?Sz**;+6HmIDb`;R-$T8nqyU4gUbYlX*wS6Gx7inW zH;WY&Ot4h|H6rY?6wK^jCB5jIn)?b#_KNiaX4?CIkJgA#34qg*@yoz}tPaf(Bg==k zf|SukIaY9ec&!l~000KG-Jkn_)WO61#)8UE%-HAE42BHuXUE;L6(TJ`sG2 zQmiL*fDNJ?Ct&DNw4=2}@JqmRmOE-ae?Qy6?O&Yx$v;M+&#I(AS-L-|F9vB+Gi!ho@PWsYl zcE>On)iJ(`#$!R)!)}Y5bveVBr)>v>b1L7XINaNKL+c0dyoXAKYzAzM*hwYmgrHI@ zS7=>zQ;)zfqE|o_@)|P1o}HDZrJkl_hcl@S22l07jDJbl;m@nRXgGr&vn5e%g4i6q zk*W~uFo+o%VupeTtRQtN;Clsdx({Y7iw2Lf9CZjT7Ghw{!CObZK5SvciMrrPF{gm5 zdY6F|GlEWe9LY8axVkSDcEk5wm+IYx1RXVJ9YZ}m3BS!~5@;S|YYj$MWSy;WRzPDs z7QM~7Bs_*dW3-ZX7K{gjZS)aHo9}elg$!>9}P>?W4C_vxN75DqF#v0OBG|1ddjoo4!U!NksXcEbT+IZ_9>n%a%)v z!kd;lV0I7hp?$!qt;WHvtWGllPuRz(f6~I{lIAsH@1K=wT zGa03+>u@ec->uW+1R~KO)J0flrPnpK!8(VNVus_+)kVae$%K2XYSBi{G08IQv6lN? zV4XHFF6i3Buy%Q%%hD>GkR1BcN0!4r+aM)dQvFu@ZcK+txYZei0DuM3!`cCWf>#cT{Kx-BB)xYd88g-?n$`nYB5cVF8dh}9fe!>DRc zj**$5@`t! znI0lAo_6G-8+ox-V1XJiehPU#9;p{MK;u*cQIdO-k3kwD%uoo^uos+7&-Z$Q5R2aB z*+q>9BM^dJ!C;3ReH}aI63Fbl&OJ1viU=65p_M_*MJHgIESS#CBVJN8lS;JnyLP%z zYCGKPR}qhS>GM&O@`U;nDXN)+Tac>N+u`BdjaTo5%Kj*$0ePB`P3UAeWQK?S-*O2c z8CO<;^HCM7SHjl!X{`1(>iZdHqcx3C2y1k`!dy9pHjmc!hV4YtK#?%d9V0n=*hF81 z-)u#0kJfZwugF7Ns}5TU4$;8io1i%wR7qcOe_!+4z9sILTc$5pTpr#DHjZHsj?d(gt&v&JzwLT4T{Wk=8PY|M!yhwi`3>FbE~MNraQ?D zOn_eYl!vy1xtrP}pF}li$qSCL(iL^IBVLI>isf8^EV!xeY1M!qHQ-7h2>WKh4Wat8 zGw#(O(-B~V;m!3Qy4mcO?ENhNO_qG1=6l2~X9y0LVun}FEhXX;onSMksz?XJ?(ch6 zyGMo>G6;GEG$gGo_@to<9HzdRG^!tVtZx0|JXWvwQU}Ootc9@M5XZKagIf%xPs@9y zDmb6cI+)zm5`r?DA;JeoJ>2HJ%?o_fx7W#Jv$Dy033mwbympg9XnI|fVZ7?|bou<_G9Z2G zFC7R}3App%&ER@8!z_St1A*88AdW1Ee9oAHuKcK>yR?lT&*-j$06Lgg(9M++%9Eew zi9AWe>j$OIF)9xpt`<0#(G2*5fB@-a8m50N$hIV~;?shcxTb;FqnI#LLu!P-tj83) z3If9WZKAz`nwaL`^)t7OZU_tGpzcmCT>hE+TNP1{>1{_W1_^3t_Pd^x zocMY)(!8`hYiMM66WVBic*;lL`;OCagDMlzKwJ9ZV*7s%>3Q834@W^+K#wqx?=W>T zaLz|PqLQX%D{8EL1Ky=0_QXRq((6Dr`CuuKHo9V7Km+0G@$%`^*cX+wxCWXe6>u1( zMZi+=@nG-AmW5`Wz%peKZye!02fYCwr`7Cb0Vm}5z{i~60l-dVfyPmxx+GJOq-Zwq zrwMc_gdHKb$r@VZc@|U^fx2|*7FB$z9sU&LUbium`EJJ(Bxq5G{|puc1i#sm=ik21 z2-NEFd8sv|6aD4q$)mW3D;=Bgb3;`%z%O2Ym+p%dO33wE`^3(WT-?}jzPsd>TQ}vy zv8<14h?&P@43r)Z6h?zY5r0MTAkkU);x*7k_!^9Tum9pnY+Ya&rt7Kq;?w(PJ?&n* zdN_h*8^odl^7((T&yOMLvzKlL2w~swMN5wzZ;iuw4;oGjVE({xNo;rHCL-z{6f?B* z>X10i%#f*xGA`@H@F_!izNe8#oMUO@XweA@T#v@GR3rKy<)FIGN=7B@h)gc9{a{%Z zbvih~s{FdN^3Kjm`-siKyoOTbGTzuOfL}WfKRi_Qgt0``{q4B5X=mN^<94-UeR>7P za_x|J-TuNXJ4iVcZrpHed5fU2IK$*uq|N! z+Z?eci~xfEWiz!XK!E_lCk9n4n;2qlg*>@!Xn5f$1Zrvl1B80E*+NYa1J2;Y_y8gdgum zEFtVNb5t!(nRZCucZ~y5j*RmL>DlKt@NF7SYDuNJ(v5mS3zeY3N{g#+A##=*q_dW* z@1x%`K7H#~{Yquk!b< zoBV?T{^h~wBegoI!wgM|PoShacbqXaxgu^qL+!i!&qvK|Yji%3#lD*|rpac&|404@ z&{v#*0M!vu+Er<3{a^d`R2bTwCJTtEl2rvlHw>aPkV>ygHkv!F-z@a^BLvcS)C@w;KtjX(@gek-jHyYp(Drm-Y87afns6bGlc0uD3lHD|<= z7il6I{=$91^20yn#qM`vdCzRKtUP}0hI4`>`9aWRh9Up<{h^2$T$Ag&RL6l~{_<|x zVxhJqrEB|vasCgr58QU z<}kMe(I%+K$^K?ZwLAazxCW9gnixms<|m?)VG0uV?UN%t9qg%3HhmnA^pKl)*0H}p z22Y4D3bUNp8&e|}Bl)JsW{l%GWK1wtf`MyYtuyEy1D$39k&W$Ss2m!SVCF>((5;qX zkb9+U#fn_K4wmuUOotL{b*Nr zSl3_{JDfZ_lzIPmrvRKG07Oayq%x3F!DCz4c8O8xM6R@#*qH)K^?EY;B>+m6U~dQ* zOu=;t&F5XrL?Y{%4;2(OrY{%OZd-Uc-dOh2totozM z0uz~G3aw&{;FbS2jH&f%qbUuQ($RVFvH3~0YY-5_{cqBo5Fia#XpNk>v_4z9z04!+ zO~DJU6{6l+MM84H&~>$u)tQ*2mg(Rmr82JJk~q4O(tPBjz{CHYP`j>i?$=pXz2Xk* zsY@{JZlKYPF>nzYGBFPx&Up)Ly`w+TZCy0qBT_0LE&q+5T_&SRor@szL+RZG%oy`@ zdUro&0+o!zl)lcJ1yj^}{S29IzhYqiz`c>`xPt^fMq^|UC{M`Zo9FZxza~%oMKJcG`8sc4G53 z;qxjz&>laR$+hA0iqV9+}$bY#fW867}fuM+l8`KPi2#suyD}v?96z&tq|lR1vZEXD-c* z_u7QUx>wY$0k$3!pxfgmrVk(ZdDH)IH-&{y2$P%(ys_4|%BK3ZQzzm&+-#-e9&t9I z39H!cI)V);eyLyf6Q5J0##7*ievNxBYxhXMHsVYEuuls6MI_z(lx#lL zH&SD(t(VeM0m!R2n%mB8M4V%;MR%eMfH?cDT~BYvX*I!Dl@3<`x-Pe^^jg&$?ojZh zFUp74)U~MGt%*<nf(>q%5&Qgf0R1Oe4l;9VN}W1o9@L&edxAwo4WjyNvnN1*@b8{{nl!2 zJIAdjzQfq~fWFss3C0m3NL)-(jFEMRL?Dk zU*9+{6ih&q7KOa68z?(nept6aze9S#=dBd;k}c$mLo>zJ?6=@pCJ!dMf;5&>g(=|d zyEkxNln||oBlF{sj85eLTHAF1stjKJ>NDCPf4PeGFX|KEWIUKNhOCHD4%uw3P0Ke7 zB8tG+R`0?Z?}`{_%dXCASiXf@{po8vtFac=v}qSOMb}#Wf}BYVJ!>!BOI3FK$m3M} zYzUjhsh`MJ2q1GplV|bn34IR#5Ck3_aafu_ocNGnol{Z`7XYm>FygtoYcEbnpoY2 z^7zsu3#)VmB0yX8Y6$M0Km|3@6mq|UEVe-Gr#!E>p&k}1@(fotX}~|d?J@qNlSwc1 zUnTgIr?cZwbU)^q<_29$Mbop0-u~>Af%#&cp3XmDVanVKiR!feaiSuM^N&P2T1^$) zFH`*tIps81=Sr07QGsa@O`5ul*NRN$HB2gWJANs*k7QW$sBE&d+Uh;ov3X*;se&w# z<7j@yN!7IL8A=8aK2Pd3e8irb0_+14quh*yl!xsw!y-EnoKtsXC)>KWXzpA1bW=AH z-_}0T9mkSm8lVT0)COP@pg0M^1Hh7Ru2F1z3IpNd$4|VKn(?2Q3@0_B??=X1-v$AM zi6%+1!I|h%qJW7Y3i3!{AXJw5e6-h-QVIQiO=|Mp#=ou{QcWGa^Ez>agkL%Brso~U zQ~Fsv((3#A$zCI5+zV1H=a0@RoBo1AvUa?+!GzbSe8i}tRH2SE>2|OXsYn=1Ez~!| z7zSz3e$|DaGy0{ons(Hn_WKytF&~d*t=}?r3d?QL4Ka%h*`?}I)(j7dL@y)~oxw)^ zJY!B0FP$m?eCE2goR24NCQd~fU3T2t zY8*GKY5i3CA{^k9;1rWcw5h+fg-`wpxJU*8uT+eu#$Wlm4G8Yb3ZAb^4di>@FY$n= zC6c1`G>GSj6QpL>pz({qk1%=vwmln?uRc$(oy%CtCYH(p&@h29X=IgM zVw0Ec^jkK-fZ_?``i#SI(uIXFPi!F2Mo+Wrnc;8iC%cNGw}{NpO{vhMF`EKSI0XRf z5e?JQ9=HvAUj1s^`en2^z+h?Gn}?Z&0HEG{Y*K6YBm1$F6V%)QZ|kde}FzrZ$n>LlmjySJ%qGGv*)tVqIAonT9nI=mme* zSQRYhi~i(VK!GnNBVc7p`ug#WB@mopO^;E356A%Sv`*B45xwJPD`D5*OZvj8iJe5) zVmj2(v?;e9-Vy^wj5a-NGF1tXj*=wljKBUJFvsy}GGhZIU!V4GyZ72%vQ|AYuLN0Z zh9vz~cUSH!wjT6^aL*tZ$r9l0V^*C5g5YI_Ry%06Ex9nMp*d94R9y4HBxlRJm|v~i zBF->in(29e`3b63%|dRdpN|;@OwYDw>cNd|7XMY7*7>_IN_}QiP#vBQomj_C=JM*h z+o7hXN4BSLxZqtW47xlZ#^YQXGKs(Iw&WRN-zNzaw_^dt zlP|b%glM9;44bj+j=g0Qkn?h~vAwkD!|rfWz91!gU-7Mmr1O*1#H12;6nrM+uLVc- zxadtneHxUZ<`tQfsDAn&cGohi{|zmX6#wb!{k(SJpM^h5UK8o{Lr?NcVBo@4;@^JQ zMSNp*Vk0?+9{9-wRLW6`F%Zyj&SmU{TTGJoD@WeJpZTaa^cP%Rkfte{=;B;+kXfr~ zNqe@@t4Jx-Y|E^aRt|0866UdKVJz~}QdL4|ceM01V_o-SUEd0MLWpDwkS1m&F$K^g;6bLJFLZn2l|4>+D4 z`vX~f;=XVWVNnB?RyZXWkp8!n?aFAp^==7$Fq|Co^mGZXZBo{pXwPk1iAaQtnaoCA6JiBAMw_~BxZ4piOZ5kRzR4}N4pvB6RotbZc3Ey|~ zen9i@MUmyh3On2UI|p}NXNui3pdNd>XuyMMMC10|iKHc@JW4_KqhMh?tgITQ!m^S6 z=^9s&V01V&2hWk7reEP^U5_a!yjx2vmJ0J&#ot^NiGvj|>RH+_7c>-(;ytw1%CeksubhhtfJ`Y*LTqp)klmYtM2#p z_xiif_`8LYGcur=p)hCoLB2v7oHW&QKCgN}NCNC;#KVMQ#*|0js%mesW45|R+E^j$ zyQidIjZ*@AV%oKGQifTWQZd?bJJ}@j$wZ7NYyWg&;48PpSJwSTG*4a5+xy}Z2UiZ) zLHJebtH<}rt;iriSmI+E0JoFsyO!Lyuh{NuagvXL8=oM{)7u>*8Sd;kd||Cl7zp{; zue2VUTtCFkRxu)W)7kon`Y#}x=3p$8+!+9~+Q74qz7JFM+cfu6iO*z5c=AY8aHz>X zJ}Khwsaf!7*v0uDGCFlE@x$XD_}t`PFh*rg54=ZAtVjD_pkh3YVOD=5VagKZ%^47> z2LycwN>KZJ!^7nPV}isi! zt5FhKa0&g^VEVC#v@;1`Y@KWTRy{gqO8|I{jnopd>EjqM!^~>wQkBzSYTV4jio^`W z-Xyb_H|q?@O)j_VFhQy9nEo1AvF%uOoCvPs(!ag$K>~5@t^Ji{Q^wTq+~3>29u6HK zZayc5W}Q@xAjR>YC7eLKFT=$Pl=0ec{o?uGW;?8ozZQb|w1G8MRD8a)9UyxSB1I)j zLw)vi-y=7K3YH3}+U6G(TVoG~Y>l&E1=RzcH!eIr`Uvp$zbZYPamO2vs)%;sSvUy{ z7y`?n1UrUc(tb1HA6_+a+v{F8#u3O|59CrKAk_#Q2*N(~WxSuL?>(SG1K}+zhzuY` z&ucx4wm_#4EWgHt?@Oo@3;&nEI;pfyMPsUz+;2QOjQm5@`8dJLT;@g{KH+R3HdCYdy-Ne-grQdIM zuML&5jORWaZAXnoE>o8_RoJbr3?#>3M9|;KUhcZf4xUDSUL3&J_hGXc(2AKqB@%Df zL;bIR2w5XHsLvDEc#a&`h(-(P-pkRxmOaU2Flj3k`mHlOWiOlgR@5(|Jnl!v6Df-9 zKypDRO(^d#{ul8#=pH8CD%;0h-1$tWsurVq{Fn4vjU13LhzAFLfYZFb_O~f5^(lRS zz|qw>uDjO*5XqZaHwHy|h`#juPUvvIpxEx(9J2)ewGNT>uDi)Am%@RAXSoMf+oxIstzGm-!|D@|e7qU6vPycXZXaFJpPjvbOJ^3&PuD7TD^_t&5 zd$r4Z+mjI= z_*JlicYRYy(5i$&A!%-fU#pMeD zD-bY^eaS}^AYeQS1<5UaFs5j2z~!Ecm&z5}u$F34M%hR;vqA7x?WDtcQvj8MG!J_f zwIF^g8w0YsHBW;VZij4eV=0jxA8kt1*z@e{oTKa+&_@6>tQBIBz2TLj-Li8)k$f&eGcRDaZHVA3Be8}Wkz)fa z1wqg3ExJ+c=FD#I9=AIDP)fw(dSO6ggIKZVfFzM_oP`=96PuO#p{(@BFYyW%2C02X zM1>6t78w5rpB$77y=mAUv}jt(7rTftZm5Wu)D;Fm2_Kof=ZDNtoVQvmlm~tX!nJSm zPf4R+R)IqvQzoS8-9eg5ZB*nlgb)PEtkqEChmdVtj62SEYnu0a^JwSlM~(|GVqs9L z54P=(6YJM87Gu-=SsB^&CM8+A;+r*Im;bhPDp10MV5A67qHOck{u4&3kYT$#4CKr8 zmNQbrkWZ0qpRgwBl3}jOe-clteHgwPw}UTC8mE={R_T2IdroI5GywCTmocA6HBI~Z zCU!Cl!71feS*QJgp63vU{f$p`qEtpPJ13-CepO-D2GaR#Go%-xs;x>1D1VIiasIKY z0Aq^rZ9SXpz++el_KSOaz%bkGM7Xqjim7uP-NQae9Xq*!;-`_by)4q}xa23Oi6V;? zjjHQ>O954|zS~FPB<$Sq)_@8}@!cO9spFwGJe9dd%A*ZX;|f&cg)Nyzzfhx#?MuvO z+cH@G8?AB~eTlB$4l;o#v0CL_N9)b^Z5_ikM061IVD7F*MO6ORC;s=ABRspip}E|~ zh@!%iw9NJB)<{vq|FU2p4S_QtpdQbrM`q+sLez4b0{|N2{4TF5tAbe)7^$YqMz>!$ zTTk|cn=^uGuM4{+0FxH36?o0g-4At@?=Oz=V>dDqOZsZDVS#7R_K8av=B`w5o5?a(*PYycGi zfMErWM^*@d1=y(b5{t?VQ1QTlN*6YIF`>3*Z@TL$A1_jS#;e5BTj2gv+n{M=ntV(S zUE-x%KB*?5ci<#cCFb%gj>|nUNy)r-^gf^ADTvCPP4U&JDO|3pV{N+`8|H$M!4UxL zC;*aIVp2(IME&(WxyY(wKqZROpf6Zc``!(9gjSR^v!}D|<$)hw-c60}gT)e`LBd8HnA_MO0e7KHaYsp9)PXD)5GGhG>}+9OTVxHsJz5nicPZ*pLs?E6-9%H?qaa4V~ScsCF@WKldli-czmpr z+ppI#q1^>z0|FO{Bh;0YM_#{*9#5*EF~$Vq^<<^2X^ms(i^iScFyn7v!vU#4pn?~+l36PFQ`KzKbLIl zd{9fanD%C4E#0>=R(P((>N_WbP-(4_Bx+D)+QyYJDp)LMZ1d!Y#xIySM7C^9#u=sdHZ&;4*zs$VD+QlSk}#i|2B-M}2hB>s!R%`rvHAJCs(0k(EQs!{6{yF?Qb&%@r9aHSEm&-!dwv4| z{`}i!qGbYw>l$ukoIWh3C+*87MI68)jS`g15-Q7rGJNk%XB^ z4E5_%DNq)zl`9aecSRZrtBa`L@HO6e4J@5~U^iDR2X7K1E1>;VLs76_(reW zPSd)B4OXLYsbAzG1AzL!#`NuT4eS}R{mpOZ4y|Olb72Z!p941;`SXUbj3%40@L&Q5plTbN70qC= zXQ0o+yOzSs%^_QqmRMH3>x4v*c>3`&10c2bth#WQ>=^ELMy$2*uMrd?jy42tK~mnqSQsZzfgn0|Mr>rNy| zZ7x5OpaUSPWf}&}!Uli!UI}k04-X&KO}_#_3_a~97APaPIGLK>mH+rzegWUOfO`SI z0|vt{iZUfOKpAPCU)P`aZqWOzyeF?it0ubwP`~|Iu>kB8!Aqe-e>&0DyC&@wkf7dD z;Tu+>b6T9@zLNsyW6W@3jM!n`LAfdrnD(aTC2?#bICf(!dx`NPJHzruDyP%C;V@%; z#?>&jN{+7p_oEtE7oG!?%ij#xDFR*{n7E>w^y{VO#I0)`dpZgNi-_p)`bxgC3<)n+LXw`2 z>dH)HHx!i1R(h(cz798VwE+U-VL4lPlgQHT0q;bOf36pdUck^bAh!u)FgHkS1N0q0 z{gtS_R}J~hlXTz?l68~)h!Ne6=%zb#X^2%!S#eES$FtNJSE{wgGTJr?SRwp;w^(BtqbZl7`Op60m~c#Xnir^XVTDPN?m?x@W;5>>ZK$of>338hYQz~p?`B_B%(bk{CHmdi_e#@} zAkl1^8WP-?^?6rdNkW1abgRoI<_i@B*?ID2mPD?%x3xoIY1_rV%_Lj;TJBe;!F7Ko zPbv-XM(X`8E~h%|27B#mJFeU)b6CqRKhZthkI{)jWV6m%Zq|3^TSH2&<$|tzj}dg6 zhkBFf*#SfOlZ@is9=r-9LAFt5w!QzA6iN00q(1WtE~S`G1Ni zqBiH7c`UB4O4G62XrS7(UB=1U%t;gqVCv?4(r5fc<04=oyo@=}nb{P_iJL{D(^=+g zESO#jc$AseV_|;@eg6n4bVs-$tc`yXUCV6Ra|FWxZ8RH|<8bsjl63EgZ=)7_Lg#^O zeb-mz&wsc{>u!!}lrsZh1SBAr7J8v7dru7?*9XkA)FHNzCJAqXcfgj9&<117mM;rc zI-T8{nL24>!hoCb?{K-!#3I&AWn4TG8a=S&|DRc8u+zHa+3wBRq8VKPzMRgtV(WUS z%iB<~z#qFvz35dYX~q@ z{7!U()^G?o^#k}jZ_9UHgHzx4*={t$;Vi!j|J-KP zXM%t@=OwEHGrJwE> z#-M9I594y=^h=2b?kfR9$L>4~Fg!qJa3H@tRxbCywTn z*^frgjb>YTu~4~R4s&61*pD%4KpN3wz?Jmi-|sQJvN@p&1YHbavTUu8#j=Qhrl=7j z_ElfT((>kEFyIC0UTXepBzpGwUWb7v7UmlQ`dEQ%tlbmR${MJO|+E0gko* zK81(+cD)<@ivQFa58tY(qHjk3dvb9cRV39oc9wN1oQ!bc@psWqy#an72Kim0^SxSc zzXks-BtF7xV=IV7kJOg`WmBi--_J+BFl>jz4x6vvoGg|Y=a&}p)o9CXPh;O?d1b)_ zn5Z-R0jZy)vzWxRwXD8LZPw@IEc;03pHe2lH;m;@9sQIc7(3lGHIA5_4v;zj{B@>2 z6{-K?)LoEAk;BGF$dSuV>C5IlN(4qeyMqL)|ICFeqRQc_OeFUcvwmD(W|x4t!#}Vs zmVoL`rT)7i{c(_S5r5fTz|J;0L>!+yJccdMQ2 z_^fbd_PeAmss$`wt>|!DDf(hk7b0Ghnq5N#)P}Z&do+go2VxPx^X%i+$l(=@QH``l z&Eh~!qGaRqtUF`d+Al4<*3nE~lv+NAwP*kV=2^OSV__H7&TUZamG((zqVDmah`6$z zOqb+UzgOz*`lgW`&nxiPYdQ_(()jArBzT$laaBNXj;DSyBn7T`>7F75Rzq{{TQ7AN z34Dt`;#P*ZNbk%Sl#j+fr*s$c!c?$3U3y2#vExDfPZQh)nK? zK_-5roIL+wVVU(4d$ZHQ>0I*b#iet8tn8zVKUFtLQC=7NfAJMwsQtLfmBZs!(OnQ%q<7t>D+<9bocsE{1{|4L46>8bcU z43tj)t*MNss5^@ovuSW&wzR&gvaoL@cOb;|%y_)69zmVc%@M<6Xdw^7{AJIATmBp@ zwiKMThJiG4T7qnEG?X2B5*o4t#6NDbgFig|V`d^?45{ogk07&1 zRc^-y9M0T9;ARR3F)J3yM%Y5GuXlv#kC>ZX?7IdTzG4$Ndib6NSc%8nr4IA|`Rp<6 zeq~>PVR0}+>f)P?l1kFU-P1C6m(R}0()@-YzznF0=&=Z`SRP;cANJBEN7rB{BOk0C zGdsMk(+QhHpCq*%lkp`c^ToEV6G4}4NTQ=T?^vZta(OGBPQ-jp2?#u9{%+y*$t?ok z4FEs}bpeAQ$NzeLz6kj~T0V$REjQi1XW4CFe)ioY>{f?7c3zV>3M!@jHx!xzj_z{!AI`8&e?Y+GmY(5R&D1&(*uXQdpW-m zE11R-=H#}9tE9H~OYqjXEuCy(J zNNI3r|31j=XZ%Lc?L{2l@T{4P4exB#-@(lU+AAS?PSgVHYBEX3W7KAoa?A6@e|&TY zLkYA?0^0a<90MsFfuayGNJVGk6+r{Ez)2kG3^7xFO-Ws7Z6Zs&u2e0S<>LgaSW7ka zK8vdy*_=ee)h=f?vHyL^$jed$Q(k57BEBQ|5MeAi)!Ia&Hak)3=M=iddTTI z%9x4hGMkA49IkAotX1J2q^mA&;2vCAfGJRa{PxQ1XC38{AN}HVnY3^Wz%EA8WGA^{ z>?*heIkFS=@Q^&;O$mD-NiGqh6ZTEpf=jsE6;cr|B1gdg(0~9B2$RPa!?N(W+T@XX zp>%ZJ_%t?|XQm%(!OOxeozDl4fUsI97ux2O1QWf*3SFFrtk}^LK`2w2-*`6q+0&gI zR-Kz)Su@qPjH?kk2yB&^K^vaU28r=P$zFgqL3DYEQ~$ODdZa)%*I(%kmSQm zo^k~$?c&|97!SVjbhQ>G*pnYTA=pv;MFkNwKFBw&?lDa*se6sm^O?FVg0)T_DQKh+ zZ9CunHiF(IRhnhk7o|w%8SmLHSgun4P*prbFV}1<{NjH!HVNr6 zW%O44N_JXT29!)iPV`RvM8*UE{ye)fsCkNR8w9i4;g}M9DrdJfXtLY;zG&>s>M<|u zCmeN{`Q6ewwKHe#f9V;iFm~9F6YgoIJ@id`*V^Or^mXaDkNn`QME1C_?`_=)vGVke z6p_~P@CvolJ|EXipzGEYdUS_i&Nr=Z`-&%!A|fbd6L=v+%--udn<;VYNqMdF$IkmJ zfmhHZiSE_)Kv(QYK9L8;MmoAgtL6>4b{M`J z27>2@4U6uj!uvw2p_Rq0|k#f46WMtLd7C>(`j^iJ*x0h~A;H#EUI^xKuODgpiyiHFaqLv%x*fUZHGf1zl5piTw=la!R2vHd4 z3Qc}CU&>@+QpEVtX1h<4*$-%*4&}n^jwY%B&210feZk+UQx-KBjQxex<;9AB*p!UB zo1IebP(kDjzSKE#lK;R!;4dkI)|B5~zvNDVrY0m=c5zo?t2D~SPm%2?*7xF#PSpdcJ0#1dAgPrG>v>~)E0 zEux~uKL&D$UB|LNCNW$}$9Q`DKCUCDFiTO^x0?H%gwo7(v+}i6-0UYO4|ndf!*}dr{*YYVyTVo zR-#v66w0o?PG$7#vyIx6nCz8h>3E(r5t%D@XR2A%zwLl@hD1sUssG2(c{oz_|9|{W zceuFM-mdH7+9P}9y7uhaLb5j@q@wPlpkxUjBTID{|qVpHm}yE*&_v zWe>!Zu7Z`Zz(GOhZs(}>ELAlyOr?vYx=%$#EA$?^%`PUIF z8h;VCp*0S5*e!xqgE4za^sR)o52?=Lth*rxZ+4T%_7RjS@`X_!{~>s ztYo+je8Z91&#y`{RrU8@_E&U(JWJCK2z3e#dG$+h{n@wDj{Di%RcN)^Zz))cux(V% z#pEruz%KVv_E`R#m9Ae6t-P#et`KB2CPY8!?^&43u8~(BQB(^-xaq)?N~d<%6Ho{x z7rGX?!g1OBj<}A|8v4fz_w{^}yAKt%t_Wo6Hb0~z}OYG!}`-LswKe9j^kP^$3B zi*TxPsDv3~vlDVLUL8*AE6wH9WTV5M*3W&3L|*5NQE}fyef?a))V8; z$n84dq_7jjU7<{$Fa9+L1=WU_vgyAjx!7Kx?ZV%<8Hjx^55g5om#jegV@A$$LE`0Y znY`?A({9o>GDw(eI+ZxosNlV3RLzAxmZ-UE+_PmX+e(*w{@em&#WwK`q_n;b`6B69!oSQfE@- zUPxuWe9gZh2f)xLmin+JYZn_ELyICtE+0Pf$4TLZOWR5B&2C2l_LTVq`%r9BZs~)n z{MIKP4R?y^K|a_D=Bc-zUv^J>W#6QXLMo==3hdw*E?wu@JwU4xBRdlMl24s)OpIS)^IGxl{T%*>T zqvj}~KA+X8xcT%`$g_V0txwj$)3(~B;E)#)c>xW1@F7LGO*kGAHou{_pEL6&Nqm(^ z+Gd8!h8HTc^?quyw6{=_XB@c6+%evqAD8+(?;9mJ7UgDBA|;LL^eqVLVC%7>JgYH} zd0Lv%)>r0rvvq_m1opKz1eriN?|0#Qsf|sBv_}QPw#%%dlWky1Mrs;ChCl(plp**wq z329->zEXp(F=C%zMf_s2l82Rcy>7peia;sNO_IC$M`)i`i_AOpt)E8je=_!LJKWqq z^tAb+2m{>hB42yLVRj=Ba_}{5L}bFN2!#T)1K_-v{<=16Q4H_`jMd%;6V&lwcayW$ z>pP5c((ZWsZb)QD*uGh0E~4*K&US0(o~-6CbL6%> zFE1dNZq$;S6r7938>vpW8nf4+C7bs{+ovVp4j27eJ+N=!VCIX*sTLaVNT0IFM2^lK zH|((6X3i7TWad_pVsh-&_9gh#5}ywCYb}{6^wO^dOOQM?3{=YEbK<(?X|F32R4J#! zY(iFHkS(4RtWuV=ZTrUu?^L;dZ=UhNTIh>QepluVBXZv#d$kvwP@@ECf+yCTBtKJ1 zER&9(Y=h zvKeG4ZLX-rHqDa8C48e`>ly=wr)rnn*7mj)x!N#pjD7D)MapAEpA=49dp5zCbq`cF>nPRR%Mb>{eZ<1VwoN7*IX=sMLqSB~#Wbro&| zbIRafN3eAU9@yq%pVi#8-z*;AKnmEf$$r((IlQBDLn&^S6*eMmAv4FTbU{88+cWQ* zad==p5?N?(;?gVQGP0n`v52hYP?QGiC?R1C)1=U)l(w%~ACPZ20qa<^9stI`@)Erl z6U@PRLFoeAXT`_RcAeAmw!*;f>=mg74PUBFdn?2k+xUpvT7{NTcV=QLCh?t(_$utT zPo9m^ZQc?|NrIP#FOM3>xUB_OJ(eIY3=qs#TsPiThpN*}3F2AOfX+f}WPxN{!Ih@e zGk$OuKY&)~_PKy5Ef;oerC3=tbqDVpubSIl6{~TJs~}dKuH7B*@y;zpP9InryR&bR zQkEC23iQOD`~4HfB>(?;t zd0WKsURN1;y7Sv<;jw93Km7q7Hg=eYgVj8f=d^0f$zeO`p3E-t^b#Ej8wK z63BtP9%T2Ig@)%O`o!khe)wve6vZ#lSue^C&=MD@{62i_^F`qP=#dYVb7*U%D=xHt z47xbl#B>e|CEBNsX&+NK&DuoU}b{g<-i@D!h7wA~Q9P+5p|^)`$;w5?X`2 z;l!d}&Ia_=6Xd0HxDys(@9%m;2~fi&J)$(UdU_f4Ea4q2iHvLooDB?=K-5Xx*vPGN zLX=E*nf5&UR4;zWRNTZqP-U{t4@;)=#W~1DYBg}gc+{7jtAOU3H$yT(n zA-69|+T?R5^69Y5#TK{(m!A5fVVooFzVX*M8@aY0u|BETaWD6~FJ6RvyE?dp$?TOO z*jd0xh@nwm1Ky*C!T~Ln$@=GbAVe-rc({q%b!(k5Z=T4 zmO9v1?h_(EK2$2vnQcf5(q%EueD+5P_R9JrexR?n~8A2AFdE^;ni=zXI*%p%4et#@#E|j6qR9+4S1#s6YCXTo z{4BU0caaapaU&07Hz}W1a5@=#==Hg<$2jNGLeK}@ufw2USjpYhOVy1W;oRPN6H6?Z z7e0e**y6&6<`{tk`5F6!8#wU_tkUXz{PAgL#V?yBGZO`#hOgXW#e3R%^=cKrPFx== ztoB<3K__*SGMu8gR)0|I3w1qgMju!X91n+Ky|WVpMW+Z2_oS`Mu8?vTV0jQ@QT5GlZ_lEANesp+L@-i#L@z!!AK@$0O+O zWkj85Z{O&s4U%F$a<&i2iDRE)fBqGfW5gHIu%NTZEKqI>ND43T%G4hZXue!V0yh_x zu6S$!E?u3yozq{)sU%*wLfZqe_u@HT`O09z3(n5p<+n1}q-e>H(WaQl9OJ%8t#+FW zMP}N(J)3JDw=(>-6I$dr+x!s6_C5$Q2j?Q+%ii!Sl$-P~DG3L8;?5OwritPIdf|I1O)UNCxHT|tw?ug5xBKAL2 zl}B6j8>rl4xBs1n}AP5eivhXxJJNiOrhI+fCXvFbabFx_zv z(*CPl@hs{=E)46jyfT?wP$J+ZPoEh~`VhnHSC8g?A^aV!@Cr7m`u(x-aOed>#2AvL z4j$3G()rZ3&T-()y@9x!*N%4*Hq1ftDu!JqH8`X$<>}Wn6XJM@Bvt5v4PS^mh)Cx4 z>JjVgfj#3>5jv|JkHtNxXr8h!yF08GSy>rHdW8U3mx|-OH-?m!Z~ZuryrTX09B9x- z!v26Q?Xiu|$XN)0L`#`UB16V%=b%tz^_I5je&GrA3eUOdT z&Lw|j`S1W^HL%qwzE{Tvv0%f0Ma}DaoU@4I`}WjYHb+^qArD zLief;T~fkGt(o8RmMg~%D7ZZi!b9UZ74VBNYG0N3`1R1uIiJLuo8k@Hzx}$)xR9r= z-?hBv0d7yENrwcbFOJ(3)K-|NE1>{yo+p_Iz45<|w*_I|^8vIv>#6|`rxxByuF1t~ zt>zJs5oTvQ3b=E%(zPSg4W>itt>21xdaQUvN>)Lk0f_x3?B>U#k2r%vUnN`v%QIRc zdzIN~03d~_NT8lBVOk_~X=Hu7$XG=TdBMSm=b(qr-|;t9cC{d!#g$pBLcQ*}mguh- zJ)H5H&r*%tBqDpa;EcDGb}}3QoNW~qyt8@VDD_#T4oXO)&{w>8B)!{>JdmbRZ|azc zzq^BJGnH2jJpT1nJLzz$y6F03;n|&h)J^46!{iDKD}LM+2N1>phXdL*gw+J~LsBdY zWvuJ95`W<*3#4sZHSYJ*Cq`YmJB9xuH8!}hdI?9{gAxiHB4Nw#rlY{0vW3*KcETX6 zbM64Ai1#ey=#~^e{ahbq1{bx?ljStB`g_tx-W;x)ktLb*f##}fN=IWr^dPk8>B8^K zHE`-e`>BLgq^?97>&YU2=eY-k^w;PszfVIGct+VdW%k)J^)8z_z{;exa#yvJwp%&d z`>R5nk{n;1wLx`ZmEc>Or=oXPsb9Czdj^?kL(pjir=;abD#{3l>2!B8@5N(eyZz9t zGSFkupc*Q~w82XrgK_1iLas6AKx-`kChVldhCJDz27qM@zfLFNr7Ss((S)Ybb+i#= z%_qxtzr>K6)2kOK-}>8M@Ae59zVfo zHx{5V-jGxK&0Z(2aPY>#$E9PMo+ zN4f0D2gW?9mf|Knj*f;HQO-QWT4PVYJd?7>fZ9>s%7|_#UtRPXw8b;5Hbt6iVJ#0X zcz-2DFuYehRgh5Wm~Ro(Kur-0`MUO@z317AsaW_3&a5YTvo}OE5*a@kfgb!0fnT4dv7V;gH_42BA1;rCYN zU!<{S-01>M$$1Tl0Sxdr&zt6M`dgeZsmMl%DyYm#u&8k5N=}@%DrV0XR=d!xU8ZKGA80}Qv-p9>3tl3Ro(l}*3C<9DF{zko{ zx2cKCaT!7nd=8SoJS58p+~jwgr*&rynkdHPK}@=^dB@m&xsPtti5G(tDw7DpJFgT$>jor^nogYEr_O(-gNl*=S|4_DsrDXJ2&@XU$ z0}7fL!gsnqYKOPkzIyjq#L=7*!g(Jcr9Ol}$6zC|UT>}xG(-z~ly zl$EL8mxZjwVkFJ;vdWY<1YF`uO9oK^IW1e)J&W>qQR!ugYAZA3dbOt4Q8?+Em4(RR zaZ}q>&1Pb9BpyV0FiHYnJh$sI|JY*NI8f)p?}oU(+W=%j;HhqOvbS_Fa8}qRl60jp zVcS>Cw)7*9_K5{Lu;$tA#zlhlWG2-dKjj^rW1^zdl$qZ`iWAnH+DHr|}>DNG1YE4=CG;*ghgux3-~<_gs?D)Jql> zbXcMtBBb^WU-C~o???7sZ(9%VojLw{!vO^0Igy7agDvq?gh;yJDsxC@ez#Gn(%X2B zIKw*G>JI<92>VP)4K#d$74KLy_st{u*^DG#+ej01c1k_}HzegoB;EMtB8s z7HUVToG5f;Kn0UExeQ||kn=r1g0fV(PItyRl&?c2>$wp^p36yn%va49W$(;i(h{YT zUAt^7oYx>corlw6P}$_W!B#IQTnE6XuorXAV&V$9!d*m`(=!O$7!nTO)rRXf1$IO| z&1l2;DEJkbx--$7H`wAw*mA;!X4KabcW75qbxUB3nKZzmqlp_6h z3)=cgN07;Y?mLxAW{F@6n(g`5@R0nav6J7=GOob@`8VU+;R^RG-=dm-^leHi$9}5d zOeeAJg;8|^z25#2-NYJnUC{Y(0N-t!#55j0`Z2Rql~?9??c0av0G4@2#xE@E>!Li- zUZQ_m3)R{KX_s!R<~rO%;A&k-;*$6nxpwy&m&x zt)ry_COHd@7i@!kCi;<=*n;M1nqe;haSQEcTu(bs)!amjx{)--DCmtCfw6 z^u9c82el)qXxc|I9q@N;^kEKCwVXs4mr{z6iL@73mHX$CmCkpEvp;-^DLj2VtSBy4 z_5u(8W>VpOMF|KP&^=n4<_^o1_jBWgrSlwp}`WxIA zWNF8IT(!_|0oWjiNESXvP<{Ez?U{_PO}5*GE1;(eR$4GJ#8Hp5=E^%+5U<=P_TM@mLGG zvM(R2_YzMQFO<0INcN|@^{QA95Q)A}P9W6%8{c(cvh1*`yM}Z4rhg*!)MPEl{U{*$ zhqL({unm*aX^6SX%UT&}mg^#vUy?G=?czD-l6qIuG#Z#JnGw7pT+Mgo68vnwlYQez z5assSx`W^(Evo=9pb(4EMyJZPU>wPb-qDH6f$pbN{Wzmoxb_n*HY6%sZMI5Ip8wo5 zQaYyLMZ_l!+c_SH4e=ty3v0EX5x4>QGv;|ZB{BrmA6ZBo%Gy$yzxU zHKPen+#BBrCc*gP2Y-k8|>ivx=ktexafsDeT3~N?zoPa>)&vS9OfP zGzNsEl?p3P>s3hQUAQC2GXdli0C7_h7WvoGd(AKVU;YnhQ4RyB1`E4tV=nTG#BN*E zkBPLbirh#IZe2?5luk>Lu*#_yYm6&;qm41v#*J`Goxpznn=>vU9JAcyw<+h3s5}y_ zCSXdZN%a=hoGOwZ12bGe*ITqMv~OozGgAI@DKaHLG81?HUfLP<=1lb1`9}mS za@4rDT*>9E5VRfNI16VJODu&Zr-{kX&W1(7vSluc7u)Tyz!Ga{%HQnqYn+Pl~u4{C5x(930GNqbFf1} ztD~Bhj7Qh|sU-gT>NO4)m_iJz zrdLi?8OC7x+NZIinXMu#^7S7s#pv6fEK8oFNQ1EZgbWhp2TzH(P6<+r_}>f4DL8d} zhDBzkj!UgHl-Z$@VL0~%7?~>;-9?V2z%pCvr8Y~|n$rwdvQbT(rj{%eVoaq4CK8QN zi#ywx9@}i8?6?y9k**nq#`u%#OM8)yswxJNapx_a;#4pb#{TCYmq%a9WwwA=C*%uM z0?x02ss@9{Ed*2URq);i3NSn898S&x(%GT%{)~BO8j>qgI0Bx^imb}jsERfUuJh9< zHKk}M#`!e;!3O`X(?BKy?z7VWmmnYMPBH)w<(cubtK42KcR&BONN zUW%k;R7j0YeHfU^d2cxg6xsT;Hd`D%bh)+4oEBel~&i;58nK~Q3K+FtU zO|hU=t-4#jAp#+MR~PVDH`l~>iq{L};xqqp@fL(5{&A=N6C$8mEL>85=xF9!9BeVf*sU(ON@ArTqTK%W5lpG&-B~K z5uR=1V#+3NRs{nSiP)=`&-yUAEeCT9;vQa~wYBnk9+&mpj5ubF9s9d??|9%bPys?z zp1#y3SvnkXlxboQZL&B5|2aVFU@=D1njjSaLxi(KpQ)4UDYPyase@@z19AKTUUZw~ z^5}VbA1JLm!ryhlNPfim)DZ9La6I&O$P4x4#U8yDU;$*d%ys;-G-FwK{^0j^{& zX65mhpq6gFnouEvV?-v^$WE6(`U!mD^_$`ij?#6&-5KiD?JY`F9TF46LOH#;e)85x zh6~d5B2p8^$?fBrS<5-?%lT8wMOhQ2@=F&Lz^crNScOS7;$%Gvq7jc_=t*Y&zQ#D} z^TA#=~WkQ6In~~uygc??WhzcjSU4Pk7CPS#Bp{IXkULhoMNr>ycap2jp*aXPW0f=eWbI_aTi9!;InX6Z8>V}1n%J81jTxq!>7NL? zxqGSnN*Wgs5%wAp*2`ZQHc|T#R3`#%PykoA-*xf_hqf&V-&P5aQVF%oSS-PnI=pBI zX>DVWhQNSOFW__I4Vp0$pgEVDI9hmaS#lo{cy+m4WTLX;T~x<}lJZhh_)=}fO1=E? z%z1Ih8{>N}L;C2IqQfOfhhA~tYkdE?NQYmZl^=>d_YE&kKM`~6uc(7gC``>-PVqeM zpTMvjUu5wl!%1#%_f;sfj^$a!gOT;uOaER=jFfJCL`%V4$I2r(wz5BFH|#|}d;H+p z>~GRpGUOsB)QSvo%!R0sl#^M85}TKOud@*b<_v>Q$GCD)pr zI#{Tg|HN7-OY!ZqNJ^w4Xw(^Re`YrDf#GWW&HY|ZDu~c#Mr#n1*b{WDn3a3DF3<&3 z>;NkMTjx_AY9IqOs?=%k%xOrAdNqsd3ze*vfNmlHb|S#e1wi+aR?m@94#%6hMF|gq z8jlX<3-}fs{^r?tesi=PasT+uMRd_w7*FGlq)rJwlaSJ{Sq%8Jm=QHmCAw0fw-P1# zzNTWSCHsAF^ZOU|ybheUec)}giLtxM?!sOYtdWq*0<6y3n9hTM1vOBO|8i;Naw+>n z#mDcVqAOMZc4D#-zPh{h9$*qt)r|UI;-j`4R;jlah4Y12&Cug8rS~(m-VXI^mE{6oT=AwYpC@!?6W>x z?prcb#5s69gx5$21@cq*Bq|)-h`!Q`RhgmtNOe(3~92fMp2aHN83ETES9B zFds^Sv5V+Lu67MJJw*v9?+w%#4ksh|E&{_^PiEJe?>Yj(d(viMYe#=V~Qr=SGYgQolJh974O zX-B?)mS>cDL1geUO6<+>(G5|*(b_!IY{CdJX#xE(dk34Z{Odk;QbGx6)lZj`!7K;$UAB>P&cFM4fQ zYpjrOvuVwTaj|Qy6@qj0c6$qQ^gh)j<>>D2e|c_jqS_@-e%jv1I{j~3V#Yu)wl~XQ zJlD})WdJ*jq|IOvRCO2=ID22IC+RT!$g<%~b%yeqaend@*D9s#~;Spr`GV9M5<5N#Ggf5V+ zS!AOnOMS{(n#!YB07;jQf3<;Fvt(Nfk$47{*7&aM12>#}7V6RA<4wG6%k^EB(xFJO zD!M)ICdrdw7lrj=C^kJ&Y{E02c-8~BE-P0fB~U78{;M(&4$;gh2+w&cbtOvsLl)OG zpq-ICD^)g56i~kDZCDqo+@JC~@{uWj778KM&LiU zFFAigp^R>Csno}?XFCIL^~I{+-R#MkSRt5Coqnvw^;G?JR>4!PwbHp~dS6yAK0gio zc8#TL3x1lH=k)tScC}~Z4V1~tONCjglcj~T$}0rUA}inEB=8q)6W=ge7}caH#IDux zPOJ0auG8&|B1~>1#KT0H*58wZP64!^2O7eMS8_KSOoc@usaB5sY36Wyg_G1KkayYH zRBI^j5MvzRERJY4=g#EiZaC%=*$4OpMRQebL%=%s0W9yFlZ|8Y>C*2Euag3c2 z=C7E9vT~en2c!YKHNH$;{}qtOceED>Ei*&~DEHQhJY_Mr61-3$CA@C_QEPkTr+IGDrwXNLl(Cz+P0^0|EvWtw#8TE#!y$I&i)N^ zh&E?_*ggDfeG5y6k%y3k;Z!T1%u!B3GkU5ktGhB|+{ELB*o~|<7Rj~2 zzCP|@p6z|*v}M2p+EM4FgUz(>Lz!b0+s4htDqgO_<|myi;PXmHZ=ZA%(B0@!NP-;? zTF6(H9EF6H&YX`9_JI_>aL$GL=SZ+xd+*2d7pXi}*+x(;200Z0WFL>y+Ysr9?@!LN zFj_Oz$j1_5!PfQSUo;-f46-T8R5Y6?gq)6l$3fc%zcX&ucB^M!GPOWEnv3%S2uX&P z%K32gW>I%x;MCtKi3%O+s=% zgr^W-kSB?7_)UrNcV);K`JSK{aKnt2OS6P*@2A-Aa*_Oy5@`H8+p2*tm3(&rdTVSA zY(E=NhhHx#s^p#ARkN$g87}%Co?5UkKQPJ^SRotmKmaGPnZdV^{7l8x#MCR*fAnNi ze(@jxkyua93kmsul7ZeH6dD)&1&8ITb!TEV6rUHY0c$YmGlZh(G%b{=5LX^QTiH!U^jdAem;tA3H zxT!~f@r!-mg+Ii6gg0f4$}w^$sWBkD@HoJO7htvf%pS6oEtC*(R_s%>i@}u-H&{bHRfx4b<<76kg;tkR(LYl&BSFY;8d%qPExxUk5+%>nLXpE z*vFAMH9^`Xoz%R)Ow*REF%iL=C-4KqIAQN1k$5K)U4C-} zt_$FpyAHnph5|#q?nKqZ&V<_Fpi+^)`Wx&z9puerjk04&KuTtJ9{>pFbami}^I=;} zbZ;O9Kkona_0~UEJ3sv%Hy7R;m#2I?g@(sCnAZKJsjMF6QHl07$36kw*rA~GaJP!T zvoG3AZiSX-BP=Y1H0SSr3^!!RhfA1oR*tMJG83=g!qB<#S)<~5F0_v62Rm-%yLF9M zGH?9sDo=RH!MzPXKDv>`pY3OK>svIQCRg@jh+4nQ6!&Vu)+n(G-2OeOG&Lif<_608 zP#po%71XX20Nw{XDAOpFuDK#{mfG;)%J{P8H$O$L)pf7$6doYJSAR)Y$Q7KDQPI%u z-pxy@X4T=_;Nt(nl`fmc-=ot0q}zCTD4QlGR?dyRU5%39c%h5fN>7&4qValz957uV z%OV$V9?3|aavvGwM_^v9eQ1)5-;g(u z)qHeODEun~!!IVrf46)G0v9%DwZH3swZlB)F5E&eG=%Xs1EpGR%O=P(ln;@ z%~ObP!BraDU=@oYYJL@0c>%YTO07FVrh-&21h*$ArMHf{PLsYbGZ?>3 zFsEIT^%Uv4FkJSisY~cVx5R^<2R(9gGO!g@WXOY>-C?$0!z!#J{%g0WuBW=lpuvqt znx_WBf@>qr>J{hTd^)X0swK!zJRq@}T%wkk2qCA8O`Pa$%||7a`)V`-Ka^nAribWJ zPIquk9(1$ukvH}AkZ(pmnoGKKMF4r(PK}KT(9Jh3T&pcwR`-08^YJ&*oWH~zJ~oSF zTc{(lCg9C!cVF25_bt?5m5E0m0)J1U+mF-P{}iw%7%OfGaz5XIkS{0aE1x-|%<<^S zv=vgIp39{nGLlMnl%3Vrl>R+%NmM`K(%yh9{(10RfJ(@cZe>vt+GrhK7g!`pJh0yJ!q_P>oKh3o?t+5q2Q!2{QT`-wq=n8n z{UKIl)@;Y}7OF)ci6D7A>ZsbqDSJRut^qluV@mT?oh?8Ll}0*xx>d+I{mtQfFK-2{ zSgEnqPBBojfON_2CUJNrHWGx-%@N$D!X))tGESL))oiaD*+RY?-{2M^QhARc0`b7# zgZ3AS~r*S^FrdmtQBUIol8SVj`6eB71 zg==ve@BC&zdw1aOI(hME4t?bh{4vTtJdS&~cl&a8O02FzxYArfAJAkjNn9+Y?u>Y1c@yLI~!9Po4l36D8Q3E7we$ zo)$EAEhopTP#X!@1poFEKJ z#HZmm-0x8DZSer#C7+2}(A>`UyeRUYMMOuMwb@4ncyk66Cknsv8h=%voZBu%d$E*n zZTJQFAp7e1s9#!(-Xf&~t4Uka^wG-lsfn9+I8)hHZO?K;KLtp>a^*?#5(-!0tM~eh z;VvL7%fDJ>e`lGKA=bir!UE3wNc1h(2LZ3DN~N{9tSwjB(D1q5XQ?%UP1t)9D_)n` z+1I`Ov~RC5sf{9Vc|yA6DyJFKVR+s_>anbWf+l}ghH&K6KW%?t!_@R`YsI*EGb2@HG3iLLSRb}^~oj&yOYkp*|&A82islurK-B(Y(ZH$boKjqxK zm<~1PhsS24ykfVYazCbm&Z0<&0+VP|i7(=*1aivE@OFO52o*xPNs! zy%6`e8#AWrEZRPvwyIY> zg`TBi6*0|;)3=DVngzGbH|KHyFI``>y5>^HSy(sq9UsoE+l^4_nqDdu&$r}KXpDI| zosHFZ8Fs(xZu6VoC3|S60PY9f(Yd=(x9DrKvC$xjdo8nzIP zxqp?W9WKSKMF!U;*l8GqN4|57iU_|L$AEQ^8rw#(f0NoF-bH^Ix42ulkL{A0?*^-n z2d=+)ul4o4wj)hqe_6@tfyaT&HcRX7Kb~!7gzDTUF_s89{xl7zyv#RlCT{C(_EURZ zy5{KMFK4M7l+R)y{TZuZGhJkHg#3f#-~o{|+lOu6*&~#GjRLQ4RQ(HvTzUnS-ZhQg zzZAvCMh-y+?mqqaYOjki4pMzsvn@)q z&$n!{OuDU?E_fsKTj>YkWBR+Ts6;uJM|uA5P|mkB%9vGnal#(g&82`=CFu)ce^t*P zlLo(4?%D5X@eSk^&*u@w_fW6*T!W;Qm(HueW~TqWb%hVfTYi!F`mX-CVMdVWJzVj< zjCKE3{?Fxz{*}dxxNIE^@9-9`C;xM7yF1uVF+!RcbI&8D2D}3bXW^LEnz9y;LZEspuJOp15=UW`+|y z)8_Yu$vyy!vO4*-J{VT2?fiH-%fm@w_orzuTug0*vHw(LlLnyK+yP3MA^_X_o`3hR z-`b>9pNqWpx+$v6=FRH~ZSto-Jf|zzhjS{UbwPr+qQcuR2}=VL0{unbYF>k(ufN}Q z`~H1dzl_!e`ut+cm7kpBcyP5*D|DzSAM*LRVL-~B7u}GHCEC{gsXa8i zA!-o+byd6omUBYuUCa6=yNCv8Uk8mMm>4M=6ZK$PEq4klpRyY>!F6`J{?z5N@&o0UmM<<6eC_MMd4)N*Yl0&+(3I<2AS%>&mexM(!|oRpd_{oA`M<` z?Mh3ouAr=)3|XhWQ)MZPAx8&`EfVXT_8soHrKqvWOREbM#AVHSEJQ}zkm`UD*qqO2 zSGY4`pGdgLZGq0+TymACyc!>gn$&(b!pWk2Krgb_6HhL7Gc|V zny;MaOOcjiBC!;~5JvRo$%42nsw~9iQcaYpSRnJ)n85YvUuFW=0U)@* zgM4vQ{&3;I6H4+OYE$dj=@e5#M^U^a38jzEGv*Sv!*B}v_u82mL5`~L;emFXXsOpO zV!59Wm{s|9tK3l7q`LjQorS`}eELks^bis&x(K$_-lNOAVmB9oXYVx@8HQbWXj*bE z{^NoRq&_4zlXfF+%#O$))v}g^s?;%(uxd;LBV8UmbnOSCH_vBj)_I zKi@R-?};fK%n5V@&?))nqq?w39HbXD^b-!`59zC+@igh{VA1t7{JWQ59TpTMlTZrL zF5s-yzjP?g@Ku@v$KE;iO^(7K&uYBEp0oug?50p^)GTB5*_8TGp^m@ z^5oA%3XCRGWo_L(FnZ+|*sD#%$DZFz5cmA!2rEm(mt2vL{A#CPm#7xPsLKrayj@>F z<1iDB+N^C6d0&5K7e&g;aG04jiW?K~?+f|x$3GWiD&S4eGdTWpb&%Ct3nd)iz~Vc2 z#w*Zkv5-HXo`M%g@Z3DcLNg=jxS%$7EGGRPO_{SNxx+&qc+!>*8shAE&!RIpA(uia zkc7yq<`5^Pa@T46rHtStcOcSn6=As5fXSc^zC68%=w5v+A7Ga`q&z*+Hy#G!HMcqc zkED{J4d8Lxw3G)id63*HRG%!Uzv*K@fdV$0l{hMBxAuR-s2uJuRw3V2M|Ge1dq;Zv zuoT93^&~LCRN+mSWn|H5pcjk`0wK%hgn70@jcq~)*U&KVs7hy)Q`_KWoy$qzDfTew z28gu|-%!EcSdy4E$ySTm>Do?>xew67fj&G)BzyVTH3&MoQ;g@vPk>9496pRf3nTQAUnc_Awpar+btNfKOJcP z{6^FCR>dH>E2HRdgj33m^)slS5=Pz8vyZ@@UtJ^in+hwK^g!`VDkcjHm5a{H^viS? z{PP@^O4JB$GN!*@ry`1Ck}Lbro@O%0^}Xa<7^kh=(>sHp)iDI^GB(|EAe-yk3uT!_ z6?NwUA8+=HR7F8-qf8@H;N>L6v7zo%1mr0X*l4V4{XqSS$CL2Mbr;fPcm$4U_+S5v zW9bP1Hn=kW8D2(v%18BCR5|K~^iwX&&Zm&DW<2Nelob6oTPFG3UNE8>9D`1~0{naaC*9lGMZ+PyyS*sN|X~8N8Ns>Pb zWi>i-mj}Fm``^#$Dy_?Jyd9RnpU8C_W~EfYhG{K+8;zlYMq@V9%R{1(oiO!%7nj9m z=Ne&t7KA7W^^XZ@)cP({NJmCv5!|BR)`nYVsWiOK z@9Y#iM6Hahc^Y;~5rF2V=v=}8SmDgd9|?hweE|UF(QV+TJ!HkWHZPWG0rqQOq&$t2 zzH}|w=YQm#E8VWEvggAy zbAXElDIcV=PY>AA6I>AFV3j5vF#11NS4Nlg6*%n~v8|IB7{j!C{X(gX7cxOjZEy4KZS3&yDs?R69SUg$z<1 zmfg;ymAEP+zy17IxEuwd&g4e^e%1@b*;W4ehN~@gk5@zqn9hjZTw&3d*9gr!G!P;% zg8DpaPcxVvh^tZ_4q-KA6p4`U>VIk09$l4136G26=>%$e*H|V@&rP$^9N(X7)4@u} zo7sHONeSGj!6lB4k;5uH_o2z;C(?t?D;{c2_#g3KhaAs$7TYUHyG0)1YUJMYF*{ zn)LsH0bt;we9wiw)V~~nzcMr#l}U=?^-Atn8Cd=(1q4ITtyal)%BoGwb2C_HQrvtVia5P6e_buByj79aRCz{y0{D96GMsFtYpdd^{?Mgqq(l_Qff? zb(@G@h<`#unpGMNtv5 z`bxZ52g1Whe5XXjY43?s);Di5U#LEuLX63x!Ui`T$yle4o=#za?v2>7L(dx@jc5r} z(L9ieG*;w4?fwH42up={1Kgd7?y7<}!iZ4TB-^D;7?J3BbIY;E)2quT;G#QaE?Lfk*$mPJn%#3( zLTs9TEkhBynZmCC9&}TJKovmhUN94=wWpYyL#!7h7XIZf`HN`Wm!JkSPPjw8G2mW_ z=DiHEv$t9MQi0-XFKwD;dR0ss6Z_S4ovXoE_q6U0$?0fby-x*1F^+A?1LkVVu&V+$ zq1IJ=8tdLdr8C;5>!6yCVnbK7uPSd01Qo$uZR0ej;!?bkC&c*Ufp}|{h0`p$Rafre zc~CFf6l*(!W?83VV{hki$*;^ixro=AeyX4Ah0Kxx=1W{VsUkjCfp(e8LXpFxj%EEB z3Yk_Fc%~I?XssDXA4s&e2Nd_^Tr~WP=nCpd!joX!XPxfPtp9n0LQ9;i3A7Z|Ss`ce?0RdwTt7W=2a0nbEf0WhFR?~|8>US%MMd*-5|(wBD} zeZ>c3Dwfk(bwqZg&BT?f?8SXj*+)3)S8VD_T<`U5h3c@8N7;zQC%xvol{j|AOUM^D z@pfgU-8kASboGqRuN!<#v`M0>x+!HT4Ev%UmY-J)unkcA255|LH6CHUM`>lq3h4o3 zs9@|u*^4i&sHf%jqK!_MGdlaz7QR%Tp)(?}OM}TBiWeIC8wBlcI%$1U0ueE99(7_P z9*2Dvexci^rZyA*B%7VH>(8zEXVB~Cc#!mxK^L}K5^T8}>5q;-gAKgW7C!;#OPk`W zfGkF7sN-HHdeU%7?eOe0_=&$x-u~#7Zo`HBF|3TqyxUpI}~fAnvR>Q*Q8ckeAiJipmSf^8tGfZC}!Bt{_TdScro z9kQI~oTV2WrvXoyuL$OSq#b19muqnC4zWo~E@Bmx`HTe4%c^aNRPubrY}c)~KWiTS*qbKn(`idsV$EZmy%UB?=p855JTj8; zw&7qB>`Hfk%C?+O;Nb0VdLiHBxN$EdjZo9#O{~%T9_|MZ;B1b-KW$QM9bMyD?{D&qZZ%~Qz#>8$lg%F zexPB|(l;m4omqb!Xc{k%i?{g?2^`#&`-CT`cltd z6H9H0BlEg%iZ$+FEco#GjiU)a{wJA*&x1O9K(_z8Xg}%d0UySRB|XUt{`yNDOVI_; zQZ%*gLuJ5aOhI-pI10Zs2rO>PPPCW2*gclq`&sD@z%sDbM(gT@j$*%SzFg;;IveRJ8I`tjhG`YtwP+)A1TJ3D}uL+)R?zY_iU5itcQx{#+VAsl z!AR}FXx+is%dhVnzJ6%@I^O(k;?=jw*Waez98R|%&U75kb{)-8kLG)h7y6DD2fi;2 ze_tLw`TXHzb>hd`^pB0%pPO?(w-$bWS^TxVe7d`Gy0`xOVB`1Kt>53a&klFaj`q%u z5B_{V{PW}Z@6YdlPk;RVefsau*}uQP|NS}rclPt&?~}i$M}L3r|M~vq>~QV$;PdI; zr(e4te{IkF+#LV0HgfWL==*a2@lx;cV)xO)+oSom!<<<1^%v~C zd$QA=yG_md(v`INCT63Byxv4yZ+y5`e}C;|*lKO?N|pa|h1aK2wj(*o&Ioz4?{mvY|`?tx8As9<4aC zw0jSI`kHydQxWTp(KjW}O)rZ%!npC%I`pNw?!rzS?z&U#gi?I+nSRAgo^qJ?zlNy2 z)R!)gz)WI_aSKh}V;pApTj~~HgL1WldweCu+Guj??bkfwKfl#IdBxB;8|T^+HzkUq zd)=^3vM5(991W=&N2j{4_4<6z`0|0G<;ZN@CU2M{X;q?aAnT!@TCa3#l)i~YGn!6o zXL;=YYcc^rrn?l0?Ix|$dW*eo6D5BCGG1zU!=$s#$ZEdH?{i9_)$zCGPQLT2*OyNZ zzrTI->uX*8!>=Z@>fZaXKc#wed^V;2Rc9wV6J^QJkHo@&*h~}HSs8_HWWsXASdn|4 zg`neA+p=BaY6wQn-;h^zEls|oQ@oyH zsg3JP#e!4@)3l&&W$Di0gu=_t(cK=Igjk-Ue)n-marE6hmaD5MyGqsRs z%h0B?xS!oCXlEj}%le0epFe1H@Y|Z$dK&D^E|h)M z3#I=|L$sE!=guc@u=BJClcfFQq815TrwGr3$y0L&DNC*}1e=AXItUvona5#YU3tY} zUq5g9?>v|a6mksSnD|&Y@Bc1~1V&1;{?bB*l1ZPaw?m%!@w1(ed~P^TuLw2Hvloh>15hh7j4K) zarm1z^8b1A-FU!V!1n?1OJt)cf^i3uKjPzh)MtEi^yulW#tHw$TYXG=gVS%j6Mnd5 z)T(oeT@PgY<^N(x@H_Wiu2_yFwpXou7Gvtw(R^ws3;m@NC6w z;V*Xr>xKX+KQDp~e3LuPSbY;5Wf|Ek|%~)U}Vpu1w2XOt$4TfRpxWc!8-Oxg#`nssjLfydTYlT0yOW)Es7gZu-PbT`q2NKP*&rbF9eSIFh zH@u018t7}?3^a1alXyR1x{g|Q(j^t1<$B=LBo22n4o9B!Aj4klsuk<3AwXjE*{DYG zUGKT$tL6LUswUjc6tH8$OtlvLLKTla<7W_@t!6f&R0uHrwsUz)s2AWP|r{;Z20j?Xk?iW7qSLNJ-8?_8`&6vFYImTyYq|XpG{c){L^@ z&#t@LW${Q$&9L~d=fpFfV7RrdTa|A_CivXo=-II*SEp*3&hxq#+L;`6Xm_tSkE~8w z6wlb5rU%Uwx9H2>X7X|G*ld8{;=_EH;U%r_=xm9?vV*welOM{pFl<6i(Pa_H`*sFjc^yVorKQwgPG>B} z^y7A>T+0u~93g&INsC}gx{9#oWv`c48t*5WzVM2StEckM&L?)dE=1qtp6p~Jq`4)<<0wyGt^nlaoZ)xTsvR`s4dRYIS+@a9AK>^;^ zzX2&z{eS0R+O$MHJ>Rd*G~Z5y|CQqH%h^nS$vd#JXSvdF0sy_#1<3db5&Z1$ z_+|7lx4rTUvSQ8~hgSmtg#Z8o0Jur3eq{^LgL30j4C4*~42MN|{X&oZ^pE-BHYsl` ztLQ#IdzB<-XY*QIeG5d^6ygjbi%z2DSv?diZ|NuoVaz3V$YO0{3xc-jJo{zm6fHq3D000XY&j0LSOHrAAQmf; zfh1nn!Fvh%(ygVs>gVfbA2y|Vt@!V-!fbmWu0hbeLGJ)pwfhz3Tm$A@T9F&;kO-`x z?;+Hmz&0WDFhN_E>m4*ANIj66Pj6lJ>;v+2VATtRo z3t@PdWEdlz=MAw(Lc`@@zOyJw1q`H*)*=d9l?%Ie$J|J_s_rFHAiU0-E{t2OFBPq5 zTWR7cv4O>;oFGHJR^vB0sl0<%g@fUCr65Hn&@fcf`8A+anyXL7&11mkesvgWN+EdSU>>Xj+smx&Lyhj#|r>nOq{biUl5|ZN`?CTO|oi16ls?A*D8KkcoCnr_t z72_#*j~3-e1B4uJqpE2m&@=bInYzl%*FOjZ2g}E6GqZRR@7)4EK$YqZuP1IPMLL#1 zC24ZkJnz*hJJ({=<8suM*jR$GYPTtg`w&Zd1*!YT=dRl(pVzc#uR}pSYW_ir8tEE?%zy&N9ljj%E~!+cgca$KtTLBSz06} z;~W5&ak&Mu@u<8bvRm=k6|C~-drY#8V?RWiMlMjA34y)CRs!U?bs9^T5s|O-*m?u+ zY?Z0BK!9(qzMeRM;|1+A@T_1V_c5f3W6HLB9vRMw{?{$IiqtjU%n1q^b<$k8OpNHJN zhQbK3YsnPDq(oR`L^8=XCeV8^5qX&!y_tz#R>!Q*%qrq^34t^~Km?%*yb5fd(QXNi z03f8C6-~6EY+rWpj;#0W-+)OJh|M7_8EK|FPH{2Nl|7d7vXp+a9>iVFHyU1yLg=;V z@lS0+s)W@m^q@wfEVo5U*xgHn@Cj1c0un;Vyd&ssgu3KeNsRVGw~~bFLFl|35_z*! znl>`IInhD(MOR)t*!A9(v&d)yZTdfoL|pct+Mxp6$)Dj^esjE6`4FpuEKg6&?>n9Bg)kWtsi9OwQ=t@^!_8} zx+&EsKeC0U6lnOFK=v49#CV;|QQb;{Oz+*?0hW$>+z=l`j^5Kfc_2EO3p&EQ7q^ORc_|c?Wv#0KqlZqd6d?N*~2W5L3ft z5L5eqcN=sm$(;AmpY(kbc~hLyiJRUjT>=tOuTw1Eu^z4h5dc>lwn5g+TDG0^4A+#K z=6=bMqix};P%wEg3qBkM%3>{m`ZV{K**p+H+PDKiOn~L51!EOqV>@n-6Kt`{h4MKZ zJPX#BV?#eFijsN7R3U&@rzo@SyXywGURpkOXsgQBd~-jJ=ooIlg3d}7%DEY$%R@!x zQnV3Bh|nnF&l^*-1<58=)^kOZC%X|eb9QextL8P4^uF4K_t~yXzm;Ncb5{;5+lzbe z*|qhw?Yj95e-(akFjUIS{OzxJioO8P7rha!7Y^rZ9iZYDZoD1-Q!k`NX$K5n=8UR; zsqXWVMeZtee*K}}o+!XG_rxD76i9i42}xQN>#`<1$$rE4<(e0m>4raGNOraC$PdiX z-q7LycIj9PFDHTs%XGl^x(bBS$T(h4>!ZP?UU1&ID^y?mcctAGDkB_0@q*0`a#Co2 zXvFioqG5ei2nZI*KZ<~1S}Y8aq|N3r17K_nlVERp!ftv^aG}zxfHb_FG7ecg-)+bsI?UE;`6UQ@*^#Qzx$4q8j8hAmZJ>M_j~QoX??b=ghp z^)5c7rW_)4&wG|)ER78CXA?fA=px&;&SeLZl#4&KNh2184*2vK4b|R{)J1VUSnwQt z)7~C#_o77&IsXpYSf!ESuio#Vo>MEBLeC=pamY)$vmIu6b!x zo4vj^9Nxi4sMoFh2px=ua5Au<5*v;T!R-%(IUTGP8#9yFivHC2D!eI&CD&vewc;n< znPQMw!W^WS%Bk-+(NUo|VtpxOEJX*I5u}h^->pR#RVs~GF+!9DaXlYPt2RbH!yuv5 zQ7@YoYlq75lPD?256<^LEC^;?{z>f`Gk&_73cgr4?S`cUSEZ#bZCDf16SIH0-MqV} z^=&A;MAy_)d%#Q@SxoS(1#|?Q2M0QiN>b!l|BGb>g=)na_aZ(iGt7*S4S9Jl4 zwaBSpqp1?Z9OZ{O9qV_n8A<9AdSDq^QpXs?wSAD~#oE2W&*cJ;Vhh86xlt!2{fmRV zd@q%=horipy`?inMjCu~?|^Yaf$aJ>CAxe9o@{W|5r}2>NHY)#da#E!$%h@ck9@Ly zTLv99eErroL8McM&FP+2rYp8fd#Sdb=0x4E8Pzo556b;V^;g*Z(;CWrAzvm6<{+Dw zjhf3Ietd3R@W2Ytu*GHb9lqP~9@Jn&-fceI7yEAXAwl)-uLtg@!WgrzLM5OHg&Dwg zj}64_fpr`g^6TGw*uQ@sk58b@Dpj5M!CIW}E(?;OO?!Cn5*hR7=QB3m$8KCf&`%CR zo#QhZlxy|iSY^lo9Zi+clw30S)R^yX@RV|$bY*)zZgT3O_#ntkR+Hf}tjj4fY%oma zws6~E!0j0kuMq{lp{%8SOZAs0yXwG^A>WlD)%@v5Rwx%E%O}J7n%ddp$8ClXh;iDp z0F1Ef;dH=iS-3j*-q?s=@%oav3(FdyJ(<{? zayN#m3jES4G=KN&Ww6_Wjpu`QC1V=*)Ro@uuj{O$Ow>b(t9iAb7GFT^oDNcdS|r&; z+Q<&mG=P$>=6$lknPG2Oq^()442!?uBOK0}je|+!6uor6`d_=I>ZjiP*znF*WKdnPL*1KV0B&gCMy%!$oEt<+z=_2{Hq*NGWoueZ0ZA z#r=ruVaq?q^LPL#{k<0>2Ee@y0Og%^ZJss?aD0?S}3 zZUtAZ=PV7?EdyNFC$MCiyIgA9-jDfWuA^1y*R}^Pa#CUQ+Pn$^2w3&lX-NVX^e6XX~}v_$ELdb@V>AH&klt>Gl@ z_Rw?mN7tX@%bE90i=-nSeEWIu$0$g_|2>yiso{WQ2x(tg?Qs zPNX`IoG?%kUN0j|QT8tJxWi}L7DIR9QxAuZ1F3c({ zESgmCD6*el4t#0!1T!Wt6HLl5R3yrQ>7@^;(JX)!sSi8iK`tbX%V+_DIaGZGV18~Q zazX^LN?_S$1PMa`wI5~8lQZJf5Rj3?ASTLKoB#ls>i+NmS%^?5nF#ATm-p66d2-ga zt7S7$4E|OY#&RS4a-))u`ZkzdAG0aO6g{|&qp&rK4#(&Tt_4C2Dzd@M2D~JG2mMCG zu^#EchKzX3t&qwk4w-706T3QIrjMG098Jhp8`l(&vORJEGnrsWV6k)cZd(fZqR3dn zXpIo5DA1Ixu1aba)qhE4FszNT+$&+RPSQ`#<{<+#VLYr%c1)QpGIly?Y1p2)PTN=; z{%{E0zVuTTcYn=(OcFg#?7^&f6ZYaBv_0O8RrUGrH2k1tzN1pY_tUxm$XM43(fwuT z`m}PCpF)?$n0Z50jbzH+iG@$f!K*(nS`Dlar1B$6)zqYuk8~+XE64K^8p|Q=622cP zSMDoZ2LrW{y=+ou$g#)@&2DH!PFfGV&=!`{SI**GwL$)|5@6vEeS1QG;%XqN>_o3drM%HeBsmNlxF?x5oo|Rgel+`Cu zf_KMa-d3%3v@3E+c3tOXdu5%;2(!>pZ-s07t zC-=;0@1}`Ur`G(D82Q_x2HJLL{UHI!gX#x!PqxCk;T9L9ZF6Sre%4pB&`p&0w z4)62Jm-gT_)0iPx5*sJ18%7?K5*w3q84$&HTOU?L?)s8E?@{V6esDgcpNn!I#q6X_ zd&UE3YP^zmS?;1M|75gY7o*AUfC49oWQU!LNBh%#@MKCFJ(9(>uU~oGuXp`8*e4R> z;oo%#spK~IeY3;x`RtWahstfg)ct%N3AJXTq@p^J28$>R&{bqWY5FvT4?@U^I0@moyQu1iG3fdAQP_F(0wgY zuRJ1$Xm|?F`98Q|9=SGbE!I**D=NB}%{pyLVrB)QhR3xfe9aJf(`S}zfTE1npu;Ke zlnYPRpUn@Jf7WjZvtm4WE~&8UF`h%f8T3E_xrL!ZB37tO%~Qk%YgHp!RpSu~hnpO} z(4N@qKHjkKwKA;yIGocPB;8QTAN&&Gu%%Zegjm7<)`gEJU`<<+{^$)Rc>y z{p9;&#rO^mKZlVPy~yMX5x8l^kgkKuJnD7pX4$PbEcn&x4F>y|6fQ2$5wl|kjris< zaff%1o3Agm+o3tuS(V~kthc-}qIy_`XW+q2J$F8^7H+2mMNiDk<;V3^D25tW)lI*7 z`yWG)c?sB5bm2SRaf=y#F55w6H2n4s(1(Tjc6cXogaBb^2nZUQU{2sNyQEGOMNuul zD^=^?U$Ler<-di^zuYL#2UHUTNWaJrR3#}p#es;M^s0BRYv$#&jH+ptWJo6{QoFI> zwc87~0Cb`}G-A?JfrjHmuz++;w#R=g`UR=msDFEJ`eG zf8{&O*X+-c_un1O%N#>rwPZqBeM^0 z`0pygjDn_TO4rX}%bz$!8N(13tZHjB`UXY+P^g@YxtDoiJ9Xa2LUByX$3}O)nbPW1 z_L`E2iLaH{cMGvUk@Z2J-bxK{az~`gbfWy3~?_xbhp*M zc!#` zQ%p`7l5hIXJ_^e(fh!QM zxkB8PChcCIFI)qqRd$B|$>XV~&IEz)+(SeEr~~M(a1N^PNOy%dKHs2GHgeIB;Zfd} zYmJJ^1m?3a(Y*P+Q*<598fVdvP}6Fm47s2pK!jun0S=nKPRIHtC4rcHTnRq}|8#g` zjCuR3hQy1PZU)IIIgntBmvjc>eV^o9Bx{_NK+5+7ngXqcDk3(naAfV zDUPEbUq!BUfWP1)GkS1m1LGCx;koVfEZVr@A-)pVYi3#;A3mm{v#w1vwzs!;54rQ# z1@pfIat{9KHXP=+hP_&n?NQWcRLsjV@m8}YPLL$X3aXPSQPN56E8EvSaG|LS(<^29sZ#n34PQyE2DPnP-P+mZdiQlIx zfA2J;mJqWnRN%0_nsy*FvCpmQddsjF*Y>+@LbU9niu`vKAXt~vHoB!2pBe(snLa2A9OW_l3Oq{#CaRi`jCv3SsZkG7TB{Uu2!lg>G@{KN=a1=W zd=p3MHq;U20nFp&tnj$^M%u!??2Yg}S$Ok-=sXfKF@&pvHU1C2jL6dQv_?mB#*F~Eu>Uy6)fyxKt+}1_B)ad+jUcpP z9u^X%%Yq!<_N&RcSM`N6Q;I~98p^njuSbwcs^8_Ty+I7kaS3tn{(`zYR&*P`^Im%j z9%=V1dcd~V zNB_WBG(>;51SBM_F6>QCuFM*nDwo*MR0X|**rf@NgX$bWWSHueC&e-lgLHlazSj5r zE%MnR>4HHQ{=p_neN}t@b=xIo2@x~57BlCgc%D^m{@*Vjut<8{qsWlhW-wQt)-o6L z8vNiVT|Fy)Evoz|PEWZ>ILvyZ}Hrtjy3F z2RmgO6Byvi00?ZJXhkG*%kqiMMH|nXz+z0&h=g$4$JxYl(e`Xs%t(=NNxQk2QX|lzJA-!@VBuq8Kz9V1Vwayi^tdX4vPi-Z$))ZwiZ0Zj z`-ZI9*k=S1g}~s<03^wbNF^?G+M|VbxeGka<5pi@o#zq==d2oz>l@YWz)lV%8A%Dh zWY4dzZY!=qBnc|xMh25w7L&%13U`e1al#dDZZX`pa2b7zFb@T#WD8}sv5BOCYY!#V ztx|Rq7!;JJNX(YTywi&HG}n|bSrd$NR*bR^f4XgYcar>?yHmRU zjoL!T`)ejRwAH4#RguW+K|c^DBA&xOULo!M`Dvp`*gKP~2k)}|9q*KyU**ZzsQZ|A zqW;7qAaS(YZfnW3e#zw+aAX0Tfk(;c<;3?rb9Oh$h}HKxUil z>TL8(wuFpfu8tvn&)bSlu!dtiVi^Uko zg@tiE#zs|mDXYP1sAG|93yT&_LTMq;d!KaaQ>qA&l)YllYc=}PIGx{$LDIHaaL^pQ zsyGc%cr!ElhD~7`MlMA_I-`SUX-|tagZIQ+Y|xo^hKHH8OJr7drOt znx>-irTZR`{`+d%lxh7>QTPCTG5Ys$mdVnX?tcKQb#UiATZObg((>U;^2Q}8{mz}g zpX@uO!bd7F<1=23SfL;9)xbq7i(~x25jxm^V-5d}*$}p6fJH<^9-!V44y&i4>Rnj!R0~RUw!@=e+kxpi zQ30zuEM26FLj+Vil6^t?7p2TB8oIoZW{JbzfSe%X6luOJy^63-upZF7&eH4045#=p+eo>Z(Q4{Fmiovx>)JatHTnt|HM zRNQG*iE`PMm*gIhDqI}pr_pd&LlNSob*H){*5B&5E3?s1c|5!l2f9iXrvBzDd4JKH z=UHhKR)Z?4FV}6jK=8Z*qsb}aqNxHeQuVC{HGdlWL@_AnFT62O#ru4ci2ju5VS=w9 z3lEBB+gd&Q94o_MVG%_I;QG>Cf)002GAA5aPCQS|SY^zVj_m;eC%H}?tB z{)14%!3uwXH`(Im?4e@`TO82uJ+kHxFn1IfdEfIJ_BzQ_ZYTI+nY$t2Yke5 zjpE+F1)mT6hC$Tc^K%b~fhz*g0cI1(2~S)(Uaj(2 zT@HIvjhRmj+nGixk!1tO%u+`(+*1kj_UymWmwnQejB3-;*0N7QZ6e8>-kZhii%l z=t0#SiRX%H=aB_Vj~vy=S?_1CDQnw|n|YS8{GaT|gKOLQjT(wgTWV%(;8Bbfgk8{b zrcDvsw#&gF5tJ;wVmb0qP>|}^!RV|6-puv~dGCYD_@+eAQ-Uj&EH)q}+R#q#T*hxO z3k~M;N&hE>ByJ?^={w*I`XKTeFoNu9ryCF`@R_3Bs#3K%O&~eX7M1pD#I+x|F86AH ziHCmzJR+~JZhc^MC5tM zIm?&nxGyI`rQuUeAmP?i@v+IZwxqS=Q+1A(@f9-X)rff*O>m-D#FbgY)>)FuM**W~ z`X?lEm{&GnC)q3)Gr0Y+NuK@7_Mm)JOCEW2rikDTf>rU24)V3Qw~xx}nZ4^lzzl@E ze63d;CO+;=fR^ODZc|XZD`|Ra5Y88_G!=}bQNwAOfdS_zNA(vp!^WC;d-!4!qZ(=d zY>@Nif4(cVlHyB5>DF>N?~NVhy@U?(t3%B#`I%Rdd&Dg={RDY+>C!m+(B7z@5|{I3 zA4jW0QR`N6RG5lYy_}l%=8kc~5)F}`5YLZ^$wmi!Jy`btw;aImSr!On)apIY&iLRM z$(bfdny<+E1=;%8br3dlV5v-eeWYy-1FXDNT^0QLAQ|ArxZ+*3b!kN;?bTKd4e`p# zEvYHn2haH-^(wrBU%t=w0Y=BkZ-I=S;F&`^k;>mQ#C@no5awpY{o4_cMUSh4>bYQ; zHHem3-1`vI6OEwbfFJY0w0UuH^z51&YdU-!$y^Kihl!%r7NX+f2IC04m}M=!Z@eb~ zBQ8rbFt1ntO6Ct)R@*oB&vZ0Kh! z8l)U9dq)p`OGHJ%uubS~Ay4IIyOtQTDw_&Mq^^7PmJ`SC|IM1WiU#r{kZl8P09S;EPqJRT$(I{j^` zFq?H8R^&M*2=SP^C<70ofsC(!7*xYcG*J)M0Evv+D{f#WBk9s4x%aj>2n%+FTZ|tT z&7LqS;|59>adh)Q7>K^nL>Rp|+{jcstc=E*BD3Q7?eL(X^PDs+b9}C1cm&N8g_R&0 z#-!-pVIZ!)WS9{*tg;B@$s@+!|H@>rA+W_=<$)08Hy~Uhjkzop#OF^VwOMp5nRMU& zo?T?U(t$#wu!6%eY!*f&b3;i6UKW)6jE4p0zOZl_2PyrCMa+XERNZZS54nhH7x%?8ehW8yb8|g8CFLJB4kQ=W>UgvOT67?y)!L>XsC=ayQXILCt*q z6LGa_In8!n&zz3FdW$^CvG=Vp#vxs5n0n0EzU%~4JRl6WXh?-;8G*AlG;V2EVJkG3T6HlyVb*-WYo zkGSuAd*c~V6}}lJhHb16O|1uEdBHp-7lmFe-mT@dwBNrzD<8)7Em>eC!b(Edo_r~K za0d6Lh>RnLvGH8gVftLYE)}sPMc;JoZk@IXsy;G=_NH}sp*OmWbjG|d_aq?`Wh*RB z;8KqPim_{t`RD#H5|Vqt(8M;3Dw-SF-xNG{out zxBGiF_h8_4p zkA3njc_3GSrZy)mDYeWtjXh7oz{kNnGrw1HcWf-Ay1qLf5Tw)*v11sz!MXB2nnsVl z%oubO1@6sj{U2uiP?UWG+$U~*ijh`EdQ7Wbj^ZqTk1%y*0N8?0{&g_k%Lk!(5$C2#NPtU7~$ zSWeJ-%)*0iMZB~NmANvVY{$$C-XUX~$EEcJSIOvJOrQ8H?jUHt&;SO%q=AxU&DV<& zx^GJ7G$_j+NhwuGdNL}HF{h8CYG)w_X&LYG7O@2bQ6PJfl&#{j@--hCJ0ccDG1upa zPOyqHOKPlW;WAjEVE|F#woJs}1SWuna48&x1;|@yifknsuF~!s@ut82-k{ zxxV9gVtL+g~$aWaUXejt|x3ROMx!68ZX3! zaV2UujD`t9l!?}cml4ERe31?D0wKj$#N&AB6|NYT)t3nRUDCBeEu_R!O0UkF(rTVR z&`SSW4+0P%0MSFBmG}(l4zC+isD00H@PvqlplD~=y2N`Kc#1w< z;zvfYA4lNn}+(; zL91cyk;r!J8D(Xh@p!{3#%4VEmU&l@e|?&vtS^y4ELJ68aqB6ABUZq8o)Z?b^-X?1 zf*IMa2F5|-%Rx2xHcK>=!_I$FQuaR0Up|`}Yn+EYz zDCY9b;^A%gXXvo`cc<`%I4#vlrUC$q5QZScPXJy@!+>+;STGL)RcWx}13C4Xw{l?LD&3%&6nFkt zqnq_mlUpW1y^Nr@G<-&@w1(1qjQK9sOqxKtTe~$+D<}|yv-2jNq{faw#Yr%qe;3?R zA4XZM7HVR$Hz1dY+1Z)V0A)Yo2;S5DLcCn#p8T2mSaM(vC0%fS3ifLCAxqA9^vbid zX|o{Bn)J`U))uQ4CE#pBx7T~)%P&~Gw&Cn?pJMFBj)nJ|HYJkvI{J_I4IwM%T8UiF|pH4kC=6-)x&CK+QeFBh~$vWNEv zd+)W_)WuA)>XTTTIatA(V*si&9Bd&1W?#w#@(>=0Ac+Rr!Ca5A54w5d&5+?-Q7X1! z{F1s*Ze4{ zsq4kZO+H`xX!ro-U5po%8DXctu(6UFxSyH_O__^7Jvub^m$?*>+;{d5(z1Z!tncP5 zTZ|vP@pJr!99wQnC9si8`@CwgjYn6JbUxC)itpfl>M@GYK*RujyEP;+)f?JISWHJ6 zl#R6vr+Brefw?WHo0c~kbh!MMHBBQvrTcX1T6Q-{fh2-w0?3od2BSDDJ$fS$ZTbd% z{?ZaX&Sf6J*ti6@V~s6Oiq%0q3n0b?1DH^&jAIK=JMAFS1=HGbEW>#s%Ack#)BtE= zoIDI@Tj(6n1A5SN`L@$sMB(iJQFI>uRQ-P(zngoFu6tdZF0OSiy7ma&Ywzq8b?p(d zl2zS%U3*YQFZLTeEKKn@IQqYhUGTgYkj<)h7#rOr_G0br+gid zCXc3{+KFKk_p48V${RRqu&hDu_@9@i8CPU*k|^4MnF=4lE0RJ{Zy=Ou=hegX++A~rBjO|~7q4!+*u5fJ zCG)Biz!NtDWWGjg+o5_%*LJ;^=JSsy2=j7FNo}o5-&DXERGn+xJcoF{PKL;Sx_Ke= zrfHCfuf!k>BI4KYyji?1!|y2VotqV0XCdE*`#vjUBVrcAs9h{Xg^DO4-YQ*rsVsG_ zadUP5Ug7QDx-YeZEh`=kV$%5aKKvsZVb4}j=uEH7EA>x^m>seO6BxF0L(Aa9xuj|l zhVOyRZJnt9{+c*GxY*ymaXh-d+M{{WR~zP6?~+g@aN(2hDLnR0g#!tJ!*J6zcpy(Y zQv~%I!^CLe*0@v7o@53@;OBKA86XcEJDR_ita`{##&Szrm-6Ojr=I0ciw!y}4!N>m zmsucm`g_gkC{o^JD%5%u?P1O@v7Y8uNz$g07WgZ9AsC#gR#DDY{U`AL$hnRl zUGiMsUmpa}Z9^d!-+!6rKCw#{v=h`RamEb zD})~xIPpI4^7F5f(&591&k^SY}ZLNBW!BI0al}NugoG zX2rUgK%r!B1_J@P*127BW&U_(@%Uu(*_WZqEy3pu&Qf|d|3d|MZ%KfomrxXfp*-(R zPLWN8Fn8Xkr1A3=8lQfMFe+nZO%5=p6hbb%z8UsYq6T(L@k&d`*6KnRnm=@Fv8v_c zT3PG8mOCv4V&!(>>Be)f@yowcT4C^riR0aTCEdWLj_t5^ z)ZnA`)9Spe_7&;G@71Tii{OWHKREPNltI3Zqp#@TyG_fl+Q07mYLAAP3bLB`8$R}! za=bZdK-nWNn#3g(j4GXmnXc4{b+b?@9AqeXTD(#;3h^A76ix=NWbx`=Eelv zYHWkA7?h*`CT+RYAxvfm;qVjIz`XYPc?3@%?;sp&F0|)VkWuSa5IWWY5bFSNHIqIM z|Hr#)j#{?yob(reLpuC@0w5=~yhEK>QUn@7C=RRZ-e*6oE!FwotqT^Z7jC(E#pl*! zaSCFvGA6=89ykvYsIeQIcR2K@br(T3?>=uO?ymTtH&#Q=ZZ%!sA;(%;rdx(NoNMUa zY3fZ?XM<+Wwd9_?R2=?q+(eqOV#3O~9xj+Ru+5aTrd-m({#blw2I0slur44azX>?p5q!cCF5#{tI& zWIT22lOMB(*dIw)5Pgkm`~a80%uZ08-0QHma06xjJ1^tQmO01f3y6|ih@81sWCgXE z4I8yq;301W*oD7 zqb`@S4FLd|B+{3EA8m6rvaVf(_C_&dqOzyu@z3AG#J>>=w}*NH z$Z??}h6VVA!?8cI1qY1)pChuW)co(wa^lB3D#;3Oa};_$z`DMWlmV?saHsxrqo3FqyhG4{6J0HMv|QC>~=;XCdU-w_*8Dnn!V*Cz;T7w%uM z@RN=%%2Oyl^Q-ve-Lfi$NlUdr>4@@h#wW?TDX9Q#IZ!wVD9lI^7%zkfqC(PErQCVn zPyJN55@9^p6*?O$`zX3vxliv7@xrm53m>Bw!y7H@2k)(lkg~RH>Ij{khW)9TOFTNLVSDv3BgOddJ< zPSDljx)1lbO@ETa`gmM^#`ms``o)bea*#>=K+b7=b~=97?y=ShnKS#D|4zDJqPymf zrpr5@ConQkNoHAj58MLTuDjV<=zmy<6FRHs60&|$+$*mh16TvV)BW=qN#0fs`KK&C zm)TYXV@T#Gc?DFTITI<{YyZx4AXOW?HMO@fott@5%zN&q@5r${iDc>lAH#19Ynokd zo#7y3S!89WN!}bxAD5NX1T3`EoSCxCniN##7{nfJX5*$dAb~bHv zTj8`XKJR+xTwf~Yq)RgjE1QfIR%N7nNE>@r>8buOFTj}>t$9@)*k^7q@4jpqyFevcXHZ{N?fLQ+zb+S^cPSblPn><+%&T@X zTj*8Sk8Svu0O%qyaIzjQ;b{}85t=C-c=sSDsx8EUsU)7Jd+|u89rtz)v=#rn^lG8~ zT`wLm+q_^d_;IC4@a5+xgL!XXa+Uo4b}wT3KNDI`wvc!f!P^Ljq3?xgP)&yAi^AvncbAbxx(VSviajUbJ zLEMAA%XGwKGYj2DG+=3*z!IzhfSGp>DWn2Li)XhsKcg~?v2K0%DC^VO?RF^M8`7* zkZRnS!$Q8?s`zGBsn65qHbu1wZLZzB|BZG-&bmyR=k)#YOt$G-eB8~W_2xU^SzXx` zaJcqL_DN-(^=rP>4@j^KP-f%8Mp)lg<3r_#J3W6|b2Ycxs(CcrB56(9>6*W$$VtDS zbGr0njkI2_CFVT-^R?;plY@WfMb&}e#Zcy0#9$dFx74evvG7W%&)|t|L~p-zb{n#m z`;|{tP3z-&d=~zr7ZM!onkh{UyXE=9v zlocb)B~5_P)^UeRC!X*ZTB}VM-!0@Zv>*EEMG!iSq-%eunYD%NGamn^&$V!`5#DIX zo*jJOVj4>gz>L5#_Gv#hd8O6V3_y zxefZHw_^(wGdzu@MIs?f1}JiY+eeRukg=|A4Yliv*_hcGQNl}REt0!d-drcMr_AS6 zgLXP5LmNQw+3o)^(WdxQ3jXAILHGR#0)-~+#`wM}$d(WFPUCwyz<6J(pcjxKkeofN z*`+cO2UD+-oS3)uRaIqiYvQAig?Y-RzGjelZ}~!<_t}|$7?Q`dqkSi1o~ylAG~M!tBVgUHYug+nEpjUZUpMSq~ zKR!UM^!h7Wca?aX4H<44(LYY{VJHOUD0)%o%XoF|e(_IA8Pfg7%7}MVsOhOQI)>}k zF%JZS!%ZuW*xFDHaPuzZEJ+0Q*ES6&qcUb;M=#iD^Z^~CP{6MT3qJLHttI9(SO;1< zLUCA{9fN)uM=VNM#>u^(-?W{Yo&Ac4K#*2~K;f$FlZiTwpEb7Zk7}iBX*}Dd`(ToMC820gZcDRaPta;&vF;$}iqLpkuyV$$-FR^vqLpxHLym-*e1J zf<8N}B`cR3{ew^i>x5p~f7wYZ(BskJ*1ky%*IJ}LL7k)F|*M2a6jFQLDq z@W=oIz;I!m0Nfbr&LQ*?8GMnyX3*?010MrI{UjaAuhgaQ-zTV%o|^1jdU7xk0`s&V z;%)3m1|S%)0_}#j)O!wTI6Dr+xTvXEz7MAwxoOT_NkSp-6Ww*o@_gL zGn*F<4nK?Pty2h@8&llhpDmBR`Kai;{vs7QVuXlvKEYwMpa6Fql#){Mu}`-~E13$* zTSjJv-SY|Vi1SspoN2u`))fA9q1gEGe1MiTuUNe7NaIDBya1e)@9}4yBtx5!k-a(5 zE>MN=&Az*eV5OIHvL2@IIqo@k`UGmbVC`I>BO$xdkAlt%$}C({7Hq5yj~*)~{I(hM zv;ajChcK<`0mVeiO$th*&4Y7oa?adTp=gz24pKLCk)3VOuJ*=Hq$m)6O0vqq|*d`9iw>P7W2>dT}IrG9) z{iF%k%Lvyy7MM!e3G0Y}OEoavSL523WEI8N# zUS@~2<{f^b2N_ufcir3Z1!juUh=nSH4nth1Qb`7`h++R?C?d_Md^PE#q(j?kB>t*K zq)dJljS`L;^!(IZ^9*CmR))=9mi=p|W%FErvuw-p1i{p9XO~`h$(!C4ui;FKcdbP^ zKOiOExKB;Ea^~^em%?vuE7WZn_w{po7s%jjyDt?dE~i-D`r{<=x z!}!LU$0Bh+t}9@abdtM%kV~f4{%%=MH;$B|0wc7NUG7RjYJdP?K$~xMs-A+ht7Lu? zonsZ+n0dje4#8P&9Z9i$8k+UAk=q*te%5LIp>Ek*>b*+Rq>s933nP5P&{usbo4kha zJoJ#(^m&){dRuc?ezFlX?MB|}Hq@@4+=`O@FDW%dH;<5z{4sa<YJeqIfG z)DfoG6=wU1anl<3`aKtho9*~J1M{!ps9^QYzh|t4Ft#kE63?pau7Y42e@0QRC^lYc zJa&~b6s>4I;6lC*$Q^*J{xRI9nl{(sd^k{(2X@;ui4Bg-J?ZCn=s4bgy|8zOK_IRk z5Km(jPj|-14Fbu2%HjNyW3-#4lx4pfK>{DGOz(Y z%BhI=b*jqAzIttH`)tljYa7}*Suc4F%6J2MR(YyEwfOA@EX(I2ngY8fISM$lt@j-U? zgIW2QDF8r^BHzl_aBzCmC7ocKi=j|c*n4(G?9@iY)3cLecP-zGFyDtL6AwTi7EMZpDMTX zfy=ao3_r%LGI&k9Mo;7eH=OyTlnSFeJWp?U?y?MPmg79UmXQ+2M(yt^+&RDCF;dn; z=Lq^Q0bdDbXCQC2^djvYwOJrtV~{o$WG4y6EyvaPzIqRTynh5;D`1*Ao_sp5*|+)= zu+4g@=t zLm_yzd(hi(lHy7)Xw1?|8bahL+q3bD{f&5mEOiM~9o2?UT6xs+|LqG5gH( z(E)?cfAhR>1G-uc8Q;n=?cF&@RnUAKu^Zbt?w~*o}fvy=fqnYMgB77yZkuoyHo2@E~sojjQH&X zjKcAPNp(fd*4QBqL<((=@v44-*~%44XYQ#=+6sGzs`*M_WCJ5SD#8DKaeyeisi>uW!3my zH|pv?O=fetuS8E_H^&_fTGd>?Q0;Vq`Dc-CV}Le4V$AtAx5PS~GT+<;Q_UVeq8q^Vjw*+WNgX83Eqx;gW*j+7 zj2v3F(q7_@P0W3)7>E8?&;-N;!TLUx9U=ibk44*s=1>tprfJy^cu(RDTKW!RngAY4f`}Bg4 z@TZW5THCNp89#1WKg0A#vUcYpL@s5IWLs*?3*a5t*^Hwh9!(d7AgI`L;djMO^Elpr zs$5EST--a+ksXmEFY)Z@Gu2%QuA0K9*=p(y8wtgjeNt2fv$eU`Z<3t4Mq7d)o+?rP$ubIN$$1TK^WT_$LTn?Z8{Q{8%RVqvIZb%}~1SXU0j*Y>%hs zpSCoK3V0sw%8#^9HJ>?m6xG!&3CW~s3< zH!T9{54-JlL2{Ick8}Ki3BnLU8M`((o%4u!T4^)t`yn40)}Z#;%$#Gvmf}5IaM0Fp@+6j98fJVcgqwaL^ z_44TFN$M$%dMT)cj-Qm}rS49z=F=`t092@d!*%M5SQ&mF(h~-i;Nop|#3HPUC^U$^ zSbb32{;FS~2BgTD{thJjPo4PKA5Kz(>Ev?Zm)|9Dqm^kHT0LIMG~ScAd=nAHTQs3&UWPq+0`d_36-;rJKEovF79^@R@nQp8-I5C}nmS*@)Qt!A;tY8x6xs7! zch)r$Hj6QjDQ2C3;_6IJiuUuE#eKG*@q>QOg7NFAj2OEQsMP*auMa``wGY{=Ui_VM zV>RWIR*t%o6DFddxnSzV%&S$dTZ1jMyJj^je)LU;U)vp^vJ0_EF6-gTkd63mwUGYr zAA-Kk^*xc=6Cl^L15O`*x!PkQo4xSw>GMYx=8uarVei*9l9z|RJj9od*Rj=^7d6oc;w|_Gan|ku_ zsHUM@CU=Y8WVL}zrha-Rq0aGA{H0UBlRtr0iUik4Hu1F|{kcG&TdgMM(#!9-0|`ag z`L}`HnOR0pUX*%L|FrpV#7$fmd)3owk9YsN(Aa;!&u|&~7;nfLf3!dD`}wMo%j=vI z_ayh?tK^gj(@GCOiEb-2$)A%K1o)M-9b;n^?ej6V%Ym-;Qy;H)KjL?ZF#Z#6!Y=uZ zG}h(r5=b6>c;pEqsAvb0J^<)~o3JlFL(d^Bt|Jgv0)Sm*)Uc zPrn$1_I4)yC^R!RNX&TsOrPSZb03*UFejV2Nsc)y^ZBvH-b9%EbAG_S^d@V&TK{xu zKJLr?*KZY00uB!dY_V=zK$-7_6_Y<#FPwh$`k9|hKI#v|Zb{+E_G`34T+-KacHGwr z)V8CO{S||sn9tXVf%emneBi^KfOj1rPrC5PpWY`jc19F+Z_I3#)Z?qo8Lw(JT6|m| zPj5W*F=|_=iOx#%G#sOpIqgMmH9T$!{HtdN-rVs0c=1vH#d+pg;`WzOKc=(ZE7G=U zQX`Pg(?o-Cp|X>am0~*4@7o&HZs!_jgl#PZA90^lTEzl`Qaj8V&Bq;@g;OpDyko1K z_2}9L6=qpq_voIRqdQu$h;mk$gT%I!|0<>K%i9;=f(xXr^Mv9<80Jn=#;M3()5buu z68LsK4sk6=N<=-tT$P{@mTggR*1Bv>D%?|m;DUrOaR7pt2gmz1C(v$2pPZ#_HEKACo}dYopMT7r0C%h5R2D zDL`s%e;GIfx*P9+V9K0XdcVxMZi#9C{HkCG;h2%OW*SH)#?GK|NiE}<$@_b-=yI$# zeK&)Dd$B3*PF1*9kNRT~;d+JBkjMIIFrKv-4o&CRuc1%9V=Gy=06e327T6jmFKLa~#M+MSM8e~%}cyi`OPIt_&cYCkMr=S zS}E238WQyG7B&^~8Q1wBk;srt^Ma(%XG&$-`HnpBLw%;Ep<;$ET zCHHrk%t>P{W;8&;@*xgpxcMSSyG1t)AM8*72UBSF>#J_3efiWa)pd5Fz6H-4&F4gfK|{0Jc8>hyUIc;HZa%E zCSd&fLOX{7&V<(;3`YX%h3uvvU`bX>*SHRc_{Dkmw2IhQ7A!Iu z!>8{L_AIOlUFs?Rl}|XZ!!T8_t!J+8#<_I9BeHhsLD~$&B4nyG?Qx%}*abg7`Kr)P z`)Sdex?4e}7sS58YQgRzBZfTs{L^(|3*KgxQb*N5Yl!+tl*W-rDe0Y3RwRh_i1g<2 zpkQ0SPoeEA$UOXW{eXL<6mo>O%yY+l`+|GTwdyithlYG-Iuk<=Wn%0$SZkcB0v%K? z&-200&aC;givG;PoD$-cc35k@#20WjL#6MLDI14QGC_g+AQnzc;bXY>5HX@qzp0u? z%7bQaWE|<`OseLQC7t5aOw9=mO1_bO-G~1+7CMqtmp`{bOskYzCH>_{#zG%;l zwRQx;L#wdGKq`A%8}+4p1f^>vmP9ob)NeP$*qlk>iFISja6{lH<8IJ+`L$$($VF8# zcIcuhhK#m1+|JFAzl;c#V@)EO)BC9gn+y`aeC*An8z(Th`%sM_c<0yhdPRmLk7Qkh zEMOXLtX?(P@+=K3+`or(?*Ls^HC5}#%vruxEzUQ{<=7-F+)S*+dk0^7{KspzpYcdl z{Dcih=+ccz4(F4wcC7TR<)^`4-_$0;#rZmA7^a&(&>d zx;&7FC^9J0d)LV@3_%_RBkl-kZgC6V#T`W7j21qWDB)FUNdFS5ruSWMA1Nctm zVLDQ5gc8ozt7X%=!`+kxr-q>Cboxoy7s_#c=ZP}%<1&`4QfVm_2vgF$UY=$_C+?j? z{v5}cRGW}u)3T)d{Dvme+g~jV^FNIDjc`yYrva9=o3SLj;^dK{mTdnk2fo--q+!dA zb>>z7p~u$LyS%j&{plp!lnY>2o%>Y~KgeVs={r#x1?P`XH)E0a@V&~D|VlL@}V&Mf}URf%HU!D^bq@32PR zunnim-yBr!-DCM1P?7|1I{P{+h6@ixpXQX}s5%I{r)ralGF(tZ*~)mhS=b9|f>M?` z=x3J7Dsds&%3}C=<&%(pQW9XW!SY_?K7Hp=nG;it8=@xlVBFl0t$Ei+rR8Pt0$7!Z z;%2IV1s=K$hesnIy?omB@&Gc3sPZ^-L5?=*Ms59#l%KWBIPkh?=3&@MKvp-3kHL3& zX+N%Jy=vJf7hg7z@7M@%Qs0$7=FbL8e4=hSz3t;U*g0XyCzG!;O6GAhzpZ<}Q)cr@ zaIKFN+VcHk!Me_W@jr_S4L%pi(VOp_#wlE=)q@%^zF&HBfjLlZJ~REt!|BQQfm{E+ z@*@JTb_M`ti_JD0^k!t*T)11EZ99qSD;Oo-buoA)XeFg=;;a0)h@`De`X>Er5$4oR z7Y%_^STMEG^$pWSyn2wPlaDFxjy{rLGMKJasB1_vjGj&GykVop4*VSjw zh_KQoa+HYb#8`@xq1q}n~hQkeah5bSWtPcP;;k2VWLDe-;}@LI>OcDP!#C>( z;aLDUfnz^eNd78JjrtmLNC-O}mTCVyz)7qgTaV>unsYP+UD5DAl|?a@e)@0pyL2D} z)6G&=HYal<#kSomI^TSLK9>A8aOpT>86{~BEc|)yP@UwD|C8$d?>oPYF-ES)5WMHz zJOz<5^$E0h3eES?HWu7aCHD3s^pL&`6=#Du0(H?mk0Jt_6D9Hl&nx_)YQW1dY;Ga=L`%%c{)C-)zKy+GooMxaS zFg7UdQe2u4NkkH0X(t)PcGBW0@EF43P0$X4GeLQQ+W+wf1B*Fx(clCZh76yya#Y3} zh?3SmzYZt_LgSG0P9FwA9eqx^ta|pM54}Fspm*2K2N)v1;zBPNhV}`b9gEeM=d&}~ zOPVT?uTZ@_?{fK>DKeoTY)R|Xa*6%FS{O?T_3pbXhdO#w^Xk|84vIsF6&FSCi1>xh z3xWZT<#)Z|LJ>#p630dnURWv_Lkr?lE@b*xmLm(<&j>mkKT820R94VM0@JPdfvcvhnx7 z`G|VkXl96&{In>UjV}*^lsg_GlduWO_oN&gWIBI%PD8!spn83G6NinEi$i*EzRAAz z4_(nX3-eAIC6hTYm)Sz+`nwIpYYA@x6<*b4^RI*nSE_@bpBa0H!qrlChAE!~I1}46 zCQV`A1e_rCim-Yezzc7@pVEeq2ZtO@-I%83N89>=F(x7nr)YobB{Tvev-6G5^?|`E zps^JohmIfNwu`!(BIL+-(sn~)(NDi##nIFs8|^>bR|J{%w-`0Eyf0oy4uIH$5-Q=2 zLhc1ADfhk=5J<y%F{vTu-H}wN=o4%sX*Ntq{itFc|Iw(5vg2r`mJHRGz}u0K>5_w??6nbuxc3$H5VKq8(3P@k-9|LB#`V7= zJX`p>KrEy%-jSo*8xhplj7(@ILfICGzU$-bWI$0y9kMvb_amLEir8${@ zq6$VOK{qA+@FU{4j|w^?{h*Nn=BecZzA8FtfEXyl;1VLz*i>o&F&JMy_Q1@-)s5du z#CF3CIfRfRrpQFJF;)UR>MK0Wk;O3;|V&;_|_UDf^JV{ zv4i4SY*Q?M&5F69j&rUOCp2lsu(B4RW(QWKM%acp?WQ|%y9%CL;RNp;x0f+C8m#@c zpC^1NFJU@zv`_dZFd307($Fq);*x)Mbvsx(|K6ToD1V26E1-1H^$PT6Vo$k}RXZ+T zu#69IWsXDFU(6Quf)c!!7(-J>Bp)dZp$YmkdlBYn85zw&Flqh z`;P?|Rlp~i;3A};$}$N28hi=>F#tf09k5UTvY6?}Fe5=Gs`xG0M|?=1%`U`x7b1B8 zv6+GtX`w8c!JbVS6(53)S&zYy9FWEuU_bQUOV40YFCLL=^Cos7c6tSV)#FB4eF>F+ z=B}YGrjOE^A8(wU(cgXUJ=m5-Vx^rsN?R4gOrQ|0Gbjyn)(`IN9xZS z?-LV)s8PY=BT4FOcQhew1Q+nh8VGnA3?2eY{!r#6q$KJv>F~;LXFeD%n3l!W3 z9%lnlBoRR=kQ52@(i-&U^~8rtiF)CvcY0CMX7CT$ljHdW2=f!A7sG~g+Q%3%rBPwvVp)rlV0Ul zQ78a#eh=WKE*@vCC5Zg( zWH@RA%C$cbj(;?pk@1=#D1o_`mQUpLs2x437g*D4R(oppWChr8VvTee!c;)XA4oNd zC=EcnOn3Vmk(^cX^LU2==dgL#zb8*BUT`@WfY4xG*!xBYdKVMr{Fpaf6!F1csEh39 zPjwB(Up8+He7F#C?x+6?_k)s`J(XUs&qPxxMOJ9P0}@FC@5fp=;l~g2qJI88{Dyz= z^e^zKWhrj!ISJdj8!d|!6^AQAS5~U^XHi#P*7J;!mXhRPA6vy=38a_Ye&*iYlGorK z`EBM^b4DaIAC$mX!9GuHDJQ~83aq~jMjdV*J6J)M_urbcD)3lc&$wwy@_s)= zuAt%sQ6TEOgxX7RsnzKV0yA2zS0blDvNm4`JMVaMzZ7tc7;R09#3XwYHtp zodTOx>^ZkLUY~6QBpm9|NL~2`5*~IN#M?Un$cZJ%&l zyA%l`v>6%g`XF_X5B}2l1?j@8wRN7^qI{a8eZUq;pded>R+L)fJ%MR@J+rcXjJ-Xz z1K`FYDzS+9o2DVgYD|^TF@(V9)EKw*G+r-8dn>vapXLkmpTa8E zb3hS{l#5s;4~kI^nvM~!_v?w-oB3l^dg95GbhxAEc%(|=cSgMQJ>rf3rrn3&Lyf!x zhp890+NKR|=l;-%TI-dGvFRNkxZpqO5v1({50Iu5e%} zQ5m(xF}vinB3so>WwGtXqkv7#E&FOA9DF84NEx!6mKs6Db6D91&5WUJo8bGn)B^I<+VmPhls50_t_l~|@xmEOU_FfGt!bgT9|rlT_V zYZl%kr%EoTx>Bp7EBx{2$BB8p-=>W69({YAd?K{%Rog)BpVf#~;^Ce1k0aQ1kBCGv zRQPp&-*Zr;e3726w@HrhcI)+HDwP{3#4Tg7{J|UeV}`G>J~LvnLXP`lY2u_ODkI`| z-UO`*)5TOdxY5Dp`Mjb&Gh^tQ=F3mnlY7 zkj_q#L=orr*LVLr1;oTmIoOgZ(~byA4A4fj>?kmR-}O-Ks_2$#GF)o+>+HG_=HeWe zosBd2pL<0y*aNx??v`3^<^D;a@HM&J6E#QJlHlC>Qc}DY^iI5C%lVyp z@W*|M(ZyfAn96O5PF~`~iLD^zLPoB?6np<3sX51EWWrTrw6&YMGs>a2lMhZDH`VNH zP%@x28HsLD(lnRNPE~9iBj`Rb0Pv)1#jc&93(R5JCZm^~rIUrp!Yl*zH7?5=hW;e} z>nQth6;1N=y4%+Rfk&ff3;^C6**ZP>yBitN8C*;T4&FhLhc{77^2Jmi%DGEfc0WX2 z>try|c7+(=0@YZ4uUTbC8M5v>#3A3-tEuxjKKih+u%bCo-+p)V^ebt8kmdBFPqY)5 z4Zk6XdhugdS#?LiXpxcjlvKp4gg}!nL{RtCDqDNhHHE)<@Z%P(iL0jiSoI2)Ic@rP16J zQ?t7O#^Vmug=)4*h-4Ey;Z^;^4#s!uWnjL=WcR@3Yh?!cW-rTpY=b+&d4Gq_NEPO; z&zMLRr3Cq#mi9jrSxreD-!2Z~w+XT>EUS0~)9ip7OKi#Jt4&Sk+HmnkumoUhxImr6 z4esaL&&7RZgJZyAQzQ;InI_&EZR&9hxixm304Z38 z;IJ+haC))74bo$#ie`f2o0T=pSJu9G-ts6)p_G|r@}XeNPB__0n*Uq}NFv59_!<;3N<|0qE-3ZIg>U!_4`v^XBdpT;~Q3SP>sJyn=Ft(C6uLEWe8=KcI26lSi z0E<1X_Hepurk7guU(##X{o4Ymr!1=@ykkzfJIZd{Z;0_XLb{&cRG&Y~n@?A&6!m)8 zx)D|OhnCfG##;A00XNZ0i*wX+5gOMdWlr&PUz@85y#|239)!TxvY2nZ936|XL3<)T z@K}5v=yMF3ddpRgbKx$Vd->9LkO_h+P3lYYa01%pQ`0#~QU#!{H!nwkvElts_@Ef5 zUg-4SjD@RW=8+>Om!}UwsZKS0RePYkV!GAA-YLx>PN^wjC9bFZqvTYduC8K7iDdwh zu-EXOo?ux1TI1<%NTq#|U6BH5y@6xO@S>Qivy0y#7_%|h#wq*0rI|Wz$j`-h_fdeR zStx8a!Rj76(eYu7-R;QrO-mmpsI$D5V7Fp1_4&8SBUqXL<%^?&B6KLc;aE=QbNByV z%8L%a@0(>hiwMgmj@>l$m&a!oa8P7M+}%4lhb@F`C&4r0SrO zwTI#>_G)UXE|PtRY?zlS8A_x)v5mV5mLT>aaQ(^O+v$()8PSUB8StMsPeN8=(s8&b z_`$#1D4uCH&?)jLP!MzIfKGgNpM%Igy>R~u8cG#~Ql+6+5%ourQL$yzR8sxY4Sp`) z6dNSk*)HW$!L9V){8@Kz9eZ^vdxpv*q~f;OeQX1O{HZMvt#Wp{IKQyH;31;up^60+ zf!=^%JCAd_^|aYSMjFNOe;W@idCtabXJ;0R(t>B;9u>s%``bNRRwi;d6SgH zE>I$_%jYnJ{y>F(wj*Qg*IlAiXDU1Sb}4A7Nw|MTxWc=7&`^&>t4^)Yd1Y5d92M0A z@#^lbw`Uv1ymPqwo6<& zKf3MB=5T8J{Ocy&F-2KL-i1izt|?OUPC0FNbYLlf@Yd6wDS_l z$U`BlfExDP19E{6)^D@pswAh*cBWNZol#Ij$;`m@M5)mHs#A&`xsi|gJXFiHOP;z5 zj&2xMHL2p8N|bfLFQa-=7fuZzOT`C!TJ{kF@>X{Y?}qx`Jy~>ZZY=#e_(WHaPVVM4 zL&ZUH5^8{xj)>r7`EvI<3O{<+^mjG+376i`17mBaoWTShcI45~>7_;JEz$L!73djM z^`>IQo;_>aowGidVM?^`ql@EG3Ubef!^_r3F#t4G?sN z?)6Z=FY=Z)vHaXC^Lixsok(V7_3*p9rq8Ztd<^M2zCrsiu6%*kRlr%{H-kN{BffW- zP$z^%)klZ}ycyp>_g_x`kG!*bi|P&6@FdKT!!V@8(A^=;&_hahcZsATqQ9A;d+0{G z1f@kl96D9HL0UvmP*hAd``UlTUI*)V9evmOp7(w3yEdc>xyc2*^Q#tteI73f-0>TI z3j?TXW#4q;xNk5HjKMwd$C4?uB%%~PyGiN}kYL$~aC#HK1nv##n(-)^Bs}n2U!Tf$ z0%fVe7?_(Mt5FCMVr?48EN*2TN^T-hz(EL|BV<8J1LtokbPS}*v6k9gxl~xYmMZ@- zGQV7ji3>p}DKNNqKKK|zkj%-iGzCdt02Jm7>WQ(x0%6SP0uI*p=8qKDVtWzQ_6~Fw z6G^Kr%n(iAMkEfV(GBOV;CWmVkGBna+S!IlA``13SWl>mf~;xJ@QiE6i05>9Qio}5 z;p$g{IUCK?)q<#1N%75=G?{H9fIi!^5WFOb_Cm1vN6W~ZqnLFSui+rCaj2>sOxLye z_1eab4cXCG`h0I-Q1N=AL9{9W1IB$IQb;!5Eu8dc1Jz&D9E82eKVo}z#`at~sY8Ul zYF|sOufO$4qMVFGtpdat2#^8-{=-|>!k_@mvjFZ}-`n*rhsJ&usfV~j|9HfORifw+ z^l%R-QgA#<`Fccyi#vG>jU6Yy5Fj|ToUw4$Gvlykrc^?JA=5Zh96e33ApNicxy*8Kpq^3&lki)gh`xr#cwA$|1wiBX2E=gYwY#LAAt}2tqk;K zdz?Cq9C_3jSjS4r$CIbW!ipw0c(e{g6C%M$g4I%P7Pq2eWX@D(sEk|T8HQGJOLIEL zabl~|skbIEW~XVJM+C9)*PB#qHS-xSFP6c+AYm9pm^pTdPsu@y?f@lx=&9ybB%*8x z$8^wr;9)O5PAr^ZtxB>Xk_5E5y|i5*vB* z=N~HM5e~5exTX!zUiQmLjlZA0^7%W94{>V}z`l9$$ox%_Y34C9 z|8QuNR8RMLkWK;ImO8HGwb(BXCtUb*`gAYrMdU8vxIBn0riTiJooHhq(6p3ydgqbpgu zegd@++#2oPP&}(7CDgzC!vsf zU(mj)lQlp|;@4EC9HDFTnUYuBYmDrmNR!;4!wL!X@}a2pk;9E&4jO4H1Es8krD%O? z&N0=DbC{|E)>G$=&a#23$?J{xiNXUQ4fJ0P}1T5 z4Za1jwSaSMz`wHrI;dn(1dhi{M1+GQ9ipa zfu{x}8qiq(i0)O1X8FEk$RsZX-%*5urD1#5B8o}-O*NTpC-$z@_oG6J`huU*HybPM=M@ihNC&W}m)@dQy_? z=U04C(0DN7+|GQI#Y0R)n{FJHZ{60sbvd+ka{KlbEZc?~4?mK)(7{I59A01Z=HyQS zr)7A<9I_|x;&CWzR|SqS#6gn4q0|ce<>}q4H>dZuPHE0?TX{Yp3SbcxaK{AwVDvp6 zt5`3oc}9ic#o6mee!?aOefYN-=k1G#I$}z4%Rdbo!SqY_*DyBP@k;hopofP162&a; zP|*0rYPh$>e&#N^Bk=T2ZvW?kte~u#PubseU%4i+w|p4$j1wELXzMINCC@*m@lRm( zHwZr?e>=Yr+%ZA{kq-8+@275fb2hcMC)w@)qqW&aI)m=N807PXiHHo;y-q#(fLoQ# zwCdu_@2H*TU(b%byoO>WUGA$SP>5f`?+%D?krQ4%GALYO3z`-`a`?j5P1#!@uU73f zl9%Td0ke{n5GO-m$N2>n-#(DN`7`Wo{Z-0a8zf%7;`txTFlD!Qs%|*>IvZH3%TF)V z9q;7dUU9+VmsgSXCM>QIx+|Em8LCqStt$m`G9f3o3tgLAmpnny{jT-4vGJ~u>#CLC z?1l0BaOQ6p3|&EYhCeZ-`?5T;`>gw|BaO`=szSr#Teaq#&j4;~-_BlOjo|y);lIx! zX?FxZeXFywcD?DF|6R-UX4@SN3z;>-OIc8<^>BsxCB$ca(l$5=(WKnGE<5*K^;Em| z`@CXL@Ydv>=IajiwzMgS$9mGV$eZUaPd8d?X%|!EZaoa??AkZaJ>S&Ul^HzWMn!QZ z>m#?w>eA|eR3=Wsf^ujSCA7`-$`EIfm=Xv6l5Zf*hnBa zo|0A4H?iIgtV6@7?0T`i10JJAtHr~FG=~$jc(CQXZti?j^g>3}*boBpNl_DG@xm=k z;8t;?_jDqczIkS}m3Ug_kL9&z<(v(#;XYMM}en`l&!Lr%;pd$Apjh=EdEZ%&;o4EBd0RlAfOmyT4 z`@q^7;F(Nbd{45)Jv~w>qS(6D_C?ty1>>j9R#!N$8j;>M3VOy7(`E?? z99;vt?16|wkozfD0XHEaa(0BtsbfWl$#JF|%H*)!9ml9lhAcC#2qMA|wAxIUXUo8SR z=T}!5%bi?bnPo041S5u0fa+S-{DJknt`-0wOczu2d^dT89HfKxgVH0y5&yTNiZtuM z>)dc%z>_maT+qSdk`aUDLT-cCcvL6uY`lE4DLsuY8C}c^ZI;{|bI&%$LrWL;+(m1r zhYXB%UZuH9_GbN1n~?Dj5yQ^luLsL0`P(=hW1ngmz0s!lA+u+;nKz4fQOM!yi;6Vs zH9u@=gMIJQfg8nv9ehA1pc$#mLF`^9&HfBwGXbdRfbo+Cn&PV=d%SHkAq4LWL$ z$lX)htF3hP#vK`!oh8(0^u?ynJ0|yQo9};r{|b;VH1*9oQ>lMNk;l+fijCc%b6&X{ z#}c%%lMvD2mcY*2hL^md)-EN#s?&DFohtnXyO*YTTo8_aK%&C2iY3f$yvuIS;te(F zf%3h=h_+$veKtiH^Xx{K4)QJIKA>oxv^s-}Wa|~zOr=O9gHky@DS;~3A?`i=m4i8m z;!&O}h@aP0+P8xLGF%_fWT|=Im|I);10=^fS*W%!YE1}So@&t8P?r~a{cf`-2{nke z%md^)C0_oSe|VNujiQ1ywNQvH%xEEvtGU@$NbMz^nZ9^!-X&>xS4U^oyBECBe90@i zJN}i4VAKRmBh#s80h&}Jdm!{F$~eD8`o_~p=yfDYnR_vk_lR!HJ6wZ6BDUlc#0Fs2 zD{wd40Z9nUEh%I;m4`W?Fv++UDiS<^l*wjc?go&V%Q7+G-E~Crgi@a9J6Uq`~HNkV^lpxM_90jq8Q)`lGDIRD^Di> z(kZv0BLZ3kt$^^8jQ@t+VQpEtqO=H89_eo+6-qe*w20TVpnKRrd}kj1$&*Zuj*yup zIWmI>Jxy5t zFpGt_QFj^xX)Q|YX3NEX=dZ%9b03d;>|$8@ek4b94xHMCKtdMt@8zA9eva; zRr90c`xtYDw^47x1T<%T)PKKtE7lI5a;%lM4kV#(zy>LV5wR?;c0GDyqLidd)*MGvK+D>>|#;hIpf@&7$x#Yt_whLGy-N=Q`G!cwn z=fi%Z7W2HIH;T04Aqm||Vk-bLP1jgMSJ$iMtn~0|<7lD@KxG?5-cb&G`96lTLlVZ& zRm#+(%+c;nepYKDU|ErDT8&0Ec59D_eF{lge`?YQAhFzZ&Su-s4{R)06y_i$1AI4z z$j^d-z7Z6(&)5JFm5Zmx)nR2|C&~7;B-PstcP3vx<7f4OLi{yFpiKm8DRSJX9t|)9 zi~(`_=!~h;gk~-??JLv$4*xP0TeL#La$y9?yl3FBX;(1ZSa6WeIhgR{1qj5{{p#=Q z&kt=_!FeWm+wePfw7za`2_~6O{2qi)Qd_U6`e_;$p?~u10KHT4oqg?g;$A=T?HF^s zcZ>K_My?$D=uZ8_4eZtB#mVWp^3_i_4zh>U9Bl)OZMW!9!ml2Dm`GyK@9xX|)8)wX zdi%aW!R5QTB*#@S2`HEO@3TOi^o-4w^-~kb(?^nZCkdAco(kr)hvC*|Hz#SuEC{Ff z+Mw{3O88reIi~H{zponbSm)rE$xB05@(R>fHHm>WWq+#2ZUj3d0x@hW+CYv?wvK6{ zECpa3xIfML>ok^WA(p9xldLgOIF(Lh8}OAK`?D29TV0ZTNzGS{R$qj`PqI&pNneM{ z$yt+NMj6yw$v>`fR@;-Stpw!J_8&{!V9OvIf|Ia-F`fpHQCDPAS^u`vGd3E4)?!%^ zNo+#%v^sB^6FFGUT(&NWSAa&?u@n>NpR>TQ^T}Ul_Jv{GMsj?MCNmNJD^tVs+ZO$I zPFkb}vJu9katp+Ly`EvfewfU+FidXwIH8pQJaWrKbBk#2@^E86wGe1K(1cj@u-Wcg zG*X0(RKNh|Ar3Z0a1;J$Ofln8OWI5)y7bJu;n(iXP0rO0jj%{y-M4ql+@nlcZpj4| z(k$2&%g4-ZcrXWhc*{WdhQU-l{*$U3Q^&3lMwTnL^eYMoQ`OkW z5c@~Ps{LBRWbBHp1mAo9n5_)o#4tg&uVD=F(0hsB6DmLYhPZ6Ni*t;V5f`QGxX3^l zmIZx!0E_xkEJ%gmO65`o0W47MlOvf$axn7p$@FEO_jFv0o2m>yX)A(o+(<8ID}g(a zCjtd(K65qa9@Q z&x~icJ9sw@6!+KQZx~>WJoJh34(ZHdYXL?JBCj60F%$8u9RqZ41_B2813%>jmEu{M z$FH>SE=WE?g4N1a7a%wOafFxP{o87NyMVs&-XaY^rdZ(qS@lW7V7~D{s4LLp7a!+& zCd&8yT>~KM@o5FzVVvL-(nXa z3K)n2DKYB^RB!}BlafX=X7%~V7l!$> zNqh)1k0yp^%^1qdHHvQgd2~D*wu}-I=ev;m%)R)X=l&n6HJU#p8pwd$7NuSFEUm1F z-NJAy$H$Uk!_IWG4nC1fc2dMC&QWo;sSAe`q8q`^JXhi2yURhJ|Cna(<0vcvI>Q0ALBg+C#9qF%tRKxW8nN6j^T*DhQr&_os`X$Ppq}>t zRV=_^z@LuJ%;B~0GZ6I6Q~wui4##Zk)@nCsI>c#IKvy=EW_KX^67l$UB@x#1QD(k? zGwF|6X9ZlF=7N2AR&5|EypYb6|Bd_|`pClkk;k7|@W;*)8Gd&>RN+nvKn4Y%h@_`p zD=@=NXSm{Xw`}1rcb#2TZa_4!Sznhf*Y0nvePe^ep?6T2>{yE5@LjQHU4-de*OToE zdx}^R&E*bS7{q_HS@!eEt7ALQStkMJWa(TS{r8obYZ$u@dovL<0>ZqWot(;*eDjhG zu#!!&ZZXn-`)|R?Qt63FbyM-kNhu4eO07}jFUw1%<1J{(Rs^B?HU_r8mnn$j=_cVi zpIj3S51gO=GmyH~P=v=KdK#?g(w;W)n{{^quBg}?5WWK-iv)@S4C6B^57X4{IpJnC zI=sm&6$$4wdkt$mU^pe67!-Gs4Op=SaE|f1%i`NP@;-lA$wEl{5Z621$YT2Qfs=Y3 z&0?GpI~HjfiVoI*eHmth5TgHN@th{^Bz_URVS*kOBaE9b*k%MoT_DA+dp< z$T*mtz9C7!Kc@-ilm0E$RvFGYMb$>rey5v?RVs=5J@M<@MRtoO1_0pt2?3G=+5n{8*uI8Gr=2@=hTdft?tQFd=71^&BJFb^F zua~-Pl(}s@_Sh)*+^q24tn}Tg^4qEo*s8hxt~TghUGTg5(Cvn>?Z$}hC--)m?(Z~5 z?L3X%efDU#<-grl{9aqkUVH3*NBn+g!hToM!Sj@Z?$pB<>4!bU!`{sIecA6{<{tGI z9FdAX43vHtDmxx7KOU+4I9l^@yzXS8{$#T8bgJoey7~0=vrlhYKfP`HG}G~Uw(IlU z^Uw1y&K7#l7W>YY`p=h1=PQF>R!6?9kA2;k{JJ%L@$T)#&g{3{g=>%6_k-2%@7I4E zZCxI3Uw+*CdAk4e)8Wt0N59TK{5t>m>&xlyub+Njoc;dx<k$LFjV+qApeMz^S(dhus8MKMcjTT zZm;d(Zp(w+XLom+Lbe+Mw`zShtK2uroz_cj*NUvy3N2RiO;&OYm$USjGqsn}wU$yf z7E{z0lhqcJR2CAI7ZMZ~;&0B!$9tF%M1Jq&kkOtFRfr3ihiS=Ihj44IX}oNwj)`P4v`h%YCx^%()ot$DP+? zD`+v_=p;f+v~3CQ^)EHlzRk-O`StJ{SFD6(eIS^U1KS=<#gstXYdakom%>0Kf3*Q= z`vODcOWawAoaNR@qSaFNte4~(jD_nsAx3&S1I%i*_3uuU-#vv~&&al3Ke*Whc@;Cn zYEp7}vS$uPyS(YUuR+!pW%%}XZ`5rIDkHylZ=94*KDWMlb#?cplH74zjht*5h@J9vRLM_$bl! zObQg{dseZZWh6hr%WA4CbinG{~tQZoF!UUnmQGzF*}rRM+_)L7rjhvHPzbCao|L z*REAvCJhLk*5h>DBC^H_#rJ_N!$?wNQ`d3hlQzMtd&fCqLC9U|>mP>oe zPn(JSfQbTVic`eape$H&$`q*WvWk)xT#rCGvI6wnGeu8LRyq@Lr?T~QP#6HCpD$Yu z;9#SHIen{he=`Z;AUZq^uPN;PM!_d)nZ)luVf;#p9}hzzO#Hjqbg`g>A8Xvl@wFAp zK&iW$v6KQSi4)R6L5^g_gxBdFSDkf2<4&qcmBJSHQ8zAKospW3jFdG)4LPrqi(5h10x_MLd(@-=CT4gc*E5_{ZSy zE}D_WdWrtlnz}u?pU0JmuH%OlQPh&lp@~-J48b^>D9PJXR2Z4;;}ZJlL3-3VmEIN@ zIT^Uy!lNiE|FJ#GUdg-I^7o-T$u9fu2wHgBZdmcwGU}mXM=5(Hr7ial1{2)~pSG3_ zx~WKRyKR}O-z8~A!wuz+uOPC?PBpuxdD0T1;vEu77nFM&EM`w3)TYnd-|I}(%%4N~{ zzvFX)QWMnbrO7O#+s>Do(%y@z{*iPQiGSXFZZPk1u_d1AAIk31#YneT{7aCPqL*~G z5JmTL8)Z+JcbgMi@p*jNNr?T>A;+riWoB*)_s64_`t0XGSGQey33|q1N2wImvt?3{ zFrkkXpLQ#2&rL@@UX9GrI2h@hvSbeg7lsyvX4ej}aX`4ghTl2m#@C zZCEgCQ-0?Hhh%#wM(cI_gFAa&2cpEtZ~O%AaTFBXK4B z2Orw+n-_Mzm~t909oBwych$)hRWGcs_YCY~R!aG!A|5ArpWDbSnC9ebCK6Fl%J*D* z%Ii~!LoDPgdUTT}wjhZgFaw#U zlBPYN{_pBXpI(^Y`2e+PiQX&qZ6Yeud|5f3;oJ0BnMTBHCO>W#9e& zjp(b(Ky5t0*DF$0`Z+znz90ibPy#~2@dgL43EUG$8l zm+0hbZC8hpkCtz4p|o}nwdLKwZBigO0N_ao^q2Bf!3Djt5>y^Gd1?{w%FC2xasNVB z-9#=VB;HF((nzf(THcl5FXixW->a2hhgQ?n?XCi>E=Y-2Eb2VSI3DU)4s|YvCLKXf zuc{ck^tjD&4~l)nU*<~&LZa_6ng`384>RezkBb3kumPrRPqU0MYfE=?xZh!%+th-Nw(VIN?RuE-_DO)un)?X@? zW81wu^mcDnY=kxIt&DDXYJb_l*UJ4k!pbQWaqM|LN?g5fR} z;fTHwcv@e1Hr=r~B`adzCvy4LdbsAI6P_29fxJ zT=PLYWcigE+jS{UZ5Ed1VQ<@c4TKOOILm;uN6Pn7d71PttPpSvEOS-<-+PED0(W6? zU*}#p1W5UDjIqu--Jv70>ChiMoZdW)|F@5CN7OmqH z2;h>$(j?t&DZlGG6w}le0001)3mI8Iz{TZ_BP>|r4@Bf@;R;u^HI1Roeuk=d$)dDL&brBa9oMx`5g!{%0kwZ zxjP-loT$BLceji_v02HRgpu88~8r9qzV~jMHI9gxVhA`RYJd`VcgoPfuVr*pUFdgkYxXzk$dt$orD=OVbE;fW&v)fkU7)>P}!Fs z^7cS?)~Yh(g%6JnZxDtY6e6U?zPhHigZ1N~X7~faE z3?cpyNyNjuRbaEi+|laWS*eqWEp6o-UT29xJZbYski~iz`$>Ul5Y!P(h6BJlen39M z>FXFFPk$D9{%<54;k=mc3Jo@f%Z(u8sIE|8MF1q@3_LmTEMOplnGwW^AhI#u9Dy6b zfuI5$yf7FfP|n6oDoHQ^B(P@s^H7pW0vXVV>T))6(>CIodv;GL+YX(+f`{y(NXc>R zWk^=rg)u-HtyT_8BEsgeMhT>hfRG^B7pBJ@>a>em>FwUxH?`GY#Q!*q2d*`vkD+%l z(}T>CfM=lJhN#a&VSbP^k@BOt_1QWSVD1 ztM;;tixg-L2>SRQDlW{BN(?_>hG4eUQQY243Z|#9_5h`TvrBwhW<;F`B@=)$vkNSJ z+0KOkX{^IJgJT1&G>$?_xCVu(Fh*Qw^IqQt0B(<@awPNjHa;#jI?Rw0eaA?;#8-&q6E*9g7Q z#3+!`2EI#*K6&vy3uXBM-2@4sr5fl~bPFzufV6@^8r82>2X;&9!Yw*vqN4Pi=ZHuDE&cZ8N{kO#^yKD30KEP3Lv=)jrCIlZ~}HAlx{0L z2m?&a^8!Y#yCb%#BRcbAwhg&W`9YG@C;em^vxKv_z6GwS9y| z$ehyCe0M!7D0}e&W37cqD`#U3tYc^E&=di=j>%O@v3|&WL!p7-Eob8(4sq`dJ`KLx zBKa2G5tC|wNEfE0z^yMgWaj+3o?v?9wZ^W?XQg>rOT?B{uqr|DFc24{M)+}E@`(D? z_H?;JqPlFi3E3@9r|_$vVjMk=ehp9v7g^kgVhO3SQ^Jex5fY_iZoeEpC0~N6GRnWU zsa}ZEqnu;T{=w7VJ=oIbb){Z$ z4eBmLIYLRoOOf1_Tr*8qy(nHMY37NTkMZLr4Q=A_HI7`QjEdynGk^k(u- z?cR6U*G%v>03gY9B++M9{cN**T|Lz!vF64dv~l=1vhtoV-HplS7s)L_GkbZzwWU?( zX@tG|b7vhFBGZ3Fs+@As^C3zF7CQ4?o~4W@r;ct-KIkY8k&>RsW{VEKzet)|Y;z?q zN$unKxAT_A_bbKAXP4$}#NDihh_#PPfT@&3-sLtx8eAkjh`8)tzC3RGpQIM-L@qmgq9!wLlp(m-=yhxId55 zgJ-Yjq);VPt!;v*g)E;y=32)1`Pbs?kIDZmtL8+opjJ?bsn$1Vd67>`?-`ag8hP7A z5oD};k?dqjlq%api5pksW@L$D*O%MD{h)1%7Qo49X$u*$h@^xo&8L z(Hq8WG-TT7403B6W*>sXS;8`_>h0?c?R>C>_rcKVJAg@X5r`ENR-?ESyq870L z-=2^dBp=7^%`Zf$m{G@`@Y@?B#ahqO^yNd1eknIR+tyd3;*@8bNN_LBysLKj;_LgF zO6@okApQ#_R+Psy^aIjXD=*qe=6{KZN^_Slh zo5H9Sn@Yb+D47a9bncGC)3B%J%Y`3p(}Az!Ua9rXmGeGnFIM&F>0-ShA8Jn7)7d8c z7018mn*kdeW1soou*!yff0t3!uYj@FPyy3wo8~F5*~Sd~yexkE^2dzKEk+!Q)UGf>_2_L>ZABoM^iI=FO!m%RCH zjZ#AKc74iIW`#Myw<(g1SneeB+;F@u>(2pb!j(HlBby~~g$oCf;ljrvc)y`zX_bDK zgAq#G9TwC|Gu!i~a^1KEN&|)if~5vA90eD@j5Via8);nvb1C2ePVnG30MHfBhjRs^ z^Hyx#;b=y%FQ0Xu`|UXSwOnkyY^=Tj-#{$F;M+buZQnN%*hmIi;fT;xuqnsuuEcH< z`&aja4G&WWE+Wq4G-mV7i}<)@n@v*V}RmEg^B1&y)s`iayQK5Frk;u3v2*FHs~CpRhhx$j~nyX7VMhjs~d!1-5N%oRt*9>`B1 zzebIWJ@swyj|JsteK}+~&@_<7FGQ;3sN}=pzOUY9I*f%LK?>r6HXkU2kprr9+Q^^M zbX<>p2giAeO|z2HGr6yA_8P2JlfsRVhGec}!QD)~#^?Vsli7Lb>k{#TO~#ebl=f%6 zp=X9~lum~&-X?xOnSUE~W$DIcyse{wW|L~|`p-}ufPSGg(qM)cwye?IZa@_@np%=T zm532RVN;;Ftn2-bCstq@;*%@K(_@=KVe8g(In5jZ2G~ZNgGzO4=EefRHzIc^Z%PhP zhfmS(P}m3zLosx|la7a)`8^BY#A8-&ana7kR{Y5sj&_T6VQ;0JiHgrhlejg~3u1?O z>5@1x)BM+|@(4E&Ri~Qf;iFP!QG}Gvw5sv6o}jSl=8h9iJkSBG#8TQ~WQHGDKXxGA}pXNB4yuhd~oXTg1?U zbwS+vX8OHunS;+bA6|aMGdRp4%c9IZ?uJx`OeF@+kSqy__m+5&5n{wU9nLSy?rprs zhnk`zzqNtmb-kh0+Et@;l1irv{6~yJg8( zf8?catCi!2-J&xY_X)5Kljg-{`QgPpyk-h=_(k>d`%=xejD~N%%5^Is@UZP{d2|mg zj`bu5Y{+!3uhp}T_asx0A zn^spzWQyG)F3Xl(_ANCKbv12%#_DqO zg+a#&E~@!u+e$vY<8lwxNVD9|ploblYl?H9zV^e9?cHs6O(+fx2}5I8-CvB-#+Pi^ zOPpS^<9BcQby1(?u}zVCth|2RnJ5ZY2k`Pzwk~L0$i<^gz-VAzm<#ggbJvt}GK`9( zO=Kzg?RaD$sD>~A*pJyXP)kPUqwNCIvTU6*W*^ND!d^;2hw@nn-28i(3O&G}w1Bnz~dMw)nFaQoo zXFIdc#5GFPape!hY0;{b9CQ{9_aAd(H7g~M3g+5FUmBN-0B)5G52zM`I2P$}K;G~f zK(g4nUZYD?`<`uU6b_I>|@1aO8K*P0?C)&+^5^V z%YE`OEYWGxUPC^Gz&YAht}(w`0>68|`rU0Gp*+1Pv0<#V)$(2vDZ5d@wI2Sy>-o#S zr~7E1qKjd6h7M4iV7c;R;}su#X?d(j$*{Fw@EN^$LEMMM{tAXisfChMSN*!d>>Bd* zk>UAG}8BfXu)MvTh zXaDigpiOkB;11x)yUP0jL*gUwPjn}NZQp;MDM{!?J>P@FcJVcl;-j+CU@GoJ2yeMM zV`vO=vMMnW*!a7uc(v(`J&8d$#+0(xT)DGNxp%!Mc_}hYU>jr-)p@A!bU#b^PhwfD&<$(x_tE<*(T{Y#1}b^K7)Z%Rf6#PEsSMKIQ=5 zES=c9=P%$$@%$}i%f>`$wE4{)DGqE*o)i%&QU3e^Jw^QF%~Lq&iVFa`&UR%AV;}dD za$4}2zp{56V0$-!%muCSzc~SfZquZ0vlk7=?P?SxHSQr??#GUbUusn#uIkV>Z#Q8* zKY(tDmYwaZ@5L2Az5*nlC<3_gM%qc}8O1+OWHcvb@|kFvbX zq`>N=-ach8YdEc|gS?wV3H!dr6}r#Fu&dv&@5~@Z#y=@7qxvcfo(y%@DCm7YcBE5_4gl0p8Z9R`yxayT%{qr>*kZe{HD-VnUFg)^ zS@MLyI5GenbT)AjrdZ(diu#f<7$|eAMbSPQqWG1mtX<~f9QfE0lE2cfMo+2~$(H*g z!ly9m>xTgpbdblAocm-;23S~UuJtK~zCXd#+VIwFG5$!x8j?c*!$%kR4C$$e3S?(+ z9QXtP-*goM?_Aq|G-MQFCPmpoo1U@-^84&+pxj@mxFV)lG)I)da>TLQSzVO(mH1E@ zU$x+}M|%HlsQgK05BVq|HeD(x`10M303T56%HfXmmEDK3q|-51=X6ms3WU7%-G-4ws|?CO-7@{Z<1aUQ|#qW`p4d zyilW(A;e5uU5+B{HId?hLbM}Ej|5pb&i^ALtEfT*T&7e!DBxtqREcDB5vzMU7=p9n zln~J8eThtoK-b|yBl^*vGoS|q&Lk+srIh$5L~73guk9xl#UeI`?7!T}PU}uNE7WOO zH|!Uc+hNJNY2SPOf2bSA%Nfj@at^Gk;H`*M7PqDQaKz`bdqb-Lq_vVJ90}sZ2of>i z_eIme*M6GdE|;&$uVnPXsZ`vnVy+ahw4+8eyfHCTKnZS@7*&PWx95mC-Fo0cR+V6| zSUpJ4mfdNRZ%>m3&l_9QZO-3a;46d$Oc`(XQ4CNS@!iMfph229c>5)(pgB-!ZTN=) zY#JN=&Das(1(3Hu`UVz-R8BQ?rYVl)&&MFNqf`erz+k*j_{ z$OP@m8ye!oC(HH^%I%!W!(UPXRP{KBB;cBH_^X;-kIDd;sGUX0NUjB^%z}ozH2fX= zLM&#IW$tb8Y$HG#s3HfoC>RQRM`lRf) zySxB2h|^ccMmxJLhxf_^3g-^yeqIDrd_tcgExWV^)Lq3BHH73GX&6~NyUyA(`c=h((9RmU6*fOgT4 zK05(nUd#8AG$IOx-=jd11wdb*s@@1YHqDYdME9N+T*`L^8lODtB(QBL2<*zA8;Iv0PY|9FpYv(08M8*{&H!`y}3 zk}`8=ZgZD3_iJvsr+(WEbD#Sqgxo`jLbs875<=1qp^`+p=;}C+bN+_!_wjzbKhO8; zf({aPhX94kH2>{AEM-{weVYDw*2S^qzQ&^o6D1ZU)_?%kA_L!d zz3kw4W`WyyjT<~@$@F*yR*qc%H$&!cyOmXD&IzeJV0CjJS&>ky-DjW0hS3&aWxWim zGVd!5Kc_{k2+4gRXeR;@1`0YR&=Z+)E&N z-xKoTp+%io{P=Bp$Is=Hpq-|&9h?b3IcM-DoC}_H!6N|qhzCA!p>S1feG#OgXr|G` zRjlsKuZ1Uktcg}IT=)AuNxc!E%}Na_PqHM{1VC{0%x0wISW4i8k-|iNrFcuAd*3_p*+BQDcjDz0|COY+>;<|5 zfVjRIlh|cmG|5S6ZE=M@+M9AXr49jz05fmB&m0W`xB+uE-{*V}%8di6bY8jR?Q{NX zgSI_^RtvQBRaDrSo2*=$Yg-eadf|Q2QA?Y_3jm0_RqL@dj-@$HC5|O%Jx(BbYO!=8 zl3vPbA+?x5KqT!ZfGR?nJHJmq_Fm_8%nqRJQW%7{qpCj-Rz$wkYBfs z!Ym_x(?WUsi9axu?y zHb`+PQU&>f?-~6l>_Bz~wDZea)*=Y!5S4}(7c|Szs#(@Va)>tVi9R^#uD~Jj;N-i4 z)baHc!NfbECTfM%Cqtt-PLF462EEi?nd@sHm3^O!-##g0ICdqM<6~#8GUweU0gYrj1#s!5qz;W!K<{Bs-9CcFq84tXr&6pv$kitH$bS2;XHslrdaGWhsv z+jrji6ydf0aR6K$wr3)|XA%g4iPMcoKVqme4yp^xM8Bdw5bHf1CDmqoOr~*fg;#Ca zGR=0qcb%brdNUTda*Z|%KU=ioci4iq@9=V11bm*Nk~{h|?ofBCs4ygir-q7D2b#}P zU2{wJUlqIa0>X<;>a?gu_5elfv!1X1f3i>f-k_3z!#?Imck+yP3|dq=O;q8Bse4iG z-=$b#4{6Nvcth?B!ZTabPLOFJRvlCuG;L<5Aay{KVu2q5K117A#%kOg5Ns=s#D1LSjqdAFYbNF6+6bE2Gfm8yqZ-E6cGw6Z}%QxyDRPi z8#gxjiEs?BkI_4iIhiYJEA{&0+^Pvvf*^pFNU+Dl(FJePZg)w#Clem(*PJ81wpkH#VTX?`RV3*8>9FTjT#tJcIU!%k=~70f)^Y zN=CO2Gg*yc1MokO+Iy=BeS=31E?W>X+B_0WJ#Jt+ZvndXg;^!41t@(B0SsHIAHq*%2#=!ths2?V3?X5SpHIOAX^#*e-KGc8`1%SJxpc!?_!1+T!I~YL#bJ%O#Z@aIVeZ z+NRu;wx<7(``gjRE0rhS_GP@?50ub16c}X*HoYIJx8fNJzB&3~N?U!Q)9Kn5rK<&q z*=)B8bzjp=K{@@HgIz4Phk?x0=o?LQWwMoBQ*=cdR9oaL z#Um<(13WJfWQSq@27Gzyg>>O0so4h zCgpYq4VzwxmRJb-Y>Yor5)$RWDhn?*6*9e)7MpJ-+D|KTfYzc4&d8QKLB#^<=L?)2 zTTsO$-4F8ujRwGtLVKh0iU>)lWU;)XZMptaCzVgL1y;!i4r8hC_Ndol;y*22+yiSz zQn6sk^-&1N{~G_DW=vhrCVUzxJxA4;W$@c{`m;n7%1TWAObx!5`4yktycTdeX7eG# zLZrx)vtG$%7}3(dC>GlOcv0+p#ZuZ3zH1W1mQi`lt}`kHb9zQfAp<>ACbi#HUJx^R zryhsOil)WAcEA83C?=f+2c56!Q2EfV+kNr#bMteDmw;?gR>Buiu+e3VyeNnU7CV@L z>P2)$4k0(=kUY@8%KKz3HnM?>HCo>U@yb_=& zqaT_9N;UFQCuq%D>;H@^)VzyHDROb~nJC8m!wec(z+}N?VFEUI{s2u{V@3MSmeMO^ z0}a!au+D_0fe1p+JCjSG0SB}5STw)%uiW}(%bf0da=qikJoKaOLcM^*so;TNd&7o{ z_72wymggOd8n@=0+e7?WilOM?;^6S0+)xwbht}&@PYfg7$p4Sa(Bf4pq##I*=;Y`-N`Y{jwN(6asy!} zs01m0r}S#b4WOqD8IX88^Kv17t^V zPI%<>HOffQ9-ABS6Zwza8R{Yo#Bmi|T1gKbKX^V|qJmt!o7RBB%5tUV}+~id6+F7Xj zjP&6j-7CxB-9Ts}gOCuZO;e222zE8i$&wUJ=6>m>dt%ZdLj1TzsUHn2tmRB{Q}C4L zi|@M+D2Spn1P+B6kw5@!5V4+w0K^$HP&2V!yUTAv%IY&v$hyQFG+y>ZDT`G@yPn}Q zUndq@>}(lAu21Irs1n;!y#nMjY~ct9HRmMi!PU}O5a~t?0=sU>+hZa~B8nQg?qRWG zR~&=?$d-8P-#|QhOZ4bdVhAQ+Wh3$-o-5=c7CR2iUp9oUk8D#gx#0eW<6Ldlh*c2oz0!sO*?hW!6foKE` zGQFEl3I-`a{7?wM(ij>D0KnbAbKDK-rxcu4xtz_U|7z7325u`6!-9@Wu4M`s$usW3u#T06X%YDs~4+GBzaw%S2r15_hvH|k_sNA?m*oGZOiOEEmkKFBk=2>?AERLezCND=y1X{ky9B3$&o*7A5mjMWEbbHiiK9sA+> z7^q}lV3Mim5js&LI?QurDpUS3yTIPcN~s~cP28tRaWY)#CefWk+CaCky4jlVUP`7D z`C{4`p9bzuE+4sPyRZA0SQXzv=kxKYXNWO#h$Gu?bw37L5X8^p(-|i^Tyy?VLm`RF zdHg134`*}F;C96<8NcjTo9d_3hX?4uz2GehFLb~Wk37Bdons+%pNn848jApiIxO=VHGUHVgQZq<-^j*-zqiTH5?C zk<<13O62cN!ELjTBqQ#V@dLLeL;ltTyT{a&rfSCT% zVy)rl#3$Ck{x`Nem^<$n_?ZWAx&xj62(f;U8ouCVk?a{`q$>*43x0Mt-!Wcv@Q=u_ z5Yr8%O@Gjp28LF$d0xVT3XQ-tf)fbDGZm>+iYg!p-KMpY_?y&TIY zD(bNno9S>#>SUK^vWXi43;u7M{-4%sA~$UM6^p(tapCJBH-j|(B$DC?2XbR()7wM@ z`rXiyU=I6y!F%JpFUuBQS>zo(rML%$Q9yrc6_P5s)Gmpte0h;P zehPYgIA4cloICg_xH<095Ifx<8$qzbu-uX^@1DirRHvPor$S3JOcYMSI@7Oc&J=C$au3Y!Bj;sAjU|BB7B>1_!L-Bcw#kQB^j87F&e5xsoX6L z>4PG`vXBrWSN$`5C!5hnxcnZGtlX3+K(AKMc~#XfSHz) zy8XEf^jZdIE9>R;yZ@?OxUY^6P(H3sKFLSp>~t7V$gH#aqbMT+ z`?jeVtem-QRd3XCZ&(a7C~ZXvnE{CP9D3MupHqtyBOZYFzj<}=$n2WD`HDS)oujfd z#Rw{mCmSFl0fz+<>rJn9N6~}~&d7WDo@+e!>(cAmD4A#;e!T%I)FwNQ398<<784m3 zkwD7CImU^u=mndBG#7+K822}3dD)JHZmI#w&*-)FASB=W6~OVYnIW9h1X=_mBUOg! zDzb#jBJ4|2Zg?juq<)1ffO{;;4_ri4EF(gSmJ;h<@-*7>)^VFjE*UvdjTG4W1LepP zG)fH1iSgzvV>71U#&xsY1i?8PlSzS>)Wdk}iT0Pd03;q2($IgNMttjKIqDn2#D>Lu zZ|BQZ&)U7kJF3JdC0`C6N^U%V{>r=B?XtML+AyrrA9Ej7jP$9HG{L=eh@EI~r5K$h z`fOPGcqLsyZ~LF~pq)GIL1|RnvhOM-|WYxnt7KCL{;aQZq&t^q2ab&;Gg zYGO93Rrmxy7gOhwao+aOsG!}zasj|+(t^aq{+M^HG-@@r90UVvb0j0ZG8#}!Q~X+`l3-M#HX;-w5NnoZkn4z?e{n+61asHo$}Ed--8rFynlNq6#r^SK z4FSGXr~<1}(Ryms*<^T6epuTaDqBEi0RH41Si8`BCk{`OaYYVpg-)XlH`)oqMULID8&b@ML!OYU0 ztpPO?LA|x|nR02!oNI@4xmO)tQ!K(~>*j~dn+;yxxpfV4jVcF1Kkd)B%q#l2j40Fs zB~`AqjZg17s|EnK8q4Zx@EH`t%nuG5E#L<8dGarWuNnv09BCRQwHGQpF@HuX^+4U?dn5x|k2U;{Qs8POum(sY%= zXdnT;t}r~79)&C+w<<~D@6%~zm$^~NvBjt25kT2vlWmHv)NO5ZH4%m4eZ<+2)`|+AXz&4sUS3^;-eX4*6N=| z8Zd(PK07Ts2;PYXfY~snFpnIKWQ2VXpvhx$V*hk<{nK=?KxH>yqjhDE|I5MhjJ53+B?pl?CvR@tr?<2sjs^q%+O;?-N7 zh<){f7}el^ykET`&-wHA{P+u4=AzuzqU)3Ulo0t4rE8*5ujyOAX$@~efLzBQ;`MZu z%DmP4SK7!?g3m(!B%1}nOycWiWlS)gT=jQ7!$n_=b}7{+Y!@06KVEWR;nWzC=VYZK zDnABfe;idyC<8fSEiqTtF;B)){&6ajngP1SxHqI}?qliI0OHrt**HO&zWj7j2L3RV zy-djRSj}e>d50|&kNH1r?9NTA^{rfW`WG|;Z4s~gJTk5MvOGlfijvgYm$9Amf?vN7 z|3v@WiI6y(afmm0gx@D`qogI(W$~S$w_zL~x65-SpSWD3)R}hp-SqZ2G!bFYYLHe= z=dRY;i~AJsSCJb@GlPeCh~zwQf$PlzD5NH!{nTiu)Rg*C1^z}0_}Wf^Mc;@(DO8$W zBJQ^94vRVnZq<%f`Wd^`Pg*fKU(NHV-YcV;yGa_Kf_xRKlVh zPl52AR79EX#w|0hHo#_u27-HnSEW7zke=NCbx6DC_#pbie7}X1U8g_^Q;8_Tc>~7pU)d=mjv5x?g#D z8J73GzUD$7m+1o6xs!Kv44&GSv1zsXSA$<)xW2xBe;yw6?G_oGW@vXs{4P+WEf~qC zfijQ025jOu9^ZWbjqK2bfCzEz`JhP5KvSWSXgyHG0Qk2v#MCazPh=rB{ObbK>~D-< z;!QS!A#Zv8wxw_=+;eClY+?pXvVIWNqcbHTJ(EZD@KKel{G)Q>&eV$GZ) zFsP?2xRR4@Tt@$_#`kL{B6XcZoINMzVW@;@se?xm2+(hm~T{Lr@%6jG^(H zS}5>cYGQj`dtX4$w)jkcna3I`Q0Cggr|?$AsH{YtJ1rtXw*u}AUcBUvKVKl<+y8V; zalTpy-ACmaCOlm-x!!-X>dj9(lF+ zgAaJ@Hr_9^^_`sn$+2>2VFGEdi8%AT@z(T;+HX*Yg5-#IDwl3))VTwuC5>|D#9 zA6I<$KI=+scGIddr{Wo5UjDw={D8KW#k095c?a`ae>*1$%0BlLG!aM^I`=T=oP57? zRK{h4L~>a2S)6*AlCYr31>YYHugq>y=!yQf@9G5J(v>msX5?Bd84!w$d;=&IM>MqF z;b!D&g?7*)vp_rF$J9OOI{}@(ms2E51keVf2XVNb#*W-k6B&PL<6lA6zDhVmOwK{eA$bBqQJlkMF;}@C~ zQ014e0D8=FXZ8x-M2q6$EnZ=XyIk-(T?^L#V^QFUl0eQ~=eur;M}_n5eH4vE(+xpI zbzift#HV;60Dfo=O>mKOLm$hBbya-hd?sx$yfZ^HKF=$rmE3HBZAe3)FFPXDXml_f zm1}PQ4>OcPZWO6knd%z^f)?R^@<_@>z~?s{Y!F`(j*-DNe%$4Omk^x$v!VAAb@gf5 zuUjXEzt0TDrVG@|uX)$mEGe_|a~T$L?;69nzViIb~61vP`yvxopBVDg)0-0FBGDsCDDG9Q4Nw z`I?|;6@^CXobruy%GxE;Fy?5yULra!6n3$PRqw z+s7kr^bD7B5zQbzH$Kg4+4{Dg})#CHy+(=v8x@Na7sSsepjFRzb0NJdVjXYv-Ba@DRmJ?SE; zmrwaVQoBRC`Pg0oSojn_;4D-uZN|-DgJdym?^vgu=t_A4dZLW5T85ui;su4Va0xK6b~`eo*6y*3AN~{|MG4 zCZzn4^tF$+hyR+C%4(iY8`Qpe8j}nxrgMGdl>0jK>AFw+Y-6=%JlQHoP}uQW_EyhR zJ6%DJpBc8MGB2Jx3o`k%c8)15mP-&*c*X4L59}xCd}@14^V2vLNXRe``nk}>iiHB5 zsSuvyEfY4p1%*|lVRR^9&@|?jl&%|A$!+(wLuMViA#Byb#$NM&(zDA@~UVn z^#;B&6&G9v6E2Iq_UN$%%;I3hhaWGDTWau5-g}e@eb}o;-GonG%<{!N)tGd{AD4MS zg2S{>01`;cU}ag(<(gPozga*}7f-mFJ&Hsf1&bYJQ>r0*YEU_PqqT&&qK8_IC)j9% zkD`-@D(c!FDds2L9#t&fy+F-Wpx{6jd4h;_se|$K)4^x1g#B^^Wr8u;#q4!E3XsEj znrg&Lx3Bo~{g~Cu6{yYu@LB!tEj*KHg5LsWI-x}-df%H8J1eu9_SdFVFI=eNn9Hj` z+DM^VR4zvIe*-k=fBIy}}_0|TXS@r`1;Uen1_c1g)e zARP`l1w~}0vRjLlb4@{q`6(+!3sh;tTxrYK_G8@MyYq7LK1xmt$DIB9q@}s($kg4> zSRNBgiOqKfUQz3=gX9_uE&ee*(;2Kl8>MGjy*;~nko$7;!*0MXn{$!J@vWFJ)@}T- zNSNrabU1It^C9)2VR6G50dJ~MonzO4oQ7HaMApM!AdQTuW<*mW9lBCpkQ2P;>xp*f zvT)H9u78KBcV9G7${#KL-%6vXx8iwAnsa?NLXyae$TXeGHGn27MQh~bpi&lGp*zIo zQLc=%Vl@W<1}G{rijb**Kn{=P z8XQ1j*rc1)H}dUE)Sw?qLpiV%%N1olvhgyHg{a44S7V*q8Vg6GpM;THl*#vC>BAX! zj(y^-D&CdcWdwp}V5?Ovf-fp7U7HL(SlZcsas1YH`^mQdi2b1!)){0qPdnrXDXuGX z{hDb~{FXRbOJQ?7@@W5&78B*+cE@@*ImLXyfrN9)geFB-$}x>J)qB zpHS;1kZq*Eh1OMyky+;UVktZO<$7i!Nlfmk+2|_?{z_3<_hZ3_F*XHKFmBQJb1%vUu1~}57zv}wpX+&s?|yBKw@mUOU;XEF$_Q?C0q@6G*@E2z`93`DH zeXUe0yh1PYI(doOHxF6xpwDj6YjbYU--^CQrvJzcca*WJ4t)55E2k_gJ3Cvr?X-pu z$8`%=UrvX|KKZ^*uj`7RRJf=Ym}}K&$c3yAj1KI)>*$>z_`FHW$Y_qih4OBnds8Vv zm@EehHpAffIl!pqmylO`7ZQ}#wB4yZ%MmBOKG#{9n4xzY3Lefr=kWo4GHd>_RDSsr z!_VDX9E?5Xs#h7F$bNB-Xj|6I8-BJVNXCED;zIThhj#_ap$gnB7TwL81r-cCrB6@O zURT1nhVApWH>Z~_?!Sr;MjFIb2XJa0`<+xjb2o8qV!1J8=wh$xo1fp0JAALwIKXT? zMb{zOS0;;oEy*j9qk`fPUd4Z5KF}0G8TN@4pQO@M#D9k`>VZGhO-pV_A@9u7zm2Yl zk|fxIf!u3Qxo5-W#QB)s>q=tpxN5(DDBjBPPanF;5s{>t*ii$6AvZ)e$EBc|{3#O( zpbt}7o9cs`ZUDcIY(M=Jh!T#|;@YE?Ol_x!97Jlza?sRav~LmXU4ViQnCc7Rc$CM& z2d{4*MHb_9Rzh#(KDn#befMGa^tA%d&)*c*w)t&7iR66Jr;MloJ0?3&YFH9xc*o}h z(a+kL4)6z15HT60B4bg|C%4lkOnLb>Li`r8speYm77}C^nSMN1*cFp?i<-w?Z23QX z-^pbVfivK$G|Q_T9{yUOY6G>s>qmnlxnbUR->b)#mz8-}A0@RC?00V5Px^d+u3_za zXtGz^!BNGrL8{|o&MPI{nU$Qu1I&N*5&yM*#{Nsky$2*7Ja$DY&_KDoYgFE$ycJms zyh%2mKfE!7>x`9Y;!F*h!S`=k4pHsnP(Q>KMoK&6PJO$x;k9deE(xYJxzawI)>`r& z;LJOLtKBd-k25e4Vytl51-I!1K*H7hG>zZcnq>!B3w5G$h%1kX>Ql_zfG}uaOrt=@ zG{}VodYP@~)!A8=2E^6i3~CI*XS#05a1I)GD-jFSly7otz#idG8z_GgQZ>xp1uNmg z)qu<7jGd{8-nw04fU=Z77U(CB=u4aLo0;e0>-$1naC;1!+%@y$Sxl&h>!lg{8mb$(9ELN)eDz!9;m=+g&#>{+vt`XSSgToh_%p5%JW@- zkag>CJr&+zHuAC$*J~f%=-HLYCwME8ywVzNT|RLJv*tAJncL-1G<){ekbO@&Z(*iN zea+$Ud)_)@M46g6BGS99Rb0S+0w$xypyIx40i|U?axBmti+4J-_DKc(l}zyVp44-T zsn-e|0gfO1uut%Guo5+>rX!ClIv6~GR-yuv@PbM!03R9}4G3AR)BoO#-)8UP zQ$t}PCh~=r^7&Nb*%|pjRg1%AR)yank7$=yPw=x6em||jYI!GSjPH{9b0r^? z?L*Fho67k?@PG)rw#WA-`7g%h-N&O|vfEYRR*JIz2P&xut&IcL4T01g+)K>mlE*w- zV|BaozZ!46)|w1MM<0hBmRZYQM|47 z@SyA2lJ_jy?V&<0>~;kCprF$rAbC^uuz3_K{qfX88G}Bv?eSdm8)`BqADtGm>CqSUFCQWn?bJaNOk zxxyL(@><8K2WZ<#c2f@(;R!oihC^^+5mo|T;oJX&Vq4UnXv>3m#$|k0{Sc&%@jjH} z1xNL`3%JAkD_WGM8N%B*g8)fo^+#ryQzd-j%0@Z&x1IMj^c^AzxSUX84XFG0Nr?4{ ze?ePdMYmcGeG!bHo-UA#1)5FcFe?CyXYSJQ(@zU=Dp`Q7{ck%0r)NeB4QgONeS1h$ z90i*l&c;0q*`d@B&bCA#wIaXf8uM}hY6vC?l*Ee^e&}W*r&*b7I`KzcD>8z4@QvH> ztNcaAV&(96&4_^>b1s*8b@@78vlZ`%^sfQv=OJ1)Zse$*0%h$uUzCH&^~WZAaC`F2 zwBg0G>reP6+ATBqtZL^Epkc3cHrT{HT*)V%>O4O1khOz@Iu`Vy^9f0@P6jQolDQ~* z$Z|O&^j5B{#AWh{?e&pHr%h00G*9ay;dKq-*T=uIBdJhr1gy8&cE5S&*x^^Z!*S3$ zfX%zcL@X^56uzzB+9SK#UiZw!b=nJcQ2uHu!zdJMoUfB3=ABCCtpi)3%Y+lwGVh51 z@Lp8FuCczdkL+)*KoTGtdpoG__%t+w#hV4xD!rhq3*?VhHM`w)Kmyq6dT>w;l$1Xv z{J_29F#KqI8r^<6;5Nt7^sEw2>SRr9A*WCP)!MD4mm&OF#jhkpQ34T&y9H3CcvJA+ z{A6%a!wDb=+kN&WBpR8ccv|L92I*>yXAOerMlos!J99qyPcUn4Lo*Z~bA9u;yRq z52%Z`PMjMD#U8{vWR&A-G@EiG%P%x39Y$8E#N8#{u27|OvA@{XaN6}e|K9u#-iV1> z3Fet7`3t~sqG^5FWcKiCAywc(tg;@{A1?on62sWP7V@>(3YSG#zidF{m2 z>Q-7(U*XA0yme6`X_`Bu-9A@EILeJuc)a=u9V_c$Pp{qF62F}ZyeD^K3=h*c({x&H zbNiK*TZmiRgic#%-=E69dOXTEHp-u-f1DPd$5iHggy!aV`z(@|C)V=cD4ce7sCPyJU;#C4DYyCkFmi{=G=8mN=$uSuEwvt1 zYlC^Ap1Qth8)Frh6I)N|+=%MDJrcJRf2ZR4o$7v{baQ3tlOwMd%ufpaSGCeTp{b(v z;;zAqn(0r;(?2U!>k{;j*DduszwD4G*G$)P)XU9 zS4t-l%}bdEzaOvxR=QE_W+$hEuRRfq&YDWtm^dC6l-!Y#+RXpfLr=N)qH-PB zD4xN-y3A7KzZrL_FsVu=eFUzd4xVq@C5Tw)-^JRr+6RsVVL!PPiwN*|^i*AeGy>O{Wh}xI!I`1a~TMfVv}$$N?8I5IIs; zrgD306hTlv5GEJEf!103O^?}IC-TEj?|*H#u-eY7NHG& z{n~VusQB3=tc)4AsAdql!9g)tPMoCdQ0FHDYh%*=1}pqDd@*hS3e(864)6lE1A6!lSaRvK@vLMepTcG z_5S3amI3m%Q;la|2yvC7P?k?wvA=qF+^@U(jws32;cWn|ZIM7W@{^wD-LKz2jUYr; zhXzG-)u5zkT{X>)S#*?5`G=s%8jX}RyZ@ua98!OdDDnHs(Z0rhvu_p>DJwErxqp6s z{rGeui22o2TKHq1=;51$P)5^e+{MF~u~(u2MGl-X$XD`@#0vMEI8B0<0D!c(pjrr4 zA|&6G>6Xn*6~tG=o}ryRdq5#a8eTC#VGsa-awH5x`~-YrMv$gR4KT}?8*7qJ1V{NH zjZFA6_y`B=qmDk3zlqDqv~L{vvQD;tsd92Zk;}W01>y=gs?0>7fV(gg0}^CbPL`n_ z2*V~IK_uy(buy>&-*TD_5*TR-=w?xZ%us|LT5Vc^x{)Hv#NMi*L66$P(G*rOso0WS zVFhBzwdNBz1Y?juxW;g+aU`(^X;RNh@^437k}a^1DUKmqDn%V3U-ekZP=)#=J)`*6 z&O8iX3NtHm$T(E&MLQO~jO1{1mm`2pY-Cyaxi(CkHD@;qO6>2hf3(xTQvkhjXU6E6 z4|g52&WIC3t@(Wa1HT3_M}{=WaId~}YY`pR3wd@X#;9lLA^%P<{oH@>R1Jp`iCa-} z8Gi|CCfTIkl-x(vQx5)R-i^-%DkZs1(P5{$d`!Y?u+VA!eFsUg(}#D{gwwcHvxJjp zAW&%^W>=bs=O3Y$kq*-7o(q%SU`a9FNwQ?D|1RxS?2Gf}HDYgT^IgrR&ih5jh4!J+ zFGW9Ta+C-+QOx=;0-+~G^OGN!l)lX7%va=(ZkL{npY<*jS=?Jq6PAt+vS7Ni-0HCM z6>cMF&qGhj1<^@G`7{-m1P3$0G=h`<qnkXD;C$^^~gmlI>uSq~^ zH%inJ;P~>%s~MYe!bU#txm+fL1HZ*>O$2gY{?|f-N_!U3O|OQ1rPbDXM$&!=k_eOa zMIKuO#VgZ%O3iJuY=Cl$2#X4A@RUW;>$$L8-U~ztekzBj39TDL?Pkb}dghzB-utp( z_5Eu>Sv}RsAX1NAw`N2(^(;3sI3N~f*eA666gX~VWzT3A~(6LZfMd+Na8Y7^?W&9_-$R?VP8V6 z8s8k^+`(EY8{n^=`k`zLEs+0RZ`2RtR2fi4DW5`TAFoOr5_<84p(e}Av&rwZUIVRM zRVWAW6 z3Y zxfkU7mIWaW1qBGMP&b!fK$D>3b6M=VV1A2O(0!i_j zCG^#qyc+j1;Rnq)#e--_gD(K<*HxPJ*AyB$FEYlv-^H4sYg{l;nsmCQ`FOW0@4uoU zfpD~B&WwX*6ks|+$i4EdY+#J*9j0luKz@gNpuT~CIDIlsXP-Nhm*rq*hOf;hS9G7= ztL0GAt?Okj)fNN!S#PFmpzJE6NJf^$zyV8dq#fJa_I60U*S`Oe=zf_iVHSNC<8o&k z4Dc=cnA`Y42Fo*HVQ$606Z4Kk2I(y1&a~Lg^_W(zI$m`+;plbJp)=#2Td;O_-|*Mn z;8WYZCKvx(BmAho181@*GI)je5ZRT})*4QidE3u5Th!)*-Qb`mu^J;6e*$=D%^6U} zRN_E~rM4uQsM--|Ni54}9W33=kOphm$l%>7##|d1T zxEKtN)HWvlL=BKTD8X0~A3Ax0TB~cHvW#oj(bqK~3-MO0$3U-CZByn>GUf8b+e|pK zlKEwVA~_h%+?&6g6i$n4JQJ%6=G5)$zR8h!xFO2Ru!Lxg8v&>@tC*!8HP>-oql>Cx zStoGIE=RK2PLPPkoQOeZEl*-ltoXb=bnKE5*ZnGFc(sM1)hAcb?hT5FKnL?%cmo&BU6o!xMGSz{{(~ZP0F$sfPqP<*xI+wrYV6vDf%op$&C|NVe zUsPa`>-bEcU0<@1M|<#l zTW5fXc#y88)W~UzQyzIRRG2z^+|Gypw43oukm^-^Zhf3?Hw_u5h|t9(@q|%gt_|+% zvh%;=kVKA9J3M&c$x&a`7v#oi)iWwgF}23~?F9cl=%P*CxqoY=-}MRGog{&{#rasB zygPSXwOxhC3E`w`kLVv-pAI}Tbxh)H;K*7DSYBy=-8=E<6wjaIxRHWXPCXFy=2Hgn zsMHK1q<=2G`?q7O1*D}9cZfhzAdCt|;V~heS)b5a-FNCyP}bP^ius)uSBMwRKP<=BU||{sRZ($E5u5NqK+a^g;A7 zv6>Xg8sLY*)9+%vmJ5%KxiXfo!EI_7(6{EEvT=@)F-GgwZDYPcJO0*`Q# zL%2Wy?x|+M3=Og?TvEUVrJ*U}5_-6V@_8Ri2~2a)iPI-@ei(v$h}N`Av52;i$nJ5C zV}|6T6D}QSGXfK0b)>{+6C{DUnXrHg-JrOx!mvj7`oN^Cf*!59dMIzb4{J%nIst=z zFl)x)#ha#?4FM4H?gpd5Za8^pBYAX0cD%t8l@D?Ez9i!xwpj!pzZSMQl2y8^ zq=P*5#{bl2VD?hU<(GH_kisQ22=tBu7J|+l2pd)q4Zo!r3U@_t>QFgn&T)V#z(ofz z6o}p{Hiyt;X}LD042Q_wb>9N z?>c$QIt$MhCj#?5Z|cI`X246~$;b@yCoFueN^!_%E{ zJ=WVt{Y24mt$z8;YbCfxCjL1N&`)q8I~HuDd9uTz#AYp2UcsD1xZ)a}^>jpTU_30~ zi>)vX+a1aAO-p_uKwhd-eyr@gXy;{Qt+Y5;NR$9O4QJ5o_P+lp!;ktXCkU3qH6U9BF+Pw>IfmP`{ zKq31+)f&EMWrcO|5H3-T_%0~l&ln<5MD)0ykR^xnv(&&JBEi1|=O!BGCrpF%#83_e z-H``J!}>sly! zT)*ws^eySFNloE#txkWKOGZr8AY|}qvDa?p5QnGHTxRcu+JK)*&gz18oh5I-){fu0 z6djCzu6;Rv7XK=>G|@f#kCWHCUOZS1@puv?f&~7M&oO8%<8wA_W0j+36?o9)1s~2? zw${Vf%aF5FMv?+HU-_;kttK%tw18F=SrM?HGW|sb%S2O>RG)uwOu<3oyXe0L1bk;2 z{qvh6w@kTZgt_3!e9^#%iur~vm1Bt_XA)4h>Vl^UOtg4af>s(+?D+BH5SM5@3bo)x zM*OQ2+QkE|YZIhd6U&qlPMr@et60odVine>mLa>K^;~^?@wDosJQi(`#Z;Z85 ziSt&hGwbnp zr99+$rHayLH%s4hDqL1XtX@#C%S1_G6yHtSp9;x=Mxt!>6$B|r3y*T1K_zK9#9NEz zO^Zl~EA1h_IueLbMFIdUc<4#2)-K4iqSM4E3ULLk0KS4kIZbWksfnY6S6t4Ar1@|{ zDC<|80Z{irRwDtHu33yP>EMbx@eGl_AF*K~EUcwMkp^{~DBLZ9bI_|XHHpbkR5DvvBHJpS?S5mu)ymyNxD#p%uSFI?y|0*X zdf@KF`&P>+G}b`+O$Ifz+kAp+@0Ogjql3Bk!6i{(dm{L0hNv}l*kb$M>x*E^NU+_X zVPo}L0i}~S9l<9f#nk{DYP+C*x3e-^m+mM04|(UI)kN2D;dDZ13B3~_^j-x-Od#|k zolvDqQvp%Inm_`f8hRB%uOdwZL=9CyK)N)QE+8T(*nRoDkvAhHS3c<04`|&>!U}`V@J*-PayhD zK3qGMk91@8pBP##D7pAZ`FN{dxo)}|`qJyni_K?+j`SDdrMGjwz35wp4tR`Sq(g7| z^WtyDU+O4)xLzn4%5-xTCZbwo>(X*LsztKKL7N8FZ30_0gV$5Q>tW;T%ix`5ux;-+ z4j}ck95W{K#t3xw_zuMQp#QAh%hh`4pM}kSm!v%9V3Ym|Hm$9hzaN%=Yh^C7^w@?K zAhcOME(Mhv+$Yj&bYI9W=bb8n$3{+Y_(`diFFj ziwA zF!Y6jhoFa-LH?YJBbm&Vo{I}N!v->E-Oh0fn>-i}eqeP7c2>XJ#*1>C0H0-ypMP}r z0_Y7+6~Z|8ron6(9}XF08?fE$w_NUj&(hkd|KcQ!W=oBF$&AvR(7PEAa^ChM;nMF} zfZy$nt%ZSCJ;u+5zuzue`qVxCWeDMn6BO4VT59txEnbSvxvQ5S#Ycj_%CH1>mj!ur z4AlUa+NHi(U8?y7;MEDfIsEy;89gd0}f z1VWt+q58D(uIEbzE8{QOmJgTjVC|O&Q2lru5CYLEe;Hw<428WU;VCCC_h=U8&3nS2{ps+sp>>Eo#yhH zqHWvmc=t%MqawoVkP&O5|Flnff=aGW<#jwcji0M!!(18Sgz(#1jkn88lH?;%HyH8f zl=aP#;|8jbbt{MgdOV$Iy+|${db0FnA8e8K-t_XxjP}Zgox!Hll}!wU+ofdo!cF~f z^MwJv;a4A%zhYSYt?q3m$ON;MZ)O9jAO%hUiu#O;hmNSj-Y?+|2{T^1EA>b?-v>DLuD1cf3>jUykS0z$17@T$ouNWN%%4UG;2_{KjgUoeQNsp%+9~T&93uJE@tdMO^9U zem~CrR%rawh4IaM`+G(E2l{VX{XUH5F5}TBP~B%J+~}K=G3OC>q=P@(Y5tC^sKUgo z>_0zIs;KJG5uLw1Ai=M~-xIbh3rl(RfE768R-U+0pe{FgAQv}SnXh@tx31Lu+Sr(u zq|jjWd?vjnR-b9E)Tk{;#Px>%Kw@iDsZfpGyN3LvZ-LfY8gIhhjlAM0DG7xo9lOqj z^{KnJ1o_kt(8Qk-HoQvR2O2%)yp)j@eippBtt2Q%mU=j|Vl18vKyhNsD$KCNpMa^p zR{>&Y)vvy92Af_IE-_kS;1&mig|q_a^CoY_v(Bsd58bnS*3Por;kp;tECEgK(T0r^ zqUHJ{HpKPGKM#8PzMb~;tv=Gl`@BwKC1HUG)x4E>5^vNWl=aj1q}G1@W& z_>ehFk)>pmv1%!V+uC-L%w?|%gmT-{)F@WYO=_7|(YHwyKGmL$c76}4IBed#yl1Ef zA^=Z3M6Q-Hy;kf~q`nqzwTCM<3;VuSN<{aKF_{^)_WmRC##p{CYz?07ipA3DwW8SC)an?Rpov6zNw%wQqzaIWFGSUeb2~{ zZ?e*S>}#ar`_m+lnz+*}BD|&$=0w4=s zW+wGjE{>|#;eBjpQLA7S9uNVUF~_Hqxm@r)5IzU0-Jq3yQv_5vrjKkYc3CLWtU90u z0~3WC_qF0MIX9wPOh<;;OL53Hqxs~-Y3rb;h=*qVPztIFng(9aln}>qJHgOEdV$~!7G@pS!&)W%gpa|17IX_^x7TQT{-n;D)eE3s zEm9jDE5`~rrSd4~d||R*Tc{)2o_oa#yv@63pBH5;eOSwEZAr-dWnyM{ylRe!`JG7z zNu5_oF_pt~E|&=!gHOyu%nIbuRqR7@uq64a;t20cr=whcv&{)Hc8ZkUu5@2OobG-Y zKE4N4)I-%1Vhl@L=nP77I@B$?BIk*sVV~fQe#tAy5v;6ZYOpw!=4b+I#%XVIU>c}IkUFFwgrt#1G-u-%>)7+p!wBWAPvbrQ71)Z^2F zfh?Ev6^Bfmb2BT$#FdPwewPK{1;5@fCpS-xV>0U;1X>(R?j9T zdU~_2yYNTSG+w+F7j!T8e?p@u4sE7hPxSM$uz01;u$nnXf}&s`3Po}CRwWN6!1lI0 zK2}<@FYPiWb*$}ef4ugKYq;6S*C6X z>Ha^<@yOz9#>!I=1hT<=t9Zy=QoVMcgv z;e37;&GZsufJylRH=oxn^y(%b`9$6QY$^V?ic$UR*B7=H+49oMs+@`>GqAB&ZI$Mx zy^T~Vi7jeH{EX4W@b=M&TZYr4K&NYkTs$O8FZjd321kwRrVF0)-&&Uyl|q~>$6cTJ zJY8{XCQ)Y-?#O!i~ec3=q}}S=Px$6M}3c# z-KgG23O167>=vhauKU|P+I8}qQ!WsJgIU!$Rk9q9Ston3FNhH90mdz&=H(;ROt;y_ z)9=8<>BXsr3D2N^*ZEji1!7m3mrkykwRf_;6511*iNk_8RgHgHizALgSB${5XF^wQOBH3May1`#3I%8qg{~{35enhhN0t7wP!-U)Nr7J zfjhGux5N$CdR%CIHcpeNQ@Hna>x*5!XBG&3M*V2L#Q2~3x|On?MNL&s$yim27U0RC z{W-0&1_w(mr?_(GX_iCC0r86GnHb8B6OG3~94~zs(YF~c(a?Xg#6EN3lN!e0Y5^b%3gXuZI!cWF3$cr? zWZ5Q?9Xi-gymh7`I(V!^5*^aHh`8iJ+|sa)&~iuM?WS89Ygn0&B@(k72TJET2({_hIk%1+D;KmKrO!mN1nhUQ6i! zCJ;^aG)>RqQz!Fk3tDPt76(Uu_M!Qk^Awup6!Blg>yRJ~l*H=_b#ZfiYv;2(&8D+; zExgu6tqotPkGh(g0a85*d{N@Y5_Qb=g#0^p1F)S(F66Qbe)w2&oLfIwsilp9IHw}` zyjQQGUqZ6wXEVG1^CvV#N29^l^7*6o0WOT!{xss7-JwFZF{!XNF}8V+gbwnL*kmk$(5d2%l}4R#)AbG}6!15EtXk zLgi78mJh6K%i6pUBkB&Wt!>dn766%PNiwrTL~;a3mvw)5@YfhQGf26c=L(iz;yxZ zLIT(TI}mFo@rs3&l$T4Q0}Z?pb46gceg*R-{Woz*mJWHIj$oT_#+Dm#J{TSQ#u?tfGsm7cPZr zA46Uq29e|Q+W(|e?s68k9GYG>YW!WE=8{xN%99!`ok)NKVALwMp~8!9XI{Eu!CHVA z4Zv?(ThP$ce@FljO=LQ!a-ZcXr1SKVC%kimhE1NJ&d;0Q`Hph0HWuCs-3Jt{25*SQ@MJnLUhO!11NJ% zo$2=!vb_PrMR9LmC_V)N^!DQNujztn4;uSU9oPQ1W$3 zt3hS!gNf19y!wly8gDQ&29~)-Y?@R3i7t}rOtt!YO%fT~3a>NE=86X9v#igocv!w3 zxY=xNEqShynwJ<1|FC>+&X48o?dSgF@^ww_mlL2&j1GagBtMiXO$4bdIEf=GrqvBT zZKTYW)$aF`gv{Pg&vW~q%Q(bj1bDu>amU%3zl9XUD}F*GjpmWYiKKBfr`3Vj+SJ_E z3h_$o`7Jl<$K4BG=m10$5k3!Kr4sFk046iaA7iYKFZ3qmOf*js#L1RmD-Bq1xr9Kz z5}>kTI3n2y|AY+Ls5m4R$T1JN(6RDn@-eq#&rEO+cRC^IQ3vN|^AzjJWz{!n$Ar6_ z${XnvWh4j{>mxlcDm^i4;v@Po-9qTt=yCQ!vAUXS1jMw-_3_^k#i(Zfv+@^*Yg*fy z&j(LonX^PfjWkEaAOeX5Z#`gxmxbD^Czj~CN{9tK_0iJn%X>zn05$m?pW;Qk)^Bza z{}pekQ{;8vingOVY`m{39ER?IwEFn8pPgkoA+_rC!5NQQZKCQkwo9AU9kCk5hJLhL zF*B_?R2Ke36aX-C%=dPtdw$Quqnr5*{GF|c(}hsDrp~OZf=N5UY2Lv^#)CsP&^(tDEWv-7kPdraRa=ul4RllB9_nhRT;HUC= z(D5*+zM(i@$w30?EAd|jg%L}ld&bhaI@r2o=xlz@G0%ZwcFEvP;bK&naXmnBV*EqmTElO#od5n4QcbO~T^&f#NCb zr1vVZ_XvPw0-4vR*dvH5zVFsa(zb*^@jr2@5kHmJfQ8VJV!j4iqCku%J#p*p{X+{=1YgI*y4+q`UZ>Y;=wiMQlb9fe$s0dPe&)>E^nX>LkG^5 zIkxdVy@HSh@tZHrT=?#BVa&KlcGF8gpq+iN$v(i&a@J3H+$B&;MoDfQA-5h#m1l5z z@_@t+W*t$2L-$&So!_gx*!wse7WhPPHx;;waCMyro~W@{4LNVjw_mZj>>@anEoZjR zkn|d9gb|+l?0!ftdJ8xa#lDY+erW#)62LwL=nzxCGHij)N$i_B!&~t>+w4BcT9ce} z^9e6x_Tp1lV=jD=PSH|HmOcP}YO@jgM|vQ=E4DRxEbUu#_luHMaEiL+Dfsn|vQez6 zTfq(Lr(`$l=CFrVf$ziAPi~q9ffWFCmu14^NGPL0{_$~!7 z{`ns|>bAiOfd2|Kt!OPU%5{i*Av((J?TR_mWMRs_rHoH%Xizh!WAy9n$lAP za>(Uu5(#&bhK9E9&-en`o#8HC*{Zq8b5y_^P&(>ZER1 z3;|`0zK{P52>2qWB|7Rc?o{Jw857#8&(A#x{W|vNwAULSFs@soLH> z|Gc$#R>K4~#`oaIpp@pjP<{PaV}c@;wEe?vZ<#0-JpqnvL_2AWYDnH!arSa@rUd9( z*esvjlYJXIa5A?!ILJD7@bk0qoU%+z73AA!+WY<6+Cn5!&}ViL{_7<{*M31nt`=Vl zPyG##xz##PjC$P&d^B`3)n)k}lyGpLhz79aoRX7SVP-W4-b66aAD1Ev!1gEcs`!r9 zL`a%imFS1BOsw_|aY!5A!OHn=j-ZrHv#0{oLU_1)yU}}garOQo4^xx< z_vgxfz%yQ$|M#C~hCj29bbP)}gzr!0^}!1j*fiG8q%8TYPRA%o7DmCN7T;00NZeqM zkfznriP`0KSmm5vIAgHc@B5zlxf?$LXTR^cJ_%hykc4ht@$9~th_3w!mB?X7l?bPe zlOucwZ5u5~pkKq>^)2(Z5RG1q7!|LMq7z@ylw6hl9>xO|*yx=zYRZifD3o>ne}5+r zeEts<8eu{TK~q-*L+O(Oc)$c|^6o~ANdg5mWoIfJF;7ZJ5Zxjr2uWsa4+=^u=lRBl zaCan-E-`}Q#jZCHKSXn{Q78G`gHD&1&IO_{yhgqelynzIl0T1wBMJp>Ic>Y}z278M zUUG%68et5>1k5pkiBfYI11o#!%dI=KtVromkbo87Cj@ zRWR4HSWI}H#7bu~K&VqymZo@29t~*-Pmf>0!KLpqNKEMK$c$=%Afz3j49gvxE$A~r ztYf2RB-Pi-(v##HnliAU2LAARU_-YrL*XSm28n|!Wg(k6&eZ_$Z0Or{t<|@VP)wL? za3si!#HxbkC%*!VswRVFitRox$T$#Tx466TmGs1$ z2H2UdOEe3&f0cRnnrh98Bk8AEQ#=GKjZ9zB!}DV&u)k~C5TWu747d8mjxokk1w;Zt&-jHPqz45*z~q5|@W$+nu;&%->cEq?){!ll#ee}194?m*dXjbH z^Ny$5Ph8n)0*s20u-NeF%{!G~wH$3-WGo0oAOeLY(W5H&fUNxz(#?sktbPxM=Lcoi zo2e^63-ZBem-k9mR;qMBwQD|GbV8i4w;EHM92Z~bwqY}5)iQ9{l|lZ-D{m6FY42b(J+=zi$aZj!)_bN<`4SK4^0%z@<m|mO+pZ zuTn1Fv2yOm@wiA=XN}S2cA`eQ7HgFaOIc(K2tY(fFE!x+OPQ|ss!+^+PK&B@c=xUN zhcY@gDGwkaD(hY6Jp00NG{m{BV7u{NfSos+Kl<;di_HjIRKRcX)yW7&U$!6jC8B%IYmtiqpsX?^$JRA$-%2B1L z#lMs*7nn993#}c%O>F0d{*;j)$2z51wyyg%Ws1m!ivy7nBz}H#_0$RUL;E(akQ##V z`!SQ`U%O>5Zk$SBGA5NY@#}2Iz9*1! z4k=21Ep#qP3Mu4dCPihJv0~@BOW8wW6bU#f2@uQ2VPBX|7o@T-;ao^_RORwBVhz6{ zi$nfZaJ}zry@!;7vssq&vtB0!<)oJ>Ax8v+8eCEMD; zngU|sFEG+tk9KTsk2;i`?4GIFP3o~VqRM1BNl9488e@)zQ3UGnjelQrS)mF|9UjNt zw^u`4vWbu;c@nclP;cH2;gz1YNy~#t{#SYV`C~zW3hfyKU3v_FV9IG>^}PnzYW3rJ zu-?Qy-T}HbZrkX!A$O9OX1Q?$$<#=1>cJcXD*M@+T2vaeDuQFuR0Uxs=wRzSJQKFb zUa<>9j z`fDmSs0be*=*bPnaG{(M0E12@kyRw}_~qtkJOHqM1sR|^Gzta~3G*I~$QU?)bLv{* zAN_6Y2P@-(fri_K9R1@Y%0mqVk;VULLpHbTzM9Q(YK_*X7HV|A$cux=8r^Nosv*zA zH!dN6(!qQOJ#aiQoOq^Sk}tfiLee@ocJ2AJ!YnrS&z0Sm&)Q~Kx%I7lLi}gDCi)Lq5B6)@ClD$c~l>;ad@*p%u$hJ8Mel@Lei=KREDwp7}vEi;Va#->H zxb|dyJ?W{$E&icm{o(Y1{c9hdnqWehoU3VKXCmq>WwRZ}uOQy>J}xc!M9xroD3)xOsiGxBX#g5$k%rh-d~sx64>!my7ytq^hUaA;z=ktly4MA2%9qsA$F8-Vv%H9?msW^riW1>xz0eIP(izaP(7HCe`v zh)9@Vh{#dlj#W4~>mj>xS#}f*P{B!2#AWR$~wK=_mk*%0FJaIYAQ3y?=wR3v_Z z(iHI}_>o@A9PDS2wdYtechLx+e%S>tx&T7TL=_~nNJJumy|z-l@YCcX%@<(jhsSa2 z<6KNg{yNhHdr|<}oYxWPcF%?uhU1IE@rU*BG>DlX$9zk(`N%zdWSrO6O|SSK{uGix zUJt)_5BG$aw$s?1tRBH`oS$BmNH+<~$-X)P6m7B&m@L~l>`9#I`4GKDp^i%Z=Z*i) z7TiJ*0l+cl7ga^mh5B>63wjo5K!GDB60+)qo6N+YBgus}^~d-0%bG;tQ~DIGoc1XK z;+K%Dq^my%Idndb9ucqA8KVvWEVlhjy&G_Sh_qv`ls8D)AH?Escp)E)n1EPU0GK@( zS^*#(z1KKwKgH@?DXcR@)=?}QF^Fv$@TG>CBd;d$KLCe*^|cn+Yx32R1$L-y zC~BT)cl}~^UhlpSF2k1OPn7bfRR&~9?bK}YG)TDz_C(1AyW6<%wN<)zZSqBtZrj#+ z{oeEf1?HsnEFw>ZeLaFd(I?1WNn;P9900%y7lh`4piRMr?Q(IRfWpDxcjkic7lPj( zdKQ0wS9=)Do*^MLySCcn4w;>v4eEvK+Y10NbMrl4pS*6qymV)Q8GnGojl`;q^mY^6 z^XCBTvg1?U;4Ik9Lu&p!6uZ|3(R?6CC9Ekz{O3^p4uYG7Q=$dcRLSv9~S?9H(0i~`fi^7VyU#N-zJrT z$O=;`BMuvIimucftH#R62jEjAOtOG-&WZerA^aiZioTF4-?5rO#h3=%BSEAHWVMzG zJa_K{Uq}6g8{XNNvaL25ZQSYWRAw*lHxWX~cJ!LCr%v`t9xo7T(=V?8PqCWvLv^1m zhhj^M!&3Q2^glx$!v^#py+)vWk|?B?lz6sL;)i&X6^X(I!w2FtZV@8R{UQw+ zTUT!6sIiMYvNNIg2n`!Pudv$+xv~|g%!y{=bR>ye0mW!_+4|;1#t=?TT;vh-%X3sD z9kuP|bnK8E?>O%F7L=f zr7mA-VLy1#Yp=q*pj=C^uu<;a7`DqE&OsLLv-bGyygYCiId^Ggdhwl) z;s+`IHDl1PTk*1JTw~*9$P*<5Bvfg?AMsl0-Qpt4$3+?{|1NZYxG06&r*K08D9#;@ zp8&AV91P2T;gsT0j10&IFoT@NB$)1ye z`E+h8+zcU}8`(P;Y!em1%ZCJ^I}e3I$ArGAn#_~T(})A7H>t8rj7Rp6lTW3( zZt&1+iCcV0j{-xUO=n>+?D|`}wnLOyjF$@ z5m(zjSF{>;)Zul)k!xavKW#iMCt?c<0f_&C0GR+S07clTmw^P103;w-SrjJkDNf#3 znIfr8#cE8&X-<>1rztwq@wzh!XJ@GAW)cl%ld!YNxY-n=xm4r1G?TdpW^ZX0Z__R3 zGpy$`FU)7zE@ay;KU7`$P#ylECUT|r+DhH^mHL~j4Kb^aZml-nS$lkMt?B++GikjgZoQSf z(H6h)Bw?dHar0^NW=G0aXWCX5ZR=V3$L`FJ&$G8*W^bhK^XrDsqmMq1J>DO0-hb1wKhgGOvi-}{(=XGV2Q$wOX1fpOUL3w< z9M1QCT^RVf^y=I4$hY^SM<3oCtxg`V%^YvcecxR8{&DHY_WPf^D?dN2|N6Y~Yk%w4 zm+jvNJHHP<{r>v-&$s@NP;p8dW#alH2W=)>^0_k&-T`@b&r94@>(obNiA zeez|hY5z_A=h3RY;nGioMLT`@J3YDEjI56@QZ_p&8&8PqEqB+NZm&JQvDy%^QXBTZ z>e9P%-(|Yz(nG>xf%9U%!$Pjje3s?g4D+|?__;KrxfI-N^7+{$gV{v=8S2@Y1l^hV zGt(69X|m>YocdI(+7wA;lBhI!AMpR?fS&*Yn1#)3y=ypx4P-rPPGy_ng?xE7o^rg8O~JluCQV@l1hnH_3I_ul~-AtYlT}(b+%Slh@QU<7qM^* zqKTTu$$E}NnbE{;%MG*5wYHELvjWZGx+nF{vetB}2uncHyj;+9nUedHM@vt^l_PgY zvgGV5uG|D2?`TwCEYc3`6|ss&4?UpjhKvR^M;N0hei9orT1x=Cfa<0BQQbJ`n_?xZ zsK|(6&2)`JLK3ph@zdK%-`B+=w{f-5jtJ9uS3u5}4~#f)E;PC0kOcIH5{mCUJ(b9p zTP6stYJXms^?7VC=5`!By<&R*MD#0EYr9In3#>;@J4|*Wl zjk3spb52dd5q(RR$J0U_h>c3$CicZ5i>B8c-tH8sZZ!(C~g-4bFZd$e~2 zO*_xw0?v$yQc6yWc1pw)4>=`WOb^I$3vyK(NZzgr9qRhD8(f~M6A2u=rl~c=XUof; z&229*H!I#wK#g2>COq8cNwPJ23rSHC1W7*oy#K2)#S3YFIDiXOte_dQcW%9!3jS4@ zdnGIE>u^K|>eYy1?p~;WfTrBHS0Zug&F(zwzpkX{8|^i9#&>%VO2vIYeerCWK%5E+ zaPAJR*#@T7u@)KFywmM>W!rmLk<(HV)IZ&ePhpX z$W6%QkDe1!+;%7Uu;y28shs!!{&NX#wb2-IV4F3`g_qd~2&z)Sh4B3z9||<5BOtxd zxog*8R5|t5_$Oi346|+T7wLF)Jcm&@|5?WqQ1|C{@a<1dNrhZraZw6MHj;0+Ui~Q_ z9J=Q5tx)XzFHC_=*CA(=lf7%?u}oo6%ZTF5FXvw0>Vy9KHePT3E!~Sw`u6b9#;WU$ zGcg_a^YUda-AxnI;#K6-zm@GZ>()dPJy7nX0plQp5}s?T;%_wG0DC3dFzIkZ9M zs}JE>oHKdZj-l&7fb1Y|pQo<*$TQKOyq6SDlW97E1mT^myA~NGU<)N|f+VsnO8wEx zBl7)899i;Y!(Mjew238W2;ycu(QPTJyjHBi*L81BwIqG~?io(-bH|UFciwF(^M}0q z&*Mkd&|Ncpc5BhTV}-Vr!7qL+%U&x==;L>jU1KvbW`2lNuJy7%KUQn(l)9RrT!$P>MS+Rq@*;Ca3WX) zFH0l^=Mb;Vi`B6yb77dxeFcB!y`t{koCE@c@LUCsl?bg%?y0c))}HyM&zVqjA|P6$ zmvbZp*xJhmvWJhkx%xtsxOx2yq&WTAlwFlwd0P=j4UDF*Dh5Z)$WqCEbqnbIA4Y4N z>~kl0nH*@+rk>08ow-(pakEUKzUQtq`7w)b)W!coMg%6QgI9RMN-j>UO!_;)2C+b}ZFqF{qI!LX|% zlF|mRUF_GBds)Pe$udR;P|XCg)s>|zZ$GY}3C;zT_ zgrt{YnGkBU7on!Uwr>8muZ*8-I?ASZ*hXqph_g>pZs7YpLzNP;rEX9X^DX%g8UU{< ze`{x+YsnM&5;V0L6ZSyN(jgP1JG58XP<8M`&VRChx;Uw{`g{PF1!aS`P+Y5aKQ>QB z{1ZfywJu2XR-?VQj0k$eyRG4`e4DHj!!59`!KMB|^M+sgNY`}<6qy~aOe7&hr!{Yn z(P6=T3PaM~07|wokiDKDsG(iBOje9iv;&2=hpVYoJP3@al^ZX+<_h$ci3P=R!IxNM zi~-^yi7W8g#_uq1 zkALRV5)@tXdylh`Evl47>MKxvmIqv|u9jrypv?;U{v7B& z^RmUO@n3-+6k{9lkM`jo_Y^boXKil2?@u_)>MyYSLzQ0_D3NlL%yp{hSNSk^?c?HS zZh`N1iNo*}zBnJ!82h8S-X(jqCNyI}IUI8>+ZJ0H6h6umb3@ zcb`EK()YXidMr&dBCTS~^d4sfikfS7_8>&lp`7>Lf_oX<0qXBMPs9U&uv|T%0)rn$ zl3!lm+vU2O5rmC}15_dGfQwDwUvX1?O9nS93rr5D!GHXE7rsr)KaATyCG12>4 zgsjD+gbGfYCLVBQ)mwMs`j<8mGpF-QXP+lgHUl@r@+mj4gIcfK!}75T_sgy;Y4b~( z`1WB$>zd`4Rt0tPF1Ml(O#r~T`8c5Woz+Z0i!Gti_EhAqfQj;3^cQMbi8IiNrr1TF z_RS>1K|?uO>fD@%-=qH>x>{7BvvTzs`5?4OP@9Al1Hwn0fdAWqvk^3baku%7W8P-l z!Okc%<%T0}-U^&^*IEnLS!2pTqQF5wLs_mGznF8)d`2hf9+A9>|JGDQn8Jf0k$>BC zTaX&J`=Lzpyb2OQBw1=cHzx6hsniqw+gbJ+--6y~s9YgR%T3=6Z3&p}`K?~_PZF>ow2GPo%*)nsbsdsQ zgqYY~$6u3eY3F8=}>?G^!&Ia#odDa47T(2K&Mp-6MUqy+Tg#27fmh6@KjF z=_mIS$ILs=i(X;oqXVUQwJX&wFPHi*Ov)2^gjyz;dFen-TVMqpnACsScnG1k(xwsYXB!kYMTh% zywv_Sri9NOmT7k}o$AX7d36b1MvV-H7{?TK%=;lB zS)(#}rByvy7a4Jo7_)w@hmdE!b}u#FpvnOm$_VjqlAMrDwaE>!W#R%5VK;Nnyw|zD zx}DWL$jx6?5bK?LIU-b-Mndw+WrD)&TQmRuyzD7S@~wL*!c5GWYB-8hbaa@MSVFTaUOM(`ppMwK z;=X3g{nc9rD6Y1jyg(##8aBX@m&YmhQdcfMjQiGqWcL!ITlb;^$j;T!^vLdUUnnvNPdPI9XDzcsP@>|ejz+FT_&ZQe>We%P6hhSTO zUOhK+{aC8xnKMYh3hbu~&*iN^9?sh>A4^5-Z?~J;Np@ZMmsKBn01E{Ocn}MnP)W}# zp|5|0OHhJNIW8&f(i*wtcUE9{J*4s`?A#*n`CTtPzKeLlrl_WKU$Y(Zn4=0}%54Pi z9_t}#B-o!on5S*QzmHKf9m*a=*cET4>j!7OZEH^L#U`pPF9gdr%uH%Dp>9o33Rb}i z%Wu#G)kJwj%+JB-z$pO^0{u;n@gu+hDthd^+qVFZ|Li zhogkJs$2RJIjSfo^jYMzMMM){;x+BW7HzwyeBqCwxy*vR8n%MMbkF+svV!Eu*O&CG zYShlDm*dOy)z0L7>#DHSg9rk_%sfn9Zai2-McS`CO{u1-PzUBXycoiYUm}d_&qa#?ja`JK7EwR~dHo6+EPqrWDU{~S>r6&{{U|e)i>ntp8BLhbb3!u8ot~1D>Qg>uR7Y%a*s2Odwyjt^M*JbR8 zJdh3;2Dax2`uQ%MnqrCzliD}(g+S@TaGEeT@~I<^a;>oLJg?DUZ?sm0;x5GP`&^+O z&Mh?v*pdPK3uejt!Am`ecl_SLww-f|$@a4A9Jtj9noW56tkZ)Tj8Fp;xe<3%&tHxx ztp{J?z0?&IFMfS6XTdkoOtbS@HIE!Zz%9JuV^Z#?GU#r>GtY(Gb&9DF_^y^--uQ{b z-3x}uY&n+Sdi<%i>RGH8r_Mfyesa9T2tMzUQY7Gbwf7icb{YAq;89BL`VWzbZZc~BLB=_{jsj%Tmz~(Bj;lLc#4H{D zwBh$@y3c>7U+kQ&+zaV=o4}X>V`LXAzyUe#lqMrwM+Y0PbhXNv6O)o8kyj)tG_+Eb7wZSelNYCrwrFu`<{qAha_p)av^+BXW zFYSWz@0+lXrj@j?e834NqG#$%ZuL3uv7AJ)DV`32KTrP-1DQO05YC zvHoFD8wmA6K6>0iLztnXI(l8+vZwPw-*EL< z!ws>VqRdV(NbSDrcAItbn6J?jblBzjEf>gYdrLUb=ky+V13nDbN=F$c2`wbAG4B@@ zS6MubN~REG{fpF#JclxmYK_hZbNSVj{5^||MI)VPS&qDPCzFA-Z8w?Jf#@Lj3l#}$ ziwWFpgPnh}!9GA{W2PX*N5XeiDhqRbxb04Pq}TVlTF|+eGsv)mv$s$5`AO1AWI9xi zNkw8HGAYND+Aw78mWOje`nSOTjC7CJpEJ2^W_Ip5w0TCPZFo2L`9MehJ9i9pQX-%! zzPkHs=8Wq%dkg2HAv`$(eYqn%*<(0i-^1x%Sj|z8!EgR~VtX;H%KCabtXKfxNWQ zAVL#{{aa9XrCdAvrt%$1P&&4$Zjm~@nEBB^KnKYJc-w-8+bN&^0+t*aCZcpCtZWl7 zNuF+_beF_zRwnAFI`ezP$rdcr)zU$|3FEN8|D)(UyxDpfFq}j}Vn*!Q5PQZ}lq6#B zJ!%U@RZ&z``-@2I5wlj+u1&34ZDa3QRb6UTQEgFGoj%_=_dmGz-0|M`eV&EvZjq>( zCh@cTR!>+i|EaPuaG(d-^o0T;6Efo;@1$TrEop|8X3G6${DD$?AF2KQ*moXy_ld;Z z_xA)DB9PVQf8XCl)4%yt6#~jPa9{udAJKW!>*5~OyIChtpL)TYbXn-VWK{ixGQ@s& zZv|ZWD-9=F3ldm)!mrUC@=k{by_EcNRqOV2%jCw$uJrge?lY{G_&V5jJ6IaMOqVr~ z_^yL1)Te$>W^#BXiU$We2NZj}YVra=yk-knWIyW+A;n%jaEB|qd~gW(pdOg>*ID1F zW=?Z>&O`U1*D8maH5i-)`CFWuS+*T|+HIujE|q?99rQM$^fK!tdg9lADl-rxNzmSz z9plycxAs6zE%*+nQ`0rMcNR*EKeobX^@bKSaC&nx-68#|MZUxOc|q{l%1OuF#c1|N zpGK3da!%#jB)bswdYVgM2xbY_t6bl_R~X#t4_JGo+w$&r)dOkxw@v!Lh4i(JYlpbi zhZB>mvh>e}e#nqbuLu=S3nw~Iq_O~zxg|&k%LarUfG(rE&i_-)gG1t86p}#xWjlyKzYdcad&)Spi-kIt&1uNj>{z1;!shwbvp6A|_>3 z!r1X*euH9Y>pc?Zc z+l)IcRivHy(2b$G(EWg6WA+aQXx)dW2i|N2Oa+a(M^R>+l9u^s(EHkjN`9@-o$jom zgct#hp#Bym)ZIUP-@T`aS)O`n+s&1xMq z_b(7*FvZkbgSixXwjQ&qcCUdDW{orulSC_$lSe;!Z3OC{<^|$6OD^LS@-G+2ag=Q+bmyUS5C%9dp2k2?xdk1W>kVi#-kk$JlZMFb4>8@i8$DC39;GkVEYS zm_3J0l@H5dVa_YU!SQ8!i_F2gm4g8?fm`*kKi;(H>YuSu*mMO~hste?AcNjWlLV<< zWQJ&YM~dv-R4_tRP-Nq|sG*RLy;#cW4oU)#^Qm&QQid_QT)7WQkq#N*a+0lSt#I;W zIh>Gu2^E>Ft?1V5nle9iPG)7hRyIr@C|MqrE!3E9&tL+Psl7}V^{y339Hs<7&81^z zQ5n?pO_|f>J6y|;_fIk03R*eUDKfQdJXx}h7<~6SIg&-{MUvV{?KyXHQu<*nsk?rv z`*jMN()1v&ujvF`S5nii@VTOW{geSQWK`&w)7(u;%0w%x! zpOhJRX#kh9v_*i7lgkHFx%T~Q)quCG0Pc4Wez4?BJ-h%Uvo~Mx=Ft4u{~WAsF@Iy= z68o{r02sooYK>#!>VJyoyTiG7jRlt^l5iKj@mNA|{erzw%ISqygUbqHjf`RkqOisy z00lCe;@CC$qABV_g9os|i|723pdE0ZJ&rZxmaoNjNQg8EZXxygCgWH;Mw&tv*xWX3 z$Mt9Z*ds1oLG6jI0oiLY^f(dmN-RaLqpTorXDa9qV+I%NLc==dGYRXR{)oGVQ$cgu zn>~JIIHHSdk~kvAGqvJ6O-fKulOUi{X9Udsm2*EB_{sSe%DHVTcXxZU%-h+{1REAA zoq997E=~I%7#OL-ZMd)Q;6yVMIC01ZDeK@OkuvqpT0R?3t2NTcvWK1G5sO#XOkS)M z*r|WSBqkuFE4dv9$;t}e3_9bNSHoNBjK0rY|MUpE_B!#xpTBh&n?LYDx?Ll!{&!re*3*k%vC|)QXL%OHhoVRh$2^Sl#rf*alrr!wg8{~qEz-y& z(iYQ>;4K?4Q=s!^sWE6maOdUA5=5{;2WCS*ZT0&5Gk>q%lS+L$R>A*{+oVXyuduxNkIP zq?|P}bklQ9vtU%vpZ@G$aGLO`mr852p9FC@jkmwmt^nG^d^$K-kACH-V7`vZB(Pjf z5S=K+H)ZF6KfO4pg*HbXX?yyDO>JJ3w86@uTGW8MYdHFk4nR+{1K_)ZsK-K8)ax2V zg$STiJhx^DjmYU&igZ&&7bA2rD0wAF}1%>|cK;82zO9;+JL3KA{-FE#8M96MN3eZw;Qf zeJApJ!HWMXWX^V1@>OUVylBMR$slUHAUWRp)nq`iG0U}r=Ex88jDo@E0SWwq0KC)H zFtWLdQ55!(vND4%zX$*1fUp1D(CFAbDAF7XS@s!FYY@l3UEN3Y#Xx*L`8IhqqVgFO zM>7KKQ4`RqdqXl&k7NZRvQN;j#{Umb?Ked}p`Cl%y=OzEhfF6A9_4`{-v4#&id8#a zY4t~B+SJNNKW!(Ywj3Y#3+2HR&@~X|-`B3?L&7~L-fnC0T)N{sC$^fitK^^&&atR) z^ZisZJQN8QULnrM#(T1mEsI}w05s07R$7~v(dgABNejC~`r(YIle@$_bu)eMzrPtg zo<2A{>pJF!Z?O(9DR5w{bgcKUwUjY*K8L|etB`+A$6TsCaiVc#QEgn67^KXkcyl>f z-w~oKPpONKiC3{>s;sGmV?KGfPVL{Lq{*+u$*S zNF38jP#}sIR=q5^H{|$tF*L1EuBdcZWhFQ>|30j?9kEwOI~RE$1XnuQCT1qby=XfC ziP>F7cg*(AdgsX>hhBSQb(ip%ra*$15cl7E0Zm@TCAWD`xD~voIHotUd=l)XmLt+J`h6D15l`++`0uDHYPGyMG2) zwOAQE8$HPD>JSHOmYL05o}iO>AV#tJ8onZnE$P*`W8vlVGYvavpu#V$W>z@4wxMZ! z;ZWKp%XZ=-0mRvyY4lj|DE@2MTwyBXH`N zQVVQ`?wt(9XOft-ongB3V%>C%)hOhn_s!G!b*6|JgNO!_Wwrykw&5k<=n+8-2DJ*ez#4S$Gt23whe zT1;4N?FU7nAW69DdjP;88zLr!lB_M3POBd6!;Rly<8>S6nFpVt-D;G!n*EiBLVvR? zPAUG~X!$3D&}_^S5RqKbZv{78dQ*HKJg!0Be6srUzFx^gCNoM54kC{Q*vvm>J#BT1 zO*M?wO^^k073(fMetsTpws79ZQ2p-H`S{4RSh7})m9p+ zE6cI{!30kmx|F3Rf6{8%tZb4pnwgigy$pMh4Y9k-%$G~iSR2d>9lHr;yamoLZ6&q! zYAezV#FI(xW~7(THl(zrU51JuusB0W|LP{i`X*6$khtc^!PU7)0{3X_ z2p(uwQS_{BDNc9|7+2PlR~tvsq5=b@59Nu{#_oegyZF?ub`l5&Qh=u{ zfEfk<_B=1?Y%OAZ#or}74^$4Eu`~sV9;^NLXABt)!hVVa@KI}J?0xl(3#`YTHh@A@ z@%ORDgepQ*Jr-abUtIa>c!H7FSH|H%?nBncXDMZ4wdCIA&h- z%P@fg^&w|5Pa{#1F$Qeq!I6?y~+ya7u z2;0h`*aXaN-xxO1u_SBNn$2slhrWo8bU1c>VV|yam@P3gVJy%<{fEi?wx*k&t^9i- ziNtI9FPce;*ZRz)&Bh#uQZ*2>);lZk0&%~H!}qK_=~;=l7QqRv#C0my;)}DYp<>aX z3}+YsAOo|=yeC`FwV2PGM;49lliUHG*48)F1CdkRzslQWDl@##!@N65@OrOMFl$%Y}eUo?ou1i*FhWM621J0 z|DlVl5ShPYk4vs>K?Yrx$$Nh0Se7GZ?xa~Hi5u8vCq14A;qr3t`@Jj7p(tpoNwyYC zlg0Z@%HNaKcf&L1++b&7#5g@ZINVPP90uP=rwZtM%)Dvlu$EAo*7+beH1~}-o9w_- zE^$@nNw5q;)a8ohg|p*nrj4embx(+daLay zz`ZjLCX1Xu3tXW~ca{!lx7KtKID$U*@pg(MNd}N4soo4RAZa2c&cq#qMUsCHL-C8%k$S@z3NA`u3Cf8Z{}0Pz3ZF$AGH+8Yy*IA-}T z4}W~WA9CLlY)o`$eP5DGEd4X_FY;*wWf%cn11fl_Bv7=Pds66DggKngV#`MWZ*fp> zn+WF*#1%2)JmnaeqzCh3KsldtuBc_^Pcsn{B{LHYiT^Fh+8H*74RARvAo~Zn90-=j z1)RCSi(-;@Vjcy!bnm1(Cdb$$XXHcJ1AL}u-0lpt%g}4}^}FJvP*-3qN;d)*h=B!O zZ`dk!f5!q{sBsD&i){uS=H#|!)XGPb{~b25-(5k)&VcVbPt@#X zPV8ik27rF-&}kfijZC}IRjnaw4A16-cj`nWHUwzER{Tc#YkLzZ)bJ39&mSZWJ>-`N z>*tW2HaHX*N#}B+y@4~nAs!~wiJs$S`5%WVt$~zkL1-ctP}Nym?jt+8(NAvg#>Oja z9*OCtwg(t?gdTMe!#fIiQ-Auj@9g5QhPOdC#*~Au#i~|Y`VRVR_Pnz_>w9u}c<}PQ z9#lcJoJK*Q>PV2Tk6kHyJeta=nMYBk+7+}i>SDshGPUs%7J9NYkMd5LmBz+3(7e6> zd(9>BvQG<5TWv6s1iS;|3{#dmgkJ%jw-3aU1}Vpck$?8@xL!PV2CWe%g`uyIp$o{q zdH(KKE+5xY{mxOwep5p_{^5RZc^<6u(e(r)hd~9zcx{O8(v$aDl60D195nqubRohVO4aRjgYQ1&NY&l>gWg&$U zbxsypadx@)-PtI~>4CB%j|=~6WwCCTZvf|S)+*dFrkU9*mTb??8actD-Petzxc(+w z0q0#2D6%H52)PPde#;`6Wk%vYq-`|(XI^QWFw_>8TE zvl}PVb2x~LOpfNvFV!Yhj!yZCc4om)P^zD1opqQ))z=+U`m{)Xuc<$+)MEq0a3>@|}F z6`2fha@N*lDq0ayH8F^xd!QP*;meS@uN}3sQ>s74a-cE#zBSBul+Bs(K79Yt>7^kd zaZ@V1eBx=+A5q=?vx7JeqHr{AAb`;VL6kFRw;zP?yB#3O=ihZe3a5FfH<;ZVFpvO` z-T{D-T7V5ub#5CVg{iE~2UMp+L}HoXcwj7C95WA~-|4Q*sBb*llz}y7Po~sb11jZ& z;1Sq#3sW)-io)}KfI2w33_``I=m%#o_oC22aWrVHR1Ty0Qy8HZ@luFYwC5FFNvPrC zP>HeJFvOGzh8srV;UeH08LAIU*vEwu@d#r~xi!5jTx_H;YY?Ahz?J-Q!6+m{42Iy7 z%7tR(Otk9FT;Jw$Tyr@0CtB%4@+czY0)Hc%j}&j6y|qbXp5v47k+GJrrFp7=-vnvd z<*Ib5k#N|A!AtjBKh@+ti9kbMVYsx!u5bocqVR&Rb5x;!5rsX0=c-gvf(zm4v0+eM z(+Pj@BbPA6r|Kr_l!xFZC2Ky|09X)Z#(WPSmL}*VRX)R|Gz@1P08wI`-^ye~>Jr9= zqORWMS&hD!c4d`$2m!{NiSEG`5Os(Nr}wa*?;c+G@82Cbjz}lNi=n6UZnK85szcWy z2-5%{g~w;5JkcWFc``|&w4fqIuGyA`GoKv9Ou?9G@XUH%0D)TDNHPW0dficmLWAD5 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 JHt4fszM z^`UB&{nvi@EL}Dg2l3vS7;>7oXZc=iA7xMlVG0uf&l`ir15l>cFi#dyBTcMi=tVN%B65$O1GHYZYY05B&T!GUB0rRQ(ljwL>os(Cml4 z*I)pzqKStpC9#-rl9^Bz_{=DLRx&1MN?Qht@QCCs+U0*B=v02jU0EpLqL0)=BeQTu z@qU6yPXk-MRlO&iFTaZ)x?z1EVlj+r88T{ljmD!5!gqFr?a)a0fS)%-b2Ul5noTqh zV@g|;=-Yey$XD}dUA!HOEOWOwvy1&|<2G}{O@CQtjxBk2%KYYun*z{cO39s84iSP& zNyJKj-MRBUHZ_rxC$&0@lhu*p=(N_~ejY zXrvQCOV8I^H}c{J{ySFn>5X2>4JGTKEla&J0Kwx@X)B8e?x$KSB7{Zh`z9)!@vHt1UAVxubxhE@@{QQmLGPc!T;?BinPMbuB5nIgdO-h3su@1$V0<0emBv9ofZ0As zJ}MM!kg|6LU=?(0*UxQ$h70fDRit!#55PZoGuR%JJ%tZ5;I+0xiB|;-2{m5#J3knK7!g{RggrvypTWc_9~V%G+So&;pG-`GPwm6dt_#|(bKgwr zgv}PG?}^2r-r43HHe3rwQXeY>g1XK0zUF+>bHl_)|HVr6RzZvQ%Y?>rFW#GjzQ7bGREgZV6ho6ogt~ln??1`Nk#2W(Tr|!fBxKxL;3At%=2mm%G{osuP7#XbEM6qiFYlTrfe=4pYd zx`HkhPET5qU zXhFpZ`g}=>2sFVTwc$XTta&wq`K5|f#06v-qDrdlG>G>*guOMLV4e4(tVLkyN>RAo@+FVnc}rRY7?J`6GS87VZ)*mkWLZBnb@->Vo=b z_o}7$Djz|EC{Qi&zHkb3GN8{OsE@D({rRJQz!3UuRUI#KL8CCs_)o7X;dY8+*~5<> z)ZIO(&IsoAkD=svx5*k1yc#)!X}k`zbdV)^11iH~p}y`=rI$cb`pKgaF0c*(tDWQxD!tLY zOclq7z`M&$iMgl{KvUa3y28HrRk>)0T`w69bv9t4iHfkzaAo47S!##j#%OS3)Z~tj zfq?zej!#?wFk0~|*W*vdkHL(HE(#6gG=r#c;gFDgMz0~xOAyyE33G1q zsc$i9r+ua35~|z5aUQ_9ssYtkm*+Ncm#fdqcu>(}Fsf=gs_Nk_nC1|cdWXN?58scU z0aANFj=v#IVuN=N`WeBMVa5YDg9ap$25z@PJw~8juf-nkgxWa49T9(eoKGdpHIu0!F99L33$^^N0K%Pk`#7(f&g>n~jnKG9EVv3gKA-9vSA7*u9sbyrq zQhyb;|N+l6s>^n%1<0@x$F^Cd?LMMNWMA> zPrz!0S!*q$jG}eJHnoWJH$Q?X8$km ztsKI2p6@1QO{=;$L2}(Ic;n-Z>9_IgDe2TspRTfTJoRu+{-poZh{uFg6iGm`y8)7W z!(hxnsHY6$Ozn`}wgr(E7Hc|VNE{&0ZTK$^q_=%Y9;FWJqE^5>B0Yl)DAL z*nAw1yFq613&{)rJzI2}M5{H&tB7xb z80C7UC9ex~y4MyxKWma`wp%!13xBS*?4a<5cB}1@fB!NZBo%DppoD;FB9ZQIqrNL1 zQ~=s=V{Pt${qn`5M`NE6k3atz`<(vnztMLcUGIKzInbiezDjsF{Sxh1F_`Zb@=&NG zi~wE+(Z%Z7v+Q6#g9PGswk@Nt(5-lbnn6TB_gTccAu@641~>;|^Q%YY&x)1Bu3XS+ zN%gK#$KC9iRmYRvr#q7tBTuiTiLfjSEeZ|atb!WKo>`h6qz50w9f?28Jt%v7aRB_# zND}{eW9;@%uaC!y{nuNhBrTY^v`ks+p~N^C$!h;s;eIhy$V*G%!%`F0UebnMLx==4JO{yl~-D zEXcXd_l!W{;zQP4IWilAQ2f}6JHCxAe!uqaXJrQ{=iPsA-u>%9w~+zCdKx@rrX49H zCjgTYa1x)O4XnaRYzQ&T!NK8h!R%VK+n=TxcP4Y-J04$Ua2FzDfmc3V?D%3Ueli%6R-nWmUmLG}7L{He(}%eX&Hj&nAqr3w}=+dNjMTWpmb zwu9gF8kpN_czmaDv^JXYsNY=c@OgFf6Hmge5A&sR!V=sX(F36&c`RlY!?;i6Vmq$b>o5+x^5Zq;Q zuV9Y1Q+UOWwNSi~#SmaZlky?_$L^&|*3r{Ji;39|=P|`%9`+S%<}OhUMg=l^Bt5vv z-jw8kKg-djgRYFNZS&jgJ zp});zpD{C;$hd~DvPjy?;ubFl^m_%rcM*gqjw0s=Of(=mn|PeKOdkk=dI+G>=Ynht zvt+FZm|u}&_fO`fZmCOTbMXW7C)Qji*>_?lSmYEa z+ECik8roQqr~sj7q>ZobZCEP~6k9cWl*XX>XNN{{b5i@LtuK-(dO+=%-JgEwcv!r1 z^gNKNgC`Y4raV|<$7ei&iq*+h)N#G$LVw`p|j5 z^Oq6G{kqddwd*u)sbxv&wMlwj@s4A-P}U34Gh4z8zT^@!Vv)>Y_p-+;(E9A`MALq z^qd4GtRhuXPt=UVG{E}>RP#*l!c8fC;rgLFYaeZ}`z?a;#9>X>$Vh|7-2grdU61Yu zeL$l3Ih=KIiv8&wT2(&rk}Hb6CHXNQXlvl6bO*N5Yik(_zb8t9XFOBRMGP1~2?*ZX zwJDA>Ldv}}t5ItysvqPRRG#N7nziIL-n3jsEB#lO4ElieP?Z)r|?&7@=u52F39 zMZr~u4g5WCyb7XOdWTFzLuBz`IDmE{(ytof;~xdhnXpVI%R$}>L5q!-8!gBx9LqEM z4rNJMzRMuZyNVKo#=7RtSm(xune$t5vxOS}bXrcjM!}@vk@A z_*}Ic3cUBXck)6kr1qPIYgUs%@~3*b(DP?FMavf|8B#$>Dv5vuccw$;Q!g))*-WJnR;ES5$A@|0SsiM< zwQ1S++K`yzp?0zk^F)x-$)(q0qfC8=u`Q(QNf_;=bX+t~CBMWZl19Zw=8gy5#6CVr03W(12;-dM32-(H( z>?1}dZDuR7O?*+Q7o&`Z@cdHqLsWf&R4NWv2RRdA?UqRrbr*gVxMgNE(3-1=Ae-Ju zwvK+n&wH|wIHDwLZf5PNC-K=!({aXggi7D;utmuneEm!1+f_eUTtsTE_rx7dtSp&+$42QJ&hcqRea1R5>AdNCiE z@fB@Gk;zD!7cF+C3(3|YNq!HnxXhXpIoC==ph@zIcs{FC^j~=(P11R~=%28PI{Bz% zv&=NQ){ETfG1?#E8WPVQ_YJT2BN;snY>KaOeke+T_;|+inXu6Fbqvl$bwuv9t(O1j znezjM*J5cC;W{`__pNlLk<`n}gcLbLVk8+wQ>&a(GF$mP4JSFdEpeE7{ zD*Gf!{!Dw4W*&#BE1~* zXwGZ?{(ZHZfP;b6jFqmTKu3y9N)A^YU3lFyuY|&PL|DuN%6hqb@yfO6e zWX*4vjOgZd!3KqgDE0vb`-SZnw-e&QLfnrAK-dW+!MO@T_j7Y8u6`%~w{n8+MJTHl zIu2i7rsC#w>-FZ}*_(bIs@3m&!Jp-4z;j2RH!PXnHIq={b$x0-LF$wre^+tOnomft3t28}p8UwD(XX$%4#1F+h#aR zDNSt;PWTtnXtgi`c3dr}_=giRl9;nngRN#~IR>MxhdmB@H`+V3?a0{2xJKpHe!cG#*SBISJt9Y z008l4tfz4QeZmRXhr7|gm= z{xl|->=;eHT|>C<)%9iksi`sz^ZwYY8Db>YCCfF(lrZ!&XDGR5$cAscOieb;O4ijw zR&!+x6fy12m~c~t6O01ZwT{DcNy1^xBr=JwOf>6cTzn?J=;AeN>vB0} zXVrE!iLaix&?(j+>U(gkea=5GlZcUmcbDo;%Fr(%V--*ivUHD02|9vYfl z5ls-IfFxv0CGlfkI{lW1Wgowl*CJnQ{+I;H}+kGWu<%DA&=^$#)nY>Z0BsEX6~ zQd#mO04WL5MPOV4z)lGC*#s*QX~7}=pj=dNO|JOG_+xt!LRbbwmZLNN+v8uqP%$RF zAPL;ZZD}xRkhnBRF9V?$A<~OL=w}G zGA^#3;6k+-9gTFKk0{3jYs#n|Bv7ed(3GkfjdqG~zDSUKxhj#%SJIzha!@>P{3rFM z3YFB4m?Gj`c&k6{L{UwT<1SuyzrfUpYds)6%I1H#RZgCjcceKY$@Wey@ifb7y2 z;sz8;jsp^C=73M0v`EIX zkR-HHOPiPx%PePgL<_qOg7 zCmUaT)+Gm5$8(~}rn-)kM0=r}=H@(4jAr97niCx{9(3kkEK}|qv~4v7`c%E;hfNYR$*bz$pDeDHs6=;c7gMeL9?@lf zC~aypcRDm4WMdOlifzj1&o4D{$(ZWmTq#X@eN@T-+U!0s=^cuvSaWS!HAq`5{H2t6 zb0MwcZFj8do!iECCl-FoB$~+Dg??LK5S>0!on{efB8Cw|oE;KGlB|@IR`0$z8?Jc0 zJrFvA3cHi!ko`uQ@%gU6a|K!0AEU;byPf}rFF{-|yYn1s@Y1b?#xLPA34|`kY#Eku0scGdZvQP%&vu zF_Uvk&9MwuXR@YYdO1tyydOOK(1K^m@kGG~l&~JDXD68bktyD{SC22ce?~*fxm__~ zY5fzIE62BWk3WKoJXe?bUJ%`#zDVY-+M?E{^j|Fx1ngB@@3Y&SvJ1(CoJtG^PrbOV z-3U77c^6{7dq*hp;ydHrGi9LOwG+|De#0*rONj0o#H2sWhmZ}M)EWBkOUI)um0qth zGveiVS+V$LQWWV_!>#cVjCO~V8S?I@tz`BdXo}PE*WY-)`qbYGaFNm^211PhOHOTu zHWuNo@x~E%DewC*Vc|@Xd2Q5cOs)dMEu4MX|B}}C|txS_i{T~158+yzcn&EWRoaOaP zPSUn@gA|~Ca*At=ul_M3XhNkP{2x)&7wPoS^QhJ(q3iW0uU+tpOBlk;gRbH^%L5{b znDEEjR}Xb`(Z=)ZNk#CXQpn+ASlt`-elN8KY4vHlq}4Z(r(O|J!a-c#C?NBtxbHLZ z<(FnMVt2=*8<>>1Yg&9QrOx}P0B<7YU}~o|R}#-FyKMfC5uluqNU*ewV@+#E^XU2% zzOD*x9xjncbf|n+*0wj>_e$ErQ9y(J%E7EiMo}ZuxATp0uQKID=ctSre|Sub~i~Y!j1Z z)7STLs(7BKf9?3>BLm3uvZsz=opKzkvzo6@eA|v$x}WU3{xZ>%RJf4ZU^<&AD8UPp zgyy`xIO~^YeZ0*K#M(XT3guaCA5fT9I?g!wllT5>%bSo<*9s}7;L%RSgZFPMa&K{y z5Xxy3LGRnxw>tDH1a%sw6aW9ZfQf0l#Z=vgnKQ(xArolTCk2u)JL&$f&?|gFy+S^H zg@x8*8{chx%62wR32uUUL8z2Hbp2kruH*EKTak|=oRzWEho-E;YP{b{^Y0}FaqErp zXrJjC^k#8~6)-jC>nl=LmT2An3}x~5>R{$-zGX?E<_7p1)KH7nRZdX@|?G=bA7^V?2)(Gs~VqRArP%d!3R+}wLX`K;2#v>r%ncs zRynCZjrOJA%<@o%fhN>nMF~CBt0=DXaK^=l#1v-8Kd^blns6kOq=*+7-+lG@Vqb$V zRMOS!K14auQsMpFCMZcWRMS4xUNT$j`e#UZwwPJu!3RA69_*a<He=O45hzSsasEB3rm7J@17)kP7(DNT5_wHn!{~*6v zzR&Yjdm}!lV6V*NYn1(FwQ{j+(f#_g75BxFFh!5e>^bh2Uo8;nZ2!Kt;kJzbLkO70 z>`}5>0v^WxYg>$Xrd(hxZA5hy`EUDHOM@qs!XTf}uv{(ACy85pD>tk8x9HR^Hq_Kh zla$GE(~C69+G)}^__D56%n)dEJ)YV91{o<76k)8PbtE#MUNV{+<6WJNrcd}2w(*{NDwQ?%EW0kemOfKC5>Tul{vz9< zH$IWMIYRJRp_E%o2GR3FT8W%_*jz~-_<1EpGuR4FNuFhrxsNfuov$rw({#B(E-+%T zwQXy2@~JPlB#Cy8#b<%&u~XuD@$8q1}2?jm&AGbwOg=Gv1u z4XWvkEjjP`u8}G4oM&VA+_E4LIFpcRsZ317pYyyAiw|%)AjllE=!-^^W^58of!N!2 zhjA1yqEP1pLx5JN&)?fpE;K{;pVuS)O8*$T!x-qt3Q(*06G5Ry$6dZ3a6*3NBy%F9 zW|BFByOxs^Q4`BhZnkUeDam1z)dndy2+=7d%$CPf5@sX&Sa z@o&MXWOmOxn~hsz+ib((ByN`umrpYfpM&jpLZy5yhI_V)3?!(_(8FQ6Pge>g;PwEqqAv%8<2 zPu&yCG}K3kwT~yU=JpKmvL033B3*Joe@+!+9`fa~Upf8{#cn7-Ol)aE$!9ZCbL3lS zBOtPqIt+gSVC0aME+fNZqm?HTh^9ncyj7FMVWmoOvbL$>0@%r~B8Qx6UeachiZ4L6 zwhc%HkO5Tnx-7P0A1l+>?T#W8UfO$JB2fXs%~5tE-p$NxkDT}j1W|Fzr9#kHlnlEk z^_6J{uPl#jB4o3_V8Z!bnl}|qc@$KVrncmTY*5?77of|zI#h>NcHSa2L~e~7L8q`U znky=6{Z(d4Nf?6TdnWtwb>xzv-@B}hmMBK!dnQBhvL$NPMR=XuV3?sM+nbv?b3i`uoh zji3E*Co^5Nyotp@yCxYwzBTNE%XS{5Sb8hC-jKvw-I`f zvTg6}{~}U&lA*ub6AXkSd0B@# zu(IXBK!vRg2JK!5O)rsO>HTH0TZXi?CeCn>fzAJrP6ccV<#^<#`e>v{|3l8lfpcj-Wy*bBXe$Xf6xk<==&sPLAK^%>>E*Y z_gLy?=4P$2yZPsPN9*roF>(dDK)jsne1235;SoOY05u7jFfe*$OntK~L(%aQ&K0Yb z;`B!ej-R8^c>$!a7Vf_sm6EZh{qaehM`5ssTz0|_=U?Pse0`c7J%`g^-7h)De0aK! zv&JlE8F`h(52{R2@(=}df}=94PSJeog!;Q7vJ~uh&R;*u*MVlUtyb`bGu1#%-92e- z)~!96gzR6o+(%_zSTXG98Ln%SKM0n zP3q}hCAd>B6vzKD%)#Mx<n=Ta6Ns+S{dLdUVPmrrJFjR}JRsO~#|Aod9y?XotYCu6bP(J8AW7RIyR$;a zIF{2Rq2Z@_cv(pMw1Yp$o`H2@3Pv?9KAl{$oW%Ahv0FoBM#yyZ@$PyjYwOGW_ZHy$ zTS7O9gbjbTWH6U*fUouzkXnsygw;HX%<{zBFWCvq=AD+{SkXiI+Bueev@~9_J9~Uh zrSE+S2_oiSM&Y=q$y9JyEEXa7X^p7K80UE?gy~W?T~v5oj13Uz2xKxCmb}HOM!{Nx zhRJtmPzMHq*{4uzas(iiKPHxA8*@dLb74(6Z1U-^GaAgya^ZsD#gTDb0%)Vjwb4BgZ_P zLE~fE^g{M;w0=a}62>-ZJ5PJ4qmN%yT#b?$lEu)3?S!G=z^|0~Dq5*12rr8Zxv^9X zwV_Okkp1F7HNlg8DhMW<=R)aw_s^#^YnW^|=b@((cs2O#!R?QohBhJK&ynLl-*;`y z#R$q*WGUaVXycxepv5`a@$4HEUQjb_PU_P9mST-I4J+HK6xGS^wCwfIc}{67Vs!5} z7Xs*r$MTD@A`eiPQJ`Ba&TLf@0?CH{ z``P(a#R;>s$F(@A3&E=Vq$nbDj`O)DpQTPnKm$bGw&2>0X@^#ga?vo*%N}N8&#y3XpLmzD_clvFO?8%Y*rd8vV zzf3xeD@V(4#a$ zt$T2lWM)!MX5oxN0LIg zec7K7y9T&4Jta%ipeiC!@;rmLVd3i*@|L^^gr!2}Bv~ps%E_%mc#aoOf)Fa81YYI) z7?mg#%oRgXx#aYy7f>2cHH~~?Y;4tod=iJGS|#YWnN!3Q)Ir;~lH(|vwGsJoVnUVg ze+^v02{BMAjCNf=YjCe>lo7!C#)dxzkmL4`@cBY{?B}CrKZun-rl$=;w)|?)WB5{G z1`u5-HDx!6f`p}9+%}1t5oC4S=W{b-+L8i!=;_B$V;~m0Ji7Lq45=UQosluujPhyj z@Oy6laW5Bqi4dwo2>I>^o*LUJ^oVf`3C8e?zSI^CaTO@%FR&192Rdjlim5*Z^Br&-d%)qqTL`QQMcb?+|UIiLV;#4{1I+_deg z$G63BWcu|Cz*dk&Wo*Q~L3@EWQwZ5Io3jsdLW2nLY*jG^#@I^}w1bd^nD!Tkrd~Ld zBsxa{ocrK_^|FF)i>6MAHC@;LUQl*CctrwWiJ z9LDrdS6+RRLWSmZ8spcErWoThc?Szz;WK?nDP*9Tx5UcSv`HW58Y+jI)Yc2L*JDKE z?qd?~WAzxb$s+9$v(|>Q-qoI-yD7bT(dGhaFBx3OJ@0sHd3K-j z9X=J@UM{?|T;#l5e0Qb9W#yUMN~!y5ndfS`_gaO|TBYAwRlw)!pwBfS>$Rclb>ZtT z9&FS{ZZt$~yo}y#e7xE8WV0E!)e^ha8n@k+u-%@x{VHkab;?dh>h7EL-A=-87x7DX z)|an#ru6FU;9hH4wN4ZRvipK|29(dZM5$2L;c}c!{K=2(M0pnWXsW1 z+kexq{+oII-|U;?xvt~+?&F1bCm%^Ci@m2y1EsUw{8x{QZ6Y_t)9w#mVLQw?E&%{65~iIQ)EZu=?}sr=NTC=Q~qB zHb0zw9{IjDc)HSmy4-WJ^!{YA^LW1fznP|^$rp!X)!#rvlqg`BqC$jXq{-FQjWN zq-xBk+?r2TpHEVo!>i6ED$gY-&c-Xu#>vgb%FM(_&)_7dvEtKDu2ok5Zx8$maQAwb zZ(f>)g46Se(Z!YZ@gR8(<7z7Pb@&aPT~=x;4Tc0zS_(Ls5WegzAtTMm`O4A{Po<#$ zBH+zsCJBg8o=e)eFB#9!Pre~V!uHZD^s|m4+RlixdFB+Ic#ZbD`C5q<-__3Ef#gO^ zPdS>ly?&{MU9HmQ#r@dj*DMPC1L)U*OJ>hR)>l9L%o2K!y-H?_e$}*rYq%Cx7G2ng z4iU&;@E{g}ozXnm_aOsIj9Y^RP@ahR(zgBCYDScKLuvcLLOBCviHTBM050Hj%MVi5 z>;I5k*u)N#mGg8K*-X*9FE6JlznDH#EuP4(ojK4=q@()X`o**uanI~(WSF)>k8ndP z^G-&~qmf*3@=VDmC*=c47o2O58``1FHvzC~-Vda}WgSOW?V`(ukl9<0Me1G{vVko0 zU4kXVBTzUF7<@1>Dq5%_=+0e%!mQX1Fj%39ZVwOMGSfXe zw_%`VD97YESk2sg&KEky?F9x{3=nMWMN7>+1l&CGM{@6UV%$C1&wh|Y+%y5efR?uj zXyU8(%~AKDLE$e&K?OTYUeCCv5Wcl3sQX-D_!=84^$NM_OH-h_KJSnuZK@ zE(+@yaG0@7~jv}ekKHtZ) zloJHqp0~eZ^K0r+E6gv)1UZt0bN*hK>Ei#oH=FhnbLyaOO}SbaRrOzy_rE(M8sB#2>fh@=L9pL$9$;e+?AR)}@{{OXuTwRDP3sG9coi1w z^3%n^n;jEFL3gyi_W7FE_c_liT{$6Hwxeu|Bw%_k`Sot$fAx(q(s#Vg^@C^9j~Fkv zBDn(GiI2>O$KMHfI>O1;l={#EiJk%OSA(Z7D&AP*VW4L4*gp4`zszRqUVq7%rZRdKj$gnnZnW=7b z=Qw@ky2{TpJReWlQ=65ieUh!xXKE{Ig0uVgW4Xil6krA70WUHc89d}P1U~uMg;ua* zQ{klw4MF#A8hgGOYmwkIoQOY)D#MaUFa;Y=il}uhY}*_0zQatSDb zZYpFMtM~(|(1Jb=r?-ZQjRGZsx>3V%0VP3KnIePKDSPDJkw>#l*UXm(P0>341+6o% zi|r`?OA%tta?9bgr@yA>x#iTk^D6}!q&p%!fb@u)Iko;#``@=PD&B>MjA*k66}}=a zS%w6dy~y zg7>a*`o>!w%B9|j=4R^T6+|fBctm0G&X?#W@|&2#aM*o~|89%jNy9hr9U_5Ay(sesQH+hKa1~=Fvz=6&amVdUebt+Z(xSeaW&2*nt)wodof`LcMOfsVkCMYdZM%&s5 zb{1d4=LF?X45Tt<-qNKt+So0P>M?K=e$E@;ICE5RtWK_f!vJ|7k+eX*hR9<%%YpSA zC!pO1sVUSyCO!SN{uW^x|CqD6SR z6Uf2v=BN+ueD>vm$#YggOZ9g5(}bml)5ZhnFaQPS)A7Jf<05V=^w&*RAhQ>rnaix# zEvrWFZ(D#e**6Bd`~|hfS0<8`?^f3IJBmjBd-v5cJ9B?(I5XGv;7nZOW`)yH$X%Zw zJGGHE0T>I_LJk4yW(;Sq$Me<&DIZS+u~?BwLBCa!>6;v_T3;XapWx&lo)|$z7fyvo zOF82D%rLqe4Y|EtsGDX=IE03hAr?J#&aDLNY1&cVHRNU0KQ zC-)B8$YM$|sRl3gMwd_I|2(dl>Dg$q_w+65=YO{eO3!~+5%Nvushl)hML2TyH~sg# z#ARmLdv;x7Rr{hX5110@OWy?kOYINP4aV{9R)@bjnvC#O*MDbR<@p-|V>r&6-g_tY zc^nPvU(Pitc>37Xsqu=U`ZEG>>BK-PVXGWr`F>?Wrm6u40xETxXpGWA*WyNn@=M|A%2v*VLo1dR$uIFexfE7 z?%NBB`y(OFJwkvc@PQQ*`+0aqpEyso;s}$Um!dpXf z?-qDTdu|J>ax)}PImt;yjZ}Ep&)j>z0~*C~NRYrWUNTG=AOk@L_SEvz4;e|jd?o7a+ulefquqZ1g;jzTk?i5s&? z><%4QDOn&nz4)-p(V1()mi^z041lsj2(qw+wGb>#09g?3F)K?|6Jq5b6x%#dw2fQ2 zZ!5kjxt~ak=Z;s4q2m%(H6}sOEfJcU@Ty-3hp9xG*vwo(X}7aXMlD9B9lEqDbk*_l9kG>`!WjBt5&?jYb*ZRwkkZMYOWx^#0Z=^2ZGaCmYaD)-Y6kj*?d!PDF8mt2$gt&8t;3K;a!59j~F6OKL_{(wFN z!)~6O9)Kca@g_4?^lM$J zH1DzF)W1J8>jonB+Y?ARmQ$13ig0x@|ASXO7l# zog$O@4iSt5D*{4IcZ*G8bTxt?*V57q4G)k-)^5JXer?wKc9858Sd9d(N=~r?XP?#T z{fxXbyrb7*0pvIaa-fj($IoC=0ODRY*f%M}wKN}JIx-}v8t&xNo8w%oE{G-P4{87r zvlpK~V#SnsMtb+2Jlbo|CB%TFo51RT7mZjj&({zFfSh>0+&;&~1P!;Bf@k2MngdUN zy?*i}k{73(_mA1voI#^MUos*pZHa{^Eh$W5?|Fm+3*x-s8ZqqU>-SLB)%FXDw}X2o z*FK6A4&o-;WT9tQfYGqKO`e+&;uS}At37<1J(JV@M6^1tSQ*KDT)3E?Q#ynpE(nV? zq7QDTou6q#|G<4{K@KQn6o%!NK%g1621UHx9vaWHBqemr%!0YwXbyEq<`&AYeX8h4 z8*X?u7ztPhB2b1YSo7*S5BB(y@Q#u@bow%_DxyJ8%6OigDFf-uffokZaThX!%&gR? z`u3>0sS9C&ssMhohnIkKIz_O@Mz4(~`iC>W}f3ANl(Qvs0Yi8y+EsI{oe{?=Yxd(V=4;}&hFHI@0a7dn*jtB4>gq_Q%~1fqAxwMPy(wjV?)8|b z;mz+Hu!X_<&IU%6PS#O&KbH`5aLUF|F4#f_;4gCoB@bD0KTYOTMI)_H$R{Mr%dd35 zUq6wK;+^OSMY`KcWx8FoRE(!!CzqR+uP#I(%@TT@eP3F%jC>_Ru*!e@nj2Atd+y4c zFZ}+&W9;nwIeUjCEn2Es-wTPN&WyyL{j#UoY>5udF9;p+M1<@1!Tc!c0}$fm$|%?v z;a#t2P=#%o4pFsQMM4@zOUY=Cgt8m-Fs(qw$>}D472S@Dhs6&W{Xtk_i_2ts^P<4= zL6NJGhQk{7G5`;swRC}l!8^){4z`J*l~H%Ab<sX|V(&lMSwaw$JV-~}vzQ*>Uo`WIPFX(^#STrYQp+-CLTlcLO!*c>+ z>{D&|sc*;nVLSG*ZDOtYlr?E;)!DKx>5LB??6i9~f%B&@hXUwHVaq)cZEm>3OXccS z)%iAIi8uuRJ;DdcjaViH``HCT7`RPQp>0HU7;8eLQbUHJkg)|tFX^EZcY5%jip)?_ zCYbRw74vf-EkqioGt>1OJdrDC;ggqWQ>JSqg@o@+NY|^?{&vg<1c8ZQ&@?EDLZt1} z>sw;LE93qfPw)7~^*3UG>a6sXih#bl{)m~WtZCMgGFf>WrwQMIoAbf>2QqLd0NT*t zwef=Swl&)n(17vQ#?|eajMFk7G33j?ox!Z+l2k)&%Z89`x;Uw+NHwISFI;szwf`EX z^fMHf)&v!58-MYUS{OBNUD$X`9)+FV5t6ts^&#+PD;81H^6KrDpV&9Z^LJt`v2LT1 z-0)i>@12qte5pP#ym(JlGV=A*Fir7ee3?==WnAQHYxwBL`>(b8MxHnJl3B!K5+5;H z&5b3GFLJTA6R3()>mXST5qb$j#utb?*qJ-viF}a>F^5;4e!7;n$U7-uUK_9!2a|A< znrwZYf*LXn&8qWT3a$K%K?>*}xcn+@8OSNSWHnWToibd>Hs$~X$_N&h$r3fZC`6{S zDH>>Q>FFUd4^4nP6u9&1K zYJ2?rt$SNDj~15{%pC~Ja%H;pa?xUHF`a4`YjjjR-h>hc~J7K1m$cK-G~E_k48 zxe12^!^P`$6-n2#%KUe^UaL6tNtmM>0C$v|8t;4u(y#Xg9PPd?UPdS)yKe&DQ_)jT z1@+wofUnGDE%!TyoN_X_2cS{|vG1aPyVwV((zO5b!DHmpBKJu!`m1%bmD|8M=Ly0hCULjxM`B-X)|{2u91>Iobe_N z-4=6;1k>eR@R~-f_l6nzN`y+0?O2WsNRZ)Zj`%B;SquG2>sz6hu*Ek($0k@r5LsZ* zuRs7<5Rxp2VB(Qhzy*uto;YmTJ)}}618daiZeimZ@=M&57O68}cb+Q@c8`h@RXoSi z=nMTx*I7Z{!*Up#GtveHP5uk{MZNupYiS{R{Ejx<4GD7q&>D~+NDHV#DReEBTm1@i zY~c)EpL{&he0-L`QZ5JCR61d#x&E#|chj>@)%5Q4*2x0&WRF3qFtDT)x%gd{8^=HZ z_hug%4np>L7}L`WK`;my6Oht85ICK-L#2@6oFJf4P2?CpS}{aUZCs+gPa+wD>tG#s z<_)W{fCkV?f25VCNq(a{6M&6D16Kx58aUM< zEUXIkc*TiLGY}ySjFm_KV#(5UkJePSr^l1%z9?4Rhz-X(Fi)S>~ zFZsZwsqA55JN61*yzG)&N|7}AJ<}iOLZ+ph#cb%7yQC{r@eQUgvs^Dr@?YyA z>Nz{KP-H-sNFqyDl*S0TK$fCXJ^z- zR928$6yg3RH~*Y{XnYR_ipd;ZtzqibVVvKB_1=CMB>JEA!^}noZIzl+7-=3zr%GSg z7ky(_R{Nv*_Q6?m2r9N;EpU8G>y+{O9#)KM>ZT#@eNNL(rQK(f7%l;CCK&{QSa=bOk1v1fL76z>GZEyq5op&>a zcO%PV%C4CKFtb|?+<)2pg??CDf4&<&PXs7cw&~eF)=6Zt#o*!@f(~sE)U7ebl-1b& zQEfQgA~XFr56G}4?)BgUCNJU2nAw6{t$KOdtC#LZ44-0h^u*9~F~2xd=m$qR z0p_jjHWETp;i zgYI50mtS#QDwYN_KZe8TRlr}ZKPukzzbaGg_F-dasWk~uC^L%zd7ahSL4p;^lsiBAmRs_4mD(P7YaEG|`8+A%Q4Bp@7$td0 z4pUnbfAYO0mZ2jVTY`s0WxotY03$UukF`k6AJJeP7h;=E-7NzQ$LL)m*N6@V#>QR1@wq)wi^LOukUl zpk5HRj6+nCNs-GM4^n<$(#^DUt}}Q#+#@a#V1O4sJ}>AbHrPv%5{!%V^IYIsDXu;x zgVXxakKzN})}+80{TRNd(E@Rmtm8gwduo+wFYT}8kKr%(O#%@U$F{uCZEhN~Co%Ut z%6_ViC?Hz?LH1mw8Q&cz^OKdR+|bminu@QhvpJzip*N(5@XI80Hbyk*b?BoZzC?HX z6(hJgN)J&k$364=7;^hJ60e2^6@bt%wNvwqyr$=?5v&z`66kU=4(uI z>fjIU-G(ihTCK&zS^BQ>?tFC}pezix=T%+8xMFXyzxS7M;kf49YS~SR8BkXF#H1yj z+mS>LP0d;AxnHujtI%qW6-346PPlQg8Jy~R8L`JMdXlM}bS zR@nX!Wj@$5a+7#PG#=o;HkA$XIK*qP`H0#LZpeHX;|(f}ot$Ho>Sh{XYX+=Ewhq!H zc5HE%1LBF#9mW&VKHX5EW>roN$PIhhntnzy9Om zF*>6XKxFfi{CF{ad-KV^SOTX}*{iV%oj z(i)`Uz7?lnRI)2iriTbTdTf7N;!EZ0m;N5$LvIIq{;tD(YUSsB6VNl*`12bw+(=pr zbAhc*x@{Si6D+L*Pod%>>y=+V*~3n^@3Ewb?f#BiM;jn3ryw)EWb;Q8UN=T0eY?^J8f-T>KZfNU5cuwxDlX?+ZR zef1!;Z0+D30*`=p++3NxiE>QC3sVTn$nZT(p%!|#DXY}2uTJ6k4zyV=%zljvY{|!U zu}$Gpl~9a%lU8*1`n)^^yu|`u%)4c1|`B>U^&?)z0k%(wRQ;Nl&O2z(j z6K!H|mva!ben_v0Eb+jligaYPyQyAx!Vi6WZf`x;E$ca&ZbU`I&>ysO;Nt*R&p0b_ z>=!*?*=~qYw{SFc@O$U5thPkKD`>A2L0MIDQ!T);HXbRRu-=O|mo{m}&d%0eg-G|w zXlu#c>0^z34J7Fx@W~G!)~y^!uWl(0UAz^Lk--_s+&Tjw0ot*x-EpCsZNGEA^tI@u zit4HHCLiDmS!0OiSbS8xhR`7K;ss$#z&sUX5yw`W54zP?JS3?xMHCpOOZX@e|$lANu8X6fGvKh<^m68C)Vj0BP=LeVE~0J^t|d zw1MN)HQ<^UyRN?3QT9i8OMY4kfB>ZLA3A?bG^LIo02`8@q2|JWI$VSA1WIh^RoO;4KU`wzO!D`)IHR$Wn?HHeqp*{;UxQvwo?_DS%RGM9ES+QT18=k_;zy zTJK_H8pGNxLM1w^ZF1QmGb>OXW>`avsnK}fe~II~!11o)P)alWHkQ+r<95RghB!;1 z@N7FFkmzBFKwga4RP2BQhlVzCN{g`N<}EH z4#)YeN8jG`Lb<7rJ&EBF)bk;fp(zEXiE~PNg`m$T)|FE=*-IC4V^0oyPL^ zVQk%soB7KW;Sj>{vQ8prum=GCHD>tlj71fna0 zrNUWZO&B{YTFEh&1e!CXqLGmAWfqCt)B zHX0#gCY{TIovNTaSqnalLW3y7t5>J(fyF~R3?Fy-x>VflllFT?C@rY1kS^|3vK0z6bw&{JgQ<|;*X z3CbVrP=j{YO;&I@wreUc-r#ychW(x@kn495Hf>&aP`-T)FQvpkoG#0mKA;~JeFQ}h zTafX!9=oyC1{l`86%fI*)p8`+xT+{9sp$LOnsP0+b?48xh%>s)UX%h>Z*?r`x;xrX zsP~t;Jl1F+=Yw*}Advv(r+e03E^*Kz@zvE8{xF{Z{-%IwH_>|&?uc0+8M}v7%Mnx% zZJncID%ANeoA&S?!Rl9ELKVFHaCZ7`3isW-mYbA3w?u`qZgY0|*T+Zzisjd&+%^a8 zIzV=@PRsT6Ohv0K{@J|d+n*_G1nu22)(6EpoL_A&a2QVnaeidYi!;FFM01d9>vvcA z#m%60?1bXMxvw;%HM`J2La7YP`o}?i{lgrl#GqOUIZZkwMe0tzZIeA9DBiqg_)oun znswjD!e-l`&vy0zA9u=>9o8o7u1~J#-SedMrd`|PGO|3a%=$h`A&*>X;N`T)znwb4 z-*=HyQ&cHLQ*0i_uzQPwrw=c^;hCL8KYK3bz_J$`1 zMl4nu1s-}7yC~+W)m3Oe=Zi|x86(g92{`x%_fy8wOiKIxA}!COZOFfE%Gu9Pgs<39 zQG6*R8PBqF_V{r`LT{&0Bj>8-FFB>8z$^w|6VKrv8MhfyS2cT{?=OuXXbg4rb@jUr ziA2PRpbUkPs{ub$??LU@Sk``SY}-mJWSBd*`lssXr_K)oBhp?8R2on&Q!q%SGUaVU z0@JAfG+l|dVH1u6=nApPNX6qHaAXZ2Ts@HbSknTl^~~yKO8>B&T3_0oyY+N^b{$DM zaBPlud}wfd$bO)ucf2_~-Z~Epmx3lrLF3_2FY@?ntY<0_fZW5H2PG39iSUSw#M4fi zP34u=V$-K==}Jrg>3HSYc-;^D;;xY^*d|50!?PgKw%GqAqLNaV^3w#%Z5H|4hG%fe zj%mvC_nbSJ-qZC;B4|zjM5Xx?aS3aKWU~ zUez914pUL^%fwF8Lg!K9fW>dL`}m>Q&Vk~g6MwXtnoaBi-P0!qP~`!Tnuw{5Nbcexp~ zs^v#|ov>`6qsVYWS|^q#Pywt2=iaw<=%H~5#3UVPb`CdBxW4L7@sDF?h z{_+FPd5mY0f|?i%uck90_5zL$*Hmwi7l|k;MyJA2eS3k5S7(z2a0>7qydLzkiByLQ z-L<;_5O+K?$6x>TWT`u40$_j{HIR_|x&Iz5J7vJarmn^+%5|ZOZ zzeU{b7d!AOWx#wV+d{S+5C+U6)rn{GH=#SR&c83{;L@}kW9{A~=h zziX+(L(@P7Y-B+a%}_5pTt%A+4fZ@eUq@nU;hxZd#fx7$wlCQNp>zx3bODK<4x_8pNe zjc-(2dbvg{1(NqU=WHIXO`JP&EjGbF+g&)H>JINvwbukthW=@@8=jn;|-#_)}lk;C3 zt~ZdSDkxI^P%6ANOL;?9nC7LFJ;QFFxM8cmi--`#-1C@fM5m@cvP=Y{S#);noAMEF zS(5sQZioDnpvjzqPw)029e>DLUF2!?#?3Mry+R??z?oX3p2YFssvTZVg# zcHIVHEvf7-RMHa@zcnmwjxojc#_iGbsYPJT<9ZP9olN!D|1v~?pHmVPOAY4%XZvK7 z@TS#LTnw0LR*|LDM#!R^3tdCKaSQScazT!R*rIv_**O@nu8l=gqZ)ijI}Wmbn_&$y z*8A%kWT$~2N$;=n!PJA{Ii>z#z7|0{!{ z!a(>mW{OLAXFa6UxMFE=0$(|n5GV~QiF)cX{Tc{9m#G`1bN{Uy_J}|@!|jlL{=B=k-0^vjk$-g4 zd@fg!fRN$HU;5#`fr2Gbr^1P!$sUytY2@4(9b`|DU?Zy_nu<(`zF^}s+{wCXnAd*h z%pg+Bc^$fCJ+F4xrF>;(Dtaov5hrTqQDiY#e9yhWoXw&&U)Rb=xJ&G(mD9;vn>hdZ zdAoO>+|b`XJ|Dy#KTDka>oCkN?7zDF@{)bg`B+_}o(1Xb@+kW_r*Xu7P$qAB?7aXj z*zRpFIV>Odma7+RoHy=5V>Rs`s3on*$wvmOwKWpxZ{8M5RGwg63NyTqJGe{$sXf$x z;0)u+&q?&$d&50(+XY+v*HeAa3J*QadiL-5%Pl>v0vwVLc-LuDkw-r-t|k}w!pB>S z^Y_$(?shvp5esSk#gk$h1Y&-Ra#5ASBNuadxlZ;1l;Jak5J=;E$t8b#J}My+|clML*u=wO{PhO)nQO#I_tl$>n~hFeEKj#4`OF;+IHBrhjkbq_CGeR|9bq_jsto0? zRG~4EnA=hrFTNUb<8s&XDR0e($I)-@ViKk#Vy}bFI_Bm~<^b&T<|MLo)^&H0SxE?J zOcrF6*F(vpir)s%3kKyurIn;t_B^2^5DBI>g#gT<_z@90X>_~Ec9)*jdFy#0V#m_k zao?0@gjt^;dvP&ps2J*f>aqTcxsy5Lo8NrmAKtemFds>S(G)u2Exy-3HOU(;Gc_ZJ zMba_2R|ra(Ud2~Ol!oo{**dDbG^yB<9b$}5U;k)Fm~1>03=bT%fs2v013os?aab1EB;?$wQ<86 zC>8=^xuv4vLOxwiBSr6QWqa)AUy{(mSe!x`f!t5sq@xiFvO9xg1=JeI`xuYz0RgM~ z3FtB_EG+{^OT7J8Q`3sCt9qIsI$3N`#POwAHf4_54po+5*C~04`Ov69+ACXsmVnYu zjOjJLiwfh75F1Cmxw6y|UeaPxe%^aC?6H}s6dja>p$$sUQAx4bYkRvR+^nNpQo^vw z=#A!8J@ezWNv9arF~)U9h=9J!z~1*>-ThfSyPEY~+TuM=Pe79asWgIYWQWpsJ2U-p zwu?C3e!q}yGmC#yr#M=J!ng@UUy6bVun+^w7n`@f&BgKjsU2ZDo3V>Vdr6&{f;?;H zPK(GA>xLaBkS%~%zV+6hZ+vb4y2&->iY1Zxe$p@wO^REc)AV%`UAW#xAW1*`_>jxV zd%mD9CRV6)L_akLFqZ8C0Fzasqg`yn%Tq6;1uG;cJ=a>ca|jgEwvqLdGdFRTq@OCeUcUJ|=z-^gsdWq4Xt_JO7Fi>uqs zbm20Qu}^-GV8Hjd70-wPc8OofM%AiR`?^0xSJIw`72?+ROZ2@@{vzbW-MdO!@M3y4 zA5_W=Y1rlO)j#@Ww6mHVc{6gY_}a2`G$`}HNh*k0=5cV9{|d=`{{lDi=I(ztQ;^dl zt1P(;@)DSilI0db^^Kv){GT39@;@msYgMj{R{-Z*i#;@{$=R$QFJO@DJs|x>J}3Ma z<1xpgk@xy^MvIVZ7*!!GJd2$s4tv0L(Me|6iEGY!^o@l?T9ia-(m9uY5LV+c=SJ7t zh4rFnOMSHjj$0~c2%ZO#vPBOfkYtNw|Gmn>3dJk{8jjw)c%+aIq23myyS#$Mc~wo7 ziT2y-c+q}vy!^B%6!40v$`kszsJiJu*7tSKxL&A{Jm+B^G#10XK^3gy$X8f}<_Sb3 zyA?51{?M>3S#IV+9u{Wa8o@ueB_THWG`Ek9w9;lQ8dP@)0>dliD^l1%8G{JWl#E5w}+6Fg9miSVdN} zL%9Y;vz=1{>={dTSi_Z^pK%W3qGRki+YM( z?wim{P7i%;xA-4NXW`Z4|M%gsX4DuPEsXB&bQ`0?(JiA90Vyd_2aHCfOT^LLh=8CY zR6tr#K)*^0hzeq0^6-29h5LN&bKdp3F24Q>&uMlqsR{g71}Yh!_4ghgPT=4tNc%DR z+B*pG*p#Z?^|!a$=V1eMBp6~nb~^5z4Y%&`6PYRZ_^BzdlHtAamsM`A!M^WsLGRA8 znLqTrPlTwgcGYSQn_{hkzY`e5<%pc2uVfkB8j)^y2Zv*aMh>^~q=cWe$d)T=y}4F+ znxGz-{d@S{Cq-Ea%FrlYK8f$h z^bnt%V4RStY2_<>&t=cbm0$og02h{nEOw?W;Fe?*&w4jz^xqW76goQQ8L(%4>g2yW zts~N7>oas%+`6ZLA4YKGKS5v_-i_cQKDAwo5e}(%8N>p_jZ2)BesjL!OGZIy7xzqG zh*rHtxNUg(2=kS?cvh}@cCNOA@p#=~ZRs(=9>aBu9ih;4nMcBY>?3)0rvRCW;Wr<1 zw{dx$avty9@+!uMJ=DfY7O(g01cQBDzX!kjZdiS?^i0j6`n`|Hy$+AN9SQ6i7yjCV zKX2WOxlW92q$2hG%tHl7)yl0@XOD5S>f&qlvg9-V-d{WoY_tXroVCs3pYWwkgpteQ zX$RxB&wWccf|d>BhuI|=h->8!2eIrunb#iyjXwc6;&;3>3$k%f=XrA2_DlS~auYP? zWN^sqv}u_h0D*?fFqwLllXfBM9LB4XRCLao*Z0}(;xkDW3+5#m^pbgt(7V<%Zw0|I z%6A4XaCZe32bs6`EW8B0{s>Klz6!(ZMQXLr9>bf zD1N8smN@F=2Qj~MJYaww!#iqrIN{|d$bxV7L`eZ70h}Qjgg-Ze@Xs#lEG^ohz3jT` z4bNef_?hu_8vM=KRchETXwpyBD1FPVno^sfV6&j!Rzz^a%O)U<(#}$5o5pi{B+>eC zeQF#dS@FK35{5i>B>pG;rJjvjlsfk5)%x*5>25_QeAkP-^7n+7wgyk#P^5$+c`(zz zCmsP?>0w)J2m8i^bZ_)IHiV?%T^}=Xi-w~m5*DKS4ba&K2(p+u`-PMDp`^sS%Z2Aw zF=9|3-0QFvh@|E1UMjpNXT4FVtk(Hm?z7m??Titg*^|>8HmxE^AM+dqc_B&!#m?vVnaW)1-o*#C8(fuL&ih}?M(#-l z7Y-h8CH)RQ<8v|H_xsz=>FHcZ^UJaC%vWdR5;MM(7Tt-n8WI|-9Zwj^K<)SKd|TmE zncNBbB6XdNq9K}S2q*QBK;Wa{76EXm12y!1C^e^!Zm;Q}#htSX-)_o#ZlxOxDizKZrNH@Ah1`{|$#C!_?h1cI3K&B0hc3t!+OL zr7LH%McE)M-V*zdt;#1n+bFTD)V#(z=@%k@?^N`Hk$r?EW4@shPZ|!_|^y#uh&@%!LIEsJFnP=5i zoOdJ+AdUkwk3YS9=sO0i+gqYEN+GA4<_yc4dv30MTe2 zz##)>;|8;-eki;o|33WFdl%1?y#H1%oe5Ac@5xU?-Hv3kKFIWu(km|Wb(KnYd^qTp zBxvH;&eT;?4MV@$*w0x<9yVtSfWozSrFK_#DdISVD|g!6zHINO7}=)fNuYx0 zVD8hAw@vm^ljJ=alLQ!yRT!OJXty)5!RwdN*j~#B_?yFzcHQY{PPxQ&|g`G3mN#c(`y|tJX-Eo7~effzAGyu%J(f86Yy4VaBF7_;d^pF8 zwz17+oB9=-@+XaQ=flgN`OZcwpPDn@yd4vm{LQ4~z|B0a3Uan^dQeU#62>q9J7z?| z^-pfID`YA0HV)&0jvXg|oIF7`0e^z??k9>6ZAaeF-BzAf8#edEeR`kLwsMKWQ!Kyd z0B|7?0cZ+7C%|4D_n~gZrt$K*=kl4#+xRhXR`C=ZQyK7vdwZ++i+EpQ zIyJAoeL-A0*8hx`B|O>!u$j1XecTpxoD;qHjTOwm4o^U;CbH4N z1>tHE!gUfUr|m#$3noU<=We)kv%+LH+?=Xxb&Vy#={{397*^@Iu5;f9CMC8JSxgS zG(ycQP?HN=Pe28l2M1;bYA3n9)_(HZ2BJILI%2l`UoXGjQ81g%7F%iyo;pmtQHh^; z&psV%N;x9RIOKvC#9Jkg#fG%N zI#G9ZfuXfV@F`qAHZatW>Z{QNta?ZKc164XNACf3m_V*+!y zp!h_w?g6^{VtfCjh&t$XMhM|p#k_;ikpPR#sB-KsKa3?2J4$D6M>2QH+6 zFBzk7y9^>ZY~1>gVNr|aWFhVp<_D20%3)qba-4FA$u6Z#?Cr!hY7e0SQG#qIJz6~s zd~R0YT%()Lb8KoUu6{?=vn#@MR@BVqku+$1DXU6_jJ6)=8Al2ml(RCTz^D6**z;|i zj6jf4CZ}X+8bZ6MC7WMkuBx^3JCubt_svX>&LtyeZly$Ff>t%g`LQ>sM=`$)W zl#p6XInvp08)U)RfyC`e0mW;(w0;B6Szg>_y5g_I6sCFw{E+sVUb4M-@e}3rVr(8I$6PshH@1qg){yn^PM6WzR{?C|}svG#imM zp~_gW8m^2msLJum5NR%{$`Ebu3rcHMNxO!&+R%7*a2I z#3q{UPMMjv@&(c=z4`)5F6WsZ#MLMipz(~UsbNz-UHK9QbKs}kNzA^JCm@E$ztwjS zy5|%XS=1Kwf36LFBk__LAV}`j0Bdy2w*M}_6PBT#<3vsqL=~CQ-12gjuZ5JkYCH0q zH_m7u2(Pt0{5-;U1$fvnR-?u4X4*(S*{y4T^jr1CtTIy7B0cq(a7b2uf}HZfqpbpv z$uORKb4M1us{4haX2VCLNMqH`F|@tUyrH2!bPZ9^aIzZt_>U%-UGSPNaa<@~P5T1wq>Xu)F)Z7H#W9WC2hw=>BxWoe@-0%*3+0{p>i+&XF=`Y4dsh z#FF!gBOlUyYDA>c5vKbitPHrb32cJTFteHJgE6u1dw2^ULIuwZIYnz%TI|6vpt{aq zPj}Ds+MDBwAEDloN9Q^m-(M^Ea@haK?EYtSeC()Jv+2Kk39(KK zqkz^+mT_el5}0I*Iu*aw28HY;e$9MotfkqCyg25vyfAJ|SZI}rmvOT)o9Mr2lFvQB zbn#1-WB*u=g0zwlquHLLZ4c^{*n~ciK5s%;APGY{4sz1{!!|goOFDUivb+^zhy^(D zuG`;2AQ>wglWr+pLxs62{s#cqLc`PYk5jkq?b2uUDS52SUD>33-lYchay3PW`2D5M^n!5zc zQEQm6j!d1jYeQ98;nF!QJhhPXHW3nL^KubCtGN5S7!#h@@5lI9_&g0MwGG%omr?Ur z4jIJ4ovN-_e_>1%HLo6*7U0P)9#mye5t}ubP-Pd>ySguR?Rwn%*gM8W#OZG9Z;^I2 zGX)p)+%m4mkDoKsB||Z%C@(w6(dR~+T4{%mpPk}MMp=C@I9Gfz<*TlTVe%3kRwR@- zQvJzDLGwII2&rbNEDcK-&&`jkp;EEwrioo6;ll;u%E68gOK2RtGct_$VIgyJPM*X` zvj>SiaJVB}k+qh}RCtFyyR4*$m&BaGo&Vgfv5!22t}W|FS$Op>cPU!=T~W?=cGgk3 z*OTa{$}P#_DnoZaH6iC%c5k{TRljPUfOeR9vnD~^i-Et1WLFN-; zWiQfcWML3z1E2U&(Y@)5;$jTr#0V0yfJe5jC&fD_P_W00H)x6RI=&-F$yk_UWg$W* z2E6--sm*Z_embjZ=B~uTknD|+Dt&ITkXIM_@RNA3*?l8MzOGB27FWA1j)oNOY^4i0 z3uRdCPray^%6uFB4`O}bzWCtd&$nh14<5y*^`7?__W1Sp2kmD>;lCJ-<{|Ef(cv4I zJ`T97r#m{4XQ<<*>9%Zu9Ch?uTbiMn_6B?1qHC8?dS92FJXal4ic&$?V7yIx!P*Cm zJwTiz4z4K35tL@ADs9`Eqrib(X*9bSo?mOSlpU<*(+*D@vXbC_5_6R4xvk9exO{>o zEfy4rw&XjDsV>z}_1f0}?$I3I9&Xsr#1~92%!rumTG1KY6=YYsM(-LbhXb=)dL*3; z5?k|V9EGR%MQc=KF8yoy^H7FtDXMCzxgh+hfab6*owNZgKr%F*%d2g=9Q)egv$EJl zvgPC+%Uw^-W8+c#or6G)WPc+Krh(0KN!5(H!K?}vucqZSrw=bY_R$vGsC^sL`N}Hz zUh(U2U_adLLD15@^&R{lSdaFw|P?vD#i zkI-b_rP*rUHyuQ*jT|z9IL35gFa2KgM)6FIxcs9DEBO#xzA7+_dK^$Oda&SLMm(9w zqU?~E$n>iUF8$sp&q9tiIZ-z*UlXLzEB*e_e)FJ7)(zI(Daon1rrpq#TXR#oE+8=h z+BD5i<8)27z2)h?PjxwJZDw~GI55M|H9FfR(m9vX$-3G3$Xu_CRm_a`acwk26PvG^ zd0zViCK4bfE+iR1Z~o32{wpMm_gNU{RG~i8Qh-#1y75uygdrI$hNeBexX#x1;paH8 z&&BwaY7AO?~bKzJ6yMf=bL$`!zNKDE*v z8=WHo=U;j^D-HwSe40;GTu@KHqTkM5mIPhMg_=-8vCm$hVF-iiumC#53JP<;f}_Y# zE2uW7Cn_9E41C^80zc4~i0q;%#4%w`y^PZ)jOTJ*rClz~HeUvq&@SRxwbO^yvCa+V zi3f8rRtn`6t>tfVhjU3*TSR25#COCz^3P3E# zk5kJ)8s_30gMf{WFa^rh+&LvLW<^n}unLJs!j;VP#iv2qjhMwzNN)xdO;qZU466ve z&tFe)qv1Vicz22n832n6?==B4C3Hpfl8U3wz_l#MfBs6R)*CM~8l{qIUgZ!?^Tyap zCeJiyOdE4$0Kf`DIL5|zrGqIp8}@CNW_YLBZNtZEe=M%e^80d^++X6*;769ZJ!^iY zM~954+Meq(A0NUYim#?`*gz`ZTC)9GGwvY3?s;>i*TdGd$fN%<(Obvpu8rdY_ zY@)G@p)(4+crWDPc9ofT(|(i9#$wc(;Nv>+cSpWfel3aFeoZQ>R-;pI8h|#N9LonF z4~EQL$$gVh>=O=%cl81gnssP%=whcK5GM*moQfCffw<8il3%qrJ$sd52F}3-S7ipWTOvnP#oUA> zin_j(fB8}%dYtzGql;7U5Gu$DW>qu06N-M=B~w1L&blghG6V>zIBe_Uh1 zMX?ecd>;Dy;5o&b{MmVOF{2+VzL?(YHO0#IN`}Kg%UBL|CG*>CH{sp%x1y5tNRP0s z*G`jNhvctzghc-;}z7o^OQ- zoK0h&G5ar1-YQ}hE3*Pys5w45&aP|QKasvXc?nQvAhZ_@Jjv2~%!zMNhjOlVTCzfk&3DA-lN97rsiBPDITGm+i{Qm_IQxy?qC(?lp^Jvuk(YmM#;m z2Rd}deX5DWsr#6|yz6(t$75J->2mz1=?Y;(8=9(L{x(a={zGO%B-~8#e5XA+<9eY6 z1v?oRHY)pZx?-}x=_!p_Sk<2?wU*T9du29>$h963!c>iZVxaB5mFpEZ zmvL@uTeZ=bb+ja@9L5V`Y@%Oigo*Hfj?(<*u+t)aj;GE* zL>dgBVRYBLfdG)zQ;@W`w)>L6?N4Ph-hxRRs6=t2R#EJ=tARJvBK}HB>NXmv8_g&Vr&;Q&!|NAMVi4m6>lkS4&;NKFPUUIwjDyF{* z63%>4uAsL|QPQ>5u%U*Hl!#QeC!S&@B0mG4E5h2rqDY zBxM=76kEnr;#3V($2~qJRpp;Slk;{;y|*xPA!WuVrMFAf^cn8l=b`m#-RXB`o$;r5 zsGH^bVr@nspPu(~JNfG!B`^k6ZnXZC<<=HA`-PSqX}WOp>0B8RvA*^7-uYuL^NoJh z8ZjHMjqqjh5r?4npD~L#*@3nEXI_!o*^NDk(kbKC2>^{Vg1W>v8^>5#+DW6XJ;Sa1 zD;}BX);;&fxt1d_S+3u*nj%1oB=W={wfmQec-aBEK=C}>?4m+%!O|@=<%s6oV{ zCGZlr*ebpD3nX=A`!Ap{^?CAU8@V6ydDr-{~ zEBJ|VaUpJNlwZAFp1Y0pD^hqKegd>LkL|TDxPazpLCeJSC~&@$-0u;wW!s`nUhrqG zjQV4dVP)Fl3gx}wlo;)F!0o^P*QvYmd9P>4RoOurPtpb>=Y4CnxPFoQHqmS2^jzEI zZn?DMiQNrNTQ5cu5Kj0NNyr?wtrGWG9h`qHaLJ|bE(k!RX?_2BdE<5XF+AdTz$N4t z%s%sM)~e87*~ZHxeVFs5xlb6EVi`PPBlqzx#^Yw-^O{zykM1{Wr&8zWZ^SyUT6 z6^tB}>1lPeWQyg;H11LO^D=Sz(8HU@)Gs!*0<0(h`aw10 zVHjCG|L2y$CKUCu`}cdr`Zvtwvy=Gbfd4KhJ*Sy3UeEbTzaMKI zIN_FJTE$`u+%P#7i{uO9Dog#CyxByXoWaRC5-;UA{Q)6V+7o+uUzpSd6 zR`O1VprGoSaBP|fUS`@?@mNA(vo{eC)}=c8ZwTBY*!k8hnqI*;m9 z{eE96jW9+pkU^?CYvph5GO1jsACv^VRj5*UmzexZ^TaOYRSPDt{F1 z#af$NRp&3WXG~VRg3Y_$k)L_l7G`;sw9NhJWiWkL2Bcjf0m`mW1}E+;#J*iH-c;xf zY+gRW00<+^31B8(-L$#kL@@J(yUK@$l??Vx4$eXMn+lCU5UoeN6vdkHT-d3Ere>@% zp#e@4CQ`5rb>K6@hh2#x2LNvYGVBv}sY)+93tty_4v* zr5Xnq!!oy%!LW>VJEtchSNLNFM#`y$y#6xTtzT(pwc)TyI>qrGvwQ}a+eXxc%5+}) zk4fsrxhz4cjb8|QVSlzC!cVDx@MOXL@;ISFLXsX9> zavhYyCaCu?`omAl6pPYz|H*PK?;Vz5WDrzZQ0o1BGZxGM?#%uhmy29^2wQ)24tH`? zI+moNp#Oqd@6tlar7JtvR4`;9SqUoWeaat$4IP{oI)}eD zq-+q3vXVsc1=WPDCx5)3YLt4eC?O?m^ytWl3Lctw%aCj<*^mhutTg88`1=6D{c(sP zMPNF((~wi|dx;@uB3!BlYlIRW$58F?X*b#zE8juE?m~uz7j%W56gny&fjQjbCdN=+ z;?~IppmZe?=K8rvsI%im>n9jPG&}Kb6fi-e;{3s0n#i?JhahiLU8S?C(~TX7$8Cig zu&S+*3~DsyNFbKnIov3-ip9t!W<_1#icAJG8hjC*jUL4*wsZtCi&6oX;R3jCcw39B z9SPQaL9}5gcg&h7lL(b&3Kypy?w@fU6_K!d@iCGC`za6ZBN5|XkZa`3-b2T|);GUn z%0#QNF_)Q8<*-z@JWPi!syK(R;uIaj$&XQzShU^3 z9bBYZzP)q}omO*XzWSuY0C|4-Cy2v`<*Oq(iN%lGD`SuDG-(y5G$(jDk*V9STxf$Q zpeomI6dXz8D*ms|b>Z>PI^$5`jI?ro1vW5+Uy{GTG=i^W{*BC~ z!7oP3kclJ2iU%LDcSW(T^SewO^ViU!x|YD;F!1IZwSB9;6DUXqLb8E76`?%1t*!e#m`d z#CID9HGP1#rmhe80{YsR|*a#hE$stHf)fM|S?TM|ENV%HIc?2Vw|@$vS)UW21r|p46d5Y#$=$MhCh~5{3=7x(CFa?$9ZAsIKSGHBc7`DM2toEEKE0Tiz$5VqT!)1=XT;NRaq+K&o&5;D&@CBOS&Y@UHsWS&S@=ND_2?7W*^>GZ?1S*dEU-?qXwsrvGGtP#n zr-5=p!bkgndaU7=)#ck;lzz#Dun5}k@)S)`ZR9qd7_T@=Z-^dZoM14~{bHSy0=9Uw z{Rt5ow~3>DMJPIDn?1c;SCn0^yOTQ+?rHLZ4kx6*FrwG++#}xyU{t+Ww)n&aaeM_} zEa49Tw(II~HkNBN8&bG&_m`dab7ds|oHZ1V$G3n_5?r)uDFYjY?Nckbq{t45^GanP zR0lHEqS*!Ak2O+VK-^|N^9Yb{08zv8n zc`nunr*ncnhGf_!2%?Qq2+e)uc{z;9P28w6Q*9h;Xi_5#FfhzxhbpBzGkZrKayKz1 z$~e%sjZM3FlnI_K{)45wuQZd@tt!iDZ2^Fffa6$YLl%l~eAC|#KN?@ka#x2{5tO&KQ z?S9&a-d8rL%oN+3Tzi1RfE5xA?(mNr@hP5sF*a!W?_+k&L#eNASbFm$uH;g!KjDS> zME=e(JQ3Oz!~i{YXEZ>y(IE-}Tuel4;`jYAy|5dfuO+N5b2L#PGC8$pn<${p(vODk z6CAc>tBZfghhx8oHed~#o{6OCb$UPaEa?Zib z^2)fvO_myrUk0(EsT(ACNP+1e;#q||foP($T#(ZKm;-$*a~cabb^@ZHBNr1z2|o5c zUiQ{cx%m(1rio`FXdCl8N@ck%0b?t?w%uh?Ckz2skk41v+_ zPE|Hy4Nj?oT$lf}O`E|%n z1$@`mb2ozaI%k(aO>rKHVy=J9wp>Tq7Pn!3j?8Bos~Y62G;>A{ZGBWo7oB>uoo#AG zs3S8f5BhO_Hh0a~DQTij2RLa(Il#r&Y?#yNNu1z|?I!?fAmB?* zrrS++3kp%jq!s0jtgZz1Kk)jC2uscplM7=Uuqpe5k*}9fEL^v^K8U{aMW(VnEy3zk zl3~%&4wnSN1AsNMcD=}@N%Ijv3djt^&Z}uP`a!ULh3l~Z5&7-<7&G!Xrpe~M zQr^wvNP*!i&oh|izez@Z!aqZsCzbl|x=yv1z-D!q2y7&fUULo+j3mGJF;=ujBFi8)2>Ipi+ zb#i)%Yv4I*!@2IWL)OEw?3d+?gHhbLVZNn zfz}efw)I4N4X%@`ijeuMuBu3HsJ)G^pVK0@GlnPlJHml{P2>nxKF{NA7_BvG)MAaK zd_@pIXG=8Wfm~yY7pl?80TZ445nq$)tAuWU6F5*3yvG?BZR-l1kLy2*qhVZX!CJNV zx#B`^ItTD{KXqx3ygcZr-OOot-4oixp)$P_^avAdLV(+1;Y)QKld574%GVbkxII4( zE=m&LG~$cgxV~O5v1xpTXH(k=cSbWm_i^h+zOu*Rs8s%%)a}p<2**?D&L~-wgbeV> zP3F2V@Nk$gAVMrU9F-gXsw;eTgdz=%KuQvXdr%xP1gS-NJ`H5~T!gZktw4;e04?SW zRR_(F6jB0BKIJqaUX`aO%7NygePuO;8JLuM~o?{kN;+` zb48Elt)!GQwF%S0)#77>-(d=p2i5n(>=G%p-^kpLY!$A0OYw#I!)=k?WAhQwv}t3v z_6`Uz-EE=XZ3!m+$`~0t$K{T5FTqPhZdktaPv7W~(4RY%*w)BvKLl%AL;k(I@kt}} z2zLHB)#KZP*cM3{|DPH6QJF9w(&?rssxI_&ekPb2d7vNTsc;b0^+{CKyHtKzGeROcSL>yG6t(8GpMKxejdQgt)E4Aqn7p zZhIdXqg`@RKz)>3kKqMM9;B0p5CA6zI2fh*H|9n^R)Ka7M^nUM(fO*dOODs=lFd#K ziRe41DvgV9=Q>c}k1&lh`EU6*(~2`Ei>g*mgc&Fo+RiS{Q&r<~}db6=kw3!daS6@OP}nQIbKk_&zdyRo5Bcpg{ywj1QB zj&!19@JQ*?msUsLZd99Gs5_CWtO{j@1+>N#!BWVet6^OyrQJG(=UqF2lfCK@}N3HWv2`4!bH#O3D1q*&@43#U=;&yG211`Cq|J%!4BM7MX8NRPM=jkK|046P~dI zFSA3TyhLmgktf1<3k}5GDb@n#J;F)`Ja{Rz<*~aCof_p!#w|Q=5{6rKqVtT?rC2NX-xkO`3 zZTJyTfvm)t6sy<)GMa;zp(M+^KnR&Aswar;nxFGxnfJP3D+wIRl-jN3&waOO@R#|ihCdb-iGu|r&&YvvKoA`kPmiF{nFf2x{9rM*U5TkV$*J|@d)O<03fMfo z4|Dm7VjRaxa*koQT4pZv*k6dQ^O6VaiG*fzxCpIia7YpVv+&;$UWc*hR-PiXZmuTRait9A3PL@looOScqcAV5*(FV_I?&yt zqk2B!`&T#3D!NB?4@TiZ^W1lP09iay^s!5!FJ$*z1~^^CZPen%wH*GOia0S_Og_k` zl8_S9Pr$|GUt(TLA^PK9hP;n8*|gO07)2mhofp zs$;23F#wp8IW9Ut&ODF-n5Y%!V7$*6ovFSG%EOyU043>xvcU=LvkCApd!?*3>oBsW zW2I(orQ+{EO~)iN67ld(H3zmtC5l5mfyBWnU4k{3X?T8&DE(`Lyq$y5Sko@b1!1Cy zPp3-X#SAT9Q&Lhq|BNMWQ)l}9<>}?YGL$*;`kZnYBDdRA<&qh1m9MGyvo(RHLQh?fgkURyu@W0v!PqtS z|6aiUyue*|C9sT{OU`418{@i@g@SU?Jb|Jk+DeE1k|c1_uAu9hpgTSO*YgI&Kgp_= zFHl;S!vlnLr(Wot8-r%8nKsicOi)D23giFsh8(p*YdGFHKe7Jr{%qY7&udM-wiwqn z9oJi8Zg+b<{<^(g;j*DDLp!Yw(BOn?+Z!@hLiwKs1wV^x@$-CJ_}Qlg%vCh0U9@E) zJ@UY;7z@;*j;Fg-CH^-K&aG-E0jc~(^@@NNI-;JAXw5++w6SyMBBCe4OJ!FI+*h!8 z_SNp3f{ji+4SsX)!J7rQY5STo|KcdfZ#PTY`(DR0 z9XQ+&0Et)t8zaFHHe#=@Q`e^B2UILf^B|5mo#S%I-mSMk-@N^F*Ztoc$d_|-a#?)Q zM8~A)c%oPQ!`cd$7hNQoc|GPaIR7TktuYY$pWL@kcBxI?hda;3(5;ae-SrH$eUOQiTwEF zq3BqRj!8IV)BV%emQNqvzCCG~{mcLE_#;GiP*c{j{9&az6u-EB?@;3LytD!=Zou)} z$I)o|zo1Z98~O{%*;JVME@c$vbn6u8am;x7mdpFFk(UkW{WXhSGliK)#qX=APk{Ke zK^ogiji+tr*~l{L)AD*iYPPX<*v4G0?ZTF6f%yBW&g02#^AZIaoAPlZ7NV+FL=ir} zeu^jgiXZ*Y*GcA#64OHY>r1}oMRFJJ`Bjz~&e=CRGBOGi;TCDd;p&5{8|&K`sS68j z*B8Ds=0CVnI_2IanvN<7B<3a$FSZxiv}Z`h@N7NvG(I1Hax!0?D)LM(%Azg{^o;d+ z>E$2EREM`Qa@+VKjw>SDlPxU)yFZ z=JrzX{}i71+(FU0)y19?*L-T<+H;w0?!Sl83=Cjq>{<0Eef-{@N3(Vx{oD3vJKqd< zZ}IQ%N|R(4K1{a%zZi}I`CusRr@z3cRtWh86NvyaN0GQQ4alO_- zSt%rMha5O2#zme?fN;8n&YuOwL{H%V~>kiH6w|k?Q#NR5$)%4H>MwW_6#Rts#Q>0I|A&w2Y>-)acYL|n(WsB!*np=C;l%3l8ce|Pq6b}ZGHAGX(h?qE;cSG?h=dn=N zGtugxuIGx+r@E$fRkV8M{Yrd%7X{L1#DvKgfBc8?=U4~54j&DD@b(5%h$i#E^;%T% zRMpP_n41fr4$R!}Y2cujgH{M=_-Ccd&?WvEb9^>m{_u^N%%*>W(yZ10v+(ls<5L_v zvHJu*C@*^XDnRmIW z{PBP0p+f1pC{ris=XA>m;xo3Z+=hKut?TuaqN2p|U}Rs6O=(*Cgd+wN-6{q3)Xwpw|; zESdCV^(rSGGxb|8!P}#({VEBK>yBI8lgwnbS|(IA>`8(tz>l)z1`7)35 zLEn>8GNY{%Di4sair!|lm7W)b+d?=v8N4&*xqbM%LAO1jK1J-#{-Jc9**5^ys>+0H zizMEqK`?tO&pPAZ@WeYS;{FdhNw7F5KT7}rgjLKo&pAf?D-F|{jeYXGzz<+glF+e! zJ+t&E|Gq<3y{?Dth2^pD<3B`kdHF=X8!unp>t%t5u%#`B13TowO1t>DO*-{YFCRTP z?Tmk2W*)g#!#~KB)E)!2h!0JFW@u$p@Gv)9{(%B)kM6>M3+HajoP#$`xXOCJ8r(~|UO z7Dsd9!2}uQ5+)&p=ePmpM9#sK$-XpW^aQ{=m+y4QqT-4$(>R9N2Wucm=ItMia;c#8x+A5%L2W6aXsh`(#VO6$Yuy-gJ$1sUIjd=Nlv7`WZ zCH}+fcgi(p$e8h>r|f|doSx-Rhp4b(x^E`A$9(eVLa|<1g5n(=3zN0Ah-YpKNa_xx zSk<#r0u0dqJU8K3FpOL+Pb_Z2LEaQL%J>8QnVwy0ub;Za^j^0@`|p?)VaPU4pjAN7 z=t;R-{}^?*+vJzHQdhgWPWD0*2TZk-v$9R}d0(CX2&wKn+KrhDi_#kfP*rap9c&S$Hqu5HFX^D6zkiGXr`alz#;Ty41Ft{-Z{LZC3PQRxub$=WTiF>}7XwEoiH)-2Rm!#!S= zNSzp}uo)M)m|QtkR9DL9!T?YNCz#@~MD@p!SH9Lo5BaZVeD1QD(PFv%Xm9Jqb&z4h z*=Mf+hZTGyqdp6xh2ZQrA?Pwih~&@)hu@rx<%qlunljECk2$WH0yMy3p2tcf9F#7|3ASjDsvbYW?M@4b_ljAs&0%TJVw1zudq zYBV>jY>Vy7qP0k5F;CpJo|x(94WF-87XH{q)&Kh-8+7z#xMh+$Ae!K*z&`3Q{F-03 zPZX|mGVH_5;eTtsf2L6A&-m9jrG4K^+wucS1u^R={>Y_;8}6Ghd9;%;TW^QFR*=`q zbiq?j8y$#AGPZMb@43sz9NRmq!VjbN?VjoD`-udL9Ve|iURDN{Q%&KbSOh$tfWCJ^ zgk+pDlAfxAIFxHUg8BOFd%s|mLQ&}rTN$HEr&x{mKUP=7ANO7ZTgZg_ z4Qv{TKNSCtkc$834$t`|(?z3-t1=*Z9!6^nQ*~sPAE*o?fanU!9fe3jMWSm((sPC6 zI|7Lv6~ANCE#}UW4(L=Kbaq5KcO2w%C;EoAHrRF`536-^vf!I(gQ`h?eg;O5Cdkr! z&via2gpq-bBUyD#P-j=#(;Dq!+8L1`nV+ZxwZT+>fZsIJM!n&zgI7BOipx3%%7u&X z?l;!JrS&qDrF5j%E(yu}&iE_D)G$!V|4X?!Lq{osQB|^As0nqxM5`pNdn^>(gVMd7 zF0A{!TP9Ja|BH%ZV!Bu;qhQ~|S%aQCr$3vYZyo_UHcgh|OXI$}m^hitWKNB(5 ziB`5S#xoJjnads6khhkR&K;0|N#j4dQy%o3eH&Ou{-XYkL&FDk?n4~oL0qzb#u#c( zzP3Y1@-T0;|L)hWL;#T^Od8h&*ZgUQ`|X9p;rpTyNy!oDYc(L@rhe2){a4cw#xwSV zGzO;AV2AHdC*%G46`3;|s5uR+FA7U?Kp(@oI^YSihr=ny{a9HkCcc5PoKBGgc+Kx; z{I2O`qg@izuRJ zUVDG;`?`iIb4jwCL*)&A$#RZ)@$(Hfqz2JJ&B8_rqt}kS#CGN2u zgp#O`M*(6n7`qpR@fuNFAZEOo=e%k7I!KQGke=(|LrSOtNa&a7{Q%nAbGKgRWkyyO z2OkRp67fn!JJA^RLPxth|duSz3;*aVg)qv$w^nyxGy?I^7{zTpOSO7$z0LN zD_LR2IO$l&L0m}oWpHV3<@Uz8==6D%He>GDJO zfXV`p31z%t)kzD_p9x+DmSPfR>6{9?JZI&lNzZqnDHMVL^8)~S+ z@lI9ZzX^329AGml@kUUlF-0}q8i0WtL*rTvzk&l@VhI*Ky3>7X`(U6oHqjs`(*SS0 z<_lq;z_3ZDOrkN&z8DreJ#X>XZ0JUI^o%d$)kW{rkF!xRJZ;#K=^hd-(q*u#SrVCr z{r87nbZz;(0rXH_|Am2mi2E!!bR3#88y*iTy!Fxn;Mck@$E^JV7>Bv`-q!t3ohA z=CBV66oH>GiVE?|=2XoUmuZzOfXlg)Y=~NNu-<|uSXEn6kV=+F7q;d!SH@G7>3KuN zv)-l+QQwBlToLHNQE!_)by^~?=T&5!T01Y_r>XAKsj;ZU)=)|yJ8c&E4!H3LqK@aO+`3hIiby^w`3|u~b7os8 zXERdU{7AtEXJmT@Pv~NhU)w*ZStQwgAGB{>E&ffJ#y7p-(&z@<;w5pT$x3$_Mj+4Y03ZND_greY-|6&^ezvS0%%9fe$nYHk zvSc{)^wW*W(YT0Oy~Uf}E7IHGd=(;Aychu$xkq{oj1`%%iauI)>A-3t8Gg3qjtAoNX_{-IDyM#u_+n@7@pok9 z_K>1E!?%8{=$viLK~CC5+o{uhqsr9t4GYnXu7ND=oK$LItr`zle$#ZtUy0P79g`M#?w6ol$-3wM3U8wy_E2vDEyi(@(pNp^Sx{Xm z;I-+vXK;}DUmK7ifL~#-76z3RAs|A)rfW0O?9JX56ca^?m3{;mfr9>;MUw+Wihz*gL zEBD~m9a~ZGo~t_930JU9Qi^S?^aOScM zR9spj6u?iRVM!Tdf2Ylxmbs8N-V$}P2RYfZFyJ6_MVq?s-gSLoXKK8?A_Z zTgiZO@cmJ{ zC|ob_j4S<2N<*zy{tOxf6nBD+P>L0ex|x2<-NF844`pa%{t zrI~nx9OtH9r173gWtop~A&lng_Q!5pS)O@uxg#6A=&6GCO(Se)Jf)iL$9@%dLD%5E z0ssCImw^GVOvC#csJ?&D_FN^1?BR^CQ{^q2Hy$;Wxj{M;%%S7-93UigD#;aDxHmmZ zqA3Y!>^Plc{vy5_vgAApboC|C@nWg>hGlwZ-`=kckT5fUd;sP%ux|a9|KcEIFzlrin2qsTsa3LuC#DoR;kb88 zS#zC#hOw)*xZV(^GJ<_F0Auxj6dHvU+poo^QmV6l@NJ&FOKbk)^duJ^$F63c?sP;L z6?Nf^MQ4OD-ATEbH#L4a-+!0>b^K2!=e`_DWB#B-9)Mp77s-9^r+%+Ro^9?n>DMGP z$61SlLh9bK|0xh}BK+E0k(Kq*bz<(amA<^5Ui)fcCc zKtNEcA?w3E62_=2?w-dl$OqxE%8Z`_uZD3X?70t zeTWLJ@Cm)9Lwc3vVC_3O-&YXcfgB4cF(p)>5qtMO|Fy_md7DS+^*H*8&;um)r5WENuzb@AH}gbFiQd&0S4uuJX4YDfs6&kaV_Ho;!KVrB2ew4uTsf> z*S_=hU2>^EIu5Uj>5t_6`f-&gQ#qFM>&MY}9-YUXrz|q|8txV}&%6@H{5NvThqwQM+EV5!>6QM4z{?BE*11*2)y=Hd{Vp z(d{lHQPqkCKGB-Lmz5&baJwXs{x$MBvs`{|7i^Zq3o{0wTo<4`IRs1&TM$uzoIQw$ ziMts<8jQVJ7NKRF8i3ElKfiA035fT#48f#YNZ697A;1;*>EC6WTTc|=_P){I`+sio zDN$>Z#7b>{PBA>Nus<*cQ~{xk7A8EPGMd+L_q@PJ^=4G5tSv}AvzWKJNJQ#ian6^TjoQacs=gKtzz-LmMcL@@T4V1slh% z_pZC#EBYST`w)U(5%aF|t~d4+W-J_QhK1@fH`WyR8Q+6Y6&X5*i;gmg3XK;%d@EC~ z!!(@#3}c&#A*xOKA0v2v>CrO04X4i&-ZLV30W5-zi$yKIgUo zZ!|FsOIg9D7{{Q82a*jAXkiV77zrKk1eK8$i49A(uaf1)*4#gc3}8Ldp_lu71a0Pf zyTS>#B#nfV<^J2vqDM{g+~bVH8+K!7<+24B{4#QArgmt3d^6?FqKS;@%CQG<@f)kjvk!62Zey?-X=`u! z0I5j)YA?b)A*0-k_P(DfwPOpG4P8zlbN&5!+&`DDO-kN*S;n^o*mmMWs%65#nfy8UFQ-jQgD>TEy&5Z2rw3D|aLQL>;gL106Y!avO?bbDfknH6?@$a1J!-+4U7t_{ z7gcgrdXB4Zn;RNMLNkAd^SAe7DFyR^+_=xosJysP+cU&36A(Jn#1NDiU?^bG#-rUs zqt`sQD`*-rX?8emUQy%;0G#liD<3jJM7@AY-G-C_``{MlT&66k5x-Ql>HvtbkgMAdo8s;5ZtSEI*cttlD&(X9nh`TGg$efhGm_aEG+U>T)h`tok`=CL7sN5XT4n<*<#ZZV6Zy0AB=8{!_QJ?4 z3PJN1EV+Szq9@1*$lcgX5g6&R z1u(O|G|gK>qIGqqZPr#)pLZRKg`CXj8Yl9U(Q-R%WWuPuu5>09u+G(v=O4t z>~i+P7&=RL+;-y4aliWjWa19sX4wZ<{Q_8@_tLE`koI|7=3(V2_+&5sok+vz5XpFAJONhGM@ZT}wR5UZbi9)?uIY*>OWx`(`Rq8$pUg5!kbeJE92wCKZWx%dGeD70>a z=mKqVR>gx;?WEm>= zvI2`ShD4%rJqe~lhmSzCcxom<-*#Q2wom}F_pZ==60KGmZ4#E&2mv=D-Y_zxvqlS3 zmn-N}-g;sHC#_r36^a_VNtx371BeL(Kxwz_%kGcH_~k;#;^va+)#IXd{(!&I$qF+@js%X4jpq3gzKoWu@3;prGGYU#Gl&zB~UIs2!2C zIL5M2)PGRe(YkGQGzfse0CMz!zf(Y>*HY)WK?q6Meo5C51^wz$7L6nyMJ)FX_Vtf& zvf8r@vDWpclCfKKbeXasS-Hent3x$&DASpX*dARF**Z}jUNptUToMBixr+67yF>>2 z6gY~NnS!t9phB)Qt~x^hUvOoO+X^Gf6rQ&umgk{T!>7{V0hi8Jopq#}*ioCRq@S)- z-}ayr9@IG9(I8t869yt+o(Nehga(jNnyF6Vnfidom8yy-sHRelOp}Gbh$l=lB5zJC z=vj0hQlFPGavJT}Kf-nlpoS#rwds^fSCq6Pj9(zA)|Qm@V1jmF9HCq*~DXp1b~1Pf}Tk0 zNPMWk@6ZLC%xc@5YUcZyB8i_wfCy1vp4{we;r1c%3yTk)e0USAkfVUHS~UnhnSso@ zDL~1YQOy%pD7a4&Iws*9C-UH*kYv@LmCW~B%3viBjeiK17%i4EPyP_kCEem^@uZ3t za@|ySKlfd&)N!$MYS7T0N?Q?k%0^+I<`s*)Cc_+`_8y#%mtAY(nZr zo^)LvIx3zIwW^c{Y6jbDlx2a8RY38cI5t}ymT99G;XW_Wn!{diUl4pEGCzw2EZ#$V zJPsEOZW*&J8hbqDqa7r45rE;2W?&1SMxafe9>!06C#&NeX9{IEj>9}8Ifh7U(y3|E zL3}hM>tbj57)(~)DPaWDcxjIT_af)0ba`WT^^eaDsin7#HW_4N76n2xJmY6rvS;G* zQrFAO+kxw#5egs?4yP6Rs&Wa=1l+KGfF^^8(F5rU{i7{7r(NDb6vcm{irXT-Fo$Fxf}v7~p_o^+;WR;l$CF%duJnexP5^kPZq z$(XJ+mcl6CCt?6=B$Ged?o&Ti``Edm+UU@~PYVG6@ce=R$pOs(aWViI00RyNB*3Vt z#O7nf=buU~U}P4satpEYi*bsJIHkpS<)sAGCA|7lqQ-I(VmVoJIYoOVRd*##ZzcVD zZ8BQTFkZ_vUCT0Cdxl)kwph;>8&^Jwm-V?ndw(ErpIC4(SbQ*CayU|UI9hQu zR(&*4dpucpJk{{!P1Bd@=U--;zs|OPecSeRuKnA5=eLDd-xj-1mU>T?`%YE{zONF$ zuMM59kDk68KYKrQwmE(Naps)#_IzvpVteUgcjd>Y^&g+#{oLQYJS1Hn?fm+(`|Io8 zuW$RmPY!;6Kl*+8<8dcIQx5f@%Pt{f4?vP{rUax@2`Jx}9FaP*7cd`BE{Nv===Gf``k<)jB-`8J%U+Xzp z?)bLQ^7U=wm+6}0$?~JIqJ!c5gTdT=;3h8?pSp3o9q665C)+Ji+s)xy&mV0y z1(WInHfwx7R64yYw%sVO-pDsz&D2>*(_Tr@Tu#2ToTRawsJ4VxT}n`1iod;xQ(TOb zUyPMqz{)IOq~_5Q^G^Z)|1OFvfD0w30h0R$pI#2XN!RXjX2OkP@r?bd3SA*{Y44Pw zRzAIy<|4)bB|6>_9E^$AF|noWO|GN>B~a`7nBXQN=yb08Wv$uGOtcZZ(X%mRrNKR6 zr>SSxX)bwJqbKzTI2f~StMiA33V$0@QTaqJW9vAL%4xEt6O9yep^fvME1e75E4rVx zH+`hl=Krze10McOd^q9CEV0GynE1i)QW2)u|Py!d|ZsB?BJCZQF@7RSR=7YNvHXn^X3LH4aY<%$<0loRYw?rk2 z{56zqJ3z$d*te3FAHx0 zaSF6x%?(G%j1i_I)x2|LlNZgcWQ#5>(3OV-A;OP6d?=R0TUlUY z5-ojueqZn%@m(7%V9|ZjtH3z1uU$dy!13<=0@bf=&Cy3+u^+4{5D)B01rkYZJ^St{ zia!c>QdNFj?xZ6uGMx3NvTIwyjb!Ux!WLT#?5ORW*F|cbyc-g!Bd&dO!QN!A_9kux zwIq%ZfO`9Gn2mh)2RRXYpG%XUtJV3pa2YvNM2Hl;a|%-5sDozyu-*&uqEFzo%UA5L z&owVUX5}MXLME5xf7vXW{F$k0SaPNE)JwG|I4P!x@2MX3^VlaJz34wx`O?BHIKjF^8@9K{iL732O@~jZIMjf4=tY#wUN>eP*NaOKehF5JT|i@9XH)a|f8wtxy>=nam$UXG86!B=*VnPY<_*P>340_}KYduPME=OP;^b^P z_igz6CtpW`H@DV!qgUpNphpj5O~Iq=9XA2;zm=QDnIv#=5Di7Oiq&c-11?vDU=@)^ z*JNL%$kb}8b3c}23v2aiwjLPWmp{BIp4$|1@BTx<_ClQAexu(PQJcg@D;D-w`O>U! zYzrA!sIC$wv^sT!@kI~kZr)uhlqAsVBpI6z%>8Isn%UR0rDjb>>qxJe^SIr}H+#a~ z$)8~N8EcWKncaYrN#-(Ruw7?$kPpV10_MbryFChiu&SGu(FvAQP`g>zXlN?LM>p~S zd|llQ&b_`#uRe6CfJ0xuoBrk#+Is81uL(mlmv7SmYH8Kd0?f?B@Ap`DEQGFv3H;{( z|LP=e{x&(|gw{-}NO&CC#ps)8FRHi%xgr`}iYBU2Bu)N~DF@0xnQtJ-shHtC^;55| zA*fse|KSju^#SXxcq%+$hl6fBzuR+Y7jIy){Q>bRzw-%2q3|i`pe|f-dD&MV@XnpZ zZS@ML_?(Q=7&-JMC|C8*GxIxIq(S=opK&DmlRvt{D&!w295e#DylkBEG^?AXxiO@0x&b@VaNE_v&l9 zzu(f_*W&<#+wIRiamL31`@Av4*v4!a)Bufb6UYDKQopV za*fC?ml8p3Z9?2cWEM`qlub`n-zQ94rMK2%L6yxr8v|FyjA>_6@bW_Lo`_kUCISdja5@i6M`l2#|9Dp} zUjCb|%^xX6{$r%-V9~qdlD>dUEQ1A zF)RA2eIc;Uz z2zSaLuKJTTyaPDqlcm3=CKnLDM1tjd>JEGYv(K?>g`~?Y2af)XyzZyBpsu*<6YqzC zvj+;dQHtMp)&oN<@00-*o~CZNJdl+E(G$>bJ_~r+QgqdWLX7BHygs3#0E4#oXH*!f zxV6t3^BrN^x-FU62VWJ|UPa*Z78)E{`*h15{CC2XgG-6M)#Q$C2_HNRm5IeKi_wgy z5-}Yz?;f#kTMJp^h3*G9@N?=oHM%8E4K}shFI^$&;gf z#D`&@xOg~x6MbLl1z*~R==PRdcF9_Z;r-Uh`+|#9ycd&uk($l>Lt?fhc&yr3V%2c# z!Cqn@`vuj+hfSYjv^4)s0D=rq*6TqCfUNX9ax+kQocV|x!WZDH9m6krAq)g>dreM3 z<2*LwpJ8mc)ffcUWOHXYWHnwGGO+nV0UAIw6u`aUngsys@tj~*8I6Dd#7V*xEABO~ z8BO}x@Ii`2p|?%%x5fOxfZ@;G(OQyEC}B<7joCl2*3?-Q)GIx8*0YlHg#$;+v!i)W~2AnpC%9Mz(RICg;H+k^&x`2 zf&OX);D35>US^%`XOH~!ZSK!MGJNJAbpcD|dxRjdE(qG5@asms;w^>Rmsy#4ojtBv z4y?73c0rK2A;@?tpnwTxYK&)N2mHKTHR~0Z)5}|TU7^TpV<7uT` zcr_YVE$7N5sAW$s4h;zn7`34}HO*R9a^dFjX~9TuVQ7EY&M0CR>tbM&w8|WwpnaQJ*Utm}w?ak50%t>^ zYECruL`L3kwQm{?H`I&I5Q^AWWT-x+M_vWRHiE##HVG&3G7dmysd6v`6IqDFKMutc zzk3ZY6PjBLkRCtW#n$=F>J}fX?nyN*sx*UDU64iK z*37i6OaoG;?4-2(I{)20NXV5k+yPP0G!*fT+5B;xgD3(VuM1Z~z~e5H!|U~DwPo3q z;eB1AJuI*)*X-&tjl4DW4`(G1O(1@Rxym;Q#AA8mb5K0W2Zyy(W_qH!oP!t+<|6mS zTEnwiN_Fq$;>c1WemUe0`5r!&F&BVqxt6)Tq)10RO%Jk~&%3v=#r1xl-n)fkQC zi+0QQNUkhWTjfCrg&MG9uKTV57iq??Fu&{yYJOn?Zo4s(6%E>j#f%mdCsyev6;;Q3 zL=jB`;ZLQ%6TDs(_9<7_cDtM34ttPbf?`+Tm}IVyBOoDKChVlWrJ=Y7iuAAeUCYTE*}lLj`ER9QwM)D9vXEY$3R zLxRphw*ine5(UV-RtyQk8C3i@Fn`OSJ^=(jbEV9q15vUo-XH@0yO#*`V}-05l(hoN z5I{z5_~2(&7q%R?$spNm?@((wdOy}?xAK=;PU==H>psbB#0TPN${cPuA2OGZyYa#> zX7@ItH_XW1EkPDQsnbG{LJ&F$)08%Pinps=^sTHvwR1lqM`EEF`7WdhD5rdy|4T)f zuLN~o)eVDCa__K5!`$HtJpD4&REE`z%3*xAVb-sznJ zlqUz%)RH!4;BR5Sgh>&BdfaeVVx{_T`mA*qTL1hGiz>Y-tX4uB|E|r^gdfq+E$|9J zY0CqdMAfEy6@<1BKr-auU>&92){kuk!}Q{`!lB`Q9+AJHrKdpoIi=DT@-$JV8}+uT z1}_x~(~o=IyjUxV!p!8Y-vss{Op2d7ptZz-O|Qka^ItpYO$bB)_FBgvX*T zO7jN1$;vJpXWry9#wwlRgP>b$?Jhp4AK7lfw~gu+Be&hPmW z%k^KuECjW$E9`8%fN|l)kzSkZQG0_#Ri6+oI6Zn4^pD=*FAUrQ8@B<}k;>jYe*WBG ztgtty*x|`)ZTZdGC;y4VZxLD=Rmb)7bHk?PQyzsB4c$#72uWN`HOs&z201iSy1A5$ z;J(~&XY|ZlRXE8OO(W))mXd#~A|I$M@pi>#bUST~xnOUk_2Ey}ePi;62=bgtNvh*V z5gpb~E&|Sz+kQ|LP8tS^xz9Aa0@dWnXT^wodhX7F#GdH^NscfVi8e~T8hUXi!^<$g znW9$S{RxlB!0_KPN&>&{rRa}ats-2+sOIzZ&*H|2gEb}`pOpnq7Zd*i29n%BzH|8c(fT4 zy_kLi{vwgu$JcV#)wZIg4}_WEtDbOZfi6J((AaHe(K)^eu;lHq7k;`DWw?F@G@K!$HB?5JHDM6Z`_x+rMhox7BFrert`1dH; zMpN@i_M8eJT5`U@HHam!d#2c8U8VdT`;&#M8OtX%Dll;Qb=fGXA! zKV#7qhCChcAn*-+b;(~6{6AP(({?g+nr1=bgFp5xYRlns^-nR|NYO{UbEH7-CCOGI zuf}$kWGv5~v)uQO@-9q1v|qkk+8m*cd1wMmJqu4%_mLwGp=9H^Jet>Mgojgecydrqrjm& z;*nwWj~ot|udvRk@iB9w|3MpU)2RBOaML;bFACfW$P9|Y`{b#Un!o-7!x!_k&@TRf zCDg1@DZm~w0sS-6(%)W`-iOX?!e+nsH!p6b_)j|sDG!h)U(A|?W-RA1OgTP37K^E7 z-0WxFOD|*#6{V^<@l^2%>I?z^o^X>}9{Pnk5reqDzSR4=tD*elY`L7RsCeXS*~>a9 z9`a^5xhFT-O-fRn?l6%V;PWzbOi|3EJM&R-jr4{@M9FQsOHoV$02yJAbJOh@a*qa=oD)C z0a*f(XpqUnz|TquA&!F@t;@BnGPZRRso>(oe9I+}xq6fyKollCXQsm!t(CMiBt&Ie z=sn>IJY1sYwRN02$AHP{iZtpb;tqCfQ}gtSBxEY$66p)?7R{YoFFKlv9{ahw-Ik&n zc;Ye#cZXzB8zLyg4BLFo#oAK2?%ZtLH&55UBWgpI$%co|7AikY!*cHki9QFQ>40_@ zek&d`f4}e4zP$?2h&n%>&vZ!U)^z9$T3^t;Jdt#Xd#ZU;@K>(%OyN(;mqOFW8c|K( z>SO**-&A3|Bo(Vto+sM1qOg3Aj9JJA8bH`pG>i5OxxxrGpSuv%5-?|<*(*Vik zn-Ql&s(hMIhQ1er#|_E%RXz)%?kev}s@>&d`Vp@COkHPxn_6B^{6XJ2MCSqVCmE#W z4vUN9Ta7;_m0(&G*&e=e)r~dt(X$0HxiitGeBvqxhD9;>w{ zsq;cp8nr71U~Rfotj72S+}m?Tn1cnA|dj^i~Geuj4Mxb`c6 zko;r%)eEPMadaP0I=jYqkSb7#{|nq=vkdF8Bxs6tYIjo%Y|g9 zsi*64wzd4EaJ;q4y>bcq`~6lBr^o+DV7llp3bwS79~#!EJKGSJ{v^gry`ODuYVcJVCk zXdW`aGcRSjsmK;_?-}ewlboLNpSt9fc6h2+sohyDygY$94!$|EB=PDa_9*1dPJ7>+ z7VN4xHtq1R%^%tO&FHU;F0&0|%WFm3tPBnIpGWDhxqv!!6Q^*;puwir;WQP>+7h@i zbt;2o%GeU1ELokAGebvy;?Mkei<@U(!1zqf_iPR~l;bG<{k;@O!uOXK{D)0(gdCsW1McF?TK#faQn!2%-4eFCmYjnt&PR_f?7(M-<~?|(Is zGmx#6|80~C|Mb};xMjrB*x^+mwYV+Q-5hRx}$%;Q`8?yma~3n6pfD3WWP9HzfihAgq~t zMXqNy3`OwL14t*#h)^0LH^9%vPL$^pfz&hI^dYb*A&<%}biuE;gcm-lL_V8yWn z?);sNuWHgXC~5J_c~!08xuL>SBQ2FIq_8%Vl)0YLO{P0wN*+W*NkKWPT68p4vVkmW z8^$9o79upn~JVprN}c@ZG3 zDkh;YCJ%ciwB45nUNDXPUSHX+30{9>aLWez93rKY#YNNcyJ=62Oan!`+; z(0se1Hl{*(l2pzoOcI*9XoXP>^UVNJvuwA7S8W9*@1-UD>#y=?KZWEz--F^QC7I|< z$L8S4HnM&=cL&VLiipK`K949UbLg?$>$G`aJwEXVutwZRK&`RI>=IW$^ArmuKA@(5 zUhFU^LH%1qhim-9J62_6|AM%6U>3u{bU*FR-w&#Np^<0#%pGe}$J6tgUt1cdOeW)c zJ9MVr0++<#d)u!35$V}t@oAN_@Uw>shUf>;$+tiHudJ?IJVB`!epY+(HLEm8P?{Ph zKKGTUtL$#hAeiYowx?7>#<@PCuZY>Ap-Q$r^RTt*+Mg$vH@2Szk-%Cq#LNOvO@1VC zinWd|e_^TS$lkEqvF)qDojowwLs3-4h8sQnm$+{$tHV`GfmNzPexo^=|KwO?EJCb; zT*^btp_&y^C?lH8CNtzDhkUSd$<(4Hx9A#CS*Z9xlj;dc$#ZH^VYJmW{W*}Ii|iLn zm!m-4+mdBy38kb^ zJoo0%+QY3o-5kU~D4XSHbwhS^Hp2soKSmw0P`pjve9G74VH9e#27EGzxuE^j`@YPl zIcQ|6<}NdZG54Ey{)KXd)aQv8TdGAK-K} zBJy8;_rk@u@DCM-94`}I)6RtP-fk+al=_#F=Q}U)(c{vk_51jx%%0rajMA&=C#6my zxoi~X!Nalf@=bqVf^hf-1xc6B#Zx ziUn#bN+!XS?y{ca)d;2Y=(UPoszd zQybL3x+olVrNlSGD=KpkwPm9 zD$9N6zc&==F9!&GO~+KUvM0kfzOg67N@b5CfH@5SpV2mX-?Ys)=16A8XP+3U(F{u` zZ@gMPs+i;(3RN4LR&8YzgFZY{oAbCmpUlnP*lL4>5^SN7I~q59)oZa0DW^=B|)a=nXhOSq2zPGrRl9Hzs|4?G+ zY?ET@S>FB;4indUG~GA7(|5(Wh__}{rwoCH$e@KeXP+st1W)3D(&%WDew&t-)_sxk zw%MZqC`3twSyeQFl!(385KU!}lG#9yhcO-|rW05>H{v?rN-5?BjIOf%2E)R!1|mBc z4sNEN%WS3SF6B^2;yYPYer#G*&eUTc}PyZQn`4EPW6rUn@sVE!>LUR zaUow2AH@Q#0weDSnqN;x1pB8)!7=Mi!Mc>sdnw~dAUhFd@&w3psUq2+ik58wdjLHgfG6`1 z_B^J*XdGwwWXj2y%_aKxWQ%jKDNphY^%{Uli^sr$CKNT!NHl}vC|PYJ)L3tU+viNd)S@xzA z#bo$&uMYooJ&QxiFMwnbb8FaQS(!?ESe8Xd!IqN1fLo(#`lo99-FCiHr72}g@V`a8 zGRdkmbaC*Ev#R%n+`sk?N6Syb#_AkvF<5MWyRNFKg4IK;wOnm$3uqw{rw(M)o# z!Rkq1J)%wEjDwSLab5n@3~FL;M^xubT9|^`dXS1~NX)HcAnPKY+OqF5VkRZBAG(Zh z#HDaE4t;Rcx7as*tb-#*w^I4ikfB?BvbDmx>+`0#W2ykle_HBJWF+JI+5D(yQ*5sI z{NKi!Ok5VdMMboI1P_oZ<=fU?ONMkMM?+Mi<5D>1URkVh`)a_ZDa`Y*DXicbuh+6v zBeH`7*$`UTlQLrqbrGj|z4tfTd_Ue!(P0}*FX#tz^eev_*%%LhQQlrz%avaDyvRHn z_v5shS9!WsiL_#{X;aot=1n4hjlAI_P;%ymx3n$gmJK?a zJ_fgh&R#O0VQLY-WBQKVM8SHenAfePe+Wxn;c)40qBryI=2r)=C52c1W5j<_vvI7a zu^^yzg&9+4^Fr_3d#X{d7L*ek7alzpzMSrhvwLoJbE%G+6!&@&AM=LM1^VHNk8?#p z9tXOlpqZlx!R*`)EfXrz7eP^m{i2}O>Q4`n8TZY;GfRQ!v_>e>*Z=`zO1mjRN$=HS zZb>J6Ams#``93zC50Wg9)qLw5RO+_@`{0adwUx(VC8}e zk<`_5hv~|MIGYJ?mmc}TqK}#l9}A)KbaTVr$*vUl_d+$6JY%3<_dh?ruNW`u?N;;I z&ATP)=H4R>@Z}NhR|KKdnC^BE_zdp)F@o+t#Qp_xUpX8a|7hPtljG{Yyh$d_k))gO z>av0XRotI#t}j#tCEnj3E7*D5VAuk zb!L*i(jbJ8gj5>e{QN$D!Ta%fyx;HFnPfMp{J)v!8GsnyHow<9j`4K zw^dZ!j+Za=w`76){X#CB^{zySy;c`PAKlQ zLhdSXI&ur`{cu-3K~$7mGk+zG`o&i5zO-}{fZc|`^Y>uxg>c56L-QV|lYrI29_MK` zK#d63RrYL7V)_ux!Nq1R&{V6YOu=#abX@0Z4R11pM!0HV)s&fX0(=7br0ysqkE+vgOZ z?NY7w?h9$`2@CqVYa}(Y7dRJP`)U#z3xD3+(h{w_@DR?juRgoCqjNp@I4MZ#rNQKIw}TSo(jL-;gd&Q_^iP;9%ak)wOBrLdwe7kQ}D_&L`& zK1UB2VS2@IsN?P?J#~38wR5emuHfx>Xhb_({#PAUGmREpA&j=~BkatF-xD7B`E>akTosCBH@bYac+q&^!_OgjAke`b zBjBrnTpqPziEN5c(s|X`v&?!^D`Ud|D#jA_1sCUVB=|Upzk>%ZB!!)eX$pxwPJ0AO zc}!#*C8kasiQS(4y*-SS-L6pcuPl3VMj`%+XG@f)=hOQJ_#Xw<`ZcLkJHEh>zK^*h z-VP<_zWt#qnszpSInCw{iGOSl*L1irGY6lIghaV;RA2qwn-Km z0QgC}CsMY6-rpN5#4GogNg8;V)TlwJL@QQQp#%+cHW#Mil6i2J416*fVuB&qw^Dmo zB}VyVy`I%L*(9X$smnd9G2NS1T$OjDwEee-Q5o2@n3HN*d!`*udx~;s zeMnen_7vMyq-@HTllT=)BJ&u4&EC{jqXME!ElF1 zz>L#j3Y9SSGi|+4$bKA|A~cETg`ka)BRPz?;Pu@5zQ%~-eAm1f6QH&Za!RI7K!FJG zt4o$FX6gJj0%)qK7&!p~5^ZI0jl9u#0r;8}11jnzu6H_zIm3o(XJ$)C_^#C$N<^!0 z8%tKjO`8xpHFzFKFCCICwA+GF94OZE2h^0q3|nh6O$f%$Ruw0TdL*alPq9r!7(i=& zo8&q9R<+xozdES;n)muO(nq0H{U4v*+c62JJd+iN94|zC56{E{v@bdN6a{UZcuS-= zh6wMrb9)6QxnUxXu1vM!w)RDzf`B;I@8@fGe-mja7AmKDdPix{6 z&yojj;h*(MgSTu3)IUU+`%ayw1UT-c@c;L0`rh&Ff5LPM9fljG#d2vS*~O{82p2_W ztd)o&wd2C3cu}gr)~1|{Fwc}T_{Eb9b8Te|d*`$1*f32d!QMdUH-BXYg*!!@OnDw- zOu-s)tg~>NX-ecdB~+~AU9+rHq&X(TNR%6(>plSAx;HmI1UwU%0)#g90W2X&hP}^e zK?=yV7)c5qrY|>;&6%P!f-)2i0$Ur26^2@y6t=cWo0r8P>eyBOjeuv0=VNhf^=_7X4V@e~)oAn9r`GDT4co&z;Kt2J!WX2GvX*5%CU)zaz{>qX4tuGe> z-;w{3 zS1K$cpEY38K^2dhzTAqdx`Hqu5LwgsgTyc;XJiWi^t-G4;y6q#3p>d=iwr1(J1!fD z@?)^_!S_$;uNhz&#J4qQ1i^zf;QT?Bv2+#t7km$+nUtFc9JaIBk^INM2=8HYiK zp3X@<)g)-+VFmX?Cs%#A>GI28L(OK^DT84z#PSZ(_4%HZh=RgvKg_Yi6T^ii)#seq zg_Y*=2r5hpp#dVI@%&Q)`XruO z`Y-{z3Qi!;R%ET%LfrQsRUGzew3c%m@2-!l?NoE{UzsM%2Fe8duH$%NY;hXFo3Npx z;4#-S&i7-l2p&`I>dsFQ&2om{Wc|+OOsuoMFEdB3r|N7wO|X4bhDcqu(-rB*1GW{E z2*@#n^q+}iE{-*&g25eqosE`Iw1A*)`8n#v_hs^KUrKews z;U6S@$62$CleR-1qhD^yRb`o7+0HLRRI?w+fW_8`P?Zx1MjTBKC4rMR=@Q3k*8Xp;>@4!+koD7S^SJx?hwzPFOW>>APcPQJnt~_*Yb_V>+{eT zJ3D&O{!vl;zge}%H<7p8@03*eL$trXiT|?ojs5etmOB|9_1e84=a~^D+_Pi$X{C^i zua~bYvrWyxtPg?Jsy%)d->@(ub}ulLmOR%a(&Babt!Iyzs$kNCmgtu^8suNKiv|%N z2`yhHvvYUR&ePiOX+*V1DFER$V{e+TREo8$eDO0qz@^@b?>=6=!faseEH26JdRMRu zf??6TjsXnB!L8<&>0CZK=~r0m;%<25hfdjKIdg}l+0y)L@EaNcC^~Y=f@{G-#Ta7e z1=&!g&t}lWQh|B3!j=&v(V9W0B%xSUOV`lFs0ik-P)B;hOngI5d(uS0Vz1B+l7l)V zwbL6_Q!)|HHd$EERk=Vwy7sIwrlm_~zGKr>g^?D*b6*M;3k3$y$NDp+=xM9M(RLm{ zsW8x(5%?aM&T3}i>hb|rUz+phLcu%u zc@xHzKDf80ndwE3pN$~y1TsZ}qV@P$_o}$pO0tOjrw=gaTg{nowa<&igG5=aMYjg^ zeTzY}6^@rrOSTv6D&tZG9?+R0%|`nc0X(pWG1?`+?k z#h*3w_;p4be1_Cs$O(n_ozMKFS;JA(!0sJEo-amqwzr0mswJ{Jh(? zyyJ^3K)d;MVg7Ab{@=nbIV%DNcHxtSW$mJ?V;L{@;!Yi=U?z=j%f96@7g=w?aQ{ko z+_Mw?2f)QFZGN$PuzvCe25z&$YFo-am%qI;;P%j!!fBhP*#b525Xguall~#dsH~7d z#2uJN6XZ_6iw3o&k+tFnzY9c51iAlYJ&_h4gB0gkR~Weh#ub7AkWoGf)rab@##p>T?n=4@G- z7`V=ULC>;^QboO&GQF&3D|1AJk5der-7neyxh` zxs+2Gr@u*Ci+!1Q zq)ro3r``9)9>*8^@LF`wvC8ZX1pM;(2mlnzIr#Z7{d6z*%f*#gi&ZSts%@XT{Px4; z1Th2^G;T1!Ri9+eUdLXLRDQ5=iv-F~hXk&^268`mHlA`WeRke{fsO(_NDvCmdhc=> ztLGD){f0|DnRY##tFF3EKZ^Q`nEKcy?r~^keZ>Q#C@zn-PnU!m8&~;FR?>*VCXoI1^BB1tNL2iXY-v=UO3& z+^T8L3}k*FFqxHQL}@MQ7oI0r(c*jPd%tAdTO>Z@=I!7-j9GGG{NLw+Lr-<~)JtCR zxmb3}GAXercn0nQfGF^Vpav#_dG&_g*k_uMT+% z4K1c5=#SI8Gma;Ow7m?&y^`r`U8x2eIB4Ux%&J_Y$;g9Q#m!ke*SL*EhrLhc29?J- zi$d__@j>*b>$?tSpP9533r#$K5@{>E+&b7bepfhQ$Z0wEA=|!FH@WxTO|vKIx%DJ^ zMOAJ}miM#jMF(Q9D6c14^eRUB>IvEP8-jElY1sD<-!C&iv*S&njuASEZ@Be2-X|^4 zV%9bMvX1IK!gplKtPj#;!2`jvkJIp@U)=%G(D(4i`}2?evVL|MOL=^ddb5+J{pI=2 z4#XGm`EXmH27h`d3W(~<`8Q2Ylc~I}bzd;A~01d1qlI;p~6Sp0hw92Aietov1pl zWy$^eJXfxZyKS=x#)Af{eFjafYZ2t#=CVSzzMQbbgiE7$*58`)a6^SuE}0eXyGQI& zl{Pm6%KK!^%mN;^9xn2`;Xl{uciJy^HhPqH8Qu~SN$N9%#FQEO!b~O5Hs#a@Sx`^0zjo3kSWGFk25J{6QekunpX?oR-b4!0=m@r9n z=8KnDIHflTXt>Fo4B+*z@ch-HA7L|0jy)!~869oZ%Og(a{<{HxcOR$~ue)oZ${OGJ6vWSdl`Zi- zqz+;~6GSc&(zCGqo*|>&V#>*FK;a1`ebd*{pVw#B=|TcHI(j`RZN$$EZZ{P!E!UZ@ zwRgaWp|l|0d%fPH3%}d=0k#1Pg@=6KhBjK;9G7#ba=x+YZ&=1cd0!~Lj`y(HRaw?w zFG&j<%z0?Xd+mj++i+l&Lq*NV+KOYJnG-IUwG#!R~t1K9(NFZiY%FdYn!gB?Fb zi&LrO+&<7@rK3<<-%^$2y_1(oT3BbaBA@=C+kksQ-cUvL(eKO3;XAbRwJm2(za#_B zSOU`4g3qyW1CY}fJL<1`R~N$ph2!=A3BuyBk*b}Ds1~rt8tci;qyi^%fpd)u`LdCpaar@x7H1o_|i^sdR zxu2KE#(f5gN!No2@B%}~Dj;1qo!ZqQj> zQ{vguVVTwUp>~3HPT>3R)C`=o*04*7_^bwN z<6UU)EIIM4RqCxa8NAt}{n+ZWOF>8G{oPOc&sx4JOrDU7K)3T~&{IAvJJ#O8aveNx z%1u43Wcimw9RRR(GEEOV_i^(5@)hEIvaGIu2=LxYxh{`0mpyxzBuzQIdSd39p#ZZ! zSW@3U1UFoaYpQL{zxPx|@ZRvV+p_sxuM+z_zfoXlYx&#fbHByCdfT)-sYzt#Z{D_} z+dbrdYR!+0CPNXT200p;o`XpMCCAR@{TL{_fWqbBX8IbGQ|fHVUn`XZzc8)pJjPph78LwRN|q%_asB~Bb38fo)U*C zpG?wExg(+<-u44k*`6*0WSzzvA#vA|Rk&Nh6*ba`UVy^cfW|!$;o2rlmoa}y5mWATS|0m5-tf#hU0bVMr`p#031vt7(g}?83OkuD&O3r!$icL z9+?|2kDgfXqp3!$ll84&s3{}79CGeFjYK8c(LCZYDpn+gc%h(m3z9^ zqCY5$LLh3Ac!P4FyegXIle+Fn$19M!y1MPUhPH}wf&em)4vVixt}E+@hl#Hyy@@ex z?B3`m$cvT{0Tl{pnF~h##$~=-dfcXOD8oi2qe^n!ldGw4Y*GJ;ri%2hN9;dN3|Ys{ zAsiiA@1w4)FdKAb#j zsmJ+*pE5{#M&0BDJR9aZx7GH8OLgu&F^#y0(58OsWxEi_1xMvK?;u{Vh_SP)9#-ge zoC627z7!JMA3!_UwhRimmyizyLsH3x_@c?xuMTf(#t>{+ndxD%Tw1HKH>8z*jS zdE7Z6wN-o30rU(^Syc1bW=u8GH``q8Dzd~>&Os`bTj%G_qxdf@!a4C|J4c{($Sov0 ztcaBlXk)Iv9vXX>$nkyFTB@07_s1bE+Rnk9L&Dw)KZ%SFBa2pHOUOy_vJkmT01(Zd z#NLVs>A{IMX)u9oETHtuU>1>IIYv+8vp8ufU_~n<1kRJSc!A=&GtFul1wvzd$xu{K z9QiU5CHRynP2K0S!;8i~q>>3I0fPfK9k&$;J>y^QIJl~m-#sZIeZUwBn!D~pDOWR#!aZS4MGP4d8Pz_+ZW|29Ibq+(%=Uh3&|D_4%BbOQ{X&8W@W9g+;dkZ zZhcVBIZ_FFD5?UshS{F}V?oZc=#{svz~$C%Kd!TDmjN|zf2ikejy}J>jR9)YK$rsA z3j(GmXtHIJOwuU?KCYs=WEidy(?F$z<3!hpo%Wziz~3v}(q84=0n?2)2B~h8DLW4L z-edS(={T5NApLpSfgZmtIZ034vo{@-Nx-imIF;E4B!}`IQ&1OfwA^vasX9aPzg#!cS8B~n*M1j&t-_)65dZX4di79`>{B)CUp>qv_W>p1AIY z8dfdUe#1mLG6^}fIS2moFd{aaD=V>DwWapv)$S9Yvr>FT&Ah_vY{b2_mp$RfCoIS& z>^+)Gbnop(c?R~-_AjTu48k!Qgr;tS4`0h9jw7N$5fGp|9l`FYa-ThvAp@OGfm^US zz%O5+s1!`+20s*+M3q0oGp!t3^?>O&iyZ5W$Ewyou z*ZFcOD1}5;$U3=~wdSnPFq#Y%R~jvB)64o&29}VvdTCBHM8?sic0IBe5y=^(5* zR(;SI{=wCg$K;C6pVgnz-*%Q5h-)>Jtw*9N$fj{4BF&uDRvet`9>e#Z_P16Bgeq%3 z_Vp$vQD-aSJ~kVuzMTTrtlsBN9cDqt>}%W)jU@y~8$VMebLLP*0q`9HKC`dzecZan z(pI`EDmF>H9Dj~_M2Molb*lPzRP z%b$MW{S@A>vnyou6=O$*BRh^Pc3MtR4-Z9dzkp>P^sKIJ%{;C;`f~NxnEW!Qy>G7s zI{~4$Y|k&pOt?Gek!})!op6p%;9El8m{%P8cJ62pO5Tuk&ZgAC0XsZH{I+2*;KmsH<~*b?7B9XDQn*vc-yN0!fHIsvd8DY$Og z3EZr|BGdPT))_4OQK=29jK;Ajjis-qrX4^pS+k^XjyIuK)q?M@fg04;((i6^ws5C$ zfQ>V1VzpGH&mPHTK8>Bme>p3YrX#+^Uhy@nkEo^lWq*`2XBN8%r*LR1Kv9((ROxTd z4P5xOITz`9Pvb*_rpIJ2WW+;CjOpv}mmxml1qO+s3}$F5>vo%8PQjJyWyU|R%Ri~3 zz)O~kTDLH5#Es#KFedBG^s7As4d#7uFvK9lHYg`<`POs;&Y!l}2BZG?T;`Ls)X5cV zviwJbab8#dufe*lU_Ji?SD%oKJ-&YBuc_#Sa)dK$%mgmTvYtNIj`4%OI0 z1u)CvqM^1#c*OFp@KGGv1N^!IeA;76p&F%PARkAw(j|qtk=RGAIK}$5{WH7=jK~BXuqbvSwZ#QmU=J;ynJ8dyBz~;BXI4Gh{BG?eM8~)Xk7pWJ{9rg#b^?&W`XY1W# z>z~}47aJO1#^|oF0T^66(5oh|q>`x|9h)3ouf#a+bBr@cG}CVfRIamJ!Jb=eJ`MAi zzXZeg4{awxQVFPdX@t>>&^*GmV=x%73b?csYjIE|9{A`OPSWFdo8j}uueW~Rfe80TG_rmu05RS^_z=^= z*huX4`o=F74a!1Qh$C>RyBKjmEH_{^W3XSO27vR8S^A1Gx-6kLc-*%yraM09X0m3) zDV@>hf^J?{TM=Ga2VOLSI#S_65{X#cC)vs2--<7x3D8vElY4@u#8aveLXgIx@Ixcb zW+_GKxSu%O^e~%NHUZPe{tPcZX!Kd;e(HFQ^f}Qqi_{&n3s-SmUp(*o^6C%i5;^`2 zfdE&U%bF*%K62JqOC7(VO?67L38%5$&C(WOy`n_W>0V~vy`nqmEAw6N5|+#NF!f0r zH<9#_^B-rB3Ky5=*IZr8!*1H4+1EUat07Cl-|e|V$%&zXUWk;j1*|8qf>><-2~lHe zTu*o#Hu1BF=JjtCQ_q1*T`nH|{BEA@Jw97$1zf5UVzFgQsJMy?1*ojQY~67D zw~0~q?YwOaAgCXJqKYnLuA5=cUK;^=8}S^l;%rT|F{?0p;-~9!wg1*XX*BD6uptC5 zJNglIrEsWYgGpWUjIF05DP_#}6gmRE%Jz(%c-}*Ql>M{8w30&q`#dCP^!7lU!tBnD z#7CovxAkk1AkO7o^-X}=%6H6rrkfMXr3#j&g9~lH)7kC>Yo|`*9oqEe)3V%FAVt4#gqX!*-o99jLp0S!hewfa8yejv_+Hj8>HxB4F zNi$6wfct(j3WPnme(}kXjbc-+>BuXyc@-t|ee;iK-OTTp&75blAeih*xGa6K^V1?c zUgeRcs4r2$jdolEf!N#F;Pn{;^1WWl%j5EWM~3!TCQt}#D@tZDLI|1~2#Q%?b!7*d zQMh9fqJn|{M{ASw_;mIaq9W@K8Dvr-MtrexL=dL z?e2;7C>oJz^L)85LwgznWs1X=2&HfqR5JtP0D@N*b9@}G*{P;GRAEgzpt@b&Eg03 zg5)0VsSW5>eC2uD9ANOUZ4%=ve!YEp@;ciKQeSJP8t=R=7yD43)99Jo81n7KTm`@~ zdkNZPD;QOqvrvC{p)J`=TJdBXnq1C%+qhN~tzIt)f@}LIijqU$d^QguzEA%idz^`J z%2qXyBKv)>^!qvcdzpjp`0!UvM-GUg24L8^i-L3|q7tlndFTGxR@wcN!L?;tV{n*d zOB>A;o5V*NVtp0O^OZ*Jnk>jQN>UuQ4 zX5b$CydJLNPxFEPxrVlYoU0ZW?%*dP`6@XDj4vb}26@%C|LfFGeT6vYMioFiLzBi> zZDF|gfbIZqgy;Ml-yhwrrca_QMF+4(yxh3m0ke2c?wIQ^E&kGEd*zc4(zZ&XT!zw~NK!Ce>t73ZEi^bmJ|kv4k~^4xw({RX=jwre z#vM5u-fIjjE{ysH+gDV4rnq>3-al)ihQevJu&VuHduA&3=4HuZTx|YVN7MXL^=t)y zqRfv0zh9y8{~g+g6Gc54$)-LKupT&Iodxv!!?5MDh^#7%1)#+cwO%R3fCC=12$og_ zU}BP`yDJ>FZQadW5LD65mw|fWcS2GtFSQ3r@eMcsfTMA&1Ez3pDE6#F8tHMG>ElQ5 z{1gPIlXs4sq*`%5L1M2$w=chDXJ4sdaT3pUR+8j}XHL2#26AMps~zt6uI*SjE!V;3 z#Psi9W)^AB8r09}ag3Vt6a@nrtuyRfhzD8+^A4iNOA3u+WR79lfU;%5*WW?Uh#c>C zqdVZy?v+jdg%<#B85Yry_7{uXdl6{`M-X;j-qLca_eDJzDwJ zgD|j&iw={*-&Npwd=Ec*?U86D=s+y&M_g)UflUp_vmFsqQnpoUv}Ulk$_qvXv%s zQ_Wy#euXx8$pD?#?0IhaD}Tfu_Pzd~8BXKM*JBmqhLA3}m0pe`+kiBA;CMIC>^)h| zOQ+H$t&f4sd4uTuuaj4?8B*NLbJTqC$D4Z7BDf8YM1heEh@E{H9x(ni!;(8E;-!2~ zn(~YVEOx~3Y<&5A8K(pv>?h_jam*OX<94=#CES*&b`sARR=5r8o9aA+{1;YW_ zs;3}gn!mV&bU;zMoObzSJ9oO>8TLwnKrp4~&SRSdGeHN8C#n@g#l*n1Lg3s!EbX4v zQssmtLi$~dA0bBf5dtT{4f#?bH=7)Q?yLXnsF?A)fjzX=v~?_O<1?8so~l0UD=f+J zrB3z!bI};cNYO(=2S-fLODC$z+UchY`l5=;XCh$DhkfbLlDgv)dGqR%_KU86q*FQhaKiS==~d@AyK@#g6v zod4Eh?B6H#*j{(wyP-Qo13yH8)T6J$af#0!(P^?no;iW1V3bqw@2-@!JXI0xLmj=$ z5l8XR9^e(qS3JIr3S28!VD^EHPMrq=T!4V)=KwGZ`hA`kJFWa<#|fr5pj-mS&k!Yw zH2k)lFnl(_lHWCvImG?do+xNv^=G|Pc|#?31F)LQ0cb)9Mo3g*kycHzPd&-T3G9M7 zzSMd}Kkl?snra)%!-|PWRMW9a5ANm4GJ&EaqT|0cT_t8nj8Q>&W}l=yDRC;IM<%m5 zG0wJvIw>l()Aw(&qr!iOX3WaQwmG(UOMK=%s>}${!pws_yq^&j$lR*;C zv;Tv=LJwroNjCVPIBxJUcNisxRj`+a;^ZY!P-Kl2D(O6WcEC;MW($C9pi&M-6ZeR? zlz;z~6w_31BnJMC>kmGDR_oUam<#-LhZ|}kr-LLVu#>? zwl(N3{C8Z-HoTcs`;m`FRnueVPEWm zNHTyQbiwG-Of-$~uyTGNumoY#39a*7jtP6HMojANp^b9lXvw+UXrt9>KT26L9NvkFP~HK+Bzo%r%C8C(u}58ACkcay>I1JD^wa@(el!?};+;B8a^| zu)i~0%?*YRVUxm)&9M7&MWdIbG*(_(u!8_9)H>wuBS#wscW$ltj*Ro06y6c6#22&| zr&sNY)w8Vj#l7kar=RG#u~#I|M@XC<0y3p`uItBoq44&y#l|T|Sq5AZNP884k zhzhL6Un8Y+=7Hqf2r^UzVzL~m^njXL{X8vYx|gj5$%nuV%g8W2UUp%a>R{9Js7!H%UKb7 zj4_RFYsEB%ddIl^GNBhG(MirL;-?u?*di4KQcfc?W%8I7`))h$ad5MiD3*!WQydlz zpUlc*Orq{a=RXZ5%ZtkPV%Zcxq`qbOo7{#pOKA!xET~!7Ki)&1UhXEz+v`2Pq9&Lv z#`7g6e}R8ZHDtIL@s@Ev#tU1kpgr=v`*%u7^?rcCmflplWYKiZHNU!FF(eAMr8%NJ z&Efp3Q$6>K8{4Y_n&9Jsq0arvm1%b`{V{u2!vj@NNXx~sydrD{K8l(2;JV_75R$u; zq2}AGbLX=Nai!6u(8dC%uRocy?`_;J4`n!!?y@{BF880*D?FblZh6nNR*Lw^i02%lsahz|Q z{;&qC(my&xD$D9lU|VdP7m5p84wkkhAt3ZVlA|a2hEAWWT+hp=Nt5u>$@zvRd|li` zu%3|MkG_y~_Vzw9jPw78tqT6JjgYrw0Zcm?s}|$2wkY9PKw>eKcOcQ8B8c zQf~ZgCa%_UQQYPsa~3yAd8ohVZ{C@hxd#-&U|Eg-ynhjBuFy*QdlCW#z=+HX4N~%a z=V5-0IS^I~DF?gc&`AT`D~QI+Ha``5%6;vH_tc#%(^xK98nB|KhP%~d@$G-sM!jpU zS$VUSM3V8_SF-UW&@zHBw`DqUzq+u#t6Ggjy9Hg5gwQVLoWLoV8$#|C{Ib%v%sr4j zj#<@mal*pYk>&f0!oR~8&to(mK?f@a>V4ajV?{C?PL_}&aFn8twyZE*0f3OrfD+hX zjQY^BLAxre&;k~wcf1Tc-pm2w&|ZjoZAzBa5lddTt(#Wzmf1`aG%ES~s+p)`Z)lQ& zi2tCB54^%LVc1U0GqpC$ZMEo27M~uscuWAtYl3*Wv181QuSIP}ou1$@p%?HMars6o z?pT%qf*ZHaw^2ssb>EIwXbLr3@RG01>w3R4W5F-)gt~k9H69aYBHpWDKjbZZMrMh6 z4c84;Fx3eQ&VlK(-y2H-@si?lkASUx-;=H|EUW`a1vVXVzMs1sh{YOBYxE%RLKdO0 zKl;3yNl5%)tmi&ARDL-sBlPhTu`B9f{REK!d11*7^O>IMY&SK1D|dKEWXTFaDh5-Z z0yOh4{h~6=0_Q&V$T#r#5$f?rMdoL$%+C`r^OxPZr%Q-P8T~J%j5hw|5o1NrQ`iugY(Xa1Lv1M z5j?><;|V5%in!`*mLXMBvSDb09*Z_L7EVGwNAb7ML@vDuc(Wcb(B}U_#dWH~Y;r*R zKVF@SO8-Cq!0}4p&iz0wJKEZ8VCpLVq>G_h@5IR)7_D!Kxo&wX-veGC^Dr1`2`6yk zJ-e(ud6GQ)$g+?r1m6)Nm746m!lI*&;sb_~F0zPs;?DRW9jV9@fTZMi79FIET(1|A zK|ToPp-Otg=n(bxYu-l)ZEtw&o()Ojy4!{V9mH?dPv3oH+m-2X<)`g6I^XpRNb%Ua zHmhNG9pYQ}BAKvz^ef^Iry-@JtKGd)zD5ElwVXI`8UDjHIIz$X~U0(Dm~f_ZRX zL?#x-Kw8xk9~o*eB%G5TGpShj?BJb)v+1TKQ;Q^SKO5V7tk8b4A{Imap4MD9Y9sb2HPM;!Uo!3yHre!fIIZ33|oK z4`O9C>BJk;E)+=(C)6gC{>XA?n#3O>yr|hR!P)Khm#!yW`sr~xu|M%|%(3xDR?RHs zhu!A-MC4$_f&pR5!>;6ZCJc!h=D$I`BxSE3ikFs~(UNuR4L=wGlz_#%OWW-NA8 z31%vHm?!%>)o(zyX&L^y6RpO}@(y3Lh!4$xb4%WYnBGW-h(hq+i%bl7z9#V~ITj~V zW69;$*1C!{DDd;V*If;4jYO65p?t<|il&>2r+Jhb6)E-L2PPi&@oJ^z<@R%3f{}an zvOK_GJyrVlEI4;(!d8)cEI_V}R7m&d!O&o0u7&zs*E4^n}>DucX)DxMV_S)yn{Z5HWA?H8j{m zGjH*AxzDfU=Xy~~-cdbx%hSx0f^7b=Wd0lU{ieYDo6>i_OX)@y;t}(Bq`EZEMTw~3 zAU5Q=b6V$q&ISz(d+0eLrIAE1g&;muDf=^3X2Vr%yAXl`D@R)ipkqiEi%!#%n-qWv zBrhnCHG^3syU9HXiuE&**D4Ryij9r0yZcw;0qq8cSc7pNGPxM2AA-$|hB&y_#Erbk z5Ipd?Jy&Za3c5Hckn9{kS1VYbsUFBQc=sz4yHy(0ax8pnGVB@z7R{UaDG}|U=jeaG z?0%7B;0#33580~+opn@mkIqiBy%oy>`LmV%mwt_6WoU3SC)-BT#5f7+kBp{z{Y*j+ zIpa9OUARm_DUVUaX0Ws)_*In)5eq&?Wl?4(D@>Mu`B84lodTtsP0u%b^MF=TQs%&c zU-|GY!N5VW`>>&WPIbUR5r7@}gk1s9Fo~$40^oaqC$FAx5rHS#?APK?eS?8Ftu4<9 zrV1}hzqYsJl}t_Dvb^hud{fSnt5MkDNgZ3LEKCM*;I(Im;S$cg=|om!F-fbsN{w2y zyeV7U&qBsRj1_Py2Nx!$ixl@+aHr(Y%pQ|}w1Np8@&GLKT4k~JLh&`kBd1=BS+)@G z8)XlM(hbR89Ilh)+FO`vmJvwxV4W5(*N7eRJ7Bs z5Z78+|KTW&*Mmhxfkhb*ridL@MzYL~MCRRbT}c%8?P~Dog+n}8Lh5bAmL+!9uhtbv z)^D0^9aubkm7nB!=l5$whBW}1aSVXcMp&Z}HGv^j82|+K2|M=Hqfr2t!mFM?PtZtO z@7bpV#Zum&)Z~{<thxvOimKG?`C1COl!8vj=Al~Bbn{Uf#Fi@Q zNOgdzv0zEKav4PCAKI>9=SUzFaui|AZNW($_ zi)x-kD0s2miwb3JnDmK;dPhUKIHn$3)doyG5dQYSQoIbR2ut~f(bMkLj~KX-IbhMI zxL6Lo6nzRA3H1$xA{r*)i|w>ELr?Mkhe4%Wtcj&o4m@cNrn69sHsMntP7bDhBCfsC ze-Sq_W{v-rM*2@X=^dXET7cf$ZciA@@_3ctC2(Dcq^Od1G5)epM37W!8(k6BivR-@`K66V*Op(YEeP%fJOkQF{mq_>U-Vm{Y z$SY4AyQu;?imTXx1%|woyvlvMiJGr8(pZ1XV$WHZJz=CKVgyD5z-b8h@<@}}s|MFs zC!Fo_$tT=<0B$6$JL28z0qN)OpZ3*T4(t@1rm8nE#1wAyfGdL~H%1Hn{9!;aIEdxm zS@7H{eUmNp{Px&sl-xVv@g@~q25r2775wYr_?}~{(lw+P6-nFc;vTYbHW0BBwbf~x zP+Od+MXApLk0(78gjvidH;o-x(8GaZ^dVKN5hJUslQ40J|!{L)Ml3Ct?31?<}~Y`rbA? z1X4GsNJ=Xu0s;c+k3Z}E8t+;A8=QUC z+3VTYeck_RX&o1z%Sz12DbH#8_6D60KQ)FMnA7PV%`rGg1&S7GPZP2$4_f_5b%}4| zqIhffPvk*j+9|N6sNB0-8cL)xRGDEB+*JeRi18JKJgF5SErvvNQg;WHD>|x#&>;mt z#p+*#Uafwb_f^3dEV{-HZ#>IfUVcE(3x=qGlYP|@eny9%UZ1!bES+1d?!X#(jR@WM z>(GWWoy4UE%QXqa^PlH4E1W8$(jj!(+?5!j4 z{eLhvyb=J_v(PoJ7n(y&F9=5_&b32fUevNhARatO(h(V(g`7O*ShLTqrV?Izz!877 zwn6sQVE8ROMMrT*JI9~i=n3k2mfHL`x-kQ34(xwg7H2=TN78Sy8&!_f0 zB}MI1bkjUk4;h^$3RU}`2LOql6>W=6Zqw-Z-DlmQeM7XaGxMTati71F{(DtKIWyqN z{GrWYWih3-eg16;fw#gfK@**9h{uc4U2g)Xg}%0+LCtTU2<@8Ysm3kc=s9?+bs!nC zdIvS1LdZkHA5nxWEoPs7&(2%Kg9BAid?Hg{0i}CakgW6v^`K|<&tPwiuR0ryuVy^D zTJ7Lv-TRd9fP<1kKpFbFBI%$QebA-1Qj7u?tsnIGbki9Fn?C=h#{mb! zzy$svBLCH;7r1PjuxV>})Fw2hkUGg2o@^|L#j70Ttsydgd@lKM@kel-OlT*uLUdO( zpOe^1D@o-Kai}r;<=tV__?=PaS`oe77)+6f zkROnGcuG?^a9`=xQ>kGS?n}sWJmUs{yF!SM)G8)>m7OS~oQ6;IvPCF3gRf_H$1zq7 z>aNr@gs7neKk;r;POxjXw3lg{)5uI>p0uBHU$ITvH|Bh~r^n+u(S`*H&kk1Eb?3-f z6@@aYotDoNa+49|7{x&MB9(BWK_8k~QBrzQij{6GP7SDO-M)7jr6+W}dfkdXk z`Ukwv^GI(1XGxJ(W}5C1B?BiZoVt`uDlf}oC^$8cbnK?;G4Lv9aa)t(g+NWdItLg< zftXejlbp{6u5p<#kx9~Ps>nc%Fp*w7;1il+dys@))VABhq?>?bXV?N~+*lI0Z>6Yw z2ESOkd3xJ|5ca@N^>h)OZ15T5L|^w^!{F)~ujbjeBLR!MlW#H*E-$fQPz$4RC;ryr z?&q8VBvL2HfEe>}g_&ymPpJ@U!?>tnJn8s85{dJ3Y{nM14p6bFQT zpcQX|$m?m+7e`RK-%V7h0u-G-Q5m055YU(dD3JhKibTT8J-Y$PD&`zl$^}2H=_oU@ z%9N57x8tOz9fID`(?&yo5g~>leG~|c2NEh3jwngdjPL--V~6MylofxC^Cl!LV)@DL z+^0b#s<0nh#&NvW+8i;gm>71^_T_`3+Is$QQVH=1>O}D=QE=kBLT7%i3{49c^4o;H zF%%ic^O+HzA_t0U-cqb-HJ8+xhc21q)JCh7;Uuq%wR3FG0U|Q>;=m*}SLvLj_4uVm znbl`eK2CAX2UT1$$JsjEc|HM8?ekxT67ZI|3RT;crP|&p)%S{e!$<|uYE1~y$aV7P9XQN}W+(p>xgoGAAf@nXrS#9GGYrKy*M-03#_Kn1y z)tS3`EG;%60Xs@GXV|RLI`35pj9&6;{?SGsdVKwUBZ}&#eJ9)H|KQrN9(HvXb{))j zunM@krx_4_GQczmI{(}Z_-JMM(yga*j1^JZ{ENx)hm|cK{s#yoc!Iix(3s=W3D}Ta zs)XD}D}6Av6huFn3bTC#eadDg2rY-hxh4%ujbtwGb9lNWx3zkzoqR zIr5N(Q+LuMhNn1RoQS!8&U8$uXVp|+WKfa{`gO@QEoME8j)NaCH9Q>Ol=j{*DPe0* zpi~CJ;A*Veu?U)0r}338aqGvLCn)1ZhE4T^87|R_Z2fl%ng4*n@DJ&v430GIxi?&@Xik zs_A}_So^C#FykoI8U_4--LV&#$+V=V;z)&zx#$b(*aG{~-|hCHV{DB1o-vCu-#w&{ zign+fDIKrmxRbF5hPLoGu}s1M@lg(DNU|+ENyS|}y^)lnY%BVJ7wC1?p}L>$muUrH zzl-E1PONI@tqhnD2F{T^!EoMzrtds7y*~${n!=Me5Fpj``pP=@o|r(`kB-tlTG6$AfbX$C-zc7{ z0Sp-K0a1xoPbX+Pg2s1;A&Ljn$(S;jZh8gHmxEsV#_*hJ?MMpQkePG`D=t>Y=HRwJ zrpJAd*tLtt240}Ac@a1WHpZFQmx`2YZ7DJVM~s}l%`2oOc2hcXdt#ChN?rCri%*wq z$JAYP{aSHe;PQu;-Midm=teZHESAKQTiuCx1=p9^E@~zEmj);oiGq-inWl#p-NnXx zHFb83=^!v>2OwHb+nbz0u7BTwg3#>wN%&V{Cj-sB>e$hPh{L9gu0?#5>5ppu&*{1r zN0B0RdpLGSSUjVTEm5;^Y7CLW7By`fu3Np=37$r8fotu>y9$aiZD)UKvu2Qca74rN zB9c}Zd(61_ z)OZY=s52yEvns(eJq$`ZVU;sI?jAP5;KZk*CI10M7&}<%ACsBCdoD&e<@rsZF!Nz6 z{h4~Mh3oV2a8{@Xia#YES$V%7FpEI~{_IbB0U^5JX(5%6?F&9G$vBfrQ$O_f)JEtZ zb_xQk_BB3X!ZImsh1-wj;DDq zkz{c!Q|-{x1;Ga7RNhzNQvmel68K_mg4YFp!20-;SWVY{>q7Oy^aTYB1h~KOvueT; zob+k$bLU_a_3MKSatcRt!HnteyJfTuyvxkNn)fmZ(?cX#oJ2k80ltlco_EIbHc6~3 zhq5HJrB5PY$;3q{#a7}er`>(bRHuWGXWrA7q3gucSMORYc>!yV0(Q#_(yGPKHe7U% z9oaQ)X4836bG-K$o!>12y!;*YTka(6_zgmNi~z>vx-<5jI4ygV)>K?YJYw4OnR4B* ziY7uuYk6Sh>|A+*JlOH4?-r$~Q-i2~!*Ay_OMML6zH))?Vb{3AxbFav= zk~TKD!03n-%H_jTQ{t3xRiPbO!n0Xb#LXhIYPEf0b@JI92mIa5Br%&LSakEB)S5oy z8Ug-pMYV3F+dmu$a84zLmMYoAxtJX3KE>YU?uo;0xlT@9CdL_oT>ZeLHejq_AaK-F z)`Y6QW`xmI+CzKZ;9x$Z+3PZ)N9r2SGJPimZNgF(rm8;qV$CVbwD^QCVr&pSF!p~Xj0hP(# zI4Vdc%&gM15f_<8TnpwGfi#v-s!^6Uy#>n~Q1EdgW5nQZBd}uW%Y|`!peFAsM*Vix z+mJ}IKOlxq)vR`2+fzQ_0d1DQIG$f`c@?U0YWx);#4YmilKNc)W5gofT{5I3e?Tzt zn*R5w=K2y6=|GJ1VHevN^p2M8ZEo)Cmk^qf7zD#QXFxY2X*~2uKqWcgDq=V-XS60~ zyj1;jv-%W?(jW)<(3bjacudtsb$vpJ1488*Hki-zChO@K#w_Wd_27P)WjtAe8%F6KZ zY{57LH?$OUz-fyyDUM<2IZW&}>gW`MF&|0%MXUp3qojsL&!va!zD+e#!TRyao0G6& zfwGG@=2aCqShqA+9C1!_XYu5)$AxS6rK#&M=0Ta%;#m&GwmP!$2Bc-9_mK7?KV zq$++@Cm$?!S4dS-)>I}Ro>*BWq(EB{q$WxdE{x;HUKQu1DzJ+;M|3D~jgoLJ_shgf zT;eb|X7wgTMd8i_k!o_(EEOLd6D5X;T1p*p;baU+)d`aP{7X^)NKa2d$sjz5?J9=Z zL+OivhACC@U>RUCmDngG**XCQa706$Ca`H)WHO9~N)ULTKCy?fpMmBOE>OXtcAE+zDwd0 zub!IXSOOH1?qsGoGGT>6cjzY$dnYns2Q+Kcdzfc+|LEyUD3!xl`qY(d)W>BKXU!Cn z&C`g7kxX7aWmQnX7({v6nqYw#zO^#g$zag!3D9txn+uwo_bGSLANtab)~oyA9zK+S zQlh3G{DWp36$3Kpe0(CnZCNhNVFT$?H&iOFT@eH^O?_l4qC6wEJXo!8e){P($-A#% z4em6BK>;Wfy&a|z05A?GH8)!9DI^b?1g*43xW+`9;{u=Ji2lgM4rG{h(BIl;v?|7b zUxKpzLrdeV>T75ysML~Z6dd*|Uz1gpmr+=_+S|dvrDGS2m zA2cOApo8pciwDI?U}@8^CaGND8hmjrZn|NkIE8GrzD&2iQ@3GMA=(C{JRZZ_*`NR$ zz%h>%JT-0KYPPxx&&;tn8s=uiQI2wIF@78Nu! zY>{#rtqV0~I;IaMp=dkET&vIr6^+PkiMgGACda&$S#vIJAD)@y5YL|c^dLWUrDNU;}DKnFea0zNd1p$!HLC^)kDkWUn*P7b_g@i zhUY@yjp6hOCZgHt+5@aIjTgo~P`~BV_FF4_t%|EdfW<1)Db>Fim{`%x(`IOsWaZxD z+K#=L?vLN1qf|HA9{b0;z5J3B7e zJ6^+J#mbt%$ZO>R^~AKkxnrpv=WAsb1!-4>e4fjd!!%;ESCKT?-&^p`=U_u0AA2i{ z3g7gPJB_OgyxTcT_CZJX&bLCAesM0-YliMpJU8;NhV$==Rp7Xlh1fBk-{+WRi%vIB z-8y`n=C`%K5yR_u8VuPEvOlER&R<3BO@8}MBaSi-X!xKHihjl-j_btY5&NV)e{FyLO+o6JLFO&~|W<1J(h=%4KK$XQ4nax-PQvu+lYIZVn2NrswR_<;n%s zC15fFOOz8NaOMI7ejBPUO<{zba}>*!z(-oEj{i!5`Rs!4TGjPQl|BfX;T6q{|3c~D z+dKYdFNCEuRi&WVIIB6d!tS>1tbQxk-mR#|6q2i*pv}b5L(5>x`K3oc-@Ok)DK*9A zkIE*J+dZx~JiN_6THO9%_Kldg@`Ef`zGp=3&!SvDU)*PPbdhn_%Fc9g)4T0$u`zuS|UjVUL4BSLD?$vn0-lKeJ0QxaOYewMLZ%!sRn`*mK zk*vp>LXV-ck6mu{G3?Pdn|Bia3|XqW__O^`rhgam@1s7foB-g^USbx zne=rz&vco-U^$k2$9?sAyZGldx(a>ddEqnp_S=)m+)2_i*xa*l@>9lr$G(Oo@ou)ien3<&>d zg%3NvOwho0^G=`d?*#q23}Vy%Y5Z^@ATCXPN?3AOi;nL3WMz`d6l#%dN2TI{_0ONj zd|z&;{p^x)|By7#S`W=X?g$@^e4B8iN%nL3bYQe_P{-{q7DMe`HAQX}-(W>TL+yW7 zK*LfCrn(I#eCF_gH8{D)8`41Al6u;syi^}DDuO?)Y|mulI+8f7M(Y0K^1`sc;R|>1 zNoeFKc;)DGxn!liX61*u&J2jS_lP``Y(n9Y*sBnctYyl_k1jWc`gT4^SdG{1FQ0oT z-I;`Ow{HkiA2P|Y?WOJy4rvXs_4RI}P)%bec3TKKTM`ychLH={kj-}a)W@O_g4)?sE4jyZUl?=^blo08 zu!c(#tCmruAQ}umG&$~+(L#71_9gHIa!=$+KlB_~()j*aSh*|YbY?d`OGGm<>YCD> zZ&umqCT5?r^KE*Vc}&i=A4bu$vv-}E`IP? z@K$zYYkWim@VHQJww-Kwxy7?<uQSlh(0@@+8 zY(?guf-T`8rc`%R&;bfk{yX4Q6KuQnd>=`n-848q3J^>iMWRrHoC($@;&E){+tPA!D1y&Ja54)E4H`8jJ+M z0WDTw@jx9Q;?$O|2g7tM;7f`bM96tIQgzz;Odr$U|93uUe3r_c>rp7x%3zW06ukCy z-r`-9kK2f}S#XeB5h3$)vhR5TG6*|b{jsvIw}@1D|}Tk_nb42@QI;# zFMFRsOTh)R@3W~)`zIe5i1HuwZD#zM=m-8LW>zK%)*9$)OrW*VOnZ8JsI&4kI+f9) zz-d0jREgls)G=l?>KRXg|KW?RlJm%yE?=Ilzq;Oh|Mty$VK{*vY-=upl3YGyAe?l| zcOZh+fj5H6ikoQ%ACi^()qEG|VPcb624 zvJpc*tI4ZC>fe93DX(>CIDMQ|Vc(tv^uc&>>;DnwR-#}Hfb4T! za^3Kr7!)B(e25yk3et8uccDc#Kq%QqTTNYC-OGWUm_Hv$wqA3~Lg1nr{qzF);Dnf% zXwpTbLUEVxrh3#(vhA*I9`5K8Yv8hgrClBea*2eHq-}r!>w8D;N~JbaQNek!Qkv4v zRi9z;9(~oQNK%tc(hut4)aNgq?m4W0bx&l{=yaU#nG}UF5^l;0&Y&orsJEN4cWYy0 zYZ4laGoKAy#fkJy`-@>r5?nWm%by-1?Ov3UI-tTu={E^p_+gSlgF43i4GBbY!Z}9n z@WCn+gu}6y+5-U+I6Ne65atauG<^d^P9H{CGY%kX zc{FDCc%RD&p3>%(1=O%X6SBc0xo9qk)z~F+pw@WtTZ$?DN2;?qby#lD#vJkD{;AZ% z8iYaSIUkAOK9w*G0HB2f0&;(uK@mLC55+KPEopf@p=3Wiw&K!G_}^Z=B#^T^b*5kd{vxaNaA08>M$#PGf36a0FNW3Niu&Ojkqj~bBm$g zDUJgycU6v5FXA;(-zYD+8qoLFoi*B!d?)`ef*{08iNq()?8+WPvumTkj)*0EfCZ9t z;=we`Iw{5mJKv-~$+0=jW20)BtsYKkHH6XxNMferzYTL_2c0K!Ge$IJP7!j3@Dc~A z=+O62eUkm$nB@F}^I_M46$?9Mid7xIxm4q=R{Bd-GODt8M#Gt?6h^5gg9x=kNut#P51yX|2z+AA>fL&(e?d55C95&scK9RCm+m z0h6njx->i;JboQE;YQCF)Ei#a`kwoqEDWqFD6F54q@s(OL>gkk z9mJe;AoVQNhM>quFFG=wMo_51%e2$m&zm2Wk1bwqs`ayj>HLCn)PmwPHDA{=nGDey@(E92Y=z3wdALgWz6c*^-wPwE*l#!9T?`Bfn?enIA@e3*sM0 z4H{D?t_5vYLeX;L&r=Lui&wdrmO$R_^fZXJq`TIw6L*H89%PF?SalrL&2j>TQMKjb z5gY>ePQRXrmS6^;Gtu`p2xIUs1Npz3mTz~07wdHj9u{v&0XxmSDfe|ku?Kvu&fW%S zY2S~I%f7P^a;R);(dNH6`VZW-X6X``C>cArJT-mzTG@|P^eC3_ImIOtwWm8 z5%8leVP2A&-IKhzJ4I`yBz>^GXI{%z(xLRh-NlgqNZZy6F^?zj2@L7Ny>apfEoJ$k%``a zTRh;GZ))-NLaywmHtnEorZr`MJ;V)V5#WMP^wq&b#GhrE0>aMqf8=PkzddaCtAp=MFj&K!+xOfJRXTB;E@W52F-CPyw_-x*HJXFlzH6 zYR3&~?XV^LUTRT?t*~BN^Dw=W#W@>v$QtSL{t$8D1Clm`KU|A^WP{X;k%d>^cHhpx zqi=FR<3%sRzrBbgbB*|0)Ka7+eRp3~N4^2Co;sknS<#953>B@rKov0Xb%&l+9Q{rPY>U0=?aYbc)=J}N*x38GIfLOTnj zV z#8$uHRzE+iKk@TOq#nyjZf=B1Ihn(zb=XlD2^GA^wbAzRQ~E_?l-g%|jVf3%&F6o9 zW&*2#^o)%;odTT_b z3`rMbe4c9$4MxyUp$|Lo4?6*tD)WvUBPkvrk^#WI9Tcgk-4FBL1yW~T_L95zA>}p6 z3yeR*`7DP&I0|*K^XwF5se&{!1_$t=O4M2u)*#zJzy8-O9TZl8K zXQ7XtHED%>UalRFb9$U)WuT(**p86YRPh_yMw-O-pp-g5o8~bj+Cw|qZ})X|xuqA> z9_aB^76?gXtbA%hEgNPw6k%cM7GAGz`FQuWt_SF8jIH69Ah%5=-D0Xyo|lCt7V;{r zIm0Z*)26%x&|+R_4~SAYQr|u^mwqs=b0r~()Du6ZKNmLqSH05%J5m(i>CuaTdfr*l z>5B}O@(o@~0^b0%(}aiL2o6)XqxF4&##@OQhI_Bi&{6htLXDR+KuHOq z3DQ-8F?YErRRMLph!~aR8egIK_x#Jb1J??U8B?KGfYzk}ZuIxs7SHE>Mowhh-;u?N zFctZF7lpzUWnvYY$DVcEQ+4j1b-Awe)P0?`@mSDd@@1tFwLmwb#wcAgQT)*4B{sIr zBg}9FR@M778%mj!(-mpyEqU0)efXZchLB94GhYu&yx&3H_x?`izEOf`kA< zKp5B`5KT-ZLz!W<9FK=zI7yh>%@NRO8MTM_Pr-#??d(; zK6k&jV86fcV4&z=u;g&4^zc*p;qbeok;

K|jZKgR2ROf(#SZakiBI-Y9zIoOe!f0&zA<^eIeoD;bFn>lv9oZw z`{nZ6(&hJ+-+N!L4mPe1x37QfTpxeC{<(K^vVU`WcyspS&#&V@=O=$I&i-Eh`g?Wp z_xkeR&DFm@H~;=#|NC?G@83gpltHRvFpYTnG}-Cw2$xUEh&1@VY!fmijn|BlLba@DEKAt;sz@@ByF5?U zcXe)LHfr)M%6AIRpB^<`D(Q?&>}~NhqZ6}lN9$bd#u!>FJXou?IE-m<>`wxS1q;SB zBjnMQqiuC|Ved3`VqSP1S9OK_Ds;wW0mIt}DWRf{sL6cfLD_HYhQi}p<^~GRAznjxh1PJPu4*K# z^db94F~zG?j*i1eBOg28uq&I>72Si-dK868Zk81prk|JPGQ&qthnQN7t&3fKjE4z@ ze1i5iqtOw(TQSH~4?=`{+0_tn-&3F_op2wo3tjY7$Z(2)){Dhew*;~3gw`N#dwMt>0eEd#kGHn6NuxwJP<&z zg9Ne{$ofPxW3CQcx(~d5(DNk~|7crH0uH@jAG+d5kX?@1W_PUQ z){Y36+dS@i+A>zp4B_(r+1RUlco_TFHm~HRB;m`)NzqrsoLK0+k*U7pj(26DdgA*g zK^B)*Yfr)M&mA##(%L$8*bEzYYlq?y4!iWrA-l3RrGq_Q1OU+F5;xKJeL_a-*`DXK z(?L3ixFN1Edo`JxWuMy3%Wq};%{dF@t|?LrBfTTod~*Rwx@xo1)KIT#2^sE4in?Vh zw&yxX%FAVf>E60l-fy--t$4y0Ki8g&D?I5wow+?L^ZU%9SNq|Q1UX5-6GUuej*&2s0zFzO{o--Ixr~1Y0^1)q$@e7-Vcj*F{fLaO3+V`DSxe1-#8}f514ue1Z zV$GPh8Ht1gat`g&CBnS`CaP3TZV`OcYs9yQ(j2b`e?(5}KoYF2N*6lFgJDdmDHS1Q zQA&bJ)-BqU931&Sphmh@O`o48M3--S>Z{O|=>{}W_S8p2(mED1T2yC=%M4aYCp%B9 z&+Mgw=~2en6zheq?9Ch5KVR<8j@d7M8rIxBO%W=6_@w@~WTl3Ims{*-F2#A~N(h!u z2Uh+i>MV_UFCtl_3WT)Z4W$5p1~q)e1XpJ#0wSaxx~m>aTc!XM@K{-~{IrFrSV**_*3=YHpnh9m61T z{z7I)ppF~fY$T!x;sl{&+|y0=amDPIFd2W8)FWe^&HONG6$Lo0eQ}ZcWPY4kND`4B zqQU#*;jVl1SJZq{D3$fcCNA-ej=K)(*&MOl=^b)^0tx#$F`r?t@71aeA6^RwVe+uJ zDaMlKIPJPy%A^DPht*0cntV_m}y3? ztByiNT3stXaA=L#J5!BL=K>10T1x7G3on__&@ z0y&`PI*!xbrk`u|397S}9?XPKwY?7RF8%A;tX0HX1i%51?(R+!r#~#E$UM;eGmq?x zCful1z2Dk1kJ!uYR@{~g#Cw!G^Uv;M@Ws^~5FVf9k^F#X=DKo@e7%P`<-(Y5^+-f* z3vVwn$f;*Khf8Pz{*bd1hvR5sf~M>W!hZOE{M-5s_Oa9HrNKo%H>DV2r)$ApXciAE zbRyg4|H4#lCPn#Qx!qpm=10QM4sI;&4YJnz?ObB^Et-#d9f&kWDfb6|rZ#SR_MLtN>bfHZozc|vNG<)Hk-EviZD=`$Y$SS@Y0^=s*V9zg(oLWA+D061jRM23 zn~UluU-{cK;~#1=#R*TeO;4=ryD{Y-24D!@I*;F?Y*v{Wl@S}xhY<>HU_))#bke*fwUu=}iQcoR3o%F;6!blripJTUcf$;~%+Vz~e0* zW}HO?x7{=$9%mm8eAwR)(-duk=Q6KWk?-P0GHmp8`>l3`9zBm8lDBNSS+_2bR<+g$ z9Cro-ip;TDkYoz|->UldM24Jw0d>Jbv4KHN-}L=5oYTOBl5h!i#B=GBm%b@hCRP~J zX9kHB>{jmrKS~P8FZ0I>U6%%9s} zdt{`0Vwrv5jC;$beL(R2j0nEj3mRb*wYMx-5%J2R_))&`t3n9>TZk^r`&a9c{&E}q zJ0=8`<*01}71#`ZqpeZ#p@`P9xv^iMDi^cM93|q`+AG zP(}Y#wRArrEV-n0pg~`PR0qxj**U_ZeyK`U>f>m=CkCvsft+_8=I(gHKHlVL(dPsz z81C$3<17QPn5s>*pn0Sh#nscX5WUUsltlZqo}a??-uoz>$v6E9HaAxY_zpiEKpG-2 z5FjDr5-oo(Tj9J*5dp%A-ZkbumB=*YHJx-m7L@tP);jDtiCfeo5Ut252z=`XVns!N z^bdU^PP{C^BSHasQ-VTU1Da4zq-Nb@np4S)(;owYNioDRk}p%Po+OC-HTK#Iwo2+t z@WDRCl4R)!`-6>_Xe)*vRaM5>tD$JW$DvLsxq?-8Wz0J(J@&IGIA!24T-Fejiu<=i zt7Nvo`vg|khd>)a0Ms&60=;lWiN>LCFOrA)0$DEukd#3?+MX9#`ZU9`O|(|}=hg~m z^1?tsh;h6{Sn^GF^2=Sh-m*Zz{u}q*K(J?a>@MZJ1vwI)WxvL8~%L zosdxG_cJzFszsBpt7}MfG!@$(r7kX2q92wKP2<9mCT`*HQ%jYXc{bjspxlc%1VJGeo6|ieq@L^4=p0(Fj4vurj1T$M~Szzq5q>iAFr$ z?8qi{oICw?6A_e6EYb`N0Z_qpX83~hw7X!2T!pE#VTQ)@AUJKEMIP-o}C$CZ? z@CRNj(kjm7T6dO;0NDkg@1o3$2BoBnDnqO!?)jA9$ zW)qdI^%zM7&hi7xAmD{48P8I)3BI=5~gTAjfFg0qCheVoQJVmi4yuu5&9w|Jn5>7eFcl|Kmwlri=mInPMsIC}D ztW9Er!-=H0^fj~m0~XMmg_LGQBCr6!28cn6#MsYeytF_#2b7gtB{5>W^Nqn#R;f_b z_gQ58U%Jp8{UK>vZny%WHY z{7W7wKdoldn$E`omy%)xv?Sjdi~_JZe(j*%I=Z-5H1CU*opj~!7KNh3c1#59UDEHN zFpe7v1MKS$F%)^uU`{`1VK1RTbpv>_h3J^D z7umrR_*AdXFp@U{c_RV^`#EB_b7k0V+3A*Mr9;pM3h*oh5FlD*rQ5`%O=4SJKGrAm z;WE)Q(ldvz^q(p`0KnpdtMK)2uKmS9YMFbl!?0g2;wO8{ryLE&#*|7o0{ufuHilFT zT>(S0Sou?3@Fv7z3taNMmDl*W^NXtWSdiP;b5}mN@ClEgW1&J$;XY@ykPKU5tN1*1Ttg;Si zIrz3Z$^D`_VW+q}$70h)DT0BxCk*wHe!-xW7K5VtcHXY!6N;a@CEUvp?%WoF=^*?e z<&USbd_1KS*0`x*PySmHc8A?=C3DP5CF5~wlK@lFhqI@D#k1G-!f9fHMraL;f}sED zk&q(FeZ-oO0w3+XKCbQImTG#wQinS{D{DRf_|IBf>4q|XH!s=kg8*&HkvVc;9}l06 zkG&h^o_gro^`K?A%$>PbGb_uvs=z=3!s-6L=~QmPw(RP@jceP7y?p^YAn|P&q&V&I z81UeTyXHpwSPq_#sA*vwP^qN{`SJX&;tBubmY_`%MN;cDG+_Rm_#PYx5gJUKW35u6?B}1%vbwfPe%v`T~vdkdBy7$6n^N zCKLx6B%uh2z}u6qFrR?_7lipEIW?D0qpt(Z2r9+|5|8>u=Up*0nj{FkAfqu6U5~f* z7YzJQf{*}5i@m2VqZ(~{(p%C|{)+L}vq1qHt)j(=Crlp|bU*&YG~Hg5<4D6mnQvP1 z^MuFQJB}zJqz1;z!T$XDrsvL zFa~16IABZM%6aWdZ12jQCZL0;HmS0m995fWH+2s^_2ahG zohbArA>I2NXkF$^Y>TKdjR8s420g>X+N1n9Uzo02~@J>8il)B~vi?Qpa<#)qQClxm`oC@&VeUoO@qm9qY}pk9Fb z$xE6yD$7Spj;!BYlA=7Jw8D;SZ?;-}j=vrL}g7;@xXc4#yQaIQ_JT9?uZSBFU zJiymNLw{PjdTGQA2ndKqY3t#4t4&{H(Fg2}5M(E$mVBMZRs=+`b<<<;uURUc?oC@IO@5`UN%{_rgb56y?V;Ot(aNZuhv zpJ1;`|n#_nj`Wh_dd10e8aBs6lMG2z&SHJsesQfp3ohQLg1CB;-^ zoKovCmwk;e;SNy2zop}k#{Bl*NVQ005E3GeDqK&_>Xl4{XBj=&fq-J5IWO#;u-^$v zZ&Njjj78W7pyE7m=h|VFMIktWNllPpVz}wfFrXQvsEEg@Xv7K$bo&%Jx`mrp7#E9Q zpdLj)DPR6~F6Tu}P^>DF(1|s#iXvgm_u4y=^dhQeL0J=|SO~;W*mot-Q*3KtnF=+7 zY&S;;xLFlhrCVBJ(^d_h9uE0YsQvs(FX?f9;T;}D))xt*vMKXODb-1fnL0~>jHbWO z5VEp_>{B4MwGmvvI^;+ymqDteXVbul7f-Aq-;o~_a9byr<-0yqhnswR|Mkm1$7<0@ z3|sC)`C@^Z*9Y!$fo>~MegZvo>nsJWa#D5h{%9h zbmrg429fYL;SUX;{kK~85m?pq(iM#gh^q4ZM zC-8gs^>?t5nxf&ffUf`*_^)%vQ*yeMaIv9_v!ZEs6uY^J$;kkfsUE?YAl%X#ie@up3}dH~J}Cl|Ki)-!lNN4SRFz4J5?7VP zq#{6nqE8H;59enjI6P_B`3y6EV_7P5z+$n9Ztx<|##Lgrwpr_l+P7sU6>BCnoA0e&#q+{>40xXVVyIx89^fy4Tq5J#*>#uaNkT^3`fpHANpfBl9ALUK@^YJA>k#t-XakZtv$nj1pfBZTWh$Y z92dLG1sF)r+y9u5-LL&6ITeH3!i=%hW^QKgV>9=Wvye2y45K+i$Zdoq zqgrn(DzX7pkVT) z<8IKAt2Vxfb!S+I4qBV+XQ?Q-J!su1mgV6>LUP3uzz!us2SF$7yjHW4iQ_?{+>IJ4 zrkggTb;F#iN~EHHHJxaTXumz=K;8_c!Me9sA9=fo92!nI7#^%Tj!>F*e`wfh-K%fS zymG&lmT)mlp-pYOCt%Tn93E5k=>a|~Toz85Zlaoe&=uR1EvrhkVPJNv?^ym+zTa!? zfQA(pB}nB6(%BAi0uAxCnZ&zUm!zWBLr-1b5@mA*J>3pHbv4y<`iF9?o}GOdcY#wt z(D?i6Z<$*zCq|cH?hjcKqw8D)lK`lLIaR<53zC)SQ(K*>sZ_Oc2L%>CMYE{<0?)mk zhq<-N{Dg-JV`-uY9xYF)EQlV2e$r69(+!8?F6u_`wEO-NIqc;a%v7HW!n%kGxjt|+ ze1Pr?GjD|>!2adF!uDcIPQM}VXGtxQn$usKCejZQC;^fz{J_fG=g>0}&U#(FVz#XNlN52t9t(>y%fGx#YlFAGJG75SEoQq;UK*o2!JN)-l)aVxingjp{ zwUQ>%J?GoqEApCNL=a^CvAo!rm6$@WyE%<&kLQ0y@S29DOoYTZ(nJZ4;zX$kM@fjZQ9 zo$e&$CDwvrzl-q3h@nonuESz}uZfy)H}CD8ogl3P3^N6ulOXwCVDE( zCg$@$%)gv@72xt?PovuQE}wI-TohGsd;D<0)#Y1Ig$ve^J{6UUx=bAQ$_g60Iq2KkYgV*X-20Cy{?TUUL@m z=mm7<#-I}2U0gAMZ^-)3NY=sHf~A!2@T{uEJfg;ZP2*y)zNZ6Cq|?2yt9c#H+X2x2 z#wK4n(=QlF)DL_~oL_x1&v!%h?0_sTL{e+==(VWTh2K+c+hlH`SlFXsL#z8kz!KA1 zuli#Cf+Q27`+My>+FG=yHadYXdF$Tol-K3J%q31ddB>nt#$H|A%UFQ#wTv1qr;dG) z-I@XBek+^45oIeo%?;?kP=C@;-N$-#;DnDkJ+vz!yHR0{Uzdt=V*jYxwL44fdI?bT ztt~pgRX(5igsA@oF7nC%z|Hv)6r8;Rxv^VWZS;~D91iE!0f4PT2>Z=naxdR0UxY=P zS|oZkp?;qUkl{7*;%PbP-EBINlI8Cq@FnG4Vu&%1ermf&{`gv#)OYvu039{QPb@PR z(TsEVG$qydgI9_2W|w_C!ajOQ0+xW*w{nbUuVC0FoHzTAfab{(zFaO@NgH+apo^&6 z+{W&I|F)^!E$_Jl^N^kGQYP+8^Uc%r9yF^_XrJ}&y>XH=mVBtJYB-_;x*&Bt22Mj6 zA#lczKRQbO^*m0~YBST)FSVEA|kE ze&}Cw^PR-4&k_eL-F8+cnjRo2uhX*fq2}EyJ?(SPW>{%SzTk}glP3u+A9|jdbIR6q zz{XK0r&Eh|FABc$1z5u|ZPguxsdwJ}KKYs@_^yOu@Uuf~8C?A8q-aO)E4JWKSR3J8 zSw#)2vd6%Jz_AylTk|$@msydf>W=K!t%o-75g(ph#-d;}g$)&o?PHIYoW&L(iusg| z*726^>C~W-w{;s?eNT@kBj#{P1YpQ{%zBau-iP6?4VMA3qvD*11F{8udJ7 z0VU^B50rS*y2#C)G<1$ffsDJ+N>6iAOV6tFGwo82a=Q^jf}urX>PuL8Rz1V!lx7AL z4m$~~xc6I~vBYCcOdI*Xw9DAgwTiCVj7UH?M-m)KnXa8D#V7tV=}EXH1J)qec#@U% z{FL>LMKw5eM^X1w0WFPtb+s*`Z`JKoB(gO#6E;B*pbXX(0w!8;v~?h_e$@w1X)&HE zap->c1Vgn_hEW;B^BU(8O~u%l-^AvK{2b5$e~5+J0q~1? zKrync1Q|AgKw-MUgKcpJ-Gv~Y(KE1%X$~92ybG7RKjh%XOK~?bCtS#neyfh{TB?_q zFtl74U;F^Bq!-^>)iX=fYrECUcDoxuJqdfox`7Q301AnOn(L{A3ATaf~Eu>rc9k9wEN!igsopoEUSJfPbEtus^hc=nq8T-x)nR(dvo z+7Cv1wgXDADZD*8;*&a8jiEJJ3`f=&A8ugqDl*O#m!Z=XrpO}-UA`rthYw}3A#Zk19%WE<*ukP6_hxh@Fnw^9pA(U z>4`Fw@rsV6nUR7gsa2PqWf~q<(iNkp=N8?wm){^G)-?Fti6Z%xgRxeojUg7@8e->9|{J`-nIm=6{N zP)(}voWCbS%agPR&^GI>_Gg0%cKn#~&Y7=O*lDC;MQ93rxj7u&pv^^f~X4 zWSklu_p#{B%_rWT3cz2EOh(#3gmH~-Fw>O{wTcw8c7p*}fsFhYm%r)KT~aMwn2*lD z#dS|UB6jgIfhm00cfE>i z{&Q`@1Zp;u@-7@1bT=TK>^8y+i1hOY!2lo<1whL4@Xf0o3*g#%r}EnR#_E_Rv6p`M zsr2m<@TF=$Nf|wQuGQ_FgfCTuEZ<0$m!<#_M)s3l)ETqqzqa2rUOC~IOzoyDaRQku z-%e9zcn{VLgOt_VY1vx|S$cUWn&U`Kntc`Xxlh(>AMbmjGrq9wigYzDM-^d55!bhO zu2o3*Fij|y^*ryH$ny=TvONwsBJu6e(5+t_u&032g)#l;fAfaqgo^?~-4|Y^j>#0h z`}BOd6u4Y!fDJuL0E1Mba@;l{k0!JSX{j3Ig#MHD{C9q|^G`gyb!bi82T)$-r#q{P z@V}4p+}&Lfx!}lmL0IsZP(4c*dxrHLB(gIVVD9I+L6dh=)OVi{^G>gIPVZi+n{Ll8 zL7x3yNDJWc-~8NX-ZYAi46BsuY6Z*x>qc~sAJ7N^M3CAHB5Vb#dZ1A8P{;f zaOMHPh#?c+#v65R%=O6&F>l5$Wqyeg5DU9#W4-1zyKah$aO7baxdAxGUiP^S%#IE0 z3`8nDb%qg6hEV3`0GIAiX$6lGnoeCc6nlCc#Gugs1FyZtFFN%@i0yPt_u$(&bf^Cs z#2dynsBuIn8rG**;IRW=TlB`;q=CU1{J|f%JPJo6SA91`?qA5Qy(M^wDco?@(Ui@9 z9>$ZO?hp_DN;vOHI=ZDNY&!1oHPyK8*L-ySr{B*q{<+rvhC(s|;zU)xChD}!@jfal zT748BC6oJ|o+zX7(EQ+O8uw?iu-hxM2T5_e||#T9Z=&IM%V zX2;KsD0my20Zj1TAy1#=`Foo*Tr}%~LbVu|te zmIo0lhKu$a$DFuGL>ADol4^$y>eeBt+RiQA@$qomUu_FYo>}y~8L3tE%S^_~5C3-P zcWkfmyxR96D+{f>$0z@o-O;64{#_UR^)GJUAtcFN^BTAFm%JaR3zd=pmj^n{0L++`$CAja8m(_0p(y5dqGXR6kJFSOsT)WeS6IFT&&^Y|3G~ye!F6lkf&6r@FbJb-OoZ>XB{#mMTV_ea;VEW znB=T{+x+ps$<=ic71Vn&(fNzYI2&j99feu1$~O<-(v=MiPAjN#d_gi)gmL;!q`mn* znjlWen)(l9X&pZ*c%P+fknx}eT~pM4e)U=PnMd~0$KqeMdh~D!vC|b74`AQJem0WK zqqlU%dL(Yiod_OTFE&JDV7-OM4v6~eUnO*kdnlhl6|J|Yy*njLri1LnYo$JWwf%8! zJFQ2GJ5HT^Wh1K6eOGg6zAQdV37xXgtco%`Kfw} z=kO`8rRF_4VYXtCh$<(K4qnDoBGjFBt(V`5!U1`{I38%8PB)A@xXn@7J=Zs%M~VbvTP1uXbPYk=8HC}P*IGJR;Gjw zD~#aH$YmA;)9~)0qC0b;A$&)A8{gjp3C9fG=k}gFoW^xMO5hjmxa26zL(ZXGCk3ia z+NhdOI@ui=>g^>OyovEbHm8e7Yh8@dQF~x1$;ffC*Hy}Oak=Z4D5DYeVay* z=UrPREl&nQyeW3b@1^&ZZ!_Z3;k#7nGt{8u52vORArUqyy0h-GnKu0za+e1k&(yEQ ziRF?v@3lpSRZBK6JZ>mA*FM!+)}-*jXS?mw`7WUKYkM1m%%W&Fa-*ZvggGz^Kjm=@ zB0Lv5aQD(%hPB+~wQsC@f*(8}@`Hl*D^>V3vSsfyKeiY7%S86A>xC10EJP(1glY;o#62ioA-Aw ztq+CV2lEHA*a-{1Hn>Dwj;a;%Bi4rCOfF+QEYkI_w$`|2k2u;7e?phS@%<30O!juDj}a@Ysj*VF8&m4xk7smYGAeRskl`016DY1p$f1 z7YNr<4hMJ&Gyov!`mgn4-5$6o9iv97*0R#*Jo6&iuQ454Y$B98C)C<NK*rkw(NR$n+q*a=*lrD-gFun zzt2iSM*%$moO2@Wlgw^HYGryENd1cA%!4?9d!YTfW|q~<7zAJ*z_AX%+?g*6E^|=` zjj+ctz(gvXOOsziOd(*O#5z{^Ih`1co4VQFoT)&jbD@596&ZCI4Q%p6-_Rql1xkeM zSXntj$%OZ&D)iAbUj)|ygoAXjs^51BQ_>hhdl@rI(Xi!cqzm6@w>a9 zC@S2qVZ4IIGQn=$C8jMpM=- zOb{3dcoFXeqypI8a4#XSI6#5tuMG8gDd6d~PEL{KMu>{rF0vjm4Tb(4l5XqMI&Yti z93M`TVHeaKVliDZK6hf8EPaU_&<@c3p%Qew-Ey{H-d*Wc9Ncx+h}4bMC)y~(EUUiVCYQe^IawN z{IKjr@d@J&M5I>0=sDjy92vLCr>}xWehvcJu22OK6)O|nB8u?)Bw{0w&y}1aCFKcK z-$j+lrXyup95n|N^$>MJwnzm)(IXUR3T{wnd6B}(5&lNK;&#HuplcCx1 zTbTQQ3_6BAGPv^Ei`^OS{2Ucc<}Ac21gR~vABN_$RJ>KfPys8=*e5(=5J2|yj0na>#sPu^)`a|B;K z&5hFz{$uJX#ag&!SZ~~m5@}*s2KtS7=T(a}3$5Rq-K#WB2Z@!i*9~h5 zXqIp6Mb(Xp__(%>hCBpvaYndwmv4TmMH0M^Q<~D7Dif2oUh}^zWhG()x%4Sre6)Od zw3IGZr>Dk8%(7hC*NO{>Oy)V{bKDOiBP!bb{hOEuJOl+0i?OYSW_xgr|3OkFiTu%sxKLGI`M{2@07>N& zeLIXWT7$!&C?M#LW{2?BZ1sg(UsvOJ&Gg7iMb1V>MCp+LyJo-C*76%{gIyCJWu5~8dZeJ{mgUCFEoEy3=wG>eDM3#^ z1T`Fa1x`}{v^XI41>EjmhLcUUCbTJjhM4Dic?sGvs3`+sUkdq`a>}zk9({_?f45{z z)KA70D*mpe; z>Is8Cl-fv5=dRWF61@7_dGWbg;l%g2J%PBkf)1yJpu7wZYH-_2qx6HSO{`597uOvi zU&Oygal$wMmcgxw5tHVMN8jo~>r!SKa8~#dsWiIQ)!Ak@>-Gy;EmRN{wK>stgYKl@ z=%)b~|HuOE0ANobk-}*<4M89o3TTzKK8ah}x)*u3_Hpv1kkiq{Wz;pPFW#(=`%nJ; zJH~f1UY_<=M8vNB{0Gx-Y8uR~4L$()?0q>Avm#{!wy};2#@%%~1<%(XUdCr|qJQu% zG+?mbvj{g3VaeUtm3Un=>5Y}oHsiYiuQ4!@8w5(BD!^uoT(DMSHgEnE^}qP`dIoxz z_0j!f4n%=4-G(Xn!c1M;d>f3N4=Ljfz2@?`wm6Xuzm&E-Q2HwQx$&#QgR|#PV!5KpwoChacpJz}@pP zXJMLW+yB}}LzyZ|To^J0GdCfZ9Iv98UmZdvY4G6Zn#;DrR_w!ozM0oA;(BgNVGbuElWu(XyP zFa4>h5JYe%7&s?}(tlUU7%@bdfBVkH`;HFDSfwcN6fW@$znCs(zj(+V_wzvaD%iki zzo)4IMXQrqFHTW11mybnt48n+1&?0~+6XV_1Tio(_5wj7EFB1|%b0d5gJ)V2qOS;`P^lPt_p&qa1;p(qN*LLd8(|P@2yS z-`WIijNIR#pdjY$xZ&+zW9|kSEL4tk+p6{f)#5aW_NgxRv&f$~iA&Q--v5Z_upb~ptijzVP>GHb1*;Q^;^Q|Vh43U(G=Nvt7U|X9|Ezquncif zNQ$nK*P@doIFy)tmZZblB7<8=0*C_kVZ3^+mH+~*+p;2XxmoF__9@B}uH|oXsezvP zbjyTs9FNdCDPLNv>;OxMprnV}Csnq|11++Ov# z>f2xBG70vY!{~rR-(YWg^6TI?PdME1Hb=gf1b=@{#md9^#&`|H0adRARUMQKGkC-0 z(hPA?T|d|5W_Wb&oLE__(~sRPpBr5Xk3)(X61GJin?gi7nG z@_cOuU#i{hk^rSzg}Mx=DVV!WJ@lfS377dMGctVH{d@RQ{0-mH;(P_V!t-?BPl2nH z3g4^0Wo$2F#+7iSzjVoI;Ekh+avSdrv_yl+5IYe0@F@TEmJt(OuK9S-B4uv}b#>gh zdkTk|FD@vwKR`Kl&oge=I;jVB>9}`~cX!jPV$)RF<;#7fZVZee)TsJv8T(9l`04d> zLDfa~tRQb!sns>H=exOQCqS@kVKz}Te4q*pQ=PS_RAU?pF{KPe`ft_UQ~9mb@VPKl zl9CtgczSwb4l=}&!KxGPENCk4+9Wp*ZF5mV--P2HiPaT-vA@ezTE5&bM1~0T+I)Xw z!;Y3O7O>>|Lm|lk)r&>`yRKL|8!Td^>F<(a8)#dxblQI7rO3}Lt0o}oq4|X^->ST= z5P-r-EiJE;<<-A?*nz_GsAjsM%R$ay3)G>sSgr!XD~Ku8#Jy0;&M(t z_3kNSKhnK|$Ne?uce$MS`Jxa>i7WGXy1B#x+y$?c462Qn>8b>pyLBg}4wG?#hq7gjKCORcy3su9=)eTfQfhng4RhxAD|C zB!CETZ>0O_3Z;CY{`2}5=<4 zSfyUVnotzCRJ1DxE<;iUmTcZhMc1ZXDSztNUCGmzp11aHId`>s4eu5HS^zpQ*$tH$ z({ck$kV=wj3;zr^49zyz6)y%?gUFlb&$fk2b!lp9QcmZiHR7!1E&0c0^Tz6*7vMq( zbbthp#DtVq@Yv>s*!5_~`xuu=CAD|T_8-|Mr9b*O0DHI+S zfD1GGzPeDUZ9(%S;O|01&U)e5X3d7KZQsORhyft55*Da&SRLY^!w%@Ol3$;9E{;9G zWmVB4&68h5TB?#EP(IxUQOOf-I(N;SmR@fb3?^z4vM@iZ^=LXXDHn_VKjIO%1+y+; zh2i3&+g78bh?I92B1o9ng(L&T$yB6^IXABrucCQ$Z{zvlw-+`brp-XNDqxu4)a!rx zdd2E>;SgB-kGl3PbH!G-!1s8>i@lg^D9xFRHYZgDL}XVYPVM0DD_jlMeIbi`F|&7# zOH8_UI@NGwqQJnma*uzZpHQ}AmrZ~4ot;zAPwcNBmt3YzUT~<R0RhUtI<1 zZ%8>q)N!$AHyp(&bD4>Q5>KMCuU!=&-LARWMSx#jJr`Ty;A7EAFUxl(E#k? zYgcY9^{c7B@=K`(Dkg`VOa@Jr1T3zc=c(%m?}l8G=4t$HXpyz&m@FvckUfRdyQgfF zx*Jx%{%wfHPsWD4kRDvP*Zvh%&P;2(B)-Ilg? znMuDF_|zEv41UqU%5@ZI@sc{k<1Q@SL<-EdIEJy*ufJifyXw&7`scxOD=20EK7|)0 zp8a85w%aoe3#3wx$5IkXW90HbXEntzq`5v+@V18?7;X3Sw%s%m*~xcEl9W5?cj}|A z{MQR43v^@4z*as#L{=(%*P@^G0r{X{lw!le5}uFF!PXRCDj**FUjOSFeBhkqT{vBwGG^ zDCqu2%M+$;gLr{+6BNDrz8-I|dgS=cfg%N?gMF*87-GV?tIh0f|Enxnkj->P!s)s8 znpTf-TsHMq+r8t&i!q(~c=7OdYCu8KIZ226HK$DcPC?7X{_K$2Bo+3LJNc1P^@9|y z2_tllq*n^Xk;*elJCR0kcpBwq8s+^H)yv-Jm5+q;HCls>^YGh%u#>+AK9E=KFB81B z;jmB=gipS9H_hpi1WWU;AkT)smk4_R(%iGnC? zKA}u>4;MGW*QL?X{6PlXrQC^On+whsaIlZh_w{2Yi~05Ebf{U{i=Zr5WtuKpCJW!A zT*0AQA#ea6N~?8mms`c*&b`QmRsRF$G;!TV|6CMU^t6<2hBm!fi0c&Mc=+~UliNpu zN5`-9mait%-?E9xhBfI1^rbMI194uEo@%nl0P(P+qSuiumIwf7tCi0_@ui(3~zzK~j+?rOPn>HOQLU#F`@+t2{hXx7N( zQmUeijj1dQ<>-*nD zV{MwF4!VztLozLlmT4o3f+ue5*9kCqgJdissCOFMDIi2j20Yv*`i zs_-L1S+AwY&`OG?`;%@f@w&zX8eFov>H=K7A|IV1TT1D+R-y1U^QaWMmkcVgi2gi^ zzt5r#(1#wf?IlyIAZ@WiYdx1z1~Vq zJQ_Nv|3bpcy^FyuKDngt;H270LI{basEXQ>rz`K_0n5e244gR*j*(EBa>f_?=A2U7 zHE<#7rgNs9?QLcdITAY1+|!PJ*jx`$AwsU3hyqyw>=n9J%!Pqt-TiF9;LWRr0gHSxI++F>`utPtQh~?6z%6*Euc~!BOFCk9?xk3dd##4rU zs;ipSHR{^C4a$rIs$ZGjXU- z>I^P<5OXIyS#zu`C}ZSFL`TUuUa_oo%E`ITRNwb_>BC6zu$MtE07BYb#ngA;TPfEn zpn_&uXT~}z?`LP29ILPKOy6#s8cX~141E5{7kA#{CwK=oO2U6`eCRg&g(I#CJ!;4Y z!8JPFnf#t08J%_$K1&wa^b0$Po`m^3zq_D{k?!Gt`)*uFaK`P`GZ8gw-BiRGuZa{5 z|FjGJQs%dC*6Q)ECwSzGVOfLdTVA*!r8=RQVl0MT&a+b0ngv5OuU;QH(#3a?sIY#8 znR=NKe=wNzXx39>?C9swsj&ufyedy#9BEL$lJ6sA;*)qRVv_LJe=sZAiL@5wX(F*k zJM~7z6$D4KmZo7YhMNfYvv+51xL}1gi4`HwC#zBXgM(n>?vxCsB?ePH z3tsPI@oDyg`Ml(boFLJnoK%$!IGmK2#q)8Z%-lh* zTXCOO#);3Nm<61RJVI#&6*~>4K+29ilif&sG>c*c<7Lo zmBgY1m8KD37Ez+n1*!K=nxh=knuP#-eyFTL1+A@bIaPzCvxeI*l?0FnNWfkQTurh2 z4zdlLYP{_iTRoyX#Aa!cNA+bA=zbC{68)qLftkfb(dMXvW2335$_$o75WZjUp6=0$ zY;`T|l8_VWv8S$LG4}^; z9J08rK%&2t^Jj@}Xr7Syb>2e| z!bPmg(Oms2QzKIrg^fb!Cx$w&HFAE47$OkU&ev{%s6L?=%sdZPzlO-dHf~xQp4^`o zbgiD0KOlVh@5~$9)6h*xq43!jW%sVEqE$~t??En8qnGya3SP%1YlVvT^ z+$?5Y2fc)Y_%8S07C3Jw`yqAu8j9zJ6Y%Era`0&%!ti>Ebn*PmvCB(ft+DCnNfJp7 zm-ar$)mg}%6!;z%y5xBYq;{{jP$j&^FFb5>YVda>zh$m{Z%`dyh<+Na;P`Vq(vo#miZqR0fve| z7bAYW4#7Kj&a~DGH+2YUR^2wjMUC7ER~R$SJa%OxF!_pj)>CuILk?-~`JX$R+1GZy zLN&G+9^qAg;>&q=*ET@LEWW z%O7bxt?b624Od`mNZbjIiRB+sLrC+``r z=w9N7jNlTh=ujZ^wXvw(WNmP4Qqmg+`PjENG2hQjqCbYVY>LF`eY!09h0b8L>F<2} z{$s`NEk6CzGr^87OZ@KM!XHsi89LXT_lbJ^!2V6RyOm{ZkPtn8A7IVXG@{+?m~jJx z;j7CdD0Ndx+vyucsuy~!v9zT78meEkY~2I9Lh@*+LR{t1`u^`qT;VWLN(vW1hy*#m z8U+~4x2{&DGTp|w(_r&*C6Q#m`NHi7C9vUx_Tdzwa;*f7KZ7O+YuHV3Fz9CnlM+69 zfz$UKDrRAVyXWdFcIwmCQLDbF74?iAm~qki*fZ@H-)mp=?`)3>j05bO;OR}I4^2z& zPOL_nDO5*9j-N!*1o2dUuHH#A-c~PC&Kx$=p*qttV%n3}vYqo;0QCj)B3fr4&zFuT z(;vkR-Hv?zHH;trEG0kXLq3-u>ItQljh$oH%u(LY4P%0^<1nZ7@2>;3<^q3&vww`K zo$5XZG4X?h10dQI?YI)CyuWDs^H&JQz3tFnyPGKOWmO-*bFcpY#v|BsAK725i+`(h z{i$4psGjcr)D;K^Df@RzZM;RpdJam0mW=q$1gayHX$bf!aJQ9LH11&UlZuzEw}?_G z003K23c9i6Qvw2Bf1vTt>Xn|>Q*tq!V$Y)-#jQNfl{o6J&0=0-b02La(y>gvUpAt$Tn24=#0@qcy&L$!D) zy;@0|u{mXX5-WmaF#Ldc9`1g`F0pQo_I@r<5CHe{VX{TkNLq!~qQ*_4-!NRk9AE## z|Mb^W_XN~e)Shvkt8DZB7Z3P7`Vl#yCH|6s|MhcD+xg^&-=7EDM!;L8;OIKt2R~l&p#f6eMG!$1N z@){Ynq3f{S zmE!OCC0x7qrE0~zfJiAZWbiVo>@#5O#Tglx;?W1C(V}I0)=CnzGO`~S9Xqvs#4Ebn z=<$T8)98VJ3UX2Dm5-|WH#V^-=#GEAbN1P8}nDYo%A)&!<&$ zkSj(RyRdmS|D3N+Pvr4P5tWNsti5l+6*?zqlWS;JgnZ>yfQfP{G`*V zj6J7IY_-nzy(0nV?|K>X!1ROCILD7TK8ZhKUu|vwBS^2nMp>`xC+o$5Pv$S9i)bvz z3;YhOy%Yv?CE2u)9=;IYuiSqoSc04Z@PWF0N}RM}J&abm*;bx0Qy3E>x-|&|KSDon z5ObAx=yTDOnXy4#;m8;YoF&J~J_4-8=EGxFJTPxa*!V%HL5L~MrVmiKN3+QS=W&(m=D|D zPX#RM0(@Gy&J7A!7Drj~d{lAf-5FPo$a}dXQp|hg9T|XXHK_KxpGDS7N6#7hpLo~W zEMnz4#P5BMR{)5v|F{rGsK|e)Ke}|==@3!)MJpM@vzm3|`0f$P^A67!!IeXEi7FzJ zB`48QO5s=(6EG)`Xune?DwuesnP&fHnoIqngERKt|5#$OK9(ny>tQR6N z2$)U3A*5a`@a?98x^l`I3c~rLXR8PhI95D?kpnz8SW>l}tR-zlcIcj0tQR{HaF_W> zCdf7F>Q?^^^(qM*F9HW+SzQJ6aYcq{Khrl&j`h!&iO!5Yp;mIjilrN^JxpvQkj5j) zC}0&O?0t%Wkl^RQMn#Wfrd*$f@hE*}Bz9aZYC3mwDQt|a$hF9hgCrr>p@2edU4MJz{$?Lc5x?H;%PcZtieDKX>JQ@WJDp_?SFo8%3!- z84Y;>bAz2?&XPsP?Y(`uiMLTMnXfeG96o$Nl)6RD&FPzMf|&0R8}AXeE$0r0eF7y% zS((V+>D~F6iUzJdi!YqTX%OY^V;_z{2(`=oYewJP9OoEDKBE;dp+s_cRBpa(DB!fW zD=otMOhGs$nX=|C0Mm<#PpgW~6!W;l$wPtC&hHGFJW9LG&JQ&g+~yK{!4iO(Us%cK zI;(gTn}zlqtxXHKb?KeCqCYpIF;@{Mu%Kv`-+B2}@E19B{h}{o6l?U8!q@P_Z!N7N z(**7LF8!_cv}Huz(4*EUN%-qyge>+f5x0Cl{Nyt+N)IS*5g+y$`wQ!#-sH7P)Gf)3tB_|T|V?PxvakjwtqLU0Xd2S z#713#zWLo@7bE76_EeO6uEe2m%c>i3Y^x2In{(1rR;omf=rF$;awU#8>K&{79is7U z6d|dCe)2UBqy;cn#3|i(1SgBZl!K$I_oK7lZ`M9Q4YWfpN9t{T9bGL%_KHA`@g>_1~y-m0RB z4K?6*HGaRE0y{JwDe&*Us_kc6Q#1%+_BY-&w;xntGsc&+o$)s_=Ytd2H*b4#w8GvA=OGOISk)yurm@?0lvuV|^2)E>Ib{^~kswN_*_~w;aew8F+v_ z1H4Kp$5xw}lqo&xw7Vkm#0brq(-8{b@$7tD{jFkgNp+7>!z&m(c_ubQXf#a6tF#a$ zbT!8zI!+ehf0)2kzi!m6V@=hX%JIA`YHxLRWM?-6StTNfNAc@bw+Q_?4+^P~e67#- z(XvZ{Zz-}?>5tIZ{87%*Y*Fi1%BI#aFPnpnANg5ow%dil*&R3KKi|wwN5+rB56MJD zb9a~){WTwpnn8EoPIHq?#5&Rk{(CMa3q7S6hHY&OAkO|2Mw81IVruPQmhvnU5RSmapi$Pf~*-%u&-xxLyWiRRyM`?met*0#vb z=S5-ue0&>kc|k2tFct}5gLis=AdOB1vK#sDobvMZfmj8E>xvHFZMq+)PrZgBQJQaZ z0gU|627q@s1&}J`uDrrchmxU?ATb#Q^w`m(v1i?pAgUD1S-)Gcs667Av6Or_IAdig z@M@c&iR;W-d5&|%Y;|8ephS>Va$@YPz&fgINwpArMx~ht0)`Kho46SaM4O7XZsJHc~Av&<&P4Y6_l6Q}9Gs z=NB_Wpnd5w$FKZ>MJobKF$Z>qrpbDTDN(5tEA!+8WF|`n2m>yGE1#!ZV#kMVAXpMw z5nxn2GsNKa!~rpzkR>549hd4j;6+%?tcBNOWs{tZl7V9Y$WXO&(y6V;>Cuagm%*^+RfE>aCrvU)AuaXOoP1*g zyuO8gXf4*5zi%j1#UZOP?hVE!eBLJPBAk1qf+woup_sW5!cJK!}1Jpmjq6<$Wj(#ZF_W))r7|us9F4U@RQH@7K-M+xCA|7 z#`QlRSv1l~JRL&wAlEV67c`x*~ZuFh*^K((k7f%!lmhgAd?PvE&r&9~V zYVTfaK3Aue2^qn=7kA;w1S~J2^!xWSQ9WYfy%z>OY&T0~E|?1=6t7|WtZgXAd@dUd z?zUdJdap8v_o55jxLO$Xua%3AE%=_SmWN-nQ zOO-t%ZH~(nkm>43E{cte4!P(2BiZJ@?w`XjFlqJQV2iZWLDjtW3766ZfmR8+ zz5OPrp{cCh$a`e#UdzGEzAI&KomC$rIzq^EDLCD+Nm3Eg6L*x3K@Q zch+xBy?p@Q#)@X-Xhyem2{^jD5p^_3mk6S6qee)#fOLbjluEZay8R9W1S~*MM3k35 zs;4)KF@vMSP+d6ziqQ-ke(&ID9;#xDo~p&1ffHrfftyG#e!duW#;L| z3j*CisjY;8=NsCTOWiZ359j8Ky|j{$WO$b;y&^zHK>Y9Q%Rh zwD4f;L8T~p{r$yy7y0^c5*n1`91vtP8C3TPy*(LXNn=2?*7wwA%+X3B&~xP+}HrD~rul`m7xAB43CyKiWO2@5iNbd*p~vInr9ru&rnlTpRu!WhhTi#8P67c_z)>+tS36=gB%`1e=iJqM~@J)505Z# zhOK|X_carVuig+Hw{-8E4-STI(aBVxbWQ;RQgoFJe54@!6Dl{JXw0}e>I7au2jME9 za(@{#=<706#5dFA`6Pi;fY@3}}4*I}oIgMZ}beV2ff{>XWkI2PKH_GI(Ejk6_ zz&rkm?fpP5@0Je61^`6lyZ~#CE+IpCrEt{pN!(4-B-_x}OE$gg-?%(S(IpVHqqQwS z97l?YD+Iylud}I|HysTMI)jYvXY44^Cw9x7b(HRG_zuZc$pFVgjVIV7_*?q9hrLZ- zCKXAnMjIJ#q-5WA$CK$v?YX_sBpc?dI!?}2-J#mD)b{!SKOzGO@9gEb69mr|l4AHV zsjt_jsv27ZKP8?dj)W=S)0pvY(D;)37J&fA==R4wgVPm5t8A}$LkTzVL8{> zjAkS<^_ih0e-=D`Hir$|!lkh}5Ixl&y?cgZUN8CEgXqVzmQ$4g;(3p-y=t~;kF0dWsJhc9 z>9Lz}k9NFRs+BCua}25h8@d(RNs~*j7Ryfy;cv8WTdzIHt}Z8tJrb*S7;MUE`(Y({ zb30~3S|bUC7z|DF-1PDZ@8!q`=`^4}9Mry2_&CF*;9hCsv}i`l=sc8Y^#&3CaoL`7 z^}EW!SXQnSfKE8q{vUDfJ6QnTc^(-!?##xVCPVur-sMY#99^5qgqrm;^XHd4YeyNpU8S*1{y(p*2e!0FaU7Nlp<`A$b2uGKJqByL zK(E*^hvh(pG-188o9bvg#&pj|ot_a8J$w!irKK0+0LaJc-s55WXH5E8%nlEhDe`TV zqT-d%mi!so26`^MxnN~MFXa#oiZ7!N%TMoDXBoa4L1ds2w*W?}M3oR;q30}4{R&Qb z=1#mV;DyawTX9*@2)sx{k%1S&xt2o|5B z)sGbdK3l_%Wp5c?3c<}RcDTnYubkQ9HF%ltQMo~@d7k)c1pknG zEb2jc+p1~Z!Xoj@;s#9X&UJ$tqrhp+`quMGzzY5J)`FWB6MLHm*sk9XhentXt!<$k z26`NGamKbfcULSp!?(ytC@#~Gu=|x^lFek4a^MhWvT!Jw7*RI@;uSP(=M}EN6y%BA zeZVfG81%`W2H+4@O;Jw*1h_r~@kGlB-^VK67XT?&cOu9cL0@d`9p;oPtPM+}1zT-Z zVopqR`Vl>yJ|Upkx1&PELcV=Eh;bcCOLQbpOKiHO@o3f^=ZCQ}(b^nakg8y?0f5{` z-~ZK^nn^R*p+qMn&H6bqitO0`sOsJ!BO+}-9;p#el)*%MN#w5}u=^Qd&OwQJOgaYy zPG)AxP_t>FCjx>NmwYfEmMoi$t0!y%8l}R>@-7I!j`MMKqRn8Uil@x4Pi*cE)}1Yg zzlxCVhoR;I91gbF+i}=CaoU!w^fxidw0X(WJY+PmpoB7Tw^b;+G}!{dZpr>ZFh8DJ zlTv5EMFbv>mGh3A$2St65g4Pze|p1s4CL+kmAnk{+#cqX^Hl<39Lof>lmyGm4Z|PU zs5YaGuc7XJpQ2wvxpZVVn-Q&26jXkOp(n>+4P&P_(QL#$yUVUJj*`{ru^wJ)dUzkN8c)fg`u6vet=1@`!PIB zlRFoNf@vabg8vD^zRO+FD~uM0&n7$pCi09($E3qOdC(+nC1;Twp?R>XXHJI(q{AL< zV(#vDp#6GZHu!faW4g`i05;qpY<%1!s$0nL7|jSI4+-LCFC`0SdhjGCZ&etL%?@V4 z;yHLzLiD`Daskz8!r5mEFFW#!3>=gA9;W@^=V$?jAV6*XE-YC43#FKjGfx5R?Xxz) zc2Zi)LX25nTBm`NT0er8PxRpc_$HeB4%~0dt*{^i><72irQcDuh)|S_NWkYz@oz&gK{ITwCI)=0>CDH+g!$#wbX>!TC3tIhcx_>g+aff)M7+-0+suI);dX((jkUr1j*$& z;^9y&^vyz7nFo9;knCs@B!wZn8Av7$c(CYoo4d;{rW27%;@O0oG1CDV11YxP`s$9} zp9tZ`&JU@5D&nIn6ucZuSNW@?fY&H5TM`gy4rHJMT1VRR8MQulZLL{-nD$9Pu)kG& zN*)ym)Dpa%3RBUBrxl}p+OaCH6hvB=*R)>c>5CI7k2J#0=2`gojkwC(rmHy{P}l5qLV*`E6uy zKNOo5aQPEjFqwJCs;V|v?;m7^6YElxhsFAI#V2*eQNlu+y8^JzcFtMH2RAT4=8&!i zmLLibBGBVlBd3d5q2m_7n*MV}oo^{DDzGQ2q$h+33zmX?Zk2pOM4oE)`W-?2h)~~> z-ky5s>(9O3fIcstKCeJ1zS)d>0Q$*VnnPX#Dr(6!FLta~{5At(P6X3lB+jp~`RGF= ze#0POh=ei(sKlW*B+D6z<)#3j48aV)5hFDoyQN(A@nr$fAi7_mLhMWN<}1)2v2s_> z+v2@;o9l&wIKN+bZM^<6zbPsx%q$ak6SNN*$90RTFElP9XTs6z3C)A{U>G?*jCF zcC2f^4IUpsFjC^-_EB;UeR#}RAmyuVUdga2XxHblE~DOdF<7fuZ}Su^xTGf`sz)IV zR^0oF!ivB(TgjI8s)@2UTD2GV1=;<)*O%1$ma@-V>iV5d4z6FR(`i6!8z!C6P-oKh zz2UcJ9mgKwUJ&Gi-K}XDoPRItk9V+$fW$5c?d{x^OVwMzG5)jx+WUFp1yx@-)jK>% zy(_tIu;+4`F^N#(dvA8zfD9N4RndI> z0zUVFKI1@20lC3IS}L|LKxFwB;&^*3)Ayhx{zu00G}}r06Z{hIdXb=yA|qp zwEQ%AI^y<3?ldg+`FLM)@j-1pHx6b10elLcj{Z9~-8WsrF*)!EI&9P@ze-0{Ite?T zbR|vNMFb_U5UL!z$=g9LI2aFrjoJd{i-CTcqw7^AaO0R0reTdf(=m+`Pm_CnntNYd z_TEQN4*HC__QUR7AO`Md+vQ86m@IGS3)gr-{qG1YCb2C)dOFKissnMiC1$t=mToA# zQD(5QTr9N$gSibU?EnH_lUQU)AOj7uUw&6)zc$H>c;xB3n!Y1~&ez^v_JUwf>|Apz zSM>Yq%lY0GXd(mWIJf)N>lDKj9oSm{jJW)>j_@`*$_6^h)@F((9zLjf_=)~pC5A%y z?*Mnwobbk6LC4??@p&nyc?I8jJ|BD4q5^f3Hhutz4TIvu?C8>fI7xZph6o*En@P<= zhe&((LZxx3pCh3iB@LD;;X3+ErfkloE=BG@t#hYO#AIjjk)yVb_9*uLQg^O!FzFql zC>V!^Sf{};Y4B@NbbKJh?#MY}MV+{@9QAIjM{Fa21ih|ej3$p0rq?3_rKq(e(gv2d zG$|Pd5N3B^0$~&VMbm>O8#(W;px)+(`6iRqeWv3Xldg1Y;-S(leT3Cz=hIb0bs+rp z$(oSOZa`r1>?cHTV^8Y0^=CAP9e>82HbVVK(>3aS;eODuN1c<8-q|Z7S@jaD_HQu_ zC{%52)*%`j5bVwN?CN6(pRZ55;T&dXIhIVh-~Mux(?b@v#L#F2I?z)tjSC=2LVN;= zlRyxEQd~sdpMpH9O*@Vt@{n{v@i+&@P5W@!e1VKv&4Tc=s3&==TU6S|l@D6D=|A#u zd=xGm6i)^+V?HVsf0T)uS1y|8J_4eM^XL!Cbl(t-{CSXW9~aSG<4qwsOqFfc&9|$3 zhJo)n(!s7=)uSzoddpYZ&JBB!Y@M^LHU9Quf#ZyRXhh9LZ94|Wh=5Tpz}x_})lp;E z=H-aKvG}H5+$Pk&c|ZBXelW4;F%j0+*po5C4Bo4=Jy{S6n;zvle4=_dy4gEp+UH$z zNL@Pl?5Sy!@tR%tjWi#P%tv(zGE%|KX1$Uyy*oGFehAp({WART0B?4qZ05_f|9SqM zN#b1=#eaM^^?wj64B>B!aHgk2vg~X-V{Z=qW%76`Er#BtXaMaf)=wys_=G`Q%Y? zFiMUsz@zeQW8N!o@tJLjra2+ik4ykM=4$}MoNg!%&(0{hK^oW^ibrWir6DzkN0Ru^ z6(x(a*DBI>-_S4V{U2#f3l;qCx6F-HA#b=$7m8>>1&woEi09{EK%+plCJl9hJF^5Y zcRm0}KU_;!V;)b3;*Alt;nPmxkj|*6U*dpf9Vet6hKicv1xQ36%)q1KtUK6aV$J9Qn zAgZ*SRrr^h6cy9VXxdrLb%`!gT$?X7g_v=Q@+X-X8fn>clE@9&VRkK5so@_ksy8f+ zE!4CY*0fhf321Gn{r*6#BrpyK&k8~D{h<>gYCqnd>J8a5Y0 z(b$KRwFY^h!Fc}rax+!Po@VoR`QwgOyiqGU=5$#hp1@tL50MdrqATFrqa6i85t^6 zK1130h7t8YX;e=pjcC<=_V~YleNJyMoNVI_97ukv3p{926h7X6yBx}#)_=94s%fqP z{R6nrC{^U^QR)}zYwo6cpJtjiAW0M(re|llL3Lv7+w%G)#9@F>Z5yQZF;T6xOpi0d zcUun?AIh6BAJ0EXL9J@2B`hrb(9%$(n#zU1a~VcqBq0*Gb_uFX8pKS z%gRdJkJ}w@eeKzKa6k)7r{?D)SaoP?8h9LH4ApdW6{H?xjMLxIKEN46l0vW z52rPrLj*RuIf+Rxcl+WQjE70EpEymuI#Q){M;f!t7XssH$IBBy(J7D_`)v;B_9uU%RuAmKfQatfu<G)a#C1qUPYIcdjV^OPu%UlOj-X? zg@{^=m@zeC`_ENeZbk3?&}~U%YG5Q{p&O#u-brh)IFmTW23^i3Kxn1Up!199V1cz!z5LrF^dDHJXj%%BpVFu8x=XY<)_t8AZOydCse>v%+k1 z)8g{A&L%UVw(&0xApn*dvU8Qe_z&>LfG1+RF^L|9YA#;h#C)2tCI(&^x|5rvfdX*` zZ?2}tjp-Qnp~o}2lz=`I|AlijYgwQp+ABB{IO%Yuuijk(O_)oGZ6u1oswZuN;mx)L z5M!F0>b=C8H2y@VL_x)F?TS(c?)-Gt&eiVsxEbj3LxA006lMzKo?`xkW=`eMkVw~o7sR(&$DV98huXA4X@l@oK@ zb?7)PMUHpAEJxe?@O5e^iIGZ4C)G?k(ZUG;<|An6@gHkA9-*s@szi)^4Ko?)d|;P8 z!)6>PpsjN$pGk>^kOyzf<6|*Vn?vUTsw?PXW0t!(g_z4wOlU0OD=dQTo0HQH8p3P7 zMd1yfWgR!?OjT}7x|=o4B2eC%?Kj%!#s7@$Q)Ilt8w>xtE?SK3k&eq;<9uvz-vA!| z!l^vVzyyK95QS5@LyYvzmDw3WlkRFmzMfj!1KM2$F@=juNFqfi4G*c)akXz2-9D~` zKVrW9AOD~EQU%{w{nv77Y9`Xwd}b`uX%!@zT-CIj77{G0u`DCPA?+(p>LKmrM4*U% z<&b1L0qZ9y?G61;V9>fIwD+k=Ti^1jzCwD2-5b?1J~;vdwHL2xNcMc;#pd_Z2G7@D z$SEMls@7%9XNX5{0287A;FTO3W>+TUbR`V7BcdE>=2W5lr5r+r4Q|ILxw7cJ5(-SU zf(-?^Hs&!L%nY%fw!rb%<6sfeTH2xm-m_hf4Bf|YxqSo&zo7gnV~rRxUIqX0**scw)f9O1r4Ns)`)@t(HsF|?iDSg4^$e`g&Q!jO{&`2j4+ zZ8s;I64M<~4AtnenxZM0NQ6qNc(`Rrua?>@Ey?0NM_<9(1ATOoNJy=RFX{v|P$fOo8i=M zrU_E#sBSQ}^sn@Iny@gjEcO_PrN(MN(*_dOvI>&`Y3((|^8=g>0Pa&^)HHwwIY@nt zhkF_1UKO?^*!TDhlxY8k8#F)T!q(=qm5TI<7@OyEnU@K|8R(`dCG!v+-enfoKNf+8 z6$Ljd>w0k!qVfwj6&gfQ6Zlel`h?`H^e&FSCBPI`sxjH*VT|}K?fT3XRhjK%9Wm{h zk7PG%Saq_V-|Ryu!{5GLdv38!<#DsYaN&)YtAMRa^5>Od+cq|P%8@Bv9a2bz_I^Xk zxi|m-Uam`hQ5*ffE8pP@!F0~pHS>+r!pPwel|>t!(S~}p2FJDabG)QX4z0VzM#~_5 z>tp0-;85WUeX{$$*}oVBIa!Osp1&0sZ&KEZ{&qp|1igI3e&d64;*S$J*{O)FGoDk4 zlKaXF&!^82*`W6cmkHmvL2>z#l8OkVnAXV<2}PkiG-R-Ua+gfeP|&eW}I5xJKR@Qu_TPWwGAqlsL-MOxkZ3T$F) zt>fsWJ<~F>yI%3h^_gfL??S(hMdI*XM&otIcVArvh7mDG?7){`@Y;gHIJr(hufR`z z@Y$avR+KdcRK)50>bDrD8(z`zd{ip$RSFj5Rj0U-pV5`n`}lLllaSK?vKSo#al6t= z1>-MceU#$Y86F&tC6MVS&FA1Q6I`h^FsuW2O5dBKl&uodv)aamP1Mo=SU_5=4INVf zgLGKNSIvu9O;FD(MAd1tiC^wK!&0 zDAPjh60WmfTRxoh!pokh5?dX0Gx!S&iWA=;1D@lHHS`0p*pbCawXHCnxBrY?~ajE?;_KWE?2Hk?G~<>iSuCds9E3H8xP|oO63k>auQl=eSg; zAWKMB6mDHTg^yZ{Hq=&roUFVv$nkDzved*!G@FRE|4x_T>;-4YOEdY`vH zRT!K}2YT?OP|mwQKM;4+&JZPT_C)pl{YE7+pZCWOxL`an%$;qzsRn&lU=2AK=XJQc)TYXT*;JuTcG@2*1gj)1!QL*P+*Ese9!p93dU z4u%eZ+0JpT>9PO1TBCyTTz_Xd*c=)N;4JyM7zYPMZQg&*WSN-gJ3sEcfs2jG8mT+6 zX+AQ!_h(L9mQR|=QRWU`uj*Q_$_qW?PJM94ph|B79_S1X6|jvs{S zX62Yv{Sljuq>iEyRlw_l+Wh=d>v=i8nJ#N8+iQ8|FikhVkbAVnVyCncV&fFV9F#75 zRIl7cJHnYsqAKdNeA1ZpY8J|;yUPi#c)=97C!Y+lOT z@mkD$43;{*)Qjj@io76a9Jh}>Q3{Bax?O8V;UnxQyqtaY|LtIghL7Y=TSZW2RYB?DTS$6UL?;VYX(a=dKaRR4+lQ7w}7g_mZ-qp zba@B9wEuonQ=)h&|ETf4{n3=tPi-f#b0uM9$3`kD>Km(3eeqhgL_iywQ!g*GCbyWc z-Izx+USa6>x-5vSA@+5jU&C5k%KGQwF;*u>HfP;6@!x5*Qb*$LKy3RN|82K7dr4c} z1zYABjXgT=d_Q=YY_1KgG^Gg3SU@(8+e~-`H*{2DSdKH2OA)ayPGiq(=67kLT|c7k|IMQb@@ah1s`;yx zC&#*F6u({AoQl$QT2EB00VgQ&-=-`&b%tk8o;KtQYdg*@|f^QJqSRk|_K}mY^k+19H z!X<#uW@5Qe@}sQPU8R`Q>;U)i!W8aIE#>TwpZ}F+hjZRFk(Oqk?*6EDxbxahpINiI zlW3T8pSt5N|Arvfc>??XP9JtqfYQCWNwbKkQgTlIIhj`?aMJeYY-iQY41v`y$+_<0 zI)#PZSC|_cUSyXak|+USXJ(Quo(3e?E+y(KrS_%7#Mkqu>S&5b(J{S3w(lE)?_|nX zcsFTn=F3t;3eA%S=_6k|%B^v4-P^n)t=Xw<^3TIZDHk7XrAS#V3sNiRMKAuDVqfx9 z9$BYuN>L#m{UJ@cF>d(Ze0w`M-6k91h>ZSv)k#fw;uI#9AtIRZ(HQhKxM&ix|Lor_ z???(B;g<(r;O{FTs4j~{>4VI;%;OyNB19^Dvj6Oqm~w>9nBjh2Z1#I}({jt+5mSSF z34rLwmgEQndGA9YN4gK(9-j^97rWJb0~p;6G#({T-Ot@Ei!~x068_#lrZzm;#Qh1( zO~HT{nvfDJ^xT}XoV;3H>RNKfZMSWM1Rf)UWD^wNp%|J*v9Am4w7%D4UxPr-Te2IDYL4v19in(fGCgEx7JgGgeW{maO(6 zLokoF-Uff2M~BG2-Ba@V@mcGk$tG#|>NkABb^e1Sf2QPTVd3a_sK19AymKJ+35@RlDW#KY&yl z)G65i+rk}p<63DG%4RnBg$WnqlO`#tNe8i zjb-UFDM4ufQh4gG%Fx1x4;-4rUmd`(!UEh5_bUNFU7g|(jB!oNO= z-0KkiKCCA2$;v%-d;_#^78CXjZhr-q$bC<#Oz;Gjj;PvGtJfqc!>{CDVlp*6CQ9gT z%sE!Giq)>JrZQ792=LM4kU4X4mt}bFr&>D5tbx0-j2x(v&M$4<3!V@7A3ulyceS_Wt zeVSoakN(%-EEhk=p;CDDjkq-gqieh!4UpzlFafl?VE@)fGX#9lz_p&gn->aVlk7nA zyRaXvh~A1S0m~Q=(hQbrpMI%%&8HQo`{wB_-hTu{ESy@fbOP$aw)gG7KO?dpZ}Knj zhabnD-lKc^&--6Bkbos?8i>O|dj{gz{rhGy(ZPMF_!#;bi&Bn|Bb+lQ``)ZGSJb#O z16S511i(e3Ij)-s0}YO{f!+v{p8h?H|BqEMC1@1sF}>`X>Kr2tMQYKnLXdYK&c zeLkR{Y0=k01WhSnWm0O6C%1O6Sv3iz1_9*bK)7ZfIv{-Pg``SHA!@maT z&n;GAkNYPCc?0MdDA6df=t&tO@q~tDLX%g*QZ?p=w*41J&Zc#0i~4ZVGoj)L1Ed?B zhN-ypRUMPmEC|bmd0m?Q&MGxl@5x2JhASKXa#iRBjO%=~D17NRCMFm!9RJ*ZsWhI; zrObOTuiYMu$SrU9Wd{?T^zJNrc<4<^@_oojO8&cOqEEs;v_=;pquImQ|BwYfV0=F)Y1tZh2RzwnrD)34J z_RU4ZfgcdvN+dyV?hnvDNMmMaM7`?NNdhF7h~+tw>_NnA09F#fN(BJ8&1SRB5#yBF zS+u19=^M>Qckd*yNKmD*H}4Q*0s7e27)>pxFe1^c5JGL{taU9kO>6@sfmPvH%7hA*gYWDk;Y9r^a( z+#HYz&+5N(=quHGyR$+d(#h)Uct*@RVvAbyXn_5RgRpID>%(f*%GRrb-_M!4N*HDf zt1MfqU;Ct7ZwAeJD75%4}k1>D3I3y4UFmnF!0u-$LeAU z^oMa)9O8PFvP_>~9wGIp!$qQ`>!zNir>BJGzmV5iP0qhikokUNG36#^Y}pY@J7xQ1C_b-A1{A zSYU_X?b` zE1O^VFz~}8|N97G*$8n|>$A9#69H$B--4yFqS~iY46+ULYSsIAKN739O!HXpQ)g^_ zLy7Ya*Qtt* zq4mZ@2LW2F3C2Hpy$R;AqTqN>Eq0oBPqbL3u_8VJt<=mdc&2`$(v29P!CexldyLv& z#ur6y)vV7VekYUCReRH69M9Bj9xGz3aYb?Q;T2<*d92_$c3hjrh2TdbOQ%A?Z=Gw< zvV92TIt6U2<|)xU#FBAr@O)%_2^{kz9kY%DcG$uP;KK)ahH$wN_UjYNs?9iR41kO& z@AtHj6cE1A2ckftBm~hPk#_yNH@H=%6aJm#@Tq|f??uA(NvGAB{a_14;Gz`<@8zrp z5hum8-6fs9oFdWZcHw9wTgcZhNwZIZAbWmb0?T&%KZ8ze0#wZI8Y`^3=P%Mx0?dPPp<_@CBZ} z45O_oDM!bV4oP$I`jVxte?Q;87xt>JQo>W&==P}0p7r(U0Cf3PUwY(us|&yiVCu zFli@i_7-Z=UXW;KhH01HA)0xrsWkUhE&p_-^0&|mn9#%ZsjenQ8C{J3HPW#+u+;i7 zBifVCXX(!)?jil}T^$b7{LDv%QdzOpp_fnMZPzN&G@6IeRTJWTH8}tktwY&Z3NJFeAhLvCA z+P_+zOd)Y(A;arxgc?SRfDz)_x~klb=6sFU5W825ZnHC+;k|G;5Lw}1k~Syhz%8*q zM^oaevW8Y^jXCoJO7)Dfp7&Gm82xFvXWsvxIkIfvH-Ngo)ac;=RdPQ?Nk0XZ8xt@8 zXjlK&Wb~DH3z4B84dlgm@?-!|^bjze2OS-x5TqCErU!DS9UP%e0jNr*!FaHgA{Ju8 z>0-jO2rX7+ne2;6H2F8eyaG_PDYbQ)x8xr&&vRF2b3s94&A{7KBpRffa~=@Q z$&XiraX#nfyYZj3@dW2AGxj7XvluYbY4`W5Q^t%Zj`uf zmU?WKd3`AN{!robq0;ZaD*ykg1OKZDCe?ch7hBDNYMx0<51Uq0Dxe!AU)``8-& zu`On&J#MEXey1~G_f^tvSMpwW%3e>}UT^xRzRXXrv-jWR?e`ZP3=|y<6(0_l9FCNI z9xeYoR{43n`pZPkm&v-XQw?9I8^6AL`E91<+xymU#P;vAo!{qPeV^|>TIfAm>^oX| zbG+PtyfS#QI&!jp4W&>1_%MC?-}}?8*|Y71vz?`%yQ@Dxt^eBpaDGTS|NQay*PY+r z_I`igzc@O$IR1Qb^7YS;Z+}jY{+yj${ye+<{qyg|`QJYme=mRk{d4~J;^*Jrr3^N z@c-*tk8%aDBInQ#EJ+uDGjKV1Rg@0J(?lcF^`!|TNyr=j;3wtfV<|V$|5h|wD<(K0 zR&~Y)E^$-2(jLQk25nVINXg*Cjqbsb#208>m3?|y&1_kYrM=^1Xr)y)yXM0@!;V8J zt(~IRW)D!yjtVp6QX}=VcD2pxdOqFHj9Y&dxK*qv^=qNF?ZJ;dldxAe1^~4nIJB^{ zd25J3`T0)YFMm;RD&JVq7u$ig4qw@}uN5VkpWc_;)Yz{`kn@HY82T7GnTxG7Ik~zv z8m>n#&~*i^d%U|XFvFx227M9Hwr89#8!2{Aoj9RQAhJt35RNdKF{U|6t22)uF$(=x zPPpQxRgV%C0?k%Cwm$iL7Clu2HprI#IQoZMD`n$p>#YgBo2tAdENI4@#CJoWh9`KG zHJl;9TRSff?O~pm$a>vV+KP(^-QSAm!NW%h{5eWxEJBf`*M`2xfsrJPeHjOnHI;u(bfLK;9O$?8$YMldp7o{ zLK~c5jRt1V>qS_VgNMIFgg`)zQY?jQvPiQNguW!FK#=Klwb~vFqPT6_v6-`8in7PE zTP4fXEtKn=wR7O5O%h0zFqPl;o~BIy67-4oTD}vZs;d!Ot73^5uWn#whQ&wZU2Sc} zwY%iSu_Crbx0?EXs%*WiH6CYUpN`FgA;$`8#F4_C`(KiX_XlE`7i`(}uSK%;UpsMB zbB)gws_I;8t&gC(&2N4t`ZSI{Av7e63Hp?^iE4h{PMQl2l3+P5It-}Y`a51!qamt& zC!N~j)ah~Y&$#GJn69!wHq9uHcYXY#!foHC$4ogePlINmeD{oGL9gc`&1|Fb(%A>l<3;3cjr&TGOHIfn(IK*nPX4e{F_MnS4R}R$s};21I;8 z+~}K{yZ!w&5h~O8W+k|{@f8W5sL+=vtSZd$?J%?j2vG*~d~Vm0@IE!a;KdgiBXh8s%e*Y1Bc0RnOf~0b7o6 zryPgJDNcL&>!}Y~Cu20eXSsq8bXc5nDrw&YuU0Emy}%{P3y-xishkqKMIulC>9e{U z1W#@Oe=ukD33I-`m9m0E@bIZuyJ=W&lIQLI(~12gpXUCubukY8s(xnVnSVHCDO^u@ z(7Fr7G;HYnDWFGCcdG$=9h;H!KM$HFlSBWrhCS1MyC+&OBg# z`y2S~iO%gT!Q70Ra?V)cmy{*^@cmKNm3}Ch<}TKdmnSospFhr;oaaG&FIPIHjU&Om z{%*DcP3JoTKUI~K*+XWgZ;groQf03p%gcF#bO)o81?G^fEXoL2ytjnGO z80$DAsS-jkx>%Ycr#u-2$v=8A6RMk2YRWqiE+IeSyRd{iBMzv^g5J6B5i0|aL+5NW*X}K#B&DNg%a@zY<5A}~G z5UR>5*`Oi2WsmU-BS-5`DPCb+>EgKtm-eRGAwLc=dT2{s^)82fnNpsxP|}uIoaF1g zSZwUus@&Hc6APJn*A&iKMNX;J@JeEg8Wo9h{!e7hE2A?p@is?iYqo@c5JT%Nr_c8v zGr~_e<3dElKPkVjb-1^SqW{HX*H?v^&^OzEmW` z;pbA_-Q}~_JRbrd&_FlZ| zL5I_+P#U0)laKhkv*Yik&YYKOJiUH4{mn`dXL>zB+T7O~)Q3Tjneh%R!yv{I*?fSv z=X^|@EFBl%9{6M~N8Lkm-ES&boDN4Y%gEThI-`&5Zf!aH@ZJ1-?Dt2Rleela-WQkc zd^ZBq2v}cSVk-qF^f?f=Eg;?j_b~bB5v^{gxgNzJrRX&!G1U8A$AH%}&CbaO(0J}q zDm+6A13CNJg7ou(nY+gy-<;X6MlgKv<^+?>yFT+hU@t;|6w`3VD)UUX4wL@rs)*cp zVL4SeJD{J6D~efYf1bihf06NGb!}s|H=JzbMC+6jEX42W{W2-UQDKCrSCa8ca!WZ| zP$g4Ou5T-0IcC`*$K%;SkQm2bLs7Y)T!G*2JS>FsV(n42?`pqf>CPWxsJyYas(Fd; zuU7)}G3&d~!5cxE)8Q;duWu={qiYW2!{|X`fe`s!5CB6CAX!ki-;YlVedTKO5ooGB2)W6_1ZXB3w6ZV@XSP^2 zx}_V&d8*Je>f>Oi+0X0?N_RDtVt7dg(BO&mNH^x8e-JHTkB#@oSNs2{xRKoP;KtsA z^bWH?MXqQPh`fWWehU2w;*Ac4ztMda4^(NYSV0jBY zm@SUns$?RnWgwa1%6Cs4b3Hk!w&>g{0`%y?7FdWC24W9}SX~QDG*oGJ102udYZX0Q zJ0tt$8H8I9DN^(aY39^mP{);`+QLVVmlnW;U~<6{b}%A}o=2w8%X8!ts~7#90xU2J z1f)L#uwuR3%7Zw5A*IidnbKCz)2)WbfN{>i^<}TDET`;H1K&6UscWJ|>)vI#x3)Cd zwPWn%DLfIaXQuJ==c^F1T$`sgA{wvA?q;KJHG{3O@umQXE-Bew%a#JFp+1Q9Ve*mR zhD4M=FvQs8hd8rz+^bQXUr1OmndSu&AKvEt&x=`yyY;Z>LQ$ zA`9Cgbf)VqFeEIIF^i%=;B+8kv)6O4L|y@Xmhc!dX<(cjpaE{h8Lp0c#Vnc*j4SmL zPy}Yk8KnOFA4TUKPv!gn@w1(CtYdGEy>;we2ge>6hY&jU7D6iZJ?B^l2gl4x$KJ9d z$}zK&5lJd5NhzdKpUThg&->5&@wgxNbzSf4`FcIoH~E4GErR(}hw zD1Ud`q zfQzk3(szjsHBqci4BOE%wUD`}g;Q3@fx%Ydznm6I`URx`z|evZdB)Kg<6twM9OQ;P z%4NMwq22!P4@|+wYxbS@FCq$tVgtZKaA!9Umx_Y(U>OAm#5TB)3*dFHNcM)BBj;~{ z)I!b>H}mERK1?#;#igJ^ARX)te7HjA!U7+;daXZdXGcwPrC2s_>f=YUUq`TLW~}cA$r@T1>$u5K;-(GKse* z^g*z8)Lf0%YR$!=SVfNeTVOxmHkzohYhOd{xwMGaH{wowY?q^zhhN1RY}>XMmKi#_ zfsIOnbF>#4v<-U{*LfqXrTm^j+&`#EoI4eWuW@$Q8Z`!aU-ZS++Hs`RJE!Jo-w2?j z2K9>U>SFF*!uW3M!wRGl+n{&WnTD$%$HkmKKBoTfqL4ToLTN^5~MDO9|6yKPP&}xGy-Y_f;q6M)Gscx?euZx;kZXA!WIBb=wut zV0)%G0#hhJAcS5e54>k3cdzMHl6%wjea<+NNph-bA}w8Kp#$tb91%chP$cDP_@snS zOWLE29L@Yc$=i2#V_^P>@(M)x0_1@^s(tV3NwG8dy7L0OU{sZBK${OH3z?EWpT!Rv za;MYc=+`R%^=)bnd#!AGg{Y9i1*9N|LpG}&pgpYsCICR*$U02xs%B7V2k)H?el|?y z*O1DamJc?VG)`74^JR)uA6>91;4bNr;wJ(sxFP?uMje}j4fVj91S>MX!%}=E_=ocq zsM?vf#w?Q06;~6Q zLYkmFU9{WHMrtzYdTOq9%spgZrz$>WzQKzE!1nasN)NSAn92DYz=VASO~E?A$lzz7{QP(kx1F$_McCqt5$> zjn6BzNYSCSnaT9iYFolhHn*( zo`!O3r^C+*5~lT0&t6wu_+166`yY*^o%25{);y%=fkE?4BX|&e>+%z8zK%vUREd3Q z)WH+H)l>jF_Q#2Cd17p%flV`YuvM5z9#}sjgA&4sPfAH9J50j|O0m<^#-FCI?dgo( zs2dFnKcCwUnn#$N6+2jWh)LDU3hR9)*UyZ0K3uDfcr>jDD*G&hx`viib>y$lbq|zh1d~X0+vQTug#jINkQhJxPe6wX_WjQ#x4wPw3k2l9(_#%Wr;?e2R!iw zgL3=FJQHpS71(b(fu1Jie(wYuBD zXbm3fdlwr$z<)tOA-HqMOXViMK&wh~|3R5q8@OenfiZEO6)~UTY^_`EY{CiA;v)zxHOo zY;KAXEcnqdylguA?{H(4go$EZ@CP5mq^^t0lRb%0h^jao1=kP1G#fTN@1dCfY!hC_ zNcGogl2pDycpgqLa`i-oDb5D2Gyu68UmL9kc0)WP9^xeO<1?h}4tfpBk#{chpV**v z7^DAvZd;UdU_C;EH{>C=jTnt%BJ03gc5A&K1U+<7c>w;RHo6qDrS|j$a6ewHEGj8( z0MMwT6;_O_Va%VCZfy^0DX??p$Xc0l{)?1AJ)l#;_Zm);`&0Q{0?M)9z?qmi)3DT(PNi9_C9d?H$rJxuS&*x9fG)RGm2mdt|T6)U|9?v@Ea-U^5akJ!3Q0#b?sDo`1WDWiZ}7|9HbT&l&jP=%inLY%7K+&f zw8mODGkJxvmG3kA`&ZNzZd$6`eZUa9GW_3O39mD#0^t(b>5qh)s?$RK9o~72PI9AUqGI!-8oWesrcAr*x*b@0dtL_uqE2k+){6 z7D58MQ!dlyrk4`!MuiZsd{v<%DbHiQE7m;2|ZH)8aRrj%t40* zjD*Krb-?2+!uko-!$l5TW?3SJxqw3>KPGl5{jh^JqxVy+{O&#$u?kz&fCdfVM3MLK zEP_)Cd-975G-qd$5w}-OCtEPX^=wy?)?d`yF&lX8>o6EDt%v~`h7P(4=meeN<#z_0 z4MCBs3cJ-Dz;T42x?dXs`1K7-u}T-kDd zE$pC5mEBeo5XXTF@=7(>dp=`Si8R4JR}SBlQ)WJ$JAjf#5kJ_W&$Q02H+n ze+<8Iw5>0?PZWJKe~fl0`GZk|ThY+=%^pL7UJ2tDQfI`}wzp|qKkUnJq>KJm+zR;J zYx7AQ03m9W2bBrKXxfag!s@V5C5?1v2pD$QB4&$4eSzL)thGSc8fH15m}G zxTM-~To>~t$GDwxBu82G2QXFq;w*ES1`#v9*}_j(s-7A$0Gw1Y(lLnTTo!JDStWnF z(o(j2-I!UvZd(GS_&`{SX4J73ZLL^;((&YXZP5dplS<<7<03Vrlq6tAheJ|lr>KgJ zPkRP6f>bQSWNQcy%(Dnby_h#QIZfqOvyjL$h_;j!D^NqJXyk(H@c%|EGL%{wn@FQp zKmp1KE-q!GI3cB;{%bZy;+f1!Vr-gBA-#K9no8%nBpLU<`;u4UN`bz2Cdy?+toJ|f zRI$--Ks}N2+q_J{+4>P^a=Lmw`c;o@kofA8nwDgqQJ%InDwgvjI8Tpox28bQw;5y& zCIad>kQ2dYwiCfU;Qvz9kf3T<1c3w>s|>7RfdNQ?}Dh@b3q^`j}+a2A(=J;Xc#$iqkT*;bkcJ|uIY2fOv)D<_2)RQ zmNVmtNqDlA3sEp+<&<#QXyA?11}mqa#QYn`a zvrSM&S05j*S%0%R*~!a(Ld5_i3+%euxFbdk$tF)#k6Z^>)TRL3!%VUUzRwZL)u@Zl z47sCl+IA2_Q*E7S4jlWT4S1aSr|{K7A?EkQOXlz~tDr-$tndV>%dTCUQGJ z!vHfTmyBoB>p3ZXIaR?sT0)&{@Xrjqj=Z3XvNKbNo=480*w`j7;HOJN5-#m$6h_ZJ z7QqS6W{(FbpNiOox{tRncXx~_C(9*ip?)nRJ?6;VGidEpW_HAR0GW;I=Xw4XpYL)$ z65s)nl)$4UV(<%i=UpRdjDEu_LS!6&{NZQSY&XQ5+*zJ6_H4-itLjXEB52%bmh70g z%F>*o`PNTaN2`7)vRPB>15H^ku#|1Jwo}Wz?OC{4TV?!qT={n$al@9W(!<{Y_borB zUigo6Mb5iOM=W!wuoawtB6Cdt8XR=3?vuv{to96NgGJ0eeUUwvyL}fj*%Sd3lV5Q$ za>pE}lNMA*N3GX&{cjFuue*S}?$%={QJn0~<0f23u$&xJ5GmCQfIZ$uF=5(X1Sj|x!S zUV3mRc9AJ$xP#hjlbKrZN+>k0a~Pv4e`=f5 z%+Q!e*s|1glrX{qR4)TAxa_Ti(N5BZa?OB-tmV&}G>Hj?_iJjl0tbq;n5wIvc0WBRP& zH5Jjp^$XhI`Db$7U*ocU@^e~)_0LsTKHm4|pzHx`o_&G;ZKRo1`oY_fUx)y=KqLgluz7zRaZ%O|C)EXnkv^hs--H%0l zV&X4;t>G)>o3(c?dodlA%$KFZ!-z&_@p&}+e%FEXj-p-S+rVWv+uWBpPvJdg7B4Ew zQ6BiqwwdNL_}F=S&(Gu#yui2I%zS{X^nnGXr7`D!nM=*AyEw2Q9RO=CNIxNqd$jAk zq=vIdil}3O=VjyX}tmC0#{$Swj$l#Qx>^$Z`d1!%$28dQ}-oNi70MhI}W6yPE~ zZY5eA;dOl#g)HX_l^hrI$(sH#3R5OlTRluS0yl-;Y2?DFI_gWH#xJ704@1@bwC`?y z`jQyIispFr(eyi1tYyTv%E;|y!iUa)oRoWb_AYszKuVy(`MQn04fR`LMPCy%A$h^6 zYHUYfaQgoBK}FZDH^aA7iwcFdz~%)>Nl5mRFmF#+_QSWAdCExP#?3D8PkmaS=jn1HnC^g zQ|E8-7eV=+cIP^q^REOIbv7NcM$s@ad8C8+)W|z;i%=+}k}H7UGiCpk_tjLP*x;f^ z+fXT{!kSx^CnI9@>&gZ<_#V(BFKR-(8wsM%HQcP~vGHZnr;+S$33cMal%cZ>6t^)h z*(u|wX&p}cR(%7+`pyg)3#b!+)oj)0z<)xzg zbjhb7{=%Qb<_}JUcalNeWoAn2MEIYwIim|2m69xowhPrxj~0XUEc9Dmln4sZ)Uwur zW(yk4RV1Nxjja`VtKWLNj21C|eWp;*AeSl@-TK{LA8%EoCVwW5gtMT_@V%J%ZxoOI zR@L3^E2YBc9kc{y;5zUe_Wz`(qre}Ak^g+k4AJ26ynDxoWQ`(bQEnQth#Rm@4tw3h z`?2TKd;nGZuUm#ntP z(`ZbS&ZaP|7fXE4%6mt#!T`+=m{J=(A@4PuR+B|Z>{~SL z?{78O?f%S}zmL??1?Y-abLs!;`Q<1wQ$gcTs11_fzPt*SUQ5IjiL8>&c|uVBzKPS* z2J^+`vs@3gc8B>70VD`br!DE1{KzR!XMOt3hkT_VEx(apM34&VDaSQ=kvtPW)k2yh zIqoxMcim7@C`)kHL|R{`LP4A&*C-oWVO&;0X{tzjO&qjGu&xkyZ>5#$@RiL0K5qf2 zu2akQ}&cpHn|~c+)(cgpDesPr49S0 zTB4@Ik5SIFC?tL|6po)HbDdA>v@{aN@if90r>OZ8kE?y2?0mDN3tO===Ej3Ysqv$q zKf$fGb)h=Yyh+@Fle)&O<|Ne%$4*s6Me4xd3+eKvC7p_G&4W~$+LOJeZVI3*Xi8nL zSu3RK9(}B$3h+>vF`NT{@Jp&%IZTiptJfRlYnmq)nx7d{SbnP@)Dvw*pCzAS3ST;4rn9-;HwdMfoxnxfY0X zhrjRrkFy?&Rb9NS&BIlmPJXDc{xb8={p_-S)gaK_;ft%E?DDD`x+b+(^U%Kg?X;5T zWDNPVuiwPrMMLo6{%vq*+1tpNUj0use7i#>I>)BO1a+`;CBRNi6CdYnr!ld&~`A-kN5(aIWNd653IuuJ`9hK zex?Jrd$VNxxgETPyN8dT7@U9B)OIP>vpwCXT>&L}XWs&!1G=SHriz-pf>=hcKg=`G z#4o}_XDDKwD;Z-X>YEjd%@uIXWKzFn)!w2tLZvXg@$O$s(}B5SX{*d#`>Jg?>J2={ zx{^z?uiACq0l=IU(>3^*5cG_qxm2Rn!qAqsVZXWddXq6$HvaagIxC30zTnZ#g+Fz=sd-Di{1+A< zRFa`Zz>6AqT0Bd%$i&As;~9BF^S>}r(`Nz5dx*3C(IlCz?>XDhL{H`1wY8z2?l2?G_iXOc^0ut)d=`un~eHKgiNdYY&;a_C%nE^DuN+5 z-mp;Z1pOL+$-dF0xE(9HAk^xq_NXR zYazrE0MSV&U3w3~WjbH3JlJHgdfbG_>cjzVxa)B-3l_MamZ~QUjZs*L-c(xJ84-t% z2eiE(?AlHeZ#5l?oJJlX8doX5^AG_3<*^y>#y$9vV4O%sbPc^bJVw3Roh ze@)o@6UcVFeM=wKGfm=B{YK<(7Bt=4wsWa|WC!i<38gYV^*>3Mo`C;q+}=K}`5Kid z03h<~gvCw46F#5wY?9vgH@-3k_s;VF)&6Cc{nJ;17;Qi;k`*Y(IojVge z2LM@0U7}ykUZ3b@v!At#T_zF1@(Q)UYRuaH;PmUp{_^|&{i&LDtwdG8xg#5>x<`9l zliYvr-=F^4`!PdNXHZlDVDm$ea_^TTGUr**n-abLYtPxEg_1pp?7tIODDQW}RhkI; z5mzq*=o4$A6owH%Z&p^2A2><~G-vz9Vv-20(AH}iq``N+)?Vq;Iz2ydwEja0?QRx| z#B>5+s|N^dDi9GtBr~L{!l1m~AcP-!@c;s$3s6($?=}_n?M*oun3i^~4p(N_^%7Zj zA#4)>yY)t-Mf&io!4W|p3EoFTy~Q4*6acN2PiIl;K8bh5F-=a&IK)4SgO5K5sE|rh zeCL-qLVBdZ3W@QlM>sJ=oC5Jur~5N-Q%1?V$QIj?O{Ky`(Ke~JrbYHLI@%TVBSuaM zy%naRvb7g7jjwSVf$LIy1q|NkwfgFxPh6_O8~1eUtd|i$9O0$M^X)O=RkBA*G0(SS zb}C}D>-{c7IKM4F#`AlA$*?^&e(TAaD@kh2nyc{;OxzHS7_a#5>08#{FO8vp@nYxP zgJHhAaVi7AazP+PqESRk-6!2E$2{LPPK-0*s_OOffC=`p-J~;6Q1o~2Fc9$ZC{BCw zLikV0+@~*x%P*Hk-u;p0lHDcul_sWkk&RRNm5U6EI0>SmLUzA_>%T!PYUE)D0w8ex zH!RRDL+ZCdFXzn;6SCTArUj^Y7dR9Cq0sOkF6~wE7;*QtJ+IUjAM4CatFhUd+d}mo-TzEM1 zYYsF_uKl?DOxNyBS47N=Z{P=si&fN&tL(beNR_IAxVYH|!_VR2wa1?=uMGY3H7rJb zh$e|>L}+2QP0aFLeu8ct6DJa7nj>_h+1WuC$_~5ho`wF{^7!K-y|b!v&HM}H%lJ!= zw=eJPzt^6Dt1%F#D)i4I?I5_`*MzGpshWfXi)2M?0<`=pEN(q=XJzL~Jhpk=s9G=+ z0D7FY0eW}_R~%5E+q3AfaKuISeWtJ+JRz#+9!d@#eLDCilq@I-ErTa>iJ9`;*A-HM zrq%A^BLUQ)Ux1H?Xcz?!lEoDf4%Q*e2zd({0L_E5LTO{0gAOIese59F=0^p&w)L*4k{`1aGd?LED)y_MjR9raR;3KQ)+VX9f=48SHl zV*lmW@)hsl&p_YTKlsiCJDSAgS#cFj4W|aqZ0TnS>qg|)O?@uw44BI_bRDHl&9H2) zKD;&LGp1j7aH*O0z~}9s{Ns=JBKw#**kYBqA71YAk)q`MIZ0&M5ZnGA%Gs7@l+7y0 z(Sh$?wNDZm#nB69)8EB%Cu^_Uk0fiZ*_*Jc$LN45sxv5XnrbiB$6BW6m@`9hCeq5( z68pl~O&>ZKDib;H^kM+0%vA%Jci&zM3kdI-+yQ`CFdxclSnQGIFm6J6399=S4Vv$^PQ)f5 z@%Bb=c{~uxqccS^mBq$@s9JktBM9*{oTHU&MOUtsN<+Q~4qyS?;$W@y$&=FG=eE44 zu?9%9H+G;TI_RNm@J_Zy-%aoiw*E<`foR$k@3LD-`Xz#Hl!cqmJ*cUBsAY~9x2tnL z3>T)&Du)l-=s*F z)K{h}r~S?~rDC^Dpzk*8eX|Y!@o&>iMbk(yz9df$X`49vU@rlCc_UfZkiHsFSpq_) zh%N<~l4En-W|-78f0WsZ(kq_=G^`h=%fqNX>0ler&%mI`Hg{G_Tu2WexA;mjSm33QdPQZ$B3#Gq3O`7Ti)f^~q?tmEs#U!#6xJ z*nNNAUu8>JYeihIYVwjV=kDNJtP~s1JZ^}kFm-U$OzYMfpd$2UZ+(>Q%^cOj(S-FoFa$0Lq=hr2u5y&CB}D- z6UFZTs=J8=7jnKWM7Ai^o3WGU7D`gY*}~;Q97{=3%_)_tA>pQ#Hyup5RVBG20>kvx zO0(z8swIQbmR8aC@*FFGS`kv8NBnM%i5_J_xEZdE#(<>h4 zhOR8iS01CxzKnr5PPQsg{*Myz1o5nB3sbuT6zAOjS%9+MvsS03{~~Ehx~FS`3k`xe zw@*16q%EC!>$#Dt*_ZT*Zyyj%c^W*V6?Z%He-49`4~qR0maU$5Z>rr*#eGi6zwxdY zeyw$+KGZX7!=+&}60Lo3_&=a9J>YZXgM^QY8nv&MudXo8Yjv4nHXGMPxm`+=-^;KT zxZ8sLKiF1YkXG|cCbzJH%KUE@B9vy+h-uJ56Q7OwkNX2 zGr;1J!SF8OE#pt}}FS6j#$Kzr^ON(pOlBY0X|D4_$kmIdG-`z$j3%jt7SFeCf z*J0X77$(iK*21(@E)QV-L_RmB!%1E#Sogf!DCo{#;R+T_ltXSbsA)0jGVs411C?WL zaSM|9d5LMaqbyU+`WNzHIwKkj^!OG~z)_g#jK^Uk)6>sDd=gK=o`4ZSo`K~;A0_w0 zXUyiS1KetzTaNlnnbn&p!s(?Btwi-Z+iEYpr`l9B!d|jOWA8Hw7b1o1w0S@?EWl6S zi#3x1SbrCppSh6^{&r|(uIPx15vo;3Tw-{k@Ih*}JHah7bEDA7pxk`e6sF3j{ zNEe#(I$Qzz<(E>5GRI3VziP^2Dj1x3K+ve$kQsp{R0sOed*1v#4` zW?z0>4+8^LCJv1|1%YU#>kvs6-3zvuXwE!-h+?D>!cuq`bu&(k4xLD|PY578azNyN z^JQ?Apd6ICOj&imXXx}zF15Lg-=$1J)mKRMK16W-fB;xP za^RO2tqRfg*7y7`{RBP(u0w1jj3m?r!L%fo!uzWt)~?&+UNpai4b4X)V!^%qkE2T$ z=gv{k<<}oJTy72v&aszap{J5~X{0L##62PDwSNNSn9B|5YP3{7@|;#av=s|!{kFv8 z>qkDw5c6)k%&$Su=wFLvX}`dqn&z)T{I+0Y@bdNBpIS0(X1GA%Fjvx@-+APpV-H}D z*V|r)*>9odjnOdi=ra!*s~13ba$f$ZptB5_NC(aM+9k^~JPaLrb!VA|yxA{k@CaYq zz||~Xpm^ZNj(P1euJw*&|DIEhAd$IdIk(Y@8x!WU&uJ~?+frMBbq2553ld!n(Q=Ct zmx&aTf)Av|3T|wZ>|T6bZHe<%i$Axra9) zKiVJUU-7(*1tIl`VcSe?Ny#OfmmhY|JcYiEC#t;E)l2<}E{e?JQhojXdjDJ?BElQZ zo|VlvJK$u{z$etkXDB}BOGm+qNGMC%W8jOhZ9e|bm$IXbo<@;)yfxLNVCB>kpp3hX+EyNRrn7ht;Sf8U*ymJm_YB! zf}0Wh&?%LBO}M*ytP5+zrQ=N-qH}Qy@C1nDhTqG*?fLxV1v>!vN*Bb*?;s{OQP?2z zfGALu=9UsO??jqdbF+1R<(QH0@`Z-8_#@^;Bf>vZ)(?x_e!W_StX4NKwC|eA@Vjq| zuU4wAdg_*VC4)d{fE+gfWG?|ibJnJb?m__y`J$y0mbDFU_o77QiR4dZ?w|KfWoF9# zY?t>77W`w0pBQ!uVE|DTW&pt|D6J5=J&UXdeC-7=_l|zWj>^-l?45BP-B!n%moY}K z!{&LrZMU2NJUz|=Cs3ovdGkJj;h#nKA1Pnze{5(XujH;fVM0VvwA2qAdXD6ttJGzx8)yL4d;Mwm0hdfLOH%En{MU|s}VG?2-$ znm2NY=_cJQ!Vl3=Hc^4*8AwKENpbgHF^jhrPT*%( z#yra$02c2hlgy|F5}D$?-nPG|hN7r86`>VP69`gT<+ZL{4O+RNsJK>)ZJ zV2ow!FA=#&#JYjt2Fgw<%UIfFv)*$-F6r%V1=+9A3ovR4=&iXAzyffmmh|djP`#i; zPvyQiM5#3k;);_tfYjLBRas@Ls|SP)%Y1Fa9z|uY3J7(`Usksh)8K`|RHn$(8b?<5BH zD4FAZ)ldcB#BtRvakQSMy)LK7*e|YO2sb|q_lSXCwq-T6T-elI%JdGMmq>@Ftif+q zBP!7|^=Ot7zsF-FA)PtaN2r(?Cqa~lf(Q4FU|FvuBi>!qbUf3v4T&mVfov6eVfuy( z`96oWm`6sMH=u$8C-A?|`74I+3!Ib0XceXg6i1R|#Eeo%GpM?6Ae9Ikxd z0N6&4U5tJA)7f;zwm9<-@RzBqrtJsKKIl&@4jN?S*>;u?8XsDJty5rRkX{F>S=2-9 z#WkmZ@9_;E`T|uvK$>_zY?4eHDdy1WwsbYrk`Qqqi;J*_!FFLjQcS!-X6f_^>3YPI zwdqOWo>Wi>JdJZeBUWo1jS-&%KhvIe?cO_!4WRcR=?r$Wt8Or5a5nuVPJ(}LV1ml%eCCM zalsz5{sqf9uzH@g3D&3t01hRH!xG;Ma8^fo`8Z0*cr8d91(aIJe2iW77bMcdvOc$O zVyOZCp3BY7xOX)mMZ}dLXXC>fMukjBINR&+S`f#9W3`pok$q#bJ)Au{nez`eN@ek< z!>fEKkUK#^B7WR#=UtbP-N6UY8vm1)4Rz9qjNpD zlL8c2ztsv9EF$SOjQ1Ns#+>K)<+j6iPh}&HUH|8`{y(!&qF42UFP5t^K&_yEoP&kcDeQD0^&E1r80s3FUIKhoNj~v znjCy8sBv!`e=|AphVlIXa2n)^Rhyyer+DNGzFUUnd`Y6LkFf+Py?wUv_H2DW{t}r$ zocIBkDSIYX$wS|mH}L>O^)A6Jv5_ea>p`L)r>GF9lV%;sLD@OciGPFIpkt3jxf+4dU1&6pCHl>|Hf-@g--nV3aA>`f`*+ zV6;NrY9TH152^3=m@C#gdTd`O;IufQ)>x=5#Mb5I+pNXrS;=id4*g>Lp%&Sj(rXWX z`wG&B=8Qa|1hyg0Ywu;Kkn>UkCQ_y$^e#t567Mc47fo*Um>62yn<}%Y4PqWwG0P8) z#zVeDr&wDt2?f>n|Ni0vcwom{kw(tH*)?_okbbcrIwuyN9` z!cex1GcW7MA|qDT7z=g{S+lZpzulcnfu(O9iZU4w@@KCKFJ1MlRKnpwc?pcM=&?^V z#;{&H2(dvj3$(!!3EXN{tNXa?DdRsbzqfA+49lgj2k-BHJZq?v`4f!Wjy`wE#+=4L zU|W=u+pMTgaW8bqTzPX*C@GBX*Rw;JP za;PDq{y+b^RF_Kv33c8pS9CWnj>01nc)xIqm2Hw?TYDK#uqtLTM`}=JE8G;~JCe+<37*#?HVp zSDzCRy)Vtcijxbs5tKss7(3yp_9T$Asfu%&ool-4QT$8z zV_POJCgWm0v&)BOvi74hLU57L_wFj;aQvNus1N_@pkCEoFyrpr){jrx&B@y{-qRL( zq(%_K(_r-eUex%>+J56F>jg>HSUHC)kb@^h*FAuamyIM|H*sT)lCs$|6wWO@-pw%r z=6Zi$j=gv14~rAbNap&6OdWaHofLEXs#|HSlc_E1D3N_;hDX?l6J^9HWh{XqvjjDd z*ZRNRf1`No6v=i>WIqA0Zxi+Y)68Ni&z@5&09AS_l&jQ%6cA6(T?c`%3@FQ#Ea0F@ zKwVAo2k_hvUx~IF!yo=yZC>SqIO6O*QQS7&U9?NM8}ULO znh_OZ&Ra7J4!gqJf7z2vCx+Uoq_1jSCO=!^L!uHQi1BX6r+C0aI3Y$!rEM>Rx6?rw zX9<~n$H#={SDd-=)3vkY(5k9^_sMlLpKY@#4aCB+`Ax;loNYHkvOwezeEh+nAmFpbFD|2|A}412M^k9c$D9uw?`9{di@#`*V{wnCX@1S{@@6Q^JEojaZfUw+ zd+<{xc|nOMIn2SSK#k&xlSrrm#fyUE0HrO_Z(~0zzMCc4o7ZS)=cn`J>EF0N&yl

Bz}>w5tVX6@MxO&gKIqhFx!3BSLyEevK`hsD1UG21W2_I^U-CU}Ie(6? zm=Utgq(!W`8%BDM-JOkCH)y5?t)Nuwf*X#eE}$>ZXyrlx97?oP7EeNyN9ejrlP zTb!n^6LX3NyK?vY?u8%`kN2_^R;ylLo{}%)RqQN3bBW_9>vljb|K8H7>*o+}c`PW- zZ~3Xh&uf!5Fi2a*JB8VbXfT{ zUY1!607fh=ZYc#87iN*ab5xbIV3=NR=%3e}|JheNSaZWiRb!2niRX+gP&zj?joW-< z=@Hx2O2WjHPBWd^`DZ`Yce<2cq;Ta$``)2i&l=OOewZsMqpc2`lyXoy#I*urIK?Bh ztjpm3qhO)Eh?{?E(Qt1vZe)~v!lbrvmd(v?3?y?8M!m_=Xldu*xvZDY~YHYU{w$Jq<{xpD`)x}JioOw3gI)Dd)) zXiG!{To!4IR9k8lm5O$A?NHNiaRaZhNJK&?uIktpQ0f(8pv09-W{uz&mHdKW1qM*C zoOQ&E^*l7C5S8K^1oA9qmCa$5AOVMqSWy6&m_0~(8pyN9Dsc){qyt4)J?=#S`2n7& z2p}5<$d3h9FoOG-yiyM((>27I835~OX-afRovVdf8~5KFkamRyORHAPw3dPSnF@RA zD--Q2d6EJH-p+m}y1mS7PXopMRdopEI^by?_XC}#pHevE^_ zmmM$m7TGLnA!MgBR3xlki~=XLxtMD7vm8ONPTTKEfa!TKza|3Ha!GN<;Ik>u*B^E) zKJqPO|4VrwI!&*y2za(7{O?w{YXa|%M8r!~ggXW-g2;Nv!}TvH9H++2s_myxpg3_5 z0UKgw=5oFK!%u)EL5N@3FaSR^aCcpKFE`4XH^3=S#r_lly#u15qFa3u{SeGP z_QIqtI7;euS5IKL-tL{`?nxl@e8q8nY=209|KU#v)iPu!1o{ zVdOHlm`KiTC1YtpX}k19o!a{S^)L{MYRu8Dy{GUm*oK3)oHX&A?}qNKE@LWvnoZ$+4ZDd^f& ziEGy@)9n)xPp?RoytXV$%NS3weqJcevTt20ckyNZwS_?7oBWF+LbekZ^ygwpRW6|2 z7?4gk&;Co{r`OyIHL_POWbeDmGtYqAT_F<|w>H0pzc_|y7)P`)OfI|9Ky{9e^PRuw5?v{K2vM?NRUPaH%! zN>qYs0`+xcn12PDX(RY(6g`=$7E(U9Ub%5s{wJTLuc`=Q#HD+ZL`5vIXf(v5n~3wf z0yYT zymq#K33xw!an=eK88)<0(js$xvP1U{<$7KB^@XI&r`j6-cm)8*h|XxP01wcG^epdd z2yBbbTPT|v%`QdLX9H@ei8c&? zK_I%n6kRPyM1!abjl`bFRlRN%MO9uf4k~B7$!`IudqZmK#ffq_)4!_87d9HL{r;I?QXpXZB_99eG>h^lAlAJ5adE|1zh-E zMXzd$F$n5ByLi9vwwpc;8mC%!)8#^RDfjrB%1Mu8xE3PRAK~NPZCnn#(hlH2nX5jg z-q^X?yAKdxW=qm-E)=LJsbh_dK^S1T(0Ai>Ew6qyQPk3WeKC>K~7%A zJzeROH|J^@8h3dv9u7bLIefeR{q2XanzsuroBpI1ZlFi^N!`~F>3ck|e6Zf2!GJ>9 zkX0Fr160!U!h->OS*u&}*L~fS050~@bMD?UmwPo%2{;*Ket$$bjd5uZcrL(+6WPH{ zyj#c>Re1X@|F)oYO@h^@dVZvUeQ4r$eavyN@zku$8O%u_Co7xqcViC>9WQy`ltolG z7K{I-iYIpl>t7C?5Hr((nUL%wg(Yi9L8A@asn;Goj|0im`A;J#`jgE_N3=sFI_bJ~ z`4%e0;WDM|-^p65mG#Tu1$3yD9ta~<*Y2i0_0rv-cN_s5baqhSPiCfET8 ze)9Mk@nYlaYG?vzg4jstALj5Z?aZiAAcJ)nU=7q21(cn(J@L5X8PYX-=z2C-HTi%|G$3W*Q7^`hh!z#i+fdee}3R73xSoLENF-cl^{VfLc4MC~zmsxl|fFy8?# z_$V!q3|RsUor`hY-or}k^-g(jA|0Obd`Rz;9LXt__m@pF6sJ8roWZLM1yp|wkgpRa zk(lF9fz(!VaK!v`kO=$xhYvF80@bP*t9&*CwN+IUjG3kV$uS}p_nX45W7{# zDNp?F=hqgDH%%*VkW5R+0HZB1gaClN!9l!#PAPFEPc#r+g09HEvFObKQvz6h++G|t zzWDd&#q*|AAiL)^ooTSrG;Uhry>#gQztw98aq_`NdJzi9T{>nF|OK1FQ;9pWaqx4Ojk)O^uCRNiye4hL)UF zELmX+dA>FIYTt@D5NDZrZuIcMJ-Rg<4{Wd+u8KZ?oQyCAHxL~3y>^FR)Xw@fvYzEZ zr4^)c1-=y3@1Y<3m-QZc7C4Dphu{t8aQ1Vi-U^S}jP|wf$erumNOd%ni(%KsdQCHV zGaQ(lFpqN@x_zE19jo{<0(^{^12J3vBxe5z=HTY7r2Z}TLlk!{7!ps< zKadjrzo&gmZtl%$ZyQU=tRb_)I`N2n`U~{*@{HK@HJw#q%*v}9saL5$He2fLqH9s# zVmTuuQ>oOn;;MKVaZL}E9H`ElKiprk3bb;G`+xO=AaXQEk4*yC?|(qFy}sxkP!zB# z)&dl>|IB=2m38WKQXTN(g%X+!j2*FG8-#vRQ?)8JleDG7(<_x*nbDGp=#YWYMrZ1G zW)@;>L|Q5&L~VJ$Alfr#yZy3!Cv9czK}KM1jZ5#BMQSg?<@MPCI1<8|HhSqj1drOV z!)-_f`kE`9II*~$y&T8~8VwwpiE#Gb%*@^-8ZBxaGM%5^{GqVrV#4f)y?Nn0bMW0C z7wC%@`;@Z(C?PFa!Z9qOwXY(*$BI3iMbn(GkY8E0lM}=Q{IQ8QpXbWTE)s8S3U6-T zQsxIN=Z!_uc`GH(AilRxRup=>Il7rV{Rqv*eylq8(X{Sk^V?Z%z^7K_PY?TkJ-+b? z)AH%_pHCRTu42p5xewe$#r;ohP+~K}cpB<$3_@e|#iSDGM8p$#_A@y9b9XY3Z|~2m zq3cV}R~9w3%Tch!aO#F~319fzA%8DcaloicU|t2QGM%-%6eNfSJ+BV>m!Q?;I>`1z z|L2Vt=fY07X8yechwrr}?{zXEgfzSa%=ZTR*^rV9-^FRPF|0z;9O=^9Kb%ozt%{kS z@oRAAki)?DrZ+6|;Vh?c(hT1FAXO*cVpeWk1Ek(SoV>cnV+;{D!J&kJ14&SG8O3UB zf8t9RpCY+!6~QwIm$M6`-ChHy!o?YZtEj}G*W8k}VR|#2O0X^&$H_ZS@X6`C*IZhN z{jZqV_!GcSQGdiu;ruNCjr`ez5l~Z(RA~$dVvd+JPK2)+B#mAWH^K9xZ62@HDZqQ3 z?=0)J6-rtZL*mptVR_PaZMzGzOSkxx`@3V-)`zVk-=Aa1vb;2yjNX1RLKSqnGbwOl z7&jUw<5V+Q^7X>dJqxF|2BjKdbN8PP24+TWJ>P6UW*`{C9B?O{{Fm>Zmg#QO!F+nV zm_3I68z)XFF0V=n8n$bZl7W>LfeaWjpvFeWtLm3@-_sOqb-1rCDbW<|hd=`g%`F4N z^31_dvWCEIKc-hvbFFBCcQ6O|kCaF?PL+?G z2C^uJt{yO}TwvfP1w)G0Ce;JTI2(aCVeJ^cbENWQKG(WDm7Xt;tsq=Rd*AHNehVEf z7$TLFOQi$>e3H1Z5pxclBA@9LPU+7iik0@XGZ?Njy!z6PORp}m#`2;i=)~EfS~lNa zo_iPw6%h!?XEJY{M`l@DFpzh!2b#RD0z~Up7J)c=RZ18Uo=O*(C8+dlIVLnCHClgo z*epk7K)5VdUwRsu0C3A@hL~4gR+`&b?}Rs>59x`7YAo2_sbbV6gV4XdkfrxVvw&H| zm$4XkRYMLy@JX)+fP)Uc*WA>{)7_~8LZ?W%6mb}+GT$S;-cgAfB99Z4Pe3DOry|c1 z9kED0E-jpuQs-hEn{vp`xcY;>xPMkH{V;tvv$?FTZ|fPjt5Bho@}Oe-bLt+vV9F)mQvbY%BGDZr5+Y22eK6=}+MF4~6=x`Co*4c>y#MmCQJA9twT~^_ z;M3$eERD;)7kpFByiy`p>dJ0l<~m2tPg6-m@gbFMwbZO zavK9fJWgJ!G`{m4z6cdeA-K&@=ar!pULb0qTl(NeMc0FVt0%fi%R7K zpE&hkY&5e#%gEd3d?a%8l-|y+uAQMb7|W6(-B08;#X-?YQ8?-x*h=NO^GkNfN2D_x z(Xg6$qFe3EZBK20Qy;jEg}DAXpf*QErJq+-1hTVYE##@HL?|vaHN+eiTO$(^?5n&u zyTCf>Ti6ks;Ta!?{IenRl(RSk{N2y(Nf=b+ngjhLG^c!&;FX+{Nvi;|&$-Auqq$Rh z!mhTEEPzO9p4&QbUp>{~p}1KDI|c$$DJ4JCxsqQcnJj0Au5_;R&GWR1@M-5#(ax|B&pkXerU1zW4y^3t*lLCAG*3D56>wM|G@j#r0UhYBUG9JSLPP zmls&w1e$+#BT`iVj8|zefE@AjDIis94`4B&;&zp|(Vy}@wON&0e-udUg7OM_X_yp) z93PH$R@+!9)q*TZKaTx%e#0NFG+h&UKKqR^Qu(MzJsGWHF0W{d1(nRS243ox5Hsv@ z?s1;GKt^VwQkm0=MA`Yy+KZnU*vyXBvI28+t32kLU6UF$3G6jw6JE^rn|wHwFK|yp zafnp$wk-GJ#Zaku9puF0l`Or7%fiZkYpf0+mm(Nmw?wqGOPG6slrad6jAGH%v2Knk za@uG?E!_^{OGjT{luqqi9S^^oU9j#Ys_TB$maqCH%w@0XzYs_U7+e%hCQ(rY)(kx2 z7MD#(u2Ig&i%lO37U3&z*$uoXoKBqO>NH%MRZk6n>;p`UZ{*Rlt20Rd4$UMG7GTuU zM@vCE9_SZUTc@ABdXan+nq$dS;VTasj}b?76`2UpobGTtxb-B`9|-(V17tz~BT-aw z6c0H4zXb}aD&t|pNkJlyzm=zWm*S&YZe=ciO~{#uxqCYH^}l>9!Lk?mIZO&@q9MaB zVV6JkMGfcN5@|&z+F(8UpK+%sDyC~%3?r{bnWXr0@yRa{9T>5THMB_o3t^)!q#6yO zt{HUE9Y5B(hNh`4YmYOB?_oFi%*(lEX-cpA!+9qWiy$1dEzAGH>6prDJUZ0x{l)`3 z5OY%k(9d5s;7xw1rAlL4Y4~;Jm}>o%UKjqN)&5JXU7xH-x9vDGa*St@MUw$~98u83 zEn~G0pB93$*oTvuV$K~WUo7Rg{O51D?sYd_>~={$DLx zLjJ)EkRq>p9gBbyR0NS=x3JR``^)VfXaayFxPmaTl)}+R0S0~_JMN|@hFDx~2vePK zw~n3Hxlk~Lszctre#7x<@BK!WaxByK@SxNqUTUY1uh%tAw-os0j~PRuRs0Jr=W)9a zT;Phv%6n?k;f;R2gs@T56=F&}{b7^pcUIS|*E)stY%P6UWB(VN>bJ&)N6HtQ(@a$* z6`w)hCC5r6qjr75pDm6^Kff3~L0cJE|B28}6BO`eId)viuY+?$x~7$Jb~_qHXDD}l zUl9G2dvd+#&E}=GjlI3mQM5wsji2j-U100d&t zfbxXyS%3Crxig7XE2$%e4wbH~M=$GlrQa{XiW7;Iw!W;c5lj~MS>*;;<=ezlr#qZZ z7{$uDJ$97_K>5mg5_<|p(|j(+nZjfUO0I?$P2ZOwK9LBn&rtnTIRg^n$0&J+QjHUn zMHhR{w;L|!HBA*)P1oJF3W2un3&l(H=~A_%7lA}sP!x#c9jP?zWv6SLGO?o6eY~_s zj-oo+_Y_A_$AY7mn$M3*W9$>9r%f-0CCU|plmV0!jPyV9BVeS+1xwoZ2NUGeGM z@J~R|y;{*#%!jnQi4|f(bwDBLn0h%Rw+v{h1LvnE@XHP}WN|!yP=+VvkW8)wC?MIP zWvmp!WIqaXdJi;AO1>46UzUNpTiKu2hJ!P3_JWVuWZNV{({JHmtlJtDjG_!ga?O(5 z%>=!ButJ|;JW-%0g#;Nz0PSOd&BE#m*1#?X-qv4T%M%^3#-kFC>{PJ)t!iw$kL(81 zYdXlR0!)ldvU@AJC}B`Xu>}^Jr0iR|$ajykCJnc=1)hqI!L%ZjzaCD-IcHzj zxPuVv`fp?86qE??k{M2bgJW#Jtx*c?6HnE1QH0o9BxJq5<$Zrk=A?NRTy|%Fl;$j( z8|F}803RTYd0rpmY|9Y1&mYPKw3(g}O>Wn2Qx)+L;VevO0BbQE1U1G=zKPaao9rbT zXnAO41$?{B(r&N2?UDk`{x8gaYhr30W7?Ht^nqH9p=TSCp6#N*-zKZ~%7P|8NF#7lqa!&U8<8%9RbbO&Ag zuR-HEMA5(tK&YCt%dm9f5>!{S;m~=N&wvoF3BJm#v=Nz~(lc~2nP2L7=%QruNfQe%#(4@xc+w@T~YR^9DzufmZxi{2ic>d;6HM@CTWK%N}GDNE~ z?>J;P%>)PArU-!YMwcW(0CWVEcNGHQbNngi7^7+_Y4cj-kCnuASY4816`5Szk`ldj;rjEEQxj7kaM(Hj)z)*fVZ@?q7fBU}O z40;$JjkpT;Dd4mo}C{1*Q-a1xa_WKTF^v-QB%bnL>vEwjjVloJ8--d&aBP zb2yp5#j;tew(XY2(RwYL1w!K>TNBXZ z7dm+h5MDsi2m9;Uc}4F6*y@U$NYJy@vqM#$c0JfWDf>0MNvH95y%{wzZ^X=~!2H&qVUU+@|fh)4p4dU@oG0InHz&8f?2jSMDXiY;b_{gek^7z$n z4VAYxPrv(~olHr-0=K{OEZxEP)o;IZDu6CXa-VOF+dnWS=Jwk+{jeKN_CeX1g$z1; zdW*8s#lC5Oit8Z5K4eRNh|;o%w!I*b<)9n<;dq`+_+e69B&6b(TZNG4FfGYs4I+B7 z)D1FnEH>Sin#UD|kYqJssdr!F?~~{?ZKvJJ>95tyu2Thx^0ZDouk=opi$`_!M9p_{ zL1B4r+z!R{^_RCkM)PIE*o3C0aUDp7LetWHYSaTtbQb~&1z1p z(yl87^*;6C)8W?rJg3zK$h#v?vmZv8>y1XC>{w+n#3aMTr0P-8)r8ICt(+69cai7b zsq21-cKt9QJFFA=;c5!st&3DbUAGA9xNX;$9C5kNM%%*Hq+0(~nHArX$w%n7~ z=l3mh>mE-eCv*_jx&cqRiW^#^*M8Sn=_Qxvgan^t@06UIE%$6>Ke7O)Cd2EBDp9`D zrN+j7)-B1_V`1Txeg%^(*2&%bR38I_4qx_HzT?6+b;X|brh;nBrQE4juao@Vz?Y|m zycVwSzf3f&65L-oe($+yct2-31xguStkTnFjcN;>F>&28JlJ{v$y)5i?zX>|_-=6K z!Juu}qM@`diCvH7T(p7FXpYFc0F~4_&izkS=l=y@1Y!{m(;rMeCw_aXb&{HMCN+5} zfobu}&xKLY(%Wc`%Mh`>=tv-nX(0QrN)qxxSD~&fJ4b;N$2a_V zuKMWO>_ol(D){AV-7v18ApNOGM9MaKn7(qB$@w(yOJ`;^I7Mq%%&~F~$p!YvKZ_t7 zR#!9OB&Mx7_0=pd#s<@A9GQ=X%+&=vC{s1V^RB%*Elc@6P5BH=-%Ticck!;-`N)6A__G$} zFBU6QaAYDnA~`)#UKU8M-b=-c!Oq|_>+EN#=_u0}*o<6dYPOgjd3o9gJjO1l5yYSY z!B%iSWg^wR9F%+;DZQGBL4zPrgX`%q`3B$&b5(Y(TEUz%$f>@pCS7PAKmowO#N+_1 z_$xjI$N0aueRqjSx&M)OSAS9bZyUh3*&vo)dP$d*77%cE>F$z{l#njLz+Kp-L0Vc` zN~9%>B}GL=x}_UM!NxB?p1Mns5nk05?! zXn!?MkBOHUiu1J`c#|vbJlqcO8<0%2RqeW^QhotqksHUMS|HNuRJv9d8y(tr!iv@P18M+FakC3_ceEuZU<%Yg8ba zL*`0xEk7XhpFOQG)^Zs`$ZsV%bf8)JsNfG(p@vi(M)imoq>CY{>TOaC)^{Toa%w zS{hua_QV)n6jf#oRbuBPo1%?%3(WJGsX5-IQ;~-LPz>plEx=#Nx!E;MblPDl;AX#A zz-x9p=Ljm6=z=|%nz<$njg)qi;Hz;mX2nkuh^Yp;b6#3rk=EYD(Rqfz9`PJf%YEX; z8^6a%i>?0cbCEm{v~O94INCvsw_9zOVr4SoU`{YDvj;GXA(Ld!1l}-tfo8;JIyy>j zUe9DN#Y<#(yFA(JNN7McYdh$BUe@jok2(EK=Xx%qM!7O;i(qatdv|6QaZ2WwJSF-& z<(GfrjSSf?5uA3YNW!&tRiwxct5otU?d7j?d6lgSXK1nbiewpxMc42O(S-ESY*!uB z@~)2eiRQa4_eE<9s6RS?O8@Rw@wF)O?ULB_g6(*+XTA=$-bBm}@g*Kl5lb>s2Vt$N8PyXAH+&9LgS z*IG?GOh)u&(~`MnPji7Iqz^mx#VF}`@`>qx)ySTj`9*m1#d6r$`Z#b)mX8b+pf z^OBmvGb@1m^(H?2^4yR0&6JpY8J@Wc=g#>YDI3N?;RQd@X}>+ahrZ5*P--v=;)ANq zzW_0Lc}dKTN7stR(kFYgI$G>aunvxW(UWbZKb~FCG;|~qv(V3X3t$0f4m@N=3Kl9i zLTJW7S=q9v6@udy0kadIzMTm6r;TWf{~=Fcd6W@;uV zYT&dP#j(!2_2E5}(Y_IG+*SpFTqRmtk0U#?Sxe6LC>PYBGG@H3^?9Drp%RSsS)x}dT9*l050&X_2^#e7IUaV;(lebj z%CQh$nl+t@D^Rsm!iLi|8G7c*-XNHkCXqav48iUJU3qA)jjdL1-`#G;;Sa9bVw%ZQ z))1BFb=Pj~O=Q(-CrE1wYsPkroY8}-Lpbl)Rcg4?Beqpdb-tiI)$ zDE*Oc`|cVqPydtN8`~5@t%`RDOlRnH64s7XEqD1N-_x8w&SU#3UQYC9NxTegG6T4g=^M7l30wDX|%^p`QeNk)I;$ z!|{Uk@rmL>XfA^qKH!aOm+k&Y#13727l-^viBlxsP!AKmRFMD;Gz|I!RoD1lt9N&< zO187w`x`j<>^B~*=tjnNVZj_@&y#IN$@Hf&9Y{efVyo=0(^G7V+zLp+8 z`pbzxOf2asnM4(9Bmu2rVth9A>5^+mX)G9m%W10T~+4^2R?0)SYp*{N)8Nk2S4Gwd^c&y zg`6f*;yhR9JTFb#(BU^UaG7TZ7;2O+8Nh1(BZ$mbP9 zSX5S(zizfwGJM689P9gRmDf^V3)+3LYTHtZix>o%A zYXw04d6DnW`mVhO{ zR<(#Fw&R798KlsC795M5+Pb1j$rurB%%H~=d4$vkr@dir|6}2s$w#n3nsV1H}IY`5}I)tw1G9U#d5HH^4O!pO?q-_ zuS=!Ja!GkH=51*mjGrI>720q4?v(R3Z=Y!Eh3`Fp>yPA(dRzY>U^~6Fr2zCeinub8 zL7gsy(FdOEOwAXSiW;I{p)qEdF;tl~JWHkz#?eRl+&(o-@MmAqh|)8uVxfI!l4*1E7F9lgU9=1i)c=PO^(Ap4bmo+08bj zf$7DeP9&H<9;lz?*HdlfMWf>#eXs^SOL@VJ%QqPJ9KUAhgQNlCE8Z}4`7-7t*M`3- z{zhV&tYcacNB<&~f(YkyD;e9zf+jO$Fi52`3PlnEMaYkxfgG-6cKg0cwY@5LGDmnH zrzeor8aULop~dLMO(JKu1Nl7d>eBaWuN%~#U%GY;J@w&a$Hq7Hxp)dM{B?Hl7TvQx zwN~2zD}Z{uAabC+t)_iZ!=tIw`}01^Dum#HSv;fc*fI) z+Fy8vM!ai(Y-=q7K$<-}j~64*=fD6_tk|cf8h5@lpW-f+AB(5+mje0X>B z!DYgHGc*BE_1$cFQg&w}Q0h_1?kfguIuL+{aLtu0j(z{_;C+4Slvl~L&hS_$-^aaI z25vgz6_YLzG(Ju5@jSjPU2V=DVuazV5cyi7wV>1q%uaoKUG$N)gtmwJav0_DiPHt` zM3=xtOm(U zx|8(SrRe~q={TZa@VMF4n-;L!9V>z$RCWlSHfv)W3rA>l4G6f&2*Vw~F=%Sg7y#$cLxA*tj2|L;=Ei)P167}+ddhPm;!5&?ZW@e2hdLBDjDa&>uPq?sJQet4fyAY{pyNU~ZT zJcDPIxsz5f_gma?bYF(FztnyG(js>x`0hx#g5&f9AbH48=i2uUJPL!OKPopV?8B1G zU_GC)_YC*s2fvre{4h?g3Z52v$?e0n*YM87w-4m|Zk}yq#@sd$o=?E$+lq=4aPQwW zit78a1KGMh@hm<%Al3VxlSDC}x41Ft7CzGJcxi=M>4($z=0!k_p8$Sbd2Vxbr1ZRy zg}i_QQ}N@`uQ}Wev!BabLNi)0U--?qeEPou=v1aqZ?Ft5UoTaDndDx@(aQmfL59`+ z%$Hjhs^h114_T~6kj3~M(L8t+Nn`z3W4#S5Qzm-0@>3* zYygHVMLQ?_{ddiA*l@0fmY6~Yv9TeLjH?l}!)TMqfui$$HH81()mz5-32I8i8W@oy z#Tz<6Z>4X-M#-Xfq2g~%(CWa8!a^9QKE>UIhqW^fXYb-ycwT5s1T5u6Wj+Xe-BDE4 zCt;Sz-<1>-)u)v>;_KV&o8MQ0)4uvJLg_K*ZQvXBlZD%ToOhgomqN1H6ba0yNSz=6Z>07=N9I7i~Bm~Z(-RqK@uQXcmP1~Hv~urXaPvU05AX<90Eu{ z(xW6l#z=j#cF? zZIq4n_>GQ)jn1Uarzx9VsaxIYTRj3Iahwq+ze&6!>L+j_U_Aehhzl=ZqGSPiB*?Tng>}dMM z*BRQ^+5T^HL*Eu&9WTB)ULHO9^x;9i_hkuVh|2;YS`~BPLkK@x{-%o%4`1j}6zrTO}o&Ns!_t(Ea zKmPqbIsNtZ^vA*9 zwJ~I+&VRYad#TcWvCMg)#D2cecD}%B?t#%vmcdM>_H??|bShyg<;qmD=2VjUWa8z? z1l7s-OA}P(35w!GocwsK+&KB-N0Ri%dw~B>*Li`W2ZC4Md_~d{ZXj-6TUI^*Ml$Lb z>xZm0fH@Uj%#XBG>ZP~1PGhZ z1+O*Ke5}$a0g$XFZa&j?Ve!RXw}B<3visSo{)=_}Ol3M4DYy0c-=Ck&zBM&;>`(u7 zIClJZnDfhW9U$~-G3=uQ9Z;N3NHNEpH4_B5ailZ5Y|@;w&8&NcPz8YW0(Vw}%@6Ht z;0{ZVrjW;vuWR`~ss5hl7&ru-VcP9bqzFfCCUX0crOWZj5x#_x3@s!v5vwaySH%%4 zTL;Z{BlQ<@yIK2I=ia8YyvlQm+}(WOO%dP9_pUA1Ex2A&w^ev!cs!mvfY#C%7*uMo z^&o1i&MVjN6kg@vf;JFgr|D1#-K{ky;N1sOE_T*V`*&{EhzAxot@I!g@AKOf z`RDcHgq;)yzpWUhACT4=udMt6(^NlGuRharAPlj^bEt`w$MQ#4W$AzJOyIP;e#a}% z(XU{e-?Jh~tVm>FZum8>l3uSkxN#8{bR#c%`xWmO-?m&h&v&{C3rr8r8*g`=g1x-y zA1;-$A{)mbGhLJyAm{&P_tC>xzlhq(LPNj$+T~Y!^}U%Hi=_9ODCx*?A^pcUSNHrf zpT6+px#rB{V=2&bl?&w*jEy<7=fz=E?3CxHZ0Pz{CqjSnQFvP}+`Qe)HffTc&StCsJq06kUY0y@?WDEl~mk^r2pN0yY==?K&<^|D~{<5EcopjZz$wv<31Z>NP?B) zRxrEF+wL^Gjp*X$XT(P4jvGNE$c)etWtAN~I@Yf@mxyn<_~Fh*h=dzL=LJ*-#-Jm( zplaT24_2wcK~?vW={slodhG6f4R<9A7#z3s>bhy7aLZeW17yvExdJg+3miWSDhoW!<^`59m7TRzI z3d?~h)Gs^_89@;XeQQ9tMbvt@;pbE^08n}-RJL|X7jDYM$D9;Bs6Skx6A+S_YI})O zp%nr@ldyN8hn-bjrB)a@&Wy|Z74y8>yIUR- zJJM8VFB%l}7Gk=a%VAxAk%iV8`&JH#Z6I)!1H0m@Fe>E9A^W%FtT5hT21IuQc zj;bZ9iY9&|oIA;2&ebw&ylUbzr;^zoQ%=>J!aBp(F3oV+Izd-I=i+2MRQ_1i>voNk zaAFDKdPnFLaT1BnU(hve0>=XKH>?p z{C)jfLau$cjzd?@QJBcC!p*_G;+@oF4}txrnch4HUu?ZBRGWpZ!$_@`gKaNCw)SgzYd~$@jTEP?&AjLv^{l!|^NycvgEYn0*YojP z)k@QiXAF-cw!=^aoK|#ZPpW^__4eMSl9&g+g{Pmz6xch6bRB*>>{GYyb90!`2b1a- z8SY-Bb+<^Po8lKP68|*xci6lSU_l&c!=zU3yDALwc*-s(*FJ@}vFEV`Zb_jiod>fL zAxOYbhx+(xM7ks+ts;{6i%mVh zdpih=l+W=-m4H7g6(XadfcB>E0L7=G?8rY#Z4vx&CmT;HVTR^`T?Y^bh{mFtVf8!l z0Kuk-`N2OHU#oJKe)tyYoE%3w(Jnv0=0J74xIAx>(9I-h`J&FU#bGKMsy`}z4VQep zo44pGiB{~WcRpk>WlzDn0sz0|4f$!bG0EOiBZi1|gF}%v*|#Ap&!oHfJU-(5?@vpC znCc3X9Y4xcwi`LbcX7v}KiQuuaD;PxDn3lrRNwEeQ8)qsBF%w-#+7}Z3yEh!LWKXm zNSKV>t9j@z8*#-n_kmv-G5prV!gB5-WbWWNMPDVy`3hHct~{Mh-gUQMIyXLe+Suty zt4u2Q3Nb&gVs@WVVR2`5u_LcsLW@>dPj3+_D{Y^^ZdhEh`4d5Unx{&)f{)iiay8#j zu;72hAr;KY;yuHn2+O3kSQ6oJUl`dAyy6#0geE^3bac z@B{kn>P7tmab5;+>H?r9XfE?Ih{@Q^7HpsU z=pHw=fgv&BunVuYMO*9?+?o@tTj_!-qIDI#$09V(Awm_07e2lX$DBmvh-K6*TWoyDr~iIL>Vy^=#G?#k5N)3NlFg5`-{bG zn;8i3XSNjFZefPv{Y7ov@#T@12KiK9vG8JVhUwijy`(*ybx(=_*7=Fc22gWt93F`H zsKXGKX)NYrMC}!@hkksU)#cbe7N?badcp}d_>dQ1UAD7z5tDwFfE9|YV4_W=mrcLi zL)>joAk5)UK=6tabu1UTu0yY2WhD#6Y(u2v zU>$H)KD(HE(|H1_7x(R4zRZX}+yGU`OGK%rrp8-me zW^cni?Teg6zgE_&)b)FUcb!oTKa?}Yg^*kDwgw3T(Z__#I5*ZjUJ{t^_ji1vmRS0XIg#%iz2gXjE*0|*L|cjlF`jRK?ga}&LYyBDalMB?u1YNu+Q)kk~CL@ zlxMC_WU6#3eiG$;u%S($xq~yIA$eI6h|79swmR)$@twyx5a#T4t!&xv(e5HavCVWO zeRMFbNh1%u+{R$Xs^~CM zKv!IHy?$P!zLjYi^amFz2%Ycio1$tS(~YC*DOAi$1D0aZ^|bR;`uzvuf)K>bTnGPGy38*O*l1kE=w_e*79@m4vtl!M zSv=6wGI}HF$UKAs39L>7g9NYXo@td>V4_I6W}zX}E6tGe{c384vo4@yW!It1rnII7 z61xKRIHI$ZhaP4_ZN>skLwVD`GtF2ZP~v&Q>aJY4kh(YIlB!(E8=iV81Du6M%L0zSX)kPxcm$EGD*(Q?kJJp&rE`Q~Pxi^-OI35Wt%J=l zdn4B#Wf+0wt{>a;X%+4>Nq-T7KRmh$g(&b(hzs7nFu%t#g77dQP&ibjsw)w5qN0y; z!LCt?ig*M+SDhNnB@4;Pk7X`1&w4*Y)I)npcAGtXBOJ$!nr#7o*oLMi%hvbQo9c?_ zek<)85OK=5`?b2M1wdDKffc_?M|61nPuybYX&K-_u!n6UPefztWx6&$NEb88ueniA z&gsLSAh8Ii*Wb&gk%+5uV5v79xNx#YRJtC{x{5kdnre)}vr_M^`B+?Se%E3lA8*z+KZBOn@CX8c z-TX&D|3L0=Ol)M%u`d;Bnxuukx`7yWK#%F()ZE*kx=b?_{J;_h=B;guYjNyHxz^3)|YPAHzkXFsmr!fMrZdvZRcUVw|}cu zsY}<_$@Ti}yrSF5MjB}ggN_F1Fc4j(BpQD6c3;o+SDQ&x6~}U;A+THdqqyYo>A-|< zkJoy73i~vng0POaEQfm`nOMZm(-aX->mPr5`@4J|O+L7I2@T}|2ywHj`{h5(XwJDF zHX#Z8dgd6L{`*=+w-LuG=|ycXjZ;2WhCj69D!Vm z1An~vNOtJYz9eXu|0PG9P)G(?j9I-UY)>d{8{{DRLR~}r zuJc?j$^++7*MoViX<8Q8f=6CG!bTkp1n{Ex>?)Q-U*Y~(9nbe#TPY>~sBqLuy_0k= z<4n0+wd|q{=_O<}_5EK&LefZ0V<8&-a<*25jjiArn_ukrnwOrh>4xub6ontw3K%~s zjseHEidEqtHHcOfP$m#0ht#uZJH4Fo)R!MrUgSH|hO>RdGD*BW|1svzqsU8rO4o%3 zw9%Sa76qDMmqLfHAVL8<8}QqEHmSs15$%XY6UK-J=V5NNr}f6W{{}f76wyAx$QBwj zfaju7XZ@k7$R*>ao+l#G)^s2O-CZ8mXwt*uMLKs^uh)8mLG4NXw{HJS)k{H*rT!gk zF$V?!5=Q4)-rpMQpp#EjdEEST3_Sxga{!hvz^tC}xIeq>#?`cfgx>0eTyDI0_pEhJ z*cqJ!XckQ#kNuI?d-+L1WT;G!i@Y?4d?5k_QwEnKK;*lR>Z!fHBLuMkgIYkUr9vOn!8urj z;d0-y4pjuf2_e_FxI#rw!I{sqQtdjoy8EH4P>>u{u2@0AKf59gszR^s$p*V3C3+h@ za*^C{Lcj3y*;q934oAcSxL*Fh;>KrbjbEhDSCby+cj*=C-=@EsJ9@@Y?tCBy{y4gz z_AxAKiL<1q00_+?pf#LeLQBJ{LClg;)?GrMf%v!RU2r=1e!<(yODXj-@rGloa zdrQNRmcT1M>rmMt%wHr3{q_rxCHRsnJZU9vv#opbEf1a?!#ApZIrH^g_*;WM!-i2; z_(f^irAFXvoW`t?%j~2SCt>AYiu0{`^>~Sb%~Wq4nO#WxWvHb)^z(l>4PobmaHyU6 zmTKdGw01+*T%DOVb*=D?MBdy_)tJY|2v!t0w4-mEF=nu%YQ}v*45X;qh&1XgTp- zUAn~SUT9>S;gbx6%T2`ZrAo@&C;A)rr*0d4w3dDm=ui94<{7ale{N;3{309;5Ozh7 z1b*cOBS`lVV%VvMycgfzUwR^D9wpqZbZCA9c7v?$f{_n5rO(7M)Q|DyA0fJ95dT@ITRK}D z8cqhl518Qhj_6_l@Pu!MzHaNmn0mQpFq z)1%k@R1}+pHQ+n%WVkuH#)u|EW`lfQc%Cg(jeQ3Cy75{-r3~=z4;VVnNxHT&CHedK z@Fg%)}+X2 z@71)WUuNx%1E{n2Sz+@4k!!A4Ys%Fj0K{Jr@QOE#cSRF;v=DT~{#((-JiAmWEXOZ* zRlLEkc@HxU@pqCJb&f0t#|QWDuhV6k>7M3`JU*RZ&jx zJTKPY*1+piMv3S0i8^Q*<{s#S>7UV?&}U(vK{iHoZmLz#3B(+gTVi9nP7<(+3_^*5 z@$mjc(=@D-wWXPclG&4bmB9>K1~U6}>u77m^wlS{??@uNFBVO>!!9!G+5vEVqyY2N zdP3gY=|_1#|0O(swEr zS*FOJ!kN#ODjn-Z=AX_d7BgtskUi$&8e2vS*Q7dXeBM6Bt%QP*<$FFKQ#j~`qb}-1 zt#oCRu)M-HNX2u>w52LMxct|+eau*kL226Q_G6_v#no=_g~dgNekRQo z66HCFw8FZcY89xuT~@^C$8u(N^4?I=f&M%?KkmWw{KER}>gT`rdYMMB-!F&a{;7`) zQdrT(vVknR%?$6$ZlbvR5g#`eMj2F3(1Sa=i!7jnT3rjBZ^?WkATFw(4m)qqVhG?Z z!_HUD|K2kKIP6`1KYQ7}zOVM?q*mmjI($&LntW(Vd9DVRU3r_0%pX_*F@(>o7^B1? zF(%LW4hps}MJo$Nv)GSdKJemp+dkk$TgqX?5PijuB`$~xdr{2&>LkN^w46KG_g@tM znO;${nfzfP8iBkhcl|qByeg4>x~;Z0_nu?e5E0OVZFEBrQEo>x(=9;qd1;VCOE8$T zN4C%VpCJ9@H#?nnD)X8A##=`L#7x&lL{45YN^I9~hIfS~6A7)BL@cIQ*b`-LQEvzf z^l?7@ro?>j-&>TotLyPEOWD@^O(`4w6vvt)jxKFCMC_~qyhav%kz|%=m#QPAIV_hE zAxD=a$L3)g-6ITn=RJvQ`#9;Z)X?YP?{4+%VK;(ESclMQ;(MQm6MaW~uZC5D zJCOP$H5MGR5c6Egm*!e^$AzoAi|hP<1{3^`=XkewgxTmOt7Z7{?6<{dYGpR$bBK<}+%29?%_MvlL08Y-5Wy3sE_|0LWUjEy~`|_{5PrG(> zz^ayWRjTjos?O5YZT(17t%D_duHG>&eI@x(dSVPm(k84dE=7yh&q`j4MZX?cb1BtB z7>tG8n&Po8*Urn=V;Dq&cl(i%t#KTlTXd3tfg&;k;@?}$tdB0|vRH}^N-Tb;_@w}0 zmp7DjT#~Rei4xGJu2IRw!1xw>t+t*cD$=DnVWvN?vmFf;9?wBix^|nwdnTL_D0>JS zgfo`gS~|F|R_{BA^Vt~e++O$4_0{6fiiKrccZgh~B-1?=&8#~bOAtxhwoA0vrb=5# z-|$7T%?oi}EGf4SjB>SCs2NJ%T=9tfx8}~MR4dnnR}Q0?xRPrOpFKuI-L5qMBwtm| zFbZov8|Lv}R;MK~l1LXt%-@fLek;~FzfzmEAjup~82RN*>l457N;z#9U+?@;pcR4# zta*Yg^GG?NMLbIf7`yHtkr&cah9EAzNSj(Ac9=?&9Gxq`*1k#a&E)-{q%8iamcXJ5 zYnG8BG`(i2M_TUZ>$_v&_5+MTB|)K$#aol0T}CSGP9a4Ez>d4aVfb*4b;Pt2|3Hj+ z6AmU2?|GEJIJt|oX3~*8Z9Fm|v#+6ne#;)SwbG#d!| zvPUm+_RL45xHuW-4B?VSq`EK4vtc1Xb21B;0ckk>VH6#zh;u`mHKG>&e)1}<_6is_G?OC$dSqYd8 z(aWS4dviFLL|)EpE50+~s7BYYxz-V2TF@MNA|{vg8(9xi!sV2sbMpn9q&Ut}smwX* z8ItsaaFEy$S;Iymk(*Fa)#EN$H71d=$K=NLxt2ZHi+TmpaxrY=4SnPq%=_`AgAhkZ zhwH7!vMX-jd;b|GJE+KV3F$ESME!h5i_qc@(iZ-)yqETGvr)OY=+#}>*~YV2DQLUA zoVzJ$4KFJ@1pC9Ym6$qQ=MM_&|jR z#cBp-J>yp;m~XzU@C}J9EMC{~KANmEU6f7q4pZzvIJs{A99MWT(c9M$Q1;<3zr;)M zTtB{swm{Yg;x99^KjPi16U3Asp>ucGJeSqI7M{%UXrQS7wk_zllNiAk;F}r;P~40RRXnKN3Opwm-iKe_S^cJ>cEU=h6s7t>zh@@o9y{` zK@=kF!L@5P?;s=~2=Hba_o1_ft%pxx)%)j9XnqBzX)UXGBUDZMb`Q|C9e@UGW;c8r z;WlvOgOAkAu4XAOpqk$DJrQO#H)6E_b6YMH+^k^jj4QP%Dt(pM+dV*LALNf|?T|Q9 za%4@$kM+`7#=2f^HWSPpknHqy>`ak|i0kK%G{j2m)S}vAFWRfWbj3cM@4OC_V?I_7 znJBC40v|~}pU~2X3XzK@Uy5ogkFL}RT$On8jZJ{nIGL~0;j6|c9ge{!m}6P5ve6Qg?umI z*-h*1Pryn8-{e~xiY#wm=vcq9>(_frt&gV+xErU$?oZ(9>a`!~b+UTlf)W6t)Xf|o zf)$4JP}g ztu>l=h@oVS**3w(wZy==JdG7jg;P$Y+JWV@=W%XYXYFXBI0=+`CqM3n9(5v?j03|& z)U;QG)CbQ~p_H9{ErYHuuKeNgN+3%$Da4?RG=TT#B6ZVe{oHAi6pXq zR!J}uaFN+ITxQ#l1^_&q@hfc($6u{#zuK-iASYs8o55yr--RXFd6ed|Nc;4=1m4Z# zb|-tC&^wz5WulP=k-Xh&**mI)yhd`DBY{1P5^(V6LXgQSn~6oNSjIbr3|0AHmFR=ci=_8E zkms^vdI5+y$;BjD``AOLSg}NE;_vrj_OU{e@=t?faVlmBA(*x@@gjL4qPc{MO5(l2 zq!K`by{v>(SuFcL@#bG*ocaiNTTA@lbJRD`Q(B#PFvudaA*pJF+$*p;px@;a+l@}< z5RXfg(#Q&LRUZXzXSZrD8sqCU%Y%VMPOO9SkLcY#zWiOp2+Z83q0I|<9+DejgpWlV zkez0h?`Wj43S+5LItt&m4KO@Gy*Y>_(MLT-MnPMO#e|b;Cb;1gcWP+$yP+KZ>S z<>jN%mqfN?jw2G|M>OVN##tX1vp%I-IalK30S;By+iGEZF(KKU81?vyl+)=Yr z~2a-CpN91fa{VWLoq@sBc*YvBAehpx&YTj>;jk& z#oJ}p@G*m&%L?y7hI*svbol~bRru6&?mKb6{j2}D4M?(?Wde*bRlQ@X7M&~^vz#Rf zTTMXB=zGk<(RBxHC9Y2@At)|-9-tK9O2E)lZTu;K<%kQgt4955&urK>WN0Nau!}r; zTM|4@GyYe?h`TtfURoMGTD+g!fs(Y!# zqpmD`e*gBJfXH;1@hgw&XR`MFYDX!TX(rnzojVj$xh}oEMxuO|={;fUdQo>lzL~UQ zqP@A`*%NcHAywkyou{#b<$$3}@{9GnC`^U@a#W}}`Nja;c~<2-xKl?;cLFo;86<6D zw!<{}MkMu{XYFph?!67=!S=B0^XEJ^7)aJ9&m&BaC}w5X6g<*}bgqanyeaPJw9n9P zx?65u*!I18zKoR&=2tsdC@lv7Yyc!X^A~h^EB5Blij^x@Zm$rRI_UFU|3fEhm<%-s zsA&tkbD++#mW7JVjU^)K6N(b!&VkzJeqprDUT0%?ze<}+0bjsa(m z0XVSLx_gQMc^u^>2>x}L;?_~?uA}$q1ShdGVs8$RUqKdVb>P#X))UHVW6$0@icdd( zH8Z6b07a|FuG^D<1~nT7r5l(;sPVM2NjfxOVZ#*WWyZJ(1S8odoa4+j0JA^<%|vqV zLzRBqEuA>|u9-Vic6B3{VtzkB&BvK7&nsuFOUlnC7ssYhKvZhVYMc2e4_sdMP1;0- z-mK7!V9beOYEJ&o|7GzetQ3B*qbvMHIjW3!UPIqh(}%x%RY}wCgA=R4*m9r%{1$5)==x zm!&o-L#?8y78t5cA_GgOAD}$ zoh1u2Pr(w71ZZTwvS8%H;0Mo+`0~jM8^IVF5L1>qVh_Bp&>Qta_$QwO#+^I^8vkAW z=`S?%A;j?T769eSTg8=VR109)AtBM^GJd~;bpYZBz>s(Y6?FrJg`({D5s4(5;C&nH zjW&)OR{H0j^GjX?Kzcj$w-yPS*rd|3qgs%CmdZ#@8|Eo5BMT$Q9*~uCtYWh?z}AP_ z5+PI1m{p?h)-%c~Dw&5=q4qYeKAp-sXl4W$kU0g(HnU0ez=K{R58qyp zMvNy68Rxp`A^A-f2MOHu+>j`KUtY%Nx}1as0gT&}&2bz}Q9sJDRCiwmfKOWi?ekIq zxZIwstY4-IaLEgP`ZaxXrp-=47JMQD4(jugSF{6fR-%2DT{$Y;n!dPeUSCbg7o)|t z3sV0Ln%U?*_4yBz9j*y_dEI+(_Ka)ioRlV$e_aee2C`zvsty^jIq9G5$a_X)0fuq2 zu4|~o5Uv>+cr$6{4~gs(9lm~ClKA^&!%nBFL#f@j84v409<*CR$%bIwg~7iJu$R1K9=ZxvBXuR*HLmr zCeuu$gci*|tlhXyuzKb1a(uz{0#ecymoVOEcfU5t@A^D*+~dO(NmBENLz9nF-{-cP zF8ge$&pq3=_FRp3PRWO>kOa*WgAQ|LBBT=!{)>dw1g@0W<=}sQ(*;Kc{Zxp$$utj^ z5B`@rc1dAWV`%}wnH98M9drgS8KXwTij90BH!sB;2YqHZV&V(F!T;%9mHhrfl30|h zac5NOt0?QWU(uQ&aIPI=ez>7)NFkI}Na=U>D~E$b(rFqCVBEu)bJ z_PYRNr!W9=y0K*t0l>B{OlLuf-d}>kWg*p_vV!_DIlU3prGgeInFP6Rjl?uC- zt2qBXMGgkT9G}rL!QH(n9;HB^p%5^gJ)a7ei(Vu+G4>JcF~oO2QXeg>m5AnY-;i+; zHcT}J+Q_EjrTrMrTrxxTk?w<5HRk2=Lx#B$wqlOmp;RPCT-a%Sft-C=k&N@RjQ`yK zwC7)R?@#oVt}%Nkdlv~Jv0A1jeCT=je)sV2rK9EWc*z4e9jQ;*FGtdDU7aCZar|9b zxy#nA0*(2<2fJD@<)}Hg;q0F*!*97Z;C2Tw^N-M{#5BH(r9wlRIpXF&z68bU+?9&J zUV@Phxm4_h)N#MyajNQBV3CYFL+o}+_C5pjr3eU~-J35Mn<$owtwt+uq>xoJqgv;v$uMgG0n(JXDqFv$I%w9x`!o-~kIG&1) zg|bU zKvX(K0Z~yLGR`}>P`8` zjbeRA9zjf)9wBDyk4x0?`~&+l^6BS~5R9qMP&3{sigTv#S4J|NDRXmWRaiquO)&n- zeDDYgO8<45$@`7w5=t!3e#>0gI=RYJ`W5et6hB>XxO9O#*%9n-fIk``yti_2PwX`iEi z$ZvCcW5+Yo)MB$t0>S{Ma1fC{p#Bj}OgU76?hq^O1%+dibc&!Ok={Rc8 zzl_YuTJS=1j0u4ax*T%LAflBfQ^~hpuoUWfBQ~>Ele~FZC7yZIfoi5q(|88U_N>Lh z`v^c5YZ6deoT&MEHzjH~*K)N0X$46k)vWSXvE(zSa>fOA3<4;cc$VC<1IwmmjH$=5 z#+{0!8#QM+3rR~w$xQKaQ5{&?1>GJI?omI?o%{4=wCL_AVlM-Z88@$`4^*5fIAF{h2U;Lx`e@ViC9 z|MuSA_4~~s3s|5x{2mKt`x1eV5tS3<2R96zn}QH#b2MftFqaUw1!Ic13fJ-y*NrS5 z$%ZvLLDC3|3IMPzF?BxTUmT6DR=~ACy{wq3%ahd!gT1%7ptf8b%fH#lZ)qUKF^vvH z4%K59!?Z8i3n6?8Rhy`iDf~-=aFnI2@!*BjD+Bn9et3<*oeLl?3bfq74R|S=DB-pB z;_OZqSRJsq>jR~h-bGDK5^c@2qH>yVdEwBk6|OQ9We+-cjHpi zn*Bk$czO3VDt+30%Z8D>I{Vj+Hp6ZCZK#yIS4GL3PhFunJn;Gz3a!f5$51^f|IiEi zbR*s~5++Z>->Bu0fK%;*3AWC8wU0jUj=|$rX{>2;5UB_t^6M6n^Kb?#yZ_*cTLn>l z1`&VVFE#qztq-0Goq`%a<557H3ZGZ;&u{*^rwk}NA5ogiRuQqqg5TK@7|vL<;@E2# zX4UN>)rx$H7Ka)hl%3H5ZHrW|eN7d{SZ@5})zc<7CiiviDU$u&uUq<_WY9?6B%7`vV5lJYm|7A>Ndvi2&sBLm zCK2oYT<5l+8xRqJaAgn@5Dy~?BB3ZFkn0RUM}}nUQq0cQ5Dk?Sl5;d%_QkZV=1RC8 zrC7I<0qvXsFG8!sG?5XQf=Lo9v&Nj$TyI>{R4jDSn6NJbbeO-N#aRw5fx5^cfwYzl zh~eH85^vrvmn6XP+TnQ{h^^tUMY1d!`O+~5lR#??VC&94(?fWGtCv65MEOsfv}Rb9 zHN7=WyGRqg&wR7#83eidcjfW3)mrQ{uqCST-FnE)qIEVnv->TXNjD zbtqzGX2xZ+nM0H`ux{i-WX)@baO5Zs;;cUOSoS{t6)b3219y|=ZMip}woU)rpCkSU z7mu1;Fe8JGjj7>$0u$ZD)ia_&PVjp?tM;L~WKfazJ=XHW7B5YuwvO~)QsFXR3+ZV~ z+JQ`6i8;m6@e)^jXW$kUUBQHV4<4_Zh8S$!{WVvwQ%XgvejT>QRgEa)n=J3b{gwYS z60-y2&moA(O|N$5>}Q+GpWsB`I)K2A_*PM{=&uB|vxmpey;QcHhaBvb4%o_WLZ$Ic z^zDqL_f6+@22vM-9%{83#hNmkp0THnl=JB@%K_FGYLJ&7OWeN~;k28~;8)uv#O^nI zT3qrdMbYLTra^72f*Zs<+x@+}J^g}Sm-7|%2_k5M9ZnZvY-HRB>D@Bhs<-#`s=a@) z@4D5ufxDlaz9%6*SgmczK43A~l*p{P@)I4gJKiqhg!W+iF6T5N0c4S;j$1g5JM%)k zcyru%b6V3T@~w<~XUNtBcQlO+4H9$$vZqxL8i1hrNS>g3bH>VI`7T4gxG2Cm3eGj%g8r{-aR#G< zfsb+*;XjLxn##k?KBLW{v$h5x(l;Y!0^R~RXpvpqTnXPFcvccVh+!L*3WET89RQSJ zao!RruSPj^zJ1H3fiN-Bi+r*OXr! zvV8yOZGkJTVQmnT*JSav4S6$yO#{0B}ue zri73V$8j2ChqJi@94*fzAu=m9N_oSOQB;hFca^`7r_?8Te&~ijhC^)F;^-oV`QIlA zT(jWkA5t_BY>U zr&qQJcpP2YGg#8y-K?v7=U*r-8_%ZN&j!hV!i5K>N>68(i@xTFl)2j;mTHJ)-eem+ z8v|YBH(-U2-e8aGkehMlrFMR;c9E+%=MqC$JfOC&7!x3@$2)rIbO{7{8gSe(I)BE#Q$jO?SlHCQH@#d#Z2YBq3EbBs&Vd2zQ$OG5DF9l|TFNm9AB zg^?S*+t5xg6~9D~D5+wI6wE)$%72Npc}i&tn7jd^FfT20DKot9t}GZ>`?MJ6@Y}R3 zaugatyYjfUJpYV=lWjv!ld?g0FKEjnp|+f>Lhe)-+I}PC(Fc;UUcbY;-OijtoSbdy zcq{(c1(%Vr-wNP%-1aUadU@4(a#8N}k=x%G?)o}@EPm-5>UF$34=f{g5d=URL%x)xl7}bu$^sa+!YCB;b3GTT^gm8E6(mQ%YoYL!!aHtWh2bFDQHDNRMY#P zO_JTAfIM5=MOXzDt|uKq_^LE?iJuxFG~^-;#5M&uJiJk1?2`FPXF^WqjScX)3VGh@32>_ zl;wmOgGfn4HOV~%PF2SEP`7Aq3GPq8vaSuBz)i19CEJfKe4o2+{8a}W= zq<3S=%i2dS-0C;jp1NIo_4yCi@d~D!vL`HS*0@ST2=jbbEy;EGRD&gA}!K^Q0RT zJ0si-#hSK0mGnp)Z~^X(Nwgc<9wvKjxTYU%NeDC!19|aV-z5Brcl}w6z>{^ARq0g} zGeTB21Tbl!>38j4fnunUNrP_Pt63Ed!vX4E{iVX8rg-%|9*w=LPrhAsfJ46WCUG84 z53)fplkv{0W388r@dtjlem4>BTetVuPZ|Q*_Vq~RgysfljLN5(&*EL*y*Wp*1`TI~ zoID(h^C#Y)^t?4SgsiL}(Ft%RXRpG}h)<&9B5={vC;oub*N6IdK+57q0|xU3qe5Nc z(s9fK>Xq!!c1&XN2Auy$3?%3Y)|7c3$IvKf89&%>c&~WZ$3c$?FbQXWlQc$d$>!g6 z5^9nguItw2mna%O*{v16wA?8w;2rtO-M4WXc2y|a_o|km`}VuV{n>)B1FjGvEctYepurau|9J)9 zDHTiFb8$!}`E1lpUr`+I!UF!{7>chN&J@ya|;scjnj1PaXMN zJubgGuDd#B;kpKsf(advb$}e22UdfGzb&m)qj!3iLGe>F`mg8#MOYK)wT~{%&))$P zlR1OLtmkWsm=ZpXWDW6o>=5RFT=;k6MTwg-f*d|=MT7p6(M|`nJ2O#CTo0Q|4w_@t zlAf5=+PY4ry3F73s%8H;!(D>6%%*(3#0*yoaOiiCHUK$VO8;oP0==+p_xEGe(YXNgm-`-Q85pf2Z4FpSJJW$yjKWK=2EU4P z>>5nstp5|TM=x7{eHPHi4L>viG2Gqz7tUd*&Sb$i$x#Epmb+*gv?HgOpf#xTX7I=Sl+}Dou&na zoiE&k+{|-e96mtSw+F0@_lbLLYyDZbC^W2VCD-fs$$KVdT-QWSo8vtu)!2vGxPVAm zQ#+$smUz5nWzzoOT8S1L|0{YiK+@|ng>}*Q<$*0Z{h{g~JSDP-5|hWj4z?!O$ubj- z_m4tBGh`P&9n;|!@n_O!;}(QD$~oe;3KmRM>hyACFWosiE~ANg3LegpPrxVHD%TaE zonmUN)6U~{Fn<<0`Z#eL$}939E?=i+x-&Qs-zs{JJY!+7A4V83ByTF_MQo@}FC#HG zQTqUAX`-+ah)M05)uCl$fh1w#bJIaEn#tQLfPm%!vWF4QJoFhYBj3`3{hFg4& zTZ)S&j!Aj*l}V)`L0Ok?f9TV2(7xji|H=$uamY$M4$HAMz%QC4G|CA%e5^1Tdq}d; z4z*cuTNoWiw|5mi1w0Hnw$JVUR^H9KkdOvB1HeYv(w%GsZr#g9l5=kCy&e^;0k-(NyQlTC&4iQfP4BIb_$5tj%OA) z0y(a*1gzj$HjNPe(SRcT*m0DJh1JpMMU^bJ%&twwpN3cM3Dy+uI31qgAVV27zPL6c zMwF>KdQS$Ix)o+}HmDl27n-im|KqI7+??-a2v9@`Vcvqb`jlWuShMIv|A*zGEDj|C zWS^Bcyxp1;))`Jsbz|_D6anK0`DoW!8CT_*#bx)!bGN>6Ll~U0>Ds&RyyKGee`$M% zK}y!Q@`EUNG&Jvpg1^R zG->5}-Nji%!0$K1JZcLc2>bZX%c?CNua~PN8SQRL^X4}9B;F8?yL8`*qp z?|aQR3HMDXTl%;42<|$BD2_P3s52NBSk2J+TT9bVTEjbGJT3Lizcp`- z@nni=>1Phk;_HE$xwR(mv5#Y6C}A$?jvJe8ZK#uMxn5W3)Ma%S{XXe?r!wFBBnPjR zaX)%mSPtN2A+Aoj-J6&DPv|9S-a|}rP8EvB8hE-^0ApJmMFYP1tu@<$T%cKM*^J0=#!V0tGgYnpoY|9rJwQmH^By~eR0#XJmgU@bAJF}=O6BZk4PJa(oEjU$;H zRee^5@~?fcSHf9!g=Tz`Rm_UMf6adcilMUS#-K~ zg!M8++LYyVif%dzLEOn@kZ4} zHsczher(L#*$(P7Zm&9vhrW`izn+v!MoQ2&7?9#6lSK8$i<7D2qQ#q1%={4%BGbI* zj&?c&Y;v6Z7`nL<#_g3QU0~;>W|VDLVv;MxZ{U*~$Gpnx*3yChiH&1AJY8*LNLqkX zjFpCK9^1=J4U*_(dIxE`Qa)icB>$a?`tq0sL-8`ds$^iH(QcQOm9^zmjfDlXZ@$$P z2VKE6S$Go*0qp`eeDmmHXb7?g_W{CCP!0|KVYmeksNeTt(N+sG)J&V6S5Bdjy z$Q7u_8TT;2_)xsVN+K=oMEd90jf@I%BNmmu2#=+|(3K>LQqU8T*Sv4HX{LMbTpC5n zHa+t&6+@F_LI)Ii8k&U@hIj9Z)os_#~c z@_awP>+PxX5_)ey@%clI;6L{OWOeH8bfd{Z(2*i3&q81D4t~KfxEX}GU!^|NdGE49^*!0+&$C8^>jo}1b8c& z-&!2u{{gpPIQ9k8R*UXD3{*D0(5J z(O9`?NVN~tX^#b!ERLd3LO;cJXB%%tE?9l9;{;TXu7hKT|U##7?y zc08Y%Xr$J*B(XP(`L$vkb*^x|NuqaC%dyOlOOS57YrJT4miyydmiQMIqX9CNN_g`r ziDXgrq=-|F31x!I!@ZJyL1dTmTZi~1FCoNiJNiZ`Qv9XM(UUj@#gYK|^hnf<*9~a8 zy<1AJ|FF*u+X;~xF~rR@Q~nhM3-DMm=~YCOke|ire*JHLD5?Am#THXsa`o?F;$@5UJpdhgO5d6d zZ)@XU$8OjNR-#d$>qd418paT9$Dw|gbzDi3jPhNZtU@!XI>lLb`o8!VSrmHUM%CKi zn39o_{osoH--32#!Pr}(@e&4Meoy|r)M>3yN!eqY9%3Jtk|Iiz9Y;BDD-$8{A+-h@di6wyX5H_vJD^|Kp4^qgPOV)kmAA8;>Rs?lc{BcRqR*-FpLz z7r%V#TBs*Or^`2Hyf1yGZ~WSN&GJw9Rf2l_97m~$QHNLA%X3e=6FSYW@^ilVEP$2}UgbJ>wa6kF+Yp#Kt(DXfR zX(L+KGZ}Eb3bEt)(mw0=B=TY^+r#NiX$>x@bp{kYXmI6EO}Iybn?D0*?*078U!L6Z zAl@(BUW;D??s=Gj0{*3J@3us$PRBVf+D|~SJ!`o1^nDKt4+kypg215IQru_02ZSGq zT|J-ac~4=zFMD2fx0|l4pPuVy=PI>|&z8Ri^=+sh_)zI%*J~@2OEq3rYV;8;y;!?~ zMM%~%>^(ETqw9=61-vgY9(f2*uZ2wcT+`=8X2Z zMQ+zV`kgQAO}&*0QAdAW(?%-Z#yM+9G@1J>i+8_np12g_mj&)&tJKMcKG6xynZGZU z9s6>MePf!r?x~2>gVXc1&Hd>m9|g(kwjesBfqTY{AmyyVk1>opfKNU_BZxFJ1GJP{ z$_O$`TYV7#(`;ZISbbK**f|&b{7j@ZhjH_;C*uRYF)7SV@RB%?_5zkZWnwNJh3X*?Q$ zn2c_+@e+h5B|gb8ovh9-wIrT&kyHMSKG&>xqgnZQdg7~)ZuHj~OSq&JYWiCb!}qTr z2LV1dWlZrEnL?rF!XiXr2mqi|M~#vF87KRmL~*IYK`c{{r8N!(nZI09KD5Wzkx;8H z2CL)H?=JhRUFK`-&zv)!uWdq?G%GAbg4QY0O=U? zoO^f`g}xyH(^awSYU;2rz4vtc{(=`oIki*zD9|$aa{sfgxOIi?inf1V-Q4NHBt(w~ z{pY>0J~EJ{7xcobQ72#*e6jwb(x@tYh`uV8K1fF60F21Z`r?wP`j3&Gl3rLYA#mOM zX4~P-`=khTbwtL5`K=4=kEK3|Oqvq0C$!o-S?T6+LV&zY79I1)Eou4`Lqc9FCYJ#q&g zGB_F-NCn>e;5S`&<}@kE(=^uh9K#wk-hWLTrwj6-XN^m8ap^eCNm{R%UOxbJhvJ2C z9UC`}+e`kpOdm-XXW z#ihCz9WVtL-z*V33;R`dY0oG}&*R15#>H_x-K9>g-JsANGj#vPrSDio<|q9kbaBou zLY{IsT5+5ud*pcBA3SUPcxn>{WC!4xTaHFt2O&wHnP<_Daz1NerP$R>a0e2PGZvzL z`@WULemjHgy*-p$-uGTMs%3hz#k8AK<<*2{O1cej1p@HW;iCU;ZN1Z(2Fn4L6c*Xv zg&M9Z#39eKg+M8~Y$5w3dt2S1?}^Lfx|OGv<8e)YR>s78i4Y|aILSa*WffH7x31*M z&Ud{@;h8IJ%XLzS)+KpC0jT&v2&!~lX6GweQ&Guo5DHMDE6YB6)*!!P)q{vT;t2LOWRVBcHU zi?OX5->N_TCK_6YV_#yK#-GhI(uH934Oe5W`e-`Oasvjyd$uBQ3Gqu%!ug8`-Ht__ z0Txk6H+0$zQpd6{46L|^w~1YZHHjECjT$+zJmkIhFplQVS*c{>x1{iN;2Fuhjd)vx zdRvb0RV~Z{Bl?7>r1NHikq#-&4TvFEi2@4F$rRT8t&(>zXk+Pk2p)ij18!vA232re z+^d#rSwh1U<{`DTJVSN>5H7^#l=91-D(II<`EB|;PDt>#`1j#05tPiIe>q}4Pc1=6 zT<@@)yW8odRS@Q6Legw<(Og(ak=+HK=7{~-hO=8$GLxuN{ERWb%u08#p(H_IX){rN z(V#3iA{bAmcWjSsqrk%slrr#$pGeg+znw+t;>3ZrYsH*+xcoffYn} zN^44Z;Msgk7a)@S6*E7i2}L&d051N1(M{UccX8q4lQI zevQ?kR^lQ6&qQQ-d7LW}E_@i*aDV|IRbHj?Qg237yn5V?4-XVmroALvp{AObqH<1F zcXLnEAp27|rrO7stU(9)5{W5dzx^>Li!m7Nf0kX98pIXQyslfko3}1bY!J;BBE8H& zTd=Xeg^6w02`_}O(p4EaxB)V((M%S#9$_GMz&QC;q*ce;`(13=G;jB#Q=cOlxwa-$ z-G!?_2eKfY6SK^Ytl7s!Gs1P6Ggc|2g79Y@2C4S@`*n+a(0Z2O>Ln+~-aZIf-C+Z) z0@5hj?RY~B7T$Ga@xnH6tOJqfYssv9G3f+Wsvj@_Kp(1D7hk_-{L;LwjbYDr&iwQT4ju&(X?w{)5rV-F#Y~GHmh8E=k?HgG2wbu1U`&=8i;)p|tj($yd z(eGzpO0dZH?uofeEUatHPstdtl9lHznB_flHTA0Ga3H{|cR{mbfMV3%`iv zN@TXbEp80+&viJca(&6_#Zs#j7~# z*;0B!g~+oQc2^)e!>BJaG2p3Jd=(@vN$RnZNpz3=S^R!-9NCAhS2h@6>Pm4@8o#g( zqJ6Ta>0XDr0`R`8rRtnB02_ZWNw^14;vtY%}rCiX1x!+Ymo-Z%fa%#%zXr`;-w()nb&EzkdF(}wwpXt4R zxiAz$b@&{co-Wm4X?oc`u{ds+L5YYuA2nie%l!3|xjpVE&YRx5g06SQ9rt^Lg_Ahd zj^cm(=$&Q=FE;_E7so?)xIE@c%?)(Pxga>4**Tf zyE^8yW>9$ax=`QkztohR?3A9z*7BZ5-Y@P^&XlWu;()R4m9PvNnC}ghgjYOsG`sWN zOx)h@vv#a%Z)~SDsb2m#psnI1q^57RaL`1>Lv41^gtr=B452=|AIo=dymL31iV^6oGzaJd*67?)2M2;eOo%51XFliN_t8}Y+E!SSF z1eH}HtK%5{p0iIcC-80-X78D?+b_e2dLysj%tC9T=LvEIMJntje6pm3UkNfvI(gN7 zLR-McS?0!r3W9B;#Rma!Y>cT&ywMvp*mXEm)Xw^sbo~Uaceq9)yt%I`diiy&K|{e9 zcVXv94%3eRTB)p_+j+mV@WC}NXs+eiu(`0tV38DnGMgzs|2!FMB}~baiUOE=xw2*6 zAS(ddx0Fh?I%!frX-v1}9y^r1FxZ|}XJ46Ac-n~CbVtI*kWSh@9s%5+t{1?>Se?8=hV!lx8wNJ5;Du5dC5~&zcwR(Sb zwz9*Q5mZHZjp>h~%_8((k#-oOfLV~P*XE%h^`UQnDWg(Z-p50~ikqTJ4Y77OfG2e@ zwaDGAVOKRraYP73@o+G>eW7Vs_^EBwz2SzEe0@n4`Gyg+=0#!}mh&`vq<^$c^wo`D zEe~W{twLJYJdJOn*o%oKQy8DK-Tp_690NhCI7f<$E!K`A-)@DcorKY}Pl%o1GHCUL z^RqKfo;Kc#?0Ku0r;YR(!}KN*ZRB3%6g@;)8N#PXRg^@pb5XJ4`Zbe-8wNbqXT>wG z>yrMWQj6QVn>z)5t<#?J!u-1{uPF<1(OnX`Rqwp(ue=_#TXy|3uLD8jV##uPy-gqB zOJNc#!+YjT2g|x<5tdUJit-7pE89KAo`j@3do?q;r-YzDy0J<;*T0Oe@Zzyq~ zVe$|F(^378=jXKQ1^Tl0WIo09&;lOb7=fsmKXp%H&kyF0dRQ*i%MXxtEmVw(QPGrp zT_P9Kg3}rBhW46td;6T(0q#WtOid`-a}c^!@|Uxcw{(9LohxYGgLsq(jyumUlGp#d zhSi5k!#*?wRG=ylND}}+0SFka2oUI=+Ry@3I~Dg1hfSumv~SQI55pmZ1O2Xlkx)~!;p)xG<}C$QVg`8qt8T&ECkgAuoapQZn~o_+D#kM&eJ zVJ4%Sh`&z#{tDh5t2oZ%@K)cOXg=e&M)cS029#;*)&2uQdE&bsC!7v%E@hI6!~q#K ztiP`iEPnn20?xi&KKmsQ&$jpvD1>jZ2LdpfK5}jVD(Y(Yt)=O;=H&C}w7pl(9RTE^ zyOjoYB?6dmXC@xg9>m<_JmK@~6UZtC&$*}Ra?WI6iObZy{reQ5)IiE?wB(X2Ny!J= zw!qp`IJ2w98U^T3_BaX!NbNvNr+sU}Q=zQpjT*tz1S%MZC#|cxsY5|9Hm9cb8Vk{H=O6d5HnX17(KJvof zkgTE>y=GLQ_=lw8OAS%&C}Zg;EGjAcd8{rrGm`6-M!ntAw$i z_dkcBxQ;j~YlQa8d0h?{lbtaRc1PSe2RTkqFOmFpUY|1<&0!2hjVn~eN`jR2Q)M2* z-BZJ!8t6+0{#2S^6zHymWokA7J-CTg1QBj8Uf*#pj}Wtb?xPX78#6fBb-QM+$cqp4n~T61 zJeDg44x|lvl)l>o!ug^|#jt^xEUGw{2Z<`q;ejW>SnY#TKoz_d0EvaOoZ|xUT-~=6 zNtE;|UaA=nd8u>Hy2hf9gL#hCPNX=D#Yi&Um1cY)nVUxUK2pTcRM4m2_lXp{&7y^o z{7dUzOW&{Az<<9-Upj@qt9^~D`YKAjN(H{E(HB8|ZPHJ*si3+G>Z`ng>W*d6@r&`I{m9ElyqFtP?fEQMAT%z0z`+J z$9@~Z>!B}W0P1C{jqu|Xqo&3Na?j8s@H{1#PIfu0FSfm{_up>;=dZ0p8)lwCddPZu zJp6OjW?RIRKjdpkvX3S?nL3HQjoL>@<YRGpuLr4cuZK=&2qjadVGKj{U_MXbZ7yW?3hQdYTx4 z-&`@5^M2gbxMt+^HmA^f_aydC=BBE0BkO7#1?Ct0{7}(;2 z3=g~=E)y>SZWriNIA(^RJOC@96!QX4YMS4fb#?hp1GGt*gYoTHY-F)LTmVq?1dlK4 zSgNDb?Btx#gn>n>(>%!$QUwx+#p%IjjG=>IffWF&c*7fn3v1~ccy^L@*bpivI8Fp7 z6!9W8p3y@tc0;X0{TVeQtjJckK3c@DgF^e2^dm(g45&@s0kIF%K}4bg(@y%BVmy-X zc1C(IBKx)ZHnEAE^KJ}|uDiplxJtyVyf)AMjd#a4`ks!F8MD-U%+J-w!(0c#*YRci zfnAehjg=vpw`;g?-hI@s9nqRcc#38y3>!}+IxJZ3_IAZDMjM9H4r$ywa|vsAc7+?u z_@o+FjaJsoZgvC^*)x9DQqu8Q9h65R=7aKZMcAtIR z>VIU-eWcaHv9xstFS$o{QL4;CJl6okb1ZE#1tN$K`PtqmX*-X{iUU64``-Z~**5_w zD$Wi-0u-iiOMM`G)Iqkpg=i`L#~bp4_}gwB@bBby6-$FYoI5pI>(3Y%nB7M1{Ny|{?f?y`T;A~P zDtKIBZ}56PNRSpF`b*_#Du`RJB;Ar{!SD;hY)+YqY4-FXBqY znyf%e4y}y`>;}JpDHVPUup60Q6D`4g*`*m~enw9@rRc*jOrsFRd{-wVS4gdTb94x*W@%pzm)kjoshUp8J7nt4Y7S^K0roFgX=LyRW^K1Gh!g$}X z>V;vijsN)+QROC7vl8nK5RDi#8cA>(`)<@18{oC6;FrbMmlu7BIk5JJaed*fY;s_J z72j;ve_Yn!t#2$s6qohR+mebPfez67%@AiC#-kE>0EwCQmbpif;dis>a0LJW(PD07 z5JpmdE%>7qYjHy2ha)`D9Yb#;$E6zl$)iGnp)X-gzG*p zpayqCjOHN5a}ei`;pP;(=^i`vQRwglUOeeWW*yC6)i>~?LQcLgD@SasqHz2R)}XHr zx5OPacGNOF92>$j8n>~56w$wv&ys0R`qJIPB#GoO``EFc}~-{k&~|=_uLJ@ zf=<~x%r!TiQ)#zGbrPDzwfK*aFTnsqxsW^L+grA#L28oyg{D01rXLm2Up%uJ*8vbf z@E4KbpTg$fHWh@{E`PqyBBl~8FV9+p^?dy?U2&Irxexs3Oz}oW5wF_d3ZgmL1phEmdsEXRX(MGeTcT0e78b`0NJh z(~i&`TLW+hUH(^vO1(^7vWNlMFL%+u0Ky+WXy~vms-F}$Pzx0ZG0>WaZMs2i220yK z#BCeIzgI%_AxK9GtN&o}n;-G7l>>QyX-?#s2+Tu#eg=r`O5hCfl954s-I5Xw&{D%!)-_W6yX{SGSGI-Qb`?zO>ucOv~)Bx$vXcKwBYK%y%D8mL|;c5w^n z2cQdEEe@|&Rm;vu&W)|#HOK}>GIUERInZldL_^c?58Dd!gNgpLZpMG~v-VZc3f&|O-N+|V8AKCZ117pxoU zx4PLqSHMJJu7!j-NaJ!sgE@{l@1IhhL;^xr8(fG5l?IdP;VfSR zwoH1+(`l0u3e+qAW4zDZQrUEgt}tW@q-CX}gNEn;Ac_F+`)^?sLJ`;g*X=SFE$jZrPw)=B0S{KF70JpkeYQ|P0kH{4-(ylP&yP3%!h^k z_8csHwWj=+&v1g z4?@fX4U3~-ABuKa8a&aZNbKc2Y0Bg%ZMw0?F zD&VR3o>VLZPwDYphF@y$xwP7I?Y5?71vs=B9NrAR9REOW(7fWeRw^>_!mpfnU%A>t z5dwo(6K*&6mjJoCd(ZMM9%%rX(S1)e`iAxZg%>$OsQ~)+KKN?ic{pMhV9IFpGDl9PA0Suro?_%F$4J2y? z#}K!CT^4f0F(m)z-I`Q6{`qp7U#hdm$qo`JVuhBRC(K21XcmB!JX>Ykj)#{-YDaKo zJQuf$Vw>Z3Ww&6V=}Se~Igl&>{AcFui6R#K#qpjbx9)$2;V-}dTePMgpq(xs`Uz-v zAfK)}UF7;vW#wl(!>Er#j=hg+hPH<9O^l&OJDsq}uumw=nm%&u z9PB23BxcJl{Lqe~OBXyi@<~ltElsFY87;UP2cExQ!RDA|o!MAi@>6Z}HnuhkjtRs1 zcZrrdg=$>-(>Wk&)U4fg{?|DUJ?=Kp)v{SP*ymJwQ}6n;ycLDj*q}V3Bzt63iSLbb z%g1`(0vqiUP_zjmdSycT5ES|b6v`Dwpwr>K1=1O7($xk1#mH8$NCw$Uey2%op5$~4 zf_xF8;)hp9)_b{hf$aE!VgIv%oSA_}UU_;sWJ_5ey(_Yg0=SRrhfx4yegKP(L^L%@ z!$Ich4^}uGUaXQO`)RO_LvxSDLtfIu2UirhvWO4qryqTK$Rj#k7celz1FUYJU2Yk` zdm*c{kgR}5xl7TgZ_$M%EgOkufz^**u%z4)Y}I*1?!bb0!J_=ZK(gU~p2=2aYg}l3 zaJ(w!La?<8E9L&YXF^(A|NMm99O%!dwl8~6#^B)rFltc3IjVr!3XpcNtm06?&lJ$k z5@n~p2SFP9(1#A8Ac9p^#r#qF=fvZ@7jz=$0FZ!T@w5^@3QSTuQ+5RwgkO|*8VR4P z(Mr1?ccPwuW0c^0Rx4jxQrjbP?)8)PIj<32vC%B;Dvl%H*Kn0KBJ++LrFM<7i+cwI z3`^9<9f}l;v~RvKzCKXDY?BaK#w}m*`qt+Oisoc&z+`AKD5-2xVs0{Zr7067D_ zQk7E&dK2>V&QD8p5kRV_y*K@#MYYqwAa_gED!XVt2P43vO%vH6J=1a8H-Dw2i4>)3 z_~;mx#EGHjr2-{4E(^8;lWY~D{DY>_OuHURKJe$_0nBnyfFtXiPp$w5FHVmb4~+Bl z4NVV39?j1F86d6=uz9x>E+xKfCa{Jl#t*xf!&~3owsevJN{c>riA2VCJpb6-s#veU z-fzWdDf>O=3{lc9Bi{kSVRcaNtkJDc{_ue1R>QvRg+)c)3w))Nzs2l~p|{Ga+X#mm zt`2^(XlGst7f-jl#YSI&r*#`RyR~1mU4`1qLEW(M79F@78pgeDPnLrzo54JSUud+x z5P!PS@asht+al3!u%eIC`)>L<{X@Ng`>EUOc`Z^uY4O*o%?s;L{Ghp23G>del3=BjL+t7*O%ZVgJ zJ{p`+@j6Qmyn{qEacG$>I{VhWHvRaTB0ZVQzckPb!YlD;zHVA!%{yA?KiN%?809q5 zJKJLeAf>nTL;#{ihf`@h12y~2^QP}QpUqaa&sLG%wx+#p(3H`}nUlWnjcjQxOkE$=Emn7x&nU>O%U*8W@XC0XHq@K9Ea1^NHb$&BFCn4`4(@;p z2tqu30?leBG9SN|Uwuu}oRmQ?6$T@^D(8x06ce2l*S5{?K2DO%sr#_WT*NT_%4C|I z1iTY?usA!*68nkS3Rxfh_Tj;&C;a;`K=2s<@~_TvK`|C-R!!U?vm)g46NVLmcB$7H zpLth_=RAm%hbvE=_cqK9x*pHs3phH;-Vx-J|N16#{&>fuismakBmyr8-uxn3_&&Dd zy?_PiRtE9tUCK%8%hJw!b+dd87GD;NjVAVZ zTO7KF1%ELQmZ3R-5l$tGKJ!YxiB@to$K7!H) zsczCV48723(VRQcwuO`(;EBwaD6Tch26-Yea}}kok)k~rG<%ov_nzr?Jt>DsD%uV{`ewd zl7*EYK21XJ8un)BZV1)A?2vPq#D91UrN8fZeC;>L<_!@3?)T2;u>19Tn)U(I zxa5l2tKA00<^Fxr(lf}HLHf*rlc&aCAZM>ZMeMPfTg~?jPk+C^2Kv{vuYd<4G_&R| zbNxISvJ%nJWusSI%+j{G6nA~p&d4d`I?xg&LHS9Zs_bH0rzhm{g>TMJFi{{mlZ+ZK zUBRaEKF5|4*PkpH4MHpYL+D^Gfp;gvwGrzqVT)j&<>Q2`cl#1s^~{3sni zM|pWYK5l#R)}%j+&sLAaJ^%i8ST3#7Eyc@TEW?n%hMANb|e`@a3GT3xOvFLn>>?sOo+z zX?#dcu*ELN&n{{&Uv1dotf(fMcgBxSw}hGm(Aps#^%KRFQLeX5NUBS}4)q^@82Av+ zueP=%2`-r5fQkp#{5Q~M{GkL-URP0vW;TfVUdZB4^WM}d@qD`#K9o@E4(uUtkIXUFY!j!=jkhH0D7sw-a7l>1rGmYgPc~<)Fup!kBdY_(ozztl2E6(cN;) zK6tR)K#370+~ zJSVDA{*m_y6^lshqI+fnhs`HN`|Q|@>(NW|pxZC)Uh+7t5_3T&wWdM-yL$@Fh-Y05 z4>WSRCIn6u%lenbZ#=en4`Ea2qFOw~Q?c1d-dr_yeYAsq1Q(2t_XA9;NQF(WJ11Qu z0MfBI48VRsUyp?v9R)y_<8L!aells;F&rg!QFE2Rm6hL249}utn&4t zY>Cv`8C*F|1)tKL9 zYdQS5{$9jlu=yU#w(nCm%c6HtTUJ844PiD$hI<|10n{|hOSlAagw+WzCS?>|%eo&<(0mov}Dz_=+%#`x>Ewx#^U zXW0CH=qYP@r)5=uycDv>Uz5Cf=?CG$9l@Eh=ig^;%Wm`7U6R{f0LDxUiB`7NHGR-{ z&8Ui*fUrZO!z#wES!*yte$wgLFHFv0X|cW{QR9*vU9TP(fd7X8+Bgm{om*ai=X}H+ zFNnm?Nz88RRTxK0Js~_#$%qtCQc+<2RuIDWC6T{`_v{|OZbiF}-&X!ZlPwl~o>{36 zU92x|aQQ_7o|9k@XR`~)i?*8=Knpd~&#z+Ac0vj~Zfzbm^xf(48xAWv(fy0qK|Nr< z0~4^(V(F=mci*dM9VljbEWgXDy}8NYxA-Ea)Oc)aqQlB8%e8zj zU$(GTF)q}sl$qLhbO`SA?&(uln^)!SU(;J`6%dW$y_&4@Dws0pYa{@DX5w774%w?CFOlt6dlpMM+6(p z02pVVk%N+K#S42b@&YnbK2euwyj{DNwYL1~mEcBvm3h2X4o`XUcP;1@vr%ra5txKf zry$Hdi5upQpL7TXCr6Y#_vbZniyMiB;rZ1Fk_(?+fGGH-6P&bq{Jkk0jLCzu0rbXz0AV}G5DN2sR z{Hl9Xg(36Sf8@zw(#}D92xGp%Pl3?HEE$uE)*nac^U2oUwlaHPV8(p5UQ|)~1mwaG z_*kv2Stf1gkQtgK0(&GvMJe~3BO^}XqSO@bYEg+@SwRa>xA7xsz9Ot>yQ$N2M=)Zj zibWO`qkI8wY+d8I@1WC>q^?=4J_YW;Gj*t7#RskO+gf-PRvN|KspYqLWw(;gZdaY6H*E#2KtBXtA-6|lzUhw+@Go-HPw$(?5CS#oTQ#_QB)u!87PgEb09|xkfZCl z$AYHOmZtZ&=RS@6eqqd4d9HjK==^4Uo)G{lVn6Ny9AXZESEvQ-aKC##?)2}Q+R_lL4cyuyrX^|8OX%EQnV z7s+Vb6JDC&p`@gt6cDrJAe=^rY#own^ECl{qU9IMyjJvB=5G(S9Abn@^Gazb3~i$t z67g2)?a0OuQ{u`gz3L@umokUCBCy;JrRh7|>ZGH^f(?BFQfjQoZ^<8FYAnTxasTF- z;N#Ww5Nil-G#u%(?~*k-N@6`n?z3yO&x#p+5R*G^Tw*WG`Ze4OfJ(use-l{HEgJIf zqju^FgbZErgr<@Tni=d%kpznRT2;j$WvS(9vVF{u`1F%tken9n<;(;?0vQZ6i2?L&-`#V+j@PPn1T04(kX`ok~`0b)ow<)jL#}V zasD!=w=AmK4Tbya*^9Mm8;AZ>T;^K=xmL*7Lo#PDkoQAb)m~3EKV5D3)F!GIRy3s; zJ&f$Zb?)}6b_}aS+IyJVyMtrUYrSoY`45BJr4PY*^*mjWMwMZ!o{s6>_4Znl4*#RR z=P8YYzj?FpJj%W-1LDb|S|$sP9i3hBl>z-Ljd>byusALFZKKA}#pS!FcFHS$h(pP! zT~(FZPRc6)n?nFad}2rrn=Es#yK-spmj>~{DLQf>ffX`bYm5z$x9K^B6G-z818${s zHnG3h=@>JchwSAPUmu45NsiN#24a1IuFM9~@<0X4AwF>U48hNaQ~(p%H#i#>TFsNFfq(xF{QKJ+U)}nkI%|Vf$6T1V|Wi^oc;#m3p>FEI8ghI@qe=8C|DyiWHbcx-ZY@FZ4E*fhL zFkU7_Agt9#lK~O{IGLbZwJxWy`9Z_Bm~U>q@FiArXIFb+HhJ%@jYb0lbi&j_eQaaq zxxS;GZO{};d8{4uibJC9PM>0Dx1K5B$=NU@eL@saPVW1<{O4Qjzp8W)`*^0DzQb+6 z$HiAZ8#UHZ__BAIKcYQwr#4eg&GSd-vCldBbU+ZcOH9U)v zjk`jCw)Z6Y_VhA<;L*<39 zqWN?m(>OLY%`ehuWIbJM%B&KRGu0qwNK@PwP1lue2R_jb+S=~c$J*VsU9(#EFJ2nc zFgvp@6^+gO5kULbT512-Iozl&%1OpYASd!~ww1Dr>zX8@aC}MPb)j5WOd~$sezUjS zCA8lqO!v(OC*c9aB0-%XLD0JOhta9+jnxIwJms>3B*dND$Bln|qtLCN<+-bx`H<5) zmEL7C*4Cas(7y7|W`ft;*CoTc-N|G;+3dHM=kq^hcX9c>?(HVG01jL{zc@a$JIqj6o=rX^P+sC`Gwnvac-!{(x%xGL`>icHoJdlJBF<>3_$c3RRhz64ZdZra1r} zKD{frwMd!)$Ah5<^In(8_&zVyWN8m#HfVYI54%j+!|#z^L*YK zaK2{6!LN#cEAQVV9`zoRHmaN?j=iqnl=7DCmG~9e)Z_^pl6SiHx!a4;)EGMFECu&3 z-!y%!cixkH{nsB0edZ5zzVhSZ8{9g!XL%*oeC5S&_|c|}{}n~QY{?B5wq4nW8Gg*Q zw`rOSBn=V6en&?ZSuIk34)8%u9%Zk(;zm3omppDVTSUp}hpXq{%}_Fed&v`|WIXg> zGy^F?0%iC=x_86ibKS4nO-;fI)iDkjCW|Jk*h^cx6)isFNhhVi_uP*_K}Q{Jx5G;eyn7! zta?feG_y8bKWOrGG||pa3Q1Lgzc=h%NZVc;6L&NH(;$CY!NmKpTz7gF@sxGU$H7B1 zqfMb72X0gd%>{7N&DSv>I7+e~_I7Lo9^3m$ahptj*!E{Q^XCt2{UOJl(uHPL z_*xP@$H|mTpafiO&tdaJGJ^KcNO|ZJ1MIEPxVW`ZCqezg%i@3t!XuVQnl-gC}XMY*zZ@l*7@|p-=~e~NnLULm&TE} zV|fZUJ+Hg*8Toe_VET+c>~wwLXh-+g4g}$0edR1l$Ac=`@6gM`du=&6nAG^6?w`$p zzf)fxc1HYnu%XT+ErK|afN{)nZfLK*x?)L!pTJ@1ah5A@?pyz8cy32lFcg35PbY<@ zzi((dMoJa@a6R9irN-Ve4QgcZ(VtANl>e!7VcC1ntFP9z|K3ffdr*<8pDy3N-nsDN zVetlRCCjaZaXkc&KfEgywE-A^|)ZrAd->Jh?H?cj-{gGNx57S1&&%_ zj7z7Ykb@$&a2qnj51$JJKQ?<9y>!fM3~UAsq=XxH;ItoH*%&hi0$ZCwvUkYjuCBu zsmmQtSd<9UdrFDwEf8Iw^^yn_IU$O*O+q)cxb`bXlYl~k#f-u3^%<<9J~XBZ;-rPL+Zz$*va%OS1vUF zyZACU&ISG)qDO{4%q*w)msG^Z&~;Xfu?1PI#Is|1GAr0`4$Sdza#NJ49%y{B+=y;f zKipysDz7MKh0qy{ammKFQkjJf+^mdnJei2Y*!%LpG1`n2D33m)dt8={wBIPssP zZKJ~@fVR*H+0Q*{xVZpxUa^6RsAf4Fm_9JzO{7)gzZw%V`0qzCwaiw$Q(((>w-)kt z?d*jR@_uD7rJPb^QI9CXB^|<(Wq&hS)@?vUsJ}u1gF^ZD+{rd*>|%qRQ^eVdy<28z zfm_`}N)o8Pywa%gaePOTb7-l~2bZY+>UwnbXDho6dyBQ*T;3AT}o>z!vmg3Z_DG&yiZ3+4JnyJ zc%5{WY6&g+Eq;nAukdWPcZu#F@%E{rlPOdAm7K)k@0;P;51)?O`COM8VSQgbwu%Z3 zQxLy;8k+GDHBz8x>XywcX2J1}*>JWg_N9?@rW}*A>Rc?786Mby`#fr;AK|u#y^tFX zOBi&;p8tD8#VR`m=c6D6>)n$Wtm^gR(Ed>mR&Fj1@^IkhWyU%D8aIA$w0gQT0fP77 zVbL!71-vnIm2>VCFk6&<8qbPvHkDB}V|;q@x0Nnj`h_xorXE)}KeL^*p>B?)9zz`3 z(f9@GdQ023SL?n!2dmbf2w_zzf+=Yy z)?P|W7PtLYY=15a**Fj-I-P#8A77=_iT%s>`QM0sOVzDNg{AE>^5y z7;ql$0B0VNl?gwerbUM5PX>7bu+}YbH~>1ZciGDifXK|rpuf7zO1CZ$fbT5SPe*pp zL-Uip?!e?1hY$dQ!>wiKXCtvy(*6-HZWy`MLOg;3P#p+IcOY$E|V zz*;r6qRh~gy%3g3SO8*VPo~O|23O;}9B%g^MEax!rMfb&5u#}MhfZR!v2F-9C;rj{ zH1*qmg34KFx=*5rL{|JxR^dATltpI@r{}4jYLZq6r5wgLgTq`B=i>)DbyqX%VKy?f^A8^nB3^%gj{2S1uXb9jD7SBxpKMV50c#**eEsKGv||f`wF$W_3mp_pQx^N+?T@fO^sP1Y z5VN<$ma!?*z;3LRB*VaKbDE*$JCpF88?&Q!=r!*d8OhRH!4rI1rFb2 zPMU%rnTypiriXaP7EpO(Yw)mhz@5sx(Mm*7#Pw)*BGcqCyf>p#QelII`h3!-q1Apc zygPt1>s?YjmB8^4iF!>8YfukgcNyj@t4b~c>dZYDmc8_9+h@eJG!(%5+I zLX!^fk^!NS>rDfc)af@0Hgz;FU&@)66PdJa^5)cU>8^uL9DYG?`a&7ugq6Ob*)0Y8^*Uzt(bIFHC3{(3M`i1mU3qR?}tP2Rid!w z&Mlp?v!NP+z@Fp1$VK%Ka`{Dcc43i&W+D7Z1ekS4?IfbM6&3UJWx$qwu-%$6aDX<@ z{TY)4J-X5Erx^g{1(_+nkk?ThL=sOfIl>a?iw_G3X&!aP5)uR{M24d!DG!pjz>H<* zRJ^aq^uf4Y)-60tbu)yTq`t>fMuMtpl7$0M($w{7hBmDvC=Pc3!{ZJ8H38uD=z~p- zqnt}@n#y-j=Vi`ptdPNF!z)ekWiolAI6j25*rKpJ$HhxU zS5PPFtM86l#LFhQAsy@KSWnfd{)VIm`D+&+Y#jW09kcnCC+$bHqD7@W(O&bW@T_T( z>CQ@J0=4Ibg?9Wh@z3+^=QQ2-)xU6GiI+{`Id{iu?y^h8%S5>$Z(JL$toc`BuMT7| zF&K(mdlHFj;{4>J)Jxy<<=^LTxWC)4f%Ku};M4cGe@YX;Vt0M8;t~laTX5w@6kiO> z1PPW?P8LwqxF);5a>r%>77Kt&JCB+=&-snA?RCJqGh*3Ud}RI3W7hfxO7Grl>M15e7yYh7$e)#RT2&M=YAp*0dz70N z^b4wBN=k$zzZ<1GB9d&6ShV}IsW*-4X$uNOQk%&O4GEd7xtWlROiUI?D?q83n((3A!-~MKB8AoyRe{ z^ZZ9z;n|@V-r<#XM+sN*jSbPCl$xU7-iWYU37O~q;TGa+C0bL=Rtc-fU5(mC1OTQ) zyoW?UNflTlsqRWCW@DLAt+r7uR#Ih{1kk(GlI4`wBbdj7#3Y?IDeXP$<{9hY327AO z6YU!k>AQU2+jZ%%X6hl*?&F_-(gAeodua#G^<4|~$pxmi*bk<5)bkeN0s=Qrwt}6>1r^Z^55!-l8YihjmRPNIo6l-O&5j>UG0n9_K!oS!gB+BRDSY^Q!K+ z9DBK(db$4gLScc#$UFv!pc~tP8`~fwzo46_Hxrh?RPf+dy%m$m;4b=AbQFm#dWjKm z;QHS_y)v&a$oqZCL-yPPTy<{YHo#nBoQ6~yU}q(;z0LnG-f0YQapPoBh%iVvT@r&3 zXwGA;oV|ScN82p~Y)C`i_SC`!JYkA;VHD604jmT%ZK)zltSt#a+%>ZYL1OvUM~R9L zJwc^fCRiI|yBwF?jP)7MJC10bxD44vrpkoxVYud7VEKZ_UF&PvF2nUiNC2`8z)Zw7F zo^DVZd#{iKpR0lk7T@_ktPhmUz>w87)O&~5TWW&41WF970z=(HNQ`tey!=}O^z!d4 zf`CNHS`Ssit?~y9-)V0DHJQZ^qCNeX?14xQ2lf^HA@ShBf-JwntOgd;!@KoIMUVXM zt>|N;$i=EJOXmH=qiyevjDKsun6>zoRr(bbiQu~NINO0t$mFhpStT0+Bb>;LMH~=c z9oQi9eEkj~FJG7FzgG8V5+*a?2}x;1d~m;zm4l$qde>JbM?3Ta3V0hqMxyZp$XGxt zYPzY%O@=v6I*4T9s1JyCyRRjLxi^PVnSo&sfjD8*AUXzVDQARNGsFWAnW&E-aWH`K z6LSFZ5U`GWc6-e<+5!&%@ce=RsQ@hiaTovwAcI2y50Es}VsrPz=k80*lV#>9a`Q3r z3$cm|amowvDvJ-)785iU6E&BT@Jq>rr4;SuRNdt?z2$U+HyK86GL2WVOjokaS8^;? zbFEhMY*zDa*9xw#6*{aHIle7+ep_<}L$~!ZkM(lTjSBCLO5e>Yzs>4^&6=Qh zwZZS|L$?|twi<74H{IHPe0%#z^iK1=otFDMt>oRdnBDf+y^i?3rw{fz6ZfAb?{}pf zbf+EkWE}KnzVFL^-=BND%#d>r>x0 zXTHCC`F-p4_wBi}oyD`g`E3#WF zv|i0OTgfqglV$iOQ)f9%dpU)$lze3=NpmSteKA38@qx->ywXCP;zF$aLX7M@MP{BX zHAj+|yMKuX{9k?W3xFGf!yvd^3<9SUH934#G5i3)afoX)4WoziJ3uYGAr9h|l{+3pi-Sw=$s)5-CTHJ8m66__N9P%&6k z8O0?T>MS+d*}AV4Renf9iwz|)e{hdWZuicAWc%Cq!P{rS@BUkn`T1a@o3NZE@6Hc@ z_k#3GQl`x_czgIksd&gxSIDlgS*5{4@xl z|H<3#katNBT|&oJ-}+7$ak66q-=I5UsJSP2V&ZluB%-aZb=;b((vJI4@=C7m%8t;R~H^mjo4|Z60rt@zMDxbbD<2@|9$9d;xEY0Aa#jOv_ zi&-RE;JNF4rLWcRVqWq1IN#ZRd9WB~?e=sjo+I?k>1x3_Mmr8F33YN#t?qKt#cKZ+fu3mS!8b-B{w`-bjTjl7Pl9L$c-O_tH|iIFC< zG7hI136#!oh5NOXy29V+6wxr)_?WbZ+JF^jA5^?!w3vMx8j2%Pk=tf zA+;$d889l=7Tj&z(;G$(^1ym`GMn8ESIZ2wunJA_9`+mQdThL1>_)b^&n$wbjuyV? zZfq`|B(iY`p;At*BL-y+J~q7N?6qw49BvD2dQ$SJ%D2Ocw(N1&=Cj6o{l9;U-5Mdqvh(7akuaD4+6w_i_T+msj!$6k6{86@G-YWAZpmBJ< zpx1F@rxyRqF8fY$!-7R}5uolz*J?Np&2(bh(bWE|-)~$E7>Zb(`IbZhq^ZSBPd53V zDgIs&QknF7WqIiRtTgbk&8<7j;O>QIF*$G`6uTO04%i6x|*wVH~9-vJObygCv^rIWI^uXTFlG3DbgE`NZC8cp}}{JLet^)3qlN zsINRKokn^KZOj=e2WQNmqzLsNtbR>nwNCv0h^6FXtDh+;oM~!DUPz50F#0x*4-IsK zk0s0@^ho^J2uy)&-UL-nFuC$rsiSqv-0JtP#YG;B+1{ka>0-gA4l*%FlYm#I`a8L7 zR-f&oka2?{b^@YiHJCMV5&%ya2oyhQpfeM`9$M_Q!Nt$%m^T$rpR`S=UTw;p9V?QF zg+iDiiauvTj>?M_J8471dn@{x{*yKfIf4|EE;*(GlF)@H3vcrZ6XsC7?7daTMT|&W zvVy(MOM08o*xROAcawlsIB`SNmB7JBDyPEFY2oppV}UfgDc}6+2Yt!gU#o6KKQvK? z)qZ#>c;_;3yVzv8masgTvBS>&1uKfe3y)YG4s6Rr(a`EtMw1#ESqmU;V4r0ed9 z@-zd_Q=@uYj?+5wWid(PW>0#KDd*WD>$*O5u)`iG#JriCq^--Gc(H9f&icEABMv(pT-SylcmEi7i z9-ePrL$rx<-0>Y%N6x1wp#`{&B}dN_G&C}JOhq>~f9~y#@oq>OlzCP2T+f-6-Z7RY zS8Cn3QjF#@(5lzsW2?qNE2j;Ov~xp!)TE$Grlz^M*3`Md$^^27^g?0Ms99@I`_&ge zm<5?~Ch(r4TOFL9Y8&WU-vk7=2Uojh*?=8=uHUorkMCm>nNN_Z5kv>5kh(Pg2dVy5 z+-Om-5SQM%N3y%wAApQ<*X;SXlU1DF zrE0!?AW=ORxiejw6>l@AIb~V3n2qJktn1s^w5*=hmK4u%Qn#OtybuY-fw`@E1oLix zxf2&Fk^vcfAo!XF6=&dTcG00aU_zeQb3yV6XHJ?jKDE^Qw|65=@ipD(oBL-GYj@P+ zT(ACC5%^21D~tn>dn!?mystjociw*EEE*7a5R!<;reyR#i~uq&Q^$#YBzPVeT|J*X z7H|AE<3v{w*Wfo72 z`9@)7&%V{DK-EFGs3C~Ku@Q1|+!PVVGvdRU*aAiG1ivZG8ovg`UWi}T);llym;em*;&OW zHk=7bdZyY$RV%Sd1;YbI%pwB><9HXsd)li~{mWKtOZHMS=+nQEEdc|c;Q*4@D~Svq z#fay&WFhs(Svvo6|D4F7mc;G|$*o41Z5&@`aRC&uEh%p^umT2X03-w;f#iH9Na+v# z;WFZ>M@>LoR`4fqKoejt{uqpVlH#?_irDgz9(td80t9>{CO=mg9;OI+A1}4eAbmDN zmSzspRVu!jo&TlZ49xmCsZzsK*ERYMa&lk0gZjX%|KiIPAj#{CaWBI3Fg4O>@`du? zx*YCBw~Sk8AtZP1C`hGSP0cTu;ki{_gvgIG`4T||+ou51Q-BAHj&N(3M!tgJqQdBy z`&g(bbp?tF#?XRc^i^Vr8NGTN>b5F_e9}S!oFLtWm;&~>Fp+M-&_F%QpcF6vfh?`t zF~$w2SE;uEPiv91ZjnZ<;ihlGHwfDA>()iES(ZbsNze#i4e%f&0e4%E z1R=TceaO}8J&wErTv}T>J+FGqEtSEhj%*^nifO!mi9nT=XQB zhF_ky2tg)5QRa3z>_$1&>oC}D0EZ!iPpF_z#Ihjzfe+Tl1z!ohEiLO-4l@XaMGJ8p z`oMYW?!^l7-6ud>h!Bt))P5FXZ+{<34i$NT@g58%4(g;=>f~U;j0IffscZ`cZGVnZ zZg>E>Nr+E8pu##d=d3H^383l#;20CI>=rYw7_Rg4D&vSKNtgGrUVyH>c#AZ0iVS45 z07lj^cFfzoUr}DEbXyJ$ny$9(@D$}c2w&K{+GNKF9=N%kY5Fu?G-1ho0b|DRDG>tI zVzOe)2gHh0m}Sbj>h75tGMN`&*qY~oF#_A*{e^fnl;dB>txvM5H5HzbmW=i%Iwl$L z=n0lHz1u(CQv?XGK`h5T0Nf5{2_L?DjTI*F6&6iJb&Ck|BMFSKI2ZfMJUmal#h+BA zj4C$J0|4?x$yToflHJO!7HnO*4rTYW8AG>{u6(14w}8i=gqV>au|HhW3>lxGq~@a- z7Za|DvtYd3%s-xa$WXZ|{IXGr3_CwlqM#mv2voUUDs9hl1NLq>=JES)fhfP=&u#PkQ0jiSf=lXUIL{J8XDhhY zD*Ww|7QoapsEX%LHCm(Yu06ZFHBGiUs1Lpz1iW(xrREDQ^x)@GI;z+Ed1VB{<~|+$_QQtFr*J?qL3K?Ap>_EP_%xeV7Z&p2_=(;G0uG9k1&cXD=6v{0D1thD^n*d z6WeJQJ92TV@Dyz}FN@|w^GpM;jxTcA`6BXWZ$=AcTMyb4j(V_zc&~jGc4Gjm;UI|_ zaF7i6(&&Rf!e{_)-I!pjw`9{JLR~Tv47MSArx5c*$UXbpKfP0w=aWa;bsYiKf3k9& zPAt0P)H?IkgucN-@u(XaWg>(;?_bg|CGW|FywS6Yz26l=GBN>}Cs;YucOj~^Bt~If znCPRs%=!k;#${!gt@{l)TFPNp4cXUn3*s0;oA`t$u!fE(m^%P=yAnz;Donw&>KI{G z57|F1-Ou%AcDd3Z$8?V=V7P=XQT7c5d;$luv0eN<39Hai^^OLo6F zX6!;Ry61)?c(OWwMDSvPB>+&#EU*NJV%hRRW4MF7xNUq&m>TL*ysp;8XKE`IOMj0! zax?X)!z?-wUn{ojM`fi!(P%@DTz@lf3ZNM$mrrz;-M;xP&cXH2Bb!%JHwN7gu4tLP z-Nw{NST!%VZmf;bt>yzoDuKVwLt_!p|5hK|OMsjSda2kZ0#3lOr&Vp)xz6R~59A*M z^@ZF{;LT_-vD^F0ZN~K?m``G!n|Ji^(+ZsJlMbgR&^Z}?W(e^s{E;xK!RfBBXmfOV z<(<|1X9XdItSIg+5FIsjjEEXnegUXgGY*3-V*yucF}SqE(;K654H3B)C%$R!)tE*d z=?C&`#ztZ6%(4i)vcAHn)~AfiK)arzMiHd*vAbw>I?GBs(Uwc?Px?Ip`{<|jukDz? zZh#6LssYCoG1YNXl*dBsDnkZ-M(62Wws2Sfc9H^WSsrlv>(dm`&Yuafas^o-?>TNP zWq~shci$L?B=Z~?K2z*wcAa3>x0ZLq>Vm$4y@%q1=fLf%T@~$~wS0vwEoCjUkk;L< zXiJN`D-W{Sy%M*r!33Bp1$v>%-u9;38PTK18yvc-_Md8nw_)%*Rd3E+%bgzer+>y? zv5$iWWdfsvp{=^yVU@#TePaHy;%j&Bi)fO6354~HR#vzqbVIl=Zpt+;SwkvPT1e0h zC&qW|&(oI;CCBgMmkR;PVVq8`rHpv1ss3gNKL;u_?v>jG)xPBI{A_sVuibE-%Jh}tw(h~2g@p1&_vdt)? zTyQ80yrk7^nI0Yj6=<@WDRAT0vzd+Z#G!l!o+ZHHrPPnRYn zXgWRV1zk(bP?i=u4Q?J?tNR}1eayfxH=V>rNlhBK6gI8rG14Ml*j(jiDD0f{3<{4M zd>I({VzK?N0V-aU@v0wDCj8}f|ExjxOcl>gweJWfl!KJ-%$Nx(2Zs^>JQKBic;JwI zE#Z~2$)nn-Y%m!PcAK~j>>`Ndd48jrDj)$Tp(@bZJ5V!?R0j4| zYtyCLo>x^xt;&-&$Gv73;F_vmis?)MCp4f8H3MmF`|$@rOM0P9NWZo_6Fr<6BJpOb zvfbC8fp#wN?Ov^~E{X+*!V!1wYecZvm9R%fSQW3j{GO6G4xiFP!7h)Q&?`2Vfo;fa zjUl~+QmM|DoSmyJ%?_Ju3LA5>);=9pC*-&?bUGP?{I4bs+Pl8B4^gp3{%7y|; zC^tOAHNZyp44C1SZ!Uh@oxxM}*EIvkb$OOF#rLh;*K8pnh@rrmf1_;`CxER@fOLTN zpI(234=>n!YrWRU=)o1lZHot(lX4#^#qhJ+L5{O(KB)IscBOp3 zQ%UYZ)8N6^&RjI;oRADWfY>wl6zc-sN(=SRG zWh55D6k+oH`QmGpze^V!2dM1$Ky`0nFM5UI3k%GGtBeOAMi$t~yIbT6DF6=k@_Y-z z08SA^@HKwiefx3$fcfeM52bGV!yO&~+V+bRM-egdQvsJ@MNy^qvY00M7E|Y~+{pW* zsE)rV^dch_FG{N`ykoicIue*Bfhq?8I7pze8-E2zAc2g(e6zq(yBXV?dwrpoI|U^+ z#Vc!H>m05VIWNRf2x+c8MgUA20LuU%EdD#Vfok&Jx4XCBlX5jk35ogwc&pOzPMpWQ zAgvI$!jXZ!z?u zx_>+ej%*p_Nw^F-R>zE|6>0?j@LfC{f5okOAM)#0_7;c_+&h3%j!Vd_(Mwa;rRYp9 zHW=hNM^JK+h0@j~CaoZ-F;?DJuun&A*1Syf&X0n*s&Nx8Ime#$ORTv$U&`hA-^(p$ zlL9G!O&Ul!3?QyTKu%@Kp-w$je8kHv!vtS?E0ivE{G#c3={tX8vqPrzH?7z5W2e_- zPWH|V|M?ok)UDwxZAaPuJKuQt;$IUrhpFf>9Oqv)p3gjuRjGQPoF{er??XRrp-+(= z_mQk1`u`qf3GC(TXx#8jWcXo?p_xH>Z50f!e|o1R96Qd-rTP1hD?e4XaOMc3p=d>H zmXRGQv5MI$Dud62YX+W;mK-|aq2-zK~I8L0jpT#KY zD+58zgLu$LErc$GO6yiR8HNca=0H=Nag$J#JtI)4OsAYAToVojUPT5A7)!L%lp9O+ zh6|7++QrKO_A}v(7~wb0j1-D0wmt?Rdjf!M>2knZS#xXmV{8hiT+zx@hDI zmkfsGiq#q=-Y?h~(<;5WR-B4c`{)bCELdp&?kFL|jJ7LEzkNdU?josbO?eltExEU^ zylLN@@)V^*E#47)uK8uiX(*$kq0I>KJq53&@dJtUkdc}idt0N+zg-M_{!7q;kwuWF zg%Kh_$RJShtWRi-H$ROWj#G8MOb^#rZ`DDk)1ob?rMpOaZYmBl0sz%5nFw4=ARf(| z*`Z)9taA6v=xyuzsqw}rnki45TGZN9p|R<7QgDO6o=kQI4uBX=VxNOG>%I`d_)O-U zQTIenMN1;;Q5u7zQfwhJR0}}h41^Bse8vTnPPHSZ8`If2+BF(7;v|Ve z(4BU2{x`wzqWG0ee|tyrroxa+!V&2CXK1qM$+U>YFBaP=z7_#=Q-WFW&=9sZj#HHe z<$#5th3sTfygo{@f1t4Z-C@}$O3(zMfYHaWSd!0U`zIA=<@1H#z8lE8Tc zk8O#lpe8(VWE;}aQLjc#yGZH?%g&YA?Ww#bU(kVvcOVVUi~vY12(6wqaIC{q&71q$ zuArzi`7zC({B6Ok1ETg7NLw`izM0Xewk@0GHiF*9&z%W` zRc`v^WnLt@27sY{WihPy7V_eNRD80T^zI1-Y?B7DxbUGHGVnYpKc1fJ^D)@pcp=SO zEap498M1v;$WyindT;3%PHY8(vh)yr@JT{jTWW%C;1EM*fppwlM4Wj_C9d8;H+4VG z!{IwMbCU2?eoP(?;5&SM5j94htx&oM;fZ0jiI4A^p^*#uXo6Mlb};j9Yrel<5X!IO zxR2$305+tZ4~=gxj?km`Pgq-gBnVsMdaqpRk|ywVUg33U7QMh7QDdzJ#jiOJf@~An z*HgB*C>d@zw|ev7X+dMd+0z@4BAv*HLfo+B5Th81EN5J;;`{K>93_ta+4sI5nguU< z|H-*R8efmp!AZRPE|@VQQq9AIi0mlxB_U)j(}s4KsqXnuBB-ckx_6lA?zI^4sOFF_ zK`z$wx`)-zw#kh0&rNf#C%6rg$Scj2r+!iV17+B(Pw>ar3MYBsg>E z4$K`Qkg=+MH8FsZ*3Mo)G$rxYA8mB|z+^vcDCypQ0p^Q$*?N;FU3#wL^c0|d#$HfP zzHznU=EuE`kAXfqji$bija2p@7X^~|!NEz|Z`alo19BE0hdrrpZ4Swr1OL*FveDq` z%(d#Clz_AKL?8cwSM)r4QX>&I>2fVJcG+)op+!$tz8+8D-WWOX1e|Y8|*~yFf(gu~g)HVs+8K$$agK5@_w@JiIv(uOQ zew9bYI=nMBtQS)QWa&n+lkJ$7tmFYLH>` zHh092o#kspE-1R~dtlr|`3o}JBAxpVL`(4z+^}G==3xdwasXJ5rQMGoZh2$}Gx|YY z>1B@}+=Zrwu7sqO1B0>#jaN$CVx-ppaJXkO zb>K^bcQUzesq^r}38g4fWe`GvB!6%mbuR}!K=^15aP+nFdan|^WT*qH6p_sRKZef5 zpXu+9q&VJ;z+?>2M4&3#18T~Z;ssME zN~Mxiy88JS&f}cV<8$8U^?E)rmDe&{T6Ww~8rkQK_oV90R~q{(_buG3Z-Zwp6_+fl z+<&$IK1yFdd|zwah5IMHOcJv76YrU=$K`*PkNd#{SIP^_&Pt@(CA*;SstU9^yul>O zK7PH{_iTeCzkQnXyn^3ax>LMeHDCTVWG_dupIZ2Wtx|SOw*208AY8}>(q(ll4&p|o zZzAhorHL%GEk_WoI&av3kvcOy&R-iHX25h5#9M{tjK;O?ukPUP<>6#m+q@ZrZbk;= zUL&oim!>e$Ys+et#+kF4`QjwqzI(1@k?E_>zyYC9CuQ-OhBn?<-v26{paKG4la?KJZcxCG9fc zv3%amq;(T8)XpLmi|4P@WWtG+r(n3=zqU%r5oVjgL8NPstDa(``@^UVUb7= zDAvl9DhBbn0VP*W+965}EUh}_D1gw+SP%1SQ8P&10eufA#Q#j{4KGPnp;TY?lTgPC zoC|ndynHfW{;rkzJRO)+B`yD9L;x zR^$W^3UqKLPpN4y06X0G?THfr62L^r$M4lBO^B+?$)Y6rYe3_63quDNBcY&e(&>PH zcM50j5U>Z?49{-fAX1om(87j;=Jn=?0=2-T8e8segE)t%A=sEPmjIa*#tI7m^mEM{ zUfZ?u&C7AtHJCMge5gg&uKc*F!D2xKkGCxg1zSO+Rk!c+&JKJ+fLr@OcT8^)O)Ih* zZ99v2XSh(6v-yfscdsi9QkTY(zl_NY65JVaS7&&5cDnpW2B`o{?-?(-CsrDuayz^* zza@2&Ds*Qe)i=C}v`d8%@YJh$q@Wp6^~`Q?oZjrB*)yZH=t7Q%YO+LHHROb<#%buZ zCuqnH9grtC^GF#Jyo{cPO1WjYP*8N$wEs{rL_3st+d&SgwUPV{NM7o+T=BQBx8@N| zRKE*&ScOWx9@`$3VY#-Z=z(4zFcZt-DiH-3>doh;%KXc_MGA~v_PTHLANus>Vt;bp z>*@MS8OzM_-7l(W*^@DwZ~VcD;YheZ1QG&^n^`^^c;3a^cmNB zP~sXRU$HT`iCKLTsTj0#LPe?-JoBzLx0~lSKRjk(eG=#$Yt>xM2oyf zJnC8JoAws(&!*~>Sw5I2(g4>#`|YmX8dN?gWJsAYK~sn11O^*z_0`@D@$o0y(#tg- zJw^|iXU{Vq#mSQ3_Qf}>HVyxWoCsN<_L28$0fQuy0$t9$v?ye7z|tcnnc>ijiLK_c zgm7&RQRHFol?j;w2&RJ14*)sDSn&W}ptstHuUZ~P_s3Ytrm5~X;?3eBwORW~QO-l_ zdW__{+T&-s>VMo_;7=a!l^W;(wD&z(^g`+uF776r=KrFBwQd~^=^bIIr<$??M30eV z=9Bl5-6Au9f{G(j3y$99(-}UcjH2D2jYtQW3q|yU*Bp5xwM+9)i$q!bi3jou4h4se zj^2I9AAA^pd(=-~AI+P4d#WLqSzb$Lq`5Q7!)1gQli)Wxe)NE=4dMEsG#=g{C)c*a z_SW6ju^a>KTVBph6VI+L^4!+J9$jLem~mHs-6}n#q8uZe?>9dg;jJ1EO^Io(C>%c$ zEo3Y(th*;$=apnou(vzsQNVTA=bb(On=N9d4BeFXKIXR9Jm~hOotv~F&p1BbnZ@I7 zgC!OEyQe37E`WN^fn=|*s#tPj3if7QGngPpTNlrvON<2*r_<2 zwS93;^#Xsf%p~1_ClrHAJt*@>Y)n($7C&{LtflCo1gFfUg(N^FDF)wggt*>hCwm-o z0NfH|ReeCHs_q$l$m{Cjvr_4C)dUM(#4wfvJ*^~jbwAOd!s6ep!F*Xi^ri8;iyAGj zLs)BXFDb!Wd_}m9h;@ABqvkm5rR67Msm2jVUNCY;aOIKwoAFzVe!Dl)gWt3sf{PvH z^0WP}51w!6)V^o#&Qq}l=XlVtM0iV=eR9ccud0vDS#A-GDZ^Tj=0NuR;qrRA@}uen zU}=y_*jw^@MT7V+`jZb-`(|eWm+7tGbQaBd=g{vC z*YKEf%+}2Qs0eq)3B+(vi7C!*tp_Aw9gc{=_UnZAz2W@NOcJy70f~3txM7}iKr+dM zi(&5$9a|Dmpr!qD1#U;Y-#q-G$dJ-x$eiWxp*;Kb7nDmb{=LZhdoZ}7q+=&OUM+*+ z$>eFUUA3FR+M(d@q2RBs;5gcUFxVhpQr5;)TOVlr*zAQ>3=wT#G7#v@g%!1j-!RS? z96EnpqAA3{T1Z^oufkxN*P&9XSA>3;CS@#l%`a8j3NcF&tB72VPgn;2q&^Cj<24&v zG!!+OTPl7Z-*a*(^?rr&Lt@`LkrkoB%ZOlU&bH|t9ga;_VfOMUcGiRTy}3v2ZX7Ls z;5MW!AmR)Za9VnM8h7+{PvK0y%5u1SL%w1;+z2l&R`3-Q%1srVkmRUIeD(aP`T9Q% z(0|`wnb^Dv`fBwr*@11bF%=VfmOZJCnJ_Jmf`#oQ{ zs_oqWM5=!7O0K<{z`-njNtEki7S#XM^ZRTUw}YF6=IZ?RkKeeHKYPNvDd@Fb!)K3@ z8ru#Y``$v^OOnbPXwYZa>m!wX=%-r@^WY>3$sjk^_Sgw8CIi>p${8oxNyq%J__jgR zCFfI@khpzuJ36KI0~GkMx5{~#Y3FuCZLZKoqoOH(yb}wVECgJfwaVDpUdWLFtbgWO z{ak3%1yG=aI5ECb8ty;WgiAMm^+5KL3Q>vscN1o}9{ct6gew0qwSTYe2-XrelVuU! z4GuidHhY(ow@Liedgr-8aux0VzTyAX@1o;YbdG=o*w8(@XcXX8F*<-=c++p0gzI(S zjYz8c1RR+RV^{&Mb&~)^>cyg39O*-k72M>Z+VjIJOYb?($IG$~*sOzccx*10!}Zi*d5Qn{rg-K@HHyt+ z@rz&XaGBsm;Wtsvpa1L)K6mD%NIX>ZcDjy9uYIZ(u=6AQ@EKMQ+YktW%Dm;D**I`- z>1yYHrFSI$B4)%sZ=^mQoEZgd4dnDvTM93KDa!vs_m0xfLM9nmDm`3&wK0BR@u#HHaz0H*W)%>YTajPkD!hvWY+bJXbPMRn7N^~MN?pjRkp^I$tA-$XdP3J)x1&P zPs2d}11NJ`dL0w3#6OR30L{ayo@D92esUa~3Dbb=4f}Co8oB5Dr`H?3x`|?n1v^HI z86Y7ygs?7@6I8DCVkAovR_F697w{|+z8*E0><;p16s^*{(Qu*{0k!BZDrq){BBUxj za>IVtKdO>tj2zU=y3;GQharH|d>Cqy#JmtfB;irg+Kh9VHCF?AdR@!q`dVq9#g9)W zo*m8Ho~3M_$@zOaBj&>C+xyQ?eO`WbKYh=yZ$j^Ogz9LAqoL41#<_C7Bu1l!A%!%Z z<4Z|e!sDedVa_fzNH03U-{P;HHP#D>wia+hB}cFtHFMAu^7-;X8F+5=kg(;bGi*;# z4lP0U;ID#2*`x4%RdQh^%878{NWf0srafd<-nsqDP|op=X;@xZTY1nDk&!|1-^BFS+h)o4M*Do z=jy!ALf+lo&z%WKVfgd_C>Moo4Z0eW=Y6bj=E$mF zukXB+_zU{dj}F8d4q36P*t1wsm$m_(m{E%%U)lq1fpKJTGn#$pzFA>C1=jvpwTikG z?4?bc09s{qvYc~N+1&uy^V^{?IT~!=u1uwL+L9ZE*-FUXP$R15o@Vdc&O3D8t4b*h zHvlTI7f)3w3UAVYm7Ew8vBaHzlT<5Ha;A5ef%wm+4$E8fdn0_c6Ljks(n2(oVeOeL zStGws`6(FEM}Q?I{anM1YC@t3DG8J`~n}EeZQOfh)Nsf^A=5OyT^eQ~$M`jZg=ipZ1r=CCOFpQO-iP_jjDS#x< zsv06!BH~|V<@XTP_;XYE{kGYw3lQ~v@m$Bk=Ov}vkWMUAlUb$Z`wqgbH>%h+V|DA*eU+&@CzGOS|c!6bm=c3cPPz8k$ z7t|401pDX$lmh^4o{Tm0;Jb5FKw+Z*FiE31g2({84c}Big=%t|`HKm0V#nraL|BT{ z)*FcG1S^VJM^A5|1d^D&y9R1=xti1i;GaF<;0;NYKmgFbm?g2Ka&6DNy}Ai4e5!}A{xKf;jhNJVr=56}s6F`VX)IH}D=$4hmPjW)hQTSi}PY2*eEiv5i3kD;2 z_X;EZt}n+U?fQ2X1cAh^G;z1xYU@x_1zy`v<}TqhA>+nKlDDAQf%{%V|?n~^Bq~f-h%#(W3Y?|5FHeDd&cvq zC`HschX<}>K`*;Tio)%ez%@81%+f?uJav~nSO5q!GVhDTvcZ+jFTeT6_0Ah^X^4$e z)y#HSs3qiw3f$w+VHQsDeFt+tK~Y025*=p+QHB{^8juKXj>ALBMf##93Fk+Iil%F8 zOzs_(eE29=W6M$1!eE|(8)&O>2u)`GrsJ9^@fy)wBx@Ti+sCOf;4dQsDYuj!l#K4e zmr%Yci%;E%MmR-=xIw2~(dn&rrP0VMG03-BFP6aJpIU4${ZTnAB&!x@sH2>is!)s> z9kC4l219rGfx&YB+w+zH=DCmz6x7M8s%VqhJQ2xryTX7ia>k& z9GCB9gaBP-Jd(>Ze%$Bd6Dq( z05GC#%R`jxxf|G_^NwZ?bGFa^m2BOU&>MBCZ=w1(rVb-k+LE^(UHtQ~yXIOc%C(IC zEU7h&IrQmqUW0FqqgMtc+5#c#2@mttIIsdbq`s6ye5IKEFYukBtnc^UWYr|4FZ$j_*KFPNZl5mVWnVXJ=Zz#W^D+*0NKP-C16u zd0pb%V*0swyYmO-FSOWs8b13f9sfU(o~|joi;FS`(tM7he8##5NeV2D9-oQHg^6bh zh<1D*Ij1igw-Z4fr1c4;;i3f@gX`>RMPEdG&grwm*0jahyPPz|9Y17Gk2vvAAf(X# z3*Z2q&Xk;{h0;9nxy}kB^BS}`Ygx=ehg=KLZ1}T<$BHlBDav_+u&6${WVv!Stu|Nu z<6j%um)_m#usGoDz$IL5BcbDtZ0;T-(23nZ9pc}uZ?Pneo{!~vSUl?QegJ|bf0KHYz!49&G`!?*iDpt7#DL-%j=0c~J1TtO?D6Fm{+&&U`>VxFX8 zJ`Fp>`_giHt+orC?H{Yha?n*h>SP*{1r^3|`6e_ed*^t8A4Z@oJHLoo^JP^iW9$6n z{Xy6q!qOxVxo#mMHY|*sr$xuhE%@7RE$_+eT`hNiz39Lt63AUN|Ux8W9!?zj>k*#?d0zO6d?0-K!8{@pl_CX{y1klZ4V5TPfL{|NB8`4uH38X`KSD$ z#?-aws8>RZDgQ3Qv^v21I>FtAue!=rLay^b1onZvA&rufUnP8_Zx&l+GI!UWK$4~I zb&MZF4-Os0U%XgC=dllnu)rOIUYpp!U>15f?367R1qk9*RR z&*+{srFYtLEDgvi)mu$x=5>!!X&nPc!3Gqrgj5iu*)*q`f#AX#tv>JTduiN~X&h_m zD=7WuWKBaGb`xWb9vB09Q?#}%Zhd)mUNWT5zNf7hOojE3NUkIWnW&_`y(i><5rHwA z5aUag_t#$jJNg!M+y}T+d%yk_z??dpr#@ExR>K zB=&|BowpDgi#IQ#n}b6+_ZPTHbS^d&(S(mvN{yozH?3lK3=0`aRBjH{Rx>ebYf03? zN^L~dwghP-?UzK@Sw@l{msId4MsiYKAf10DqKTQf5-V)%ny;X@4Oqu83m>tbVgA!P=Hj9Swk+T)R83wMno~4?Sk!-!b$Dg(ytbdl5?drcI4S@1 zs4V3$tVHaG;_LSoMS=HAub$l<5@*`fffUg%%3_0Gep8e-w7qIOc2#Ll&4}EyUDH%J zx4Dmp>d2}efqX%R>3)Y$bR@*tuA)S!zUljjtRwCPfk1``0IzECDQ0!-sWpB3Gc>?F zxxx5-idO9N4iTU3z=SiG)X%hQLPbiJHs79m5CAP?G|h`bHXC->Wb$;&@GiaGwhSw5OQ5ln;<*s=-N;f zGr5JShJ0hw!#Vq>iQL`jsI)9T(G)l^EcN2U)4?^`1c|(H-8q)#*-=AdgB~H>)9GpH zMazrvLH+dWUZoY@;IXVvel~Tz;`NYKi=+4l(<4er*+7pxq{#Qz1Nph$zcabHbC36& z8})xc`=wU;{&9G5Xa(E7NTy`crj7gYoZR4jjhE6lUNv}E;ctX!XxD@$(^7fT(NJ0Y zjce~K%Eq#qk25FDr;tiaq?lMS?MhOk3SU@Zq8XpRSYtVBIBbCekT+c59aWD_0Ia^8m?_K98; zVqV&J`9Abe*%B9xgAnUPgR3l!NHFcnh}ILXXOGbWdGS|1BosADG<>jWK9*XIbo{g` zxtWmx4ayW#y}V_GT5qJK_MTfi8d%4rE`=VuRQ}xA+VfW^CgNnjDcQTV{H5fWsvk%e zvH^aiTKZG@_xBO6r0a{#(&`@_{{Bky5qXe@zOrUf@$NWONfx~)nH>r*{!mm)u{qtyTsZ4e@al7Vt(ntMY zyWKOKYZNxWW@W=*AL35TrxDPX5nuJw^N8+^<1fW~UbesQjvabq894?;uH@yfbgJ(k zR$t8n^rBj5v&9wxO_!4OE*k~jI{(SLYe6o(kL!(-ccStRYrVf8aS~eV=ccgE`w&?} zlqhQa1YX}2gFIRcOnP61nCMBWAZ@KcH^)*p`_?WSp7f07I>-;=qAhR};kejkX5?%2 z%Rw0{i>y4=%u>~?A-|t!s9J}+vNZI{A7%Vz;mG}y>LL*psQJI|H^;tzUjB2lFqEu5 zNhbUF6J$3E(H^$3eKnEuLjQZGfu>LYst>McHFbCvEOhJZ3xXbS zSV!t6@1jV{KaFF{Gp|;SET#9nYBCMO-th$LDKd>=_hlQc`H`(xS~$61WbX||WzxrO z8cEmny$sBbrR~6@-^Dy{Y%O@>4YV9FTwUfckBMQGbOb zbMq^mMl-qA%)6!IZ*)AbnHC%RwQfeMMCSa*O2~Z*tZ?-eY!HGO@x*)XHL4ZA{Qmk2 zqXY4lp@FrM$6(Y8wbVHE>vD#tLyX4?>5!y8Xeb930fx5m?DV29SmI+%bykCWR$ZcL z(QnU)a_&nO$D}C0Pd8cBwg^RNLiolu`AzQ*rz;p&+6HhG?bbekuhQCrhC)e+4RfEA zAQJ2nTC^1c&{h82raat(i~xWV>XB{RR=1nGtCTsdW$80=D^wXUR^~8Y%HzpX4=b+`gcLUzXz$0>i+$AHsd*{ zU^qti?PCSLd)n$UEmcDe+gy16@EhVr0l!y8AvWteCpYCv?cJlFxN0v40UygIIOc}4 z!bgAkBSw{rH4~qPz8@_)WTcuQdNtriaQ3b7GD`HZ@ux35*i5JMLZ-06W)aK)JkU-! zP_td5v)0!2&X2HbsE_$V>rZdqX<0>FQ0U*m>vcz*cRgs`XJDT_)lq@^ z)LIoy&ZY6ce_h==y9z;Wv?a|K&~dbe>0<IO z+oZUg|La%IlMvlQO?`nH%xr`dtp<&JI{RX-br5lGNb2V@?k;U${lx)skX7&p5fAlC zz1g)4T*MpIzgO#yp8Ko;kzHt|m;Bso@cp6sweM3EwTv4j#iFWzt5?#=CSYk<@z*SL7&-9fe2S$8#;l6N>-o?sv7RA)>h2k`I}YZgtBeg?i? zr0HJ#9y8-wi*np3{7ue(Wh$t;ex&Ch%hh{=ejcF6Inf8bQcHeW5WT zmkHKH^~|J!BzyHT^M`z!m5Nc>=8&akLVSv}R#&1ruHORr_3h;6xn;2Hmv|ahcXe4{ z^;hxW#*dAbm%|b}|G4lC;cdb%p&I9vlo4stX-*JKA)>%9L5&m+7A2MRP?8aqYt8){ z)RI0?)D6-!L#vr8nIi3pVW6~qW_Q4r+61}Rsqv|B`Y=9qZ5l2r6Gk!9ADJ?j&>y3i z+o*n}sHCZvyP2mMtfFWYNcK^cUTy7)ezE=Q5#|#{BjM&^29r51wuV~;adCnd5!P2^ ziG&$PvyD~rRLVBI-|4?z_$vgZbK9Sg*gQ8a(CN%@PT6JLIJ?M*Nb)o6M5g;hSeKY5 z%PHtT{Ta?yzI_?0On15nF~_9hu?qWOA6)%(TzqPLS2JhaS4Ikqw&CnT1#ARY8F-!# zW$j)vdO6qA##E3e^pV~vM#&B?E)(iyi?EvnrWr|U#St?}>eo1EO>TRZcY{W4X|gD} zvAHj~vFU!Rrv4b#f9Lh}Hbd9Ox;vIOLDvO%pseYh;mnATxas@G`E_!$euW*FGsyG4 z`tj`eP}}(ly%y4~ROyKs@03MX0a~=JlrcVP_(%;`z4wXBDQ%-?!Hr^ZVwPYL@PzcFFo@WE|Mw8xnedzJIlR z|IhY^&;Q#4hO7vyqI~Kkme|~*yO{rp>0wdra6&&~U#h?8pgGZbK+?(H*#fH)ul2u5gtUCnY?SPg988Ms^l)^t zs(zIu)|l*p%%F=aKX!A=XXd(l2E^YUmaA>fAg^BGNJ{dEm%eHFIndDqrdV3eUZMZ> zsspncY=3|%f(k7YihY+d)f9gthe0LeM~p!Jz`&7@SJfm&MjfeNr5;{y)GnSzc9pH@ zHcV-0{pfqzrFKjHa=9uy%&SlX#ilgkf)63?*&Q|l>q-2wAQGnC88D#~WZ*i_H*mrO zFI5Q2L1ObqCxT606+Y}D2Uiw)g)vVVTpbJ!*>9fo&^D@ABCE`!n;Q@RqgGNaU3+x& z-2ADXidPB<9in9RQ7(f$b^9}F;Fun2tb@r5|FdOr?cKY+tYY~^2%{_> z2ge?=?uFJ;l}RJ|=8kh2iE>X(My^_0WFK?>IV6+$@m(^~lU(*Lqsmn6?x&cR#;orz zHfoFq9;W#Iaw!;cQ(G2uvTgre1?^kPFtLHeoEdvm`fIC}!aT|J1s>D&HyrnFYa=sw zIj_}j6SlmPMfn*cZx)vB3<#3lYE9i6JRUcA4!*adfT$*ZdN8tkwt9$1x@1jByd zLifSNFs&IxNKybM%^EP70g1avh7vVK!^s>_QU(gEvP2;F?UlmjArjVHR9vpi>@=NO zW*+|9;3@I>wKU$cnrsf43x=H*b@drl^$uk9m*H9@UZ@F4Yxxb4@yzVQsK6_FjkwX! zp&Z$1ZS6w=p_Z?P9uH*hJryoyyMET{Ipl`7vW*g1rv+uVDu%T+a8%BJRPq*&Hg!$0 ze#~wW3b1TFc0_&zaV1`6`e`P>a~s@VYg&IRcX>OgTlc3?VC(fvLk^#b zCa;1$f^e801U-~@=AmX1*YALRPiumneq)`ztkY@By3<1jx+)g(HbTSjI<*kfeyxXF z?+p&VsYyqa*Wg8LtX~t2!lnxyV-!S2KSvzLFMW{|M|^S;rwTN8pV z8#}LmpZ8vP>T4L8?Qm*)m2UR)>FYvIr&yP_rjHMi{Mx<6`0nQ0>+?WzodQ5Xa4(^4i7stLdt7KQE-o*Cbp2k@wcZDDq1|L^+PHj7Qx-r)MWST z4(;tREvYd}HRKodh)HR4S&4Yxdh3dNrBQix_E56bH$Q-b#Oj;6Pk;l)HI(p@_p9fU z&PL*Tt0&xVYWf7I-uThf9_!I&lq=mpx|{jjJJ@K*J=kR2tN0!Ktx0iJHU0O6tz#XH)Nb^;9|Ho`!>GO8bTA?%VR=ey0ph z52n;wjM>26z7y$wbgT4Rv(;ie)6DMsgi-3mkGo%e$3*{?e5S8a9R?YwGEKU-_cjV9K`jr@vFSE zP5}q{?~q=v00Fk*+__ij$5dAX3MVp>1Vg#igx5>nBQZ^)V^yR#$SW^eLsK$t21K0z zJp2N?Qr|LbMrN$jCXkae$dJ{wy;)5m8=(*AymUfe`Xv>UojRR|8U05wTA3Id0w6A& zdKA`~aI7l<-}m6akPeKvB7&_D#vgB6s`>b@f5uG&%M!uDwa$EMq6dpamf=OK*q?E4 z+pURFjt*{qZ5M#Euzo##Uwt z28KF`r&ANxUk`6utlm#uePXMTOd$O_sazw9wXS=;(`>8K*09u>Ttu-9pxa8_4Fp-$ zjQ_Hb-n=RB7`k!Ee=WLV%|m4`gSL0w_MQJ&2*cmMv?=sTjfZ0yr0@+yOam&b^?}$Y zjHQ2Ib6dN_!~1lm4L?YRQ;)gqKDuy(qWSwB@p@R=N8(4}AFtY8G z(Q}e&C;VQl+64IRk(_M3PPDf#03)!6!Mnd1wQT!@*=o|2ioXl0!&LjKer@Hzm@pWP(Sh)9haErD<2cQwaLIu{!*@1uE)u3JMBB zG_g^XpzrWr(K=uQFU-Kr68@BxA;)x7TQ2`oKM5>(uy>h*4~_klB{yzJ$qUeCUnrh{ z@QxF&24ki|hSKeYw&|frNB)N9a;NHrE>)~XncBqXL89YdlUFMb*2sq9kuka70H=9kySy#^dxOt!hKFHs@4agz}4r8p)a3Cf~HH!a(+Z zAS@eW83R&Say!)rDmb<8ElG8KjaczDq~yWPRY|NqSE>Gr$(6YM-}Ev8G(n-t1Jc@h zzXmpZC4BBHjliJq5MA2d$9>e9`M4e|VwQz8&HC@^YtbUA2zy)vnP_gmNv!3F!rNvE zAr|7Zcj)R!{kIm1E69qr072lBXcrs&P4%GR@-ls~%yiPn9#ZD!nS_t`{H?^)cv|%~ znpYDwZ$w?)YDUcVXlXcE20;*=K(yv}%V8H(6Z?Buy3eTe9co}+=Mr42PeSR-LI$Rn ze{+#o@w5$~WJ&;NKZ=NyUGe9i9sv5Z5CDg;E~Y`1vQ^sO+fUDe?>`(3t4Ve-u zen|ZrWd{kE$WDQVLyrwV39GhUV+Ugb-+=X|n%)bH=BJ5OQpRS@9(d5Rh{uo{I?(Q0 zpKwR@eSq<>o)06ZV7iD~WE=EQP54;wL6tzSM2eY8nsAx>3Eqa8N*_YOUv%xXXV=mr zGQ>O#;p31+9lq`3?y`Y0wDg&A?xYrUW>u^YAQhZqhMEhbj%TheX&BG;n_}46QS77} z4rWI$5O_uJnOmPISM5(;t#Y`qO7+C)N%p!}ej2qrX=rtGNt6=*J=QJI>a=Je7Mt9r zBSGHI$=SyCiTZ$m9{k~!mf%Limuet8by|p&0RgET5We)L%)+}aNc!$gX{QF`d(gq1 zuoZ_xBk@yCVe$Tdo3O9i+x1g5wYu`OIH9ZFw?-;#F~pStQqSmuQ6sU&xmMC;`RL^j z9t6#pRGr(Y!7L5Ts~ShqxK1c2=Zb!Sw9?VNgo;m)KQ)j%OHXx~uhC*u9#`I3L1UY# zf)5T{jZ|)dgk6jW!3UJOrw`*GDt+=+sQM`*$5aYD;1RMC5RXs#oj|xUUMQsk90oBb zD@@#nw6~oI$hg;-y~&+k`t_-A7;M=@6Fuydi>KeoYhbn;t@vIjhW%>aqO*sru@E)G z7)yY{8zJk?51FVBCH+ygZKtKwV68fl!Si4K_^1ol&vN~0d*a5{I2TOO~Fv| z+bzR)*P6!__LQL-65jcACYHv}a$VL-6pP90Har994GQ|5r?H zZu3x?ibFf+0YLLNB6OCC>`qQ%O;yj?Y}}`cYp)>F-y+wGJ#p#ha^D>`DBOH7)j4;U z)Aqp}>Tw}4B&YUxKhrSxTJ2U-)YUn}#akg4C7}0#7BNO5YUk|F8=pO!6o=ct=j(i# zF96M5#*ztm`_rIeDQW8>SwRu)%l<#XRW-A`O6cvh_TL$Ks=pGBYVl6aMho^(^-YR# z#wrSvUKiS-0G*cT>v(PXyY8=Z$_-znrk>qa2e&I$g5n?PXR*q6Eh3Y#%I#f^b6?62 zQiP&aAeq`J2AwHCS}MJ!GL6LG3jXjcVPSUJfg$l~)d3i+`(R+?^2fzJ-`*_?? z??=$RZK3z1lH!MM&lc@Nh8|4S!6yuRwS*A+TJ6~5e&teFLWxYk_@Zyse;pILD_Rvi zvLG{Ik%wUu02e>yBGR?;mc+?uxkj>(Xx9mv+Hx8WOE91KpR;rpxzBK^31u7!ic7wH zs2R&c>&)vkG$0)Fzlbt^n`h|s&aKm4=fzGNBcnOe11hnG2Jz# z>+Po+#E95zadX=eee(=;JSKli+V|OB+5@>KZ({2ANMu(Is4tIh+Fm5x6zPRNxyQJx zan7L($#=7lyv3s{l^;en{jKTciyajg2snE=S# z^os5!)BpS{{Cd;P$~>;9RG!PNH0Z4aH9_t5Bo+FEM{occ@WN`dvT~<_%RvIS|K4=S`P{9qT_PiBsMr0~Mlr#Qb~<0Qfi2q5LxrY40)d~1veMa;oZGL202P8y zEEj4gCM>{cd3On%#S7DdM+$zz_0tOti92=;&ZjJ|z>`m^wRR%Et~Ck=&qlBZ%@RcAy{r&n~x?CxIyDWccL~x!uP&?-$jcCWpJSsnjx4W&rCd&AU9)31(aYE zssP`o-=m3=#$US#t)tsoV3=je8fbho6>8{+>V-;&HU~g)sc0Ik(jKME-xKTKD~!P# z)1q+rkdny$eYlbcDd#{D24TRG2j3$Ag7XO(R5g*pj0n2g(bAqbB06pZLJYkD4`=kO zBEMfkuLCegUg}&5K~_#u*N{eY?6q=}2^2%hlJuoc(!(AOkucrh9F-{gzGPl&_6N|c zqo)4qM^`duhr!K;w6_U*cOx~i-VqiF(yr8L&!!9Ry&o{qf5V{I==exg^2xTy1m+Qb z`H+n7gsq4C(Ve^JGcD!cakZ_3%k*ZfRjVUsWRDl*Vl1qj8_OL`-p#r?{U-wbEE~cM z$E%GJD+&|jR2y5~ zcw02h<&8Y(aGs&fBEjkkkLWB6zL!LTzXla0)f64CIjV1w#7vA^!)j-pLN)XPp=EK{u+R#=nah3)Cp=XWW$IJ( zuBP;o+i@E3EG^f_7{_VFumgkf74#(~2F#oTVxaO)LCAoH3R{GoKWK=JJ&8~Nnh z2FG%_$Y(Qo*7q&4BI@~ssEE<00829Fb)fnuaI)MS9#hc+j$v*@cBgg}KEs`Dze^WpGdCdB)TCEaM8) zjXr6Ti^4^gTjuj=M&{LLil5J)+1uzWVuCwBB=X?lGrnp;8FZOA~ilh04dy4XDhoc*u1Hp>tAq5CmD0D0y`w&cmM( zzaEy4SeDjNnX%Lus(9o&GIg^axSnWc$dXgmPds`U;upLxZV=wfxC?0Mo=GB%+%M}c zEk$5snT1lQe*K{iWU3#L7nFg930R;CLbvrpYlAjp?8nekT{H`E<*Q+*a|z6^m2Vq7 z6liKzLX%u;;V2kjxss3{z4G3EqECs%(2vXsEO})gnGC7p5kTxcs~Oitf$6jJ!$?OB1Ij>@VpySj>}a}l8&sS<#8Y(Kz5jF7_cKrj5nA)e%FJ4BVj z74g5oMUX`b{@33V$G76GT#P4-A1nPjwlxvjsv)(r^GbGC7j>I4z!s)jLkOrAxXs3B z@deS^&=IRTbLIDv|3&bm%J5K)k}&xDgrZDI8V4foW!vWS=y{ z3}cncgeKi-V)H#mXefl$pE?Dd@J!=1Wv~6#R~aqU!p94j z7TjNsdF}I!fN<5UxT)}~QBwuc-y@1;2-rg`cqLpoFDAj5g~R$XSJ1^Kv-i%1>*A&O z9Scja)OA`4`9p=w5v=8y3CO&X1MW8YU3l&1L>eQdGVDzU#j}b-HlZ3Y=HEYYfuWDt zuMp!)rLLwx$^iHVM4g=yTJMaPu7K|}!IXMLox4J#dT(s49_Akv<@-M>Kg+3=1>QqA z=VwK%djRl<(z$0*k=re+8upUKqq}H_YXd%SFayQ^T@t6Gy{qEekA{eyt1}-^GfH9{ zvXfrUBHRh!0D)kDJzixgT}FipC`SMe|jzEVpc7<9N6* z3iy0hmkoWfDrKUo7xxw20DGreE5=~9JI%^snFUPLdhwC7L$_SNZrwCG8OLZ) zncOZS*ypGMSK^Jduf7^Ne&v`r%F7%@;U+z06QEil`_v+8Y2pbuB6ctozSmYKua7?H z8`NUslQ#z-PhTNIS`%mXGZ)HK@4C z>r_OoOFzGTB1p&fTM8xHK1egmowcDmXXvMLly8|-mgat?DXG_!E}CLtEgui`e4RDB z!#((Q-*IU@keAt)+n+F;e5}nLAaru>(K&(l`!7tQyD#gHLf(HB+oGocAlW;>F{k(g z^(|lc>G+kV=$zW$hlPc`1zqCU(Nf=EYQiayr4t})xu(yp#}}4BiDxnODqnLGBj2Xg zFT6b=6{AIfAjSR>w6~SEdt~Ecv(sPxYo}Q>$?+#)%K!U3d$Z=v(+HPENdic1+wAEW z+xHx@51BJAtz^9;Ss4NIQ-S?>>W+RaNigy^I&(@xPdO?Bt1B69_EbmrJ6+E(z*<>2 z@C!`mo6Fx;^Mf_gJ|IR&f-9n5_6SoC+t2VGruu}q9r2X(Wm>kB3v1l6&^B;CZY67A zE+0x&JiO%8o_O(i^cnxx!Pd9H8BF!vGuHdWLyl^Dt@3P~n>9ufByhXHn3KKbvHv=i zb*|qe;?|z}GCNQiaJu?@dGxU=hH6)WZNsXk4=I%U$g~Br-|wuQHeC`4NJ8>VE7>6H zsN=)xs-t&~%QEe~`&6f8)FvSZrwk9i^Kdv7C2!E9zWhqv0Cj2E_|jh^p|obNHP0jP zx0euB|CLAW{72h}KyjUHDT$78&^xV>;xNBc%a;{yF;UCTAr$_NEkDIA$-SQbCd1B3 z)qzrHrL?wAedf84%3Z$ECEGJ~e}i4|f@m*iid`bb9E-gZ33IZ9dl}0d3Sqi1pw3i3 z%rY!+85Z&u;yqw-`2Xac1z%JDy_ zDc{kpbSlz<7@&Zd-2DDe<36wR6wY~l&ULQq{eBUHKMwa$pw-8+*&|c@JnsewDMUPt z4b(@OfKg^QZ)HsxSZ#509io$e2!-tUJ(+(J3P-)!y_~M=7=NNtNj9Ni?OI;MXSAti zK_BE~>z9M+@;%czK?41!4$ZOt<(`f#z{rZHYDj{RVRS&kJtu0q()cgMyXV)dp@} zEf00EM47Xv_)>LQzF!Z-KenN;M*=TEnHm5Yn%ZyBrY4#%C)UY99LFQ!_={@NQ%>{I zxbVBN0bUA`?Vve0;1@ICN`hBT3(#td#7zb=`Zxs7-^x9)zp<2Yk>r7ueyR$@ z*Cd~`T0~Mc8%7))WpKVmIGUZDAT4+|oGw5nEj*sUq8IYcDs_{Id$c2DtV3FA*If%0 zDsCgqV}nYm(OKTRsW=#VO`HZ}$m~ba%~pN9m7A9@rY8qSm7%qOt=2c!A*OC{i|qVb zgx9Hxmmv{`l)bw>DzCq%4{|8Dv&)r%Xe^0T(!I zHXq+;C?8q0DDAsS@{T|&e{p)4U&D`x2sX8M`hIML``%q9G6=^#1YLJ~amC34BtMyx z38=1m{%qt&X5UdH1!vG1^Nd|I%FyTbY)BMKdK87={B#MlCX`*s@erW~R+zf8ZB5b& z6xTv>8wa`~Xmb9vm7K{xqTn7;#vGh+DGWn7m+zMHgj7ffvw0k*M6jgFvoq1dr7`K{ zI-{jSeFc7l!0*w_6ZS-D>s&eAn`?%mszX;&M|D6gX(Ux0eFYl57v*LVVGap-yaqUz zDrf-^pj!?bm8Z2_hnSH#;z}TH#x}AcHp1+&TgI{H_FUQ_Tv=0<#`nM;N*C~218>`V z_ZLWgqgAMVA=V&aL7C!wWA00&2W~1xNgsVF@hpW37for&?0ZyxD@Oq(b6L5$L^?1VT_Rw zl|%Tcuo&(TVzh4Vl2DI~DMI#?Cm%j5)Bo+7iGf;mmW8I<1gBT&X6h+nc#@(|R} z3(C2u`cg=b6bhT}D!$UOGsK&sk=$HpC+CB<;!~eX0AG#B7xd&86@8$i!#0PF@R z5*H};Owf6&W>jCyEf(=4gjVGz&%-5q!O;j|)f$2|kCK0XZ#K^}8K9>V(Dk=KfFNxt z8tC_F=(ZCrJ&rcSbhv`_B7|o^zkb-@Ws8t|u|P(#ypKs~gd-)es9HYp88tJ$y8R+n z)Iy*X04P43I)-dIp%C@jx^BVFd9kbxOpk)B)SWIQ71*C+=#0UNZs6K_jLQ00<4dqg zpsSjj>)A>6SA(u&p$0ZBHXNY9cdko_wwrdKflJUjm=hJ&*}eUXMJ0=X4ze8NV37`I z5;=h_n$j%u(5&*%uZiX_7L9)4)r`GcS3iBe zq9UV}1JpVWE$S`rEv4{3&5J6@bET1Uy@f?EK%uQuKOX$|KSUyO`uA#+JB02T=Ur!+ z>INZ*nLya^;0h!8946H5aO0V5kvYl<1-GgwE~14pFzG zJ4)!sC^)dp)Wuhjd7+_Zb^X}(EpVd-`1zl)Ca3Yw7n@4QuCtpK<|X20hvOI_7)^Q5 z3s84B3jgmG)D~5fqhCX&rw-4&6rN9= zRrgS@x;W_&&C_3Vue^*QQ1yKn>MNP=!A_S^;H86Hf*8>Sed{$qd8guFU! z1@$t*+3=~;S_4qt6zg#ZDqh$YGk&)bKO_1IBu<)<{yZZSuql6awyWZX0AW^;JS)9s zAS+10*@dC+?(IZP`x{mDtFMJckHABtP>Zq97zApuw@%Ch?oY5Hk+wIickHb|}L6 zMJbN<-s?pp7uxE>MU$T4I`kfI--~31kyM6S1=ZS@?bEXO$eNFtV?RdP`eW7ExkcTw zNE}HsZCMLCywVhh!gP>-#yLoq8vI};I6Y-dfsARmJl+}L+WaZ|%W*xuXim2i$_q~s zfWq3&x|&wsY74_%`DyUXuwK(~z zPv!`lmVih>JKfh2lT&7H`HYk6jQ`f%llcKySTvOCoezFW3MK{4WKcj>$_Qy0lA!Yp zpY*2S>lu-UAYsZk35(gzuvz8FS@j}sIf=pt$$}^(5NoA$^->4G&2$BCXU@B=^>2tA z|AA~fj38}4ayehJa2<%&+$$0X@CpDxfE|HNwdPh@w`u`*vm&Bz<{P3}9^n~%%x+@) ztsGk*JE48-XgApW!~MTQ<>U_!ULQwaUBcY~T)zV-lvTMhy41|lF432e{?4f5`p_6~ zKcXO!W4rygHR!R*O1^)`j5X@Y!Ch|cszpC!)l0G>08#gLOa-_4{PIDmpQ|Rt^~L3e zYg3Tl3b6ind)%8)EA&MF1hz$jUCdE(-5ho?%nv5;54zLbq7pEkpEZbU%jPt1&1qIm zY1X9ILZ)!Le_>lLU(`^quBER8quIEH-=9Q=o)m>bZpsv-1A`XVIISuvXqd-<2gv2{ zKmylVeLe-X#?q}pOs38m7?H+VJe;`~JKfQTDGUXO=U&3H@ z3^Fm6!Im>nx-1FnM$;1QLE&7C^r)`o_kl?!QR7dNvZc4)pagXZ96)yryxd5gus7AZ z53A-2{InOD-PX|HA?m-+P#TbcTJ1N8>*&;Yucw1@w%wR$h#5<&>`e`6Ax^;K#$cMEfIybLvB9 zR?MMFu((Ybw~JeDa+l3-R0BY3-xw&Z2XsVgyK)Qb1urN1CIbkNK&k+p$dOy z0n>y~ToMx}Lqh#j85|YwPq_o5mn|p8bKTDEy(pjpPy7f#@2z8|xEeS+sl4VbXs-UTy#KMvCRvA3^Y`)hH<};F;2f)6V0amNH7F`*OWg zg|da3X$5L>1(LS$em0?8doEx^qbs=ZR-BY5;!=l zJWBQS1xAI~%-!+A*;e*iaV&0KLU9F4di=eG_g0J`f~M-xqVHSbG#mwEC^Li+G^&w zQeU>}2e7VhX7u>vHhR1Ji?uYCb&@NBz<(Xv5-xDE=cT73UccEmZYurK-D-CN&ACE!Wb{Bm7tI(Jqdg;p>~%uhXFR45f~ znNV2v_->~1LE$(}{rem?LnKd0e%({;IzYcMa zO8;2AeGS9y-|1`|?iYf!WZIWoMPCBrB%JjQ?WnXl%&~g)!a^o!NRX{ITQeXYK((Qu zbz(E__9dY=1sQz}+h0PZv&v(IrM2v6{YRuPYJ%sY8Ymz_*X=nf7M-X)@sx(t_q^(l z37DITy(I(zFi%J()6hM(AF#?vxSHx)x$f@7gq2N=v-ErXGsPXUPMgjj?|~kFJc`@p z%`vK!dulYeGSrX!W*W;xFJ`6_5ug7I*l@dSSp}1G9=CC z*ZO#N?LVnHZlz30XK*Mr2LUMSbdnulgTo~nHWs%;UQ3Sl&gq8RcMC508M^WPp$b6T zV&X0VMOAtc)mR$iDKoY?@^n4fktJFWd92h)e_@nW&fQH2#Kka$y$&JI_U?tR$FLPi zht;;A9&?on+GB|X3?zf4oFpNrh%_=j*Je5_RY0lRBdk6hqW~~?ijt`=qc=w9c*6SC z!7C$ky7}UgJ#2!;XR%1;B5(<#A&pwm9ZNsj>L=Ui^hZn<55Lc{RB1qKWfFZKxysnU zcc@5JOo$0DgP7zDE)Hr=v~3k&Y7B1=xk)>o z+m}rt6-|glgW@{~PYIv$&V8M(E7KvHy;*QV0byVutswx^{Z0yBW^0sW_Q}_wJ2$jr zR}sgk@9%ynXy&vk6x4nU3#6!BL_%%s^meQQTNVYni6^Z8evWshAHk*o<6h*#Zf5zItQUW z$LT|5GEoD%;&EcXmaovC(b=5;`f)AZW1SvYzi@zDB5AB=-PKUEV!7NQ;c`-oFwm=Q zN^P6-QXPkzBi~@i=f9vogYTI6@y{B1C_OKgivaju_mVq3KbYp^vp214CL{FW7% zVE%b0CSyJQ&8o!$z)LDmU>u;nq{JU!PJ1c{d1z4l_fs21YTIAx;?Y>MZSQ&~B_x_R zSmVHyY4?E9rMxBiz;@d&Mr>JpB!2ABROm^g{fAY7Kn0!G4Y!|Xzxhv&%oo4?4Sf4M zem#Q2v=7yiJN$OU+Y#3wBmS&c&9BP5OB}{+t`<`Ccw|HquBCRDZCYRp$Dsv05?2dP4Ay?Pc zuSP6Ggp4ELoyaY{fL2R}pV8Wdmmz}4aaw1$%h+qCHR-U<>lm~Be}?l`@h?m6@4Ohp zbWut29C#Ysa5O8Mp{Jlx@0jf_>+{&KUTHs44gPf9%h8vbbE6JUuca&UCnkv_RafY} z@o2|TMRZffNxWjoOUA!{K0HVQxQ0~JIV+QNUnvC%pwMCaMri=6D_8*l0PPkdzaJ<2 zX*o}Nu#!12Z;ZS-n- zrjI;FFKNV#QSi*;%L3}l%!z=X_g8Pc*O%(($h`nQbH$LrUUZTkow~GMq{7g7$jFz zwtJ0yBI%SUmN&y>&0m5E?1l;BSi1!cT0{pmvZH}AIZrakZ%QXD*)0I&R4bS zWMXL@h#VLMgOkQkoqTX_i-x>JOLLOuR?@ZaB@^<=0&)sfXD@ZTd;S9fOghp!e?n~j z#KH**_88)KQtNtkt8u@8lzYm`fxr|<>fLXJ?!8x*bUhoH|H-Qf+WOI}L?}LOqMwk7 zi^d*|3#<-%!sf!q%#9W*|mB(M^!@H6R+*jN*Rt&=~Uk(W#6xr5!F()KPPAGF3ae? z%m}Vi<<|u(s$>@G$gFhn8AVI_$5y1Y8jD`80*(9a;sxn8sMW9(EdH3^C0Z6s=>#ut;XBzO0M#X+J+2bsNR&bL@n1A5 z@L<+?)wr?Z&v`K_K7FMM#wp2t>NT0_lo%e*ED2M&6inJoF17Av;#tYqFR}hVM{4Il zGW}9j?n;K`RP+ofv7QQPazfwWH{l10x4!M~7RPBpkd9jESExNx?yo zOi!j&VvPcU)I#~;0$mOH2@~tOftMP5n*;pc2Fhg=E}g>%N3$+%_vWSSnc3fi+IPg- z7q(KW6_y_;^~=Sr`sIxs4$hjIf$mM>O%)X)CI|18dBiGu)t4oWym^xgXGQ{zaX^$e zkd-pDPXFTIM*F6Q($V*!Z!rX0p5Zr=!?nZ|Q)7c4t6Uo)P#75ih7%eC4bt~HKChdP zk_|N6;g%yWe1HB|rlq`!#D1-- z!Luk%fv+Mn<*}LRihZn}mgGO3Dkv6JWEbaoc1)S>7_al#BwY519!9y7X`53lqjoHF zjPLKyXshK^<-(ZpW0c*7nwA@rxVNKthtAyHIB$p^31V!o5tu%ZjRRiDD*H7|$7gkco$cf)jn@Y+M~Yxf!*>Y_CoPy$*ui1b~+xmB<_`O#K*#4mr1LB*kon?y*B$9rf||+=Yo1^Sx;L zujSJA80bSa<#NmRs6W#Beu-e7=Y(?>!)QrE_l!5pqSMP%C-mG+8PFVX+)lP)pR{UZb5u(Dt8OqX(qRa+K< zjIr+&Cqyh`Yb@1C+C1}?$4z~jAX&f|8z5KNejRWi(av|UEXdnjxBX0*wW;YrLE{MR zBPJ7}Q7eHYN9Sd$(vPfVU5<2eITy(sWBzzNM%Nx0a|FHdZ62EmHw{kG7@cjoTC`f^ zHenuVs;xL@IRS{qCj!nWfU~j5DcWRs{cwX`Ldn(DvbSd0^y62B?ar(xcbpc5+C0Og zKN!1srnWhx4Endx`hDWmI)81m$@Avs*8B?V@M@TnX$seMH@elEjBB6Y{gCH0h|<4u z+2w$|0CbDffGOL2ycYcZzkn>4@7o49{z~8a8pT-+K4Bt zOTc$T1I$vggbb-XJ$OYW9=Y`-WywL6iL2f|_t;H(8z*e;c9o+Ks(>dAw=cJD47;3A;-li*^+6FypPFU%sG6F zv+ZxyWka;=yA(C5J93 zLEumvQgWot+R15a`n=w0EoN(iwP%ubeyn-RG8k&X=Dd=X>Kxi*GoK8?Uc$P+Taem5 zV)^g(T$*jG^NxqPw;=TTh@PpQq;EGz!@dVNH?dyi<|hUE+sr+WM}4lkxY)1%H}{Td z?_FbHk;`%Y+AA$;C=_|_wiupfahi;cUms^)0siUobpE&Ke0d2u4dlg4MW2Y^b6nlM zida41G|oHlv|fa>7ng!6cVscooINE9%@c!Fs>! zbTnCM&+T}m3#~IFa6?R}AWxY^Jv=yuE4({$?bcn8_@iBXSr=KjW2JrvE&H1s509BX z1VJu5cVf};=eIWliF@kfv!ex@$u1Kqwm+j~q!zfZd%z8&1T18HjBjwjaQjmO z)=JWoPrp1?xu_gu{5PgnHXjQu{O8f4W%G9R&9x!Lv?)!kwI2UJdse&iM}pTX*%p?6 zH<-1&8kiCKG0X9|(<7ck*nY|PO67k|C_fyV@?oS4K*6({)9!9e`|pt_1P>}K))SiU z8Sf$5^BT#0=tu1lagj##iF{(-7nwHlpUZn6r?@LM^4{aIR{9Mu#}fMoT}!$384~<% zaXB%sPd~AQtxwr5vnvMBvVGt#SPo`u@>RvN-2T8Ce~TD_XDJ0&hBt-}XG;P?wLo9e?Kd@gGVz7m^UZX_V!CwQNtbV)Yv9oyhwWmt+(e;e)#0S8x5v zU^+{mEYvejdz;NRX^l4f{ZB7<;j87&9UL@}na{a6JnuRoFCOljkNSumny04_7D?}x zubO}FcCLDuyXj3ZSQxZcSy+>5e0BEf?|1ihRNj_+8(`6Ar%2k~4RAQ;FGXC_k$%G_ zO8pslU@&>9-N)j%HmExuJ2>k1a3zTT=-~4E9W>u3{)_XY%r`-cDIwQSzXITzBwqNt z-oHDpLPxxS9xW!h;(g7~^ubXWEF@rUoh%fPbM&zIxk;7P^^hYzwWasmfx zY_C7toIkW;$p@=u^;f>y*mxas^}QV3X0r^dyR2XfQqEmSGgSBaY{Cn9OyfXH?aQw( z+)I`;M(XQKe>|NFY^OiFkc~-=`zshleQm+iTUFgLGdnSllT^*|QPWJdY}=ad2)`V{X)_$Zc zK-ja*<=}dc=H2hZ_tfoNtQLRN`$csJdF0zY{7CHiM~b-QpE!OuN;#NWK}LA7BV@DV zrL|YlM_%!3suFkjqA~)==S%#$7)2F_tcJ6I%imM4gwW+U1n*)}0-y11I5hlT7GTaU z2}l$^kAaqXiatI6yq_+W3 zhUdJI9iiP3zj{0wm_dZA-@*w5;XpK;wuJF=Z>p{&+$mBGBG5MD0s5Q&T!9GiwwMTV(ar z0G*_JVX&%bwKCo6@vt{m+9;oOzZ#I$AXX)WBe+rk0OKe&l@n|0pN^znFFPMt4)tb{ z&7lJ$`V$pHr$Qn0GY+5J7GL)2P8YAI1j`O7cYZUrLI84DI;D7Xq=!W7N{#VG&S;$Y zV{=%qU9QjRomoiswmQLM z&H*|MHw^5Y!=4a$!^7WwUe^&^PqT9o)WU>eSgb5M;tK!l+qFOXU39;a)r(1PO4Ng% zGd?|#nK9*ZLLKoej=jsZ_=hsB9v%&5w=I4dWOoUj9%Ki5B8N(Rc^vO?Sg)u3VX>r&-6}L+=Zs>G z_%>Sg%n(D2J=I6!V}HEomK{KTMJObrKzJf{W(c@%9O~tqP z)fvOgb#Ds)3Jt9{mNAknMtf}I9qa|fBOrjMD(AEhK7k1qv3)>G>J8j}%oICS( zBXO&orp$m;p?IIs5m>)=e=dETh&2>pK?GctSRzQ_d z9200%cgkv=s|<=HbS*Q`%1jrOOKPL#ajqK#lH#sPm8we}V|eHk?;$xrV_Wu8#1u0` zIB-6AnDp~;M7A%Yx^ryG|~^`|`swBI16&%SnCJs|Uf)ALvs{58bBY96_PzFPRWwaV;@+ zC*SJ@UC*kpHnj=)wU1tChm@^R8vrV@(? zxs1#p{}f684Yq)p!BnZr_^PFSF)k8|c6h7ig#cVa4!}o8xm9h)GiU;!5GpmZF>vpX z)?feuWUt6-=7|9Ky3fzgmDjnpiN8q%5h`I`Xq(tUz+>Uuve;BhsTe&>^ot%#`cc+r zNI^+`OJ+L&^?4j;tuz18d5iqY0IPu86S!R7Qh6YXVqTYYXF(a55akZzv!~L_$R@vu zR%$K%JAg6tbF_~XXsdOW@^Y-plJ1yn0-71qxnxF*O<*C{m$dZoF$mNjwhk~5z}qp` zwVXT(mksP?oI}QPR`()o>$t+6d%df>>JD?8y}i`;BF2Ek@bVpW)vED?{)t0h z@Lh5O-vHr~e)SyfuAOUKr5jYtFqtQQ4t9EE3y~#9)5&PX89;ozv0N*9mlCmQ+V_YS zO#rEPIA$3tQ|G~Nz(uTxV83XFOH-&IwYPY?LJnY@`wj6uxE=fRz2)D(d zF)Azwq3sO7x#T+QXmp(!l&O^xoL#3qwAnO~Y+I-KvmRIZG_%Hkr^SVH^f+S?tKhbc z=#}f|YTR*cR>>1lPEPe~+zCt+7E$esW>PgDLqM8c^`T5`n$=CBchSgePwvZk5G2)!;#w!JD9zxz&l7F=x~V&Yh}Uuk01SLUq1 z0ADveqTLvi8De|l?+Q!dHQswIx-QYc-DX=??0`mR)?quG#+fLUmhn&~Pp`mgo{1IB zM1mtW8;!)o?oYd@%&~pp`KUxmpwBKM@pS0M`~Qd#a}v1Kh1ahSmZR0Y!*MsZPnE<9 zm^)_x1;ym@{Z+S5B)<7vj1+*##S)d1nRGXF%c6Z*Vi$uOFFnCMd4@PbWUDq9kKW4< z}q9jKGgT_Wm-e0?N zp)4`y1B~?QgP!0zlRSd^YQA<Mc2(tgh+>(cBf~K|%VIZ0>B}u9Mrim%dx* zD>^gO{JYP+3l@37KK!4B=ws*{wb(pm9~XS6{#%nDPV`urOXkwpWsoN6B)&XBoyQ^p z(c+b~a!#kIeRxj18DCQwdAIpZ$7sUmaQ%&ZQd?vPJnKpc*W!ISWkUX<=yq~tZ|M#o zDjJ(gUX#5-MtmHP9^-ojBO1Dx^5Bj+7c>tI*^Pb}I}j^|5qC`4zy4$D>cob+sQf!1 z9{Ma*O&H~eve!IYwXFT?Y@i<)vG`>(#bSq}ICO*-ELxP9Whd08Jv+ERt|Odc-SPPDQi==%)zu zlnt&iEW;F*;Vt$;A#_PHnz5d6I?er?hlu?{WuzxQ!)P%f1^8(Nn9=S6`fr#uw3uZ! znN=(b3^yNg7+xrFP&{iaaCGoAaSN}-LDs^QYH^Y_G06g4^}Sq@nQXDWk%sT)Q7Mj6LwCazrm%LhC)Lg?}3ua&dcrZ{(=&4w(J}TWyEZs|L1%+@Bi8|DK~~SG~-9~rGIWrn|dZ(0AuoiNtXxHLwY2rgt@ew;bNn|xiYX! zvpjg&67ETd5;pZn#|bPRNexm|$C`B4e`yrrUq+``?zkahg$`G++WyLZVYp0;{hxZ0oArPTF+lXy4X z4A13-|A%+~LbxyEbswU2Q*PJYc=s8vm!}u|%eG#qmHr{ilX+9S`2nnV@@{ZkCEWlv z3NgS#TKkn-jMeD7vz*Rb1PGk=y0h$c?guc6QCy1Q9Np@?IRPBK2&)DTt%JJy#CUE6 z3*5q&YuohYn*E)}4y<{=9URl&edgq}>AY!ZmI%IGWd-1Vi+bF2KVzwP(pLmZ=Uo}K zj>Vqv_eE*_DI{A6V4~I? zW3vcus<%>D^G2rV9w=msQ6T{DY#Tf)B_KOf(T0t8Eb(!5p44EzjsCr8%&R|hq51Fd zLOFPi1&eG;*NO$qCwInJf_GXUBr?aRLYU+QbJM2#-~wqJZ(O!B1|`QeiS_tGDGqpP0;g3v{mReFCyh|j`?`E0%LeSMc8z|~Lq zjR`H5(1fsM;R{ZLmaTC`E4D^_u4dP+_De-&2O#**KIk=-HWCY~&ViOMcv09UtJ&WCJz2LuaH zAtNagSS--A;y)PSeAxO!0;6!7h~5oFP7`>n_&rkqy2|X#53hm^nZaP4sfG;;UAv~S z{J2X&OCCDs|yiZ`Qi`=eMu3aY;+X0V~A~ zSW|=~&!U9f*w;U+OPjAFr1fBPjFNNaUoUW!;Y2oo^!mRBHn{diPSQK`5z#pWt1j^y z+^|rASfu-gY2^9|i*b>=bkSH%0n^7B=?2u!a~LuXC3E z2`<(wuA$OW!&X00Lc=t9zwW=&ga6D4RHk~fL=ZkV+N4}NtQpj4SpSVn*r_R+)T$=LZ6t@%`)g*3f|$NCFT3>Qhpi|Hmy8D>kF7E4*U zSyD|rqp`HrgvPOF73t3|GFi}7!t-F!>-cvs@}uGD+2%xA6KZ>=KW zedX=+WvU-`{A6*l3L0eEx8g@@TV(xYZo9)e^hi8o%9^u>B%w zr#)q-BXzg)@opDsw>$mA%ghh2viEv&_o(^%y+!-|#UBUA9|ubhhRP0xD-K4gK8;p? z8ms+0UjKQb;q&D4FRz=vylMV2)%tb%#n+kkud|(pbKQsYFAo=bjuxp$OMTy#2fwWj ze}6mneQo0S{p9ibo8yh?lg*ivt+|u!h0~qI)7|CM534`+*3LezpB-%d{Jj11%kIyw zd%q6%e;pnC`u6$v_b+heRe}A9-{dN53?C8(w zr{CW{{5sq``~3dw;N6dpD?j$;Pj{wHHph?OkA7bp`u29<+iLI8@_$E5U5E2+UuP&^ z-qd}bsQffqaxhf5-=DYN`*e?*^`YmP9 zRhW&Dn~9d4Azqm#NKHQi{C{x4&jCIN9z&cPz_Hnb->Euqy%&HKG%hr>6ljQn%KN?@ zX(}^tY|(ygY}i~rnkmFSqm3q2m_TH1j_FbeB9l+}Ec6m(d|BQUV=qv$igVGHWt@^i zlhjrp7^6y=@gycXbH1+7+QX)|&5w>%sxSKQk~@r^!)x}n{djml8mHpsP?52nFR5GS z+sAj^?4!v42nSxVGv$b^{=`cbmKqgJTSGWDof5@7H)h8;`CIxkvGN&j$T|uCOje$| z^gW`tnQ@bPk=^_K?Uvn%ug%EVbG|3dkw);T=3^pxl$D(Nyx%du3VDyWM9bw=Y!iQ06WTZ;_Geca5)1cEf_|MTWFZ-Q8 zM}xBh8d99fk7P%MpQY~Rdv*Y+!2u&b<2b^@ABj#rJauRxtA9@&`OM=?>`MKi_VHo) z_mXR5hFstL{UECcj;fuRev^vZ#{6T8iLAiYpYb3BX|%D>MkNI~AS7Rt1VJe%cO5m@ zpfKFN^@C(aZ3DGofiWJ69_nZ8zuB=FTFJ z#6joJ;;5JB+i`4D4H)>fOg^nIVO{@~H_DrJxEPcJpu9(z@W(&XpLqQc6!wZ`FagD8 z0D8L8{9Y)YF!D2C^I`rb%Y1od3GVYHzxA$-h1|Y|fUAiF4b?c{105ik zT8@?VVQav3prBmvK*FkdIDxf@{Y$xMUHLf0&0doI1xf8}!+hOMAO%XDiH(sk+F&U~ z=19^6%|~P}ak1;rTs600>cV#fV`_0b)5;$m?Go2cItAj*^%SeOvGFn3gZG*2sD~m_8@5!M#@j2d~Kl+(_Qgv z(Vb#@(f{dITdO2$^$-smN3AQ(Jgmq^^yc+5t@HOwvpd=!Qbc9u*erWERj(82YO}iw z@5l`!)+2YCu`HJAX&t4PC9&0&b)uR&vw2e-rN%rZ(T}S~1eXJ34X-V zRCA^+oig3wM5*CY4(BcXt26q$ow7F_`mBz%)m~wyL2cC+OJLiTnQI@Dyw+QmCu#3% zmWcPEQX%xI4}bL-$(U7-H$iA|f!XFu7S~NDpQ$$Jch)BbYg?O*)yB(VVWZj7a0P-L z-PGbCGMCy?Kks%U+P3vn{ApL{LFfxrJmW7r`^1y=rHC5lKe&TC<8Pl3Q5v>T!Pxu> zEvM*DvnE4vlR+t;ZQEbkl>DNN(k*0nTC<8-jtGM5a62*&nmEoo z4+&Zu()%^#4iZo4yIelS^auXNC44(I66@c(6C?4qI&|)Sp!MyiLdG*0+N0+qVd-B^ zYk$jWKxZuOUH15i3^M#0t^6{@mjxFy75k=iQCZjXYhZFnVfpRgp5M$*qbzo^03VgX zc7qFR|J1W>MX1?ug7BjmeHG?s!@?3`r0P5O$+RoCf>!)lt3slUyMsCM1ay%t_p+5B z2G`jH5Xj_9w~E;Mxtc~!y)5-Db*$&_1*cbHe8VTC8$H-YikX9N=!;rM%!F9hY??O-fzR@24_RZ^d642Tq0t?PlLw0-$i#mw#cx*d`y*H&u}Q?VI9d!uX9p-=3fpU z{&74u#iL2DT_F+XGw};@T@%$@nMl#*T$lV$S7Q^NQZAZm`d#yf@aL)hbLTs>sbh#A z0yK{VynX*+6~+I>G5u$j-Xz9ex4$=`CSI&bwbL}Oh zb3}#1{l0iV&miRC&)P=2K($A!b?f@h#UyxQK7wEJ3VSQQQDPy>CZIkJZSn&bRO;`U zV)M8xqqEU|btRwI{l&8TK)Y!vv3+JyZgWNZ#ce@>>1Is1pLV5gXEXYu71%gd~#`Pk_%0ayOzjEUvV7WP~$jepu0j}M7E-2&1!-3I~ybW1)k zs?7zvCCbLl7kojDFIhfN9xzCr;r zqgk_9d2*q;!WjXg-^>HG=ya%%3KT>oPDT$4G0cfKJ=HD7!=nbEQROy8Rf79Nj==fb zoOUpG0hmR!P(Ayd=O1w0o_E|TMMSn}+;OmgRv3#s*y=opCW3k=lt2@TjPy~6%r$fx zI?*3(b4ob##A(w-a?sq}hGm?f=4gVL*bNn!ufH`g+-|wmx=!OHlhPKV{{961WSw1O z$MBP~cP0P|(&M~71-Wr}@3akx6YPVy%7^q6YcX`7&|(I zUe%>j!?U0XC>4|&3>^xUaqH3d6(tfs;$!xviM6}Lo@i4ojFL^eG7p)@;s_}0_LwI< zOuZ>=kR^7c+>c4f{pErm+Zo?cu7uBGOzRZj`ZR6aj<(c-mW-0lIz+Y1<7TLxIad&Z zr4toZW$uvcaO0kU^AekGCF;Yxf-V~3gSk&WCtCa}SL51{2M0m_26Ke(Ieuaj;zH4k z*k3thgCFnGxWFF78Cw0!LEQytsBApQNTj(JNRyMOk;f4T{$V*f?394HiUYy9)3bQe zljorFOQMFWKa$^8pcX>ZFaHSAL*M@_?|ruf^)@G!7ysz#075iS`pe*>QLVIRR*EEH z8&GbDNw0s#e%jS`X09M0CKpu z(hgD2LgfZ#a82vv_e*OD-E&g8a_3wYoQaEXr9$?;!l3_jfz#<4StGy+weaAC4@lL=lP3U?eCqA1Gm=r;x;n7KQawuVoDB&1G z-Lj1gjE!`jxp6iWC`Cr)IUpHD;dpYg`+5%ZtsteATlepUwaPrp%1BXX4JHmf{8Gjh zmxHpw-6rIrPOH2{Nskhit%rIa6^5p53ixEjhuC=9@3Hd0bSZ zB+pS;p2zes+qWdVQm%>g^9Y~ZlD;bB$AyaGla*{WDxet+cda75m@9yvk__3?L9+g> z>i`xkU7Rr;A}~E9g!Jot$!tuCWs=Q&`QFXn%pvk_2z56EZ4hWB4<+{V?pjj72B%t( zEI6A0vAB@N9y(~@O2OI?Jv789=c%8e;GqxBn*{R-RCdH79S$L9MtW9gC~)wZ9zsZ` z85Tjw-ie0!s*0J6W<^)uum~o{V=N)ErLF7a%fxJyS`tfg$))aqVoluVm5$J6>C63soFkhjm%u6vLtq^GZzG}TwYIxU?+#S17Fd5b*mew%&SXs#3bYJmJg}Yf@`~rs#C^Q{)7IZ}dIk#J+DIyTg zeZ}u6L*~A+Kp^e+Uy!$sR}_;dsSxjjEsw%pg!^}(MLeud6nSInN*@>J02l5#tB~R$ zi@=n#H?n;a)zGNiVD57#)LLYH7@@d+BNW-e11v#_mpD2kZK-v$bCD-P8X@go!Q~=? zgI-oM;33)Pt${ReYEh#3rP5)}XCITF!&4Fo&_n>3xexKKf<{42Oez22CnFcEqR~z% zi1Qp5(Mo_~jhn#uM{hdh z=-mJ?YzxzpUo*u2&=6%ZjJ$dy)F;^lZ72orOuu~#3B(Bj0$W3B!=OF|DgEKkbNsGw zxKCwK?YgPVO`%uq0ia%hOMw}egYr8_O-h)Lp-PR4bA5%|DCf;bU%1LT`y)c}L=VLIBDSXQPwe9JkNr62Ma)O5)b_eXKq*%QqoCErPbp4UdvgD->} z_}PCRzI}97SoOJ!aPseyJl59K=Ql1sP7<3HeP<`$Am=;!79H0_VU zwQHANYdieCA1RGR-iVOl^~=9&+j_rmG##Y4ZH%O$knx_S)gf%P2Wg7*rwL1J!uwCn zd#hYz#{bE*Uy`YIeT4ykdL~Hk6ys);eWySU;F=aeT=h(WOeKYFraI7KVp)7d+H3qL z;as^Bpp^}z3W_bEU2AwD!luMi<1+r)oc!A5uj-_n*CG(bBh%Iewu4he2IkfQAC;MD zM)4*wd?;b-mnPP~jgV(s>r0FHcljDhI^1rI3sm16c0?h~BA(pYm})og;W1eGS*7Pr zaSxA>@jC(uKbiJ8(QmqI2N`yUd~ik@w@0#w!1g}aVpfLd8zLZfa;5vtnxU-jg|nL} zBWIiSnO~j{*S7d58oY37j><5tq^x#E%&?rsep~0}yZSa*M)l$#+Hyz$iWNkK?qsJ* zUT-yt$iCTDX<|gySp>r)hpH`+{^|33!Jz%3wxlJ+xsfWAlJ1+NHy*R;Bi+3B@+sA8 zm9i1iDx`CfxdUpoPN+Wu%#E!>E`mLNe6Kh6sg`t zG>xj_DCU#m`Opy=;PAr_=6mes1*@U?c9e?$9{&q%%NS`*+KEhx#Zv)b{A)hh- zk_-yrcxU!)zBMFWGr*@(wq4h{aBburxZF@YV@ZVDlVHt&;x{#eUy~%b$>%ctznQak zO9qK}sVzrOmuuhC26fsl0X~XdoTPayILj(;2k1?p*)s!FGHLJ*5D?+u1)l=}YXQL1 zK*f6v{qBdCjMVd9x(&P$y<(p~3rbf(GCy3WTz?oK730w(ZLEbPffEV(ihG6m85#K| zsNITw@T+u4vka?t#Zckl3UhDBQ+c(FQ?@PI-fybu^`W#S2-aOa6LhiJR_$OBgA_)x z+S;wzaRbAqR38MhUZ>3VNF2M5?vKRzOX%gHzqDMBD#3WBfK(54$`8y})+{;lBNfr7 zD)om2pRPn`Po}(bfAQ+S8-kY&khe_6b`Fuw(`ox*{D+hC%@b|nXD^o$KYlq|(kWJ0 zU{VO)w z8~|GV_*WeFPsA43?N-}0`hu;#HkHHK*o8;dWRhL^ezhACmxkOPfpfK`MPUi`MZ(n zptYXfZs$>xy0w_z(C((;-9M98wqKN}Kj*!qC-H8edA3dBdHS^vVSSI?-Wl|jX>JCW zNgmdwmFe7$xgzm6AcJn`1zmYCQT$fErlZA(r+@YfYBjJGMKvvO^*3Ds2Y}+P} zwT_t-gjY*-{kim6-Ev;Jv)oj(!qPwZc(jZCuB?(3oUQ7f-bRv4DyCsaDo(|HO&ToC zSOZb@D^GcY>@mF7EFRQqem8gzuZ`RH;_8=xe_+Qv!1v|syyBD3L^=g@s#pb@a=QZT zNrLn!V4QWE1}IKq<7;`6_c9x#gg}?0A*>CYP@0emPg;`1eE`TxytmWYLaEwu##Hi> zV;=F-ATN+`C)ZB^e@|S)&FCryKq*m~G7|`hrVUv^o1WwWL%XmrDVUh+&KqmFESM!u zccxAT+o545i>c@9=e4PdwU<{gcteC($7A4%m7Zj3eT^q&ufCD91$d`1s*buYL9U8q z>qtRKV`b)p_M3awn4md_PMrh+tj=N}f&ut#0Y^h+l-m@W>S(G;e1jGKMW?-BLehV5 zBC_m%@^NW+dl?Z;jfsd4X-fdFU;3}duHI9~I=S_F_wM>;lF_}UZ&L$04>CIj?&{|a zynFQihz|Ks(lhTKywU#V@bO2c(o2pnVI88{%f&V6nl%nzLb!f0Te+V7`StF>c>vHc zmI0Fd-|{x5wLrhEzhTjB*huf-h#P4nHa*B4S0D>3?HSok=2s8++bFIz4gPNBUwi2? zzZSO|#5zU8YgBH(`o~IcOUU)A%udIL<0&d?`O;U!@2~N>%N)=1LD4XJht^iwRcqI$ z&#u}S{QS!6X82Nj&eoIPsdM_0+z`KuCAK-**+v|co9Cv(0sy=JKk(B&7vpS)2l3+9 zda7#c-Gw)uduWmmWP8xx+rnLMsR^|Ii#m5gnDz!P&5p7VMtEIXnO1B)0`P}J%@EY! zg|$7$g30Ji_R}!X9w_+}$y@(=wXEDxiv5!u*=&I(73H6Ve)zF*P9=a{5r`;Y6cccD zb0`e!0F@cRXcYnX6PouYcqF9aO&u+zE&q<9+01`O?>XC> z#R)C*cC%WY&ZE~6t=Zy-BUWKiO7~WeyGy^-nHu=TSpH+Gu`;<2&&7?{;Di@?fVz|T z%tp5fbiRKyeKISvUgF{a&7BWS2h*^!7Nh zx*yLG>ASP4U{H56iDGHBf-)W_%sXM z7Uf>Y->d=AP^v-|62xw^a|CoBG=vl^-;LhHEw|ig*0}-mTbf5fw^M-mJs~Pr zpP>?4cb+5vxLu^AJX(3q1`FS$-kkO{$Q3f&((I-Kolq zs+W1eGOOqz$IX4GlPdXl=X{BF=KCcZDR>|QWZ5r&I2ve;gh;k&q33*d!xcIf12dvU zn*DZt`%P+2hAOkfs3jvHN7WR7DOiUAG6&pjzXAjQLU^gn65=~`00ghL6ekT${w4Qv zMO!a;1FdmfUux8KYa$B;|73cGE~6Dpq3t23GJ|(<0?Ki6aHH2#N+8X3ZLwt`ET`CS>^0WA z%{u!T=BePLDt;L13tLyTf2i97AO{Xo^b7*Do4e3-)g#%Bsy(<8<$oE^W?trUG>A8~ zo4RJ8A@IOH`N_Xmi}&OYggGn%dtC3QCsDYtn@BbFK7eu{Ojz2vA$y?HK(z~k6+m*o zp6(8N)s(0?odQjAJ9)tcV85VCn&ZOgmds6F@Z$hV&PKB_}_ z*W=b3f6*a$`u^CmVIVc6JO<)j&KnL8h72{zJ3RV%gY{~N{c?(uT zZ~37!_u|88tL0HYF8>64aSn-yBmcaBI5qL00kt{?V$a)0K9wrpjGg}!&I<&fOjS^f ze37ZKw)=`KkIHnRJF-BNZ~TQNdMD~d760mbZcJKzYQ{eMY0gwqlJe$}@9&t@0d|}B zp7ZIg;T0k%71SAZ_SqMR2+_{m>@ZD=@Bb{;T&d9#jF4;X)Ew)yDgv}-XuGdHC}DsG zJZtiQHqB%|E;h@+x1XK$)_h_iTeJIkVTxrH&WT96f0m-{j_w-}<>>y|_0F7Ae~QH& z-TQ4e`4dItvF^Q3S?LR7)su^H6Y65Y9o;|1T0W#anEKLfcY)oXMmyvB_(c1{6M&I6 z35DaP8c5Nf@D_sQT*a*i4A4>H@ZV3YrXHdKYp?H#Tdf(Dpn++)e1Ug433#0ypC0YZ z!9<$Q5fvgXnqyR)$wN{^6au)u=zw@&Y$)SBMO^?v*J@6p1PO@0sas$H_+^pcW(oP7 zjVT`2jbMAMo!wD}&@1oAmk-R+Hv(1#D?!qfYOnSR*D0}TXb@)`%^ARk#`v2`GHQAV zP9ms<#ZVSt_^6fEDtgwPL2HeP`$s^lkRRuBcfFjU*(+%Z%VPV!2#aaN&2OAta_3@* zA^@jqz5fV}qntBSEQ1!zO+4rGS>k9V;pwNd?w?uSGxdYii%(v3jQju@fTRXSfLL|D zhc{<4ICW_p#MA{PkQBw>x>2SSpK!?tO>=JWzb{YVEW~I6P)6#UwI`327!?l4zOu{L zdgWYv5}$|O8 zhQcA&8o!wbb3R7evS!iQ1B*sxda~kgDicDpXf0I`wl4!wGY`EjsYT1;QoAK@^#-|- zFqcS)D`wAjf%&I$qsq><#9q~d3uDP}3J_rP()IDj-$brTz`aH_`y2oa|IC2_sBF*n zQf%j6Z-X1>O$6$?+cuP-DXkY1+LSFiI)^$P$1gc16zizTAuLSoAYAqdg14rR15ww3 zdNJjKJt0}vYvobY(K1p6cvHQms1aHv9Y7SSuN+ne2^HzU`C|xVkqOnTPEO(k7ue9E zk*1jzm(fqVw45Bd33|c9J#NT8gfn`R_4wrZjpv%xsS2K;M@VkrpDdz%hELRAxHW1n zEvNk&i|bqH3UPGSyofx6kf^F(yU%rS+%xVnWZp&n9ZPah|+P2Vx6N>Po))> z9UUXwaLQW;yvkPvA7$u|J!U#OrLQyC$3~S8EgvxJN8TNPUiyfoiwzV@J=yL%6WMVZ z@C0%1QiH^5Uscv{wM{M#OdjZS%cHfdWxy*AL;w9Y`rK(&Ec?_N_tcC)=SeLM|J%T8 z)MlPcza-3yAm=6vwOO;}L#Emt&z~Zfp8; z3TzAc1Q{qqVP1Ok zJzbd|Ral{RE0@NC*zS$Mknh~M-z-5ewg`&N6}N@BN(n)|1gLESOw+X#2H5Lp0}BB@ z+L??0UMQKOhxckyt?CaH7yXuaO*0{>xn&WdbYOMDm#rk0g!-n7Q;=8f*>Bj+aM+fT z&vRa~6S}nI7P7=WWbA;30J20>DyK!{+|TZVTj`eWF+iV?*VPuoeL3zj1~X4BJL7?f zJa8rYg-Kh9MmzS+;K6Xjax%6$L5(s|IuiG9HhH|ya&W}5h+3?P)oj&yz^HbZ)>xj| zJiGXMR+yV`vLMDeZp<_>ZZTa+W{+SwRDU(SQsjR@L=9T}AnASy{odvKua=A_arzn8 z#f-u$g$w&-1_7zv*!e&noUgHmpOZ(x%u263ek5?B|u*T!bmYvFQ+Da1laRyBxRJ;|E z*|w#3(qX)T46(#cdAfVXtxWB_;7{un*Hlh?-iM8Q##A4PihI(7?0?eFl)Hb{_@abz zZ?3d(RwmHBZuh=PAU8UWVmT+1W;GFdTG?JcyM|57<7OBKxs&#)orBzWfYahDGu3kX z*W&H~FOF2PFt%n5k#Rxl)_9e8uh%NY)0d3GtjE042Btu3F*BM%tMiJhduf}xzRprT z1FsZK-G#Jk`Ccv+u10;HUVl>(GXp*?K9%a^d^J0W{8tn0zx}{ zBWRu^Y@t|+?k|Y}xXs)AxrIJZ|B$@r!kO4wDiSbnUADl92-Jnxde&d&+y=`T!{~q$ z(?QP?ih%nuh3yEyRRBOP0@mjN4m$w$2wS*L0wROrL|*#_cwou3o+aw3v4;_Y!%1Jy~N?lodCgz-Wu2rX2U|lTB8LQ zS>j0(;8&0;wU;rm?D$}!>^cxRv0rMNfWd)PJ(19UP;c~OgCKV0`HjhoaVC4-+N7>F z+g-iT-GLXgi*Re_F;2Z6xiN84na62yT#Lk?dbS{Qm2nq$FK$)(%ZaMw)zMVBRZFKO zTQ2>>i!ZAo28%;2-{1XR)W^3tgnF>U$v1G z0F)Q&;f?~kBr>12I2+6gUMKMIOo1y?z$H^uK{C+pe?We8w7ecwK!?FQmCV8igt!2~ zg4FvG5rE6U!l@)1%PlbGk@D52;f6^+r*qhkB;C>9E8E{57G?wK*|ViC|NC|s=}q}~ z`&qEzN3bjR2dxBW9Np=-$6<i#Lq>lsSQ`NYB4`Q+x8`QQBbRoWzV(}l@6Fwf4j`*K|7q@di`18CN`p5e|by5r( z;1%=KuE#O!l@zEX#@n57)crc3Y9~UF^17MhQ(cS++rhed%zQ?_Z{(F7fgkwfFqy+S zjpt%NCtc&l>)#mFJdpaIn#jS=oPXX#@7=kG-9dvmwUIal{yvaz=IDxA;~A#L@RjaD ztJmz+Z!8xHasj73snT*t zSX${Lpuq-jwTjtx!FI@5qy1I6w14dYlRTEn$>N@KtI^rM{wvlNt425X$5!`!yMOr} ztbA1%vJ#Ea=L7hci04mSd;8DflUtpO-}43c)yp-ql#{^IAOnQLF4s`H_I!=?_R0Oc z^e?zHG`&#d5&YU9>n(wj~ie0cpF6 z!4!x)&)eb(C%>G*G!g9?H3wt!T#Q45tD+kZ!kn1YS?8NPk}<3XAU~S!|5P)0-V6Nc zz-{}zjXL24Ksp7AL=}p?i)GySVlAc}S#wz55OA-w$?e1DsLvfGaXn#RQgyUx{A_1X z$xLL&cC%#9?d?h9poeLabdlYN9>wkPw`by>o*)lrhEms65_@o3n~QA%d09@LLl+D>#fQQ_$z3Vryk{apOMMkppM zHXF`2Y8Md{Msuj;m35xTA!p}tti9F_Yrw6aJ%;h3h-j|$J{B0~e3^8$T^!LtL{YY` zjKR3Y0`=gCfmqD{uN{WA004#P+$_MUinVJTdO}o)fG@HfQt67LLipkZso?j9II1GQ zADBjL)QPExYevz>QW|>d;Ft!Dj8inQmM8qKjvlIlC`ZyTj7EPy2C@=wN62PL$I`1{ zRw;N8n{?C+%_NgV^fNhA7ePCLW15b(^07%vG=yIs8_Rw2Bz>DbbkcU9D108Lz^OB5 zTU%nJoG~o`JGH!L2pbUMk@sM-wuVrtswsj6nhNF>sg}wNds_X;jwIzq9jYm+nVT_J z&YjLDMiS#qpig(_cy0XBfR4gO+)i_LlufDtwz6kBU$p107JsXgS3<7y4c{$(*UpHn zX>qIRwh(&7%BOje+dYPY-O)cI7i=B}Y4C~*`N=|sXdG%nUYEuEU8T|^+ZF{fjJJ9G za^3v1Y8*&*^~4y*uZa%`dH%1t6XA0rvJZw#ijamdAQ34NC^gMHZK5O=IUu}HeFcqs zFBUZu$V6eCy|q(MhJqlJ_3M@K+N&`V4G&juB5dh)N<7iH%eQ`Q)xK4F0MPkL0rADq zi4gPns4**xQ{^F_bBq*3B+(QIb~TDffylnKL_m9V4YaVZg1^$QIY!7n>AHC!L!xlM zDFWurnnKGY?Y)qFYh$uhWpb7z8)tGRVo9#R>uz8T0;f?b&mL@kSW|p;=7vJ4reno4 zKaW(V8h89T>A~`3i5z65nMORAC_H30Ep+BM(S(EwG`FwKv(s7{6y8Z9{N4O3!n4xG z{m8pGwdJIDU!;5S@yFp{lCRQF8{t||(wQvMIwGKk?u+%os};P}ZJT)MqGOclxd^GeThH~?zS!Ojs)46p z#7kfP;`Q<{yNv$r{du6s)U*&70OeTImVINL(a9oJ?*=n+VtEM;*^!c%wvw)mk!DYb z>J}*H;8*p&a5rjji2DyLO#x$=sbgCuD_$RNm13_@<0!X0hYIVQvd>4d)~IGCN2|!e)pnb_a;<^NdFU92ItbL}EApU2o$8D@s!+8SBLsoZbtFG92hTK3a^VUTvuN#5 za`JhumQbCgcKnHx-)@WyvePf?gQMFqaQjJHsNV4&XZ;l;z6E=OhKn{zH*xdo&zJ^& ze({`>a95!f`{q!8iwipz=3?=tsgBd$P!TCXq3wK?XsH^+hq1Tb61;`VmNg!a20*7E zFi1M(oSF;zz*Pv=hkcz2tD&`2q(YP^-*JQ+=ya4q`Wt5Tofo=dwQxA}Gk_?D|FE`~j z@gk9cJ`tDhF}v_)=j7`pPkly)&_{%NNkO-h5@-cj|X3- zP$4y~c1*Sd4VGJ|;EPvn6P=D|BK33pBqgWfW4sR^)|$=W=JL#Ld|h@wTY86asQQ&tH*+T(?!s_~>s zD=hJw8ST@S>$oe-n+c{v6#|BX#`z6|anbJ_EIh3#POWum{zM;794GW4LSISyMv5pic%7}h#dK+eRecnQY}9wR(~EAa>{PcDDL;ZEDp3G98DcqKk>+Q89gsIs@ddHvUi)`d z*hb|by!7YRga4^l3TMUp!oBe<(nKClbVND(<%qZk53j;ldB1w{cnA%|ZP| zjOfbQQkg2(+Gf20EIWY!XDPa4t2ciW2|}@4hr9LAKtcIK809JFmcot0+MQI4v*8Z% zQBtaCn{B@7r>z=O&-#lpg;0MUr;_j!g%{19SA`!mI6Vw)(=UB49izf0Rc?+Z@ao-y z+P?&zH~KaG-*{s-I^8t=*fT;Tvp)}1s6$Bnm62quyy=Nz(drt_Y`Co{tL_pkzC&j< zNb=>1?~I?06tF8b7AG6;n`$_M5#!g!gj^9k^I>AwTR()?iB)FB=s`=r-8#|BwFrwG z2x8w7FtYXh)~AzWJ{t@KIFgL7EfuLp3%ce&uo=cC41n!4uq(o$8!2|PI^RSnn}}P! z3^wTZ7kGnj+nk-$5CKR zxs&$1Kyd_Au*6l9u+`=U>KhUE%T2GEep76EgK}rwGjqqxO0q^mp`}#p)`mi_ggBN> zQI9Q$Xx8T?f~}*h%mLs{_D^ ztU*LtzpJESto>fRK-FM8F}DB5`e3zZijq@*a5eaUR&NHoF5kKZ^huKOM4EW|uKmru za0ZfK8_O#~r^Y-wIpwJXJ4uLZ(-+kF$J=5SFlvYv9Eu2BW0^GmIh6n|brG&|(V@DS z;FeKj)L>WkV1IVs=E6xG*qnUvXNf0ecYNg08m}icqp3KMZeqBCN^nE+fJX%<;!y|d zT%U{Y2%Ew~Knh4HllrL<->KwjCMQaKsmV2-#7G=WzFC8^%qhSrOfMfauZWXOLOps-Sm!BZj8pN zKy>=@1Q;w^ac%KAMx#PzY~(dj+T{l6Qi|!gqg}ooY0nI%a7}vO(7kGf|DaI1IvVuB z49pHbNoJ3+L~+c+k36uauGvBSa>gsx(@6UW6Erf~@J*VtNu*S{4Cl=JqFI)qL)eM8 z4$zcoOXlIBJDZFc{*ZHU@5ApUQ}`JGR#!v0<0aN&u-}2pua$h(D^wltfIqo1dXr^B zY5k3Bm%m05vNLLLq~HzwDK|g8jb=lg3vvNoOSmpYhoP4fSEyQS7r_Ezzt%1rnb6!4RWvD5L=V(>#ABFPrY99vcGr^nrpx3K0|Zz^X(J`H6VDg3u%m zKrt{(19&+MJPcbOUam^wFOLGJjV=PD^&~DN;a-q3vEJ3AbZMkK*IRM&wO9TX zMRTet)KYm_)}wH~`rUdp@$|7L;nRB+ZR7w-t+Zlj&Fc`4)6Ts8`p!8Umiia)TVdtHjw*wIYke+5%_IuC%QbFnpfXB3N_u+RlR@7@XTyi%WY{t9a3 z#n(c?;Z5e5hxah5n9aKNC&pY;$t59sHMfIS_nJ9#8vM(H8m?u9 z!&Nl0vWNvdSYPx8CPOkASW6kZy}sbHDEKGg-PYLU?U~Du{tke$xm(Ct=ZP z&D-gFIyn;)%~@bnbN%{5e5ht&JO~V2Hykh!d(@u#=nAm6!B}LC_ncpf?Dfr&^0kB@ zDr;&YzH9l5x$VTa{Zpn15lLfx6!=}j6s{cdLn|cNFMf0oFMlD;w&sDE!D%WHc6T?8 zsDaOzGDQ|DFNiBHZiO$7GXKFxEFeb!i~5|Dt@~ZDBDq$L$}RD8;uEnWCAvdkhv_NI zt4n&XEXkCgw_AyfT}&0TxOCd{b-kkH$p)Kt$;OcSZlQnO65NEbzRcjL)R_|iOg6G3{48vP6=q}8<^&s{yf5c}0`pjMr zW8VBAf72sC$Gdi`#sXTQ`fs|d=UYm6e12EWP+x9X?6xRqhG}i_O2Q}Dkf-=SWVx;3 zy(NI$v#m1tN#)<7V3+ti6a98A6SQdxIM}Km-ixvX=frX31Vvp*vd=JgHZ^)d6o@@i zob}CL-eijgDHP+=uH@AGm9-tUP4#NJ+xrouvqf7gfX2Ky^ReJBPdeqIpR+)cDr+GT z76Am~fG?F_RJ_QYU&^)CVtIe!mA`>5u+F^b-deb7dmjVCD1TtRgHuX7lF5rW`*!M8 zucf}t3-5V#`pr;ZS~GjTH>_a8W2lI0%>+>wdU(UE3Ny(i+4+t>>dtO>n!)B_mGMo+ zG;2nJ@0slIr5HChsQX_gkj50nTdws{iE~7g*8b*SHUdI~Xj(<3p@H6~(s(W8N)YAS)0tt}Ws|VAVg8&eDK*iNRiBxqQy0o8a640zP5j_+`w;yM zwkUA8^f^|W0@i=|0&qfk9<-hjCO&pkJPWg{ET*nD4)Rg|?$bzGv*QOf(m-W2zi^IV zADosC-h{$E2Wt-C{h)n*%Tfmrc==3m#no-AP`1Zk%(||5+mGl!#4R+eF3j_ntBq!D zeiy&we|ef20h|wDZbZc0kE|SxCJpin8S*Ftt8CsjXm%Q@Uk-g;6RLL^Ng2F-38-}A zA=&ZzH#1d}^OfYxyg)B%`c<%mrHmk*D%RRBfL9ZGzi=xv(jmLDCD?O&wM^u_YR59Q zmFw~Eix$xlJyzqYvHtgBCDyE3RfvB(Z;Nbp4Dy~dUrZw^cxXiTuSJiQ^>3KU5L_SYDn{>=0Xv2%AOD$JU2{t5vwb zC4vQ}PAqOO>77vCd1q9bvo`A1e6pB*+xF8C89^)1$4J%VIhs`3&kk9=RP8)`C>~7Q zAIRu5LUlcRr=3fsUI3^VbL(Mah?w@@hR2GrR2pV9D*~k4_L$!m`1L>E#0xvTctb=~ zC^Qz3@+RBg_p%0rO=gKTYcpmh49XRJ6ytQjs+QcO&~!pesZ;)kJ7yc_j;cCAj44y zd7El|2vshgtyaD#N7sv{?A}NQM#w1{QDDN#U=BUZ4j8VXKqQ%$Zz&>xWaB3Q%E>&W z$ga)*Rc~&v%Y)00tggAj;dHyuws?TE|-^;yklHqAelQsJxYo6gb zI`*Cqt6X{i>XSGRC4nEV$iQUD^Qlt56MNQPzDzg~RQdM|W{X!HJf<;N#*s4p;CD!% zlJ-nx!d+Nf$^ZfJW5o8J!PlKuqqCn!1~`$s1sTj!zqe1i<{!_2dm4X|PNumu$W`1K zqChvK5?~`~@2+q=4+}dE&Ucim(t89NRrxyp5{Vr{pTV5(*#Mb`53o1eWG8(O&c|jP$!c{fQOPFq zuMiXXB({k5*48@Z*}NoMs;--3QV{DT+&Gg3AzueLE!L;hS3g`0YRnb4$b>i4p>Zf6 z6|Ck-cY(%0q~hhToc8_WL&?%jD(2lX_mJf3CMTp&eHFyfo{g5{9<=Ltzo#8Qft?U}Yb=rmt_lYeS&$kNjJ7nLqwm}wb(9%!E~)-w z>1#6C#CGJrnQa2XsC`TZqsTn;f?-=XuUM$m@0{>hs*Nl2!qKhxjr}(jzcz^4?{hi= z&wd)rerXT~10|t4wFYXGq!|})O(30e?2IL?q!P>&yZ9Wet}O!__&Pl#n|Lr0IhC@; zic8*XjqY6l2QrpVx12JMj!?R=YmNfnNHE0JA%X0vu3UvMV{&;@dRm6tBs>{YVayX$ zKIp!nwq}X)q&n2m1YkXIIQdid6TY;IEJP=80h6b;FBsEFVLbkmPi&b;cn;AKJHyW7xfJaZd<}CJGH%9;otF3i~O?3EOm8t4p(R1X{+4ysBe|K1k+p zPDAEO%&lphX)SyaTd6J%VAUA{iY%T)d3H$svA*=;hILepL&?5xz*&cV&X~zK{?Aht z>}Sw9{26O~+g^boU#6K$2OVX9JJWL^G|}aqVLF%R7N=dLk?`y8?vn_haiURiqQHA8 zq=5>T-0RHbod{jIo5oiIZR6g1@>PnYb0VuB+pV+{a&^9PT{GrEX63}o{;lFLPgmv= z={y1}0uId&6+QTwU#k^KUZpSoli=%Ck3Tv^>~J$BinkqW&+hL2XNY9VwzpGI#G&x< zw1sk^Z|6)6gKlRv%o&@}ldo@~6!HPNMqTzGc-q{Z_gSM2uCD~xJmmoG$uQ?2xqi08 z2A5~QJc)W}Kw^lk6hE66P)+TGc~_%RS%Gh~(~;0D;IV%Zc$Lk{nj2XA)uD3jS86}I zvr1J#j+||=6y%0H@O@)!4^W_m&1Evw8FKu5);&+SG}qquP%V(ean1uklOP_=-* z0+?E2E-4~Sy_8LO6nQ~Sw`Kx@Bc+eGGL!0dP)h$}-}Jkh5mzcaqvO`k1e2-pZMXdb zrz2m_$}4Jqj14&Ja#n==H$bi(qH?a5c;PVWQFr&Fnb6jY8F$-h+S*@d|0l>q;tZ

?yK}sq0d%~hJFuT^!`wwcjAEsk|Edi=Lm_dzv*6TNGoec#L`?1|`m6eCyz|A#(8fLG)JvvcKVK%_pU!~#DK@?v zI~3x7JtfUHXtf#2^Rvj%UyGF?Joyr55D)P*hMPLbyGkgE*BYj0y>Y|JFBu_pa##_P zoUa6jp_15<=O@TIF@@WIIxGN?I#Nq~HH~sf*S)xcz58O#nR|^rn|odbFEqC|>%MNa zXnZML$gN2y)?UJUIz)GRmhAVsd3;d|3_Vsl;rvzN>)){{%zG(0#!9*C{0BSG(qDB^ENgsO<`j8( zr&mCSF@Yt|YO2!>7qH)bCEA=u9AX_sj5+Wra+Mts<5sgX^v(;Ex={~}WwY`w;1?wN z@t)IN>>+I#fkciPKM=f`?}ms9cW*R@7^3dK(r?vztm}nnY%Kob9+3R)mABArfmP7j zrl6RBM)`?dNV(JeVxFIIdnaAlS>jg5%0bCY(a_hI?G=z>RC%VSV(6@FV2S;OtS;vP z_&nJQDlBWq{()UAG&EJq>2^O7^y?)h=C9TxIg(-91YG>c}; z6}$2Qewqshyvnvi>!59e%k&;Y;Zc zFqXsy2(Oc%bZD=ExcGQ7U_FN1DY+1x+sK3k0~gteBa@*A72i58o4n;u{`*Lzs#3Zw zxr4ph-ke$>1Mp50kbu?rTe<{=Y(<1fMIA_aAG%h6WJ1E~EJ?D55PlsbzbzBk;wRTY zzHnk+E4@e>iz45-Np8%mQ0%tE)NLfKa$TK(Hy_Dit48+Lyuk~WXTB0r*%SBs!f=Ne z9CMOIKJfuY{^d;4ON}`7d23&NA2=(=p-8Zwr_UyNo%<_V={qbVYCt<`4f^YF=3f|W zt{PrJV>=F4B4D#@Ej~G>1~Fw`Ils`Cu)Ku{?;i7?zh?Ty+3dV)@`8t17`q*NJO{v% zxR^5D<8h|ooGh{U1@THnp7Q&@buS7l6gg@X-!ufiNx@-o z{3I3=Yq}D2Phc7UtLC0XL%8MMXz6!^l00LTOy&6AtozIA4|@|V5f7Vq`G;ZJf0Bn% z>S4&=_k{LK|C1eC-}_jBNl*z@=HXD3mo^miA&nY=4^9+HABmw+*gBqaGIihjOSpL3B z)9Vb8e(8zBE#Ko=*0Lwvck`Zfb#`sFv@h?s{{_^k0s2iUkBq)Cy38Fvjk>d zurq8JDHKMJf=yPY4K%F06(rPwx`pHUCvYgTr64N9>7chgs&^orOBy5{+_60n6I~l> ztzfAD0Z~cA{wXyQI4Nmc(Dgl6@}YG6-nN=8zZMTxF9IbF02jh9O&WXRP)dtrFbD-R z%|e?uKoWK!frcz~%yExw+pq-X(jDmNgv|A+=G!nulRehB3Rx^&R+ZB7l+2<<_u4*S z(Fd?S=al~v@=|}u!5ldiHuIq&;_k%(&GrbbujJXUJNKA4_LF;RinpNTMAYdJ6iQoz z5K*uoJj8>MjknSxVn0>XWLbxnrSNjQVx?3AxXy4X2k&<~nmt^U|GH}Ol@e-*R1K7# z&7-S%tnoa`YFr}4^B4mN71FKryBvCVIKIxjCfvE-X@z~Lh*?%!CI>9vCq5ZNz1mp& z*}B2ezA5l%^XXA)>BOLA=L5KP6s%{B?WQM!@?PiuJLD=+gY7_w4X()rXKUTyL-7NU zD(4i&2)*a-n!YO z`{9%Br%$!CvF+n&w=0v%tsN{7`?DQ#p4LMKZQfERSH1b_4y0}*IZpUVC!m$^D2&M3 zu5y>I5MKGfZ;wd0+%P#Y$u4V?08;j>>P(WgP-C&2+1Fv<*#=l#)w~bV4lwT?^yCOY zQw8)OO1=OHL0-co&Q~R;ZLiMnCXbSUSNohX`py-!9EW0p5o-)XhavByt_8b^?__wh zRb830N0{zTeR_INpof7~4Pf^UJ#BY!ad;u-OdgJ?eZAR?wBYIqc~JJRi~E0OOF{uE zQtvODs`d(WTQrUre*bLz$39A5759!_W|C>O;VI;0dTlIKbv6|tp{}rr!hA*b(c08L ze0|y)ZrT2kZSFf;0Y0>FEwm8N(Z7Kd1JX~@J~2dI(f<$;Z-bGJ^Y*Gv4%)AnD9%a^ zxRRb6KnJg#y~AQ|+-P~AZFC5@x&f+?2Hfos7j6ZHPdKMnOMLwFoD$-8J9k6_*Q|%+ z2Y<%)%%UEr$Y}tSVx$#PCiR~uTy#Aeod0skJ72&xopTGr5yH>2A;Va>vl{8#!PS1(tJHc=L>Ce^j<0syBL{ z`5y2Tdb{5r#@J?yjZ!M6n5<`D%;qs|jL{7Zr4s=U2E}671kuOCeQ4w#iNK$;Veg?Y zEZ02r*hMU(0!^m#{6As26qY<6n4idfGH=^psYn;-ba~Ctu>6r_GDk7_L$N%c`G!bm zFBk|)kmbJ@ZzVd5M6O#+Yw&H+!LtnOk2#i~`9eRAh8E#Ne6wcpVfDZnd^Ke6#^~CbCvkKv!+Vq#jbvae^ogRM!ST>$gkhhO~?h z7aU3A{6h(;jkuN#lxIIbR9Jvi$L|cx0-lzc+3CjWiXgpU2?v>!2#F|x*2Zd@nf%kodB=r?%>SooqIc+5QTRm1#> zzRaRERd=M(bfg($f6e6q>&e`Zad=#SY&V*<-W&VfTl0IcPM*(hzTy**Z@xdqbbf9J zX+Mp{*lQ)v(EYd@c(oQu7B?lAH3nRMk)+0JX-_~F`9}9#DRT3hzHC%7qs6xLzYm;5`+{b5wx>KT!=4d#PM&$NA?Oc zvcNn6lJRK=HtIfO|0GshQ4H&5s;j*H#_1hD+vC}%N9N391dY20PSXf?Nv+r?)NrEa76 z3Rw4l2$lYQHNHie#Ree}AcINzp$U`YlTUEW?$_m>SHH#$;}VoQ4ruvf-R`%{lNFL~ zr16~=mS%TNb|Z56`~&Z?udE#tenXJXs8-!}MYlJrGP5jjPNltH>OA`x6NGzDMe zK5KtsU*h$WJNZxU1YU|DgTDK>&>e-jxb=E3L!wZ*DX;880&#=^5`sA1pLgk82dVN^ z#LH#Wohm7$VQc)DZ@JdxM{@-i%epUqXXRWzuY?e=wkg)wKh$8d-DvlQhv3N!#wcAp$zkB3?_t}!Mw0G?+PcqU=EqhTW8DTCh`vRF9OYG1^9~*jt zMPe&c$jgWBhx$GKA6;pfDkrca<3s9cQG z{c_&rhwB@rP{^`Y`ygxO2H(-@Efz((gGC0#&KPiP0&NkI$uB|OU?9U)XDNPBTVLSd zO}72hx4xAzfs();8qnf$ug{Qg3UPh##Sk{nPmb3jqVg$-C15ot)8J&qvmbY+fvdo^?oa+x}hDg9=Ct zKlK-9J~@;(`QQDw{CZJ4{k1e8HAsM6v#-o0B`eax6s$Q2&g>{V!LJd^@_Ql>ky zVhy$IsvW4)yj%S8Hb>=!`r}(><}yTdZ%FLeRF$czgn%x-8dfcZkNtuKYosDj7QUKB z)#2%*w=GNbuKtR=j0SnA@3O{Q<@cTdfjxR@ zC#y(5Kv)Q8liRKMi(a4HdwAuS4g9>mKvTHU?SHxcXg97m@l~teGZ$!h!&R*P>h}VE zZ@1&@l{M1e#Opk$-bBQ{K~n0zj8Wcajc~Rhv1yTk$%d#A4gr;|S20tA$=8I`NQ~GD z7BH6x6TyG-2onW0JZFi7aO*jIA<63RFj#o<4=C$0!iNYWgfMr76W=YEnl~?6lv>G4 zs85CPne`VY@hN9c+lbYX4!}`C>$s{Dr!^c>@UCK!HaKV$b41AO8nas1@?JQ~Et0%t zlBYL-ynZ4)q{sAj{D%tDLa)W+_J4Gi8JoD}-8k8(kS1n|Xhg%hY1uV;xEzWM4AzNI zQf*BP1$~X4h@aCs4t|n!c4czGagsJd#!^WH5$$O(Afa8^T1Zd9$Xw4SM;bLCWzrn@ z%oWv`ih^b80{m8*E64DGGv6Q%_wbQ0BgBnNCPI?8!|a2FovojMlWX`J0XdR^#hkCs z5vjqm^-(_H+*y^Z#q>Z#jxK2s1j> z2V}{ZM1z_NsKJ66d?%o7Qv$_Zx78uSDh=JgLr3^|V@kvC%5;?~J`bNOV&T!2;RIw) zM7518)`fhUPJzeZA1wmoFlHVZj%8SQs$Ox1rgN6wQ`a z%AmiY(9%3qyVq-sP4F2p`%AslE_yV~*>b68_eR?P-Lzq=G)^JbhB zhLExFU7S6VnT47m!3aB`SG|IgE&Ft~b7PY-6LS?3`V}0FP2legi~+j8*(k!0cf}P_ zCH$;j@=CX@1tgYjH?IQTd6`9+J0Qy}T+)u9F0o0U$vQI$ij3SF5}H_JJqJmbjA`*5 z!9JEfm2GY5i}#S2p2=gyhf9a$l$CwTlUoK2o~KlfiE9a6x0VgE9ZhhkPWMesba-c1 zHD4=cawu%&wZ%!E97NWx@!C;+D_Am)obt6Df zvhUao*$bj%HOy}51?Cn*O0uVYIsX0?Uy`v7)QY*tZ0iVfYu<25Lf%a^j!xiRJs3r3 zZ0$tFkvIgAgZd*Xp=QIIb;9Tfwl{jRj|6|a>zK-+4GOIp0eW@sgmb7m^_kr7sslgx z*0C-we2TtOT=yyVaUotjUGle#=eb-{M3R`1B!4Tf$|M8HNnV!)j`@KyK@Ik0!uEt7f;*Kq1W?3bV8~-#PKxH%_XJFw*}g=#8k;%qHxDV11Tr42#Y^3>x;c?}6B*l?a8Z3+fPV z?S{dNDx()}=P?l+X3N=v0sR|SLB-1soWR+ZXP1I@A6?*Rk(h8HMV?Kuf_r`~k+76I zvz|8C((>V zKk+#`kGriurEuR>z4b`!CE(b3WBaz2NzX1ooV!I4vrZ; zeFt=L#z-mZQf_U>+Tq*4k(!b4#!;l)0SRUu1{SDqft=g{dWC+8-n)F!gQtd%-Hzxl z1VnFwxhi18+@F`nZhR1|xLt)lHK)L_!KS+f;)x>;2_;oqzB=6=zBq8o&}5x8L;DSP z%pQvh_P{DE{oZoT8IN9`r&fQ}MONDzUnYHX@H+SLUW@>4F_Q!5$5F}w3c)}7F2;qM%p+(Tlasu?_8E`h-fk7XPo+{t_+|s z_~8I!h>NEdxyP1lJww&wixzoF9?y=)B##e|ql^6Pp*Is+`aW3>A^LI!*z^}sCuMGCei!)CO+O)9T=gy}zZ z)?YT^3}skZ(!u|5nKOkN|7~8Vkhb1V2_d_#XO6=F)!Nwg31+Z&i5xs+Iq< zEkE}4{1g5#$T#RQTU2%ok^j{+Xo#I>Hf&WIDxIx}4yRaQ!c`a`5fqq+LiwStdZ74a zG^xncbA5b}hw8jOB&0LO{$inoLX5r5sK6ObKOb8`E?Z3yieOm)5=gqND<`Td?9YV- z@UdXk zE6o}?5AWRISIHjt#t@EljAr0lLN%;O(n-FNZ}=>ZO@ni;(i_OuW|9evLm|I-FrFF8JR~_5Y;i z^pUm(p?f#D)@``ieAZ6;K+_=q3`^nb`fxRv0h%f<^3mrM8QXaNlAyyr2cmWR{{ zKAJvMr5GcU%|QIJ%V?1@{HMuZrw+Q5$oFCV+aYl zXu@`Ye|;3K(@1#Is3VO0BQD?GLCmL1?Q(2%d8=`*6G$)@RHSu!**5iBWGY>Y0Jn_` z4GhYXPx}R88FEbHS`X@2vx;LkubOA6CZ|{D#T!r{%d$}TK7@ZCVu(q&k#=J=#iEZG z&c!^&kcPNy+omRFjCW^boYsFH%Qhf=^)}Rc5k>;dWxn0g6PQA9by{!g$bHth)E?+{ zXu>JdVl_B^^UsXVKeU30`mGn2rKi81@MeUm31-!*`=FT+D^ivv2FlN5-Tju;l?pix z@Oh)g z6%kBqBDvt$OPi+cX5!_Be;Iodc3bJ4MjkQlu_L5-9$9V_-076w6P-@!ImZQJ6{13` z%sCAeAtsmv)AZ_Fah_aiMXs1)Fj5AeV|mQJbaK@%cabl#H*j$(UYfrOsV#=pk~$_s zT@H(R{jaqtoPT%SYoLlZMZHeKsqW#p{vjK$DAoIsEo;*OZ;c%v%R*doP`xFCZ&`rN zq}7{C4W?iRc^P=_8%qQ(Jey|5$!Md#j&mtfJj!7=Dt9=v=L%;jlyb0S1>90Dd(;}K zi}>@k2qRlm=X#sYw>n=Px#OCe$4>`YOg(_}8YOItwp2vvv4QFs^kDKGa!IEpH6Q6( zQ9he5JYifz1qlv;Xg?|><6~2amE9w;#<`Ul$h4bCvm<6u2cnAe%ZX2-9@>j6b#+x= z8zc#Pm`4W?rb%_WO9-eW!D8`7$C{#x6YFiW|Gv~@l#I(bi^|ruSgmyU3;eFxazO8H z>q(^5MpV{r6EE%lsD*0t#9Hqf&xfr?G$ht(=nOn^orP;O&7_FPP64WtH8ADaCzZsc)diZLbDk@ z^=Npydm%{t%JEo!w>h{cHpNKetJ=wLpt$XZARB2-2NtnU=`0VN;%iE(X1=l1MVfy| z@Hr1)5`Xp{#aG`fN?1@R+I5W6n6(+xsBxGmLfr6jwCi6CXsydFW{sBrL#|ZbWn1WA zMF^H0_v$gnY*{`(W)R*-J!i#oe&`pxQLENq(;b;=D3ab}FT@;{I!%im(jIEQg9a;^ zan9zjOn(QbZKc)~^yCPc=`lRiiXr}s_c{(y+UZrocIGzlLQ4vixCbdQtu~sNI=^<` z?bGv5wdjGbJRX80Tyv8~y z%odDbbLq97^_{uaa>Qy|#=7r+ZE~r0xA44Ww!C#)Ni;{;vKwnx{%kPtAYiXfQnEg4 zIgYQZ-HHNzTnF{fg1TW?eJIeVb}4!Mqi*%P78bl07zh{OQ7K2x`Oye^JXm&;y=)Jx zvL>KnBXD~y=IZT;^C{pG3b?3wL6)&lwz4373?oJ@(wjkU2Sn|+<#!ipB!7S?{!%^ z{vhvaCkEcW`1%X5^l$|}36%XvYqT+TFi0#|@KcfyKe8 z1|f`Pm%9EDr$Daw-!gHX4}pQYyxEgOa7h;9uLT1Fx0T;j=6AvJ^TznEvhd*7Yn;+N0%286J`LXZYY|#(d*0h4mwQ9ET zuvUB-IuNYKm5rW}+rmFbyh$nsq1h59Yp!I;ntt3e6`!>oV72FJbGpdteAZI-31Yw` zEVXU+dEP_GQT#Y;RF^qZ^Ra8vEDAzF(JzH07z?VYszOsKpYn3y6jKjmO;`Pg>z`F82j*3(sgN2ji9 z@B9C8fEXM>6@v~kFZ@hjq@Ll8H|2D9P|m#Z`QKmhfZf2HCy29{$0@!&w^DnIEYr%C zV2FPwaC>Iu!?&-ct)BxRuVy9BG1kTL)h8vUjpL_#3a7V6`1anjYF9#)xQ#<7mb3mc z8Y_LJJ**)tb=R(hxi8k}anJbV^!rL}8LYk$vtTp4#^yKe8IR@McKUkf>z4H@>m|O~ zP{h$2n~!WQB5m;*tbX(}{5X04dzTW|ZhkJ*kLhj6mzvXVV?F$bBx9i=pSDA<4U`GJ zz3y61uz4H3DYaGeHskL2m1d9CBx*5lRs2HaZqdMQ z+3GIc`sW>MkN^f$_`*+Un9=%Fq&_R@np@%hPtV>rJ%2E;`my;}*C)Q+KO7&Mo-aTD zXs1yzF8RT5{E6xA@@3wxTj9iDL(Z9}X(9+vw;HHCmLrbyPVM9h&!pJe)sJ67#71yp zZKsY8{rQ`w_noV&?+t)K-q$X=uO;kXQ~Wr7H2bM%3^H*Azd~a{14G=tNEK4^XV6Kk z`_aAPGjS$Q5p8Q$1^|MS%q2sdBxMS!`0f;fs`zfEoL2n1gWG>RauSQS;+US=%(`i4 zl&<0JOBk0jHW$SW^0DX0sO8GXxr9B#s*1orZ7sYEz-hU4gsrRR$xof~=#}|9eZVP< zxiE~(sCxP0e3MJuJKnD~mZjLd3;ygO#+Y0a72f@R$?^V1WC zN*`Z$BpeJ&y3bpc4uv95IrWdWo;Y{&Wj${5@e3itRJh;s3|O{+S`}$(=7%jybkV<-R49pT^=PL1w0Bcwt%11zWzrbc5& zr&7Q$6cH-`iFn$Y@5CX$hNOYXekd-E%Ku|Z3DW@jLYyB3RG!3V4q}C^nJc%$N#?2- zDn=~OMZP0|)2MQ8zC)ISaXxg>2%67oqgw;Af2UECCn}i=fJNl8u!#UG2UY{XVhCql zbqbSFXT3_ZU+7ELe+8Y{4EZ{(>4?k8hdh`0YR^)d^u^4sVt9Jy$i8bUg7WkEmIl?q z&enL2)gf?g&h6vyOBzj2cno~_ZZnOA`z+>==%a!JL3r_qbr(LV$QHUdD~&WhM){V_rdpCzEk(5ZF5}dQ5VSUn(`7EVL?t9^8>a{oBq8>-{jOq^kex3#SU>a8LK90n8#pZDf(0$fYfDIV5xJ4;{{>@y>7trDk&?=|OdG znALGAa}%7cPO0_-)Aq=?RW;UM8=kHmgquCvwLadVNADXFcV!E@H=n0|9z5Z#&zR!( z&XM&M*+Iq!#!1cRov4dOS#b1LV_A3{_KwFzzfUc)E5Jd9T!yddhR1YM+hKkyqkXV| zN2I~%+3zxa+JHPAO5x0Ki1@B%cy)v#pLoX(Z~@DET7Wu{baN*carqg{P~?NB8n5~f zp6R^pXn(HncMAn(AC@2|9XH_e=zP}+Z#J1u@iF*Q{HD6`0aZayHdF8R0VFUC>T9 zrjg4sQP~)Gu2{{ikc*9O%N)-5u_#rcVeF9yMn08~m6nt}E62fNwv1J^%T6a%U%(P> zF-qkzv?3$fD~5#@Bq{!4Oa;oeeV$XlKL2zGF2j4d&`z&2@0PKjA`6Hi9$9%k_G;_d z7+B>KPr>%Cts4L63S-ZRQK60>H35n2u3m_Cw$TnIR|wH?x?r1aOoxk$(#`Sl6E;hv z)I>3nm}%c}D0+X~U!E1jCo25v#96qnH)MBksbDB+d-9pyOzYAL-<R~f$bSY=?*wJ5;` zj%W(v?EiFiuMxP4MaXkB-2(R=x!D004;r?_dEzD#bHER`WaQG)EcG|CkNC0_v>)zT zExvU8E^16=@8665Ke|*9(Uk2+F~FpYSATK9*$VM_8@=m2Bt)WM(~XO1zd<7%I`?Ui3O2LlSA0b~*;yHobi>Iv$nX9mN~1|=Zt1ZT zlq9I1w`_)wOuB6^Bl&b2tFKh>0(Jv2%=lI9@g*+gNo=Q}{__m~sGb`qLF|Q(euVHu zVBp?s7!7SMr4H)>XDjN1}; z7U=X9C)-_{NdMM^$N(UB`m(eiu|Jds?$^^!#mYX*>zCIrEakvjruZ0VKVNa^OYZ zaFNyUO0qeU2McgRVB*708m;C-&5JGWo-1Cs6v7Rix$7Jsp)<+88RIBy;;bdCpt2@bT7 z&~#HLe&-F)b9?dxe@57F5ckT+Ugm`G!r0?}tP`BuP2Qba^Q-EoO(K+>_QdIS9w&z-n1aGVL=+`%@~axMp%O zwwS}*hPG2;1zZ1q`Oh~1_Qygm{3eWrWq>j0>`c8}%U=m!eF;tf#Sdh8qXgv$3ONV+ zM;K5fMXUh2-vRM{6BX;gLV+rC05>qr8Y9i}>NgzK>{9-9n5^d7X3rO%1#CK;S=Jf9 z79W#tP29JRyF*sfDH)XvUt&7wDhpC5yA;iNrqH19Bs#SHBGb~7nnAW15C#o46b$9+To z44Gbcb_>zWk(Av69;|30eA^?78A|0WJge+|n>K1~`ljc(_?T$DN6-W-rwTdTqEYjN z5b-rUpd-w}6(S;hC%XXCi@ql1_&fNO-l+ODEQ@%gRFfrNQkQL7y4<<(2UU|tqum|C zBSK=0?p|*yJgU%R!j*t_=*y3~b6hc&B)gn~Evws+$ws{|+C#s!$c03sykRtK!qa{XA%a+!DjXF2N-y=gCh@h&DF zjLPnlbCK8G)a?PrBhO6%t1ybtH0ppo4@RUQ z^OC=dK!2p&`MIl*Ev4{Jt?lkH@AXTW-9n2)TpQXA^|n=k?U$Zw5#4=3LPZ+JRZO{I zD1ve8f6yV_72;;H=ef=$y6N%FTJ8VW_SH_}K+)T2pIdIeaj7zA2} zj0WpJ<^-b^HL`%TZqYP%yN)0H+NO;+2_+gwCsI#$cT^xAW-e&VByeahrt5Q#&?q++ zCTTi_H_o&@TyCF;1 z`Rr_iR&g4sgi6fg&Vequ;An+)oEGY`7Wf>cavsB;oFjW(?yBU1gq}h}wG-94R;{|? z%M$v#%(Fjpze(N!Y=$bTl40aKpTkdn&u%5Kx78-(mNe?-HsF!LIRyy{O;7aXpXl9t z&)(6ekVnfeT~v?{si%Jta0LoZNflrchB_~w2~2A^Y!eJwFcjGk?EhVGZ=+9)Pl*(zLE;2UY=Y6XpCGdjsa?fN<PkSF> z8s4t20KWO__r2eowO)$nV&6ZyCX;Fk%d^~pOo(J5Q_(EUQmB4=O61tTbOsl@9LFET zv7{Ytgn>nsrQIlFuRm^SP@t2%E?5dzellDD=1PY08Rf|AmgVOqJMXk0pUMeF;))LV zZnuvs>9^G{nUy=#mu}(=0+D;Xcj&+KNeWa=eLWsaJyc_!n~1uUVU80Kn%_HNlZGP} z-o3Ys=kv)oUPx_vSvgldE1DV^fXPh$8gW-TdyFMo(-+kwv1o?D=Uo#w zn&33LN>l2$wZ7gxQMawIj1)%~{242WfYQB_~$dVqL&fe@-NXxA^ z{rsio2lCV^5kO-~WBblG!zd0)FyWo^)@xl)L?%xMV_&SFN;I520=IA=dExT_+d~3Z zUN?IblI_e%U~jfOwJz5;M^?>NE;|`a8kccr=3cgyH)9ren28RXN%Y|ApWwt+n0Jp+26Fg92pg_(J5vunY~|fSnRBSw9?}DQW5?18maes;6|v@3q{&F?tjonj*9Wt1-x4+Ctrv<#^$=Vr zHLf9h$(06^dO+ko=e>L3S}nXK3vVD^Ad2=RyQhoQKKunbR<_rL7VP&(NmTo=)U=lm zyx;vOiiLJX45n~2mN^o)+*A+gH~}*gMoeVihxm?xuQC!c3D7bEn=FR%jF9#o`y>^t>sMvvT%D@$EVdw_=@?Ud^v4CqVh1)#&TN>1fnCytn zfqk)SZO=U|c89v7-k@pUX!TOf%|6BQv}?=IQrgS=-wK-2+Xwzw2&0T>7$aA#QCu2r zO_-*b1$HITMtmB3o0lFRq~a5$#;Y51Z8V-FI6Q1%rxiM=2rtL|wzPa-Y-u$z{YS&K zp5xIG+4$&8vU%|h7YDfANMbwb#*>ldGn2*xdz>p`G9<`~x7dp7imzs2hKFn?%?AD| z@|h-`1q%}l7Mlx!*HrG9JXVxN%%s~-f(;QYsy}FM_rUv>+;8ih-aK^b8&@^ioIA|$ z+%H_+^OiaU5`EJBK53?!)q_$ClO(dX1+&l-W&~(YZSqWQyn)TPS!dQauhL~OoI1Q2 zPYK+4uZ0y-#g>xT*C>2Dfc+YUx0|Bp#lK=Uo|Ql(5=a78Oz?BQ8{+xYy=;+w^ZW+e z`jUzA5P5ne@=l|C8#^F1n$ArMkZW3ye~K%u(WNO;%JQfGE6*w1not?eN&k{_>Zm$d z`6>@BELq(pS+PW*_V2of?nHZ)tB-!0^PQDtnNPs-0F#v9Au2Pgxs1U(=K=hd1wEbE>S0ot?~d^nqJzqC7O~9narXF+gde!V?Xyo&_e^ z0phclK+(IQ(Oogs_xh@H!KKST08j20fd;ElfBa;L7n8$hCK6YDYCZobr9#QHUsty8 z5QolaWKA%|LeY~cMz3j{Z>PLo9~!(BxC^`yoafHtW%J>I38ywpzfPB`tvu?baI*Bk zH(e-bUBPDJW*ja@8c5Bz6}41L8JL2tmb3q3s+>Nvq6Oo~0;+=tr9JdY@&|Lw0Xav7 zY!kI|$Mqca+U*%W^w%_Q_2lDobQ)V1FAyjhXghf?7xkP@#hCL%Sk&lw>nK+=8JtFU z8tNx`pWPaI*#GkKl;oqOm()pb)kFG-Wu@7UP@jQ=ma4E=ozxSV_sYMF%)dRgSn|1v zTeiyQNJMBZuci&o2lq2r9vxPBJWbStgnr#JN;<&3fuTi7=|_o*?{E zQ4$e4FwWX(d!5_mPRQpyNp!km-oLME^J;eWPcPkCHOo5uoGrLVIV<771E9}@u`9tu zYN5`=lf=n&M@p0{d!KsbBK=E`E$VBNyuFXEJ^gU+H|@;5hk|_C`%Qdq`q0MOT4;x? zZ=MM4ft?b*Ec|h#Ww-h*b1~sK{X&J_ntsLL6 zUS(hPx-RQKDv^m@Z8>z>{ToM$w>%`Aa9w+p`Z?2F_yPpScpb`EyEb;fB~JK`;Fd2l zI~8DYj@Q!pEj23iCcn4q-oYv(RCX#Df84uBL6W_;uAn~3(Ux9Dpisl0hvaFr?3L^wR}P++ zRNq-v!(>_a^+F`mric2M70(%kU-k+Ttb?kk(s~31WdMz)fmD(2w>7xYS8wa* z?%XuFnD$|3m{0>fb2j$CJU02WPt(3eNvw42f7`_sv2sUUKM14BP7k)6S2Ty_e5W-% zve_X1vb|VV$0mYblgpcnA$}#;d)nuVwB97iQNNRxuHa2ya zYIvRdgigp;FES-d8upi77EL&qBqibwFzg6^Ft1TE4kh*${u!Wl27oyXSW2RdbrsDDRv~7vt3Et}^ zVGQ=V6QPW$l(LwHsT9$KFGWWwCn-BjkdfxJZ57QM_0u@Vgl{2P2>)xo>Q-u=qyRpa zkepIsZxU%Pa2~UtRBx#<1>vt#%?{#gXh@Yxm$@k~m5RrL$FX1m$S@y5l7zA@d7M~#=9*RoL*+)>YJ;pNo8e+VnFyifQ2oY zZsjBjVQ(CDWkTgpEfHL#np*sZg_>Y+mHnGpsm5e^2u!u0fCMt5r~8{YAy zoxCQMMi^EM<~5uYJ$Elo4nh%u@q#yZemidsp+_VdrN=qU%qD%x^Nl#sFVoE)gjVL3 z9YExlM32mEZ&v2jXe6r(PzY0GHH{a(vc4Wu97IoC!AiZd%C`tk_bu;&z5}IGz$PaN z=7hN;9-=m_km)NgP2~(}AOrJ8(aI5O8G#=(CcQFWcp74Irbe~oa$mXZJQ%#qbzPN} zGRA_Cx?%OeI{-#Be!}B7?w=A8wto^eJW=lrAqY`lv6*OO59nlyI%) zI{P*I#jC+i+TR~<&mB=gRK7MP*8|4IsuQ%tZLKu3KUh5fAVTian=YQeTw#fq9XDJL zq>_{Y-qU=@BW3lTiHqwIg`9lZrMK&4Ww*G`2l;K8$H=OzdqoZQ8@`{2L@zW4@?4DP z|8%s|(YLy5cK*s@?j2yn+;y00qwVThSCq!1=8kX$1RSE*8T(kfr07{mC0#uWz~z!ti)2A z%Sl+b<#%(o41s%a^KG!PG8B&`RRJB1N*Cgw{We;s1m@m*s^&pKKca3w3`zEFZLMJP zC7O)xWqH|5VR>H>!PRCE_58b*qo_?v9mhowHxY0Y!WKHBumRG!nM%F!ZOd7*$;y3F zZmae-Ni!LO9+s)f2*hYvwrzl8^E(B>u=Kj}%6o{Zrvd)0kKkkBfv@jY#c(Jc(RlQq z$f!&%&XOy@sT~yQuj@uv99n2kHsnHn;!Z50+F(fz_?*fr-^6Vb?H<1`Yz==w$vSD0 z+{_o3PH@<+L7X9nWjmdSpJ04)NneQW%Mg#vvY7 zw}mz!vGdfhpfNqe9v{5}6P9vW;w}oYX_pE;yc{tkx;VM2$q*Vy_>w64hxB?KbQ~;{ zHxzjn!zSE=gJ^+SgnEA_zcE+r8J`6wKA=EVeh8V6iB&suYt}YB_Uxk3j-d-|jR_m= zm&AJ=SJ6pkG9)g*5eq|Rxvq45WI)tSKm<`&#&wC#mespHFCQs{TjfOvK7NncrN+cX zmMpEP?k>Uu37wx$$cA!Vm*NU8WGCv#;WtnMDt`_~I!G+aK4~P^2xeTmce(H!YRzMM zkZZbN`c!*KFNr%hY7u%$+}HWc7uA>1@5{Q%<`^kI?%|HOg7=07J~m#LPoqDYUqe=n*ET_%4r1;;AMOmx+p-J^mQPtp*P8hUXeXyD0 zH<_$;z)|H7Wm^ojbXo>wn4r%yf7vPD3f~leN*WbUAGIOxr`WTBEpq?rI=xaP6gPy z@K`){QpV39#`;{L=Nlm^o=cI5kav*cPOXO={d|4wU=sxnUjuU|Rc0=5%}_v6Yhd*? zxCV*qDHdYB1{q|)A*oPSm=Tf#ZQ_02~J1NtU?1 zB5n*&{*hP(Syv5-f>Y+c9}Xt<6qQ0X~!GU1dNW z#EvKAWP>CGZaprohGYA}gPo!&s7m3-nC&&*c$j;QWt!>~p0jhqaT0`|1hvn4w*U-r zjG0(idiNBKu>u3us7avPhfPE!Fd)=#%KyQ8i{ky=$fw0ds%~;c!l??!HRX>?rBN8+ zz7*NeO-GG#SCy3jvB!|CD&xqkQW5042b7P2k9qnZ`&cCz;O*q%SwBtDxB+JYlGPts zufr*@Xg^kE0))@Q9Zdv2)}6xf#x!%qPJwy4w=(x;UCqp-iw7+~od=qx*s9Bn^x-OdRn~S!(yklFYTsss zu^!l)^AYCg>EoRYI~L4me1Odism{~EHG^&=XJGu3RTH{P=8E2ms#`d{H%4RLVp&qQ z(^O-=8Y5rIK8fFa5k^sX3$I$CV{ zrp{J(ATASuw@{aR9(i8o%3C9ho3y!E^!!$Akf}D90dsikN1-Uy%Nc2SgR83)W?)b{ z+PH%^S{TWJGUEK>=l#zXZ$0cdJ;*a(6MzEb(W$sNSrrGW1A1M7M0LocQfs{l&%HxE zjTr`JQRsD*X|QW8%=Q;vHU`L4UrW8ID!0M$Rn-!X2}6poH50W4Vx zMd?{i^?A;|+0HGyPGP((h{w9-N#uKjU`s~+#@&3oyR4A_%<}Hka(w^^j4w1jp7lz7 zGll;#J<^P7iPDAo zalswiIEgWwh**Fa!Pi|;E`EF?W|Vpo%N`j%A)3!@$G<;8e}AHd+B>);8b-|=C={DX z6{CYh3z@=5ccBpKz`u7l_uk#}XErY?Hak(9M3n{HnF0jd5u$Jd6v|y))OjLe=wJ1NKF<_}|PdkmQCPSV@W1-3n z`5A)m>X=`XB^U!trdalM?1pvhI+2a>sWy`ZcMH=M3Xko9go_1=&;n0|d{0JR(8Xso zESn`Zhr{p1FOP?SMDBB6Flv@Xcw#l&obAKgJV5?AM&3H5W~fWueT?9+>5x}C!qkSpeNa|- zCKmwGcceJv06G5%yD)rreE{I&=N+ocH%M_pJ9=VXED~Hj_8dvI(PWD9)4|su zhC=`P;$5&CfK@`Qaw)B@3rL6`OPnXXHqz>4YvtBv!=eFf@60+UiznxkK`=y-!(yap zVfM9MxPWw$!&9;mK&1ncUnM^stskOh>nSkbmK_`1Y~f>eeNL4o`uA3~6IFM7nqU7M zzy6bpPqNju@gW7SOhWR zlHw{e{QB-7(ZL98S;iK%Iv}I595pI`1%NPC+*K-YnT$=lPg^OA82$O7u~BI4Hl)razI2e( z0U=THHm&smO_E4U!--tl6#sDIzlCp@Lm@TwttHYu;mDg8SfWXshe8@Jn7Zdh;P+`n zY=l~RwG7d%QBx2F79YtB@2tC``r0sjreNq9O1itGOTZaQnxR{UZb=al zb!O;NTH2voX+c1TZl$F}KtMo5Ku|>e@$x<1^T+uBXRp1^UiWkF>nhY^aBr@QxN94D zW^L1^ouTNnBn7`wDJYU`O4ni1=QP7a#Wt(!h2~vB5d4Wm*q*TGmNBrV^getLi8Y60)9uf@1Q&LljPd}2FetdlfFEc}sn~9d6 zjZvJ9Ri2GgnTx+Mm!LkEs4<^}noma0r)Vvt>MT6bU3jYZAsN-fLw(Yvq1x6#<_r zgFaP9ftbv#o`n+e<&cto+(tJKtYF|N8m&x2@lYJHNm0UL5UR9Dlty`S$0>;h)o^ zKW8U@fByJ;e)jkG&wm%^|NdP3`}_OfpYwkgKmYwc{d<1=_vgW%A73txHqXC({I$RQ zYj^5wbNuwv=#RCblhuKfmEPl}x5taGkLKIH&omysulqJpc`#b?b*ON!KX0%1*)A#T z%bTY=T`Ak0v0H6~&n>vm%?~zThHumduh;mmReF6ab6q7mtrXcV=UXr58Ggvn|B$Y| z@I-4N1wEg9b3RF9K2dEh;l^CN%3Pe%Y^>sJjQni0>27i4TCprBX_92P&CDU6l`2AeAka|%&| zTb99m!c?q5o{OECT4c{T2$b7L_}%z2y-co#(bhc@ql9gD+C^!tRh{f@_v~IPaUE9q zzPN|l(Fgo3Ub7mT!8!4>ub5I(N(-=Ssv{tj+&Ujv>7{qQs?R=vMZF4p?X4B53EUJ$^y=sAjq3we)Hk$Q&meQWaWF57uj z3^LQH!F$ugg9IyWougw}Vj0Y`y!n`LQv&JC1Yq;(=luqrz%2K!dN9>-6&?rU=sVM8 z^U3AiO4FvLC}%~1e-Dw9dV1q5JX_5L(jzT>wzF+rDdM=WVSC%pun`+N&)gzQ$VFQ5 zyk#kb)+HgPpdnT-M$24pzQky=X?{mNU|P>x?Qh~ntY1w7{Xv2@D+I0ffS)%S$= z9x_|{f`oHJG`U5PLCf?8MCMnHp(H08iwB3jUL4gzopV0W)9K4+H5J(=uRjIi8#EpD z4jW3>UQ)Ep6y4`(O2Q?zjD}@a6k=3GAK#?e=Ei|2y#w%Md?Mf=%Ybd37*~Do>XHO8 zdj@)g)$C`kiEZqB#nEO02~0t+eXghYy{L)pG-@*QNYf?D+af`$raxykFxE@=>M`e4 z<>t@0Rpo>G6=V*a%n*i(8J^vfQ}q3Y{T9D&mKrR|HnY|9o#5z8%!BrQ!cKXT-v>Dc5lq#KCRbO2NO!9ZxMh8s4{Z=PR;v zm6cdZBle?1Gw7v=Anz$z#QU`W{Qbx#r5swDoBHy@s;;Z3|60v@?@4+Y$gj7mo6-KS z!d>wChW&?wFKsWUDQ~_{Vm@3q$NfVh1jh7fSC+#O2}cHZydg}@dJw#!-I!g}4~O>z z^Q<-MLwkr=+6L{d={G;ec`d5WODQPuLuD)EU^%&KZN4)@2hUg9 z^FtBlq#iRW%vbqyQ-y3?+Td>()W6=Qk zBFi6GoYmV1X9VAo7M*oP(;i3b%7f)MqU|PT%$d<43G_UeB(sHAn#65^z-(~AKgHi- z6`kciLz<{dy-eUN0ujxR*8fPmD`pxzVnl7 zi7?LGG-xF`ZV#G^0OXX9FzDU7+x1YMFQm7R3WFwJsUf7vz zShI&G3h)jR0})QY)n^>enodcp$d~noWEM|6Z;JSB-jPMGJ{_zllghwd^ zxG>>$NMrs=AXRkVS|a_gxeCly2Q)KQSfgXbGmCtG9Vu-C-VfBEtDpaK&Ob@|=M^+h z-zx}xFxhLrNgsbBjXH;wCFr%M0(OHJ5r|?QGuaJhj?@-U>UbyEVUcLC%JHReWGMmY zqdb@RczUA4|H##>DhNe?;7dNebg5Dk-kaXf!DfJnQD;m+8k_e6o^eU`H64hR-GR*! zHMy+zC+A~j`oEQBZ-&zrtExl!J=v8({fu+kPn@dDysV zGd-PfYr)`yRRet%KeZ9`b)>$-Qb_Eqk%2;}TY;aixC6OCFTR-S+2WS8f`){!=cttk zzx;gEHzPjRT;*kCFdo7%3iho?czNx(<+=RD7uAorxWut{M6WP&%|T`IIE{gn20$up zYNSzt@0Edr`5}A%*0j9*`K4qyqbL`;eOP6_#>Aq7!B9&e;o#1%G<+Y%c({t0S30~q zP&82J-AZ3c>-UGsiQFx#RE9>p3PLs>_VCX?+^V*ec>i*Gf)x8UI>A>VSC`lacfL$p zyZkx|T^GuSC!UpQXFk;v>ml+9$iu$|s(*{5ai&fry6Yrx{G;)NqR!ji?cW1MnH{lq z5?Xh}640&FV}3lzpJ#U$3;kTiBcdl>2qq& z{hfjN7e<%vdOB;TU2d1?Q~xo)+qaHWyo8JA?bKRu2ytMf6r>$J*mQC>bKcyo4*6C&Z;u30Mdac z9gr$4yV=yJz{b5}cn~5a8s>0T z9>UZcl04)c@`hvQOe|f>9)09YnlU)==K-2HZmkH6C9rwDR|dB(%ow@)ad~;pFKdvUIx3}7{22QPvNn^XFT47+|gWT3E_pB zuE&8FDWULKl!y3jL3!uTz6?ChFT$+U;uU<_0OSz`Jr5;y8s!ii$<{%6?I#Dg$-105 z9IeNpACICN5vAPnZOS6F%*6;XGxCwZUBNsV806~#LnN6|--7iU0@v!s-9SNqq!6M1 z7{P|aGd9I&38qcOxJqc>6ZgD#@0Ia#m@=K26w@(Sh8UnKU~+RyYfVTy-OYmT5(N8} zD-y&Kf4rq#^aJru0pWy;to2frZ#Mm#r2}mO92+wqXnJ@B1wIu?%+k9NHb+@VByWKu z2k%97$t4oc9m>w5`aF`Tsywk%LG<@z7{4HVfNYpq@~ed8z|!OpS(v3XENb-G?$D!S zuo59H`)>$b{Rk4f1yQJY?3*4!o31=Bf`r=QaF9@J#cgg@MU0mvb=b|*wooD?R&WCW zS{FAu_5ow^zir(0u?n*kj4>`#&~w8U*~trSg}-OaxOt4&6hN_f;s(;(`&tEOjIf0H#H+=LDA#|iz&u=!A081y!w^Ff; zBlE@uUbH{xZ3*}1c?K(SC=x+rR(tpdIgt3(P_B-H(Pnn$4<7_QTzuu|(g(Zq6R~KG zY_ok7K%ekH`}%`Culv^V0$`ZhSXm+n-Xn^z$CfO+z<*VxB3fJw z6@BS1-f7|7#}<~^%+5tIH;F17BvAv};Baj*1O<-*Ij_P5zVNNK@7vSLaTP4ZDQ-Q2 za2Zh`2G5wLx2DAt8Jf~;t`(}Bb4|Foe3<5B72+Hf4G+HbwPKwD@c>}(2H-&%eg8-J z1BfJ_e#Sx!d*m}7J|4Hy3-D6_s?`Vw^Fp%4iltdIUG?I2!gyKupe8t&4Fc}H>6WMT z{8TjlOWbn{0PZHo4M~KQUmxJ=>yYsdy(V8Mh6iSZ(bhv$ zAbUf2(kSw9x1V@cOe$Kws;T%d!LshB=;zCXFyd7(8w9$B0)f#W4i%)SpaK}v>Zkg& z?5A^8yW!oku!|0be~V$4B8DBT=rlfXhBQS+S{t|o!gO5FJ&;z>ZtXYA5m)PcTiBPu z^J(gZi^nyq*=dkI9657e^ZO6~e;YOgh;1j{c*&9{;jXeMis3AVh+Z$=P$}N_DZV+^ zkvbfa`YWRO0(8~pypEM?AVM)?vO%`b&sa^1s=QNWFW(N zS4O^3^7=XqjC^Lq+X#JNuILMkyc`V{+b*MhAe(~)0X$G!9&Q|_hUD^iWge1BYLb~v zNz;Pp34sfz%JaH9g~Smeb&@k&By7a+^X zINhk|4gLI^RH`T~eH`YkI`e8C$#K@X#;ixtE?MTA86TuoD;Pp&@(G zpUxd-7;T*wREA9cI16$Nv%y|j(O=hmc&)fu|55?rgE937ivP|KI}I_f<1w77H(*VY z&-J2q^Ir^gl{{7pqQ2B}oVw>o7g`#SrN(hrhFfPCa$L&D+ela+I&HoaoZ+?q>XAe& z3p8EF4cn9metVPWtp@7t&#rP$G-A{0#pMk|rYPx2p9U6&mM+lG8p>lhN>%<+VIQq< zsdKe05y+@~3K>909AK%p<}mZ+O?ayR!9c}}AOcT&msQXb*CEb>;6}94f!Zz4Dnl!* z81I`DL8yRRo zLF$sU8pb;0R0h)@WM;T&S@0mN5zp0UOpMk|o`0cOEpaiT5B z-jocE#kntC7WBxc%0*5zKxcO2^U?C1YSfWF z_;>9?*G)C3%P_!>%s&|VL3pGXS@y8=k&qh)QHhoKW{H^V((5lM)@+s9KYQBDk{-AK1D{pT@ z5Xj^Jfv&Hbo>NkX#4g%pL|l2Kw_Ou;5L!G4jiA}Yt^hh&lcZk(%##@oJlxu-RE`;w zAD2r|?CO1iqDCQe`+%UK!Q}JI}&u6b>+1EpI2C!--*{Hlm zj5G|huB~qW94Cz+l3*OXWYyimN8)c!#&a8^n3Z^-6|tWi(x7b;73mkB#r}M-O2X|A zj>j}q91T`%!114L{S~1A^-;?f2a>6<@*VZ0$V8=X{M}y>lRS=9R9h&q-O1o*pfGpE z4C(ElSrYY<%dVPBjD59ZJc2n+jBO)-Zne6hlpcvfu<#(xCm3~X+wMKUiFyunT;lJ^ zgxRJ%naTn3z-CfCP6$kWz@~e#JcagCM5lF>iTc7#){m5q-F46Up$|c8CR0#tYrbtm zQ%DIJ7(_PudL|$WCiQQoUU)b%Xco3c8JV_F&YS(fG5gV9euv6zYtHqU!WU#3iQ!ms zuE;t4hB-@uzqx`xd-QsH7Q|D4v8)xa*;Q-k0w$I-@a1?@`%#B~HYF}#?#Luag`z_=W9`TYq&LUwJp%A`vlG$aoT~}8nG61@DW+!b=9YlR0xas-P~_P$?U(@ zn#QYTQpBo!emaWYC8y<1VAHuk$Cy{}X*<3?W!$$k%#pHdu+Jt#@T%CBM;!MJQn9H$ z%GEBgrcb#hyZWXAt)=V8rnJ9C`m;No+7tOtV-vXu&ym33ju08P@GMaq=JHq$yAyX& z{VYe%jQjeTLMCcA@S6gZj3Q=6ejC_k<577vqqsa*ou8N4;^W=UMc<$~)@ zDu*Jr12+vJGh(|DYHk{LXNHDDIe5!)SV}lGPyS=i1FwD|$bs@_;U2t}2KegiW zx`3+se{cZ=8k}dWC}wn}Sz@fp?UTQDi|U))R6enfN-E!5kmhxNj~ zSh96X0L;gy$Crv=Y2(+u$)-OIqh@NIM>|i-S*!i(_Nh|&@>jpga4Ms`T;H0t0#D8o zfwd)j;=uzV(`AK~KhYDsTbc-EgO?KMXXIA^%E)SmBx$hCvKPDYT|5O?+9xJM1AWhl zLWH;uiuh8oPM{Ja`3M(>Ei2iS{Kr{aU7j0KD_T{azh7HlYk=Qims+!BH_Fzq^?jY7?HMm6CSJ`*K4I1_KuTO3qU9g+p zJYJO6hbj^teTKC*m)+>H-XgwaQa$kAexo?Y{JpFB6+;jSOy?}EBK?93>6SJ->)YJQoYu#`~WDKK+Y#yuz@7b@~M zckg*EssF_~H@D|}S45QeH@z;FD+Y;@W`OQZPqe14)WuD;~s zto~bn%AlE&!n%}?=uHw%^N_f7m+=ElvOUw)R&K!C4%jcwSfkrL5UoZ~`_Gi4fX)o% zMM*gm18Q8DhX-_jI{7ve$);f+iY@}EvA0+k&9fq;W7J{dHfFZgJc>HZQG-eHjV#n_ zo6P$DDN^AeyW8iovLZ5~(jta-Y;@ZDhfA(bVg8``k^On3mfsf`;RQ2*?;Lw31gg-K@-h{sozd+O7+= zQ{5Kp2u2xrD10@sun#OGgHJS(-(k}75NnZpZuK)D@D~UY6k%b%LkUK_i+#aUF770T zxfez(*paW98g~YVhNML0Gt0i}MCw2De&0$+GV;owHOsuE;NZlNGxgg4_7L2F_{n9K z?;V!}pMHlNf7TpDOcB0Mp&_DA=Iln7ehSb1pHa7CX64ntI=wgDw(#zpQKI)!|H{X$ zxDii7pFz{|^r41ewrIHyE*w4l=$r~wb*hB+@qpT!49dL1wSG<_r8&w{@99xxgIW)2 znYuJD0VwL2f@Y1eN)^UydZQUDR#gEJ!~^y`jUJSaZR0;)1KnalpaLwhuDv$Iz7Rgx zt|;Nk80|;t4U~_rT9HM}`(5ig*XdgKNO{Oxc6%a1mrEWD-?q#{2IcN#uxujqZR_cG zMq*T#C7NqK_l1TG$c;wfy@uyB?}Slt%n^S1J`Hp8bFPb*P22FxlV6b-*7~v=grW;5 zCqo|L4o0xkJF%X_K`Xg00#I6tpY~-egQDh5Ng=UMi`sI%)V28Nxge}GKk2>i(y@7% z+SrY4O~xpX`9H~iA;e|O&vr3gqUA~AbKl3%Uq7!Ga9m9Fxqh#6&Ys4vMrg{f=cjMW zJhK|x@(la>j=9tK*8*X?1)TrIos5>_7t;$0S6EpKAIm)m9Zv~o(jb3*X3wr0{@2KB zOZ?O1gp#Lqu2>nJOV{NNZP67b!g(<-HUqIK6BGYVAjjtWgUy0Ze}0k*N+~b@w7TO; zQbMXt>2BjJ9g|9_o(7+AzPU={zn*o!XM-7PQUKl@jI;o3I2{&}CX8h)=TW(3bZora zH4#@db&}Jt-yb>eH=!gGYBR}oz_8mrNgEyN_?RnvcL0^F^wji=~JXK(ID^~hNhTTb_4@K<=<@mK{G3+VDSSisTVzwU$h{W z)+rth@D|u*K%QNdCcU3pPjxwdz0sZb)Vvv@iY++NO5W^9J3NsZvs06@RUDZr^omwo zN>lVhfimHzZy4`%P-Q=t!)E#p`O&HbSg6{Ub82$!O~T)Z(UWTDdV zu2+b(V#NBZRlm%S44O2y*_L+C2^KOIda;`heXZ3Zf6>||OClEM+~)T+l;$$@6Kbf; zn-BZ|Gv%DauNp^GZ+}v#>oL+JL=tZm0%t3{4IyuTwcyTMa0s5aVmI&%xTMeDbi-sG--Y=Y;yX;Pe+?)7A*oDjgE7 z0K|-4K}S=v+N`10rJ?^gU+z3#uDZe25Td~HR5CqUU94h#|Hh_lA~!W1raZb|PIHi5 z$d!_QGFWjWx=*A~(cAT<*hD)^;`MjAFXL7(B9n?~LQA}1&ZO2~%K8(mY$=jx2_G;+ z>SJP_0BJl1BOL}3S(tU@L#oa+ewc-HY;_8A>0AbtNTM;e(_BsDy4zcs2RE$?Z|XX< zwD-L&E)Z9Ve#(-7zwuQEB!R+G^P~86-PCl+pkQ+MA$Y5*LG*R1E4KXz%Ge>*8Uy)H zE4j}d^54qP9ZM;O!hCsewP!Bq7oxWs(ZejKvJ;oc&;ITk3o< z926>jkEpN9VR}cr`#fAL@mY$~i3FzmNdNKn2|v!uc&HLTpnm=I`pgX_A>RJ2xe*Q$ zRe0npYn*VMKTKI3{~$>^>lB9=F;R=kL;yN8h*-?J^jWY-bU^}oV6@O(c{{WNM1*fb z#-iPTy%;(GkYa1r&~h=Tn$xymiLD9awul%g9m^5x?~D%;dqTuzD63>j51u7={_MK; zfVU-|0Uv0j8o{N7-+0zotTrKS@>(`xQrF8J`|MXi~n`)BZbY`P!8q;(`3abN0PVz*c> z*tDX1#^>eQYWu`W_vA|7IGmbSnVKts>Yr-Nri79}a7+?3Wou%Dy$7$m$O`q&(_}H= z&5A)*n=C)zipu0Voamup0bLxc(^64*CCWYlnm7zG=0NNbV`A>#egvgWf%_OH z$RPdC`^&KBiKygb@mf*4tNu%_tuY8^qg|VU5{t;TNmg#kwr$N=AIovL*#I_tfHZ8? z!&9T`$ocsARVndkzKX_}i?YGkz0SV{bvz!q^+bG5Ilg^Bu7@z*0qE*go9$?$NYec7 z60Q9r!n{)>qz*GSZ}6N17vHyWgBE^&@gi#f(Y}Mc5u$}Sh9Fiy^AYnxqJiVp4fJY| zRGClgt*H!Y$>FQ@Y5e`LiZ8mlN{kh?QNeiv^ zHRUJdqKa-*LLcV9=n-R$RDyNKdo=*LXOO?#N)Y*Ld6rZ;6Pz9sb^Se7FFvJc0L#j& z$138jACzR3DA98n`g&e=V4fWl6G$M4sz5IaWA;Q&(&wpGqnM^s z$U<#QQL`r&_gP~SM(MzBG7M$TwN1;|DuRs%tdAH?Hn@Fcl$jpjnEk|{lJKWa&v{mE zAHdA65R1{2S?cLbFPkY$BA)11Yg9NI#B9G$PaY$e!rc;pUBuARU^{d8mnhOyBJwb@ z4udaVV=_hXVho^y20Kwr!;WUYq(tfg>gD#Ran=g6Zp`u<5&lZaRyVaFExFc_w4(l* zWqmpG0(^Y~L$PwhkY*MHCQh+pD`oB=FxSlHl9VEoJ2Be0(lxmvuMAo79nM&X${{j( zQ!S?D9WVc^7d$w1*kzCAi*l6IQCv$((U474Kp>H#A5Dy%W)p>msx>F%<`*`)CI7aM z;J~)oR=U!{pSD4cJ!U%-_<6`ER`TtZPPw^Hu=T=Qt#OJ2v*tU@l&6)w&qUj++XSESflVxz?fJxQaDKXeN zWu#B4;0-HT6m8m$PuuaW+zIE^q1F(IUS6ktF_o{CtdI3@5c<-}^CdYoCA1PmukP}y zl&{Db#HyF7m@mdVCEtlNV@;cWQ;RSAm&-=m3am`KJMxLoSF$A_=4>sXaQf`GR*=l9}TTXNA&YnR;Jo zo+1UW?bE**v(~DPvj;uaBlW+4eQWssFOj{BoPx4DuyX7Da!+3gvA)~pY6A5f(j>l4 z&6IRO0HK6l(=^0&o>;1PlTPAh7`kRL39 zHK#QR0B-~^jkrKIJ3vDuq6wF;ycF0DMO^45r;Afr4E4r4U*kJzyFC11?AUP@ zL3{PDUsx+$-eBKx+(i{ZW~sO6Q53f- zBo0@QRCedT<(_Eip5(se_|Scanh@BRZk-0+YB1RjC`O|6zt9|zhO|5@p0kN0a*2TG zm2=5&=2~O$1pO-tCnc>Oh>_>&&E;|(>^>664Mh z@Vc_cknre@v`8Yx*?T-_i1%=rk86U)Kema6-h@bKby%rRbd@|Ke9=oK>i51Dyd~+m z*OMLHYW-CSe#0a;1HO3P{+3;?7@`VvbITi3H22O$ME^>wHS;YK1;u(maBrT8k*eq~ zKsdUi3fGleHN|W?vR!mUHlMmLVbU6YI+lS|0pfwaG4 zd$r@#;kotcp*wr^eYQSAh91~l*!t^Vzf01N``-MbIwdZ&q$42sCtb7uiMmI$p+_su z)Bk#+mZiqOUXkhlxFKn#t)#I`%z>lswXYTipVn+VgRwyN7zUO6`EbPX3H7tt@6r3; zf70||!JcVDx-mPIh00nqj4Z~GGDp^HCTmn6F{98Z{Lp`9fC59r z#AzmiJt%R}A=6c8WkdOO1$=7lsX&>QK8J-rmMvgT~h7Ntp z>+qVHsu&ldDTrnsYLDdaJ1|lqkY6%zsd>>B;lmGFT{G|TkiQ8W1HbUUPvnx$?~T|v z*p~CY?x=kaHV`YwPSr4hQ>_*n&t4?dPgOc?|I8Az2scgV6HNMEiYcTP=4u_K-x_%) zVdg3zJT3$2cYG7&ZAPcN#D2EBrnNQ9@l?^bdwN$C%&Si!S$+85zg~TQ-Q3)p+ye;+ zUepo!M((GAx#-W$0tn*X5zyD;7UdeO01^74>06uKS2TH zf(Q}-s#ZoNNwjMMAV_O7Zz`KCW)oFFEl^5O?^B`_K~r{;z$GzY-i*o=SIW%t!e&ZC zM(UWdr&e#2dZ7sSM8V4(#t0t}j5)!l7sisCi1$54dkPv#X9a-^gY0F!D09k5gd)+^ z`@&SBQ#cY@pIRGYs3I|d(L(BjDQFn1Vxo1FvThaM;NZQW%J5zWAHhwytg)>Dxe2Sf{33O16Yii2#e12but*bn^0>(1Vk_ON zOGInrivUBxTHAv}@zMmoWC%%wKb1_{2RACK|66XUfrm$6S%)jp9c(By>JBPtbSI%o z&mDh`F?-htK4XYt^@O=4`BcKVV^aH2lC4U<)Ph+k&BD-S(fiLMe~xNCzuqW-5m#fZ zP1BT@F0a;RGK=Y_QC`Pt(Po5L)>LSuxzihnG_?+nmNwh%*Hr%ponsh4DmXF@O3lyJ z<>cYTtt>sPvaRio$D|BHsO96oJo{L(U?0#UXyZ7!8EbO}`Mu56{=v7-_SF+kDDEr* zlJnxy>+7Qh@t0QrA`#9F-n8P(M|WeF7s-^sE2)9B;f)(Ayp-tRzP_A(4nZ|)s?tqu z1~Niywu{k?l=Ua~Nc_#mY4LXl$FSnmP0GJJZ)pexEseV`JkMY9hwv-w0f+(3JSdil zh!~to@hmCtHP+%ZfQj3J+pNzg08w+~0(8B!VKPA%hRqO7YU?2HsIt9LIJ(4_rifvo z&@a;iuI%%a3aX<14J;1yZ*8S|jvE587v}3^UU<0*A09Ga82PHL;I(d16X7(+qNDOE zb*FcxIJN|$JrW8P+`>@f;WXX=#jPS*`r3Zb{oB7}($9%ml@oLNns?Kr00WBKm-Y1D zBZp%wLGP8dWGJsmFxOe*dewbMRxgOZ)NY>{$on+fisyz*>~G;8j9ze^4?K(W(n*#` z<$dNcW5W_4I7FK`O4S)NpKEiN_NcJQS?2gvb}eHvjnQ=U;qA~^;LuMIM$Hl!Y0(Ii zJ;Iyl(V~QRIRqQapEV$~xEtosslgL&+!S;a4#OE%_2o>uX>NDze>Nl6$!}idZIJu# zfQ++%TfnZJI3`GmSvG-DOU{jLzC)fSpxTGimEszRRAz1Y%~vv`bip+y$!b;{K2*c&>GhPS%9ve4Z_`%}Bs+t%p(}owTGyDNKsr zk=40zia7t8G8;S0$_EqBwpb7^ivYD6&d&(zBPMuMF8nrB^C<3?+Gm+T(Rr6>=0<5I zC;dPh$6d{9-J7sU3N#sZii14@0Jg=H$bmEva2o)aN!jE+tPs}M!_3~Yb$0iDTayV` zOpe*|&@2l~T-Z0|4n=*1M5RZMZNZZ!{B@m5T&~G<0txD;!ME$0Yyg-oyO?b+?za&S z?2n9U{7{X5bzjl301 zF;hi0ew%%%Az5W|KTP!k6p5}@t~|WnzEik!b9hB?s_npLoOy7PB?pf$#BMXQWU&+k zoatx^`hrr%%8T;BVjuvuNEA>23O9K?xZooD`JO<1=VTpj2D;DorDH*%MAi;~4k=s7 zwHxc(Mww@knJ2N>=tCIJ2XZ>XZb?(zdFnNWgBy@QRPO5#KA^tXkpwn=g@TF(;j7$u zz}FI%Ve-oO8Z+Z)#st8Gkj+rU+&QBAbI+9ArH>dk44L9JAj_S*y8P{9hpD~8e?$2v ztuEgUuG|^&o97^dh{hOEI2w+^<&F_?v6n(&^Xjn)9)3O)4h}+#6rE~t9(OCV@oMy| z-mL%|T(YxVIsTFV6r|^PGxy>9uQZ<0{p>R&by>AgyA1|zg^IfxmsVeB0+j>X5&e>Z zO#{hXId0}tci&Bw|8TMwwZ7yEMyT#8D*mgbA%%plKm6IEj0kJ&51HZMhxbv@$q|@3 zreZ|BdnsLT!34^5nQv(wZhQxNA^}0st{%J|F^b6oyIP;0tSv%R-Yh*OZ>FR@s;>cI zVh!hH;A8OYl5{LM0+3_UK*P_08POv(a@|zpJSYYDzKH`XZ;Y;@ zt4y6KSHjBkVjdbj(&@t=4!MAEhnx!!r00{`j?8<|Mkw)o@RCHXjQeZoa52Ldp>bLg zsAoHDzmPJpI!I1Pz#m~_yB;0v3a}r{H%O5yDnAxcr_XqobFw4?>Z??=3gEJ2|Ib*j zm_Qi}Ul=oAZSue#b7-^8E$rqEvhFg!ILKj^r;`EM1mTCgW61thQ@uXGdmi9nAy30a z$63n<-#)~?*+a8snL7V{?}2h05R8j#GktJo`txBr$DEDV>a|Cm-?+ zGyq!!N(%8bMnTj%P?gKv5KRTjD4*VRXlgt-=E*9O(+;8nD=7Wz9=p z!J4td*ck8|2M-BQ42sjE^x5uO?$Mes8TT>kr(<63>$1+L|28RF?WJkmw#rEqbBSlu z`GTt?{`4_ogfn&SG8Jm!WkALrykNi4i2w|_b%ZAimxL?5p-z9weH zZ!qVGBZb-aa^i~aC$az?73ArrnYC8NLJ31E|2})#V#yBC_K0$_J3R6gy{uaW_eOgM z69M+DLH2H}2a(7TG)t)%XB0DzN_ocNvr4T=qKD;R?&6mCs>V&UHYatD(S+s4`BY0X z#$aDz-PS~5{QFWR(Hz|s2@S<4DqP8Exrqo6^fw%OTb zd6%P-l+IFO+}W~CT?BRtIH|xfPvPL+AcTZ- z;I}f!+BK9l4+|FSNvyKR;q7zLzq(@`;P|u2d_nh5t+}SYMLd+1A0j)Swhml*gFTn# zp8ZQGS8bT(iZFr%;+QpFsGY(@sU{nMWDO*^BJc5prjVV<&B7 zo0Z9K3ABt(nS3_6>k&b*-cwqdI)A91FHHM9Z+}xNsq)7^z7qN}=`sDH`kne3fU25Q zw787o=tCLJpjnaxf7@#VD+(FUXRLJfq#gdKJ19R9Q=!Cv`Mc{O4Z0#6U^+e@6|&b; zYj+DIpjqcoQ99kJRnT|Dp`tG^SDr>TfnOb8!Z+A2^vZE$Lyrs`#lw!N{oNfdPe}SYV%r1D zRpSkB6G=*?HsryLv=Qh5PVsk(x-MWiR`bci6pemzE0Iz6At9~DGF@h8>7!FTYDuhi zWVKK*37B7P5{&oGTQW|#<609)=*uA+6;|7H8Y;c*$`_a6<8|Rfff`&wWe19RTICge zm8p3@3TYt=u^4F%SkUgmQU#}SthiArUvV%2G zEOJ2&-SIcRVK)dM{r{M69*VuR#(M688kx7yuZ5%b(HypyRdxgK`}Twfj)ke3V;+y9 ze_}0JMzDCU-7bM$w3;s40E54Fb;oP>AS|9|GKN{irJn~Msy5Nz?Z?1Z@f2s9RIL3* zV-A!5iLVh#>BKnoddec4v9H*LCVLJ&M?zVGdtRhWb-Qrn6fMnfWL6hh_hf(yJ?stA zvtfK@72xbA5~Df(ft31VguXFZy<(ryXSYjplYE4IHzz`HXERXnR-mN{%JN&@e$i$I zX%8h)GX$2z!oP_d;9&PgB{JrVLp-sMDy38<=McQlr|D@oe?@T%&J+F*N!HezLdpB)JBGT1*~feq7O`lr5hDJHz-ffp zgI)BE8h8K3tt$dwr!F-%0@OdS>MO&0MD~*OCcdY#I>~jzGj>9^k=D?$7iEb~lojlK zlhSK)ebQ)~%yK)6MH_HOxn~$pq3R7Q()liyj5wL%9VN0;CVz7c{5G%yA`XUymdx@v zMJ*Fp)vc!IUjE~=7g*-XjjIv7_%4)FlXs_hvew5^hsml<^juc|y_A`g{t?vSsK^}1 zk!F+3XCn7P314qG=5_X_^!<@|^aJ*Jj{efEkmd8dRK~)cZ&Zlk?GIp&(=g# zX_cPM3oeOrE}+^YD!Hw&$u)v1;>7-QD$7*g)--BS-481ww{ct`EaKMDm8w^_o!|%RiQPQ@Q;6$l&DeoK1g`A6zWy zbPg6PLck)%=Zce=DcfT0PEE>4$*OH`UD2HFlk^HRKHZVO;2*;9mj4btlzs`yLW0Pj zg~~mkCOeg`GTqbfogMQng@buDykq zwXC!fw@@oh)!0bYqAfTGxV!iY6*cSy`MQDP=%v~9&r$pcdBk5*HJiL9mCV{^jKpL{ zX!BT*L~#Ad%+Hw^N}+2EN<^l6VS0~FUy{{rak)QuEqvc59-fP~OG##ZEY+e~XYebw z!}4BdM^jq}hYw(5)0hd!is0-(i2dW3lVC<|v0Gis~sU-1J;=YPZ82 zO}*h$^4cN0yzTu|j+oPOqVnxWk>ZW6P~W~#wf8M$A>U9E*Qw);1HS&C{v}8A_Xpa) zmq88Rs|RBEiwY`+60g0Yds>D-p}nGduK^#J-9hXHIyLfPpETsn1cCuFDvQEiDw}wY z>LC_fFTB0?3Xh^I_o7=Ld*4?#(nRV3y}-54E9CpV90KCTVRsapPAQl1kLkyEb6)(9 zqqFd8@_*m>Hny>i0UOqk?YpC-zH6$YpQc=d;e|Q*bj1mmhzl=a+7nJ6*0lwZ*}V@geo-=1;%VxVAgY3$)oq_2Nbej--}&Nw^B45! z@6}QznDeGj;huc6YI%n$;_-LH1rK3(F!J^H_T`gnuf4A`kF!PtB&dwYwDJ0*lOWcJ z;KF8<14ukFTFR4xO8;Xr%1NVevvz3^#bIq(OT0a#NM4T9@Tu_-)O(ozkso!dr7pY!v6W7F0mGhV3Wwcc?0 z)Sp8&CzlC$TsOXq+Z4%c5(RnR``$$8h+WG!x}x$c8?SVaof}`QL!Pm1B+)yIs7+x2 zI!Vk}gM76dcYOq#u+h)>oKY}>L;Ft8c_}?Kk4w>$*5URp5y?lM{Cg5JW>zMsx6RgR z1BSC`SthF~T-ayJ~1m*4p#h?!YW=Q zrXJJ!emHpwAyVp~qXXyE)qyi)5OKOfYDe-o z>21@GD1)2ics&EYgQ^_1sK{DjLZxGz<5+T zcqEQl%V&j-Ne2%D7&GlqwFO2P$OV>q2O;@ZESQOWFibL!3NkZ(mv6`7vyY+U2>D-z zv@vf%linwRa?#4Tj&jMIuxh4YhYrDf*;lwX+Klba861tl-B@DY-}gAp+#F`NU@lTui6#&ATifM+#4HWpgPr z)D9#x^Q2Jw@Uwm+%ZqDHdpjno+T9*I zM;#rwOaK1)ONXoIQ?wB_2^?c2oZ+%!%&f`c?!3o~HaNBE^?)!edDWzVBX;Fai_nXPf|pBfli@rO+xKfoq6st@eKyx~&^Kfp+I+ z5awtEbmbT17W8=1EtjVBT#L=~KOPs;K_lRU1*5eNIuK*@v;JD0g1B9Z>xY}q>}iVa zH(xSb@pDUaW&#zFxMJwPR>!zR0^$dMV&rapHYHR06-8zyVb01stA12z?3MJ6!FvZhHszgwd?GC2Ehpd7QiIKh0M0t5wS+c+;t|GCBWds7s zQJ{+l=GSjV@c{1qt-Tn@GDGanWv>B?HyK%dur6C+YcK#w{zx#nQ{OB|ujdt0$_J)z zbttI808#;xek8Z%^|p|NYvRm27T%sRr-*$Di)vL6p%)1?^@oMg+U)B0j z5)_+=VyFd51<+FISh@L|?4B^K6Ba^H)%Hae;D93KV}Ip==~Z#_K!?1s*yksGf2IO2 z-O#L~AUrYI`amo-+eL~e9wmbpS7dXFeIp$o@2a?c3&`Q6~*z| zLa=uEI#PzS<^K1tf%QNmFh!u|hWfOKb*`SSp)qks?^+k5GM$a?H({E!B_`nEiMZHUYgwtm&Y^0G$vYu0`< zWxg?w@*5K|CM8`;Up9ZBLr>oX{O><{n*MwvtbyE8jO+Fj)A^)lCr!PF>pvqQzXbs@aa za2uSFrE@s<6mwg{*fdGQwwp(<&->KkF3*}bugWH1*@>w7KCrc3=H=y#8HZ0#v4IXK zPCgLSRX17TiQUJ89CG|x!z&ok$BPTc>FQ;9A#@>pdC#-fwY|@seN7k~$u5`^*u5~k zZ`a;mW;-P1s1u2w?wt4v=&DW4838#ec`y0z!y z9JLXy1y^R;X?vA^G{fYvm+p}XO+N`ppJ|NP{^+$tw*(5*+D*<3sL70^TaHcq^m|IJ zbgT|4=hqmQb41I*jUG2QC!c+f-wK5PQIEe zwAn}0!k`qL5cs$JG~Hwx?55Q7_!RZrHa(;GLBH!I)yJU$U~EuO8+NV%Cs#nYyWQ-;b+LvvCLJ0Iv?<&#$@yR@$vufcvqiw}P>W}|q*zo(z7 z6p{ZqSpbVBY~>|QQ0~zFnsKLhc6uh!uw>#H5wYv!R%fvAOEg&7=LjG-8oRS79Nr2n z6LG2>H@*e}H$$8$i1)In=~TaIjiCL)=BC7;L;>i*pw8vinS+}~SNcA&&gLc;cv;9=O*>+UHI?mzH`m9-?=`A>U6L-PoXI7Jk0Jtof@{}v0z2) z3)(?D0KmdC3T{MK=qdb0G;(B!5zi2m$JPYd2p^Nf;-ym{S?qi4Yuu_%2<9_qw#=kiuSA zvuNqKXu2MF%4*7NpQTHL-;%~s2$G=b@4LgW)pN7eeo{e=&HE5R%b@w%ZA?0O-T{^J zJC-~%H`jN~tB?i*h|%{bK^%w&JHfb0JpKHA`omU_kFD(Z5e98t2GIroN!p^t*rLv{ ziWx&E?A$}M&WiR%bWSc?Zwt3)uSjPwFrPy`FnDt&q;HqDAhQafYK;j;hf)^U!Z5rz zVLUeY60b<0Oo8z2>}OMWf-@G0i~EYJmH5}8>y(w8>4S*J&{MZz0`KLS6wq>FQrzgF z(t{Evci`{`mQoj7gUB|DwJIc7fqS)T^9iR=b3LWg)+JW)mj1X=08r@o!f!4Q`IdX=w zCP`~p*=^+vuUJWOpljxnM`%$EDjk^>lo!wE&*~g>?R{9@g*+c6t65sRu2*k>Ru77Mu-T|q6d;c7*cCWQbz|B^$Ug`r{y&W+dZ3KJT zia0B0)yPSfh3-M%xrf)-?HNh1VIqR5N@3>+d;EcZb+tfyjN#Q6`$;^5a76EKX}Fwk zX3&^W6Z@x0YUnL*=CEANy|;{0>-K{x4E7-m?R(fpqUgmog2x`gQb`qBO~_jHbyv@ zn3g}>H7&iI>yfvJlamGD@WfkJW%Xm&UQe^V^EL0fz8m?@eUGZg2bYK#;0%1N4m};CiagLB9McZsymUwLOHhk$l)uhH znhcQMk^#nN46sUH>OWo+Elb#G~k| z@o)gU>?{j4_|b>iM>O!`S3e?8t)pxe?r~Jxlly4M008j~CjUL#-!`+ep;I;_Cf{79UQ^g*JF>x4z9&Xeuu&+FMcdf6S?7~kK}Wgdv=#eBuX zvo^dyR8F>1j2?ajRtWe8dkO5*dVRDz$mbA2LRq~x6X>jrAFLbRBu=|+RqvRE%*6SpJ5R+{dTNp&Gmp`9nMzCuW4&I=k5$e{DGXt_qES!ZVE9snSBU-$D@wY-2+<-f(QXI~nwY^!xq@|BgD zE-)ilHni2=iVNYcw8?}h@9Nlo8eJYNT66NEM&zAwKMiR;L`R0*A38;-#lhF2SSWf0 z509k_zP67wu`+`otY1GBvxeeQW*d6$Gt5*9@pCzUA(>Waf z6qB9@Qgt4QO!FSyV;H48yvXuMlM}f|OA~R|7RY$Snu6y&@BvB{C6L2I9Kw;w8MVeK(o(*!H3mLlkMf!A~a}^w6rI@?oJ8cT|QjWVD zc*mh=+q;qN>`XC>rmKdEeZ$#n6VpZE|49Pojyo)qOm$)^NL!tjR(JICcw2G;8N>uIpH%%`8MS|hHR)18 z)7m@g7U`hguj*a+fOAX>h6iVmG;d_Pq)>VoOR4d_(^gWnEa^VP{9q`-?l{%1Iiq0Z z7QH6*=IRPwRqgh}OShd~-a-aIwPa^sGQSOL@D@4b!uEKx{NZ&i+RPI9w5B!|m>Kts zb@=l8j*;6f$2cB@notWGgtWHQJ^SwjrXk$cK6DshJJ@Ki&qt8G#%vHGM=$WXYAC|U zZuo^g_e{IzSS3vC}Wo+fFd4Fu5!rYVS+_16-DWeYx z1YI`&(zWUIq}o(HtUie4T6D%fFXzSeVeY0=Hj~8NN*(s>6JbY}+v*~5)NOnIi@}e5 zf+vhlMU9zc|CwY8Je>AW)%v$OMuT7f&1hDE1L$OClfdU&^U3m6YpmZjgcVl8kBE=rL!PFZi$~NrIkFaI|3B!JBbsuMYVb5QJf_%p)=r z8~2rQMcor%^;_461j9$2Jp!!767SEJ@rdxoNW6qf{Qd5#btf8eFW;0JbY&ofVYbYf zk{JVmw&kKgZD(BjIWbVx39^)0K5-PzVv?+rAYMzsCQ1f0f*7QiC}gQ*lwKS`d7Oe{ zRBSb(W6bSwpc7V{NsePw*&{EpLlMEOI`$~fco-lEKw-!8!Q)W`aFsBcfFi`Btg3Gm zqDUv_xh@4jHV9zD4%TNeNuSm$@lsumXZ6#517-`<0XA?rqSq$vL}s2W8*n%hgqcMP zz=@n>B*K6zk!R4b`c}Haq`*zCuSQZj(Etjn03<<2;<%l1mB4g+5TzGpq-Utl6)b5; z3dnyd;f_5oa^}3-tmkj)90Y`M)=i`Hxa;0|LM*j`BDPlg`~_@mR$Ob7=7XYZi7^gm zX5V5V2GTL(_8W;1657*BLa$#(IT!?x4T1yeplF;u^TnlaA6=v7XP;fU0I*t;r0dPq zYx%El^f@epV8HEF=vL2tl{X)71)v8)zJ4s=N~8HzQwFj5O%d6uR~YdItVMqS>$X6u+_?p z_$vaSGO{ZBy_0`uNp+<}6_;KY5N&D}PWZMSaplf$$gyA;2L+EI$U21RAPS$)>m)|q z+OJ}e9?#Z6N)Mm>NRllVha*r1YE=oG-o>CK{(4$0K^OJa=tbLe3NBUgtnufsH05zM zAz_srye{fu9|)4o_wBsG+W@}|X4Bz|8p<`({^i+EEL0&mov$iDIL9nUn%K;QN5CcC z)lK!R0i(tbS*?zESUI`#&6mD=!~6`W6qer;)GK_cjn(O~pP>zHQ&maa*E>EZo@=#h zGc|2Jate+2QZ;g{U28FXOZWIrCN5^i3zNDO;U<3TEV4d8QAi1$?a>LSoJ?>S`)%Br z9Ku#Bo*8XvuxMyRM!!f`3 zKTDIf-ihBYn?;X7;qU*u)Sx@k9qxq%TgRIaO-Y7QV@nUF|H$gEd1+KE7lbyzw zqy?^yO>bnsAkO$+>xVi=N@kdnMg?P#EsDRl8ZIt8m4(73U@#I9rXeez{xcR45(Xn% z@?u$v8^B5x66XGXt7Qgi4UH%;5`^Owr%|$zK4)(%V{x`sOAU?sDkgU!O;>e({*XIU z*Ry5mjMF<2%e$cWwU|E2uGL$=rJ!S%Gyuwge0|o+=8ko^ct9p5hDg^4`=3{?OmwR$ z0yEPIaB`I-qkrTWU#Y&!XgUw`(|58$gQk~nM(JHC|7j8D8H4{*?duLw?U1pUBwsOm z?yZK3T}iHIFWi31PS=4*YZ3)=m(>IY*if?HbYf1pDGL8KIi?JrDpP-QhbDeEtMS4V zavN=O!B0g@g6P@MQ+1K8B!SUut^QY}rrd4zNOvpDOXgl=DV4lc?Pu33U6BYv`8nNM6_k>F%&5K%6-T3e;9Z z6(lNcH&KATSR6;p!5@U;_;cBldM2|J3ujQW!uYs%z`mXDX2(c1&$l>Zb}Spf4W*>) zg=D%cM^FRmHan?As`g;fsnjw4YAXhzYx?N-E6GE`jwxR^)2>`wN{-fVW;ata3*cn6 zpCVVM(bLt87ISoCOEHf*dW(7Ce@xY%?x_wq>Vt57&*||TeenDML;RN*I3Tc~ZB2Sb z!?dELkAf*k-i&DEsC0Fo*V+f3iQYm~Kgr$rHIv$8srcE(xz9@#4=n*#NhqWk+s4ur z0eXckWL&uq04y3ly)7by6jr2tS*Fs*3zyicgIDmO@6$&@bva+I46s&yS4M%DyAT5D zvk3dCQI1-9@NGp2V^bbEd`wZHs1Fe70UP@cv&ohieh`I7xUBn9?_r%@k42l&ScNbY zpi03qhLMwpCpg8k?tCTEiJoxL@YVn-o7i381E<(qsVp9(&IM z0_XdFaZTZ%0QCoRr9hus=i6m`x$A|XoI8{d*`OHn{o9}T_bI{`t71qm4C%`D0WhX^ zeVK47sOg20cktoNytSck)I^XEljo*hjjDf$*X+b?+!*Z zgPw;uJAUZpFB|mkKYpil8t5PzPi+Ilt-{~DyF9;i>6vnJL1Q4co?f*PC`Y{;&Ib0dxEuG@5xHc=5^~I0E>E0_ zLqe|tp9gQau%ZH;pgZdg^%}0gXNZD26V`3PW={TGdLLc@2nwVFV=*tSoCa!r9>#lz z)Cy!7pvHR~Y}NSclYHlDeY1b?A)g|Ms|W#%f}lKh`2DR3Nb~~^3viWx-LlIRy=#ro znd?}gkU0>%@ecj0=(Z{DvL5X=!0R%!3UWaKa(Iq&n!K~DbdkC4nvfCdGCAeRxYb)F zap2SIAXpH6760|@^e4NN5SlgXcWYPJtUxX44hev zvT5)lH6)bB*i+E}Ard^#K$WeV=PHL`=$b+bM3H`pSCTc6#TSLIF^Nb{Y6tsp%Y%CK zP!ePxn^3309-n3v-c~PEn!cj6qwkC`u;H0mM6q+}m@{rWr@rde*!rzrjZ*gv)*0l{x4_JgSIRRMBM+sO;b_#+x|d-4JBRjrK*JP}ne|FnN^CcWb($?F4QZl^2GqXi2$aEN}379SyBJ5&rA^R<@CTRp8eGL7gC=DW!OB|tB@-GSuX@mK+e`Cj{y zaBWRUZ1VZFPX;!xm9*#f(*gU}mtn~PR7vjeVy5DZI(PJXz9hlK_YJH>U@}HCLCW%* zwy0{X=173(REg00k}t`u<5dt3_qc!iS=vy~cOqaD6wVEgYGbuDG%AB1$ZFBzZ@uxf z)C&MN6?}usXXJR<*_Vf}b0cj~xp-6_{$AT@IlE9Uk8Zd?jRWIbUO+ZremXbpYEpP( zevVJAw_+%2nXAz&Ke52JEES|71;bl8vrg3DqZIN?V+|-Wkd87e}>q8S{9m8eH>AA z&HV~^MC#P=uCH*-ukTl4B*?0g6#DNXg}!Jl6Psjs%%*sZd;t(>Uz?>amCH?Fwe{ps zcH`${7qquMF{(4Ei&Z4hEA5ftoshpicFJL)YP%kqYor$Z9S5RkJf+?UCgpPeZw|6FnHiO-YWcO zq(W0tei46Pejb(Lb2x44sFeG8LqM{oThQvP`ImY`BLY*HoFcencZzn|RBm}}-$F{E z)*9iU!Ksh>C|9N}9^Vv#5~|t&VB8jZGF5Y}zUpkv!-tSX!eB@zj_?++*v;zCUPV#} zE7{a^`$Fj5-0K40?#89mY-)(n&t5#zklf^|gO9o-d=tM#B{K6Ymcwuf#Yol)ZL#! z#fHfZ>y=G*4zzdxBL>J!F``>K3vXEpZ^8p&z8ccuX<$A8B%O9=4H3*40pF%Er$0oO z8>Q4fW`Dt9z@737txOoohgkWvQG3r;dRb3^v=hv>i;6P!YgqqQZ20RHRqbVgZ zVs7o2?=h^)Quo%Oj@jhMDi@)-i}gx0%>;*5D3nGN-yf9e|q zy&eb+mU-quhNine^FUF6{qz)W@#>Zs;~5sGpv;SQsOtdsN7ksY?My9T2gbI}%u5O4 zd`E2#oozNfV#DzuZD0hNN);-1{ck1Tn0IOcI!#6OmDVy$ zi}o^ldB1hx>LkzcU-&ftD`v{Qcee#ugBJ00J%5`Xt$lU`WD@|}zHf4(v`U;R=*_x= z&z2jPmLMI7CJX?ETxR_@50PGIlm|3Yx8J!SphN^S)03o;Ms2aY{T2aj2T-vMji+{I1D>nUYj(L=g1WvDJS!YSq8F#XO{Ex5IC0czLvyoN3f$ z9uE#Zzu4(~CwhS0J-E~(XQMEBx`JPpdRw^5Xj6Ne?!k0@U@6^oKRR8ix_9##c;3Ik z-lyy4W<(hP)c+88t6?j0e@?r_c+T_HFVlz&VPNL?A@T%X9i$SQ;Js>`yZq04>&W?~ z^fJVPw&Q{VchL4;Y@;qCnEo0lXwE;ne?0sVK78k1U8*1}3fS0lwTT{c{1AxlMQox0 zPD2P7;M0zRF~ibBh&%$e&0}uL%aE~+U0tF>ZEp`BJ-fI-U%AcjaT{)V3@0LDdwKX} zdbwPB_n6E_jbmiC(Bd*wkw+_b8(5D&E5bWF3kiKR(XjxjD)mP~`Tt^Na?dX@I%N#_ zRxq8MoJi!MjB3)F!GD+k{Jb37vYTLf#X|nBECOm>fw#H5PI^cWQa!t$ZF+7LN%$_k zyQV_#fw~kzgWjZmb7-dzOP^%@4)qYM^_hp#KX`ie?w(KPo+s*A;H$mwW8yboehs^S zJ(8&;mds{F`(Z`CxNO4{j$$p%SZRMqyh#rf>i~fi2{vBuR9zVLz^Z3;ZZDU0L`^W#Kg$u7Hw`BuBi$_vIe2ruP zto?-H$}!Uwg~uO{Gk4(!_x>}G0PeqxY!P00JH5zDj8c8u&=zzCqE~QdMJd$FKkfjc z;AU8PyQO?!d?Q=6NrB(`7!U*oQyn^YC;%>cq|bV)&=XQ4GGE7TW_Q}U+Pa=?y{j%h zQ^9NUuU=I>7|tfXTT8CG3Sl6~kTHIQ@#f+J4QnU))dQQxHobDH(|WE5o1;y@%jVG* z1c!vfl-XKN(j1?{+DbMyPKMD~*=LPn`#_e7SIq|uWM4IU^4JO@b@06Qx*<*{9_40M zJM|W4AZ$uf90++QYNC9Vv{DOaU9`}<6bzbBW|Od}1|&;7(UFpk^;*2MIcdr%M-GX5 zGk?pHYx~ysWURLLA}?BDHOw=w{Cntr+tryWr6dc(GmUVkjm|HLjAf+P6WzwTmC-z3 zeHU7pzL@eSbs_94OwT?W985`$rM5wnq0+ ziRR*+QHk1Y5JJWsM9p{vO_!~g*60E?WtPj2c208H2rEp)1KbU`ul97J`FKCO84BDy z`ivH!%)QCsOC1KnK%9}aLRf>a)9Fmn1EZ!|MIaE&tbIbBmC<}on`Km8@{B{yNau+o zWr^J&q-0Pxs3LsSc)?P*adpA+bj>E;(&bbMXl2Gd!Dd~3i&_KrRHRj>D{jJSi`jHw zRo3TGQKH#DPs$=u#}~h7sfD%!S$-@JjXi^_y%BD#jORhjD@`Mn=uqzN@8UB3ijCs{ zPL32B3Cs@xS{q^4sBw}i_$naLv92nKlwt%(=X$trW#p`YsWq1R#!+`}uXX2dn7x#+ z2$KPrh$|~Z%7~h1jIz%(7^#h1Zs}M_ZDD%^N`vsAk`J;oDccxP-Zkr%wg)?M?>AGLH+$9Ey{N<6*0W-y zQSR?-;DcA+0dFt7`N&I$yy!iul97Jn>45KtJ)d_Pnq6=2Yo2rod>a0`=8<(#aY5W8 z;MZ5Kxb)vAt8&PyKdbZaWoNd92hZxCbEOSHc%$V<;W9laUB;?poqAOV%^KhR@qHMg zq!~3Z!RZ0k3E{oE3rsekJhwVOxf81?!*KB~BVGFDy^^E|h*94_(y$hqTWJLsb%D_Qm&owu+IBg}8tVr{co4 z{rtXvV`&+8*ossGX{(ms*w)wHhU%y^!lOgJ*;TvGlYgLZ9`7fR0GP{)C3r*SAPsQ3 zPRxW3i6Gi5?X5nJWv(jhm0b}JNRSiWxrOPs#>rlCv{XGwW3@bbaGy$@24U3r5xbDq zryC4cYzIt8L|Xfu+ahPu5r;;OP6UN`32A76Tk+UFNa25?se-YqRB});+PF$y(J{xJ zN2n{8f5g*S%kiqiq~ek?LD>Q`?`u}WL!NfK<}yV&tgL~)03=@`@bYXW+LXV=+!wkT zRzv2Xl#>8mD(flmh`zCk3(pDuuO$|v`s)SS2d)O>m0RB4*X8T;3c2)fBV!Q^)*W%c z>-hpWcu)7J4+)Le*++?qs$f`FOto$s7oGS$RxY`oP9b}WSz7W*HD=04LAkj+;~aV^ zu%B#rkAWq}peQZ{c!=P~nhCZ^q=^4&XYU@eF6`e0GR4Eqx^a0Wwp_yq7YgV++4(p3 zYMw?+swMJ_jQ`@ImQb=aTLGC$al}MgMu%%>s0)*@09?v&eT+fucdcF`T|OubPCn!mi^o3r&*8i(nWf z=RE)6=}+P)%loEUmnWK>gEv=r$C0LaJO0HgV*9nC_v zK?wu;tsyz%=;Pe+l<8dr$bTiTZ;4IH0Viq#a76RPP2{>7c!|Gl;dBfPi)*0&TM0|w)ku+%o02RG-Ge^86o z{Yb528_cDSddBOu8C#i_+eUQCi^}Gws(3jRu^yCwp<(dK6wj}0@DaJ zy=G?S3wfw-)PKj{YrAJ#0Z?2e)FoiWlYN>H(Tih9AzOK{xxI8V7%DFM0*rV#JBPi< zTB?gj1L`UHCeA!23o-Gk11WXaV^>Z2rmlaQ``TOuhS0PXDmCBulJ_8XY}~R_2R_}F zN7xx0@OFNtW^O7d8I0xNNKRxg*0wSVtFCIc(SIWbv_06Jme5jwS`WvZp(1@KpH;|) zqL@)qt2+?p;0uBzwDRf(J<+PCn%@o>N!CiG&a>QmkUn?by2Bi=<9b>SY7J)FS#&iG zgwVtO2{*bs&f31~V(R3{YwN3Sabb6No|7(0!dkiGUx+wYaaBC%8-*#O7#Q5gxrTOb zC>k!gbN(siUhD`+_xh`pWa8IN+BSQk^rmEyg}=wZvad@qitc`?Qsl-k5NU^y`mf`}4-)DE&9zlg-xIgG*!bWOcGQ4YD=Y7vI4WB{f_NQ*qk$X2# zM+tvJJ2p~{gWQ2=`5D+=*)^utp_hNQ+98TTm;UhgRy-MEZ9J{mhDa9bsLPcPCTGF` zWMja`H6@|%mhGSe~ zIy1!q)_4ktJWybRardS2N>RKBYCa`YyT&Ha8qBY%FjKxUt~&qTZ2tRTIM^XUM2{j; zPFYP6tKLbevF1d(RB@EDoYoXcwWKyx58hwG%B8{O{Syq;D&nz9eaQ@}$}P!OI8{2N zc#0CXpN|I(66(O^V7aHMnzAcWk47~Mfzp?hghtMs7zuG1<2+iEH>IE1^o%Q&YNe)J z5Wh4srF;=zn%S?_v^;F-AGaVR^My;r=y$2H&`ulx(`$tCuqADEFqNrU#ZX|`0H*O2 z*pzt2mbmyBAPbTAUkY3T4tS;5)Ic17oE4Ao#H-sF_0!i52=ojH))vMd!mdhm-BX0` z!<)O)Qo4nzs(GgW_!fC?iAHtSHx*a0{?y^?YHnF=&IDX)|1hW$&oB@)rp&9^%Tg?= zraiJxcRF;RePb+F0W_!y8q(z+o<)l9D?L(T9Q{2s=4WDXR5JNHRzGfho2X@;pAzR* z8dRQP5|A+KY<3K5Q$5kLGGkVik5k6e*=Y5y@Wv@}(RC|iUQCv$-e7(f+WHuot-LKm z%vRnc(}`Qtz1FSUMm%y8epF1JAB)PBKY1`ZHFo*8$TqSb1B;zTgLaffkGm)RJ?jmF zL{AkfW@AC$Hb=+fQ}vHvravjDvnepMgW-+PC%>c`lq3)l=+vAwxb3uIL@XdRPmB_l z^2LoxkI4pX&o^>Wz2-V3LvXIRKz$l zt!q^xhVz3^^gz6HO=?rKiNbCbNn95v-lUukOw0kQ{xwmy=@$8rEQU|7j?R>dWXy_U zlp>~U#cOH~bq{W}4DsseNDC<;dW?B{g0$k~_Cb2L^j?uK8rHNLBQfe^rf#+eB1?m$ zY$+c3IFtN13oYTPvvf0JPn>~do1vu2_iSMk6Mefm=BEs1@{u-xlwNE13{{O{)ep8N zM-Q)rWs`SRXjo~oZnk`+#t0y8prq{4HsE|0T=oW99~OI{WO1mw;MX(x(S6}FkKD>M z&=>UJLp&2M18xihG83O}ZRCbW8~)3E5;ZJXGt-zkEFL|3fntj(QcR#IV?_+buB(ZrmJVJtL)_Fw{GO;IMicX1R0|cGWil6!|P64(xEBUnV!+Hv06RVy?@=uY=1PLj>ZyS#4Fc3b0_^;7d`M~X->NV*s#734adY}Ze>a8UN> z&G5^&!`jDVImctF?}aN2)Y`j!Afi(n?gen;j(D~XFn1)-lni7clDymGe#IGn{>y)Q z7FHn1*ZH-a;{T4Obzpe5df}QE=I#(B6A(l10I^zvhqdJc9N6?XDk0Kp*{F>Ke`?~8 z5pW*=V}nKqnZDh&TzAx;#V|0N%K}VAaMi&IQXQDS5z}6d2=FfdK6;*HG>}~}^;c#i zk87riA>o>51}HTJzE2D5wE8EOLR}j^t@y4Ef_be*}`3G zk1F8=IL&DuCtG&MO%2C=#;={HGuChq4HMcXAm&dG8K307s7%pk9$A>J<_4TaGM-H2i96k7jB15vQ7yfN9sQ!g2O7KEjeXs zIs>1>nD{JyMvNmc1$dMkArZ4$La{8SWFb3H($n>Pn1;ie#__JEa<_YJZ(0HpT5Eu0 z?sJf(Q@hsY1+$i77`Xeerup5<(9QH|@z@7?pn_kz19O_%46P%lnqy~v5V>vBU`^-M zuRBK4nWO0`W47xbqUkEHB`8;x&THDL2ECm&%NjLr87WyaBsy zWfqn*s!YYcW_7jp+Fhv=dFm6VP|qlpz3#|pu{H4$^Am_6AG#j9zFs$2S)R$H#b#pS zB~`pOrz}DPo_>laMer{As#yNpvj0>EVA3Lfp3k%R_3p|eu{%Nuw?Dw3-IgPO#gMhI|PoNK%uYK=lc|plfXW^{OunX!3<6v>w+hxe*@*Z-; zUUG7}(N*t$YoDe(Mk5i^D|vZv*vVDfHz|2r-r^o_GMB7=D!xLW9avS`?Kii)Q8-Fg zl#Y%1g%uEJQDW6pOHaXGgJ3&8?)!jb&3AI%jEQN3gej)|`8$NxS(?VG)OTN%-tp12 z7wYakrS+?3{ux25hYi_um9Ml^ziU-zzO`b#s9~Y9;G$ye#e4C-t@h;F45L;>AzW`k z<-WJb%D>)O5c+wCsC>4a$YrEypWO=xXvgzV@Mp|}y+#MRb z#9xf^>j!R`DKghh?p*%Jy7wxqX``AvcoJR_xA0MhK{pZV)OwV>y74B#Y9%q)`pe{g z=914hrr`acPd_f3itg!(S;YP(#r`SWksuv(tk2S!rI3YgUl_|An4civyn z3=En=>Q|H2;Fsv;0;{N%ypvj+>JCI(ObmCAeK%c5ff1;uF3B zqwBu&dDHIjMc^Ut;txM(QA-XMa)RhSl6qa}@^_AqlH&MB-w&@cS~>~@+)8J4);=2d zUW85MCHoy&be_9og{wdc1M%Iy(VqW-z*WLmI9q;{eyHCW(-nX!5kVvdW`4Qrbxg^C z2VdUakWb*wpVn@lW?TqN=MM{iME3oB3<6wRlub_bZ@hI1E@>?IKl0ACh=9ASKQKLxV$ihm;7?f;vM9NH<6f-Q6M5jgk_gG=hYH=oc)0Jb%Y?*1gV~ z^Zs7f+1K9t)B5Slj2lR9+4VbE3~BzeF|r-wn)443q!6uM(;a zV`@>;PjDaksm23ty3{V^Wl`NqC_i>^Jbd*@B0yMu+n1@QmspZX0)K#oDQYTqJ@FpC zcBltRx76m`GIf9UCE&rk)`u~rnP?|IrI0=K4=dhI{?(sdjLyzN0`sE+`S0Ujq?X6t zQ1`t0bP=qp4#@NnUCUE5+jZW6r7W)9F$t1dRCgbCy8AL{e)xPr(e{Sfm`(y5yX?bv zc$qC3Vc2`+`}t9UsITjoEy2Zzgb_3(qsi9HOiZoND^|sZ^Sc>dH}#9O7JBJ%slpJv zAvx$LASj$s+qj-I3yMrf3EO($_BSc;;*U2cgA4{K==h%hQ{iC1#S~sMpN;xLq0|sm ziY3(@b~}lorX&BI$;KvogM$Yxh;R3Gg{w;7yPA(RIiH_=whU1oq4Ab~w6tXB^OE|t)Z`o{;rT9A_P*0Q zOVOfSs;q0>mvC$DslvhBu0g`T%^^yrJ7N}Q05HiY`?BMsZQm28E2PlB8bjK31|w~~ zbq0NHMx3^_@bow{-{G(s3b!PcVR`NLR*A&&(}lmp5MYBhxZ?N1q7^QB!(tQ^`Z;gN zDkO)+N=t^)f-sin5DVI~B?sN)*mWT6X2?1*>896o9~qlhJu!vZBaxWO+&(y~$(L(5 zt4^&(MjHzj@O&%BQg4b*2y#)zj09EH-9|{r4%%`zr4B6BCk-RK3DwKhA2INdc zI*c0nk$`?Wzm*vetjadLAIF(^z@@90>qUX*qr#=?32>Jf!i5J@M{%-}fqF(JP9AD$H!W>W`6~l@@0m2F$iR&f6B{+|q;T7CWP0Xk0*)+VOjpJ37QZcK z66#6&tu&I!D%LnV$hLYo+r9qiba+C%fhanuJi@Ier0Q^fHOxB1oRRZnvZg_m#Y3Cj zie+BsCA_z4ye;xgIivXf?L5HUP_&APY3IdAH zP?}m6tb4oe6>Mqp8fclQcw@w^Q_V z&33eeTq}3dt&QQ>Vu!q}-Ru}e%CeRHC)><`fPdp<4K*mCy~Zi&dBkyS{FzH}o(cd9 zp~Ikw8JWqBbnUsFkjir1cmgdT#Zoe*64o7~_^XN0YrN*$HEt5ITdzDy+yrs@Gqk;wnv>ed! zUqth0y7V)chFbrpNu;uW67^~t{aDhg�O+jP)~~4)i_7#KRWMvWn(?j-QB+EQNGf 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.44.0", ] -[project.scripts] -ventis = "ventis.cli:main" +# Import-only: the core runtime ships no console script. The user-facing `canyonos` +# executable lives in the separate `cli/` distribution. In-container entrypoints are +# invoked as `python -m canyonos_core.server` / `.cli` / `.llm_proxy`. [build-system] requires = ["setuptools>=64"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] -include = ["ventis*"] +include = ["canyonos_core*"] [tool.setuptools.package-data] -ventis = [ - "templates/**/*", +canyonos_core = [ "controller/proto/*.proto", "controller/utils/aws_pricing_chart.db", ] - - [tool.ty.environment] python = ".venv" [tool.ty.src] -include = ["ventis"] +include = ["canyonos_core"] exclude = [ - "ventis/templates/**", - "ventis/stub_generator.py", + "canyonos_core/stub_generator.py", ] [tool.ty.analysis] @@ -55,7 +52,7 @@ allowed-unresolved-imports = [ "local_controler_pb2_grpc", "local_controller_frontend", "redis_client", - "ventis_context", + "canyonos_context", "deploy", "*_stub", "*_agent_stub", @@ -64,6 +61,9 @@ allowed-unresolved-imports = [ [dependency-groups] dev = [ "pytest>=9.1.1", + # The standalone user CLI (`canyonos`) lives in cli/. It is a DISTINCT + # distribution from this `canyonos-core` runtime, so there is no name + # collision; the root test suite imports it to cover CLI behavior. "canyonos", ] diff --git a/tests/README.md b/tests/README.md index 09394de..90b902a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,13 +1,13 @@ -# Ventis Testing & Load Analysis Tools +# CanyonOS Testing & Load Analysis Tools -This directory contains an automated end-to-end testing suite for Ventis. It is designed to verify both functional correctness and concurrent performance of the distributed agent architecture. +This directory contains an automated end-to-end testing suite for CanyonOS. It is designed to verify both functional correctness and concurrent performance of the distributed agent architecture. ## 1. Automated Test Runner (`run_tests.sh`) -This script automates the entire testing lifecycle by interacting with the `ventis` CLI: +This script automates the entire testing lifecycle by interacting with the `canyonos` CLI: 0. Runs a small pytest suite from this `tests/` directory. -1. Scaffolds a new temporary project using `ventis new-project`. -2. Compiles the project using `ventis build`. -3. Launches the project using `ventis deploy` in the background. +1. Scaffolds a new temporary project using `canyonos new-project`. +2. Compiles the project using `canyonos build`. +3. Launches the project using `canyonos deploy` in the background. 4. Waits for the deployed workflow endpoint to become reachable, then gives the agents a few extra seconds to register. 5. Runs the Python integration and performance scripts. 6. **Cleanup:** Automatically terminates the deployment and cleans up the temporary directory upon success or failure. @@ -18,22 +18,22 @@ To run the complete suite: ``` ## 2. Functional Integration Validation (`test_integration.py`) -Verifies that Ventis correctly passes data and dependencies between chained agents. +Verifies that CanyonOS correctly passes data and dependencies between chained agents. - Dispatches a single query to the deployed `/main` endpoint. - Polls the `/status/` endpoint until completion. - Validates the output payload structure and ensures that data successfully flowed through `FinanceAgent`, `MarketResearchAgent`, and `VllmAgent`. -To run manually against an already-deployed Ventis instance: +To run manually against an already-deployed CanyonOS instance: ```bash python test_integration.py ``` ## 3. High-Concurrency Stress Test (`test_performance.py`) -Evaluates the robustness and scalability of the Ventis Redis routing and Docker architecture under load. Using `concurrent.futures`, this script models N concurrent users actively polling Ventis simultaneously. +Evaluates the robustness and scalability of the CanyonOS Redis routing and Docker architecture under load. Using `concurrent.futures`, this script models N concurrent users actively polling CanyonOS simultaneously. It produces an analytical report summarizing throughput, dropped requests, and latency percentiles. -To run manually against an already-deployed Ventis instance (e.g. 50 requests across 10 concurrent virtual users): +To run manually against an already-deployed CanyonOS instance (e.g. 50 requests across 10 concurrent virtual users): ```bash python test_performance.py --concurrent 10 --total 50 ``` diff --git a/tests/run_tests.sh b/tests/run_tests.sh index c5556ec..4eec785 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -2,7 +2,7 @@ set -e echo "===========================================" -echo " Ventis Integration & Performance Tests" +echo " CanyonOS Integration & Performance Tests" echo "===========================================" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" @@ -10,8 +10,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd)" echo ">> 0. Running small pytest suite..." python3 -m pytest "$SCRIPT_DIR" -TEST_DIR="/tmp/ventis_test_env_$$" -PROJECT_NAME="ventis_test" +TEST_DIR="/tmp/canyonos_test_env_$$" +PROJECT_NAME="canyonos_test" # Cleanup function ensures we kill the deployed Flask/GlobalController on exit function cleanup { @@ -28,13 +28,13 @@ mkdir -p "$TEST_DIR" cd "$TEST_DIR" echo ">> 1. Generating new project..." -ventis new-project $PROJECT_NAME +canyonos new-project $PROJECT_NAME cd $PROJECT_NAME grep -v 'gpu:' .car/config/global_controller.yaml > .car/config/global_controller.yaml.tmp mv .car/config/global_controller.yaml.tmp .car/config/global_controller.yaml -echo ">> 2. Building and deploying workflow (ventis deploy)..." -ventis deploy & +echo ">> 2. Building and deploying workflow (canyonos deploy)..." +canyonos deploy & DEPLOY_PID=$! # Wait for the workflow flask app to become reachable diff --git a/tests/test_canyonos_context.py b/tests/test_canyonos_context.py new file mode 100644 index 0000000..43fc0d2 --- /dev/null +++ b/tests/test_canyonos_context.py @@ -0,0 +1,49 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import canyonos_core.controller.canyonos_context as canyonos_context + + +class CanyonosContextTests(unittest.TestCase): + def setUp(self): + canyonos_context._local = canyonos_context.threading.local() + + def tearDown(self): + canyonos_context._local = canyonos_context.threading.local() + + def test_request_id_defaults_to_empty_string(self): + self.assertEqual(canyonos_context.get_request_id(), "") + + def test_request_id_round_trips(self): + canyonos_context.set_request_id("req-123") + self.assertEqual(canyonos_context.get_request_id(), "req-123") + + def test_current_future_id_defaults_to_empty_string(self): + self.assertEqual(canyonos_context.get_current_future_id(), "") + + def test_current_future_id_round_trips(self): + canyonos_context.set_current_future_id("future-abc") + self.assertEqual(canyonos_context.get_current_future_id(), "future-abc") + + def test_request_id_and_future_id_are_independent(self): + canyonos_context.set_request_id("req-123") + canyonos_context.set_current_future_id("future-abc") + self.assertEqual(canyonos_context.get_request_id(), "req-123") + self.assertEqual(canyonos_context.get_current_future_id(), "future-abc") + + def test_current_metrics_key_defaults_to_empty_string(self): + self.assertEqual(canyonos_context.get_current_metrics_key(), "") + + def test_current_metrics_key_round_trips(self): + canyonos_context.set_current_metrics_key("controller:localhost:50051:metrics") + self.assertEqual( + canyonos_context.get_current_metrics_key(), + "controller:localhost:50051:metrics", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_canyonos_test.py b/tests/test_canyonos_test.py index 13f0b68..21e0918 100644 --- a/tests/test_canyonos_test.py +++ b/tests/test_canyonos_test.py @@ -41,9 +41,9 @@ def project(monkeypatch, tmp_path): return tmp_path -def report(errors=0, warnings=0, findings=(), ventis=False): +def report(errors=0, warnings=0, findings=(), canyonos=False): return { - "capabilities": {"ventis": ventis}, + "capabilities": {"canyonos_core": canyonos}, "errors": errors, "warnings": warnings, "findings": list(findings), @@ -147,7 +147,7 @@ def test_validator_warnings_pass(monkeypatch, project): assert (summary["errors"], summary["warnings"]) == (0, 1) -def test_rules_needing_ventis_are_dropped_when_it_is_not_importable(monkeypatch, project): +def test_rules_needing_canyonos_are_dropped_when_it_is_not_importable(monkeypatch, project): monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") monkeypatch.setattr( verify, @@ -161,12 +161,12 @@ def test_rules_needing_ventis_are_dropped_when_it_is_not_importable(monkeypatch, assert summary["findings"] == [] -def test_rules_needing_ventis_are_kept_when_it_is_importable(monkeypatch, project): +def test_rules_needing_canyonos_are_kept_when_it_is_importable(monkeypatch, project): monkeypatch.setattr(verify, "_find_validator", lambda _root: "/validate.py") monkeypatch.setattr( verify, "_run_validator", - lambda *_: report(errors=1, findings=[finding("V030")], ventis=True), + lambda *_: report(errors=1, findings=[finding("V030")], canyonos=True), ) with pytest.raises(RuntimeError): @@ -242,14 +242,14 @@ def install(images, containers): ALL_UP = [ - "ventis-local-echoagent-0", - "ventis-local-echoagent-1", - "ventis-local-workflow-0", + "canyonos-local-echoagent-0", + "canyonos-local-echoagent-1", + "canyonos-local-workflow-0", ] def test_a_complete_deploy_passes(project, runtime): - runtime({"ventis-echoagent", "ventis-workflow"}, ALL_UP) + runtime({"canyonos-echoagent", "canyonos-workflow"}, ALL_UP) result = verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) @@ -260,21 +260,21 @@ def test_a_complete_deploy_passes(project, runtime): def test_a_short_replica_count_fails(project, runtime): - runtime({"ventis-echoagent", "ventis-workflow"}, ALL_UP[1:]) + runtime({"canyonos-echoagent", "canyonos-workflow"}, ALL_UP[1:]) with pytest.raises(RuntimeError, 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"]) + runtime({"canyonos-workflow"}, ["canyonos-local-workflow-0"]) - with pytest.raises(RuntimeError, match="ventis-echoagent was never built"): + with pytest.raises(RuntimeError, match="canyonos-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) + runtime({"canyonos-echoagent", "canyonos-workflow"}, ALL_UP) result = verify.verify_runtime(str(project / ".car" / "config" / "global_controller.yaml"), 8000) @@ -293,7 +293,7 @@ def deployable(monkeypatch, project): 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_init", lambda banner=True, extra_env=None: None) monkeypatch.setattr(test_cmd, "run_sync", lambda: True) monkeypatch.setattr(test_cmd, "load_state", lambda: {"container_id": "abc", "port": 8000}) monkeypatch.setattr(test_cmd, "_port_in_use", lambda _port: False) @@ -320,6 +320,27 @@ def test_a_passing_run_tears_everything_down(deployable): assert deployable["quit"] == 1 +def test_llm_is_stubbed_by_default(monkeypatch, deployable): + """`canyonos test` hands the stub flag to the GC container so no real LLM is hit.""" + seen = {} + monkeypatch.setattr( + test_cmd, "run_init", + lambda banner=True, extra_env=None: seen.update(extra_env=extra_env), + ) + assert test_cmd.run_test("hi") == 0 + assert seen["extra_env"] == {"CANYONOS_LLM_STUB_TEXT": "test"} + + +def test_real_llm_flag_disables_the_stub(monkeypatch, deployable): + seen = {} + monkeypatch.setattr( + test_cmd, "run_init", + lambda banner=True, extra_env=None: seen.update(extra_env=extra_env), + ) + assert test_cmd.run_test("hi", llm_stub=None) == 0 + assert seen["extra_env"] is None + + def test_the_provider_is_restored_after_the_run(project, deployable): config = project / ".car" / "config" / "global_controller.yaml" @@ -418,9 +439,9 @@ def test_running_containers_are_filtered_to_the_local_provider(monkeypatch): def fake_run(argv, **_): seen.append(argv) - return subprocess.CompletedProcess(argv, 0, "ventis-local-echoagent-0\n", "") + return subprocess.CompletedProcess(argv, 0, "canyonos-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] + assert verify._running_containers() == ["canyonos-local-echoagent-0"] + assert "name=canyonos-local-" in seen[0] diff --git a/tests/test_cli.py b/tests/test_cli.py index 44b9270..f07a39d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,20 +12,20 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis import cli +from canyonos_core import cli class CliDeployTests(unittest.TestCase): def _fake_controller_module(self, controller): - module = types.ModuleType("ventis.controller.global_controller") + module = types.ModuleType("canyonos_core.controller.global_controller") module.GlobalController = lambda _config_path: controller return module @patch("atexit.register") @patch("signal.signal") - @patch("ventis.cli._run_build") - @patch("ventis.cli._ensure_grpc_stubs_importable") - @patch("ventis.cli._preflight_ec2_deploy") + @patch("canyonos_core.cli._run_build") + @patch("canyonos_core.cli._ensure_grpc_stubs_importable") + @patch("canyonos_core.cli._preflight_ec2_deploy") def test_deploy_skips_ec2_preflight_for_local_config( self, preflight, @@ -40,10 +40,10 @@ def test_deploy_skips_ec2_preflight_for_local_config( config = {"agents": [{"name": "LocalAgent", "provider": "local"}]} with ( - patch("ventis.cli.os.path.isfile", return_value=True), - patch("ventis.cli._load_config", return_value=config), + patch("canyonos_core.cli.os.path.isfile", return_value=True), + patch("canyonos_core.cli._load_config", return_value=config), patch.dict( - sys.modules, {"ventis.controller.global_controller": controller_module} + sys.modules, {"canyonos_core.controller.global_controller": controller_module} ), ): cli.cmd_deploy(args) @@ -56,9 +56,9 @@ def test_deploy_skips_ec2_preflight_for_local_config( @patch("atexit.register") @patch("signal.signal") - @patch("ventis.cli._run_build") - @patch("ventis.cli._ensure_grpc_stubs_importable") - @patch("ventis.cli._preflight_ec2_deploy") + @patch("canyonos_core.cli._run_build") + @patch("canyonos_core.cli._ensure_grpc_stubs_importable") + @patch("canyonos_core.cli._preflight_ec2_deploy") def test_deploy_runs_ec2_preflight_for_ec2_config( self, preflight, @@ -73,10 +73,10 @@ def test_deploy_runs_ec2_preflight_for_ec2_config( config = {"agents": [{"name": "Ec2Agent", "provider": "EC2"}]} with ( - patch("ventis.cli.os.path.isfile", return_value=True), - patch("ventis.cli._load_config", return_value=config), + patch("canyonos_core.cli.os.path.isfile", return_value=True), + patch("canyonos_core.cli._load_config", return_value=config), patch.dict( - sys.modules, {"ventis.controller.global_controller": controller_module} + sys.modules, {"canyonos_core.controller.global_controller": controller_module} ), ): cli.cmd_deploy(args) @@ -87,9 +87,9 @@ def test_deploy_runs_ec2_preflight_for_ec2_config( @patch("atexit.register") @patch("signal.signal") - @patch("ventis.cli._run_build") - @patch("ventis.cli._ensure_grpc_stubs_importable") - @patch("ventis.cli._preflight_ec2_deploy") + @patch("canyonos_core.cli._run_build") + @patch("canyonos_core.cli._ensure_grpc_stubs_importable") + @patch("canyonos_core.cli._preflight_ec2_deploy") def test_deploy_uses_car_when_present( self, preflight, ensure_grpc, _run_build, _signal_patch, _atexit_patch ): @@ -98,11 +98,11 @@ def test_deploy_uses_car_when_present( args = SimpleNamespace(config=".car/config/global_controller.yaml") with tempfile.TemporaryDirectory() as tmpdir, patch( - "ventis.cli.os.path.isfile", return_value=True + "canyonos_core.cli.os.path.isfile", return_value=True ), patch( - "ventis.cli._load_config", return_value={"agents": []} + "canyonos_core.cli._load_config", return_value={"agents": []} ), patch.dict( - sys.modules, {"ventis.controller.global_controller": controller_module} + sys.modules, {"canyonos_core.controller.global_controller": controller_module} ): Path(tmpdir, ".car").mkdir() cwd = os.getcwd() @@ -115,8 +115,8 @@ def test_deploy_uses_car_when_present( ensure_grpc.assert_called_once_with(os.path.join(os.path.realpath(tmpdir), ".car")) preflight.assert_not_called() - @patch("ventis.cli._ensure_grpc_stubs_importable") - @patch("ventis.cli._require_docker_for_ec2") + @patch("canyonos_core.cli._ensure_grpc_stubs_importable") + @patch("canyonos_core.cli._require_docker_for_ec2") def test_preflight_does_not_require_ssh_fields(self, require_docker, ensure_grpc): config = { "ec2": { @@ -167,20 +167,20 @@ def fake_generate_stub(yaml_path, _output_path): with ( patch( - "ventis.cli._get_package_dir", + "canyonos_core.cli._get_package_dir", return_value=str(project_dir / "package"), ), - patch("ventis.cli.glob.glob", side_effect=fake_glob), + patch("canyonos_core.cli.glob.glob", side_effect=fake_glob), patch( - "ventis.stub_generator.generate_stub", side_effect=fake_generate_stub + "canyonos_core.stub_generator.generate_stub", side_effect=fake_generate_stub ), - patch("ventis.stub_generator.generate_docker") as generate_docker, + patch("canyonos_core.stub_generator.generate_docker") as generate_docker, patch( - "ventis.stub_generator.generate_workflow_docker" + "canyonos_core.stub_generator.generate_workflow_docker" ) as generate_workflow_docker, - patch("ventis.cli.subprocess.run", side_effect=fake_run), - patch("ventis.cli._docker_available", return_value=buildx_available), - patch("ventis.cli._docker_platform", return_value=platform), + patch("canyonos_core.cli.subprocess.run", side_effect=fake_run), + patch("canyonos_core.cli._docker_available", return_value=buildx_available), + patch("canyonos_core.cli._docker_platform", return_value=platform), ): cwd = os.getcwd() os.chdir(project_dir) @@ -280,14 +280,14 @@ def test_build_uses_buildx_bake_when_available(self): os.path.realpath(project_dir / "docker_container" / "ExampleAgent"), ) self.assertTrue(os.path.isabs(targets["exampleagent"]["context"])) - self.assertEqual(targets["exampleagent"]["tags"], ["ventis-exampleagent"]) + self.assertEqual(targets["exampleagent"]["tags"], ["canyonos-exampleagent"]) self.assertEqual(targets["exampleagent"]["platforms"], ["linux/amd64"]) self.assertEqual(targets["exampleagent"]["output"], ["type=docker"]) self.assertEqual( os.path.realpath(targets["workflow"]["context"]), os.path.realpath(project_dir / "docker_container" / "Workflow"), ) - self.assertEqual(targets["workflow"]["tags"], ["ventis-workflow"]) + self.assertEqual(targets["workflow"]["tags"], ["canyonos-workflow"]) def test_build_uses_car_when_present(self): with tempfile.TemporaryDirectory() as tmpdir: @@ -438,7 +438,7 @@ def test_build_ignores_non_list_requirements(self): ) ) - with self.assertLogs("ventis", level="WARNING") as log: + with self.assertLogs("canyonos_core", level="WARNING") as log: _, generate_docker, _ = self._run_build( project_dir, [str(example_yaml)], buildx_available=True ) diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 3c02008..a16578d 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -6,7 +6,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import ventis.controller.deploy as deploy_module +import canyonos_core.controller.deploy as deploy_module class _FakeRedis: @@ -94,16 +94,16 @@ def fake_run(self, *args, **kwargs): class DeployHandleWorkflowTests(unittest.TestCase): def setUp(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) def tearDown(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) def test_records_working_status_before_dispatch_when_configured(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" with patch.object(deploy_module, "upsert_session") as mock_upsert, \ _deployed_app() as app: @@ -130,8 +130,8 @@ def test_skips_session_upsert_when_not_configured(self): mock_upsert.assert_not_called() def test_session_upsert_failure_does_not_fail_the_request(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" with patch.object( deploy_module, "upsert_session", side_effect=RuntimeError("db down") @@ -143,8 +143,8 @@ def test_session_upsert_failure_does_not_fail_the_request(self): self.assertIn("request_id", resp.get_json()) def test_marks_session_success_when_workflow_completes(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" with patch.object(deploy_module, "upsert_session") as mock_upsert, \ _deployed_app() as app: @@ -158,8 +158,8 @@ def test_marks_session_success_when_workflow_completes(self): self.assertEqual(success_call_kwargs["output_payload"], {"x": 2}) def test_marks_session_failed_when_workflow_raises(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" with patch.object(deploy_module, "upsert_session") as mock_upsert, \ _deployed_app(workflow_fn=_failing_workflow) as app: @@ -177,7 +177,7 @@ def test_marks_session_failed_when_workflow_raises(self): def test_skips_session_upsert_when_project_id_is_missing(self): # project_id is NOT NULL in the session table, so a URL without a project # id can only produce failing writes -- don't attempt them at all. - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" with patch.object(deploy_module, "upsert_session") as mock_upsert, \ _deployed_app() as app: @@ -202,8 +202,8 @@ class DeployRequestKeyExpiryTests(unittest.TestCase): accumulate for the lifetime of the Redis instance and eventually OOM it.""" def setUp(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) def test_expires_status_and_result_on_success(self): with _deployed_app() as app: @@ -252,12 +252,12 @@ class DeployStatusFallbackTests(unittest.TestCase): session row instead of 404-ing.""" def setUp(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" def tearDown(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) def test_maps_completed_session_to_done_with_result(self): row = {"status": "completed", "output": {"x": 2}} @@ -334,8 +334,8 @@ def test_does_not_touch_postgres_while_redis_still_has_the_request(self): mock_get.assert_not_called() def test_skips_the_fallback_when_the_database_is_not_configured(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) with patch.object(deploy_module, "get_session") as mock_get, \ _deployed_app() as app: @@ -346,19 +346,19 @@ def test_skips_the_fallback_when_the_database_is_not_configured(self): class DeployLiveIdentityTests(unittest.TestCase): - """Bug E: VENTIS_PROJECT_ID/VENTIS_DATABASE_URL are Docker env vars frozen at container + """Bug E: CANYONOS_PROJECT_ID/CANYONOS_DATABASE_URL are Docker env vars frozen at container launch. A GlobalController reload (SIGHUP) publishes the current project/database identity to Redis (controller:identity); this container must read that fresh on every request instead of trusting the env vars it booted with, or a project switch leaves it creating session rows under the *old* project indefinitely.""" def setUp(self): - os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/old-db" - os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" + os.environ["CANYONOS_DATABASE_URL"] = "postgresql://example/old-db" + os.environ["CANYONOS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111" def tearDown(self): - os.environ.pop("VENTIS_DATABASE_URL", None) - os.environ.pop("VENTIS_PROJECT_ID", None) + os.environ.pop("CANYONOS_DATABASE_URL", None) + os.environ.pop("CANYONOS_PROJECT_ID", None) def test_a_value_already_in_redis_at_boot_overrides_the_env_var(self): with patch.object(deploy_module, "upsert_session") as mock_upsert, \ diff --git a/tests/test_deploy_progress.py b/tests/test_deploy_progress.py index d1c8cf6..db0bb5a 100644 --- a/tests/test_deploy_progress.py +++ b/tests/test_deploy_progress.py @@ -23,16 +23,16 @@ def drive(lines): 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", + "INFO:canyonos_core:Generating stub: a.yaml -> a_stub.py\n", + "INFO:canyonos_core:Compiling gRPC proto: a.proto\n", + "INFO:canyonos_core: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", + "INFO:canyonos_core:Build complete.\n", + "INFO:canyonos_core:Deploying from config: config.yaml\n", + "INFO:canyonos_core.controller.global_controller:Redis launched on 1 node(s).\n", + "INFO:canyonos_core.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:canyonos_core.controller.global_controller:Controller Intent (127.0.0.1:50051) is ready.\n", + "INFO:canyonos_core.controller.global_controller:Controller Metrics (127.0.0.1:50052) is ready.\n", ] ) assert not errored @@ -48,9 +48,9 @@ def test_phases_are_matched_in_the_order_the_container_emits_them(): """ _, 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", + "INFO:canyonos_core.controller.global_controller:Checking for stale containers from previous runs...\n", + "INFO:canyonos_core.controller.global_controller:Redis launched on 1 node(s).\n", + "INFO:canyonos_core:Deploying from config: config.yaml\n", ] ) assert done == ["Redis ready"] @@ -60,9 +60,9 @@ def test_phases_are_matched_in_the_order_the_container_emits_them(): 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", + "INFO:canyonos_core:Generating stub: a.yaml -> a_stub.py\n", + "INFO:canyonos_core:Generating stub: b.yaml -> b_stub.py\n", + "INFO:canyonos_core:Generating Docker context for 'b'\n", ] ) assert spinners == ["Generating stubs and Docker contexts..."] @@ -71,8 +71,8 @@ def test_repeated_build_lines_collapse_to_one_spinner_update(): 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", + "INFO:canyonos_core:No Docker images to build.\n", + "INFO:canyonos_core:Build complete.\n", ] ) assert done == ["No images to build", "Build complete"] @@ -81,9 +81,9 @@ def test_a_run_with_nothing_to_build_still_reports_the_phase(): 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", + "INFO:canyonos_core.controller.global_controller:Waiting for 3 replica(s) to become healthy (timeout=300s)...\n", + "INFO:canyonos_core.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n", + "INFO:canyonos_core.controller.global_controller:Controller B (127.0.0.1:2) is ready.\n", ] ) assert spinners[-1] == "Starting agents (2/3 ready)..." @@ -96,9 +96,9 @@ def test_replicas_of_one_agent_are_counted_separately(): """ 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", + "INFO:canyonos_core.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:canyonos_core.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", + "INFO:canyonos_core.controller.global_controller:Controller Echo (127.0.0.1:50052) is ready.\n", ] ) assert spinners[-1] == "Starting agents (2/2 ready)..." @@ -108,9 +108,9 @@ def test_replicas_of_one_agent_are_counted_separately(): 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", + "INFO:canyonos_core.controller.global_controller:Waiting for 2 replica(s) to become healthy (timeout=300s)...\n", + "INFO:canyonos_core.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", + "INFO:canyonos_core.controller.global_controller:Controller Echo (127.0.0.1:50051) is ready.\n", ] ) assert tracker.agents_ready_message() == ( @@ -125,8 +125,8 @@ def test_coming_up_short_of_the_announced_replicas_is_not_reported_as_success(): """ 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", + "INFO:canyonos_core.controller.global_controller:Waiting for 3 replica(s) to become healthy (timeout=300s)...\n", + "INFO:canyonos_core.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n", ] ) message, all_ready = tracker.agents_ready_message() @@ -135,13 +135,13 @@ def test_coming_up_short_of_the_announced_replicas_is_not_reported_as_success(): def test_a_run_that_never_announced_replicas_still_reports_ready(): - tracker, _, _, _ = drive(["INFO:ventis:Build complete.\n"]) + tracker, _, _, _ = drive(["INFO:canyonos_core: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"] + ["INFO:canyonos_core.controller.global_controller:Controller A (127.0.0.1:1) is ready.\n"] ) assert spinners == ["Starting agents..."] @@ -149,7 +149,7 @@ def test_replicas_ready_without_an_announced_total_still_reports_progress(): @pytest.mark.parametrize( "line", [ - "ERROR:ventis:Config file not found: missing.yaml\n", + "ERROR:canyonos_core: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", ], @@ -162,7 +162,7 @@ def test_fatal_lines_are_flagged(line): @pytest.mark.parametrize( "line", [ - "WARNING:ventis.controller.global_controller:otel.destinations not configured -- no OTel metrics collection will happen.\n", + "WARNING:canyonos_core.controller.global_controller:otel.destinations not configured -- no OTel metrics collection will happen.\n", " Warning: no entrypoint mapping for 'agent'\n", ], ) @@ -206,8 +206,8 @@ def test_the_clis_own_status_requests_are_not_shown_or_buffered(monkeypatch): 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", + "INFO:canyonos_core:Build complete.\n", + "INFO:canyonos_core.controller.global_controller:Global controller started, polling every 5s...\n", ] ) ) @@ -225,7 +225,7 @@ def test_a_build_that_dies_silently_does_not_hang(monkeypatch, capsys): 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"])) + lines = deploy_cmd._queued_lines(iter(["INFO:canyonos_core: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 diff --git a/tests/test_env_file_reserved_keys.py b/tests/test_env_file_reserved_keys.py new file mode 100644 index 0000000..19dcbd1 --- /dev/null +++ b/tests/test_env_file_reserved_keys.py @@ -0,0 +1,48 @@ +"""The LLM stub is a `canyonos test`-only control: a user's project `.env` must +never be able to inject CANYONOS_LLM_STUB_TEXT into the controller environment +(which would silently stub real LLM calls in a normal deploy).""" + +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from canyonos_core.controller.global_controller import GlobalController + + +class LoadDotenvReservedKeysTests(unittest.TestCase): + def _write_env(self, text): + f = tempfile.NamedTemporaryFile("w", suffix=".env", delete=False) + f.write(text) + f.close() + self.addCleanup(os.unlink, f.name) + return f.name + + def test_reserved_stub_key_is_not_loaded_from_user_env(self): + os.environ.pop("CANYONOS_LLM_STUB_TEXT", None) + os.environ.pop("MY_API_KEY", None) + self.addCleanup(os.environ.pop, "MY_API_KEY", None) + + path = self._write_env("CANYONOS_LLM_STUB_TEXT=sneaky\nMY_API_KEY=real-secret\n") + GlobalController._load_dotenv(path) + + # The reserved control key is ignored... + self.assertNotIn("CANYONOS_LLM_STUB_TEXT", os.environ) + # ...while ordinary user secrets still load as before. + self.assertEqual(os.environ.get("MY_API_KEY"), "real-secret") + + def test_a_stub_value_already_set_is_left_untouched(self): + # `canyonos test` sets it on the GC container; _load_dotenv must not clear it. + os.environ["CANYONOS_LLM_STUB_TEXT"] = "test" + self.addCleanup(os.environ.pop, "CANYONOS_LLM_STUB_TEXT", None) + + path = self._write_env("CANYONOS_LLM_STUB_TEXT=sneaky\n") + GlobalController._load_dotenv(path) + + self.assertEqual(os.environ["CANYONOS_LLM_STUB_TEXT"], "test") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_error_propagation.py b/tests/test_error_propagation.py index 59c6eba..78ad996 100644 --- a/tests/test_error_propagation.py +++ b/tests/test_error_propagation.py @@ -10,14 +10,14 @@ 0, os.path.abspath( os.path.join( - os.path.dirname(__file__), "..", "ventis", "templates", "grpc_stubs" + os.path.dirname(__file__), "..", "canyonos_core", "templates", "grpc_stubs" ) ), ) -from ventis.controller.local_controller import LocalController -from ventis.controller.local_controller_frontend import LocalControllerServicer -from ventis.controller.future import Future +from canyonos_core.controller.local_controller import LocalController +from canyonos_core.controller.local_controller_frontend import LocalControllerServicer +from canyonos_core.controller.future import Future import local_controler_pb2 @@ -42,11 +42,19 @@ def hincrby(self, name, field, amount=1): bucket[field] = int(bucket.get(field, 0)) + amount return bucket[field] + def smembers(self, key): + return set() + def _bind_failure_marker(controller): controller._mark_future_failed = lambda future_id, error, origin=None: ( LocalController._mark_future_failed(controller, future_id, error, origin) ) + controller._fan_out_to_consumers = ( + lambda future_id, result=None, failed=0, error_message="": LocalController._fan_out_to_consumers( + controller, future_id, result, failed, error_message + ) + ) return controller @@ -222,6 +230,9 @@ def capture_write_result(request): executor._send_result_callback = lambda *a, **k: ( LocalController._send_result_callback(executor, *a, **k) ) + executor._fan_out_to_consumers = lambda *a, **k: ( + LocalController._fan_out_to_consumers(executor, *a, **k) + ) LocalController._execute_locally( executor, "Greeter", "greet", {}, "future-1", origin="origin:50051" diff --git a/tests/test_future.py b/tests/test_future.py index 4914b29..d26e6e4 100644 --- a/tests/test_future.py +++ b/tests/test_future.py @@ -8,13 +8,13 @@ 0, os.path.abspath( os.path.join( - os.path.dirname(__file__), "..", "ventis", "templates", "grpc_stubs" + os.path.dirname(__file__), "..", "canyonos_core", "templates", "grpc_stubs" ) ), ) -import ventis.controller.future as future_module -import ventis.controller.ventis_context as ventis_context +import canyonos_core.controller.future as future_module +import canyonos_core.controller.canyonos_context as canyonos_context class _FakeRedis: @@ -45,12 +45,12 @@ def setUp(self): self._orig_stub = future_module.Future._stub future_module.Future.redis = self.fake_redis future_module.Future._stub = MagicMock() - ventis_context.set_current_future_id("") + canyonos_context.set_current_future_id("") def tearDown(self): future_module.Future.redis = self._orig_redis future_module.Future._stub = self._orig_stub - ventis_context.set_current_future_id("") + canyonos_context.set_current_future_id("") def test_parent_defaults_to_empty_when_no_future_executing(self): f = future_module.Future( @@ -60,7 +60,7 @@ def test_parent_defaults_to_empty_when_no_future_executing(self): self.assertEqual(self.fake_redis.hashes[f"future:{f.id}"]["parent"], "") def test_parent_is_the_currently_executing_future_id(self): - ventis_context.set_current_future_id("caller-future-id") + canyonos_context.set_current_future_id("caller-future-id") f = future_module.Future( parent="ignored/file.py", service="Svc", method="do_thing" diff --git a/tests/test_global_controller_cleanup.py b/tests/test_global_controller_cleanup.py index 5d2c264..581a966 100644 --- a/tests/test_global_controller_cleanup.py +++ b/tests/test_global_controller_cleanup.py @@ -6,7 +6,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "grpc_stubs"))) -from ventis.controller.global_controller import GlobalController +from canyonos_core.controller.global_controller import GlobalController import local_controler_pb2 diff --git a/tests/test_global_controller_identity.py b/tests/test_global_controller_identity.py index 76a6689..46eb330 100644 --- a/tests/test_global_controller_identity.py +++ b/tests/test_global_controller_identity.py @@ -1,4 +1,4 @@ -"""Bug E: a Workflow container's VENTIS_PROJECT_ID/VENTIS_DATABASE_URL env vars are frozen at +"""Bug E: a Workflow container's CANYONOS_PROJECT_ID/CANYONOS_DATABASE_URL env vars are frozen at launch. _write_identity() publishes the controller's current project/database identity to every node's Redis (mirroring the existing policy:rules/routing_table:* pattern) so deploy.py's _current_identity() can read it live instead of trusting a boot-time env var. reload_config() @@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.global_controller import GlobalController +from canyonos_core.controller.global_controller import GlobalController class _FakeRedis: diff --git a/tests/test_global_controller_project_id.py b/tests/test_global_controller_project_id.py index 1feebc8..cf5f43a 100644 --- a/tests/test_global_controller_project_id.py +++ b/tests/test_global_controller_project_id.py @@ -13,7 +13,7 @@ import yaml -from ventis.controller.global_controller import GlobalController +from canyonos_core.controller.global_controller import GlobalController UUID_HEX_RE = re.compile(r"^[0-9a-f]{32}$") diff --git a/tests/test_global_controller_redis_reuse.py b/tests/test_global_controller_redis_reuse.py index 1c1682d..2f3e74a 100644 --- a/tests/test_global_controller_redis_reuse.py +++ b/tests/test_global_controller_redis_reuse.py @@ -1,6 +1,6 @@ """Fix C: a restart must not unconditionally wipe and recreate each node's Redis container. -_launch_redis_containers() used to `docker run` a fresh ventis-redis- container on every +_launch_redis_containers() used to `docker run` a fresh canyonos-redis- container on every __init__, unconditionally -- wiping every `agent_instance:*` record InstanceManager needs to recognize already-running EC2 replicas as reusable. ensure_instances()'s dedup logic was already correct; it was just fed an empty Redis on every restart, so it reprovisioned everything from @@ -16,7 +16,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.global_controller import GlobalController +from canyonos_core.controller.global_controller import GlobalController def _bare_controller(controllers): @@ -40,8 +40,8 @@ def fake_run_cmd(cmd, host, user=None): controller._run_cmd = fake_run_cmd - with patch("ventis.controller.global_controller.RedisClient") as fake_redis_cls, patch( - "ventis.controller.global_controller._wait_for_redis" + with patch("canyonos_core.controller.global_controller.RedisClient") as fake_redis_cls, patch( + "canyonos_core.controller.global_controller._wait_for_redis" ): fake_redis_cls.return_value = MagicMock() controller._launch_redis_containers() @@ -88,14 +88,14 @@ def fake_run_cmd(cmd, host, user=None): controller._run_cmd = fake_run_cmd - with patch("ventis.controller.global_controller.RedisClient") as fake_redis_cls, patch( - "ventis.controller.global_controller._wait_for_redis" + with patch("canyonos_core.controller.global_controller.RedisClient") as fake_redis_cls, patch( + "canyonos_core.controller.global_controller._wait_for_redis" ): fake_redis_cls.return_value = MagicMock() controller._launch_redis_containers() self.assertEqual(len(inspect_calls), 1) - self.assertIn("ventis-redis-10-0-0-5", inspect_calls[0]) + self.assertIn("canyonos-redis-10-0-0-5", inspect_calls[0]) if __name__ == "__main__": diff --git a/tests/test_global_controller_reload.py b/tests/test_global_controller_reload.py index 26d0662..fd4b40c 100644 --- a/tests/test_global_controller_reload.py +++ b/tests/test_global_controller_reload.py @@ -16,8 +16,8 @@ import yaml -import ventis.controller.utils.telemetry_logging as sqlmod -from ventis.controller.global_controller import GlobalController +import canyonos_core.controller.utils.telemetry_logging as sqlmod +from canyonos_core.controller.global_controller import GlobalController class _FakeInstanceManager: diff --git a/tests/test_gpu_metrics.py b/tests/test_gpu_metrics.py index 9eac921..a4c6df4 100644 --- a/tests/test_gpu_metrics.py +++ b/tests/test_gpu_metrics.py @@ -6,13 +6,13 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.utils.gpu_metrics import read_gpu_percent +from canyonos_core.controller.utils.gpu_metrics import read_gpu_percent class ReadGpuPercentTests(unittest.TestCase): def test_falls_back_to_zero_when_nvidia_smi_missing(self): with patch( - "ventis.controller.utils.gpu_metrics.subprocess.run", + "canyonos_core.controller.utils.gpu_metrics.subprocess.run", side_effect=FileNotFoundError(), ): self.assertEqual(read_gpu_percent(), 0.0) @@ -20,7 +20,7 @@ def test_falls_back_to_zero_when_nvidia_smi_missing(self): def test_parses_nvidia_smi_output(self): fake_result = SimpleNamespace(returncode=0, stdout="42\n") with patch( - "ventis.controller.utils.gpu_metrics.subprocess.run", + "canyonos_core.controller.utils.gpu_metrics.subprocess.run", return_value=fake_result, ): self.assertEqual(read_gpu_percent(), 42.0) @@ -28,7 +28,7 @@ def test_parses_nvidia_smi_output(self): def test_falls_back_on_nonzero_returncode(self): fake_result = SimpleNamespace(returncode=1, stdout="") with patch( - "ventis.controller.utils.gpu_metrics.subprocess.run", + "canyonos_core.controller.utils.gpu_metrics.subprocess.run", return_value=fake_result, ): self.assertEqual(read_gpu_percent(), 0.0) diff --git a/tests/test_instance_manager_runtime.py b/tests/test_instance_manager_runtime.py index 2c481c3..41bfb50 100644 --- a/tests/test_instance_manager_runtime.py +++ b/tests/test_instance_manager_runtime.py @@ -6,8 +6,8 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.cloud_provider_logic.Local import _runtime as local_runtime -from ventis.controller.instance_manager import InstanceManager +from canyonos_core.controller.cloud_provider_logic.Local import _runtime as local_runtime +from canyonos_core.controller.instance_manager import InstanceManager class _FakeRedis: @@ -138,9 +138,9 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "host_port": "8000", "container_port": "50051", "endpoint": "localhost:8000", - "redis_host": "ventis-redis-localhost", + "redis_host": "canyonos-redis-localhost", "redis_port": "6379", - "runtime_id": "ventis-local-alpha-0", + "runtime_id": "canyonos-local-alpha-0", }, ) self.assertEqual(beta["host"], "localhost") @@ -155,24 +155,26 @@ def test_local_instances_keep_default_host_and_increment_host_ports(self): "-d", "-it", "--network", - "ventis-local", + "canyonos-local", "--name", - "ventis-local-alpha-0", + "canyonos-local-alpha-0", "-p", "8000:50051", "-e", - "VENTIS_AGENT_PORT=50051", + "CANYONOS_AGENT_PORT=50051", "-e", - "VENTIS_AGENT_HOST=ventis-local-alpha-0", + "CANYONOS_AGENT_HOST=canyonos-local-alpha-0", "-e", - "VENTIS_REDIS_HOST=ventis-redis-localhost", + "CANYONOS_REDIS_HOST=canyonos-redis-localhost", "-e", - "VENTIS_REDIS_PORT=6379", + "CANYONOS_REDIS_PORT=6379", "-e", - "VENTIS_POLL_INTERVAL=5", + "CANYONOS_POLL_INTERVAL=5", "-e", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", - "ventis-alpha", + "-e", + "CANYONOS_LLM_STUB_TEXT=", + "canyonos-alpha", ], "localhost", None, @@ -187,7 +189,31 @@ def test_bootstrap_instance_passes_poll_interval_env_var(self): manager.ensure_instances([{"name": "Alpha", "provider": "local"}]) cmd = controller._run_cmd.call_args.args[0] - self.assertIn("VENTIS_POLL_INTERVAL=7", cmd) + self.assertIn("CANYONOS_POLL_INTERVAL=7", cmd) + + def test_llm_stub_env_is_forwarded_to_the_agent_when_set(self): + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + with patch.dict(os.environ, {"CANYONOS_LLM_STUB_TEXT": "test"}): + manager.ensure_instances([{"name": "Alpha", "provider": "local"}]) + + cmd = controller._run_cmd.call_args_list[1].args[0] + self.assertIn("CANYONOS_LLM_STUB_TEXT=test", cmd) + + def test_llm_stub_is_explicitly_disabled_by_default(self): + """Without `canyonos test`, the stub is pinned empty (off) and immune to --env-file.""" + controller = _fake_controller() + manager = InstanceManager(controller, controller.redis) + + os.environ.pop("CANYONOS_LLM_STUB_TEXT", None) + manager.ensure_instances([{"name": "Alpha", "provider": "local"}]) + + cmd = controller._run_cmd.call_args_list[1].args[0] + # Always present, explicitly empty -> stub off, and a user's .env value + # for this key is overridden (docker: -e beats --env-file). + self.assertIn("CANYONOS_LLM_STUB_TEXT=", cmd) + self.assertNotIn("CANYONOS_LLM_STUB_TEXT=test", cmd) def test_local_workflow_and_resource_flags_stay_the_same(self): controller = _fake_controller() @@ -213,23 +239,25 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): "-d", "-it", "--network", - "ventis-local", + "canyonos-local", "--name", - "ventis-local-workflow-0", + "canyonos-local-workflow-0", "-p", "8000:50051", "-e", - "VENTIS_AGENT_PORT=50051", + "CANYONOS_AGENT_PORT=50051", "-e", - "VENTIS_AGENT_HOST=ventis-local-workflow-0", + "CANYONOS_AGENT_HOST=canyonos-local-workflow-0", "-e", - "VENTIS_REDIS_HOST=ventis-redis-localhost", + "CANYONOS_REDIS_HOST=canyonos-redis-localhost", "-e", - "VENTIS_REDIS_PORT=6379", + "CANYONOS_REDIS_PORT=6379", "-e", - "VENTIS_POLL_INTERVAL=5", + "CANYONOS_POLL_INTERVAL=5", "-e", "AWS_ENDPOINT_URL_BEDROCK_RUNTIME=http://127.0.0.1:8081/bedrock", + "-e", + "CANYONOS_LLM_STUB_TEXT=", "-p", "8080:8080", "--cpus", @@ -238,7 +266,7 @@ def test_local_workflow_and_resource_flags_stay_the_same(self): "1024m", "--gpus", "1", - "ventis-workflow", + "canyonos-workflow", ], "localhost", None, @@ -261,7 +289,7 @@ def test_agent_id_is_published_under_the_controller_endpoint_key(self): alpha = manager.ensure_instances([{"name": "Alpha", "provider": "local"}])[0] self.assertEqual( - controller.redis.get("controller:ventis-local-alpha-0:50051:agent_id"), + controller.redis.get("controller:canyonos-local-alpha-0:50051:agent_id"), alpha["agent_id"], ) @@ -275,7 +303,7 @@ def test_local_remove_instance_still_removes_the_same_container(self): self.assertEqual( controller._run_cmd.call_args.args, - (["docker", "rm", "-f", "ventis-local-alpha-0"], "localhost", None), + (["docker", "rm", "-f", "canyonos-local-alpha-0"], "localhost", None), ) self.assertEqual(controller.redis.hgetall("agent_instance:local:Alpha:0"), {}) self.assertEqual(controller.containers["Alpha"], []) @@ -286,7 +314,7 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self): provisioned = { "host": "10.0.0.30", - "runtime_id": "ventis-ec2-remote-0--i-test1", + "runtime_id": "canyonos-ec2-remote-0--i-test1", "redis_port": 6390, } instance = { @@ -299,7 +327,7 @@ def test_manager_keeps_ec2_runtime_boundary_behavior(self): "endpoint": "10.0.0.30:50051", "redis_host": "10.0.0.30", "redis_port": "6390", - "runtime_id": "ventis-ec2-remote-0--i-test1", + "runtime_id": "canyonos-ec2-remote-0--i-test1", } runtime = _fake_runtime( @@ -368,7 +396,7 @@ def test_manager_uses_same_runtime_contract_for_local_and_ec2(self): "endpoint": "localhost:8000", "redis_host": "host.docker.internal", "redis_port": "6379", - "runtime_id": "ventis-local-local-0", + "runtime_id": "canyonos-local-local-0", } ec2_instance = { "agent_name": "Remote", @@ -380,7 +408,7 @@ def test_manager_uses_same_runtime_contract_for_local_and_ec2(self): "endpoint": "10.0.0.30:50051", "redis_host": "10.0.0.30", "redis_port": "6379", - "runtime_id": "ventis-ec2-remote-0--i-test1", + "runtime_id": "canyonos-ec2-remote-0--i-test1", } local_runtime = _fake_runtime( diff --git a/tests/test_local_controller_cleanup.py b/tests/test_local_controller_cleanup.py index 0466bb7..aa0c7ff 100644 --- a/tests/test_local_controller_cleanup.py +++ b/tests/test_local_controller_cleanup.py @@ -8,7 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "grpc_stubs"))) -from ventis.controller.local_controller_frontend import LocalControllerServicer +from canyonos_core.controller.local_controller_frontend import LocalControllerServicer import local_controler_pb2 @@ -36,7 +36,7 @@ def test_batched_request_ids_dispatches_cleanup_for_each(self): resonse=json.dumps({"request_ids": ["req1", "req2", "req3"]}) ) - with patch("ventis.controller.local_controller_frontend.Thread", _SyncThread): + with patch("canyonos_core.controller.local_controller_frontend.Thread", _SyncThread): LocalControllerServicer.Cleanup(servicer, request, context=None) self.assertEqual(cleaned, ["req1", "req2", "req3"]) @@ -46,7 +46,7 @@ def test_missing_ids_does_not_dispatch(self): servicer = SimpleNamespace(_cleanup_request=lambda rid: cleaned.append(rid)) request = local_controler_pb2.JsonResponse(resonse=json.dumps({})) - with patch("ventis.controller.local_controller_frontend.Thread", _SyncThread): + with patch("canyonos_core.controller.local_controller_frontend.Thread", _SyncThread): LocalControllerServicer.Cleanup(servicer, request, context=None) self.assertEqual(cleaned, []) @@ -61,7 +61,7 @@ def test_old_single_request_id_payload_is_no_longer_supported(self): resonse=json.dumps({"request_id": "req-legacy"}) ) - with patch("ventis.controller.local_controller_frontend.Thread", _SyncThread): + with patch("canyonos_core.controller.local_controller_frontend.Thread", _SyncThread): LocalControllerServicer.Cleanup(servicer, request, context=None) self.assertEqual(cleaned, []) diff --git a/tests/test_local_controller_metrics.py b/tests/test_local_controller_metrics.py index 6dbe332..2498999 100644 --- a/tests/test_local_controller_metrics.py +++ b/tests/test_local_controller_metrics.py @@ -13,12 +13,12 @@ 0, os.path.abspath( os.path.join( - os.path.dirname(__file__), "..", "ventis", "templates", "grpc_stubs" + os.path.dirname(__file__), "..", "canyonos_core", "templates", "grpc_stubs" ) ), ) -from ventis.controller.local_controller import LocalController +from canyonos_core.controller.local_controller import LocalController def _bind_failure_marker(controller): @@ -30,6 +30,11 @@ def _bind_failure_marker(controller): controller, origin, future_id, result, failed, error_message ) ) + controller._fan_out_to_consumers = ( + lambda future_id, result=None, failed=0, error_message="": LocalController._fan_out_to_consumers( + controller, future_id, result, failed, error_message + ) + ) return controller @@ -71,6 +76,9 @@ def set(self, key, value): def get(self, key): return self.strings.get(key) + def smembers(self, key): + return set() + class LocalControllerMetricsTests(unittest.TestCase): def test_collect_metrics_returns_expected_keys(self): @@ -79,7 +87,7 @@ def test_collect_metrics_returns_expected_keys(self): _metrics_interval=5, ) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=0.0 ): metrics = LocalController._collect_metrics(controller) self.assertEqual(metrics["status"], "healthy") @@ -148,7 +156,7 @@ def test_execute_locally_writes_gpu_resource_to_future_hash(self): )) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=17.5 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=17.5 ): LocalController._execute_locally( controller, "Greeter", "greet", {"name": "world"}, "future-1" @@ -183,7 +191,7 @@ def boom(name): )) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=0.0 ): LocalController._execute_locally( controller, "Greeter", "greet", {"name": "world"}, "future-2" @@ -214,7 +222,7 @@ def test_execute_locally_marks_missing_agent_as_failed(self): )) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=0.0 ): LocalController._execute_locally( controller, "MissingAgent", "greet", {}, "future-3" @@ -245,7 +253,7 @@ def boom(): )) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=0.0 ): LocalController._execute_locally( controller, @@ -290,7 +298,7 @@ def spy_send_result_callback(origin, future_id, result=None, failed=0, error_mes ) with patch( - "ventis.controller.local_controller.read_gpu_percent", return_value=0.0 + "canyonos_core.controller.local_controller.read_gpu_percent", return_value=0.0 ): LocalController._execute_locally( controller, diff --git a/tests/test_otel_exporter_fanout.py b/tests/test_otel_exporter_fanout.py index 59d4345..38d2cd4 100644 --- a/tests/test_otel_exporter_fanout.py +++ b/tests/test_otel_exporter_fanout.py @@ -1,4 +1,4 @@ -"""Focused tests for the Ventis OTel exporter fan-out configuration.""" +"""Focused tests for the CanyonOS OTel exporter fan-out configuration.""" import json import os @@ -13,7 +13,7 @@ ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) # ``otel_exporter.py`` is also executed as a script from its own directory and # therefore imports ``convert`` and ``db`` as top-level modules. -sys.path.insert(0, os.path.join(ROOT, "ventis", "OTLP_Exporter")) +sys.path.insert(0, os.path.join(ROOT, "canyonos_core", "OTLP_Exporter")) import db # noqa: E402 import otel_exporter # noqa: E402 @@ -137,9 +137,9 @@ def test_configured_destinations_rejects_malformed_empty_and_duplicate_values(se def test_controller_expands_env_in_destinations(self): # NOTE: the pre-existing Basic-auth-header-injection expectation this test # once carried was already unimplemented/failing before the Redis-backed - # reload change (VENTIS_OTEL_DESTINATIONS -> otel:destinations); out of + # reload change (CANYONOS_OTEL_DESTINATIONS -> otel:destinations); out of # scope here, so this only covers ${ENV_VAR} expansion, which does work. - from ventis.controller.global_controller import GlobalController + from canyonos_core.controller.global_controller import GlobalController with patch.dict( os.environ, @@ -164,7 +164,7 @@ def test_controller_expands_env_in_destinations(self): ) def test_controller_destinations_is_none_when_otel_not_configured(self): - from ventis.controller.global_controller import GlobalController + from canyonos_core.controller.global_controller import GlobalController self.assertIsNone(GlobalController._otel_destinations({})) diff --git a/tests/test_otel_exporter_fields.py b/tests/test_otel_exporter_fields.py index f6c2074..7ca3a02 100644 --- a/tests/test_otel_exporter_fields.py +++ b/tests/test_otel_exporter_fields.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from ventis.OTLP_Exporter import convert, db +from canyonos_core.OTLP_Exporter import convert, db class OTelExporterFieldTests(unittest.TestCase): diff --git a/tests/test_redis_utils.py b/tests/test_redis_utils.py index 789b04d..4e1ff56 100644 --- a/tests/test_redis_utils.py +++ b/tests/test_redis_utils.py @@ -5,12 +5,12 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.utils.redis_utils import _wait_for_redis +from canyonos_core.controller.utils.redis_utils import _wait_for_redis class WaitForRedisTests(unittest.TestCase): - @patch("ventis.controller.utils.redis_utils.time.sleep") - @patch("ventis.controller.utils.redis_utils.time.time", return_value=0) + @patch("canyonos_core.controller.utils.redis_utils.time.sleep") + @patch("canyonos_core.controller.utils.redis_utils.time.time", return_value=0) def test_timeout_message_stays_the_same(self, mock_time, mock_sleep): redis_client = MagicMock() diff --git a/tests/test_runtime_ec2.py b/tests/test_runtime_ec2.py index c9853db..ebeda8a 100644 --- a/tests/test_runtime_ec2.py +++ b/tests/test_runtime_ec2.py @@ -7,7 +7,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.controller.cloud_provider_logic.EC2 import _runtime as ec2_runtime +from canyonos_core.controller.cloud_provider_logic.EC2 import _runtime as ec2_runtime class _FakeWaiter: @@ -111,7 +111,7 @@ def test_aws_clients_fails_when_required_fields_are_missing(self): def test_aws_clients_rejects_missing_ssh_private_key(self): self.controller.config["ec2"]["ssh_private_key_path"] = ( - "/tmp/missing-ventis-key" + "/tmp/missing-canyonos-key" ) with self.assertRaisesRegex(ValueError, "does not exist"): @@ -139,7 +139,7 @@ def test_provision_uses_ec2_client(self): self.assertNotIn("UserData", request) self.assertEqual( request["TagSpecifications"][0]["Tags"][0], - {"Key": "Name", "Value": "ventis-Tagged-2"}, + {"Key": "Name", "Value": "canyonos-Tagged-2"}, ) self.assertEqual(self.fake_client.waiter.calls, [["i-test1"]]) self.assertEqual(provisioned["host"], "10.0.0.30") diff --git a/tests/test_session_logging.py b/tests/test_session_logging.py index 55ccccc..5cec43d 100644 --- a/tests/test_session_logging.py +++ b/tests/test_session_logging.py @@ -9,7 +9,7 @@ from sqlalchemy import text -import ventis.controller.utils.session_logging as session_logging +import canyonos_core.controller.utils.session_logging as session_logging def _stored_epoch(stored): @@ -21,7 +21,7 @@ class SessionStoreTests(unittest.TestCase): def setUp(self): self.db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.db.close() - os.environ["VENTIS_DATABASE_URL"] = f"sqlite:///{self.db.name}" + os.environ["CANYONOS_DATABASE_URL"] = f"sqlite:///{self.db.name}" session_logging._engine = None # session_logging no longer bootstraps the schema itself (that's expected # to already exist on the real database) -- tests create it directly. diff --git a/tests/test_stub_generator.py b/tests/test_stub_generator.py index 916bf80..d3af7f2 100644 --- a/tests/test_stub_generator.py +++ b/tests/test_stub_generator.py @@ -8,7 +8,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -from ventis.stub_generator import ( +from canyonos_core.stub_generator import ( BASE_AGENT_REQUIREMENTS, BASE_WORKFLOW_REQUIREMENTS, _stub_destination, diff --git a/tests/test_telemetry_logging.py b/tests/test_telemetry_logging.py index 89e753f..b321965 100644 --- a/tests/test_telemetry_logging.py +++ b/tests/test_telemetry_logging.py @@ -9,7 +9,7 @@ from sqlalchemy import create_engine, text -import ventis.controller.utils.telemetry_logging as sqlmod +import canyonos_core.controller.utils.telemetry_logging as sqlmod def _parse_shifted(stored): @@ -99,11 +99,11 @@ class RuntimeSqlalchemyTests(unittest.TestCase): def setUp(self): self.db = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.db.close() - os.environ["VENTIS_DATABASE_URL"] = f"sqlite:///{self.db.name}" + os.environ["CANYONOS_DATABASE_URL"] = f"sqlite:///{self.db.name}" # sqlmod no longer creates the schema itself (a separate service owns # that in real deployments) -- create it here so tests still get a # ready-to-use database, matching what that service is assumed to do. - engine = create_engine(os.environ["VENTIS_DATABASE_URL"]) + engine = create_engine(os.environ["CANYONOS_DATABASE_URL"]) with engine.begin() as conn: conn.execute(_RUNTIME_CREATE_TABLE) conn.execute(_AGENT_CREATE_TABLE) @@ -410,20 +410,20 @@ def test_demo_cost_multipliers_scale_costs_independently_and_warn(self): ) rows = sqlmod.pull_runtime_information(redis) - os.environ["VENTIS_DEMO_TOKEN_COST_MULTIPLIER"] = "2" - os.environ["VENTIS_DEMO_SERVER_COST_MULTIPLIER"] = "3" + os.environ["CANYONOS_DEMO_TOKEN_COST_MULTIPLIER"] = "2" + os.environ["CANYONOS_DEMO_SERVER_COST_MULTIPLIER"] = "3" try: - with self.assertLogs("ventis.controller.utils.telemetry_logging", level="WARNING") as cm: + with self.assertLogs("canyonos_core.controller.utils.telemetry_logging", level="WARNING") as cm: sqlmod.send_runtime_information(rows, redis) self.assertTrue( - any("VENTIS_DEMO_TOKEN_COST_MULTIPLIER" in msg for msg in cm.output) + any("CANYONOS_DEMO_TOKEN_COST_MULTIPLIER" in msg for msg in cm.output) ) self.assertTrue( - any("VENTIS_DEMO_SERVER_COST_MULTIPLIER" in msg for msg in cm.output) + any("CANYONOS_DEMO_SERVER_COST_MULTIPLIER" in msg for msg in cm.output) ) finally: - del os.environ["VENTIS_DEMO_TOKEN_COST_MULTIPLIER"] - del os.environ["VENTIS_DEMO_SERVER_COST_MULTIPLIER"] + del os.environ["CANYONOS_DEMO_TOKEN_COST_MULTIPLIER"] + del os.environ["CANYONOS_DEMO_SERVER_COST_MULTIPLIER"] with sqlmod._get_engine("").connect() as conn: row = conn.execute( diff --git a/tests/test_ventis_context.py b/tests/test_ventis_context.py deleted file mode 100644 index f860d1f..0000000 --- a/tests/test_ventis_context.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -import sys -import unittest - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - -import ventis.controller.ventis_context as ventis_context - - -class VentisContextTests(unittest.TestCase): - def setUp(self): - ventis_context._local = ventis_context.threading.local() - - def tearDown(self): - ventis_context._local = ventis_context.threading.local() - - def test_request_id_defaults_to_empty_string(self): - self.assertEqual(ventis_context.get_request_id(), "") - - def test_request_id_round_trips(self): - ventis_context.set_request_id("req-123") - self.assertEqual(ventis_context.get_request_id(), "req-123") - - def test_current_future_id_defaults_to_empty_string(self): - self.assertEqual(ventis_context.get_current_future_id(), "") - - def test_current_future_id_round_trips(self): - ventis_context.set_current_future_id("future-abc") - self.assertEqual(ventis_context.get_current_future_id(), "future-abc") - - def test_request_id_and_future_id_are_independent(self): - ventis_context.set_request_id("req-123") - ventis_context.set_current_future_id("future-abc") - self.assertEqual(ventis_context.get_request_id(), "req-123") - self.assertEqual(ventis_context.get_current_future_id(), "future-abc") - - def test_current_metrics_key_defaults_to_empty_string(self): - self.assertEqual(ventis_context.get_current_metrics_key(), "") - - def test_current_metrics_key_round_trips(self): - ventis_context.set_current_metrics_key("controller:localhost:50051:metrics") - self.assertEqual( - ventis_context.get_current_metrics_key(), - "controller:localhost:50051:metrics", - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/uv.lock b/uv.lock index dd3ff5f..5244377 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,57 @@ requires-dist = [ { name = "ruamel-yaml" }, ] +[[package]] +name = "canyonos-core" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "boto3" }, + { name = "flask" }, + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "psutil" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] + +[package.dev-dependencies] +dev = [ + { name = "canyonos" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3" }, + { name = "flask" }, + { name = "grpcio" }, + { name = "grpcio-tools" }, + { name = "opentelemetry-api", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.44.0" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.44.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.44.0" }, + { name = "psutil" }, + { name = "psycopg", extras = ["binary"] }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "canyonos", editable = "cli" }, + { name = "pytest", specifier = ">=9.1.1" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -1221,57 +1272,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] -[[package]] -name = "ventis" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "boto3" }, - { name = "flask" }, - { name = "grpcio" }, - { name = "grpcio-tools" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, - { name = "opentelemetry-sdk" }, - { name = "psutil" }, - { name = "psycopg", extra = ["binary"] }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "requests" }, - { name = "sqlalchemy" }, -] - -[package.dev-dependencies] -dev = [ - { name = "canyonos" }, - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "boto3" }, - { name = "flask" }, - { name = "grpcio" }, - { name = "grpcio-tools" }, - { name = "opentelemetry-api", specifier = ">=1.44.0" }, - { name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.44.0" }, - { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.44.0" }, - { name = "opentelemetry-sdk", specifier = ">=1.44.0" }, - { name = "psutil" }, - { name = "psycopg", extras = ["binary"] }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "requests" }, - { name = "sqlalchemy" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "canyonos", editable = "cli" }, - { name = "pytest", specifier = ">=9.1.1" }, -] - [[package]] name = "werkzeug" version = "3.1.8" diff --git a/ventis/__init__.py b/ventis/__init__.py deleted file mode 100644 index d33ecaa..0000000 --- a/ventis/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Ventis - Distributed Agent Framework -__version__ = "0.1.0" diff --git a/ventis/controller/__init__.py b/ventis/controller/__init__.py deleted file mode 100644 index a124ecf..0000000 --- a/ventis/controller/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Ventis Controller Sub-Package diff --git a/ventis/controller/utils/__init__.py b/ventis/controller/utils/__init__.py deleted file mode 100644 index 3db8269..0000000 --- a/ventis/controller/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Ventis Controller Utility helpers diff --git a/ventis/llm_proxy/proxy.py b/ventis/llm_proxy/proxy.py deleted file mode 100644 index ec0956d..0000000 --- a/ventis/llm_proxy/proxy.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Auto-inject Ventis headers into ALL boto3 Bedrock calls. - -Import this module once and all subsequent boto3.client("bedrock-runtime") calls -will automatically include the X-Ventis-Future-ID header. - -Usage: - import ventis.llm_proxy_auto # Just import once - import boto3 - - # Now this automatically includes the header! - client = boto3.client("bedrock-runtime") - response = client.converse(...) -""" - -import boto3 -import logging - -try: - import ventis.controller.ventis_context as ventis_context -except ImportError: - # In-container the framework files are copied flat to /app. - try: - import ventis_context - except ImportError: - ventis_context = None - -log = logging.getLogger(__name__) - - -def _inject_ventis_headers(params=None, **kwargs): - """Inject X-Ventis-Future-ID into the outgoing Bedrock HTTP request. - - Registered on boto3's ``before-call.bedrock-runtime`` event, whose handlers - receive the prepared-request ``params`` dict (with a mutable ``headers``). - The ``request`` object only exists on the later ``before-send`` event, so - reading it here would always be None and silently drop the header. - """ - if not ventis_context or params is None: - return - - # Get current future_id from thread-local context - try: - future_id = ventis_context.get_current_future_id() - if future_id: - params.setdefault("headers", {})["X-Ventis-Future-ID"] = future_id - log.debug("Injected X-Ventis-Future-ID: %s", future_id) - except Exception as e: - log.debug("Could not inject future_id: %s", e) - - -# Register the hook globally on the default session -_session = boto3.Session() -_session.events.register_first('before-call.bedrock-runtime', _inject_ventis_headers) - -# Also patch the default session used by boto3.client() -boto3.DEFAULT_SESSION = _session - -log.info("Ventis boto3 hook registered - all Bedrock calls will include future_id header") From 6eec20619c72f7c2ab940745a24134e2f639fbfa Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 12:56:14 -0700 Subject: [PATCH 35/44] Add binary distribution pipeline for canyonos CLI (curl + brew) - cli-release.yml: builds a Nuitka onefile binary for macOS arm64/x86_64 and Linux x86_64, attaches to a cli-v* GitHub release - cli-release-tag.yml: auto-tags cli-v when cli/pyproject.toml's version changes on main, then calls cli-release.yml directly (a tag pushed by GITHUB_TOKEN doesn't trigger other workflows on its own) - cli/install.sh: curl-installable script that fetches the right binary for the caller's OS/arch from the latest cli-v* release - Formula/canyonos.rb: Homebrew formula for the same binaries, tapped directly from this repo (two-arg brew tap, no separate homebrew-* repo needed) Co-Authored-By: Claude Sonnet 5 --- .github/workflows/cli-release-tag.yml | 51 +++++++++++++++++ .github/workflows/cli-release.yml | 81 +++++++++++++++++++++++++++ Formula/canyonos.rb | 28 +++++++++ cli/ARCHITECTURE.md | 10 ++++ cli/README.md | 26 +++++++++ cli/install.sh | 57 +++++++++++++++++++ 6 files changed, 253 insertions(+) create mode 100644 .github/workflows/cli-release-tag.yml create mode 100644 .github/workflows/cli-release.yml create mode 100644 Formula/canyonos.rb create mode 100755 cli/install.sh diff --git a/.github/workflows/cli-release-tag.yml b/.github/workflows/cli-release-tag.yml new file mode 100644 index 0000000..18c02c2 --- /dev/null +++ b/.github/workflows/cli-release-tag.yml @@ -0,0 +1,51 @@ +name: Tag CLI Release + +on: + push: + branches: [main] + paths: + - 'cli/pyproject.toml' + +permissions: + contents: write + +jobs: + tag: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.version.outputs.tag }} + created: ${{ steps.tag.outputs.created }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Read version from cli/pyproject.toml + id: version + run: | + version=$(grep -m1 '^version = ' cli/pyproject.toml | sed -E 's/version = "(.*)"/\1/') + echo "tag=cli-v$version" >> "$GITHUB_OUTPUT" + + - name: Create and push tag if it doesn't exist yet + id: tag + run: | + if git ls-remote --exit-code --tags origin "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then + echo "Tag ${{ steps.version.outputs.tag }} already exists, skipping." + echo "created=false" >> "$GITHUB_OUTPUT" + else + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + echo "created=true" >> "$GITHUB_OUTPUT" + fi + + release: + needs: tag + if: needs.tag.outputs.created == 'true' + permissions: + contents: write + uses: ./.github/workflows/cli-release.yml + with: + tag: ${{ needs.tag.outputs.tag }} + +# Calls cli-release.yml directly rather than relying on its tag-push trigger, since a tag pushed with the default GITHUB_TOKEN doesn't re-trigger other workflows. diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000..6244f2a --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,81 @@ +name: Release CLI + +on: + push: + tags: + - 'cli-v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. cli-v0.1.5)' + required: false + type: string + workflow_call: + inputs: + tag: + required: true + type: string + +permissions: + contents: write + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + asset: canyonos-macos-arm64 + - os: macos-13 + asset: canyonos-macos-x86_64 + - os: ubuntu-latest + asset: canyonos-linux-x86_64 + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Install project and Nuitka + working-directory: cli + run: | + uv venv + uv pip install -e . + uv pip install nuitka + + - name: Build standalone binary + working-directory: cli + run: | + uv run python -m nuitka \ + --mode=onefile \ + --output-filename=${{ matrix.asset }} \ + --output-dir=dist \ + --include-data-file=canyonos/dashboard.compose.yml=canyonos/dashboard.compose.yml \ + --include-distribution-metadata=canyonos \ + --assume-yes-for-downloads \ + cli.py + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: cli/dist/${{ matrix.asset }} + + - name: Attach to GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ inputs.tag || github.ref_name }} + files: cli/dist/${{ matrix.asset }} + +# Triggered by: pushing a "cli-v*" tag directly, running manually, or cli-release-tag.yml calling +# this workflow after it auto-tags a cli/pyproject.toml version bump on main. +# cli/install.sh downloads the matching asset for the latest "cli-v*" release. diff --git a/Formula/canyonos.rb b/Formula/canyonos.rb new file mode 100644 index 0000000..0cf80dc --- /dev/null +++ b/Formula/canyonos.rb @@ -0,0 +1,28 @@ +class Canyonos < Formula + desc "CLI for CanyonOS" + homepage "https://github.com/CanyonCodeCoreAI/canyoncodecore" + version "0.1.5" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-macos-arm64" + sha256 "REPLACE_WITH_MACOS_ARM64_SHA256" + else + url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-macos-x86_64" + sha256 "REPLACE_WITH_MACOS_X86_64_SHA256" + end + end + + on_linux do + url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-x86_64" + sha256 "REPLACE_WITH_LINUX_X86_64_SHA256" + end + + def install + bin.install Dir["canyonos-*"].first => "canyonos" + end + + test do + system "#{bin}/canyonos", "version" + end +end diff --git a/cli/ARCHITECTURE.md b/cli/ARCHITECTURE.md index e719ba2..7737a6d 100644 --- a/cli/ARCHITECTURE.md +++ b/cli/ARCHITECTURE.md @@ -203,3 +203,13 @@ whole thing. ### Other Notes: - The ui import is for styling, logging basic commands in the canyonos theme, nothing else. +## Release automation + +`cli-release-tag.yml` watches `cli/pyproject.toml` on `main`; when its `version` changes it pushes +a `cli-v` tag and then calls `cli-release.yml` directly as a job, rather than letting the +tag push trigger it. That indirection exists because GitHub Actions doesn't let a tag pushed with +the default `GITHUB_TOKEN` trigger other workflows (a loop-prevention measure), so a plain +`push: tags:` listener on `cli-release.yml` would never fire from an automated tag push — only from +a human pushing the tag themselves. Calling it as a reusable workflow (`workflow_call`) sidesteps +that restriction entirely. + diff --git a/cli/README.md b/cli/README.md index 4acfdb7..80b4bbf 100644 --- a/cli/README.md +++ b/cli/README.md @@ -8,6 +8,32 @@ Need a coding agent(Claude Code, Codex, Cursor) Need uv or pip Need docker and docker compose +## Install + +Via pip/uv: +``` +pip install canyonos +``` + +Via curl (downloads a standalone binary, macOS/Linux, no Python required): +``` +curl -fsSL https://raw.githubusercontent.com/CanyonCodeCoreAI/canyoncodecore/main/cli/install.sh | sh +``` + +Via Homebrew (same standalone binary): +``` +brew tap CanyonCodeCoreAI/canyonos https://github.com/CanyonCodeCoreAI/canyoncodecore +brew install canyonos +``` +The formula lives at `Formula/canyonos.rb` in this repo, so no separate `homebrew-*` tap repo is +needed. + +The binaries are built and attached to GitHub releases by `.github/workflows/cli-release.yml`, +triggered automatically by `.github/workflows/cli-release-tag.yml` when `cli/pyproject.toml`'s +version changes on `main` (or by pushing a `cli-v*` tag by hand). After each release, update +`Formula/canyonos.rb`'s `version` and the three `sha256` values (`shasum -a 256 `) +to match. + ## Architecture For a full walkthrough of the `build`, `deploy`, and `config` flows — plus how diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 0000000..5def895 --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Installs the canyonos CLI from a prebuilt binary attached to the latest +# "cli-v*" GitHub release. Usage: curl -fsSL /install.sh | sh + +set -eu + +REPO="CanyonCodeCoreAI/canyoncodecore" +INSTALL_DIR="${CANYONOS_INSTALL_DIR:-$HOME/.local/bin}" + +os="$(uname -s)" +arch="$(uname -m)" + +case "$os" in + Darwin) + case "$arch" in + arm64) asset="canyonos-macos-arm64" ;; + x86_64) asset="canyonos-macos-x86_64" ;; + *) echo "error: unsupported macOS arch: $arch" >&2; exit 1 ;; + esac + ;; + Linux) + case "$arch" in + x86_64) asset="canyonos-linux-x86_64" ;; + *) echo "error: unsupported Linux arch: $arch" >&2; exit 1 ;; + esac + ;; + *) + echo "error: unsupported OS: $os (try 'pip install canyonos' instead)" >&2 + exit 1 + ;; +esac + +tag="${CANYONOS_VERSION:-}" +if [ -z "$tag" ]; then + tag="$(curl -fsSL "https://api.github.com/repos/$REPO/releases" \ + | grep '"tag_name"' \ + | grep -o 'cli-v[0-9][^"]*' \ + | head -n1)" +fi + +if [ -z "$tag" ]; then + echo "error: could not find a cli-v* release for $REPO" >&2 + exit 1 +fi + +url="https://github.com/$REPO/releases/download/$tag/$asset" + +echo "Installing canyonos $tag ($asset) to $INSTALL_DIR..." +mkdir -p "$INSTALL_DIR" +curl -fsSL "$url" -o "$INSTALL_DIR/canyonos" +chmod +x "$INSTALL_DIR/canyonos" + +echo "Installed: $INSTALL_DIR/canyonos" +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Add $INSTALL_DIR to your PATH to use 'canyonos' directly." ;; +esac From 521ad47c4b1eabec21dfe45edd7a71532f290699 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 14:32:48 -0700 Subject: [PATCH 36/44] Fill in real sha256 for cli-v0.1.5 macos-arm64/linux-x86_64 assets macos-x86_64 build is still queued in CI (GitHub Intel-Mac runner capacity), so that placeholder is left as-is for now. Co-Authored-By: Claude Sonnet 5 --- Formula/canyonos.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Formula/canyonos.rb b/Formula/canyonos.rb index 0cf80dc..63038c9 100644 --- a/Formula/canyonos.rb +++ b/Formula/canyonos.rb @@ -6,7 +6,7 @@ class Canyonos < Formula on_macos do if Hardware::CPU.arm? url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-macos-arm64" - sha256 "REPLACE_WITH_MACOS_ARM64_SHA256" + sha256 "068136e3e69ea502b8651cb2ab889ee53a7efc5d35723dce85f91b04817bef76" else url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-macos-x86_64" sha256 "REPLACE_WITH_MACOS_X86_64_SHA256" @@ -15,7 +15,7 @@ class Canyonos < Formula on_linux do url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-x86_64" - sha256 "REPLACE_WITH_LINUX_X86_64_SHA256" + sha256 "1b5add3432079d06b2ec055f4a0da8a65961a33a0fd2f57ca79787ff01ca243f" end def install From 6258046bdcc84fa06d37e94f37a1d9a42de4acc8 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 15:20:48 -0700 Subject: [PATCH 37/44] Fix cli-release.yml: macos-13 runner is retired, use macos-15-intel The macos-x86_64 leg of the release matrix was stuck queued indefinitely -- GitHub fully retired the macos-13 hosted runner image in December 2025, so there was no runner left to pick up the job. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/cli-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 6244f2a..c684971 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -27,7 +27,7 @@ jobs: include: - os: macos-latest asset: canyonos-macos-arm64 - - os: macos-13 + - os: macos-15-intel asset: canyonos-macos-x86_64 - os: ubuntu-latest asset: canyonos-linux-x86_64 From 00ba2ce578a97f0eb446c8230a2531a265308f28 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 15:32:47 -0700 Subject: [PATCH 38/44] Fill in real sha256 for cli-v0.1.5 macos-x86_64 asset All three Formula/canyonos.rb sha256 values are now real, computed from the actual cli-v0.1.5 GitHub release assets. No placeholders left. Co-Authored-By: Claude Sonnet 5 --- Formula/canyonos.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Formula/canyonos.rb b/Formula/canyonos.rb index 63038c9..fa9be09 100644 --- a/Formula/canyonos.rb +++ b/Formula/canyonos.rb @@ -9,7 +9,7 @@ class Canyonos < Formula sha256 "068136e3e69ea502b8651cb2ab889ee53a7efc5d35723dce85f91b04817bef76" else url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-macos-x86_64" - sha256 "REPLACE_WITH_MACOS_X86_64_SHA256" + sha256 "c3f5e9adefc27f1cbfc2682138fde779b1865b3b66abba8b0d9dbd17d021526f" end end From 1c6338f996892b29e13a8dfec0f5c177c0550355 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 17:02:26 -0700 Subject: [PATCH 39/44] Add linux-arm64 to the CLI release matrix install.sh only handled Linux x86_64, so it failed with "unsupported Linux arch: aarch64" on arm64 Linux (e.g. Ubuntu on Apple Silicon, Graviton). Adds an ubuntu-24.04-arm build leg producing canyonos-linux-arm64, teaches install.sh to map aarch64/arm64 to it, and adds the matching branch to the Homebrew formula. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/cli-release.yml | 2 ++ Formula/canyonos.rb | 9 +++++++-- cli/install.sh | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index c684971..5a5ff60 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -31,6 +31,8 @@ jobs: asset: canyonos-macos-x86_64 - os: ubuntu-latest asset: canyonos-linux-x86_64 + - os: ubuntu-24.04-arm + asset: canyonos-linux-arm64 runs-on: ${{ matrix.os }} diff --git a/Formula/canyonos.rb b/Formula/canyonos.rb index fa9be09..db63a3d 100644 --- a/Formula/canyonos.rb +++ b/Formula/canyonos.rb @@ -14,8 +14,13 @@ class Canyonos < Formula end on_linux do - url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-x86_64" - sha256 "1b5add3432079d06b2ec055f4a0da8a65961a33a0fd2f57ca79787ff01ca243f" + if Hardware::CPU.arm? + url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-arm64" + sha256 "REPLACE_WITH_LINUX_ARM64_SHA256" + else + url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-x86_64" + sha256 "1b5add3432079d06b2ec055f4a0da8a65961a33a0fd2f57ca79787ff01ca243f" + end end def install diff --git a/cli/install.sh b/cli/install.sh index 5def895..53172b6 100755 --- a/cli/install.sh +++ b/cli/install.sh @@ -21,6 +21,7 @@ case "$os" in Linux) case "$arch" in x86_64) asset="canyonos-linux-x86_64" ;; + aarch64|arm64) asset="canyonos-linux-arm64" ;; *) echo "error: unsupported Linux arch: $arch" >&2; exit 1 ;; esac ;; From ef8498de676666afce832460b4991f95120cffb6 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 17:17:12 -0700 Subject: [PATCH 40/44] Fill in real sha256 for cli-v0.1.5 linux-arm64 asset All four Formula/canyonos.rb sha256 values are now real. Co-Authored-By: Claude Sonnet 5 --- Formula/canyonos.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Formula/canyonos.rb b/Formula/canyonos.rb index db63a3d..140754e 100644 --- a/Formula/canyonos.rb +++ b/Formula/canyonos.rb @@ -16,7 +16,7 @@ class Canyonos < Formula on_linux do if Hardware::CPU.arm? url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-arm64" - sha256 "REPLACE_WITH_LINUX_ARM64_SHA256" + sha256 "378d4a0a022e27396c531980c7a0808cb5a1d624ff0884f4ae5de0586533be4a" else url "https://github.com/CanyonCodeCoreAI/canyoncodecore/releases/download/cli-v#{version}/canyonos-linux-x86_64" sha256 "1b5add3432079d06b2ec055f4a0da8a65961a33a0fd2f57ca79787ff01ca243f" From 560623cdcb10f947fc212891388c574358afd4ee Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Wed, 9 Sep 2026 18:04:15 -0700 Subject: [PATCH 41/44] Auto-bump version and publish to PyPI on any cli/ change to main cli-release-tag.yml now triggers on any push to main touching cli/** (not just a manual version-line edit): it auto-bumps the patch version in cli/pyproject.toml, commits and tags it, then fans out to cli-release.yml (binaries) and a new publish-pypi job (uv build + uv publish, using a PYPI_API_TOKEN secret). Requires a repo secret named PYPI_API_TOKEN to actually publish. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/cli-release-tag.yml | 70 ++++++++++++++++++--------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/.github/workflows/cli-release-tag.yml b/.github/workflows/cli-release-tag.yml index 18c02c2..744a99c 100644 --- a/.github/workflows/cli-release-tag.yml +++ b/.github/workflows/cli-release-tag.yml @@ -4,48 +4,70 @@ on: push: branches: [main] paths: - - 'cli/pyproject.toml' + - 'cli/**' permissions: contents: write jobs: - tag: + bump: runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip release]')" outputs: - tag: ${{ steps.version.outputs.tag }} - created: ${{ steps.tag.outputs.created }} + tag: ${{ steps.bump.outputs.tag }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Read version from cli/pyproject.toml - id: version + - name: Bump patch version in cli/pyproject.toml + id: bump run: | - version=$(grep -m1 '^version = ' cli/pyproject.toml | sed -E 's/version = "(.*)"/\1/') - echo "tag=cli-v$version" >> "$GITHUB_OUTPUT" + current=$(grep -m1 '^version = ' cli/pyproject.toml | sed -E 's/version = "(.*)"/\1/') + major=$(echo "$current" | cut -d. -f1) + minor=$(echo "$current" | cut -d. -f2) + patch=$(echo "$current" | cut -d. -f3) + next="$major.$minor.$((patch + 1))" + sed -i "s/^version = \".*\"/version = \"$next\"/" cli/pyproject.toml + echo "tag=cli-v$next" >> "$GITHUB_OUTPUT" - - name: Create and push tag if it doesn't exist yet - id: tag + - name: Commit version bump and push tag run: | - if git ls-remote --exit-code --tags origin "refs/tags/${{ steps.version.outputs.tag }}" >/dev/null 2>&1; then - echo "Tag ${{ steps.version.outputs.tag }} already exists, skipping." - echo "created=false" >> "$GITHUB_OUTPUT" - else - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "${{ steps.version.outputs.tag }}" - git push origin "${{ steps.version.outputs.tag }}" - echo "created=true" >> "$GITHUB_OUTPUT" - fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add cli/pyproject.toml + git commit -m "cli: bump version to ${{ steps.bump.outputs.tag }} [skip release]" + git push origin HEAD:main + git tag "${{ steps.bump.outputs.tag }}" + git push origin "${{ steps.bump.outputs.tag }}" release: - needs: tag - if: needs.tag.outputs.created == 'true' + needs: bump permissions: contents: write uses: ./.github/workflows/cli-release.yml with: - tag: ${{ needs.tag.outputs.tag }} + tag: ${{ needs.bump.outputs.tag }} -# Calls cli-release.yml directly rather than relying on its tag-push trigger, since a tag pushed with the default GITHUB_TOKEN doesn't re-trigger other workflows. + publish-pypi: + needs: bump + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ needs.bump.outputs.tag }} + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + + - name: Build sdist and wheel + working-directory: cli + run: uv build + + - name: Publish to PyPI + working-directory: cli + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }} + run: uv publish + +# Calls cli-release.yml directly (a GITHUB_TOKEN tag push won't retrigger it); "[skip release]" is a defensive backstop against the same push looping. From 38fcd51b19ec9b16836ba3e07b3037028c8f51ef Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 10 Sep 2026 10:42:59 -0700 Subject: [PATCH 42/44] Bump cli version to 0.1.711 PyPI already has canyonos published up through 0.1.71 (from some process outside this branch's new release pipeline), so starting the auto-bump workflow from the old 0.1.5 would immediately collide on its first publish. Jumping ahead clears that. Co-Authored-By: Claude Sonnet 5 --- cli/pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/pyproject.toml b/cli/pyproject.toml index f11a241..608d6c0 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "canyonos" -version = "0.1.5" +version = "0.1.711" description = "CanyonOS CLI" requires-python = ">=3.10" dependencies = [ diff --git a/uv.lock b/uv.lock index be54781..21dc886 100644 --- a/uv.lock +++ b/uv.lock @@ -55,7 +55,7 @@ wheels = [ [[package]] name = "canyonos" -version = "0.1.5" +version = "0.1.711" source = { editable = "cli" } dependencies = [ { name = "pyfiglet" }, From a5bb097eb8b763b9b4c662575380bf73f98d53b3 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 10 Sep 2026 11:02:51 -0700 Subject: [PATCH 43/44] Add multi-arch build/push script for the saakeths/canyonos GC image canyonos_core/Dockerfile was only ever built via a plain `docker build`, producing an image matching whatever machine ran it (arm64 on this laptop). canyonos_core/build-and-push.sh uses `docker buildx build --platform linux/amd64,linux/arm64 --push` instead, so the published saakeths/canyonos:latest covers both architectures. Verified locally: ran the same buildx command (without --push) and confirmed both platforms build cleanly, including the amd64 leg under QEMU emulation. Co-Authored-By: Claude Sonnet 5 --- canyonos_core/Dockerfile | 3 ++- canyonos_core/build-and-push.sh | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100755 canyonos_core/build-and-push.sh diff --git a/canyonos_core/Dockerfile b/canyonos_core/Dockerfile index 0edcbe1..adc70d4 100644 --- a/canyonos_core/Dockerfile +++ b/canyonos_core/Dockerfile @@ -17,4 +17,5 @@ EXPOSE 8000 ENTRYPOINT ["python", "-m", "canyonos_core.server"] -# to run: docker build -f canyonos_core/Dockerfile -t saakeths/canyonos:latest . +# to run (single-arch, host platform only): docker build -f canyonos_core/Dockerfile -t saakeths/canyonos:latest . +# to build+push both amd64 and arm64: canyonos_core/build-and-push.sh diff --git a/canyonos_core/build-and-push.sh b/canyonos_core/build-and-push.sh new file mode 100755 index 0000000..51820c9 --- /dev/null +++ b/canyonos_core/build-and-push.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Builds and pushes saakeths/canyonos:latest for linux/amd64 and linux/arm64. Run from the repo root; requires `docker login`. + +set -eu + +IMAGE="saakeths/canyonos:latest" +PLATFORMS="linux/amd64,linux/arm64" +BUILDER_NAME="canyonos-multiarch" + +if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then + docker buildx create --name "$BUILDER_NAME" --driver docker-container --use +else + docker buildx use "$BUILDER_NAME" +fi + +docker buildx build \ + --builder "$BUILDER_NAME" \ + --platform "$PLATFORMS" \ + -f canyonos_core/Dockerfile \ + -t "$IMAGE" \ + --push \ + . From 406af9685a0c9e2aa890ede9c7b8eec998fff6c2 Mon Sep 17 00:00:00 2001 From: Saaketh Sodanapalli Date: Thu, 10 Sep 2026 11:29:45 -0700 Subject: [PATCH 44/44] small comment --- canyonos_core/build-and-push.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/canyonos_core/build-and-push.sh b/canyonos_core/build-and-push.sh index 51820c9..bb9448b 100755 --- a/canyonos_core/build-and-push.sh +++ b/canyonos_core/build-and-push.sh @@ -1,5 +1,6 @@ #!/bin/sh -# Builds and pushes saakeths/canyonos:latest for linux/amd64 and linux/arm64. Run from the repo root; requires `docker login`. +# Builds and pushes saakeths/canyonos:latest for linux/amd64 and linux/arm64. +# NOTE: Will need to create an enterprise account on a registry and switch from saakeths to canyonos, this is temporary set -eu

?;=0j3GH_Z-_Qdfm0I@G^~> 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 w6g{(fzTYH@cE`hsw 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