feat: link effect to specific git commit for provenance

- Capture effects repo commit hash at render time
- Store effects_commit in run record
- Effect URLs now link to exact commit, not main branch
- Include commit in raw JSON provenance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 14:29:40 +00:00
parent 5f7b6c3031
commit 0c7e43e069
2 changed files with 48 additions and 2 deletions

View File

@@ -89,6 +89,7 @@ class RunStatus(BaseModel):
output_hash: Optional[str] = None output_hash: Optional[str] = None
error: Optional[str] = None error: Optional[str] = None
celery_task_id: Optional[str] = None celery_task_id: Optional[str] = None
effects_commit: Optional[str] = None
def file_hash(path: Path) -> str: def file_hash(path: Path) -> str:
@@ -268,6 +269,11 @@ async def get_run(run_id: str):
run.completed_at = datetime.now(timezone.utc).isoformat() run.completed_at = datetime.now(timezone.utc).isoformat()
run.output_hash = result.get("output", {}).get("content_hash") run.output_hash = result.get("output", {}).get("content_hash")
# Extract effects commit from provenance
effects = result.get("effects", [])
if effects:
run.effects_commit = effects[0].get("repo_commit")
# Cache the output # Cache the output
output_path = Path(result.get("output", {}).get("local_path", "")) output_path = Path(result.get("output", {}).get("local_path", ""))
if output_path.exists(): if output_path.exists():
@@ -641,6 +647,10 @@ async def ui_detail_page(run_id: str):
run.status = "completed" run.status = "completed"
run.completed_at = datetime.now(timezone.utc).isoformat() run.completed_at = datetime.now(timezone.utc).isoformat()
run.output_hash = result.get("output", {}).get("content_hash") run.output_hash = result.get("output", {}).get("content_hash")
# Extract effects commit
effects = result.get("effects", [])
if effects:
run.effects_commit = effects[0].get("repo_commit")
output_path = Path(result.get("output", {}).get("local_path", "")) output_path = Path(result.get("output", {}).get("local_path", ""))
if output_path.exists(): if output_path.exists():
cache_file(output_path) cache_file(output_path)
@@ -649,7 +659,11 @@ async def ui_detail_page(run_id: str):
run.error = str(task.result) run.error = str(task.result)
save_run(run) save_run(run)
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}" # Build effect URL - use commit hash if available
if run.effects_commit and run.effects_commit != "unknown":
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/commit/{run.effects_commit}/{run.recipe}"
else:
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}"
status_class = run.status status_class = run.status
html = f""" html = f"""
@@ -769,6 +783,7 @@ async def ui_detail_page(run_id: str):
"run_id": run.run_id, "run_id": run.run_id,
"status": run.status, "status": run.status,
"recipe": run.recipe, "recipe": run.recipe,
"effects_commit": run.effects_commit,
"effect_url": effect_url, "effect_url": effect_url,
"inputs": run.inputs, "inputs": run.inputs,
"output_hash": run.output_hash, "output_hash": run.output_hash,
@@ -805,6 +820,10 @@ async def ui_run_partial(run_id: str):
run.status = "completed" run.status = "completed"
run.completed_at = datetime.now(timezone.utc).isoformat() run.completed_at = datetime.now(timezone.utc).isoformat()
run.output_hash = result.get("output", {}).get("content_hash") run.output_hash = result.get("output", {}).get("content_hash")
# Extract effects commit
effects = result.get("effects", [])
if effects:
run.effects_commit = effects[0].get("repo_commit")
output_path = Path(result.get("output", {}).get("local_path", "")) output_path = Path(result.get("output", {}).get("local_path", ""))
if output_path.exists(): if output_path.exists():
cache_file(output_path) cache_file(output_path)

View File

@@ -7,6 +7,7 @@ Distributed rendering tasks for the Art DAG system.
import hashlib import hashlib
import json import json
import os import os
import subprocess
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -16,6 +17,24 @@ from celery_app import app
# Add effects to path (use env var in Docker, fallback to home dir locally) # Add effects to path (use env var in Docker, fallback to home dir locally)
EFFECTS_PATH = Path(os.environ.get("EFFECTS_PATH", str(Path.home() / "artdag-effects"))) EFFECTS_PATH = Path(os.environ.get("EFFECTS_PATH", str(Path.home() / "artdag-effects")))
def get_effects_commit() -> str:
"""Get current git commit hash of effects repo."""
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=EFFECTS_PATH,
capture_output=True,
text=True
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return "unknown"
sys.path.insert(0, str(EFFECTS_PATH / "dog")) sys.path.insert(0, str(EFFECTS_PATH / "dog"))
@@ -106,6 +125,9 @@ def render_effect(self, input_hash: str, effect_name: str, output_name: str) ->
if output_hash != expected_hash: if output_hash != expected_hash:
raise ValueError(f"Output hash mismatch: expected {expected_hash}, got {output_hash}") raise ValueError(f"Output hash mismatch: expected {expected_hash}, got {output_hash}")
# Get effects repo commit
effects_commit = get_effects_commit()
# Build provenance # Build provenance
provenance = { provenance = {
"task_id": self.request.id, "task_id": self.request.id,
@@ -120,7 +142,12 @@ def render_effect(self, input_hash: str, effect_name: str, output_name: str) ->
{"content_hash": input_hash} {"content_hash": input_hash}
], ],
"effects": [ "effects": [
{"name": f"effect:{effect_name}", "content_hash": REGISTRY[f"effect:{effect_name}"]["hash"]} {
"name": f"effect:{effect_name}",
"content_hash": REGISTRY[f"effect:{effect_name}"]["hash"],
"repo_commit": effects_commit,
"repo_url": f"https://git.rose-ash.com/art-dag/effects/src/commit/{effects_commit}/{effect_name}"
}
], ],
"infrastructure": { "infrastructure": {
"software": {"name": "infra:artdag", "content_hash": REGISTRY["infra:artdag"]["hash"]}, "software": {"name": "infra:artdag", "content_hash": REGISTRY["infra:artdag"]["hash"]},