Add project registration endpoint

This commit is contained in:
2026-04-06 09:52:19 -04:00
parent 1f1e6b5749
commit 9715fe3143
10 changed files with 265 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ ATOCORE_SOURCE_DRIVE_ENABLED=true
ATOCORE_LOG_DIR=./logs
ATOCORE_BACKUP_DIR=./backups
ATOCORE_RUN_DIR=./run
ATOCORE_PROJECT_REGISTRY_DIR=./config
ATOCORE_PROJECT_REGISTRY_PATH=./config/project-registry.json
ATOCORE_HOST=127.0.0.1
ATOCORE_PORT=8100

View File

@@ -17,6 +17,7 @@ services:
- ${ATOCORE_LOG_DIR}:${ATOCORE_LOG_DIR}
- ${ATOCORE_BACKUP_DIR}:${ATOCORE_BACKUP_DIR}
- ${ATOCORE_RUN_DIR}:${ATOCORE_RUN_DIR}
- ${ATOCORE_PROJECT_REGISTRY_DIR}:${ATOCORE_PROJECT_REGISTRY_DIR}
- ${ATOCORE_VAULT_SOURCE_DIR}:${ATOCORE_VAULT_SOURCE_DIR}:ro
- ${ATOCORE_DRIVE_SOURCE_DIR}:${ATOCORE_DRIVE_SOURCE_DIR}:ro
healthcheck:

View File

@@ -64,15 +64,23 @@ These map to the configured Dalidou source boundaries.
For a new project:
1. stage the initial source docs on Dalidou
2. add the project entry to the registry
3. verify the entry with:
2. inspect the expected shape with:
- `GET /projects/template`
- or `atocore.sh project-template`
3. preview the entry without mutating state:
- `POST /projects/proposal`
- or `atocore.sh propose-project ...`
4. register the approved entry:
- `POST /projects/register`
- or `atocore.sh register-project ...`
5. verify the entry with:
- `GET /projects`
- or the T420 helper `atocore.sh projects`
4. refresh it with:
6. refresh it with:
- `POST /projects/{id}/refresh`
- or `atocore.sh refresh-project <id>`
5. verify retrieval and context quality
6. only later promote stable facts into Trusted Project State
7. verify retrieval and context quality
8. only later promote stable facts into Trusted Project State
## What Not To Do
@@ -93,3 +101,9 @@ Use:
And the API template endpoint:
- `GET /projects/template`
Other lifecycle endpoints:
- `POST /projects/proposal`
- `POST /projects/register`
- `POST /projects/{id}/refresh`

View File

@@ -85,6 +85,8 @@ The first concrete foundation for this now exists in AtoCore:
- a project registry file records known project ids, aliases, and ingest roots
- the API can list those registered projects
- the API can return a registration template for new projects
- the API can preview a proposed registration before writing it
- the API can persist an approved registration to the registry
- the API can refresh a single registered project from its configured roots
This is not full source automation yet, but it gives the refresh model a real

View File

@@ -37,6 +37,7 @@ from atocore.projects.registry import (
build_project_registration_proposal,
get_project_registry_template,
list_registered_projects,
register_project,
refresh_registered_project,
)
from atocore.retrieval.retriever import retrieve
@@ -202,6 +203,20 @@ def api_project_registration_proposal(req: ProjectRegistrationProposalRequest) -
raise HTTPException(status_code=400, detail=str(e))
@router.post("/projects/register")
def api_project_registration(req: ProjectRegistrationProposalRequest) -> dict:
"""Persist a validated project registration to the registry file."""
try:
return register_project(
project_id=req.project_id,
aliases=req.aliases,
description=req.description,
ingest_roots=req.ingest_roots,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/projects/{project_name}/refresh", response_model=ProjectRefreshResponse)
def api_refresh_project(project_name: str, purge_deleted: bool = False) -> ProjectRefreshResponse:
"""Refresh one registered project from its configured ingest roots."""

View File

@@ -106,6 +106,7 @@ class Settings(BaseSettings):
self.resolved_log_dir,
self.resolved_backup_dir,
self.resolved_run_dir,
self.resolved_project_registry_path.parent,
]
@property

View File

