Plan-based caching with artifact playback in UI

RunStatus now stores:
- plan_id, plan_name for linking to execution plan
- step_results for per-step execution status
- all_outputs for all artifacts from all steps

Plan visualization:
- Shows human-readable step names from recipe structure
- Video/audio artifact preview on node click
- Outputs list with links to cached artifacts
- Stats reflect actual execution status (completed/cached/pending)

Execution:
- Step results include outputs list with cache_ids
- run_plan returns all outputs from all steps
- Support for completed_by_other status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-11 00:20:19 +00:00
parent 36bf0cd3f7
commit 3db606bf15
3 changed files with 278 additions and 32 deletions

247
server.py
View File

@@ -272,6 +272,11 @@ class RunStatus(BaseModel):
username: Optional[str] = None # Owner of the run (ActivityPub actor ID)
infrastructure: Optional[dict] = None # Hardware/software used for rendering
provenance_cid: Optional[str] = None # IPFS CID of provenance record
# Plan execution tracking
plan_id: Optional[str] = None # ID of the execution plan
plan_name: Optional[str] = None # Human-readable plan name
step_results: Optional[dict] = None # step_id -> result dict (status, cache_id, outputs)
all_outputs: Optional[list] = None # All outputs from all steps
# ============ Recipe Models ============
@@ -825,13 +830,18 @@ async def get_run(run_id: str):
run.status = "completed"
run.completed_at = datetime.now(timezone.utc).isoformat()
# Handle both legacy (render_effect) and new (execute_dag) result formats
if "output_hash" in result:
# New DAG result format
run.output_hash = result.get("output_hash")
# Handle both legacy (render_effect) and new (execute_dag/run_plan) result formats
if "output_hash" in result or "output_cache_id" in result:
# New DAG/plan result format
run.output_hash = result.get("output_hash") or result.get("output_cache_id")
run.provenance_cid = result.get("provenance_cid")
output_path = Path(result.get("output_path", "")) if result.get("output_path") else None
else:
# Store plan execution data
run.plan_id = result.get("plan_id")
run.plan_name = result.get("plan_name")
run.step_results = result.get("results") # step_id -> result dict
run.all_outputs = result.get("outputs") # All outputs from all steps
elif "output" in result:
# Legacy render_effect format
run.output_hash = result.get("output", {}).get("content_hash")
run.provenance_cid = result.get("provenance_cid")
@@ -846,8 +856,9 @@ async def get_run(run_id: str):
# Extract infrastructure info (legacy only)
run.infrastructure = result.get("infrastructure")
# Cache the output (legacy mode - DAG already caches via cache_manager)
if output_path and output_path.exists() and "output_hash" not in result:
# Cache the output (legacy mode - DAG/plan already caches via cache_manager)
is_plan_result = "output_hash" in result or "output_cache_id" in result
if output_path and output_path.exists() and not is_plan_result:
t0 = time.time()
await cache_file(output_path, node_type="effect_output")
logger.info(f"get_run: cache_file took {time.time()-t0:.3f}s")
@@ -1383,27 +1394,70 @@ async def run_plan_visualization(run_id: str, request: Request):
nodes = []
edges = []
steps = plan_data.get("steps", [])
all_outputs = plan_data.get("outputs", []) # Pre-built outputs if available
# Use run's execution results if available (from completed runs)
step_results = run.step_results or {}
if run.all_outputs:
all_outputs = run.all_outputs
# Build output lookup by step_id
outputs_by_step = {}
for output in all_outputs:
step_id = output.get("step_id")
if step_id:
if step_id not in outputs_by_step:
outputs_by_step[step_id] = []
outputs_by_step[step_id].append(output)
for step in steps:
node_type = step.get("node_type", "EFFECT")
color = NODE_COLORS.get(node_type, NODE_COLORS["default"])
cached = step.get("cached", False)
status = "cached" if cached else "pending"
# Shorter label for display
step_id = step.get("step_id", "")
label = step_id[:12] + "..." if len(step_id) > 12 else step_id
# Get execution result for this step if available
step_result = step_results.get(step_id, {})
result_status = step_result.get("status")
# Determine status from result or plan
if result_status in ("completed", "cached", "completed_by_other"):
status = result_status
elif step.get("cached", False):
status = "cached"
elif run.status == "running":
status = "pending"
else:
status = "pending"
# Use human-readable name if available, otherwise short step_id
step_name = step.get("name", "")
if step_name:
# Use last part of dotted name for label
label_parts = step_name.split(".")
label = label_parts[-1] if label_parts else step_name
else:
label = step_id[:12] + "..." if len(step_id) > 12 else step_id
# Get outputs for this step - prefer result outputs, then plan outputs
step_outputs = step_result.get("outputs", []) or step.get("outputs", []) or outputs_by_step.get(step_id, [])
output_cache_ids = [o.get("cache_id") for o in step_outputs]
# Get cache_id from result if available
cache_id = step_result.get("cache_id") or step.get("cache_id", "")
nodes.append({
"data": {
"id": step_id,
"label": label,
"name": step_name,
"nodeType": node_type,
"level": step.get("level", 0),
"cacheId": step.get("cache_id", ""),
"cacheId": cache_id,
"status": status,
"color": color,
"config": step.get("config")
"config": step.get("config"),
"outputs": step_outputs,
"outputCacheIds": output_cache_ids,
}
})
@@ -1422,16 +1476,31 @@ async def run_plan_visualization(run_id: str, request: Request):
})
except json.JSONDecodeError:
pass
else:
# Build edges directly from steps
for step in steps:
step_id = step.get("step_id", "")
for input_step in step.get("input_steps", []):
edges.append({
"data": {
"source": input_step,
"target": step_id
}
})
nodes_json = json.dumps(nodes)
edges_json = json.dumps(edges)
dag_html = render_dag_cytoscape(nodes_json, edges_json)
# Stats summary
total = plan_data.get("total_steps", len(steps))
cached = plan_data.get("cached_steps", sum(1 for s in steps if s.get("cached")))
pending = plan_data.get("pending_steps", total - cached)
# Stats summary - count from built nodes to reflect actual execution status
total = len(nodes)
completed_count = sum(1 for n in nodes if n["data"]["status"] in ("completed", "completed_by_other"))
cached_count = sum(1 for n in nodes if n["data"]["status"] == "cached")
pending_count = total - completed_count - cached_count
# Plan name for display
plan_name = run.plan_name or plan_data.get("recipe", run.recipe)
content = f'''
<a href="/runs" class="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6">
@@ -1444,20 +1513,24 @@ async def run_plan_visualization(run_id: str, request: Request):
{tabs_html}
<div class="bg-dark-700 rounded-lg p-6">
<h2 class="text-xl font-bold text-white mb-4">Execution Plan</h2>
<h2 class="text-xl font-bold text-white mb-4">Execution Plan: {plan_name}</h2>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-dark-600 rounded-lg p-4 text-center">
<div class="text-2xl font-bold text-white">{total}</div>
<div class="text-sm text-gray-400">Total Steps</div>
</div>
<div class="bg-dark-600 rounded-lg p-4 text-center">
<div class="text-2xl font-bold text-green-400">{cached}</div>
<div class="text-2xl font-bold text-green-400">{completed_count}</div>
<div class="text-sm text-gray-400">Completed</div>
</div>
<div class="bg-dark-600 rounded-lg p-4 text-center">
<div class="text-2xl font-bold text-blue-400">{cached_count}</div>
<div class="text-sm text-gray-400">Cached</div>
</div>
<div class="bg-dark-600 rounded-lg p-4 text-center">
<div class="text-2xl font-bold text-yellow-400">{pending}</div>
<div class="text-sm text-gray-400">Executed</div>
<div class="text-2xl font-bold text-yellow-400">{pending_count}</div>
<div class="text-sm text-gray-400">Pending</div>
</div>
</div>
@@ -4489,8 +4562,27 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
return f'''
<div id="{container_id}" class="w-full h-96 bg-dark-800 rounded-lg border border-dark-500"></div>
<div id="{container_id}-details" class="mt-4 p-4 bg-dark-700 rounded-lg hidden">
<h4 class="text-sm font-medium text-gray-400 mb-2">Node Details</h4>
<div class="flex justify-between items-start mb-3">
<div>
<h4 id="{container_id}-node-name" class="text-lg font-semibold text-white"></h4>
<span id="{container_id}-node-type" class="text-xs text-gray-400"></span>
</div>
<button id="{container_id}-close-btn" class="text-gray-400 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="{container_id}-artifact-preview" class="mb-4 hidden">
<div class="bg-dark-800 rounded-lg overflow-hidden">
<video id="{container_id}-video-player" class="w-full max-h-64" controls muted></video>
</div>
</div>
<div id="{container_id}-node-info" class="text-gray-200 font-mono text-xs space-y-1"></div>
<div id="{container_id}-outputs-list" class="mt-4 hidden">
<h5 class="text-sm font-medium text-gray-400 mb-2">Outputs</h5>
<div id="{container_id}-outputs-container" class="space-y-2"></div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {{
@@ -4532,6 +4624,13 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
'border-width': 3
}}
}},
{{
selector: 'node[status="completed"]',
style: {{
'border-color': '#22c55e',
'border-width': 3
}}
}},
{{
selector: 'node[status="running"]',
style: {{
@@ -4573,35 +4672,119 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
}}
}});
// Close button handler
document.getElementById('{container_id}-close-btn').addEventListener('click', function() {{
document.getElementById('{container_id}-details').classList.add('hidden');
var player = document.getElementById('{container_id}-video-player');
if (player) player.pause();
}});
window.artdagCy.on('tap', 'node', function(evt) {{
var node = evt.target;
var details = document.getElementById('{container_id}-details');
var info = document.getElementById('{container_id}-node-info');
var nameEl = document.getElementById('{container_id}-node-name');
var typeEl = document.getElementById('{container_id}-node-type');
var previewEl = document.getElementById('{container_id}-artifact-preview');
var videoPlayer = document.getElementById('{container_id}-video-player');
var outputsList = document.getElementById('{container_id}-outputs-list');
var outputsContainer = document.getElementById('{container_id}-outputs-container');
details.classList.remove('hidden');
var configStr = node.data('config') ? JSON.stringify(node.data('config'), null, 2) : 'N/A';
// Set name and type
var nodeName = node.data('name') || node.data('label') || node.data('id');
nameEl.textContent = nodeName;
typeEl.textContent = node.data('nodeType') || '';
// Handle artifact preview
var outputs = node.data('outputs') || [];
var cacheId = node.data('cacheId');
var status = node.data('status');
var hasPlayableArtifact = false;
// Check for playable artifacts
if ((status === 'cached' || status === 'completed' || status === 'completed_by_other') && outputs.length > 0) {{
// Find first video/audio output
for (var i = 0; i < outputs.length; i++) {{
var output = outputs[i];
var mediaType = output.media_type || output.mediaType || '';
var outputCacheId = output.cache_id || output.cacheId || cacheId;
if (outputCacheId && (mediaType.startsWith('video/') || mediaType.startsWith('audio/'))) {{
hasPlayableArtifact = true;
videoPlayer.src = '/cache/' + outputCacheId + '/raw';
break;
}}
}}
}} else if ((status === 'cached' || status === 'completed') && cacheId) {{
// Try cacheId directly for single-output nodes
hasPlayableArtifact = true;
videoPlayer.src = '/cache/' + cacheId + '/raw';
}}
if (hasPlayableArtifact) {{
previewEl.classList.remove('hidden');
}} else {{
previewEl.classList.add('hidden');
videoPlayer.src = '';
}}
// Show outputs list
if (outputs.length > 0) {{
outputsList.classList.remove('hidden');
outputsContainer.innerHTML = '';
outputs.forEach(function(output, idx) {{
var outCacheId = output.cache_id || output.cacheId || '';
var outName = output.name || ('output_' + idx);
var outMediaType = output.media_type || output.mediaType || 'unknown';
var outStatus = output.status || (outCacheId ? 'cached' : 'pending');
var statusColor = outStatus === 'cached' || outStatus === 'completed' ? 'text-green-400' : 'text-yellow-400';
var html = '<div class="flex items-center justify-between bg-dark-600 rounded p-2">' +
'<div class="flex-1 min-w-0">' +
'<div class="text-sm text-gray-200 truncate">' + outName + '</div>' +
'<div class="text-xs text-gray-500">' + outMediaType + '</div>' +
'</div>' +
'<div class="flex items-center gap-2">' +
'<span class="text-xs ' + statusColor + '">' + outStatus + '</span>';
if (outCacheId && (outStatus === 'cached' || outStatus === 'completed')) {{
html += '<a href="/cache/' + outCacheId + '" class="text-blue-400 hover:text-blue-300" title="View artifact">' +
'<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">' +
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>' +
'</svg></a>';
}}
html += '</div></div>';
outputsContainer.innerHTML += html;
}});
}} else {{
outputsList.classList.add('hidden');
}}
// Show basic info
var configStr = node.data('config') ? JSON.stringify(node.data('config'), null, 2) : null;
info.innerHTML =
'<div><span class="text-gray-400">ID:</span> ' + node.data('id') + '</div>' +
'<div><span class="text-gray-400">Type:</span> ' + (node.data('nodeType') || 'N/A') + '</div>' +
'<div><span class="text-gray-400">ID:</span> ' + node.data('id').substring(0, 24) + '...</div>' +
'<div><span class="text-gray-400">Level:</span> ' + (node.data('level') !== undefined ? node.data('level') : 'N/A') + '</div>' +
'<div><span class="text-gray-400">Cache ID:</span> ' + (node.data('cacheId') || 'N/A') + '</div>' +
'<div><span class="text-gray-400">Status:</span> ' + (node.data('status') || 'N/A') + '</div>' +
(node.data('config') ? '<div class="mt-2"><span class="text-gray-400">Config:</span><pre class="mt-1 text-xs bg-dark-600 p-2 rounded overflow-x-auto">' + configStr + '</pre></div>' : '');
'<div><span class="text-gray-400">Cache ID:</span> ' + (cacheId ? cacheId.substring(0, 24) + '...' : 'N/A') + '</div>' +
'<div><span class="text-gray-400">Status:</span> <span class="' + (status === 'cached' || status === 'completed' ? 'text-green-400' : 'text-yellow-400') + '">' + (status || 'N/A') + '</span></div>' +
(configStr ? '<div class="mt-2"><span class="text-gray-400">Config:</span><pre class="mt-1 text-xs bg-dark-600 p-2 rounded overflow-x-auto max-h-32">' + configStr + '</pre></div>' : '');
}});
window.artdagCy.on('tap', function(evt) {{
if (evt.target === window.artdagCy) {{
document.getElementById('{container_id}-details').classList.add('hidden');
var player = document.getElementById('{container_id}-video-player');
if (player) player.pause();
}}
}});
// Future WebSocket update function
window.updateNodeStatus = function(stepId, status, cacheId) {{
// WebSocket update function for real-time status updates
window.updateNodeStatus = function(stepId, status, cacheId, outputs) {{
if (!window.artdagCy) return;
var node = window.artdagCy.getElementById(stepId);
if (node && node.length) {{
node.data('status', status);
if (cacheId) node.data('cacheId', cacheId);
if (outputs) node.data('outputs', outputs);
}}
}};
}});