feat(ops): run artefacts in API/UI + llm-connect host access design
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 20s

ACTIVITY-WP-0027: document durable ClusterIP host access for llm-connect;
join activity_runs to ops_runs; surface Forgejo artefact links on
/ops/ui automation detail and new run detail pages.
This commit is contained in:
tegwick 2026-08-05 17:23:02 +02:00
parent 9965af2096
commit 7711bf9c70
6 changed files with 604 additions and 55 deletions

View file

@ -25,6 +25,7 @@ from activity_core.ops_console import (
is_side_effect_definition,
ops_definition_detail,
ops_inventory,
ops_run_detail,
ops_runs,
ops_status,
recent_audits,
@ -168,6 +169,17 @@ async def list_runs(
return await ops_runs(_db(), definition_id, since=since_dt, limit=limit)
@router.get("/automations/{definition_id}/runs/{run_id}")
async def get_run(
definition_id: uuid.UUID,
run_id: uuid.UUID,
) -> dict[str, Any]:
detail = await ops_run_detail(_db(), definition_id, run_id)
if not detail:
raise HTTPException(status_code=404, detail="run not found")
return detail
@router.post("/automations/{definition_id}/trigger")
async def trigger_automation(
definition_id: uuid.UUID,
@ -539,12 +551,31 @@ async def ui_detail(definition_id: uuid.UUID) -> HTMLResponse:
confirm = "true" if side else "false"
run_rows = []
for r in runs.get("runs") or []:
rid = str(r.get("run_id") or "")
arts = r.get("artifacts") or []
if arts:
art_html = " ".join(
f'<a href="{html.escape(str(a.get("url")))}" target="_blank" rel="noopener">'
f'{html.escape(str(a.get("label") or a.get("kind") or "artifact"))}</a>'
for a in arts
if a.get("url") and str(a.get("url")).startswith("https://")
) or '<span class="muted">none</span>'
else:
ops_states = [o.get("state") for o in (r.get("ops_runs") or [])]
if any(s == "open" for s in ops_states):
art_html = '<span class="muted">pending</span>'
elif any(s in ("failed", "expired") for s in ops_states):
art_html = '<span class="muted">failed</span>'
else:
art_html = '<span class="muted">none</span>'
run_rows.append(
f"<tr><td><code>{html.escape(str(r.get('run_id')))}</code></td>"
f"<td>{html.escape(str(r.get('fired_at')))}</td>"
f"<td>{html.escape(str(r.get('scheduled_for')))}</td>"
f"<tr><td><a href='/ops/ui/automations/{html.escape(aid)}/runs/{html.escape(rid)}'>"
f"<code>{html.escape(rid[:13])}…</code></a></td>"
f"<td>{html.escape(str(r.get('fired_at') or ''))}</td>"
f"<td>{html.escape(str(r.get('scheduled_for') or ''))}</td>"
f"<td>{html.escape(str(r.get('tasks_spawned')))}</td>"
f"<td>{html.escape(str(len((r.get('evidence') or {}).get('task_spawns') or [])))}</td></tr>"
f"<td>{html.escape(str(len((r.get('evidence') or {}).get('task_spawns') or [])))}</td>"
f"<td>{art_html}</td></tr>"
)
detail_json = html.escape(json.dumps(detail, indent=2, default=str))
body = f"""
@ -563,8 +594,56 @@ async def ui_detail(definition_id: uuid.UUID) -> HTMLResponse:
<pre>{detail_json}</pre>
<h2>Recent runs</h2>
<table>
<thead><tr><th>run_id</th><th>fired_at</th><th>scheduled_for</th><th>tasks</th><th>spawn evidence</th></tr></thead>
<tbody>{''.join(run_rows) or '<tr><td colspan="5">No runs</td></tr>'}</tbody>
<thead><tr><th>run_id</th><th>fired_at</th><th>scheduled_for</th><th>tasks</th><th>spawns</th><th>Artifacts</th></tr></thead>
<tbody>{''.join(run_rows) or '<tr><td colspan="6">No runs</td></tr>'}</tbody>
</table>
"""
return _page(name, body)
@router.get("/ui/automations/{definition_id}/runs/{run_id}", response_class=HTMLResponse)
async def ui_run_detail(definition_id: uuid.UUID, run_id: uuid.UUID) -> HTMLResponse:
run = await ops_run_detail(_db(), definition_id, run_id)
if not run:
raise HTTPException(status_code=404, detail="run not found")
aid = str(definition_id)
rid = str(run_id)
arts = run.get("artifacts") or []
art_lis = []
for a in arts:
url = a.get("url") or ""
label = html.escape(str(a.get("label") or a.get("kind") or "artifact"))
if isinstance(url, str) and url.startswith("https://"):
art_lis.append(
f'<li><a href="{html.escape(url)}" target="_blank" rel="noopener">{label}</a>'
f' <span class="muted">({html.escape(str(a.get("kind") or ""))})</span></li>'
)
else:
art_lis.append(f"<li>{label} <code>{html.escape(str(url))}</code></li>")
ops_blocks = []
for op in run.get("ops_runs") or []:
ops_blocks.append(
"<div class='card'>"
f"<p><strong>ops_run</strong> <code>{html.escape(str(op.get('id')))}</code> "
f"state=<strong>{html.escape(str(op.get('state')))}</strong></p>"
f"<p>{html.escape(str(op.get('title') or ''))}</p>"
f"<pre>{html.escape(json.dumps(op.get('result') or {{}}, indent=2, default=str))}</pre>"
"</div>"
)
body = f"""
<p class="muted"><a href="/ops/ui/automations/{html.escape(aid)}"> back to automation</a></p>
<h1>Run <code>{html.escape(rid)}</code></h1>
<div class="card">
<p>fired_at: <code>{html.escape(str(run.get('fired_at') or ''))}</code></p>
<p>scheduled_for: <code>{html.escape(str(run.get('scheduled_for') or ''))}</code></p>
<p>tasks_spawned: <strong>{html.escape(str(run.get('tasks_spawned')))}</strong>
version: <code>{html.escape(str(run.get('version_used') or ''))}</code></p>
</div>
<h2>Artifacts</h2>
<ul>{''.join(art_lis) or '<li class="muted">No deliverable links for this run</li>'}</ul>
<h2>Ops runs</h2>
{''.join(ops_blocks) or '<p class="muted">No linked ops_runs</p>'}
<h2>Evidence (compact)</h2>
<pre>{html.escape(json.dumps(run.get('evidence') or {{}}, indent=2, default=str))}</pre>
"""
return _page(f"Run {rid[:8]}", body)