diff --git a/server.py b/server.py index 24c194f..13bfc62 100644 --- a/server.py +++ b/server.py @@ -1335,6 +1335,44 @@ PLAN_CACHE_DIR = CACHE_DIR / 'plans' ANALYSIS_CACHE_DIR = CACHE_DIR / 'analysis' +def load_plan_for_run(run: RunStatus) -> Optional[dict]: + """Load plan data for a run, trying plan_id first, then matching by inputs.""" + PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True) + + # First try by plan_id if available + if run.plan_id: + plan_file = PLAN_CACHE_DIR / f"{run.plan_id}.json" + if plan_file.exists(): + try: + with open(plan_file) as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + pass + + # Fall back to matching by inputs + for plan_file in PLAN_CACHE_DIR.glob("*.json"): + try: + with open(plan_file) as f: + data = json.load(f) + plan_inputs = data.get("input_hashes", {}) + if run.inputs and set(plan_inputs.values()) == set(run.inputs): + return data + except (json.JSONDecodeError, IOError): + continue + + # Try to load from plan_json in step_results (for IPFS_PRIMARY mode) + if run.step_results: + # Check if there's embedded plan data + for step_id, result in run.step_results.items(): + if isinstance(result, dict) and "plan_json" in result: + try: + return json.loads(result["plan_json"]) + except (json.JSONDecodeError, TypeError): + pass + + return None + + @app.get("/run/{run_id}/plan/node/{step_id}", response_class=HTMLResponse) async def run_plan_node_detail(run_id: str, step_id: str, request: Request): """HTMX partial: Get node detail HTML fragment.""" @@ -1347,18 +1385,7 @@ async def run_plan_node_detail(run_id: str, step_id: str, request: Request): return HTMLResponse(f'
Run not found
', status_code=404) # Load plan data - plan_data = None - PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True) - for plan_file in PLAN_CACHE_DIR.glob("*.json"): - try: - with open(plan_file) as f: - data = json.load(f) - plan_inputs = data.get("input_hashes", {}) - if set(plan_inputs.values()) == set(run.inputs): - plan_data = data - break - except (json.JSONDecodeError, IOError): - continue + plan_data = await asyncio.to_thread(load_plan_for_run, run) if not plan_data: return HTMLResponse('Plan not found
') @@ -1505,21 +1532,7 @@ async def run_plan_visualization(run_id: str, request: Request, node: Optional[s return HTMLResponse(render_page("Access Denied", content, ctx.actor_id, active_tab="runs"), status_code=403) # Try to load existing plan from cache - plan_data = None - PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True) - - # Look for plan file matching this run - for plan_file in PLAN_CACHE_DIR.glob("*.json"): - try: - with open(plan_file) as f: - data = json.load(f) - # Check if this plan matches our run inputs - plan_inputs = data.get("input_hashes", {}) - if set(plan_inputs.values()) == set(run.inputs): - plan_data = data - break - except (json.JSONDecodeError, IOError): - continue + plan_data = await asyncio.to_thread(load_plan_for_run, run) # If no cached plan, try to generate one from the recipe if not plan_data: @@ -2700,9 +2713,11 @@ async def recipe_detail_page(recipe_id: str, request: Request): # Load recipe source YAML recipe_path = cache_manager.get_by_content_hash(recipe_id) recipe_source = "" + recipe_config = {} if recipe_path and recipe_path.exists(): try: recipe_source = recipe_path.read_text() + recipe_config = yaml.safe_load(recipe_source) except Exception: recipe_source = "(Could not load recipe source)" @@ -2710,6 +2725,52 @@ async def recipe_detail_page(recipe_id: str, request: Request): import html recipe_source_escaped = html.escape(recipe_source) + # Build DAG visualization for this recipe + dag_nodes = [] + dag_edges = [] + dag_config = recipe_config.get("dag", {}) + dag_node_defs = dag_config.get("nodes", []) + output_node = dag_config.get("output") + + for node_def in dag_node_defs: + node_id = node_def.get("id", "") + node_type = node_def.get("type", "EFFECT") + node_config = node_def.get("config", {}) + input_names = node_def.get("inputs", []) + + is_output = node_id == output_node + if is_output: + color = NODE_COLORS.get("OUTPUT", NODE_COLORS["default"]) + else: + color = NODE_COLORS.get(node_type, NODE_COLORS["default"]) + + label = node_id + if node_type == "EFFECT" and "effect" in node_config: + label = node_config["effect"] + + dag_nodes.append({ + "data": { + "id": node_id, + "label": label, + "nodeType": node_type, + "isOutput": is_output, + "color": color, + "config": node_config + } + }) + + for input_name in input_names: + dag_edges.append({ + "data": { + "source": input_name, + "target": node_id + } + }) + + nodes_json = json.dumps(dag_nodes) + edges_json = json.dumps(dag_edges) + dag_html = render_dag_cytoscape(nodes_json, edges_json) if dag_nodes else 'No DAG structure found in recipe.
' + content = f'''{recipe.description or 'No description'}
{recipe_source_escaped}
+ Click on a node to see its configuration.
+ {dag_html} +{recipe_source_escaped}
+