@@ -93,13 +93,38 @@ def build_project_registration_proposal(
}
def register_project(
project_id: str,
aliases: list[str] | tuple[str, ...] | None = None,
description: str = "",
ingest_roots: list[dict] | tuple[dict, ...] | None = None,
) -> dict:
"""Persist a validated project registration to the registry file."""
proposal = build_project_registration_proposal(
project_id=project_id,
aliases=aliases,
description=description,
ingest_roots=ingest_roots,
)
if not proposal["valid"]:
collision_names = ", ".join(collision["name"] for collision in proposal["collisions"])
raise ValueError(f"Project registration has collisions: {collision_names}")
registry_path = _config.settings.resolved_project_registry_path
payload = _load_registry_payload(registry_path)
payload.setdefault("projects", []).append(proposal["project"])
_write_registry_payload(registry_path, payload)
return {
**proposal,
"status": "registered",
}
def load_project_registry() -> list[RegisteredProject]:
"""Load project registry entries from JSON config."""
registry_path = _config.settings.resolved_project_registry_path
if not registry_path.exists():
return []
payload = json.loads(registry_path.read_text(encoding="utf-8"))
payload = _load_registry_payload(registry_path)
entries = payload.get("projects", [])
projects: list[RegisteredProject] = []
@@ -285,3 +310,17 @@ def _find_name_collisions(project_id: str, aliases: list[str]) -> list[dict]:
)
break
return collisions
def _load_registry_payload(registry_path: Path) -> dict:
if not registry_path.exists():
return {"projects": []}
return json.loads(registry_path.read_text(encoding="utf-8"))
def _write_registry_payload(registry_path: Path, payload: dict) -> None:
registry_path.parent.mkdir(parents=True, exist_ok=True)
registry_path.write_text(
json.dumps(payload, indent=2, ensure_ascii=True) + "\n",
encoding="utf-8",
)

View File

