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:
247
server.py
247
server.py
@@ -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);
|
||||
}}
|
||||
}};
|
||||
}});
|
||||
|
||||
Reference in New Issue
Block a user