171 lines
4.9 KiB
Python
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
|