@@ -203,3 +203,94 @@ def test_project_proposal_endpoint_returns_normalized_preview(tmp_data_dir, monk
assert body["project"]["aliases"] == ["p07", "example-project"]
assert body["resolved_ingest_roots"][0]["exists"] is True
assert body["valid"] is True
def test_project_register_endpoint_persists_entry(tmp_data_dir, monkeypatch):
vault_dir = tmp_data_dir / "vault-source"
drive_dir = tmp_data_dir / "drive-source"
config_dir = tmp_data_dir / "config"
staged = vault_dir / "incoming" / "projects" / "p07-example"
staged.mkdir(parents=True)
drive_dir.mkdir()
config_dir.mkdir()
registry_path = config_dir / "project-registry.json"
registry_path.write_text('{"projects": []}', encoding="utf-8")
monkeypatch.setenv("ATOCORE_VAULT_SOURCE_DIR", str(vault_dir))
monkeypatch.setenv("ATOCORE_DRIVE_SOURCE_DIR", str(drive_dir))
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
config.settings = config.Settings()
client = TestClient(app)
response = client.post(
"/projects/register",
json={
"project_id": "p07-example",
"aliases": ["p07", "example-project"],
"description": "Example project",
"ingest_roots": [
{
"source": "vault",
"subpath": "incoming/projects/p07-example",
"label": "Primary docs",
}
],
},
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "registered"
assert body["project"]["id"] == "p07-example"
assert '"p07-example"' in registry_path.read_text(encoding="utf-8")
def test_project_register_endpoint_rejects_collisions(tmp_data_dir, monkeypatch):
vault_dir = tmp_data_dir / "vault-source"
drive_dir = tmp_data_dir / "drive-source"
config_dir = tmp_data_dir / "config"
vault_dir.mkdir()
drive_dir.mkdir()
config_dir.mkdir()
registry_path = config_dir / "project-registry.json"
registry_path.write_text(
"""
{
"projects": [
{
"id": "p05-interferometer",
"aliases": ["p05", "interferometer"],
"ingest_roots": [
{"source": "vault", "subpath": "incoming/projects/p05-interferometer"}
]
}
]
}
""".strip(),
encoding="utf-8",
)
monkeypatch.setenv("ATOCORE_VAULT_SOURCE_DIR", str(vault_dir))
monkeypatch.setenv("ATOCORE_DRIVE_SOURCE_DIR", str(drive_dir))
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
config.settings = config.Settings()
client = TestClient(app)
response = client.post(
"/projects/register",
json={
"project_id": "p07-example",
"aliases": ["interferometer"],
"ingest_roots": [
{
"source": "vault",
"subpath": "incoming/projects/p07-example",
}
],
},
)
assert response.status_code == 400
assert "collisions" in response.json()["detail"]

View File

@@ -50,6 +50,9 @@ def test_ensure_runtime_dirs_creates_machine_dirs_only(tmp_path, monkeypatch):
monkeypatch.setenv("ATOCORE_DRIVE_SOURCE_DIR", str(tmp_path / "drive-source"))
monkeypatch.setenv("ATOCORE_LOG_DIR", str(tmp_path / "logs"))
monkeypatch.setenv("ATOCORE_BACKUP_DIR", str(tmp_path / "backups"))
monkeypatch.setenv(
"ATOCORE_PROJECT_REGISTRY_PATH", str(tmp_path / "config" / "project-registry.json")
)
original_settings = config.settings
try:
@@ -63,6 +66,7 @@ def test_ensure_runtime_dirs_creates_machine_dirs_only(tmp_path, monkeypatch):
assert config.settings.resolved_log_dir.exists()
assert config.settings.resolved_backup_dir.exists()
assert config.settings.resolved_run_dir.exists()
assert config.settings.resolved_project_registry_path.parent.exists()
assert not config.settings.resolved_vault_source_dir.exists()
assert not config.settings.resolved_drive_source_dir.exists()
finally:

View File

@@ -8,6 +8,7 @@ from atocore.projects.registry import (
get_registered_project,
get_project_registry_template,
list_registered_projects,
register_project,
refresh_registered_project,
)
@@ -293,3 +294,90 @@ def test_project_registration_proposal_reports_collisions(tmp_path, monkeypatch)
assert proposal["valid"] is False
assert proposal["collisions"][0]["existing_project"] == "p05-interferometer"
def test_register_project_persists_new_entry(tmp_path, monkeypatch):
vault_dir = tmp_path / "vault"
drive_dir = tmp_path / "drive"
config_dir = tmp_path / "config"
staged = vault_dir / "incoming" / "projects" / "p07-example"
staged.mkdir(parents=True)
drive_dir.mkdir()
config_dir.mkdir()
registry_path = config_dir / "project-registry.json"
registry_path.write_text(json.dumps({"projects": []}), encoding="utf-8")
monkeypatch.setenv("ATOCORE_VAULT_SOURCE_DIR", str(vault_dir))
monkeypatch.setenv("ATOCORE_DRIVE_SOURCE_DIR", str(drive_dir))
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
original_settings = config.settings
try:
config.settings = config.Settings()
result = register_project(
project_id="p07-example",
aliases=["p07", "example-project"],
description="Example project",
ingest_roots=[
{
"source": "vault",
"subpath": "incoming/projects/p07-example",
"label": "Primary docs",
}
],
)
finally:
config.settings = original_settings
assert result["status"] == "registered"
payload = json.loads(registry_path.read_text(encoding="utf-8"))
assert payload["projects"][0]["id"] == "p07-example"
assert payload["projects"][0]["aliases"] == ["p07", "example-project"]
def test_register_project_rejects_collisions(tmp_path, monkeypatch):
vault_dir = tmp_path / "vault"
drive_dir = tmp_path / "drive"
config_dir = tmp_path / "config"
vault_dir.mkdir()
drive_dir.mkdir()
config_dir.mkdir()
registry_path = config_dir / "project-registry.json"
registry_path.write_text(
json.dumps(
{
"projects": [
{
"id": "p05-interferometer",
"aliases": ["p05", "interferometer"],
"ingest_roots": [
{"source": "vault", "subpath": "incoming/projects/p05-interferometer"}
],
}
]
}
),
encoding="utf-8",
)
monkeypatch.setenv("ATOCORE_VAULT_SOURCE_DIR", str(vault_dir))
monkeypatch.setenv("ATOCORE_DRIVE_SOURCE_DIR", str(drive_dir))
monkeypatch.setenv("ATOCORE_PROJECT_REGISTRY_PATH", str(registry_path))
original_settings = config.settings
try:
config.settings = config.Settings()
try:
register_project(
project_id="p07-example",
aliases=["interferometer"],
ingest_roots=[
{"source": "vault", "subpath": "incoming/projects/p07-example"}
],
)
except ValueError as exc:
assert "collisions" in str(exc)
else:
raise AssertionError("Expected collision to prevent project registration")
finally:
config.settings = original_settings