diff --git a/AGENTS.md b/AGENTS.md index 07b5590..1092007 100644 --- a/AGENTS.md +++ b/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 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= + cd /home/worsch/state-hub && make fix-consistency-remote REPO= ``` **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. diff --git a/CLAUDE.md b/CLAUDE.md index bbeda86..2f12e03 100644 --- a/CLAUDE.md +++ b/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 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= + cd /home/worsch/state-hub && make fix-consistency-remote REPO= ``` **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. diff --git a/README.md b/README.md index 8c9e645..ffe5095 100644 --- a/README.md +++ b/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//`: ## 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. diff --git a/SCOPE.md b/SCOPE.md index 3f63a1e..299fd59 100644 --- a/SCOPE.md +++ b/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. diff --git a/state-hub/.dockerignore b/state-hub/.dockerignore deleted file mode 100644 index 7a5b430..0000000 --- a/state-hub/.dockerignore +++ /dev/null @@ -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 diff --git a/state-hub/.env.example b/state-hub/.env.example deleted file mode 100644 index 3c94cbf..0000000 --- a/state-hub/.env.example +++ /dev/null @@ -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= diff --git a/state-hub/Dockerfile b/state-hub/Dockerfile deleted file mode 100644 index 534811e..0000000 --- a/state-hub/Dockerfile +++ /dev/null @@ -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"] diff --git a/state-hub/Makefile b/state-hub/Makefile deleted file mode 100644 index 2cd08a8..0000000 --- a/state-hub/Makefile +++ /dev/null @@ -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= PROJECT_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= PROJECT_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= PATH="; exit 1) - @test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO= 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= [DOMAIN=]"; 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="; 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="; 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="; 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="; 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= -gitea-inventory: - uv run python scripts/gitea_inventory.py $(if $(JSON),--json) - -clean: - $(COMPOSE) down -v diff --git a/state-hub/README.md b/state-hub/README.md index a8ba6f4..3dbcd10 100644 --- a/state-hub/README.md +++ b/state-hub/README.md @@ -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. diff --git a/state-hub/alembic.ini b/state-hub/alembic.ini deleted file mode 100644 index 801d441..0000000 --- a/state-hub/alembic.ini +++ /dev/null @@ -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 diff --git a/state-hub/api/config.py b/state-hub/api/config.py deleted file mode 100644 index 51c3cc7..0000000 --- a/state-hub/api/config.py +++ /dev/null @@ -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() diff --git a/state-hub/api/database.py b/state-hub/api/database.py deleted file mode 100644 index 9e21ab3..0000000 --- a/state-hub/api/database.py +++ /dev/null @@ -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 diff --git a/state-hub/api/doi_engine.py b/state-hub/api/doi_engine.py deleted file mode 100644 index 9490cd7..0000000 --- a/state-hub/api/doi_engine.py +++ /dev/null @@ -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, - ) diff --git a/state-hub/api/events/__init__.py b/state-hub/api/events/__init__.py deleted file mode 100644 index 975bec7..0000000 --- a/state-hub/api/events/__init__.py +++ /dev/null @@ -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", -] diff --git a/state-hub/api/events/envelope.py b/state-hub/api/events/envelope.py deleted file mode 100644 index 99034ad..0000000 --- a/state-hub/api/events/envelope.py +++ /dev/null @@ -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 {}, - ) diff --git a/state-hub/api/events/nats_publisher.py b/state-hub/api/events/nats_publisher.py deleted file mode 100644 index 6f8b8f4..0000000 --- a/state-hub/api/events/nats_publisher.py +++ /dev/null @@ -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() diff --git a/state-hub/api/flow_defs.py b/state-hub/api/flow_defs.py deleted file mode 100644 index 9c7c5a2..0000000 --- a/state-hub/api/flow_defs.py +++ /dev/null @@ -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 - ], - } diff --git a/state-hub/api/main.py b/state-hub/api/main.py deleted file mode 100644 index e1c368d..0000000 --- a/state-hub/api/main.py +++ /dev/null @@ -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"} diff --git a/state-hub/api/models/__init__.py b/state-hub/api/models/__init__.py deleted file mode 100644 index c3377d0..0000000 --- a/state-hub/api/models/__init__.py +++ /dev/null @@ -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", -] diff --git a/state-hub/api/models/agent_message.py b/state-hub/api/models/agent_message.py deleted file mode 100644 index d521165..0000000 --- a/state-hub/api/models/agent_message.py +++ /dev/null @@ -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", - ) diff --git a/state-hub/api/models/base.py b/state-hub/api/models/base.py deleted file mode 100644 index 4dfbd1c..0000000 --- a/state-hub/api/models/base.py +++ /dev/null @@ -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() diff --git a/state-hub/api/models/capability_catalog.py b/state-hub/api/models/capability_catalog.py deleted file mode 100644 index 316ccb3..0000000 --- a/state-hub/api/models/capability_catalog.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/capability_request.py b/state-hub/api/models/capability_request.py deleted file mode 100644 index d30d8f7..0000000 --- a/state-hub/api/models/capability_request.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/contribution.py b/state-hub/api/models/contribution.py deleted file mode 100644 index f6d731f..0000000 --- a/state-hub/api/models/contribution.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/decision.py b/state-hub/api/models/decision.py deleted file mode 100644 index de807d4..0000000 --- a/state-hub/api/models/decision.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/doi_cache.py b/state-hub/api/models/doi_cache.py deleted file mode 100644 index 27173d5..0000000 --- a/state-hub/api/models/doi_cache.py +++ /dev/null @@ -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()) diff --git a/state-hub/api/models/domain.py b/state-hub/api/models/domain.py deleted file mode 100644 index a33fa6d..0000000 --- a/state-hub/api/models/domain.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/domain_goal.py b/state-hub/api/models/domain_goal.py deleted file mode 100644 index c1ad44c..0000000 --- a/state-hub/api/models/domain_goal.py +++ /dev/null @@ -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 "" diff --git a/state-hub/api/models/extension_point.py b/state-hub/api/models/extension_point.py deleted file mode 100644 index f34dcbb..0000000 --- a/state-hub/api/models/extension_point.py +++ /dev/null @@ -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 "" diff --git a/state-hub/api/models/interface_change.py b/state-hub/api/models/interface_change.py deleted file mode 100644 index 9d6652f..0000000 --- a/state-hub/api/models/interface_change.py +++ /dev/null @@ -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"), - ) diff --git a/state-hub/api/models/managed_repo.py b/state-hub/api/models/managed_repo.py deleted file mode 100644 index 9e1f1ed..0000000 --- a/state-hub/api/models/managed_repo.py +++ /dev/null @@ -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 "" diff --git a/state-hub/api/models/progress_event.py b/state-hub/api/models/progress_event.py deleted file mode 100644 index f52b8b5..0000000 --- a/state-hub/api/models/progress_event.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/repo_goal.py b/state-hub/api/models/repo_goal.py deleted file mode 100644 index 743a832..0000000 --- a/state-hub/api/models/repo_goal.py +++ /dev/null @@ -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 "" diff --git a/state-hub/api/models/sbom_entry.py b/state-hub/api/models/sbom_entry.py deleted file mode 100644 index 70a15b1..0000000 --- a/state-hub/api/models/sbom_entry.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/sbom_snapshot.py b/state-hub/api/models/sbom_snapshot.py deleted file mode 100644 index 538dd6e..0000000 --- a/state-hub/api/models/sbom_snapshot.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/task.py b/state-hub/api/models/task.py deleted file mode 100644 index 2bd83da..0000000 --- a/state-hub/api/models/task.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/technical_debt.py b/state-hub/api/models/technical_debt.py deleted file mode 100644 index 9a64260..0000000 --- a/state-hub/api/models/technical_debt.py +++ /dev/null @@ -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 "" diff --git a/state-hub/api/models/token_event.py b/state-hub/api/models/token_event.py deleted file mode 100644 index 01ae8d2..0000000 --- a/state-hub/api/models/token_event.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/topic.py b/state-hub/api/models/topic.py deleted file mode 100644 index 6a35073..0000000 --- a/state-hub/api/models/topic.py +++ /dev/null @@ -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 diff --git a/state-hub/api/models/tpsc.py b/state-hub/api/models/tpsc.py deleted file mode 100644 index 1ed9128..0000000 --- a/state-hub/api/models/tpsc.py +++ /dev/null @@ -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") diff --git a/state-hub/api/models/workstream.py b/state-hub/api/models/workstream.py deleted file mode 100644 index 9b3223b..0000000 --- a/state-hub/api/models/workstream.py +++ /dev/null @@ -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" - ) diff --git a/state-hub/api/models/workstream_dependency.py b/state-hub/api/models/workstream_dependency.py deleted file mode 100644 index 31a192e..0000000 --- a/state-hub/api/models/workstream_dependency.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/__init__.py b/state-hub/api/routers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/state-hub/api/routers/capability_requests.py b/state-hub/api/routers/capability_requests.py deleted file mode 100644 index 6b5fda9..0000000 --- a/state-hub/api/routers/capability_requests.py +++ /dev/null @@ -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), - }, - ) diff --git a/state-hub/api/routers/contributions.py b/state-hub/api/routers/contributions.py deleted file mode 100644 index 7427ee2..0000000 --- a/state-hub/api/routers/contributions.py +++ /dev/null @@ -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) diff --git a/state-hub/api/routers/decisions.py b/state-hub/api/routers/decisions.py deleted file mode 100644 index 6ee0af6..0000000 --- a/state-hub/api/routers/decisions.py +++ /dev/null @@ -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 - ) diff --git a/state-hub/api/routers/domain_goals.py b/state-hub/api/routers/domain_goals.py deleted file mode 100644 index 2a1fcb3..0000000 --- a/state-hub/api/routers/domain_goals.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/domains.py b/state-hub/api/routers/domains.py deleted file mode 100644 index 5c54c2b..0000000 --- a/state-hub/api/routers/domains.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/extension_points.py b/state-hub/api/routers/extension_points.py deleted file mode 100644 index ee5883f..0000000 --- a/state-hub/api/routers/extension_points.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/flows.py b/state-hub/api/routers/flows.py deleted file mode 100644 index c3919d3..0000000 --- a/state-hub/api/routers/flows.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/interface_changes.py b/state-hub/api/routers/interface_changes.py deleted file mode 100644 index d4985a3..0000000 --- a/state-hub/api/routers/interface_changes.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/messages.py b/state-hub/api/routers/messages.py deleted file mode 100644 index 5b31604..0000000 --- a/state-hub/api/routers/messages.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/policy.py b/state-hub/api/routers/policy.py deleted file mode 100644 index 85ead6f..0000000 --- a/state-hub/api/routers/policy.py +++ /dev/null @@ -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) diff --git a/state-hub/api/routers/progress.py b/state-hub/api/routers/progress.py deleted file mode 100644 index ddb73b4..0000000 --- a/state-hub/api/routers/progress.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/repo_goals.py b/state-hub/api/routers/repo_goals.py deleted file mode 100644 index f836b91..0000000 --- a/state-hub/api/routers/repo_goals.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/repos.py b/state-hub/api/routers/repos.py deleted file mode 100644 index 04b5e68..0000000 --- a/state-hub/api/routers/repos.py +++ /dev/null @@ -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 "", - } diff --git a/state-hub/api/routers/sbom.py b/state-hub/api/routers/sbom.py deleted file mode 100644 index ee9bf1e..0000000 --- a/state-hub/api/routers/sbom.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/state.py b/state-hub/api/routers/state.py deleted file mode 100644 index 62aabe8..0000000 --- a/state-hub/api/routers/state.py +++ /dev/null @@ -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)}, - ) diff --git a/state-hub/api/routers/tasks.py b/state-hub/api/routers/tasks.py deleted file mode 100644 index adf6cc6..0000000 --- a/state-hub/api/routers/tasks.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/technical_debt.py b/state-hub/api/routers/technical_debt.py deleted file mode 100644 index b8f9e77..0000000 --- a/state-hub/api/routers/technical_debt.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/token_events.py b/state-hub/api/routers/token_events.py deleted file mode 100644 index 6dd6bf7..0000000 --- a/state-hub/api/routers/token_events.py +++ /dev/null @@ -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()) diff --git a/state-hub/api/routers/topics.py b/state-hub/api/routers/topics.py deleted file mode 100644 index 4498d08..0000000 --- a/state-hub/api/routers/topics.py +++ /dev/null @@ -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 diff --git a/state-hub/api/routers/tpsc.py b/state-hub/api/routers/tpsc.py deleted file mode 100644 index f9708ef..0000000 --- a/state-hub/api/routers/tpsc.py +++ /dev/null @@ -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, - ) diff --git a/state-hub/api/routers/workstream_dependencies.py b/state-hub/api/routers/workstream_dependencies.py deleted file mode 100644 index 6821f6d..0000000 --- a/state-hub/api/routers/workstream_dependencies.py +++ /dev/null @@ -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() diff --git a/state-hub/api/routers/workstreams.py b/state-hub/api/routers/workstreams.py deleted file mode 100644 index 64868e9..0000000 --- a/state-hub/api/routers/workstreams.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/__init__.py b/state-hub/api/schemas/__init__.py deleted file mode 100644 index 139016d..0000000 --- a/state-hub/api/schemas/__init__.py +++ /dev/null @@ -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", -] diff --git a/state-hub/api/schemas/agent_message.py b/state-hub/api/schemas/agent_message.py deleted file mode 100644 index 1721116..0000000 --- a/state-hub/api/schemas/agent_message.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/capability_request.py b/state-hub/api/schemas/capability_request.py deleted file mode 100644 index a0ca3ae..0000000 --- a/state-hub/api/schemas/capability_request.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/contribution.py b/state-hub/api/schemas/contribution.py deleted file mode 100644 index a241c09..0000000 --- a/state-hub/api/schemas/contribution.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/decision.py b/state-hub/api/schemas/decision.py deleted file mode 100644 index f02041f..0000000 --- a/state-hub/api/schemas/decision.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/doi.py b/state-hub/api/schemas/doi.py deleted file mode 100644 index 3c990e4..0000000 --- a/state-hub/api/schemas/doi.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/domain.py b/state-hub/api/schemas/domain.py deleted file mode 100644 index 07f085d..0000000 --- a/state-hub/api/schemas/domain.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/domain_goal.py b/state-hub/api/schemas/domain_goal.py deleted file mode 100644 index 04b87e6..0000000 --- a/state-hub/api/schemas/domain_goal.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/extension_point.py b/state-hub/api/schemas/extension_point.py deleted file mode 100644 index 67cc1c1..0000000 --- a/state-hub/api/schemas/extension_point.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/interface_change.py b/state-hub/api/schemas/interface_change.py deleted file mode 100644 index 35c87b8..0000000 --- a/state-hub/api/schemas/interface_change.py +++ /dev/null @@ -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, - ) diff --git a/state-hub/api/schemas/managed_repo.py b/state-hub/api/schemas/managed_repo.py deleted file mode 100644 index 9486ec6..0000000 --- a/state-hub/api/schemas/managed_repo.py +++ /dev/null @@ -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] diff --git a/state-hub/api/schemas/progress_event.py b/state-hub/api/schemas/progress_event.py deleted file mode 100644 index 1c16e51..0000000 --- a/state-hub/api/schemas/progress_event.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/repo_goal.py b/state-hub/api/schemas/repo_goal.py deleted file mode 100644 index b2f69ca..0000000 --- a/state-hub/api/schemas/repo_goal.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/sbom.py b/state-hub/api/schemas/sbom.py deleted file mode 100644 index 51f00d7..0000000 --- a/state-hub/api/schemas/sbom.py +++ /dev/null @@ -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] diff --git a/state-hub/api/schemas/state.py b/state-hub/api/schemas/state.py deleted file mode 100644 index 5f21777..0000000 --- a/state-hub/api/schemas/state.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/task.py b/state-hub/api/schemas/task.py deleted file mode 100644 index 048dba5..0000000 --- a/state-hub/api/schemas/task.py +++ /dev/null @@ -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 diff --git a/state-hub/api/schemas/technical_debt.py b/state-hub/api/schemas/technical_debt.py deleted file mode 100644 index 6926c20..0000000 --- a/state-hub/api/schemas/technical_debt.py +++ /dev/null @@ -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] = [] diff --git a/state-hub/api/schemas/token_event.py b/state-hub/api/schemas/token_event.py deleted file mode 100644 index 60acbda..0000000 --- a/state-hub/api/schemas/token_event.py +++ /dev/null @@ -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] diff --git a/state-hub/api/schemas/topic.py b/state-hub/api/schemas/topic.py deleted file mode 100644 index b0a1e6b..0000000 --- a/state-hub/api/schemas/topic.py +++ /dev/null @@ -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] = [] diff --git a/state-hub/api/schemas/tpsc.py b/state-hub/api/schemas/tpsc.py deleted file mode 100644 index dabed4e..0000000 --- a/state-hub/api/schemas/tpsc.py +++ /dev/null @@ -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] diff --git a/state-hub/api/schemas/workstream.py b/state-hub/api/schemas/workstream.py deleted file mode 100644 index 26e1698..0000000 --- a/state-hub/api/schemas/workstream.py +++ /dev/null @@ -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] = [] diff --git a/state-hub/api/schemas/workstream_dependency.py b/state-hub/api/schemas/workstream_dependency.py deleted file mode 100644 index ec17ed7..0000000 --- a/state-hub/api/schemas/workstream_dependency.py +++ /dev/null @@ -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 diff --git a/state-hub/custodian_cli.py b/state-hub/custodian_cli.py deleted file mode 100644 index 49ef9eb..0000000 --- a/state-hub/custodian_cli.py +++ /dev/null @@ -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 = """\ - - -""" - -_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 ") - 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() diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js deleted file mode 100644 index de71d03..0000000 --- a/state-hub/dashboard/observablehq.config.js +++ /dev/null @@ -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 -`, - footer: "Custodian State Hub — local-first, append-only, sovereignty-preserving.", -}; diff --git a/state-hub/dashboard/package-lock.json b/state-hub/dashboard/package-lock.json deleted file mode 100644 index 64b2d2c..0000000 --- a/state-hub/dashboard/package-lock.json +++ /dev/null @@ -1,4184 +0,0 @@ -{ - "name": "custodian-state-hub-dashboard", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "custodian-state-hub-dashboard", - "version": "0.1.0", - "dependencies": { - "@observablehq/framework": "^1.13.3" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-2.0.2.tgz", - "integrity": "sha512-x1KXOatwofR6ZAYzXRBL5wrdV0vwNxlTCK9NCuLqAzQYARqGcvFwiJA6A1ERuh+dgeA4Dxm3JBYictIes+SqUQ==", - "license": "MIT", - "dependencies": { - "bidi-js": "^1.0.3", - "css-tree": "^2.3.1", - "is-potential-custom-element-name": "^1.0.1" - } - }, - "node_modules/@clack/core": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", - "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", - "license": "MIT", - "dependencies": { - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", - "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", - "bundleDependencies": [ - "is-unicode-supported" - ], - "license": "MIT", - "dependencies": { - "@clack/core": "^0.3.3", - "is-unicode-supported": "*", - "picocolors": "^1.0.0", - "sisteransi": "^1.0.5" - } - }, - "node_modules/@clack/prompts/node_modules/is-unicode-supported": { - "version": "1.3.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@observablehq/framework": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/@observablehq/framework/-/framework-1.13.3.tgz", - "integrity": "sha512-rlKeVob043Eti9AtF6fJ+z4cGacGYwX9cgvVnpozA4dKRiQvNGiGdMt9OJrCa80KnbTt8k8pRoTCd0q+zAMu2Q==", - "license": "ISC", - "dependencies": { - "@clack/prompts": "^0.7.0", - "@observablehq/inputs": "^0.12.0", - "@observablehq/inspector": "^5.0.1", - "@observablehq/runtime": "^6.0.0", - "@rollup/plugin-commonjs": "^25.0.7", - "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-virtual": "^3.0.2", - "@sindresorhus/slugify": "^2.2.1", - "acorn": "^8.11.2", - "acorn-walk": "^8.3.0", - "ci-info": "^4.0.0", - "cross-spawn": "^7.0.3", - "d3-array": "^3.2.4", - "d3-hierarchy": "^3.1.2", - "esbuild": "^0.20.1", - "fast-array-diff": "^1.1.0", - "fast-deep-equal": "^3.1.3", - "glob": "^10.3.10", - "gray-matter": "^4.0.3", - "he": "^1.2.0", - "highlight.js": "^11.8.0", - "is-docker": "^3.0.0", - "is-wsl": "^3.1.0", - "jsdom": "^23.2.0", - "jszip": "^3.10.1", - "markdown-it": "^14.0.0", - "markdown-it-anchor": "^8.6.7", - "mime": "^4.0.0", - "minisearch": "^6.3.0", - "open": "^10.1.0", - "picocolors": "^1.1.1", - "pkg-dir": "^8.0.0", - "resolve.exports": "^2.0.2", - "rollup": "^4.6.0", - "rollup-plugin-esbuild": "^6.1.0", - "semver": "^7.5.4", - "send": "^0.19.0", - "tar": "^6.2.0", - "tar-stream": "^3.1.6", - "tsx": "^4.7.1", - "untildify": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.14.2" - }, - "bin": { - "observable": "dist/bin/observable.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@observablehq/inputs": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@observablehq/inputs/-/inputs-0.12.0.tgz", - "integrity": "sha512-1ln7+PYe31cMx00K9awVbiCscQM0THnXRJ/AEzd+FfTA25Gu3KRWknAGECxU49QzHyKqiXpLl5LCg3XtYm70eQ==", - "license": "ISC", - "dependencies": { - "htl": "^0.3.1", - "isoformat": "^0.2.0" - }, - "engines": { - "node": ">=14.5.0" - } - }, - "node_modules/@observablehq/inspector": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@observablehq/inspector/-/inspector-5.0.1.tgz", - "integrity": "sha512-euwWxwDa6KccU4G3D2JBD7GI/2McJh/z7HHEzJKbj2TDa7zhI37eTbTxiwE9rgTWBagvVBel+hAmnJRYBYOv2Q==", - "license": "ISC", - "dependencies": { - "isoformat": "^0.2.0" - } - }, - "node_modules/@observablehq/runtime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@observablehq/runtime/-/runtime-6.0.0.tgz", - "integrity": "sha512-t3UXP69O0JK20HY/neF4/DDDSDorwo92As806Y1pNTgTmj1NtoPyVpesYzfH31gTFOFrXC2cArV+wLpebMk9eA==", - "license": "ISC" - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", - "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "glob": "^8.0.3", - "is-reference": "1.2.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.7.tgz", - "integrity": "sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-virtual": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", - "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sindresorhus/slugify": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", - "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", - "license": "MIT", - "dependencies": { - "@sindresorhus/transliterate": "^1.0.0", - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@sindresorhus/transliterate": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", - "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^5.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/b4a": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", - "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" - }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "license": "MIT" - }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-hierarchy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", - "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", - "hasInstallScript": true, - "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/events-universal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", - "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", - "license": "Apache-2.0", - "dependencies": { - "bare-events": "^2.7.0" - } - }, - "node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "license": "MIT", - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-array-diff": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fast-array-diff/-/fast-array-diff-1.1.0.tgz", - "integrity": "sha512-muSPyZa/yHCoDQhah9th57AmLENB1nekbrUoLAqOpQXdl1Kw8VbH24Syl5XLscaQJlx7KRU95bfTDPvVB5BJvw==", - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gray-matter": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", - "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", - "license": "MIT", - "dependencies": { - "js-yaml": "^3.13.1", - "kind-of": "^6.0.2", - "section-matter": "^1.0.0", - "strip-bom-string": "^1.0.0" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/htl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/htl/-/htl-0.3.1.tgz", - "integrity": "sha512-1LBtd+XhSc+++jpOOt0lCcEycXs/zTQSupOISnVAUmvGBpV7DH+C2M6hwV7zWYfpTMMg9ch4NO0lHiOTAMHdVA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extendable": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "license": "MIT" - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "license": "MIT" - }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/isoformat": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz", - "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==", - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "23.2.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", - "integrity": "sha512-L88oL7D/8ufIES+Zjz7v0aes+oBMh2Xnh3ygWvL0OaICOomKEPKuPnIfBJekiXr+BHbbMjrWn/xqrDQuxFTeyA==", - "license": "MIT", - "dependencies": { - "@asamuzakjp/dom-selector": "^2.0.1", - "cssstyle": "^4.0.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.4.3", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.2", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.3", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0", - "ws": "^8.16.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^2.11.2" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, - "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.0", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, - "node_modules/markdown-it-anchor": { - "version": "8.6.7", - "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-8.6.7.tgz", - "integrity": "sha512-FlCHFwNnutLgVTflOYHPW2pPcl2AACqVzExlkGQNsi4CJgqOHN7YTgDd4LuhgN1BFO3TS0vLAruV1Td6dwWPJA==", - "license": "Unlicense", - "peerDependencies": { - "@types/markdown-it": "*", - "markdown-it": "*" - } - }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "license": "CC0-1.0" - }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, - "node_modules/mime": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", - "integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==", - "funding": [ - "https://github.com/sponsors/broofa" - ], - "license": "MIT", - "bin": { - "mime": "bin/cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.6.tgz", - "integrity": "sha512-kQAVowdR33euIqeA0+VZTDqU+qo1IeVY+hrKYtZMio3Pg0P0vuh/kwRylLUddJhB6pf3q/botcOvRtx4IN1wqQ==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^5.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minisearch": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz", - "integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==", - "license": "MIT" - }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, - "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-8.0.0.tgz", - "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", - "license": "MIT", - "dependencies": { - "find-up-simple": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, - "node_modules/psl": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", - "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/lupomontero" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-esbuild": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-esbuild/-/rollup-plugin-esbuild-6.2.1.tgz", - "integrity": "sha512-jTNOMGoMRhs0JuueJrJqbW8tOwxumaWYq+V5i+PD+8ecSCVkuX27tGW7BXqDgoULQ55rO7IdNxPcnsWtshz3AA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "es-module-lexer": "^1.6.0", - "get-tsconfig": "^4.10.0", - "unplugin-utils": "^0.2.4" - }, - "engines": { - "node": ">=14.18.0" - }, - "peerDependencies": { - "esbuild": ">=0.18.0", - "rollup": "^1.20.0 || ^2.0.0 || ^3.0.0 || ^4.0.0" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", - "license": "MIT" - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/section-matter": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", - "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", - "license": "MIT", - "dependencies": { - "extend-shallow": "^2.0.1", - "kind-of": "^6.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamx": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", - "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", - "license": "MIT", - "dependencies": { - "events-universal": "^1.0.0", - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", - "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "license": "MIT" - }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "license": "ISC", - "engines": { - "node": ">=8" - } - }, - "node_modules/text-decoder": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", - "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "license": "BSD-3-Clause", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "license": "MIT", - "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unplugin-utils": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.2.5.tgz", - "integrity": "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==", - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=18.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/untildify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-5.0.0.tgz", - "integrity": "sha512-bOgQLUnd2G5rhzaTvh1VCI9Fo6bC5cLTpH17T5aFfamyXFYDbbdzN6IXdeoc3jBS7T9hNTmJtYUzJCJ2Xlc9gA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", - "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", - "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "license": "MIT" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "license": "ISC" - } - } -} diff --git a/state-hub/dashboard/package.json b/state-hub/dashboard/package.json deleted file mode 100644 index b5487b4..0000000 --- a/state-hub/dashboard/package.json +++ /dev/null @@ -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" - } -} diff --git a/state-hub/dashboard/src/capability-requests.md b/state-hub/dashboard/src/capability-requests.md deleted file mode 100644 index db7d043..0000000 --- a/state-hub/dashboard/src/capability-requests.md +++ /dev/null @@ -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`
- - ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`} -
`; -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`
-
Capability Requests
-
- Open${open.length} - Avg fulfill${avgFulfill}d - High/Critical${critical} - Total${requests.length} -
-
`; -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`
- ${typeFilter}${statFilter}${domFilter} -
`); -``` - -```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`
-
⚠ Routing Disputed (${disputed.length})
- ${disputed.map(r => html` -
-
- ${r.title} - ${r.requesting_domain_slug} → ${r.fulfilling_domain_slug ?? "unassigned"} -
-
Dispute: ${r.dispute_reason ?? "(no reason given)"}
- ${r.dispute_suggested_domain ? html`
Suggested domain: ${r.dispute_suggested_domain}
` : ""} - ${r.disputed_by ? html`
Raised by ${r.disputed_by} · ${new Date(r.disputed_at).toLocaleString()}
` : ""} -
- `)} -
`); -} - -display(html`
-

Requested

${requests.filter(r => r.status === "requested").length}

-

In Progress

${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}

-

Ready for Review

${requests.filter(r => r.status === "ready_for_review").length}

-

Completed

${completed.length}

-
`); -``` - -## 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`

