Coverage for core/langgraph/discover.py: 0%
66 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-04 15:34 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-10-04 15:34 +0000
1"""
2discover.py — dynaaminen pipeline-lataaja.
4Käynnistyksessä:
5 1. Skannaa /project/.agent-platform/**/*.yaml (manifest-pohjainen)
6 2. Generoi /tmp/langgraph.json (graph-nimet manifest-nimistä)
7 3. Tulostaa löydetyt pipelinet
9YAML-manifest-pipelinet:
10 - Löydetyt Pipeline-manifestit rekisteröidään nimellä metadata.name
11 - Graph-polku osoittaa runner.py:hin: /project/langgraph/runner.py:workflow_<name>
12 - runner.py:ssä on thin wrapper workflow_<name> per pipeline
13 - Dynaaminen registry: /tmp/pipeline_registry.json
15Graphin nimi: metadata.name.
16Tukee PostgreSQL-storea (LANGGRAPH_STORE_POSTGRES_URI env-var).
17"""
19import json
20import os
21import sys
22from pathlib import Path
24PIPELINE_DIR = Path(os.getenv("PROJECT_ROOT", "/project")) / ".agent-platform"
25MANIFEST_PATTERN = "**/*.yaml"
26OUTPUT = Path("/tmp/langgraph.json")
27REGISTRY_OUTPUT = Path("/tmp/pipeline_registry.json")
28STORE_URI = os.getenv("LANGGRAPH_STORE_POSTGRES_URI", "")
30# Runner.py sijaitsee samassa hakemistossa kuin discover.py
31RUNNER_PATH = Path(__file__).parent / "runner.py"
32RUNNER_MODULE_PATH = "/project/langgraph/runner.py"
35def _load_yaml_pipelines(pipeline_dir: Path) -> list[dict]:
36 """
37 Skannaa *.yaml manifestit ja palauttaa löydettyjen Pipeline-manifestien
38 listan: [{name: str, source: str, budget: dict}].
39 Ei tarvita pyyaml:ia jos se ei ole asennettuna — graceful degradation.
40 """
41 try:
42 import yaml # noqa: PLC0415
43 except ImportError:
44 print("[discover] pyyaml not installed — skipping YAML manifest scan")
45 return []
47 pipelines: list[dict] = []
48 if not pipeline_dir.exists():
49 return pipelines
51 for yaml_path in sorted(pipeline_dir.glob(MANIFEST_PATTERN)):
52 try:
53 content = yaml_path.read_text(encoding="utf-8")
54 except OSError:
55 continue
56 for raw in content.split("\n---"):
57 raw_clean = "\n".join(
58 line for line in raw.splitlines()
59 if not line.strip().startswith("#")
60 )
61 try:
62 doc = yaml.safe_load(raw_clean)
63 except yaml.YAMLError:
64 continue
65 if (
66 isinstance(doc, dict)
67 and doc.get("apiVersion") == "agent-platform/v1"
68 and doc.get("kind") == "Pipeline"
69 ):
70 name = (doc.get("metadata") or {}).get("name")
71 if name:
72 budget = (doc.get("spec") or {}).get("budget") or {}
73 pipelines.append({
74 "name": name,
75 "source": str(yaml_path),
76 "budget": budget,
77 })
78 print(f"[discover] YAML Pipeline '{name}' ← {yaml_path.name}")
80 return pipelines
83def _generate_wrapper(name: str, source_yaml: str, budget: dict) -> str:
84 """
85 Tuottaa thin-wrapperin /tmp/<name>-pipeline.py.
87 Wrapper:
88 - lataa config.yaml
89 - alustaa LangfuseTracer (per ajo, uuid-seed)
90 - alustaa RunBudget pipeline-manifestin budget-asetuksista
91 - alustaa OpenAIInvoker
92 - kutsuu build_workflow(name, invoke_agent_fn=invoker, run_context={budget, config})
93 """
94 import json as _json
95 # repr() tuottaa validia Python-dict-literalia (True/False/None) toisin
96 # kuin json.dumps() joka tuottaa JSON-syntaksia (true/false/null).
97 budget_literal = repr(budget)
98 return f'''# Auto-generated by discover.py — do not edit
99"""Per-pipeline wrapper using LangGraph factory pattern.
101LangGraph calls ``make_workflow(config)`` ONCE PER RUN, ensuring fresh
102RunBudget / LangfuseTracer / OpenAIInvoker per invocation. This avoids the
103singleton-state bug where module-level instances accumulated across runs
104(exhausting the LLM-call budget after the first run).
106The factory is async because blockbuster (LangGraph local_dev) detects
107blocking I/O on the event loop. All file/network work runs in a thread.
108"""
109import asyncio
110import os
111import sys
112import uuid
114import yaml
116sys.path.insert(0, "/project/langgraph")
118from runner import build_workflow # noqa: E402
119from _llm import OpenAIInvoker, LangfuseTracer, RunBudget, DEFAULT_MAX_PARALLEL # noqa: E402
121# --- Static (safe at module scope: no side effects, no mutable state) -----
122_PIPELINE_NAME = {name!r}
123_BUDGET_SPEC = {budget_literal}
126def _build_workflow_sync():
127 """All blocking work (file I/O, manifest load, tracer init) in one place."""
128 # Provider registry — K8s Job path sets LLM_CONFIGS env; dev/local path
129 # passes an empty registry (no config.yaml). By ADR-0016 D6, K8s is the
130 # only path; config.yaml is obsolete.
131 import json as _json_mod
132 _providers_raw = os.getenv("LLM_CONFIGS", "[]")
133 try:
134 _PROVIDERS = _json_mod.loads(_providers_raw)
135 except _json_mod.JSONDecodeError:
136 _PROVIDERS = []
138 _prov_registry = {{}}
139 for _c in _PROVIDERS:
140 _n = _c.get("name")
141 if _n and _n not in _prov_registry:
142 _prov_registry[_n] = {{
143 "base_url": _c.get("base_url", ""),
144 "model": _c.get("model", ""),
145 "api_key": _c.get("api_key", ""),
146 "temperature": float(_c.get("temperature", 0.0)),
147 }}
149 max_parallel = int(_BUDGET_SPEC.get("max_parallel", DEFAULT_MAX_PARALLEL))
151 budget = RunBudget(
152 max_llm_calls=int(_BUDGET_SPEC.get("max_llm_calls", 48)),
153 run_timeout_seconds=int(_BUDGET_SPEC.get("timeout_seconds", 240)),
154 )
156 tracer = LangfuseTracer(
157 project_slug=os.getenv("PROJECT_SLUG", "unknown"),
158 pipeline_name=_PIPELINE_NAME,
159 run_id=uuid.uuid4().hex,
160 )
161 tracer.init_trace()
163 invoker = OpenAIInvoker(
164 providers=_prov_registry,
165 max_parallel=max_parallel,
166 budget=budget,
167 tracer=tracer,
168 )
170 run_context = {{
171 "budget": dict(_BUDGET_SPEC),
172 "config": {{}},
173 }}
175 return build_workflow(
176 _PIPELINE_NAME,
177 invoke_agent_fn=invoker,
178 run_context=run_context,
179 tracer=tracer,
180 )
183async def make_workflow(config):
184 """Factory invoked by LangGraph per run. Builds fresh runtime objects."""
185 return await asyncio.to_thread(_build_workflow_sync)
186'''
189def main() -> None:
190 graphs = {}
192 # --- YAML-manifest-pipelinet ---
193 yaml_pipelines = _load_yaml_pipelines(PIPELINE_DIR)
195 # Kirjoita pipeline registry jotta runner.py voi käyttää sitä
196 registry_data = {
197 "manifest_dir": str(PIPELINE_DIR),
198 "pipelines": [p["name"] for p in yaml_pipelines],
199 }
200 REGISTRY_OUTPUT.write_text(json.dumps(registry_data, indent=2))
202 # Luo thin-wrapper-tiedostot /tmp/:iin per YAML-pipeline.
203 for p in yaml_pipelines:
204 name = p["name"]
205 wrapper_path = Path(f"/tmp/{name}-pipeline.py")
206 wrapper_path.write_text(
207 _generate_wrapper(name, p["source"], p["budget"])
208 )
209 graphs[name] = f"{wrapper_path}:make_workflow"
210 print(f"[discover] YAML '{name}' → {wrapper_path}")
212 config: dict = {
213 "dependencies": ["."],
214 "graphs": graphs,
215 }
217 # PostgreSQL store
218 if STORE_URI:
219 config["store"] = {
220 "postgres": {"uri": STORE_URI},
221 }
222 print("[discover] Store: postgres")
224 OUTPUT.write_text(json.dumps(config, indent=2))
226 total = len(graphs)
227 yaml_count = len(yaml_pipelines)
228 if graphs:
229 print(
230 f"[discover] Total pipelines: {total} "
231 f"({yaml_count} YAML)"
232 )
233 print(f"[discover] Config → {OUTPUT}")
234 else:
235 print("[discover] No pipelines found — LangGraph starts with empty graphs")
238if __name__ == "__main__":
239 main()