diff --git a/.gitignore b/.gitignore index d51c04a..511b393 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ dashboard/dist/ dashboard/src/.observablehq/ dashboard/.observablehq/ +# Generated State Hub reports +reports/recently-on-scope/ + # Local tools and machine-specific binaries kubectl diff --git a/api/config.py b/api/config.py index 51c3cc7..14d7798 100644 --- a/api/config.py +++ b/api/config.py @@ -11,6 +11,8 @@ class Settings(BaseSettings): database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian" api_base: str = "http://127.0.0.1:8000" debug: bool = False + state_hub_report_dir: str = "reports/recently-on-scope" + state_hub_markitect_cli_path: str | None = None settings = Settings() diff --git a/api/main.py b/api/main.py index e1c368d..96d9274 100644 --- a/api/main.py +++ b/api/main.py @@ -15,6 +15,7 @@ from api.routers import domains, repos, contributions, sbom, policy, domain_goal from api.routers import token_events from api.routers import interface_changes from api.routers import flows +from api.routers import recently_on_scope class ETagMiddleware(BaseHTTPMiddleware): @@ -78,6 +79,7 @@ app.add_middleware( ) app.include_router(domains.router) +app.include_router(recently_on_scope.router) app.include_router(repos.router) app.include_router(topics.router) app.include_router(workstreams.router) diff --git a/api/routers/recently_on_scope.py b/api/routers/recently_on_scope.py new file mode 100644 index 0000000..5607710 --- /dev/null +++ b/api/routers/recently_on_scope.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import PlainTextResponse + +from api.database import get_session +from api.schemas.recently_on_scope import ( + RecentlyOnScopeGenerate, + RecentlyOnScopeGeneratedReport, + RecentlyOnScopeReportMetadata, +) +from api.services.markitect_templates import MarkitectRenderError, MarkitectUnavailable +from api.services.recently_on_scope import ( + generate_report, + list_reports, + read_report, + resolve_window, +) + +router = APIRouter(prefix="/domains/{slug}/recently-on-scope", tags=["recently-on-scope"]) + + +@router.post("/", response_model=RecentlyOnScopeGeneratedReport, status_code=status.HTTP_201_CREATED) +async def generate_recently_on_scope( + slug: str, + body: RecentlyOnScopeGenerate, + session: AsyncSession = Depends(get_session), +) -> RecentlyOnScopeGeneratedReport: + try: + window = resolve_window(body.range, body.since, body.until) + metadata, markdown = await generate_report(session, slug, window) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except MarkitectUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except MarkitectRenderError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return RecentlyOnScopeGeneratedReport(**metadata.model_dump(), markdown=markdown) + + +@router.get("/", response_model=list[RecentlyOnScopeReportMetadata]) +async def list_recently_on_scope(slug: str) -> list[RecentlyOnScopeReportMetadata]: + return list_reports(slug) + + +@router.get("/{report_id}", response_class=PlainTextResponse) +async def get_recently_on_scope_report(slug: str, report_id: str) -> PlainTextResponse: + try: + markdown = read_report(slug, report_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Report not found") from exc + return PlainTextResponse(markdown, media_type="text/markdown") diff --git a/api/schemas/recently_on_scope.py b/api/schemas/recently_on_scope.py new file mode 100644 index 0000000..e030ba0 --- /dev/null +++ b/api/schemas/recently_on_scope.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RecentlyOnScopeGenerate(BaseModel): + range: str = Field(default="1h", description="Time range such as 15m, 1h, 6h, or 1d") + since: datetime | None = None + until: datetime | None = None + + +class RecentlyOnScopeSourceCounts(BaseModel): + progress_events: int = 0 + decisions: int = 0 + workstreams: int = 0 + tasks: int = 0 + repos: int = 0 + attention_items: int = 0 + + +class RecentlyOnScopeReportMetadata(BaseModel): + id: str + domain_slug: str + range: str + since: datetime + until: datetime + generated_at: datetime + path: str + source_counts: RecentlyOnScopeSourceCounts + + +class RecentlyOnScopeGeneratedReport(RecentlyOnScopeReportMetadata): + markdown: str diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 0000000..9dbc675 --- /dev/null +++ b/api/services/__init__.py @@ -0,0 +1 @@ +"""Service-layer helpers for State Hub API features.""" diff --git a/api/services/markitect_templates.py b/api/services/markitect_templates.py new file mode 100644 index 0000000..813573c --- /dev/null +++ b/api/services/markitect_templates.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +import yaml + +from api.config import settings + + +class MarkitectUnavailable(RuntimeError): + """Raised when neither MarkiTect's Python API nor CLI can render templates.""" + + +class MarkitectRenderError(RuntimeError): + """Raised when MarkiTect rejects a template or data payload.""" + + +def render_markdown_template(template_path: Path, data: dict[str, Any]) -> str: + """Render a Markdown template with MarkiTect. + + Prefer the Python API when available. Fall back to the CLI because local + workstations may have MarkiTect installed as a standalone tool. + """ + + template_text = template_path.read_text(encoding="utf-8") + try: + from markitect_tool.template import render_template + except ImportError: + return _render_with_cli(template_path, data) + + try: + return render_template(template_text, data, strict=True).markdown + except Exception as exc: # MarkiTect raises its own TemplateError hierarchy. + raise MarkitectRenderError(str(exc)) from exc + + +def _render_with_cli(template_path: Path, data: dict[str, Any]) -> str: + cli = _find_markitect_cli() + if cli is None: + raise MarkitectUnavailable( + "MarkiTect is required to render RecentlyOnScope digests. " + "Install markitect_tool, put `markitect` on PATH, or set STATE_HUB_MARKITECT_CLI_PATH." + ) + + with tempfile.TemporaryDirectory(prefix="state-hub-markitect-") as tmp: + data_path = Path(tmp) / "data.yaml" + data_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + result = _run_first_supported( + [ + [cli, "template", "render", str(template_path), "--data", str(data_path)], + [cli, "template-render", str(template_path), str(data_path), "--strict"], + ] + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "MarkiTect render failed").strip() + raise MarkitectRenderError(detail) + return result.stdout + + +def inspect_markdown_template(template_path: Path) -> dict[str, Any]: + """Return MarkiTect template analysis data where available.""" + + template_text = template_path.read_text(encoding="utf-8") + try: + from markitect_tool.template import analyze_template + except ImportError: + return _inspect_with_cli(template_path) + + analysis = analyze_template(template_text).to_dict() + if not analysis.get("valid", False): + raise MarkitectRenderError("; ".join(analysis.get("syntax_errors") or [])) + return analysis + + +def _inspect_with_cli(template_path: Path) -> dict[str, Any]: + cli = _find_markitect_cli() + if cli is None: + raise MarkitectUnavailable( + "MarkiTect is required to inspect RecentlyOnScope templates. " + "Install markitect_tool, put `markitect` on PATH, or set STATE_HUB_MARKITECT_CLI_PATH." + ) + result = subprocess.run( + [cli, "template", "inspect", str(template_path), "--format", "json"], + check=False, + capture_output=True, + text=True, + ) + if _is_unsupported_cli_command(result): + with tempfile.TemporaryDirectory(prefix="state-hub-markitect-") as tmp: + data_path = Path(tmp) / "empty.yaml" + data_path.write_text("{}\n", encoding="utf-8") + legacy = subprocess.run( + [cli, "template-render", str(template_path), str(data_path), "--lenient", "--validate"], + check=False, + capture_output=True, + text=True, + ) + if legacy.returncode != 0: + detail = (legacy.stderr or legacy.stdout or "MarkiTect template inspection failed").strip() + raise MarkitectRenderError(detail) + return {"valid": True, "variables": [], "legacy_cli": True} + if result.returncode != 0: + detail = (result.stderr or result.stdout or "MarkiTect template inspection failed").strip() + raise MarkitectRenderError(detail) + try: + return json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise MarkitectRenderError("MarkiTect returned invalid JSON during template inspection") from exc + + +def _find_markitect_cli() -> str | None: + candidates = [ + settings.state_hub_markitect_cli_path, + shutil.which("markitect"), + "/home/worsch/bin/markitect", + ] + for candidate in candidates: + if not candidate: + continue + path = Path(candidate).expanduser() + if path.is_file(): + return str(path) + if shutil.which(str(candidate)): + return str(candidate) + return None + + +def _run_first_supported(commands: list[list[str]]) -> subprocess.CompletedProcess[str]: + last_result: subprocess.CompletedProcess[str] | None = None + for command in commands: + result = subprocess.run(command, check=False, capture_output=True, text=True) + if not _is_unsupported_cli_command(result): + return result + last_result = result + return last_result if last_result is not None else subprocess.CompletedProcess([], 1, "", "") + + +def _is_unsupported_cli_command(result: subprocess.CompletedProcess[str]) -> bool: + output = f"{result.stdout}\n{result.stderr}" + return result.returncode != 0 and ( + "No such command" in output + or "Missing command" in output + or "Got unexpected extra argument" in output + ) diff --git a/api/services/recently_on_scope.py b/api/services/recently_on_scope.py new file mode 100644 index 0000000..242490a --- /dev/null +++ b/api/services/recently_on_scope.py @@ -0,0 +1,507 @@ +from __future__ import annotations + +import os +import re +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import yaml +from fastapi import HTTPException +from sqlalchemy import false, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.models.decision import Decision +from api.models.domain import Domain +from api.models.managed_repo import ManagedRepo +from api.models.progress_event import ProgressEvent +from api.models.task import Task, TaskStatus +from api.models.topic import Topic +from api.models.workstream import Workstream +from api.schemas.recently_on_scope import ( + RecentlyOnScopeReportMetadata, + RecentlyOnScopeSourceCounts, +) +from api.services.markitect_templates import inspect_markdown_template, render_markdown_template + +_DURATION_RE = re.compile(r"^(?P[1-9][0-9]*)(?P[mhd])$") +_REPORT_ID_RE = re.compile(r"^[0-9]{8}T[0-9]{6}Z--(?:[0-9]+[mhd]|[0-9]{8}T[0-9]{6}Z)$") +_TEMPLATE_PATH = Path("templates/recently-on-scope/domain-digest.md") +_TEMPLATE_VERSION = "1" + + +@dataclass(frozen=True) +class DigestWindow: + range: str + since: datetime + until: datetime + exact: bool = False + + +def parse_duration(value: str) -> timedelta: + match = _DURATION_RE.fullmatch(value.strip()) + if match is None: + raise ValueError("range must use a duration such as 15m, 1h, 6h, or 1d") + count = int(match.group("count")) + unit = match.group("unit") + if unit == "m": + return timedelta(minutes=count) + if unit == "h": + return timedelta(hours=count) + return timedelta(days=count) + + +def resolve_window( + range_value: str = "1h", + since: datetime | None = None, + until: datetime | None = None, + *, + now: datetime | None = None, +) -> DigestWindow: + duration = parse_duration(range_value) + end = _as_utc(until or now or datetime.now(tz=UTC)) + start = _as_utc(since) if since is not None else end - duration + if start >= end: + raise ValueError("since must be earlier than until") + return DigestWindow(range=range_value, since=start, until=end, exact=since is not None or until is not None) + + +def report_root() -> Path: + root = Path(settings.state_hub_report_dir).expanduser() + if not root.is_absolute(): + root = Path.cwd() / root + return root + + +def template_path() -> Path: + path = _TEMPLATE_PATH + if not path.is_absolute(): + path = Path.cwd() / path + return path + + +def report_id_for(window: DigestWindow) -> str: + if window.exact: + return f"{_stamp(window.since)}--{_stamp(window.until)}" + return f"{_stamp(window.until)}--{window.range}" + + +def report_path_for(domain_slug: str, window: DigestWindow) -> Path: + return report_root() / domain_slug / f"{report_id_for(window)}.md" + + +async def collect_domain_activity( + session: AsyncSession, + domain_slug: str, + window: DigestWindow, +) -> dict[str, Any]: + domain = await _get_domain(domain_slug, session) + topics = await _list_topics(domain.id, session) + topic_ids = [topic.id for topic in topics] + workstreams = await _list_workstreams(topic_ids, session) + workstream_ids = [workstream.id for workstream in workstreams] + tasks = await _list_tasks(workstream_ids, session) + task_ids = [task.id for task in tasks] + decisions = await _list_recent_decisions(topic_ids, workstream_ids, window, session) + decision_ids = [decision.id for decision in decisions] + progress_events = await _list_recent_progress(topic_ids, workstream_ids, task_ids, decision_ids, window, session) + repos = await _list_repos(domain.id, session) + + recent_workstreams = [ + workstream for workstream in workstreams + if _in_window(workstream.created_at, window) or _in_window(workstream.updated_at, window) + ] + recent_tasks = [ + task for task in tasks + if _in_window(task.created_at, window) or _in_window(task.updated_at, window) + ] + attention_tasks = [ + task for task in tasks + if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancelled.value} + ] + + data = { + "domain": _domain_data(domain), + "window": { + "range": window.range, + "since": _iso(window.since), + "until": _iso(window.until), + }, + "generated_at": _iso(datetime.now(tz=UTC)), + "template_version": _TEMPLATE_VERSION, + "source_counts": { + "progress_events": len(progress_events), + "decisions": len(decisions), + "workstreams": len(recent_workstreams), + "tasks": len(recent_tasks), + "repos": len(repos), + "attention_items": len(attention_tasks), + }, + "progress_events": [_progress_data(event) for event in progress_events], + "decisions": [_decision_data(decision) for decision in decisions], + "workstreams": [_workstream_data(workstream) for workstream in recent_workstreams], + "tasks": [_task_data(task) for task in recent_tasks], + "repos": [_repo_data(repo) for repo in repos], + "attention_items": [_attention_task_data(task) for task in attention_tasks], + } + data |= _section_text(data) + return data + + +async def generate_report( + session: AsyncSession, + domain_slug: str, + window: DigestWindow, +) -> tuple[RecentlyOnScopeReportMetadata, str]: + data = await collect_domain_activity(session, domain_slug, window) + tmpl = template_path() + inspect_markdown_template(tmpl) + markdown = render_markdown_template(tmpl, data) + path = report_path_for(data["domain"]["slug"], window) + _write_report(path, markdown) + return _metadata_from_data(report_id_for(window), path, data, window), markdown + + +def list_reports(domain_slug: str) -> list[RecentlyOnScopeReportMetadata]: + directory = report_root() / domain_slug + if not directory.is_dir(): + return [] + reports = [] + for path in sorted(directory.glob("*.md"), reverse=True): + metadata = metadata_from_report(path) + if metadata is not None: + reports.append(metadata) + return reports + + +def read_report(domain_slug: str, report_id: str) -> str: + if _REPORT_ID_RE.fullmatch(report_id) is None: + raise FileNotFoundError(report_id) + path = report_root() / domain_slug / f"{report_id}.md" + if not path.is_file(): + raise FileNotFoundError(report_id) + return path.read_text(encoding="utf-8") + + +def metadata_from_report(path: Path) -> RecentlyOnScopeReportMetadata | None: + text = path.read_text(encoding="utf-8") + frontmatter = _frontmatter(text) + if not frontmatter: + return None + source_counts = frontmatter.get("source_counts") or {} + try: + return RecentlyOnScopeReportMetadata( + id=path.stem, + domain_slug=str(frontmatter["domain_slug"]), + range=str(frontmatter["range"]), + since=_as_utc(frontmatter["since"]), + until=_as_utc(frontmatter["until"]), + generated_at=_as_utc(frontmatter["generated_at"]), + path=str(path), + source_counts=RecentlyOnScopeSourceCounts(**source_counts), + ) + except (KeyError, TypeError, ValueError): + return None + + +async def _get_domain(domain_slug: str, session: AsyncSession) -> Domain: + result = await session.execute(select(Domain).where(Domain.slug == domain_slug)) + domain = result.scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found") + return domain + + +async def _list_topics(domain_id: uuid.UUID, session: AsyncSession) -> list[Topic]: + result = await session.execute(select(Topic).where(Topic.domain_id == domain_id).order_by(Topic.slug)) + return list(result.scalars().all()) + + +async def _list_workstreams(topic_ids: list[uuid.UUID], session: AsyncSession) -> list[Workstream]: + result = await session.execute( + select(Workstream) + .where(_in(Workstream.topic_id, topic_ids)) + .order_by(Workstream.updated_at.desc(), Workstream.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def _list_tasks(workstream_ids: list[uuid.UUID], session: AsyncSession) -> list[Task]: + result = await session.execute( + select(Task) + .where(_in(Task.workstream_id, workstream_ids)) + .order_by(Task.updated_at.desc(), Task.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def _list_recent_decisions( + topic_ids: list[uuid.UUID], + workstream_ids: list[uuid.UUID], + window: DigestWindow, + session: AsyncSession, +) -> list[Decision]: + result = await session.execute( + select(Decision) + .where(or_(_in(Decision.topic_id, topic_ids), _in(Decision.workstream_id, workstream_ids))) + .where( + or_( + _between(Decision.created_at, window), + _between(Decision.updated_at, window), + _between(Decision.decided_at, window), + ) + ) + .order_by(Decision.updated_at.desc(), Decision.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def _list_recent_progress( + topic_ids: list[uuid.UUID], + workstream_ids: list[uuid.UUID], + task_ids: list[uuid.UUID], + decision_ids: list[uuid.UUID], + window: DigestWindow, + session: AsyncSession, +) -> list[ProgressEvent]: + result = await session.execute( + select(ProgressEvent) + .where(_between(ProgressEvent.created_at, window)) + .where( + or_( + _in(ProgressEvent.topic_id, topic_ids), + _in(ProgressEvent.workstream_id, workstream_ids), + _in(ProgressEvent.task_id, task_ids), + _in(ProgressEvent.decision_id, decision_ids), + ) + ) + .order_by(ProgressEvent.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def _list_repos(domain_id: uuid.UUID, session: AsyncSession) -> list[ManagedRepo]: + result = await session.execute( + select(ManagedRepo) + .where(ManagedRepo.domain_id == domain_id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.slug) + ) + return list(result.scalars().all()) + + +def _in(column, values: list[uuid.UUID]): + return column.in_(values) if values else false() + + +def _between(column, window: DigestWindow): + return column.is_not(None) & (column >= window.since) & (column <= window.until) + + +def _in_window(value: datetime | None, window: DigestWindow) -> bool: + if value is None: + return False + value = _as_utc(value) + return window.since <= value <= window.until + + +def _metadata_from_data( + report_id: str, + path: Path, + data: dict[str, Any], + window: DigestWindow, +) -> RecentlyOnScopeReportMetadata: + return RecentlyOnScopeReportMetadata( + id=report_id, + domain_slug=data["domain"]["slug"], + range=window.range, + since=window.since, + until=window.until, + generated_at=_as_utc(data["generated_at"]), + path=str(path), + source_counts=RecentlyOnScopeSourceCounts(**data["source_counts"]), + ) + + +def _write_report(path: Path, markdown: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(markdown, encoding="utf-8") + os.replace(tmp, path) + + +def _frontmatter(text: str) -> dict[str, Any] | None: + if not text.startswith("---\n"): + return None + end = text.find("\n---", 4) + if end == -1: + return None + data = yaml.safe_load(text[4:end].strip()) or {} + return data if isinstance(data, dict) else None + + +def _section_text(data: dict[str, Any]) -> dict[str, str]: + return { + "progress_section": _progress_section(data["progress_events"]), + "decisions_section": _decisions_section(data["decisions"]), + "workstreams_section": _workstreams_section(data["workstreams"]), + "tasks_section": _tasks_section(data["tasks"]), + "attention_section": _attention_section(data["attention_items"]), + "repos_section": _repos_section(data["repos"]), + } + + +def _progress_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No progress events in this window._" + return "\n".join( + f"- `{item['created_at']}` `{item['event_type']}` {item['summary']}" + + (f" - {item['author']}" if item.get("author") else "") + for item in items + ) + + +def _decisions_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No decisions changed in this window._" + return "\n".join( + f"- `{item['updated_at']}` **{item['title']}** - {item['status']}" + + (f"; decided by {item['decided_by']}" if item.get("decided_by") else "") + for item in items + ) + + +def _workstreams_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No workstreams changed in this window._" + return "\n".join( + f"- `{item['updated_at']}` **{item['title']}** (`{item['slug']}`) - {item['status']}" + for item in items + ) + + +def _tasks_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No tasks changed in this window._" + return "\n".join( + f"- `{item['updated_at']}` **{item['title']}** - {item['status']} / {item['priority']}" + for item in items + ) + + +def _attention_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No open human-intervention items for this domain._" + return "\n".join( + f"- **{item['title']}** - {item.get('intervention_note') or item.get('blocking_reason') or 'needs human attention'}" + for item in items + ) + + +def _repos_section(items: list[dict[str, Any]]) -> str: + if not items: + return "_No active repositories are registered for this domain._" + return "\n".join( + f"- `{item['slug']}` - {item['name']}" + (f" ({item['remote_url']})" if item.get("remote_url") else "") + for item in items + ) + + +def _domain_data(domain: Domain) -> dict[str, Any]: + return { + "id": str(domain.id), + "slug": domain.slug, + "name": domain.name, + "description": domain.description or "", + } + + +def _progress_data(event: ProgressEvent) -> dict[str, Any]: + return { + "id": str(event.id), + "created_at": _iso(event.created_at), + "event_type": event.event_type, + "summary": event.summary, + "author": event.author, + "workstream_id": str(event.workstream_id) if event.workstream_id else None, + "task_id": str(event.task_id) if event.task_id else None, + "decision_id": str(event.decision_id) if event.decision_id else None, + } + + +def _decision_data(decision: Decision) -> dict[str, Any]: + return { + "id": str(decision.id), + "title": decision.title, + "status": _enum_value(decision.status), + "decision_type": _enum_value(decision.decision_type), + "decided_by": decision.decided_by, + "decided_at": _iso(decision.decided_at) if decision.decided_at else "", + "created_at": _iso(decision.created_at), + "updated_at": _iso(decision.updated_at), + } + + +def _workstream_data(workstream: Workstream) -> dict[str, Any]: + return { + "id": str(workstream.id), + "slug": workstream.slug, + "title": workstream.title, + "status": workstream.status, + "owner": workstream.owner or "", + "created_at": _iso(workstream.created_at), + "updated_at": _iso(workstream.updated_at), + } + + +def _task_data(task: Task) -> dict[str, Any]: + return { + "id": str(task.id), + "workstream_id": str(task.workstream_id), + "title": task.title, + "status": _enum_value(task.status), + "priority": _enum_value(task.priority), + "assignee": task.assignee or "", + "blocking_reason": task.blocking_reason or "", + "created_at": _iso(task.created_at), + "updated_at": _iso(task.updated_at), + } + + +def _attention_task_data(task: Task) -> dict[str, Any]: + data = _task_data(task) + data["intervention_note"] = task.intervention_note or "" + return data + + +def _repo_data(repo: ManagedRepo) -> dict[str, Any]: + return { + "id": str(repo.id), + "slug": repo.slug, + "name": repo.name, + "remote_url": repo.remote_url or "", + "local_path": repo.local_path or "", + } + + +def _enum_value(value: Any) -> Any: + return getattr(value, "value", value) + + +def _as_utc(value: datetime | str) -> datetime: + if isinstance(value, str): + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def _iso(value: datetime) -> str: + return _as_utc(value).isoformat().replace("+00:00", "Z") + + +def _stamp(value: datetime) -> str: + return _as_utc(value).strftime("%Y%m%dT%H%M%SZ") diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index de71d03..490de96 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -21,7 +21,15 @@ export default { { name: "Overview", path: "/" }, { name: "Capabilities", path: "/capability-requests" }, { name: "Contributions", path: "/contributions" }, - { name: "Domains", path: "/domains" }, + { + name: "Domains", + path: "/domains", + collapsible: true, + open: false, + pages: [ + { name: "RecentlyOnScope", path: "/domains/recently-on-scope" }, + ], + }, { name: "Goals", path: "/goals" }, { name: "Inbox", path: "/inbox" }, { name: "Progress", path: "/progress" }, diff --git a/dashboard/src/docs/domains.md b/dashboard/src/docs/domains.md index 2bc9b8a..82b8010 100644 --- a/dashboard/src/docs/domains.md +++ b/dashboard/src/docs/domains.md @@ -62,6 +62,18 @@ One card per domain showing: --- +## RecentlyOnScope + +The `Domains / RecentlyOnScope` page generates deterministic Markdown digests +for a selected domain. The range parameter defaults to `1h` and accepts compact +durations such as `15m`, `6h`, or `1d`. + +Generated reports are written under the configured State Hub report directory, +defaulting to `reports/recently-on-scope//`. The dashboard lists +those Markdown files and previews the raw report content. + +--- + ## Managing domains Via MCP: diff --git a/dashboard/src/domains/recently-on-scope.md b/dashboard/src/domains/recently-on-scope.md new file mode 100644 index 0000000..3b7ed3c --- /dev/null +++ b/dashboard/src/domains/recently-on-scope.md @@ -0,0 +1,128 @@ +--- +title: RecentlyOnScope +--- + +```js +import {apiFetch} from "../components/config.js"; +``` + +```js +const domainsResp = await apiFetch("/domains/?status=all"); +const domains = domainsResp.ok ? await domainsResp.json() : []; +const domainOptions = domains.map(d => d.slug).sort(); +const defaultDomain = domainOptions.includes("custodian") ? "custodian" : domainOptions[0]; +``` + +# RecentlyOnScope + +```js +import {injectTocTop} from "../components/toc-sidebar.js"; +import {withDocHelp} from "../components/doc-overlay.js"; + +const _liveEl = html`
+ โ— + ${domainsResp.ok + ? `Live ยท ${domains.length} domains` + : html`Offline โ€” run: make api`} +
`; +withDocHelp(_liveEl, "/docs/domains"); +injectTocTop("live-indicator", _liveEl); +``` + +```js +const selectedDomain = view(Inputs.select(domainOptions, {label: "Domain", value: defaultDomain})); +const selectedRange = view(Inputs.text({label: "Range", value: "1h", placeholder: "15m, 1h, 6h, 1d"})); + +const generated = view(Inputs.button("Generate", { + reduce: async () => { + if (!selectedDomain) return {ok: false, error: "No domain selected"}; + const resp = await apiFetch(`/domains/${encodeURIComponent(selectedDomain)}/recently-on-scope/`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({range: selectedRange || "1h"}), + timeout: 30_000, + }); + if (!resp.ok) return {ok: false, error: await resp.text()}; + return {ok: true, report: await resp.json()}; + }, +})); +``` + +```js +if (generated?.ok) { + display(html`
Generated ${generated.report.id}
`); +} else if (generated?.error) { + display(html`
${generated.error}
`); +} +``` + +```js +generated; +const reportsResp = selectedDomain + ? await apiFetch(`/domains/${encodeURIComponent(selectedDomain)}/recently-on-scope/`) + : {ok: false}; +const reports = reportsResp.ok ? await reportsResp.json() : []; +``` + +## Reports + +```js +function fmtDate(value) { + if (!value) return "โ€”"; + return new Date(value).toLocaleString(); +} + +if (!selectedDomain) { + display(html`

