diff --git a/.env.example b/.env.example index ee71338..18ff83d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/deploy/dalidou/docker-compose.yml b/deploy/dalidou/docker-compose.yml index e00e9fa..58ca585 100644 --- a/deploy/dalidou/docker-compose.yml +++ b/deploy/dalidou/docker-compose.yml @@ -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: diff --git a/docs/project-registration-policy.md b/docs/project-registration-policy.md index 5a5fa69..f94e4ca 100644 --- a/docs/project-registration-policy.md +++ b/docs/project-registration-policy.md @@ -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 ` -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` diff --git a/docs/source-refresh-model.md b/docs/source-refresh-model.md index dcdadf4..90702c0 100644 --- a/docs/source-refresh-model.md +++ b/docs/source-refresh-model.md @@ -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 diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 8231786..11f86c2 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -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.""" diff --git a/src/atocore/config.py b/src/atocore/config.py index 37e8fc2..8f05fe5 100644 --- a/src/atocore/config.py +++ b/src/atocore/config.py @@ -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 diff --git a/src/atocore/projects/registry.py b/src/atocore/projects/registry.py index f3e2256..9070c66 100644 --- a/src/atocore/projects/registry.py +++ b/src/atocore/projects/registry.py @@ -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", + ) diff --git a/tests/test_api_storage.py b/tests/test_api_storage.py index b3e468e..67cafe1 100644 --- a/tests/test_api_storage.py +++ b/tests/test_api_storage.py @@ -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"] diff --git a/tests/test_config.py b/tests/test_config.py index f876c7a..a64f1d7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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: diff --git a/tests/test_project_registry.py b/tests/test_project_registry.py index 8f6f55c..d03b2b1 100644 --- a/tests/test_project_registry.py +++ b/tests/test_project_registry.py @@ -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