2026-03-20 01:29:27 +01:00
|
|
|
import asyncio
|
2026-04-26 15:13:01 +02:00
|
|
|
import json
|
2026-05-04 19:18:10 +02:00
|
|
|
import os
|
|
|
|
|
import re
|
2026-04-26 15:13:01 +02:00
|
|
|
import socket
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
import uuid
|
2026-03-20 01:47:19 +01:00
|
|
|
from datetime import datetime, timezone
|
2026-04-26 15:13:01 +02:00
|
|
|
from pathlib import Path
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
|
|
|
from sqlalchemy import case, func, or_, select
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-06-22 13:52:13 +02:00
|
|
|
from sqlalchemy.orm import noload
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
|
2026-04-26 15:13:01 +02:00
|
|
|
from api.config import settings
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
from api.database import get_session
|
2026-05-17 05:49:29 +02:00
|
|
|
from api.events import EventEnvelope, publish_event
|
2026-05-01 01:47:14 +02:00
|
|
|
from api.doi_engine import (
|
|
|
|
|
compute_fingerprint,
|
|
|
|
|
evaluate as _doi_evaluate,
|
|
|
|
|
evaluate_scope_health,
|
|
|
|
|
resolve_repo_path,
|
|
|
|
|
)
|
2026-03-20 01:47:19 +01:00
|
|
|
from api.models.doi_cache import DOICache
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
from api.models.domain import Domain
|
feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:29:08 +02:00
|
|
|
from api.models.interface_change import InterfaceChange
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
from api.models.managed_repo import ManagedRepo
|
2026-03-16 01:41:16 +01:00
|
|
|
from api.models.repo_goal import RepoGoal
|
2026-03-20 01:37:40 +01:00
|
|
|
from api.models.tpsc import TPSCSnapshot
|
2026-03-16 01:41:16 +01:00
|
|
|
from api.models.task import Task
|
2026-06-22 13:52:13 +02:00
|
|
|
from api.models.workplan import Workplan
|
2026-03-20 01:08:18 +01:00
|
|
|
from api.schemas.doi import DoICriterion, DoIReport, DoISummaryEntry
|
2026-03-16 01:41:16 +01:00
|
|
|
from api.schemas.managed_repo import (
|
|
|
|
|
DispatchTask,
|
2026-06-22 13:52:13 +02:00
|
|
|
DispatchWorkplan,
|
feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:29:08 +02:00
|
|
|
PendingInterfaceChange,
|
2026-03-16 01:41:16 +01:00
|
|
|
RepoCreate,
|
|
|
|
|
RepoDispatch,
|
2026-05-04 19:18:10 +02:00
|
|
|
RepoOnboardRequest,
|
|
|
|
|
RepoOnboardResult,
|
2026-03-16 16:30:55 +01:00
|
|
|
RepoPathRegister,
|
2026-03-16 01:41:16 +01:00
|
|
|
RepoRead,
|
2026-05-01 01:47:14 +02:00
|
|
|
RepoScopeHealth,
|
2026-03-16 01:41:16 +01:00
|
|
|
RepoUpdate,
|
2026-05-01 01:33:15 +02:00
|
|
|
ScopeIssueDetail,
|
2026-06-22 13:52:13 +02:00
|
|
|
classification_fields_set,
|
|
|
|
|
validate_repo_classification_fields,
|
2026-03-16 01:41:16 +01:00
|
|
|
)
|
2026-06-07 22:24:53 +02:00
|
|
|
from hub_core.routers.repos import create_repos_router
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/repos", tags=["repos"])
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 22:24:53 +02:00
|
|
|
async def _publish_repo_registered(repo: ManagedRepo, body: RepoCreate, domain: Domain) -> None:
|
2026-05-17 05:49:29 +02:00
|
|
|
subject = "org.statehub.repo.registered"
|
|
|
|
|
envelope = EventEnvelope.new(
|
|
|
|
|
subject,
|
|
|
|
|
attributes={
|
|
|
|
|
"repo_id": str(repo.id),
|
|
|
|
|
"repo_slug": repo.slug,
|
2026-06-07 22:24:53 +02:00
|
|
|
"domain_slug": domain.slug,
|
2026-05-17 05:49:29 +02:00
|
|
|
"remote_url": repo.remote_url,
|
|
|
|
|
"local_path": repo.local_path,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
asyncio.create_task(publish_event(subject, envelope))
|
|
|
|
|
|
2026-06-07 22:24:53 +02:00
|
|
|
|
|
|
|
|
def _core_repo_router(**route_flags) -> APIRouter:
|
|
|
|
|
return create_repos_router(
|
|
|
|
|
get_session,
|
|
|
|
|
prefix="",
|
|
|
|
|
domain_model=Domain,
|
|
|
|
|
repo_model=ManagedRepo,
|
|
|
|
|
repo_create_schema=RepoCreate,
|
|
|
|
|
repo_update_schema=RepoUpdate,
|
|
|
|
|
repo_read_schema=RepoRead,
|
|
|
|
|
repo_path_register_schema=RepoPathRegister,
|
|
|
|
|
list_noload_fields=("goals",),
|
2026-06-22 13:52:13 +02:00
|
|
|
create_extension_fields=(
|
|
|
|
|
"topic_id",
|
|
|
|
|
"category",
|
|
|
|
|
"secondary_domains",
|
|
|
|
|
"capability_tags",
|
|
|
|
|
"business_stake",
|
|
|
|
|
"business_mechanics",
|
|
|
|
|
"classified_at",
|
|
|
|
|
"classified_by",
|
|
|
|
|
"standard_version",
|
|
|
|
|
),
|
2026-06-07 22:24:53 +02:00
|
|
|
after_register=_publish_repo_registered,
|
|
|
|
|
**route_flags,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
router.include_router(
|
|
|
|
|
_core_repo_router(include_collection_routes=False, include_slug_routes=False)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/", response_model=list[RepoRead])
|
|
|
|
|
async def list_repos(
|
|
|
|
|
response: Response,
|
|
|
|
|
domain: str | None = None,
|
|
|
|
|
category: str | None = None,
|
|
|
|
|
capability_tag: str | None = None,
|
|
|
|
|
business_stake: str | None = None,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> list[ManagedRepo]:
|
|
|
|
|
"""List repos with optional domain and classification filters."""
|
|
|
|
|
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_result = await session.execute(select(Domain).where(Domain.slug == domain))
|
|
|
|
|
domain_obj = domain_result.scalar_one_or_none()
|
|
|
|
|
if domain_obj is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Domain '{domain}' not found")
|
|
|
|
|
q = q.where(
|
|
|
|
|
or_(
|
|
|
|
|
ManagedRepo.domain_id == domain_obj.id,
|
|
|
|
|
ManagedRepo.secondary_domains.contains([domain]),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if category:
|
|
|
|
|
q = q.where(ManagedRepo.category == category)
|
|
|
|
|
if capability_tag:
|
|
|
|
|
q = q.where(ManagedRepo.capability_tags.contains([capability_tag]))
|
|
|
|
|
if business_stake:
|
|
|
|
|
q = q.where(ManagedRepo.business_stake.contains([business_stake]))
|
|
|
|
|
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_result = await session.execute(select(Domain).where(Domain.slug == body.domain_slug))
|
|
|
|
|
domain_obj = domain_result.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")
|
|
|
|
|
|
|
|
|
|
payload = body.model_dump()
|
|
|
|
|
validate_repo_classification_fields(
|
|
|
|
|
domain_slug=body.domain_slug,
|
|
|
|
|
fields=payload,
|
|
|
|
|
require_complete=classification_fields_set(payload),
|
|
|
|
|
)
|
|
|
|
|
repo = ManagedRepo(
|
|
|
|
|
domain_id=domain_obj.id,
|
|
|
|
|
slug=body.slug,
|
|
|
|
|
name=body.name,
|
|
|
|
|
local_path=body.local_path,
|
|
|
|
|
host_paths=body.host_paths,
|
|
|
|
|
remote_url=body.remote_url,
|
|
|
|
|
git_fingerprint=body.git_fingerprint,
|
|
|
|
|
description=body.description,
|
|
|
|
|
topic_id=body.topic_id,
|
|
|
|
|
category=body.category,
|
|
|
|
|
secondary_domains=body.secondary_domains,
|
|
|
|
|
capability_tags=body.capability_tags,
|
|
|
|
|
business_stake=body.business_stake,
|
|
|
|
|
business_mechanics=body.business_mechanics,
|
|
|
|
|
classified_at=body.classified_at,
|
|
|
|
|
classified_by=body.classified_by,
|
|
|
|
|
standard_version=body.standard_version,
|
|
|
|
|
)
|
|
|
|
|
session.add(repo)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(repo)
|
|
|
|
|
await _publish_repo_registered(repo, body, domain_obj)
|
|
|
|
|
return repo
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
|
|
|
|
|
|
2026-05-04 19:18:10 +02:00
|
|
|
@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,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-08 13:19:06 +02:00
|
|
|
def _todo_md_entry(repo_slug: str, todo_path: Path, mtime: datetime) -> dict:
|
|
|
|
|
if mtime.tzinfo is None:
|
|
|
|
|
mtime = mtime.replace(tzinfo=timezone.utc)
|
|
|
|
|
age_days = max(0, (datetime.now(timezone.utc) - mtime).days)
|
|
|
|
|
return {
|
|
|
|
|
"repo_slug": repo_slug,
|
|
|
|
|
"has_todo": True,
|
|
|
|
|
"todo_path": str(todo_path),
|
|
|
|
|
"mtime": mtime.isoformat(),
|
|
|
|
|
"age_days": age_days,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/todo-md-staleness")
|
|
|
|
|
async def list_todo_md_staleness(
|
|
|
|
|
stale_days: int = 6,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Scan registered repos for TODO.md files unchanged longer than stale_days.
|
|
|
|
|
|
|
|
|
|
Used by activity-core daily stale-review automation. Only repos with an
|
|
|
|
|
accessible checkout and an existing TODO.md are included.
|
|
|
|
|
"""
|
|
|
|
|
if stale_days < 1:
|
|
|
|
|
raise HTTPException(status_code=400, detail="stale_days must be >= 1")
|
|
|
|
|
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
select(ManagedRepo, Domain.slug)
|
|
|
|
|
.join(Domain, Domain.id == ManagedRepo.domain_id)
|
|
|
|
|
.where(ManagedRepo.status == "active")
|
|
|
|
|
.order_by(ManagedRepo.slug)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
all_with_todo: list[dict] = []
|
|
|
|
|
scanned = 0
|
|
|
|
|
for repo, _domain_slug in result.all():
|
|
|
|
|
scanned += 1
|
|
|
|
|
repo_dict = _repo_doi_dict(repo, _domain_slug)
|
|
|
|
|
resolved_path = resolve_repo_path(repo_dict)
|
|
|
|
|
if not resolved_path:
|
|
|
|
|
continue
|
|
|
|
|
todo_path = Path(resolved_path) / "TODO.md"
|
|
|
|
|
if not todo_path.is_file():
|
|
|
|
|
continue
|
|
|
|
|
mtime = datetime.fromtimestamp(todo_path.stat().st_mtime, tz=timezone.utc)
|
|
|
|
|
all_with_todo.append(_todo_md_entry(repo.slug, todo_path, mtime))
|
|
|
|
|
|
|
|
|
|
stale_entries = [e for e in all_with_todo if e["age_days"] >= stale_days]
|
|
|
|
|
stale_count = len(stale_entries)
|
|
|
|
|
worst = max(stale_entries, key=lambda e: e["age_days"], default=None)
|
|
|
|
|
payload: dict = {
|
|
|
|
|
"stale_days": stale_days,
|
|
|
|
|
"repos": stale_entries,
|
|
|
|
|
"stale_count": stale_count,
|
|
|
|
|
"total_with_todo": len(all_with_todo),
|
|
|
|
|
"total_scanned": scanned,
|
|
|
|
|
"worst_repo_slug": worst["repo_slug"] if worst else None,
|
|
|
|
|
"worst_age_days": worst["age_days"] if worst else None,
|
|
|
|
|
"worst_mtime": worst["mtime"] if worst else None,
|
|
|
|
|
}
|
|
|
|
|
if worst:
|
|
|
|
|
payload.update(worst)
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
2026-03-20 01:08:18 +01:00
|
|
|
@router.get("/doi/summary", response_model=list[DoISummaryEntry])
|
|
|
|
|
async def doi_summary(session: AsyncSession = Depends(get_session)) -> list[DoISummaryEntry]:
|
2026-03-20 01:47:19 +01:00
|
|
|
"""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).
|
|
|
|
|
"""
|
2026-03-20 01:37:40 +01:00
|
|
|
repos_result = await session.execute(
|
2026-03-20 01:08:18 +01:00
|
|
|
select(ManagedRepo).where(ManagedRepo.status == "active").order_by(ManagedRepo.name)
|
|
|
|
|
)
|
2026-03-20 01:37:40 +01:00
|
|
|
repos = list(repos_result.scalars().all())
|
2026-03-20 01:47:19 +01:00
|
|
|
repo_ids = [r.id for r in repos]
|
|
|
|
|
id_to_slug = {r.id: r.slug for r in repos}
|
2026-03-20 01:37:40 +01:00
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
# ── Bulk DB queries for fingerprint inputs ────────────────────────────────
|
2026-03-20 01:37:40 +01:00
|
|
|
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()}
|
|
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
# Latest TPSC snapshot timestamp per repo (for fingerprint + C9 count)
|
2026-03-20 01:37:40 +01:00
|
|
|
tpsc_result = await session.execute(
|
2026-03-20 01:47:19 +01:00
|
|
|
select(TPSCSnapshot.repo_id,
|
|
|
|
|
func.count().label("cnt"),
|
|
|
|
|
func.max(TPSCSnapshot.snapshot_at).label("latest"))
|
2026-03-20 01:37:40 +01:00
|
|
|
.where(TPSCSnapshot.repo_id.in_(repo_ids))
|
|
|
|
|
.group_by(TPSCSnapshot.repo_id)
|
|
|
|
|
)
|
2026-03-20 01:47:19 +01:00
|
|
|
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}
|
2026-03-20 01:37:40 +01:00
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
# Latest goal updated_at + active count per repo (for fingerprint + C10)
|
2026-03-20 01:37:40 +01:00
|
|
|
goals_result = await session.execute(
|
2026-03-20 01:47:19 +01:00
|
|
|
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))
|
2026-03-20 01:37:40 +01:00
|
|
|
.group_by(RepoGoal.repo_id)
|
|
|
|
|
)
|
2026-03-20 01:47:19 +01:00
|
|
|
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()}
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────
|
2026-03-20 01:37:40 +01:00
|
|
|
|
|
|
|
|
prefetch = {
|
2026-03-20 01:47:19 +01:00
|
|
|
"domain_status": domain_status,
|
|
|
|
|
"tpsc_snap_counts": tpsc_snap_counts,
|
2026-03-20 01:37:40 +01:00
|
|
|
"active_goal_counts": active_goal_counts,
|
|
|
|
|
}
|
2026-03-20 01:08:18 +01:00
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
async def _get_or_refresh(repo: ManagedRepo) -> DoISummaryEntry:
|
|
|
|
|
slug = repo.slug
|
2026-03-20 01:08:18 +01:00
|
|
|
repo_dict = {
|
2026-03-20 01:47:19 +01:00
|
|
|
"slug": slug,
|
2026-03-20 01:08:18 +01:00
|
|
|
"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,
|
2026-03-20 01:47:19 +01:00
|
|
|
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
2026-03-20 01:08:18 +01:00
|
|
|
}
|
2026-03-20 01:47:19 +01:00
|
|
|
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
|
2026-03-20 01:37:40 +01:00
|
|
|
report = await _doi_evaluate(repo_dict, skip_consistency=True, prefetch=prefetch)
|
2026-03-20 01:47:19 +01:00
|
|
|
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,
|
|
|
|
|
))
|
|
|
|
|
|
2026-03-20 01:29:27 +01:00
|
|
|
return DoISummaryEntry(
|
2026-03-20 01:47:19 +01:00
|
|
|
repo_slug=slug,
|
2026-03-20 01:08:18 +01:00
|
|
|
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,
|
2026-03-20 01:47:19 +01:00
|
|
|
checked_at=now.isoformat(),
|
2026-03-20 01:29:27 +01:00
|
|
|
)
|
|
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
entries: list[DoISummaryEntry] = list(await asyncio.gather(*[_get_or_refresh(r) for r in repos]))
|
|
|
|
|
await session.commit()
|
2026-03-20 01:08:18 +01:00
|
|
|
|
|
|
|
|
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)
|
2026-03-20 01:47:19 +01:00
|
|
|
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.
|
|
|
|
|
"""
|
2026-03-20 01:08:18 +01:00
|
|
|
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()
|
|
|
|
|
|
2026-03-20 01:47:19 +01:00
|
|
|
# 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()
|
|
|
|
|
|
2026-03-20 01:08:18 +01:00
|
|
|
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,
|
2026-03-20 01:47:19 +01:00
|
|
|
"updated_at": str(repo.updated_at) if repo.updated_at else "",
|
2026-03-20 01:08:18 +01:00
|
|
|
}
|
2026-03-20 01:47:19 +01:00
|
|
|
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)
|
2026-03-20 01:08:18 +01:00
|
|
|
report = await _doi_evaluate(repo_dict)
|
2026-03-20 01:47:19 +01:00
|
|
|
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()
|
|
|
|
|
|
2026-03-20 01:08:18 +01:00
|
|
|
return DoIReport(
|
2026-03-20 01:47:19 +01:00
|
|
|
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],
|
2026-03-20 01:08:18 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-29 22:35:35 +02:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 01:47:14 +02:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
@router.patch("/{slug}", response_model=RepoRead)
|
|
|
|
|
async def update_repo_with_classification(
|
|
|
|
|
slug: str,
|
|
|
|
|
body: RepoUpdate,
|
|
|
|
|
session: AsyncSession = Depends(get_session),
|
|
|
|
|
) -> ManagedRepo:
|
|
|
|
|
"""Patch repo metadata including classification spine fields."""
|
|
|
|
|
repo = await _get_repo_by_slug(slug, session)
|
|
|
|
|
payload = body.model_dump(exclude_unset=True)
|
|
|
|
|
domain_result = await session.execute(select(Domain).where(Domain.id == repo.domain_id))
|
|
|
|
|
domain_obj = domain_result.scalar_one_or_none()
|
|
|
|
|
domain_slug = domain_obj.slug if domain_obj else ""
|
|
|
|
|
if classification_fields_set(payload):
|
|
|
|
|
merged = {
|
|
|
|
|
"category": payload.get("category", repo.category),
|
|
|
|
|
"secondary_domains": payload.get("secondary_domains", repo.secondary_domains),
|
|
|
|
|
"capability_tags": payload.get("capability_tags", repo.capability_tags),
|
|
|
|
|
"business_stake": payload.get("business_stake", repo.business_stake),
|
|
|
|
|
"business_mechanics": payload.get("business_mechanics", repo.business_mechanics),
|
|
|
|
|
}
|
|
|
|
|
validate_repo_classification_fields(
|
|
|
|
|
domain_slug=domain_slug,
|
|
|
|
|
fields=merged,
|
|
|
|
|
require_complete=True,
|
|
|
|
|
)
|
|
|
|
|
for field, value in payload.items():
|
|
|
|
|
setattr(repo, field, value)
|
|
|
|
|
await session.commit()
|
|
|
|
|
await session.refresh(repo)
|
|
|
|
|
return repo
|
|
|
|
|
|
|
|
|
|
|
2026-06-07 22:24:53 +02:00
|
|
|
router.include_router(
|
|
|
|
|
_core_repo_router(
|
|
|
|
|
include_collection_routes=False,
|
|
|
|
|
include_lookup_routes=False,
|
|
|
|
|
)
|
|
|
|
|
)
|
2026-03-16 16:30:55 +01:00
|
|
|
|
|
|
|
|
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-03-16 01:41:16 +01:00
|
|
|
@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(
|
2026-06-22 13:52:13 +02:00
|
|
|
select(Workplan)
|
|
|
|
|
.where(Workplan.repo_id == repo.id, Workplan.status == "active")
|
|
|
|
|
.order_by(Workplan.created_at)
|
2026-03-16 01:41:16 +01:00
|
|
|
)
|
|
|
|
|
workstreams = list(ws_result.scalars().all())
|
|
|
|
|
|
2026-06-22 13:52:13 +02:00
|
|
|
dispatch_workstreams: list[DispatchWorkplan] = []
|
2026-03-16 01:41:16 +01:00
|
|
|
all_interventions: list[DispatchTask] = []
|
|
|
|
|
|
|
|
|
|
for ws in workstreams:
|
|
|
|
|
task_result = await session.execute(
|
|
|
|
|
select(Task)
|
2026-06-22 13:52:13 +02:00
|
|
|
.where(Task.workplan_id == ws.id, Task.status.in_(["todo", "progress"]))
|
2026-03-16 01:41:16 +01:00
|
|
|
.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(
|
2026-06-22 13:52:13 +02:00
|
|
|
DispatchWorkplan(
|
2026-03-16 01:41:16 +01:00
|
|
|
id=ws.id,
|
|
|
|
|
title=ws.title,
|
|
|
|
|
status=ws.status,
|
|
|
|
|
pending_tasks=pending,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:29:08 +02:00
|
|
|
# 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()
|
|
|
|
|
]
|
|
|
|
|
|
2026-05-01 01:33:15 +02:00
|
|
|
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)
|
2026-05-01 01:47:14 +02:00
|
|
|
for issue in evaluate_scope_health(_repo_doi_dict(repo, domain_obj.slug if domain_obj else None))
|
2026-05-01 01:33:15 +02:00
|
|
|
]
|
|
|
|
|
scope_needs_review = any(
|
|
|
|
|
issue.id in {"C5a", "C5b", "C5c"} and issue.status in {"fail", "warn"}
|
|
|
|
|
for issue in scope_issue_details
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-16 01:41:16 +01:00
|
|
|
return RepoDispatch(
|
|
|
|
|
repo_slug=slug,
|
|
|
|
|
active_goal=active_goal,
|
2026-06-22 13:52:13 +02:00
|
|
|
active_workplans=dispatch_workstreams,
|
2026-03-16 01:41:16 +01:00
|
|
|
human_interventions=all_interventions,
|
feat(state-hub): Interface Change Registry (CUST-WP-0033 T01-T06)
Adds first-class tracking for API and interface mutations across the
agent ecosystem. Breaking changes are documented, affected repos are
notified via inbox, and agents discover pending changes at session
start via the dispatch endpoint.
- Migration q4l5m6n7o8p9: interface_changes table
- Model/schema: InterfaceChange with draft→published→resolved lifecycle
- Router: POST/GET/PATCH /interface-changes/, /publish, /resolve actions
(auto-notify affected repo agents on publish; progress event on origin)
- Dispatch: GET /repos/{slug}/dispatch now returns pending_interface_changes
- MCP tools: register_interface_change, list_interface_changes,
publish_interface_change, resolve_interface_change
- Dashboard: /interface-changes page with type badges, planned calendar,
published cards, and draft table
- EP-CUST-ICR-001 registered: webhook subscriptions (deliberately deferred)
First record: trailing-slash normalisation (2026-04-26), published,
affecting repo-registry — visible in repo-registry dispatch immediately.
223 tests passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 15:29:08 +02:00
|
|
|
pending_interface_changes=pending_changes,
|
2026-05-01 01:33:15 +02:00
|
|
|
scope_needs_review=scope_needs_review,
|
|
|
|
|
scope_issue_details=scope_issue_details,
|
2026-03-16 01:41:16 +01:00
|
|
|
last_state_synced_at=repo.last_state_synced_at,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-04-26 15:13:01 +02:00
|
|
|
@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)'}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
|
|
|
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
|
2026-05-01 01:47:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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 "",
|
|
|
|
|
}
|