Fix plan loading and add inline DAG view for recipes
- Add load_plan_for_run() helper that tries plan_id first, then matches by inputs - Fix "plan not found" error when clicking plan nodes - Add inline DAG visualization to recipe detail page with tabs (DAG View / YAML Source) - Recipe page now uses render_page_with_cytoscape for proper DAG rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
169
server.py
169
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'<p class="text-red-400">Run not found</p>', 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('<p class="text-gray-400">Plan not found</p>')
|
||||
@@ -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 '<p class="text-gray-400">No DAG structure found in recipe.</p>'
|
||||
|
||||
content = f'''
|
||||
<div class="mb-6">
|
||||
<a href="/recipes" class="text-blue-400 hover:text-blue-300 text-sm">← Back to recipes</a>
|
||||
@@ -2721,7 +2782,6 @@ async def recipe_detail_page(recipe_id: str, request: Request):
|
||||
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
|
||||
{pinned_badge}
|
||||
{l2_link_html}
|
||||
<a href="/recipe/{recipe.recipe_id}/dag" class="px-2 py-1 bg-purple-600 text-white text-xs rounded-full hover:bg-purple-700 transition-colors">View DAG</a>
|
||||
</div>
|
||||
<p class="text-gray-400 mb-4">{recipe.description or 'No description'}</p>
|
||||
<div class="text-xs text-gray-500 font-mono mb-4">{recipe.recipe_id}</div>
|
||||
@@ -2729,10 +2789,57 @@ async def recipe_detail_page(recipe_id: str, request: Request):
|
||||
</div>
|
||||
|
||||
<div class="bg-dark-700 rounded-lg p-6 mb-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Recipe Source</h3>
|
||||
<pre class="bg-dark-900 p-4 rounded-lg overflow-x-auto text-sm text-gray-300 font-mono whitespace-pre-wrap"><code>{recipe_source_escaped}</code></pre>
|
||||
<div class="flex gap-4 mb-4 border-b border-dark-500">
|
||||
<button id="tab-dag" onclick="showRecipeTab('dag')" class="px-4 py-2 text-white font-medium border-b-2 border-blue-500">
|
||||
DAG View
|
||||
</button>
|
||||
<button id="tab-yaml" onclick="showRecipeTab('yaml')" class="px-4 py-2 text-gray-400 hover:text-white">
|
||||
YAML Source
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="panel-dag">
|
||||
<div class="mb-4">
|
||||
<div class="flex gap-4 text-sm flex-wrap">
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded" style="background-color: #3b82f6"></span> SOURCE
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded" style="background-color: #22c55e"></span> EFFECT
|
||||
</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 rounded" style="background-color: #a855f7"></span> OUTPUT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400 mb-4">Click on a node to see its configuration.</p>
|
||||
{dag_html}
|
||||
</div>
|
||||
|
||||
<div id="panel-yaml" class="hidden">
|
||||
<pre class="bg-dark-900 p-4 rounded-lg overflow-x-auto text-sm text-gray-300 font-mono whitespace-pre-wrap"><code>{recipe_source_escaped}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showRecipeTab(tab) {{
|
||||
document.getElementById('panel-dag').classList.toggle('hidden', tab !== 'dag');
|
||||
document.getElementById('panel-yaml').classList.toggle('hidden', tab !== 'yaml');
|
||||
document.getElementById('tab-dag').classList.toggle('border-blue-500', tab === 'dag');
|
||||
document.getElementById('tab-dag').classList.toggle('text-white', tab === 'dag');
|
||||
document.getElementById('tab-dag').classList.toggle('text-gray-400', tab !== 'dag');
|
||||
document.getElementById('tab-dag').classList.toggle('border-b-2', tab === 'dag');
|
||||
document.getElementById('tab-yaml').classList.toggle('border-blue-500', tab === 'yaml');
|
||||
document.getElementById('tab-yaml').classList.toggle('text-white', tab === 'yaml');
|
||||
document.getElementById('tab-yaml').classList.toggle('text-gray-400', tab !== 'yaml');
|
||||
document.getElementById('tab-yaml').classList.toggle('border-b-2', tab === 'yaml');
|
||||
// Trigger resize for cytoscape
|
||||
if (tab === 'dag' && window.cy) {{
|
||||
setTimeout(function() {{ window.cy.resize(); window.cy.fit(); }}, 100);
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
|
||||
<div class="bg-dark-700 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-white mb-4">Run this Recipe</h3>
|
||||
<form hx-post="/ui/recipes/{recipe_id}/run" hx-target="#run-result" hx-swap="innerHTML">
|
||||
@@ -2746,7 +2853,7 @@ async def recipe_detail_page(recipe_id: str, request: Request):
|
||||
</div>
|
||||
'''
|
||||
|
||||
return HTMLResponse(render_page(f"Recipe: {recipe.name}", content, ctx.actor_id if ctx else None, active_tab="recipes"))
|
||||
return HTMLResponse(render_page_with_cytoscape(f"Recipe: {recipe.name}", content, ctx.actor_id if ctx else None, active_tab="recipes"))
|
||||
|
||||
|
||||
@app.get("/recipe/{recipe_id}/dag", response_class=HTMLResponse)
|
||||
|
||||
Reference in New Issue
Block a user