chore(state-hub): decouple embedded service tree
This commit is contained in:
20
AGENTS.md
20
AGENTS.md
@@ -4,7 +4,7 @@ This file provides guidance to Codex (Codex.ai/code) when working with code in t
|
||||
|
||||
## What This Repository Is
|
||||
|
||||
**The Custodian** is a *transgenerational cognitive infrastructure* — a local-first, sovereignty-preserving agent system for co-creating and stewarding knowledge across seven project domains. v0.1 is a governance and schema skeleton; `state-hub/` is the first live implementation layer.
|
||||
**The Custodian** is a *transgenerational cognitive infrastructure* — a local-first, sovereignty-preserving agent system for co-creating and stewarding knowledge across seven project domains. v0.1 is a governance and schema skeleton; State Hub is the first live implementation layer and now lives in the standalone repo at `/home/worsch/state-hub`.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -24,13 +24,7 @@ memory/ # Operational logs — append-only, never silently rew
|
||||
working/ # Session notes (scoped, time-bounded)
|
||||
episodic/ # Immutable event archive
|
||||
|
||||
state-hub/ # Live state service (PostgreSQL + FastAPI + MCP + dashboard)
|
||||
api/ # FastAPI app (models, schemas, routers)
|
||||
mcp_server/ # FastMCP stdio server for Codex
|
||||
migrations/ # Alembic migrations
|
||||
dashboard/ # Observable Framework telemetry dashboard
|
||||
infra/ # docker-compose.yml (postgres + optional pgadmin)
|
||||
scripts/ # seed.py — inserts 6 canonical topics
|
||||
state-hub/ # Pointer only; service source lives at /home/worsch/state-hub
|
||||
|
||||
runtime/ # Agent runtime scaffolding (policies, prompts, tool adapters)
|
||||
infra/ # Deployment, backups, encryption scaffolding
|
||||
@@ -47,7 +41,7 @@ Each project under `canon/projects/` follows a consistent three-file pattern:
|
||||
### State Hub (primary active service)
|
||||
|
||||
```bash
|
||||
cd state-hub
|
||||
cd /home/worsch/state-hub
|
||||
|
||||
# One-time setup
|
||||
cp .env.example .env # edit POSTGRES_PASSWORD
|
||||
@@ -96,7 +90,7 @@ Every Codex session in this repository must follow this ritual:
|
||||
3. If decisions were made, record them with `record_decision()`
|
||||
4. If API routers or models were changed, run the test suite as a gate:
|
||||
```bash
|
||||
cd state-hub && make test
|
||||
cd /home/worsch/state-hub && make test
|
||||
```
|
||||
Requires postgres running (`make db`) and `custodian_test` database to exist.
|
||||
Create it once with: `psql -U custodian -c "CREATE DATABASE custodian_test"`
|
||||
@@ -109,7 +103,7 @@ Every Codex session in this repository must follow this ritual:
|
||||
local copy is up to date, then run the consistency sync:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd state-hub && make fix-consistency REPO=the-custodian
|
||||
cd /home/worsch/state-hub && make fix-consistency REPO=the-custodian
|
||||
```
|
||||
This syncs task blocks → DB and updates task statuses. Without this step, the
|
||||
"Open Workstreams by Domain" chart will show 0 progress even for completed work.
|
||||
@@ -124,13 +118,13 @@ Every Codex session in this repository must follow this ritual:
|
||||
|
||||
For repos where work runs on a remote machine, prefer the combined target:
|
||||
```bash
|
||||
cd state-hub && make fix-consistency-remote REPO=<slug>
|
||||
cd /home/worsch/state-hub && make fix-consistency-remote REPO=<slug>
|
||||
```
|
||||
|
||||
**On a machine where the checkout path differs from what's in the DB**, use
|
||||
`--here` to auto-detect the slug from the git root-commit fingerprint:
|
||||
```bash
|
||||
cd state-hub && make fix-consistency-here REPO_PATH=/path/to/repo
|
||||
cd /home/worsch/state-hub && make fix-consistency-here REPO_PATH=/path/to/repo
|
||||
```
|
||||
This also auto-registers `host_paths[hostname]` so subsequent runs need no override.
|
||||
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## What This Repository Is
|
||||
|
||||
**The Custodian** is a *transgenerational cognitive infrastructure* — a local-first, sovereignty-preserving agent system for co-creating and stewarding knowledge across seven project domains. v0.1 is a governance and schema skeleton; `state-hub/` is the first live implementation layer.
|
||||
**The Custodian** is a *transgenerational cognitive infrastructure* — a local-first, sovereignty-preserving agent system for co-creating and stewarding knowledge across seven project domains. v0.1 is a governance and schema skeleton; State Hub is the first live implementation layer and now lives in the standalone repo at `/home/worsch/state-hub`.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -24,13 +24,7 @@ memory/ # Operational logs — append-only, never silently rew
|
||||
working/ # Session notes (scoped, time-bounded)
|
||||
episodic/ # Immutable event archive
|
||||
|
||||
state-hub/ # Live state service (PostgreSQL + FastAPI + MCP + dashboard)
|
||||
api/ # FastAPI app (models, schemas, routers)
|
||||
mcp_server/ # FastMCP stdio server for Claude Code
|
||||
migrations/ # Alembic migrations
|
||||
dashboard/ # Observable Framework telemetry dashboard
|
||||
infra/ # docker-compose.yml (postgres + optional pgadmin)
|
||||
scripts/ # seed.py — inserts 6 canonical topics
|
||||
state-hub/ # Pointer only; service source lives at /home/worsch/state-hub
|
||||
|
||||
runtime/ # Agent runtime scaffolding (policies, prompts, tool adapters)
|
||||
infra/ # Deployment, backups, encryption scaffolding
|
||||
@@ -47,7 +41,7 @@ Each project under `canon/projects/` follows a consistent three-file pattern:
|
||||
### State Hub (primary active service)
|
||||
|
||||
```bash
|
||||
cd state-hub
|
||||
cd /home/worsch/state-hub
|
||||
|
||||
# One-time setup
|
||||
cp .env.example .env # edit POSTGRES_PASSWORD
|
||||
@@ -96,7 +90,7 @@ Every Claude Code session in this repository must follow this ritual:
|
||||
3. If decisions were made, record them with `record_decision()`
|
||||
4. If API routers or models were changed, run the test suite as a gate:
|
||||
```bash
|
||||
cd state-hub && make test
|
||||
cd /home/worsch/state-hub && make test
|
||||
```
|
||||
Requires postgres running (`make db`) and `custodian_test` database to exist.
|
||||
Create it once with: `psql -U custodian -c "CREATE DATABASE custodian_test"`
|
||||
@@ -109,7 +103,7 @@ Every Claude Code session in this repository must follow this ritual:
|
||||
local copy is up to date, then run the consistency sync:
|
||||
```bash
|
||||
git -C <repo_path> pull --ff-only
|
||||
cd state-hub && make fix-consistency REPO=the-custodian
|
||||
cd /home/worsch/state-hub && make fix-consistency REPO=the-custodian
|
||||
```
|
||||
This syncs task blocks → DB and updates task statuses. Without this step, the
|
||||
"Open Workstreams by Domain" chart will show 0 progress even for completed work.
|
||||
@@ -124,13 +118,13 @@ Every Claude Code session in this repository must follow this ritual:
|
||||
|
||||
For repos where work runs on a remote machine, prefer the combined target:
|
||||
```bash
|
||||
cd state-hub && make fix-consistency-remote REPO=<slug>
|
||||
cd /home/worsch/state-hub && make fix-consistency-remote REPO=<slug>
|
||||
```
|
||||
|
||||
**On a machine where the checkout path differs from what's in the DB**, use
|
||||
`--here` to auto-detect the slug from the git root-commit fingerprint:
|
||||
```bash
|
||||
cd state-hub && make fix-consistency-here REPO_PATH=/path/to/repo
|
||||
cd /home/worsch/state-hub && make fix-consistency-here REPO_PATH=/path/to/repo
|
||||
```
|
||||
This also auto-registers `host_paths[hostname]` so subsequent runs need no override.
|
||||
|
||||
|
||||
16
README.md
16
README.md
@@ -19,13 +19,7 @@ the-custodian/
|
||||
├── memory/ # Operational logs — append-only, never rewritten
|
||||
│ ├── working/ # Session notes (scoped, time-bounded)
|
||||
│ └── episodic/ # Immutable event archive
|
||||
├── state-hub/ # Live state service (the operational brain)
|
||||
│ ├── api/ # FastAPI (PostgreSQL-backed REST + /state/summary)
|
||||
│ ├── mcp_server/ # FastMCP stdio — Claude Code's native interface
|
||||
│ ├── migrations/ # Alembic schema migrations
|
||||
│ ├── dashboard/ # Observable Framework telemetry dashboard
|
||||
│ ├── infra/ # docker-compose.yml (postgres + optional pgadmin)
|
||||
│ └── scripts/ # seed.py, register_project.sh, custodian CLI
|
||||
├── state-hub/ # Pointer only; service source lives at /home/worsch/state-hub
|
||||
├── runtime/ # Agent runtime scaffolding (policies, prompts, adapters)
|
||||
├── infra/ # Deployment, backups, encryption scaffolding
|
||||
└── eval/ # Policy and regression test placeholders
|
||||
@@ -60,7 +54,7 @@ Each domain has three canon artifacts under `canon/projects/<domain>/`:
|
||||
|
||||
## State Hub — Quick Start
|
||||
|
||||
The State Hub is the live operational layer: a PostgreSQL database, a FastAPI REST service, an MCP server for Claude Code, and an Observable dashboard.
|
||||
The State Hub is the live operational layer: a PostgreSQL database, a FastAPI REST service, an MCP server, and an Observable dashboard. Its authoritative implementation now lives in the standalone checkout at `/home/worsch/state-hub`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -71,7 +65,7 @@ The State Hub is the live operational layer: a PostgreSQL database, a FastAPI RE
|
||||
### First-time setup
|
||||
|
||||
```bash
|
||||
cd state-hub
|
||||
cd /home/worsch/state-hub
|
||||
|
||||
cp .env.example .env # set POSTGRES_PASSWORD
|
||||
make install # uv sync → Python deps + custodian CLI in .venv
|
||||
@@ -94,7 +88,7 @@ make api # db + migrate + api (restarts if already running)
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
cd state-hub
|
||||
cd /home/worsch/state-hub
|
||||
make dashboard # Observable Framework dev server on :3000
|
||||
```
|
||||
|
||||
@@ -134,7 +128,7 @@ It exposes 11 tools and 5 resources directly in every Claude Code session.
|
||||
- Start: `get_state_summary()` — orientation snapshot
|
||||
- End: `add_progress_event()` — append to the immutable log
|
||||
|
||||
Tool reference: `state-hub/mcp_server/TOOLS.md`
|
||||
Tool reference: `/home/worsch/state-hub/mcp_server/TOOLS.md`
|
||||
|
||||
If the MCP server is missing from a session: check `~/.claude/CLAUDE.md` → MCP Server Registration.
|
||||
|
||||
|
||||
21
SCOPE.md
21
SCOPE.md
@@ -8,21 +8,21 @@
|
||||
|
||||
## One-liner
|
||||
|
||||
Central cognitive infrastructure and coordination hub for seven project domains — provides governance canon, a live state-tracking API, and MCP integration for cross-domain agent sessions.
|
||||
Central cognitive infrastructure and coordination hub for seven project domains — provides governance canon and coordinates through the standalone State Hub API/MCP service.
|
||||
|
||||
---
|
||||
|
||||
## Core Idea
|
||||
|
||||
The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI + MCP server + Observable dashboard) and a **governance substrate** (canon: constitution, values, domain charters). It acts as episodic memory and coordination layer so that work across multiple repos remains visible, tracked, and aligned with long-term intent.
|
||||
The Custodian repository is the **governance substrate**: canon, constitution, values, domain charters, workplans, and runtime scaffolding. The operational State Hub service (PostgreSQL + FastAPI + MCP server + Observable dashboard) now lives in the standalone `/home/worsch/state-hub` repository and acts as episodic memory and coordination layer for work across repos.
|
||||
|
||||
---
|
||||
|
||||
## In Scope
|
||||
|
||||
- Canon layer: governance constitution, foundational values, six domain charters/roadmaps
|
||||
- State Hub API: topics, workstreams, tasks, decisions, progress events, contributions, SBOM, goals
|
||||
- MCP server: exposes state-hub tools to Claude Code sessions hub-wide
|
||||
- Coordination through the standalone State Hub API: topics, workstreams, tasks, decisions, progress events, contributions, SBOM, goals
|
||||
- MCP session protocol: use the State Hub MCP tools from registered agent sessions
|
||||
- Memory: append-only episodic archive (working notes + immutable event logs)
|
||||
- Agent runtime scaffolding: policies, kaizen agent copies, tool adapters
|
||||
- Cross-domain coordination: dependency tracking, human-intervention flags, next-steps suggestions
|
||||
@@ -36,7 +36,8 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
|
||||
- Financial/legal transactions or external publication
|
||||
- Storing plaintext credentials
|
||||
- Direct writes to `canon/` without a human-approved review gate
|
||||
- Maintenance task *creation* in response to lifecycle events — that responsibility lives in activity-core (see `state-hub/docs/activity-core-delegation.md`). The state hub remains a **read model**, not a task factory.
|
||||
- State Hub implementation work; use `/home/worsch/state-hub`
|
||||
- Maintenance task *creation* in response to lifecycle events — that responsibility lives in activity-core (see `/home/worsch/state-hub/docs/activity-core-delegation.md`). The state hub remains a **read model**, not a task factory.
|
||||
|
||||
---
|
||||
|
||||
@@ -46,7 +47,7 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
|
||||
- Tracking cross-domain decisions, blockers, or workplan progress
|
||||
- Registering a new project into the ecosystem (`make register-project`)
|
||||
- Consulting governance rules or domain charters
|
||||
- Running the State Hub API locally for MCP connectivity
|
||||
- Running the standalone State Hub API locally for MCP connectivity
|
||||
|
||||
---
|
||||
|
||||
@@ -61,7 +62,7 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
|
||||
## Current State
|
||||
|
||||
- Status: active
|
||||
- Implementation: ~60% — canon + state-hub operational; RAG/drafting pipelines (Phase 2) not yet started
|
||||
- Implementation: ~60% — canon + standalone State Hub operational; RAG/drafting pipelines (Phase 2) not yet started
|
||||
- Stability: stable (versioned Alembic migrations; no breaking API changes since v0.3)
|
||||
- Usage: running daily; 15+ active workstreams across 6 domains; MCP server active in Claude Code
|
||||
|
||||
@@ -94,8 +95,8 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI
|
||||
## Getting Oriented
|
||||
|
||||
- Start with: `CLAUDE.md` (session protocol) + `README.md` (architecture overview)
|
||||
- Key files / directories: `state-hub/` (live API + MCP), `canon/` (governance), `workplans/` (active work), `state-hub/mcp_server/TOOLS.md` (tool reference)
|
||||
- Entry points: `cd state-hub && make api` (API); Claude Code with state-hub MCP registered
|
||||
- Key files / directories: `canon/` (governance), `workplans/` (active Custodian work), `state-hub/` (pointer), `/home/worsch/state-hub/mcp_server/TOOLS.md` (tool reference)
|
||||
- Entry points: `cd /home/worsch/state-hub && make api` (API); Codex/Claude Code with state-hub MCP registered
|
||||
|
||||
---
|
||||
|
||||
@@ -126,4 +127,4 @@ keywords: [sbom, licence, license, dependency, lockfile, copyleft]
|
||||
|
||||
## Notes
|
||||
|
||||
Dependency order for domain sequencing: Railiance → Markitect → Coulomb.social → Personhood/Foerster → Custodian. The consistency checker (`make fix-consistency REPO=the-custodian`) must be run after any workplan changes to keep the dashboard accurate.
|
||||
Dependency order for domain sequencing: Railiance → Markitect → Coulomb.social → Personhood/Foerster → Custodian. The consistency checker (`cd /home/worsch/state-hub && make fix-consistency REPO=the-custodian`) must be run after any workplan changes to keep the dashboard accurate.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
.venv
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
**/__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
dashboard/node_modules
|
||||
dashboard/dist
|
||||
dashboard/src/.observablehq/cache
|
||||
dashboard/.observablehq/cache
|
||||
kubectl
|
||||
tests
|
||||
docs
|
||||
infra
|
||||
@@ -1,17 +0,0 @@
|
||||
# Copy to .env and fill in values before running
|
||||
POSTGRES_DB=custodian
|
||||
POSTGRES_USER=custodian
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian
|
||||
|
||||
# pgAdmin (optional, only used with --profile tools)
|
||||
PGADMIN_EMAIL=admin@local.dev
|
||||
PGADMIN_PASSWORD=admin
|
||||
|
||||
# API
|
||||
API_BASE=http://127.0.0.1:8000
|
||||
|
||||
# Gitea (for gitea_inventory.py)
|
||||
GITEA_URL=http://92.205.130.254:32166
|
||||
GITEA_TOKEN=
|
||||
@@ -1,49 +0,0 @@
|
||||
FROM python:3.12-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PATH="/app/.venv/bin:${PATH}"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir uv
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN python - <<'PY' > /tmp/requirements.txt
|
||||
import tomllib
|
||||
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
project = tomllib.load(f)["project"]
|
||||
|
||||
for dep in project["dependencies"]:
|
||||
# llm-connect is currently a local editable test integration in this repo.
|
||||
# The State Hub API/MCP runtime does not import it, and a container build
|
||||
# must not depend on /home/worsch existing inside the image.
|
||||
if dep == "llm-connect":
|
||||
continue
|
||||
print(dep)
|
||||
PY
|
||||
|
||||
RUN uv venv /app/.venv \
|
||||
&& uv pip install --python /app/.venv/bin/python --no-cache -r /tmp/requirements.txt
|
||||
|
||||
COPY alembic.ini ./
|
||||
COPY api/ ./api/
|
||||
COPY flows/ ./flows/
|
||||
COPY mcp_server/ ./mcp_server/
|
||||
COPY migrations/ ./migrations/
|
||||
COPY policies/ ./policies/
|
||||
COPY prompts/ ./prompts/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY task_flow_engine/ ./task_flow_engine/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/state/health', timeout=3).read()"
|
||||
|
||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -1,284 +0,0 @@
|
||||
.PHONY: install install-cli db db-tools migrate seed api dashboard check test clean register-project register-codex-project validate-adr add-domain rename-domain add-repo list-repos register-path cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory
|
||||
|
||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||
|
||||
start:
|
||||
@echo "# run in different terminals"
|
||||
@echo "make db # docker compose up postgres"
|
||||
@echo "make api # start backend api"
|
||||
@echo "make mcp-http # start state-hub mcp service"
|
||||
@echo "make dashboard # Observable dev server on :3000"
|
||||
@echo "make bridges # Set up ssh bridges for cross machines access"
|
||||
|
||||
install:
|
||||
uv sync
|
||||
|
||||
## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide
|
||||
install-cli: install
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian
|
||||
@echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)"
|
||||
@echo "Make sure ~/.local/bin is on your PATH:"
|
||||
@echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc"
|
||||
|
||||
db:
|
||||
$(COMPOSE) up -d postgres
|
||||
|
||||
db-tools:
|
||||
$(COMPOSE) --profile tools up -d
|
||||
|
||||
migrate:
|
||||
uv run alembic upgrade head
|
||||
|
||||
seed:
|
||||
uv run python scripts/seed.py
|
||||
|
||||
## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code.
|
||||
## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001).
|
||||
## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
|
||||
mcp-http:
|
||||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 uv run python mcp_server/server.py
|
||||
|
||||
dashboard:
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
cd dashboard && npm run dev
|
||||
|
||||
check:
|
||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||
|
||||
test:
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
||||
uv run pytest -x -q
|
||||
|
||||
## ops-bridge managed tunnels
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
tunnels-up:
|
||||
bridge up
|
||||
|
||||
tunnels-status:
|
||||
bridge status
|
||||
|
||||
## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE.
|
||||
## Exits non-zero if any tunnel is not fully operational.
|
||||
tunnels-check:
|
||||
bridge check
|
||||
|
||||
## Ensure all ops-bridge tunnels are up and healthy.
|
||||
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
|
||||
bridges:
|
||||
@echo "==> Bringing up all tunnels..."
|
||||
bridge up
|
||||
@echo ""
|
||||
@echo "==> Tunnel status:"
|
||||
bridge status
|
||||
@echo ""
|
||||
@echo "==> Checking tunnel health..."
|
||||
bridge check
|
||||
|
||||
## Start (or restart) the full backend — db + migrate + uvicorn.
|
||||
## Stops uvicorn on :8000 if already running, then starts fresh.
|
||||
api: db
|
||||
@echo "Waiting for postgres..."; \
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
nc -z 127.0.0.1 5432 2>/dev/null && break; \
|
||||
sleep 1; \
|
||||
done
|
||||
$(MAKE) migrate
|
||||
@fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true
|
||||
uv run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
|
||||
|
||||
## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance
|
||||
register-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)"
|
||||
|
||||
## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo
|
||||
register-codex-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex
|
||||
|
||||
## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra
|
||||
add-repo:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
@test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional
|
||||
|
||||
## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain"
|
||||
add-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1)
|
||||
@test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1)
|
||||
curl -sf -X POST http://127.0.0.1:8000/domains/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name"
|
||||
rename-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1)
|
||||
@test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1)
|
||||
@test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1)
|
||||
curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
|
||||
register-path:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
|
||||
|
||||
## List repos for a domain: make list-repos DOMAIN=railiance
|
||||
list-repos:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool
|
||||
|
||||
## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml).
|
||||
## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian
|
||||
## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock
|
||||
## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1
|
||||
## Tip: run capture-tools first for repos with system-level tool dependencies.
|
||||
ingest-sbom:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_sbom.py --repo "$(REPO)" \
|
||||
$(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Ingest capability declarations from SCOPE.md into the catalog.
|
||||
## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
|
||||
## Or: make ingest-capabilities-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-capabilities:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-capabilities-all:
|
||||
uv run python scripts/ingest_capabilities.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check Repository Definition of Integrated (DoI) criteria for a repo.
|
||||
## Usage: make check-doi REPO=llm-connect
|
||||
## Or: make check-doi-all
|
||||
## Add JSON=1 for machine-readable output.
|
||||
check-doi:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
|
||||
|
||||
check-doi-all:
|
||||
uv run python scripts/check_doi.py --all $(if $(JSON),--json)
|
||||
|
||||
## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog.
|
||||
## Usage: make ingest-tpsc REPO=llm-connect
|
||||
## Or: make ingest-tpsc-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-tpsc:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/ingest_tpsc.py --repo "$(REPO)" \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-tpsc-all:
|
||||
uv run python scripts/ingest_tpsc.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
|
||||
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
capture-tools:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
uv run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
|
||||
validate-adr:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
|
||||
uv run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
|
||||
|
||||
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
|
||||
check-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
|
||||
uv run python scripts/consistency_check.py --repo "$(REPO)" \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean, exit 2 = warnings only (treated as success), exit 1 = failures
|
||||
fix-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
|
||||
uv run python scripts/consistency_check.py --repo "$(REPO)" --fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Pull then fix: single repo or all repos if REPO omitted
|
||||
## make fix-consistency-remote — smart pull+fix all repos that need it
|
||||
## make fix-consistency-remote REPO=slug — pull+fix one repo
|
||||
fix-consistency-remote:
|
||||
uv run python scripts/consistency_check.py \
|
||||
$(if $(REPO),--repo "$(REPO)",--all) \
|
||||
--remote \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(NO_WRITEBACK),--no-writeback,); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo]
|
||||
## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag).
|
||||
check-consistency-here:
|
||||
uv run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo]
|
||||
fix-consistency-here:
|
||||
uv run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
--fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check all registered repos for ADR-001 consistency
|
||||
check-consistency-all:
|
||||
uv run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix all registered repos
|
||||
fix-consistency-all:
|
||||
uv run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Cancel open tasks belonging to completed/archived workstreams.
|
||||
## Safe to run at any time; also suitable for a daily cron job.
|
||||
## Cron example: 0 3 * * * cd ~/the-custodian/state-hub && make cleanup-stale
|
||||
cleanup-stale:
|
||||
uv run python scripts/cleanup_stale_tasks.py
|
||||
|
||||
## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx
|
||||
install-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)"
|
||||
|
||||
## Install custodian post-commit sync hook into all active registered repos
|
||||
install-hooks-all:
|
||||
bash scripts/install_hooks.sh --all
|
||||
|
||||
## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx
|
||||
remove-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)" --remove
|
||||
|
||||
## Compare Gitea coulomb org repos against state-hub registered repos
|
||||
## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN=<token>
|
||||
gitea-inventory:
|
||||
uv run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
||||
|
||||
clean:
|
||||
$(COMPOSE) down -v
|
||||
@@ -1,254 +1,27 @@
|
||||
# State Hub v0.1
|
||||
# State Hub Has Moved
|
||||
|
||||
The operational brain of the Custodian: a local PostgreSQL database, FastAPI REST service, FastMCP SSE server for Claude Code, Observable Framework dashboard, and a `custodian` CLI.
|
||||
State Hub is no longer owned as an embedded implementation tree in this
|
||||
repository.
|
||||
|
||||
---
|
||||
Authoritative repo:
|
||||
|
||||
## Stack
|
||||
```text
|
||||
/home/worsch/state-hub
|
||||
```
|
||||
|
||||
| Layer | Technology | Port |
|
||||
|-------|-----------|------|
|
||||
| Database | PostgreSQL 16-alpine (Docker) | `127.0.0.1:5432` |
|
||||
| API | FastAPI + SQLAlchemy 2.0 async + asyncpg | `127.0.0.1:8000` |
|
||||
| MCP server | FastMCP SSE | `127.0.0.1:8001` |
|
||||
| Dashboard | Observable Framework | `127.0.0.1:3000` |
|
||||
| CLI | `custodian` (Python, uv entry point) | — |
|
||||
Use that checkout for API, MCP server, dashboard, migrations, tests, scripts,
|
||||
policies, and State Hub workplans.
|
||||
|
||||
All services bind to `127.0.0.1` only — nothing exposed to the network.
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker Engine (WSL2: see `CLAUDE.md` in repo root → Docker Setup)
|
||||
- Python 3.12+ with `uv` (`pip install uv`)
|
||||
- Node.js 18+ (dashboard only)
|
||||
|
||||
### First-time
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
cd state-hub
|
||||
|
||||
cp .env.example .env # edit POSTGRES_PASSWORD
|
||||
make install # uv sync
|
||||
make db # docker compose up postgres
|
||||
make migrate # alembic upgrade head (creates 5 tables)
|
||||
make seed # insert 6 canonical topics
|
||||
make api # db + migrate + uvicorn :8000 (restarts if running)
|
||||
cd /home/worsch/state-hub
|
||||
make test
|
||||
make api
|
||||
make dashboard
|
||||
make fix-consistency REPO=the-custodian
|
||||
```
|
||||
|
||||
### Dashboard
|
||||
|
||||
```bash
|
||||
make dashboard # Observable dev server on :3000
|
||||
```
|
||||
|
||||
### Start Everything
|
||||
|
||||
To start all the infrastructure on separate consoles do:
|
||||
|
||||
```bash
|
||||
make db # docker compose up postgres
|
||||
make mcp-http # start state-hub mcp service
|
||||
make dashboard # Observable dev server on :3000
|
||||
make bridges # Set up ssh bridges for cross machines access
|
||||
```
|
||||
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
make install-cli # symlink .venv/bin/custodian → ~/.local/bin
|
||||
custodian status # API health + summary totals
|
||||
custodian register-project # register cwd as a Custodian project
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
| Target | What it does |
|
||||
|--------|-------------|
|
||||
| `make install` | `uv sync` — install Python deps + entry points |
|
||||
| `make install-cli` | Symlink `custodian` to `~/.local/bin` |
|
||||
| `make db` | Start postgres container |
|
||||
| `make db-tools` | Start postgres + pgadmin (http://127.0.0.1:5050) |
|
||||
| `make migrate` | `alembic upgrade head` |
|
||||
| `make seed` | Insert 6 canonical topics |
|
||||
| `make api` | `db` + wait + `migrate` + `uvicorn` (restarts if running) |
|
||||
| `make dashboard` | Observable dev server (restarts if running) |
|
||||
| `make check` | `curl /state/health` |
|
||||
| `make register-project DOMAIN=x PROJECT_PATH=y` | Register a project |
|
||||
| `make clean` | `docker compose down -v` (destroys DB volume) |
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
Five tables in dependency order:
|
||||
|
||||
```
|
||||
topics
|
||||
└── workstreams
|
||||
└── tasks (self-FK: parent_task_id)
|
||||
└── progress_events
|
||||
decisions (FK: topic_id, workstream_id — at least one required)
|
||||
└── progress_events
|
||||
```
|
||||
|
||||
### Enums
|
||||
|
||||
| Enum | Values |
|
||||
|------|--------|
|
||||
| `topic_status` | `active` · `paused` · `archived` |
|
||||
| `workstream_status` | `active` · `blocked` · `completed` · `archived` |
|
||||
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||
| `decision_type` | `made` · `pending` |
|
||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
||||
| `domain` | `custodian` · `railiance` · `markitect` · `coulomb_social` · `personhood` · `foerster_capabilities` |
|
||||
|
||||
### Governance constraints encoded in schema
|
||||
|
||||
- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded`
|
||||
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
|
||||
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
Interactive docs at http://127.0.0.1:8000/docs once the API is running.
|
||||
|
||||
### Key endpoint: `/state/summary`
|
||||
|
||||
Returns a full snapshot in one call — used by both the MCP server and dashboard:
|
||||
|
||||
```json
|
||||
{
|
||||
"generated_at": "...",
|
||||
"totals": {
|
||||
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
||||
"workstreams": { "active": 1, "blocked": 0, "completed": 1, "total": 2 },
|
||||
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
||||
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
||||
},
|
||||
"topics": [...], // topics with nested workstream stubs
|
||||
"blocking_decisions": [...], // pending decisions only
|
||||
"blocked_tasks": [...],
|
||||
"recent_progress": [...], // last 20 events
|
||||
"open_workstreams": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Router summary
|
||||
|
||||
| Prefix | Operations |
|
||||
|--------|-----------|
|
||||
| `/topics` | CRUD (soft-delete: `archived`) |
|
||||
| `/workstreams` | CRUD (soft-delete: `archived`) |
|
||||
| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status |
|
||||
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
|
||||
| `/progress` | `GET` list + `POST` append — no DELETE |
|
||||
| `/state/summary` | Full snapshot |
|
||||
| `/state/health` | DB connectivity check |
|
||||
|
||||
---
|
||||
|
||||
## MCP Server
|
||||
|
||||
Runs as a persistent SSE service on `:8001`, independent of the Claude Code session.
|
||||
Restart it anytime without restarting Claude Code.
|
||||
|
||||
```bash
|
||||
make mcp-http # start (or restart) the MCP SSE server on :8001
|
||||
```
|
||||
|
||||
Registered at user scope in `~/.claude.json`:
|
||||
```json
|
||||
{ "type": "sse", "url": "http://127.0.0.1:8001/sse" }
|
||||
```
|
||||
|
||||
To re-register from scratch:
|
||||
```bash
|
||||
claude mcp remove state-hub -s user 2>/dev/null || true
|
||||
claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
|
||||
```
|
||||
|
||||
See `mcp_server/TOOLS.md` for the full tool reference card (30 lines, faster than reading `server.py`).
|
||||
|
||||
### Tools at a glance
|
||||
|
||||
**Query** (read-only): `get_state_summary` · `get_topic` · `list_blocked_tasks` · `list_pending_decisions` · `get_recent_progress`
|
||||
|
||||
**Mutate** (each auto-emits a progress event): `create_task` · `update_task_status` · `record_decision` · `resolve_decision` · `add_progress_event` · `update_workstream_status`
|
||||
|
||||
**Resources**: `state://summary` · `state://topics` · `state://workstreams/{topic_slug}` · `state://decisions/blocking` · `state://tasks/blocked`
|
||||
|
||||
---
|
||||
|
||||
## `custodian` CLI
|
||||
|
||||
Installed into `.venv/bin/custodian` by `uv sync`; symlinked to `~/.local/bin` by `make install-cli`.
|
||||
|
||||
```
|
||||
custodian register-project [--domain DOMAIN] [--path PATH]
|
||||
```
|
||||
|
||||
- `--path` defaults to current working directory
|
||||
- `--domain` is auto-detected from `project_charter_v*.md` frontmatter if omitted
|
||||
|
||||
```
|
||||
custodian status
|
||||
```
|
||||
|
||||
Prints API health, totals, and any blocking decisions.
|
||||
|
||||
### What `register-project` does
|
||||
|
||||
1. Verifies the API is reachable (fails fast with `make api` hint)
|
||||
2. Looks up the topic ID for the domain via `/topics/?status=active`
|
||||
3. Checks that `state-hub` is in `~/.claude.json`
|
||||
4. Writes `$PROJECT_PATH/CLAUDE.md` from `scripts/project_claude_md.template`
|
||||
5. Posts a `milestone` progress event recording the registration
|
||||
|
||||
---
|
||||
|
||||
## Project Registration Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/register_project.sh` | Shell version of `custodian register-project` |
|
||||
| `scripts/patch_mcp_cwd.py` | Legacy: patched `cwd` for the old stdio registration (no longer needed) |
|
||||
| `scripts/project_claude_md.template` | CLAUDE.md template with `{PROJECT_NAME}`, `{DOMAIN}`, `{TOPIC_ID}` |
|
||||
| `scripts/seed.py` | Insert the 6 canonical topics into a fresh database |
|
||||
| `scripts/pull_image.py` | WSL2 workaround: pull Docker images via Python urllib with Range-request chunking |
|
||||
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
Four pages at http://127.0.0.1:3000 (dev) or built with `npm run build`:
|
||||
|
||||
| Page | Content |
|
||||
|------|---------|
|
||||
| **Overview** | Status cards, task-by-status chart, recent activity feed, decisions due within 7 days |
|
||||
| **Workstreams** | Filterable table by domain/status/owner; selected workstream task list; progress timeline |
|
||||
| **Decisions** | Pending tab (with escalation highlights) and Made tab; resolution velocity chart |
|
||||
| **Progress** | Append-only event feed with author badges; 30-day event volume chart |
|
||||
|
||||
Data loaders (`src/data/*.json.py`) are Python scripts that call the local API. They run at dev-server start and on `npm run build`. Clear the cache if data appears stale:
|
||||
|
||||
```bash
|
||||
rm -rf dashboard/src/.observablehq/cache/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / WSL2 Notes
|
||||
|
||||
- **TLS bad record MAC on large downloads**: WSL2 corrupts packets on big TCP transfers. Use `scripts/pull_image.py` instead of `docker pull` for future image pulls.
|
||||
- **MCP server is now SSE, not stdio**: Re-registration is `claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'`. The `patch_mcp_cwd.py` script and `.mcp.json` config are legacy artifacts from the old stdio setup.
|
||||
- **AsyncSession concurrency**: SQLAlchemy 2.0 async sessions don't support concurrent operations. All queries in `/state/summary` run sequentially on a single session.
|
||||
This directory remains only as a pointer so old references to
|
||||
`the-custodian/state-hub` fail gently instead of implying that this repository
|
||||
still owns the service source.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql+psycopg2://custodian:changeme@127.0.0.1:5432/custodian
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,16 +0,0 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
debug: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -1,24 +0,0 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from api.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
@@ -1,536 +0,0 @@
|
||||
"""DoI engine — evaluates all 14 Repository Definition of Integrated criteria.
|
||||
|
||||
Shared by the API endpoint (async) and the CLI check script (asyncio.run).
|
||||
All checks use only the repo dict from /repos/{slug} + HTTP calls to the API
|
||||
+ local filesystem reads. No direct DB access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
CriterionStatus = Literal["pass", "fail", "warn", "skip"]
|
||||
Tier = Literal["none", "core", "standard", "full"]
|
||||
|
||||
# Criteria that belong to each tier (in check order)
|
||||
CORE_IDS = {"C1", "C2", "C3", "C4"}
|
||||
STANDARD_IDS = {"C5a", "C5b", "C5c", "C6", "C7", "C8", "C9"}
|
||||
FULL_IDS = {"C10", "C11", "C12", "C13", "C14"}
|
||||
|
||||
STANDARD_SCOPE_SECTIONS = [
|
||||
"One-liner",
|
||||
"Core Idea",
|
||||
"In Scope",
|
||||
"Out of Scope",
|
||||
"Relevant When",
|
||||
"Not Relevant When",
|
||||
"Current State",
|
||||
"How It Fits",
|
||||
"Terminology",
|
||||
"Related / Overlapping",
|
||||
"Provided Capabilities",
|
||||
]
|
||||
|
||||
_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
||||
_H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CriterionResult:
|
||||
id: str
|
||||
label: str
|
||||
tier: str
|
||||
status: CriterionStatus
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoIReport:
|
||||
repo_slug: str
|
||||
tier: Tier
|
||||
core_pass: bool
|
||||
standard_pass: bool
|
||||
full_pass: bool
|
||||
criteria: list[CriterionResult] = field(default_factory=list)
|
||||
checked_at: str = field(default_factory=lambda: datetime.now(tz=timezone.utc).isoformat())
|
||||
|
||||
|
||||
def evaluate_scope_health(repo: dict) -> list[dict[str, Any]]:
|
||||
"""Return machine-readable SCOPE.md health issues for C5a/C5b/C5c.
|
||||
|
||||
The returned records intentionally mirror DoI criterion IDs while carrying
|
||||
section-level hints that downstream repo-scoping can use to refresh only
|
||||
the affected parts of SCOPE.md.
|
||||
"""
|
||||
repo_path = _resolve_path(repo)
|
||||
if not repo_path:
|
||||
return [
|
||||
{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
{
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
{
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
]
|
||||
|
||||
scope_path = Path(repo_path) / "SCOPE.md"
|
||||
if not scope_path.exists():
|
||||
return [
|
||||
{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "fail",
|
||||
"detail": "SCOPE.md not found at repo root",
|
||||
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
},
|
||||
{
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "skip",
|
||||
"detail": "SCOPE.md absent",
|
||||
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
},
|
||||
{
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "skip",
|
||||
"detail": "SCOPE.md absent",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": ["Provided Capabilities"],
|
||||
},
|
||||
]
|
||||
|
||||
text = scope_path.read_text()
|
||||
issues: list[dict[str, Any]] = [{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "pass",
|
||||
"detail": "",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
}]
|
||||
|
||||
headings = {h.strip() for h in _H2_RE.findall(text)}
|
||||
missing_sections = [section for section in STANDARD_SCOPE_SECTIONS if section not in headings]
|
||||
if missing_sections:
|
||||
issues.append({
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "warn",
|
||||
"detail": f"Missing H2 section(s): {', '.join(missing_sections)}",
|
||||
"missing_sections": missing_sections,
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": missing_sections,
|
||||
})
|
||||
else:
|
||||
issues.append({
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "pass",
|
||||
"detail": f"All {len(STANDARD_SCOPE_SECTIONS)} standard sections present",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
})
|
||||
|
||||
capability_blocks = _CAPABILITY_BLOCK_RE.findall(text)
|
||||
valid_blocks = 0
|
||||
invalid_blocks: list[dict[str, Any]] = []
|
||||
for index, block in enumerate(capability_blocks, start=1):
|
||||
try:
|
||||
parsed = yaml.safe_load(block) or {}
|
||||
if isinstance(parsed, dict) and parsed.get("type") and parsed.get("title"):
|
||||
valid_blocks += 1
|
||||
else:
|
||||
invalid_blocks.append({
|
||||
"index": index,
|
||||
"reason": "Capability block must be YAML with type and title",
|
||||
})
|
||||
except yaml.YAMLError as exc:
|
||||
invalid_blocks.append({"index": index, "reason": str(exc)})
|
||||
|
||||
if valid_blocks > 0:
|
||||
issues.append({
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "pass",
|
||||
"detail": f"{valid_blocks} valid capability block(s)",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": invalid_blocks,
|
||||
"needs_refresh_sections": [],
|
||||
})
|
||||
else:
|
||||
detail = "No fenced capability block found"
|
||||
if invalid_blocks:
|
||||
detail = "No valid capability block found"
|
||||
issues.append({
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "warn",
|
||||
"detail": detail,
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": invalid_blocks,
|
||||
"needs_refresh_sections": ["Provided Capabilities"],
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def compute_fingerprint(
|
||||
repo: dict,
|
||||
latest_tpsc_snap_at: str | None,
|
||||
latest_goal_updated_at: str | None,
|
||||
) -> str:
|
||||
"""Compute a pipe-joined fingerprint of all inputs that affect DoI criteria.
|
||||
|
||||
If any component changes, the fingerprint changes and the cache is invalidated:
|
||||
- repo.updated_at → covers last_sbom_at, remote_url, host_paths, domain changes
|
||||
- latest_tpsc_snap_at → C9 (TPSC snapshot exists)
|
||||
- latest_goal_updated_at → C10 (active repo goal)
|
||||
- mtime of SCOPE.md, CLAUDE.md, tpsc.yaml → C5, C6, C9, C11, C12
|
||||
"""
|
||||
parts = [
|
||||
str(repo.get("updated_at") or ""),
|
||||
str(latest_tpsc_snap_at or ""),
|
||||
str(latest_goal_updated_at or ""),
|
||||
]
|
||||
repo_path = _resolve_path(repo)
|
||||
if repo_path:
|
||||
for fname in ("SCOPE.md", "CLAUDE.md", "tpsc.yaml"):
|
||||
f = Path(repo_path) / fname
|
||||
try:
|
||||
parts.append(f"{fname}:{f.stat().st_mtime:.3f}")
|
||||
except FileNotFoundError:
|
||||
parts.append(f"{fname}:absent")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _resolve_path(repo: dict) -> str:
|
||||
hostname = socket.gethostname()
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
candidates = []
|
||||
if host_paths.get(hostname):
|
||||
candidates.append(host_paths[hostname])
|
||||
if repo.get("local_path"):
|
||||
candidates.append(repo["local_path"])
|
||||
for raw in candidates:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_repo_path(repo: dict) -> str:
|
||||
"""Resolve the repo path using the same host-aware rules as DoI checks."""
|
||||
return _resolve_path(repo)
|
||||
|
||||
|
||||
def _get_sync(api_base: str, path: str, params: dict | None = None) -> object:
|
||||
url = f"{api_base}{path}"
|
||||
if params:
|
||||
q = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
if q:
|
||||
url = f"{url}?{q}"
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _get(api_base: str, path: str, params: dict | None = None) -> object:
|
||||
"""Async wrapper — runs blocking urllib in a thread so the event loop stays free."""
|
||||
return await asyncio.to_thread(_get_sync, api_base, path, params)
|
||||
|
||||
|
||||
async def _run_consistency(repo_slug: str, api_base: str) -> tuple[int, int, int]:
|
||||
"""Run consistency_check.py and return (fail, warn, info) counts."""
|
||||
script = Path(__file__).parent.parent / "scripts" / "consistency_check.py"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"uv", "run", "python", str(script),
|
||||
"--repo", repo_slug,
|
||||
"--api-base", api_base,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(__file__).parent.parent),
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
text = stdout.decode()
|
||||
fail = warn = info = 0
|
||||
for line in text.splitlines():
|
||||
if "Summary:" in line:
|
||||
parts = line.split("|")
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if "fail" in p:
|
||||
try: fail = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
elif "warn" in p:
|
||||
try: warn = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
elif "info" in p:
|
||||
try: info = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
return fail, warn, info
|
||||
|
||||
|
||||
async def evaluate(
|
||||
repo: dict,
|
||||
api_base: str = "http://127.0.0.1:8000",
|
||||
skip_consistency: bool = False,
|
||||
prefetch: dict | None = None,
|
||||
) -> DoIReport:
|
||||
"""Evaluate all 14 DoI criteria for a repo.
|
||||
|
||||
Args:
|
||||
repo: Repo dict (slug, domain_slug, local_path, remote_url, host_paths, last_sbom_at).
|
||||
api_base: API base URL — only used when prefetch is absent.
|
||||
skip_consistency: Skip C7/C13 subprocess calls (used in summary mode).
|
||||
prefetch: Optional pre-fetched bulk data to avoid HTTP self-calls:
|
||||
{
|
||||
"domain_status": {"custodian": "active", ...}, # slug → status
|
||||
"tpsc_snap_counts": {"llm-connect": 1, ...}, # repo_slug → count
|
||||
"active_goal_counts": {"llm-connect": 0, ...}, # repo_slug → count
|
||||
}
|
||||
"""
|
||||
slug = repo.get("slug", "unknown")
|
||||
results: list[CriterionResult] = []
|
||||
|
||||
def _r(id: str, label: str, tier: str, status: CriterionStatus, detail: str = "") -> CriterionResult:
|
||||
r = CriterionResult(id=id, label=label, tier=tier, status=status, detail=detail)
|
||||
results.append(r)
|
||||
return r
|
||||
|
||||
# ── Tier 1: Core ─────────────────────────────────────────────────────────
|
||||
|
||||
# C1: registered
|
||||
_r("C1", "Registered in state-hub", "core", "pass", "Repo record exists")
|
||||
|
||||
# C2: domain assigned and active
|
||||
domain_slug = repo.get("domain_slug") or ""
|
||||
if not domain_slug:
|
||||
_r("C2", "Domain assigned", "core", "fail", "No domain_slug on repo record")
|
||||
else:
|
||||
if prefetch and "domain_status" in prefetch:
|
||||
dom_status = prefetch["domain_status"].get(domain_slug)
|
||||
else:
|
||||
d = await _get(api_base, f"/domains/{domain_slug}/")
|
||||
dom_status = d.get("status") if d else None
|
||||
if dom_status == "active":
|
||||
_r("C2", "Domain assigned", "core", "pass", f"domain: {domain_slug}")
|
||||
elif dom_status:
|
||||
_r("C2", "Domain assigned", "core", "warn", f"Domain '{domain_slug}' status: {dom_status}")
|
||||
else:
|
||||
_r("C2", "Domain assigned", "core", "fail", f"Domain '{domain_slug}' not found")
|
||||
|
||||
# C3: local path resolves
|
||||
repo_path = _resolve_path(repo)
|
||||
if repo_path:
|
||||
_r("C3", "Local path resolves", "core", "pass", repo_path)
|
||||
else:
|
||||
raw = repo.get("local_path") or "(none)"
|
||||
_r("C3", "Local path resolves", "core", "fail", f"Path not accessible: {raw}")
|
||||
|
||||
# C4: remote URL set
|
||||
remote = repo.get("remote_url") or ""
|
||||
if remote.strip():
|
||||
_r("C4", "Remote URL set", "core", "pass", remote)
|
||||
else:
|
||||
_r("C4", "Remote URL set", "core", "fail", "remote_url is empty")
|
||||
|
||||
# ── Tier 2: Standard ─────────────────────────────────────────────────────
|
||||
|
||||
# C5a/C5b/C5c: SCOPE.md structure and capability declarations
|
||||
for issue in evaluate_scope_health(repo):
|
||||
_r(issue["id"], issue["label"], "standard", issue["status"], issue["detail"])
|
||||
|
||||
# C6: CLAUDE.md
|
||||
if not repo_path:
|
||||
_r("C6", "CLAUDE.md present", "standard", "skip", "Local path unavailable")
|
||||
elif (Path(repo_path) / "CLAUDE.md").exists():
|
||||
_r("C6", "CLAUDE.md present", "standard", "pass")
|
||||
else:
|
||||
_r("C6", "CLAUDE.md present", "standard", "fail", "CLAUDE.md not found at repo root")
|
||||
|
||||
# C7: workplan convention — consistency check 0 FAIL
|
||||
if skip_consistency:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
|
||||
else:
|
||||
try:
|
||||
fail, warn, _ = await _run_consistency(slug, api_base)
|
||||
if fail == 0:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "pass", f"consistency: {fail} fail / {warn} warn")
|
||||
else:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "fail", f"consistency: {fail} fail / {warn} warn")
|
||||
except Exception as e:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", f"Could not run consistency check: {e}")
|
||||
|
||||
# C8: SBOM ingested
|
||||
last_sbom = repo.get("last_sbom_at")
|
||||
if last_sbom:
|
||||
_r("C8", "SBOM ingested", "standard", "pass", f"last ingested: {last_sbom[:10]}")
|
||||
else:
|
||||
_r("C8", "SBOM ingested", "standard", "fail", "last_sbom_at not set — run make ingest-sbom")
|
||||
|
||||
# C9: TPSC declared (tpsc.yaml present + snapshot exists)
|
||||
tpsc_file_ok = repo_path and (Path(repo_path) / "tpsc.yaml").exists()
|
||||
if prefetch and "tpsc_snap_counts" in prefetch:
|
||||
has_snap = (prefetch["tpsc_snap_counts"].get(slug, 0) > 0)
|
||||
snap_count = prefetch["tpsc_snap_counts"].get(slug, 0)
|
||||
else:
|
||||
tpsc_snaps = await _get(api_base, "/tpsc/snapshots/", {"repo_slug": slug}) or []
|
||||
has_snap = len(tpsc_snaps) > 0
|
||||
snap_count = len(tpsc_snaps)
|
||||
if not repo_path:
|
||||
_r("C9", "TPSC declared", "standard", "skip", "Local path unavailable")
|
||||
elif tpsc_file_ok and has_snap:
|
||||
_r("C9", "TPSC declared", "standard", "pass", f"{snap_count} snapshot(s)")
|
||||
elif tpsc_file_ok and not has_snap:
|
||||
_r("C9", "TPSC declared", "standard", "warn", "tpsc.yaml exists but not yet ingested — run make ingest-tpsc")
|
||||
elif not tpsc_file_ok:
|
||||
_r("C9", "TPSC declared", "standard", "fail", "tpsc.yaml missing at repo root")
|
||||
|
||||
# ── Tier 3: Full ─────────────────────────────────────────────────────────
|
||||
|
||||
# C10: active repo goal
|
||||
if prefetch and "active_goal_counts" in prefetch:
|
||||
active_goal_count = prefetch["active_goal_counts"].get(slug, 0)
|
||||
else:
|
||||
goals = await _get(api_base, "/repo-goals/", {"repo_slug": slug}) or []
|
||||
active_goal_count = sum(1 for g in goals if g.get("status") == "active")
|
||||
if active_goal_count > 0:
|
||||
_r("C10", "Active repo goal", "full", "pass", f"{active_goal_count} active goal(s)")
|
||||
else:
|
||||
_r("C10", "Active repo goal", "full", "fail", "No active repo goal — create one with create_repo_goal()")
|
||||
|
||||
# C11: Provided Capabilities declared in SCOPE.md
|
||||
if not repo_path:
|
||||
_r("C11", "Provided Capabilities declared", "full", "skip", "Local path unavailable")
|
||||
else:
|
||||
scope = Path(repo_path) / "SCOPE.md"
|
||||
if not scope.exists():
|
||||
_r("C11", "Provided Capabilities declared", "full", "skip", "SCOPE.md absent")
|
||||
else:
|
||||
text = scope.read_text()
|
||||
has_cap_block = "```capability" in text
|
||||
has_none_explicit = "## Provided Capabilities" in text and (
|
||||
"none" in text.lower().split("## provided capabilities")[-1][:200]
|
||||
or "no capabilities" in text.lower().split("## provided capabilities")[-1][:200]
|
||||
)
|
||||
if has_cap_block:
|
||||
_r("C11", "Provided Capabilities declared", "full", "pass", "capability block(s) found in SCOPE.md")
|
||||
elif has_none_explicit:
|
||||
_r("C11", "Provided Capabilities declared", "full", "pass", "Explicitly declared none in SCOPE.md")
|
||||
elif "## Provided Capabilities" in text:
|
||||
_r("C11", "Provided Capabilities declared", "full", "warn",
|
||||
"Section present but no capability block or explicit none — add blocks or state 'none'")
|
||||
else:
|
||||
_r("C11", "Provided Capabilities declared", "full", "fail",
|
||||
"No '## Provided Capabilities' section in SCOPE.md")
|
||||
|
||||
# C12: agents template applied (CLAUDE.md mentions kaizen)
|
||||
if not repo_path:
|
||||
_r("C12", "Agents template applied", "full", "skip", "Local path unavailable")
|
||||
else:
|
||||
claude_md = Path(repo_path) / "CLAUDE.md"
|
||||
if not claude_md.exists():
|
||||
_r("C12", "Agents template applied", "full", "skip", "CLAUDE.md absent")
|
||||
else:
|
||||
text = claude_md.read_text()
|
||||
if "get_kaizen_agent" in text or "kaizen" in text.lower():
|
||||
_r("C12", "Agents template applied", "full", "pass")
|
||||
else:
|
||||
_r("C12", "Agents template applied", "full", "fail",
|
||||
"CLAUDE.md has no kaizen agent reference")
|
||||
|
||||
# C13: consistency check clean (0 FAIL, 0 WARN — C-12 exempt)
|
||||
if skip_consistency:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
|
||||
else:
|
||||
try:
|
||||
fail, warn, _ = await _run_consistency(slug, api_base)
|
||||
if fail == 0 and warn == 0:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "pass")
|
||||
elif fail == 0 and warn > 0:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "warn",
|
||||
f"{warn} warn(s) — C-12 legacy tasks may be exempt")
|
||||
else:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "fail",
|
||||
f"{fail} fail(s), {warn} warn(s)")
|
||||
except Exception as e:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", f"Could not run: {e}")
|
||||
|
||||
# C14: host paths registered
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
if host_paths:
|
||||
_r("C14", "Host paths registered", "full", "pass",
|
||||
f"{len(host_paths)} host(s): {', '.join(host_paths.keys())}")
|
||||
else:
|
||||
_r("C14", "Host paths registered", "full", "fail",
|
||||
"host_paths empty — run update_repo_path() for each active machine")
|
||||
|
||||
# ── Compute tier ─────────────────────────────────────────────────────────
|
||||
by_id = {r.id: r for r in results}
|
||||
|
||||
def _tier_pass(ids: set[str]) -> bool:
|
||||
return all(by_id[i].status in ("pass", "warn") for i in ids if i in by_id)
|
||||
|
||||
core_pass = _tier_pass(CORE_IDS)
|
||||
standard_pass = core_pass and _tier_pass(STANDARD_IDS)
|
||||
full_pass = standard_pass and _tier_pass(FULL_IDS)
|
||||
|
||||
if full_pass:
|
||||
tier: Tier = "full"
|
||||
elif standard_pass:
|
||||
tier = "standard"
|
||||
elif core_pass:
|
||||
tier = "core"
|
||||
else:
|
||||
tier = "none"
|
||||
|
||||
return DoIReport(
|
||||
repo_slug=slug,
|
||||
tier=tier,
|
||||
core_pass=core_pass,
|
||||
standard_pass=standard_pass,
|
||||
full_pass=full_pass,
|
||||
criteria=results,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
from api.events.envelope import EventEnvelope
|
||||
from api.events.nats_publisher import (
|
||||
publish_event,
|
||||
publish_event_sync,
|
||||
shutdown_publisher,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EventEnvelope",
|
||||
"publish_event",
|
||||
"publish_event_sync",
|
||||
"shutdown_publisher",
|
||||
]
|
||||
@@ -1,55 +0,0 @@
|
||||
"""EventEnvelope — schema for state-hub lifecycle events published to NATS.
|
||||
|
||||
Mirrors the EventEnvelope contract defined in activity-core
|
||||
(`src/activity_core/models.py`). The state-hub publishes; activity-core
|
||||
consumes and routes to ActivityDefinitions.
|
||||
|
||||
Subject naming convention (see docs/nats-event-subjects.md):
|
||||
|
||||
org.statehub.{noun}.{verb}
|
||||
|
||||
Examples:
|
||||
org.statehub.repo.registered
|
||||
org.statehub.workstream.completed
|
||||
org.statehub.decision.resolved
|
||||
org.statehub.domain.goal.activated
|
||||
org.statehub.task.stale
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
PUBLISHER = "the-custodian/state-hub"
|
||||
|
||||
|
||||
class EventEnvelope(BaseModel):
|
||||
"""Standard envelope shared with activity-core. Do not break compatibility.
|
||||
|
||||
All inbound events on activity-core's side are normalised into this shape.
|
||||
"""
|
||||
|
||||
id: str = Field(description="UUID v4 — stable unique ID for deduplication.")
|
||||
type: str = Field(description="Dot-namespaced event type, e.g. 'org.statehub.repo.registered'.")
|
||||
version: str = Field(default="1.0", description="Schema version string.")
|
||||
timestamp: datetime = Field(description="When the event occurred (UTC).")
|
||||
publisher: str = Field(default=PUBLISHER, description="Originating service.")
|
||||
attributes: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Event-specific attributes; structure varies by event type.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new(cls, event_type: str, attributes: dict[str, Any] | None = None) -> "EventEnvelope":
|
||||
"""Construct an envelope with a fresh UUID and current UTC timestamp."""
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
type=event_type,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
publisher=PUBLISHER,
|
||||
attributes=attributes or {},
|
||||
)
|
||||
@@ -1,139 +0,0 @@
|
||||
"""NATS JetStream publisher for state-hub lifecycle events.
|
||||
|
||||
Design:
|
||||
- One process-wide publisher (`_Publisher` singleton).
|
||||
- Connects lazily on first publish; reuses the connection thereafter.
|
||||
- When ``NATS_URL`` is unset or empty, every publish is a logged no-op
|
||||
so the state hub remains usable in environments without NATS.
|
||||
- All publishes are fire-and-forget from the caller's perspective.
|
||||
Failures are logged but never raise — losing a lifecycle event must
|
||||
never break the API request that triggered it.
|
||||
|
||||
Stream + subject conventions live in ``docs/nats-event-subjects.md``.
|
||||
Envelope schema lives in :mod:`api.events.envelope`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from api.events.envelope import EventEnvelope
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover — import-only for typing
|
||||
from nats.aio.client import Client as NATSClient
|
||||
from nats.js.client import JetStreamContext
|
||||
|
||||
logger = logging.getLogger("state_hub.events.nats")
|
||||
|
||||
_STREAM_NAME = "ACTIVITY_EVENTS"
|
||||
_STREAM_SUBJECT_PATTERN = "org.>"
|
||||
|
||||
|
||||
def _nats_url() -> str | None:
|
||||
"""Resolve NATS_URL at call time so tests / configs can override it."""
|
||||
url = os.environ.get("NATS_URL", "").strip()
|
||||
return url or None
|
||||
|
||||
|
||||
class _Publisher:
|
||||
"""Singleton holding the live NATS connection + JetStream context."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._nc: "NATSClient | None" = None
|
||||
self._js: "JetStreamContext | None" = None
|
||||
self._connect_lock = asyncio.Lock()
|
||||
self._ensured_stream = False
|
||||
|
||||
async def _connect(self, url: str) -> None:
|
||||
# Imported inside the method so module import works without the dep.
|
||||
import nats
|
||||
import nats.js.api
|
||||
|
||||
async with self._connect_lock:
|
||||
if self._nc is not None and self._nc.is_connected:
|
||||
return
|
||||
self._nc = await nats.connect(url, connect_timeout=2)
|
||||
self._js = self._nc.jetstream()
|
||||
logger.info("nats: connected to %s", url)
|
||||
|
||||
if not self._ensured_stream:
|
||||
try:
|
||||
await self._js.find_stream_name_by_subject("org.statehub.repo.registered")
|
||||
self._ensured_stream = True
|
||||
except Exception:
|
||||
try:
|
||||
await self._js.add_stream(
|
||||
nats.js.api.StreamConfig(
|
||||
name=_STREAM_NAME,
|
||||
subjects=[_STREAM_SUBJECT_PATTERN],
|
||||
)
|
||||
)
|
||||
logger.info("nats: created JetStream stream %r", _STREAM_NAME)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.warning("nats: could not ensure stream %r: %s", _STREAM_NAME, exc)
|
||||
self._ensured_stream = True
|
||||
|
||||
async def publish(self, subject: str, envelope: EventEnvelope) -> None:
|
||||
url = _nats_url()
|
||||
if url is None:
|
||||
logger.debug("nats: NATS_URL unset — skipping publish %s (id=%s)", subject, envelope.id)
|
||||
return
|
||||
|
||||
try:
|
||||
if self._nc is None or not self._nc.is_connected:
|
||||
await self._connect(url)
|
||||
assert self._js is not None
|
||||
payload = envelope.model_dump_json().encode()
|
||||
ack = await self._js.publish(subject, payload)
|
||||
logger.info(
|
||||
"nats: published %s id=%s stream=%s seq=%s",
|
||||
subject,
|
||||
envelope.id,
|
||||
getattr(ack, "stream", "?"),
|
||||
getattr(ack, "seq", "?"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("nats: publish failed %s id=%s err=%s", subject, envelope.id, exc)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._nc is not None:
|
||||
try:
|
||||
await self._nc.drain()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
self._nc = None
|
||||
self._js = None
|
||||
self._ensured_stream = False
|
||||
|
||||
|
||||
_PUBLISHER = _Publisher()
|
||||
|
||||
|
||||
async def publish_event(subject: str, envelope: EventEnvelope) -> None:
|
||||
"""Publish ``envelope`` on ``subject``. Logs but never raises on failure.
|
||||
|
||||
No-op when ``NATS_URL`` is not configured.
|
||||
"""
|
||||
await _PUBLISHER.publish(subject, envelope)
|
||||
|
||||
|
||||
def publish_event_sync(subject: str, envelope: EventEnvelope) -> None:
|
||||
"""Fire-and-forget variant for sync callers (scripts, cron jobs).
|
||||
|
||||
Runs the publish in a short-lived event loop. Intended for one-shot CLI
|
||||
callers that aren't already inside an async context. Server code should
|
||||
prefer :func:`publish_event` with ``asyncio.create_task``.
|
||||
"""
|
||||
try:
|
||||
asyncio.run(publish_event(subject, envelope))
|
||||
except RuntimeError:
|
||||
# Already inside a running loop — schedule and forget.
|
||||
asyncio.get_event_loop().create_task(publish_event(subject, envelope))
|
||||
|
||||
|
||||
async def shutdown_publisher() -> None:
|
||||
"""Drain the NATS connection on app shutdown."""
|
||||
await _PUBLISHER.shutdown()
|
||||
@@ -1,85 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from task_flow_engine import AssertionDef, AssertionResult, FlowDef, FlowEngine, FlowResult
|
||||
|
||||
|
||||
FLOW_DIR = Path(__file__).resolve().parents[1] / "flows"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_flow(entity_type: str) -> FlowDef:
|
||||
path = FLOW_DIR / f"{entity_type}.yaml"
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return FlowDef.from_dict(data)
|
||||
|
||||
|
||||
def evaluate_transition(
|
||||
entity_type: str,
|
||||
current_workstation: str,
|
||||
target_workstation: str,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, list[AssertionResult], FlowResult]:
|
||||
flow = load_flow(entity_type)
|
||||
obj = {
|
||||
"status": current_workstation,
|
||||
"workstation": current_workstation,
|
||||
"previous_workstation": current_workstation,
|
||||
**(extra or {}),
|
||||
}
|
||||
engine = create_flow_engine()
|
||||
result = engine.evaluate(obj, flow)
|
||||
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
|
||||
return can_reach, failures, result
|
||||
|
||||
|
||||
def create_flow_engine() -> FlowEngine:
|
||||
return FlowEngine(
|
||||
custom_ops={
|
||||
"dependencies.any_incomplete": _dependencies_any_incomplete,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dependencies_any_incomplete(
|
||||
assertion: AssertionDef,
|
||||
obj: dict[str, Any],
|
||||
values: list[Any],
|
||||
) -> bool:
|
||||
return bool(values) and any(value != assertion.value for value in values)
|
||||
|
||||
|
||||
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
|
||||
return {
|
||||
"id": result.id,
|
||||
"passed": result.passed,
|
||||
"target": result.target,
|
||||
"op": result.op,
|
||||
"expected": result.expected,
|
||||
"actual": result.actual,
|
||||
"description": result.description,
|
||||
"reason": result.reason,
|
||||
}
|
||||
|
||||
|
||||
def flow_result_to_dict(result: FlowResult) -> dict[str, Any]:
|
||||
return {
|
||||
"current_workstation": result.current_workstation,
|
||||
"exit_blocked": result.exit_blocked,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in result.blocking_assertions
|
||||
],
|
||||
"reachable": result.reachable,
|
||||
"unreachable": [
|
||||
{
|
||||
"workstation": item.workstation,
|
||||
"blocking": assertion_result_to_dict(item.blocking),
|
||||
}
|
||||
for item in result.unreachable
|
||||
],
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import hashlib
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
from api.routers import flows
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
"""Add ETag + conditional-GET (304) support to all JSON GET responses."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if request.method != "GET":
|
||||
return response
|
||||
if "application/json" not in response.headers.get("content-type", ""):
|
||||
return response
|
||||
|
||||
body_parts = []
|
||||
async for chunk in response.body_iterator:
|
||||
body_parts.append(chunk)
|
||||
body = b"".join(body_parts)
|
||||
|
||||
etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return StarletteResponse(
|
||||
status_code=304,
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
|
||||
headers["ETag"] = etag
|
||||
if not any(k.lower() == "cache-control" for k in headers):
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
return StarletteResponse(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=headers,
|
||||
media_type=response.media_type,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await shutdown_publisher()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Custodian State Hub",
|
||||
description="Local-first state API for the Custodian agent system.",
|
||||
version="0.6.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_cors_env = os.getenv("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000")
|
||||
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(ETagMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||
allow_headers=["Content-Type", "If-None-Match"],
|
||||
expose_headers=["ETag"],
|
||||
)
|
||||
|
||||
app.include_router(domains.router)
|
||||
app.include_router(repos.router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstream_dependencies.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(extension_points.router)
|
||||
app.include_router(technical_debt.router)
|
||||
app.include_router(progress.router)
|
||||
app.include_router(domain_goals.router)
|
||||
app.include_router(repo_goals.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(token_events.router)
|
||||
app.include_router(interface_changes.router)
|
||||
app.include_router(flows.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return {"service": "state-hub", "docs": "/docs"}
|
||||
@@ -1,49 +0,0 @@
|
||||
from api.models.base import Base
|
||||
from api.models.domain import Domain
|
||||
from api.models.domain_goal import DomainGoal, DomainGoalStatus
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
||||
from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
from api.models.contribution import Contribution, ContributionType, ContributionStatus
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Domain",
|
||||
"DomainGoal", "DomainGoalStatus",
|
||||
"Topic", "TopicStatus",
|
||||
"ManagedRepo",
|
||||
"RepoGoal", "RepoGoalStatus",
|
||||
"Workstream",
|
||||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"ProgressEvent",
|
||||
"ExtensionPoint", "EPStatus",
|
||||
"TechnicalDebt", "TDStatus",
|
||||
"Contribution", "ContributionType", "ContributionStatus",
|
||||
"SBOMSnapshot",
|
||||
"SBOMEntry", "Ecosystem",
|
||||
"AgentMessage",
|
||||
"CapabilityCatalog",
|
||||
"CapabilityRequest",
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
]
|
||||
@@ -1,44 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class AgentMessage(Base):
|
||||
__tablename__ = "agent_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text("now()"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
thread_root: Mapped["AgentMessage | None"] = relationship(
|
||||
"AgentMessage",
|
||||
remote_side="AgentMessage.id",
|
||||
foreign_keys=[thread_id],
|
||||
lazy="select",
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
@@ -1,50 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityCatalog(Base, TimestampMixin):
|
||||
__tablename__ = "capability_catalog"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
keywords: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String), nullable=False, server_default="{}"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
||||
@property
|
||||
def repo_slug(self) -> str | None:
|
||||
return self.repo.slug if self.repo is not None else None
|
||||
@@ -1,101 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityRequest(Base, TimestampMixin):
|
||||
__tablename__ = "capability_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
priority: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="medium", server_default="medium"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="requested", server_default="requested"
|
||||
)
|
||||
|
||||
# Requester side
|
||||
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Fulfiller side (populated on accept / auto-route)
|
||||
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Links
|
||||
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Dispute fields (populated when status = routing_disputed)
|
||||
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
|
||||
)
|
||||
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
|
||||
)
|
||||
blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
|
||||
"CapabilityCatalog", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def requesting_domain_slug(self) -> str:
|
||||
return self.requesting_domain.slug if self.requesting_domain else ""
|
||||
|
||||
@property
|
||||
def fulfilling_domain_slug(self) -> str | None:
|
||||
return self.fulfilling_domain.slug if self.fulfilling_domain else None
|
||||
@@ -1,66 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class ContributionType(str, enum.Enum):
|
||||
br = "br"
|
||||
fr = "fr"
|
||||
ep = "ep"
|
||||
upr = "upr"
|
||||
|
||||
|
||||
class ContributionStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
submitted = "submitted"
|
||||
acknowledged = "acknowledged"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
merged = "merged"
|
||||
withdrawn = "withdrawn"
|
||||
|
||||
|
||||
class Contribution(Base, TimestampMixin):
|
||||
__tablename__ = "contributions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
type: Mapped[ContributionType] = mapped_column(
|
||||
Enum(ContributionType, name="contributiontype"), nullable=False
|
||||
)
|
||||
target_org: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
target_repo: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
slug: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status: Mapped[ContributionStatus] = mapped_column(
|
||||
Enum(ContributionStatus, name="contributionstatus"),
|
||||
nullable=False, default=ContributionStatus.draft,
|
||||
)
|
||||
body_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
related_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
@@ -1,63 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class DecisionType(str, enum.Enum):
|
||||
made = "made"
|
||||
pending = "pending"
|
||||
|
||||
|
||||
class DecisionStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
resolved = "resolved"
|
||||
escalated = "escalated"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workstream_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workstream",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_type: Mapped[DecisionType] = mapped_column(
|
||||
Enum(DecisionType), nullable=False, default=DecisionType.pending
|
||||
)
|
||||
status: Mapped[DecisionStatus] = mapped_column(
|
||||
Enum(DecisionStatus), nullable=False, default=DecisionStatus.open
|
||||
)
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
superseded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="decisions") # noqa: F821
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
@@ -1,27 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class DOICache(Base):
|
||||
__tablename__ = "doi_cache"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, unique=True, index=True,
|
||||
)
|
||||
tier: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
core_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
standard_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
full_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
criteria: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
# Pipe-joined string of timestamps/mtimes used to detect staleness
|
||||
fingerprint: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -1,29 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Domain(Base, TimestampMixin):
|
||||
__tablename__ = "domains"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
topics: Mapped[list["Topic"]] = relationship( # noqa: F821
|
||||
"Topic", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
goals: Mapped[list["DomainGoal"]] = relationship( # noqa: F821
|
||||
"DomainGoal", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
@@ -1,41 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class DomainGoalStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
archived = "archived"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class DomainGoal(Base, TimestampMixin):
|
||||
__tablename__ = "domain_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=DomainGoalStatus.active.value, server_default="active"
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="goals", lazy="selectin"
|
||||
)
|
||||
repo_goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
|
||||
"RepoGoal", back_populates="domain_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
@@ -1,57 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class EPStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
addressed = "addressed"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
|
||||
|
||||
class ExtensionPoint(Base, TimestampMixin):
|
||||
__tablename__ = "extension_points"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
ep_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. EP-CUST-001
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ep_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # api | schema | mcp | dashboard | architecture | integration | other
|
||||
status: Mapped[EPStatus] = mapped_column(
|
||||
Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open
|
||||
)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
@@ -1,53 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class InterfaceChange(Base, TimestampMixin):
|
||||
__tablename__ = "interface_changes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
interface_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False
|
||||
) # breaking | additive | deprecation | removal
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
affected_paths: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
affected_repo_slugs: Mapped[list] = mapped_column(
|
||||
JSONB, nullable=False, default=list, server_default="[]"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="draft", index=True
|
||||
) # draft | published | resolved
|
||||
planned_for: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian")
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", lazy="selectin"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_interface_changes_repo_status", "repo_id", "status"),
|
||||
)
|
||||
@@ -1,49 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class ManagedRepo(Base, TimestampMixin):
|
||||
__tablename__ = "managed_repos"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}")
|
||||
remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_sbom_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_state_synced_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="repos", lazy="selectin"
|
||||
)
|
||||
|
||||
goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
|
||||
"RepoGoal", back_populates="repo", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
@@ -1,43 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class ProgressEvent(Base):
|
||||
"""Append-only event log. No updated_at. No DELETE endpoint (constitution §5)."""
|
||||
|
||||
__tablename__ = "progress_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
session_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", back_populates="progress_events") # noqa: F821
|
||||
task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821
|
||||
decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821
|
||||
@@ -1,49 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class RepoGoalStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
completed = "completed"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class RepoGoal(Base, TimestampMixin):
|
||||
__tablename__ = "repo_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
domain_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domain_goals.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, server_default="100")
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=RepoGoalStatus.active.value, server_default="active"
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="goals", lazy="selectin"
|
||||
)
|
||||
domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821
|
||||
"DomainGoal", back_populates="repo_goals", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="repo_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def repo_slug(self) -> str:
|
||||
return self.repo.slug if self.repo is not None else ""
|
||||
@@ -1,59 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class Ecosystem(str, enum.Enum):
|
||||
python = "python"
|
||||
node = "node"
|
||||
rust = "rust"
|
||||
go = "go"
|
||||
java = "java"
|
||||
terraform = "terraform"
|
||||
ansible = "ansible"
|
||||
tool = "tool"
|
||||
other = "other"
|
||||
|
||||
|
||||
class SBOMEntry(Base):
|
||||
"""Snapshot-based SBOM entry — no updated_at; new ingest replaces old rows."""
|
||||
__tablename__ = "sbom_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
package_name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
package_version: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
ecosystem: Mapped[Ecosystem] = mapped_column(
|
||||
Enum(Ecosystem, name="ecosystem"), nullable=False
|
||||
)
|
||||
license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("sbom_snapshots.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821
|
||||
"SBOMSnapshot", lazy="selectin", back_populates="entries"
|
||||
)
|
||||
@@ -1,32 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class SBOMSnapshot(Base):
|
||||
"""Container entity for a point-in-time SBOM scan of a repository (GEMS Complex)."""
|
||||
__tablename__ = "sbom_snapshots"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
source: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
entries: Mapped[list["SBOMEntry"]] = relationship( # noqa: F821
|
||||
"SBOMEntry", lazy="select", back_populates="snapshot"
|
||||
)
|
||||
@@ -1,59 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
todo = "todo"
|
||||
in_progress = "in_progress"
|
||||
blocked = "blocked"
|
||||
done = "done"
|
||||
cancelled = "cancelled"
|
||||
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
low = "low"
|
||||
medium = "medium"
|
||||
high = "high"
|
||||
critical = "critical"
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
Enum(TaskStatus), nullable=False, default=TaskStatus.todo
|
||||
)
|
||||
priority: Mapped[TaskPriority] = mapped_column(
|
||||
Enum(TaskPriority), nullable=False, default=TaskPriority.medium
|
||||
)
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", back_populates="tasks") # noqa: F821
|
||||
subtasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", foreign_keys=[parent_task_id], lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="task", lazy="selectin"
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TDStatus(str, enum.Enum):
|
||||
# Legacy general statuses
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
resolved = "resolved"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
# Dashboard-improvement workflow steps
|
||||
submitted = "submitted"
|
||||
analyse = "analyse"
|
||||
plan = "plan"
|
||||
implement = "implement"
|
||||
test = "test"
|
||||
review = "review"
|
||||
finished = "finished"
|
||||
|
||||
|
||||
# Ordered workflow steps for dashboard-improvement suggestions
|
||||
SUGGESTION_STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"]
|
||||
|
||||
|
||||
class TDNote(Base):
|
||||
__tablename__ = "td_notes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid)
|
||||
td_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("technical_debt.id", ondelete="CASCADE"),
|
||||
nullable=False, index=True,
|
||||
)
|
||||
step: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
author: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[DateTime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
td: Mapped["TechnicalDebt"] = relationship("TechnicalDebt", back_populates="notes")
|
||||
|
||||
|
||||
class TechnicalDebt(Base, TimestampMixin):
|
||||
__tablename__ = "technical_debt"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
td_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. TD-CUST-001
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
debt_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # design | implementation | test | docs | dependencies | performance | security | other
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
status: Mapped[TDStatus] = mapped_column(
|
||||
Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
notes: Mapped[list["TDNote"]] = relationship(
|
||||
"TDNote", back_populates="td", lazy="selectin",
|
||||
order_by="TDNote.created_at",
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
@@ -1,40 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
class TokenEvent(Base):
|
||||
__tablename__ = "token_events"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("workstreams.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
session_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
model: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
tokens_in: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
tokens_out: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ref_type: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ref_id: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
|
||||
task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
workstream: Mapped["Workstream | None"] = relationship("Workstream", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
@@ -1,54 +0,0 @@
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class TopicStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
paused = "paused"
|
||||
archived = "archived"
|
||||
|
||||
|
||||
class Topic(Base, TimestampMixin):
|
||||
__tablename__ = "topics"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[TopicStatus] = mapped_column(
|
||||
Enum(TopicStatus), nullable=False, default=TopicStatus.active
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="topics", lazy="selectin"
|
||||
)
|
||||
workstreams: Mapped[list["Workstream"]] = relationship( # noqa: F821
|
||||
"Workstream", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="topic", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str | None:
|
||||
"""Returns the domain slug string for serialization."""
|
||||
if self.domain is not None:
|
||||
return self.domain.slug
|
||||
return None
|
||||
@@ -1,64 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class TPSCCatalog(Base):
|
||||
__tablename__ = "tpsc_catalog"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Pricing: free | paid | freemium | usage_based | unknown
|
||||
pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown")
|
||||
# GDPR maturity (CNIL/IAPP CMMI-aligned):
|
||||
# unknown | non_compliant | initial | developing | defined | managed | certified
|
||||
gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True)
|
||||
gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
tos_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# status: active | deprecated
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry")
|
||||
|
||||
|
||||
class TPSCSnapshot(Base):
|
||||
__tablename__ = "tpsc_snapshots"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
source_file: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0")
|
||||
|
||||
entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class TPSCEntry(Base):
|
||||
__tablename__ = "tpsc_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
snapshot_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
catalog_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True)
|
||||
service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
purpose: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# auth_type: api_key | oauth | cli | none | unknown
|
||||
auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries")
|
||||
catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries")
|
||||
@@ -1,55 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Workstream(Base, TimestampMixin):
|
||||
__tablename__ = "workstreams"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
owner: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
repo_goal_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("repo_goals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", back_populates="workstreams") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workstreams", lazy="selectin") # noqa: F821
|
||||
tasks: Mapped[list["Task"]] = relationship( # noqa: F821
|
||||
"Task", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
decisions: Mapped[list["Decision"]] = relationship( # noqa: F821
|
||||
"Decision", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="workstream", lazy="selectin"
|
||||
)
|
||||
@@ -1,75 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class WorkstreamDependency(Base, TimestampMixin):
|
||||
"""Directed dependency edge: `from_workstream` depends on a workstream or task.
|
||||
|
||||
Semantics: the target must reach a satisfactory state before `from_workstream`
|
||||
can fully proceed. Hard deletes are intentional —
|
||||
removing an edge removes a constraint, not information.
|
||||
"""
|
||||
|
||||
__tablename__ = "workstream_dependencies"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(to_workstream_id IS NOT NULL AND to_task_id IS NULL) "
|
||||
"OR (to_workstream_id IS NULL AND to_task_id IS NOT NULL)",
|
||||
name="ck_ws_dep_exactly_one_target",
|
||||
),
|
||||
Index(
|
||||
"uq_ws_dep_workstream_target",
|
||||
"from_workstream_id",
|
||||
"to_workstream_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_workstream_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"uq_ws_dep_task_target",
|
||||
"from_workstream_id",
|
||||
"to_task_id",
|
||||
"relationship_type",
|
||||
unique=True,
|
||||
postgresql_where=text("to_task_id IS NOT NULL"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_workstream_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
to_workstream_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workstreams.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
to_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
relationship_type: Mapped[str] = mapped_column(
|
||||
String(40), nullable=False, default="blocks", server_default="blocks", index=True
|
||||
)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
from_workstream: Mapped["Workstream"] = relationship( # noqa: F821
|
||||
"Workstream", foreign_keys=[from_workstream_id]
|
||||
)
|
||||
to_workstream: Mapped["Workstream | None"] = relationship( # noqa: F821
|
||||
"Workstream", foreign_keys=[to_workstream_id]
|
||||
)
|
||||
to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821
|
||||
@@ -1,607 +0,0 @@
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.domain import Domain
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.schemas.capability_request import (
|
||||
CatalogCreate,
|
||||
CatalogPatch,
|
||||
CatalogRead,
|
||||
CapabilityRequestAccept,
|
||||
CapabilityRequestCreate,
|
||||
CapabilityRequestDispute,
|
||||
CapabilityRequestPatch,
|
||||
CapabilityRequestRead,
|
||||
CapabilityRequestReroute,
|
||||
CapabilityRequestStatusPatch,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["capability-requests"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_catalog_entry(
|
||||
body: CatalogCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
domain = await _resolve_domain(body.domain, session)
|
||||
|
||||
repo_id = None
|
||||
if body.repo_slug:
|
||||
repo = await _resolve_repo(body.repo_slug, session)
|
||||
repo_id = repo.id
|
||||
|
||||
entry = CapabilityCatalog(
|
||||
domain_id=domain.id,
|
||||
repo_id=repo_id,
|
||||
capability_type=body.capability_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
keywords=body.keywords,
|
||||
)
|
||||
session.add(entry)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Catalog entry '{body.title}' for type '{body.capability_type}' already exists in domain '{body.domain}'",
|
||||
)
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
|
||||
async def patch_catalog_entry(
|
||||
entry_id: uuid.UUID,
|
||||
body: CatalogPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityCatalog:
|
||||
entry = await session.get(CapabilityCatalog, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
|
||||
|
||||
if body.repo_slug is not None:
|
||||
repo = await _resolve_repo(body.repo_slug, session)
|
||||
entry.repo_id = repo.id
|
||||
if body.description is not None:
|
||||
entry.description = body.description
|
||||
if body.keywords is not None:
|
||||
entry.keywords = body.keywords
|
||||
if body.status is not None:
|
||||
entry.status = body.status
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/capability-catalog/", response_model=list[CatalogRead])
|
||||
async def list_catalog(
|
||||
domain: str | None = Query(None),
|
||||
capability_type: str | None = Query(None),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityCatalog]:
|
||||
q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(CapabilityCatalog.domain_id == d.id)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityCatalog.capability_type == capability_type)
|
||||
if status_filter and status_filter != "all":
|
||||
q = q.where(CapabilityCatalog.status == status_filter)
|
||||
elif not status_filter:
|
||||
q = q.where(CapabilityCatalog.status == "active")
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_request(
|
||||
body: CapabilityRequestCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req_domain = await _resolve_domain(body.requesting_domain, session)
|
||||
|
||||
# Route to provider
|
||||
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
|
||||
session, body.capability_type, body.title, body.description or ""
|
||||
)
|
||||
|
||||
req = CapabilityRequest(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
capability_type=body.capability_type,
|
||||
priority=body.priority,
|
||||
requesting_domain_id=req_domain.id,
|
||||
requesting_agent=body.requesting_agent,
|
||||
requesting_workstream_id=body.requesting_workstream_id,
|
||||
blocking_task_id=body.blocking_task_id,
|
||||
fulfilling_domain_id=fulfilling_domain_id,
|
||||
catalog_entry_id=catalog_entry_id,
|
||||
routing_note=routing_note,
|
||||
)
|
||||
session.add(req)
|
||||
await session.flush() # get req.id before creating notification
|
||||
|
||||
# Auto-notify
|
||||
if fulfilling_domain_id:
|
||||
ful_domain = await session.get(Domain, fulfilling_domain_id)
|
||||
to_agent = ful_domain.slug if ful_domain else "broadcast"
|
||||
else:
|
||||
to_agent = "broadcast"
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=to_agent,
|
||||
subject=f"[capability-request] {body.title}",
|
||||
body=(
|
||||
f"New capability request from **{body.requesting_agent}** "
|
||||
f"({body.requesting_domain}):\n\n"
|
||||
f"**Type:** {body.capability_type}\n"
|
||||
f"**Priority:** {body.priority}\n\n"
|
||||
f"{body.description or '(no description)'}"
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.get("/capability-requests/", response_model=list[CapabilityRequestRead])
|
||||
async def list_requests(
|
||||
domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"),
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
capability_type: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[CapabilityRequest]:
|
||||
q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc())
|
||||
if domain:
|
||||
d = await _resolve_domain(domain, session)
|
||||
q = q.where(
|
||||
(CapabilityRequest.requesting_domain_id == d.id)
|
||||
| (CapabilityRequest.fulfilling_domain_id == d.id)
|
||||
)
|
||||
if status_filter:
|
||||
q = q.where(CapabilityRequest.status == status_filter)
|
||||
if capability_type:
|
||||
q = q.where(CapabilityRequest.capability_type == capability_type)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def get_request(
|
||||
request_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
return await _get_request_or_404(request_id, session)
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
|
||||
async def accept_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestAccept,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, "accepted")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "accepted"
|
||||
req.fulfilling_agent = body.fulfilling_agent
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
req.accepted_at = now
|
||||
|
||||
# If no fulfilling domain was set by routing, infer from the accepting agent's context
|
||||
# (The agent can also PATCH it later if needed)
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.fulfilling_agent,
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-accepted] {req.title}",
|
||||
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
|
||||
async def patch_request_status(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, body.status)
|
||||
|
||||
req.status = body.status
|
||||
if body.note:
|
||||
req.resolution_note = body.note
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Status-specific side effects
|
||||
if body.status == "completed":
|
||||
req.completed_at = now
|
||||
# Auto-unblock the blocking task
|
||||
if req.blocking_task_id:
|
||||
task = await session.get(Task, req.blocking_task_id)
|
||||
if task and task.status == "blocked":
|
||||
task.status = "todo"
|
||||
task.blocking_reason = None
|
||||
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent="system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-completed] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been completed.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "ready_for_review":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-ready] {req.title} -- please review",
|
||||
body=(
|
||||
f"Capability **{req.title}** is ready for your review and optimization.\n\n"
|
||||
f"{body.note or ''}"
|
||||
),
|
||||
)
|
||||
elif body.status == "rejected":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-rejected] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been rejected.\n\n"
|
||||
f"**Reason:** {body.note or '(no reason given)'}"
|
||||
),
|
||||
)
|
||||
elif body.status == "in_progress":
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=req.fulfilling_agent or "system",
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[capability-in-progress] {req.title}",
|
||||
body=f"Work on capability **{req.title}** is now in progress.",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
|
||||
async def patch_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
|
||||
priority, blocking_task_id, fulfilling_workstream_id.
|
||||
Only fields present in the request body (non-None) are updated.
|
||||
"""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
|
||||
corrections: list[str] = []
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
old_entry_id = req.catalog_entry_id
|
||||
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||
req.catalog_entry_id = entry.id
|
||||
# Re-derive fulfilling domain from catalog entry
|
||||
old_domain_id = req.fulfilling_domain_id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
corrections.append(
|
||||
f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); "
|
||||
f"fulfilling_domain re-derived → {entry.domain_id}"
|
||||
)
|
||||
|
||||
if body.priority is not None:
|
||||
req.priority = body.priority
|
||||
corrections.append(f"priority → {body.priority}")
|
||||
|
||||
if body.blocking_task_id is not None:
|
||||
req.blocking_task_id = body.blocking_task_id
|
||||
corrections.append(f"blocking_task_id → {body.blocking_task_id}")
|
||||
|
||||
if body.fulfilling_workstream_id is not None:
|
||||
req.fulfilling_workstream_id = body.fulfilling_workstream_id
|
||||
corrections.append(f"fulfilling_workstream_id → {body.fulfilling_workstream_id}")
|
||||
|
||||
if not corrections:
|
||||
return req # no-op
|
||||
|
||||
correction_note = "hub correction: " + "; ".join(corrections)
|
||||
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispute endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
|
||||
async def dispute_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestDispute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
_check_transition(req.status, "routing_disputed")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
req.status = "routing_disputed"
|
||||
req.dispute_reason = body.reason
|
||||
req.disputed_by = body.disputed_by
|
||||
req.dispute_suggested_domain = body.suggested_domain
|
||||
req.disputed_at = now
|
||||
|
||||
dispute_entry = (
|
||||
f"disputed by {body.disputed_by}: {body.reason}"
|
||||
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
|
||||
)
|
||||
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
|
||||
|
||||
# Notify custodian
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
to_agent="custodian",
|
||||
subject=f"[routing-disputed] {req.title}",
|
||||
body=(
|
||||
f"**{body.disputed_by}** has disputed the routing of capability request "
|
||||
f"**{req.title}**.\n\n"
|
||||
f"**Reason:** {body.reason}\n"
|
||||
+ (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "")
|
||||
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
|
||||
),
|
||||
)
|
||||
# Notify current fulfilling domain
|
||||
if req.fulfilling_domain_slug:
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.disputed_by,
|
||||
to_agent=req.fulfilling_domain_slug,
|
||||
subject=f"[routing-disputed] {req.title}",
|
||||
body=(
|
||||
f"The capability request **{req.title}** routed to your domain has been disputed "
|
||||
f"by **{body.disputed_by}**.\n\n"
|
||||
f"**Reason:** {body.reason}\n"
|
||||
+ (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "")
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
|
||||
async def reroute_request(
|
||||
request_id: uuid.UUID,
|
||||
body: CapabilityRequestReroute,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CapabilityRequest:
|
||||
"""Re-route a disputed request to a new domain. Resets to requested."""
|
||||
req = await _get_request_or_404(request_id, session)
|
||||
if req.status != "routing_disputed":
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
|
||||
)
|
||||
if body.catalog_entry_id is None and body.domain is None:
|
||||
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
|
||||
|
||||
if body.catalog_entry_id is not None:
|
||||
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
|
||||
req.catalog_entry_id = entry.id
|
||||
req.fulfilling_domain_id = entry.domain_id
|
||||
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
|
||||
else:
|
||||
new_domain = await _resolve_domain(body.domain, session)
|
||||
req.fulfilling_domain_id = new_domain.id
|
||||
new_domain_slug = new_domain.slug
|
||||
|
||||
old_domain = req.dispute_suggested_domain or "unknown"
|
||||
|
||||
# Clear dispute fields
|
||||
req.dispute_reason = None
|
||||
req.disputed_by = None
|
||||
req.dispute_suggested_domain = None
|
||||
req.disputed_at = None
|
||||
req.status = "requested"
|
||||
|
||||
reroute_entry = f"re-routed by {body.rerouted_by} → {new_domain_slug}: {body.note}"
|
||||
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
|
||||
|
||||
# Notify requester
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
to_agent=req.requesting_agent,
|
||||
subject=f"[re-routed] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n"
|
||||
f"**Note:** {body.note}"
|
||||
),
|
||||
)
|
||||
# Notify new fulfilling domain
|
||||
_add_notification(
|
||||
session,
|
||||
from_agent=body.rerouted_by,
|
||||
to_agent=new_domain_slug,
|
||||
subject=f"[capability-request] {req.title}",
|
||||
body=(
|
||||
f"Capability request **{req.title}** has been re-routed to your domain.\n\n"
|
||||
f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n"
|
||||
f"**Type:** {req.capability_type}\n"
|
||||
f"**Priority:** {req.priority}\n\n"
|
||||
f"{req.description or '(no description)'}"
|
||||
),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(req)
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing algorithm
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _route_capability(
|
||||
session: AsyncSession, capability_type: str, title: str, description: str
|
||||
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
|
||||
"""Find the best-matching catalog entry for a capability request.
|
||||
|
||||
Returns (domain_id, catalog_entry_id, routing_note).
|
||||
Uses word-boundary matching on (title + description) combined to avoid
|
||||
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
|
||||
'ha' inside 'has').
|
||||
"""
|
||||
q = select(CapabilityCatalog).where(
|
||||
CapabilityCatalog.capability_type == capability_type,
|
||||
CapabilityCatalog.status == "active",
|
||||
)
|
||||
entries = list((await session.execute(q)).scalars().all())
|
||||
|
||||
if not entries:
|
||||
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
|
||||
|
||||
if len(entries) == 1:
|
||||
e = entries[0]
|
||||
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
|
||||
|
||||
# Score by word-boundary keyword overlap against title + description combined
|
||||
combined = f"{title} {description or ''}".lower()
|
||||
scored: list[tuple[int, CapabilityCatalog]] = []
|
||||
for entry in entries:
|
||||
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
|
||||
score = sum(
|
||||
1 for kw in keywords
|
||||
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
|
||||
)
|
||||
scored.append((score, entry))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
|
||||
best_score, best = scored[0]
|
||||
if best_score == 0:
|
||||
return None, None, (
|
||||
f"no keyword overlap for type '{capability_type}' among "
|
||||
f"{len(entries)} entries — broadcast"
|
||||
)
|
||||
if len(scored) >= 2 and scored[1][0] == best_score:
|
||||
return None, None, (
|
||||
f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' "
|
||||
f"both scored {best_score} — broadcast"
|
||||
)
|
||||
|
||||
return best.domain_id, best.id, (
|
||||
f"matched '{best.title}' (score={best_score}, "
|
||||
f"keywords matched from: {title!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _add_notification(
|
||||
session: AsyncSession,
|
||||
from_agent: str,
|
||||
to_agent: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None:
|
||||
"""Create an AgentMessage notification in the current session (no commit)."""
|
||||
msg = AgentMessage(
|
||||
from_agent=from_agent,
|
||||
to_agent=to_agent,
|
||||
subject=subject,
|
||||
body=body,
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
|
||||
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
|
||||
result = await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
domain = result.scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
|
||||
|
||||
async def _resolve_repo(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
|
||||
req = await session.get(CapabilityRequest, request_id)
|
||||
if req is None:
|
||||
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
|
||||
return req
|
||||
|
||||
|
||||
def _check_transition(current: str, target: str) -> None:
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"capability_request",
|
||||
current,
|
||||
target,
|
||||
)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": f"Cannot transition from '{current}' to '{target}'.",
|
||||
"current_workstation": current,
|
||||
"target_workstation": target,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(flow_result),
|
||||
},
|
||||
)
|
||||
@@ -1,137 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict
|
||||
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
||||
from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch
|
||||
|
||||
router = APIRouter(prefix="/contributions", tags=["contributions"])
|
||||
|
||||
@router.get("/", response_model=list[ContributionRead])
|
||||
async def list_contributions(
|
||||
type: ContributionType | None = Query(None),
|
||||
status: ContributionStatus | None = Query(None),
|
||||
target_repo: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Contribution]:
|
||||
q = select(Contribution).order_by(Contribution.created_at.desc())
|
||||
if type is not None:
|
||||
q = q.where(Contribution.type == type)
|
||||
if status is not None:
|
||||
q = q.where(Contribution.status == status)
|
||||
if target_repo:
|
||||
q = q.where(Contribution.target_repo == target_repo)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_contribution(
|
||||
body: ContributionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
contrib = Contribution(
|
||||
type=body.type,
|
||||
target_org=body.target_org,
|
||||
target_repo=body.target_repo,
|
||||
slug=body.slug,
|
||||
title=body.title,
|
||||
body_path=body.body_path,
|
||||
related_topic_id=body.related_topic_id,
|
||||
related_workstream_id=body.related_workstream_id,
|
||||
notes=body.notes,
|
||||
status=ContributionStatus.draft,
|
||||
)
|
||||
session.add(contrib)
|
||||
await session.commit()
|
||||
await session.refresh(contrib)
|
||||
return contrib
|
||||
|
||||
|
||||
@router.get("/{contribution_id}", response_model=ContributionRead)
|
||||
async def get_contribution(
|
||||
contribution_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
return await _get_or_404(contribution_id, session)
|
||||
|
||||
|
||||
@router.patch("/{contribution_id}/status", response_model=ContributionRead)
|
||||
async def patch_contribution_status(
|
||||
contribution_id: uuid.UUID,
|
||||
body: ContributionStatusPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Contribution:
|
||||
contrib = await _get_or_404(contribution_id, session)
|
||||
current = _status_value(contrib.status)
|
||||
target = _status_value(body.status)
|
||||
can_reach, failures, flow_result = evaluate_transition(
|
||||
"contribution",
|
||||
current,
|
||||
target,
|
||||
)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"message": f"Cannot transition from '{current}' to '{target}'.",
|
||||
"current_workstation": current,
|
||||
"target_workstation": target,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(flow_result),
|
||||
},
|
||||
)
|
||||
contrib.status = body.status
|
||||
if body.notes:
|
||||
contrib.notes = body.notes
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if body.status == ContributionStatus.submitted:
|
||||
contrib.submitted_at = now
|
||||
elif body.status in (
|
||||
ContributionStatus.accepted, ContributionStatus.rejected,
|
||||
ContributionStatus.merged, ContributionStatus.withdrawn,
|
||||
):
|
||||
contrib.resolved_at = now
|
||||
await session.commit()
|
||||
await session.refresh(contrib)
|
||||
return contrib
|
||||
|
||||
|
||||
@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def withdraw_contribution(
|
||||
contribution_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Soft-delete: sets status to 'withdrawn'."""
|
||||
contrib = await _get_or_404(contribution_id, session)
|
||||
if contrib.status == ContributionStatus.withdrawn:
|
||||
return # idempotent
|
||||
if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot withdraw a contribution with status '{contrib.status}'.",
|
||||
)
|
||||
contrib.status = ContributionStatus.withdrawn
|
||||
contrib.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution:
|
||||
result = await session.execute(
|
||||
select(Contribution).where(Contribution.id == contribution_id)
|
||||
)
|
||||
contrib = result.scalar_one_or_none()
|
||||
if contrib is None:
|
||||
raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found")
|
||||
return contrib
|
||||
|
||||
|
||||
def _status_value(status: ContributionStatus | str) -> str:
|
||||
return status.value if isinstance(status, ContributionStatus) else str(status)
|
||||
@@ -1,217 +0,0 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate
|
||||
|
||||
router = APIRouter(prefix="/decisions", tags=["decisions"])
|
||||
|
||||
_FINANCIAL_LEGAL_KEYWORDS = (
|
||||
"financ", "legal", "payment", "purchas", "contract", "commit",
|
||||
"obligation", "external representation",
|
||||
)
|
||||
|
||||
|
||||
def _needs_escalation(body: DecisionCreate) -> str | None:
|
||||
if body.decision_type != DecisionType.pending:
|
||||
return None
|
||||
text = f"{body.title} {body.description or ''}".lower()
|
||||
for kw in _FINANCIAL_LEGAL_KEYWORDS:
|
||||
if kw in text:
|
||||
return (
|
||||
"Auto-escalated per constitution §4: this pending decision touches "
|
||||
"financial or legal territory and requires explicit human approval before action."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DecisionRead])
|
||||
async def list_decisions(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: DecisionStatus | None = None,
|
||||
decision_type: DecisionType | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Decision]:
|
||||
q = select(Decision)
|
||||
if topic_id:
|
||||
q = q.where(Decision.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(Decision.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Decision.status == status)
|
||||
if decision_type:
|
||||
q = q.where(Decision.decision_type == decision_type)
|
||||
q = q.order_by(Decision.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_decision(
|
||||
body: DecisionCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
data = body.model_dump()
|
||||
note = _needs_escalation(body)
|
||||
if note:
|
||||
data["escalation_note"] = note
|
||||
data["status"] = DecisionStatus.escalated
|
||||
decision = Decision(**data)
|
||||
session.add(decision)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.get("/{decision_id}", response_model=DecisionRead)
|
||||
async def get_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
return decision
|
||||
|
||||
|
||||
@router.patch("/{decision_id}", response_model=DecisionRead)
|
||||
async def update_decision(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(decision, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.delete("/{decision_id}", response_model=DecisionRead)
|
||||
async def supersede_decision(
|
||||
decision_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
decision.status = DecisionStatus.superseded
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
return decision
|
||||
|
||||
|
||||
@router.post("/{decision_id}/resolve", response_model=DecisionRead)
|
||||
async def resolve_decision_action(
|
||||
decision_id: uuid.UUID,
|
||||
body: DecisionResolve,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Decision:
|
||||
decision = await session.get(Decision, decision_id)
|
||||
if decision is None:
|
||||
raise HTTPException(status_code=404, detail="Decision not found")
|
||||
if decision.status == DecisionStatus.resolved:
|
||||
raise HTTPException(status_code=409, detail="Decision already resolved")
|
||||
|
||||
decision.status = DecisionStatus.resolved
|
||||
decision.decision_type = DecisionType.made
|
||||
decision.rationale = body.rationale
|
||||
decision.decided_by = body.decided_by
|
||||
decision.decided_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(decision)
|
||||
|
||||
event = ProgressEvent(
|
||||
topic_id=decision.topic_id,
|
||||
workstream_id=decision.workstream_id,
|
||||
decision_id=decision.id,
|
||||
event_type="decision_resolved",
|
||||
summary=f"Decision resolved: {decision.title}",
|
||||
author=body.decided_by,
|
||||
detail={"rationale": body.rationale},
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
if body.write_log:
|
||||
await _write_project_log(decision, body.rationale, body.decided_by, session)
|
||||
|
||||
subject = "org.statehub.decision.resolved"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"decision_id": str(decision.id),
|
||||
"title": decision.title,
|
||||
"topic_id": str(decision.topic_id) if decision.topic_id else None,
|
||||
"workstream_id": str(decision.workstream_id) if decision.workstream_id else None,
|
||||
"decided_by": body.decided_by,
|
||||
"rationale_snippet": (body.rationale or "")[:240],
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return decision
|
||||
|
||||
|
||||
async def _write_project_log(
|
||||
decision: Decision, rationale: str, decided_by: str, session: AsyncSession
|
||||
) -> None:
|
||||
"""Append a DECISIONS.md entry to the registered project directory for this topic."""
|
||||
if decision.topic_id is None:
|
||||
return
|
||||
|
||||
rows = await session.execute(
|
||||
select(ProgressEvent)
|
||||
.where(ProgressEvent.topic_id == decision.topic_id)
|
||||
.where(ProgressEvent.event_type == "milestone")
|
||||
.order_by(ProgressEvent.created_at.desc())
|
||||
)
|
||||
project_path: str | None = None
|
||||
for pe in rows.scalars():
|
||||
if pe.summary and "Project registered with State Hub:" in pe.summary:
|
||||
project_path = (pe.detail or {}).get("project_path")
|
||||
if project_path:
|
||||
break
|
||||
|
||||
if not project_path:
|
||||
logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id)
|
||||
return
|
||||
|
||||
p = Path(project_path)
|
||||
if not p.is_dir():
|
||||
logger.warning("write_log requested but project_path does not exist: %s", project_path)
|
||||
return
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
entry = (
|
||||
f"\n## {decision.title}\n\n"
|
||||
f"**Date:** {now.strftime('%Y-%m-%d')} \n"
|
||||
f"**Decided by:** {decided_by} \n\n"
|
||||
f"{rationale}\n\n"
|
||||
f"---\n"
|
||||
)
|
||||
log_file = p / "DECISIONS.md"
|
||||
if log_file.exists():
|
||||
log_file.write_text(log_file.read_text() + entry)
|
||||
else:
|
||||
log_file.write_text(
|
||||
"# Decision Log\n\n"
|
||||
"_Auto-generated by the Custodian State Hub._\n"
|
||||
+ entry
|
||||
)
|
||||
@@ -1,135 +0,0 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.domain import Domain
|
||||
from api.models.domain_goal import DomainGoal, DomainGoalStatus # noqa: F401 (DomainGoalStatus used in activate)
|
||||
from api.schemas.domain_goal import DomainGoalCreate, DomainGoalRead, DomainGoalUpdate
|
||||
|
||||
router = APIRouter(prefix="/domain-goals", tags=["domain-goals"])
|
||||
|
||||
|
||||
async def _resolve_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
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DomainGoalRead])
|
||||
async def list_domain_goals(
|
||||
domain_slug: str | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[DomainGoal]:
|
||||
q = select(DomainGoal)
|
||||
if domain_slug:
|
||||
domain = await _resolve_domain(domain_slug, session)
|
||||
q = q.where(DomainGoal.domain_id == domain.id)
|
||||
if status:
|
||||
q = q.where(DomainGoal.status == status)
|
||||
q = q.order_by(DomainGoal.created_at.desc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DomainGoalRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_domain_goal(
|
||||
body: DomainGoalCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
if body.status == DomainGoalStatus.active:
|
||||
# Archive any existing active goal for this domain
|
||||
existing = await session.execute(
|
||||
select(DomainGoal).where(
|
||||
DomainGoal.domain_id == body.domain_id,
|
||||
DomainGoal.status == DomainGoalStatus.active,
|
||||
)
|
||||
)
|
||||
for old in existing.scalars().all():
|
||||
old.status = DomainGoalStatus.superseded
|
||||
|
||||
goal = DomainGoal(**body.model_dump())
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.get("/{goal_id}", response_model=DomainGoalRead)
|
||||
async def get_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
return goal
|
||||
|
||||
|
||||
@router.patch("/{goal_id}", response_model=DomainGoalRead)
|
||||
async def update_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
body: DomainGoalUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(goal, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.post("/{goal_id}/activate", response_model=DomainGoalRead)
|
||||
async def activate_domain_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainGoal:
|
||||
"""Set this goal as the active domain goal, superseding any currently active one."""
|
||||
goal = await session.get(DomainGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Domain goal not found")
|
||||
was_active = goal.status == DomainGoalStatus.active
|
||||
|
||||
# Supersede any other active goal for this domain
|
||||
existing = await session.execute(
|
||||
select(DomainGoal).where(
|
||||
DomainGoal.domain_id == goal.domain_id,
|
||||
DomainGoal.status == DomainGoalStatus.active,
|
||||
DomainGoal.id != goal_id,
|
||||
)
|
||||
)
|
||||
superseded_ids: list[str] = []
|
||||
for old in existing.scalars().all():
|
||||
old.status = DomainGoalStatus.superseded
|
||||
superseded_ids.append(str(old.id))
|
||||
|
||||
goal.status = DomainGoalStatus.active
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
|
||||
if not was_active:
|
||||
domain = await session.get(Domain, goal.domain_id)
|
||||
subject = "org.statehub.domain.goal.activated"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"goal_id": str(goal.id),
|
||||
"domain_id": str(goal.domain_id),
|
||||
"domain_slug": domain.slug if domain else None,
|
||||
"title": goal.title,
|
||||
"superseded_goal_ids": superseded_ids,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return goal
|
||||
@@ -1,172 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import noload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.extension_point import ExtensionPoint
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.domain import DomainCreate, DomainDetail, DomainRead, DomainRename, DomainUpdate, RepoStub
|
||||
|
||||
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[DomainRead])
|
||||
async def list_domains(
|
||||
response: Response,
|
||||
status: str | None = Query(None, description="active | archived | all"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Domain]:
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = select(Domain).options(
|
||||
noload(Domain.topics),
|
||||
noload(Domain.repos),
|
||||
noload(Domain.goals),
|
||||
).order_by(Domain.name)
|
||||
if status and status != "all":
|
||||
q = q.where(Domain.status == status)
|
||||
elif status is None:
|
||||
q = q.where(Domain.status == "active")
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=DomainRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_domain(
|
||||
body: DomainCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Domain:
|
||||
existing = await session.execute(select(Domain).where(Domain.slug == body.slug))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Domain slug '{body.slug}' already exists")
|
||||
domain = Domain(slug=body.slug, name=body.name, description=body.description)
|
||||
session.add(domain)
|
||||
await session.commit()
|
||||
await session.refresh(domain)
|
||||
return domain
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=DomainDetail)
|
||||
async def get_domain(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DomainDetail:
|
||||
domain = await _get_domain_by_slug(slug, session)
|
||||
|
||||
# Count topics
|
||||
topic_count_row = await session.execute(
|
||||
select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id)
|
||||
)
|
||||
topic_count = topic_count_row.scalar_one()
|
||||
|
||||
# Count active workstreams (via topics)
|
||||
topic_ids_row = await session.execute(
|
||||
select(Topic.id).where(Topic.domain_id == domain.id)
|
||||
)
|
||||
topic_ids = [r[0] for r in topic_ids_row.all()]
|
||||
|
||||
ws_count = 0
|
||||
if topic_ids:
|
||||
ws_count_row = await session.execute(
|
||||
select(func.count()).select_from(Workstream)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.where(Workstream.status == "active")
|
||||
)
|
||||
ws_count = ws_count_row.scalar_one()
|
||||
|
||||
# Count EPs and TDs
|
||||
ep_count_row = await session.execute(
|
||||
select(func.count()).select_from(ExtensionPoint)
|
||||
.where(ExtensionPoint.domain_id == domain.id)
|
||||
)
|
||||
ep_count = ep_count_row.scalar_one()
|
||||
|
||||
td_count_row = await session.execute(
|
||||
select(func.count()).select_from(TechnicalDebt)
|
||||
.where(TechnicalDebt.domain_id == domain.id)
|
||||
)
|
||||
td_count = td_count_row.scalar_one()
|
||||
|
||||
# Repos
|
||||
repos_row = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.domain_id == domain.id)
|
||||
.where(ManagedRepo.status == "active")
|
||||
.order_by(ManagedRepo.name)
|
||||
)
|
||||
repos = list(repos_row.scalars().all())
|
||||
|
||||
return DomainDetail(
|
||||
id=domain.id,
|
||||
slug=domain.slug,
|
||||
name=domain.name,
|
||||
description=domain.description,
|
||||
status=domain.status,
|
||||
created_at=domain.created_at,
|
||||
updated_at=domain.updated_at,
|
||||
topic_count=topic_count,
|
||||
workstream_count=ws_count,
|
||||
ep_count=ep_count,
|
||||
td_count=td_count,
|
||||
repos=[RepoStub.model_validate(r) for r in repos],
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{slug}/rename", response_model=DomainRead)
|
||||
async def rename_domain(
|
||||
slug: str,
|
||||
body: DomainRename,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Domain:
|
||||
domain = await _get_domain_by_slug(slug, session)
|
||||
|
||||
if body.new_slug != slug:
|
||||
conflict = await session.execute(select(Domain).where(Domain.slug == body.new_slug))
|
||||
if conflict.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Slug '{body.new_slug}' already taken")
|
||||
|
||||
old_slug = domain.slug
|
||||
domain.slug = body.new_slug
|
||||
domain.name = body.new_name
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(domain)
|
||||
return domain
|
||||
|
||||
|
||||
@router.patch("/{slug}/archive", response_model=DomainRead)
|
||||
async def archive_domain(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Domain:
|
||||
domain = await _get_domain_by_slug(slug, session)
|
||||
|
||||
# Reject if any active topics exist for this domain
|
||||
active_topics = await session.execute(
|
||||
select(func.count()).select_from(Topic)
|
||||
.where(Topic.domain_id == domain.id)
|
||||
.where(Topic.status == "active")
|
||||
)
|
||||
if active_topics.scalar_one() > 0:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot archive domain with active topics. Archive or reassign topics first.",
|
||||
)
|
||||
|
||||
domain.status = "archived"
|
||||
await session.commit()
|
||||
await session.refresh(domain)
|
||||
return domain
|
||||
|
||||
|
||||
async def _get_domain_by_slug(slug: str, session: AsyncSession) -> Domain:
|
||||
result = await session.execute(select(Domain).where(Domain.slug == slug))
|
||||
domain = result.scalar_one_or_none()
|
||||
if domain is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
|
||||
return domain
|
||||
@@ -1,105 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.extension_point import EPStatus, ExtensionPoint
|
||||
from api.schemas.extension_point import EPCreate, EPRead, EPUpdate
|
||||
|
||||
router = APIRouter(prefix="/extension-points", tags=["extension-points"])
|
||||
|
||||
|
||||
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
|
||||
row = await session.execute(
|
||||
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
|
||||
)
|
||||
domain_id = row.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
valid = [r[0] for r in (await session.execute(
|
||||
select(Domain.slug).where(Domain.status == "active")
|
||||
)).all()]
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
|
||||
)
|
||||
return domain_id
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EPRead])
|
||||
async def list_eps(
|
||||
domain: str | None = None,
|
||||
status: EPStatus | None = None,
|
||||
ep_type: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ExtensionPoint]:
|
||||
q = select(ExtensionPoint)
|
||||
if domain:
|
||||
domain_id = await _resolve_domain_id(domain, session)
|
||||
q = q.where(ExtensionPoint.domain_id == domain_id)
|
||||
if status:
|
||||
q = q.where(ExtensionPoint.status == status)
|
||||
if ep_type:
|
||||
q = q.where(ExtensionPoint.ep_type == ep_type)
|
||||
q = q.order_by(ExtensionPoint.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ep(
|
||||
body: EPCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
data = body.model_dump(exclude={"domain"})
|
||||
data["domain_id"] = domain_id
|
||||
ep = ExtensionPoint(**data)
|
||||
session.add(ep)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.get("/{ep_id}", response_model=EPRead)
|
||||
async def get_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
return ep
|
||||
|
||||
|
||||
@router.patch("/{ep_id}", response_model=EPRead)
|
||||
async def update_ep(
|
||||
ep_id: uuid.UUID,
|
||||
body: EPUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ep, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
|
||||
|
||||
@router.delete("/{ep_id}", response_model=EPRead)
|
||||
async def defer_ep(
|
||||
ep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ExtensionPoint:
|
||||
ep = await session.get(ExtensionPoint, ep_id)
|
||||
if ep is None:
|
||||
raise HTTPException(status_code=404, detail="Extension point not found")
|
||||
ep.status = EPStatus.deferred
|
||||
await session.commit()
|
||||
await session.refresh(ep)
|
||||
return ep
|
||||
@@ -1,167 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.flow_defs import (
|
||||
assertion_result_to_dict,
|
||||
create_flow_engine,
|
||||
flow_result_to_dict,
|
||||
load_flow,
|
||||
)
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.contribution import Contribution
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
|
||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||
|
||||
|
||||
@router.get("/definitions")
|
||||
async def list_flow_definitions() -> list[dict[str, Any]]:
|
||||
flows = [
|
||||
load_flow(entity_type)
|
||||
for entity_type in (
|
||||
"workstream",
|
||||
"task",
|
||||
"contribution",
|
||||
"capability_request",
|
||||
)
|
||||
]
|
||||
return [
|
||||
{
|
||||
"id": flow.id,
|
||||
"entity_type": flow.entity_type,
|
||||
"workstations": [
|
||||
{
|
||||
"name": workstation.name,
|
||||
"description": workstation.description,
|
||||
"entry_assertion_count": len(workstation.entry_assertions),
|
||||
"exit_assertion_count": len(workstation.exit_assertions),
|
||||
}
|
||||
for workstation in flow.workstations
|
||||
],
|
||||
}
|
||||
for flow in flows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{entity_type}/{entity_id}")
|
||||
async def get_flow_state(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
obj = await _flow_object(entity_type, entity_id, session)
|
||||
flow = load_flow(entity_type)
|
||||
result = create_flow_engine().evaluate(obj, flow)
|
||||
return flow_result_to_dict(result)
|
||||
|
||||
|
||||
@router.post("/{entity_type}/{entity_id}/advance/{target_workstation}")
|
||||
async def advance_workstation(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
target_workstation: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
obj = await _flow_object(entity_type, entity_id, session)
|
||||
flow = load_flow(entity_type)
|
||||
engine = create_flow_engine()
|
||||
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
|
||||
if not can_reach:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"message": (
|
||||
f"Cannot advance {entity_type} '{entity_id}' "
|
||||
f"to '{target_workstation}'."
|
||||
),
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in failures
|
||||
],
|
||||
"flow_result": flow_result_to_dict(engine.evaluate(obj, flow)),
|
||||
},
|
||||
)
|
||||
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
entity.status = target_workstation
|
||||
await session.commit()
|
||||
await session.refresh(entity)
|
||||
return await get_flow_state(entity_type, entity_id, session)
|
||||
|
||||
|
||||
async def _flow_object(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
entity = await _entity(entity_type, entity_id, session)
|
||||
status = _value(entity.status)
|
||||
obj: dict[str, Any] = {
|
||||
"id": str(entity.id),
|
||||
"status": status,
|
||||
"workstation": status,
|
||||
"previous_workstation": status,
|
||||
}
|
||||
|
||||
if entity_type == "workstream":
|
||||
tasks = list((await session.execute(
|
||||
select(Task).where(Task.workstream_id == entity_id)
|
||||
)).scalars().all())
|
||||
deps = list((await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
WorkstreamDependency.from_workstream_id == entity_id
|
||||
)
|
||||
)).scalars().all())
|
||||
dependency_ids = [dep.to_workstream_id for dep in deps]
|
||||
dependency_workstations: list[dict[str, Any]] = []
|
||||
if dependency_ids:
|
||||
dep_ws = list((await session.execute(
|
||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
||||
)).scalars().all())
|
||||
dependency_workstations = [
|
||||
{"id": str(ws.id), "workstation": ws.status}
|
||||
for ws in dep_ws
|
||||
]
|
||||
obj.update({
|
||||
"tasks": [{"id": str(task.id), "status": _value(task.status)} for task in tasks],
|
||||
"dependencies": dependency_workstations,
|
||||
})
|
||||
elif entity_type == "task":
|
||||
obj.update({
|
||||
"needs_human": entity.needs_human,
|
||||
"blocking_reason": entity.blocking_reason,
|
||||
})
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
async def _entity(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
session: AsyncSession,
|
||||
):
|
||||
model_by_type = {
|
||||
"workstream": Workstream,
|
||||
"task": Task,
|
||||
"contribution": Contribution,
|
||||
"capability_request": CapabilityRequest,
|
||||
}
|
||||
model = model_by_type.get(entity_type)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown flow entity type '{entity_type}'")
|
||||
entity = await session.get(model, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail=f"{entity_type} '{entity_id}' not found")
|
||||
return entity
|
||||
|
||||
|
||||
def _value(item):
|
||||
return item.value if hasattr(item, "value") else item
|
||||
@@ -1,192 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.interface_change import (
|
||||
InterfaceChangeCreate,
|
||||
InterfaceChangePatch,
|
||||
InterfaceChangeRead,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/interface-changes", tags=["interface-changes"])
|
||||
|
||||
_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"}
|
||||
_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"}
|
||||
|
||||
|
||||
@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_interface_change(
|
||||
body: InterfaceChangeCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
if body.interface_type not in _VALID_INTERFACE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}")
|
||||
if body.change_type not in _VALID_CHANGE_TYPES:
|
||||
raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}")
|
||||
|
||||
repo = await _repo_by_slug(body.repo_slug, session)
|
||||
change = InterfaceChange(
|
||||
repo_id=repo.id,
|
||||
interface_type=body.interface_type,
|
||||
change_type=body.change_type,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
affected_paths=body.affected_paths,
|
||||
affected_repo_slugs=body.affected_repo_slugs,
|
||||
planned_for=body.planned_for,
|
||||
author=body.author,
|
||||
status="draft",
|
||||
)
|
||||
session.add(change)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[InterfaceChangeRead])
|
||||
async def list_interface_changes(
|
||||
repo_slug: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
change_type: str | None = Query(None),
|
||||
affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[InterfaceChangeRead]:
|
||||
q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _repo_by_slug(repo_slug, session)
|
||||
q = q.where(InterfaceChange.repo_id == repo.id)
|
||||
if status:
|
||||
q = q.where(InterfaceChange.status == status)
|
||||
if change_type:
|
||||
q = q.where(InterfaceChange.change_type == change_type)
|
||||
if affected_repo:
|
||||
q = q.where(InterfaceChange.affected_repo_slugs.contains([affected_repo]))
|
||||
result = await session.execute(q)
|
||||
return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def get_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.patch("/{change_id}", response_model=InterfaceChangeRead)
|
||||
async def patch_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
body: InterfaceChangePatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.",
|
||||
)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(change, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/publish", response_model=InterfaceChangeRead)
|
||||
async def publish_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.",
|
||||
)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
change.status = "published"
|
||||
change.published_at = now
|
||||
|
||||
# Send inbox notifications to agents of affected repos
|
||||
affected = change.affected_repo_slugs or []
|
||||
for slug in affected:
|
||||
paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description"
|
||||
if len(change.affected_paths) > 5:
|
||||
paths_summary += f" (+{len(change.affected_paths) - 5} more)"
|
||||
msg = AgentMessage(
|
||||
from_agent=change.repo.slug,
|
||||
to_agent=slug,
|
||||
subject=f"[{change.change_type.upper()}] {change.title}",
|
||||
body=(
|
||||
f"**Interface change published by `{change.repo.slug}`**\n\n"
|
||||
f"- Type: `{change.interface_type}` / `{change.change_type}`\n"
|
||||
f"- Affected paths: {paths_summary}\n\n"
|
||||
f"{change.description}\n\n"
|
||||
f"Change ID: `{change.id}` — resolve with "
|
||||
f"`POST /interface-changes/{change.id}/resolve` once adapted."
|
||||
),
|
||||
)
|
||||
session.add(msg)
|
||||
|
||||
# Progress event on the originating repo
|
||||
session.add(ProgressEvent(
|
||||
event_type="milestone",
|
||||
summary=f"Interface change published: {change.title}",
|
||||
detail={
|
||||
"change_id": str(change.id),
|
||||
"change_type": change.change_type,
|
||||
"interface_type": change.interface_type,
|
||||
"affected_repos": affected,
|
||||
"notifications_sent": len(affected),
|
||||
},
|
||||
author=change.author,
|
||||
))
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead)
|
||||
async def resolve_interface_change(
|
||||
change_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InterfaceChangeRead:
|
||||
change = await _get_or_404(change_id, session)
|
||||
if change.status != "published":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.",
|
||||
)
|
||||
change.status = "resolved"
|
||||
change.resolved_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(change)
|
||||
return InterfaceChangeRead.from_orm_with_slug(change)
|
||||
|
||||
|
||||
async def _repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange:
|
||||
result = await session.execute(
|
||||
select(InterfaceChange).where(InterfaceChange.id == change_id)
|
||||
)
|
||||
change = result.scalar_one_or_none()
|
||||
if change is None:
|
||||
raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found")
|
||||
return change
|
||||
@@ -1,138 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.agent_message import AgentMessage
|
||||
from api.schemas.agent_message import MessageCreate, MessageRead, MessageReply
|
||||
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
|
||||
|
||||
@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def send_message(
|
||||
body: MessageCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Send a message from one agent to another (or 'broadcast')."""
|
||||
if body.thread_id:
|
||||
root = await session.get(AgentMessage, body.thread_id)
|
||||
if root is None:
|
||||
raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found")
|
||||
|
||||
msg = AgentMessage(
|
||||
from_agent=body.from_agent,
|
||||
to_agent=body.to_agent,
|
||||
subject=body.subject,
|
||||
body=body.body,
|
||||
thread_id=body.thread_id,
|
||||
)
|
||||
session.add(msg)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MessageRead])
|
||||
async def list_messages(
|
||||
to_agent: str | None = None,
|
||||
from_agent: str | None = None,
|
||||
unread_only: bool = False,
|
||||
limit: int = 50,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
"""List messages. Filter by recipient, sender, or unread status."""
|
||||
q = select(AgentMessage).where(AgentMessage.archived_at.is_(None))
|
||||
if to_agent:
|
||||
q = q.where(
|
||||
(AgentMessage.to_agent == to_agent) | (AgentMessage.to_agent == "broadcast")
|
||||
)
|
||||
if from_agent:
|
||||
q = q.where(AgentMessage.from_agent == from_agent)
|
||||
if unread_only:
|
||||
q = q.where(AgentMessage.read_at.is_(None))
|
||||
q = q.order_by(AgentMessage.created_at.desc()).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/thread/{thread_id}", response_model=list[MessageRead])
|
||||
async def get_thread(
|
||||
thread_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentMessage]:
|
||||
"""Get all messages in a thread (root + replies), oldest first."""
|
||||
# Include the root message itself
|
||||
q = select(AgentMessage).where(
|
||||
(AgentMessage.id == thread_id) | (AgentMessage.thread_id == thread_id)
|
||||
).order_by(AgentMessage.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.patch("/{message_id}/read", response_model=MessageRead)
|
||||
async def mark_read(
|
||||
message_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Mark a message as read."""
|
||||
msg = await _get_message(message_id, session)
|
||||
if msg.read_at is None:
|
||||
msg.read_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.patch("/{message_id}/archive", response_model=MessageRead)
|
||||
async def archive_message(
|
||||
message_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Archive a message (soft-delete)."""
|
||||
msg = await _get_message(message_id, session)
|
||||
msg.archived_at = datetime.now(timezone.utc)
|
||||
if msg.read_at is None:
|
||||
msg.read_at = msg.archived_at
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
@router.post("/{message_id}/reply", response_model=MessageRead, status_code=status.HTTP_201_CREATED)
|
||||
async def reply_to_message(
|
||||
message_id: uuid.UUID,
|
||||
body: MessageReply,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentMessage:
|
||||
"""Reply to a message. Marks the original as read and creates a reply in the same thread."""
|
||||
original = await _get_message(message_id, session)
|
||||
|
||||
# Mark original as read
|
||||
if original.read_at is None:
|
||||
original.read_at = datetime.now(timezone.utc)
|
||||
|
||||
# Thread root is either the original's thread_id or the original itself
|
||||
thread_root = original.thread_id or original.id
|
||||
|
||||
reply = AgentMessage(
|
||||
from_agent=body.from_agent,
|
||||
to_agent=original.from_agent,
|
||||
subject=f"Re: {original.subject}",
|
||||
body=body.body,
|
||||
thread_id=thread_root,
|
||||
)
|
||||
session.add(reply)
|
||||
await session.commit()
|
||||
await session.refresh(reply)
|
||||
return reply
|
||||
|
||||
|
||||
async def _get_message(message_id: uuid.UUID, session: AsyncSession) -> AgentMessage:
|
||||
msg = await session.get(AgentMessage, message_id)
|
||||
if msg is None:
|
||||
raise HTTPException(status_code=404, detail=f"Message {message_id} not found")
|
||||
return msg
|
||||
@@ -1,41 +0,0 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
|
||||
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
router = APIRouter(prefix="/policy", tags=["policy"])
|
||||
|
||||
|
||||
class PolicyRead(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
class PolicyUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _policy_path(name: str) -> Path:
|
||||
if not _VALID_NAME.match(name):
|
||||
raise HTTPException(status_code=400, detail="Invalid policy name")
|
||||
path = POLICY_DIR / f"{name}.md"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=PolicyRead)
|
||||
def get_policy(name: str) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
return PolicyRead(name=name, content=path.read_text())
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=PolicyRead)
|
||||
def update_policy(name: str, body: PolicyUpdate) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
path.write_text(body.content)
|
||||
return PolicyRead(name=name, content=body.content)
|
||||
@@ -1,51 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
|
||||
router = APIRouter(prefix="/progress", tags=["progress"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[ProgressEventRead])
|
||||
async def list_progress(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
task_id: uuid.UUID | None = None,
|
||||
event_type: str | None = None,
|
||||
since: datetime | None = None,
|
||||
limit: int = Query(100, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ProgressEvent]:
|
||||
q = select(ProgressEvent)
|
||||
if topic_id:
|
||||
q = q.where(ProgressEvent.topic_id == topic_id)
|
||||
if workstream_id:
|
||||
q = q.where(ProgressEvent.workstream_id == workstream_id)
|
||||
if task_id:
|
||||
q = q.where(ProgressEvent.task_id == task_id)
|
||||
if event_type:
|
||||
q = q.where(ProgressEvent.event_type == event_type)
|
||||
if since:
|
||||
q = q.where(ProgressEvent.created_at >= since)
|
||||
q = q.order_by(ProgressEvent.created_at.desc()).offset(offset).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=ProgressEventRead, status_code=status.HTTP_201_CREATED)
|
||||
async def append_progress(
|
||||
body: ProgressEventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProgressEvent:
|
||||
event = ProgressEvent(**body.model_dump())
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event
|
||||
@@ -1,79 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate
|
||||
|
||||
router = APIRouter(prefix="/repo-goals", tags=["repo-goals"])
|
||||
|
||||
|
||||
async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{repo_slug}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RepoGoalRead])
|
||||
async def list_repo_goals(
|
||||
repo_slug: str | None = None,
|
||||
domain_goal_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RepoGoal]:
|
||||
q = select(RepoGoal)
|
||||
if repo_slug:
|
||||
repo = await _resolve_repo(repo_slug, session)
|
||||
q = q.where(RepoGoal.repo_id == repo.id)
|
||||
if domain_goal_id:
|
||||
q = q.where(RepoGoal.domain_goal_id == domain_goal_id)
|
||||
if status:
|
||||
q = q.where(RepoGoal.status == status)
|
||||
q = q.order_by(RepoGoal.priority.asc(), RepoGoal.created_at.asc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=RepoGoalRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_repo_goal(
|
||||
body: RepoGoalCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = RepoGoal(**body.model_dump())
|
||||
session.add(goal)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
|
||||
|
||||
@router.get("/{goal_id}", response_model=RepoGoalRead)
|
||||
async def get_repo_goal(
|
||||
goal_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = await session.get(RepoGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Repo goal not found")
|
||||
return goal
|
||||
|
||||
|
||||
@router.patch("/{goal_id}", response_model=RepoGoalRead)
|
||||
async def update_repo_goal(
|
||||
goal_id: uuid.UUID,
|
||||
body: RepoGoalUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoGoal:
|
||||
goal = await session.get(RepoGoal, goal_id)
|
||||
if goal is None:
|
||||
raise HTTPException(status_code=404, detail="Repo goal not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(goal, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(goal)
|
||||
return goal
|
||||
@@ -1,728 +0,0 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import noload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.config import settings
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.doi_engine import (
|
||||
compute_fingerprint,
|
||||
evaluate as _doi_evaluate,
|
||||
evaluate_scope_health,
|
||||
resolve_repo_path,
|
||||
)
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.domain import Domain
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal
|
||||
from api.models.tpsc import TPSCSnapshot
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
||||
from api.schemas.managed_repo import (
|
||||
DispatchTask,
|
||||
DispatchWorkstream,
|
||||
PendingInterfaceChange,
|
||||
RepoCreate,
|
||||
RepoDispatch,
|
||||
RepoOnboardRequest,
|
||||
RepoOnboardResult,
|
||||
RepoPathRegister,
|
||||
RepoRead,
|
||||
RepoScopeHealth,
|
||||
RepoUpdate,
|
||||
ScopeIssueDetail,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/repos", tags=["repos"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[RepoRead])
|
||||
async def list_repos(
|
||||
response: Response,
|
||||
domain: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ManagedRepo]:
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = select(ManagedRepo).options(noload(ManagedRepo.goals)).order_by(ManagedRepo.name)
|
||||
if domain:
|
||||
domain_row = await session.execute(select(Domain).where(Domain.slug == domain))
|
||||
domain_obj = domain_row.scalar_one_or_none()
|
||||
if domain_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
||||
q = q.where(ManagedRepo.domain_id == domain_obj.id)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=RepoRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register_repo(
|
||||
body: RepoCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
domain_row = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
||||
domain_obj = domain_row.scalar_one_or_none()
|
||||
if domain_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Domain '{body.domain_slug}' not found")
|
||||
|
||||
existing = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.slug))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Repo slug '{body.slug}' already exists")
|
||||
|
||||
repo = ManagedRepo(
|
||||
domain_id=domain_obj.id,
|
||||
slug=body.slug,
|
||||
name=body.name,
|
||||
local_path=body.local_path,
|
||||
remote_url=body.remote_url,
|
||||
git_fingerprint=body.git_fingerprint,
|
||||
description=body.description,
|
||||
topic_id=body.topic_id,
|
||||
)
|
||||
session.add(repo)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
|
||||
subject = "org.statehub.repo.registered"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"repo_id": str(repo.id),
|
||||
"repo_slug": repo.slug,
|
||||
"domain_slug": body.domain_slug,
|
||||
"remote_url": repo.remote_url,
|
||||
"local_path": repo.local_path,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return repo
|
||||
|
||||
|
||||
@router.post("/onboard", response_model=RepoOnboardResult)
|
||||
async def onboard_repo(body: RepoOnboardRequest) -> RepoOnboardResult:
|
||||
"""Run the local repo onboarding script for an accessible working copy.
|
||||
|
||||
The dashboard uses this for the "Add Repo" action. The path must be visible
|
||||
from the State Hub host, either as a local checkout or through an ops-bridge
|
||||
mounted/exposed working copy. Keep the API agent-profile based so future
|
||||
native coding agents can gain their own profiles without changing callers.
|
||||
"""
|
||||
project_path = Path(body.project_path).expanduser()
|
||||
if not project_path.exists() or not project_path.is_dir():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"project_path is not an accessible directory: {body.project_path}",
|
||||
)
|
||||
if not (project_path / ".git").exists():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"project_path does not look like a git working copy: {body.project_path}",
|
||||
)
|
||||
|
||||
script = Path(__file__).parent.parent.parent / "scripts" / "register_project.sh"
|
||||
cmd = ["bash", str(script), body.domain_slug, str(project_path)]
|
||||
if body.agent_profile == "codex":
|
||||
cmd.append("--codex")
|
||||
if body.additional:
|
||||
cmd.append("--additional")
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"API_BASE": settings.api_base,
|
||||
"CUSTODIAN_SKIP_SBOM_PROMPT": "true",
|
||||
}
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
cmd,
|
||||
cwd=str(script.parent.parent),
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail={
|
||||
"message": "Repo onboarding failed.",
|
||||
"command": cmd,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
},
|
||||
)
|
||||
|
||||
repo_slug = None
|
||||
match = re.search(r"Repo slug:\s+([a-z0-9][a-z0-9-]*)", stdout)
|
||||
if match:
|
||||
repo_slug = match.group(1)
|
||||
|
||||
return RepoOnboardResult(
|
||||
ok=True,
|
||||
repo_slug=repo_slug,
|
||||
agent_profile=body.agent_profile,
|
||||
command=cmd,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-fingerprint", response_model=list[RepoRead])
|
||||
async def get_repo_by_fingerprint(
|
||||
hash: str,
|
||||
remote_url: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ManagedRepo]:
|
||||
"""Look up repos by git root-commit SHA-1 fingerprint.
|
||||
|
||||
The fingerprint is the output of ``git rev-list --max-parents=0 HEAD`` and
|
||||
is identical across every clone of the same repository. Repos that share
|
||||
git history (forks, monorepo splits) will have the same fingerprint.
|
||||
|
||||
Pass ``remote_url`` to narrow results to a specific remote — useful when
|
||||
multiple repos share the same ancestor commit.
|
||||
|
||||
Returns an empty list if no match is found.
|
||||
"""
|
||||
q = select(ManagedRepo).where(ManagedRepo.git_fingerprint == hash)
|
||||
if remote_url:
|
||||
q = q.where(ManagedRepo.remote_url == remote_url)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/by-remote", response_model=RepoRead)
|
||||
async def get_repo_by_remote_url(
|
||||
url: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
"""Look up a repo by its git remote URL (fallback; prefer /by-fingerprint)."""
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.remote_url == url))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"No repo with remote_url '{url}' found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
|
||||
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
||||
"""Return DoI tier for all active repos, worst tier first.
|
||||
|
||||
Results are cached in doi_cache. A repo is only re-evaluated when its
|
||||
fingerprint changes (repo record updated, new TPSC snapshot, goal change,
|
||||
or a key file mtime changes on disk).
|
||||
"""
|
||||
repos_result = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
|
||||
)
|
||||
repos = list(repos_result.scalars().all())
|
||||
repo_ids = [r.id for r in repos]
|
||||
id_to_slug = {r.id: r.slug for r in repos}
|
||||
|
||||
# ── Bulk DB queries for fingerprint inputs ────────────────────────────────
|
||||
domains_result = await session.execute(select(Domain))
|
||||
domain_obj_map = {d.id: d for d in domains_result.scalars().all()}
|
||||
domain_map = {d.id: d.slug for d in domain_obj_map.values()}
|
||||
domain_status = {d.slug: d.status for d in domain_obj_map.values()}
|
||||
|
||||
# Latest TPSC snapshot timestamp per repo (for fingerprint + C9 count)
|
||||
tpsc_result = await session.execute(
|
||||
select(TPSCSnapshot.repo_id,
|
||||
func.count().label("cnt"),
|
||||
func.max(TPSCSnapshot.snapshot_at).label("latest"))
|
||||
.where(TPSCSnapshot.repo_id.in_(repo_ids))
|
||||
.group_by(TPSCSnapshot.repo_id)
|
||||
)
|
||||
tpsc_by_id = {row.repo_id: row for row in tpsc_result}
|
||||
tpsc_snap_counts = {id_to_slug[rid]: row.cnt for rid, row in tpsc_by_id.items() if rid in id_to_slug}
|
||||
tpsc_snap_latest = {id_to_slug[rid]: str(row.latest) for rid, row in tpsc_by_id.items() if rid in id_to_slug}
|
||||
|
||||
# Latest goal updated_at + active count per repo (for fingerprint + C10)
|
||||
goals_result = await session.execute(
|
||||
select(RepoGoal.repo_id,
|
||||
func.count().label("total"),
|
||||
func.sum(case((RepoGoal.status == "active", 1), else_=0)).label("active_cnt"),
|
||||
func.max(RepoGoal.updated_at).label("latest"))
|
||||
.where(RepoGoal.repo_id.in_(repo_ids))
|
||||
.group_by(RepoGoal.repo_id)
|
||||
)
|
||||
goals_by_id = {row.repo_id: row for row in goals_result}
|
||||
active_goal_counts = {id_to_slug[rid]: int(row.active_cnt or 0) for rid, row in goals_by_id.items() if rid in id_to_slug}
|
||||
goals_latest = {id_to_slug[rid]: str(row.latest) for rid, row in goals_by_id.items() if rid in id_to_slug}
|
||||
|
||||
# Load existing cache rows
|
||||
cache_result = await session.execute(
|
||||
select(DOICache).where(DOICache.repo_id.in_(repo_ids))
|
||||
)
|
||||
cache_by_repo_id = {row.repo_id: row for row in cache_result.scalars().all()}
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
prefetch = {
|
||||
"domain_status": domain_status,
|
||||
"tpsc_snap_counts": tpsc_snap_counts,
|
||||
"active_goal_counts": active_goal_counts,
|
||||
}
|
||||
|
||||
async def _get_or_refresh(repo: ManagedRepo) -> DoISummaryEntry:
|
||||
slug = repo.slug
|
||||
repo_dict = {
|
||||
"slug": slug,
|
||||
"domain_slug": domain_map.get(repo.domain_id),
|
||||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
fp = compute_fingerprint(
|
||||
repo_dict,
|
||||
tpsc_snap_latest.get(slug),
|
||||
goals_latest.get(slug),
|
||||
)
|
||||
|
||||
cached = cache_by_repo_id.get(repo.id)
|
||||
if cached and cached.fingerprint == fp:
|
||||
# Cache hit — return stored result
|
||||
return DoISummaryEntry(
|
||||
repo_slug=slug,
|
||||
domain_slug=domain_map.get(repo.domain_id),
|
||||
tier=cached.tier,
|
||||
core_pass=cached.core_pass,
|
||||
standard_pass=cached.standard_pass,
|
||||
full_pass=cached.full_pass,
|
||||
checked_at=cached.checked_at.isoformat(),
|
||||
)
|
||||
|
||||
# Cache miss — evaluate and store
|
||||
report = await _doi_evaluate(repo_dict, skip_consistency=True, prefetch=prefetch)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
if cached:
|
||||
cached.tier = report.tier
|
||||
cached.core_pass = report.core_pass
|
||||
cached.standard_pass = report.standard_pass
|
||||
cached.full_pass = report.full_pass
|
||||
cached.criteria = [{"id": c.id, "label": c.label, "tier": c.tier,
|
||||
"status": c.status, "detail": c.detail}
|
||||
for c in report.criteria]
|
||||
cached.fingerprint = fp
|
||||
cached.checked_at = now
|
||||
cached.updated_at = now
|
||||
else:
|
||||
session.add(DOICache(
|
||||
repo_id=repo.id,
|
||||
tier=report.tier,
|
||||
core_pass=report.core_pass,
|
||||
standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass,
|
||||
criteria=[{"id": c.id, "label": c.label, "tier": c.tier,
|
||||
"status": c.status, "detail": c.detail}
|
||||
for c in report.criteria],
|
||||
fingerprint=fp,
|
||||
checked_at=now,
|
||||
updated_at=now,
|
||||
))
|
||||
|
||||
return DoISummaryEntry(
|
||||
repo_slug=slug,
|
||||
domain_slug=domain_map.get(repo.domain_id),
|
||||
tier=report.tier,
|
||||
core_pass=report.core_pass,
|
||||
standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass,
|
||||
checked_at=now.isoformat(),
|
||||
)
|
||||
|
||||
entries: list[DoISummaryEntry] = list(await asyncio.gather(*[_get_or_refresh(r) for r in repos]))
|
||||
await session.commit()
|
||||
|
||||
tier_order = {"none": 0, "core": 1, "standard": 2, "full": 3}
|
||||
entries.sort(key=lambda e: tier_order.get(e.tier, 0))
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/{slug}/doi", response_model=DoIReport)
|
||||
async def get_repo_doi(
|
||||
slug: str,
|
||||
force_refresh: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> DoIReport:
|
||||
"""Evaluate the 14 DoI criteria for a single repo (full check including C7/C13).
|
||||
|
||||
Results are cached by fingerprint. Pass ?force_refresh=true to bypass the cache.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
|
||||
# Fingerprint inputs for this single repo
|
||||
tpsc_row = (await session.execute(
|
||||
select(func.count().label("cnt"), func.max(TPSCSnapshot.snapshot_at).label("latest"))
|
||||
.where(TPSCSnapshot.repo_id == repo.id)
|
||||
)).one()
|
||||
goal_row = (await session.execute(
|
||||
select(func.max(RepoGoal.updated_at).label("latest"))
|
||||
.where(RepoGoal.repo_id == repo.id)
|
||||
)).one()
|
||||
|
||||
repo_dict = {
|
||||
"slug": repo.slug,
|
||||
"domain_slug": domain_obj.slug if domain_obj else None,
|
||||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
fp = compute_fingerprint(repo_dict, str(tpsc_row.latest) if tpsc_row.latest else None,
|
||||
str(goal_row.latest) if goal_row.latest else None)
|
||||
|
||||
# Check cache (unless force_refresh)
|
||||
cached = (await session.execute(
|
||||
select(DOICache).where(DOICache.repo_id == repo.id)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if not force_refresh and cached and cached.fingerprint == fp and cached.criteria:
|
||||
return DoIReport(
|
||||
repo_slug=slug,
|
||||
tier=cached.tier,
|
||||
core_pass=cached.core_pass,
|
||||
standard_pass=cached.standard_pass,
|
||||
full_pass=cached.full_pass,
|
||||
checked_at=cached.checked_at.isoformat(),
|
||||
criteria=[DoICriterion(**c) for c in cached.criteria],
|
||||
)
|
||||
|
||||
# Full evaluation (includes C7/C13 consistency subprocesses)
|
||||
report = await _doi_evaluate(repo_dict)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
criteria_json = [{"id": c.id, "label": c.label, "tier": c.tier,
|
||||
"status": c.status, "detail": c.detail} for c in report.criteria]
|
||||
if cached:
|
||||
cached.tier = report.tier; cached.core_pass = report.core_pass
|
||||
cached.standard_pass = report.standard_pass; cached.full_pass = report.full_pass
|
||||
cached.criteria = criteria_json; cached.fingerprint = fp
|
||||
cached.checked_at = now; cached.updated_at = now
|
||||
else:
|
||||
session.add(DOICache(repo_id=repo.id, tier=report.tier,
|
||||
core_pass=report.core_pass, standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass, criteria=criteria_json,
|
||||
fingerprint=fp, checked_at=now, updated_at=now))
|
||||
await session.commit()
|
||||
|
||||
return DoIReport(
|
||||
repo_slug=report.repo_slug, tier=report.tier,
|
||||
core_pass=report.core_pass, standard_pass=report.standard_pass,
|
||||
full_pass=report.full_pass, checked_at=report.checked_at,
|
||||
criteria=[DoICriterion(id=c.id, label=c.label, tier=c.tier,
|
||||
status=c.status, detail=c.detail) for c in report.criteria],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-id/{repo_id}", response_model=RepoRead)
|
||||
async def get_repo_by_id(
|
||||
repo_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
repo = await session.get(ManagedRepo, repo_id)
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/scope-health", response_model=list[RepoScopeHealth])
|
||||
async def list_repo_scope_health(
|
||||
needs_review: bool | None = None,
|
||||
reachable_only: bool = False,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RepoScopeHealth]:
|
||||
"""Return machine-readable SCOPE.md health for active repos.
|
||||
|
||||
Repo-scoping uses this to refresh only repos and SCOPE.md sections that
|
||||
need attention, without guessing from free-text DoI output.
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(ManagedRepo, Domain.slug)
|
||||
.join(Domain, Domain.id == ManagedRepo.domain_id)
|
||||
.where(ManagedRepo.status == "active")
|
||||
.order_by(ManagedRepo.slug)
|
||||
)
|
||||
|
||||
entries: list[RepoScopeHealth] = []
|
||||
for repo, domain_slug in result.all():
|
||||
repo_dict = _repo_doi_dict(repo, domain_slug)
|
||||
resolved_path = resolve_repo_path(repo_dict)
|
||||
scope_issue_details = [
|
||||
ScopeIssueDetail(**issue)
|
||||
for issue in evaluate_scope_health(repo_dict)
|
||||
]
|
||||
scope_needs_review = any(
|
||||
issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"}
|
||||
for issue in scope_issue_details
|
||||
)
|
||||
entry = RepoScopeHealth(
|
||||
repo_slug=repo.slug,
|
||||
domain_slug=domain_slug,
|
||||
local_path=resolved_path or repo.local_path,
|
||||
path_available=bool(resolved_path),
|
||||
scope_needs_review=scope_needs_review,
|
||||
scope_issue_details=scope_issue_details,
|
||||
)
|
||||
if needs_review is not None and entry.scope_needs_review != needs_review:
|
||||
continue
|
||||
if reachable_only and not entry.path_available:
|
||||
continue
|
||||
entries.append(entry)
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=RepoRead)
|
||||
async def get_repo(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
return await _get_repo_by_slug(slug, session)
|
||||
|
||||
|
||||
@router.patch("/{slug}", response_model=RepoRead)
|
||||
async def update_repo(
|
||||
slug: str,
|
||||
body: RepoUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(repo, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.post("/{slug}/paths", response_model=RepoRead)
|
||||
async def register_host_path(
|
||||
slug: str,
|
||||
body: RepoPathRegister,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
"""Register or update the local path for a specific host.
|
||||
|
||||
Merges {"host": path} into host_paths without overwriting other entries.
|
||||
Use this when a repo lives at a different absolute path on different machines.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
updated = dict(repo.host_paths or {})
|
||||
updated[body.host] = body.path
|
||||
repo.host_paths = updated
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.patch("/{slug}/archive", response_model=RepoRead)
|
||||
async def archive_repo(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ManagedRepo:
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
repo.status = "archived"
|
||||
await session.commit()
|
||||
await session.refresh(repo)
|
||||
return repo
|
||||
|
||||
|
||||
@router.get("/{slug}/dispatch", response_model=RepoDispatch)
|
||||
async def get_repo_dispatch(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoDispatch:
|
||||
"""Return active workstreams, pending tasks, and goal for a repo.
|
||||
|
||||
This endpoint is the foundation for autonomous agent sessions: an agent can
|
||||
call it at session start to discover what work is pending without needing to
|
||||
read state-hub summary or scan workplan files manually.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
|
||||
# Active goal
|
||||
goal_result = await session.execute(
|
||||
select(RepoGoal)
|
||||
.where(RepoGoal.repo_id == repo.id, RepoGoal.status == "active")
|
||||
.order_by(RepoGoal.priority)
|
||||
.limit(1)
|
||||
)
|
||||
goal_obj = goal_result.scalar_one_or_none()
|
||||
active_goal = None
|
||||
if goal_obj:
|
||||
active_goal = {
|
||||
"id": str(goal_obj.id),
|
||||
"title": goal_obj.title,
|
||||
"description": goal_obj.description,
|
||||
"priority": goal_obj.priority,
|
||||
}
|
||||
|
||||
# Active workstreams
|
||||
ws_result = await session.execute(
|
||||
select(Workstream)
|
||||
.where(Workstream.repo_id == repo.id, Workstream.status == "active")
|
||||
.order_by(Workstream.created_at)
|
||||
)
|
||||
workstreams = list(ws_result.scalars().all())
|
||||
|
||||
dispatch_workstreams: list[DispatchWorkstream] = []
|
||||
all_interventions: list[DispatchTask] = []
|
||||
|
||||
for ws in workstreams:
|
||||
task_result = await session.execute(
|
||||
select(Task)
|
||||
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
tasks = list(task_result.scalars().all())
|
||||
|
||||
pending = [
|
||||
DispatchTask(
|
||||
id=t.id,
|
||||
title=t.title,
|
||||
priority=t.priority,
|
||||
status=t.status,
|
||||
needs_human=t.needs_human,
|
||||
)
|
||||
for t in tasks
|
||||
]
|
||||
interventions = [t for t in pending if t.needs_human]
|
||||
all_interventions.extend(interventions)
|
||||
|
||||
dispatch_workstreams.append(
|
||||
DispatchWorkstream(
|
||||
id=ws.id,
|
||||
title=ws.title,
|
||||
status=ws.status,
|
||||
pending_tasks=pending,
|
||||
)
|
||||
)
|
||||
|
||||
# Published interface changes that affect this repo and are not yet resolved
|
||||
ic_result = await session.execute(
|
||||
select(InterfaceChange).where(
|
||||
InterfaceChange.status == "published",
|
||||
InterfaceChange.affected_repo_slugs.contains([slug]),
|
||||
).order_by(InterfaceChange.published_at.desc())
|
||||
)
|
||||
pending_changes = [
|
||||
PendingInterfaceChange(
|
||||
id=ic.id,
|
||||
title=ic.title,
|
||||
change_type=ic.change_type,
|
||||
interface_type=ic.interface_type,
|
||||
origin_repo_slug=ic.repo.slug,
|
||||
affected_paths=ic.affected_paths or [],
|
||||
planned_for=ic.planned_for,
|
||||
published_at=ic.published_at,
|
||||
)
|
||||
for ic in ic_result.scalars().all()
|
||||
]
|
||||
|
||||
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
||||
domain_obj = domain_result.scalar_one_or_none()
|
||||
scope_issue_details = [
|
||||
ScopeIssueDetail(**issue)
|
||||
for issue in evaluate_scope_health(_repo_doi_dict(repo, domain_obj.slug if domain_obj else None))
|
||||
]
|
||||
scope_needs_review = any(
|
||||
issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"}
|
||||
for issue in scope_issue_details
|
||||
)
|
||||
|
||||
return RepoDispatch(
|
||||
repo_slug=slug,
|
||||
active_goal=active_goal,
|
||||
active_workstreams=dispatch_workstreams,
|
||||
human_interventions=all_interventions,
|
||||
pending_interface_changes=pending_changes,
|
||||
scope_needs_review=scope_needs_review,
|
||||
scope_issue_details=scope_issue_details,
|
||||
last_state_synced_at=repo.last_state_synced_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{slug}/sync")
|
||||
async def sync_repo_consistency(
|
||||
slug: str,
|
||||
fix: bool = True,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Run ADR-001 consistency check (and optional --fix) for a repo via HTTP.
|
||||
|
||||
Intended for non-Claude-Code agents (e.g. Codex) that cannot use MCP tools
|
||||
but need to sync workplan file state to the state-hub DB after making changes.
|
||||
|
||||
Returns the raw JSON output from consistency_check.py.
|
||||
Query param ?fix=false to run check-only without writing.
|
||||
"""
|
||||
repo = await _get_repo_by_slug(slug, session)
|
||||
|
||||
hostname = socket.gethostname()
|
||||
host_paths = repo.host_paths or {}
|
||||
repo_path = host_paths.get(hostname)
|
||||
if not repo_path or not Path(repo_path).exists():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=(
|
||||
f"No accessible path for repo '{slug}' on host '{hostname}'. "
|
||||
f"Register with: POST /repos/{slug}/paths/"
|
||||
),
|
||||
)
|
||||
|
||||
script = Path(__file__).parent.parent.parent / "scripts" / "consistency_check.py"
|
||||
cmd = [sys.executable, str(script), "--repo", slug, "--json",
|
||||
"--api-base", settings.api_base]
|
||||
if fix:
|
||||
cmd.append("--fix")
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
subprocess.run, cmd, capture_output=True, text=True
|
||||
)
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Consistency check failed: {result.stderr or result.stdout or '(no output)'}",
|
||||
)
|
||||
|
||||
|
||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
|
||||
|
||||
def _repo_doi_dict(repo: ManagedRepo, domain_slug: str | None) -> dict:
|
||||
return {
|
||||
"slug": repo.slug,
|
||||
"domain_slug": domain_slug,
|
||||
"local_path": repo.local_path,
|
||||
"remote_url": repo.remote_url,
|
||||
"host_paths": repo.host_paths or {},
|
||||
"last_sbom_at": str(repo.last_sbom_at) if repo.last_sbom_at else None,
|
||||
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.sbom_entry import Ecosystem, SBOMEntry
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.schemas.sbom import (
|
||||
LicenceGroup,
|
||||
LicenceReport,
|
||||
SBOMEntryRead,
|
||||
SBOMIngest,
|
||||
SBOMRepoView,
|
||||
SBOMSnapshotDetail,
|
||||
SBOMSnapshotRead,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/sbom", tags=["sbom"])
|
||||
|
||||
_COPYLEFT_PATTERNS = {"GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL"}
|
||||
|
||||
|
||||
def _is_copyleft(spdx: str | None) -> bool:
|
||||
if not spdx:
|
||||
return False
|
||||
upper = spdx.upper()
|
||||
return any(pat in upper for pat in _COPYLEFT_PATTERNS)
|
||||
|
||||
|
||||
def _latest_snapshot_ids_subquery():
|
||||
"""Subquery returning the latest SBOMSnapshot.id per repo."""
|
||||
max_at_sq = (
|
||||
select(SBOMSnapshot.repo_id, func.max(SBOMSnapshot.snapshot_at).label("max_at"))
|
||||
.group_by(SBOMSnapshot.repo_id)
|
||||
.subquery("max_snap_at")
|
||||
)
|
||||
return (
|
||||
select(SBOMSnapshot.id)
|
||||
.join(
|
||||
max_at_sq,
|
||||
and_(
|
||||
SBOMSnapshot.repo_id == max_at_sq.c.repo_id,
|
||||
SBOMSnapshot.snapshot_at == max_at_sq.c.max_at,
|
||||
),
|
||||
)
|
||||
.subquery("latest_snap_ids")
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ingest/")
|
||||
async def ingest_sbom(
|
||||
body: SBOMIngest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Create a new SBOM snapshot for a repo. Previous snapshots are retained."""
|
||||
repo = await _get_repo_by_slug(body.repo_slug, session)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
snap = SBOMSnapshot(
|
||||
repo_id=repo.id,
|
||||
snapshot_at=now,
|
||||
source="manual",
|
||||
entry_count=len(body.entries),
|
||||
created_at=now,
|
||||
)
|
||||
session.add(snap)
|
||||
await session.flush() # materialise snap.id before creating entries
|
||||
|
||||
for entry in body.entries:
|
||||
sbom = SBOMEntry(
|
||||
repo_id=repo.id,
|
||||
snapshot_id=snap.id,
|
||||
package_name=entry.package_name,
|
||||
package_version=entry.package_version,
|
||||
ecosystem=entry.ecosystem,
|
||||
license_spdx=entry.license_spdx,
|
||||
is_direct=entry.is_direct,
|
||||
is_dev=entry.is_dev,
|
||||
snapshot_at=now,
|
||||
created_at=now,
|
||||
)
|
||||
session.add(sbom)
|
||||
|
||||
repo.last_sbom_at = now
|
||||
if not repo.sbom_source:
|
||||
repo.sbom_source = "manual"
|
||||
|
||||
await session.commit()
|
||||
return {
|
||||
"repo_slug": body.repo_slug,
|
||||
"snapshot_id": str(snap.id),
|
||||
"ingested": len(body.entries),
|
||||
"snapshot_at": now.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/snapshots/", response_model=list[SBOMSnapshotRead])
|
||||
async def list_snapshots(
|
||||
repo_slug: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SBOMSnapshotRead]:
|
||||
"""List SBOM snapshots, newest first. Optionally filter by repo."""
|
||||
q = select(SBOMSnapshot).order_by(SBOMSnapshot.snapshot_at.desc())
|
||||
if repo_slug:
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
q = q.where(SBOMSnapshot.repo_id == repo.id)
|
||||
result = await session.execute(q)
|
||||
return [SBOMSnapshotRead.model_validate(s) for s in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/snapshots/{snapshot_id}", response_model=SBOMSnapshotDetail)
|
||||
async def get_snapshot(
|
||||
snapshot_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SBOMSnapshotDetail:
|
||||
"""Get a snapshot with its full entry list."""
|
||||
snap = await session.get(SBOMSnapshot, snapshot_id)
|
||||
if snap is None:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot_id}' not found")
|
||||
result = await session.execute(
|
||||
select(SBOMEntry)
|
||||
.where(SBOMEntry.snapshot_id == snapshot_id)
|
||||
.order_by(SBOMEntry.package_name)
|
||||
)
|
||||
entries = list(result.scalars().all())
|
||||
return SBOMSnapshotDetail(
|
||||
id=snap.id,
|
||||
repo_id=snap.repo_id,
|
||||
snapshot_at=snap.snapshot_at,
|
||||
source=snap.source,
|
||||
entry_count=snap.entry_count,
|
||||
created_at=snap.created_at,
|
||||
entries=[SBOMEntryRead.model_validate(e) for e in entries],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_sbom_entries(
|
||||
repo_slug: str | None = Query(None),
|
||||
ecosystem: Ecosystem | None = Query(None),
|
||||
license_spdx: str | None = Query(None),
|
||||
is_direct: bool | None = Query(None),
|
||||
is_dev: bool | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[SBOMEntryRead]:
|
||||
"""Return entries from the latest snapshot per repo (default) or filter by repo."""
|
||||
if repo_slug:
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
latest_snap_id_sq = (
|
||||
select(SBOMSnapshot.id)
|
||||
.where(SBOMSnapshot.repo_id == repo.id)
|
||||
.order_by(SBOMSnapshot.snapshot_at.desc())
|
||||
.limit(1)
|
||||
.scalar_subquery()
|
||||
)
|
||||
q = select(SBOMEntry).where(SBOMEntry.snapshot_id == latest_snap_id_sq)
|
||||
else:
|
||||
latest_ids_sq = _latest_snapshot_ids_subquery()
|
||||
q = select(SBOMEntry).where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
|
||||
|
||||
if ecosystem is not None:
|
||||
q = q.where(SBOMEntry.ecosystem == ecosystem)
|
||||
if license_spdx:
|
||||
q = q.where(SBOMEntry.license_spdx == license_spdx)
|
||||
if is_direct is not None:
|
||||
q = q.where(SBOMEntry.is_direct == is_direct)
|
||||
if is_dev is not None:
|
||||
q = q.where(SBOMEntry.is_dev == is_dev)
|
||||
q = q.order_by(SBOMEntry.package_name)
|
||||
result = await session.execute(q)
|
||||
return [SBOMEntryRead.model_validate(e) for e in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/report/licences/", response_model=LicenceReport)
|
||||
async def licence_report(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> LicenceReport:
|
||||
"""Group latest-snapshot SBOM entries by SPDX licence identifier, flag copyleft."""
|
||||
latest_ids_sq = _latest_snapshot_ids_subquery()
|
||||
rows = await session.execute(
|
||||
select(SBOMEntry, ManagedRepo.slug)
|
||||
.join(ManagedRepo, ManagedRepo.id == SBOMEntry.repo_id)
|
||||
.where(SBOMEntry.snapshot_id.in_(select(latest_ids_sq.c.id)))
|
||||
)
|
||||
groups: dict[str | None, dict] = {}
|
||||
copyleft_direct_count = 0
|
||||
for entry, repo_slug in rows.all():
|
||||
key = entry.license_spdx
|
||||
if key not in groups:
|
||||
groups[key] = {"count": 0, "repos": set()}
|
||||
groups[key]["count"] += 1
|
||||
groups[key]["repos"].add(repo_slug)
|
||||
if _is_copyleft(key) and entry.is_direct and not entry.is_dev:
|
||||
copyleft_direct_count += 1
|
||||
|
||||
licence_groups = [
|
||||
LicenceGroup(
|
||||
license_spdx=lic,
|
||||
count=info["count"],
|
||||
repos=sorted(info["repos"]),
|
||||
is_copyleft=_is_copyleft(lic),
|
||||
)
|
||||
for lic, info in sorted(groups.items(), key=lambda x: -x[1]["count"])
|
||||
]
|
||||
return LicenceReport(groups=licence_groups, copyleft_direct_count=copyleft_direct_count)
|
||||
|
||||
|
||||
@router.get("/{repo_slug}", response_model=SBOMRepoView)
|
||||
async def get_repo_sbom(
|
||||
repo_slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SBOMRepoView:
|
||||
"""Return the latest snapshot entries for a specific repo."""
|
||||
repo = await _get_repo_by_slug(repo_slug, session)
|
||||
latest_snap_id_sq = (
|
||||
select(SBOMSnapshot.id)
|
||||
.where(SBOMSnapshot.repo_id == repo.id)
|
||||
.order_by(SBOMSnapshot.snapshot_at.desc())
|
||||
.limit(1)
|
||||
.scalar_subquery()
|
||||
)
|
||||
rows = await session.execute(
|
||||
select(SBOMEntry)
|
||||
.where(SBOMEntry.snapshot_id == latest_snap_id_sq)
|
||||
.order_by(SBOMEntry.package_name)
|
||||
)
|
||||
entries = list(rows.scalars().all())
|
||||
return SBOMRepoView(
|
||||
repo_slug=repo_slug,
|
||||
last_sbom_at=repo.last_sbom_at,
|
||||
entry_count=len(entries),
|
||||
entries=[SBOMEntryRead.model_validate(e) for e in entries],
|
||||
)
|
||||
|
||||
|
||||
async def _get_repo_by_slug(slug: str, session: AsyncSession) -> ManagedRepo:
|
||||
result = await session.execute(select(ManagedRepo).where(ManagedRepo.slug == slug))
|
||||
repo = result.scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{slug}' not found")
|
||||
return repo
|
||||
@@ -1,668 +0,0 @@
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload, selectinload
|
||||
|
||||
from api.database import get_session, engine
|
||||
from api.flow_defs import assertion_result_to_dict, load_flow
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.contribution import Contribution, ContributionStatus, ContributionType
|
||||
from api.models.decision import Decision, DecisionStatus, DecisionType
|
||||
from api.models.domain import Domain
|
||||
from api.models.extension_point import ExtensionPoint
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.sbom_entry import SBOMEntry
|
||||
from api.models.task import Task, TaskPriority, TaskStatus
|
||||
from api.models.technical_debt import TechnicalDebt
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.state import (
|
||||
DecisionTotals,
|
||||
NextStep,
|
||||
StateSummary,
|
||||
TaskTotals,
|
||||
Totals,
|
||||
TopicTotals,
|
||||
WorkstreamTotals,
|
||||
)
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
from task_flow_engine import FlowEngine
|
||||
|
||||
router = APIRouter(prefix="/state", tags=["state"])
|
||||
|
||||
_SUMMARY_CACHE: StateSummary | None = None
|
||||
_SUMMARY_CACHE_AT: float = 0.0
|
||||
_SUMMARY_TTL = 15.0
|
||||
|
||||
|
||||
@router.get("/summary", response_model=StateSummary)
|
||||
async def get_summary(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> StateSummary:
|
||||
global _SUMMARY_CACHE, _SUMMARY_CACHE_AT
|
||||
no_cache = "no-cache" in request.headers.get("cache-control", "")
|
||||
if not no_cache and _SUMMARY_CACHE is not None and (time.monotonic() - _SUMMARY_CACHE_AT) < _SUMMARY_TTL:
|
||||
return _SUMMARY_CACHE
|
||||
# Run all queries sequentially on one session.
|
||||
# AsyncSession does not support concurrent operations (no gather on same session).
|
||||
|
||||
topics_rows = await session.execute(
|
||||
select(Topic)
|
||||
.options(
|
||||
selectinload(Topic.domain),
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
.where(Topic.status != TopicStatus.archived)
|
||||
.order_by(Topic.created_at)
|
||||
)
|
||||
topics = list(topics_rows.scalars().all())
|
||||
topic_ids = [t.id for t in topics]
|
||||
|
||||
topic_workstreams: dict = {t.id: [] for t in topics}
|
||||
if topic_ids:
|
||||
topic_ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.topic_id,
|
||||
Workstream.id,
|
||||
Workstream.slug,
|
||||
Workstream.title,
|
||||
Workstream.status,
|
||||
Workstream.owner,
|
||||
Workstream.due_date,
|
||||
)
|
||||
.where(Workstream.topic_id.in_(topic_ids))
|
||||
.order_by(Workstream.created_at)
|
||||
)
|
||||
for topic_id, ws_id, slug, title, status, owner, due_date in topic_ws_rows:
|
||||
topic_workstreams.setdefault(topic_id, []).append({
|
||||
"id": ws_id,
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"owner": owner,
|
||||
"due_date": due_date,
|
||||
})
|
||||
|
||||
blocking_rows = await session.execute(
|
||||
select(Decision)
|
||||
.where(Decision.decision_type == DecisionType.pending)
|
||||
.where(Decision.status.in_([DecisionStatus.open, DecisionStatus.escalated]))
|
||||
.order_by(Decision.deadline.asc().nullslast(), Decision.created_at)
|
||||
)
|
||||
blocking = list(blocking_rows.scalars().all())
|
||||
|
||||
blocked_rows = await session.execute(
|
||||
select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at)
|
||||
)
|
||||
blocked = list(blocked_rows.scalars().all())
|
||||
|
||||
recent_rows = await session.execute(
|
||||
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20)
|
||||
)
|
||||
recent = list(recent_rows.scalars().all())
|
||||
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
# Task counts per workstream (used to enrich open_workstreams)
|
||||
task_per_ws: dict = {}
|
||||
task_statuses_per_ws: dict = {}
|
||||
for ws_id, tstat, cnt in await session.execute(
|
||||
select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
|
||||
):
|
||||
task_per_ws.setdefault(ws_id, {})[tstat] = cnt
|
||||
task_statuses_per_ws.setdefault(ws_id, []).extend([_value(tstat)] * cnt)
|
||||
|
||||
# Dependency graph for open workstreams
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
|
||||
# Build a slug+title lookup for all workstreams referenced in deps
|
||||
dep_ws_ids = set()
|
||||
dep_task_ids = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
task_lookup: dict = {}
|
||||
if dep_task_ids:
|
||||
task_rows = await session.execute(select(Task).where(Task.id.in_(dep_task_ids)))
|
||||
task_lookup = {t.id: t for t in task_rows.scalars().all()}
|
||||
|
||||
# Index: workstream_id → (depends_on stubs, blocks stubs)
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
target_type="workstream",
|
||||
relationship_type=d.relationship_type,
|
||||
workstream_id=to_id,
|
||||
workstream_slug=ws_lookup[to_id].slug,
|
||||
workstream_title=ws_lookup[to_id].title,
|
||||
description=d.description,
|
||||
))
|
||||
if from_id in dep_index and task_id and task_id in task_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
target_type="task",
|
||||
relationship_type=d.relationship_type,
|
||||
task_id=task_id,
|
||||
task_title=task_lookup[task_id].title,
|
||||
description=d.description,
|
||||
))
|
||||
if to_id and to_id in dep_index and from_id in ws_lookup:
|
||||
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
|
||||
dep_id=d.id,
|
||||
target_type="workstream",
|
||||
relationship_type=d.relationship_type,
|
||||
workstream_id=from_id,
|
||||
workstream_slug=ws_lookup[from_id].slug,
|
||||
workstream_title=ws_lookup[from_id].title,
|
||||
description=d.description,
|
||||
))
|
||||
|
||||
workstream_flow = load_flow("workstream")
|
||||
flow_engine = FlowEngine()
|
||||
effective_status: dict = {}
|
||||
blocked_reasons: dict = {}
|
||||
for w in open_ws:
|
||||
flow_obj = {
|
||||
"status": w.status,
|
||||
"workstation": w.status,
|
||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||
"dependencies": [
|
||||
{"workstation": ws_lookup[d.to_workstream_id].status}
|
||||
for d in dep_rows
|
||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||
],
|
||||
}
|
||||
flow_result = flow_engine.evaluate(flow_obj, workstream_flow)
|
||||
effective_status[w.id] = "blocked" if flow_result.exit_blocked else w.status
|
||||
blocked_reasons[w.id] = [
|
||||
assertion_result_to_dict(item) for item in flow_result.blocking_assertions
|
||||
]
|
||||
|
||||
# Totals — one GROUP BY per table
|
||||
topic_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Topic.status, func.count()).group_by(Topic.status)
|
||||
)}
|
||||
ws_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Workstream.status, func.count()).group_by(Workstream.status)
|
||||
)}
|
||||
task_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Task.status, func.count()).group_by(Task.status)
|
||||
)}
|
||||
dec_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(Decision.status, func.count()).group_by(Decision.status)
|
||||
)}
|
||||
|
||||
totals = Totals(
|
||||
topics=TopicTotals(
|
||||
active=topic_counts.get(TopicStatus.active, 0),
|
||||
paused=topic_counts.get(TopicStatus.paused, 0),
|
||||
archived=topic_counts.get(TopicStatus.archived, 0),
|
||||
total=sum(topic_counts.values()),
|
||||
),
|
||||
workstreams=WorkstreamTotals(
|
||||
active=sum(1 for status in effective_status.values() if status == "active"),
|
||||
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
|
||||
completed=ws_counts.get("completed", 0),
|
||||
archived=ws_counts.get("archived", 0),
|
||||
total=sum(ws_counts.values()),
|
||||
),
|
||||
tasks=TaskTotals(
|
||||
todo=task_counts.get(TaskStatus.todo, 0),
|
||||
in_progress=task_counts.get(TaskStatus.in_progress, 0),
|
||||
blocked=task_counts.get(TaskStatus.blocked, 0),
|
||||
done=task_counts.get(TaskStatus.done, 0),
|
||||
cancelled=task_counts.get(TaskStatus.cancelled, 0),
|
||||
total=sum(task_counts.values()),
|
||||
),
|
||||
decisions=DecisionTotals(
|
||||
open=dec_counts.get(DecisionStatus.open, 0),
|
||||
resolved=dec_counts.get(DecisionStatus.resolved, 0),
|
||||
escalated=dec_counts.get(DecisionStatus.escalated, 0),
|
||||
superseded=dec_counts.get(DecisionStatus.superseded, 0),
|
||||
total=sum(dec_counts.values()),
|
||||
),
|
||||
)
|
||||
|
||||
next_steps = await _derive_next_steps(session)
|
||||
|
||||
# Domain summary stats
|
||||
domain_summaries = await _build_domain_summaries(session)
|
||||
|
||||
# Contribution counts (by type and status)
|
||||
contrib_type_counts = {r[0].value: r[1] for r in await session.execute(
|
||||
select(Contribution.type, func.count()).group_by(Contribution.type)
|
||||
)}
|
||||
contrib_status_counts = {r[0].value: r[1] for r in await session.execute(
|
||||
select(Contribution.status, func.count()).group_by(Contribution.status)
|
||||
)}
|
||||
contribution_counts = {**contrib_type_counts, **contrib_status_counts}
|
||||
|
||||
# Licence risk: copyleft packages in direct prod deps
|
||||
_COPYLEFT_PATS = ("GPL", "AGPL", "LGPL", "EUPL", "CDDL", "MPL")
|
||||
copyleft_risk_rows = await session.execute(
|
||||
select(func.count()).select_from(SBOMEntry)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
# Filter in Python since ILIKE across multiple patterns is verbose in SQLAlchemy
|
||||
all_direct_prod_rows = await session.execute(
|
||||
select(SBOMEntry.license_spdx)
|
||||
.where(SBOMEntry.is_direct.is_(True))
|
||||
.where(SBOMEntry.is_dev.is_(False))
|
||||
)
|
||||
licence_risk_count = sum(
|
||||
1 for (lic,) in all_direct_prod_rows.all()
|
||||
if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS)
|
||||
)
|
||||
|
||||
# Open capability requests (non-terminal statuses)
|
||||
open_cap_req_count = (await session.execute(
|
||||
select(func.count()).select_from(CapabilityRequest).where(
|
||||
CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"])
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
result = StateSummary(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
totals=totals,
|
||||
topics=[
|
||||
TopicWithWorkstreams(
|
||||
**TopicRead.model_validate(t).model_dump(),
|
||||
workstreams=topic_workstreams.get(t.id, []),
|
||||
)
|
||||
for t in topics
|
||||
],
|
||||
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking],
|
||||
blocked_tasks=[TaskRead.model_validate(t) for t in blocked],
|
||||
recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
|
||||
next_steps=next_steps,
|
||||
domains=domain_summaries,
|
||||
contribution_counts=contribution_counts,
|
||||
licence_risk_count=licence_risk_count,
|
||||
open_capability_requests=open_cap_req_count,
|
||||
open_workstreams=[
|
||||
WorkstreamWithDeps(
|
||||
**{
|
||||
**WorkstreamRead.model_validate(w).model_dump(),
|
||||
"status": effective_status.get(w.id, w.status),
|
||||
},
|
||||
tasks_total=sum(task_per_ws.get(w.id, {}).values()),
|
||||
tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0),
|
||||
tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0),
|
||||
tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0),
|
||||
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0),
|
||||
depends_on=dep_index.get(w.id, {}).get("depends_on", []),
|
||||
blocks=dep_index.get(w.id, {}).get("blocks", []),
|
||||
blocked_reasons=blocked_reasons.get(w.id, []),
|
||||
)
|
||||
for w in open_ws
|
||||
],
|
||||
)
|
||||
_SUMMARY_CACHE = result
|
||||
_SUMMARY_CACHE_AT = time.monotonic()
|
||||
return result
|
||||
|
||||
|
||||
async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
||||
"""Compute per-domain stats for the state summary."""
|
||||
domains_rows = await session.execute(
|
||||
select(Domain).options(noload("*")).where(Domain.status == "active").order_by(Domain.name)
|
||||
)
|
||||
domains = list(domains_rows.scalars().all())
|
||||
|
||||
# Repo counts per domain
|
||||
repo_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(ManagedRepo.domain_id, func.count())
|
||||
.where(ManagedRepo.status == "active")
|
||||
.group_by(ManagedRepo.domain_id)
|
||||
)}
|
||||
|
||||
# Active workstream counts per domain (join through topics)
|
||||
ws_per_domain = {}
|
||||
for domain_id, cnt in await session.execute(
|
||||
select(Topic.domain_id, func.count(Workstream.id))
|
||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||
.where(Workstream.status == "active")
|
||||
.group_by(Topic.domain_id)
|
||||
):
|
||||
ws_per_domain[domain_id] = cnt
|
||||
|
||||
# EP counts per domain id (via FK)
|
||||
ep_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(ExtensionPoint.domain_id, func.count()).group_by(ExtensionPoint.domain_id)
|
||||
)}
|
||||
|
||||
# TD counts per domain id (via FK)
|
||||
td_counts = {r[0]: r[1] for r in await session.execute(
|
||||
select(TechnicalDebt.domain_id, func.count()).group_by(TechnicalDebt.domain_id)
|
||||
)}
|
||||
|
||||
return [
|
||||
DomainSummary(
|
||||
slug=d.slug,
|
||||
name=d.name,
|
||||
repo_count=repo_counts.get(d.id, 0),
|
||||
active_workstream_count=ws_per_domain.get(d.id, 0),
|
||||
ep_count=ep_counts.get(d.id, 0),
|
||||
td_count=td_counts.get(d.id, 0),
|
||||
)
|
||||
for d in domains
|
||||
]
|
||||
|
||||
|
||||
@router.get("/deps", response_model=list[WorkstreamWithDeps])
|
||||
async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]:
|
||||
"""Lightweight dep-graph endpoint: open workstreams with their dependency edges only.
|
||||
|
||||
Returns the same structure as open_workstreams in /state/summary but skips
|
||||
the 10-table full-summary computation. Task counts are omitted (all zero).
|
||||
Used by workstreams.md and dependencies.md which only need dep edges.
|
||||
"""
|
||||
open_ws_rows = await session.execute(
|
||||
select(Workstream)
|
||||
.options(noload("*"))
|
||||
.where(Workstream.status.in_(["active", "blocked"]))
|
||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||
)
|
||||
open_ws = list(open_ws_rows.scalars().all())
|
||||
|
||||
open_ws_ids = [w.id for w in open_ws]
|
||||
dep_rows = []
|
||||
if open_ws_ids:
|
||||
dep_result = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id.in_(open_ws_ids))
|
||||
| (WorkstreamDependency.to_workstream_id.in_(open_ws_ids))
|
||||
)
|
||||
)
|
||||
dep_rows = list(dep_result.scalars().all())
|
||||
|
||||
dep_ws_ids: set = set()
|
||||
dep_task_ids: set = set()
|
||||
for d in dep_rows:
|
||||
dep_ws_ids.add(d.from_workstream_id)
|
||||
if d.to_workstream_id:
|
||||
dep_ws_ids.add(d.to_workstream_id)
|
||||
if d.to_task_id:
|
||||
dep_task_ids.add(d.to_task_id)
|
||||
|
||||
ws_lookup: dict = {w.id: w for w in open_ws}
|
||||
extra_ids = dep_ws_ids - set(ws_lookup.keys())
|
||||
if extra_ids:
|
||||
extra_rows = await session.execute(
|
||||
select(Workstream).options(noload("*")).where(Workstream.id.in_(extra_ids))
|
||||
)
|
||||
for w in extra_rows.scalars():
|
||||
ws_lookup[w.id] = w
|
||||
|
||||
task_lookup: dict = {}
|
||||
if dep_task_ids:
|
||||
task_rows = await session.execute(select(Task).options(noload("*")).where(Task.id.in_(dep_task_ids)))
|
||||
task_lookup = {t.id: t for t in task_rows.scalars().all()}
|
||||
|
||||
dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws}
|
||||
for d in dep_rows:
|
||||
from_id, to_id, task_id = d.from_workstream_id, d.to_workstream_id, d.to_task_id
|
||||
if from_id in dep_index and to_id and to_id in ws_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||
workstream_id=to_id, workstream_slug=ws_lookup[to_id].slug,
|
||||
workstream_title=ws_lookup[to_id].title, description=d.description,
|
||||
))
|
||||
if from_id in dep_index and task_id and task_id in task_lookup:
|
||||
dep_index[from_id]["depends_on"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="task", relationship_type=d.relationship_type,
|
||||
task_id=task_id, task_title=task_lookup[task_id].title, description=d.description,
|
||||
))
|
||||
if to_id and to_id in dep_index and from_id in ws_lookup:
|
||||
dep_index[to_id]["blocks"].append(WorkstreamDepStub(
|
||||
dep_id=d.id, target_type="workstream", relationship_type=d.relationship_type,
|
||||
workstream_id=from_id, workstream_slug=ws_lookup[from_id].slug,
|
||||
workstream_title=ws_lookup[from_id].title, description=d.description,
|
||||
))
|
||||
|
||||
return [
|
||||
WorkstreamWithDeps(
|
||||
**WorkstreamRead.model_validate(w).model_dump(),
|
||||
depends_on=dep_index[w.id]["depends_on"],
|
||||
blocks=dep_index[w.id]["blocks"],
|
||||
)
|
||||
for w in open_ws
|
||||
]
|
||||
|
||||
|
||||
_PRIORITY_RANK = {
|
||||
TaskPriority.critical: 0,
|
||||
TaskPriority.high: 1,
|
||||
TaskPriority.medium: 2,
|
||||
TaskPriority.low: 3,
|
||||
}
|
||||
|
||||
|
||||
async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
||||
"""Derive contextual next-action suggestions from current hub state.
|
||||
|
||||
Two signal sources:
|
||||
1. Recently resolved decisions (last 7 days) → first open task in same workstream
|
||||
2. Workstreams whose every dependency is now completed → first todo task in that workstream
|
||||
"""
|
||||
steps: list[NextStep] = []
|
||||
seen_task_ids: set = set()
|
||||
|
||||
# ── Signal 1: recently resolved decisions ────────────────────────────────
|
||||
cutoff = datetime.now(tz=timezone.utc) - timedelta(days=7)
|
||||
resolved_rows = await session.execute(
|
||||
select(Decision)
|
||||
.options(noload("*"))
|
||||
.where(Decision.status == DecisionStatus.resolved)
|
||||
.where(Decision.decided_at >= cutoff)
|
||||
.where(Decision.workstream_id.isnot(None))
|
||||
.order_by(Decision.decided_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
for decision in resolved_rows.scalars().all():
|
||||
open_tasks_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id == decision.workstream_id)
|
||||
.where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress]))
|
||||
)
|
||||
open_tasks = list(open_tasks_rows.scalars().all())
|
||||
if not open_tasks:
|
||||
continue
|
||||
task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
ws = await session.get(Workstream, decision.workstream_id, options=[noload("*")])
|
||||
domain_slug = await _get_domain_slug_for_workstream(ws, session)
|
||||
steps.append(NextStep(
|
||||
type="resolved_decision",
|
||||
domain=domain_slug,
|
||||
workstream_id=ws.id if ws else None,
|
||||
workstream_title=ws.title if ws else None,
|
||||
workstream_slug=ws.slug if ws else None,
|
||||
task_id=task.id,
|
||||
task_title=task.title,
|
||||
message=(
|
||||
f"Decision '{decision.title}' was resolved → "
|
||||
f"'{task.title}' is the next open task in '{ws.title if ws else '?'}'"
|
||||
),
|
||||
))
|
||||
seen_task_ids.add(task.id)
|
||||
|
||||
# ── Signal 2: cleared dependencies ──────────────────────────────────────
|
||||
all_dep_rows = await session.execute(
|
||||
select(
|
||||
WorkstreamDependency.from_workstream_id,
|
||||
WorkstreamDependency.to_workstream_id,
|
||||
).where(WorkstreamDependency.to_workstream_id.isnot(None))
|
||||
)
|
||||
all_deps = all_dep_rows.all()
|
||||
|
||||
# Group from_workstream_id → set of to_workstream_ids
|
||||
dep_map: dict = {}
|
||||
dep_ws_ids = set()
|
||||
for from_ws_id, to_ws_id in all_deps:
|
||||
dep_map.setdefault(from_ws_id, set()).add(to_ws_id)
|
||||
dep_ws_ids.add(from_ws_id)
|
||||
dep_ws_ids.add(to_ws_id)
|
||||
|
||||
ws_info = {}
|
||||
if dep_ws_ids:
|
||||
ws_rows = await session.execute(
|
||||
select(
|
||||
Workstream.id,
|
||||
Workstream.status,
|
||||
Workstream.title,
|
||||
Workstream.slug,
|
||||
Workstream.topic_id,
|
||||
).where(Workstream.id.in_(dep_ws_ids))
|
||||
)
|
||||
ws_info = {
|
||||
ws_id: {
|
||||
"status": status,
|
||||
"title": title,
|
||||
"slug": slug,
|
||||
"topic_id": topic_id,
|
||||
}
|
||||
for ws_id, status, title, slug, topic_id in ws_rows
|
||||
}
|
||||
|
||||
ready_from_ws_ids = [
|
||||
from_ws_id
|
||||
for from_ws_id, to_ws_ids in dep_map.items()
|
||||
if ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
|
||||
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
|
||||
]
|
||||
|
||||
todo_by_ws: dict = {}
|
||||
if ready_from_ws_ids:
|
||||
todo_rows = await session.execute(
|
||||
select(Task)
|
||||
.options(noload("*"))
|
||||
.where(Task.workstream_id.in_(ready_from_ws_ids))
|
||||
.where(Task.status == TaskStatus.todo)
|
||||
)
|
||||
for task in todo_rows.scalars().all():
|
||||
todo_by_ws.setdefault(task.workstream_id, []).append(task)
|
||||
|
||||
for from_ws_id in ready_from_ws_ids:
|
||||
from_ws = ws_info.get(from_ws_id, {})
|
||||
todo_tasks = todo_by_ws.get(from_ws_id, [])
|
||||
if not todo_tasks:
|
||||
continue
|
||||
task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at))
|
||||
if task.id in seen_task_ids:
|
||||
continue
|
||||
domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session)
|
||||
_blocker_slugs = []
|
||||
for tid in dep_map[from_ws_id]:
|
||||
if tid in ws_info:
|
||||
_blocker_slugs.append(ws_info[tid]["slug"])
|
||||
blocker_slugs = ", ".join(_blocker_slugs)
|
||||
steps.append(NextStep(
|
||||
type="dependency_cleared",
|
||||
domain=domain_slug,
|
||||
workstream_id=from_ws_id,
|
||||
workstream_title=from_ws["title"],
|
||||
workstream_slug=from_ws["slug"],
|
||||
task_id=task.id,
|
||||
task_title=task.title,
|
||||
message=(
|
||||
f"All dependencies of '{from_ws['title']}' are completed ({blocker_slugs}) → "
|
||||
f"'{task.title}' is ready to start"
|
||||
),
|
||||
))
|
||||
seen_task_ids.add(task.id)
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
async def _get_domain_slug_for_workstream(ws: Workstream | None, session: AsyncSession) -> str | None:
|
||||
"""Get the domain slug for a workstream via its topic."""
|
||||
if ws is None or ws.topic_id is None:
|
||||
return None
|
||||
return await _get_domain_slug_for_topic(ws.topic_id, session)
|
||||
|
||||
|
||||
async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | None:
|
||||
"""Get the domain slug for a topic id."""
|
||||
if topic_id is None:
|
||||
return None
|
||||
topic = await session.get(Topic, topic_id, options=[noload("*")])
|
||||
if topic is None or topic.domain_id is None:
|
||||
return None
|
||||
domain = await session.get(Domain, topic.domain_id, options=[noload("*")])
|
||||
return domain.slug if domain else None
|
||||
|
||||
|
||||
def _value(item):
|
||||
return item.value if hasattr(item, "value") else item
|
||||
|
||||
|
||||
@router.get("/next_steps", response_model=list[NextStep])
|
||||
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
|
||||
"""Derive contextual next-action suggestions from current hub state.
|
||||
|
||||
Returns suggestions based on:
|
||||
- Recently resolved decisions → first open task in the same workstream
|
||||
- Workstreams whose every dependency workstream is now completed → first todo task
|
||||
"""
|
||||
return await _derive_next_steps(session)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check() -> dict:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
return {"status": "ok", "db": "connected"}
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"status": "error", "db": str(exc)},
|
||||
)
|
||||
@@ -1,142 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TaskRead])
|
||||
async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
assignee: str | None = None,
|
||||
needs_human: bool | None = Query(None),
|
||||
priority: str | None = None,
|
||||
due_date_before: date | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
if workstream_id:
|
||||
q = q.where(Task.workstream_id == workstream_id)
|
||||
if status:
|
||||
q = q.where(Task.status == status)
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
if needs_human is not None:
|
||||
q = q.where(Task.needs_human == needs_human)
|
||||
if priority:
|
||||
q = q.where(Task.priority == priority)
|
||||
if due_date_before is not None:
|
||||
q = q.where(Task.due_date <= due_date_before)
|
||||
q = q.order_by(Task.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TaskRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
body: TaskCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = Task(**body.model_dump())
|
||||
session.add(task)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskRead)
|
||||
async def get_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskRead)
|
||||
async def update_task(
|
||||
task_id: uuid.UUID,
|
||||
body: TaskUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Separate token fields from task fields
|
||||
token_field_names = {"tokens_in", "tokens_out", "workplan_tokens_in", "workplan_tokens_out", "token_note", "model", "agent", "session_id"}
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names}
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(task, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
|
||||
# Token event — three-tier logic, only when marking done
|
||||
if update_data.get("status") == "done":
|
||||
if "tokens_in" in token_data and "tokens_out" in token_data:
|
||||
# Tier 1: exact counts — default note "measured"; caller may override with token_note
|
||||
tin = token_data["tokens_in"]
|
||||
tout = token_data["tokens_out"]
|
||||
tnote = token_data.get("token_note") or "measured"
|
||||
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
|
||||
# Tier 2: prorate workplan total across task count
|
||||
count_result = await session.execute(
|
||||
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
|
||||
)
|
||||
task_count = max(count_result.scalar() or 1, 1)
|
||||
tin = token_data["workplan_tokens_in"] // task_count
|
||||
tout = token_data["workplan_tokens_out"] // task_count
|
||||
tnote = "workplan"
|
||||
else:
|
||||
# Tier 3: heuristic fallback
|
||||
tin, tout, tnote = 1000, 500, "heuristic"
|
||||
|
||||
# Resolve repo_id via workstream
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
repo_id = ws.repo_id if ws else None
|
||||
|
||||
event = TokenEvent(
|
||||
task_id=task_id,
|
||||
workstream_id=task.workstream_id,
|
||||
repo_id=repo_id,
|
||||
tokens_in=tin,
|
||||
tokens_out=tout,
|
||||
model=token_data.get("model"),
|
||||
agent=token_data.get("agent"),
|
||||
session_id=token_data.get("session_id"),
|
||||
ref_type="task",
|
||||
ref_id=str(task_id),
|
||||
note=tnote,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}", response_model=TaskRead)
|
||||
async def cancel_task(
|
||||
task_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Task:
|
||||
task = await session.get(Task, task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
task.status = TaskStatus.cancelled
|
||||
await session.commit()
|
||||
await session.refresh(task)
|
||||
return task
|
||||
@@ -1,140 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.technical_debt import TDNote, TDStatus, TechnicalDebt
|
||||
from api.schemas.technical_debt import TDCreate, TDNoteCreate, TDNoteRead, TDRead, TDUpdate
|
||||
|
||||
router = APIRouter(prefix="/technical-debt", tags=["technical-debt"])
|
||||
|
||||
|
||||
async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
"""Resolve a domain slug to its UUID, raising 422 if unknown."""
|
||||
row = await session.execute(
|
||||
select(Domain.id).where(Domain.slug == slug, Domain.status == "active")
|
||||
)
|
||||
domain_id = row.scalar_one_or_none()
|
||||
if domain_id is None:
|
||||
valid = [r[0] for r in (await session.execute(
|
||||
select(Domain.slug).where(Domain.status == "active")
|
||||
)).all()]
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}",
|
||||
)
|
||||
return domain_id
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TDRead])
|
||||
async def list_td(
|
||||
domain: str | None = None,
|
||||
status: str | None = None, # str to accept both legacy and workflow values
|
||||
debt_type: str | None = None,
|
||||
severity: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TechnicalDebt]:
|
||||
q = select(TechnicalDebt)
|
||||
if domain:
|
||||
domain_id = await _resolve_domain_id(domain, session)
|
||||
q = q.where(TechnicalDebt.domain_id == domain_id)
|
||||
if status:
|
||||
q = q.where(TechnicalDebt.status == status)
|
||||
if debt_type:
|
||||
q = q.where(TechnicalDebt.debt_type == debt_type)
|
||||
if severity:
|
||||
q = q.where(TechnicalDebt.severity == severity)
|
||||
q = q.order_by(TechnicalDebt.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TDRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_td(
|
||||
body: TDCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TechnicalDebt:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
data = body.model_dump(exclude={"domain"})
|
||||
data["domain_id"] = domain_id
|
||||
td = TechnicalDebt(**data)
|
||||
session.add(td)
|
||||
await session.commit()
|
||||
await session.refresh(td)
|
||||
return td
|
||||
|
||||
|
||||
@router.get("/{td_id}", response_model=TDRead)
|
||||
async def get_td(
|
||||
td_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TechnicalDebt:
|
||||
td = await session.get(TechnicalDebt, td_id)
|
||||
if td is None:
|
||||
raise HTTPException(status_code=404, detail="Technical debt item not found")
|
||||
return td
|
||||
|
||||
|
||||
@router.patch("/{td_id}", response_model=TDRead)
|
||||
async def update_td(
|
||||
td_id: uuid.UUID,
|
||||
body: TDUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TechnicalDebt:
|
||||
td = await session.get(TechnicalDebt, td_id)
|
||||
if td is None:
|
||||
raise HTTPException(status_code=404, detail="Technical debt item not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(td, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(td)
|
||||
return td
|
||||
|
||||
|
||||
@router.delete("/{td_id}", response_model=TDRead)
|
||||
async def defer_td(
|
||||
td_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TechnicalDebt:
|
||||
td = await session.get(TechnicalDebt, td_id)
|
||||
if td is None:
|
||||
raise HTTPException(status_code=404, detail="Technical debt item not found")
|
||||
td.status = TDStatus.deferred
|
||||
await session.commit()
|
||||
await session.refresh(td)
|
||||
return td
|
||||
|
||||
|
||||
# ── Notes ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{td_id}/notes", response_model=list[TDNoteRead])
|
||||
async def list_notes(
|
||||
td_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TDNote]:
|
||||
td = await session.get(TechnicalDebt, td_id)
|
||||
if td is None:
|
||||
raise HTTPException(status_code=404, detail="Technical debt item not found")
|
||||
result = await session.execute(
|
||||
select(TDNote).where(TDNote.td_id == td_id).order_by(TDNote.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/{td_id}/notes", response_model=TDNoteRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_note(
|
||||
td_id: uuid.UUID,
|
||||
body: TDNoteCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TDNote:
|
||||
td = await session.get(TechnicalDebt, td_id)
|
||||
if td is None:
|
||||
raise HTTPException(status_code=404, detail="Technical debt item not found")
|
||||
note = TDNote(td_id=td_id, **body.model_dump())
|
||||
session.add(note)
|
||||
await session.commit()
|
||||
await session.refresh(note)
|
||||
return note
|
||||
@@ -1,228 +0,0 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.task import Task
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventPatch, TokenEventRead, TokenSummary
|
||||
|
||||
router = APIRouter(prefix="/token-events", tags=["token-events"])
|
||||
|
||||
|
||||
@router.post("/", response_model=TokenEventRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token_event(
|
||||
body: TokenEventCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenEvent:
|
||||
data = body.model_dump()
|
||||
|
||||
# Auto-populate workstream_id from task if not provided
|
||||
if data.get("task_id") and not data.get("workstream_id"):
|
||||
task = await session.get(Task, data["task_id"])
|
||||
if task:
|
||||
data["workstream_id"] = task.workstream_id
|
||||
|
||||
# Auto-populate repo_id from workstream if not provided
|
||||
if data.get("workstream_id") and not data.get("repo_id"):
|
||||
ws = await session.get(Workstream, data["workstream_id"])
|
||||
if ws and ws.repo_id:
|
||||
data["repo_id"] = ws.repo_id
|
||||
|
||||
event = TokenEvent(**data)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
@router.get("/summary/", response_model=TokenSummary)
|
||||
async def get_token_summary(
|
||||
scope: str = Query(..., description="task|workstream|repo|commit|release|session"),
|
||||
id: str = Query(..., description="FK value or ref_id depending on scope"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenSummary:
|
||||
q = select(TokenEvent)
|
||||
|
||||
if scope == "task":
|
||||
try:
|
||||
uid = uuid.UUID(id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=task")
|
||||
q = q.where(TokenEvent.task_id == uid)
|
||||
elif scope == "workstream":
|
||||
try:
|
||||
uid = uuid.UUID(id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=workstream")
|
||||
q = q.where(TokenEvent.workstream_id == uid)
|
||||
elif scope == "repo":
|
||||
try:
|
||||
uid = uuid.UUID(id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422, detail="id must be a valid UUID for scope=repo")
|
||||
q = q.where(TokenEvent.repo_id == uid)
|
||||
elif scope in ("commit", "release", "session"):
|
||||
q = q.where(TokenEvent.ref_type == scope, TokenEvent.ref_id == id)
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown scope: {scope!r}")
|
||||
|
||||
result = await session.execute(q)
|
||||
events = list(result.scalars().all())
|
||||
|
||||
tokens_in = sum(e.tokens_in for e in events)
|
||||
tokens_out = sum(e.tokens_out for e in events)
|
||||
|
||||
by_model: dict[str, int] = defaultdict(int)
|
||||
by_agent: dict[str, int] = defaultdict(int)
|
||||
for e in events:
|
||||
if e.model:
|
||||
by_model[e.model] += e.tokens_in + e.tokens_out
|
||||
if e.agent:
|
||||
by_agent[e.agent] += e.tokens_in + e.tokens_out
|
||||
|
||||
return TokenSummary(
|
||||
scope=scope,
|
||||
scope_id=id,
|
||||
tokens_in=tokens_in,
|
||||
tokens_out=tokens_out,
|
||||
tokens_total=tokens_in + tokens_out,
|
||||
event_count=len(events),
|
||||
by_model=dict(by_model),
|
||||
by_agent=dict(by_agent),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
|
||||
async def get_tokens_by_repo(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RepoTokenSummary]:
|
||||
"""Aggregate token consumption per repo, resolving via the full graph.
|
||||
|
||||
Resolution order for each event:
|
||||
1. token_events.repo_id (direct)
|
||||
2. → workstreams.repo_id (via workstream_id)
|
||||
3. → task.workstream_id → workstreams.repo_id (via task_id)
|
||||
|
||||
Only events that resolve to a repo are included.
|
||||
"""
|
||||
# Fetch all events, workstreams, repos in three queries (avoids N+1)
|
||||
events_result = await session.execute(select(TokenEvent))
|
||||
events = list(events_result.scalars().all())
|
||||
|
||||
ws_result = await session.execute(select(Workstream))
|
||||
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
|
||||
|
||||
task_result = await session.execute(select(Task))
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
|
||||
|
||||
repo_result = await session.execute(select(ManagedRepo))
|
||||
repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()}
|
||||
|
||||
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
|
||||
if e.repo_id:
|
||||
return e.repo_id
|
||||
ws_id = e.workstream_id
|
||||
if not ws_id and e.task_id and e.task_id in task_map:
|
||||
ws_id = task_map[e.task_id].workstream_id
|
||||
if ws_id and ws_id in ws_map:
|
||||
return ws_map[ws_id].repo_id
|
||||
return None
|
||||
|
||||
groups: dict[uuid.UUID, dict] = {}
|
||||
for e in events:
|
||||
rid = resolve_repo_id(e)
|
||||
if not rid or rid not in repo_map:
|
||||
continue
|
||||
if rid not in groups:
|
||||
groups[rid] = {
|
||||
"repo_id": rid,
|
||||
"repo_slug": repo_map[rid].slug,
|
||||
"tokens_in": 0,
|
||||
"tokens_out": 0,
|
||||
"event_count": 0,
|
||||
"by_model": defaultdict(int),
|
||||
"by_note": defaultdict(int),
|
||||
}
|
||||
g = groups[rid]
|
||||
g["tokens_in"] += e.tokens_in
|
||||
g["tokens_out"] += e.tokens_out
|
||||
g["event_count"] += 1
|
||||
if e.model:
|
||||
g["by_model"][e.model] += e.tokens_in + e.tokens_out
|
||||
g["by_note"][e.note or "unknown"] += e.tokens_in + e.tokens_out
|
||||
|
||||
return [
|
||||
RepoTokenSummary(
|
||||
**{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in g.items()},
|
||||
tokens_total=g["tokens_in"] + g["tokens_out"],
|
||||
)
|
||||
for g in sorted(groups.values(), key=lambda x: -(x["tokens_in"] + x["tokens_out"]))
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/{event_id}", response_model=TokenEventRead)
|
||||
async def patch_token_event(
|
||||
event_id: uuid.UUID,
|
||||
body: TokenEventPatch,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenEvent:
|
||||
event = await session.get(TokenEvent, event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Token event not found")
|
||||
for field, value in body.model_dump(exclude_none=True).items():
|
||||
setattr(event, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event
|
||||
|
||||
|
||||
@router.get("/{event_id}", response_model=TokenEventRead)
|
||||
async def get_token_event(
|
||||
event_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenEvent:
|
||||
event = await session.get(TokenEvent, event_id)
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Token event not found")
|
||||
return event
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TokenEventRead])
|
||||
async def list_token_events(
|
||||
task_id: uuid.UUID | None = None,
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
ref_type: str | None = None,
|
||||
ref_id: str | None = None,
|
||||
model: str | None = None,
|
||||
agent: str | None = None,
|
||||
note: str | None = None,
|
||||
limit: int = Query(100, le=1000),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[TokenEvent]:
|
||||
q = select(TokenEvent)
|
||||
if task_id:
|
||||
q = q.where(TokenEvent.task_id == task_id)
|
||||
if workstream_id:
|
||||
q = q.where(TokenEvent.workstream_id == workstream_id)
|
||||
if repo_id:
|
||||
q = q.where(TokenEvent.repo_id == repo_id)
|
||||
if ref_type:
|
||||
q = q.where(TokenEvent.ref_type == ref_type)
|
||||
if ref_id:
|
||||
q = q.where(TokenEvent.ref_id == ref_id)
|
||||
if model:
|
||||
q = q.where(TokenEvent.model == model)
|
||||
if agent:
|
||||
q = q.where(TokenEvent.agent == agent)
|
||||
if note:
|
||||
q = q.where(TokenEvent.note == note)
|
||||
q = q.order_by(TokenEvent.created_at.desc()).limit(limit)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
@@ -1,107 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import noload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.domain import Domain
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.schemas.topic import TopicCreate, TopicRead, TopicUpdate, TopicWithWorkstreams
|
||||
|
||||
router = APIRouter(prefix="/topics", tags=["topics"])
|
||||
|
||||
|
||||
async def _resolve_domain_id(domain_slug: str, session: AsyncSession) -> uuid.UUID:
|
||||
"""Resolve a domain slug to its UUID. Raises 404 if not found."""
|
||||
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.id
|
||||
|
||||
|
||||
@router.get("/", response_model=list[TopicRead])
|
||||
async def list_topics(
|
||||
response: Response,
|
||||
status: TopicStatus | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Topic]:
|
||||
response.headers["Cache-Control"] = "max-age=60, stale-while-revalidate=30"
|
||||
q = select(Topic).options(
|
||||
noload(Topic.workstreams),
|
||||
noload(Topic.decisions),
|
||||
noload(Topic.progress_events),
|
||||
)
|
||||
if status:
|
||||
q = q.where(Topic.status == status)
|
||||
q = q.order_by(Topic.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.post("/", response_model=TopicRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_topic(
|
||||
body: TopicCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
domain_id = await _resolve_domain_id(body.domain, session)
|
||||
existing = await session.execute(select(Topic).where(Topic.slug == body.slug))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=409, detail=f"Topic slug '{body.slug}' already exists")
|
||||
topic = Topic(
|
||||
slug=body.slug,
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
domain_id=domain_id,
|
||||
status=body.status,
|
||||
)
|
||||
session.add(topic)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.get("/{topic_id}", response_model=TopicWithWorkstreams)
|
||||
async def get_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
return topic
|
||||
|
||||
|
||||
@router.patch("/{topic_id}", response_model=TopicRead)
|
||||
async def update_topic(
|
||||
topic_id: uuid.UUID,
|
||||
body: TopicUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if "domain" in updates:
|
||||
topic.domain_id = await _resolve_domain_id(updates.pop("domain"), session)
|
||||
for field, value in updates.items():
|
||||
setattr(topic, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
|
||||
|
||||
@router.delete("/{topic_id}", response_model=TopicRead)
|
||||
async def archive_topic(
|
||||
topic_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Topic:
|
||||
topic = await session.get(Topic, topic_id)
|
||||
if topic is None:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
topic.status = TopicStatus.archived
|
||||
await session.commit()
|
||||
await session.refresh(topic)
|
||||
return topic
|
||||
@@ -1,240 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.schemas.tpsc import (
|
||||
TPSCCatalogCreate, TPSCCatalogRead,
|
||||
TPSCEntryRead, TPSCIngestRequest, TPSCSnapshotRead,
|
||||
TPSCGDPRReport, TPSCGDPRWarning, GDPR_WARNING_LEVELS,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tpsc", tags=["tpsc"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catalog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/catalog/", response_model=list[TPSCCatalogRead])
|
||||
async def list_catalog(
|
||||
gdpr_maturity: str | None = None,
|
||||
category: str | None = None,
|
||||
pricing_model: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
q = select(TPSCCatalog).where(TPSCCatalog.status != "deprecated")
|
||||
if gdpr_maturity:
|
||||
q = q.where(TPSCCatalog.gdpr_maturity == gdpr_maturity)
|
||||
if category:
|
||||
q = q.where(TPSCCatalog.category == category)
|
||||
if pricing_model:
|
||||
q = q.where(TPSCCatalog.pricing_model == pricing_model)
|
||||
q = q.order_by(TPSCCatalog.name)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/catalog/{slug}", response_model=TPSCCatalogRead)
|
||||
async def get_catalog_entry(slug: str, session: AsyncSession = Depends(get_session)):
|
||||
row = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug == slug))).scalar_one_or_none()
|
||||
if not row:
|
||||
raise HTTPException(404, f"Service '{slug}' not found in catalog")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/catalog/", response_model=TPSCCatalogRead, status_code=201)
|
||||
async def register_service(body: TPSCCatalogCreate, session: AsyncSession = Depends(get_session)):
|
||||
"""Register a new service or upsert an existing one by slug."""
|
||||
existing = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug == body.slug))).scalar_one_or_none()
|
||||
if existing:
|
||||
for k, v in body.model_dump(exclude_unset=True).items():
|
||||
setattr(existing, k, v)
|
||||
existing.updated_at = datetime.now(tz=timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
return existing
|
||||
entry = TPSCCatalog(**body.model_dump())
|
||||
session.add(entry)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ingest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/ingest/", response_model=TPSCSnapshotRead, status_code=201)
|
||||
async def ingest_tpsc(body: TPSCIngestRequest, session: AsyncSession = Depends(get_session)):
|
||||
"""Accept a tpsc.yaml snapshot for a repo."""
|
||||
# Resolve repo_id
|
||||
repo = (await session.execute(select(ManagedRepo).where(ManagedRepo.slug == body.repo_slug))).scalar_one_or_none()
|
||||
repo_id = repo.id if repo else None
|
||||
|
||||
# Build catalog lookup by slug
|
||||
slugs = {e.service_slug for e in body.entries}
|
||||
catalog_rows = (await session.execute(select(TPSCCatalog).where(TPSCCatalog.slug.in_(slugs)))).scalars().all()
|
||||
catalog_map = {r.slug: r for r in catalog_rows}
|
||||
|
||||
snapshot = TPSCSnapshot(
|
||||
repo_id=repo_id,
|
||||
source_file=body.source_file,
|
||||
entry_count=len(body.entries),
|
||||
)
|
||||
session.add(snapshot)
|
||||
await session.flush()
|
||||
|
||||
entries_with_cats = []
|
||||
for e in body.entries:
|
||||
cat = catalog_map.get(e.service_slug)
|
||||
entry = TPSCEntry(
|
||||
snapshot_id=snapshot.id,
|
||||
catalog_id=cat.id if cat else None,
|
||||
service_slug=e.service_slug,
|
||||
purpose=e.purpose,
|
||||
auth_type=e.auth_type,
|
||||
endpoint_override=e.endpoint_override,
|
||||
notes=e.notes,
|
||||
)
|
||||
session.add(entry)
|
||||
entries_with_cats.append((entry, cat))
|
||||
|
||||
await session.flush() # assign UUIDs to all entries
|
||||
await session.commit()
|
||||
await session.refresh(snapshot)
|
||||
|
||||
entry_reads = [
|
||||
TPSCEntryRead(
|
||||
id=entry.id,
|
||||
snapshot_id=snapshot.id,
|
||||
catalog_id=cat.id if cat else None,
|
||||
service_slug=entry.service_slug,
|
||||
purpose=entry.purpose,
|
||||
auth_type=entry.auth_type,
|
||||
endpoint_override=entry.endpoint_override,
|
||||
notes=entry.notes,
|
||||
gdpr_maturity=cat.gdpr_maturity if cat else None,
|
||||
gdpr_warning=(cat.gdpr_maturity in GDPR_WARNING_LEVELS) if cat else True,
|
||||
pricing_model=cat.pricing_model if cat else None,
|
||||
)
|
||||
for entry, cat in entries_with_cats
|
||||
]
|
||||
|
||||
return TPSCSnapshotRead(
|
||||
id=snapshot.id,
|
||||
repo_id=snapshot.repo_id,
|
||||
snapshot_at=snapshot.snapshot_at,
|
||||
source_file=snapshot.source_file,
|
||||
entry_count=snapshot.entry_count,
|
||||
entries=entry_reads,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/snapshots/", response_model=list[TPSCSnapshotRead])
|
||||
async def list_snapshots(
|
||||
repo_slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
q = select(TPSCSnapshot).options(
|
||||
selectinload(TPSCSnapshot.entries).selectinload(TPSCEntry.catalog_entry)
|
||||
)
|
||||
if repo_slug:
|
||||
repo = (await session.execute(select(ManagedRepo).where(ManagedRepo.slug == repo_slug))).scalar_one_or_none()
|
||||
if not repo:
|
||||
raise HTTPException(404, f"Repo '{repo_slug}' not found")
|
||||
q = q.where(TPSCSnapshot.repo_id == repo.id)
|
||||
q = q.order_by(TPSCSnapshot.snapshot_at.desc())
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
result = []
|
||||
for snap in rows:
|
||||
entry_reads = []
|
||||
for e in snap.entries:
|
||||
cat = e.catalog_entry
|
||||
entry_reads.append(TPSCEntryRead(
|
||||
id=e.id,
|
||||
snapshot_id=e.snapshot_id,
|
||||
catalog_id=e.catalog_id,
|
||||
service_slug=e.service_slug,
|
||||
purpose=e.purpose,
|
||||
auth_type=e.auth_type,
|
||||
endpoint_override=e.endpoint_override,
|
||||
notes=e.notes,
|
||||
gdpr_maturity=cat.gdpr_maturity if cat else None,
|
||||
gdpr_warning=(cat.gdpr_maturity in GDPR_WARNING_LEVELS) if cat else True,
|
||||
pricing_model=cat.pricing_model if cat else None,
|
||||
))
|
||||
result.append(TPSCSnapshotRead(
|
||||
id=snap.id,
|
||||
repo_id=snap.repo_id,
|
||||
snapshot_at=snap.snapshot_at,
|
||||
source_file=snap.source_file,
|
||||
entry_count=snap.entry_count,
|
||||
entries=entry_reads,
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GDPR report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/report/gdpr", response_model=TPSCGDPRReport)
|
||||
async def gdpr_report(session: AsyncSession = Depends(get_session)):
|
||||
"""Aggregated GDPR warnings across all latest repo snapshots."""
|
||||
# Latest snapshot per repo
|
||||
latest_sub = (
|
||||
select(TPSCSnapshot.repo_id, func.max(TPSCSnapshot.snapshot_at).label("max_at"))
|
||||
.group_by(TPSCSnapshot.repo_id)
|
||||
.subquery()
|
||||
)
|
||||
latest_snaps = (await session.execute(
|
||||
select(TPSCSnapshot)
|
||||
.join(latest_sub, (TPSCSnapshot.repo_id == latest_sub.c.repo_id) & (TPSCSnapshot.snapshot_at == latest_sub.c.max_at))
|
||||
.options(selectinload(TPSCSnapshot.entries).selectinload(TPSCEntry.catalog_entry))
|
||||
)).scalars().all()
|
||||
|
||||
# Repo slug lookup
|
||||
all_repos = (await session.execute(select(ManagedRepo))).scalars().all()
|
||||
repo_map = {r.id: r.slug for r in all_repos}
|
||||
|
||||
all_services = (await session.execute(select(TPSCCatalog))).scalars().all()
|
||||
by_maturity: dict[str, int] = {}
|
||||
for s in all_services:
|
||||
by_maturity[s.gdpr_maturity] = by_maturity.get(s.gdpr_maturity, 0) + 1
|
||||
|
||||
warnings = []
|
||||
seen = set()
|
||||
for snap in latest_snaps:
|
||||
repo_slug = repo_map.get(snap.repo_id) if snap.repo_id else None
|
||||
for entry in snap.entries:
|
||||
cat = entry.catalog_entry
|
||||
maturity = cat.gdpr_maturity if cat else "unknown"
|
||||
if maturity in GDPR_WARNING_LEVELS:
|
||||
key = (repo_slug, entry.service_slug)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
warnings.append(TPSCGDPRWarning(
|
||||
repo_slug=repo_slug,
|
||||
service_slug=entry.service_slug,
|
||||
gdpr_maturity=maturity,
|
||||
purpose=entry.purpose,
|
||||
pricing_model=cat.pricing_model if cat else None,
|
||||
))
|
||||
|
||||
return TPSCGDPRReport(
|
||||
generated_at=datetime.now(tz=timezone.utc),
|
||||
total_services=len(all_services),
|
||||
warning_count=len(warnings),
|
||||
warnings=warnings,
|
||||
by_maturity=by_maturity,
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.task import Task
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["dependencies"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=WorkstreamDependencyRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_dependency(
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamDependencyCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkstreamDependency:
|
||||
"""Record that workstream_id depends on another workstream or a task."""
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
raise HTTPException(status_code=404, detail="from workstream not found")
|
||||
|
||||
has_workstream_target = body.to_workstream_id is not None
|
||||
has_task_target = body.to_task_id is not None
|
||||
if has_workstream_target == has_task_target:
|
||||
raise HTTPException(status_code=422, detail="provide exactly one dependency target")
|
||||
|
||||
if body.to_workstream_id and await session.get(Workstream, body.to_workstream_id) is None:
|
||||
raise HTTPException(status_code=404, detail="target workstream not found")
|
||||
if body.to_task_id and await session.get(Task, body.to_task_id) is None:
|
||||
raise HTTPException(status_code=404, detail="target task not found")
|
||||
if workstream_id == body.to_workstream_id:
|
||||
raise HTTPException(status_code=422, detail="a workstream cannot depend on itself")
|
||||
|
||||
dep = WorkstreamDependency(
|
||||
from_workstream_id=workstream_id,
|
||||
to_workstream_id=body.to_workstream_id,
|
||||
to_task_id=body.to_task_id,
|
||||
relationship_type=body.relationship_type,
|
||||
description=body.description,
|
||||
)
|
||||
session.add(dep)
|
||||
await session.commit()
|
||||
await session.refresh(dep)
|
||||
return dep
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{workstream_id}/dependencies/",
|
||||
response_model=list[WorkstreamDependencyRead],
|
||||
)
|
||||
async def list_dependencies(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[WorkstreamDependency]:
|
||||
"""Return all dependency edges touching this workstream (both directions)."""
|
||||
if await session.get(Workstream, workstream_id) is None:
|
||||
raise HTTPException(status_code=404, detail="workstream not found")
|
||||
rows = await session.execute(
|
||||
select(WorkstreamDependency).where(
|
||||
(WorkstreamDependency.from_workstream_id == workstream_id)
|
||||
| (WorkstreamDependency.to_workstream_id == workstream_id)
|
||||
)
|
||||
)
|
||||
return list(rows.scalars().all())
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{workstream_id}/dependencies/{dep_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_dependency(
|
||||
workstream_id: uuid.UUID,
|
||||
dep_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Hard-delete a dependency edge. Removing a constraint is safe — no information is lost."""
|
||||
dep = await session.get(WorkstreamDependency, dep_id)
|
||||
if dep is None:
|
||||
raise HTTPException(status_code=404, detail="dependency not found")
|
||||
if dep.from_workstream_id != workstream_id:
|
||||
raise HTTPException(status_code=403, detail="dependency does not belong to this workstream")
|
||||
await session.delete(dep)
|
||||
await session.commit()
|
||||
@@ -1,208 +0,0 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.events import EventEnvelope, publish_event
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.workstream import (
|
||||
WorkstreamCreate,
|
||||
WorkstreamRead,
|
||||
WorkstreamStatus,
|
||||
WorkstreamUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||
|
||||
_INDEX_CACHE: dict[str, Any] | None = None
|
||||
_INDEX_CACHE_AT: float = 0.0
|
||||
_INDEX_TTL = 30.0
|
||||
|
||||
|
||||
def _repo_path(repo: ManagedRepo) -> Path | None:
|
||||
hostname = socket.gethostname()
|
||||
candidates = []
|
||||
host_paths = repo.host_paths or {}
|
||||
if host_paths.get(hostname):
|
||||
candidates.append(host_paths[hostname])
|
||||
if repo.local_path:
|
||||
candidates.append(repo.local_path)
|
||||
for raw in candidates:
|
||||
path = Path(raw).expanduser()
|
||||
if path.is_dir():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _frontmatter(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return {}
|
||||
if not text.startswith("---\n"):
|
||||
return {}
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return {}
|
||||
|
||||
data: dict[str, Any] = {}
|
||||
for raw_line in text[4:end].splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
||||
value = value[1:-1]
|
||||
data[key.strip()] = value
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/", response_model=list[WorkstreamRead])
|
||||
async def list_workstreams(
|
||||
topic_id: uuid.UUID | None = None,
|
||||
repo_id: uuid.UUID | None = None,
|
||||
repo_goal_id: uuid.UUID | None = None,
|
||||
status: WorkstreamStatus | None = None,
|
||||
owner: str | None = None,
|
||||
slug: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Workstream]:
|
||||
q = select(Workstream)
|
||||
if topic_id:
|
||||
q = q.where(Workstream.topic_id == topic_id)
|
||||
if repo_id:
|
||||
q = q.where(Workstream.repo_id == repo_id)
|
||||
if repo_goal_id:
|
||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
||||
if status:
|
||||
q = q.where(Workstream.status == status)
|
||||
if owner:
|
||||
q = q.where(Workstream.owner == owner)
|
||||
if slug:
|
||||
q = q.where(Workstream.slug == slug)
|
||||
q = q.order_by(
|
||||
Workstream.planning_priority.asc().nullslast(),
|
||||
Workstream.planning_order.asc().nullslast(),
|
||||
Workstream.updated_at.desc(),
|
||||
)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/workplan-index")
|
||||
async def workplan_index(
|
||||
refresh: bool = Query(False, description="Force cache invalidation"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Map file-backed workstream ids to their local workplan filenames."""
|
||||
global _INDEX_CACHE, _INDEX_CACHE_AT
|
||||
if not refresh and _INDEX_CACHE is not None and (time.monotonic() - _INDEX_CACHE_AT) < _INDEX_TTL:
|
||||
return _INDEX_CACHE
|
||||
|
||||
result = await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
|
||||
)
|
||||
index: dict[str, Any] = {}
|
||||
for repo in result.scalars().all():
|
||||
root = _repo_path(repo)
|
||||
if root is None:
|
||||
continue
|
||||
for directory, archived in (
|
||||
(root / "workplans", False),
|
||||
(root / "workplans" / "archived", True),
|
||||
):
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
data = _frontmatter(path)
|
||||
workstream_id = data.get("state_hub_workstream_id")
|
||||
if not workstream_id:
|
||||
continue
|
||||
index[str(workstream_id)] = {
|
||||
"filename": path.name,
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"repo_slug": repo.slug,
|
||||
"archived": archived,
|
||||
}
|
||||
_INDEX_CACHE = {"workstreams": index}
|
||||
_INDEX_CACHE_AT = time.monotonic()
|
||||
return _INDEX_CACHE
|
||||
|
||||
|
||||
@router.post("/", response_model=WorkstreamRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_workstream(
|
||||
body: WorkstreamCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = Workstream(**body.model_dump())
|
||||
session.add(ws)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.get("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def get_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
return ws
|
||||
|
||||
|
||||
@router.patch("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def update_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
body: WorkstreamUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
prev_status = ws.status
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
setattr(ws, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
|
||||
if prev_status != "completed" and ws.status == "completed":
|
||||
subject = "org.statehub.workstream.completed"
|
||||
envelope = EventEnvelope.new(
|
||||
subject,
|
||||
attributes={
|
||||
"workstream_id": str(ws.id),
|
||||
"slug": ws.slug,
|
||||
"title": ws.title,
|
||||
"topic_id": str(ws.topic_id),
|
||||
"repo_id": str(ws.repo_id) if ws.repo_id else None,
|
||||
"repo_goal_id": str(ws.repo_goal_id) if ws.repo_goal_id else None,
|
||||
},
|
||||
)
|
||||
asyncio.create_task(publish_event(subject, envelope))
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
@router.delete("/{workstream_id}", response_model=WorkstreamRead)
|
||||
async def archive_workstream(
|
||||
workstream_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Workstream:
|
||||
ws = await session.get(Workstream, workstream_id)
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=404, detail="Workstream not found")
|
||||
ws.status = "archived"
|
||||
await session.commit()
|
||||
await session.refresh(ws)
|
||||
return ws
|
||||
@@ -1,19 +0,0 @@
|
||||
from api.schemas.topic import TopicCreate, TopicUpdate, TopicRead, TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamCreate, WorkstreamUpdate, WorkstreamRead
|
||||
from api.schemas.task import TaskCreate, TaskUpdate, TaskRead
|
||||
from api.schemas.decision import DecisionCreate, DecisionUpdate, DecisionRead
|
||||
from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead
|
||||
from api.schemas.state import StateSummary, Totals, TopicTotals, WorkstreamTotals, TaskTotals, DecisionTotals
|
||||
from api.schemas.extension_point import EPCreate, EPUpdate, EPRead
|
||||
from api.schemas.technical_debt import TDCreate, TDUpdate, TDRead
|
||||
|
||||
__all__ = [
|
||||
"TopicCreate", "TopicUpdate", "TopicRead", "TopicWithWorkstreams",
|
||||
"WorkstreamCreate", "WorkstreamUpdate", "WorkstreamRead",
|
||||
"TaskCreate", "TaskUpdate", "TaskRead",
|
||||
"DecisionCreate", "DecisionUpdate", "DecisionRead",
|
||||
"ProgressEventCreate", "ProgressEventRead",
|
||||
"StateSummary", "Totals", "TopicTotals", "WorkstreamTotals", "TaskTotals", "DecisionTotals",
|
||||
"EPCreate", "EPUpdate", "EPRead",
|
||||
"TDCreate", "TDUpdate", "TDRead",
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
subject: str
|
||||
body: str
|
||||
thread_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class MessageReply(BaseModel):
|
||||
from_agent: str
|
||||
body: str
|
||||
|
||||
|
||||
class MessageRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
from_agent: str
|
||||
to_agent: str
|
||||
subject: str
|
||||
body: str
|
||||
thread_id: uuid.UUID | None = None
|
||||
read_at: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
created_at: datetime
|
||||
@@ -1,114 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Catalog schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CatalogCreate(BaseModel):
|
||||
domain: str # slug, resolved to domain_id in router
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
repo_slug: str | None = None # optional repo attribution
|
||||
|
||||
|
||||
class CatalogPatch(BaseModel):
|
||||
repo_slug: str | None = None
|
||||
description: str | None = None
|
||||
keywords: list[str] | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class CatalogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
domain_slug: str
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_slug: str | None = None
|
||||
capability_type: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
keywords: list[str] = []
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capability Request schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CapabilityRequestCreate(BaseModel):
|
||||
title: str
|
||||
description: str | None = None
|
||||
capability_type: str
|
||||
priority: str = "medium"
|
||||
requesting_domain: str # slug, resolved to domain_id in router
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestAccept(BaseModel):
|
||||
fulfilling_agent: str
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestStatusPatch(BaseModel):
|
||||
status: str # in_progress | ready_for_review | completed | rejected | withdrawn
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class CapabilityRequestPatch(BaseModel):
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
priority: str | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class CapabilityRequestDispute(BaseModel):
|
||||
reason: str
|
||||
disputed_by: str
|
||||
suggested_domain: str | None = None
|
||||
|
||||
|
||||
class CapabilityRequestReroute(BaseModel):
|
||||
note: str
|
||||
rerouted_by: str
|
||||
domain: str | None = None # slug — used if catalog_entry_id not given
|
||||
catalog_entry_id: uuid.UUID | None = None # preferred: re-derives domain
|
||||
|
||||
|
||||
class CapabilityRequestRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
capability_type: str
|
||||
priority: str
|
||||
status: str
|
||||
requesting_domain_slug: str
|
||||
requesting_agent: str
|
||||
requesting_workstream_id: uuid.UUID | None = None
|
||||
fulfilling_domain_slug: str | None = None
|
||||
fulfilling_agent: str | None = None
|
||||
fulfilling_workstream_id: uuid.UUID | None = None
|
||||
blocking_task_id: uuid.UUID | None = None
|
||||
catalog_entry_id: uuid.UUID | None = None
|
||||
resolution_note: str | None = None
|
||||
routing_note: str | None = None
|
||||
dispute_reason: str | None = None
|
||||
disputed_by: str | None = None
|
||||
dispute_suggested_domain: str | None = None
|
||||
disputed_at: datetime | None = None
|
||||
accepted_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,45 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.contribution import ContributionStatus, ContributionType
|
||||
|
||||
|
||||
class ContributionCreate(BaseModel):
|
||||
type: ContributionType
|
||||
target_org: str | None = None
|
||||
target_repo: str | None = None
|
||||
slug: str | None = None
|
||||
title: str
|
||||
body_path: str | None = None
|
||||
related_topic_id: uuid.UUID | None = None
|
||||
related_workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ContributionStatusPatch(BaseModel):
|
||||
status: ContributionStatus
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ContributionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
type: ContributionType
|
||||
target_org: str | None = None
|
||||
target_repo: str | None = None
|
||||
slug: str | None = None
|
||||
title: str
|
||||
status: ContributionStatus
|
||||
body_path: str | None = None
|
||||
related_topic_id: uuid.UUID | None = None
|
||||
related_workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
submitted_at: datetime | None = None
|
||||
resolved_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,64 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.decision import DecisionStatus, DecisionType
|
||||
|
||||
|
||||
class DecisionCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType = DecisionType.pending
|
||||
status: DecisionStatus = DecisionStatus.open
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def topic_or_workstream_required(self) -> "DecisionCreate":
|
||||
if self.topic_id is None and self.workstream_id is None:
|
||||
raise ValueError("At least one of topic_id or workstream_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionResolve(BaseModel):
|
||||
rationale: str
|
||||
decided_by: str
|
||||
write_log: bool = True # append to DECISIONS.md in the registered project directory
|
||||
|
||||
|
||||
class DecisionUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
decision_type: DecisionType | None = None
|
||||
status: DecisionStatus | None = None
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
|
||||
|
||||
class DecisionRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str | None = None
|
||||
decision_type: DecisionType
|
||||
status: DecisionStatus
|
||||
rationale: str | None = None
|
||||
decided_by: str | None = None
|
||||
decided_at: datetime | None = None
|
||||
deadline: datetime | None = None
|
||||
escalation_note: str | None = None
|
||||
superseded_by: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,29 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DoICriterion(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
tier: str
|
||||
status: str # pass | fail | warn | skip
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class DoIReport(BaseModel):
|
||||
repo_slug: str
|
||||
tier: str # none | core | standard | full
|
||||
core_pass: bool
|
||||
standard_pass: bool
|
||||
full_pass: bool
|
||||
criteria: list[DoICriterion] = []
|
||||
checked_at: str
|
||||
|
||||
|
||||
class DoISummaryEntry(BaseModel):
|
||||
repo_slug: str
|
||||
domain_slug: str | None
|
||||
tier: str
|
||||
core_pass: bool
|
||||
standard_pass: bool
|
||||
full_pass: bool
|
||||
checked_at: str
|
||||
@@ -1,61 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class DomainCreate(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class DomainUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class DomainRename(BaseModel):
|
||||
new_slug: str
|
||||
new_name: str
|
||||
|
||||
|
||||
class RepoStub(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
name: str
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class DomainRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DomainDetail(DomainRead):
|
||||
"""Domain with entity counts and repo list."""
|
||||
topic_count: int = 0
|
||||
workstream_count: int = 0
|
||||
ep_count: int = 0
|
||||
td_count: int = 0
|
||||
repos: list[RepoStub] = []
|
||||
|
||||
|
||||
class DomainSummary(BaseModel):
|
||||
"""Lightweight domain stats for the state summary."""
|
||||
slug: str
|
||||
name: str
|
||||
repo_count: int = 0
|
||||
active_workstream_count: int = 0
|
||||
ep_count: int = 0
|
||||
td_count: int = 0
|
||||
@@ -1,31 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.domain_goal import DomainGoalStatus
|
||||
|
||||
|
||||
class DomainGoalCreate(BaseModel):
|
||||
domain_id: uuid.UUID
|
||||
title: str
|
||||
description: str
|
||||
status: str = DomainGoalStatus.active.value
|
||||
|
||||
|
||||
class DomainGoalUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
|
||||
|
||||
class DomainGoalRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
domain_id: uuid.UUID
|
||||
domain_slug: str
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,50 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.extension_point import EPStatus
|
||||
|
||||
VALID_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||
|
||||
|
||||
class EPCreate(BaseModel):
|
||||
ep_id: str | None = None
|
||||
domain: str # slug; router resolves to domain_id FK
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str = "other"
|
||||
status: EPStatus = EPStatus.open
|
||||
priority: str = "medium"
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class EPUpdate(BaseModel):
|
||||
ep_id: str | None = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str | None = None
|
||||
status: EPStatus | None = None
|
||||
priority: str | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class EPRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
ep_id: str | None = None
|
||||
domain_slug: str # derived from domain relationship
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
ep_type: str
|
||||
status: EPStatus
|
||||
priority: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,66 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class InterfaceChangeCreate(BaseModel):
|
||||
repo_slug: str
|
||||
interface_type: str # rest_api | mcp_tool | cli | schema | capability
|
||||
change_type: str # breaking | additive | deprecation | removal
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str] = []
|
||||
affected_repo_slugs: list[str] = []
|
||||
planned_for: date | None = None
|
||||
author: str = "custodian"
|
||||
|
||||
|
||||
class InterfaceChangePatch(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
affected_paths: list[str] | None = None
|
||||
affected_repo_slugs: list[str] | None = None
|
||||
planned_for: date | None = None
|
||||
|
||||
|
||||
class InterfaceChangeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
interface_type: str
|
||||
change_type: str
|
||||
title: str
|
||||
description: str
|
||||
affected_paths: list[str]
|
||||
affected_repo_slugs: list[str]
|
||||
status: str
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
resolved_at: datetime | None
|
||||
author: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@classmethod
|
||||
def from_orm_with_slug(cls, obj) -> "InterfaceChangeRead":
|
||||
return cls(
|
||||
id=obj.id,
|
||||
repo_id=obj.repo_id,
|
||||
repo_slug=obj.repo.slug,
|
||||
interface_type=obj.interface_type,
|
||||
change_type=obj.change_type,
|
||||
title=obj.title,
|
||||
description=obj.description,
|
||||
affected_paths=obj.affected_paths or [],
|
||||
affected_repo_slugs=obj.affected_repo_slugs or [],
|
||||
status=obj.status,
|
||||
planned_for=obj.planned_for,
|
||||
published_at=obj.published_at,
|
||||
resolved_at=obj.resolved_at,
|
||||
author=obj.author,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RepoCreate(BaseModel):
|
||||
domain_slug: str
|
||||
slug: str
|
||||
name: str
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
git_fingerprint: str | None = None
|
||||
description: str | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class RepoUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
local_path: str | None = None
|
||||
remote_url: str | None = None
|
||||
git_fingerprint: str | None = None
|
||||
description: str | None = None
|
||||
topic_id: uuid.UUID | None = None
|
||||
last_state_synced_at: datetime | None = None
|
||||
|
||||
|
||||
class RepoPathRegister(BaseModel):
|
||||
"""Register a machine-local path for a repo on a specific host."""
|
||||
host: str
|
||||
path: str
|
||||
|
||||
|
||||
class RepoOnboardRequest(BaseModel):
|
||||
"""Start scripted onboarding for a working copy that is visible to State Hub."""
|
||||
domain_slug: str
|
||||
project_path: str
|
||||
agent_profile: Literal["claude-code", "codex"] = "codex"
|
||||
additional: bool = False
|
||||
|
||||
|
||||
class RepoOnboardResult(BaseModel):
|
||||
ok: bool
|
||||
repo_slug: str | None = None
|
||||
agent_profile: str
|
||||
command: list[str]
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
|
||||
|
||||
class RepoRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
domain_id: uuid.UUID
|
||||
domain_slug: str # derived from domain relationship
|
||||
slug: str
|
||||
name: str
|
||||
local_path: str | None = None
|
||||
host_paths: dict = {}
|
||||
remote_url: str | None = None
|
||||
git_fingerprint: str | None = None
|
||||
description: str | None = None
|
||||
status: str
|
||||
topic_id: uuid.UUID | None = None
|
||||
sbom_source: str | None = None
|
||||
last_sbom_at: datetime | None = None
|
||||
last_state_synced_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DispatchTask(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
priority: str
|
||||
status: str
|
||||
needs_human: bool
|
||||
|
||||
|
||||
class DispatchWorkstream(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
status: str
|
||||
pending_tasks: list[DispatchTask]
|
||||
|
||||
|
||||
class PendingInterfaceChange(BaseModel):
|
||||
id: uuid.UUID
|
||||
title: str
|
||||
change_type: str
|
||||
interface_type: str
|
||||
origin_repo_slug: str
|
||||
affected_paths: list[str]
|
||||
planned_for: date | None
|
||||
published_at: datetime | None
|
||||
|
||||
|
||||
class ScopeIssueDetail(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
status: str
|
||||
detail: str
|
||||
missing_sections: list[str] = Field(default_factory=list)
|
||||
invalid_capability_blocks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
needs_refresh_sections: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RepoDispatch(BaseModel):
|
||||
repo_slug: str
|
||||
active_goal: dict[str, Any] | None
|
||||
active_workstreams: list[DispatchWorkstream]
|
||||
human_interventions: list[DispatchTask]
|
||||
pending_interface_changes: list[PendingInterfaceChange]
|
||||
scope_needs_review: bool
|
||||
scope_issue_details: list[ScopeIssueDetail]
|
||||
last_state_synced_at: datetime | None
|
||||
|
||||
|
||||
class RepoScopeHealth(BaseModel):
|
||||
repo_slug: str
|
||||
domain_slug: str | None = None
|
||||
local_path: str | None = None
|
||||
path_available: bool
|
||||
scope_needs_review: bool
|
||||
scope_issue_details: list[ScopeIssueDetail]
|
||||
@@ -1,32 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class ProgressEventCreate(BaseModel):
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
class ProgressEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
decision_id: uuid.UUID | None = None
|
||||
event_type: str
|
||||
summary: str
|
||||
detail: dict[str, Any] | None = None
|
||||
author: str | None = None
|
||||
session_id: str | None = None
|
||||
created_at: datetime
|
||||
@@ -1,37 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.repo_goal import RepoGoalStatus
|
||||
|
||||
|
||||
class RepoGoalCreate(BaseModel):
|
||||
repo_id: uuid.UUID
|
||||
domain_goal_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str
|
||||
priority: int = 100
|
||||
status: str = RepoGoalStatus.active.value
|
||||
|
||||
|
||||
class RepoGoalUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
priority: int | None = None
|
||||
status: str | None = None
|
||||
domain_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class RepoGoalRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
domain_goal_id: uuid.UUID | None = None
|
||||
title: str
|
||||
description: str
|
||||
priority: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,78 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.sbom_entry import Ecosystem
|
||||
|
||||
|
||||
class SBOMEntryCreate(BaseModel):
|
||||
package_name: str
|
||||
package_version: str | None = None
|
||||
ecosystem: Ecosystem
|
||||
license_spdx: str | None = None
|
||||
is_direct: bool = True
|
||||
is_dev: bool = False
|
||||
|
||||
|
||||
class SBOMIngest(BaseModel):
|
||||
repo_slug: str
|
||||
entries: list[SBOMEntryCreate]
|
||||
|
||||
|
||||
class SBOMEntryRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
snapshot_id: uuid.UUID
|
||||
package_name: str
|
||||
package_version: str | None = None
|
||||
ecosystem: Ecosystem
|
||||
license_spdx: str | None = None
|
||||
is_direct: bool
|
||||
is_dev: bool
|
||||
snapshot_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SBOMSnapshotRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
snapshot_at: datetime
|
||||
source: str | None = None
|
||||
entry_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SBOMSnapshotDetail(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID
|
||||
snapshot_at: datetime
|
||||
source: str | None = None
|
||||
entry_count: int
|
||||
created_at: datetime
|
||||
entries: list[SBOMEntryRead] = []
|
||||
|
||||
|
||||
class LicenceGroup(BaseModel):
|
||||
license_spdx: str | None
|
||||
count: int
|
||||
repos: list[str]
|
||||
is_copyleft: bool
|
||||
|
||||
|
||||
class LicenceReport(BaseModel):
|
||||
groups: list[LicenceGroup]
|
||||
copyleft_direct_count: int
|
||||
|
||||
|
||||
class SBOMRepoView(BaseModel):
|
||||
repo_slug: str
|
||||
last_sbom_at: datetime | None = None
|
||||
entry_count: int
|
||||
entries: list[SBOMEntryRead]
|
||||
@@ -1,82 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.schemas.decision import DecisionRead
|
||||
from api.schemas.domain import DomainSummary
|
||||
from api.schemas.progress_event import ProgressEventRead
|
||||
from api.schemas.task import TaskRead
|
||||
from api.schemas.topic import TopicWithWorkstreams
|
||||
from api.schemas.workstream import WorkstreamWithDeps
|
||||
|
||||
|
||||
class TopicTotals(BaseModel):
|
||||
active: int = 0
|
||||
paused: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class WorkstreamTotals(BaseModel):
|
||||
active: int = 0
|
||||
blocked: int = 0
|
||||
completed: int = 0
|
||||
archived: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class TaskTotals(BaseModel):
|
||||
todo: int = 0
|
||||
in_progress: int = 0
|
||||
blocked: int = 0
|
||||
done: int = 0
|
||||
cancelled: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class DecisionTotals(BaseModel):
|
||||
open: int = 0
|
||||
resolved: int = 0
|
||||
escalated: int = 0
|
||||
superseded: int = 0
|
||||
total: int = 0
|
||||
|
||||
|
||||
class Totals(BaseModel):
|
||||
topics: TopicTotals
|
||||
workstreams: WorkstreamTotals
|
||||
tasks: TaskTotals
|
||||
decisions: DecisionTotals
|
||||
|
||||
|
||||
class NextStep(BaseModel):
|
||||
"""A derived suggestion pointing to where work should happen next.
|
||||
|
||||
Suggestions are never persisted — they are computed on demand from
|
||||
current hub state: recently resolved decisions, newly unblocked tasks,
|
||||
cleared dependencies.
|
||||
"""
|
||||
type: str # unblocked_task | resolved_decision | dependency_cleared
|
||||
domain: str | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workstream_title: str | None = None
|
||||
workstream_slug: str | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
task_title: str | None = None
|
||||
message: str # plain-language explanation
|
||||
|
||||
|
||||
class StateSummary(BaseModel):
|
||||
generated_at: datetime
|
||||
totals: Totals
|
||||
topics: list[TopicWithWorkstreams]
|
||||
blocking_decisions: list[DecisionRead]
|
||||
blocked_tasks: list[TaskRead]
|
||||
recent_progress: list[ProgressEventRead]
|
||||
open_workstreams: list[WorkstreamWithDeps]
|
||||
next_steps: list[NextStep] = []
|
||||
domains: list[DomainSummary] = []
|
||||
contribution_counts: dict[str, int] = {}
|
||||
licence_risk_count: int = 0
|
||||
open_capability_requests: int = 0
|
||||
@@ -1,83 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
from api.models.task import TaskPriority, TaskStatus
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus = TaskStatus.todo
|
||||
priority: TaskPriority = TaskPriority.medium
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool = False
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
priority: TaskPriority | None = None
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool | None = None
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
# Token passthrough — three tiers (highest precision wins):
|
||||
# 1. tokens_in + tokens_out → exact counts; note defaults to "measured"
|
||||
# 2. workplan_tokens_in + workplan_tokens_out → prorated across task count (note="workplan")
|
||||
# 3. neither provided, status=done → heuristic 1000/500 (note="heuristic")
|
||||
# token_note overrides the auto-assigned note for Tier 1 only (e.g. "userbased")
|
||||
tokens_in: int | None = None
|
||||
tokens_out: int | None = None
|
||||
workplan_tokens_in: int | None = None
|
||||
workplan_tokens_out: int | None = None
|
||||
token_note: str | None = None
|
||||
model: str | None = None
|
||||
agent: str | None = None
|
||||
session_id: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> Self:
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
workstream_id: uuid.UUID
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,67 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.technical_debt import TDStatus
|
||||
|
||||
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||
|
||||
|
||||
class TDNoteCreate(BaseModel):
|
||||
step: str
|
||||
author: str | None = None
|
||||
content: str
|
||||
|
||||
|
||||
class TDNoteRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
td_id: uuid.UUID
|
||||
step: str
|
||||
author: str | None = None
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TDCreate(BaseModel):
|
||||
td_id: str | None = None
|
||||
domain: str # slug; router resolves to domain_id FK
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str = "other"
|
||||
severity: str = "medium"
|
||||
status: TDStatus = TDStatus.open
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TDUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str | None = None
|
||||
severity: str | None = None
|
||||
status: TDStatus | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TDRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
td_id: str | None = None
|
||||
domain_slug: str # derived from domain relationship
|
||||
title: str
|
||||
description: str | None = None
|
||||
location: str | None = None
|
||||
debt_type: str
|
||||
severity: str
|
||||
status: TDStatus
|
||||
topic_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
notes: list[TDNoteRead] = []
|
||||
@@ -1,71 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, computed_field
|
||||
|
||||
|
||||
class TokenEventCreate(BaseModel):
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
agent: str | None = None
|
||||
ref_type: str | None = None
|
||||
ref_id: str | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class TokenEventRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
task_id: uuid.UUID | None = None
|
||||
workstream_id: uuid.UUID | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
session_id: str | None = None
|
||||
model: str | None = None
|
||||
agent: str | None = None
|
||||
ref_type: str | None = None
|
||||
ref_id: str | None = None
|
||||
note: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tokens_total(self) -> int:
|
||||
return self.tokens_in + self.tokens_out
|
||||
|
||||
|
||||
class TokenSummary(BaseModel):
|
||||
scope: str
|
||||
scope_id: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
tokens_total: int
|
||||
event_count: int
|
||||
by_model: dict[str, int]
|
||||
by_agent: dict[str, int]
|
||||
|
||||
|
||||
class TokenEventPatch(BaseModel):
|
||||
tokens_in: int | None = None
|
||||
tokens_out: int | None = None
|
||||
note: str | None = None
|
||||
model: str | None = None
|
||||
agent: str | None = None
|
||||
|
||||
|
||||
class RepoTokenSummary(BaseModel):
|
||||
repo_id: uuid.UUID
|
||||
repo_slug: str
|
||||
tokens_in: int
|
||||
tokens_out: int
|
||||
tokens_total: int
|
||||
event_count: int
|
||||
by_model: dict[str, int]
|
||||
by_note: dict[str, int]
|
||||
@@ -1,47 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.models.topic import TopicStatus
|
||||
|
||||
|
||||
class TopicCreate(BaseModel):
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain: str # domain slug — resolved to domain_id in the router
|
||||
status: TopicStatus = TopicStatus.active
|
||||
|
||||
|
||||
class TopicUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: TopicStatus | None = None
|
||||
domain: str | None = None # domain slug — resolved to domain_id in the router
|
||||
|
||||
|
||||
class WorkstreamStub(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
status: str
|
||||
owner: str | None = None
|
||||
due_date: datetime | None = None
|
||||
|
||||
|
||||
class TopicRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
domain_slug: str | None = None # resolved from FK relationship via @property
|
||||
status: TopicStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TopicWithWorkstreams(TopicRead):
|
||||
workstreams: list[WorkstreamStub] = []
|
||||
@@ -1,115 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, computed_field
|
||||
|
||||
# GDPR maturity scale (CNIL/IAPP CMMI-aligned, adapted for third-party assessment)
|
||||
GDPRMaturity = Literal["unknown", "non_compliant", "initial", "developing", "defined", "managed", "certified"]
|
||||
|
||||
# Services at these levels trigger a GDPR warning
|
||||
GDPR_WARNING_LEVELS = {"unknown", "non_compliant", "initial"}
|
||||
|
||||
PricingModel = Literal["free", "paid", "freemium", "usage_based", "unknown"]
|
||||
AuthType = Literal["api_key", "oauth", "cli", "none", "unknown"]
|
||||
|
||||
|
||||
class TPSCCatalogCreate(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
provider: str | None = None
|
||||
category: str | None = None
|
||||
website_url: str | None = None
|
||||
pricing_model: PricingModel = "unknown"
|
||||
gdpr_maturity: GDPRMaturity = "unknown"
|
||||
gdpr_notes: str | None = None
|
||||
dpa_available: bool = False
|
||||
tos_url: str | None = None
|
||||
privacy_policy_url: str | None = None
|
||||
data_processing_regions: list[str] | None = None
|
||||
data_retention_notes: str | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class TPSCCatalogRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
name: str
|
||||
provider: str | None
|
||||
category: str | None
|
||||
website_url: str | None
|
||||
pricing_model: str
|
||||
gdpr_maturity: str
|
||||
gdpr_notes: str | None
|
||||
dpa_available: bool
|
||||
tos_url: str | None
|
||||
privacy_policy_url: str | None
|
||||
data_processing_regions: list[str] | None
|
||||
data_retention_notes: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def gdpr_warning(self) -> bool:
|
||||
return self.gdpr_maturity in GDPR_WARNING_LEVELS
|
||||
|
||||
|
||||
class TPSCEntryCreate(BaseModel):
|
||||
service_slug: str
|
||||
purpose: str | None = None
|
||||
auth_type: str | None = None
|
||||
endpoint_override: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class TPSCEntryRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
snapshot_id: uuid.UUID
|
||||
catalog_id: uuid.UUID | None
|
||||
service_slug: str
|
||||
purpose: str | None
|
||||
auth_type: str | None
|
||||
endpoint_override: str | None
|
||||
notes: str | None
|
||||
# Denormalised from catalog for convenience
|
||||
gdpr_maturity: str | None = None
|
||||
gdpr_warning: bool = False
|
||||
pricing_model: str | None = None
|
||||
|
||||
|
||||
class TPSCIngestRequest(BaseModel):
|
||||
repo_slug: str
|
||||
source_file: str = "tpsc.yaml"
|
||||
entries: list[TPSCEntryCreate]
|
||||
|
||||
|
||||
class TPSCSnapshotRead(BaseModel):
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
id: uuid.UUID
|
||||
repo_id: uuid.UUID | None
|
||||
snapshot_at: datetime
|
||||
source_file: str | None
|
||||
entry_count: int
|
||||
entries: list[TPSCEntryRead] = []
|
||||
|
||||
|
||||
class TPSCGDPRWarning(BaseModel):
|
||||
repo_slug: str | None
|
||||
service_slug: str
|
||||
gdpr_maturity: str
|
||||
purpose: str | None
|
||||
pricing_model: str | None
|
||||
|
||||
|
||||
class TPSCGDPRReport(BaseModel):
|
||||
generated_at: datetime
|
||||
total_services: int
|
||||
warning_count: int
|
||||
warnings: list[TPSCGDPRWarning]
|
||||
by_maturity: dict[str, int]
|
||||
@@ -1,68 +0,0 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||
|
||||
WorkstreamStatus = Literal["todo", "active", "blocked", "completed", "archived"]
|
||||
|
||||
|
||||
class WorkstreamCreate(BaseModel):
|
||||
topic_id: uuid.UUID
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus = "active"
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
repo_id: uuid.UUID | None = None # GEMS primary: the owning repository
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus | None = None
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class WorkstreamRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
topic_id: uuid.UUID
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_goal_id: uuid.UUID | None = None
|
||||
slug: str
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: WorkstreamStatus
|
||||
owner: str | None = None
|
||||
due_date: date | None = None
|
||||
planning_priority: str | None = None
|
||||
planning_order: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkstreamWithTaskCounts(WorkstreamRead):
|
||||
tasks_total: int = 0
|
||||
tasks_todo: int = 0
|
||||
tasks_in_progress: int = 0
|
||||
tasks_blocked: int = 0
|
||||
tasks_done: int = 0
|
||||
|
||||
|
||||
class WorkstreamWithDeps(WorkstreamWithTaskCounts):
|
||||
"""WorkstreamWithTaskCounts enriched with dependency graph edges."""
|
||||
depends_on: list[WorkstreamDepStub] = []
|
||||
blocks: list[WorkstreamDepStub] = []
|
||||
blocked_reasons: list[dict] = []
|
||||
@@ -1,36 +0,0 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class WorkstreamDependencyCreate(BaseModel):
|
||||
to_workstream_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str = "blocks"
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class WorkstreamDependencyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
from_workstream_id: uuid.UUID
|
||||
to_workstream_id: uuid.UUID | None = None
|
||||
to_task_id: uuid.UUID | None = None
|
||||
relationship_type: str
|
||||
description: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkstreamDepStub(BaseModel):
|
||||
"""Minimal projection of the other end of a dependency edge."""
|
||||
dep_id: uuid.UUID
|
||||
target_type: str = "workstream"
|
||||
relationship_type: str = "blocks"
|
||||
workstream_id: uuid.UUID | None = None
|
||||
workstream_slug: str | None = None
|
||||
workstream_title: str | None = None
|
||||
task_id: uuid.UUID | None = None
|
||||
task_title: str | None = None
|
||||
description: str | None = None
|
||||
@@ -1,529 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
custodian — CLI for the Custodian State Hub.
|
||||
|
||||
Usage:
|
||||
custodian register-project [--domain DOMAIN] [--path PATH]
|
||||
|
||||
Run from inside the project directory you want to connect.
|
||||
--domain defaults to auto-detection from the project charter.
|
||||
--path defaults to current working directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
STATE_HUB_DIR = Path(__file__).resolve().parent
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000")
|
||||
TEMPLATE = STATE_HUB_DIR / "scripts" / "project_claude_md.template"
|
||||
PATCH_CWD = STATE_HUB_DIR / "scripts" / "patch_mcp_cwd.py"
|
||||
|
||||
_SUGGESTION_PREAMBLE = """\
|
||||
<!--
|
||||
CUSTODIAN SUGGESTION — generated by `custodian register-project`
|
||||
|
||||
This file was written by the Custodian State Hub registration workflow.
|
||||
It contains the standard State Hub integration block for this repository.
|
||||
|
||||
A Claude Code agent in this repo should:
|
||||
1. Read this file and the existing CLAUDE.md side by side
|
||||
2. Integrate the sections below into CLAUDE.md — preserve all existing
|
||||
project-specific conventions; add or update the hub integration block
|
||||
3. Delete this file once integration is complete and commit the result
|
||||
|
||||
Do NOT add this file to .gitignore. It is a temporary artefact that signals
|
||||
pending integration work to the repo agent.
|
||||
-->
|
||||
|
||||
"""
|
||||
|
||||
_ONBOARDING_TASKS = [
|
||||
(
|
||||
"Integrate CLAUDE.custodian.md → CLAUDE.md",
|
||||
"high",
|
||||
"A CLAUDE.custodian.md suggestion file was written by the custodian registration workflow. "
|
||||
"Read both files, merge the hub integration block into the existing CLAUDE.md "
|
||||
"(preserve all project-specific conventions), then delete CLAUDE.custodian.md and commit.",
|
||||
),
|
||||
(
|
||||
"Write first workplan and initialise workplans/",
|
||||
"high",
|
||||
"Create a workplans/ directory and write the first workplan file following ADR-001 "
|
||||
"(~/the-custodian/canon/architecture/adr-001-workplans-as-repo-artefacts.md). "
|
||||
"Cover the repo's primary near-term work strand. Register the workstream in the state hub via MCP.",
|
||||
),
|
||||
(
|
||||
"Ingest SBOM",
|
||||
"medium",
|
||||
# path substituted at call time
|
||||
"",
|
||||
),
|
||||
(
|
||||
"Register known EPs and TDs",
|
||||
"low",
|
||||
"Catalogue any known extension points (future enhancement hooks) and technical debt items "
|
||||
"using the register_extension_point() and register_technical_debt() MCP tools.",
|
||||
),
|
||||
]
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _api_get(path: str) -> object:
|
||||
url = API_BASE.rstrip("/") + path
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.URLError as e:
|
||||
print(f"ERROR: Cannot reach API at {API_BASE}: {e}")
|
||||
print(f" Start it: cd {STATE_HUB_DIR} && make api")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _api_post(path: str, body: dict) -> object:
|
||||
url = API_BASE.rstrip("/") + path
|
||||
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _api_patch(path: str, body: dict) -> object:
|
||||
url = API_BASE.rstrip("/") + path
|
||||
data = json.dumps({k: v for k, v in body.items() if v is not None}).encode()
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="PATCH",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def _find_repo_by_slug(repo_slug: str) -> dict | None:
|
||||
repos = _api_get("/repos/")
|
||||
return next((r for r in repos if r.get("slug") == repo_slug), None)
|
||||
|
||||
|
||||
def _detect_domain(project_path: Path) -> str | None:
|
||||
"""Try to read domain from project charter frontmatter."""
|
||||
for charter in project_path.rglob("project_charter_v*.md"):
|
||||
text = charter.read_text()
|
||||
m = re.search(r"^domain:\s*(\S+)", text, re.MULTILINE)
|
||||
if m:
|
||||
return m.group(1).strip('"\'')
|
||||
return None
|
||||
|
||||
|
||||
def _check_mcp() -> bool:
|
||||
claude_json = Path.home() / ".claude.json"
|
||||
if not claude_json.exists():
|
||||
return False
|
||||
config = json.loads(claude_json.read_text())
|
||||
return "state-hub" in config.get("mcpServers", {})
|
||||
|
||||
|
||||
# ── Subcommands ────────────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_register(args: argparse.Namespace) -> None:
|
||||
"""Register a project/repo with the State Hub and generate onboarding tasks."""
|
||||
project_path = Path(args.path).resolve()
|
||||
if not project_path.is_dir():
|
||||
print(f"ERROR: {project_path} is not a directory.")
|
||||
sys.exit(1)
|
||||
|
||||
project_name = project_path.name
|
||||
repo_slug = re.sub(r"-+", "-", re.sub(r"[^a-z0-9]", "-", project_name.lower())).strip("-")
|
||||
|
||||
# ── Step 1: API health ─────────────────────────────────────────────────────
|
||||
print(f"==> Checking API at {API_BASE} ...")
|
||||
_api_get("/state/health")
|
||||
print(" API OK")
|
||||
|
||||
# ── Step 2: Domain ─────────────────────────────────────────────────────────
|
||||
domain = args.domain
|
||||
valid_domains = [d["slug"] for d in _api_get("/domains/?status=active")]
|
||||
if not domain:
|
||||
print("==> Auto-detecting domain from project charter ...")
|
||||
domain = _detect_domain(project_path)
|
||||
if domain:
|
||||
print(f" Detected: {domain}")
|
||||
else:
|
||||
print("ERROR: Could not auto-detect domain. Pass --domain explicitly.")
|
||||
print(f" Valid: {', '.join(valid_domains)}")
|
||||
sys.exit(1)
|
||||
|
||||
if domain not in valid_domains:
|
||||
print(f"ERROR: Unknown domain '{domain}'. Valid: {', '.join(valid_domains)}")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Step 3: Topic ID lookup (auto-create if new domain) ───────────────────
|
||||
print(f"==> Looking up topic for domain '{domain}' ...")
|
||||
topics = _api_get("/topics/?status=active")
|
||||
match = next((t for t in topics if t.get("domain_slug") == domain), None)
|
||||
if not match:
|
||||
print(f" No topic found — creating one for domain '{domain}' ...")
|
||||
t_slug = re.sub(r"[^a-z0-9]+", "-", domain.lower()).strip("-")
|
||||
try:
|
||||
match = _api_post("/topics/", {
|
||||
"slug": t_slug,
|
||||
"title": project_name,
|
||||
"domain": domain,
|
||||
"status": "active",
|
||||
})
|
||||
print(f" Topic created: {match['title']} ({match['id']})")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Could not create topic for domain '{domain}': {e}")
|
||||
sys.exit(1)
|
||||
topic_id = match["id"]
|
||||
print(f" topic_id: {topic_id}")
|
||||
|
||||
# ── Step 4: MCP check ──────────────────────────────────────────────────────
|
||||
print("==> Checking MCP server registration ...")
|
||||
if _check_mcp():
|
||||
print(" MCP OK")
|
||||
else:
|
||||
print("WARNING: 'state-hub' not in ~/.claude.json.")
|
||||
print(" See ~/.claude/CLAUDE.md → MCP Server Registration section.")
|
||||
|
||||
# ── Step 5: Write CLAUDE.custodian.md ─────────────────────────────────────
|
||||
suggestion_file = project_path / "CLAUDE.custodian.md"
|
||||
print(f"==> Writing custodian suggestion to {suggestion_file} ...")
|
||||
content = (
|
||||
_SUGGESTION_PREAMBLE
|
||||
+ TEMPLATE.read_text()
|
||||
.replace("{PROJECT_NAME}", project_name)
|
||||
.replace("{DOMAIN}", domain)
|
||||
.replace("{TOPIC_ID}", topic_id)
|
||||
.replace("{REPO_SLUG}", repo_slug)
|
||||
)
|
||||
suggestion_file.write_text(content)
|
||||
print(" Written. The repo agent integrates it into CLAUDE.md then deletes it.")
|
||||
|
||||
# ── Step 6: Register repo ─────────────────────────────────────────────────
|
||||
print(f"==> Registering repo '{repo_slug}' under domain '{domain}' ...")
|
||||
repo = None
|
||||
try:
|
||||
repo = _api_post("/repos/", {
|
||||
"domain_slug": domain,
|
||||
"slug": repo_slug,
|
||||
"name": project_name,
|
||||
"local_path": str(project_path),
|
||||
})
|
||||
print(" Registered.")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 409:
|
||||
print(f" NOTE: {e} — repo registration failed, continuing.")
|
||||
else:
|
||||
print(" Repo already registered, reusing existing record.")
|
||||
repo = _find_repo_by_slug(repo_slug)
|
||||
except Exception as e:
|
||||
print(f" NOTE: {e} — repo may already be registered, continuing.")
|
||||
repo = _find_repo_by_slug(repo_slug)
|
||||
|
||||
repo_id = repo.get("id") if isinstance(repo, dict) else None
|
||||
if repo_id:
|
||||
print(f" repo_id: {repo_id}")
|
||||
else:
|
||||
print(" WARNING: Could not resolve repo_id; onboarding workstream will remain domain-level.")
|
||||
|
||||
# ── Step 7: Onboarding workstream + tasks ─────────────────────────────────
|
||||
ws_slug = f"repo-integration-{repo_slug}"
|
||||
print(f"==> Creating onboarding workstream '{ws_slug}' ...")
|
||||
# Check if it already exists
|
||||
existing_ws = next(
|
||||
(w for w in _api_get("/workstreams/") if w.get("slug") == ws_slug and w.get("status") == "active"),
|
||||
None,
|
||||
)
|
||||
if existing_ws:
|
||||
print(" Onboarding workstream already exists — skipping task creation.")
|
||||
if repo_id and not existing_ws.get("repo_id"):
|
||||
existing_owner = existing_ws.get("owner")
|
||||
_api_patch(f"/workstreams/{existing_ws['id']}/", {
|
||||
"repo_id": repo_id,
|
||||
"owner": repo_slug if existing_owner in (None, domain) else existing_owner,
|
||||
})
|
||||
print(" Attached existing onboarding workstream to repo.")
|
||||
elif repo_id and existing_ws.get("repo_id") != repo_id:
|
||||
print(
|
||||
" WARNING: Existing onboarding workstream is attached to a different repo_id; "
|
||||
"leaving it unchanged."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
ws = _api_post("/workstreams/", {
|
||||
"topic_id": topic_id,
|
||||
"title": f"Repo Integration: {repo_slug}",
|
||||
"slug": ws_slug,
|
||||
"description": (
|
||||
f"Bootstrapping workstream created by the custodian during registration of "
|
||||
f"'{repo_slug}'. Contains onboarding tasks for the repo agent to execute. "
|
||||
f"ADR-001 exception: this workstream is DB-first because the repo has no "
|
||||
f"workplans/ directory yet. Task T2 produces the first workplan file."
|
||||
),
|
||||
"owner": repo_slug,
|
||||
"status": "active",
|
||||
"repo_id": repo_id,
|
||||
})
|
||||
ws_id = ws["id"]
|
||||
sbom_desc = (
|
||||
f"Capture the repo's dependency snapshot. From state-hub dir: "
|
||||
f"make ingest-sbom REPO={repo_slug} SCAN=1 REPO_PATH={project_path}"
|
||||
)
|
||||
tasks = [
|
||||
(_ONBOARDING_TASKS[0][0], _ONBOARDING_TASKS[0][1], _ONBOARDING_TASKS[0][2]),
|
||||
(_ONBOARDING_TASKS[1][0], _ONBOARDING_TASKS[1][1], _ONBOARDING_TASKS[1][2]),
|
||||
(_ONBOARDING_TASKS[2][0], _ONBOARDING_TASKS[2][1], sbom_desc),
|
||||
(_ONBOARDING_TASKS[3][0], _ONBOARDING_TASKS[3][1], _ONBOARDING_TASKS[3][2]),
|
||||
]
|
||||
for title, priority, description in tasks:
|
||||
_api_post("/tasks/", {
|
||||
"workstream_id": ws_id,
|
||||
"title": title,
|
||||
"priority": priority,
|
||||
"description": description,
|
||||
})
|
||||
print(f" Created with {len(tasks)} onboarding tasks.")
|
||||
print(f" The {domain} repo agent will see these at next session start.")
|
||||
except Exception as e:
|
||||
print(f" WARNING: Could not create onboarding tasks: {e}")
|
||||
ws_id = None
|
||||
|
||||
# ── Step 8: Progress event ─────────────────────────────────────────────────
|
||||
print("==> Recording registration event ...")
|
||||
try:
|
||||
_api_post("/progress/", {
|
||||
"topic_id": topic_id,
|
||||
"event_type": "milestone",
|
||||
"summary": f"Repo registered: {project_name} ({domain}) — onboarding tasks created",
|
||||
"author": "custodian",
|
||||
"detail": {
|
||||
"project_path": str(project_path),
|
||||
"suggestion_file": str(suggestion_file),
|
||||
"repo_slug": repo_slug,
|
||||
"domain": domain,
|
||||
"onboarding_workstream_slug": ws_slug,
|
||||
},
|
||||
})
|
||||
print(" Event recorded.")
|
||||
except Exception as e:
|
||||
print(f" WARNING: Could not record progress event: {e}")
|
||||
|
||||
print()
|
||||
print("Registration complete!")
|
||||
print(f" Project: {project_name}")
|
||||
print(f" Domain: {domain}")
|
||||
print(f" Repo slug: {repo_slug}")
|
||||
print(f" Topic ID: {topic_id}")
|
||||
print(f" Suggestion: {suggestion_file}")
|
||||
print()
|
||||
print("Next: open the repo in Claude Code.")
|
||||
print(" The repo agent will pick up 4 onboarding tasks and integrate autonomously.")
|
||||
|
||||
|
||||
def cmd_ingest_sbom(args: argparse.Namespace) -> None:
|
||||
"""Ingest SBOM for the current (or specified) repo. Auto-detects slug from registration."""
|
||||
project_path = Path(args.path).resolve()
|
||||
|
||||
_api_get("/state/health")
|
||||
|
||||
# Resolve repo slug: explicit override, or look up by local_path
|
||||
repo_slug = args.slug
|
||||
if not repo_slug:
|
||||
repos = _api_get("/repos/")
|
||||
repo = next((r for r in repos if r.get("local_path") == str(project_path)), None)
|
||||
if not repo:
|
||||
print(f"ERROR: No registered repo found for path '{project_path}'.")
|
||||
print(" Register first: custodian register-project --domain <slug>")
|
||||
print(" Or pass --slug explicitly.")
|
||||
sys.exit(1)
|
||||
repo_slug = repo["slug"]
|
||||
|
||||
print(f"==> Ingesting SBOM for '{repo_slug}' from {project_path} ...")
|
||||
|
||||
python = STATE_HUB_DIR / ".venv" / "bin" / "python"
|
||||
ingest_script = STATE_HUB_DIR / "scripts" / "ingest_sbom.py"
|
||||
|
||||
if not python.exists():
|
||||
print(f"ERROR: .venv not found at {STATE_HUB_DIR}. Run 'make install' in the state-hub directory.")
|
||||
sys.exit(1)
|
||||
|
||||
cmd = [str(python), str(ingest_script), "--repo", repo_slug, "--scan", "--repo-path", str(project_path)]
|
||||
if args.dry_run:
|
||||
cmd.append("--dry-run")
|
||||
|
||||
result = subprocess.run(cmd)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def cmd_create_workstream(args: argparse.Namespace) -> None:
|
||||
"""Create a workstream under a domain's topic."""
|
||||
_api_get("/state/health")
|
||||
|
||||
# Resolve topic_id from domain
|
||||
topics = _api_get("/topics/?status=active")
|
||||
match = next((t for t in topics if t.get("domain_slug") == args.domain), None)
|
||||
if not match:
|
||||
print(f"ERROR: No active topic for domain '{args.domain}'.")
|
||||
sys.exit(1)
|
||||
topic_id = match["id"]
|
||||
|
||||
slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
|
||||
|
||||
ws = _api_post("/workstreams/", {
|
||||
"topic_id": topic_id,
|
||||
"title": args.title,
|
||||
"slug": slug,
|
||||
"description": args.description,
|
||||
"owner": args.owner,
|
||||
"status": "active",
|
||||
})
|
||||
_api_post("/progress/", {
|
||||
"topic_id": topic_id,
|
||||
"workstream_id": ws["id"],
|
||||
"event_type": "workstream_created",
|
||||
"summary": f"Workstream created: {args.title}",
|
||||
"author": "custodian",
|
||||
"detail": {"owner": args.owner, "slug": slug},
|
||||
})
|
||||
print(f"Created workstream: {ws['title']}")
|
||||
print(f" id: {ws['id']}")
|
||||
print(f" slug: {ws['slug']}")
|
||||
print(f" domain: {args.domain}")
|
||||
print(f" owner: {ws.get('owner') or '—'}")
|
||||
|
||||
|
||||
def cmd_create_task(args: argparse.Namespace) -> None:
|
||||
"""Create a task under a workstream (by ID or slug)."""
|
||||
_api_get("/state/health")
|
||||
|
||||
# Resolve workstream: accept UUID or slug
|
||||
workstream_id = args.workstream
|
||||
if not _is_uuid(workstream_id):
|
||||
wss = _api_get("/workstreams/")
|
||||
match = next((w for w in wss if w.get("slug") == workstream_id), None)
|
||||
if not match:
|
||||
print(f"ERROR: No workstream found with slug '{workstream_id}'.")
|
||||
print(" Use 'custodian status' or check the dashboard for valid slugs.")
|
||||
sys.exit(1)
|
||||
workstream_id = match["id"]
|
||||
|
||||
task = _api_post("/tasks/", {
|
||||
"workstream_id": workstream_id,
|
||||
"title": args.title,
|
||||
"priority": args.priority,
|
||||
"description": args.description,
|
||||
"assignee": args.assignee,
|
||||
})
|
||||
_api_post("/progress/", {
|
||||
"workstream_id": workstream_id,
|
||||
"task_id": task["id"],
|
||||
"event_type": "task_created",
|
||||
"summary": f"Task created: {args.title}",
|
||||
"author": "custodian",
|
||||
"detail": {"priority": args.priority},
|
||||
})
|
||||
print(f"Created task: {task['title']}")
|
||||
print(f" id: {task['id']}")
|
||||
print(f" priority: {task['priority']}")
|
||||
print(f" status: {task['status']}")
|
||||
|
||||
|
||||
def _is_uuid(s: str) -> bool:
|
||||
import uuid as _uuid
|
||||
try:
|
||||
_uuid.UUID(s)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def cmd_status(_args: argparse.Namespace) -> None:
|
||||
"""Quick status: API health + summary totals."""
|
||||
health = _api_get("/state/health")
|
||||
print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}")
|
||||
summary = _api_get("/state/summary")
|
||||
t = summary["totals"]
|
||||
print(f"Topics: {t['topics']['active']} active")
|
||||
print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked")
|
||||
print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked")
|
||||
print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated")
|
||||
blocking = summary.get("blocking_decisions", [])
|
||||
if blocking:
|
||||
print(f"\nBlocking decisions ({len(blocking)}):")
|
||||
for d in blocking:
|
||||
deadline = d.get("deadline") or "no deadline"
|
||||
print(f" [{deadline}] {d['title']}")
|
||||
|
||||
|
||||
# ── Entry point ────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="custodian",
|
||||
description="Custodian State Hub CLI",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
# register-project
|
||||
reg = sub.add_parser("register-project", help="Register a project with the State Hub")
|
||||
reg.add_argument(
|
||||
"--domain",
|
||||
default=None,
|
||||
help="Project domain slug (auto-detected from charter if omitted)",
|
||||
)
|
||||
reg.add_argument(
|
||||
"--path",
|
||||
default=os.getcwd(),
|
||||
help="Project directory (defaults to current directory)",
|
||||
)
|
||||
|
||||
# ingest-sbom
|
||||
ing = sub.add_parser("ingest-sbom", help="Ingest SBOM for the repo at the current directory")
|
||||
ing.add_argument("--path", default=os.getcwd(), help="Repo directory (defaults to cwd)")
|
||||
ing.add_argument("--slug", default=None, help="Repo slug (auto-detected from path if omitted)")
|
||||
ing.add_argument("--dry-run", action="store_true", help="Parse lockfiles but do not submit to API")
|
||||
|
||||
# create-workstream
|
||||
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
|
||||
cws.add_argument("--domain", required=True, help="Domain slug to create the workstream under")
|
||||
cws.add_argument("--title", required=True, help="Workstream title")
|
||||
cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)")
|
||||
cws.add_argument("--owner", default=None, help="Owner name")
|
||||
cws.add_argument("--description", default=None, help="Optional description")
|
||||
|
||||
# create-task
|
||||
ctask = sub.add_parser("create-task", help="Create a task under a workstream")
|
||||
ctask.add_argument("--workstream", required=True, metavar="ID_OR_SLUG", help="Workstream UUID or slug")
|
||||
ctask.add_argument("--title", required=True, help="Task title")
|
||||
ctask.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
|
||||
ctask.add_argument("--assignee", default=None)
|
||||
ctask.add_argument("--description", default=None)
|
||||
|
||||
# status
|
||||
sub.add_parser("status", help="Show State Hub health and summary totals")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "register-project":
|
||||
cmd_register(args)
|
||||
elif args.command == "ingest-sbom":
|
||||
cmd_ingest_sbom(args)
|
||||
elif args.command == "create-workstream":
|
||||
cmd_create_workstream(args)
|
||||
elif args.command == "create-task":
|
||||
cmd_create_task(args)
|
||||
elif args.command == "status":
|
||||
cmd_status(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,119 +0,0 @@
|
||||
import {readFileSync} from "node:fs";
|
||||
import {fileURLToPath} from "node:url";
|
||||
import {dirname, join} from "node:path";
|
||||
|
||||
// Read improvement-modal.js at config load time and inject as a plain <script>.
|
||||
// Observable Framework proxies all src/*.js files through its own module
|
||||
// bundler — they cannot be imported via a raw <script type="module"> in <head>.
|
||||
// Reading the file here and stripping the ES module export is the reliable path.
|
||||
const _configDir = dirname(fileURLToPath(import.meta.url));
|
||||
const _modalScript = readFileSync(
|
||||
join(_configDir, "src/components/improvement-modal.js"), "utf-8"
|
||||
)
|
||||
.replace(/^export function /gm, "function ") // strip ES module export
|
||||
+ "\ninitImprovementModal();\n"; // auto-initialise
|
||||
|
||||
export default {
|
||||
root: "src",
|
||||
title: "Custodian State Hub",
|
||||
pages: [
|
||||
// ── Pages (Overview first, then alphabetical) ────────────────────────────
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Capabilities", path: "/capability-requests" },
|
||||
{ name: "Contributions", path: "/contributions" },
|
||||
{ name: "Domains", path: "/domains" },
|
||||
{ name: "Goals", path: "/goals" },
|
||||
{ name: "Inbox", path: "/inbox" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
{ name: "Token Cost", path: "/token-cost" },
|
||||
{ name: "Services (TPSC)", path: "/tpsc" },
|
||||
{ name: "Todo", path: "/todo" },
|
||||
{ name: "Tools & Apps", path: "/tools" },
|
||||
// ── Sections (alphabetical) ───────────────────────────────────────────────
|
||||
{
|
||||
name: "Policies",
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Repository DoI", path: "/policy/repo-doi" },
|
||||
{ name: "Workstream DoD", path: "/policy/workstream-dod" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Repositories",
|
||||
path: "/repos",
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Debt", path: "/techdept" },
|
||||
{ name: "Repo Sync", path: "/repo-sync" },
|
||||
{ name: "SBOM", path: "/sbom" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Workstreams",
|
||||
path: "/workstreams",
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Decisions", path: "/decisions" },
|
||||
{ name: "Dependencies", path: "/dependencies" },
|
||||
{ name: "Extends", path: "/extensions" },
|
||||
{ name: "Interface Changes", path: "/interface-changes" },
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
{ name: "Tasks", path: "/tasks" },
|
||||
{ name: "UI Feedback", path: "/ui-feedback" },
|
||||
],
|
||||
},
|
||||
// ── Reference (always last) ───────────────────────────────────────────────
|
||||
{
|
||||
name: "Reference",
|
||||
path: "/reference",
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Capabilities", path: "/docs/capabilities" },
|
||||
{ name: "Connecting to the Hub", path: "/docs/connecting" },
|
||||
{ name: "Dashboard", path: "/docs/dashboard" },
|
||||
{ name: "Contributions", path: "/docs/contributions" },
|
||||
{ name: "Decision Health", path: "/docs/decisions-kpi" },
|
||||
{ name: "Decisions", path: "/docs/decisions" },
|
||||
{ name: "Dependencies", path: "/docs/dependencies" },
|
||||
{ name: "Domains", path: "/docs/domains" },
|
||||
{ name: "Goals", path: "/docs/goals" },
|
||||
{ name: "Extension Points", path: "/docs/extensions" },
|
||||
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
|
||||
{ name: "Interventions", path: "/docs/interventions" },
|
||||
{ name: "Live Data", path: "/docs/live-data" },
|
||||
{ name: "Overview", path: "/docs/overview" },
|
||||
{ name: "Progress Log", path: "/docs/progress-log" },
|
||||
{ name: "Ralph Workplan", path: "/docs/ralph-workplan" },
|
||||
{ name: "Reference & Context Help", path: "/docs/reference" },
|
||||
{ name: "Repo Integration", path: "/docs/repo-integration" },
|
||||
{ name: "State Hub", path: "/docs/state-hub" },
|
||||
{ name: "Repos", path: "/docs/repos" },
|
||||
{ name: "SBOM", path: "/docs/sbom" },
|
||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||
{ name: "Tasks", path: "/docs/tasks" },
|
||||
{ name: "TPSC", path: "/docs/tpsc" },
|
||||
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
||||
{ name: "Technical Debt", path: "/docs/debt" },
|
||||
{ name: "Todo", path: "/docs/todo" },
|
||||
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
|
||||
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
|
||||
{ name: "Workstreams", path: "/docs/workstreams" },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: ["air", "near-midnight"],
|
||||
head: `<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🗄️</text></svg>">
|
||||
<script>${_modalScript}</script>
|
||||
<style>
|
||||
.kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; }
|
||||
.kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; padding-right: 1.6rem; }
|
||||
.filter-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
|
||||
.filter-text-input { display: flex; align-items: center; }
|
||||
.filter-text-input input { height: 30px; font-size: 0.85rem; padding: 0.25rem 0.5rem; border-radius: 6px; border: 1px solid var(--theme-foreground-faint, #ccc); background: var(--theme-background, #fff); font-family: inherit; color: inherit; }
|
||||
</style>`,
|
||||
footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.",
|
||||
};
|
||||
4184
state-hub/dashboard/package-lock.json
generated
4184
state-hub/dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"name": "custodian-state-hub-dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "observable preview",
|
||||
"build": "observable build",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@observablehq/framework": "^1.13.3"
|
||||
}
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
---
|
||||
title: Capability Requests
|
||||
---
|
||||
|
||||
```js
|
||||
import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||
const POLL = 30_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll for capability requests
|
||||
const reqState = (async function*() {
|
||||
let failures = 0;
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
try {
|
||||
const r = await apiFetch("/capability-requests/");
|
||||
ok = r.ok;
|
||||
data = ok ? await r.json() : [];
|
||||
} catch {}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield {data, ok, ts: new Date()};
|
||||
await waitForVisible(pollDelay({ok, base: POLL, failures}));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const requests = reqState.data ?? [];
|
||||
const _ok = reqState.ok ?? false;
|
||||
const _ts = reqState.ts;
|
||||
```
|
||||
|
||||
# Capability Requests
|
||||
|
||||
```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:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||
${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`<span style="color:red">API offline</span>`}
|
||||
</div>`;
|
||||
withDocHelp(_liveEl, "/docs/live-data");
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
|
||||
const _h1 = document.querySelector("#observablehq-main h1");
|
||||
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities"); }
|
||||
```
|
||||
|
||||
```js
|
||||
// KPI sidebar
|
||||
const open = requests.filter(r => ["requested","routing_disputed","accepted","in_progress","ready_for_review"].includes(r.status));
|
||||
const completed = requests.filter(r => r.status === "completed");
|
||||
const avgFulfill = completed.length > 0
|
||||
? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1)
|
||||
: "—";
|
||||
const critical = open.filter(r => r.priority === "critical" || r.priority === "high").length;
|
||||
|
||||
const kpiEl = html`<div class="kpi-infobox">
|
||||
<div class="kpi-infobox-title">Capability Requests</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.4rem 0.8rem;font-size:0.82rem">
|
||||
<span>Open</span><strong>${open.length}</strong>
|
||||
<span>Avg fulfill</span><strong>${avgFulfill}d</strong>
|
||||
<span>High/Critical</span><strong style="color:${critical > 0 ? 'orange' : 'inherit'}">${critical}</strong>
|
||||
<span>Total</span><strong>${requests.length}</strong>
|
||||
</div>
|
||||
</div>`;
|
||||
injectTocTop("cap-req-kpi", kpiEl);
|
||||
```
|
||||
|
||||
```js
|
||||
// Filters
|
||||
const typeFilter = Inputs.select(
|
||||
["all", ...new Set(requests.map(r => r.capability_type))],
|
||||
{label: "Type", value: "all"}
|
||||
);
|
||||
const statFilter = Inputs.select(
|
||||
["all", "routing_disputed", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"],
|
||||
{label: "Status", value: "all"}
|
||||
);
|
||||
const domFilter = Inputs.select(
|
||||
["all", ...new Set([...requests.map(r => r.requesting_domain_slug), ...requests.map(r => r.fulfilling_domain_slug).filter(Boolean)])],
|
||||
{label: "Domain", value: "all"}
|
||||
);
|
||||
display(html`<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
${typeFilter}${statFilter}${domFilter}
|
||||
</div>`);
|
||||
```
|
||||
|
||||
```js
|
||||
const tf = typeFilter.value;
|
||||
const sf = statFilter.value;
|
||||
const df = domFilter.value;
|
||||
|
||||
const filtered = requests.filter(r =>
|
||||
(tf === "all" || r.capability_type === tf) &&
|
||||
(sf === "all" || r.status === sf) &&
|
||||
(df === "all" || r.requesting_domain_slug === df || r.fulfilling_domain_slug === df)
|
||||
);
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
```js
|
||||
const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"};
|
||||
const disputed = requests.filter(r => r.status === "routing_disputed");
|
||||
|
||||
// Disputed banner — shown at top when any exist
|
||||
if (disputed.length > 0) {
|
||||
display(html`<div class="disputed-banner">
|
||||
<div class="disputed-banner-title">⚠ Routing Disputed (${disputed.length})</div>
|
||||
${disputed.map(r => html`
|
||||
<div class="disputed-card">
|
||||
<div class="disputed-card-header">
|
||||
<span class="cap-title">${r.title}</span>
|
||||
<span class="cap-domains">${r.requesting_domain_slug} → <strong>${r.fulfilling_domain_slug ?? "unassigned"}</strong></span>
|
||||
</div>
|
||||
<div class="disputed-reason"><strong>Dispute:</strong> ${r.dispute_reason ?? "(no reason given)"}</div>
|
||||
${r.dispute_suggested_domain ? html`<div class="disputed-suggestion">Suggested domain: <strong>${r.dispute_suggested_domain}</strong></div>` : ""}
|
||||
${r.disputed_by ? html`<div class="disputed-meta">Raised by <em>${r.disputed_by}</em> · ${new Date(r.disputed_at).toLocaleString()}</div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem">
|
||||
<div class="card"><h3>Requested</h3><p class="big-num">${requests.filter(r => r.status === "requested").length}</p></div>
|
||||
<div class="card"><h3>In Progress</h3><p class="big-num">${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}</p></div>
|
||||
<div class="card"><h3>Ready for Review</h3><p class="big-num">${requests.filter(r => r.status === "ready_for_review").length}</p></div>
|
||||
<div class="card"><h3>Completed</h3><p class="big-num">${completed.length}</p></div>
|
||||
</div>`);
|
||||
```
|
||||
|
||||
## Status Kanban
|
||||
|
||||
```js
|
||||
const statusCols = [
|
||||
{key: "routing_disputed", label: "⚠ Routing Disputed", color: "#f59e0b"},
|
||||
{key: "requested", label: "Requested", color: "steelblue"},
|
||||
{key: "accepted", label: "Accepted", color: "#f0a500"},
|
||||
{key: "in_progress", label: "In Progress", color: "#2196f3"},
|
||||
{key: "ready_for_review", label: "Ready for Review", color: "#4caf50"},
|
||||
{key: "completed", label: "Completed", color: "#2e7d32"},
|
||||
{key: "rejected", label: "Rejected", color: "#e53935"},
|
||||
{key: "withdrawn", label: "Withdrawn", color: "#bbb"},
|
||||
];
|
||||
|
||||
const colMap = {};
|
||||
for (const r of filtered) {
|
||||
(colMap[r.status] = colMap[r.status] ?? []).push(r);
|
||||
}
|
||||
|
||||
const activeCols = statusCols.filter(s => colMap[s.key]?.length);
|
||||
if (activeCols.length === 0) {
|
||||
display(html`<p style="color:gray">No capability requests match the current filters.</p>`);
|
||||
} else {
|
||||
const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0);
|
||||
display(html`<div class="kanban">
|
||||
${activeCols.map(s => html`
|
||||
<div class="kanban-col">
|
||||
<div class="kanban-header" style="border-bottom:2px solid ${s.color}">${s.label} <span class="kanban-count">${colMap[s.key].length}</span></div>
|
||||
${colMap[s.key].map(r => html`
|
||||
<div class="cap-card">
|
||||
<div class="cap-type-badge" style="background:${priorityColors[r.priority] ?? '#aaa'}20;color:${priorityColors[r.priority] ?? '#aaa'}">${r.capability_type}</div>
|
||||
<div class="cap-priority-badge" style="color:${priorityColors[r.priority] ?? '#888'}">${r.priority}</div>
|
||||
<div class="cap-title">${r.title}</div>
|
||||
<div class="cap-domains">
|
||||
<span>${r.requesting_domain_slug}</span>
|
||||
${r.fulfilling_domain_slug ? html` → <strong>${r.fulfilling_domain_slug}</strong>` : html` → <em>unassigned</em>`}
|
||||
</div>
|
||||
<div class="cap-age">${ageDays(r)}d old</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
## All Requests
|
||||
|
||||
```js
|
||||
display(Inputs.table(filtered.map(r => ({
|
||||
Type: r.capability_type,
|
||||
Title: r.title,
|
||||
Priority: r.priority,
|
||||
Status: r.status,
|
||||
Requester: r.requesting_domain_slug,
|
||||
Provider: r.fulfilling_domain_slug ?? "—",
|
||||
Agent: r.requesting_agent,
|
||||
Created: new Date(r.created_at).toLocaleDateString(),
|
||||
})), {maxWidth: 1000}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Capability Catalog
|
||||
|
||||
```js
|
||||
// Live poll for catalog entries
|
||||
const catalogState = (async function*() {
|
||||
let failures = 0;
|
||||
while (true) {
|
||||
let data = [], ok = false;
|
||||
try {
|
||||
const r = await apiFetch("/capability-catalog/?status=all");
|
||||
ok = r.ok;
|
||||
if (r.ok) data = await r.json();
|
||||
} catch {}
|
||||
failures = ok ? 0 : failures + 1;
|
||||
yield data;
|
||||
await waitForVisible(pollDelay({ok, base: POLL, failures}));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const catalog = catalogState ?? [];
|
||||
```
|
||||
|
||||
```js
|
||||
if (catalog.length === 0) {
|
||||
display(html`<p style="color:gray">No capabilities registered yet. Add <code>```capability</code> blocks to SCOPE.md files and run <code>make ingest-capabilities-all</code>.</p>`);
|
||||
} else {
|
||||
// Group by domain
|
||||
const byDomain = {};
|
||||
for (const c of catalog) {
|
||||
(byDomain[c.domain_slug] = byDomain[c.domain_slug] ?? []).push(c);
|
||||
}
|
||||
const typeColors = {
|
||||
infrastructure: "#e65100", api: "#1565c0", data: "#2e7d32",
|
||||
security: "#c62828", documentation: "#6a1b9a", other: "#888"
|
||||
};
|
||||
display(html`<div class="catalog-grid">
|
||||
${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html`
|
||||
<div class="catalog-domain">
|
||||
<div class="catalog-domain-header">${domain} <span class="kanban-count">${caps.length}</span></div>
|
||||
${caps.map(c => html`
|
||||
<div class="catalog-entry ${c.status === 'deprecated' ? 'catalog-deprecated' : ''}">
|
||||
<div class="cap-type-badge" style="background:${(typeColors[c.capability_type] ?? '#888')}18;color:${typeColors[c.capability_type] ?? '#888'}">${c.capability_type}</div>
|
||||
<div class="catalog-title">${c.title}</div>
|
||||
${c.description ? html`<div class="catalog-desc">${c.description}</div>` : ""}
|
||||
${c.keywords?.length ? html`<div class="catalog-kw">${c.keywords.map(k => html`<span class="kw-tag">${k}</span>`)}</div>` : ""}
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
.live-indicator { font-size: 0.8rem; color: gray; padding: 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||
.big-num { font-size: 2.2rem; font-weight: bold; margin: 0.25rem 0; }
|
||||
.kanban { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.kanban-col { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
|
||||
.kanban-header { font-weight: 600; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||
.kanban-count { font-size: 0.75rem; background: var(--theme-background); border-radius: 10px; padding: 0.1rem 0.4rem; font-weight: 500; }
|
||||
.cap-card { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
|
||||
.cap-type-badge { display: inline-block; font-size: 0.65rem; font-weight: 700; border-radius: 3px; padding: 0.1rem 0.35rem; margin-bottom: 0.15rem; }
|
||||
.cap-priority-badge { display: inline-block; font-size: 0.6rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 0.3rem; }
|
||||
.cap-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.2rem; line-height: 1.3; }
|
||||
.cap-domains { font-size: 0.75rem; color: steelblue; font-family: monospace; }
|
||||
.cap-age { font-size: 0.7rem; color: gray; margin-top: 0.3rem; }
|
||||
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.catalog-domain { background: var(--theme-background-alt); border-radius: 8px; padding: 0.75rem; }
|
||||
.catalog-domain-header { font-weight: 600; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.04em; padding-bottom: 0.5rem; margin-bottom: 0.5rem; border-bottom: 2px solid var(--theme-foreground-faint, #ddd); display: flex; align-items: center; gap: 0.4rem; }
|
||||
.catalog-entry { background: var(--theme-background); border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
|
||||
.catalog-deprecated { opacity: 0.5; }
|
||||
.catalog-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 0.15rem; }
|
||||
.catalog-desc { font-size: 0.75rem; color: var(--theme-foreground-muted, #666); line-height: 1.35; margin-bottom: 0.3rem; }
|
||||
.catalog-kw { display: flex; flex-wrap: wrap; gap: 0.25rem; }
|
||||
.kw-tag { font-size: 0.6rem; background: var(--theme-background-alt, #f0f0f0); border: 1px solid var(--theme-foreground-faint, #ddd); border-radius: 3px; padding: 0.05rem 0.3rem; font-family: monospace; color: var(--theme-foreground-muted, #666); }
|
||||
.disputed-banner { background: #fff8e1; border: 1.5px solid #f59e0b; border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 1.25rem; }
|
||||
.disputed-banner-title { font-weight: 700; font-size: 0.9rem; color: #b45309; margin-bottom: 0.6rem; }
|
||||
.disputed-card { background: #fffbf0; border: 1px solid #fcd34d; border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.5rem; }
|
||||
.disputed-card-header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; margin-bottom: 0.3rem; flex-wrap: wrap; }
|
||||
.disputed-reason { font-size: 0.8rem; color: #92400e; margin-bottom: 0.2rem; }
|
||||
.disputed-suggestion { font-size: 0.78rem; color: #1d4ed8; margin-bottom: 0.15rem; }
|
||||
.disputed-meta { font-size: 0.72rem; color: gray; margin-top: 0.2rem; }
|
||||
</style>
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* action-confirm — modal dialog that requires a non-empty comment before
|
||||
* confirming an action (e.g. resolving a decision, marking an intervention done).
|
||||
*
|
||||
* Lives on document.body so it survives live-poll re-renders of the page content.
|
||||
*
|
||||
* Usage:
|
||||
* import {openActionConfirm} from "./components/action-confirm.js";
|
||||
*
|
||||
* openActionConfirm({
|
||||
* title: "Mark as Done", // modal header
|
||||
* entityTitle: task.title, // shown as context below the header
|
||||
* label: "Resolution comment", // textarea label
|
||||
* placeholder: "What was done?", // textarea placeholder
|
||||
* confirmLabel: "Mark Done", // confirm button text
|
||||
* onConfirm: async (comment) => { ... }, // called with trimmed comment
|
||||
* // onConfirm should throw (or return a rejected promise) on API error
|
||||
* });
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "action-confirm-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── Backdrop ────────────────────────────────────────────────────────────── */
|
||||
.ac-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
z-index: 9200; display: flex; align-items: center; justify-content: center;
|
||||
animation: _ac-fade 0.15s ease;
|
||||
}
|
||||
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
|
||||
|
||||
/* ── Box ──────────────────────────────────────────────────────────────────── */
|
||||
.ac-box {
|
||||
width: min(480px, 92vw);
|
||||
background: var(--theme-background, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
animation: _ac-rise 0.15s ease;
|
||||
}
|
||||
@keyframes _ac-rise {
|
||||
from { transform: translateY(12px); opacity: 0 }
|
||||
to { transform: translateY(0); opacity: 1 }
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.ac-header {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
background: var(--theme-background-alt, #f7f7f7);
|
||||
}
|
||||
.ac-title {
|
||||
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
|
||||
color: var(--theme-foreground, #111);
|
||||
}
|
||||
.ac-close {
|
||||
background: none; border: 1px solid transparent; cursor: pointer;
|
||||
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
|
||||
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
|
||||
font-family: inherit; line-height: 1.3;
|
||||
}
|
||||
.ac-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
||||
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.ac-entity-title {
|
||||
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
|
||||
background: var(--theme-background-alt, #f9f9f9);
|
||||
border: 1px solid var(--theme-foreground-faint, #eee);
|
||||
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ac-label {
|
||||
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
|
||||
margin-bottom: 0.25rem; display: block;
|
||||
}
|
||||
|
||||
.ac-textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
min-height: 80px; resize: vertical;
|
||||
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
||||
border-radius: 6px;
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground, #111);
|
||||
transition: border-color 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
|
||||
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
|
||||
|
||||
.ac-error {
|
||||
font-size: 0.8rem; color: #dc2626;
|
||||
background: #fef2f2; border: 1px solid #fecaca;
|
||||
border-radius: 6px; padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────────────────── */
|
||||
.ac-footer {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding: 0.65rem 1rem 0.9rem;
|
||||
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
}
|
||||
.ac-btn-cancel {
|
||||
padding: 0.3rem 0.85rem; border-radius: 6px;
|
||||
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground-muted, #555);
|
||||
font-size: 0.85rem; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
|
||||
|
||||
.ac-btn-confirm {
|
||||
padding: 0.3rem 0.85rem; border-radius: 6px;
|
||||
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
|
||||
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
|
||||
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title Modal header text
|
||||
* @param {string} [opts.entityTitle] Optional context shown below the header
|
||||
* @param {string} opts.label Textarea label
|
||||
* @param {string} [opts.placeholder] Textarea placeholder
|
||||
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
|
||||
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
|
||||
*/
|
||||
export function openActionConfirm({
|
||||
title,
|
||||
entityTitle,
|
||||
label,
|
||||
placeholder = "Add a comment…",
|
||||
confirmLabel = "Confirm",
|
||||
onConfirm,
|
||||
}) {
|
||||
_ensureStyles();
|
||||
document.getElementById("_ac-root")?.remove();
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "_ac-root";
|
||||
root.className = "ac-backdrop";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-label", title);
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
const header = document.createElement("div");
|
||||
header.className = "ac-header";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "ac-title";
|
||||
titleEl.textContent = title;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "ac-close";
|
||||
closeBtn.textContent = "✕";
|
||||
closeBtn.setAttribute("aria-label", "Cancel");
|
||||
header.append(titleEl, closeBtn);
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
const body = document.createElement("div");
|
||||
body.className = "ac-body";
|
||||
|
||||
if (entityTitle) {
|
||||
const ctx = document.createElement("div");
|
||||
ctx.className = "ac-entity-title";
|
||||
ctx.textContent = entityTitle;
|
||||
body.append(ctx);
|
||||
}
|
||||
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.className = "ac-label";
|
||||
labelEl.textContent = label;
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "ac-textarea";
|
||||
textarea.placeholder = placeholder;
|
||||
textarea.rows = 3;
|
||||
labelEl.append(textarea); // make label clickable
|
||||
|
||||
const fieldWrap = document.createElement("div");
|
||||
fieldWrap.append(labelEl);
|
||||
body.append(fieldWrap);
|
||||
|
||||
const errorEl = document.createElement("div");
|
||||
errorEl.className = "ac-error";
|
||||
errorEl.style.display = "none";
|
||||
body.append(errorEl);
|
||||
|
||||
// ── Footer ────────────────────────────────────────────────────────────────
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "ac-footer";
|
||||
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "ac-btn-cancel";
|
||||
cancelBtn.textContent = "Cancel";
|
||||
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.className = "ac-btn-confirm";
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
footer.append(cancelBtn, confirmBtn);
|
||||
|
||||
// ── Assemble ──────────────────────────────────────────────────────────────
|
||||
const box = document.createElement("div");
|
||||
box.className = "ac-box";
|
||||
box.append(header, body, footer);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
// Focus the textarea after animation
|
||||
setTimeout(() => textarea.focus(), 50);
|
||||
|
||||
// ── Behaviour ─────────────────────────────────────────────────────────────
|
||||
const close = () => root.remove();
|
||||
|
||||
cancelBtn.addEventListener("click", close);
|
||||
closeBtn.addEventListener("click", close);
|
||||
root.addEventListener("click", e => { if (e.target === root) close(); });
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
textarea.addEventListener("input", () => {
|
||||
textarea.classList.remove("ac-invalid");
|
||||
errorEl.style.display = "none";
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
const comment = textarea.value.trim();
|
||||
if (!comment) {
|
||||
textarea.classList.add("ac-invalid");
|
||||
textarea.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmBtn.disabled = true;
|
||||
cancelBtn.disabled = true;
|
||||
confirmBtn.textContent = "…";
|
||||
errorEl.style.display = "none";
|
||||
|
||||
try {
|
||||
await onConfirm(comment);
|
||||
close();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
|
||||
errorEl.style.display = "";
|
||||
confirmBtn.disabled = false;
|
||||
cancelBtn.disabled = false;
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
export const API = "http://127.0.0.1:8000";
|
||||
export const POLL = 15_000;
|
||||
export const POLL_HEAVY = 60_000;
|
||||
export const FETCH_TIMEOUT = 12_000;
|
||||
|
||||
export function pollDelay({ok = true, base = POLL, failures = 0} = {}) {
|
||||
return ok ? base : Math.min(base * 2 ** Math.min(failures, 4), 300_000);
|
||||
}
|
||||
|
||||
export function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Waits `ms` if the tab is visible; pauses until the tab becomes visible if hidden,
|
||||
// then returns immediately so the next poll fires as soon as the user returns.
|
||||
export async function waitForVisible(ms) {
|
||||
if (typeof document === "undefined") return sleep(ms);
|
||||
if (document.visibilityState === "visible") return sleep(ms);
|
||||
return new Promise(resolve => {
|
||||
const handler = () => {
|
||||
document.removeEventListener("visibilitychange", handler);
|
||||
resolve();
|
||||
};
|
||||
document.addEventListener("visibilitychange", handler);
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiFetch(path, options = {}) {
|
||||
const url = path.startsWith("http") ? path : `${API}${path}`;
|
||||
const timeout = options.timeout ?? FETCH_TIMEOUT;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), timeout);
|
||||
try {
|
||||
return await fetch(url, {...options, signal: ctrl.signal});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* doc-overlay — hoverable ? button that opens a documentation page in an overlay.
|
||||
*
|
||||
* Usage:
|
||||
* import {withDocHelp} from "./components/doc-overlay.js";
|
||||
*
|
||||
* const el = html`<div class="my-card">...</div>`;
|
||||
* withDocHelp(el, "/docs/my-page"); // mutates el in place, returns it
|
||||
* display(el);
|
||||
*
|
||||
* The element must have position:relative (or set it via inline style before calling).
|
||||
* The ? button is invisible until the user hovers over the element.
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "doc-overlay-styles";
|
||||
const _titleCache = new Map();
|
||||
|
||||
async function _fetchDocTitle(docPath) {
|
||||
if (_titleCache.has(docPath)) return _titleCache.get(docPath);
|
||||
try {
|
||||
const res = await fetch(docPath);
|
||||
if (!res.ok) return null;
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(await res.text(), "text/html");
|
||||
const title = doc.querySelector("h1")?.textContent?.trim() ?? null;
|
||||
if (title) _titleCache.set(docPath, title);
|
||||
return title;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── ? help button ─────────────────────────────────────────────────────────── */
|
||||
.doc-help-btn {
|
||||
position: absolute;
|
||||
top: 0.45rem;
|
||||
right: 0.45rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground-muted, #999);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
font-family: var(--sans-serif, system-ui, sans-serif);
|
||||
}
|
||||
.doc-help-wrap:hover .doc-help-btn,
|
||||
.doc-help-btn:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.doc-help-btn:hover {
|
||||
background: var(--theme-background-alt, #f0f0f0);
|
||||
border-color: steelblue;
|
||||
color: steelblue;
|
||||
}
|
||||
|
||||
/* ── overlay backdrop ───────────────────────────────────────────────────────── */
|
||||
.doc-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: _doc-fade-in 0.15s ease;
|
||||
}
|
||||
@keyframes _doc-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── overlay box ────────────────────────────────────────────────────────────── */
|
||||
.doc-overlay-box {
|
||||
width: min(780px, 92vw);
|
||||
height: 82vh;
|
||||
background: var(--theme-background, #fff);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.28);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: _doc-rise 0.15s ease;
|
||||
}
|
||||
@keyframes _doc-rise {
|
||||
from { transform: translateY(14px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── overlay header bar ─────────────────────────────────────────────────────── */
|
||||
.doc-overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
background: var(--theme-background-alt, #f7f7f7);
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.doc-overlay-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-foreground-faint, #aaa);
|
||||
margin-right: auto;
|
||||
}
|
||||
.doc-overlay-close {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
color: var(--theme-foreground-muted, #888);
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1.2;
|
||||
font-family: inherit;
|
||||
}
|
||||
.doc-overlay-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── iframe ─────────────────────────────────────────────────────────────────── */
|
||||
.doc-overlay-frame {
|
||||
flex: 1;
|
||||
border: none;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
function _openOverlay(docPath) {
|
||||
// Remove any existing overlay
|
||||
document.getElementById("_doc-overlay-root")?.remove();
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "_doc-overlay-root";
|
||||
root.className = "doc-overlay";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "doc-overlay-box";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "doc-overlay-header";
|
||||
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "doc-overlay-hint";
|
||||
hint.textContent = "Press Esc or click outside to close";
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "doc-overlay-close";
|
||||
closeBtn.textContent = "✕ close";
|
||||
closeBtn.setAttribute("aria-label", "Close documentation");
|
||||
|
||||
header.append(hint, closeBtn);
|
||||
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "doc-overlay-frame";
|
||||
frame.src = docPath;
|
||||
frame.setAttribute("loading", "lazy");
|
||||
frame.title = "Documentation";
|
||||
|
||||
box.append(header, frame);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
const close = () => root.remove();
|
||||
|
||||
closeBtn.addEventListener("click", close);
|
||||
root.addEventListener("click", e => { if (e.target === root) close(); });
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a hoverable ? button to an element that opens a documentation overlay.
|
||||
*
|
||||
* @param {HTMLElement} element - Element to annotate. Must have position:relative.
|
||||
* @param {string} docPath - Root-relative URL, e.g. "/docs/decisions-kpi"
|
||||
* @returns {HTMLElement} The element (mutated in place).
|
||||
*/
|
||||
export function withDocHelp(element, docPath) {
|
||||
_ensureStyles();
|
||||
|
||||
element.classList.add("doc-help-wrap");
|
||||
|
||||
const btn = document.createElement("button");
|
||||
btn.className = "doc-help-btn";
|
||||
btn.textContent = "?";
|
||||
btn.setAttribute("aria-label", "Open documentation");
|
||||
btn.addEventListener("click", e => { e.stopPropagation(); _openOverlay(docPath); });
|
||||
|
||||
// Lazy-load the h1 of the target doc page as a native tooltip (bubblehelp)
|
||||
btn.addEventListener("mouseenter", async () => {
|
||||
if (btn.dataset.titleFetched) return;
|
||||
btn.dataset.titleFetched = "1";
|
||||
const title = await _fetchDocTitle(docPath);
|
||||
if (title) btn.title = title;
|
||||
}, {once: true});
|
||||
|
||||
element.append(btn);
|
||||
return element;
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
/**
|
||||
* entity-modal — click any entity row or card to open a full-detail overlay.
|
||||
*
|
||||
* Usage:
|
||||
* import {openEntityModal} from "./components/entity-modal.js";
|
||||
* row.addEventListener("click", () => openEntityModal(entity, "workstream"));
|
||||
*
|
||||
* Supported types: "workstream" | "task" | "ep" | "td"
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "entity-modal-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── Modal backdrop ──────────────────────────────────────────────────────── */
|
||||
.entity-modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
|
||||
z-index: 9100; display: flex; align-items: center; justify-content: center;
|
||||
animation: _em-fade 0.15s ease;
|
||||
}
|
||||
@keyframes _em-fade { from { opacity:0 } to { opacity:1 } }
|
||||
|
||||
/* ── Modal box ────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-box {
|
||||
width: min(700px, 92vw); max-height: 88vh;
|
||||
background: var(--theme-background, #fff); border-radius: 12px;
|
||||
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
|
||||
display: flex; flex-direction: column;
|
||||
animation: _em-rise 0.15s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes _em-rise {
|
||||
from { transform: translateY(14px); opacity: 0 }
|
||||
to { transform: translateY(0); opacity: 1 }
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-header {
|
||||
display: flex; align-items: flex-start; gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
|
||||
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
|
||||
}
|
||||
.entity-modal-title {
|
||||
flex: 1; font-size: 1rem; font-weight: 700; line-height: 1.35;
|
||||
color: var(--theme-foreground, #111); word-break: break-word;
|
||||
}
|
||||
.entity-modal-close {
|
||||
background: none; border: 1px solid transparent; cursor: pointer;
|
||||
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
|
||||
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
|
||||
font-family: inherit; line-height: 1.3;
|
||||
}
|
||||
.entity-modal-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── Body ─────────────────────────────────────────────────────────────────── */
|
||||
.entity-modal-body {
|
||||
overflow-y: auto; padding: 0.85rem 1rem;
|
||||
display: flex; flex-direction: column; gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Field rows ───────────────────────────────────────────────────────────── */
|
||||
.em-field {
|
||||
display: grid; grid-template-columns: 130px 1fr;
|
||||
gap: 0.15rem 0.65rem; font-size: 0.85rem; align-items: baseline;
|
||||
}
|
||||
.em-label {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
|
||||
padding-top: 0.14rem; white-space: nowrap;
|
||||
}
|
||||
.em-value { color: var(--theme-foreground, #222); line-height: 1.5; word-break: break-word; }
|
||||
|
||||
/* ── Description block ────────────────────────────────────────────────────── */
|
||||
.em-desc {
|
||||
font-size: 0.83rem; color: var(--theme-foreground-muted, #555);
|
||||
line-height: 1.55; white-space: pre-wrap; word-break: break-word;
|
||||
background: var(--theme-background-alt, #f9f9f9);
|
||||
border-radius: 6px; padding: 0.55rem 0.75rem;
|
||||
border: 1px solid var(--theme-foreground-faint, #eee);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ── Divider ──────────────────────────────────────────────────────────────── */
|
||||
.em-divider { border: none; border-top: 1px solid var(--theme-foreground-faint, #eee); margin: 0.2rem 0; }
|
||||
|
||||
/* ── Badge (reused from page styles, self-contained) ─────────────────────── */
|
||||
.em-badge {
|
||||
display: inline-block; padding: 0.12rem 0.5rem; border-radius: 10px;
|
||||
font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Deps list ────────────────────────────────────────────────────────────── */
|
||||
.em-deps-list { display: flex; flex-direction: column; gap: 0.12rem; }
|
||||
.em-dep-item { font-size: 0.82rem; color: var(--theme-foreground-muted, #666); }
|
||||
|
||||
/* ── Entity table (shared across all list pages) ──────────────────────────── */
|
||||
.entity-table-wrap { overflow-x: auto; max-width: 100%; }
|
||||
.entity-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 0.87rem;
|
||||
table-layout: fixed; /* honour column widths; never spill outside container */
|
||||
}
|
||||
.entity-table thead th {
|
||||
text-align: left; padding: 0.4rem 0.65rem;
|
||||
border-bottom: 2px solid var(--theme-foreground-faint, #ddd);
|
||||
font-size: 0.73rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.05em; color: var(--theme-foreground-muted, #777);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.entity-table tbody tr { border-bottom: 1px solid var(--theme-foreground-faint, #eee); }
|
||||
.entity-table tbody tr:last-child { border-bottom: none; }
|
||||
.entity-table tbody tr:hover { background: var(--theme-background-alt, #f5f5f5); }
|
||||
.entity-table td {
|
||||
padding: 0.4rem 0.65rem; vertical-align: middle;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.entity-row { cursor: pointer; }
|
||||
/* Proportional column widths — other cols share the remainder equally */
|
||||
.et-title-col { width: 32%; }
|
||||
.et-ws-col { width: 14%; }
|
||||
.et-title-cell { font-weight: 500; }
|
||||
.et-ws-cell { font-style: italic; }
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
||||
const _STATUS_STYLE = {
|
||||
active: "background:#d4edda;color:#155724",
|
||||
blocked: "background:#f8d7da;color:#721c24",
|
||||
completed: "background:#cce5ff;color:#004085",
|
||||
archived: "background:#e2e3e5;color:#383d41",
|
||||
open: "background:#dbeafe;color:#1e40af",
|
||||
in_progress: "background:#fef3c7;color:#92400e",
|
||||
addressed: "background:#dcfce7;color:#166534",
|
||||
deferred: "background:#f1f5f9;color:#64748b",
|
||||
wont_fix: "background:#f3f4f6;color:#9ca3af",
|
||||
todo: "background:#f1f5f9;color:#475569",
|
||||
done: "background:#dcfce7;color:#166534",
|
||||
cancelled: "background:#f3f4f6;color:#9ca3af",
|
||||
resolved: "background:#dcfce7;color:#166534",
|
||||
superseded: "background:#e2e3e5;color:#383d41",
|
||||
};
|
||||
|
||||
const _PRIORITY_STYLE = {
|
||||
critical: "background:#fee2e2;color:#991b1b",
|
||||
high: "background:#ffedd5;color:#9a3412",
|
||||
medium: "background:#dbeafe;color:#1e40af",
|
||||
low: "background:#f1f5f9;color:#475569",
|
||||
};
|
||||
|
||||
/* ── DOM helpers ─────────────────────────────────────────────────────────── */
|
||||
function _badge(text, styleMap) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "em-badge";
|
||||
el.style.cssText = styleMap[text] ?? "background:#f1f5f9;color:#555";
|
||||
el.textContent = (text ?? "").replace(/_/g, " ");
|
||||
return el;
|
||||
}
|
||||
|
||||
function _field(label, valueEl) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "em-field";
|
||||
const l = document.createElement("span");
|
||||
l.className = "em-label";
|
||||
l.textContent = label;
|
||||
row.append(l, valueEl);
|
||||
return row;
|
||||
}
|
||||
|
||||
function _textVal(text) {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value";
|
||||
v.textContent = text ?? "—";
|
||||
return v;
|
||||
}
|
||||
|
||||
function _descVal(text) {
|
||||
const v = document.createElement("div");
|
||||
v.className = "em-desc";
|
||||
v.textContent = text;
|
||||
return v;
|
||||
}
|
||||
|
||||
function _divider() {
|
||||
const hr = document.createElement("hr");
|
||||
hr.className = "em-divider";
|
||||
return hr;
|
||||
}
|
||||
|
||||
function _fmtDate(iso) {
|
||||
if (!iso) return "—";
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
/* ── Body builders per entity type ─────────────────────────────────────── */
|
||||
function _buildBody(entity, type) {
|
||||
const els = [];
|
||||
const tf = (label, text) => _field(label, _textVal(text));
|
||||
const bf = (label, val, styleMap) => {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value";
|
||||
v.append(_badge(val, styleMap));
|
||||
return _field(label, v);
|
||||
};
|
||||
|
||||
if (type === "workstream") {
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
tf("Domain", entity.domain ?? entity.topic_title ?? "—"),
|
||||
tf("Owner", entity.owner ?? "—"),
|
||||
tf("Due", entity.due_date ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
if (entity.tasks_total !== undefined) {
|
||||
els.push(_divider(),
|
||||
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
|
||||
(entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") +
|
||||
(entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : ""))
|
||||
);
|
||||
}
|
||||
if (entity.depends_on?.length) {
|
||||
const list = document.createElement("div"); list.className = "em-deps-list";
|
||||
for (const d of entity.depends_on) {
|
||||
const span = document.createElement("span"); span.className = "em-dep-item";
|
||||
span.textContent = `↳ ${d.workstream_title}`;
|
||||
list.append(span);
|
||||
}
|
||||
els.push(_field("Depends on", list));
|
||||
}
|
||||
if (entity.blocks?.length) {
|
||||
const list = document.createElement("div"); list.className = "em-deps-list";
|
||||
for (const d of entity.blocks) {
|
||||
const span = document.createElement("span"); span.className = "em-dep-item";
|
||||
span.textContent = `⊳ ${d.workstream_title}`;
|
||||
list.append(span);
|
||||
}
|
||||
els.push(_field("Blocks", list));
|
||||
}
|
||||
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
||||
if (entity.slug) els.push(tf("Slug", entity.slug));
|
||||
els.push(tf("ID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "task") {
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Assignee", entity.assignee ?? "—"),
|
||||
tf("Due", entity.due_date ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
if (entity.blocking_reason) {
|
||||
const v = document.createElement("span");
|
||||
v.className = "em-value"; v.style.color = "#b45309";
|
||||
v.textContent = entity.blocking_reason;
|
||||
els.push(_divider(), _field("Blocking reason", v));
|
||||
}
|
||||
els.push(_divider(), tf("Updated", _fmtDate(entity.updated_at)));
|
||||
els.push(tf("ID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "ep") {
|
||||
if (entity.ep_id) els.push(tf("EP ID", entity.ep_id));
|
||||
els.push(
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
bf("Priority", entity.priority, _PRIORITY_STYLE),
|
||||
tf("Type", entity.ep_type ?? "—"),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Location", entity.location ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
||||
els.push(tf("UUID", entity.id));
|
||||
}
|
||||
|
||||
else if (type === "td") {
|
||||
if (entity.td_id) els.push(tf("TD ID", entity.td_id));
|
||||
els.push(
|
||||
bf("Severity", entity.severity, _PRIORITY_STYLE),
|
||||
bf("Status", entity.status, _STATUS_STYLE),
|
||||
tf("Type", entity.debt_type ?? "—"),
|
||||
tf("Domain", entity.domain ?? "—"),
|
||||
tf("Workstream", entity.workstream_title ?? "—"),
|
||||
tf("Location", entity.location ?? "—"),
|
||||
);
|
||||
if (entity.description) {
|
||||
els.push(_divider(), _field("Description", _descVal(entity.description)));
|
||||
}
|
||||
els.push(_divider(), tf("Recorded", _fmtDate(entity.created_at)));
|
||||
els.push(tf("UUID", entity.id));
|
||||
}
|
||||
|
||||
return els;
|
||||
}
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Open a detail modal for the given entity.
|
||||
* @param {object} entity - The entity data object (workstream, task, ep, or td)
|
||||
* @param {string} type - One of: "workstream" | "task" | "ep" | "td"
|
||||
*/
|
||||
export function openEntityModal(entity, type) {
|
||||
_ensureStyles();
|
||||
document.getElementById("_entity-modal-root")?.remove();
|
||||
|
||||
const title = entity.title ?? "(no title)";
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.id = "_entity-modal-root";
|
||||
root.className = "entity-modal";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-label", title);
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "entity-modal-box";
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "entity-modal-header";
|
||||
const titleEl = document.createElement("div");
|
||||
titleEl.className = "entity-modal-title";
|
||||
titleEl.textContent = title;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "entity-modal-close";
|
||||
closeBtn.textContent = "✕ close";
|
||||
closeBtn.setAttribute("aria-label", "Close detail panel");
|
||||
header.append(titleEl, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.className = "entity-modal-body";
|
||||
for (const el of _buildBody(entity, type)) body.append(el);
|
||||
|
||||
box.append(header, body);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
const close = () => root.remove();
|
||||
closeBtn.addEventListener("click", close);
|
||||
root.addEventListener("click", e => { if (e.target === root) close(); });
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an interactive entity table element.
|
||||
*
|
||||
* @param {Array} rows - Array of entity objects to display
|
||||
* @param {Array} columns - [{label, key, cls?}] — columns in order
|
||||
* @param {Function} onRowClick - Called with the full entity when a row is clicked
|
||||
* @returns {HTMLTableElement}
|
||||
*/
|
||||
export function buildEntityTable(rows, columns, onRowClick) {
|
||||
_ensureStyles();
|
||||
|
||||
const table = document.createElement("table");
|
||||
table.className = "entity-table";
|
||||
|
||||
// Header
|
||||
const thead = document.createElement("thead");
|
||||
const htr = document.createElement("tr");
|
||||
for (const col of columns) {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = col.label;
|
||||
if (col.cls) th.className = col.cls;
|
||||
htr.append(th);
|
||||
}
|
||||
thead.append(htr);
|
||||
table.append(thead);
|
||||
|
||||
// Body
|
||||
const tbody = document.createElement("tbody");
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "entity-row";
|
||||
tr.addEventListener("click", () => onRowClick(row));
|
||||
|
||||
for (const col of columns) {
|
||||
const td = document.createElement("td");
|
||||
const raw = col.key ? row[col.key] : null;
|
||||
const text = col.render ? col.render(row) : (raw ?? "—");
|
||||
const textStr = String(text ?? "—");
|
||||
td.textContent = textStr;
|
||||
if (col.cls) td.className = col.cls;
|
||||
// Native tooltip so full value shows on hover (skip placeholder "—")
|
||||
if (textStr && textStr !== "—") td.title = textStr;
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
}
|
||||
table.append(tbody);
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "entity-table-wrap";
|
||||
wrap.append(table);
|
||||
return wrap;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
// Field-level help registry for dashboard entity detail pages.
|
||||
//
|
||||
// FIELD_HELP maps a field key to { label, description, doc? }.
|
||||
// label — human-readable field name (used as bold heading in help-tip)
|
||||
// description — one sentence explaining the field
|
||||
// doc — optional anchor into /docs/ pages for "Learn more"
|
||||
//
|
||||
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
|
||||
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
|
||||
//
|
||||
// Usage:
|
||||
// import {fieldRow} from "./components/field-help.js";
|
||||
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
|
||||
|
||||
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
|
||||
import {API} from "./config.js";
|
||||
void HelpTip; // silence unused-import linters
|
||||
|
||||
// ── Entity link registry ────────────────────────────────────────────────────
|
||||
// Maps FK field names to fetch/URL/title resolution rules.
|
||||
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
|
||||
const FIELD_LINKS = {
|
||||
task_id: {
|
||||
apiUrl: id => `${API}/tasks/${id}`,
|
||||
getUrl: (id, _d) => `/tasks/${id}`,
|
||||
getTitle: d => d.title,
|
||||
},
|
||||
workstream_id: {
|
||||
apiUrl: id => `${API}/workstreams/${id}`,
|
||||
getUrl: (id, _d) => `/workstreams/${id}`,
|
||||
getTitle: d => d.title || d.slug,
|
||||
},
|
||||
repo_id: {
|
||||
apiUrl: id => `${API}/repos/by-id/${id}`,
|
||||
getUrl: (_id, d) => `/repos/${d.slug}`,
|
||||
getTitle: d => d.name || d.slug,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render an entity-reference value as a link with an async help-tip showing
|
||||
* the entity title. Falls back gracefully if the fetch fails.
|
||||
*/
|
||||
function _linkCell(key, id) {
|
||||
const rule = FIELD_LINKS[key];
|
||||
const shortId = String(id).slice(0, 8) + "…";
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.textContent = shortId;
|
||||
a.href = "#";
|
||||
a.style.fontFamily = "var(--monospace, monospace)";
|
||||
a.title = String(id); // full UUID as native tooltip while async loads
|
||||
|
||||
fetch(rule.apiUrl(id))
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const title = rule.getTitle(data);
|
||||
const url = rule.getUrl(id, data);
|
||||
if (url) a.href = url;
|
||||
if (title) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", title);
|
||||
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
|
||||
a.replaceWith(tip);
|
||||
tip.appendChild(a);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* leave plain link */ });
|
||||
|
||||
return a;
|
||||
}
|
||||
|
||||
export const FIELD_HELP = {
|
||||
// ── TokenEvent ─────────────────────────────────────────────────────────────
|
||||
id: {
|
||||
label: "ID",
|
||||
description: "Unique identifier for this token event (UUID v4).",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_in: {
|
||||
label: "Tokens In",
|
||||
description: "Number of input (prompt) tokens consumed in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_out: {
|
||||
label: "Tokens Out",
|
||||
description: "Number of output (completion) tokens generated in this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
tokens_total: {
|
||||
label: "Tokens Total",
|
||||
description: "Sum of input and output tokens — total cost proxy for this event.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
task_id: {
|
||||
label: "Task ID",
|
||||
description: "The task this token event was recorded against (if any).",
|
||||
doc: "/docs/tasks",
|
||||
},
|
||||
workstream_id: {
|
||||
label: "Workstream ID",
|
||||
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
|
||||
doc: "/docs/workstreams",
|
||||
},
|
||||
repo_id: {
|
||||
label: "Repo ID",
|
||||
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
session_id: {
|
||||
label: "Session ID",
|
||||
description: "Opaque identifier for the Claude Code session that produced this event.",
|
||||
},
|
||||
model: {
|
||||
label: "Model",
|
||||
description: "The Claude model used (e.g. claude-sonnet-4-6).",
|
||||
doc: "/docs/reference#models",
|
||||
},
|
||||
agent: {
|
||||
label: "Agent",
|
||||
description: "The agent persona that produced this event (e.g. custodian, ralph).",
|
||||
doc: "/docs/reference#agents",
|
||||
},
|
||||
ref_type: {
|
||||
label: "Ref Type",
|
||||
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
ref_id: {
|
||||
label: "Ref ID",
|
||||
description: "Free-form reference ID; interpretation depends on ref_type.",
|
||||
doc: "/docs/reference#token-events",
|
||||
},
|
||||
note: {
|
||||
label: "Note",
|
||||
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
created_at: {
|
||||
label: "Created At",
|
||||
description: "Timestamp when this token event was recorded (UTC).",
|
||||
},
|
||||
|
||||
// ── Workstream ──────────────────────────────────────────────────────────────
|
||||
slug: {
|
||||
label: "Slug",
|
||||
description: "URL-safe short identifier for this entity.",
|
||||
},
|
||||
title: {
|
||||
label: "Title",
|
||||
description: "Human-readable name for this workstream or task.",
|
||||
},
|
||||
status: {
|
||||
label: "Status",
|
||||
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
|
||||
doc: "/docs/workstream-lifecycle",
|
||||
},
|
||||
topic_id: {
|
||||
label: "Topic ID",
|
||||
description: "The topic this workstream is grouped under.",
|
||||
doc: "/docs/reference#topics",
|
||||
},
|
||||
repo_goal_id: {
|
||||
label: "Repo Goal ID",
|
||||
description: "Optional link to a repo-level strategic goal this workstream advances.",
|
||||
doc: "/docs/goals",
|
||||
},
|
||||
|
||||
// ── Task ───────────────────────────────────────────────────────────────────
|
||||
assignee: {
|
||||
label: "Assignee",
|
||||
description: "Who is responsible for completing this task (agent name or human).",
|
||||
},
|
||||
priority: {
|
||||
label: "Priority",
|
||||
description: "Relative urgency: high, medium, or low.",
|
||||
},
|
||||
due_date: {
|
||||
label: "Due Date",
|
||||
description: "Target completion date (ISO 8601).",
|
||||
},
|
||||
needs_human: {
|
||||
label: "Needs Human",
|
||||
description: "True if the task is blocked waiting for human input or approval.",
|
||||
doc: "/interventions",
|
||||
},
|
||||
intervention_note: {
|
||||
label: "Intervention Note",
|
||||
description: "Why human intervention is required for this task.",
|
||||
},
|
||||
|
||||
// ── Repo ───────────────────────────────────────────────────────────────────
|
||||
repo_slug: {
|
||||
label: "Repo Slug",
|
||||
description: "Short identifier for the repository (matches the git remote slug).",
|
||||
doc: "/docs/repos",
|
||||
},
|
||||
event_count: {
|
||||
label: "Event Count",
|
||||
description: "Total number of token events attributed to this entity.",
|
||||
},
|
||||
by_model: {
|
||||
label: "By Model",
|
||||
description: "Token totals broken down by Claude model.",
|
||||
},
|
||||
by_note: {
|
||||
label: "By Note",
|
||||
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
|
||||
doc: "/docs/reference#token-note-taxonomy",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single key-value row for an entity detail table.
|
||||
* @param {string} key — field name
|
||||
* @param {*} value — field value (stringified automatically)
|
||||
* @returns {HTMLTableRowElement}
|
||||
*/
|
||||
export function fieldRow(key, value) {
|
||||
const tr = document.createElement("tr");
|
||||
|
||||
// Key cell
|
||||
const tdKey = document.createElement("td");
|
||||
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
|
||||
|
||||
const help = FIELD_HELP[key];
|
||||
if (help) {
|
||||
const tip = document.createElement("help-tip");
|
||||
tip.setAttribute("label", help.label);
|
||||
tip.setAttribute("description", help.description);
|
||||
if (help.doc) tip.setAttribute("doc", help.doc);
|
||||
tip.textContent = help.label;
|
||||
tdKey.appendChild(tip);
|
||||
} else {
|
||||
tdKey.textContent = key;
|
||||
}
|
||||
tr.appendChild(tdKey);
|
||||
|
||||
// Value cell
|
||||
const tdVal = document.createElement("td");
|
||||
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
|
||||
|
||||
let display;
|
||||
if (value === null || value === undefined) {
|
||||
display = document.createElement("span");
|
||||
display.style.color = "var(--theme-foreground-faint,#aaa)";
|
||||
display.textContent = "—";
|
||||
} else if (key in FIELD_LINKS) {
|
||||
display = _linkCell(key, value);
|
||||
} else if (typeof value === "object") {
|
||||
display = document.createElement("pre");
|
||||
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
|
||||
display.textContent = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
display = document.createElement("span");
|
||||
display.textContent = String(value);
|
||||
}
|
||||
tdVal.appendChild(display);
|
||||
tr.appendChild(tdVal);
|
||||
|
||||
return tr;
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// <help-tip label="Full Name" description="One sentence." doc="/path">ABBR</help-tip>
|
||||
//
|
||||
// A custom element that shows a floating card on hover/focus.
|
||||
// Attributes:
|
||||
// label — bold title line in the card
|
||||
// description — body text
|
||||
// doc — optional URL; adds a "Learn more →" link
|
||||
//
|
||||
// The card is appended to document.body (position:fixed) so it escapes
|
||||
// any overflow:hidden or clipping ancestors (e.g. the TOC sidebar).
|
||||
|
||||
const _STYLE_ID = "helptip-global-style";
|
||||
if (!document.getElementById(_STYLE_ID)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
help-tip {
|
||||
cursor: help;
|
||||
display: inline;
|
||||
}
|
||||
.helptip-card {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 9px;
|
||||
padding: 0.6rem 0.85rem;
|
||||
max-width: 270px;
|
||||
min-width: 130px;
|
||||
box-shadow: 0 6px 22px rgba(0,0,0,0.13), 0 1px 4px rgba(0,0,0,0.07);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--theme-foreground, #333);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.helptip-card.helptip-visible { opacity: 1; }
|
||||
.helptip-card-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.3rem;
|
||||
color: var(--theme-foreground, #222);
|
||||
}
|
||||
.helptip-card-desc {
|
||||
color: var(--theme-foreground-muted, #555);
|
||||
}
|
||||
.helptip-card-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--theme-foreground-focus, #3b82f6);
|
||||
text-decoration: none;
|
||||
}
|
||||
.helptip-card-link:hover { text-decoration: underline; }
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
class HelpTip extends HTMLElement {
|
||||
#card = null;
|
||||
#showTimer = null;
|
||||
#hideTimer = null;
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("mouseenter", this.#onEnter);
|
||||
this.addEventListener("mouseleave", this.#onLeave);
|
||||
this.addEventListener("focusin", this.#onEnter);
|
||||
this.addEventListener("focusout", this.#onLeave);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
clearTimeout(this.#showTimer);
|
||||
clearTimeout(this.#hideTimer);
|
||||
this.#clearCard();
|
||||
this.removeEventListener("mouseenter", this.#onEnter);
|
||||
this.removeEventListener("mouseleave", this.#onLeave);
|
||||
this.removeEventListener("focusin", this.#onEnter);
|
||||
this.removeEventListener("focusout", this.#onLeave);
|
||||
}
|
||||
|
||||
#onEnter = () => {
|
||||
clearTimeout(this.#hideTimer);
|
||||
this.#showTimer = setTimeout(() => this.#showCard(), 80);
|
||||
};
|
||||
|
||||
#onLeave = () => {
|
||||
clearTimeout(this.#showTimer);
|
||||
this.#hideTimer = setTimeout(() => this.#clearCard(), 200);
|
||||
};
|
||||
|
||||
#showCard() {
|
||||
if (this.#card) return;
|
||||
|
||||
const label = this.getAttribute("label") || "";
|
||||
const desc = this.getAttribute("description") || "";
|
||||
const doc = this.getAttribute("doc") || "";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "helptip-card";
|
||||
|
||||
if (label) {
|
||||
const h = document.createElement("div");
|
||||
h.className = "helptip-card-label";
|
||||
h.textContent = label;
|
||||
card.appendChild(h);
|
||||
}
|
||||
if (desc) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "helptip-card-desc";
|
||||
d.textContent = desc;
|
||||
card.appendChild(d);
|
||||
}
|
||||
if (doc) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "helptip-card-link";
|
||||
a.textContent = "Learn more →";
|
||||
a.href = doc;
|
||||
card.appendChild(a);
|
||||
}
|
||||
|
||||
// Keep card alive while mouse is over it
|
||||
card.addEventListener("mouseenter", () => clearTimeout(this.#hideTimer));
|
||||
card.addEventListener("mouseleave", this.#onLeave);
|
||||
|
||||
document.body.appendChild(card);
|
||||
this.#card = card;
|
||||
|
||||
this.#position(card);
|
||||
requestAnimationFrame(() => card.classList.add("helptip-visible"));
|
||||
}
|
||||
|
||||
#position(card) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
const cw = card.offsetWidth || 230;
|
||||
const ch = card.offsetHeight || 80;
|
||||
const gap = 8;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
// Horizontal: align to left of trigger, clamp inside viewport
|
||||
let left = rect.left;
|
||||
if (left + cw + gap > vw) left = vw - cw - gap;
|
||||
if (left < gap) left = gap;
|
||||
|
||||
// Vertical: prefer above; fall back to below
|
||||
const top = (rect.top - ch - gap >= 0)
|
||||
? rect.top - ch - gap
|
||||
: Math.min(rect.bottom + gap, vh - ch - gap);
|
||||
|
||||
card.style.left = `${left}px`;
|
||||
card.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
#clearCard() {
|
||||
if (this.#card) {
|
||||
this.#card.remove();
|
||||
this.#card = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("help-tip")) {
|
||||
customElements.define("help-tip", HelpTip);
|
||||
}
|
||||
|
||||
export { HelpTip };
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* improvement-modal — Shift+click any dashboard widget to suggest an improvement.
|
||||
*
|
||||
* Usage (once per page, usually via _footer.md):
|
||||
* import {initImprovementModal} from "./components/improvement-modal.js";
|
||||
* initImprovementModal({apiBase: "http://127.0.0.1:8000"});
|
||||
*
|
||||
* Widget names can be declared explicitly via data attribute:
|
||||
* <div data-widget-name="Workstreams by Domain">…</div>
|
||||
*
|
||||
* Otherwise the component walks the DOM to infer the nearest section heading.
|
||||
* Submissions are stored as technical-debt items with debt_type="dashboard-improvement".
|
||||
*
|
||||
* Interaction:
|
||||
* - Hold Shift → cursor changes to crosshair across the entire page
|
||||
* - Shift+click any element (except form controls) → opens suggestion modal
|
||||
*/
|
||||
|
||||
const _STYLE_ID = "improvement-modal-styles";
|
||||
|
||||
function _ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = _STYLE_ID;
|
||||
s.textContent = `
|
||||
/* ── Backdrop ──────────────────────────────────────────────────────────── */
|
||||
.impr-modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.42);
|
||||
z-index: 9200; display: flex; align-items: center; justify-content: center;
|
||||
animation: _im-fade 0.15s ease;
|
||||
}
|
||||
@keyframes _im-fade { from { opacity:0 } to { opacity:1 } }
|
||||
|
||||
/* ── Box ────────────────────────────────────────────────────────────────── */
|
||||
.impr-modal-box {
|
||||
width: min(480px, 92vw); max-height: 90vh;
|
||||
background: var(--theme-background, #fff); border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.30);
|
||||
display: flex; flex-direction: column;
|
||||
animation: _im-rise 0.15s ease; overflow: hidden;
|
||||
}
|
||||
@keyframes _im-rise {
|
||||
from { transform: translateY(12px); opacity: 0 }
|
||||
to { transform: translateY(0); opacity: 1 }
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.impr-header {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.8rem 1rem 0.75rem;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #e4e4e4);
|
||||
background: var(--theme-background-alt, #f7f7f7); flex-shrink: 0;
|
||||
}
|
||||
.impr-header-icon { font-size: 1.1rem; flex-shrink: 0; line-height: 1; }
|
||||
.impr-header-title {
|
||||
flex: 1; font-size: 0.95rem; font-weight: 700;
|
||||
color: var(--theme-foreground, #111); margin: 0;
|
||||
}
|
||||
.impr-header-close {
|
||||
background: none; border: 1px solid transparent; cursor: pointer;
|
||||
font-size: 0.82rem; color: var(--theme-foreground-muted, #999);
|
||||
padding: 0.15rem 0.42rem; border-radius: 6px; flex-shrink: 0;
|
||||
font-family: inherit; line-height: 1.3;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
.impr-header-close:hover {
|
||||
border-color: var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground, #111);
|
||||
}
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
.impr-body {
|
||||
padding: 0.85rem 1rem 0.25rem; overflow-y: auto;
|
||||
display: flex; flex-direction: column; gap: 0.6rem; flex: 1;
|
||||
}
|
||||
.impr-field-label {
|
||||
font-size: 0.7rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--theme-foreground-faint, #aaa);
|
||||
margin-bottom: 0.18rem;
|
||||
}
|
||||
.impr-context-chip {
|
||||
font-size: 0.82rem; color: var(--theme-foreground-muted, #555);
|
||||
background: var(--theme-background-alt, #f4f4f4);
|
||||
border: 1px solid var(--theme-foreground-faint, #e0e0e0);
|
||||
border-radius: 6px; padding: 0.32rem 0.65rem;
|
||||
word-break: break-word; line-height: 1.45;
|
||||
}
|
||||
.impr-textarea {
|
||||
width: 100%; box-sizing: border-box;
|
||||
min-height: 106px; resize: vertical;
|
||||
font-size: 0.87rem; font-family: inherit; line-height: 1.55;
|
||||
color: var(--theme-foreground, #111);
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ccc);
|
||||
border-radius: 7px; padding: 0.5rem 0.7rem; outline: none;
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.impr-textarea:focus {
|
||||
border-color: #6366f1;
|
||||
box-shadow: 0 0 0 2px rgba(99,102,241,0.18);
|
||||
}
|
||||
.impr-textarea.impr-error { border-color: #e53e3e; }
|
||||
.impr-hint {
|
||||
font-size: 0.71rem; color: var(--theme-foreground-faint, #bbb);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* ── Footer ─────────────────────────────────────────────────────────────── */
|
||||
.impr-footer {
|
||||
display: flex; justify-content: flex-end; gap: 0.45rem;
|
||||
padding: 0.7rem 1rem 0.8rem; flex-shrink: 0;
|
||||
border-top: 1px solid var(--theme-foreground-faint, #e4e4e4);
|
||||
}
|
||||
.impr-btn {
|
||||
padding: 0.38rem 1rem; border-radius: 7px;
|
||||
font-size: 0.83rem; cursor: pointer; font-family: inherit;
|
||||
font-weight: 600; border: 1px solid transparent;
|
||||
transition: background 0.12s, opacity 0.12s;
|
||||
}
|
||||
.impr-btn-cancel {
|
||||
background: var(--theme-background-alt, #f1f1f1);
|
||||
border-color: var(--theme-foreground-faint, #ddd);
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
}
|
||||
.impr-btn-cancel:hover { background: var(--theme-foreground-faint, #e6e6e6); }
|
||||
.impr-btn-submit { background: #6366f1; color: #fff; }
|
||||
.impr-btn-submit:hover:not(:disabled) { background: #4f46e5; }
|
||||
.impr-btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Toast ──────────────────────────────────────────────────────────────── */
|
||||
.impr-toast {
|
||||
position: fixed; bottom: 1.4rem; left: 50%; transform: translateX(-50%);
|
||||
background: #1e1b4b; color: #e0e7ff; border-radius: 8px;
|
||||
padding: 0.5rem 1.15rem; font-size: 0.82rem; font-weight: 500;
|
||||
z-index: 9300; box-shadow: 0 4px 24px rgba(0,0,0,0.28);
|
||||
white-space: nowrap; pointer-events: none;
|
||||
animation: _im-tin 0.18s ease, _im-tout 0.28s ease 1.7s forwards;
|
||||
}
|
||||
@keyframes _im-tin { from { opacity:0; transform:translateX(-50%) translateY(6px) } to { opacity:1; transform:translateX(-50%) translateY(0) } }
|
||||
@keyframes _im-tout { from { opacity:1 } to { opacity:0 } }
|
||||
|
||||
/* ── Shift-held mode: cursor + element highlighting ─────────────────────── */
|
||||
.impr-mode-shift,
|
||||
.impr-mode-shift * { cursor: copy !important; }
|
||||
|
||||
/* Highlight annotatable elements in main content, left nav, and right TOC */
|
||||
.impr-mode-shift #observablehq-main figure,
|
||||
.impr-mode-shift #observablehq-main h2,
|
||||
.impr-mode-shift #observablehq-main h3,
|
||||
.impr-mode-shift #observablehq-main h4,
|
||||
.impr-mode-shift #observablehq-main [data-widget-name],
|
||||
.impr-mode-shift #observablehq-sidebar a,
|
||||
.impr-mode-shift #observablehq-sidebar summary,
|
||||
.impr-mode-shift #observablehq-toc a,
|
||||
.impr-mode-shift #observablehq-toc .kpi-infobox,
|
||||
.impr-mode-shift #observablehq-toc [id] {
|
||||
outline: 1px dashed rgba(99, 102, 241, 0.45);
|
||||
background: rgba(99, 102, 241, 0.055) !important;
|
||||
border-radius: 4px;
|
||||
transition: background 0.1s, outline 0.1s;
|
||||
}
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/* ── Widget name inference ─────────────────────────────────────────────── */
|
||||
function _inferWidgetName(target) {
|
||||
// 0a. Right-margin TOC: link text, KPI box title, or nearest labelled container
|
||||
if (target.closest("#observablehq-toc")) {
|
||||
const link = target.closest("a");
|
||||
if (link) return (link.textContent.trim() || "TOC link") + " (TOC)";
|
||||
const kpiTitle = target.closest(".kpi-infobox")
|
||||
?.querySelector(".kpi-infobox-title");
|
||||
if (kpiTitle) return kpiTitle.textContent.trim() + " (sidebar widget)";
|
||||
const labelled = target.closest("[id]");
|
||||
if (labelled) return labelled.id.replace(/-/g, " ") + " (TOC)";
|
||||
return "Right margin";
|
||||
}
|
||||
|
||||
// 0b. Left sidebar navigation: nav link text or section heading
|
||||
if (target.closest("#observablehq-sidebar")) {
|
||||
const link = target.closest("a");
|
||||
if (link) return link.textContent.trim() || "Nav link";
|
||||
const summary = target.closest("summary");
|
||||
if (summary) return summary.textContent.trim() || "Nav section";
|
||||
return target.textContent.trim() || "Navigation";
|
||||
}
|
||||
|
||||
// 1. Explicit data-widget-name on self or ancestor
|
||||
let el = target;
|
||||
while (el && el !== document.body) {
|
||||
if (el.dataset?.widgetName) return el.dataset.widgetName;
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
// 2. Direct child heading inside a container (chart cards etc.)
|
||||
el = target;
|
||||
const main = document.querySelector("#observablehq-main") ?? document.body;
|
||||
while (el && el !== main) {
|
||||
const h = el.querySelector(":scope > h2, :scope > h3, :scope > h4");
|
||||
if (h) return h.textContent.trim();
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
// 3. Nearest preceding sibling or ancestor heading in the main flow
|
||||
el = target;
|
||||
while (el && el !== main) {
|
||||
let sib = el.previousElementSibling;
|
||||
while (sib) {
|
||||
if (sib.matches("h2, h3, h4")) return sib.textContent.trim();
|
||||
const inner = sib.querySelector("h2, h3, h4");
|
||||
if (inner) return inner.textContent.trim();
|
||||
sib = sib.previousElementSibling;
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
|
||||
// 4. Page h1 as final fallback
|
||||
return document.querySelector("#observablehq-main h1, h1")?.textContent?.trim()
|
||||
?? "Dashboard page";
|
||||
}
|
||||
|
||||
/* ── Toast helper ──────────────────────────────────────────────────────── */
|
||||
function _toast(msg) {
|
||||
document.querySelector(".impr-toast")?.remove();
|
||||
const t = document.createElement("div");
|
||||
t.className = "impr-toast";
|
||||
t.textContent = msg;
|
||||
document.body.append(t);
|
||||
setTimeout(() => t.remove(), 2100);
|
||||
}
|
||||
|
||||
/* ── Module-level guard — one listener per page load ──────────────────── */
|
||||
let _initialized = false;
|
||||
|
||||
/**
|
||||
* Wire Shift+click → improvement modal on the current page.
|
||||
* Safe to call multiple times — only the first call takes effect.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.apiBase State Hub API base URL (default: "http://127.0.0.1:8000")
|
||||
* @param {string} opts.domain Domain slug for the TD record (default: "custodian")
|
||||
*/
|
||||
export function initImprovementModal({ apiBase = "http://127.0.0.1:8000", domain = "custodian" } = {}) {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_ensureStyles();
|
||||
|
||||
// Track modifier state. Highlighting is delayed 1 s so normal Shift+typing
|
||||
// doesn't trigger the visual mode change; releasing Shift cancels immediately.
|
||||
let _shiftTimer = null;
|
||||
function _updateMode(e) {
|
||||
if (e.shiftKey) {
|
||||
if (!_shiftTimer && !document.body.classList.contains("impr-mode-shift")) {
|
||||
_shiftTimer = setTimeout(() => {
|
||||
document.body.classList.add("impr-mode-shift");
|
||||
_shiftTimer = null;
|
||||
}, 1000);
|
||||
}
|
||||
} else {
|
||||
clearTimeout(_shiftTimer);
|
||||
_shiftTimer = null;
|
||||
document.body.classList.remove("impr-mode-shift");
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", _updateMode);
|
||||
window.addEventListener("keyup", _updateMode);
|
||||
window.addEventListener("mousemove", _updateMode);
|
||||
// Clear on blur in case Shift is held when the window loses focus
|
||||
window.addEventListener("blur", () => {
|
||||
clearTimeout(_shiftTimer);
|
||||
_shiftTimer = null;
|
||||
document.body.classList.remove("impr-mode-shift");
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.shiftKey) return;
|
||||
const inSidebar = !!e.target.closest("#observablehq-sidebar");
|
||||
const inToc = !!e.target.closest("#observablehq-toc");
|
||||
// Block shift-clicks on form controls; allow nav/toc links (preventDefault stops navigation)
|
||||
if (!inSidebar && !inToc && e.target.matches("input, textarea, select, a, button")) return;
|
||||
if ((inSidebar || inToc) && e.target.matches("input, textarea, select")) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const widgetName = _inferWidgetName(e.target);
|
||||
const currentPage = document.title
|
||||
? document.title.replace(" – Custodian State Hub", "").trim()
|
||||
: (location.pathname.replace(/^\//, "") || "Overview");
|
||||
const pageName = inSidebar ? "Navigation" : currentPage;
|
||||
|
||||
// Remove any open modal
|
||||
document.getElementById("_impr-root")?.remove();
|
||||
|
||||
/* ── DOM construction ────────────────────────────────────────────── */
|
||||
const root = document.createElement("div");
|
||||
root.id = "_impr-root";
|
||||
root.className = "impr-modal";
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-modal", "true");
|
||||
root.setAttribute("aria-label", "Request Improvement");
|
||||
|
||||
const box = document.createElement("div");
|
||||
box.className = "impr-modal-box";
|
||||
|
||||
// Header
|
||||
const header = document.createElement("div");
|
||||
header.className = "impr-header";
|
||||
const icon = Object.assign(document.createElement("span"), { className: "impr-header-icon", textContent: "💡" });
|
||||
const title = Object.assign(document.createElement("div"), { className: "impr-header-title", textContent: "Request Improvement" });
|
||||
const closeBtn = Object.assign(document.createElement("button"), { className: "impr-header-close", textContent: "✕ close" });
|
||||
header.append(icon, title, closeBtn);
|
||||
|
||||
// Body
|
||||
const body = document.createElement("div");
|
||||
body.className = "impr-body";
|
||||
|
||||
const ctxLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Widget / Section" });
|
||||
const chip = Object.assign(document.createElement("div"), {
|
||||
className: "impr-context-chip",
|
||||
textContent: `${pageName} › ${widgetName}`,
|
||||
});
|
||||
|
||||
const sugLabel = Object.assign(document.createElement("div"), { className: "impr-field-label", textContent: "Your suggestion" });
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.className = "impr-textarea";
|
||||
textarea.placeholder = "Describe what you'd like to improve or change…";
|
||||
textarea.rows = 5;
|
||||
|
||||
const hint = Object.assign(document.createElement("div"), {
|
||||
className: "impr-hint",
|
||||
textContent: "Ctrl + Enter to submit · Escape to cancel",
|
||||
});
|
||||
|
||||
body.append(ctxLabel, chip, sugLabel, textarea, hint);
|
||||
|
||||
// Footer
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "impr-footer";
|
||||
const cancelBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-cancel", textContent: "Cancel" });
|
||||
const submitBtn = Object.assign(document.createElement("button"), { className: "impr-btn impr-btn-submit", textContent: "Submit suggestion" });
|
||||
footer.append(cancelBtn, submitBtn);
|
||||
|
||||
box.append(header, body, footer);
|
||||
root.append(box);
|
||||
document.body.append(root);
|
||||
|
||||
// Focus textarea after animation settles
|
||||
setTimeout(() => textarea.focus(), 80);
|
||||
|
||||
/* ── Close behaviour ─────────────────────────────────────────────── */
|
||||
const close = () => {
|
||||
root.remove();
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
closeBtn.addEventListener("click", close);
|
||||
cancelBtn.addEventListener("click", close);
|
||||
root.addEventListener("click", e => { if (e.target === root) close(); });
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === "Escape") close();
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") submitBtn.click();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
/* ── Submit ──────────────────────────────────────────────────────── */
|
||||
submitBtn.addEventListener("click", async () => {
|
||||
const suggestion = textarea.value.trim();
|
||||
if (!suggestion) {
|
||||
textarea.classList.add("impr-error");
|
||||
textarea.focus();
|
||||
setTimeout(() => textarea.classList.remove("impr-error"), 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = "Submitting…";
|
||||
|
||||
const location = `${pageName} › ${widgetName}`;
|
||||
const payload = {
|
||||
domain: domain,
|
||||
title: `UI: ${widgetName}`,
|
||||
description: suggestion,
|
||||
debt_type: "dashboard-improvement",
|
||||
severity: "low",
|
||||
status: "submitted",
|
||||
location,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/technical-debt/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.ok) {
|
||||
close();
|
||||
_toast("✓ Suggestion saved — check UI Feedback in the nav");
|
||||
} else {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Submit suggestion";
|
||||
_toast(`⚠ Submission failed (HTTP ${r.status})`);
|
||||
}
|
||||
} catch {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = "Submit suggestion";
|
||||
_toast("⚠ API unreachable — submission failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* MultiSelect — compact dropdown multi-select filter component.
|
||||
*
|
||||
* Observable-compatible: exposes `.value` (string[]) and dispatches bubbling
|
||||
* `input` events on change, so it works with `view()`, `Inputs.form`, and
|
||||
* `Generators.input`.
|
||||
*
|
||||
* Usage:
|
||||
* const el = MultiSelect(["a", "b", "c"], {label: "Domain"});
|
||||
* const selected = view(el); // string[] — empty means "all / no filter"
|
||||
*/
|
||||
|
||||
const STYLE_ID = "ms-component-styles";
|
||||
|
||||
function ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
|
||||
const s = document.createElement("style");
|
||||
s.id = STYLE_ID;
|
||||
s.textContent = `
|
||||
.ms-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-family: var(--sans-serif, system-ui, sans-serif);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ms-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--theme-foreground-faint, #ccc);
|
||||
background: var(--theme-background, #fff);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
user-select: none;
|
||||
color: var(--theme-foreground, #111);
|
||||
}
|
||||
.ms-trigger:hover {
|
||||
border-color: var(--theme-foreground-muted, #888);
|
||||
}
|
||||
.ms-trigger.ms-has-selection {
|
||||
border-color: steelblue;
|
||||
background: color-mix(in srgb, steelblue 8%, var(--theme-background, #fff));
|
||||
}
|
||||
.ms-trigger-label {
|
||||
color: var(--theme-foreground-muted, #666);
|
||||
}
|
||||
.ms-trigger-value {
|
||||
font-weight: 500;
|
||||
color: var(--theme-foreground, #111);
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ms-trigger-value.ms-placeholder {
|
||||
font-weight: 400;
|
||||
color: var(--theme-foreground-muted, #888);
|
||||
}
|
||||
.ms-chevron {
|
||||
font-size: 0.65rem;
|
||||
color: var(--theme-foreground-muted, #888);
|
||||
transition: transform 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.ms-wrap.ms-open .ms-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.ms-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
min-width: max(100%, 160px);
|
||||
background: var(--theme-background, #fff);
|
||||
border: 1px solid var(--theme-foreground-faint, #ddd);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,0.12);
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
.ms-wrap.ms-open .ms-dropdown {
|
||||
display: block;
|
||||
}
|
||||
.ms-clear {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0.75rem 0.35rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: steelblue;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--theme-foreground-faint, #eee);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.ms-clear:hover { text-decoration: underline; }
|
||||
.ms-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.28rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
}
|
||||
.ms-option:hover {
|
||||
background: var(--theme-background-alt, #f5f5f5);
|
||||
}
|
||||
.ms-option input[type=checkbox] {
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: steelblue;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
document.head.append(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[] | {value: string, label: string}[]} options
|
||||
* @param {{ label?: string, value?: string[], placeholder?: string }} opts
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function MultiSelect(options, { label = "", value = [], placeholder = "All" } = {}) {
|
||||
ensureStyles();
|
||||
|
||||
// Normalise options to {value, label} pairs
|
||||
const opts = options.map(o => typeof o === "string" ? { value: o, label: o } : o);
|
||||
let selected = new Set(value);
|
||||
|
||||
// ── Build DOM ──────────────────────────────────────────────────────────────
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ms-wrap";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "ms-trigger";
|
||||
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "ms-trigger-label";
|
||||
if (label) labelSpan.textContent = label + ":";
|
||||
|
||||
const valueSpan = document.createElement("span");
|
||||
|
||||
const chevron = document.createElement("span");
|
||||
chevron.className = "ms-chevron";
|
||||
chevron.textContent = "▾";
|
||||
|
||||
if (label) trigger.append(labelSpan, "\u00a0"); // non-breaking space between label and value
|
||||
trigger.append(valueSpan, chevron);
|
||||
|
||||
// Dropdown
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "ms-dropdown";
|
||||
|
||||
const clearBtn = document.createElement("button");
|
||||
clearBtn.type = "button";
|
||||
clearBtn.className = "ms-clear";
|
||||
clearBtn.textContent = "Clear selection";
|
||||
dropdown.append(clearBtn);
|
||||
|
||||
const checkboxes = opts.map(opt => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "ms-option";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.value = opt.value;
|
||||
cb.checked = selected.has(opt.value);
|
||||
row.append(cb, opt.label);
|
||||
dropdown.append(row);
|
||||
return cb;
|
||||
});
|
||||
|
||||
wrap.append(trigger, dropdown);
|
||||
|
||||
// ── State helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function syncUI() {
|
||||
const n = selected.size;
|
||||
if (n === 0) {
|
||||
valueSpan.textContent = placeholder;
|
||||
valueSpan.className = "ms-trigger-value ms-placeholder";
|
||||
trigger.classList.remove("ms-has-selection");
|
||||
clearBtn.style.display = "none";
|
||||
} else {
|
||||
const names = [...selected].map(v => opts.find(o => o.value === v)?.label ?? v);
|
||||
valueSpan.textContent = n <= 2 ? names.join(", ") : `${n} of ${opts.length}`;
|
||||
valueSpan.className = "ms-trigger-value";
|
||||
trigger.classList.add("ms-has-selection");
|
||||
clearBtn.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
function emit() {
|
||||
syncUI();
|
||||
wrap.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}
|
||||
|
||||
// ── Open / close ───────────────────────────────────────────────────────────
|
||||
|
||||
function open() {
|
||||
// Close any other open dropdowns on the page
|
||||
document.querySelectorAll(".ms-wrap.ms-open").forEach(w => {
|
||||
if (w !== wrap) w.classList.remove("ms-open");
|
||||
});
|
||||
wrap.classList.add("ms-open");
|
||||
}
|
||||
|
||||
function close() {
|
||||
wrap.classList.remove("ms-open");
|
||||
}
|
||||
|
||||
// ── Events ─────────────────────────────────────────────────────────────────
|
||||
|
||||
trigger.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
wrap.classList.contains("ms-open") ? close() : open();
|
||||
});
|
||||
|
||||
checkboxes.forEach((cb, i) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) selected.add(opts[i].value);
|
||||
else selected.delete(opts[i].value);
|
||||
emit();
|
||||
});
|
||||
});
|
||||
|
||||
clearBtn.addEventListener("click", e => {
|
||||
e.stopPropagation();
|
||||
selected.clear();
|
||||
checkboxes.forEach(cb => (cb.checked = false));
|
||||
emit();
|
||||
});
|
||||
|
||||
// Prevent dropdown clicks from bubbling to the document closer
|
||||
dropdown.addEventListener("click", e => e.stopPropagation());
|
||||
|
||||
document.addEventListener("click", close);
|
||||
document.addEventListener("keydown", e => { if (e.key === "Escape") close(); });
|
||||
|
||||
// ── Observable compatibility ───────────────────────────────────────────────
|
||||
|
||||
// Empty array = "no filter" (show all). Semantics: any checked item = restrict to those.
|
||||
Object.defineProperty(wrap, "value", {
|
||||
get: () => [...selected],
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
syncUI();
|
||||
return wrap;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user