spore-commons/node/spore_node/api/routers/governance.py

171 lines
4.9 KiB
Python

"""Governance document and DAG endpoints."""
import json
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from spore_node.db.connection import get_pool
from spore_node.governance.parser import parse_governance_doc, GovernanceDoc
from spore_node.governance.dag import build_dag
router = APIRouter(prefix="/governance", tags=["governance"])
class DocIngest(BaseModel):
content: str # Raw markdown with YAML frontmatter
class DocResponse(BaseModel):
doc_id: str
doc_kind: str
title: str
status: str
depends_on: list[str]
body: str
@router.post("/docs", response_model=DocResponse, status_code=201)
async def ingest_doc(data: DocIngest):
"""Ingest a governance document (markdown with YAML frontmatter)."""
try:
doc = parse_governance_doc(data.content)
except ValueError as e:
raise HTTPException(422, str(e))
pool = get_pool()
# Upsert doc
await pool.execute(
"""
INSERT INTO governance_docs (doc_id, doc_kind, title, status, body, frontmatter)
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
ON CONFLICT (doc_id) DO UPDATE SET
doc_kind = EXCLUDED.doc_kind,
title = EXCLUDED.title,
status = EXCLUDED.status,
body = EXCLUDED.body,
frontmatter = EXCLUDED.frontmatter,
updated_at = now()
""",
doc.doc_id, doc.doc_kind, doc.title, doc.status, doc.body,
json.dumps(doc.frontmatter),
)
# Replace dependency edges
await pool.execute(
"DELETE FROM governance_deps WHERE from_doc = $1", doc.doc_id
)
for dep in doc.depends_on:
# Ensure target exists (placeholder if needed)
await pool.execute(
"""
INSERT INTO governance_docs (doc_id, doc_kind, title, status)
VALUES ($1, 'unknown', '', 'placeholder')
ON CONFLICT (doc_id) DO NOTHING
""",
dep,
)
await pool.execute(
"INSERT INTO governance_deps (from_doc, to_doc) VALUES ($1, $2) ON CONFLICT DO NOTHING",
doc.doc_id, dep,
)
# Log event
await pool.execute(
"""
INSERT INTO events (entity_rid, event_kind, payload)
VALUES ($1, 'governance.doc.ingested', $2::jsonb)
""",
f"governance:{doc.doc_id}",
json.dumps({"doc_id": doc.doc_id, "doc_kind": doc.doc_kind}),
)
return doc.model_dump()
@router.get("/docs", response_model=list[DocResponse])
async def list_docs(doc_kind: str | None = None, status: str | None = None):
"""List governance documents with optional filters."""
pool = get_pool()
query = "SELECT * FROM governance_docs WHERE doc_kind != 'unknown'"
params = []
idx = 1
if doc_kind:
query += f" AND doc_kind = ${idx}"
params.append(doc_kind)
idx += 1
if status:
query += f" AND status = ${idx}"
params.append(status)
idx += 1
query += " ORDER BY created_at"
rows = await pool.fetch(query, *params)
result = []
for row in rows:
deps = await pool.fetch(
"SELECT to_doc FROM governance_deps WHERE from_doc = $1", row["doc_id"]
)
result.append(DocResponse(
doc_id=row["doc_id"],
doc_kind=row["doc_kind"],
title=row["title"],
status=row["status"],
depends_on=[d["to_doc"] for d in deps],
body=row["body"],
))
return result
@router.get("/docs/{doc_id}", response_model=DocResponse)
async def get_doc(doc_id: str):
"""Get a single governance document."""
pool = get_pool()
row = await pool.fetchrow(
"SELECT * FROM governance_docs WHERE doc_id = $1", doc_id
)
if not row:
raise HTTPException(404, f"Document '{doc_id}' not found")
deps = await pool.fetch(
"SELECT to_doc FROM governance_deps WHERE from_doc = $1", doc_id
)
return DocResponse(
doc_id=row["doc_id"],
doc_kind=row["doc_kind"],
title=row["title"],
status=row["status"],
depends_on=[d["to_doc"] for d in deps],
body=row["body"],
)
@router.get("/dag")
async def get_dag():
"""Get the full governance DAG."""
pool = get_pool()
rows = await pool.fetch(
"SELECT * FROM governance_docs WHERE doc_kind != 'unknown' ORDER BY created_at"
)
docs = []
for row in rows:
deps = await pool.fetch(
"SELECT to_doc FROM governance_deps WHERE from_doc = $1", row["doc_id"]
)
docs.append(GovernanceDoc(
doc_id=row["doc_id"],
doc_kind=row["doc_kind"],
title=row["title"],
status=row["status"],
depends_on=[d["to_doc"] for d in deps],
body=row["body"],
))
dag = build_dag(docs)
result = dag.to_dict()
result["errors"] = dag.validate()
return result