diff --git a/solstone/think/talent_provenance.py b/solstone/think/talent_provenance.py index ce9e2d4a6..329146cae 100644 --- a/solstone/think/talent_provenance.py +++ b/solstone/think/talent_provenance.py @@ -30,7 +30,7 @@ from solstone.think.journal_io import ( read_json, write_json, ) -from solstone.think.utils import get_journal +from solstone.think.utils import DATE_RE, get_journal LOG = logging.getLogger(__name__) @@ -38,6 +38,10 @@ SCHEMA_VERSION = 1 _PROVENANCE_DIR = "talent-provenance" +class UnsupportedProvenancePath(ValueError): + """Raised when a talent output path has no day-rooted provenance home.""" + + def _canonical_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) @@ -58,6 +62,15 @@ def _journal_relative(path: Path) -> Path: return path.resolve().relative_to(journal) +def _stem_day(output_path: Path, parts: tuple[str, ...]) -> str: + stem = Path(parts[-1]).stem + if not DATE_RE.fullmatch(stem): + raise UnsupportedProvenancePath( + f"unsupported talent output path for provenance: {output_path}" + ) + return stem + + def _day_and_logical_output(output_path: Path) -> tuple[str, Path]: rel = _journal_relative(output_path) parts = rel.parts @@ -67,7 +80,14 @@ def _day_and_logical_output(output_path: Path) -> tuple[str, Path]: facet = parts[1] day = parts[3] return day, Path("facets", facet, "activities", *parts[4:]) - raise ValueError(f"unsupported talent output path for provenance: {output_path}") + # Day-rooted outputs whose day is the filename stem (it STAYS in logical). + if len(parts) == 3 and parts[0] == "reflections" and parts[1] == "weekly": + return _stem_day(output_path, parts), rel + if len(parts) == 4 and parts[0] == "facets" and parts[2] == "news": + return _stem_day(output_path, parts), rel + raise UnsupportedProvenancePath( + f"unsupported talent output path for provenance: {output_path}" + ) def _base_dir(day: str) -> Path: diff --git a/solstone/think/talents.py b/solstone/think/talents.py index 5728d9e94..75e1f86e7 100644 --- a/solstone/think/talents.py +++ b/solstone/think/talents.py @@ -49,6 +49,7 @@ from solstone.think.talent import ( source_is_required, ) from solstone.think.talent_provenance import ( + UnsupportedProvenancePath, compute_identity_hash, output_digest, read_provenance, @@ -1028,20 +1029,38 @@ def _write_clean_provenance( ) -> None: if not output_path or not result: return - output_sha256, output_size = output_digest(output_path) - write_provenance( - output_path, - identity_hash=_identity_hash(config, runtime_json_schema), - output_sha256=output_sha256, - output_size=output_size, - provider=config.get("provider"), - model=config.get("model"), - fallback_from=config.get("fallback_from"), - generation_params=_generation_params(config), - completed_at_ms=completed_at_ms, - use_id=config.get("use_id"), - identity_fields=_identity_fields(config), - ) + # Provenance is an observability sidecar, never the run's success + # contract: a sidecar failure must not flip a saved output to "error". + # Mirrors the non-fatal read path in _try_reuse_output. The unsupported- + # path sentinel is the benign "this output shape has no day-rooted + # provenance home" signal (logged at WARNING); any other failure is + # unexpected and logged LOUDLY at ERROR (not a silent swallow). + try: + output_sha256, output_size = output_digest(output_path) + write_provenance( + output_path, + identity_hash=_identity_hash(config, runtime_json_schema), + output_sha256=output_sha256, + output_size=output_size, + provider=config.get("provider"), + model=config.get("model"), + fallback_from=config.get("fallback_from"), + generation_params=_generation_params(config), + completed_at_ms=completed_at_ms, + use_id=config.get("use_id"), + identity_fields=_identity_fields(config), + ) + except UnsupportedProvenancePath: + LOG.warning( + "skipping talent provenance for unmapped output path %s", + output_path, + ) + except Exception: + LOG.error( + "failed to write talent provenance for %s", + output_path, + exc_info=True, + ) def _build_dry_run_event(config: dict, before_values: dict) -> dict: diff --git a/tests/test_talent_provenance.py b/tests/test_talent_provenance.py new file mode 100644 index 000000000..bdc09a615 --- /dev/null +++ b/tests/test_talent_provenance.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +import asyncio +import logging +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from solstone.think import talent_provenance, talents + + +def _set_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + journal = tmp_path / "journal" + journal.mkdir() + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal.resolve())) + return journal + + +def _minimal_config(output_path: Path, *, day: str = "20260101") -> dict: + return { + "name": "daily", + "type": "cogitate", + "provider": "google", + "model": "gemini-test", + "output": "md", + "output_path": str(output_path), + "day": day, + "schedule": "daily", + "prompt": "prompt", + "user_instruction": "", + "system_instruction": "", + "sources": {}, + "health_stale": False, + } + + +def test_weekly_reflection_provenance_path(tmp_path, monkeypatch): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "reflections" / "weekly" / "20260622.md" + + assert talent_provenance._day_and_logical_output(output_path) == ( + "20260622", + Path("reflections", "weekly", "20260622.md"), + ) + assert talent_provenance.provenance_path_for_output(output_path) == ( + journal + / "chronicle" + / "20260622" + / "health" + / "talent-provenance" + / "reflections" + / "weekly" + / "20260622.md.json" + ) + + +def test_facet_news_provenance_path(tmp_path, monkeypatch): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "facets" / "work" / "news" / "20260622.md" + + assert talent_provenance._day_and_logical_output(output_path) == ( + "20260622", + Path("facets", "work", "news", "20260622.md"), + ) + assert talent_provenance.provenance_path_for_output(output_path) == ( + journal + / "chronicle" + / "20260622" + / "health" + / "talent-provenance" + / "facets" + / "work" + / "news" + / "20260622.md.json" + ) + + +@pytest.mark.parametrize( + "relative", + [ + Path("reflections", "weekly", "draft.md"), + Path("facets", "work", "news", "draft.md"), + ], +) +def test_stem_day_requires_date_without_day_dir_side_effect( + tmp_path, + monkeypatch, + relative, +): + journal = _set_journal(tmp_path, monkeypatch) + + with pytest.raises(talent_provenance.UnsupportedProvenancePath): + talent_provenance.provenance_path_for_output(journal / relative) + + assert not (journal / "chronicle" / "draft").exists() + + +def test_existing_chronicle_mapping_unchanged(tmp_path, monkeypatch): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "chronicle" / "20260101" / "talents" / "flow.md" + + assert talent_provenance._day_and_logical_output(output_path) == ( + "20260101", + Path("talents", "flow.md"), + ) + + +def test_existing_activity_mapping_unchanged(tmp_path, monkeypatch): + journal = _set_journal(tmp_path, monkeypatch) + output_path = ( + journal / "facets" / "work" / "activities" / "20260101" / "abc" / "summary.md" + ) + + assert talent_provenance._day_and_logical_output(output_path) == ( + "20260101", + Path("facets", "work", "activities", "abc", "summary.md"), + ) + + +def test_write_clean_provenance_skips_unmapped_path_without_error( + tmp_path, + monkeypatch, + caplog, +): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "apps" / "chat" / "talents" / "support.md" + output_path.parent.mkdir(parents=True) + output_path.write_text("support", encoding="utf-8") + caplog.set_level(logging.WARNING, logger="solstone.think.talents") + + talents._write_clean_provenance( + _minimal_config(output_path), + output_path, + "support", + None, + 123, + ) + + assert any(record.levelno == logging.WARNING for record in caplog.records) + assert not any(record.levelno >= logging.ERROR for record in caplog.records) + + +def test_write_clean_provenance_logs_unexpected_write_failure( + tmp_path, + monkeypatch, + caplog, +): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "chronicle" / "20260101" / "talents" / "daily.md" + output_path.parent.mkdir(parents=True) + output_path.write_text("daily", encoding="utf-8") + + def raise_os_error(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr(talents, "write_provenance", raise_os_error) + caplog.set_level(logging.WARNING, logger="solstone.think.talents") + + talents._write_clean_provenance( + _minimal_config(output_path), + output_path, + "daily", + None, + 123, + ) + + assert any(record.levelno == logging.ERROR for record in caplog.records) + assert not any(record.levelno == logging.WARNING for record in caplog.records) + + +def test_execute_with_tools_weekly_reflection_finish_writes_sidecar( + tmp_path, + monkeypatch, +): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "reflections" / "weekly" / "20260622.md" + result = "Full reflection text." + events: list[dict] = [] + + async def run_cogitate(config, on_event): + on_event({"event": "finish", "result": result}) + return "" + + monkeypatch.setattr( + "solstone.think.providers.get_provider_module", + lambda _provider: SimpleNamespace(run_cogitate=run_cogitate), + ) + + config = _minimal_config(output_path, day="20260622") + asyncio.run(talents._execute_with_tools(config, events.append)) + + assert any(event.get("event") == "finish" for event in events) + assert not any(event.get("event") == "error" for event in events) + assert output_path.read_text(encoding="utf-8") == result + assert ( + journal + / "chronicle" + / "20260622" + / "health" + / "talent-provenance" + / "reflections" + / "weekly" + / "20260622.md.json" + ).exists() + + +def test_execute_with_tools_unmapped_output_still_finishes( + tmp_path, + monkeypatch, +): + journal = _set_journal(tmp_path, monkeypatch) + output_path = journal / "apps" / "chat" / "talents" / "support.md" + result = "Support response." + events: list[dict] = [] + + async def run_cogitate(config, on_event): + on_event({"event": "finish", "result": result}) + return "" + + monkeypatch.setattr( + "solstone.think.providers.get_provider_module", + lambda _provider: SimpleNamespace(run_cogitate=run_cogitate), + ) + + config = _minimal_config(output_path, day="20260622") + asyncio.run(talents._execute_with_tools(config, events.append)) + + assert any(event.get("event") == "finish" for event in events) + assert not any(event.get("event") == "error" for event in events) + assert output_path.read_text(encoding="utf-8") == result + assert not any((journal / "chronicle").glob("*/health/talent-provenance/**/*.json"))