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

@ -28,7 +28,7 @@ from activity_core.automation_status import (
)
from activity_core.models import ActivityDefinition, CronTriggerConfig, ScheduledTriggerConfig
from activity_core.orm import ActivityDefinition as ActivityDefinitionRow
from activity_core.orm import ActivityRun, TaskSpawnLog
from activity_core.orm import ActivityRun
from activity_core.runtime_paths import custodian_working_memory_dir
from activity_core.schedule_manager import pause_schedule, unpause_schedule, upsert_schedule
from activity_core.state_hub_write import idempotency_headers, parse_state_hub_write_response
@ -207,6 +207,9 @@ async def ops_runs(
since: datetime | None = None,
limit: int = 50,
) -> dict[str, Any]:
"""Recent activity_runs enriched with ops_runs + artefact links (WP-0027)."""
from activity_core.run_artifacts import enrich_activity_runs
limit = max(1, min(limit, 200))
async with session_factory() as session:
stmt = (
@ -218,54 +221,8 @@ async def ops_runs(
if since is not None:
stmt = stmt.where(ActivityRun.fired_at >= since)
runs = list((await session.scalars(stmt)).all())
run_ids = [r.run_id for r in runs]
spawn_by_run: dict[str, list[dict[str, Any]]] = {str(rid): [] for rid in run_ids}
if run_ids:
# Spawns keyed by triggering_event_id often equal run_id or workflow key;
# also collect by activity_def_id for recent window.
spawn_stmt = (
select(TaskSpawnLog)
.where(TaskSpawnLog.activity_def_id == definition_id)
.order_by(TaskSpawnLog.id.desc())
.limit(limit * 5)
)
for log in (await session.scalars(spawn_stmt)).all():
entry = {
"task_ref": log.task_ref,
"source_type": log.source_type,
"source_id": log.source_id,
"triggering_event_id": log.triggering_event_id,
"condition_matched": log.condition_matched,
}
# Attach if triggering_event_id matches a run_id string, else keep under activity.
tid = log.triggering_event_id or ""
matched = False
for rid in run_ids:
if tid == str(rid) or str(rid) in tid:
spawn_by_run[str(rid)].append(entry)
matched = True
break
if not matched and runs:
# bucket orphan spawns onto most recent run for operator visibility
spawn_by_run[str(runs[0].run_id)].append(entry)
items = []
for r in runs:
items.append(
{
"run_id": str(r.run_id),
"activity_id": str(r.activity_id),
"scheduled_for": r.scheduled_for.isoformat() if r.scheduled_for else None,
"fired_at": r.fired_at.isoformat() if r.fired_at else None,
"tasks_spawned": r.tasks_spawned,
"version_used": r.version_used,
"evidence": {
"task_spawns": spawn_by_run.get(str(r.run_id), [])[:20],
# context_snapshot may be large; only surface shallow keys
"context_keys": sorted((r.context_snapshot or {}).keys())[:40],
},
}
items = await enrich_activity_runs(
session, definition_id, runs, since=since
)
return {
"activity_id": str(definition_id),
@ -274,6 +231,17 @@ async def ops_runs(
}
async def ops_run_detail(
session_factory: async_sessionmaker[AsyncSession],
definition_id: uuid.UUID,
run_id: uuid.UUID,
) -> dict[str, Any] | None:
from activity_core.run_artifacts import get_enriched_run
async with session_factory() as session:
return await get_enriched_run(session, definition_id, run_id)
async def record_ops_audit(
*,
action: str,