No domains registered.

`); +} else if (reports.length === 0) { + display(html`

No reports for ${selectedDomain}.

`); +} else { + display(html`
${reports.map(report => html`
+
+ ${report.id} +
${fmtDate(report.since)} -> ${fmtDate(report.until)}
+
+
+ ${report.source_counts.progress_events} progress + ${report.source_counts.decisions} decisions + ${report.source_counts.tasks} tasks + ${report.source_counts.attention_items} attention +
+
`)}
`); +} +``` + +```js +const reportIds = reports.map(report => report.id); +const selectedReport = reportIds.length > 0 + ? view(Inputs.select(reportIds, {label: "Preview", value: reportIds[0]})) + : null; +``` + +```js +if (selectedDomain && selectedReport) { + const markdownResp = await apiFetch(`/domains/${encodeURIComponent(selectedDomain)}/recently-on-scope/${encodeURIComponent(selectedReport)}`); + const markdown = markdownResp.ok ? await markdownResp.text() : ""; + display(html`
${markdown}
`); +} +``` + + diff --git a/scripts/recently_on_scope.py b/scripts/recently_on_scope.py new file mode 100644 index 0000000..afe8a63 --- /dev/null +++ b/scripts/recently_on_scope.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate a RecentlyOnScope digest via the State Hub API.") + parser.add_argument("--domain", required=True, help="Domain slug, for example custodian") + parser.add_argument("--range", default="1h", help="Time range such as 15m, 1h, 6h, or 1d") + parser.add_argument("--since", help="Optional ISO timestamp for the start of the window") + parser.add_argument("--until", help="Optional ISO timestamp for the end of the window") + parser.add_argument("--api-base", default="http://127.0.0.1:8000", help="State Hub API base URL") + parser.add_argument("--print-markdown", action="store_true", help="Print the generated Markdown instead of metadata") + args = parser.parse_args() + + payload = {"range": args.range} + if args.since: + payload["since"] = args.since + if args.until: + payload["until"] = args.until + + url = f"{args.api_base.rstrip('/')}/domains/{args.domain}/recently-on-scope/" + request = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8") + print(f"State Hub returned {exc.code}: {detail}", file=sys.stderr) + return 1 + except urllib.error.URLError as exc: + print(f"Could not reach State Hub at {args.api_base}: {exc.reason}", file=sys.stderr) + return 1 + + if args.print_markdown: + print(body["markdown"]) + else: + print(json.dumps({key: value for key, value in body.items() if key != "markdown"}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/recently-on-scope/domain-digest.md b/templates/recently-on-scope/domain-digest.md new file mode 100644 index 0000000..0dce332 --- /dev/null +++ b/templates/recently-on-scope/domain-digest.md @@ -0,0 +1,47 @@ +--- +type: recently_on_scope_digest +domain_slug: "{{domain.slug}}" +domain_name: "{{domain.name}}" +range: "{{window.range}}" +since: "{{window.since}}" +until: "{{window.until}}" +generated_at: "{{generated_at}}" +template_version: "{{template_version}}" +source_counts: + progress_events: {{source_counts.progress_events}} + decisions: {{source_counts.decisions}} + workstreams: {{source_counts.workstreams}} + tasks: {{source_counts.tasks}} + repos: {{source_counts.repos}} + attention_items: {{source_counts.attention_items}} +--- + +# RecentlyOnScope - {{domain.name}} + +Domain: `{{domain.slug}}` +Window: `{{window.since}}` to `{{window.until}}` (`{{window.range}}`) +Generated: `{{generated_at}}` + +## Progress + +{{progress_section}} + +## Decisions + +{{decisions_section}} + +## Workstreams + +{{workstreams_section}} + +## Tasks + +{{tasks_section}} + +## Still Needs Attention + +{{attention_section}} + +## Repositories + +{{repos_section}} diff --git a/tests/test_recently_on_scope.py b/tests/test_recently_on_scope.py new file mode 100644 index 0000000..fbe609e --- /dev/null +++ b/tests/test_recently_on_scope.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from api.services import recently_on_scope as ros +from api.services.markitect_templates import MarkitectUnavailable + + +async def _create_domain(client, slug="digest", name="Digest Domain"): + response = await client.post("/domains/", json={"slug": slug, "name": name}) + assert response.status_code == 201, response.text + return response.json() + + +async def _create_topic(client, domain_slug="digest", slug="digest-topic", title="Digest Topic"): + response = await client.post("/topics/", json={"slug": slug, "title": title, "domain": domain_slug}) + assert response.status_code == 201, response.text + return response.json() + + +async def _create_workstream(client, topic_id, slug="digest-ws", title="Digest Workstream"): + response = await client.post("/workstreams/", json={"topic_id": topic_id, "slug": slug, "title": title}) + assert response.status_code == 201, response.text + return response.json() + + +async def _create_task(client, workstream_id, title="Digest task"): + response = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title}) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.fixture +def fake_markitect(monkeypatch): + def _inspect(_template_path): + return {"valid": True, "variables": []} + + def _render(_template_path, data): + counts = data["source_counts"] + return f"""--- +type: recently_on_scope_digest +domain_slug: "{data["domain"]["slug"]}" +domain_name: "{data["domain"]["name"]}" +range: "{data["window"]["range"]}" +since: "{data["window"]["since"]}" +until: "{data["window"]["until"]}" +generated_at: "{data["generated_at"]}" +template_version: "{data["template_version"]}" +source_counts: + progress_events: {counts["progress_events"]} + decisions: {counts["decisions"]} + workstreams: {counts["workstreams"]} + tasks: {counts["tasks"]} + repos: {counts["repos"]} + attention_items: {counts["attention_items"]} +--- + +# RecentlyOnScope - {data["domain"]["name"]} + +{data["progress_section"]} +""" + + monkeypatch.setattr(ros, "inspect_markdown_template", _inspect) + monkeypatch.setattr(ros, "render_markdown_template", _render) + + +def test_resolve_window_defaults_to_one_hour(): + now = datetime(2026, 5, 22, 12, 0, tzinfo=UTC) + window = ros.resolve_window(now=now) + + assert window.range == "1h" + assert window.since == datetime(2026, 5, 22, 11, 0, tzinfo=UTC) + assert window.until == now + assert ros.report_id_for(window) == "20260522T120000Z--1h" + + +def test_resolve_window_exact_range_id(): + since = datetime(2026, 5, 22, 10, 30, tzinfo=UTC) + until = datetime(2026, 5, 22, 12, 0, tzinfo=UTC) + window = ros.resolve_window("6h", since=since, until=until) + + assert window.exact is True + assert ros.report_id_for(window) == "20260522T103000Z--20260522T120000Z" + + +def test_resolve_window_rejects_ambiguous_duration(): + with pytest.raises(ValueError): + ros.resolve_window("hour") + + +class TestRecentlyOnScopeRoutes: + async def test_generate_list_and_read_digest(self, client, tmp_path, monkeypatch, fake_markitect): + monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) + + await _create_domain(client) + topic = await _create_topic(client) + workstream = await _create_workstream(client, topic["id"]) + task = await _create_task(client, workstream["id"], title="Build digest source") + await client.patch( + f"/tasks/{task['id']}", + json={"needs_human": True, "intervention_note": "Review the generated summary."}, + ) + progress = await client.post( + "/progress/", + json={ + "topic_id": topic["id"], + "workstream_id": workstream["id"], + "task_id": task["id"], + "event_type": "note", + "summary": "Built digest source", + "author": "codex", + }, + ) + assert progress.status_code == 201, progress.text + + await _create_domain(client, slug="elsewhere", name="Elsewhere") + other_topic = await _create_topic(client, domain_slug="elsewhere", slug="other-topic") + other_progress = await client.post( + "/progress/", + json={"topic_id": other_topic["id"], "event_type": "note", "summary": "Do not include"}, + ) + assert other_progress.status_code == 201, other_progress.text + + response = await client.post("/domains/digest/recently-on-scope/", json={"range": "1d"}) + assert response.status_code == 201, response.text + body = response.json() + + assert body["domain_slug"] == "digest" + assert body["range"] == "1d" + assert body["source_counts"]["progress_events"] == 1 + assert body["source_counts"]["attention_items"] == 1 + assert "Built digest source" in body["markdown"] + + listed = await client.get("/domains/digest/recently-on-scope/") + assert listed.status_code == 200 + reports = listed.json() + assert len(reports) == 1 + assert reports[0]["id"] == body["id"] + + markdown = await client.get(f"/domains/digest/recently-on-scope/{body['id']}") + assert markdown.status_code == 200 + assert markdown.headers["content-type"].startswith("text/markdown") + assert "RecentlyOnScope - Digest Domain" in markdown.text + + async def test_generate_rejects_bad_range(self, client, tmp_path, monkeypatch, fake_markitect): + monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) + await _create_domain(client) + + response = await client.post("/domains/digest/recently-on-scope/", json={"range": "yesterday"}) + + assert response.status_code == 422 + + async def test_generate_reports_missing_markitect(self, client, tmp_path, monkeypatch): + monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) + + def _missing_markitect(_template_path): + raise MarkitectUnavailable("MarkiTect unavailable") + + monkeypatch.setattr(ros, "inspect_markdown_template", _missing_markitect) + await _create_domain(client) + + response = await client.post("/domains/digest/recently-on-scope/", json={"range": "1h"}) + + assert response.status_code == 503 + + async def test_missing_report_returns_404(self, client, tmp_path, monkeypatch): + monkeypatch.setattr(ros.settings, "state_hub_report_dir", str(tmp_path)) + + response = await client.get("/domains/digest/recently-on-scope/20260522T120000Z--1h") + + assert response.status_code == 404 diff --git a/workplans/STATE-WP-0044-recently-on-scope-digest.md b/workplans/STATE-WP-0044-recently-on-scope-digest.md index 3ed3057..9c0cad6 100644 --- a/workplans/STATE-WP-0044-recently-on-scope-digest.md +++ b/workplans/STATE-WP-0044-recently-on-scope-digest.md @@ -4,11 +4,12 @@ type: workplan title: "RecentlyOnScope Domain Digest" domain: custodian repo: state-hub -status: ready +status: finished owner: codex topic_slug: custodian created: "2026-05-22" updated: "2026-05-22" +state_hub_workstream_id: "ffefe4b2-e162-44c7-8658-5d8d9e27ad9c" --- # RecentlyOnScope Domain Digest @@ -58,8 +59,9 @@ new ad hoc templating language. The local tool surface currently provides ```task id: STATE-WP-0044-T01 -status: todo +status: done priority: high +state_hub_task_id: "0b8d0211-1d66-4808-b078-2d7c23af886a" ``` Define the input, output, and file layout for a RecentlyOnScope digest before @@ -92,8 +94,9 @@ default range behavior, and output path generation. ```task id: STATE-WP-0044-T02 -status: todo +status: done priority: high +state_hub_task_id: "4d18fc40-dd28-417f-a2bf-538165eaa1e7" ``` Create a domain-scoped collector that turns recent State Hub records into a @@ -127,8 +130,9 @@ one domain and time window. ```task id: STATE-WP-0044-T03 -status: todo +status: done priority: high +state_hub_task_id: "b3c5a238-3559-442b-a5ac-6f9aae802bac" ``` Render the digest with MarkiTect rather than custom string substitution. @@ -157,8 +161,9 @@ and written to the configured report directory. ```task id: STATE-WP-0044-T04 -status: todo +status: done priority: high +state_hub_task_id: "e54ddc1e-75a5-44e5-b452-d8db58cce901" ``` Expose a practical mechanism to generate, list, and read RecentlyOnScope @@ -187,8 +192,9 @@ existing reports, and read the Markdown contents. ```task id: STATE-WP-0044-T05 -status: todo +status: done priority: medium +state_hub_task_id: "dedad1d0-4afd-490a-ab59-a2ad348de5d2" ``` Make the reports accessible from a subentry under Domains in the State Hub @@ -219,8 +225,9 @@ accessible for each entry. ```task id: STATE-WP-0044-T06 -status: todo +status: done priority: medium +state_hub_task_id: "cecfb226-435b-429e-89fc-0da743a229ce" ``` Cover the feature with focused tests and operator documentation.