Implement RecentlyOnScope domain digest

This commit is contained in:
2026-05-22 13:45:53 +02:00
parent 0ccbb13892
commit bb985812e5
15 changed files with 1187 additions and 8 deletions

3
.gitignore vendored
View File

@@ -31,6 +31,9 @@ dashboard/dist/
dashboard/src/.observablehq/ dashboard/src/.observablehq/
dashboard/.observablehq/ dashboard/.observablehq/
# Generated State Hub reports
reports/recently-on-scope/
# Local tools and machine-specific binaries # Local tools and machine-specific binaries
kubectl kubectl

View File

@@ -11,6 +11,8 @@ class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian" database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
api_base: str = "http://127.0.0.1:8000" api_base: str = "http://127.0.0.1:8000"
debug: bool = False debug: bool = False
state_hub_report_dir: str = "reports/recently-on-scope"
state_hub_markitect_cli_path: str | None = None
settings = Settings() settings = Settings()

View File

@@ -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 token_events
from api.routers import interface_changes from api.routers import interface_changes
from api.routers import flows from api.routers import flows
from api.routers import recently_on_scope
class ETagMiddleware(BaseHTTPMiddleware): class ETagMiddleware(BaseHTTPMiddleware):
@@ -78,6 +79,7 @@ app.add_middleware(
) )
app.include_router(domains.router) app.include_router(domains.router)
app.include_router(recently_on_scope.router)
app.include_router(repos.router) app.include_router(repos.router)
app.include_router(topics.router) app.include_router(topics.router)
app.include_router(workstreams.router) app.include_router(workstreams.router)

View File

@@ -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")

View File

@@ -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