No capability requests match the current filters.

`); -} else { - const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0); - display(html`
- ${activeCols.map(s => html` -
-
${s.label} ${colMap[s.key].length}
- ${colMap[s.key].map(r => html` -
-
${r.capability_type}
-
${r.priority}
-
${r.title}
-
- ${r.requesting_domain_slug} - ${r.fulfilling_domain_slug ? html` → ${r.fulfilling_domain_slug}` : html` → unassigned`} -
-
${ageDays(r)}d old
-
- `)} -
- `)} -
`); -} -``` - -## 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`

No capabilities registered yet. Add ```capability blocks to SCOPE.md files and run make ingest-capabilities-all.

`); -} 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`
- ${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html` -
-
${domain} ${caps.length}
- ${caps.map(c => html` -
-
${c.capability_type}
-
${c.title}
- ${c.description ? html`
${c.description}
` : ""} - ${c.keywords?.length ? html`
${c.keywords.map(k => html`${k}`)}
` : ""} -
- `)} -
- `)} -
`); -} -``` - - diff --git a/state-hub/dashboard/src/components/action-confirm.js b/state-hub/dashboard/src/components/action-confirm.js deleted file mode 100644 index 536600d..0000000 --- a/state-hub/dashboard/src/components/action-confirm.js +++ /dev/null @@ -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; - } - }); -} diff --git a/state-hub/dashboard/src/components/config.js b/state-hub/dashboard/src/components/config.js deleted file mode 100644 index 3499f94..0000000 --- a/state-hub/dashboard/src/components/config.js +++ /dev/null @@ -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); - } -} diff --git a/state-hub/dashboard/src/components/doc-overlay.js b/state-hub/dashboard/src/components/doc-overlay.js deleted file mode 100644 index 54493b7..0000000 --- a/state-hub/dashboard/src/components/doc-overlay.js +++ /dev/null @@ -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`
...
`; - * 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; -} diff --git a/state-hub/dashboard/src/components/entity-modal.js b/state-hub/dashboard/src/components/entity-modal.js deleted file mode 100644 index c3fe3af..0000000 --- a/state-hub/dashboard/src/components/entity-modal.js +++ /dev/null @@ -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; -} diff --git a/state-hub/dashboard/src/components/field-help.js b/state-hub/dashboard/src/components/field-help.js deleted file mode 100644 index 1c6ad8c..0000000 --- a/state-hub/dashboard/src/components/field-help.js +++ /dev/null @@ -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) → 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`${fields.map(([k,v]) => fieldRow(k,v))}`; - -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; -} diff --git a/state-hub/dashboard/src/components/help-tip.js b/state-hub/dashboard/src/components/help-tip.js deleted file mode 100644 index 7f37d33..0000000 --- a/state-hub/dashboard/src/components/help-tip.js +++ /dev/null @@ -1,167 +0,0 @@ -// ABBR -// -// 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 }; diff --git a/state-hub/dashboard/src/components/improvement-modal.js b/state-hub/dashboard/src/components/improvement-modal.js deleted file mode 100644 index e37ad50..0000000 --- a/state-hub/dashboard/src/components/improvement-modal.js +++ /dev/null @@ -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: - *
- * - * 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"); - } - }); - }); -} diff --git a/state-hub/dashboard/src/components/multiselect.js b/state-hub/dashboard/src/components/multiselect.js deleted file mode 100644 index 671c8dc..0000000 --- a/state-hub/dashboard/src/components/multiselect.js +++ /dev/null @@ -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; -} diff --git a/state-hub/dashboard/src/components/ref-cell.js b/state-hub/dashboard/src/components/ref-cell.js deleted file mode 100644 index 025182e..0000000 --- a/state-hub/dashboard/src/components/ref-cell.js +++ /dev/null @@ -1,101 +0,0 @@ -// refCell(index, recordType, id) → HTMLElement -// -// Renders a 1-based row number in a table cell. -// Single click — copies deep-link to clipboard and flashes "Copied!". -// Double click — opens deep-link in a new tab. -// -// Deep-link format: /data// -// -// Usage: -// import {refCell} from "./components/ref-cell.js"; -// // in an Inputs.table format callback: -// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) } - -const _STYLE_ID = "refcell-global-style"; -if (!document.getElementById(_STYLE_ID)) { - const s = document.createElement("style"); - s.id = _STYLE_ID; - s.textContent = ` -.ref-cell { - display: inline-block; - font-family: var(--monospace, monospace); - font-size: 0.78rem; - color: var(--theme-foreground-focus, #3b82f6); - cursor: pointer; - user-select: none; - padding: 0 2px; - border-radius: 3px; - transition: background 0.1s; -} -.ref-cell:hover { - background: var(--theme-foreground-faint, #e8f0fe); -} -.ref-cell-toast { - position: fixed; - z-index: 10000; - background: var(--theme-background, #fff); - border: 1px solid var(--theme-foreground-faint, #ddd); - border-radius: 6px; - padding: 0.3rem 0.65rem; - font-size: 0.75rem; - color: var(--theme-foreground, #333); - box-shadow: 0 4px 14px rgba(0,0,0,0.12); - opacity: 0; - transition: opacity 0.1s ease; - pointer-events: none; -} -.ref-cell-toast.ref-cell-toast-visible { opacity: 1; } -`; - document.head.appendChild(s); -} - -function _showToast(anchorEl, text) { - const toast = document.createElement("div"); - toast.className = "ref-cell-toast"; - toast.textContent = text; - document.body.appendChild(toast); - - const rect = anchorEl.getBoundingClientRect(); - const gap = 6; - toast.style.left = `${rect.left}px`; - toast.style.top = `${rect.top - toast.offsetHeight - gap}px`; - - requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible")); - setTimeout(() => { - toast.classList.remove("ref-cell-toast-visible"); - toast.addEventListener("transitionend", () => toast.remove(), {once: true}); - }, 1200); -} - -export function refCell(index, recordType, id) { - const deepLink = `${location.origin}/${recordType}/${id}`; - - const el = document.createElement("span"); - el.className = "ref-cell"; - el.title = `Click to copy link · Double-click to open\n${deepLink}`; - el.textContent = String(index); - - let clickTimer = null; - - el.addEventListener("click", (e) => { - e.stopPropagation(); - // Use a short delay so a double-click cancels the single-click handler. - clickTimer = setTimeout(async () => { - try { - await navigator.clipboard.writeText(deepLink); - _showToast(el, "Copied!"); - } catch { - // Fallback for environments where clipboard API is blocked. - _showToast(el, deepLink); - } - }, 180); - }); - - el.addEventListener("dblclick", (e) => { - e.stopPropagation(); - clearTimeout(clickTimer); - window.open(deepLink, "_blank", "noopener,noreferrer"); - }); - - return el; -} diff --git a/state-hub/dashboard/src/components/toc-sidebar.js b/state-hub/dashboard/src/components/toc-sidebar.js deleted file mode 100644 index 7986db9..0000000 --- a/state-hub/dashboard/src/components/toc-sidebar.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * toc-sidebar — inject a persistent widget into Observable Framework's - * right-column table-of-contents sidebar. - * - * Observable Framework renders a non-scrolling TOC aside (#observablehq-toc) - * in the right column. This helper lets you prepend a custom element to it, - * replacing any previously injected element with the same id on each call so - * reactive cells can refresh the widget without accumulating duplicates. - * - * Usage: - * import {injectTocTop} from "./components/toc-sidebar.js"; - * - * const el = html`
`; - * injectTocTop("my-widget-id", el); // call again on each reactive update - * - * @param {string} id Stable id used to find and remove the previous - * instance. Must be unique per widget on the page. - * @param {HTMLElement} element Element to inject. Its id will be set to `id`. - * @returns {boolean} true if injected into the TOC sidebar; - * false if #observablehq-toc was not found. - */ -export function injectTocTop(id, element) { - document.getElementById(id)?.remove(); - element.id = id; - const toc = document.querySelector("#observablehq-toc"); - if (!toc) return false; - toc.prepend(element); - return true; -} diff --git a/state-hub/dashboard/src/contributions.md b/state-hub/dashboard/src/contributions.md deleted file mode 100644 index 3a676ea..0000000 --- a/state-hub/dashboard/src/contributions.md +++ /dev/null @@ -1,173 +0,0 @@ ---- -title: Contributions ---- - -```js -import {API, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; -const POLL = 30_000; -``` - -```js -// Live poll for contributions -const contribState = (async function*() { - let failures = 0; - while (true) { - let data = [], ok = false; - try { - const r = await apiFetch("/contributions/"); - 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 contribs = contribState.data ?? []; -const _ok = contribState.ok ?? false; -const _ts = contribState.ts; -``` - -# Contributions - -```js -import {injectTocTop} from "./components/toc-sidebar.js"; -import {withDocHelp} from "./components/doc-overlay.js"; - -const _liveEl = html`
- - ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`} -
`; -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/contributions"); } -``` - -```js -// Filters -const typeFilter = Inputs.select(["all", "br", "fr", "ep", "upr"], {label: "Type", value: "all"}); -const statFilter = Inputs.select( - ["all", "draft", "submitted", "acknowledged", "accepted", "rejected", "merged", "withdrawn"], - {label: "Status", value: "all"} -); -const repoFilter = Inputs.text({label: "Target repo", placeholder: "filter by repo…"}); -display(html`
- ${typeFilter}${statFilter}${repoFilter} -
`); -``` - -```js -const tf = typeFilter.value; -const sf = statFilter.value; -const rf = repoFilter.value?.trim().toLowerCase() ?? ""; - -const filtered = contribs.filter(c => - (tf === "all" || c.type === tf) && - (sf === "all" || c.status === sf) && - (!rf || (c.target_repo ?? "").toLowerCase().includes(rf)) -); -``` - -## Summary - -```js -const typeLabels = {br: "Bug Report", fr: "Feature Request", ep: "Extension Point", upr: "Upstream PR"}; -const typeCounts = Object.fromEntries(["br","fr","ep","upr"].map(t => [ - t, contribs.filter(c => c.type === t).length -])); -const needsFollowUp = contribs.filter(c => ["submitted","acknowledged"].includes(c.status)).length; - -display(html`
-
-

Total

-

${contribs.length}

-
- ${["br","fr","ep","upr"].map(t => html` -
-

${typeLabels[t]}

-

${typeCounts[t]}

-
- `)} -
-${needsFollowUp > 0 ? html`` : ""} -`); -``` - -## Status Kanban - -```js -const statusCols = [ - {key: "draft", label: "Draft", color: "#aaa"}, - {key: "submitted", label: "Submitted", color: "steelblue"}, - {key: "acknowledged", label: "Acknowledged",color: "#f0a500"}, - {key: "accepted", label: "Accepted", color: "#4caf50"}, - {key: "merged", label: "Merged", color: "#2e7d32"}, - {key: "rejected", label: "Rejected", color: "#e53935"}, - {key: "withdrawn", label: "Withdrawn", color: "#bbb"}, -]; - -const colMap = {}; -for (const c of filtered) { - (colMap[c.status] = colMap[c.status] ?? []).push(c); -} - -const activeCols = statusCols.filter(s => colMap[s.key]?.length); -if (activeCols.length === 0) { - display(html`

No contributions match the current filters.

`); -} else { - display(html`
- ${activeCols.map(s => html` -
-
${s.label} ${colMap[s.key].length}
- ${colMap[s.key].map(c => html` -
-
${c.type.toUpperCase()}
-
${c.title}
- ${c.target_org || c.target_repo ? html`
${[c.target_org, c.target_repo].filter(Boolean).join("/")}
` : ""} - ${c.body_path ? html`
${c.body_path}
` : ""} -
${new Date(c.created_at).toLocaleDateString()}
-
- `)} -
- `)} -
`); -} -``` - -## All Contributions - -```js -display(Inputs.table(filtered.map(c => ({ - Type: c.type.toUpperCase(), - Title: c.title, - Status: c.status, - Target: [c.target_org, c.target_repo].filter(Boolean).join("/") || "—", - Created: new Date(c.created_at).toLocaleDateString(), -})), {maxWidth: 900})); -``` - - diff --git a/state-hub/dashboard/src/data/contributions.json.py b/state-hub/dashboard/src/data/contributions.json.py deleted file mode 100644 index b1420a0..0000000 --- a/state-hub/dashboard/src/data/contributions.json.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /contributions/ from the API.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/contributions/", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "contributions": []})) diff --git a/state-hub/dashboard/src/data/decisions.json.py b/state-hub/dashboard/src/data/decisions.json.py deleted file mode 100644 index c9c00ed..0000000 --- a/state-hub/dashboard/src/data/decisions.json.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: all decisions.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/decisions", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "decisions": []})) diff --git a/state-hub/dashboard/src/data/domains.json.py b/state-hub/dashboard/src/data/domains.json.py deleted file mode 100644 index 70526d7..0000000 --- a/state-hub/dashboard/src/data/domains.json.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /domains/ from the API.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/domains/?status=all", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "domains": []})) diff --git a/state-hub/dashboard/src/data/gitea-inventory.json.py b/state-hub/dashboard/src/data/gitea-inventory.json.py deleted file mode 100644 index 2e11542..0000000 --- a/state-hub/dashboard/src/data/gitea-inventory.json.py +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: runs gitea_inventory.py and returns JSON output.""" -import json -import os -import subprocess -import sys - -SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "scripts") -SCRIPTS_DIR = os.path.normpath(SCRIPTS_DIR) -PYTHON = os.path.join(os.path.dirname(sys.executable), "python") -if not os.path.exists(PYTHON): - PYTHON = sys.executable - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000") - -try: - result = subprocess.run( - [PYTHON, os.path.join(SCRIPTS_DIR, "gitea_inventory.py"), "--json", - "--api-base", API_BASE], - capture_output=True, text=True, timeout=30, - ) - if result.returncode == 0 and result.stdout.strip(): - print(result.stdout) - else: - print(json.dumps({ - "error": result.stderr or "empty output", - "registered": [], "unregistered": [], "hub_only": [], - })) -except Exception as exc: - print(json.dumps({ - "error": str(exc), - "registered": [], "unregistered": [], "hub_only": [], - })) diff --git a/state-hub/dashboard/src/data/messages.json.py b/state-hub/dashboard/src/data/messages.json.py deleted file mode 100644 index 05fa84d..0000000 --- a/state-hub/dashboard/src/data/messages.json.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /messages/ from the API.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/messages/?limit=100", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "messages": []})) diff --git a/state-hub/dashboard/src/data/progress.json.py b/state-hub/dashboard/src/data/progress.json.py deleted file mode 100644 index 32e6b3a..0000000 --- a/state-hub/dashboard/src/data/progress.json.py +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: recent progress events (last 200).""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - url = f"{API_BASE}/progress?limit=200" - with urllib.request.urlopen(url, timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "events": []})) diff --git a/state-hub/dashboard/src/data/repos.json.py b/state-hub/dashboard/src/data/repos.json.py deleted file mode 100644 index 613bc7a..0000000 --- a/state-hub/dashboard/src/data/repos.json.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /repos/ enriched with SBOM snapshot stats.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - - -def fetch(url): - try: - with urllib.request.urlopen(url, timeout=10) as resp: - return json.loads(resp.read()) - except urllib.error.URLError as e: - print(f"Warning: could not fetch {url}: {e}", flush=True) - return [] - - -repos = fetch(f"{API_BASE}/repos/") -snapshots = fetch(f"{API_BASE}/sbom/snapshots/") - -# Build map: repo_id → {count, latest_at, latest_entry_count} -snap_stats: dict = {} -for s in snapshots: - rid = s["repo_id"] - if rid not in snap_stats: - snap_stats[rid] = {"count": 0, "latest_at": None, "latest_entry_count": 0} - snap_stats[rid]["count"] += 1 - if snap_stats[rid]["latest_at"] is None or s["snapshot_at"] > snap_stats[rid]["latest_at"]: - snap_stats[rid]["latest_at"] = s["snapshot_at"] - snap_stats[rid]["latest_entry_count"] = s["entry_count"] - -# Enrich repos — fall back to snapshot data if denormalized field is missing -for r in repos: - stats = snap_stats.get(str(r["id"]), {}) - if not r.get("last_sbom_at") and stats.get("latest_at"): - r["last_sbom_at"] = stats["latest_at"] - r["sbom_snapshot_count"] = stats.get("count", 0) - r["sbom_entry_count"] = stats.get("latest_entry_count", 0) - -print(json.dumps(repos)) diff --git a/state-hub/dashboard/src/data/repos/[slug].json.py b/state-hub/dashboard/src/data/repos/[slug].json.py deleted file mode 100644 index fe81175..0000000 --- a/state-hub/dashboard/src/data/repos/[slug].json.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches a single repo by slug.""" -import json -import os -import sys -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -slug = sys.argv[1] if len(sys.argv) > 1 else "" - -if not slug: - print(json.dumps({"error": "No repo slug provided"})) - sys.exit(1) - -try: - with urllib.request.urlopen(f"{API_BASE}/repos/{slug}/", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.HTTPError as e: - if e.code == 404: - print(json.dumps({"error": f"Repo {slug!r} not found"})) - sys.exit(1) - print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) - sys.exit(1) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e)})) - sys.exit(1) diff --git a/state-hub/dashboard/src/data/sbom.json.py b/state-hub/dashboard/src/data/sbom.json.py deleted file mode 100644 index 6a9606b..0000000 --- a/state-hub/dashboard/src/data/sbom.json.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /sbom/ and /sbom/report/licences/ from the API.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -result = {"entries": [], "licence_report": {"groups": [], "copyleft_direct_count": 0}} - -try: - with urllib.request.urlopen(f"{API_BASE}/sbom/", timeout=10) as resp: - result["entries"] = json.loads(resp.read()) -except urllib.error.URLError as e: - result["error_entries"] = str(e) - -try: - with urllib.request.urlopen(f"{API_BASE}/sbom/report/licences/", timeout=10) as resp: - result["licence_report"] = json.loads(resp.read()) -except urllib.error.URLError as e: - result["error_licences"] = str(e) - -print(json.dumps(result)) diff --git a/state-hub/dashboard/src/data/summary.json.py b/state-hub/dashboard/src/data/summary.json.py deleted file mode 100644 index 4d6ab11..0000000 --- a/state-hub/dashboard/src/data/summary.json.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches /state/summary from the API.""" -import json -import os -import sys -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/state/summary", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - # Return empty structure so the dashboard can show an error state - print(json.dumps({ - "error": str(e), - "generated_at": None, - "totals": { - "topics": {"active": 0, "paused": 0, "archived": 0, "total": 0}, - "workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0}, - "tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0}, - "decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0}, - }, - "topics": [], - "blocking_decisions": [], - "blocked_tasks": [], - "recent_progress": [], - "open_workstreams": [], - })) diff --git a/state-hub/dashboard/src/data/token-events/[id].json.py b/state-hub/dashboard/src/data/token-events/[id].json.py deleted file mode 100644 index 7f448ec..0000000 --- a/state-hub/dashboard/src/data/token-events/[id].json.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches a single token event by ID.""" -import json -import os -import sys -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -event_id = sys.argv[1] if len(sys.argv) > 1 else "" - -if not event_id: - print(json.dumps({"error": "No event ID provided"})) - sys.exit(1) - -try: - with urllib.request.urlopen(f"{API_BASE}/token-events/{event_id}", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.HTTPError as e: - if e.code == 404: - print(json.dumps({"error": f"Token event {event_id!r} not found"})) - sys.exit(1) - print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) - sys.exit(1) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e)})) - sys.exit(1) diff --git a/state-hub/dashboard/src/data/token-summary.json.py b/state-hub/dashboard/src/data/token-summary.json.py deleted file mode 100644 index 78f81fe..0000000 --- a/state-hub/dashboard/src/data/token-summary.json.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: token consumption summary by repo and workstream.""" -import json -import os -import urllib.error -import urllib.request - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - - -def fetch(url: str): - try: - with urllib.request.urlopen(url, timeout=10) as resp: - return json.loads(resp.read()) - except urllib.error.URLError: - return None - - -# Fetch all repos and workstreams for scope resolution -repos = fetch(f"{API_BASE}/repos/") or [] -workstreams_raw = fetch(f"{API_BASE}/workstreams/?limit=500") or [] - -# Fetch all token events (up to 1000) for aggregation -events = fetch(f"{API_BASE}/token-events/?limit=1000") or [] - - -def aggregate(events, key_fn, label_fn): - """Group token events by a key function and return aggregated records.""" - groups: dict = {} - for e in events: - k = key_fn(e) - if not k: - continue - if k not in groups: - groups[k] = {"scope_id": k, "label": label_fn(k), "tokens_in": 0, "tokens_out": 0, "event_count": 0, "by_model": {}} - groups[k]["tokens_in"] += e.get("tokens_in", 0) - groups[k]["tokens_out"] += e.get("tokens_out", 0) - groups[k]["event_count"] += 1 - model = e.get("model") or "unknown" - groups[k]["by_model"][model] = groups[k]["by_model"].get(model, 0) + e.get("tokens_in", 0) + e.get("tokens_out", 0) - for v in groups.values(): - v["tokens_total"] = v["tokens_in"] + v["tokens_out"] - return sorted(groups.values(), key=lambda x: -x["tokens_total"]) - - -repo_map = {r["id"]: r.get("slug", r["id"]) for r in repos} -ws_map = {w["id"]: w.get("title", w["id"]) for w in workstreams_raw} - -by_repo = aggregate(events, lambda e: e.get("repo_id"), lambda k: repo_map.get(k, k)) -by_workstream = aggregate(events, lambda e: e.get("workstream_id"), lambda k: ws_map.get(k, k)) - -# Top 10 tasks by tokens -task_groups: dict = {} -for e in events: - tid = e.get("task_id") - if not tid: - continue - if tid not in task_groups: - task_groups[tid] = {"task_id": tid, "tokens_in": 0, "tokens_out": 0, "event_count": 0} - task_groups[tid]["tokens_in"] += e.get("tokens_in", 0) - task_groups[tid]["tokens_out"] += e.get("tokens_out", 0) - task_groups[tid]["event_count"] += 1 -for v in task_groups.values(): - v["tokens_total"] = v["tokens_in"] + v["tokens_out"] -top_tasks = sorted(task_groups.values(), key=lambda x: -x["tokens_total"])[:10] - -# Model breakdown across all events -model_totals: dict = {} -for e in events: - model = e.get("model") or "unknown" - model_totals[model] = model_totals.get(model, 0) + e.get("tokens_in", 0) + e.get("tokens_out", 0) -by_model = [{"model": k, "tokens_total": v} for k, v in sorted(model_totals.items(), key=lambda x: -x[1])] - -print(json.dumps({ - "by_repo": by_repo, - "by_workstream": by_workstream, - "top_tasks": top_tasks, - "by_model": by_model, - "total_events": len(events), -})) diff --git a/state-hub/dashboard/src/data/workstreams.json.py b/state-hub/dashboard/src/data/workstreams.json.py deleted file mode 100644 index d3368cd..0000000 --- a/state-hub/dashboard/src/data/workstreams.json.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: all workstreams.""" -import json -import os -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -try: - with urllib.request.urlopen(f"{API_BASE}/workstreams", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e), "workstreams": []})) diff --git a/state-hub/dashboard/src/data/workstreams/[id].json.py b/state-hub/dashboard/src/data/workstreams/[id].json.py deleted file mode 100644 index 92774cb..0000000 --- a/state-hub/dashboard/src/data/workstreams/[id].json.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -"""Observable data loader: fetches a single workstream by ID.""" -import json -import os -import sys -import urllib.request -import urllib.error - -API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") - -ws_id = sys.argv[1] if len(sys.argv) > 1 else "" - -if not ws_id: - print(json.dumps({"error": "No workstream ID provided"})) - sys.exit(1) - -try: - with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp: - data = json.loads(resp.read()) - print(json.dumps(data)) -except urllib.error.HTTPError as e: - if e.code == 404: - print(json.dumps({"error": f"Workstream {ws_id!r} not found"})) - sys.exit(1) - print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"})) - sys.exit(1) -except urllib.error.URLError as e: - print(json.dumps({"error": str(e)})) - sys.exit(1) diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md deleted file mode 100644 index efba13a..0000000 --- a/state-hub/dashboard/src/decisions.md +++ /dev/null @@ -1,468 +0,0 @@ ---- -title: Decisions ---- - -```js -import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; -``` - -```js -// Fetch decisions + topics (for domain context) in parallel -const decState = (async function*() { - let failures = 0; - while (true) { - let data = [], ok = false; - try { - const [rd, rt] = await Promise.all([ - apiFetch("/decisions/?limit=500"), - apiFetch("/topics/"), - ]); - ok = rd.ok && rt.ok; - if (ok) { - const [decisions, topics] = await Promise.all([rd.json(), rt.json()]); - const topicMap = Object.fromEntries(topics.map(t => [t.id, t])); - data = decisions - .map(d => ({ - ...d, - domain: topicMap[d.topic_id]?.domain_slug ?? null, - topic_title: topicMap[d.topic_id]?.title ?? null, - })) - .sort((a, b) => { - const rank = {escalated: 0, open: 1, resolved: 2, superseded: 3}; - const dr = (rank[a.status] ?? 9) - (rank[b.status] ?? 9); - if (dr !== 0) return dr; - // Resolved / superseded: most recently decided first - if (a.status === "resolved" || a.status === "superseded") { - if (a.decided_at && b.decided_at) return b.decided_at.localeCompare(a.decided_at); - return a.decided_at ? -1 : 1; - } - // Open / escalated: soonest deadline first, then most recently created - if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline); - if (a.deadline) return -1; - if (b.deadline) return 1; - return b.created_at.localeCompare(a.created_at); - }); - } - } catch {} - failures = ok ? 0 : failures + 1; - yield {data, ok, ts: new Date()}; - await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); - } -})(); -``` - -```js -const data = decState.data ?? []; -const _ok = decState.ok ?? false; -const _ts = decState.ts; -``` - -```js -import {MultiSelect} from "./components/multiselect.js"; - -// Create filter form without displaying — shown below the chart via display(_filtersForm) -const _filtersForm = Inputs.form( - { - type: MultiSelect(["pending", "made"], {label: "Type", placeholder: "All types"}), - status: MultiSelect(["open", "escalated", "resolved", "superseded"], {label: "Status", placeholder: "All statuses"}), - search: Inputs.text({placeholder: "Search title…", style: "width:160px"}), - }, - { - template: ({type, status, search}) => html`
- ${type}${status} -
${search}
-
`, - } -); -``` - -```js -const filters = Generators.input(_filtersForm); -``` - -```js -const filtered = data.filter(d => - (filters.type.length === 0 || filters.type.includes(d.decision_type)) && - (filters.status.length === 0 || filters.status.includes(d.status)) && - (!filters.search || d.title.toLowerCase().includes(filters.search.toLowerCase())) -); - -const escalated = filtered.filter(d => d.escalation_note && !["resolved", "superseded"].includes(d.status)); - -const STATUS_BORDER = {open: "steelblue", escalated: "#f59e0b", resolved: "#22c55e", superseded: "#aaa"}; -const TYPE_CLASS = {pending: "badge-pending", made: "badge-made"}; - -function fmtDate(iso) { - return iso ? new Date(iso).toLocaleDateString(undefined, {day: "numeric", month: "short", year: "numeric"}) : null; -} -function isOverdue(iso) { - return iso && new Date(iso) < new Date(); -} -function fmtDuration(ms) { - if (ms < 0) ms = 0; - const h = 3_600_000, d = 86_400_000, w = 7 * d; - if (ms < 2 * h) return `${Math.floor(ms / 60_000)}m`; - if (ms < 2 * d) return `${Math.floor(ms / h)}h`; - if (ms < 2 * w) return `${Math.floor(ms / d)}d`; - if (ms < 8 * w) return `${Math.floor(ms / w)}w`; - return `${Math.round(ms / (30.5 * d))}mo`; // 30.5 = avg days per month (365/12) -} -``` - -# Decisions - -```js -import {withDocHelp} from "./components/doc-overlay.js"; -import {injectTocTop} from "./components/toc-sidebar.js"; -import {openActionConfirm} from "./components/action-confirm.js"; - -// ── KPI computation (uses full data, not filtered) ────────────────────────── -const _resolved5 = data - .filter(d => d.decided_at && d.created_at) - .sort((a, b) => b.decided_at.localeCompare(a.decided_at)) - .slice(0, 5); - -const _meanResolveMs = _resolved5.length - ? _resolved5.reduce((s, d) => s + (new Date(d.decided_at) - new Date(d.created_at)), 0) / _resolved5.length - : null; - -const _nowKpi = new Date(); -const _openDecs = data.filter(d => d.status === "open" || d.status === "escalated"); -const _openAges = _openDecs.map(d => _nowKpi - new Date(d.created_at)); -const _meanOpenMs = _openAges.length - ? _openAges.reduce((s, a) => s + a, 0) / _openAges.length - : null; - -// Color: red = mean open > baseline; orange = any individual > baseline; black = all fine -let _openAgeColor = "inherit"; -if (_meanOpenMs !== null && _meanResolveMs !== null) { - if (_meanOpenMs > _meanResolveMs) _openAgeColor = "#dc2626"; - else if (_openAges.some(a => a > _meanResolveMs)) _openAgeColor = "#d97706"; -} - -// ── Build the KPI infobox ─────────────────────────────────────────────────── -const _kpiBox = html`
-
Decision Health
- ${_meanResolveMs !== null ? html`
- avg resolve -
-
${fmtDuration(_meanResolveMs)}
-
last ${_resolved5.length}
-
-
` : ""} - ${_meanOpenMs !== null ? html`
- avg open age -
-
${fmtDuration(_meanOpenMs)}
-
${_openDecs.length} open
-
-
` : html`
no open decisions
`} -
`; - -withDocHelp(_kpiBox, "/docs/decisions-kpi"); - -// ── Build live indicator ──────────────────────────────────────────────────── -const _liveEl = html`
- - ${_ok - ? `Live · updated ${_ts?.toLocaleTimeString()}` - : html`Offline — run: make api`} -
`; -withDocHelp(_liveEl, "/docs/live-data"); - -const _h1 = document.querySelector("#observablehq-main h1"); -if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/decisions"); } - -// ── Inject into TOC sidebar: KPI first (prepend → bottom), live last (→ top) ─ -const _toc = document.querySelector("#observablehq-toc"); -if (_toc) { - injectTocTop("decisions-kpi-box", _kpiBox); - injectTocTop("live-indicator", _liveEl); -} else { - display(html`
${_liveEl}${_kpiBox}
`); -} -``` - -## Resolution History - -```js -const period = view(Inputs.radio( - ["day", "week", "month", "quarter", "YTD", "year", "all"], - {value: "month", label: "Period"} -)); -``` - -```js -import * as Plot from "npm:@observablehq/plot"; - -function _getTs(d) { return new Date(d.decided_at ?? d.created_at); } - -function _bucketKey(t, unit, start) { - switch (unit) { - case "hour": return new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours()).getTime(); - case "day": return new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime(); - case "week": { - const w = Math.floor((t - start) / (7 * 864e5)); - return start.getTime() + w * 7 * 864e5; - } - case "month": return new Date(t.getFullYear(), t.getMonth(), 1).getTime(); - } -} - -function _genBuckets(start, end, unit) { - const bkts = []; - let cur = new Date(start); - while (cur <= end) { - bkts.push(cur.getTime()); - if (unit === "hour") cur = new Date(cur.getTime() + 36e5); - else if (unit === "day") cur = new Date(cur.getTime() + 864e5); - else if (unit === "week") cur = new Date(cur.getTime() + 7 * 864e5); - else if (unit === "month") cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); - } - return bkts; -} - -const _now = new Date(); -const _y = _now.getFullYear(), _mo = _now.getMonth(); - -let _start, _unit, _tickFmt; -switch (period) { - case "day": - _start = new Date(_y, _mo, _now.getDate()); - _unit = "hour"; - _tickFmt = d => `${String(d.getHours()).padStart(2, "0")}:00`; - break; - case "week": { - const _7ago = new Date(_now - 7 * 864e5); - _start = new Date(_7ago.getFullYear(), _7ago.getMonth(), _7ago.getDate()); - _unit = "day"; - _tickFmt = d => d.toLocaleDateString(undefined, {weekday: "short", month: "short", day: "numeric"}); - break; - } - case "month": - _start = new Date(_y, _mo, 1); - _unit = "week"; - _tickFmt = d => `W/${d.toLocaleDateString(undefined, {month: "short", day: "numeric"})}`; - break; - case "quarter": - _start = new Date(_y, Math.floor(_mo / 3) * 3, 1); - _unit = "month"; - _tickFmt = d => d.toLocaleDateString(undefined, {month: "short"}); - break; - case "YTD": - _start = new Date(_y, 0, 1); - _unit = "month"; - _tickFmt = d => d.toLocaleDateString(undefined, {month: "short"}); - break; - case "year": { - const _ago = new Date(_now - 365 * 864e5); - _start = new Date(_ago.getFullYear(), _ago.getMonth(), 1); - _unit = "month"; - _tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "2-digit"}); - break; - } - default: { - const _minTs = filtered.length ? Math.min(...filtered.map(d => _getTs(d))) : _now.getTime(); - const _minD = new Date(_minTs); - _start = new Date(_minD.getFullYear(), _minD.getMonth(), 1); - _unit = "month"; - _tickFmt = d => d.toLocaleDateString(undefined, {month: "short", year: "numeric"}); - break; - } -} - -const _inWindow = period === "all" - ? [...filtered] - : filtered.filter(d => { const t = _getTs(d); return t >= _start && t <= _now; }); - -const _bktKeys = _genBuckets(_start, _now, _unit); -const _cntMap = new Map(_bktKeys.map(k => [k, 0])); -for (const d of _inWindow) { - const key = _bucketKey(_getTs(d), _unit, _start); - if (_cntMap.has(key)) _cntMap.set(key, (_cntMap.get(key) || 0) + 1); -} - -let _cum = 0; -const _chartData = _bktKeys.map(k => { - const delta = _cntMap.get(k) || 0; - _cum += delta; - return {date: new Date(k), count: _cum, delta}; -}); - -if (_inWindow.length === 0) { - display(html`

No decisions in this period.

`); -} else { - const _plotEl = Plot.plot({ - x: {type: "time", tickFormat: _tickFmt, tickRotate: -30, label: null}, - y: {grid: true, label: "Cumulative decisions"}, - marks: [ - Plot.areaY(_chartData, {x: "date", y: "count", fill: "steelblue", fillOpacity: 0.15, curve: "step-after"}), - Plot.lineY(_chartData, {x: "date", y: "count", stroke: "steelblue", strokeWidth: 2, curve: "step-after"}), - Plot.dot(_chartData.filter(d => d.delta > 0), { - x: "date", y: "count", fill: "steelblue", r: 4, tip: true, - title: d => `${_tickFmt(d.date)}\n+${d.delta} → ${d.count} total`, - }), - Plot.ruleY([0]), - ], - marginBottom: 70, - width: 700, - }); - const _plotWrap = html`
${_plotEl}
`; - withDocHelp(_plotWrap, "/docs/decisions-kpi"); - display(_plotWrap); -} -``` - -## Filter & List - -```js -const _filterWrap = html`
${_filtersForm}
`; -withDocHelp(_filterWrap, "/docs/decisions-kpi"); -display(_filterWrap); -``` - -```js -const _nowCards = new Date(); - -if (filtered.length === 0) { - display(html`

No decisions match the current filter.

`); -} else { - display(html`
${filtered.map(d => { - const border = STATUS_BORDER[d.status] ?? "#ccc"; - const snippet = (d.description || d.rationale || "").slice(0, 200); - const due = fmtDate(d.deadline); - const decided = fmtDate(d.decided_at); - const overdue = isOverdue(d.deadline); - - const _isOpen = d.status === "open" || d.status === "escalated"; - const _ageMs = d.decided_at - ? new Date(d.decided_at) - new Date(d.created_at) - : _nowCards - new Date(d.created_at); - const _ageText = d.decided_at ? `took ${fmtDuration(_ageMs)}` : `open ${fmtDuration(_ageMs)}`; - const _ageWarn = _isOpen && _meanResolveMs !== null && _ageMs > _meanResolveMs; - - function onResolve() { - openActionConfirm({ - title: "Resolve Decision", - entityTitle: d.title, - label: "Rationale", - placeholder: "Why is this resolved, and what was decided?", - confirmLabel: "Resolve", - onConfirm: async (rationale) => { - const res = await fetch(`${API}/decisions/${d.id}/resolve`, { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify({rationale, decided_by: "human"}), - }); - if (!res.ok) throw new Error(`API error ${res.status}`); - }, - }); - } - - return html`
-
- ${d.decision_type} - - ${d.status === "escalated" ? "⚠ " : ""}${d.status} - - ${d.domain ? html`${d.domain}` : ""} - ${due ? html` - ${overdue ? "⚠ overdue" : "due"} ${due} - ` : ""} - ${_ageText} - ${fmtDate(d.created_at)} - ${_isOpen ? html`` : ""} -
-
${d.title}
- ${snippet ? html`
${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}
` : ""} - ${d.decided_by ? html`
✓ ${d.decided_by}${decided ? " · " + decided : ""}
` : ""} - ${d.escalation_note && !["resolved", "superseded"].includes(d.status) ? html`
${d.escalation_note}
` : ""} -
`; - })}
`); -} -``` - -```js -if (escalated.length > 0) { - display(html`
- ⚠ ${escalated.length} escalated decision${escalated.length > 1 ? "s" : ""} require human approval before any action is taken (constitution §4). -
    ${escalated.map(d => html`
  • ${d.title}: ${d.escalation_note}
  • `)}
-
`); -} -``` - - diff --git a/state-hub/dashboard/src/dependencies.md b/state-hub/dashboard/src/dependencies.md deleted file mode 100644 index a4dbce9..0000000 --- a/state-hub/dashboard/src/dependencies.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -title: Dependencies ---- - -```js -import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js"; -``` - -```js -// Fetch workstreams + topics + dep edges; /state/deps replaces the heavier -// /state/summary which was only used here to extract dependency edges. -const depState = (async function*() { - let failures = 0; - while (true) { - let wsMap = {}, edges = [], ok = false; - try { - const [rw, rto, rr, rd] = await Promise.all([ - apiFetch("/workstreams/"), - apiFetch("/topics/"), - apiFetch("/repos/"), - apiFetch("/state/deps"), - ]); - ok = rw.ok && rto.ok && rr.ok && rd.ok; - if (ok) { - const [wsList, topicList, repoList, depsList] = await Promise.all([ - rw.json(), rto.json(), rr.json(), rd.json(), - ]); - const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); - const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); - wsMap = Object.fromEntries(wsList.map(w => [w.id, { - ...w, - // Prefer repo→domain (GEMS primary); fall back to topic→domain - domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", - }])); - for (const ow of depsList) { - for (const depStub of (ow.depends_on ?? [])) { - edges.push({from_id: ow.id, to_id: depStub}); - } - } - } - } catch {} - failures = ok ? 0 : failures + 1; - yield {wsMap, edges, ok, ts: new Date()}; - await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures})); - } -})(); -``` - -```js -const wsMap = depState.wsMap ?? {}; -const edges = depState.edges ?? []; -const _ok = depState.ok ?? false; -const _ts = depState.ts; -``` - -# Dependencies - -```js -import {injectTocTop} from "./components/toc-sidebar.js"; -import {withDocHelp} from "./components/doc-overlay.js"; - -// ── KPI sidebar card ────────────────────────────────────────────────────────── -const _wsWithDeps = new Set([...edges.map(e => e.from_id), ...edges.map(e => e.to_id)]); -const _kpiBox = html`
-
Dependencies
-
- edges -
${edges.length}
-
-
- workstreams involved -
${_wsWithDeps.size}
-
-
`; - -const _liveEl = html`
- - ${_ok - ? `Live · updated ${_ts?.toLocaleTimeString()}` - : html`Offline — run: make api`} -
`; - -const _h1 = document.querySelector("#observablehq-main h1"); -if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/dependencies"); } - -injectTocTop("dep-kpi-box", _kpiBox); -injectTocTop("live-indicator", _liveEl); -``` - -Directed edges between active workstreams. An edge **A → B** means A cannot -fully proceed until B reaches a satisfactory state. - -```js -if (edges.length === 0) { - display(html`

No dependency edges registered.

`); -} else { - const rows = edges.map(e => { - const from = wsMap[e.from_id]; - const to = wsMap[e.to_id]; - return { - from_domain: from?.domain ?? "—", - from_title: from?.title ?? e.from_id, - from_status: from?.status ?? "—", - to_domain: to?.domain ?? "—", - to_title: to?.title ?? e.to_id, - to_status: to?.status ?? "—", - }; - }); - - display(html` - - - - - - - - - - - ${rows.map(r => html` - - - - - - - - - `)} -
Depends-on domainDepends-on workstreamBlocked-by domainBlocked-by workstreamStatus
${r.from_domain}${r.from_title}${r.to_domain}${r.to_title}${r.to_status}
`); -} -``` - - diff --git a/state-hub/dashboard/src/docs/capabilities.md b/state-hub/dashboard/src/docs/capabilities.md deleted file mode 100644 index 25b54d0..0000000 --- a/state-hub/dashboard/src/docs/capabilities.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -title: Capabilities — Reference ---- - -# Capabilities — Reference - -The Capability Requests page shows cross-domain provisioning requests — a -decoupled mechanism for one domain to request something that another domain is -responsible for, without needing to know *who* is responsible. - ---- - -## What is a capability? - -A **capability** is something a domain can provide to the broader ecosystem — -infrastructure provisioning, API endpoints, security tooling, documentation, -data pipelines, etc. Capabilities are registered in the **capability catalog** -so the system knows which domain provides what. - -A **capability request** is a structured declaration from a requester -("I need X") that the system routes to the right provider automatically. - ---- - -## Capability catalog - -The catalog is the routing backbone. Each entry registers one thing a domain -can provide. - -**Origin of truth: SCOPE.md** — following ADR-001, capability declarations -live in each repo's `SCOPE.md` file under the `## Provided Capabilities` -section. The state-hub catalog table is a derived index, reconstructable from -repo files via `make ingest-capabilities-all`. - -### SCOPE.md capability blocks - -Add fenced `capability` blocks to your repo's SCOPE.md: - -````markdown -## Provided Capabilities - -```capability -type: infrastructure -title: Cluster provisioning -description: Provision k8s clusters and managed instances for any domain. -keywords: [cluster, k8s, privacy, instance] -``` -```` - -| Field | Purpose | -|-------|---------| -| **type** | Category — `infrastructure`, `api`, `data`, `security`, `documentation`, `other` | -| **title** | Short name (unique within domain + type) | -| **description** | What this capability provides, in one or two sentences | -| **keywords** | Routing hints matched against request descriptions | - -### Ingesting into the catalog - -```bash -make ingest-capabilities REPO=the-custodian # single repo -make ingest-capabilities-all # all registered repos -make ingest-capabilities REPO=railiance-infra DRY_RUN=1 # preview -``` - -The ingest script reads `SCOPE.md` → parses `capability` blocks → upserts into -the `capability_catalog` table via the API. Existing entries (same domain + type -+ title) are skipped. - -### Browsing the catalog - -Via MCP: -``` -list_capabilities(domain="railiance") -``` - -Via API: -``` -GET /capability-catalog/?domain=railiance -``` - -The catalog is also shown at the bottom of the Capabilities dashboard page, -grouped by domain with type badges and keyword tags. - ---- - -## Routing algorithm - -When a request is created, the system auto-routes it: - -1. **Exact type match** — find catalog entries where `capability_type` matches -2. **Single match** — auto-assign the providing domain -3. **Multiple matches** — keyword-score the request description against each entry's keywords; pick the winner if unambiguous -4. **No match or tie** — leave the provider unassigned and **broadcast** a notification to all domains so one can claim it - -This means the requester never needs to know which domain owns a capability. - ---- - -## Request flow - -``` -requested → accepted → in_progress → ready_for_review → completed - ↓ ↓ ↓ ↓ - withdrawn rejected withdrawn withdrawn - withdrawn -``` - -| Workstation | Meaning | -|--------|---------| -| **requested** | Need declared; routed (or broadcast) to provider | -| **accepted** | Provider acknowledged and claimed the request | -| **in_progress** | Provider is actively working on it | -| **ready_for_review** | Provider finished; requester should review and optimise | -| **completed** | Requester confirmed; capability is live | -| **rejected** | Provider cannot or will not fulfil the request | -| **withdrawn** | Requester cancelled the request | - -Request movement is flow-aware. The API evaluates the target workstation's -entry assertions and rejects unreachable movement with machine-readable -blocking assertions. Terminal workstations (`completed`, `rejected`, -`withdrawn`) have no reachable outgoing path in the current flow definition. - ---- - -## Auto-notifications - -Every request movement handled by the capability request API creates an -**AgentMessage** atomically: - -| Transition | Notification to | -|------------|----------------| -| **requested** | Provider domain agent (or `broadcast` if unrouted) | -| **accepted** | Requesting agent | -| **in_progress** | Requesting agent | -| **ready_for_review** | Requesting agent | -| **completed** | Requesting agent | -| **rejected** | Requesting agent (with reason) | - -Notifications appear in the [Inbox](/inbox) page and are queryable via -`get_messages(to_agent="")`. - ---- - -## Auto-unblock - -A request can optionally link to a **blocking task** via `blocking_task_id`. -When the request reaches `completed`, the system automatically patches that -task from `blocked` → `todo` and clears its `blocking_reason`. This means -blocked work resumes without manual intervention. - ---- - -## Creating a request - -Via MCP: - -``` -request_capability( - title = "Privacy idea instance on cluster", - description = "Need a privacy idea instance provisioned on the k8s cluster", - capability_type = "infrastructure", - requesting_agent = "net-kingdom-worker", - requesting_domain = "custodian", - requesting_workstream_id = "", # optional - priority = "high", # low | medium | high | critical - blocking_task_id = "" # optional — auto-unblocked on completion -) -``` - -The system routes this to `railiance` (if a matching catalog entry exists), -creates an AgentMessage notification, and returns the request with -`fulfilling_domain_slug: "railiance"`. - ---- - -## Accepting and fulfilling - -The provider agent checks their inbox, sees the request, and accepts: - -``` -accept_capability_request( - request_id = "", - fulfilling_agent = "railiance-worker", - fulfilling_workstream_id = "" # optional -) -``` - -Then advances through the flow: - -``` -advance_workstation(entity_type="capability_request", entity_id=request_id, target_workstation="in_progress") -advance_workstation(entity_type="capability_request", entity_id=request_id, target_workstation="ready_for_review") -``` - -The requester reviews and completes: - -``` -advance_workstation(entity_type="capability_request", entity_id=request_id, target_workstation="completed") -``` - ---- - -## Dashboard - -The Capabilities page shows: - -- **KPI sidebar** — open count, average fulfillment time, high/critical count -- **Summary cards** — requested, in progress, ready for review, completed -- **Kanban board** — cards grouped by workstation/status column -- **Table** — all requests with filters by type, status, and domain - -Each card shows the capability type, priority, requester → provider domains, -and age in days. - ---- - -## Relation to other concepts - -| Concept | Relationship | -|---------|-------------| -| **SCOPE.md** | Defines what a repo *is responsible for* — the catalog registers what it *can provide* | -| **Dependencies** | Workstream-to-workstream edges — capabilities are higher-level, domain-to-domain | -| **Extension Points** | Design forks for *future* enhancement — capabilities are *operational* requests | -| **Contributions** | Outbound upstream work — capabilities are *inbound* requests between internal domains | -| **Human Interventions** | Flagged tasks for Bernd — capabilities are agent-to-agent coordination | - ---- - -*Capability requests are a sanctioned write use case of the State Hub alongside -`resolve_decision` and `get_next_steps`. They do not originate in workplan files — -they are operational coordination.* diff --git a/state-hub/dashboard/src/docs/connecting.md b/state-hub/dashboard/src/docs/connecting.md deleted file mode 100644 index 582e543..0000000 --- a/state-hub/dashboard/src/docs/connecting.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -title: Connecting to the State Hub — Reference ---- - -# Connecting to the State Hub — Reference - -How Claude Code agents on local and remote machines connect to the State Hub API -and its MCP server. - ---- - -## Architecture overview - -The State Hub runs on the **work laptop** only. Remote machines (COULOMBCORE, -Railiance nodes) never run their own copy — they connect to the single source -of truth via an encrypted SSH reverse tunnel managed by **ops-bridge**. - -``` -Work laptop Remote machine (e.g. COULOMBCORE) -───────────────────── ───────────────────────────────── -PostgreSQL :5432 Claude Code session - ↑ │ -FastAPI :8000 ←── ops-bridge ────→ :18000 (API health / tools) -MCP SSE :8001 ←── ops-bridge ────→ :18001 (MCP for Claude Code) -``` - -Two services are exposed through the tunnel: - -| Service | Local port | Remote port | Purpose | -|---------|-----------|-------------|---------| -| State Hub API | `8000` | `18000` | Health checks, direct curl queries | -| MCP SSE server | `8001` | `18001` | Claude Code MCP integration | - ---- - -## Local setup (work laptop) - -### Start the services - -```bash -cd ~/the-custodian/state-hub - -make api # FastAPI on :8000 -make mcp-http # MCP SSE server on :8001 (separate terminal) -``` - -`make mcp-http` sets `MCP_TRANSPORT=sse MCP_PORT=8001` and starts the same -MCP server that Claude Code uses locally in stdio mode. The SSE mode exposes -the identical tool surface over HTTP. - -### Start the tunnels - -```bash -bridge up # brings up both tunnels defined in ~/.config/bridge/tunnels.yaml -bridge status -``` - -Both tunnels must show `connected` (or `degraded` — see below) before remote -agents can connect. - -### Local Claude Code registration (stdio, default) - -Claude Code on the work laptop uses the stdio MCP server. Registration is in -`~/.claude.json` via `.mcp.json` at the repo root. No changes needed for local -use. - ---- - -## Remote setup (COULOMBCORE or any remote host) - -### Prerequisites - -- ops-bridge tunnels running on the work laptop (`bridge status`) -- SSH key `~/.ssh/id_ops` authorised on the remote host for user `tegwick` - -### One-time MCP registration on the remote - -```bash -claude mcp add-json -s user state-hub \ - '{"type":"sse","url":"http://127.0.0.1:18001/sse"}' -``` - -Restart Claude Code after running this. That's the only setup required — no -Python, no repo clone, no local services. - -### Verify connectivity - -```bash -# API reachable through tunnel? -curl -s http://127.0.0.1:18000/state/health -# → {"status":"ok","db":"connected"} - -# MCP SSE endpoint reachable? -curl -s --max-time 2 http://127.0.0.1:18001/sse | head -2 -# → event: endpoint -# → data: /messages/?session_id=... -``` - ---- - -## ops-bridge tunnel config - -Tunnels are defined in `~/.config/bridge/tunnels.yaml` on the work laptop: - -```yaml -tunnels: - state-hub-coulombcore: # API tunnel - host: 92.205.130.254 - remote_port: 18000 - local_port: 8000 - ssh_user: tegwick - ssh_key: ~/.ssh/id_ops - actor: agent.claude-coulombcore - health_check: - url: http://127.0.0.1:18000/state/health - interval_seconds: 30 - timeout_seconds: 5 - reconnect: - max_attempts: 0 # retry forever - backoff_initial: 5 - backoff_max: 60 - - state-hub-mcp-coulombcore: # MCP SSE tunnel - host: 92.205.130.254 - remote_port: 18001 - local_port: 8001 - ssh_user: tegwick - ssh_key: ~/.ssh/id_ops - actor: agent.claude-coulombcore - health_check: - url: http://127.0.0.1:18001/sse - interval_seconds: 30 - timeout_seconds: 5 - reconnect: - max_attempts: 0 - backoff_initial: 5 - backoff_max: 60 - - state-hub-railiance01: # API tunnel - host: 92.205.62.239 - remote_port: 18000 - local_port: 8000 - ssh_user: tegwick - ssh_key: ~/.ssh/id_ops - actor: agent.claude-railiance01 - health_check: - url: http://127.0.0.1:8000/state/health - interval_seconds: 30 - timeout_seconds: 5 - reconnect: - max_attempts: 0 - backoff_initial: 5 - backoff_max: 60 - - state-hub-mcp-railiance01: # MCP SSE tunnel - host: 92.205.62.239 - remote_port: 18001 - local_port: 8001 - ssh_user: tegwick - ssh_key: ~/.ssh/id_ops - actor: agent.claude-railiance01 - health_check: - url: http://127.0.0.1:18001/sse - interval_seconds: 30 - timeout_seconds: 5 - reconnect: - max_attempts: 0 - backoff_initial: 5 - backoff_max: 60 -``` - -ops-bridge source: `~/ops-bridge` · SSH key: `~/.ssh/id_ops` - ---- - -## Bridge states - -| State | Meaning | -|-------|---------| -| `connected` | SSH process alive, health check passing | -| `degraded` | SSH process alive, health check failing (SSE endpoint streams — not always a real error) | -| `reconnecting` | SSH dropped, backoff loop active | -| `stopped` | Not started or manually stopped | - -The MCP SSE tunnel often shows `degraded` because the `/sse` health check -receives a streaming response rather than a clean 200 — the tunnel is still -functional. Confirm with `curl http://127.0.0.1:18001/sse` from the remote. - ---- - -## MCP transport modes - -The MCP server (`state-hub/mcp_server/server.py`) supports two transports -selected by environment variable: - -| Variable | Default | Effect | -|----------|---------|--------| -| `MCP_TRANSPORT` | `stdio` | stdio transport (local Claude Code) | -| `MCP_TRANSPORT=sse` | — | SSE/HTTP transport for remote clients | -| `MCP_PORT` | `8001` | Port for SSE mode | -| `API_BASE` | `http://127.0.0.1:8000` | State Hub API URL the MCP server calls | - -On a remote machine the MCP server process runs locally inside Claude Code — it -does not run on the remote host. The transport layer (SSE over the tunnel) -handles the connection. - ---- - -## Adding a new remote host - -1. Generate or reuse an SSH key pair (recommend `~/.ssh/id_ops`) -2. Add the public key to the remote host's `~/.ssh/authorized_keys` -3. Check port availability on the remote: `ssh user@host "ss -tlnp | grep 18001"` -4. Add two tunnel entries to `~/.config/bridge/tunnels.yaml` (API + MCP) -5. `bridge up ` for each -6. On the remote: `claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:18001/sse"}'` - ---- - -*The State Hub runs on the work laptop only. Remote machines are read-only -consumers connected via tunnel — they never own a copy of the database.* diff --git a/state-hub/dashboard/src/docs/contributions.md b/state-hub/dashboard/src/docs/contributions.md deleted file mode 100644 index d773ea4..0000000 --- a/state-hub/dashboard/src/docs/contributions.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -title: Contributions — Reference ---- - -# Contributions — Reference - -Contributions track **outbound upstream work** — things the Custodian has -identified that belong in a repo it does not own or control. Each contribution -is a structured artifact filed locally in the repo's `contrib/` directory and -registered in the state hub so it is never lost. - ---- - -## Contribution types - -| Type | Full name | Use when | -|------|-----------|----------| -| `br` | Bug Report | You found a defect in an upstream tool or library | -| `fr` | Feature Request | You need functionality that upstream does not yet provide | -| `ep` | Extension Point | You identified a future enhancement opportunity in upstream code | -| `upr` | Upstream PR | You have written (or are writing) a patch for an upstream repo | - ---- - -## Flow workstations - -``` -draft → submitted → acknowledged → accepted → merged - ↘ ↘ - rejected withdrawn -``` - -| Workstation | Meaning | -|--------|---------| -| **draft** | Artifact written locally; not yet sent upstream | -| **submitted** | Filed as a GitHub issue, PR, or email — awaiting upstream response | -| **acknowledged** | Upstream has seen it and responded (e.g. triaged, commented) | -| **accepted** | Upstream agreed to take action | -| **merged** | PR accepted and merged; issue resolved | -| **rejected** | Upstream declined; record kept for future reference | -| **withdrawn** | We decided not to pursue it | - -Contribution movement is evaluated by the task-flow engine. The API asks -whether the target workstation is reachable from the current information -object; if not, it returns structured blocking assertions instead of a generic -"transition not allowed" message. `submitted_at` is stamped automatically when -the contribution reaches `submitted`; `resolved_at` is stamped when it reaches -`merged`, `rejected`, or `withdrawn`. - ---- - -## Relation to the Todo classification - -Contributions map directly to the **Third-party** class in the inter-repo -communication taxonomy: - -| Todo class | Mechanism | -|------------|-----------| -| Internal | Workplan file + task in this repo's workstream | -| Ecosystem | State hub task with `[repo:]` prefix | -| **Third-party** | **Contribution artifact in `contrib/` + state hub registration** | - -Contributions in `draft`, `submitted`, or `acknowledged` workstations appear as -open Third-party todos on the [Todo](/todo) page. - ---- - -## File layout - -Each artifact lives in the current repo under `contrib/`: - -``` -contrib/ - bug-reports/ br-YYYY-MM-DD------.md - feature-requests/ fr-YYYY-MM-DD------.md - extension-points/ EP--NNN------.md - upstream-prs/ upr-YYYY-MM-DD------.md -``` - -Templates live in `~/the-custodian/canon/standards/contrib-templates/`. -Convention details: `~/the-custodian/canon/standards/contribution-convention_v0.1.md`. - ---- - -## Adding a contribution - -**1. Write the artifact file** using the appropriate template. - -**2. Register it in the state hub** via MCP: - -``` -register_contribution( - type = "fr", - title = "Add sidebar TOC injection API", - target_org = "observablehq", - target_repo = "framework", - body_path = "contrib/feature-requests/fr-2026-02-26--observablehq--framework--toc.md", - related_workstream_id = "" -) -``` - -**3. Close the loop** when you file it upstream: - -``` -advance_workstation(entity_type="contribution", entity_id="", target_workstation="submitted") -``` - -**4. Keep updating** as upstream responds — `acknowledged`, `accepted`, `merged`. - ---- - -## Kanban board - -The Contributions page groups artifacts by status column. Only columns with at -least one entry are shown. The **⚠ follow-up banner** appears when any -contribution has been in `submitted` or `acknowledged` for an extended period -without further movement — a prompt to check in with upstream. - ---- - -*Contributions are append-only. Rejected or withdrawn artifacts are retained as -institutional memory — they explain why certain approaches were tried and -dropped.* diff --git a/state-hub/dashboard/src/docs/dashboard.md b/state-hub/dashboard/src/docs/dashboard.md deleted file mode 100644 index 7b07b08..0000000 --- a/state-hub/dashboard/src/docs/dashboard.md +++ /dev/null @@ -1,338 +0,0 @@ ---- -title: Dashboard — Technical Reference ---- - -# State Hub Dashboard — Technical Reference - -The State Hub dashboard is the primary visual interface for the Custodian -ecosystem. It provides live, reactive views of all tracked domains, -workstreams, tasks, decisions, contributions, SBOM data, and agent activity — -all sourced from the local FastAPI state service. - ---- - -## Framework: Observable Framework - -The dashboard is built on **[Observable Framework](https://observablehq.com/framework/)**, -an open-source static-site framework from Observable, Inc. designed specifically -for data-driven pages. - -### Why Observable Framework? - -| Requirement | How Observable Framework satisfies it | -|---|---| -| **Local-first, no build-time cloud dependency** | Compiles to a static site (`npm run build`); the preview server and data loaders run entirely on localhost. | -| **Live data without a separate frontend service** | Pages poll the FastAPI backend directly from the browser via `fetch`. No BFF, no GraphQL, no WebSockets required. | -| **Reactive updates without React complexity** | Observable's cell-based execution model re-runs any code block whose inputs change. Async generators produce new values every poll cycle and trigger re-renders automatically. | -| **No JS bundler configuration** | `.md` files containing fenced JS code blocks are the entire source. No webpack, no Vite config, no `tsconfig.json`. | -| **Native data visualisation** | First-class integration with `@observablehq/plot` — a concise, grammar-of-graphics library — for all charts. | -| **Sovereignty-compatible** | The built output is a folder of static HTML/JS/CSS. It can be served by any web server, archived, or opened directly from disk. | -| **Offline-graceful** | Data loaders (Python scripts that run at build time) produce JSON snapshots. If the API is unreachable at build time, the loader emits an empty-structure JSON so the page still renders with a clear error state instead of crashing. | - -Observable Framework was chosen over alternatives (Grafana, Metabase, Streamlit, -Next.js) because its design principles are uniquely aligned with the Custodian -philosophy: **local-first**, **no vendor lock-in**, **sovereignty-preserving**, -and **auditable** — the full data pipeline is visible in plain Markdown files. - ---- - -## Architecture - -``` -src/ - observablehq.config.js — site metadata, page registry, theme, global head - components/ — shared JS modules - data/ — Python data loaders (run at build time) - docs/ — reference pages (this file lives here) - *.md — one page per feature area -``` - -### Data flow - -There are two complementary data-fetching strategies: - -**1. Static data loaders** (`src/data/*.json.py`) - -Python scripts executed by the Observable build toolchain at `npm run build` -or `npm run dev`. Each script calls the FastAPI backend via `urllib`, serialises -the response to JSON on stdout, and Observable Framework captures that output -as a static snapshot file that the page imports with `FileAttachment(...)`. - -Current loaders: - -| File | API endpoint | -|---|---| -| `summary.json.py` | `/state/summary` | -| `workstreams.json.py` | `/workstreams/` | -| `contributions.json.py` | `/contributions/` | -| `decisions.json.py` | `/decisions/` | -| `domains.json.py` | `/domains/` | -| `messages.json.py` | `/messages/` | -| `progress.json.py` | `/progress/` | -| `repos.json.py` | `/repos/` | -| `sbom.json.py` | `/sbom/aggregated` | -| `gitea-inventory.json.py` | Gitea instance inventory | - -**2. Live browser polling** (async generators in page `.md` files) - -All interactive pages bypass the static snapshots for live data by using -Observable's async generator pattern directly in the browser: - -```js -const summaryState = (async function*() { - while (true) { - const r = await fetch(`${API}/state/summary`); - yield { data: r.ok ? await r.json() : {error: `HTTP ${r.status}`}, ok: r.ok }; - await new Promise(res => setTimeout(res, POLL)); - } -})(); -``` - -`POLL` is set to **15 000 ms** (15 seconds) in `src/components/config.js`. -Observable's reactivity engine detects each new yield value and re-runs all -dependent code blocks, updating charts, tables, and KPI cards automatically. -A `●` live indicator in the top-left corner of each page shows the connection -status and the last-updated time. - -### Global configuration — `observablehq.config.js` - -| Setting | Value | -|---|---| -| Root directory | `src/` | -| Site title | "Custodian State Hub" | -| Theme | `["air", "near-midnight"]` — light body with dark sidebar | -| Favicon | Inline SVG data URI (🗄️ emoji) | -| Global head | KPI infobox styles, filter-bar styles, improvement-modal script | - -The `improvement-modal.js` component is injected at the config level rather -than imported per-page because Observable proxies `src/*.js` through its own -bundler, which prevents them from being loaded as raw `