diff --git a/tests/test_surfaces_ledger.py b/tests/test_surfaces_ledger.py index 80028334e..90b8807b3 100644 --- a/tests/test_surfaces_ledger.py +++ b/tests/test_surfaces_ledger.py @@ -15,6 +15,14 @@ def _utc_ms(value: str) -> int: return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) +def _ledger_close_edits(record: dict) -> list[dict]: + return [ + edit + for edit in record.get("edits", []) + if isinstance(edit, dict) and edit.get("fields") == ["ledger_close"] + ] + + def _minimal_facet_tree(tmp_path, facets=("work",), *, muted_facets=()) -> None: for facet in facets: facet_dir = tmp_path / "facets" / facet @@ -286,6 +294,7 @@ def test_cross_facet_dedup(tmp_path, monkeypatch): def test_manual_close_round_trip(tmp_path, monkeypatch): + from think.activities import load_activity_records from think.surfaces import ledger as ledger_surface monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) @@ -300,11 +309,15 @@ def test_manual_close_round_trip(tmp_path, monkeypatch): item = ledger_surface.list(state="open")[0] closed = ledger_surface.close(item.id, note="done") + record = load_activity_records("work", "20260410", include_hidden=True)[0] + manual_edit = _ledger_close_edits(record)[0] assert closed.state == "closed" + assert closed.closed_at == _utc_ms(manual_edit["timestamp"]) refreshed = ledger_surface.get(item.id) assert refreshed is not None assert refreshed.state == "closed" + assert refreshed.closed_at == closed.closed_at assert any(source.field == "edits" for source in refreshed.sources) @@ -351,7 +364,7 @@ def test_close_as_dropped(tmp_path, monkeypatch): assert dropped.state == "dropped" -def test_close_with_new_as_state_appends_and_first_close_wins(tmp_path, monkeypatch): +def test_close_with_new_as_state_appends_and_latest_manual_wins(tmp_path, monkeypatch): from think.activities import load_activity_records from think.surfaces import ledger as ledger_surface @@ -369,14 +382,14 @@ def test_close_with_new_as_state_appends_and_first_close_wins(tmp_path, monkeypa ledger_surface.close(item.id, note="done", as_state="closed") dropped = ledger_surface.close(item.id, note="actually dropped", as_state="dropped") - assert dropped.state == "closed" + assert dropped.state == "dropped" record = load_activity_records("work", "20260410", include_hidden=True)[0] - closes = [ - edit["ledger_close"]["as_state"] - for edit in record["edits"] - if edit.get("fields") == ["ledger_close"] + closes = _ledger_close_edits(record) + assert [edit["ledger_close"]["as_state"] for edit in closes] == [ + "closed", + "dropped", ] - assert closes == ["closed", "dropped"] + assert dropped.closed_at == _utc_ms(closes[-1]["timestamp"]) def test_decisions_dedup(tmp_path, monkeypatch): @@ -583,7 +596,8 @@ def test_sort_default_varies_by_state(tmp_path, monkeypatch): assert [item.action for item in closed_items] == ["newer closed", "older closed"] -def test_manual_dropped_does_not_override_earlier_story_closure(tmp_path, monkeypatch): +def test_manual_dropped_overrides_earlier_story_closure(tmp_path, monkeypatch): + from think.activities import load_activity_records from think.surfaces import ledger as ledger_surface monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) @@ -607,8 +621,13 @@ def test_manual_dropped_does_not_override_earlier_story_closure(tmp_path, monkey refreshed = ledger_surface.close( item.id, note="operator says drop", as_state="dropped" ) + record = load_activity_records("work", "20260410", include_hidden=True)[0] + manual_edit = _ledger_close_edits(record)[-1] - assert refreshed.state == "closed" + assert refreshed.state == "dropped" + assert refreshed.closed_at == _utc_ms(manual_edit["timestamp"]) + assert refreshed.closed_at != _utc_ms("2026-04-11T10:00:00Z") + assert any(source.field == "closures" for source in refreshed.sources) assert any( source.field == "edits" and source.activity_id == "meeting_090000_300" for source in refreshed.sources diff --git a/think/surfaces/ledger.py b/think/surfaces/ledger.py index 58d9d59b1..ad1e783df 100644 --- a/think/surfaces/ledger.py +++ b/think/surfaces/ledger.py @@ -5,7 +5,8 @@ Dropped/deferred talent resolutions: any matched closure -> state="closed" regardless of its `resolution` field. CLI `--as dropped` is the only path to -state="dropped". +state="dropped". Manual `ledger_close` edits override talent-extracted +closures for state computation. Among manual edits, latest wins. """ from __future__ import annotations @@ -201,6 +202,22 @@ def _list_all_facets() -> builtins.list[str]: return builtins.list(get_facets().keys()) +def _resolve_close_state( + story_closes: builtins.list[dict[str, Any]], + manual_closes: builtins.list[dict[str, Any]], +) -> tuple[str, int | None]: + if manual_closes: + # Manual ledger_close edits override storyteller closures; latest manual wins so operators can close -> dropped. + latest_manual = max( + manual_closes, key=lambda candidate: candidate["manual_order_key"] + ) + return latest_manual["state"], latest_manual["closed_at"] + if story_closes: + earliest_story = min(story_closes, key=lambda candidate: candidate["sort_key"]) + return earliest_story["state"], earliest_story["closed_at"] + return "open", None + + def _build_ledger_items( records: Iterable[tuple[str, str, dict[str, Any]]], ) -> builtins.list[LedgerItem]: @@ -314,7 +331,7 @@ def _build_ledger_items( } ) - for raw_edit in record.get("edits", []): + for edit_index, raw_edit in enumerate(record.get("edits", [])): if not isinstance(raw_edit, dict): continue if raw_edit.get("fields") != ["ledger_close"]: @@ -334,6 +351,13 @@ def _build_ledger_items( "closed_at": closed_at, "state": as_state, "sort_key": _chronological_key(closed_at, facet, day, record_id), + "manual_order_key": ( + closed_at, + facet, + day, + record_id, + edit_index, + ), "source": _source_ref( facet=facet, day=day, @@ -358,12 +382,12 @@ def _build_ledger_items( matched_story_sources.append(candidate) consumed_story_closures.add(index) - closure_sources = matched_story_sources + manual_closes.get(entry["id"], []) - # State/closed_at follow the earliest close across story and manual sources; later manual edits remain visible in sources but do not rewrite the first close. + item_manual_closes = manual_closes.get(entry["id"], []) + closure_sources = matched_story_sources + item_manual_closes closure_sources.sort(key=lambda candidate: candidate["sort_key"]) - first_close = closure_sources[0] if closure_sources else None - state = first_close["state"] if first_close is not None else "open" - closed_at = first_close["closed_at"] if first_close is not None else None + state, closed_at = _resolve_close_state( + matched_story_sources, item_manual_closes + ) sources = builtins.list(entry["sources"]) sources.extend(candidate["source"] for candidate in closure_sources) sources.sort(key=_source_sort_key)