1
api/services/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Service-layer helpers for State Hub API features."""

View File

@@ -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
)

View File

@@ -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<count>[1-9][0-9]*)(?P<unit>[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")

View File

@@ -21,7 +21,15 @@ export default {
{ name: "Overview", path: "/" }, { name: "Overview", path: "/" },
{ name: "Capabilities", path: "/capability-requests" }, { name: "Capabilities", path: "/capability-requests" },
{ name: "Contributions", path: "/contributions" }, { 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: "Goals", path: "/goals" },
{ name: "Inbox", path: "/inbox" }, { name: "Inbox", path: "/inbox" },
{ name: "Progress", path: "/progress" }, { name: "Progress", path: "/progress" },

View File

@@ -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/<domain_slug>/`. The dashboard lists
those Markdown files and previews the raw report content.
---
## Managing domains ## Managing domains
Via MCP: Via MCP:

View File

@@ -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`<div class="live-indicator">
<span style="color:${domainsResp.ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${domainsResp.ok
? `Live · ${domains.length} domains`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
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`<div class="notice success">Generated <code>${generated.report.id}</code></div>`);
} else if (generated?.error) {
display(html`<div class="notice error">${generated.error}</div>`);
}
```
```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`<p class="dim">No domains registered.</p>`);
} else if (reports.length === 0) {
display(html`<p class="dim">No reports for <code>${selectedDomain}</code>.</p>`);
} else {
display(html`<div class="report-list">${reports.map(report => html`<article class="report-row">
<div>
<a class="report-id" href=${`#${report.id}`}>${report.id}</a>
<div class="report-window">${fmtDate(report.since)} -> ${fmtDate(report.until)}</div>
</div>
<div class="report-counts">
<span>${report.source_counts.progress_events} progress</span>
<span>${report.source_counts.decisions} decisions</span>
<span>${report.source_counts.tasks} tasks</span>
<span>${report.source_counts.attention_items} attention</span>
</div>
</article>`)}</div>`);
}
```
```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`<pre class="markdown-preview">${markdown}</pre>`);
}
```
<style>
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.notice { border-radius: 6px; padding: 0.55rem 0.75rem; margin: 0.75rem 0; font-size: 0.85rem; }
.notice.success { border: 1px solid #86efac; background: #f0fdf4; color: #166534; }
.notice.error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; white-space: pre-wrap; }
.report-list { display: flex; flex-direction: column; gap: 0.5rem; }
.report-row { display: grid; grid-template-columns: minmax(220px, 1.2fr) minmax(260px, 1fr); gap: 1rem; align-items: center; border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 6px; padding: 0.7rem 0.85rem; background: var(--theme-background-alt); }
.report-id { font-family: var(--mono); font-size: 0.86rem; font-weight: 700; text-decoration: none; color: var(--theme-foreground-focus); }
.report-id:hover { text-decoration: underline; }
.report-window { font-size: 0.78rem; color: var(--theme-foreground-muted); margin-top: 0.2rem; }
.report-counts { display: flex; flex-wrap: wrap; gap: 0.35rem; justify-content: flex-end; }
.report-counts span { border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 999px; padding: 0.12rem 0.45rem; font-size: 0.72rem; background: var(--theme-background); color: var(--theme-foreground-muted); }
.markdown-preview { white-space: pre-wrap; overflow-x: auto; margin-top: 1rem; padding: 0.85rem; border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 6px; background: var(--theme-background-alt); font-size: 0.82rem; line-height: 1.45; }
.dim { color: gray; font-style: italic; }
@media (max-width: 720px) {
.report-row { grid-template-columns: 1fr; }
.report-counts { justify-content: flex-start; }
}
</style>

View File

@@ -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())

View File

@@ -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}}

View File

@@ -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

View File

@@ -4,11 +4,12 @@ type: workplan
title: "RecentlyOnScope Domain Digest" title: "RecentlyOnScope Domain Digest"
domain: custodian domain: custodian
repo: state-hub repo: state-hub
status: ready status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-05-22" created: "2026-05-22"
updated: "2026-05-22" updated: "2026-05-22"
state_hub_workstream_id: "ffefe4b2-e162-44c7-8658-5d8d9e27ad9c"
--- ---
# RecentlyOnScope Domain Digest # RecentlyOnScope Domain Digest
@@ -58,8 +59,9 @@ new ad hoc templating language. The local tool surface currently provides
```task ```task
id: STATE-WP-0044-T01 id: STATE-WP-0044-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "0b8d0211-1d66-4808-b078-2d7c23af886a"
``` ```
Define the input, output, and file layout for a RecentlyOnScope digest before Define the input, output, and file layout for a RecentlyOnScope digest before
@@ -92,8 +94,9 @@ default range behavior, and output path generation.
```task ```task
id: STATE-WP-0044-T02 id: STATE-WP-0044-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "4d18fc40-dd28-417f-a2bf-538165eaa1e7"
``` ```
Create a domain-scoped collector that turns recent State Hub records into a Create a domain-scoped collector that turns recent State Hub records into a
@@ -127,8 +130,9 @@ one domain and time window.
```task ```task
id: STATE-WP-0044-T03 id: STATE-WP-0044-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "b3c5a238-3559-442b-a5ac-6f9aae802bac"
``` ```
Render the digest with MarkiTect rather than custom string substitution. Render the digest with MarkiTect rather than custom string substitution.
@@ -157,8 +161,9 @@ and written to the configured report directory.
```task ```task
id: STATE-WP-0044-T04 id: STATE-WP-0044-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "e54ddc1e-75a5-44e5-b452-d8db58cce901"
``` ```
Expose a practical mechanism to generate, list, and read RecentlyOnScope Expose a practical mechanism to generate, list, and read RecentlyOnScope
@@ -187,8 +192,9 @@ existing reports, and read the Markdown contents.
```task ```task
id: STATE-WP-0044-T05 id: STATE-WP-0044-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "dedad1d0-4afd-490a-ab59-a2ad348de5d2"
``` ```
Make the reports accessible from a subentry under Domains in the State Hub Make the reports accessible from a subentry under Domains in the State Hub
@@ -219,8 +225,9 @@ accessible for each entry.
```task ```task
id: STATE-WP-0044-T06 id: STATE-WP-0044-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "cecfb226-435b-429e-89fc-0da743a229ce"
``` ```
Cover the feature with focused tests and operator documentation. Cover the feature with focused tests and operator documentation.