SPB Git

spb/akos

Public

adopt: état déployé sur M4M64c adopté comme source de vérité (remote-first, 2026-11-01)

mld committed (Nov 1, 2026) Browse files

138 changed files +11,662 −0

138 changed files
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
Menu

Theme