adopt: état déployé sur M4M64c adopté comme source de vérité (remote-first, 2026-11-01)
138 changed files +11,662 −0
added
.env.example
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +# AKOS runtime configuration (copy to .env on the node; mode 600; never commit) | |
| 2 | +DATABASE_URL=postgresql://akos@127.0.0.1:5432/akos | |
| 3 | +SESSION_SECRET=<64 random hex chars: python3 -c "import secrets;print(secrets.token_hex(32))"> | |
| 4 | +AKOS_PUBLIC_URL=https://akos.spboucher.ai | |
| 5 | +AKOS_PORT=8520 | |
| 6 | +AKOS_BIND=127.0.0.1,wg1 # loopback + WireGuard address only (never the LAN interface) | |
| 7 | +AKOS_PROXY_PORT=8521 | |
| 8 | +AKOS_TRUSTED_PROXIES=127.0.0.1,::1,10.67.0.1 | |
| 9 | +AKOS_DATA_DIR=/Users/<user>/apps/akos/data | |
| 10 | +AKOS_LOG_DIR=/Users/<user>/apps/akos/logs | |
| 11 | +AKOS_VAULT_BACKEND=keychain # keychain (macOS) | file (tests only) | |
| 12 | +AKOS_KEYCHAIN_SERVICE=akos | |
| 13 | +AKOS_ADMIN_EMAIL=<bootstrap admin email> # used once, when the users table is empty | |
| 14 | +AKOS_ADMIN_PASSWORD=<bootstrap admin password> | |
| 15 | +AKOS_RATE_LIMIT_PER_MINUTE=300 | |
| 16 | +AKOS_EXECUTE_TIMEOUT=30 | |
| 17 | +AKOS_MAX_RESPONSE_BYTES=26214400 | |
| 18 | +AKOS_WORKER_CONCURRENCY=3 | |
| 19 | +AKOS_BACKUP_DIR=/Users/<user>/backups/akos | |
| 20 | +AKOS_BACKUP_REMOTE=M3U96b:~/backups/akos | |
| 21 | +AKOS_SANDBOX=1 | |
| 22 | +AKOS_SECURE_COOKIES=1 | |
| 23 | +AKOS_ENV=production | |
added
.gitignore
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +.env | |
| 2 | +.env.* | |
| 3 | +!.env.example | |
| 4 | +.venv/ | |
| 5 | +__pycache__/ | |
| 6 | +*.pyc | |
| 7 | +data/ | |
| 8 | +logs/ | |
| 9 | +backups/ | |
| 10 | +web/node_modules/ | |
| 11 | +web/dist/ | |
| 12 | +.pytest_cache/ | |
| 13 | +.DS_Store | |
| 14 | +*.egg-info/ | |
added
CLAUDE.md
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +# AKOS — API Knowledge Operating System | |
| 2 | + | |
| 3 | +Production: https://akos.spboucher.ai (node M4M64c, `~/apps/akos`, PM2 `akos-web|worker|proxy`, Postgres `akos`). Remote-first: edit/build/test/commit ON THE NODE (`ssh M4M64c` or `ssh akosnode` jump), origin = spbgit `akos.git`. Laptop copy = mirror. | |
| 4 | + | |
| 5 | +- Stack: Python 3.13 (uv, `.venv`), FastAPI, asyncpg, SQL migrations in `akos/migrations/*.sql` (append-only), React/Vite UI in `web/` (built to `web/dist`, served by FastAPI), MCP Streamable HTTP at `/mcp`, CLI `akos`, SDK `akos.sdk`. | |
| 6 | +- Tests: `make test` (offline, mock API, DB `akos_test`), `make test-node` (sandbox-exec + backup drill), live demos in `examples/`. Never mark a scenario PASS without running it; evidence goes to `docs/acceptance-report.md`. | |
| 7 | +- Secrets: `.env` (node, 0600) + macOS keychain service `akos`; never commit values; AKOS stores only `vault://` references. | |
| 8 | +- Policies/lifecycle are enforced in code (`akos/gateway/policy.py`, `akos/registry/lifecycle.py`); knowledge state is derived from evidence rows only. | |
| 9 | +- Docs: `docs/architecture.md`, `threat-model.md`, `operations.md`, `security-review.md`, `acceptance-matrix.md`, `acceptance-report.md`, `build-log.md`, `infra-inventory.md`. | |
added
Makefile
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +.PHONY: test test-live test-node lint typecheck build-web run-web run-worker run-proxy | |
| 2 | +PY=.venv/bin/python | |
| 3 | +test: ## offline fixtures + mock API (CI) | |
| 4 | + $(PY) -m pytest -q -m "not live and not node" tests/unit tests/integration tests/security | |
| 5 | +test-node: ## macOS node only: sandbox-exec enforcement + backup/restore | |
| 6 | + $(PY) -m pytest -q -m node tests | |
| 7 | +test-live: ## real providers (network) | |
| 8 | + $(PY) -m pytest -q -m live tests | |
| 9 | +lint: | |
| 10 | + $(PY) -m ruff check akos tests | |
| 11 | +build-web: | |
| 12 | + cd web && pnpm install --frozen-lockfile && pnpm build | |
| 13 | +run-web: | |
| 14 | + $(PY) -m akos web | |
| 15 | +run-worker: | |
| 16 | + $(PY) -m akos worker | |
| 17 | +run-proxy: | |
| 18 | + $(PY) -m akos proxy | |
added
README.md
+33 −0
@@ -0,0 +1,33 @@ | ||
| 1 | +# AKOS — API Knowledge Operating System | |
| 2 | + | |
| 3 | +Persistent, enforceable, executable API knowledge for Claude Code and other agents: a PostgreSQL registry of versioned API Knowledge Packages (AKPs), a governed execution gateway with provenance, a compiler that turns documentation into tested Python/TypeScript clients and MCP tool contracts, a provenance engine with reproducible research exports, a credential vault (keychain references only), knowledge-debt and drift tracking, an MCP endpoint, a CLI and a responsive web console. | |
| 4 | + | |
| 5 | +**Live:** https://akos.spboucher.ai (UI) · `/api/v1` (REST) · `/mcp` (MCP, Bearer token) · `/docs` (OpenAPI, login required). | |
| 6 | + | |
| 7 | +## Quick start (operator / researcher) | |
| 8 | +```bash | |
| 9 | +# CLI | |
| 10 | +uv tool install --from /path/to/akos akos # or: pip install -e . | |
| 11 | +akos login --url https://akos.spboucher.ai # mints a scoped token into ~/.config/akos/config.json | |
| 12 | +akos status && akos doctor | |
| 13 | +akos search "exchange rates" | |
| 14 | +akos inspect frankfurter.frankfurter-exchange-rates | |
| 15 | +akos discover --openapi-url https://…/openapi.json --name "My API" --license CC-BY-4.0 | |
| 16 | +akos execute -p worldbank.world-bank-indicators --op get-v2-country-ca-indicator-ny-gdp-mktp-cd -q format=json -q date=2015:2023 | |
| 17 | +akos research export --dataset <id> --output ./export/ # manifest.json, acquire.py, transform.py, lineage.json, METHODS.md … | |
| 18 | + | |
| 19 | +# Claude Code (adds only the `akos` MCP server; nothing else in your settings is touched) | |
| 20 | +AKOS_TOKEN=akos_… integrations/claude-code/install.sh --with-hooks | |
| 21 | +``` | |
| 22 | + | |
| 23 | +## Layout | |
| 24 | +`akos/` service code (api, gateway, registry, compiler, provenance, composition, vault, jobs, mcp_server, cli, sdk) · `web/` React UI · `tests/` unit/integration/security/e2e · `examples/` cross-source recipe + opaque MCP demos · `scripts/` backup/restore/bench · `integrations/claude-code/` installer + hook · `infra/akos.mld.json` deployment manifest · `docs/` architecture, threat model, operations, security review, acceptance. | |
| 25 | + | |
| 26 | +## Development | |
| 27 | +```bash | |
| 28 | +uv sync --frozen && cp .env.example .env # fill secrets; Postgres 17 with pg_trgm + pgcrypto | |
| 29 | +python -m akos bootstrap && python -m akos web # + `python -m akos worker`, `python -m akos proxy` | |
| 30 | +cd web && pnpm install --frozen-lockfile && pnpm build | |
| 31 | +make test # offline suite (mock API, akos_test DB) | |
| 32 | +make test-node # macOS: sandbox-exec enforcement + backup/restore drill | |
| 33 | +``` | |
added
akos/__init__.py
+2 −0
@@ -0,0 +1,2 @@ | ||
| 1 | +"""AKOS — API Knowledge Operating System.""" | |
| 2 | +__version__ = "0.1.0" | |
added
akos/__main__.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +"""python -m akos web|worker|proxy|migrate|bootstrap""" | |
| 2 | +from __future__ import annotations | |
| 3 | +import asyncio | |
| 4 | +import sys | |
| 5 | + | |
| 6 | + | |
| 7 | +def main() -> None: | |
| 8 | + cmd = sys.argv[1] if len(sys.argv) > 1 else "web" | |
| 9 | + if cmd == "web": | |
| 10 | + from .app import serve | |
| 11 | + serve() | |
| 12 | + elif cmd == "worker": | |
| 13 | + from .jobs.worker import main as w | |
| 14 | + w() | |
| 15 | + elif cmd == "proxy": | |
| 16 | + from .gateway.egress_proxy import main as p | |
| 17 | + p() | |
| 18 | + elif cmd == "migrate": | |
| 19 | + from .db import run_migrations_sync | |
| 20 | + print("applied:", run_migrations_sync()) | |
| 21 | + elif cmd == "bootstrap": | |
| 22 | + from .db import get_pool, close_pool, migrate | |
| 23 | + from .auth import ensure_bootstrap_admin | |
| 24 | + from .system import ensure_system_projects | |
| 25 | + | |
| 26 | + async def go(): | |
| 27 | + pool = await get_pool() | |
| 28 | + print("migrations:", await migrate(pool)) | |
| 29 | + async with pool.acquire() as c: | |
| 30 | + print("admin created:", await ensure_bootstrap_admin(c)) | |
| 31 | + await ensure_system_projects(c) | |
| 32 | + await close_pool() | |
| 33 | + asyncio.run(go()) | |
| 34 | + else: | |
| 35 | + print(__doc__); sys.exit(2) | |
| 36 | + | |
| 37 | + | |
| 38 | +if __name__ == "__main__": | |
| 39 | + main() | |
added
akos/api/__init__.py
+0 −0
added
akos/api/auth_routes.py
+122 −0
@@ -0,0 +1,122 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | +import asyncpg | |
| 3 | +from fastapi import APIRouter, Depends, Request, Response | |
| 4 | +from pydantic import BaseModel, EmailStr, Field | |
| 5 | +from .. import audit, auth | |
| 6 | +from ..settings import settings | |
| 7 | +from ..util import jsonable | |
| 8 | +from .deps import ApiError, db, principal, rate_limit, client_ip, require_role | |
| 9 | + | |
| 10 | +router = APIRouter(prefix="/auth", tags=["auth"]) | |
| 11 | + | |
| 12 | + | |
| 13 | +class LoginIn(BaseModel): | |
| 14 | + email: str | |
| 15 | + password: str = Field(min_length=1, max_length=256) | |
| 16 | + | |
| 17 | + | |
| 18 | +class TokenIn(BaseModel): | |
| 19 | + name: str = Field(min_length=1, max_length=80) | |
| 20 | + scopes: list[str] = ["read", "execute", "discover", "mcp"] | |
| 21 | + project_id: str | None = None | |
| 22 | + | |
| 23 | + | |
| 24 | +class PasswordIn(BaseModel): | |
| 25 | + current_password: str | |
| 26 | + new_password: str = Field(min_length=10, max_length=256) | |
| 27 | + | |
| 28 | + | |
| 29 | +class UserIn(BaseModel): | |
| 30 | + email: str | |
| 31 | + password: str = Field(min_length=10, max_length=256) | |
| 32 | + role: str = "operator" | |
| 33 | + | |
| 34 | + | |
| 35 | +@router.post("/login") | |
| 36 | +async def login(body: LoginIn, request: Request, response: Response, conn: asyncpg.Connection = Depends(db)): | |
| 37 | + rate_limit(f"login:{client_ip(request)}", limit=20) | |
| 38 | + user = await auth.login(conn, body.email, body.password) | |
| 39 | + if not user: | |
| 40 | + await audit.record(conn, actor_type="anonymous", actor_id=client_ip(request), action="auth.login_failed", details={"email": body.email}) | |
| 41 | + raise ApiError(401, "invalid_credentials", "invalid email or password") | |
| 42 | + response.set_cookie(auth.SESSION_COOKIE, auth.make_session_cookie(user["id"]), max_age=auth.SESSION_MAX_AGE, httponly=True, secure=settings.secure_cookies, samesite="lax", path="/") | |
| 43 | + await audit.record(conn, actor_type="user", actor_id=user["id"], action="auth.login", details={"ip": client_ip(request)}) | |
| 44 | + return {"user": user} | |
| 45 | + | |
| 46 | + | |
| 47 | +@router.post("/logout") | |
| 48 | +async def logout(response: Response, request: Request, conn: asyncpg.Connection = Depends(db)): | |
| 49 | + response.delete_cookie(auth.SESSION_COOKIE, path="/") | |
| 50 | + p = getattr(request.state, "principal", None) | |
| 51 | + if p: | |
| 52 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="auth.logout") | |
| 53 | + return {"ok": True} | |
| 54 | + | |
| 55 | + | |
| 56 | +@router.get("/me") | |
| 57 | +async def me(p: auth.Principal = Depends(principal)): | |
| 58 | + return {"id": p.id, "email": p.email, "role": p.role, "kind": p.kind, "scopes": sorted(p.scopes), "project_id": p.project_id} | |
| 59 | + | |
| 60 | + | |
| 61 | +@router.get("/tokens") | |
| 62 | +async def list_tokens(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 63 | + rows = await conn.fetch("SELECT t.id, t.name, t.prefix, t.scopes, t.project_id, pr.slug AS project_slug, t.created_at, t.last_used_at, t.revoked_at FROM api_tokens t LEFT JOIN projects pr ON pr.id = t.project_id WHERE t.user_id = $1::uuid ORDER BY t.created_at DESC", p.id) | |
| 64 | + return {"tokens": jsonable([dict(r) for r in rows])} | |
| 65 | + | |
| 66 | + | |
| 67 | +@router.post("/tokens") | |
| 68 | +async def create_token(body: TokenIn, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 69 | + if p.kind != "user": | |
| 70 | + raise ApiError(403, "forbidden", "tokens can only be created from a browser session") | |
| 71 | + if "admin" in body.scopes and p.role != "admin": | |
| 72 | + raise ApiError(403, "forbidden", "admin scope requires admin role") | |
| 73 | + project_id = None | |
| 74 | + if body.project_id: | |
| 75 | + pr = await conn.fetchrow("SELECT id FROM projects WHERE id::text = $1 OR slug = $1", body.project_id) | |
| 76 | + if not pr: | |
| 77 | + raise ApiError(404, "not_found", "project not found") | |
| 78 | + project_id = pr["id"] | |
| 79 | + try: | |
| 80 | + raw, row = await auth.create_token(conn, p.id, body.name, body.scopes, project_id) | |
| 81 | + except ValueError as e: | |
| 82 | + raise ApiError(422, "validation", str(e)) | |
| 83 | + await audit.record(conn, actor_type="user", actor_id=p.id, action="token.create", target_type="token", target_id=row["id"], details={"name": body.name, "scopes": body.scopes, "project_id": project_id}) | |
| 84 | + return {"token": raw, "record": jsonable(row), "note": "store this token now; it is not shown again"} | |
| 85 | + | |
| 86 | + | |
| 87 | +@router.delete("/tokens/{token_id}") | |
| 88 | +async def revoke_token(token_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 89 | + r = await conn.execute("UPDATE api_tokens SET revoked_at = now() WHERE id = $1::uuid AND user_id = $2::uuid AND revoked_at IS NULL", token_id, p.id) | |
| 90 | + if r.endswith("0"): | |
| 91 | + raise ApiError(404, "not_found", "token not found") | |
| 92 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="token.revoke", target_type="token", target_id=token_id) | |
| 93 | + return {"ok": True} | |
| 94 | + | |
| 95 | + | |
| 96 | +@router.post("/password") | |
| 97 | +async def change_password(body: PasswordIn, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 98 | + if p.kind != "user": | |
| 99 | + raise ApiError(403, "forbidden", "password change requires a browser session") | |
| 100 | + row = await conn.fetchrow("SELECT password_hash FROM users WHERE id = $1::uuid", p.id) | |
| 101 | + if not auth.verify_password(row["password_hash"], body.current_password): | |
| 102 | + raise ApiError(401, "invalid_credentials", "current password is wrong") | |
| 103 | + await conn.execute("UPDATE users SET password_hash = $2 WHERE id = $1::uuid", p.id, auth.hash_password(body.new_password)) | |
| 104 | + await audit.record(conn, actor_type="user", actor_id=p.id, action="user.password_change") | |
| 105 | + return {"ok": True} | |
| 106 | + | |
| 107 | + | |
| 108 | +@router.get("/users") | |
| 109 | +async def list_users(p: auth.Principal = Depends(require_role("admin")), conn: asyncpg.Connection = Depends(db)): | |
| 110 | + return {"users": jsonable([dict(r) for r in await conn.fetch("SELECT id, email, role, disabled, created_at, last_login_at FROM users ORDER BY created_at")])} | |
| 111 | + | |
| 112 | + | |
| 113 | +@router.post("/users") | |
| 114 | +async def create_user(body: UserIn, p: auth.Principal = Depends(require_role("admin")), conn: asyncpg.Connection = Depends(db)): | |
| 115 | + if body.role not in auth.ROLE_RANK: | |
| 116 | + raise ApiError(422, "validation", "role must be admin|operator|viewer") | |
| 117 | + try: | |
| 118 | + row = await conn.fetchrow("INSERT INTO users(email, password_hash, role) VALUES ($1,$2,$3) RETURNING id, email, role, created_at", body.email, auth.hash_password(body.password), body.role) | |
| 119 | + except asyncpg.UniqueViolationError: | |
| 120 | + raise ApiError(409, "conflict", "email already exists") | |
| 121 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="user.create", target_type="user", target_id=row["id"], details={"email": body.email, "role": body.role}) | |
| 122 | + return jsonable(dict(row)) | |
added
akos/api/deps.py
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +"""Request-scoped dependencies: DB connection, principal (cookie or bearer), RBAC/scope guards, rate limit.""" | |
| 2 | +from __future__ import annotations | |
| 3 | +import time | |
| 4 | +from collections import defaultdict, deque | |
| 5 | +from typing import AsyncIterator | |
| 6 | +import asyncpg | |
| 7 | +from fastapi import Depends, HTTPException, Request | |
| 8 | +from .. import auth | |
| 9 | +from ..db import get_pool | |
| 10 | +from ..settings import settings | |
| 11 | + | |
| 12 | + | |
| 13 | +class ApiError(HTTPException): | |
| 14 | + def __init__(self, status: int, code: str, message: str, **extra): | |
| 15 | + super().__init__(status_code=status, detail={"code": code, "message": message, **extra}) | |
| 16 | + | |
| 17 | + | |
| 18 | +async def db() -> AsyncIterator[asyncpg.Connection]: | |
| 19 | + pool = await get_pool() | |
| 20 | + async with pool.acquire() as conn: | |
| 21 | + yield conn | |
| 22 | + | |
| 23 | + | |
| 24 | +_buckets: dict[str, deque] = defaultdict(deque) | |
| 25 | + | |
| 26 | + | |
| 27 | +def rate_limit(key: str, limit: int | None = None, window: float = 60.0) -> None: | |
| 28 | + limit = limit or settings.rate_limit_per_minute | |
| 29 | + now = time.monotonic() | |
| 30 | + q = _buckets[key] | |
| 31 | + while q and q[0] < now - window: | |
| 32 | + q.popleft() | |
| 33 | + if len(q) >= limit: | |
| 34 | + raise ApiError(429, "rate_limited", f"rate limit of {limit} requests per {int(window)}s exceeded") | |
| 35 | + q.append(now) | |
| 36 | + | |
| 37 | + | |
| 38 | +def client_ip(request: Request) -> str: | |
| 39 | + peer = request.client.host if request.client else "?" | |
| 40 | + if peer in settings.trusted_proxies: | |
| 41 | + xff = request.headers.get("x-forwarded-for") | |
| 42 | + if xff: | |
| 43 | + return xff.split(",")[0].strip() | |
| 44 | + return peer | |
| 45 | + | |
| 46 | + | |
| 47 | +async def principal_optional(request: Request, conn: asyncpg.Connection = Depends(db)) -> auth.Principal | None: | |
| 48 | + authz = request.headers.get("authorization", "") | |
| 49 | + if authz.lower().startswith("bearer "): | |
| 50 | + p = await auth.principal_from_token(conn, authz[7:].strip()) | |
| 51 | + if p is None: | |
| 52 | + raise ApiError(401, "invalid_token", "invalid or revoked token") | |
| 53 | + request.state.principal = p | |
| 54 | + return p | |
| 55 | + p = await auth.principal_from_session(conn, request.cookies.get(auth.SESSION_COOKIE)) | |
| 56 | + request.state.principal = p | |
| 57 | + return p | |
| 58 | + | |
| 59 | + | |
| 60 | +async def principal(request: Request, p: auth.Principal | None = Depends(principal_optional)) -> auth.Principal: | |
| 61 | + if p is None: | |
| 62 | + raise ApiError(401, "unauthenticated", "login or bearer token required") | |
| 63 | + rate_limit(f"{p.kind}:{p.token_id or p.id}") | |
| 64 | + return p | |
| 65 | + | |
| 66 | + | |
| 67 | +def require_scope(scope: str): | |
| 68 | + async def dep(p: auth.Principal = Depends(principal)) -> auth.Principal: | |
| 69 | + if not p.has_scope(scope): | |
| 70 | + raise ApiError(403, "forbidden", f"scope {scope!r} required") | |
| 71 | + return p | |
| 72 | + return dep | |
| 73 | + | |
| 74 | + | |
| 75 | +def require_role(role: str): | |
| 76 | + async def dep(p: auth.Principal = Depends(principal)) -> auth.Principal: | |
| 77 | + if not p.has_role(role): | |
| 78 | + raise ApiError(403, "forbidden", f"role {role!r} required") | |
| 79 | + return p | |
| 80 | + return dep | |
| 81 | + | |
| 82 | + | |
| 83 | +def check_project(p: auth.Principal, project_id) -> None: | |
| 84 | + if not p.can_access_project(project_id): | |
| 85 | + raise ApiError(403, "forbidden", "token is bound to another project") | |
| 86 | + | |
| 87 | + | |
| 88 | +async def resolve_project(conn: asyncpg.Connection, ref: str) -> dict: | |
| 89 | + row = await conn.fetchrow("SELECT * FROM projects WHERE id::text = $1 OR slug = $1", str(ref)) | |
| 90 | + if not row: | |
| 91 | + raise ApiError(404, "not_found", "project not found") | |
| 92 | + return dict(row) | |
added
akos/api/exec_routes.py
+311 −0
@@ -0,0 +1,311 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | +import asyncpg | |
| 3 | +from fastapi import APIRouter, Depends, Response | |
| 4 | +from pydantic import BaseModel, Field | |
| 5 | +from .. import artifacts, audit, auth | |
| 6 | +from ..gateway.executor import ExecuteRequest, ExecutionError, execute, get_execution | |
| 7 | +from ..db import get_pool | |
| 8 | +from ..util import jsonable | |
| 9 | +from ..vault import keychain | |
| 10 | +from ..vault.redact import redact_obj | |
| 11 | +from .deps import ApiError, check_project, db, principal, require_role, require_scope, resolve_project | |
| 12 | + | |
| 13 | +router = APIRouter(tags=["execution"]) | |
| 14 | + | |
| 15 | + | |
| 16 | +class ExecuteIn(BaseModel): | |
| 17 | + project_id: str = "default" | |
| 18 | + purpose: str = "" | |
| 19 | + api_id: str | None = None | |
| 20 | + package_id: str | None = None | |
| 21 | + endpoint_id: str | None = None | |
| 22 | + operation_id: str | None = None | |
| 23 | + url: str | None = None | |
| 24 | + method: str = "GET" | |
| 25 | + path_params: dict = {} | |
| 26 | + query: dict = {} | |
| 27 | + headers: dict[str, str] = {} | |
| 28 | + body: object | None = None | |
| 29 | + credential_ref_id: str | None = None | |
| 30 | + agent_name: str | None = None | |
| 31 | + idempotency_key: str | None = Field(default=None, max_length=200) | |
| 32 | + caller: str = "api" | |
| 33 | + snapshot: bool | None = None | |
| 34 | + | |
| 35 | + | |
| 36 | +class CredentialIn(BaseModel): | |
| 37 | + name: str = Field(min_length=2, max_length=60, pattern=r"^[A-Za-z0-9_\-]+$") | |
| 38 | + provider_id: str | None = None | |
| 39 | + scheme: str = "bearer" | |
| 40 | + placement: str | None = None | |
| 41 | + value: str = Field(min_length=1, max_length=4000) | |
| 42 | + scopes: list[str] = [] | |
| 43 | + allowed_projects: list[str] = [] | |
| 44 | + environment: str = "development" | |
| 45 | + expires_at: str | None = None | |
| 46 | + | |
| 47 | + | |
| 48 | +class RotateIn(BaseModel): | |
| 49 | + value: str = Field(min_length=1, max_length=4000) | |
| 50 | + | |
| 51 | + | |
| 52 | +class ProjectIn(BaseModel): | |
| 53 | + slug: str = Field(min_length=2, max_length=60, pattern=r"^[a-z0-9\-]+$") | |
| 54 | + name: str | |
| 55 | + policy_mode: str = "development" | |
| 56 | + | |
| 57 | + | |
| 58 | +class ProjectPatch(BaseModel): | |
| 59 | + name: str | None = None | |
| 60 | + policy_mode: str | None = None | |
| 61 | + approved_akps: list[str] | None = None | |
| 62 | + budget: dict | None = None | |
| 63 | + | |
| 64 | + | |
| 65 | +class AgentIn(BaseModel): | |
| 66 | + name: str | |
| 67 | + kind: str = "claude-code" | |
| 68 | + project_id: str = "default" | |
| 69 | + budget: dict = {"max_executions_per_day": 500} | |
| 70 | + permissions: dict = {"execute": True, "discover": True, "export": False} | |
| 71 | + | |
| 72 | + | |
| 73 | +@router.post("/execute") | |
| 74 | +async def execute_route(body: ExecuteIn, p: auth.Principal = Depends(require_scope("execute")), conn: asyncpg.Connection = Depends(db)): | |
| 75 | + if body.method.upper() not in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"): | |
| 76 | + raise ApiError(422, "validation", "unsupported method") | |
| 77 | + pool = await get_pool() | |
| 78 | + req = ExecuteRequest(project_id=body.project_id, purpose=body.purpose[:500], api_id=body.api_id, package_id=body.package_id, endpoint_id=body.endpoint_id, operation_id=body.operation_id, | |
| 79 | + url=body.url, method=body.method.upper(), path_params=body.path_params, query=body.query, headers={k: v for k, v in body.headers.items() if k.lower() not in ("host", "authorization", "cookie")}, | |
| 80 | + body=body.body, credential_ref_id=body.credential_ref_id, agent_name=body.agent_name, idempotency_key=body.idempotency_key, caller=f"{body.caller}:{p.email}", snapshot=body.snapshot) | |
| 81 | + try: | |
| 82 | + return await execute(pool, p, req) | |
| 83 | + except ExecutionError as e: | |
| 84 | + raise ApiError(e.status, e.code, e.message, execution=e.execution) | |
| 85 | + | |
| 86 | + | |
| 87 | +@router.get("/executions") | |
| 88 | +async def executions(project_id: str | None = None, state: str | None = None, api_id: str | None = None, limit: int = 50, offset: int = 0, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 89 | + if p.project_id: | |
| 90 | + project_id = p.project_id | |
| 91 | + pid = None | |
| 92 | + if project_id: | |
| 93 | + pid = (await resolve_project(conn, project_id))["id"] | |
| 94 | + rows = await conn.fetch( | |
| 95 | + "SELECT e.id, e.state, e.method, e.url, e.status_code, e.duration_ms, e.created_at, e.finished_at, e.policy_mode, e.purpose, e.caller, e.observed, e.provisional, e.response_hash, e.response_bytes, e.error, e.correlation_id," | |
| 96 | + " p.slug AS project_slug, a.name AS api_name, a.id AS api_id, k.akp_id, k.version AS akp_version, ag.name AS agent_name, (SELECT decision FROM policy_decisions d WHERE d.execution_id = e.id ORDER BY created_at DESC LIMIT 1) AS decision" | |
| 97 | + " FROM executions e JOIN projects p ON p.id = e.project_id LEFT JOIN apis a ON a.id = e.api_id LEFT JOIN knowledge_packages k ON k.id = e.package_id LEFT JOIN agents ag ON ag.id = e.agent_id" | |
| 98 | + " WHERE ($1::uuid IS NULL OR e.project_id = $1) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR e.api_id::text = $3) ORDER BY e.created_at DESC LIMIT $4 OFFSET $5", pid, state, api_id, min(limit, 500), offset) | |
| 99 | + total = await conn.fetchval("SELECT count(*) FROM executions e WHERE ($1::uuid IS NULL OR e.project_id = $1) AND ($2::text IS NULL OR e.state = $2) AND ($3::text IS NULL OR e.api_id::text = $3)", pid, state, api_id) | |
| 100 | + return {"executions": redact_obj(jsonable([dict(r) for r in rows])), "total": total} | |
| 101 | + | |
| 102 | + | |
| 103 | +@router.get("/executions/{execution_id}") | |
| 104 | +async def execution_detail(execution_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 105 | + try: | |
| 106 | + d = await get_execution(conn, execution_id) | |
| 107 | + except (ExecutionError, asyncpg.DataError): | |
| 108 | + raise ApiError(404, "not_found", "execution not found") | |
| 109 | + check_project(p, d["project_id"]) | |
| 110 | + d["lineage"] = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM lineage_edges WHERE (from_type='execution' AND from_id=$1) OR (to_type='execution' AND to_id=$1)", execution_id)]) | |
| 111 | + d["audit"] = jsonable([dict(r) for r in await conn.fetch("SELECT seq, ts, actor_type, actor_id, action, details, hash FROM audit_events WHERE target_type='execution' AND target_id=$1 ORDER BY seq", execution_id)]) | |
| 112 | + d["egress"] = jsonable([dict(r) for r in await conn.fetch("SELECT ts, host, port, decision, reason FROM egress_log WHERE execution_id=$1::uuid ORDER BY ts", execution_id)]) | |
| 113 | + return d | |
| 114 | + | |
| 115 | + | |
| 116 | +@router.get("/executions/{execution_id}/body") | |
| 117 | +async def execution_body(execution_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 118 | + ex = await conn.fetchrow("SELECT project_id, response_artifact_id, response_media_type FROM executions WHERE id::text = $1", execution_id) | |
| 119 | + if not ex: | |
| 120 | + raise ApiError(404, "not_found", "execution not found") | |
| 121 | + check_project(p, ex["project_id"]) | |
| 122 | + art = await artifacts.get(conn, ex["response_artifact_id"]) if ex["response_artifact_id"] else None | |
| 123 | + data = artifacts.read(art) if art else None | |
| 124 | + if data is None: | |
| 125 | + raise ApiError(404, "no_snapshot", "raw response not retained for this execution (license metadata_only or failed call)") | |
| 126 | + return Response(content=data, media_type=ex["response_media_type"] or "application/octet-stream") | |
| 127 | + | |
| 128 | + | |
| 129 | +# ---------- credentials (references only) ---------- | |
| 130 | +@router.get("/credentials") | |
| 131 | +async def credentials(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 132 | + rows = await conn.fetch("SELECT c.id, c.name, c.ref, c.scheme, c.placement, c.scopes, c.allowed_projects, c.environment, c.version, c.fingerprint, c.expires_at, c.rotated_at, c.revoked_at, c.created_at, pr.name AS provider_name, pr.slug AS provider_slug, u.email AS owner," | |
| 133 | + " (SELECT count(*) FROM executions e WHERE e.credential_ref_id = c.id) AS uses, (SELECT max(created_at) FROM executions e WHERE e.credential_ref_id = c.id) AS last_used" | |
| 134 | + " FROM credential_refs c LEFT JOIN providers pr ON pr.id = c.provider_id LEFT JOIN users u ON u.id = c.owner_id ORDER BY c.name") | |
| 135 | + return {"credentials": jsonable([dict(r) for r in rows])} | |
| 136 | + | |
| 137 | + | |
| 138 | +@router.post("/credentials") | |
| 139 | +async def create_credential(body: CredentialIn, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 140 | + if body.scheme not in ("bearer", "header", "query", "basic"): | |
| 141 | + raise ApiError(422, "validation", "scheme must be bearer|header|query|basic") | |
| 142 | + if body.environment not in ("development", "production"): | |
| 143 | + raise ApiError(422, "validation", "environment must be development|production") | |
| 144 | + prov = None | |
| 145 | + if body.provider_id: | |
| 146 | + prov = await conn.fetchrow("SELECT id FROM providers WHERE id::text = $1 OR slug = $1", body.provider_id) | |
| 147 | + if not prov: | |
| 148 | + raise ApiError(404, "not_found", "provider not found") | |
| 149 | + projects = [] | |
| 150 | + for ref in body.allowed_projects: | |
| 151 | + projects.append((await resolve_project(conn, ref))["id"]) | |
| 152 | + if await conn.fetchval("SELECT 1 FROM credential_refs WHERE name = $1", body.name): | |
| 153 | + raise ApiError(409, "conflict", "credential name already exists") | |
| 154 | + try: | |
| 155 | + ref = keychain.put(body.name, body.value) | |
| 156 | + except keychain.VaultError as e: | |
| 157 | + raise ApiError(503, "vault_unavailable", str(e)) | |
| 158 | + row = await conn.fetchrow("INSERT INTO credential_refs(name, ref, provider_id, scheme, placement, scopes, allowed_projects, environment, owner_id, fingerprint, expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7::uuid[],$8,$9::uuid,$10,$11::timestamptz) RETURNING *", | |
| 159 | + body.name, ref, prov["id"] if prov else None, body.scheme, body.placement, body.scopes, projects, body.environment, p.id, keychain.fingerprint(body.value), body.expires_at) | |
| 160 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="credential.create", target_type="credential", target_id=row["id"], details={"name": body.name, "ref": ref, "scheme": body.scheme, "environment": body.environment, "allowed_projects": [str(x) for x in projects]}) | |
| 161 | + return jsonable(dict(row)) | |
| 162 | + | |
| 163 | + | |
| 164 | +@router.get("/credentials/{cred_id}") | |
| 165 | +async def credential_detail(cred_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 166 | + row = await conn.fetchrow("SELECT c.*, pr.name AS provider_name FROM credential_refs c LEFT JOIN providers pr ON pr.id = c.provider_id WHERE c.id::text = $1 OR c.name = $1", cred_id) | |
| 167 | + if not row: | |
| 168 | + raise ApiError(404, "not_found", "credential not found") | |
| 169 | + d = jsonable(dict(row)) | |
| 170 | + d["vault_present"] = keychain.exists(row["ref"]) if not row["revoked_at"] else False | |
| 171 | + d["packages"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, akp_id, version, lifecycle FROM knowledge_packages WHERE credential_ref_id = $1", row["id"])]) | |
| 172 | + return d | |
| 173 | + | |
| 174 | + | |
| 175 | +@router.get("/credentials/{cred_id}/audit") | |
| 176 | +async def credential_audit(cred_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 177 | + row = await conn.fetchrow("SELECT id FROM credential_refs WHERE id::text = $1 OR name = $1", cred_id) | |
| 178 | + if not row: | |
| 179 | + raise ApiError(404, "not_found", "credential not found") | |
| 180 | + rows = await conn.fetch("SELECT seq, ts, actor_type, actor_id, action, details, project_id FROM audit_events WHERE target_type='credential' AND target_id=$1 ORDER BY seq DESC LIMIT 200", str(row["id"])) | |
| 181 | + return {"events": jsonable([dict(r) for r in rows])} | |
| 182 | + | |
| 183 | + | |
| 184 | +@router.post("/credentials/{cred_id}/rotate") | |
| 185 | +async def rotate_credential(cred_id: str, body: RotateIn, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 186 | + row = await conn.fetchrow("SELECT * FROM credential_refs WHERE id::text = $1 OR name = $1", cred_id) | |
| 187 | + if not row: | |
| 188 | + raise ApiError(404, "not_found", "credential not found") | |
| 189 | + if row["revoked_at"]: | |
| 190 | + raise ApiError(409, "conflict", "credential is revoked") | |
| 191 | + try: | |
| 192 | + old = keychain.get(row["ref"]) | |
| 193 | + keychain.put(row["name"], body.value) | |
| 194 | + except keychain.VaultError as e: | |
| 195 | + raise ApiError(503, "vault_unavailable", str(e)) | |
| 196 | + from ..vault.redact import forget_secret | |
| 197 | + forget_secret(old) | |
| 198 | + out = await conn.fetchrow("UPDATE credential_refs SET version = version + 1, rotated_at = now(), fingerprint = $2 WHERE id = $1 RETURNING *", row["id"], keychain.fingerprint(body.value)) | |
| 199 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="credential.rotate", target_type="credential", target_id=row["id"], details={"version": out["version"]}) | |
| 200 | + return jsonable(dict(out)) | |
| 201 | + | |
| 202 | + | |
| 203 | +@router.post("/credentials/{cred_id}/revoke") | |
| 204 | +async def revoke_credential(cred_id: str, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 205 | + row = await conn.fetchrow("SELECT * FROM credential_refs WHERE id::text = $1 OR name = $1", cred_id) | |
| 206 | + if not row: | |
| 207 | + raise ApiError(404, "not_found", "credential not found") | |
| 208 | + try: | |
| 209 | + keychain.delete(row["ref"]) | |
| 210 | + except keychain.VaultError: | |
| 211 | + pass | |
| 212 | + out = await conn.fetchrow("UPDATE credential_refs SET revoked_at = now() WHERE id = $1 RETURNING *", row["id"]) | |
| 213 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="credential.revoke", target_type="credential", target_id=row["id"]) | |
| 214 | + return jsonable(dict(out)) | |
| 215 | + | |
| 216 | + | |
| 217 | +# ---------- projects / agents / policy ---------- | |
| 218 | +@router.get("/projects") | |
| 219 | +async def projects(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 220 | + rows = await conn.fetch("SELECT p.*, (SELECT count(*) FROM executions e WHERE e.project_id = p.id) AS executions, (SELECT count(*) FROM datasets d WHERE d.project_id = p.id) AS datasets," | |
| 221 | + " (SELECT count(*) FROM agents a WHERE a.project_id = p.id) AS agents, (SELECT count(*) FROM executions e WHERE e.project_id = p.id AND e.created_at > now() - interval '1 day') AS executions_24h FROM projects p ORDER BY slug") | |
| 222 | + out = [dict(r) for r in rows] | |
| 223 | + if p.project_id: | |
| 224 | + out = [r for r in out if str(r["id"]) == p.project_id] | |
| 225 | + return {"projects": jsonable(out)} | |
| 226 | + | |
| 227 | + | |
| 228 | +@router.post("/projects") | |
| 229 | +async def create_project(body: ProjectIn, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 230 | + if body.policy_mode not in ("discovery", "development", "research", "production"): | |
| 231 | + raise ApiError(422, "validation", "invalid policy mode") | |
| 232 | + try: | |
| 233 | + row = await conn.fetchrow("INSERT INTO projects(slug, name, policy_mode, owner_id) VALUES ($1,$2,$3,$4::uuid) RETURNING *", body.slug, body.name, body.policy_mode, p.id) | |
| 234 | + except asyncpg.UniqueViolationError: | |
| 235 | + raise ApiError(409, "conflict", "slug already exists") | |
| 236 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="project.create", target_type="project", target_id=row["id"], project_id=row["id"], details=body.model_dump()) | |
| 237 | + return jsonable(dict(row)) | |
| 238 | + | |
| 239 | + | |
| 240 | +@router.get("/projects/{project_id}") | |
| 241 | +async def project_detail(project_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 242 | + pr = await resolve_project(conn, project_id) | |
| 243 | + check_project(p, pr["id"]) | |
| 244 | + d = jsonable(pr) | |
| 245 | + d["agents"] = jsonable([dict(r) for r in await conn.fetch("SELECT a.*, (SELECT count(*) FROM executions e WHERE e.agent_id = a.id) AS executions FROM agents a WHERE a.project_id = $1 ORDER BY name", pr["id"])]) | |
| 246 | + d["datasets"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, name, row_count, verification, granularity, created_at FROM datasets WHERE project_id = $1 ORDER BY created_at DESC", pr["id"])]) | |
| 247 | + d["recent_executions"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, state, method, url, status_code, created_at FROM executions WHERE project_id = $1 ORDER BY created_at DESC LIMIT 20", pr["id"])]) | |
| 248 | + d["decisions"] = jsonable([dict(r) for r in await conn.fetch("SELECT decision, count(*) AS n FROM policy_decisions WHERE project_id = $1 GROUP BY decision", pr["id"])]) | |
| 249 | + d["debt"] = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM knowledge_debt WHERE $1 = ANY(project_ids) AND status='open' ORDER BY severity, created_at DESC", pr["id"])]) | |
| 250 | + d["approved_akp_details"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, akp_id, version, lifecycle FROM knowledge_packages WHERE id::text = ANY($1::text[])", [str(x) for x in (pr.get("approved_akps") or [])])]) | |
| 251 | + return d | |
| 252 | + | |
| 253 | + | |
| 254 | +@router.patch("/projects/{project_id}") | |
| 255 | +async def patch_project(project_id: str, body: ProjectPatch, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 256 | + pr = await resolve_project(conn, project_id) | |
| 257 | + check_project(p, pr["id"]) | |
| 258 | + if body.policy_mode and body.policy_mode not in ("discovery", "development", "research", "production"): | |
| 259 | + raise ApiError(422, "validation", "invalid policy mode") | |
| 260 | + if body.policy_mode == "production" and p.role != "admin": | |
| 261 | + raise ApiError(403, "forbidden", "production mode requires admin") | |
| 262 | + approved = pr["approved_akps"] | |
| 263 | + if body.approved_akps is not None: | |
| 264 | + approved = [] | |
| 265 | + for ref in body.approved_akps: | |
| 266 | + k = await conn.fetchrow("SELECT id, lifecycle FROM knowledge_packages WHERE id::text = $1", ref) | |
| 267 | + if not k: | |
| 268 | + raise ApiError(404, "not_found", f"package {ref} not found") | |
| 269 | + if k["lifecycle"] not in ("tested", "registered", "monitored"): | |
| 270 | + raise ApiError(409, "conflict", f"package {ref} is {k['lifecycle']}; only tested/registered/monitored packages can be approved") | |
| 271 | + approved.append(str(k["id"])) | |
| 272 | + row = await conn.fetchrow("UPDATE projects SET name = coalesce($2, name), policy_mode = coalesce($3, policy_mode), approved_akps = $4, budget = coalesce($5, budget) WHERE id = $1 RETURNING *", | |
| 273 | + pr["id"], body.name, body.policy_mode, approved, body.budget) | |
| 274 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="project.update", target_type="project", target_id=pr["id"], project_id=pr["id"], details=body.model_dump(exclude_none=True)) | |
| 275 | + return jsonable(dict(row)) | |
| 276 | + | |
| 277 | + | |
| 278 | +@router.get("/agents") | |
| 279 | +async def agents(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 280 | + rows = await conn.fetch("SELECT a.*, p.slug AS project_slug, (SELECT count(*) FROM executions e WHERE e.agent_id = a.id) AS executions, (SELECT count(*) FROM executions e WHERE e.agent_id = a.id AND e.created_at > now() - interval '1 day') AS executions_24h FROM agents a LEFT JOIN projects p ON p.id = a.project_id ORDER BY a.last_seen_at DESC NULLS LAST") | |
| 281 | + out = [dict(r) for r in rows] | |
| 282 | + if p.project_id: | |
| 283 | + out = [r for r in out if str(r["project_id"]) == p.project_id] | |
| 284 | + return {"agents": jsonable(out)} | |
| 285 | + | |
| 286 | + | |
| 287 | +@router.post("/agents") | |
| 288 | +async def create_agent(body: AgentIn, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 289 | + pr = await resolve_project(conn, body.project_id) | |
| 290 | + row = await conn.fetchrow("INSERT INTO agents(name, kind, project_id, budget, permissions) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (name, project_id) DO UPDATE SET kind = EXCLUDED.kind, budget = EXCLUDED.budget, permissions = EXCLUDED.permissions RETURNING *", | |
| 291 | + body.name, body.kind, pr["id"], body.budget, body.permissions) | |
| 292 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="agent.upsert", target_type="agent", target_id=row["id"], project_id=pr["id"], details=body.model_dump()) | |
| 293 | + return jsonable(dict(row)) | |
| 294 | + | |
| 295 | + | |
| 296 | +@router.get("/policy/decisions") | |
| 297 | +async def policy_decisions(project_id: str | None = None, decision: str | None = None, limit: int = 100, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 298 | + pid = (await resolve_project(conn, project_id))["id"] if project_id else (p.project_id or None) | |
| 299 | + rows = await conn.fetch("SELECT d.*, p.slug AS project_slug, e.url, e.method, e.state FROM policy_decisions d JOIN projects p ON p.id = d.project_id LEFT JOIN executions e ON e.id = d.execution_id WHERE ($1::uuid IS NULL OR d.project_id = $1) AND ($2::text IS NULL OR d.decision = $2) ORDER BY d.created_at DESC LIMIT $3", pid, decision, min(limit, 500)) | |
| 300 | + summary = await conn.fetch("SELECT mode, decision, count(*) AS n FROM policy_decisions GROUP BY mode, decision ORDER BY mode, decision") | |
| 301 | + return {"decisions": redact_obj(jsonable([dict(r) for r in rows])), "summary": jsonable([dict(r) for r in summary]), "modes": { | |
| 302 | + "discovery": "logged provisional calls; unregistered targets allowed and recorded as provisional + knowledge debt", | |
| 303 | + "development": "endpoint must belong to an API with lifecycle ≥ inspected", | |
| 304 | + "research": "AKP lifecycle ≥ tested, license recorded, lineage; fails closed if provenance cannot be persisted", | |
| 305 | + "production": "approved AKP version pinned on the project, scoped production credential, monitoring"}} | |
| 306 | + | |
| 307 | + | |
| 308 | +@router.get("/egress") | |
| 309 | +async def egress(limit: int = 100, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 310 | + rows = await conn.fetch("SELECT * FROM egress_log ORDER BY ts DESC LIMIT $1", min(limit, 1000)) | |
| 311 | + return {"egress": jsonable([dict(r) for r in rows])} | |
added
akos/api/ops_routes.py
+131 −0
@@ -0,0 +1,131 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | +import os | |
| 3 | +import time | |
| 4 | +import asyncpg | |
| 5 | +from fastapi import APIRouter, Depends | |
| 6 | +from .. import __version__, audit, auth | |
| 7 | +from ..audit import verify_chain | |
| 8 | +from ..db import get_pool | |
| 9 | +from ..jobs import queue | |
| 10 | +from ..settings import settings | |
| 11 | +from ..util import jsonable | |
| 12 | +from .deps import ApiError, db, principal, require_role | |
| 13 | + | |
| 14 | +router = APIRouter(tags=["ops"]) | |
| 15 | +_started = time.time() | |
| 16 | + | |
| 17 | + | |
| 18 | +@router.get("/overview") | |
| 19 | +async def overview(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 20 | + counts = {} | |
| 21 | + for key, sql in {"providers": "SELECT count(*) FROM providers", "apis": "SELECT count(*) FROM apis", "endpoints": "SELECT count(*) FROM endpoints", "packages": "SELECT count(*) FROM knowledge_packages", | |
| 22 | + "executions": "SELECT count(*) FROM executions", "executions_24h": "SELECT count(*) FROM executions WHERE created_at > now() - interval '1 day'", "datasets": "SELECT count(*) FROM datasets", | |
| 23 | + "verified_datasets": "SELECT count(*) FROM datasets WHERE verification='verified'", "open_debt": "SELECT count(*) FROM knowledge_debt WHERE status IN ('open','in_progress')", | |
| 24 | + "critical_debt": "SELECT count(*) FROM knowledge_debt WHERE status IN ('open','in_progress') AND severity='critical'", "credentials": "SELECT count(*) FROM credential_refs WHERE revoked_at IS NULL", | |
| 25 | + "projects": "SELECT count(*) FROM projects", "agents": "SELECT count(*) FROM agents", "drift_open": "SELECT count(*) FROM drift_alerts WHERE status='open'", "audit_events": "SELECT count(*) FROM audit_events"}.items(): | |
| 26 | + counts[key] = await conn.fetchval(sql) | |
| 27 | + lifecycle = jsonable([dict(r) for r in await conn.fetch("SELECT lifecycle, count(*) AS n FROM apis GROUP BY lifecycle ORDER BY n DESC")]) | |
| 28 | + knowledge = jsonable([dict(r) for r in await conn.fetch("SELECT knowledge_state, count(*) AS n FROM knowledge_packages GROUP BY knowledge_state ORDER BY n DESC")]) | |
| 29 | + exec_states = jsonable([dict(r) for r in await conn.fetch("SELECT state, count(*) AS n FROM executions WHERE created_at > now() - interval '7 days' GROUP BY state")]) | |
| 30 | + daily = jsonable([dict(r) for r in await conn.fetch("SELECT date_trunc('day', created_at)::date AS day, count(*) AS n, count(*) FILTER (WHERE state='succeeded') AS ok, count(*) FILTER (WHERE state IN ('blocked','failed')) AS bad FROM executions WHERE created_at > now() - interval '14 days' GROUP BY 1 ORDER BY 1")]) | |
| 31 | + recent = jsonable([dict(r) for r in await conn.fetch("SELECT e.id, e.state, e.method, e.url, e.status_code, e.duration_ms, e.created_at, a.name AS api_name, p.slug AS project_slug FROM executions e LEFT JOIN apis a ON a.id = e.api_id JOIN projects p ON p.id = e.project_id ORDER BY e.created_at DESC LIMIT 8")]) | |
| 32 | + debt = jsonable([dict(r) for r in await conn.fetch("SELECT severity, count(*) AS n FROM knowledge_debt WHERE status IN ('open','in_progress') GROUP BY severity")]) | |
| 33 | + jobs = await queue.stats(conn) | |
| 34 | + workers = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM worker_heartbeats WHERE last_seen > now() - interval '2 minutes'")]) | |
| 35 | + from ..vault.redact import redact_obj | |
| 36 | + return {"counts": counts, "lifecycle": lifecycle, "knowledge_states": knowledge, "execution_states_7d": exec_states, "daily": daily, "recent_executions": redact_obj(recent), "debt": debt, "jobs": jsonable(jobs), "workers": workers, "version": __version__} | |
| 37 | + | |
| 38 | + | |
| 39 | +@router.get("/ops/health") | |
| 40 | +async def ops_health(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 41 | + t0 = time.perf_counter() | |
| 42 | + await conn.fetchval("SELECT 1") | |
| 43 | + db_ms = round((time.perf_counter() - t0) * 1000, 2) | |
| 44 | + workers = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM worker_heartbeats ORDER BY last_seen DESC")]) | |
| 45 | + live_workers = [w for w in workers if (time.time() - time.mktime(time.strptime(w["last_seen"][:19], "%Y-%m-%dT%H:%M:%S"))) < 180] | |
| 46 | + jobs = jsonable(await queue.stats(conn)) | |
| 47 | + import socket | |
| 48 | + proxy_ok = False | |
| 49 | + try: | |
| 50 | + s = socket.create_connection(("127.0.0.1", settings.proxy_port), timeout=1); s.close(); proxy_ok = True | |
| 51 | + except OSError: | |
| 52 | + pass | |
| 53 | + disk = os.statvfs(str(settings.data_dir)) if settings.data_dir.exists() else None | |
| 54 | + size = await conn.fetchval("SELECT pg_database_size(current_database())") | |
| 55 | + migr = await conn.fetch("SELECT name, applied_at FROM schema_migrations ORDER BY name") | |
| 56 | + status = "ok" if (live_workers and proxy_ok) else "degraded" | |
| 57 | + return {"status": status, "version": __version__, "uptime_s": int(time.time() - _started), "db": {"ok": True, "latency_ms": db_ms, "size_bytes": size, "migrations": jsonable([dict(m) for m in migr])}, | |
| 58 | + "workers": workers, "live_workers": len(live_workers), "proxy": {"port": settings.proxy_port, "listening": proxy_ok}, "jobs": jobs, "sandbox": __import__("akos.gateway.sandbox", fromlist=["available"]).available(), | |
| 59 | + "disk": {"free_bytes": disk.f_bavail * disk.f_frsize, "total_bytes": disk.f_blocks * disk.f_frsize} if disk else None, "data_dir": str(settings.data_dir), "public_url": settings.public_url, "host": socket.gethostname()} | |
| 60 | + | |
| 61 | + | |
| 62 | +@router.get("/ops/metrics") | |
| 63 | +async def ops_metrics(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 64 | + lat = await conn.fetchrow("SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95, max(duration_ms) AS max, count(*) AS n FROM executions WHERE duration_ms IS NOT NULL AND created_at > now() - interval '1 day'") | |
| 65 | + jobs = await conn.fetchrow("SELECT count(*) FILTER (WHERE state='done') AS done, count(*) FILTER (WHERE state='dead') AS dead, avg(extract(epoch FROM (finished_at - started_at))) AS avg_s FROM jobs WHERE created_at > now() - interval '1 day'") | |
| 66 | + egress = await conn.fetch("SELECT decision, count(*) AS n FROM egress_log WHERE ts > now() - interval '1 day' GROUP BY decision") | |
| 67 | + by_api = await conn.fetch("SELECT a.name, count(*) AS n, avg(e.duration_ms) AS avg_ms, count(*) FILTER (WHERE e.state='succeeded') AS ok FROM executions e JOIN apis a ON a.id = e.api_id WHERE e.created_at > now() - interval '7 days' GROUP BY a.name ORDER BY n DESC LIMIT 20") | |
| 68 | + hourly = await conn.fetch("SELECT date_trunc('hour', created_at) AS hour, count(*) AS n FROM executions WHERE created_at > now() - interval '2 days' GROUP BY 1 ORDER BY 1") | |
| 69 | + return {"executions_24h": jsonable(dict(lat)), "jobs_24h": jsonable(dict(jobs)), "egress_24h": jsonable([dict(r) for r in egress]), "by_api_7d": jsonable([dict(r) for r in by_api]), "hourly": jsonable([dict(r) for r in hourly])} | |
| 70 | + | |
| 71 | + | |
| 72 | +@router.get("/ops/backups") | |
| 73 | +async def ops_backups(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 74 | + files = [] | |
| 75 | + if settings.backup_dir.exists(): | |
| 76 | + for f in sorted(settings.backup_dir.glob("akos-*"), reverse=True)[:30]: | |
| 77 | + st = f.stat() | |
| 78 | + files.append({"name": f.name, "bytes": st.st_size, "mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime))}) | |
| 79 | + runs = jsonable([dict(r) for r in await conn.fetch("SELECT ts, details FROM audit_events WHERE action='backup.run' ORDER BY seq DESC LIMIT 10")]) | |
| 80 | + return {"dir": str(settings.backup_dir), "remote": settings.backup_remote, "files": files, "runs": runs} | |
| 81 | + | |
| 82 | + | |
| 83 | +@router.post("/ops/backup") | |
| 84 | +async def ops_backup(p: auth.Principal = Depends(require_role("admin")), conn: asyncpg.Connection = Depends(db)): | |
| 85 | + job = await queue.enqueue(conn, "backup", {}, created_by=p.email, max_attempts=1) | |
| 86 | + return {"job_id": str(job["id"])} | |
| 87 | + | |
| 88 | + | |
| 89 | +@router.get("/audit") | |
| 90 | +async def audit_list(limit: int = 100, offset: int = 0, target_type: str | None = None, target_id: str | None = None, action: str | None = None, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 91 | + rows = await conn.fetch("SELECT seq, id, ts, actor_type, actor_id, action, target_type, target_id, project_id, correlation_id, details, prev_hash, hash FROM audit_events WHERE ($1::text IS NULL OR target_type = $1) AND ($2::text IS NULL OR target_id = $2) AND ($3::text IS NULL OR action ILIKE $3 || '%')" | |
| 92 | + " AND ($6::uuid IS NULL OR project_id = $6 OR project_id IS NULL) ORDER BY seq DESC LIMIT $4 OFFSET $5", target_type, target_id, action, min(limit, 500), offset, p.project_id) | |
| 93 | + total = await conn.fetchval("SELECT count(*) FROM audit_events") | |
| 94 | + return {"events": jsonable([dict(r) for r in rows]), "total": total} | |
| 95 | + | |
| 96 | + | |
| 97 | +@router.get("/audit/verify") | |
| 98 | +async def audit_verify(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 99 | + return await verify_chain(conn) | |
| 100 | + | |
| 101 | + | |
| 102 | +@router.get("/jobs") | |
| 103 | +async def jobs_list(state: str | None = None, limit: int = 100, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 104 | + rows = await conn.fetch("SELECT id, kind, state, attempts, max_attempts, run_after, locked_by, heartbeat_at, error, project_id, correlation_id, created_by, created_at, started_at, finished_at, result FROM jobs WHERE ($1::text IS NULL OR state = $1) ORDER BY created_at DESC LIMIT $2", state, min(limit, 500)) | |
| 105 | + from ..vault.redact import redact_obj | |
| 106 | + return {"jobs": redact_obj(jsonable([dict(r) for r in rows])), "stats": jsonable(await queue.stats(conn))} | |
| 107 | + | |
| 108 | + | |
| 109 | +@router.get("/jobs/{job_id}") | |
| 110 | +async def job_detail(job_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 111 | + row = await conn.fetchrow("SELECT * FROM jobs WHERE id::text = $1", job_id) | |
| 112 | + if not row: | |
| 113 | + raise ApiError(404, "not_found", "job not found") | |
| 114 | + from ..vault.redact import redact_obj | |
| 115 | + return redact_obj(jsonable(dict(row))) | |
| 116 | + | |
| 117 | + | |
| 118 | +@router.post("/jobs/{job_id}/retry") | |
| 119 | +async def job_retry(job_id: str, p: auth.Principal = Depends(require_role("operator")), conn: asyncpg.Connection = Depends(db)): | |
| 120 | + row = await queue.retry(conn, job_id) | |
| 121 | + if not row: | |
| 122 | + raise ApiError(409, "conflict", "only dead/failed jobs can be retried") | |
| 123 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="job.retry", target_type="job", target_id=job_id) | |
| 124 | + return jsonable(row) | |
| 125 | + | |
| 126 | + | |
| 127 | +@router.get("/settings") | |
| 128 | +async def get_settings(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 129 | + rows = await conn.fetch("SELECT key, value, updated_at FROM settings") | |
| 130 | + return {"settings": jsonable([dict(r) for r in rows]), "env": {"public_url": settings.public_url, "env": settings.env, "sandbox": settings.sandbox_enabled, "vault_backend": settings.vault_backend, "rate_limit_per_minute": settings.rate_limit_per_minute, | |
| 131 | + "execute_timeout_s": settings.execute_timeout_s, "max_response_bytes": settings.max_response_bytes, "backup_dir": str(settings.backup_dir), "backup_remote": settings.backup_remote, "version": __version__}} | |
added
akos/api/registry_routes.py
+276 −0
@@ -0,0 +1,276 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | +import io | |
| 3 | +import zipfile | |
| 4 | +import asyncpg | |
| 5 | +from fastapi import APIRouter, Depends, Query, Response | |
| 6 | +from pydantic import BaseModel, Field | |
| 7 | +from .. import audit, auth, artifacts | |
| 8 | +from ..compiler import akp as akp_mod | |
| 9 | +from ..jobs import queue | |
| 10 | +from ..registry import graph, lifecycle as lc, search as search_mod, store | |
| 11 | +from ..util import jsonable, slugify | |
| 12 | +from .deps import ApiError, db, principal, require_scope | |
| 13 | + | |
| 14 | +router = APIRouter(tags=["registry"]) | |
| 15 | + | |
| 16 | + | |
| 17 | +class ProviderIn(BaseModel): | |
| 18 | + slug: str | None = None | |
| 19 | + name: str | |
| 20 | + homepage: str | None = None | |
| 21 | + docs_url: str | None = None | |
| 22 | + license: dict = {} | |
| 23 | + terms_url: str | None = None | |
| 24 | + notes: str | None = None | |
| 25 | + | |
| 26 | + | |
| 27 | +class ApiIn(BaseModel): | |
| 28 | + provider_id: str | |
| 29 | + slug: str | None = None | |
| 30 | + name: str | |
| 31 | + kind: str = "rest" | |
| 32 | + base_url: str | None = None | |
| 33 | + docs_url: str | None = None | |
| 34 | + auth_scheme: dict = {"type": "none"} | |
| 35 | + opaque_internal_implementation: bool = False | |
| 36 | + description: str | None = None | |
| 37 | + tags: list[str] = [] | |
| 38 | + | |
| 39 | + | |
| 40 | +class LifecycleIn(BaseModel): | |
| 41 | + to: str | |
| 42 | + reason: str | None = None | |
| 43 | + | |
| 44 | + | |
| 45 | +class DiscoverIn(BaseModel): | |
| 46 | + project_id: str = "default" | |
| 47 | + openapi_url: str | None = None | |
| 48 | + probe_url: str | None = None | |
| 49 | + name: str | None = None | |
| 50 | + provider_slug: str | None = None | |
| 51 | + provider_name: str | None = None | |
| 52 | + license: str | None = None | |
| 53 | + license_url: str | None = None | |
| 54 | + docs_url: str | None = None | |
| 55 | + base_url: str | None = None | |
| 56 | + records_path: str | None = None | |
| 57 | + examples: dict[str, dict] = {} | |
| 58 | + tags: list[str] = [] | |
| 59 | + credential_ref_id: str | None = None | |
| 60 | + kind: str = "rest" | |
| 61 | + mcp_contract: dict | None = None | |
| 62 | + | |
| 63 | + | |
| 64 | +class CompileIn(BaseModel): | |
| 65 | + credential_ref_id: str | None = None | |
| 66 | + live: bool = True | |
| 67 | + | |
| 68 | + | |
| 69 | +@router.get("/search") | |
| 70 | +async def search(q: str = Query(min_length=1, max_length=200), limit: int = 20, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 71 | + return {"query": q, "results": jsonable(await search_mod.search(conn, q, min(limit, 100)))} | |
| 72 | + | |
| 73 | + | |
| 74 | +@router.get("/providers") | |
| 75 | +async def providers(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 76 | + rows = await conn.fetch("SELECT p.*, (SELECT count(*) FROM apis a WHERE a.provider_id = p.id) AS api_count FROM providers p ORDER BY name") | |
| 77 | + return {"providers": jsonable([dict(r) for r in rows])} | |
| 78 | + | |
| 79 | + | |
| 80 | +@router.post("/providers") | |
| 81 | +async def create_provider(body: ProviderIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 82 | + row = await store.upsert_provider(conn, slug=body.slug or slugify(body.name), name=body.name, homepage=body.homepage, docs_url=body.docs_url, license=body.license, terms_url=body.terms_url, notes=body.notes) | |
| 83 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="provider.upsert", target_type="provider", target_id=row["id"], details=body.model_dump()) | |
| 84 | + return jsonable(row) | |
| 85 | + | |
| 86 | + | |
| 87 | +@router.get("/providers/{provider_id}") | |
| 88 | +async def provider(provider_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 89 | + row = await conn.fetchrow("SELECT * FROM providers WHERE id::text = $1 OR slug = $1", provider_id) | |
| 90 | + if not row: | |
| 91 | + raise ApiError(404, "not_found", "provider not found") | |
| 92 | + apis = await conn.fetch("SELECT id, slug, name, kind, lifecycle, knowledge_state, base_url, opaque_internal_implementation, updated_at FROM apis WHERE provider_id = $1 ORDER BY name", row["id"]) | |
| 93 | + return {**jsonable(dict(row)), "apis": jsonable([dict(a) for a in apis])} | |
| 94 | + | |
| 95 | + | |
| 96 | +@router.get("/apis") | |
| 97 | +async def apis(q: str | None = None, kind: str | None = None, lifecycle: str | None = None, limit: int = 200, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 98 | + rows = await conn.fetch( | |
| 99 | + "SELECT a.id, a.slug, a.name, a.kind, a.base_url, a.lifecycle, a.knowledge_state, a.opaque_internal_implementation, a.tags, a.updated_at, a.created_at, p.slug AS provider_slug, p.name AS provider_name," | |
| 100 | + " (SELECT count(*) FROM api_versions v WHERE v.api_id = a.id) AS versions, (SELECT count(*) FROM endpoints e JOIN api_versions v ON v.id = e.api_version_id WHERE v.api_id = a.id AND v.id = a.current_version_id) AS endpoints," | |
| 101 | + " (SELECT count(*) FROM knowledge_packages k WHERE k.api_id = a.id) AS packages, (SELECT count(*) FROM knowledge_debt d WHERE d.subject_type='api' AND d.subject_id = a.id::text AND d.status='open') AS open_debt," | |
| 102 | + " (SELECT count(*) FROM executions x WHERE x.api_id = a.id) AS executions" | |
| 103 | + " FROM apis a JOIN providers p ON p.id = a.provider_id WHERE ($1::text IS NULL OR a.name ILIKE '%' || $1 || '%' OR a.slug ILIKE '%' || $1 || '%' OR p.name ILIKE '%' || $1 || '%')" | |
| 104 | + " AND ($2::text IS NULL OR a.kind = $2) AND ($3::text IS NULL OR a.lifecycle = $3) ORDER BY a.updated_at DESC LIMIT $4", q, kind, lifecycle, min(limit, 500)) | |
| 105 | + return {"apis": jsonable([dict(r) for r in rows])} | |
| 106 | + | |
| 107 | + | |
| 108 | +@router.post("/apis") | |
| 109 | +async def create_api(body: ApiIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 110 | + prov = await conn.fetchrow("SELECT id FROM providers WHERE id::text = $1 OR slug = $1", body.provider_id) | |
| 111 | + if not prov: | |
| 112 | + raise ApiError(404, "not_found", "provider not found") | |
| 113 | + if body.kind not in ("rest", "graphql", "sdk", "mcp"): | |
| 114 | + raise ApiError(422, "validation", "kind must be rest|graphql|sdk|mcp") | |
| 115 | + row = await store.upsert_api(conn, provider_id=prov["id"], slug=body.slug or slugify(body.name), name=body.name, kind=body.kind, base_url=body.base_url, docs_url=body.docs_url, | |
| 116 | + auth_scheme=body.auth_scheme, opaque=body.opaque_internal_implementation or body.kind == "mcp", description=body.description, tags=body.tags) | |
| 117 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="api.upsert", target_type="api", target_id=row["id"], details=body.model_dump()) | |
| 118 | + return jsonable(row) | |
| 119 | + | |
| 120 | + | |
| 121 | +@router.get("/apis/{api_id}") | |
| 122 | +async def api_detail(api_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 123 | + row = await conn.fetchrow("SELECT a.*, p.slug AS provider_slug, p.name AS provider_name, p.license AS provider_license, p.docs_url AS provider_docs FROM apis a JOIN providers p ON p.id = a.provider_id WHERE a.id::text = $1 OR a.slug = $1", api_id) | |
| 124 | + if not row: | |
| 125 | + raise ApiError(404, "not_found", "api not found") | |
| 126 | + d = jsonable(dict(row)) | |
| 127 | + d.pop("search", None) | |
| 128 | + d["versions"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, version, lifecycle, contract_hash, source_kind, source_url, created_at, created_by, (SELECT count(*) FROM endpoints e WHERE e.api_version_id = v.id) AS endpoints FROM api_versions v WHERE api_id = $1 ORDER BY created_at DESC", row["id"])]) | |
| 129 | + if row["current_version_id"]: | |
| 130 | + d["endpoints"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, operation_id, method, path, summary, inferred, pagination, parameters FROM endpoints WHERE api_version_id = $1 ORDER BY path, method", row["current_version_id"])]) | |
| 131 | + d["variables"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, name, json_path, data_type, unit, description, semantic_concept, endpoint_id FROM variables WHERE api_version_id = $1 ORDER BY name LIMIT 300", row["current_version_id"])]) | |
| 132 | + d["packages"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, akp_id, version, lifecycle, knowledge_state, hash, created_at, superseded_by, credential_ref_id FROM knowledge_packages WHERE api_id = $1 ORDER BY version DESC", row["id"])]) | |
| 133 | + d["evidence"] = jsonable(await store.evidence_for(conn, "api", row["id"])) | |
| 134 | + d["debt"] = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM knowledge_debt WHERE (subject_type='api' AND subject_id=$1) OR (subject_type='akp' AND subject_id IN (SELECT id::text FROM knowledge_packages WHERE api_id=$2)) OR (subject_type='version' AND subject_id IN (SELECT id::text FROM api_versions WHERE api_id=$2)) ORDER BY created_at DESC", str(row["id"]), row["id"])]) | |
| 135 | + d["executions"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, state, method, url, status_code, duration_ms, created_at, policy_mode, project_id FROM executions WHERE api_id = $1 ORDER BY created_at DESC LIMIT 20", row["id"])]) | |
| 136 | + d["drift"] = jsonable([dict(r) for r in await conn.fetch("SELECT d.* FROM drift_alerts d JOIN api_versions v ON v.id = d.api_version_id WHERE v.api_id = $1 ORDER BY detected_at DESC LIMIT 20", row["id"])]) | |
| 137 | + d["allowed_transitions"] = sorted(lc.TRANSITIONS.get(row["lifecycle"], set())) | |
| 138 | + return d | |
| 139 | + | |
| 140 | + | |
| 141 | +@router.post("/apis/{api_id}/lifecycle") | |
| 142 | +async def api_lifecycle(api_id: str, body: LifecycleIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 143 | + row = await conn.fetchrow("SELECT id FROM apis WHERE id::text = $1 OR slug = $1", api_id) | |
| 144 | + if not row: | |
| 145 | + raise ApiError(404, "not_found", "api not found") | |
| 146 | + try: | |
| 147 | + out = await store.transition(conn, "apis", row["id"], body.to, actor=(p.kind, p.id), reason=body.reason) | |
| 148 | + except lc.TransitionError as e: | |
| 149 | + raise ApiError(409, "transition_refused", str(e)) | |
| 150 | + out.pop("search", None) | |
| 151 | + return jsonable(out) | |
| 152 | + | |
| 153 | + | |
| 154 | +@router.get("/versions/{version_id}") | |
| 155 | +async def version_detail(version_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 156 | + row = await conn.fetchrow("SELECT v.*, a.name AS api_name, a.slug AS api_slug FROM api_versions v JOIN apis a ON a.id = v.api_id WHERE v.id::text = $1", version_id) | |
| 157 | + if not row: | |
| 158 | + raise ApiError(404, "not_found", "version not found") | |
| 159 | + d = jsonable(dict(row)) | |
| 160 | + d["endpoints"] = jsonable([dict(r) for r in await conn.fetch("SELECT * FROM endpoints WHERE api_version_id = $1 ORDER BY path, method", row["id"])]) | |
| 161 | + for e in d["endpoints"]: | |
| 162 | + e.pop("search", None) | |
| 163 | + d["variables"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, name, json_path, data_type, unit, description, semantic_concept, endpoint_id FROM variables WHERE api_version_id = $1 ORDER BY name LIMIT 500", row["id"])]) | |
| 164 | + d["evidence"] = jsonable(await store.evidence_for(conn, "version", row["id"])) | |
| 165 | + d["packages"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, akp_id, version, lifecycle, knowledge_state, created_at FROM knowledge_packages WHERE api_version_id = $1 ORDER BY version DESC", row["id"])]) | |
| 166 | + return d | |
| 167 | + | |
| 168 | + | |
| 169 | +@router.get("/endpoints/{endpoint_id}") | |
| 170 | +async def endpoint_detail(endpoint_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 171 | + row = await conn.fetchrow("SELECT e.*, v.version, v.api_id, a.name AS api_name, a.base_url FROM endpoints e JOIN api_versions v ON v.id = e.api_version_id JOIN apis a ON a.id = v.api_id WHERE e.id::text = $1", endpoint_id) | |
| 172 | + if not row: | |
| 173 | + raise ApiError(404, "not_found", "endpoint not found") | |
| 174 | + d = jsonable(dict(row)); d.pop("search", None) | |
| 175 | + d["variables"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, name, json_path, data_type, unit, description FROM variables WHERE endpoint_id = $1 ORDER BY name LIMIT 300", row["id"])]) | |
| 176 | + d["executions"] = jsonable([dict(r) for r in await conn.fetch("SELECT id, state, status_code, duration_ms, created_at, response_hash FROM executions WHERE endpoint_id = $1 ORDER BY created_at DESC LIMIT 20", row["id"])]) | |
| 177 | + return d | |
| 178 | + | |
| 179 | + | |
| 180 | +@router.get("/akps") | |
| 181 | +async def akps(lifecycle: str | None = None, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 182 | + rows = await conn.fetch("SELECT k.id, k.akp_id, k.version, k.lifecycle, k.knowledge_state, k.hash, k.created_at, k.superseded_by, k.license, a.name AS api_name, a.id AS api_id, a.opaque_internal_implementation AS opaque, pr.slug AS provider_slug," | |
| 183 | + " (SELECT count(*) FROM knowledge_debt d WHERE d.subject_type='akp' AND d.subject_id=k.id::text AND d.status='open') AS open_debt," | |
| 184 | + " (SELECT count(*) FROM verification_evidence e WHERE e.subject_type='akp' AND e.subject_id=k.id AND e.result='pass') AS evidence_pass," | |
| 185 | + " (SELECT count(*) FROM verification_evidence e WHERE e.subject_type='akp' AND e.subject_id=k.id AND e.result='fail') AS evidence_fail" | |
| 186 | + " FROM knowledge_packages k JOIN apis a ON a.id = k.api_id JOIN providers pr ON pr.id = a.provider_id WHERE ($1::text IS NULL OR k.lifecycle = $1) ORDER BY k.akp_id, k.version DESC", lifecycle) | |
| 187 | + return {"packages": jsonable([dict(r) for r in rows])} | |
| 188 | + | |
| 189 | + | |
| 190 | +@router.get("/akps/{package_id}") | |
| 191 | +async def akp_detail(package_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 192 | + d = await akp_mod.get_package(conn, package_id) | |
| 193 | + if not d: | |
| 194 | + raise ApiError(404, "not_found", "package not found") | |
| 195 | + d["allowed_transitions"] = sorted(lc.TRANSITIONS.get(d["lifecycle"], set())) | |
| 196 | + return d | |
| 197 | + | |
| 198 | + | |
| 199 | +@router.get("/akps/{package_id}/files/{filename:path}") | |
| 200 | +async def akp_file(package_id: str, filename: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 201 | + pkg = await conn.fetchrow("SELECT id FROM knowledge_packages WHERE id::text = $1 OR (akp_id = $1 AND superseded_by IS NULL)", package_id) | |
| 202 | + if not pkg: | |
| 203 | + raise ApiError(404, "not_found", "package not found") | |
| 204 | + if ".." in filename or filename.startswith("/"): | |
| 205 | + raise ApiError(400, "bad_request", "invalid file name") | |
| 206 | + f = await akp_mod.read_file(conn, pkg["id"], filename) | |
| 207 | + if not f: | |
| 208 | + raise ApiError(404, "not_found", "file not found") | |
| 209 | + return Response(content=f[0], media_type=f[1] if f[1] != "text/x-python" else "text/plain; charset=utf-8") | |
| 210 | + | |
| 211 | + | |
| 212 | +@router.get("/akps/{package_id}/bundle.zip") | |
| 213 | +async def akp_bundle(package_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 214 | + pkg = await conn.fetchrow("SELECT id, akp_id, version FROM knowledge_packages WHERE id::text = $1 OR (akp_id = $1 AND superseded_by IS NULL)", package_id) | |
| 215 | + if not pkg: | |
| 216 | + raise ApiError(404, "not_found", "package not found") | |
| 217 | + files = await akp_mod.all_files(conn, pkg["id"]) | |
| 218 | + buf = io.BytesIO() | |
| 219 | + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: | |
| 220 | + for name in sorted(files): | |
| 221 | + z.writestr(f"{pkg['akp_id']}-v{pkg['version']}/{name}", files[name]) | |
| 222 | + return Response(content=buf.getvalue(), media_type="application/zip", headers={"Content-Disposition": f"attachment; filename={pkg['akp_id']}-v{pkg['version']}.zip"}) | |
| 223 | + | |
| 224 | + | |
| 225 | +@router.post("/akps/{package_id}/verify") | |
| 226 | +async def akp_verify(package_id: str, body: CompileIn | None = None, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 227 | + pkg = await conn.fetchrow("SELECT id FROM knowledge_packages WHERE id::text = $1 OR (akp_id = $1 AND superseded_by IS NULL)", package_id) | |
| 228 | + if not pkg: | |
| 229 | + raise ApiError(404, "not_found", "package not found") | |
| 230 | + job = await queue.enqueue(conn, "verify", {"package_id": str(pkg["id"]), "live": body.live if body else True, "created_by": p.email}, created_by=p.email) | |
| 231 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="akp.verify_requested", target_type="akp", target_id=pkg["id"], details={"job_id": job["id"]}) | |
| 232 | + return {"job_id": str(job["id"])} | |
| 233 | + | |
| 234 | + | |
| 235 | +@router.post("/akps/{package_id}/compile") | |
| 236 | +async def akp_recompile(package_id: str, body: CompileIn | None = None, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 237 | + pkg = await conn.fetchrow("SELECT id, api_version_id FROM knowledge_packages WHERE id::text = $1 OR (akp_id = $1 AND superseded_by IS NULL)", package_id) | |
| 238 | + if not pkg: | |
| 239 | + raise ApiError(404, "not_found", "package not found") | |
| 240 | + job = await queue.enqueue(conn, "compile", {"api_version_id": str(pkg["api_version_id"]), "credential_ref_id": (body.credential_ref_id if body else None), "live": body.live if body else True, "created_by": p.email}, created_by=p.email) | |
| 241 | + return {"job_id": str(job["id"])} | |
| 242 | + | |
| 243 | + | |
| 244 | +@router.post("/akps/{package_id}/lifecycle") | |
| 245 | +async def akp_lifecycle(package_id: str, body: LifecycleIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 246 | + pkg = await conn.fetchrow("SELECT id FROM knowledge_packages WHERE id::text = $1", package_id) | |
| 247 | + if not pkg: | |
| 248 | + raise ApiError(404, "not_found", "package not found") | |
| 249 | + try: | |
| 250 | + out = await store.transition(conn, "knowledge_packages", pkg["id"], body.to, actor=(p.kind, p.id), reason=body.reason) | |
| 251 | + except lc.TransitionError as e: | |
| 252 | + raise ApiError(409, "transition_refused", str(e)) | |
| 253 | + return jsonable({k: v for k, v in out.items() if k not in ("manifest", "contract_yaml", "readme")}) | |
| 254 | + | |
| 255 | + | |
| 256 | +@router.post("/discover") | |
| 257 | +async def discover(body: DiscoverIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 258 | + pr = await conn.fetchrow("SELECT id FROM projects WHERE id::text = $1 OR slug = $1", body.project_id) | |
| 259 | + if not pr: | |
| 260 | + raise ApiError(404, "not_found", "project not found") | |
| 261 | + if not p.can_access_project(pr["id"]): | |
| 262 | + raise ApiError(403, "forbidden", "token is bound to another project") | |
| 263 | + if body.kind == "mcp" and not body.mcp_contract: | |
| 264 | + raise ApiError(422, "validation", "mcp_contract is required for kind=mcp") | |
| 265 | + if body.kind != "mcp" and not (body.openapi_url or body.probe_url): | |
| 266 | + raise ApiError(422, "validation", "openapi_url or probe_url is required") | |
| 267 | + payload = {**body.model_dump(), "project_id": str(pr["id"]), "created_by": p.email} | |
| 268 | + job = await queue.enqueue(conn, "discover", payload, project_id=pr["id"], created_by=p.email) | |
| 269 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="discover.requested", target_type="job", target_id=job["id"], project_id=pr["id"], details={"openapi_url": body.openapi_url, "probe_url": body.probe_url, "kind": body.kind}) | |
| 270 | + return {"job_id": str(job["id"]), "state": job["state"]} | |
| 271 | + | |
| 272 | + | |
| 273 | +@router.get("/graph") | |
| 274 | +async def graph_view(focus: str | None = None, depth: int = 2, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 275 | + ft, fi = (focus.split(":", 1) if focus and ":" in focus else (None, None)) | |
| 276 | + return await graph.neighborhood(conn, ft, fi, depth=min(depth, 4)) | |
added
akos/api/research_routes.py
+208 −0
@@ -0,0 +1,208 @@ | ||
| 1 | +from __future__ import annotations | |
| 2 | +import asyncpg | |
| 3 | +from fastapi import APIRouter, Depends, Response | |
| 4 | +from pydantic import BaseModel | |
| 5 | +from .. import artifacts, audit, auth | |
| 6 | +from ..composition import joins, recipes | |
| 7 | +from ..jobs import queue | |
| 8 | +from ..provenance import datasets as D, lineage as L | |
| 9 | +from ..provenance.quality import quality_report | |
| 10 | +from ..util import jsonable | |
| 11 | +from .deps import ApiError, check_project, db, principal, require_scope, resolve_project | |
| 12 | + | |
| 13 | +router = APIRouter(tags=["research"]) | |
| 14 | + | |
| 15 | + | |
| 16 | +class DatasetIn(BaseModel): | |
| 17 | + project_id: str = "default" | |
| 18 | + name: str | |
| 19 | + execution_ids: list[str] | |
| 20 | + records_path: str | None = None | |
| 21 | + identifiers: list[str] = [] | |
| 22 | + units: dict = {} | |
| 23 | + granularity: str | None = None | |
| 24 | + normalize: dict | None = None | |
| 25 | + | |
| 26 | + | |
| 27 | +class ExportIn(BaseModel): | |
| 28 | + verified: bool = True | |
| 29 | + | |
| 30 | + | |
| 31 | +class ComposeIn(BaseModel): | |
| 32 | + project_id: str = "default" | |
| 33 | + name: str | |
| 34 | + left_dataset_id: str | |
| 35 | + right_dataset_id: str | |
| 36 | + on: list[dict] | |
| 37 | + how: str = "inner" | |
| 38 | + derive: list[dict] = [] | |
| 39 | + aggregate: dict | None = None | |
| 40 | + allow_many_to_many: bool = False | |
| 41 | + output_name: str | None = None | |
| 42 | + | |
| 43 | + | |
| 44 | +class DebtPatch(BaseModel): | |
| 45 | + status: str | None = None | |
| 46 | + owner_id: str | None = None | |
| 47 | + remediation: str | None = None | |
| 48 | + | |
| 49 | + | |
| 50 | +class DriftIn(BaseModel): | |
| 51 | + api_version_id: str | |
| 52 | + | |
| 53 | + | |
| 54 | +@router.get("/datasets") | |
| 55 | +async def datasets(project_id: str | None = None, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 56 | + pid = (await resolve_project(conn, project_id))["id"] if project_id else (p.project_id or None) | |
| 57 | + rows = await conn.fetch("SELECT d.id, d.name, d.row_count, d.verification, d.granularity, d.units, d.identifiers, d.hash, d.created_at, d.created_by, d.license, p.slug AS project_slug, cardinality(d.execution_ids) AS executions," | |
| 58 | + " (SELECT count(*) FROM transformations t WHERE t.output_dataset_id = d.id) AS transformations FROM datasets d JOIN projects p ON p.id = d.project_id WHERE ($1::uuid IS NULL OR d.project_id = $1) ORDER BY d.created_at DESC", pid) | |
| 59 | + return {"datasets": jsonable([dict(r) for r in rows])} | |
| 60 | + | |
| 61 | + | |
| 62 | +@router.post("/datasets") | |
| 63 | +async def create_dataset(body: DatasetIn, p: auth.Principal = Depends(require_scope("execute")), conn: asyncpg.Connection = Depends(db)): | |
| 64 | + pr = await resolve_project(conn, body.project_id) | |
| 65 | + check_project(p, pr["id"]) | |
| 66 | + try: | |
| 67 | + return await D.create_dataset(conn, project_id=pr["id"], name=body.name, execution_ids=body.execution_ids, records_path=body.records_path, identifiers=body.identifiers, units=body.units, granularity=body.granularity, created_by=p.email, normalize=body.normalize) | |
| 68 | + except D.DatasetError as e: | |
| 69 | + raise ApiError(422, "dataset_error", str(e)) | |
| 70 | + | |
| 71 | + | |
| 72 | +@router.get("/datasets/{dataset_id}") | |
| 73 | +async def dataset_detail(dataset_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 74 | + d = await D.get_dataset(conn, dataset_id) | |
| 75 | + if not d: | |
| 76 | + raise ApiError(404, "not_found", "dataset not found") | |
| 77 | + check_project(p, d["project_id"]) | |
| 78 | + d["exports"] = jsonable([dict(r) for r in await conn.fetch("SELECT l.to_id AS artifact_id, l.details, l.created_at, a.sha256, a.size_bytes FROM lineage_edges l JOIN artifacts a ON a.id::text = l.to_id WHERE l.from_type='dataset' AND l.from_id=$1 AND l.to_type='export' ORDER BY l.created_at DESC", dataset_id)]) | |
| 79 | + d["preview"] = (await D.load_rows(conn, dataset_id))[:25] | |
| 80 | + return d | |
| 81 | + | |
| 82 | + | |
| 83 | +@router.get("/datasets/{dataset_id}/lineage") | |
| 84 | +async def dataset_lineage(dataset_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 85 | + d = await conn.fetchrow("SELECT project_id FROM datasets WHERE id::text = $1", dataset_id) | |
| 86 | + if not d: | |
| 87 | + raise ApiError(404, "not_found", "dataset not found") | |
| 88 | + check_project(p, d["project_id"]) | |
| 89 | + return await L.dag_for_dataset(conn, dataset_id) | |
| 90 | + | |
| 91 | + | |
| 92 | +@router.get("/datasets/{dataset_id}/quality") | |
| 93 | +async def dataset_quality(dataset_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 94 | + d = await conn.fetchrow("SELECT * FROM datasets WHERE id::text = $1", dataset_id) | |
| 95 | + if not d: | |
| 96 | + raise ApiError(404, "not_found", "dataset not found") | |
| 97 | + check_project(p, d["project_id"]) | |
| 98 | + rows = await D.load_rows(conn, dataset_id) | |
| 99 | + return quality_report(rows, (d["identifiers"] or {}).get("keys"), d["units"], d["granularity"]) | |
| 100 | + | |
| 101 | + | |
| 102 | +@router.post("/datasets/{dataset_id}/export") | |
| 103 | +async def dataset_export(dataset_id: str, body: ExportIn, p: auth.Principal = Depends(require_scope("export")), conn: asyncpg.Connection = Depends(db)): | |
| 104 | + d = await conn.fetchrow("SELECT project_id FROM datasets WHERE id::text = $1", dataset_id) | |
| 105 | + if not d: | |
| 106 | + raise ApiError(404, "not_found", "dataset not found") | |
| 107 | + check_project(p, d["project_id"]) | |
| 108 | + job = await queue.enqueue(conn, "export", {"dataset_id": dataset_id, "verified": body.verified, "created_by": p.email}, project_id=d["project_id"], created_by=p.email) | |
| 109 | + return {"job_id": str(job["id"])} | |
| 110 | + | |
| 111 | + | |
| 112 | +@router.get("/artifacts/{artifact_id}") | |
| 113 | +async def artifact_download(artifact_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 114 | + a = await conn.fetchrow("SELECT * FROM artifacts WHERE id::text = $1 OR sha256 = $1", artifact_id) | |
| 115 | + if not a: | |
| 116 | + raise ApiError(404, "not_found", "artifact not found") | |
| 117 | + # authorization: artifact must be an export of a dataset the principal can access, or an AKP/generated file | |
| 118 | + link = await conn.fetchrow("SELECT from_id FROM lineage_edges WHERE to_type='export' AND to_id=$1", str(a["id"])) | |
| 119 | + if link: | |
| 120 | + ds = await conn.fetchrow("SELECT project_id FROM datasets WHERE id::text = $1", link["from_id"]) | |
| 121 | + if ds: | |
| 122 | + check_project(p, ds["project_id"]) | |
| 123 | + elif (a["license"] or {}).get("akos") not in ("generated", "export"): | |
| 124 | + ex = await conn.fetchrow("SELECT project_id FROM executions WHERE response_artifact_id = $1", a["id"]) | |
| 125 | + if ex: | |
| 126 | + check_project(p, ex["project_id"]) | |
| 127 | + data = artifacts.read(a) | |
| 128 | + if data is None: | |
| 129 | + raise ApiError(404, "no_content", "artifact content not retained") | |
| 130 | + return Response(content=data, media_type=a["media_type"], headers={"Content-Disposition": f"attachment; filename=akos-{a['sha256'][:12]}.{'zip' if a['media_type']=='application/zip' else 'bin'}"}) | |
| 131 | + | |
| 132 | + | |
| 133 | +@router.get("/recipes") | |
| 134 | +async def list_recipes(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 135 | + rows = await conn.fetch("SELECT r.id, r.name, r.version, r.status, r.validation, r.output_dataset_id, r.created_at, r.spec, p.slug AS project_slug, r.project_id FROM recipes r JOIN projects p ON p.id = r.project_id ORDER BY r.created_at DESC") | |
| 136 | + out = [dict(r) for r in rows] | |
| 137 | + if p.project_id: | |
| 138 | + out = [r for r in out if str(r["project_id"]) == p.project_id] | |
| 139 | + return {"recipes": jsonable(out)} | |
| 140 | + | |
| 141 | + | |
| 142 | +@router.get("/recipes/{recipe_id}") | |
| 143 | +async def recipe_detail(recipe_id: str, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 144 | + r = await conn.fetchrow("SELECT * FROM recipes WHERE id::text = $1", recipe_id) | |
| 145 | + if not r: | |
| 146 | + raise ApiError(404, "not_found", "recipe not found") | |
| 147 | + check_project(p, r["project_id"]) | |
| 148 | + return jsonable(dict(r)) | |
| 149 | + | |
| 150 | + | |
| 151 | +@router.post("/recipes/compose") | |
| 152 | +async def compose(body: ComposeIn, p: auth.Principal = Depends(require_scope("execute")), conn: asyncpg.Connection = Depends(db)): | |
| 153 | + pr = await resolve_project(conn, body.project_id) | |
| 154 | + check_project(p, pr["id"]) | |
| 155 | + spec = body.model_dump(exclude={"project_id", "name"}) | |
| 156 | + try: | |
| 157 | + return await recipes.compose(conn, project_id=pr["id"], name=body.name, spec=spec, created_by=p.email) | |
| 158 | + except joins.JoinError as e: | |
| 159 | + raise ApiError(422, "join_error", str(e)) | |
| 160 | + | |
| 161 | + | |
| 162 | +@router.post("/recipes/{recipe_id}/run") | |
| 163 | +async def run_recipe(recipe_id: str, p: auth.Principal = Depends(require_scope("execute")), conn: asyncpg.Connection = Depends(db)): | |
| 164 | + r = await conn.fetchrow("SELECT project_id FROM recipes WHERE id::text = $1", recipe_id) | |
| 165 | + if not r: | |
| 166 | + raise ApiError(404, "not_found", "recipe not found") | |
| 167 | + check_project(p, r["project_id"]) | |
| 168 | + try: | |
| 169 | + return await recipes.run(conn, recipe_id, created_by=p.email) | |
| 170 | + except (joins.JoinError, D.DatasetError) as e: | |
| 171 | + raise ApiError(409, "recipe_error", str(e)) | |
| 172 | + | |
| 173 | + | |
| 174 | +@router.get("/debt") | |
| 175 | +async def debt(status: str | None = "open", severity: str | None = None, p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 176 | + rows = await conn.fetch("SELECT d.*, u.email AS owner, CASE d.subject_type WHEN 'akp' THEN (SELECT akp_id || ' v' || version FROM knowledge_packages k WHERE k.id::text = d.subject_id) WHEN 'api' THEN (SELECT name FROM apis a WHERE a.id::text = d.subject_id)" | |
| 177 | + " WHEN 'version' THEN (SELECT a.name || ' ' || v.version FROM api_versions v JOIN apis a ON a.id = v.api_id WHERE v.id::text = d.subject_id) WHEN 'execution' THEN (SELECT left(url, 80) FROM executions e WHERE e.id::text = d.subject_id) END AS subject_label" | |
| 178 | + " FROM knowledge_debt d LEFT JOIN users u ON u.id = d.owner_id WHERE ($1::text IS NULL OR $1 = 'all' OR d.status = $1) AND ($2::text IS NULL OR d.severity = $2) ORDER BY CASE d.severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, d.created_at DESC", status, severity) | |
| 179 | + summary = await conn.fetch("SELECT severity, kind, count(*) AS n FROM knowledge_debt WHERE status IN ('open','in_progress') GROUP BY severity, kind") | |
| 180 | + return {"debt": jsonable([dict(r) for r in rows]), "summary": jsonable([dict(r) for r in summary])} | |
| 181 | + | |
| 182 | + | |
| 183 | +@router.patch("/debt/{debt_id}") | |
| 184 | +async def patch_debt(debt_id: str, body: DebtPatch, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 185 | + if body.status and body.status not in ("open", "in_progress", "resolved", "accepted"): | |
| 186 | + raise ApiError(422, "validation", "invalid status") | |
| 187 | + row = await conn.fetchrow("UPDATE knowledge_debt SET status = coalesce($2, status), owner_id = coalesce($3::uuid, owner_id), remediation = coalesce($4, remediation), resolved_at = CASE WHEN $2 IN ('resolved','accepted') THEN now() ELSE resolved_at END WHERE id::text = $1 RETURNING *", | |
| 188 | + debt_id, body.status, body.owner_id, body.remediation) | |
| 189 | + if not row: | |
| 190 | + raise ApiError(404, "not_found", "debt not found") | |
| 191 | + await audit.record(conn, actor_type=p.kind, actor_id=p.id, action="debt.update", target_type="debt", target_id=row["id"], details=body.model_dump(exclude_none=True)) | |
| 192 | + return jsonable(dict(row)) | |
| 193 | + | |
| 194 | + | |
| 195 | +@router.get("/drift") | |
| 196 | +async def drift(p: auth.Principal = Depends(principal), conn: asyncpg.Connection = Depends(db)): | |
| 197 | + rows = await conn.fetch("SELECT d.*, v.version, a.name AS api_name, a.id AS api_id FROM drift_alerts d JOIN api_versions v ON v.id = d.api_version_id JOIN apis a ON a.id = v.api_id ORDER BY d.detected_at DESC LIMIT 200") | |
| 198 | + checks = await conn.fetch("SELECT e.created_at, e.result, e.subject_id AS version_id, a.name AS api_name, e.details FROM verification_evidence e JOIN api_versions v ON v.id = e.subject_id JOIN apis a ON a.id = v.api_id WHERE e.kind = 'drift_check' ORDER BY e.created_at DESC LIMIT 50") | |
| 199 | + return {"alerts": jsonable([dict(r) for r in rows]), "checks": jsonable([dict(r) for r in checks])} | |
| 200 | + | |
| 201 | + | |
| 202 | +@router.post("/drift/check") | |
| 203 | +async def drift_check(body: DriftIn, p: auth.Principal = Depends(require_scope("discover")), conn: asyncpg.Connection = Depends(db)): | |
| 204 | + v = await conn.fetchrow("SELECT id FROM api_versions WHERE id::text = $1", body.api_version_id) | |
| 205 | + if not v: | |
| 206 | + raise ApiError(404, "not_found", "version not found") | |
| 207 | + job = await queue.enqueue(conn, "drift_check", {"api_version_id": str(v["id"]), "created_by": p.email}, created_by=p.email) | |
| 208 | + return {"job_id": str(job["id"])} | |
added
akos/app.py
+175 −0
@@ -0,0 +1,175 @@ | ||
| 1 | +"""FastAPI application: /api/v1 (REST), /mcp (Streamable HTTP JSON-RPC), /docs (protected), static React UI.""" | |
| 2 | +from __future__ import annotations | |
| 3 | +import logging | |
| 4 | +import socket | |
| 5 | +import subprocess | |
| 6 | +import time | |
| 7 | +import uuid | |
| 8 | +from contextlib import asynccontextmanager | |
| 9 | +from pathlib import Path | |
| 10 | +from fastapi import Depends, FastAPI, HTTPException, Request | |
| 11 | +from fastapi.openapi.docs import get_swagger_ui_html | |
| 12 | +from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, PlainTextResponse | |
| 13 | +from fastapi.staticfiles import StaticFiles | |
| 14 | +from . import __version__, auth | |
| 15 | +from .api import auth_routes, exec_routes, ops_routes, registry_routes, research_routes | |
| 16 | +from .api.deps import ApiError, principal, db | |
| 17 | +from .db import close_pool, get_pool, migrate | |
| 18 | +from .logging_setup import setup_logging | |
| 19 | +from .mcp_server import router as mcp_router | |
| 20 | +from .settings import settings | |
| 21 | +from .system import ensure_system_projects | |
| 22 | +from .vault.redact import redact_text | |
| 23 | + | |
| 24 | +log = logging.getLogger("akos.web") | |
| 25 | +WEB_DIST = settings.root / "web" / "dist" | |
| 26 | +DOCS_DIR = settings.root / "docs" | |
| 27 | + | |
| 28 | + | |
| 29 | +@asynccontextmanager | |
| 30 | +async def lifespan(app: FastAPI): | |
| 31 | + setup_logging("akos-web") | |
| 32 | + pool = await get_pool() | |
| 33 | + applied = await migrate(pool) | |
| 34 | + async with pool.acquire() as conn: | |
| 35 | + created = await auth.ensure_bootstrap_admin(conn) | |
| 36 | + await ensure_system_projects(conn) | |
| 37 | + log.info("akos-web %s ready (migrations applied: %s, bootstrap admin: %s)", __version__, applied, created) | |
| 38 | + yield | |
| 39 | + await close_pool() | |
| 40 | + | |
| 41 | + | |
| 42 | +def create_app() -> FastAPI: | |
| 43 | + app = FastAPI(title="AKOS", version=__version__, docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) | |
| 44 | + | |
| 45 | + @app.middleware("http") | |
| 46 | + async def _mw(request: Request, call_next): | |
| 47 | + cid = request.headers.get("x-correlation-id") or uuid.uuid4().hex[:16] | |
| 48 | + request.state.cid = cid | |
| 49 | + t0 = time.perf_counter() | |
| 50 | + if request.headers.get("content-length") and int(request.headers["content-length"]) > 10 * 1024 * 1024: | |
| 51 | + return JSONResponse({"error": {"code": "payload_too_large", "message": "body exceeds 10 MiB", "correlation_id": cid}}, status_code=413) | |
| 52 | + try: | |
| 53 | + resp = await call_next(request) | |
| 54 | + except Exception as e: # noqa: BLE001 | |
| 55 | + log.exception("unhandled error cid=%s", cid) | |
| 56 | + return JSONResponse({"error": {"code": "internal", "message": redact_text(str(e))[:300], "correlation_id": cid}}, status_code=500) | |
| 57 | + ms = int((time.perf_counter() - t0) * 1000) | |
| 58 | + resp.headers["X-Correlation-Id"] = cid | |
| 59 | + resp.headers["X-Content-Type-Options"] = "nosniff" | |
| 60 | + resp.headers["X-Frame-Options"] = "DENY" | |
| 61 | + resp.headers["Referrer-Policy"] = "same-origin" | |
| 62 | + resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" | |
| 63 | + if not request.url.path.startswith("/assets/"): | |
| 64 | + resp.headers["Cache-Control"] = "no-store" | |
| 65 | + if request.url.path.startswith(("/api/", "/mcp")): | |
| 66 | + log.info("%s %s %s %sms", request.method, request.url.path, resp.status_code, ms, extra={"cid": cid, "status": resp.status_code, "ms": ms}) | |
| 67 | + return resp | |
| 68 | + | |
| 69 | + @app.exception_handler(ApiError) | |
| 70 | + async def _api_err(request: Request, exc: ApiError): | |
| 71 | + d = exc.detail if isinstance(exc.detail, dict) else {"code": "error", "message": str(exc.detail)} | |
| 72 | + return JSONResponse({"error": {**d, "correlation_id": getattr(request.state, "cid", None)}}, status_code=exc.status_code) | |
| 73 | + | |
| 74 | + @app.exception_handler(HTTPException) | |
| 75 | + async def _http_err(request: Request, exc: HTTPException): | |
| 76 | + d = exc.detail if isinstance(exc.detail, dict) else {"code": "http_error", "message": str(exc.detail)} | |
| 77 | + return JSONResponse({"error": {**d, "correlation_id": getattr(request.state, "cid", None)}}, status_code=exc.status_code) | |
| 78 | + | |
| 79 | + api = "/api/v1" | |
| 80 | + app.include_router(auth_routes.router, prefix=api) | |
| 81 | + app.include_router(registry_routes.router, prefix=api) | |
| 82 | + app.include_router(exec_routes.router, prefix=api) | |
| 83 | + app.include_router(research_routes.router, prefix=api) | |
| 84 | + app.include_router(ops_routes.router, prefix=api) | |
| 85 | + app.include_router(mcp_router) | |
| 86 | + | |
| 87 | + @app.get("/api/v1/health") | |
| 88 | + async def health(): | |
| 89 | + try: | |
| 90 | + pool = await get_pool() | |
| 91 | + async with pool.acquire() as c: | |
| 92 | + await c.fetchval("SELECT 1") | |
| 93 | + return {"status": "ok", "version": __version__, "host": socket.gethostname()} | |
| 94 | + except Exception as e: # noqa: BLE001 | |
| 95 | + return JSONResponse({"status": "degraded", "version": __version__, "error": redact_text(str(e))[:200]}, status_code=503) | |
| 96 | + | |
| 97 | + @app.get("/docs", include_in_schema=False) | |
| 98 | + async def docs_ui(p=Depends(principal)): | |
| 99 | + return get_swagger_ui_html(openapi_url="/docs/openapi.json", title="AKOS API") | |
| 100 | + | |
| 101 | + @app.get("/docs/openapi.json", include_in_schema=False) | |
| 102 | + async def docs_openapi(p=Depends(principal)): | |
| 103 | + return app.openapi() | |
| 104 | + | |
| 105 | + @app.get("/docs/md/{name}", include_in_schema=False) | |
| 106 | + async def docs_md(name: str, p=Depends(principal)): | |
| 107 | + if not name.replace("-", "").replace("_", "").isalnum(): | |
| 108 | + raise ApiError(400, "bad_request", "bad name") | |
| 109 | + f = DOCS_DIR / f"{name}.md" | |
| 110 | + if not f.exists(): | |
| 111 | + raise ApiError(404, "not_found", "document not found") | |
| 112 | + return PlainTextResponse(f.read_text(), media_type="text/markdown; charset=utf-8") | |
| 113 | + | |
| 114 | + @app.get("/docs/index", include_in_schema=False) | |
| 115 | + async def docs_index(p=Depends(principal)): | |
| 116 | + return {"documents": sorted(f.stem for f in DOCS_DIR.glob("*.md"))} | |
| 117 | + | |
| 118 | + if WEB_DIST.exists(): | |
| 119 | + app.mount("/assets", StaticFiles(directory=str(WEB_DIST / "assets")), name="assets") | |
| 120 | + | |
| 121 | + @app.get("/{path:path}", include_in_schema=False) | |
| 122 | + async def spa(path: str): | |
| 123 | + if path.startswith(("api/", "mcp", "docs")): | |
| 124 | + raise ApiError(404, "not_found", "no such route") | |
| 125 | + f = WEB_DIST / path | |
| 126 | + if path and f.is_file() and ".." not in path: | |
| 127 | + return FileResponse(str(f)) | |
| 128 | + return FileResponse(str(WEB_DIST / "index.html")) | |
| 129 | + else: | |
| 130 | + @app.get("/", include_in_schema=False) | |
| 131 | + async def root(): | |
| 132 | + return HTMLResponse("<h1>AKOS</h1><p>UI not built. API at /api/v1, MCP at /mcp.</p>") | |
| 133 | + return app | |
| 134 | + | |
| 135 | + | |
| 136 | +def _wg_address() -> str | None: | |
| 137 | + try: | |
| 138 | + out = subprocess.run(["/sbin/ifconfig"], capture_output=True, text=True, timeout=5).stdout | |
| 139 | + except Exception: | |
| 140 | + return None | |
| 141 | + cur = None | |
| 142 | + for line in out.splitlines(): | |
| 143 | + if not line.startswith((" ", "\t")): | |
| 144 | + cur = line.split(":")[0] | |
| 145 | + elif "inet 10.67." in line: | |
| 146 | + return line.split()[1] | |
| 147 | + return None | |
| 148 | + | |
| 149 | + | |
| 150 | +def serve() -> None: | |
| 151 | + import uvicorn | |
| 152 | + setup_logging("akos-web") | |
| 153 | + app = create_app() | |
| 154 | + hosts = [] | |
| 155 | + for h in settings.bind_hosts: | |
| 156 | + if h == "wg1": | |
| 157 | + a = _wg_address() | |
| 158 | + if a: | |
| 159 | + hosts.append(a) | |
| 160 | + else: | |
| 161 | + log.warning("wg1 address not found; binding loopback only") | |
| 162 | + else: | |
| 163 | + hosts.append(h) | |
| 164 | + socks = [] | |
| 165 | + for h in hosts: | |
| 166 | + s = socket.socket(socket.AF_INET6 if ":" in h else socket.AF_INET, socket.SOCK_STREAM) | |
| 167 | + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| 168 | + s.bind((h, settings.bind_port)) | |
| 169 | + s.listen(128) | |
| 170 | + socks.append(s) | |
| 171 | + log.info("akos-web binding %s", [f"{h}:{settings.bind_port}" for h in hosts]) | |
| 172 | + config = uvicorn.Config(app, log_config=None, proxy_headers=True, forwarded_allow_ips=",".join(settings.trusted_proxies), access_log=False, timeout_keep_alive=15) | |
| 173 | + server = uvicorn.Server(config) | |
| 174 | + import asyncio | |
| 175 | + asyncio.run(server.serve(sockets=socks)) | |
added
akos/artifacts.py
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +"""Content-addressed artifact store (filesystem, sha256 path) with a row in `artifacts`.""" | |
| 2 | +from __future__ import annotations | |
| 3 | +from pathlib import Path | |
| 4 | +import asyncpg | |
| 5 | +from .settings import settings | |
| 6 | +from .util import sha256_hex | |
| 7 | + | |
| 8 | + | |
| 9 | +def _path_for(digest: str) -> Path: | |
| 10 | + return settings.artifacts_dir / digest[:2] / digest[2:4] / digest | |
| 11 | + | |
| 12 | + | |
| 13 | +async def put(conn: asyncpg.Connection, data: bytes, media_type: str = "application/octet-stream", | |
| 14 | + retention: str = "permitted", license: dict | None = None) -> dict: | |
| 15 | + digest = sha256_hex(data) | |
| 16 | + p = _path_for(digest) | |
| 17 | + if retention == "permitted": | |
| 18 | + p.parent.mkdir(parents=True, exist_ok=True) | |
| 19 | + if not p.exists(): | |
| 20 | + tmp = p.with_suffix(".tmp") | |
| 21 | + tmp.write_bytes(data) | |
| 22 | + tmp.replace(p) | |
| 23 | + # content-addressed: an identical body seen again under a permitted license upgrades the row (never downgrades) | |
| 24 | + row = await conn.fetchrow( | |
| 25 | + "INSERT INTO artifacts(sha256, size_bytes, media_type, path, retention, license) VALUES ($1,$2,$3,$4,$5,$6)" | |
| 26 | + " ON CONFLICT (sha256) DO UPDATE SET media_type = EXCLUDED.media_type," | |
| 27 | + " retention = CASE WHEN EXCLUDED.retention = 'permitted' THEN 'permitted' ELSE artifacts.retention END," | |
| 28 | + " path = CASE WHEN EXCLUDED.retention = 'permitted' THEN EXCLUDED.path ELSE artifacts.path END," | |
| 29 | + " license = CASE WHEN EXCLUDED.retention = 'permitted' THEN EXCLUDED.license ELSE artifacts.license END RETURNING *", | |
| 30 | + digest, len(data), media_type, str(p) if retention == "permitted" else "", retention, license or {}) | |
| 31 | + return dict(row) | |
| 32 | + | |
| 33 | + | |
| 34 | +def read(row: dict | asyncpg.Record) -> bytes | None: | |
| 35 | + if row["retention"] != "permitted" or not row["path"]: | |
| 36 | + return None | |
| 37 | + p = Path(row["path"]) | |
| 38 | + if not p.exists(): | |
| 39 | + p = _path_for(row["sha256"]) | |
| 40 | + return p.read_bytes() if p.exists() else None | |
| 41 | + | |
| 42 | + | |
| 43 | +async def get(conn: asyncpg.Connection, artifact_id) -> dict | None: | |
| 44 | + r = await conn.fetchrow("SELECT * FROM artifacts WHERE id = $1", artifact_id) | |
| 45 | + return dict(r) if r else None | |
| 46 | + | |
| 47 | + | |
| 48 | +def safe_name(name: str) -> str: | |
| 49 | + import re | |
| 50 | + if not re.fullmatch(r"[A-Za-z0-9._\-]{1,120}", name) or ".." in name: | |
| 51 | + raise ValueError("unsafe file name") | |
| 52 | + return name | |
added
akos/audit.py
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
akos/auth.py
+135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added
akos/cli.py
+242 −0
Large diff (242 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/akp.py
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/codegen_mcp.py
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/codegen_python.py
+345 −0
Large diff (345 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/codegen_ts.py
+89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/openapi.py
+166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/probe.py
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/scan.py
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
akos/compiler/verify.py
+169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
akos/composition/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/composition/joins.py
+241 −0
Large diff (241 lines) is not loaded by default to keep the page fast.Load diff
added
akos/composition/recipes.py
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
akos/db.py
+59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
akos/drift.py
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/egress_proxy.py
+126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/executor.py
+334 −0
Large diff (334 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/policy.py
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/sandbox.py
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
akos/gateway/ssrf.py
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
akos/jobs/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/jobs/handlers.py
+237 −0
Large diff (237 lines) is not loaded by default to keep the page fast.Load diff
added
akos/jobs/queue.py
+74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added
akos/jobs/scheduler.py
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
akos/jobs/worker.py
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
akos/logging_setup.py
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
akos/mcp_server.py
+219 −0
Large diff (219 lines) is not loaded by default to keep the page fast.Load diff
added
akos/migrations/0001_init.sql
+451 −0
Large diff (451 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/datasets.py
+129 −0
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/export.py
+240 −0
Large diff (240 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/lineage.py
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/quality.py
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
akos/provenance/records.py
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
akos/registry/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/registry/graph.py
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
akos/registry/lifecycle.py
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
akos/registry/search.py
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
akos/registry/store.py
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
akos/sdk.py
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
akos/settings.py
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
akos/system.py
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
akos/util.py
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
akos/vault/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
akos/vault/keychain.py
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
akos/vault/redact.py
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
docs/acceptance-matrix.md
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
docs/architecture.md
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
docs/build-log.md
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
docs/implementation-plan.md
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
docs/infra-inventory.md
+63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
added
docs/operations.md
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
docs/progress.md
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
docs/security-review.md
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
docs/threat-model.md
+20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added
examples/cross-source-recipe/demo_research.py
+93 −0
Large diff (93 lines) is not loaded by default to keep the page fast.Load diff
added
examples/opaque-mcp/demo_opaque.py
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
infra/akos.mld.json
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
integrations/claude-code/CLAUDE.md.snippet
+5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added
integrations/claude-code/install.sh
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
integrations/claude-code/merge_hooks.py
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
integrations/claude-code/session_start.sh
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
pyproject.toml
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/backup.sh
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/bench.py
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/restore.sh
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
tests/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
tests/conftest.py
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/.gitignore
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/package.json
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/playwright.config.ts
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/pnpm-lock.yaml
+77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/tsconfig.json
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
tests/e2e/ui.spec.ts
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
tests/integration/test_auth_tenant.py
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
tests/integration/test_gateway_policy.py
+59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
tests/integration/test_opaque_drift_recovery.py
+82 −0
Large diff (82 lines) is not loaded by default to keep the page fast.Load diff
added
tests/integration/test_reuse_credentials_export.py
+100 −0
Large diff (100 lines) is not loaded by default to keep the page fast.Load diff
added
tests/security/test_enforcement.py
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
tests/security/test_ssrf_injection_limits.py
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
tests/unit/test_codegen_scan.py
+53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
added
tests/unit/test_joins_redact_audit.py
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
tests/unit/test_lifecycle.py
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
uv.lock
+858 −0
Large diff (858 lines) is not loaded by default to keep the page fast.Load diff
added
web/index.html
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
web/package.json
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
web/pnpm-lock.yaml
+1186 −0
Large diff (1,186 lines) is not loaded by default to keep the page fast.Load diff
added
web/pnpm-workspace.yaml
+2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/App.tsx
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/api.ts
+96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/Graph.tsx
+54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/charts.tsx
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/shared.tsx
+106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/components/ui.tsx
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/hooks.ts
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/main.tsx
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/AkpDetail.tsx
+43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/ApiDetail.tsx
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Apis.tsx
+64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Credentials.tsx
+68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/DatasetDetail.tsx
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Debt.tsx
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/EndpointDetail.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/ExecutionDetail.tsx
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Executions.tsx
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/GraphPage.tsx
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Lineage.tsx
+77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Login.tsx
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Operations.tsx
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Overview.tsx
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Policy.tsx
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/ProjectDetail.tsx
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Projects.tsx
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Search.tsx
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/Settings.tsx
+55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/pages/VersionDetail.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
web/src/styles.css
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
web/tsconfig.json
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
web/vite.config.ts
+12 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff