From 6d6ed759c8df3bf0db9124a55de8a4b0f6d327b8 Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 8 Jun 2026 21:52:00 +0100 Subject: [PATCH 01/20] feat(core): document resource for storing text documents related to resources --- .../skills/examining-ado-operations/SKILL.md | 30 +++++ .cursor/skills/examining-ado-project/SKILL.md | 32 +++++ .../examining-discovery-spaces/SKILL.md | 30 +++++ orchestrator/cli/commands/create.py | 2 + orchestrator/cli/commands/delete.py | 2 + orchestrator/cli/commands/edit.py | 2 + orchestrator/cli/commands/get.py | 2 + orchestrator/cli/commands/template.py | 2 + orchestrator/cli/models/types.py | 6 + orchestrator/cli/utils/resources/mappings.py | 2 + orchestrator/core/__init__.py | 2 + orchestrator/core/resources.py | 1 + orchestrator/metastore/base.py | 4 + orchestrator/metastore/sqlstore.py | 31 +++++ tests/ado/create/test_ado_create_document.py | 126 ++++++++++++++++++ tests/ado/delete/test_ado_delete_document.py | 72 ++++++++++ 16 files changed, 346 insertions(+) create mode 100644 tests/ado/create/test_ado_create_document.py create mode 100644 tests/ado/delete/test_ado_delete_document.py diff --git a/.cursor/skills/examining-ado-operations/SKILL.md b/.cursor/skills/examining-ado-operations/SKILL.md index 480c91b2c..7ab28172d 100644 --- a/.cursor/skills/examining-ado-operations/SKILL.md +++ b/.cursor/skills/examining-ado-operations/SKILL.md @@ -125,6 +125,14 @@ failure. In this case: If the operation is finished, +- Query the metastore for an existing document linked to this operation: + + ```bash + uv run ado get document -q config.relatedResources[*]=OPERATION_ID + ``` + + If a document is found, retrieve its metadata (name, created timestamp) and ask + the user whether to replace it with a new report. - Check if there is an existing report for this operation in `reports//` - If yes, check if that report indicated the operation was finished @@ -310,6 +318,28 @@ Structure the report as: distributions 5. **Next Steps**: A plan for the next research steps to take using ado. +After writing the local report file, persist it as a document resource: + +```yaml +# __document.yaml (temp file, not committed) +metadata: + name: "" + description: "" +content: | + +relatedResources: + - + - +``` + +Any charts or images generated during analysis should be base64-encoded and +included in the `attachments` section, with the markdown referencing them by +filename (for example `![Performance chart](perf_chart.png)`). + +```bash +uv run ado create document -f __document.yaml +``` + ## Troubleshooting ### Number points sampled is greater than the number of entities measured by operation diff --git a/.cursor/skills/examining-ado-project/SKILL.md b/.cursor/skills/examining-ado-project/SKILL.md index f1408ad17..5158f89e9 100644 --- a/.cursor/skills/examining-ado-project/SKILL.md +++ b/.cursor/skills/examining-ado-project/SKILL.md @@ -163,6 +163,13 @@ Write a concise markdown report needed), where `ado_context_name` is the **active ado metastore context** (`uv run ado context`). - Write the report as `project__report.md`. +- Query the metastore for an existing project report document: + + ```bash + uv run ado get document -q metadata.name=project__report + ``` + + If a matching document is found, ask the user whether to replace it. - If a report already exists, check whether there has been meaningful activity since it was written before replacing it: 1. Run `uv run ado get spaces --details` and @@ -199,3 +206,28 @@ Write a concise markdown report - Which operations **submitted** the most entities (from operation YAML/config). - What **analysis**-style operations ran (infer from operator names and parameters). + +After writing the local report file, persist it as a document resource. Since a +project report spans many resources, set `relatedResources` to the identifiers +of the most recently active spaces and operations (for example the five most +recently created of each): + +```yaml +# project__document.yaml (temp file, not committed) +metadata: + name: "project__report" + description: "" +content: | + +relatedResources: + - + - +``` + +Any charts or images generated during analysis should be base64-encoded and +included in the `attachments` section, with the markdown referencing them by +filename. + +```bash +uv run ado create document -f project__document.yaml +``` diff --git a/.cursor/skills/examining-discovery-spaces/SKILL.md b/.cursor/skills/examining-discovery-spaces/SKILL.md index c5058990c..f223b5c98 100644 --- a/.cursor/skills/examining-discovery-spaces/SKILL.md +++ b/.cursor/skills/examining-discovery-spaces/SKILL.md @@ -151,6 +151,14 @@ overlapping spaces exist. ### Step 3: Check for existing report +- Query the metastore for an existing document linked to this space: + + ```bash + uv run ado get document -q config.relatedResources[*]=SPACE_ID + ``` + + If a document is found, retrieve its metadata (name, created timestamp) and + ask the user whether to replace it with a new report. - Check if there is an existing report for this space in `reports//` - If yes, check if either of the following are true: @@ -219,3 +227,25 @@ Structure the report as: 4. **Data summary** – distributions of measured properties, notable performers, outliers, correlations 5. **Related operations** – which operations ran on this space and their status + +After writing the local report file, persist it as a document resource: + +```yaml +# __document.yaml (temp file, not committed) +metadata: + name: "" + description: "" +content: | + +relatedResources: + - + - +``` + +Any charts or images generated during analysis should be base64-encoded and +included in the `attachments` section, with the markdown referencing them by +filename. + +```bash +uv run ado create document -f __document.yaml +``` diff --git a/orchestrator/cli/commands/create.py b/orchestrator/cli/commands/create.py index e3f24ae2e..ef1072d64 100644 --- a/orchestrator/cli/commands/create.py +++ b/orchestrator/cli/commands/create.py @@ -22,6 +22,7 @@ ) from orchestrator.cli.resources.context.create import create_context from orchestrator.cli.resources.discovery_space.create import create_discovery_space +from orchestrator.cli.resources.document.create import create_document from orchestrator.cli.resources.operation.create import create_operation from orchestrator.cli.resources.sample_store.create import create_sample_store from orchestrator.cli.utils.input.parsers import ( @@ -239,6 +240,7 @@ def create_resource( AdoCreateSupportedResourceTypes.ACTUATOR_CONFIGURATION: create_actuator_configuration, AdoCreateSupportedResourceTypes.CONTEXT: create_context, AdoCreateSupportedResourceTypes.DISCOVERY_SPACE: create_discovery_space, + AdoCreateSupportedResourceTypes.DOCUMENT: create_document, AdoCreateSupportedResourceTypes.SAMPLE_STORE: create_sample_store, AdoCreateSupportedResourceTypes.OPERATION: create_operation, } diff --git a/orchestrator/cli/commands/delete.py b/orchestrator/cli/commands/delete.py index 14ea481c0..0c7ba0092 100644 --- a/orchestrator/cli/commands/delete.py +++ b/orchestrator/cli/commands/delete.py @@ -14,6 +14,7 @@ from orchestrator.cli.resources.context.delete import delete_context from orchestrator.cli.resources.data_container.delete import delete_data_container from orchestrator.cli.resources.discovery_space.delete import delete_discovery_space +from orchestrator.cli.resources.document.delete import delete_document from orchestrator.cli.resources.operation.delete import delete_operation from orchestrator.cli.resources.sample_store.delete import delete_sample_store from orchestrator.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -165,6 +166,7 @@ def delete_resource( AdoDeleteSupportedResourceTypes.CONTEXT: delete_context, AdoDeleteSupportedResourceTypes.DATA_CONTAINER: delete_data_container, AdoDeleteSupportedResourceTypes.DISCOVERY_SPACE: delete_discovery_space, + AdoDeleteSupportedResourceTypes.DOCUMENT: delete_document, AdoDeleteSupportedResourceTypes.SAMPLE_STORE: delete_sample_store, AdoDeleteSupportedResourceTypes.OPERATION: delete_operation, } diff --git a/orchestrator/cli/commands/edit.py b/orchestrator/cli/commands/edit.py index fa85635f3..df1927eba 100644 --- a/orchestrator/cli/commands/edit.py +++ b/orchestrator/cli/commands/edit.py @@ -21,6 +21,7 @@ ) from orchestrator.cli.resources.data_container.edit import edit_data_container from orchestrator.cli.resources.discovery_space.edit import edit_discovery_space +from orchestrator.cli.resources.document.edit import edit_document from orchestrator.cli.resources.operation.edit import edit_operation from orchestrator.cli.resources.sample_store.edit import edit_sample_store from orchestrator.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -132,6 +133,7 @@ def edit_resource( AdoEditSupportedResourceTypes.ACTUATOR_CONFIGURATION: edit_actuator_configuration, AdoEditSupportedResourceTypes.DATA_CONTAINER: edit_data_container, AdoEditSupportedResourceTypes.DISCOVERY_SPACE: edit_discovery_space, + AdoEditSupportedResourceTypes.DOCUMENT: edit_document, AdoEditSupportedResourceTypes.SAMPLE_STORE: edit_sample_store, AdoEditSupportedResourceTypes.OPERATION: edit_operation, } diff --git a/orchestrator/cli/commands/get.py b/orchestrator/cli/commands/get.py index db956f3c4..f5ffdd138 100644 --- a/orchestrator/cli/commands/get.py +++ b/orchestrator/cli/commands/get.py @@ -23,6 +23,7 @@ from orchestrator.cli.resources.context.get import get_context from orchestrator.cli.resources.data_container.get import get_data_container from orchestrator.cli.resources.discovery_space.get import get_discovery_space +from orchestrator.cli.resources.document.get import get_document from orchestrator.cli.resources.experiment.get import get_experiment from orchestrator.cli.resources.measurement_request.get import get_measurement_request from orchestrator.cli.resources.operation.get import get_operation @@ -412,6 +413,7 @@ def get_resource( AdoGetSupportedResourceTypes.CONTEXT: get_context, AdoGetSupportedResourceTypes.DATA_CONTAINER: get_data_container, AdoGetSupportedResourceTypes.DISCOVERY_SPACE: get_discovery_space, + AdoGetSupportedResourceTypes.DOCUMENT: get_document, AdoGetSupportedResourceTypes.EXPERIMENT: get_experiment, AdoGetSupportedResourceTypes.SAMPLE_STORE: get_sample_store, AdoGetSupportedResourceTypes.MEASUREMENT_REQUEST: get_measurement_request, diff --git a/orchestrator/cli/commands/template.py b/orchestrator/cli/commands/template.py index 3f649eaf9..3bd9a9424 100644 --- a/orchestrator/cli/commands/template.py +++ b/orchestrator/cli/commands/template.py @@ -16,6 +16,7 @@ ) from orchestrator.cli.resources.context.template import template_context from orchestrator.cli.resources.discovery_space.template import template_discovery_space +from orchestrator.cli.resources.document.template import template_document from orchestrator.cli.resources.operation.template import template_operation from orchestrator.cli.resources.sample_store.template import template_sample_store from orchestrator.cli.utils.input.parsers import ( @@ -175,6 +176,7 @@ def template_resource( AdoTemplateSupportedResourceTypes.ACTUATOR_CONFIGURATION: template_actuator_configuration, AdoTemplateSupportedResourceTypes.CONTEXT: template_context, AdoTemplateSupportedResourceTypes.DISCOVERY_SPACE: template_discovery_space, + AdoTemplateSupportedResourceTypes.DOCUMENT: template_document, AdoTemplateSupportedResourceTypes.SAMPLE_STORE: template_sample_store, AdoTemplateSupportedResourceTypes.OPERATION: template_operation, } diff --git a/orchestrator/cli/models/types.py b/orchestrator/cli/models/types.py index 3a1f6282a..c01025ed8 100644 --- a/orchestrator/cli/models/types.py +++ b/orchestrator/cli/models/types.py @@ -11,6 +11,7 @@ _ACTUATOR_SINGULAR = "actuator" _CONTEXT_SINGULAR = "context" _DATA_CONTAINER_SINGULAR = "datacontainer" +_DOCUMENT_SINGULAR = "document" _DISCOVERY_SPACE_SINGULAR = "discoveryspace" _EXPERIMENT_SINGULAR = "experiment" _MEASUREMENT_REQUEST_SINGULAR = "measurementrequest" @@ -48,6 +49,7 @@ class AdoCreateSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR CONTEXT = _CONTEXT_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -64,6 +66,7 @@ class AdoDeleteSupportedResourceTypes(Enum): CONTEXT = _CONTEXT_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -86,6 +89,7 @@ class AdoEditSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR @@ -106,6 +110,7 @@ class AdoGetSupportedResourceTypes(Enum): CONTEXT = _CONTEXT_SINGULAR DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR EXPERIMENT = _EXPERIMENT_SINGULAR MEASUREMENT_REQUEST = _MEASUREMENT_REQUEST_SINGULAR OPERATION = _OPERATION_SINGULAR @@ -191,6 +196,7 @@ class AdoTemplateSupportedResourceTypes(Enum): ACTUATOR_CONFIGURATION = _ACTUATOR_CONFIGURATION_SINGULAR CONTEXT = _CONTEXT_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR OPERATION = _OPERATION_SINGULAR SAMPLE_STORE = _SAMPLE_STORE_SINGULAR diff --git a/orchestrator/cli/utils/resources/mappings.py b/orchestrator/cli/utils/resources/mappings.py index 1d47ca0b0..10693237d 100644 --- a/orchestrator/cli/utils/resources/mappings.py +++ b/orchestrator/cli/utils/resources/mappings.py @@ -8,6 +8,7 @@ "ac": CoreResourceKinds.ACTUATORCONFIGURATION.value, "ctx": "context", "dcr": CoreResourceKinds.DATACONTAINER.value, + "doc": CoreResourceKinds.DOCUMENT.value, "exp": "experiment", "op": CoreResourceKinds.OPERATION.value, "request": "measurementrequest", @@ -19,6 +20,7 @@ CoreResourceKinds.ACTUATORCONFIGURATION: "actuator configuration", CoreResourceKinds.DATACONTAINER: "data container", CoreResourceKinds.DISCOVERYSPACE: "space", + CoreResourceKinds.DOCUMENT: "document", CoreResourceKinds.OPERATION: "operation", CoreResourceKinds.SAMPLESTORE: "sample store", } diff --git a/orchestrator/core/__init__.py b/orchestrator/core/__init__.py index 3d40e7f62..263b07459 100644 --- a/orchestrator/core/__init__.py +++ b/orchestrator/core/__init__.py @@ -4,6 +4,7 @@ from .actuatorconfiguration.resource import ActuatorConfigurationResource from .datacontainer.resource import DataContainerResource from .discoveryspace.resource import DiscoverySpaceResource +from .document.resource import DocumentResource from .operation.resource import OperationResource from .resources import ADOResource as ADOResource from .resources import CoreResourceKinds @@ -15,4 +16,5 @@ CoreResourceKinds.SAMPLESTORE.value: SampleStoreResource, CoreResourceKinds.ACTUATORCONFIGURATION.value: ActuatorConfigurationResource, CoreResourceKinds.DATACONTAINER.value: DataContainerResource, + CoreResourceKinds.DOCUMENT.value: DocumentResource, } diff --git a/orchestrator/core/resources.py b/orchestrator/core/resources.py index 586d3238b..60211424b 100644 --- a/orchestrator/core/resources.py +++ b/orchestrator/core/resources.py @@ -22,6 +22,7 @@ class CoreResourceKinds(enum.Enum): ACTUATORCONFIGURATION = "actuatorconfiguration" SAMPLESTORE = "samplestore" DATACONTAINER = "datacontainer" + DOCUMENT = "document" def VersionIsGreaterThan(v1: str, v2: str) -> bool: diff --git a/orchestrator/metastore/base.py b/orchestrator/metastore/base.py index 8a473b27d..e0f1cb247 100644 --- a/orchestrator/metastore/base.py +++ b/orchestrator/metastore/base.py @@ -312,6 +312,10 @@ def delete_data_container(self, identifier: str) -> None: def delete_actuator_configuration(self, identifier: str) -> None: pass + @abc.abstractmethod + def delete_document(self, identifier: str) -> None: + pass + def sample_store_dump( sample_store_resource: SampleStoreResource, diff --git a/orchestrator/metastore/sqlstore.py b/orchestrator/metastore/sqlstore.py index 4dc0b06d5..7efb786c0 100644 --- a/orchestrator/metastore/sqlstore.py +++ b/orchestrator/metastore/sqlstore.py @@ -1540,3 +1540,34 @@ def delete_actuator_configuration(self, identifier: str) -> None: resource_kind=CoreResourceKinds.ACTUATORCONFIGURATION, rollback_occurred=True, ) from e + + def delete_document(self, identifier: str) -> None: + import sqlalchemy.orm + + with sqlalchemy.orm.Session(self.engine) as session: + try: + with session.begin(): + + session.execute( + sqlalchemy.text( + r"DELETE FROM resource_relationships WHERE object_identifier=:identifier" + ).bindparams(identifier=identifier) + ) + + session.execute( + sqlalchemy.text( + r"DELETE FROM resources " + r"WHERE identifier=:identifier AND kind=:kind" + ).bindparams( + identifier=identifier, + kind=CoreResourceKinds.DOCUMENT.value, + ) + ) + + except Exception as e: + session.rollback() + raise DeleteFromDatabaseError( + resource_id=identifier, + resource_kind=CoreResourceKinds.DOCUMENT, + rollback_occurred=True, + ) from e diff --git a/tests/ado/create/test_ado_create_document.py b/tests/ado/create/test_ado_create_document.py new file mode 100644 index 000000000..6d8c85f14 --- /dev/null +++ b/tests/ado/create/test_ado_create_document.py @@ -0,0 +1,126 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +from collections.abc import Callable + +import yaml +from typer.testing import CliRunner + +from orchestrator.cli.core.cli import app as ado +from orchestrator.core.document.config import DocumentConfiguration +from orchestrator.core.document.resource import DocumentResource +from orchestrator.metastore.project import ProjectContext +from orchestrator.metastore.sqlstore import SQLStore + + +def test_create_document_dry_run_success(tmp_path: pathlib.Path) -> None: + """Dry-run validates a document configuration without persisting it.""" + document_file = "tests/fixtures/document.yaml" + runner = CliRunner() + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + document_file, + "--dry-run", + ], + ) + assert result.exit_code == 0 + assert "The configuration passed is valid!" in result.output + + +def test_create_document_dry_run_failure(tmp_path: pathlib.Path) -> None: + """Dry-run rejects an invalid document configuration.""" + document_file = pathlib.Path("tests/fixtures/document.yaml") + invalid_document_file = tmp_path / "invalid.yaml" + document_configuration = yaml.safe_load(document_file.read_text()) + del document_configuration["content"] + invalid_document_file.write_text(yaml.safe_dump(document_configuration)) + + runner = CliRunner() + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + invalid_document_file, + "--dry-run", + ], + ) + assert result.exit_code == 1 + assert "The document provided was not valid:" in result.output + + +def test_create_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], +) -> None: + """Create persists a document resource.""" + document_file = "tests/fixtures/document.yaml" + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "create", + "document", + "-f", + document_file, + ], + ) + + assert result.exit_code == 0 + assert result.output.startswith("Success! Created document with identifier") + + +def test_get_document_config_round_trip( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, +) -> None: + """Get document -o config returns the stored configuration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(pathlib.Path("tests/fixtures/document.yaml").read_text()) + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "get", + "document", + resource.identifier, + "-o", + "config", + ], + ) + + assert result.exit_code == 0 + restored = DocumentConfiguration.model_validate(yaml.safe_load(result.output)) + assert restored.content == config.content + assert restored.relatedResources == config.relatedResources diff --git a/tests/ado/delete/test_ado_delete_document.py b/tests/ado/delete/test_ado_delete_document.py new file mode 100644 index 000000000..206fb7835 --- /dev/null +++ b/tests/ado/delete/test_ado_delete_document.py @@ -0,0 +1,72 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +from collections.abc import Callable + +from typer.testing import CliRunner + +from orchestrator.cli.core.cli import app as ado +from orchestrator.core.document.config import DocumentConfiguration +from orchestrator.core.document.resource import DocumentResource +from orchestrator.metastore.project import ProjectContext +from orchestrator.metastore.sqlstore import SQLStore + + +def test_delete_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, +) -> None: + """Delete removes a document resource.""" + config = DocumentConfiguration(content="Example report") + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "delete", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert "Success!" in result.output.strip() + + +def test_delete_nonexistent_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], +) -> None: + """Delete reports an error for a missing document.""" + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "delete", + "document", + "does-not-exist", + ], + ) + assert result.exit_code == 1, result.output + assert "Failed to delete does-not-exist" in result.output + assert "Resource does not exist" in result.output From 4f42a37a12abf908885f874db2b473d80c3b1f39 Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 8 Jun 2026 21:53:46 +0100 Subject: [PATCH 02/20] test(document): additional tests --- tests/core/document/conftest.py | 18 ++++++ tests/core/document/test_document_config.py | 58 +++++++++++++++++++ tests/core/document/test_document_resource.py | 52 +++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 tests/core/document/conftest.py create mode 100644 tests/core/document/test_document_config.py create mode 100644 tests/core/document/test_document_resource.py diff --git a/tests/core/document/conftest.py b/tests/core/document/conftest.py new file mode 100644 index 000000000..3f56527bb --- /dev/null +++ b/tests/core/document/conftest.py @@ -0,0 +1,18 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib + +import pytest + + +@pytest.fixture +def document_configuration_file() -> pathlib.Path: + """Return path to a valid document configuration fixture.""" + return pathlib.Path("tests/fixtures/document.yaml") + + +@pytest.fixture +def document_with_attachment_configuration_file() -> pathlib.Path: + """Return path to a document configuration fixture with attachments.""" + return pathlib.Path("tests/fixtures/document_with_attachment.yaml") diff --git a/tests/core/document/test_document_config.py b/tests/core/document/test_document_config.py new file mode 100644 index 000000000..3381e6ed1 --- /dev/null +++ b/tests/core/document/test_document_config.py @@ -0,0 +1,58 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib + +import pydantic +import pytest +import yaml + +from orchestrator.core.document.config import DocumentConfiguration +from orchestrator.utilities.output import pydantic_model_as_yaml + + +def test_document_configuration_defaults() -> None: + """DocumentConfiguration defaults relatedResources and attachments.""" + config = DocumentConfiguration(content="Example report") + assert config.relatedResources == [] + assert config.attachments == {} + + +def test_document_configuration_requires_content() -> None: + """DocumentConfiguration requires content.""" + with pytest.raises(pydantic.ValidationError): + DocumentConfiguration.model_validate({}) + + +def test_document_configuration_round_trip_yaml() -> None: + """DocumentConfiguration round-trips through YAML.""" + config = DocumentConfiguration( + content="# Report\n\nBody text", + relatedResources=["operation-abc-12345678"], + attachments={"chart.png": "cGV4"}, + metadata={"name": "Test report"}, + ) + yaml_text = pydantic_model_as_yaml(config) + restored = DocumentConfiguration.model_validate(yaml.safe_load(yaml_text)) + assert restored == config + + +def test_document_configuration_from_fixture( + document_configuration_file: pathlib.Path, +) -> None: + """Document fixture validates as DocumentConfiguration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(document_configuration_file.read_text()) + ) + assert config.content.startswith("# Operation report") + assert config.relatedResources == ["operation-test-12345678"] + + +def test_document_configuration_with_attachment_fixture( + document_with_attachment_configuration_file: pathlib.Path, +) -> None: + """Document with attachment fixture validates as DocumentConfiguration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(document_with_attachment_configuration_file.read_text()) + ) + assert config.attachments == {"perf_chart.png": "cGV4"} diff --git a/tests/core/document/test_document_resource.py b/tests/core/document/test_document_resource.py new file mode 100644 index 000000000..2bea82cf1 --- /dev/null +++ b/tests/core/document/test_document_resource.py @@ -0,0 +1,52 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import re + +import pydantic +import pytest + +from orchestrator.core.document.config import DocumentConfiguration +from orchestrator.core.document.resource import DocumentResource +from orchestrator.core.resources import CoreResourceKinds + + +def test_document_resource_lifecycle() -> None: + """DocumentResource round-trips through model_dump and model_validate.""" + config = DocumentConfiguration( + content="# Report\n\nExample body", + relatedResources=["operation-test-12345678"], + attachments={"chart.png": "cGV4"}, + ) + resource = DocumentResource(config=config) + + dumped = resource.model_dump() + restored = DocumentResource.model_validate(dumped) + assert restored == resource + + +def test_document_resource_identifier_auto_generation() -> None: + """DocumentResource auto-generates a document identifier.""" + config = DocumentConfiguration(content="Example report") + resource = DocumentResource(config=config) + + assert re.fullmatch(r"document-[0-9a-f]{8}", resource.identifier) + + +def test_document_resource_kind_is_pinned() -> None: + """DocumentResource kind is pinned to document.""" + config = DocumentConfiguration(content="Example report") + resource = DocumentResource(config=config) + + assert resource.kind == CoreResourceKinds.DOCUMENT + + +def test_document_resource_requires_content() -> None: + """DocumentResource requires config content.""" + with pytest.raises(pydantic.ValidationError, match="content"): + DocumentResource.model_validate( + { + "kind": CoreResourceKinds.DOCUMENT, + "config": {}, + } + ) From 2aebbf259f0a945173634ca691fac7baa07354fc Mon Sep 17 00:00:00 2001 From: michaelj Date: Wed, 1 Jul 2026 20:07:59 +0100 Subject: [PATCH 03/20] feat(core): document resource --- orchestrator/core/document/__init__.py | 2 ++ orchestrator/core/document/config.py | 44 ++++++++++++++++++++++++++ orchestrator/core/document/resource.py | 29 +++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 orchestrator/core/document/__init__.py create mode 100644 orchestrator/core/document/config.py create mode 100644 orchestrator/core/document/resource.py diff --git a/orchestrator/core/document/__init__.py b/orchestrator/core/document/__init__.py new file mode 100644 index 000000000..2a4c79e8b --- /dev/null +++ b/orchestrator/core/document/__init__.py @@ -0,0 +1,2 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT diff --git a/orchestrator/core/document/config.py b/orchestrator/core/document/config.py new file mode 100644 index 000000000..211601b78 --- /dev/null +++ b/orchestrator/core/document/config.py @@ -0,0 +1,44 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +from typing import Annotated + +import pydantic +from pydantic import ConfigDict + +from orchestrator.core.metadata import ConfigurationMetadata + + +class DocumentConfiguration(pydantic.BaseModel): + """Configuration for a document resource.""" + + model_config = ConfigDict(extra="forbid") + + content: Annotated[ + str, + pydantic.Field(description="Markdown body of the document"), + ] + relatedResources: Annotated[ + list[str], + pydantic.Field( + default_factory=list, + description="Identifiers of related ado resources, if any", + ), + ] + attachments: Annotated[ + dict[str, str], + pydantic.Field( + default_factory=dict, + description=( + "Mapping of filename to base64-encoded content referenced " + "from the markdown content" + ), + ), + ] + metadata: Annotated[ + ConfigurationMetadata, + pydantic.Field( + description="Metadata about the document including optional name, " + "description, labels for filtering, and any additional custom fields" + ), + ] = ConfigurationMetadata() diff --git a/orchestrator/core/document/resource.py b/orchestrator/core/document/resource.py new file mode 100644 index 000000000..1ca3df85b --- /dev/null +++ b/orchestrator/core/document/resource.py @@ -0,0 +1,29 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import uuid +from typing import Annotated, Any + +import pydantic + +from orchestrator.core.document.config import DocumentConfiguration +from orchestrator.core.resources import ADOResource, CoreResourceKinds +from orchestrator.utilities.pydantic import Defaultable + + +class DocumentResource(ADOResource): + """A resource that stores markdown documents and optional attachments.""" + + @staticmethod + def _identifier_from_data(data: dict[str, Any]) -> str: + return f"document-{str(uuid.uuid4())[:8]}" + + version: Annotated[str, pydantic.Field()] = "v1" + kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT + config: DocumentConfiguration + identifier: Annotated[ + Defaultable[str], + pydantic.Field( + default_factory=_identifier_from_data, + ), + ] From a65059e4d0b96b2c39689c7fa10117b316cb9f72 Mon Sep 17 00:00:00 2001 From: michaelj Date: Wed, 1 Jul 2026 20:09:47 +0100 Subject: [PATCH 04/20] test(core): fixtures for document testing --- tests/fixtures/document.yaml | 11 +++++++++++ tests/fixtures/document_with_attachment.yaml | 13 +++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 tests/fixtures/document.yaml create mode 100644 tests/fixtures/document_with_attachment.yaml diff --git a/tests/fixtures/document.yaml b/tests/fixtures/document.yaml new file mode 100644 index 000000000..64f928782 --- /dev/null +++ b/tests/fixtures/document.yaml @@ -0,0 +1,11 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +metadata: + name: Example operation report + description: Summary report for an operation +content: | + # Operation report + + This is an example document resource. +relatedResources: + - operation-test-12345678 diff --git a/tests/fixtures/document_with_attachment.yaml b/tests/fixtures/document_with_attachment.yaml new file mode 100644 index 000000000..559bdcc50 --- /dev/null +++ b/tests/fixtures/document_with_attachment.yaml @@ -0,0 +1,13 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +metadata: + name: Report with attachment + description: Example document with an embedded image +content: | + # Report with chart + + ![Performance chart](perf_chart.png) +relatedResources: + - space-test-12345678 +attachments: + perf_chart.png: cGV4 From 46f347c685674590958f267e2d77dc4dc1480641 Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 21 Jul 2026 16:21:13 +0100 Subject: [PATCH 05/20] fix(core): remove attachments features Can re-add separately, possible by different mechanism --- ado/core/document/config.py | 10 ---------- tests/core/document/test_document_config.py | 11 ----------- 2 files changed, 21 deletions(-) diff --git a/ado/core/document/config.py b/ado/core/document/config.py index bce485f96..fed1f5426 100644 --- a/ado/core/document/config.py +++ b/ado/core/document/config.py @@ -25,16 +25,6 @@ class DocumentConfiguration(pydantic.BaseModel): description="Identifiers of related ado resources, if any", ), ] - attachments: Annotated[ - dict[str, str], - pydantic.Field( - default_factory=dict, - description=( - "Mapping of filename to base64-encoded content referenced " - "from the markdown content" - ), - ), - ] metadata: Annotated[ ConfigurationMetadata, pydantic.Field( diff --git a/tests/core/document/test_document_config.py b/tests/core/document/test_document_config.py index bcaf2c502..af5b40c73 100644 --- a/tests/core/document/test_document_config.py +++ b/tests/core/document/test_document_config.py @@ -15,7 +15,6 @@ def test_document_configuration_defaults() -> None: """DocumentConfiguration defaults relatedResources and attachments.""" config = DocumentConfiguration(content="Example report") assert config.relatedResources == [] - assert config.attachments == {} def test_document_configuration_requires_content() -> None: @@ -46,13 +45,3 @@ def test_document_configuration_from_fixture( ) assert config.content.startswith("# Operation report") assert config.relatedResources == ["operation-test-12345678"] - - -def test_document_configuration_with_attachment_fixture( - document_with_attachment_configuration_file: pathlib.Path, -) -> None: - """Document with attachment fixture validates as DocumentConfiguration.""" - config = DocumentConfiguration.model_validate( - yaml.safe_load(document_with_attachment_configuration_file.read_text()) - ) - assert config.attachments == {"perf_chart.png": "cGV4"} From 46efbcb074224141f3cceee564e294609946ba83 Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 21 Jul 2026 17:43:10 +0100 Subject: [PATCH 06/20] docs: update --- .../skills/examining-ado-operations/SKILL.md | 29 ++++++-------- .cursor/skills/examining-ado-project/SKILL.md | 38 ++++++++++--------- .../examining-discovery-spaces/SKILL.md | 29 ++++++-------- 3 files changed, 44 insertions(+), 52 deletions(-) diff --git a/.cursor/skills/examining-ado-operations/SKILL.md b/.cursor/skills/examining-ado-operations/SKILL.md index 1149dd9e8..b78451572 100644 --- a/.cursor/skills/examining-ado-operations/SKILL.md +++ b/.cursor/skills/examining-ado-operations/SKILL.md @@ -16,11 +16,9 @@ Structured workflow for understanding what an operation did, which space it ran on, and whether measurements and results look healthy. - Run all commands from the **repository root** with `uv run`. -- Write the report to `reports//` (create the directory if - needed) - - where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`) -- Write the report as `__report.md` +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context — + (see [Producing a report](#producing-a-report)). **Related skills**: @@ -131,13 +129,13 @@ If the operation is finished, uv run ado get document -q config.relatedResources[*]=OPERATION_ID ``` - If a document is found, retrieve its metadata (name, created timestamp) and ask - the user whether to replace it with a new report. -- Check if there is an existing report for this operation in - `reports//` -- If yes, check if that report indicated the operation was finished - - If yes, ask the user if they want to replace it with a new report - - If no, continue with creating new report + If a document is found, retrieve its metadata (name, created timestamp) and + fetch its `content` (`uv run ado get document DOCUMENT_ID -o yaml`) to check + if that report indicated the operation was finished. + - If yes, ask the user whether to replace it with a new report. If they + agree, delete the existing document (`uv run ado delete document + DOCUMENT_ID`) once the new report has been created. + - If no, continue with creating a new report. ### Step 3: Review the operator @@ -329,7 +327,8 @@ Structure the report as: distributions 5. **Next Steps**: A plan for the next research steps to take using ado. -After writing the local report file, persist it as a document resource: +Store the report by creating a document resource, writing the markdown +directly into the `content` field: ```yaml # __document.yaml (temp file, not committed) @@ -343,10 +342,6 @@ relatedResources: - ``` -Any charts or images generated during analysis should be base64-encoded and -included in the `attachments` section, with the markdown referencing them by -filename (for example `![Performance chart](perf_chart.png)`). - ```bash uv run ado create document -f __document.yaml ``` diff --git a/.cursor/skills/examining-ado-project/SKILL.md b/.cursor/skills/examining-ado-project/SKILL.md index 24bfa2ad4..1547d4e8d 100644 --- a/.cursor/skills/examining-ado-project/SKILL.md +++ b/.cursor/skills/examining-ado-project/SKILL.md @@ -211,12 +211,11 @@ uv run ado show related space SPACE_ID ## 4. Report template -Write a concise markdown report +Write a concise markdown report. The report is stored directly as the +`content` field of a `document` resource . -- Write the report to `reports//` (create the directory if - needed), where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`). -- Write the report as `project__report.md`. +- The document's `metadata.name` is `project__report`, where the + date is today's date - Query the metastore for an existing project report document: ```bash @@ -229,13 +228,14 @@ Write a concise markdown report 1. Run `uv run ado get spaces --details` and `uv run ado get operations --details` and note the age of the most recent resource. - 2. If the most recent resource is **younger** than the date of the existing - report, there has been new activity — proceed to write a new report. + 2. If the most recent resource is **younger** than the existing document's + `creationTimestamp`, there has been new activity — proceed to write a new + report. 3. If not, ask the user whether they want to replace it. 4. If finer-grained confirmation is needed, fetch the YAML of the most recent - space or operation - (`uv run ado get space SPACE_ID -o yaml --output-file SPACE_ID.yaml`, or - the same pattern for `operation`) and read its `creationTimestamp` field. + space or operation (`uv run ado get space SPACE_ID -o yaml --output-file + SPACE_ID.yaml`, or the same pattern for `operation`) and read its + `creationTimestamp` field. ### Project summary @@ -264,10 +264,10 @@ Write a concise markdown report - Make a note of operations with failed measurements and highlight ones with abnormal failure rates (from the stats). -After writing the local report file, persist it as a document resource. Since a -project report spans many resources, set `relatedResources` to the identifiers -of the most recently active spaces and operations (for example the five most -recently created of each): +Persist the report as a document resource. Since a project +report spans many resources, set `relatedResources` to the identifiers of the +most recently active spaces and operations (for example the five most recently +created of each): ```yaml # project__document.yaml (temp file, not committed) @@ -281,10 +281,12 @@ relatedResources: - ``` -Any charts or images generated during analysis should be base64-encoded and -included in the `attachments` section, with the markdown referencing them by -filename. - ```bash uv run ado create document -f project__document.yaml ``` + +If an existing report document is being replaced (see above), delete it first: + +```bash +uv run ado delete document EXISTING_DOCUMENT_ID +``` diff --git a/.cursor/skills/examining-discovery-spaces/SKILL.md b/.cursor/skills/examining-discovery-spaces/SKILL.md index d1031d966..042e462e5 100644 --- a/.cursor/skills/examining-discovery-spaces/SKILL.md +++ b/.cursor/skills/examining-discovery-spaces/SKILL.md @@ -15,11 +15,8 @@ Structured workflow for understanding what a discoveryspace contains, how covered its entity space is, and what data has been collected. - Run all commands from the **repository root** with `uv run`. -- Write the report to `reports//` (create the directory if - needed) - - where `ado_context_name` is the **active ado metastore context** - (`uv run ado context`) -- Write the report as `__report.md` +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context (see [Producing a Report](#producing-a-report)). **Related skills**: @@ -157,14 +154,16 @@ uv run ado show related space SPACE_ID ``` If a document is found, retrieve its metadata (name, created timestamp) and - ask the user whether to replace it with a new report. -- Check if there is an existing report for this space in - `reports//` -- If yes, check if either of the following are true: - - New operations have been run on space since report + fetch its `content` (`uv run ado get document DOCUMENT_ID -o yaml`) to + compare against current state. +- If a document is found, check if either of the following are true: + - New operations have been run on the space since the document was created - The number of measured entities has increased -- If neither of above are true, ask the user if they want to write a new report - or use existing +- If yes to either, ask the user whether to replace it with a new report. If + they agree, delete the existing document (`uv run ado delete document + DOCUMENT_ID`) once the new report has been created. +- If neither of the above are true, ask the user if they want to write a new + report or use the existing one - As nothing has changed, the only purpose of creating a new report is if a different agent is being used @@ -226,7 +225,7 @@ Structure the report as: outliers, correlations 5. **Related operations** – which operations ran on this space and their status -After writing the local report file, persist it as a document resource: +Store the report by creating a document resource: ```yaml # __document.yaml (temp file, not committed) @@ -240,10 +239,6 @@ relatedResources: - ``` -Any charts or images generated during analysis should be base64-encoded and -included in the `attachments` section, with the markdown referencing them by -filename. - ```bash uv run ado create document -f __document.yaml ``` From a47904044f9a3690a3ff1128bbea5e07b7d8ae41 Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 21 Jul 2026 18:18:22 +0100 Subject: [PATCH 07/20] docs: update --- .../skills/examining-ado-operations/SKILL.md | 32 ++--- .cursor/skills/examining-ado-project/SKILL.md | 110 +++++++++--------- .../examining-discovery-spaces/SKILL.md | 33 ++---- .../skills/resource-yaml-creation/SKILL.md | 52 ++++++++- AGENTS.md | 1 + 5 files changed, 126 insertions(+), 102 deletions(-) diff --git a/.cursor/skills/examining-ado-operations/SKILL.md b/.cursor/skills/examining-ado-operations/SKILL.md index b78451572..a980f9c84 100644 --- a/.cursor/skills/examining-ado-operations/SKILL.md +++ b/.cursor/skills/examining-ado-operations/SKILL.md @@ -17,8 +17,8 @@ on, and whether measurements and results look healthy. - Run all commands from the **repository root** with `uv run`. - The report produced by this skill is stored as the `content` of a - `document` resource in the active ado metastore context — - (see [Producing a report](#producing-a-report)). + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). **Related skills**: @@ -26,6 +26,8 @@ on, and whether measurements and results look healthy. [using-ado-cli](../using-ado-cli/SKILL.md). - For metastore filtering, schemas see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For a project/context wide view (all spaces and operations), see [examining-ado-project](../examining-ado-project/SKILL.md). @@ -126,7 +128,7 @@ If the operation is finished, - Query the metastore for an existing document linked to this operation: ```bash - uv run ado get document -q config.relatedResources[*]=OPERATION_ID + uv run ado get document -q 'config.relatedResources=OPERATION_ID' ``` If a document is found, retrieve its metadata (name, created timestamp) and @@ -134,7 +136,8 @@ If the operation is finished, if that report indicated the operation was finished. - If yes, ask the user whether to replace it with a new report. If they agree, delete the existing document (`uv run ado delete document - DOCUMENT_ID`) once the new report has been created. + DOCUMENT_ID`) once the new report has been created. See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - If no, continue with creating a new report. ### Step 3: Review the operator @@ -327,24 +330,9 @@ Structure the report as: distributions 5. **Next Steps**: A plan for the next research steps to take using ado. -Store the report by creating a document resource, writing the markdown -directly into the `content` field: - -```yaml -# __document.yaml (temp file, not committed) -metadata: - name: "" - description: "" -content: | - -relatedResources: - - - - -``` - -```bash -uv run ado create document -f __document.yaml -``` +Store the report as a document resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). +Set `relatedResources` to the operation id and the input space ids from step 1. ## Troubleshooting diff --git a/.cursor/skills/examining-ado-project/SKILL.md b/.cursor/skills/examining-ado-project/SKILL.md index 1547d4e8d..4116b0f85 100644 --- a/.cursor/skills/examining-ado-project/SKILL.md +++ b/.cursor/skills/examining-ado-project/SKILL.md @@ -15,6 +15,9 @@ related metadata in the ado project associated to the active context. - Run all commands from the **repository root** with `uv run` (see [using-ado-cli](../using-ado-cli/SKILL.md)). +- The report produced by this skill is stored as the `content` of a + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). - See [projects and contexts](../../../docs/resources/metastore.md#contexts-and-projects) for details on what projects and contexts are @@ -24,6 +27,8 @@ related metadata in the ado project associated to the active context. - Prefer metastore listing and YAML dumps before heavy `uv run ado show` data pulls; see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For one space in depth: [examining-discovery-spaces](../examining-discovery-spaces/SKILL.md). - For one operation in depth: @@ -209,33 +214,57 @@ Optionally complement with one-hop links: uv run ado show related space SPACE_ID ``` -## 4. Report template - -Write a concise markdown report. The report is stored directly as the -`content` field of a `document` resource . - -- The document's `metadata.name` is `project__report`, where the - date is today's date -- Query the metastore for an existing project report document: - - ```bash - uv run ado get document -q metadata.name=project__report - ``` - - If a matching document is found, ask the user whether to replace it. -- If a report already exists, check whether there has been meaningful activity - since it was written before replacing it: - 1. Run `uv run ado get spaces --details` and - `uv run ado get operations --details` and note the age of the most recent - resource. - 2. If the most recent resource is **younger** than the existing document's - `creationTimestamp`, there has been new activity — proceed to write a new - report. - 3. If not, ask the user whether they want to replace it. - 4. If finer-grained confirmation is needed, fetch the YAML of the most recent - space or operation (`uv run ado get space SPACE_ID -o yaml --output-file - SPACE_ID.yaml`, or the same pattern for `operation`) and read its - `creationTimestamp` field. +## 4. Check for existing report + +The project report is stored as a `document` resource whose `metadata.name` is + `project_report`. There **may** be zero, one, or +many documents with that name — each has a unique identifier. If several match, +the **current** report is the most recently created one. + +Query the metastore: + +```bash +uv run ado get document -q 'config.metadata.name=project_report' --details +``` + +If matches exist: + +1. Identify the **current** document (newest by creation timestamp from + `--details` or from document YAML `created` / status timestamps). Fetch its + `content` (`uv run ado get document DOCUMENT_ID -o yaml`). +2. Check whether there has been meaningful activity since the current report + was written: + 1. Run `uv run ado get spaces --details` and + `uv run ado get operations --details` and note the age of the most recent + resource. + 2. If the most recent resource is **younger** than the current document's + creation timestamp, there has been new activity — proceed toward writing + a new report. + 3. If not, ask the user whether they want to write a new report anyway. If + they decline, stop and use/refer to the current document id. + 4. If finer-grained confirmation is needed, fetch the YAML of the most recent + space or operation (`uv run ado get space SPACE_ID -o yaml --output-file + SPACE_ID.yaml`, or the same pattern for `operation`) and read its + `creationTimestamp` field. +3. Before creating a new report when a current one exists, **ask** whether to: + - **Keep history** — leave existing `project_report` document(s); create + another with the same name. + - **Replace** — delete the current document + (`uv run ado delete document DOCUMENT_ID`), then create the new one. + See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). + Do not auto-delete; do not assume history must be kept. + +## Producing a report + +Write a concise markdown report. Store it as the `content` field of a +`document` resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). + +- Set `metadata.name` to `project_report`. +- Set `metadata.description` to include today's date, e.g. + `Project overview as of YYYY-MM-DD`. +- Omit `relatedResources` (or leave empty). ### Project summary @@ -263,30 +292,3 @@ Write a concise markdown report. The report is stored directly as the parameters). - Make a note of operations with failed measurements and highlight ones with abnormal failure rates (from the stats). - -Persist the report as a document resource. Since a project -report spans many resources, set `relatedResources` to the identifiers of the -most recently active spaces and operations (for example the five most recently -created of each): - -```yaml -# project__document.yaml (temp file, not committed) -metadata: - name: "project__report" - description: "" -content: | - -relatedResources: - - - - -``` - -```bash -uv run ado create document -f project__document.yaml -``` - -If an existing report document is being replaced (see above), delete it first: - -```bash -uv run ado delete document EXISTING_DOCUMENT_ID -``` diff --git a/.cursor/skills/examining-discovery-spaces/SKILL.md b/.cursor/skills/examining-discovery-spaces/SKILL.md index 042e462e5..aa32f54ab 100644 --- a/.cursor/skills/examining-discovery-spaces/SKILL.md +++ b/.cursor/skills/examining-discovery-spaces/SKILL.md @@ -16,7 +16,8 @@ covered its entity space is, and what data has been collected. - Run all commands from the **repository root** with `uv run`. - The report produced by this skill is stored as the `content` of a - `document` resource in the active ado metastore context (see [Producing a Report](#producing-a-report)). + `document` resource in the active ado metastore context (see + [Producing a report](#producing-a-report)). **Related skills**: @@ -24,6 +25,8 @@ covered its entity space is, and what data has been collected. [using-ado-cli](../using-ado-cli/SKILL.md). - For metastore filtering and schemas, see [query-ado-data](../query-ado-data/SKILL.md). +- For creating document resources that store reports, see + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - For examining operations run on a space, see [examining-ado-operations](../examining-ado-operations/SKILL.md). - For a project/context wide view (all spaces and operations), see @@ -150,7 +153,7 @@ uv run ado show related space SPACE_ID - Query the metastore for an existing document linked to this space: ```bash - uv run ado get document -q config.relatedResources[*]=SPACE_ID + uv run ado get document -q 'config.relatedResources=SPACE_ID' ``` If a document is found, retrieve its metadata (name, created timestamp) and @@ -161,7 +164,8 @@ uv run ado show related space SPACE_ID - The number of measured entities has increased - If yes to either, ask the user whether to replace it with a new report. If they agree, delete the existing document (`uv run ado delete document - DOCUMENT_ID`) once the new report has been created. + DOCUMENT_ID`) once the new report has been created. See + [resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document). - If neither of the above are true, ask the user if they want to write a new report or use the existing one - As nothing has changed, the only purpose of creating a new report is if a @@ -208,7 +212,7 @@ understand what each operation did and what it produced. Note: Do not analyze the data in the operations, or do detailed diagnoses. Just enough for summary. -## Producing a Report +## Producing a report Structure the report as: @@ -225,20 +229,7 @@ Structure the report as: outliers, correlations 5. **Related operations** – which operations ran on this space and their status -Store the report by creating a document resource: - -```yaml -# __document.yaml (temp file, not committed) -metadata: - name: "" - description: "" -content: | - -relatedResources: - - - - -``` - -```bash -uv run ado create document -f __document.yaml -``` +Store the report as a document resource (see +[resource-yaml-creation — Document](../resource-yaml-creation/SKILL.md#document)). +Set `relatedResources` to the space id and the related operation ids from +step 2. diff --git a/.cursor/skills/resource-yaml-creation/SKILL.md b/.cursor/skills/resource-yaml-creation/SKILL.md index 91489e2c7..e6595b619 100644 --- a/.cursor/skills/resource-yaml-creation/SKILL.md +++ b/.cursor/skills/resource-yaml-creation/SKILL.md @@ -2,10 +2,10 @@ name: resource-yaml-creation description: | Guidance for creating ado resource YAML files (discoveryspace, operation, - actuatorconfiguration, samplestore). Covers metadata conventions, dynamic - reference resolution with --use-latest/--with/--set, space design principles, - avoiding duplicate resources, and validation. Use when creating or editing - any ado resource YAML file. + actuatorconfiguration, samplestore, document). Covers metadata conventions, + dynamic reference resolution with --use-latest/--with/--set, space design + principles, avoiding duplicate resources, document reports, and validation. + Use when creating or editing any ado resource YAML file. --- # Creating ado Resource YAML Files @@ -33,7 +33,7 @@ metadata: - `name` and `description` are shown by `ado get --details` - `labels` support filtering: `uv run ado get spaces --label project=my_project` - `--filter` supports path-based filtering across any field: - `uv run ado get spaces --filter "metadata.name=my_space"` + `uv run ado get spaces --filter 'config.metadata.name=my_space'` ## Dynamic Reference Resolution @@ -194,6 +194,48 @@ shared measurement history. uv run ado create samplestore -f samplestore.yaml ``` +### Document + +Use `document` resources to persist markdown reports (and similar text) in the +metastore. Examining skills store their reports this way: put the markdown in +`content`, and list related resource identifiers in `relatedResources`. + +```yaml +# _document.yaml (temp file, not committed) +metadata: + name: "" + description: "" +content: | + +relatedResources: + - +``` + +```bash +uv run ado create document -f _document.yaml +uv run ado delete document DOCUMENT_ID +``` + +Validate with `uv run ado create document -f FILE --dry-run` before creating. + +**Querying existing documents** (paths are under `config.`; string candidates +must be single-quoted — see [query-ado-data](../query-ado-data/SKILL.md)): + +```bash +# By related resource id (array containment — no [*] subscript) +uv run ado get document -q 'config.relatedResources=RESOURCE_ID' + +# By metadata name +uv run ado get document -q 'config.metadata.name=NAME' +``` + +Fetch a document's body with +`uv run ado get document DOCUMENT_ID -o yaml`. + +When replacing a report, delete the existing document only after the user +agrees and the new report has been created (or immediately before creating the +replacement, if the skill's replace policy says so). + ## Related Resources - [using-ado-cli](../using-ado-cli/SKILL.md) — CLI command syntax and shortcuts diff --git a/AGENTS.md b/AGENTS.md index fb65d90e9..e9a0e2c48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ These guidelines apply to all code development in the ado codebase. - samplestore - datacontainer - actuatorconfiguration + - document - **modules/actuators**: defines actuators, custom experiments, and their associated management code (plugins, registry) - **modules/operators**: defines operators and their associated management From 036a9bb2bd7ffa13bf0c3df3b1e46504b60218cc Mon Sep 17 00:00:00 2001 From: michaelj Date: Thu, 23 Jul 2026 18:06:15 +0100 Subject: [PATCH 08/20] chore: changes for code review --- ado/core/document/resource.py | 10 ++---- tests/core/document/conftest.py | 6 ---- tests/core/document/test_document_config.py | 7 ---- tests/core/document/test_document_resource.py | 34 ------------------- 4 files changed, 3 insertions(+), 54 deletions(-) diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py index 6ef314012..ae6c0ddd6 100644 --- a/ado/core/document/resource.py +++ b/ado/core/document/resource.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: MIT import uuid -from typing import Annotated, Any +from typing import Annotated import pydantic @@ -12,11 +12,7 @@ class DocumentResource(ADOResource): - """A resource that stores markdown documents and optional attachments.""" - - @staticmethod - def _identifier_from_data(data: dict[str, Any]) -> str: - return f"document-{str(uuid.uuid4())[:8]}" + """A resource that stores markdown documents.""" version: Annotated[str, pydantic.Field()] = "v1" kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT @@ -24,6 +20,6 @@ def _identifier_from_data(data: dict[str, Any]) -> str: identifier: Annotated[ Defaultable[str], pydantic.Field( - default_factory=_identifier_from_data, + default_factory=lambda: f"document-{str(uuid.uuid4())[:8]}", ), ] diff --git a/tests/core/document/conftest.py b/tests/core/document/conftest.py index 3f56527bb..1c4ecb13c 100644 --- a/tests/core/document/conftest.py +++ b/tests/core/document/conftest.py @@ -10,9 +10,3 @@ def document_configuration_file() -> pathlib.Path: """Return path to a valid document configuration fixture.""" return pathlib.Path("tests/fixtures/document.yaml") - - -@pytest.fixture -def document_with_attachment_configuration_file() -> pathlib.Path: - """Return path to a document configuration fixture with attachments.""" - return pathlib.Path("tests/fixtures/document_with_attachment.yaml") diff --git a/tests/core/document/test_document_config.py b/tests/core/document/test_document_config.py index af5b40c73..147810727 100644 --- a/tests/core/document/test_document_config.py +++ b/tests/core/document/test_document_config.py @@ -11,12 +11,6 @@ from ado.utilities.output import pydantic_model_as_yaml -def test_document_configuration_defaults() -> None: - """DocumentConfiguration defaults relatedResources and attachments.""" - config = DocumentConfiguration(content="Example report") - assert config.relatedResources == [] - - def test_document_configuration_requires_content() -> None: """DocumentConfiguration requires content.""" with pytest.raises(pydantic.ValidationError): @@ -28,7 +22,6 @@ def test_document_configuration_round_trip_yaml() -> None: config = DocumentConfiguration( content="# Report\n\nBody text", relatedResources=["operation-abc-12345678"], - attachments={"chart.png": "cGV4"}, metadata={"name": "Test report"}, ) yaml_text = pydantic_model_as_yaml(config) diff --git a/tests/core/document/test_document_resource.py b/tests/core/document/test_document_resource.py index d8d761e06..7f8c0723b 100644 --- a/tests/core/document/test_document_resource.py +++ b/tests/core/document/test_document_resource.py @@ -1,14 +1,8 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT -import re - -import pydantic -import pytest - from ado.core.document.config import DocumentConfiguration from ado.core.document.resource import DocumentResource -from ado.core.resources import CoreResourceKinds def test_document_resource_lifecycle() -> None: @@ -16,37 +10,9 @@ def test_document_resource_lifecycle() -> None: config = DocumentConfiguration( content="# Report\n\nExample body", relatedResources=["operation-test-12345678"], - attachments={"chart.png": "cGV4"}, ) resource = DocumentResource(config=config) dumped = resource.model_dump() restored = DocumentResource.model_validate(dumped) assert restored == resource - - -def test_document_resource_identifier_auto_generation() -> None: - """DocumentResource auto-generates a document identifier.""" - config = DocumentConfiguration(content="Example report") - resource = DocumentResource(config=config) - - assert re.fullmatch(r"document-[0-9a-f]{8}", resource.identifier) - - -def test_document_resource_kind_is_pinned() -> None: - """DocumentResource kind is pinned to document.""" - config = DocumentConfiguration(content="Example report") - resource = DocumentResource(config=config) - - assert resource.kind == CoreResourceKinds.DOCUMENT - - -def test_document_resource_requires_content() -> None: - """DocumentResource requires config content.""" - with pytest.raises(pydantic.ValidationError, match="content"): - DocumentResource.model_validate( - { - "kind": CoreResourceKinds.DOCUMENT, - "config": {}, - } - ) From d47738e58b082bccf65d98c0c83c92965263a527 Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 24 Jul 2026 14:39:39 +0100 Subject: [PATCH 09/20] feat(core): add contentType field For discriminating markdown and html content --- ado/core/document/config.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ado/core/document/config.py b/ado/core/document/config.py index fed1f5426..0824ac217 100644 --- a/ado/core/document/config.py +++ b/ado/core/document/config.py @@ -1,7 +1,7 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT -from typing import Annotated +from typing import Annotated, Literal import pydantic from pydantic import ConfigDict @@ -16,8 +16,12 @@ class DocumentConfiguration(pydantic.BaseModel): content: Annotated[ str, - pydantic.Field(description="Markdown body of the document"), + pydantic.Field(description="Body of the document (markdown or HTML)"), ] + contentType: Annotated[ + Literal["markdown", "html"], + pydantic.Field(description="Format of the content field"), + ] = "markdown" relatedResources: Annotated[ list[str], pydantic.Field( From d909784a092b67e6d52f5b3101e77eda0095bff1 Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 24 Jul 2026 14:41:12 +0100 Subject: [PATCH 10/20] feat(core): add contentType field For discriminating markdown and html content --- .../skills/resource-yaml-creation/SKILL.md | 12 ++++--- ado/cli/resources/document/template.py | 1 + ado/core/document/resource.py | 2 +- tests/core/document/test_document_config.py | 33 +++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/.cursor/skills/resource-yaml-creation/SKILL.md b/.cursor/skills/resource-yaml-creation/SKILL.md index e6595b619..5538dadd1 100644 --- a/.cursor/skills/resource-yaml-creation/SKILL.md +++ b/.cursor/skills/resource-yaml-creation/SKILL.md @@ -196,17 +196,21 @@ uv run ado create samplestore -f samplestore.yaml ### Document -Use `document` resources to persist markdown reports (and similar text) in the -metastore. Examining skills store their reports this way: put the markdown in -`content`, and list related resource identifiers in `relatedResources`. +Use `document` resources to persist markdown or HTML reports in the metastore. +Set `contentType` to `markdown` (default) or `html`. See the +[document resource documentation](../../../docs/resources/document.md). +Examining skills store their reports this way: put the body in `content`, +optionally set `contentType`, and list related resource identifiers in +`relatedResources`. ```yaml # _document.yaml (temp file, not committed) metadata: name: "" description: "" +contentType: markdown # or html content: | - + relatedResources: - ``` diff --git a/ado/cli/resources/document/template.py b/ado/cli/resources/document/template.py index 793a30d43..09ab9b96e 100644 --- a/ado/cli/resources/document/template.py +++ b/ado/cli/resources/document/template.py @@ -16,6 +16,7 @@ def template_document(parameters: AdoTemplateCommandParameters) -> None: model_instance = DocumentConfiguration( content="# Report title\n\nReport body.", + contentType="markdown", relatedResources=[], ) serialise_pydantic_model( diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py index ae6c0ddd6..2c3e6368e 100644 --- a/ado/core/document/resource.py +++ b/ado/core/document/resource.py @@ -12,7 +12,7 @@ class DocumentResource(ADOResource): - """A resource that stores markdown documents.""" + """A resource that stores markdown or HTML documents.""" version: Annotated[str, pydantic.Field()] = "v1" kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT diff --git a/tests/core/document/test_document_config.py b/tests/core/document/test_document_config.py index 147810727..1bdac2c17 100644 --- a/tests/core/document/test_document_config.py +++ b/tests/core/document/test_document_config.py @@ -17,10 +17,17 @@ def test_document_configuration_requires_content() -> None: DocumentConfiguration.model_validate({}) +def test_document_configuration_default_content_type() -> None: + """DocumentConfiguration defaults contentType to markdown.""" + config = DocumentConfiguration(content="Example report") + assert config.contentType == "markdown" + + def test_document_configuration_round_trip_yaml() -> None: """DocumentConfiguration round-trips through YAML.""" config = DocumentConfiguration( content="# Report\n\nBody text", + contentType="markdown", relatedResources=["operation-abc-12345678"], metadata={"name": "Test report"}, ) @@ -29,6 +36,20 @@ def test_document_configuration_round_trip_yaml() -> None: assert restored == config +def test_document_configuration_html_round_trip_yaml() -> None: + """DocumentConfiguration with HTML contentType round-trips through YAML.""" + config = DocumentConfiguration( + content="

