personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

think/surfaces: manual ledger close overrides talent closure on state

+59 -16
+28 -9
tests/test_surfaces_ledger.py
··· 15 15 return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) 16 16 17 17 18 + def _ledger_close_edits(record: dict) -> list[dict]: 19 + return [ 20 + edit 21 + for edit in record.get("edits", []) 22 + if isinstance(edit, dict) and edit.get("fields") == ["ledger_close"] 23 + ] 24 + 25 + 18 26 def _minimal_facet_tree(tmp_path, facets=("work",), *, muted_facets=()) -> None: 19 27 for facet in facets: 20 28 facet_dir = tmp_path / "facets" / facet ··· 286 294 287 295 288 296 def test_manual_close_round_trip(tmp_path, monkeypatch): 297 + from think.activities import load_activity_records 289 298 from think.surfaces import ledger as ledger_surface 290 299 291 300 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) ··· 300 309 301 310 item = ledger_surface.list(state="open")[0] 302 311 closed = ledger_surface.close(item.id, note="done") 312 + record = load_activity_records("work", "20260410", include_hidden=True)[0] 313 + manual_edit = _ledger_close_edits(record)[0] 303 314 304 315 assert closed.state == "closed" 316 + assert closed.closed_at == _utc_ms(manual_edit["timestamp"]) 305 317 refreshed = ledger_surface.get(item.id) 306 318 assert refreshed is not None 307 319 assert refreshed.state == "closed" 320 + assert refreshed.closed_at == closed.closed_at 308 321 assert any(source.field == "edits" for source in refreshed.sources) 309 322 310 323 ··· 351 364 assert dropped.state == "dropped" 352 365 353 366 354 - def test_close_with_new_as_state_appends_and_first_close_wins(tmp_path, monkeypatch): 367 + def test_close_with_new_as_state_appends_and_latest_manual_wins(tmp_path, monkeypatch): 355 368 from think.activities import load_activity_records 356 369 from think.surfaces import ledger as ledger_surface 357 370 ··· 369 382 ledger_surface.close(item.id, note="done", as_state="closed") 370 383 dropped = ledger_surface.close(item.id, note="actually dropped", as_state="dropped") 371 384 372 - assert dropped.state == "closed" 385 + assert dropped.state == "dropped" 373 386 record = load_activity_records("work", "20260410", include_hidden=True)[0] 374 - closes = [ 375 - edit["ledger_close"]["as_state"] 376 - for edit in record["edits"] 377 - if edit.get("fields") == ["ledger_close"] 387 + closes = _ledger_close_edits(record) 388 + assert [edit["ledger_close"]["as_state"] for edit in closes] == [ 389 + "closed", 390 + "dropped", 378 391 ] 379 - assert closes == ["closed", "dropped"] 392 + assert dropped.closed_at == _utc_ms(closes[-1]["timestamp"]) 380 393 381 394 382 395 def test_decisions_dedup(tmp_path, monkeypatch): ··· 583 596 assert [item.action for item in closed_items] == ["newer closed", "older closed"] 584 597 585 598 586 - def test_manual_dropped_does_not_override_earlier_story_closure(tmp_path, monkeypatch): 599 + def test_manual_dropped_overrides_earlier_story_closure(tmp_path, monkeypatch): 600 + from think.activities import load_activity_records 587 601 from think.surfaces import ledger as ledger_surface 588 602 589 603 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) ··· 607 621 refreshed = ledger_surface.close( 608 622 item.id, note="operator says drop", as_state="dropped" 609 623 ) 624 + record = load_activity_records("work", "20260410", include_hidden=True)[0] 625 + manual_edit = _ledger_close_edits(record)[-1] 610 626 611 - assert refreshed.state == "closed" 627 + assert refreshed.state == "dropped" 628 + assert refreshed.closed_at == _utc_ms(manual_edit["timestamp"]) 629 + assert refreshed.closed_at != _utc_ms("2026-04-11T10:00:00Z") 630 + assert any(source.field == "closures" for source in refreshed.sources) 612 631 assert any( 613 632 source.field == "edits" and source.activity_id == "meeting_090000_300" 614 633 for source in refreshed.sources
+31 -7
think/surfaces/ledger.py
··· 5 5 6 6 Dropped/deferred talent resolutions: any matched closure -> state="closed" 7 7 regardless of its `resolution` field. CLI `--as dropped` is the only path to 8 - state="dropped". 8 + state="dropped". Manual `ledger_close` edits override talent-extracted 9 + closures for state computation. Among manual edits, latest wins. 9 10 """ 10 11 11 12 from __future__ import annotations ··· 201 202 return builtins.list(get_facets().keys()) 202 203 203 204 205 + def _resolve_close_state( 206 + story_closes: builtins.list[dict[str, Any]], 207 + manual_closes: builtins.list[dict[str, Any]], 208 + ) -> tuple[str, int | None]: 209 + if manual_closes: 210 + # Manual ledger_close edits override storyteller closures; latest manual wins so operators can close -> dropped. 211 + latest_manual = max( 212 + manual_closes, key=lambda candidate: candidate["manual_order_key"] 213 + ) 214 + return latest_manual["state"], latest_manual["closed_at"] 215 + if story_closes: 216 + earliest_story = min(story_closes, key=lambda candidate: candidate["sort_key"]) 217 + return earliest_story["state"], earliest_story["closed_at"] 218 + return "open", None 219 + 220 + 204 221 def _build_ledger_items( 205 222 records: Iterable[tuple[str, str, dict[str, Any]]], 206 223 ) -> builtins.list[LedgerItem]: ··· 314 331 } 315 332 ) 316 333 317 - for raw_edit in record.get("edits", []): 334 + for edit_index, raw_edit in enumerate(record.get("edits", [])): 318 335 if not isinstance(raw_edit, dict): 319 336 continue 320 337 if raw_edit.get("fields") != ["ledger_close"]: ··· 334 351 "closed_at": closed_at, 335 352 "state": as_state, 336 353 "sort_key": _chronological_key(closed_at, facet, day, record_id), 354 + "manual_order_key": ( 355 + closed_at, 356 + facet, 357 + day, 358 + record_id, 359 + edit_index, 360 + ), 337 361 "source": _source_ref( 338 362 facet=facet, 339 363 day=day, ··· 358 382 matched_story_sources.append(candidate) 359 383 consumed_story_closures.add(index) 360 384 361 - closure_sources = matched_story_sources + manual_closes.get(entry["id"], []) 362 - # 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. 385 + item_manual_closes = manual_closes.get(entry["id"], []) 386 + closure_sources = matched_story_sources + item_manual_closes 363 387 closure_sources.sort(key=lambda candidate: candidate["sort_key"]) 364 - first_close = closure_sources[0] if closure_sources else None 365 - state = first_close["state"] if first_close is not None else "open" 366 - closed_at = first_close["closed_at"] if first_close is not None else None 388 + state, closed_at = _resolve_close_state( 389 + matched_story_sources, item_manual_closes 390 + ) 367 391 sources = builtins.list(entry["sources"]) 368 392 sources.extend(candidate["source"] for candidate in closure_sources) 369 393 sources.sort(key=_source_sort_key)