feat(webui): workflow-event and conversation timeline model (Closes #637)
Phase 1 child of the Web Console epic #631. Adds a durable, versioned WorkflowEvent schema with per-source adapters and a read-only query API so operators can browse a unified timeline of workflow events, decisions, tool calls, and handoffs instead of scattered evidence. - webui/timeline.py (new): versioned WorkflowEvent schema; control-plane event adapter and Gitea CTH handoff-comment adapter; read-only mode=ro control-plane reader; conjunctive filter by issue/PR/session; stable (timestamp, source_rank, event_key) ordering; bounded pagination; fail-soft per-source status; redaction at the boundary, fail closed. - webui/app.py: GET /api/v1/timeline read-only route with thread-scoped, fail-soft handoff comment source. - tests/test_webui_timeline.py (new): schema, adapters, redaction of secret-like payloads, filter/sort/pagination, scoped CP reader, fail-soft composition, and API integration. - docs/webui-local-dev.md: timeline route and field-authority notes. Read-only Phase 1; no mutation of historical events; no full chat replay; no unredacted tool-argument storage. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -45,6 +45,7 @@ from webui.worktree_scanner import load_hygiene_snapshot, snapshot_to_dict as wo
|
||||
from webui.worktree_views import render_worktrees_page
|
||||
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
|
||||
from webui.runtime_views import render_runtime_page
|
||||
from webui.timeline import load_timeline, snapshot_to_dict as timeline_snapshot_to_dict
|
||||
|
||||
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
||||
_AUDIT_MUTATION_PATHS = frozenset({"/audit", "/api/audit"})
|
||||
@@ -377,6 +378,101 @@ async def api_console_security_model(_request: Request) -> JSONResponse:
|
||||
})
|
||||
|
||||
|
||||
def _query_int(request: Request, key: str) -> int | None:
|
||||
"""Parse an optional integer query parameter; None when absent/invalid."""
|
||||
raw = request.query_params.get(key)
|
||||
if raw is None or not str(raw).strip():
|
||||
return None
|
||||
try:
|
||||
return int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _derive_remote(host: str) -> str:
|
||||
"""Map a Gitea host to its known short remote name (control-plane scope key)."""
|
||||
text = (host or "").lower()
|
||||
if "prgs" in text:
|
||||
return "prgs"
|
||||
if "dadeschools" in text:
|
||||
return "dadeschools"
|
||||
return text.split(".")[0] if text else ""
|
||||
|
||||
|
||||
def _timeline_comment_source(host: str, org: str, repo: str):
|
||||
"""Build a fail-soft CTH-comment fetcher for one repo, or None when offline.
|
||||
|
||||
Returns a callable ``(kind, number) -> list[comment]``. Credentials or
|
||||
network failures raise inside the callable so ``load_timeline`` degrades the
|
||||
handoff source rather than the whole timeline. Offline test mode yields no
|
||||
live source so the handoff section reports ``not run``.
|
||||
"""
|
||||
import os
|
||||
|
||||
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
|
||||
|
||||
offline = (os.environ.get("WEBUI_TEST_OFFLINE") or "").strip().lower() in {"1", "true", "yes"}
|
||||
if offline:
|
||||
return None
|
||||
auth = get_auth_header(host)
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
def _fetch(kind: str, number: int) -> list:
|
||||
segment = "pulls" if kind == "pr" else "issues"
|
||||
url = f"{repo_api_url(host, org, repo)}/{segment}/{int(number)}/comments"
|
||||
comments: list = []
|
||||
page = 1
|
||||
while page <= 20:
|
||||
raw, meta = api_fetch_page(url, auth, page=page, limit=50)
|
||||
comments.extend(raw)
|
||||
if bool(meta["is_final_page"]):
|
||||
break
|
||||
page += 1
|
||||
return comments
|
||||
|
||||
return _fetch
|
||||
|
||||
|
||||
async def api_v1_timeline(request: Request) -> JSONResponse:
|
||||
"""Read-only workflow-event timeline (#637). Filter by issue/PR/session."""
|
||||
from webui.queue_loader import _host_from_url # host normalisation helper
|
||||
|
||||
registry, error = _load_project_registry()
|
||||
if error is not None:
|
||||
return JSONResponse(error.to_dict(), status_code=500)
|
||||
project = registry.projects[0] if registry.projects else None
|
||||
|
||||
org = request.query_params.get("org") or (project.gitea_owner if project else "")
|
||||
repo = request.query_params.get("repo") or (project.repo_name if project else "")
|
||||
host = _host_from_url(project.remote_host) if project else ""
|
||||
remote = request.query_params.get("remote") or _derive_remote(host)
|
||||
|
||||
if not (remote and org and repo):
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "timeline_scope_unresolved",
|
||||
"detail": "no project in registry and no remote/org/repo query params provided",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
comment_source = _timeline_comment_source(host, org, repo) if (host and org and repo) else None
|
||||
|
||||
snapshot = load_timeline(
|
||||
remote=remote,
|
||||
org=org,
|
||||
repo=repo,
|
||||
issue=_query_int(request, "issue"),
|
||||
pr=_query_int(request, "pr"),
|
||||
session=(request.query_params.get("session") or None),
|
||||
limit=_query_int(request, "limit"),
|
||||
offset=_query_int(request, "offset"),
|
||||
comment_source=comment_source,
|
||||
)
|
||||
return JSONResponse(timeline_snapshot_to_dict(snapshot))
|
||||
|
||||
|
||||
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
|
||||
path = request.url.path
|
||||
if path in _AUDIT_MUTATION_PATHS and request.method == "POST":
|
||||
@@ -415,6 +511,7 @@ def create_app(*, bind_host: str | None = None) -> Starlette:
|
||||
Route("/api/prompts", api_prompts, methods=["GET"]),
|
||||
Route("/runtime", runtime, methods=["GET"]),
|
||||
Route("/api/runtime", api_runtime, methods=["GET"]),
|
||||
Route("/api/v1/timeline", api_v1_timeline, methods=["GET"]),
|
||||
Route("/audit", audit, methods=["GET", "POST"]),
|
||||
Route("/api/audit", api_audit, methods=["GET", "POST"]),
|
||||
Route("/worktrees", worktrees, methods=["GET"]),
|
||||
|
||||
Reference in New Issue
Block a user