Report

", + contentType="html", + relatedResources=["operation-abc-12345678"], + metadata={"name": "HTML report"}, + ) + yaml_text = pydantic_model_as_yaml(config) + restored = DocumentConfiguration.model_validate(yaml.safe_load(yaml_text)) + assert restored == config + assert restored.contentType == "html" + + def test_document_configuration_from_fixture( document_configuration_file: pathlib.Path, ) -> None: @@ -38,3 +59,15 @@ def test_document_configuration_from_fixture( ) assert config.content.startswith("# Operation report") assert config.relatedResources == ["operation-test-12345678"] + assert config.contentType == "markdown" + + +def test_document_configuration_from_html_fixture( + document_html_configuration_file: pathlib.Path, +) -> None: + """HTML document fixture validates as DocumentConfiguration.""" + config = DocumentConfiguration.model_validate( + yaml.safe_load(document_html_configuration_file.read_text()) + ) + assert config.contentType == "html" + assert "

Operation report

" in config.content From 6f1109fb5f9b7e9f18f55693281ae6b1fc8098d6 Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 24 Jul 2026 14:45:24 +0100 Subject: [PATCH 11/20] feat(core): enable ado describe document outputs markdown as rich objects if output stream is terminal, otherwise writes content field to pipe/file. if html and directed to terminal the content is written to file and then browser opened. --- ado/cli/commands/describe.py | 5 + ado/cli/models/types.py | 1 + ado/cli/resources/document/describe.py | 108 ++++++++++++ tests/ado/describe/test_ado_describe.py | 215 ++++++++++++++++++++++++ 4 files changed, 329 insertions(+) create mode 100644 ado/cli/resources/document/describe.py diff --git a/ado/cli/commands/describe.py b/ado/cli/commands/describe.py index 9c917fd0f..f109ca917 100644 --- a/ado/cli/commands/describe.py +++ b/ado/cli/commands/describe.py @@ -16,6 +16,7 @@ from ado.cli.models.types import AdoDescribeSupportedResourceTypes from ado.cli.resources.data_container.describe import describe_data_container from ado.cli.resources.discovery_space.describe import describe_discovery_space +from ado.cli.resources.document.describe import describe_document from ado.cli.resources.experiment.describe import describe_experiment from ado.cli.utils.generic.common import get_effective_resource_id from ado.cli.utils.input.parsers import enum_choice_with_plural_parser @@ -96,6 +97,9 @@ def describe_resource( # Describe an experiment, optionally with actuator prefix ado describe experiment . + + # Describe a document (markdown in-terminal; HTML opens in browser) + ado describe document """ ado_configuration: AdoConfiguration = ctx.obj @@ -141,6 +145,7 @@ def describe_resource( method_mapping = { AdoDescribeSupportedResourceTypes.DATA_CONTAINER: describe_data_container, AdoDescribeSupportedResourceTypes.DISCOVERY_SPACE: describe_discovery_space, + AdoDescribeSupportedResourceTypes.DOCUMENT: describe_document, AdoDescribeSupportedResourceTypes.EXPERIMENT: describe_experiment, } diff --git a/ado/cli/models/types.py b/ado/cli/models/types.py index 885661945..667078a2c 100644 --- a/ado/cli/models/types.py +++ b/ado/cli/models/types.py @@ -75,6 +75,7 @@ class AdoDeleteSupportedResourceTypes(Enum): class AdoDescribeSupportedResourceTypes(Enum): DATA_CONTAINER = _DATA_CONTAINER_SINGULAR DISCOVERY_SPACE = _DISCOVERY_SPACE_SINGULAR + DOCUMENT = _DOCUMENT_SINGULAR EXPERIMENT = _EXPERIMENT_SINGULAR diff --git a/ado/cli/resources/document/describe.py b/ado/cli/resources/document/describe.py new file mode 100644 index 000000000..301863de8 --- /dev/null +++ b/ado/cli/resources/document/describe.py @@ -0,0 +1,108 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT + +import pathlib +import sys +import tempfile +import webbrowser + +from rich.console import Group +from rich.markdown import Markdown +from rich.status import Status +from rich.text import Text + +from ado.cli.models.parameters import AdoDescribeCommandParameters +from ado.cli.utils.generic.wrappers import get_sql_store +from ado.cli.utils.output.prints import ( + ADO_SPINNER_QUERYING_DB, + INFO, + console_print, + stdout_console, +) +from ado.core.resources import CoreResourceKinds +from ado.metastore.base import ResourceDoesNotExistError + + +def _stdout_is_terminal() -> bool: + """Return True when stdout is an interactive terminal.""" + return stdout_console.is_terminal + + +def describe_document(parameters: AdoDescribeCommandParameters) -> None: + """Print a human-friendly description of a document resource. + + When stdout is a terminal, markdown is rendered with rich and HTML is opened + in the default browser. When stdout is redirected (pipe or file), the raw + ``content`` body is written with no styling. + """ + sql = get_sql_store(project_context=parameters.ado_configuration.project_context) + to_terminal = _stdout_is_terminal() + + if to_terminal: + with Status(ADO_SPINNER_QUERYING_DB) as status: + document_resource = sql.getResource( + identifier=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + if not document_resource: + status.stop() + raise ResourceDoesNotExistError( + resource_id=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + else: + document_resource = sql.getResource( + identifier=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + if not document_resource: + raise ResourceDoesNotExistError( + resource_id=parameters.resource_id, kind=CoreResourceKinds.DOCUMENT + ) + + config = document_resource.config + + if not to_terminal: + sys.stdout.write(config.content) + if not config.content.endswith("\n"): + sys.stdout.write("\n") + return + + header_parts: list[Text | str] = [ + Text.assemble( + ("Identifier: ", "bold"), (document_resource.identifier, "bold green") + ), + Text.assemble(("Content type: ", "bold"), (config.contentType,)), + "", + ] + if config.metadata.name: + header_parts.append(Text.assemble(("Name: ", "bold"), (config.metadata.name,))) + if config.metadata.description: + header_parts.append( + Text.assemble(("Description: ", "bold"), (config.metadata.description,)) + ) + if config.relatedResources: + header_parts.append( + Text.assemble( + ("Related resources: ", "bold"), + (", ".join(config.relatedResources),), + ) + ) + header_parts.append("") + + if config.contentType == "html": + console_print(Group(*header_parts)) + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".html", + prefix=f"{document_resource.identifier}-", + delete=False, + encoding="utf-8", + ) as html_file: + html_file.write(config.content) + html_path = pathlib.Path(html_file.name) + + webbrowser.open(html_path.as_uri()) + console_print( + f"{INFO}Opened HTML document content in the default browser ({html_path})." + ) + return + + console_print(Group(*header_parts, Markdown(config.content))) diff --git a/tests/ado/describe/test_ado_describe.py b/tests/ado/describe/test_ado_describe.py index 0f6d35479..fbd7e9760 100644 --- a/tests/ado/describe/test_ado_describe.py +++ b/tests/ado/describe/test_ado_describe.py @@ -4,7 +4,9 @@ import os import pathlib from collections.abc import Callable +from urllib.parse import unquote, urlparse +import pytest from testcontainers.mysql import MySqlContainer from typer.testing import CliRunner @@ -112,3 +114,216 @@ def test_describe_use_latest_rejected_for_experiment() -> None: runner = CliRunner() result = runner.invoke(ado, ["describe", "experiment", "--use-latest"]) assert result.exit_code == 1 + + +def test_describe_document( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe writes raw markdown when stdout is not a terminal.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + monkeypatch.setattr( + "ado.cli.resources.document.describe._stdout_is_terminal", + lambda: False, + ) + + content = "# Operation report\n\nExample body for describe." + config = DocumentConfiguration( + content=content, + relatedResources=["operation-test-12345678"], + metadata={"name": "Describe test report"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert result.output == f"{content}\n" + + +def test_describe_document_terminal_renders_markdown( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe renders markdown with rich when stdout is a terminal.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + monkeypatch.setattr( + "ado.cli.resources.document.describe._stdout_is_terminal", + lambda: True, + ) + + config = DocumentConfiguration( + content="# Operation report\n\nExample body for describe.", + relatedResources=["operation-test-12345678"], + metadata={"name": "Describe test report"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert resource.identifier in result.output + assert "markdown" in result.output + assert "Operation report" in result.output + assert "Example body for describe" in result.output + + +def test_describe_document_html_redirect_prints_content( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe writes raw HTML when stdout is not a terminal.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + monkeypatch.setattr( + "ado.cli.resources.document.describe._stdout_is_terminal", + lambda: False, + ) + + html_body = ( + "

HTML report

Opened via describe.

" + ) + config = DocumentConfiguration( + content=html_body, + contentType="html", + metadata={"name": "HTML describe test"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + opened_urls: list[str] = [] + + def _fake_open(url: str) -> bool: + opened_urls.append(url) + return True + + monkeypatch.setattr( + "ado.cli.resources.document.describe.webbrowser.open", _fake_open + ) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert result.output == f"{html_body}\n" + assert opened_urls == [] + + +def test_describe_document_html_opens_browser( + tmp_path: pathlib.Path, + valid_ado_project_context: ProjectContext, + create_active_ado_context: Callable[ + [CliRunner, pathlib.Path, ProjectContext], None + ], + sql_store: SQLStore, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Describe opens HTML document content in the default browser on a TTY.""" + from ado.core.document.config import DocumentConfiguration + from ado.core.document.resource import DocumentResource + + monkeypatch.setattr( + "ado.cli.resources.document.describe._stdout_is_terminal", + lambda: True, + ) + + html_body = ( + "

HTML report

Opened via describe.

" + ) + config = DocumentConfiguration( + content=html_body, + contentType="html", + metadata={"name": "HTML describe test"}, + ) + resource = DocumentResource(config=config) + sql_store.addResource(resource) + + opened_urls: list[str] = [] + + def _fake_open(url: str) -> bool: + opened_urls.append(url) + return True + + monkeypatch.setattr( + "ado.cli.resources.document.describe.webbrowser.open", _fake_open + ) + + runner = CliRunner() + create_active_ado_context( + runner=runner, path=tmp_path, project_context=valid_ado_project_context + ) + result = runner.invoke( + ado, + [ + "--override-ado-app-dir", + tmp_path, + "describe", + "document", + resource.identifier, + ], + ) + assert result.exit_code == 0, result.output + assert resource.identifier in result.output + assert "html" in result.output + assert "Opened HTML document content" in result.output + assert len(opened_urls) == 1 + assert opened_urls[0].startswith("file://") + opened_path = pathlib.Path(unquote(urlparse(opened_urls[0]).path)) + assert opened_path.read_text(encoding="utf-8") == html_body From b824fa849b6f12d2bfa4c7dc419aece33b1a9598 Mon Sep 17 00:00:00 2001 From: michaelj Date: Fri, 24 Jul 2026 14:53:25 +0100 Subject: [PATCH 12/20] fix: missing commits --- tests/core/document/conftest.py | 6 ++++++ tests/fixtures/document_with_attachment.yaml | 13 ------------- 2 files changed, 6 insertions(+), 13 deletions(-) delete mode 100644 tests/fixtures/document_with_attachment.yaml diff --git a/tests/core/document/conftest.py b/tests/core/document/conftest.py index 1c4ecb13c..ce3bb147f 100644 --- a/tests/core/document/conftest.py +++ b/tests/core/document/conftest.py @@ -10,3 +10,9 @@ def document_configuration_file() -> pathlib.Path: """Return path to a valid document configuration fixture.""" return pathlib.Path("tests/fixtures/document.yaml") + + +@pytest.fixture +def document_html_configuration_file() -> pathlib.Path: + """Return path to a valid HTML document configuration fixture.""" + return pathlib.Path("tests/fixtures/document_html.yaml") diff --git a/tests/fixtures/document_with_attachment.yaml b/tests/fixtures/document_with_attachment.yaml deleted file mode 100644 index 559bdcc50..000000000 --- a/tests/fixtures/document_with_attachment.yaml +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright IBM Corporation 2025, 2026 -# SPDX-License-Identifier: MIT -metadata: - name: Report with attachment - description: Example document with an embedded image -content: | - # Report with chart - - ![Performance chart](perf_chart.png) -relatedResources: - - space-test-12345678 -attachments: - perf_chart.png: cGV4 From 0c137241f6762cbf677baf7ae762319a75713f6a Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 27 Jul 2026 14:26:59 +0100 Subject: [PATCH 13/20] fix: missing fixture --- tests/fixtures/document_html.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 tests/fixtures/document_html.yaml diff --git a/tests/fixtures/document_html.yaml b/tests/fixtures/document_html.yaml new file mode 100644 index 000000000..773710a5a --- /dev/null +++ b/tests/fixtures/document_html.yaml @@ -0,0 +1,10 @@ +# Copyright IBM Corporation 2025, 2026 +# SPDX-License-Identifier: MIT +metadata: + name: Example HTML report + description: Summary report stored as HTML +contentType: html +content: | +

Operation report

Example HTML body.

+relatedResources: + - operation-test-12345678 From 2293733c2040f5038c90f6a960c9ca1bc7df3a5c Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 27 Jul 2026 16:57:12 +0100 Subject: [PATCH 14/20] chore: remove function --- ado/cli/resources/document/describe.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/ado/cli/resources/document/describe.py b/ado/cli/resources/document/describe.py index 301863de8..a5d334481 100644 --- a/ado/cli/resources/document/describe.py +++ b/ado/cli/resources/document/describe.py @@ -23,11 +23,6 @@ from ado.metastore.base import ResourceDoesNotExistError -def _stdout_is_terminal() -> bool: - """Return True when stdout is an interactive terminal.""" - return stdout_console.is_terminal - - def describe_document(parameters: AdoDescribeCommandParameters) -> None: """Print a human-friendly description of a document resource. @@ -36,7 +31,7 @@ def describe_document(parameters: AdoDescribeCommandParameters) -> None: ``content`` body is written with no styling. """ sql = get_sql_store(project_context=parameters.ado_configuration.project_context) - to_terminal = _stdout_is_terminal() + to_terminal = stdout_console.is_terminal if to_terminal: with Status(ADO_SPINNER_QUERYING_DB) as status: From 0300a28a0b19f5f210f9facc9b61411fd01eb2de Mon Sep 17 00:00:00 2001 From: michaelj Date: Mon, 27 Jul 2026 16:59:26 +0100 Subject: [PATCH 15/20] chore: remove printing content type --- ado/cli/resources/document/describe.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ado/cli/resources/document/describe.py b/ado/cli/resources/document/describe.py index a5d334481..ba7177238 100644 --- a/ado/cli/resources/document/describe.py +++ b/ado/cli/resources/document/describe.py @@ -64,7 +64,6 @@ def describe_document(parameters: AdoDescribeCommandParameters) -> None: Text.assemble( ("Identifier: ", "bold"), (document_resource.identifier, "bold green") ), - Text.assemble(("Content type: ", "bold"), (config.contentType,)), "", ] if config.metadata.name: From f02b76c784a0636b620d1cc37a036a196d611d03 Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 28 Jul 2026 10:18:01 +0100 Subject: [PATCH 16/20] fix: typing --- ado/core/document/resource.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py index 2c3e6368e..c68572097 100644 --- a/ado/core/document/resource.py +++ b/ado/core/document/resource.py @@ -1,6 +1,6 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT - +import typing import uuid from typing import Annotated @@ -15,7 +15,9 @@ class DocumentResource(ADOResource): """A resource that stores markdown or HTML documents.""" version: Annotated[str, pydantic.Field()] = "v1" - kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT + kind: Annotated[typing.Literal[CoreResourceKinds.DOCUMENT], pydantic.Field()] = ( + CoreResourceKinds.DOCUMENT + ) config: DocumentConfiguration identifier: Annotated[ Defaultable[str], From d2c1cf1813d8da20765986455f5e7b3097c9fc4f Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 28 Jul 2026 11:58:07 +0100 Subject: [PATCH 17/20] fix: revert prior change Needs a different solution --- ado/core/document/resource.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py index c68572097..f0d8e8435 100644 --- a/ado/core/document/resource.py +++ b/ado/core/document/resource.py @@ -1,6 +1,5 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT -import typing import uuid from typing import Annotated @@ -15,9 +14,7 @@ class DocumentResource(ADOResource): """A resource that stores markdown or HTML documents.""" version: Annotated[str, pydantic.Field()] = "v1" - kind: Annotated[typing.Literal[CoreResourceKinds.DOCUMENT], pydantic.Field()] = ( - CoreResourceKinds.DOCUMENT - ) + kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT config: DocumentConfiguration identifier: Annotated[ Defaultable[str], From 28408494723232e1e14ca67c0258ec350a8b6651 Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 28 Jul 2026 12:00:34 +0100 Subject: [PATCH 18/20] fix: branch on isatty is_terminal is not accurate enough for identify non-terminal output. In particular if certain rich envvars e.g. FORCE_COLOR are set it will report True even if output is redirected. --- ado/cli/resources/document/describe.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ado/cli/resources/document/describe.py b/ado/cli/resources/document/describe.py index ba7177238..74ad2f4f9 100644 --- a/ado/cli/resources/document/describe.py +++ b/ado/cli/resources/document/describe.py @@ -17,7 +17,6 @@ ADO_SPINNER_QUERYING_DB, INFO, console_print, - stdout_console, ) from ado.core.resources import CoreResourceKinds from ado.metastore.base import ResourceDoesNotExistError @@ -31,7 +30,7 @@ def describe_document(parameters: AdoDescribeCommandParameters) -> None: ``content`` body is written with no styling. """ sql = get_sql_store(project_context=parameters.ado_configuration.project_context) - to_terminal = stdout_console.is_terminal + to_terminal = sys.stdout.isatty() if to_terminal: with Status(ADO_SPINNER_QUERYING_DB) as status: From 5ea48f9c9b5f417318e2e7e56ff71a810d6629ee Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 28 Jul 2026 12:01:50 +0100 Subject: [PATCH 19/20] fix: tests --- tests/ado/describe/test_ado_describe.py | 29 ++++++++----------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/tests/ado/describe/test_ado_describe.py b/tests/ado/describe/test_ado_describe.py index fbd7e9760..5e85487fd 100644 --- a/tests/ado/describe/test_ado_describe.py +++ b/tests/ado/describe/test_ado_describe.py @@ -123,17 +123,11 @@ def test_describe_document( [CliRunner, pathlib.Path, ProjectContext], None ], sql_store: SQLStore, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Describe writes raw markdown when stdout is not a terminal.""" from ado.core.document.config import DocumentConfiguration from ado.core.document.resource import DocumentResource - monkeypatch.setattr( - "ado.cli.resources.document.describe._stdout_is_terminal", - lambda: False, - ) - content = "# Operation report\n\nExample body for describe." config = DocumentConfiguration( content=content, @@ -171,13 +165,13 @@ def test_describe_document_terminal_renders_markdown( monkeypatch: pytest.MonkeyPatch, ) -> None: """Describe renders markdown with rich when stdout is a terminal.""" + from typer.testing import _NamedTextIOWrapper + from ado.core.document.config import DocumentConfiguration from ado.core.document.resource import DocumentResource - monkeypatch.setattr( - "ado.cli.resources.document.describe._stdout_is_terminal", - lambda: True, - ) + # CliRunner replaces sys.stdout; patch the wrapper class it installs. + monkeypatch.setattr(_NamedTextIOWrapper, "isatty", lambda self: True) config = DocumentConfiguration( content="# Operation report\n\nExample body for describe.", @@ -203,8 +197,8 @@ def test_describe_document_terminal_renders_markdown( ) assert result.exit_code == 0, result.output assert resource.identifier in result.output - assert "markdown" in result.output assert "Operation report" in result.output + assert "# Operation report" not in result.output assert "Example body for describe" in result.output @@ -221,11 +215,6 @@ def test_describe_document_html_redirect_prints_content( from ado.core.document.config import DocumentConfiguration from ado.core.document.resource import DocumentResource - monkeypatch.setattr( - "ado.cli.resources.document.describe._stdout_is_terminal", - lambda: False, - ) - html_body = ( "

HTML report

Opened via describe.

" ) @@ -276,13 +265,13 @@ def test_describe_document_html_opens_browser( monkeypatch: pytest.MonkeyPatch, ) -> None: """Describe opens HTML document content in the default browser on a TTY.""" + from typer.testing import _NamedTextIOWrapper + from ado.core.document.config import DocumentConfiguration from ado.core.document.resource import DocumentResource - monkeypatch.setattr( - "ado.cli.resources.document.describe._stdout_is_terminal", - lambda: True, - ) + # CliRunner replaces sys.stdout; patch the wrapper class it installs. + monkeypatch.setattr(_NamedTextIOWrapper, "isatty", lambda self: True) html_body = ( "

HTML report

Opened via describe.

" From b1c6a4b991f4b31e716349b94f19ca28d669146e Mon Sep 17 00:00:00 2001 From: michaelj Date: Tue, 28 Jul 2026 19:33:22 +0100 Subject: [PATCH 20/20] fix: typing --- ado/core/document/resource.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ado/core/document/resource.py b/ado/core/document/resource.py index f0d8e8435..0eaee31bf 100644 --- a/ado/core/document/resource.py +++ b/ado/core/document/resource.py @@ -1,7 +1,7 @@ # Copyright IBM Corporation 2025, 2026 # SPDX-License-Identifier: MIT import uuid -from typing import Annotated +from typing import Annotated, Literal import pydantic @@ -14,7 +14,9 @@ class DocumentResource(ADOResource): """A resource that stores markdown or HTML documents.""" version: Annotated[str, pydantic.Field()] = "v1" - kind: Annotated[CoreResourceKinds, pydantic.Field()] = CoreResourceKinds.DOCUMENT + kind: Annotated[Literal[CoreResourceKinds.DOCUMENT], pydantic.Field()] = ( + CoreResourceKinds.DOCUMENT + ) config: DocumentConfiguration identifier: Annotated[ Defaultable[str],