generated from coulomb/repo-seed
Implement RecentlyOnScope domain digest
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
53
api/routers/recently_on_scope.py
Normal file
53
api/routers/recently_on_scope.py
Normal 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")
|
||||||
35
api/schemas/recently_on_scope.py
Normal file
35
api/schemas/recently_on_scope.py
Normal 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
1
api/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Service-layer helpers for State Hub API features."""
|
||||||
149
api/services/markitect_templates.py
Normal file
149
api/services/markitect_templates.py
Normal 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
|
||||||
|
)
|
||||||
507
api/services/recently_on_scope.py
Normal file
507
api/services/recently_on_scope.py
Normal 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")
|
||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
128
dashboard/src/domains/recently-on-scope.md
Normal file
128
dashboard/src/domains/recently-on-scope.md
Normal 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>
|
||||||
52
scripts/recently_on_scope.py
Normal file
52
scripts/recently_on_scope.py
Normal 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())
|
||||||
47
templates/recently-on-scope/domain-digest.md
Normal file
47
templates/recently-on-scope/domain-digest.md
Normal 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}}
|
||||||
173
tests/test_recently_on_scope.py
Normal file
173
tests/test_recently_on_scope.py
Normal 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
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user