diff --git a/docs/engineering/workflow_first_jira_to_von_migration_jvnautosci_1223.md b/docs/engineering/workflow_first_jira_to_von_migration_jvnautosci_1223.md index 665767892..c9f15ab33 100644 --- a/docs/engineering/workflow_first_jira_to_von_migration_jvnautosci_1223.md +++ b/docs/engineering/workflow_first_jira_to_von_migration_jvnautosci_1223.md @@ -98,6 +98,8 @@ to their retained original without needing Jira. Native MCP tools expose the same canonical services, including `task_list_projects`, `task_get_project`, `task_get_collection`, `task_get`, `task_search` and `task_get_source_archive`. `task_get` accepts legacy Jira keys as well as Von task concept IDs. +Project listings return navigation metadata and descriptions; retrieve source +archives, retained-document references and their histories with `task_get_project`. A local stdio connection can bind the trusted operator once in its server environment: diff --git a/src/backend/services/task_project_service.py b/src/backend/services/task_project_service.py index 4d0ae6557..e4f1b72bc 100644 --- a/src/backend/services/task_project_service.py +++ b/src/backend/services/task_project_service.py @@ -23,6 +23,20 @@ PROJECT_RECORD = "task_project" COLLECTION_RECORD = "task_collection" +_PROJECT_LIST_FIELDS = ( + "name", + "key", + "description", + "source_system", + "source_site", + "source_id", + "source_url", + "organisation_concept_id", + "domain_project_concept_id", + "collection_concept_ids", + "writer", + "cutover_state", +) def _find_concept(concept_id): @@ -60,10 +74,22 @@ def list_project_collections(project): def list_task_projects(*, limit=100, offset=0): + """List navigation metadata; archive details remain in get_task_project. + + Retained source histories can grow independently of project navigation. + Do not load those histories and document manifests into every catalogue + response, where they can exceed the MCP transport's response size limit. + """ query = {f"attributes.{PROJECT_RECORD}": {"$exists": True}} docs = ConceptsRepository.find( query, - projection={"concept_id": 1, "attributes.task_project": 1}, + projection={ + "concept_id": 1, + **{ + f"attributes.{PROJECT_RECORD}.{field}": 1 + for field in _PROJECT_LIST_FIELDS + }, + }, limit=max(1, min(int(limit), 200)), skip=max(0, int(offset)), ) diff --git a/tests/backend/test_task_project_service.py b/tests/backend/test_task_project_service.py index 676280a70..917d395df 100644 --- a/tests/backend/test_task_project_service.py +++ b/tests/backend/test_task_project_service.py @@ -1,10 +1,66 @@ import copy +import json import pytest from src.backend.services import task_project_service as projects +def test_project_catalogue_does_not_grow_with_retained_source_history(monkeypatch): + record = { + "name": "Research project", + "key": "RESEARCH", + "description": "The complete project description", + "writer": "jira", + "source_id": "123", + "collection_concept_ids": ["#V#research_tasks"], + "source_archive_history": [{"manifest": "x" * 100_000}], + "external_document_archives": [{"manifest": "y" * 100_000}], + } + query_seen = {} + + def find(query, *, projection, limit, skip): + query_seen.update(query=query, limit=limit, skip=skip) + selected = { + key: value + for key, value in record.items() + if projection.get("attributes.task_project") + or projection.get(f"attributes.task_project.{key}") + } + return [ + { + "concept_id": f"#V#project_{index}", + "attributes": {"task_project": selected}, + } + for index in range(skip, min(skip + limit, 43)) + ] + + monkeypatch.setattr(projects.ConceptsRepository, "find", find) + result = projects.list_task_projects() + assert len(result) == 43 + assert len(json.dumps({"success": True, "projects": result})) < 100_000 + assert result[0]["description"] == record["description"] + assert result[0]["collection_concept_ids"] == record["collection_concept_ids"] + assert result[0]["writer"] == "jira" + assert result[0]["source_id"] == "123" + assert projects.list_task_projects(limit=2, offset=41) == result[41:] + assert query_seen == { + "query": {"attributes.task_project": {"$exists": True}}, + "limit": 2, + "skip": 41, + } + + monkeypatch.setattr(projects, "can_access_concept", lambda key: True) + monkeypatch.setattr( + projects, + "get_concept_by_concept_id_exact", + lambda key: {"attributes": {"task_project": copy.deepcopy(record)}}, + ) + detail = projects.get_task_project("#V#project_0") + assert detail["source_archive_history"] == record["source_archive_history"] + assert detail["external_document_archives"] == record["external_document_archives"] + + def test_project_collection_rerun_preserves_identity_and_writer(monkeypatch): docs = {} publish_during_refresh = False