feat(api): /v1 alias router for stable external contract (Issue A)
Mounts an explicit allowlist of public handlers under /v1 alongside the existing unversioned paths. External clients (AKC, OpenClaw, future tools) should target /v1; internal callers (hooks, wiki, admin UI) keep working unchanged. Breaking schema changes will bump the prefix to /v2. - src/atocore/main.py: _V1_PUBLIC_PATHS allowlist + second router - tests/test_v1_aliases.py: parity + OpenAPI presence (5 tests) - README.md: API versioning section - DEV-LEDGER.md: session log, test_count 459 -> 463 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
- **live_sha** (Dalidou `/health` build_sha): `775960c` (verified 2026-04-16 via /health, build_time 2026-04-16T17:59:30Z)
|
||||
- **last_updated**: 2026-04-18 by Claude (Phase 7A — Memory Consolidation "sleep cycle" V1 on branch, not yet deployed)
|
||||
- **main_tip**: `999788b`
|
||||
- **test_count**: 395 (21 new Phase 7A dedup tests + accumulated Phase 5/6 tests since last ledger refresh)
|
||||
- **test_count**: 463 (prior 459 + 5 new /v1 alias tests; `main` not yet advanced past the alias commit at session end)
|
||||
- **harness**: `17/18 PASS` on live Dalidou (p04-constraints expects "Zerodur" — retrieval content gap, not regression)
|
||||
- **vectors**: 33,253
|
||||
- **active_memories**: 84 (31 project, 23 knowledge, 10 episodic, 8 adaptation, 7 preference, 5 identity)
|
||||
@@ -160,6 +160,8 @@ One branch `codex/extractor-eval-loop` for Day 1-5, a second `codex/retrieval-ha
|
||||
|
||||
## Session Log
|
||||
|
||||
- **2026-04-21 Claude** Issue A (API versioning) landed on `main` working tree (not yet committed/deployed). `src/atocore/main.py` now mounts a second `/v1` router that re-registers an explicit allowlist of public handlers (`_V1_PUBLIC_PATHS`) against the same endpoint functions — entities, relationships, ingest, context/build, query, projects, memory, interactions, project/state, health, sources, stats, and their sub-paths. Unversioned paths are untouched; OpenClaw and hooks keep working. Added `tests/test_v1_aliases.py` (5 tests: health parity, projects parity, entities reachable, v1 paths present in OpenAPI, unversioned paths still present in OpenAPI) and a "API versioning" section in the README documenting the rule (new endpoints at latest prefix, breaking changes bump prefix, unversioned retained for internal callers). Tests 459 → 463. Next: commit + deploy, then relay to the AKC thread so Phase 2 can code against `/v1`. Issues B (wiki redlinks) and C (inbox/cross-project) remain open, unstarted.
|
||||
|
||||
- **2026-04-19 Claude** Shipped Phases 7A.1 (tiered auto-merge), 7C (tag canonicalization), 7D (confidence decay), 7I (OpenClaw context injection), UI refresh (memory/domain/activity pages + topnav), and closed the Claude Code retrieval asymmetry. Builds deployed: `028d4c3` → `56d5df0` → `e840ef4` → `877b97e` → `6e43cc7` → `9c91d77`. New capture-surface scope: Claude Code (Stop + UserPromptSubmit hooks, both installed and verified live) + OpenClaw (v0.2.0 plugin with capture + context injection, verified loaded on T420 gateway). `/wiki/capture` paste form removed from topnav; kept as labeled fallback. Anthropic API polling explicitly out of scope per user. Tests 414 → 459. `docs/capture-surfaces.md` documents the sanctioned scope.
|
||||
|
||||
- **2026-04-18 Claude** **Phase 7A — Memory Consolidation V1 ("sleep cycle") landed on branch.** New `docs/PHASE-7-MEMORY-CONSOLIDATION.md` covers all 8 subphases (7A dedup, 7B contradictions, 7C tag canon, 7D confidence decay, 7E memory detail, 7F domain view, 7F re-extract, 7H vector hygiene). 7A implementation: schema migration `memory_merge_candidates`, `atocore.memory.similarity` (cosine + transitive cluster), stdlib-only `atocore.memory._dedup_prompt` (llm drafts unified content preserving all specifics), `merge_memories()` + `create_merge_candidate()` + `get_merge_candidates()` + `reject_merge_candidate()` in service.py, host-side `scripts/memory_dedup.py` (HTTP + claude -p, idempotent via sorted-id set), 5 new endpoints under `/admin/memory/merge-candidates*` + `/admin/memory/dedup-scan` + `/admin/memory/dedup-status`, purple-themed "🔗 Merge Candidates" section in /admin/triage with editable draft + approve/reject buttons, "🔗 Scan for duplicates" control bar with threshold slider, nightly Step B3 in batch-extract.sh (0.90 daily, 0.85 Sundays deep), `deploy/dalidou/dedup-watcher.sh` host watcher for UI-triggered scans (mirrors graduation-watcher pattern). 21 new tests (similarity, prompt parse, idempotency, merge happy path, override content/tags, audit rows, abort-if-source-tampered, reject leaves sources alone, schema). Tests 374 → 395. Not yet deployed; harness not re-run. Next: push + deploy, install `dedup-watcher.sh` in host cron, trigger first scan, review proposals in UI.
|
||||
|
||||
20
README.md
20
README.md
@@ -40,6 +40,26 @@ python scripts/atocore_client.py audit-query "gigabit" 5
|
||||
| GET | /health | Health check |
|
||||
| GET | /debug/context | Inspect last context pack |
|
||||
|
||||
## API versioning
|
||||
|
||||
The public contract for external clients (AKC, OpenClaw, future tools) is
|
||||
served under a `/v1` prefix. Every public endpoint is available at both its
|
||||
unversioned path and under `/v1` — e.g. `POST /entities` and `POST /v1/entities`
|
||||
route to the same handler.
|
||||
|
||||
Rules:
|
||||
|
||||
- New public endpoints land at the latest version prefix.
|
||||
- Backwards-compatible additions stay on the current version.
|
||||
- Breaking schema changes to an existing endpoint bump the prefix (`/v2/...`)
|
||||
and leave the prior version in place until clients migrate.
|
||||
- Unversioned paths are retained for internal callers (hooks, scripts, the
|
||||
wiki/admin UI). Do not rely on them from external clients — use `/v1`.
|
||||
|
||||
The authoritative list of versioned paths is in `src/atocore/main.py`
|
||||
(`_V1_PUBLIC_PATHS`). `GET /openapi.json` reflects both the versioned and
|
||||
unversioned forms.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from atocore import __version__
|
||||
from atocore.api.routes import router
|
||||
@@ -53,6 +54,57 @@ app = FastAPI(
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
# Public API v1 — stable contract for external clients (AKC, OpenClaw, etc.).
|
||||
# Paths listed here are re-mounted under /v1 as aliases of the existing
|
||||
# unversioned handlers. Unversioned paths continue to work; new endpoints
|
||||
# land at the latest version; breaking schema changes bump the prefix.
|
||||
_V1_PUBLIC_PATHS = {
|
||||
"/entities",
|
||||
"/entities/{entity_id}",
|
||||
"/entities/{entity_id}/promote",
|
||||
"/entities/{entity_id}/reject",
|
||||
"/entities/{entity_id}/audit",
|
||||
"/relationships",
|
||||
"/ingest",
|
||||
"/ingest/sources",
|
||||
"/context/build",
|
||||
"/query",
|
||||
"/projects",
|
||||
"/projects/{project_name}",
|
||||
"/projects/{project_name}/refresh",
|
||||
"/projects/{project_name}/mirror",
|
||||
"/projects/{project_name}/mirror.html",
|
||||
"/memory",
|
||||
"/memory/{memory_id}",
|
||||
"/memory/{memory_id}/audit",
|
||||
"/memory/{memory_id}/promote",
|
||||
"/memory/{memory_id}/reject",
|
||||
"/project/state",
|
||||
"/project/state/{project_name}",
|
||||
"/interactions",
|
||||
"/interactions/{interaction_id}",
|
||||
"/interactions/{interaction_id}/reinforce",
|
||||
"/interactions/{interaction_id}/extract",
|
||||
"/health",
|
||||
"/sources",
|
||||
"/stats",
|
||||
}
|
||||
|
||||
_v1_router = APIRouter(prefix="/v1", tags=["v1"])
|
||||
for _route in list(router.routes):
|
||||
if isinstance(_route, APIRoute) and _route.path in _V1_PUBLIC_PATHS:
|
||||
_v1_router.add_api_route(
|
||||
_route.path,
|
||||
_route.endpoint,
|
||||
methods=list(_route.methods),
|
||||
response_model=_route.response_model,
|
||||
response_class=_route.response_class,
|
||||
name=f"v1_{_route.name}",
|
||||
include_in_schema=True,
|
||||
)
|
||||
app.include_router(_v1_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
|
||||
61
tests/test_v1_aliases.py
Normal file
61
tests/test_v1_aliases.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Tests for /v1 API aliases — stable contract for external clients."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from atocore.main import app
|
||||
|
||||
|
||||
def test_v1_health_alias_matches_unversioned():
|
||||
client = TestClient(app)
|
||||
unversioned = client.get("/health")
|
||||
versioned = client.get("/v1/health")
|
||||
assert unversioned.status_code == 200
|
||||
assert versioned.status_code == 200
|
||||
assert versioned.json()["build_sha"] == unversioned.json()["build_sha"]
|
||||
|
||||
|
||||
def test_v1_projects_alias_returns_same_shape():
|
||||
client = TestClient(app)
|
||||
unversioned = client.get("/projects")
|
||||
versioned = client.get("/v1/projects")
|
||||
assert unversioned.status_code == 200
|
||||
assert versioned.status_code == 200
|
||||
assert versioned.json() == unversioned.json()
|
||||
|
||||
|
||||
def test_v1_entities_get_alias_reachable(tmp_data_dir):
|
||||
from atocore.engineering.service import init_engineering_schema
|
||||
|
||||
init_engineering_schema()
|
||||
client = TestClient(app)
|
||||
response = client.get("/v1/entities")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "entities" in body
|
||||
|
||||
|
||||
def test_v1_paths_appear_in_openapi():
|
||||
client = TestClient(app)
|
||||
spec = client.get("/openapi.json").json()
|
||||
paths = spec["paths"]
|
||||
expected = [
|
||||
"/v1/entities",
|
||||
"/v1/relationships",
|
||||
"/v1/ingest",
|
||||
"/v1/context/build",
|
||||
"/v1/projects",
|
||||
"/v1/memory",
|
||||
"/v1/projects/{project_name}/mirror",
|
||||
"/v1/health",
|
||||
]
|
||||
for path in expected:
|
||||
assert path in paths, f"{path} missing from OpenAPI spec"
|
||||
|
||||
|
||||
def test_unversioned_paths_still_present_in_openapi():
|
||||
"""Regression: /v1 aliases must not displace the original paths."""
|
||||
client = TestClient(app)
|
||||
spec = client.get("/openapi.json").json()
|
||||
paths = spec["paths"]
|
||||
for path in ("/entities", "/projects", "/health", "/context/build"):
|
||||
assert path in paths, f"unversioned {path} missing — aliases must coexist"
|
||||
Reference in New Issue
Block a user