cctally 1.47.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ import json as _json
16
16
  import os
17
17
  import re
18
18
  import sqlite3
19
- from datetime import datetime as _datetime
19
+ from datetime import datetime as _datetime, timezone as _timezone
20
20
 
21
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
22
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
@@ -272,6 +272,55 @@ def _subagent_key(source_path):
272
272
  return stem or None
273
273
 
274
274
 
275
+ # §4 1a — nested (grandchild) subagent result parse. The new Claude Code format
276
+ # emits a grandchild's spawn result as STRING content (no structured
277
+ # `agent_id`/`subagent_meta`), with a trailing `agentId: <hash> (use SendMessage
278
+ # …)` line and an OPTIONAL `<usage>` totals wrapper. Anchored on the literal
279
+ # `<usage>` wrapper; tolerant of whitespace/newline variants (re.DOTALL).
280
+ _NESTED_AGENT_ID_RE = re.compile(r"agentId:\s*([0-9a-f]+)")
281
+ _NESTED_USAGE_RE = re.compile(
282
+ r"<usage>\s*subagent_tokens:\s*(\d+)\s*tool_uses:\s*(\d+)\s*duration_ms:\s*(\d+)\s*</usage>",
283
+ re.DOTALL,
284
+ )
285
+
286
+
287
+ def _parse_nested_agent_result(text):
288
+ """Nested (grandchild) subagent result: string-content, no structured
289
+ `agentId` (spec §4 1a). Parse `agentId` (+ OPTIONAL `<usage>` totals) out of
290
+ the result text. Returns (agent_id, meta) — meta is {} when `<usage>` is
291
+ absent/clipped past the 16 KB cap, populated with completed totals when
292
+ present. Returns None when no `agentId` is present (an ordinary result, or a
293
+ >16 KB clip past the `agentId:` line itself — degrades to a flat card, no
294
+ mis-link). Linking on `agentId` ALONE is sufficient (Codex P1-B)."""
295
+ if not text:
296
+ return None
297
+ m = _NESTED_AGENT_ID_RE.search(text)
298
+ if not m:
299
+ return None
300
+ meta = {}
301
+ u = _NESTED_USAGE_RE.search(text)
302
+ if u:
303
+ meta["total_tokens"] = int(u.group(1))
304
+ meta["total_tool_use_count"] = int(u.group(2))
305
+ meta["total_duration_ms"] = int(u.group(3))
306
+ meta["status"] = "completed"
307
+ return m.group(1), meta
308
+
309
+
310
+ def _iso_ms(ts):
311
+ """Epoch milliseconds for an ISO-8601 timestamp string (tolerant of a
312
+ trailing 'Z'); None on parse failure. Used by §4 1c derived-duration."""
313
+ if not ts:
314
+ return None
315
+ try:
316
+ dt = _datetime.fromisoformat(ts.replace("Z", "+00:00"))
317
+ if dt.tzinfo is None:
318
+ dt = dt.replace(tzinfo=_timezone.utc)
319
+ return int(dt.timestamp() * 1000)
320
+ except (ValueError, TypeError):
321
+ return None
322
+
323
+
275
324
  def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
276
325
  """Cost for one session_entries row via the shared pricing helper. Tokens →
277
326
  the helper's usage dict. cost_usd_raw is passed as the optional override the
@@ -396,6 +445,30 @@ def _stamp_cache_failures(items):
396
445
  running_max[key] = max(rm, cr)
397
446
 
398
447
 
448
+ def session_cache_rebuild_count(conn, session_id) -> int:
449
+ """Per-session count of cache-rebuild (cache-failure) turns.
450
+
451
+ Single source of truth: assembles the session via the SAME pipeline the
452
+ reader/outline use and runs the SAME _stamp_cache_failures kernel, then
453
+ counts flagged items. /outline omits stats.cache_failures when the count is
454
+ 0, so "no flagged items" -> 0 (byte-identical to outline). Stored on the
455
+ conversation_sessions rollup by _recompute_conversation_sessions; filtered
456
+ by list_conversations(rebuild_min=...).
457
+
458
+ _assemble_session already runs _stamp_cache_failures internally before
459
+ returning, so the items carry the flag on entry; re-running the kernel here
460
+ is an idempotent recompute (it only ADDS the key on a flagged turn, never
461
+ clears one, and the predicate is deterministic over the same items) that
462
+ keeps the helper correct even if assembly ever stops stamping.
463
+ """
464
+ asm = _assemble_session(conn, session_id)
465
+ if asm is None:
466
+ return 0
467
+ items = asm["items"]
468
+ _stamp_cache_failures(items)
469
+ return sum(1 for it in items if "cache_failure" in it)
470
+
471
+
399
472
  def _session_cost_map(conn, session_ids):
400
473
  """{session_id: total_cost_usd} for the given sessions. Joins
401
474
  conversation_messages turn keys to the single deduped session_entries row
@@ -521,40 +594,135 @@ def _rollup_authoritative(conn) -> bool:
521
594
  return not pending
522
595
 
523
596
 
524
- def _list_session_rows_rollup(conn, order, limit, offset):
597
+ # --- Browse-list filter predicates (spec §2) ------------------------------
598
+ # The rail filters on four axes (date / project / cost / cache-rebuilds). All
599
+ # four are STORED columns on the conversation_sessions rollup, so the rollup
600
+ # fast path expresses every axis as a parameterized WHERE pushed BEFORE
601
+ # LIMIT/OFFSET (pagination stays correct over the filtered set). The live
602
+ # GROUP BY fallback has no cost/project/rebuild columns, so it expresses ONLY
603
+ # the date axis (via HAVING over MAX(timestamp_utc)) — the rollup-only axes
604
+ # degrade in that brief, self-correcting non-authoritative window (spec §1
605
+ # dual-branch parity). The date predicate compares against the stored
606
+ # last_activity_utc string, so the caller MUST pass UTC-ISO bounds in the same
607
+ # format (...Z) — see bin/_lib_dashboard_dates.parse_filter_date_range.
608
+ _FILTER_KEYS = ("date_from", "date_to", "projects",
609
+ "cost_min", "cost_max", "rebuild_min")
610
+ # The rollup-only axes — the ones the live fallback cannot express. Used to set
611
+ # the page's filter_degraded flag when one is requested under the live branch.
612
+ _ROLLUP_ONLY_FILTER_KEYS = ("projects", "cost_min", "cost_max", "rebuild_min")
613
+
614
+
615
+ def _empty_filters() -> dict:
616
+ """A no-op filter dict (every axis None) — the unfiltered default and the
617
+ shape the row-source helpers expect."""
618
+ return {k: None for k in _FILTER_KEYS}
619
+
620
+
621
+ def _rollup_where(filters):
622
+ """(sql_fragment, params) for the ROLLUP branch — all four axes are stored
623
+ columns. Returns (" WHERE ...", [params]) or ("", []) when no axis is set.
624
+ Project IN-list naturally excludes empty/NULL project_label (neither the ''
625
+ no-cwd sentinel nor a NULL not-yet-filled row matches a real label).
626
+
627
+ The date axis is HALF-OPEN: ``date_from`` is an inclusive start-of-day lower
628
+ bound (``>=``) and ``date_to`` is the EXCLUSIVE start-of-next-day upper bound
629
+ (``<``) emitted by ``_lib_dashboard_dates.parse_filter_date_range``. The
630
+ strict ``<`` is load-bearing: it makes the lex compare against the stored
631
+ mixed-precision ``last_activity_utc`` (whole-second AND millisecond ``...Z``)
632
+ chronologically correct at the day edge (review Finding 1)."""
633
+ clauses, params = [], []
634
+ if filters["date_from"] is not None:
635
+ clauses.append("last_activity_utc >= ?"); params.append(filters["date_from"])
636
+ if filters["date_to"] is not None:
637
+ clauses.append("last_activity_utc < ?"); params.append(filters["date_to"])
638
+ if filters["projects"]:
639
+ ph = ",".join("?" for _ in filters["projects"])
640
+ clauses.append("project_label IN (%s)" % ph); params.extend(filters["projects"])
641
+ if filters["cost_min"] is not None:
642
+ clauses.append("cost_usd >= ?"); params.append(filters["cost_min"])
643
+ if filters["cost_max"] is not None:
644
+ clauses.append("cost_usd <= ?"); params.append(filters["cost_max"])
645
+ if filters["rebuild_min"] is not None:
646
+ clauses.append("cache_rebuild_count >= ?"); params.append(filters["rebuild_min"])
647
+ return (" WHERE " + " AND ".join(clauses)) if clauses else "", params
648
+
649
+
650
+ def _live_having(filters):
651
+ """(sql_fragment, params) for the LIVE fallback — only the DATE axis is
652
+ expressible (no cost/project/rebuild columns exist on the raw messages).
653
+ The rollup's last_activity_utc IS MAX(timestamp_utc), so the date predicate
654
+ is the same HALF-OPEN bound applied via HAVING over the aggregate: inclusive
655
+ ``>=`` lower, EXCLUSIVE strict-``<`` upper — kept byte-for-byte in lockstep
656
+ with ``_rollup_where``'s date axis so both branches agree at the day edge
657
+ over mixed-precision timestamps (review Finding 1)."""
658
+ having, params = [], []
659
+ if filters["date_from"] is not None:
660
+ having.append("MAX(timestamp_utc) >= ?"); params.append(filters["date_from"])
661
+ if filters["date_to"] is not None:
662
+ having.append("MAX(timestamp_utc) < ?"); params.append(filters["date_to"])
663
+ return (" HAVING " + " AND ".join(having)) if having else "", params
664
+
665
+
666
+ def _list_session_rows_rollup(conn, order, limit, offset, filters=None):
525
667
  """FAST path: read the pre-aggregated rail rows straight from the
526
668
  conversation_sessions rollup (spec §3). No GROUP BY, no temp B-tree for the
527
669
  ``recent`` sort. Returns (session_id, msg_count, started, last_activity)
528
670
  tuples — the same shape the live aggregate yields, so the downstream
529
- assembly is identical. No WHERE is needed: the rollup is non-null by
530
- construction (PK NOT NULL; the recompute's GROUP BY already drops NULLs)."""
671
+ assembly is identical. The optional ``filters`` dict pushes the four-axis
672
+ browse predicate (date/project/cost/rebuild all stored columns) into the
673
+ WHERE BEFORE LIMIT/OFFSET, so ``has_more``/``next_offset`` stay correct over
674
+ the filtered set."""
675
+ where, params = _rollup_where(filters or _empty_filters())
531
676
  return conn.execute(
532
677
  "SELECT session_id, msg_count, "
533
678
  " started_utc AS started, last_activity_utc AS last_activity "
534
- "FROM conversation_sessions "
535
- "ORDER BY " + order + " LIMIT ? OFFSET ?",
536
- (limit + 1, offset),
679
+ "FROM conversation_sessions"
680
+ + where +
681
+ " ORDER BY " + order + " LIMIT ? OFFSET ?",
682
+ (*params, limit + 1, offset),
537
683
  ).fetchall()
538
684
 
539
685
 
540
- def _list_session_rows_live(conn, order, limit, offset):
686
+ def _list_session_rows_live(conn, order, limit, offset, filters=None):
541
687
  """RETAINED fallback (Codex gate BLOCKER 2): the original live GROUP BY over
542
688
  conversation_messages, used while the rollup is not authoritative (the flag
543
689
  is set — e.g. an existing install before its first sync, or permanently
544
690
  under ``--no-sync``). Byte-identical output to the rollup branch by
545
- construction; ``order`` here is the _SORTS_LIVE aggregate expression."""
691
+ construction; ``order`` here is the _SORTS_LIVE aggregate expression. The
692
+ optional ``filters`` dict applies ONLY the date axis (via HAVING over
693
+ MAX(timestamp_utc)); the rollup-only axes (project/cost/rebuild) cannot be
694
+ expressed here and are dropped — the caller flags filter_degraded."""
695
+ having, hparams = _live_having(filters or _empty_filters())
546
696
  return conn.execute(
547
697
  "SELECT session_id, COUNT(*) AS msg_count, "
548
698
  " MIN(timestamp_utc) AS started, MAX(timestamp_utc) AS last_activity "
549
699
  "FROM conversation_messages "
550
700
  "WHERE session_id IS NOT NULL "
551
- "GROUP BY session_id "
552
- "ORDER BY " + order + " LIMIT ? OFFSET ?",
553
- (limit + 1, offset),
701
+ "GROUP BY session_id"
702
+ + having +
703
+ " ORDER BY " + order + " LIMIT ? OFFSET ?",
704
+ (*hparams, limit + 1, offset),
554
705
  ).fetchall()
555
706
 
556
707
 
557
- def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
708
+ def list_conversation_facets(conn) -> dict:
709
+ """Distinct projects (+ conversation counts) for the browse filter
710
+ multi-select (spec §2). Reads the rollup; cheap GROUP BY. Empty/NULL
711
+ project labels are dropped (a no-cwd session stores '' and a not-yet-filled
712
+ row stores NULL — neither is a real selectable project). Sorted ascending
713
+ by label so the popover renders a stable list. Returns
714
+ ``{"projects": [{"project_label": str, "count": int}, ...]}``."""
715
+ rows = conn.execute(
716
+ "SELECT project_label, COUNT(*) FROM conversation_sessions "
717
+ "WHERE project_label IS NOT NULL AND project_label != '' "
718
+ "GROUP BY project_label ORDER BY project_label"
719
+ ).fetchall()
720
+ return {"projects": [{"project_label": p, "count": n} for p, n in rows]}
721
+
722
+
723
+ def list_conversations(conn, *, sort="recent", limit=50, offset=0,
724
+ date_from=None, date_to=None, projects=None,
725
+ cost_min=None, cost_max=None, rebuild_min=None) -> dict:
558
726
  """All-history per-session browse rows (spec §3.1). NOT 365-day bounded.
559
727
 
560
728
  Reads the conversation_sessions rollup (Task A) when it is authoritative —
@@ -573,12 +741,27 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
573
741
  revalidates every visible tick anyway."""
574
742
  limit = max(1, min(int(limit), 200))
575
743
  offset = max(0, int(offset))
744
+ # Browse-list filters (spec §2). The rollup branch expresses all four axes
745
+ # as column predicates; the live fallback only the date axis, so when a
746
+ # rollup-only axis is requested under the live branch we flag the page
747
+ # filter_degraded (a brief, self-correcting non-authoritative window).
748
+ filters = {
749
+ "date_from": date_from,
750
+ "date_to": date_to,
751
+ "projects": list(projects) if projects else None,
752
+ "cost_min": cost_min,
753
+ "cost_max": cost_max,
754
+ "rebuild_min": rebuild_min,
755
+ }
756
+ degraded = False
576
757
  if _rollup_authoritative(conn):
577
758
  order = _SORTS.get(sort, _SORTS["recent"])
578
- rows = _list_session_rows_rollup(conn, order, limit, offset)
759
+ rows = _list_session_rows_rollup(conn, order, limit, offset, filters)
579
760
  else:
580
761
  order = _SORTS_LIVE.get(sort, _SORTS_LIVE["recent"])
581
- rows = _list_session_rows_live(conn, order, limit, offset)
762
+ degraded = any(
763
+ filters[k] is not None for k in _ROLLUP_ONLY_FILTER_KEYS)
764
+ rows = _list_session_rows_live(conn, order, limit, offset, filters)
582
765
  has_more = len(rows) > limit
583
766
  rows = rows[:limit]
584
767
  session_ids = [r[0] for r in rows]
@@ -601,12 +784,17 @@ def list_conversations(conn, *, sort="recent", limit=50, offset=0) -> dict:
601
784
  }
602
785
  for (sid, msg_count, started, last_activity) in rows
603
786
  ]
787
+ page = {
788
+ "next_offset": offset + len(conversations) if has_more else None,
789
+ "has_more": has_more,
790
+ }
791
+ if degraded:
792
+ # The rail surfaces this: project/cost/rebuild filters apply once the
793
+ # rollup finishes indexing; only the date axis held this page.
794
+ page["filter_degraded"] = True
604
795
  return {
605
796
  "conversations": conversations,
606
- "page": {
607
- "next_offset": offset + len(conversations) if has_more else None,
608
- "has_more": has_more,
609
- },
797
+ "page": page,
610
798
  }
611
799
 
612
800
 
@@ -758,6 +946,9 @@ def _assemble_session(conn, session_id):
758
946
  spawn_kind = {} # tool_use id -> subagent_type
759
947
  spawn_desc = {} # tool_use id -> spawning Task description (#193)
760
948
  agent_link = {} # tool_use id -> (agent_id, raw_meta)
949
+ nested_candidates = [] # §4 1a: (tool_use_id, block) — string-content spawn
950
+ # results with no structured agent_id, resolved after
951
+ # spawn_kind is complete (text still present pre-fold)
761
952
  ask_link = {} # tool_use id -> (answers, annotations) (#177 S2)
762
953
  bash_link = {} # tool_use id -> (stderr, interrupted) (#177 S3)
763
954
  web_search_link = {} # tool_use id -> web_search payload (#177 S4)
@@ -782,6 +973,11 @@ def _assemble_session(conn, session_id):
782
973
  meta = b.pop("subagent_meta", None)
783
974
  if aid and b.get("tool_use_id") is not None:
784
975
  agent_link[b["tool_use_id"]] = (aid, meta or {})
976
+ elif b.get("tool_use_id") is not None:
977
+ # §4 1a candidate — a tool_result with no structured agentId.
978
+ # Resolved AFTER spawn_kind is complete (text still present,
979
+ # pre-Phase-2-fold). The block's `text` holds the result body.
980
+ nested_candidates.append((b["tool_use_id"], b))
785
981
  ans = b.pop("ask_answers", None) # #177 S2
786
982
  anno = b.pop("ask_annotations", None)
787
983
  if ans is not None and b.get("tool_use_id") is not None:
@@ -800,6 +996,14 @@ def _assemble_session(conn, session_id):
800
996
  tlist_ = b.pop("task_list", None)
801
997
  if b.get("tool_use_id") is not None and (tid_ is not None or tlist_ is not None):
802
998
  task_link[b["tool_use_id"]] = {"task_id": tid_, "task_list": tlist_}
999
+ # §4 1a — nested grandchild results: gate the text-parse on the owning
1000
+ # tool_use being a spawn (in spawn_kind), so an ordinary tool result that
1001
+ # merely mentions "agentId" never matches.
1002
+ for _tuid, _block in nested_candidates:
1003
+ if _tuid in spawn_kind and _tuid not in agent_link:
1004
+ parsed = _parse_nested_agent_result(_block.get("text"))
1005
+ if parsed is not None:
1006
+ agent_link[_tuid] = parsed
803
1007
  subagent_meta = {}
804
1008
  for _tuid, _kind in spawn_kind.items():
805
1009
  _link = agent_link.get(_tuid)
@@ -814,6 +1018,24 @@ def _assemble_session(conn, session_id):
814
1018
  _entry[_f] = _raw[_f]
815
1019
  subagent_meta[_aid] = _entry # agent_id == subagent_key
816
1020
 
1021
+ # §4 1b — parent linkage: the item HOLDING each linked spawn is the child's
1022
+ # parent thread + placement anchor. tooluse_index[spawn_id] = (item, block);
1023
+ # the item's subagent_key/anchor.uuid were set at build time (available now,
1024
+ # pre-Phase-3). Works for a main parent (subagent_key=None) AND a grandchild
1025
+ # whose parent is a child subagent. spawn_tool_use_id is required because one
1026
+ # assistant item can hold MORE than one spawn (Codex P1-C).
1027
+ for _tuid, (_aid, _raw) in agent_link.items():
1028
+ _entry = subagent_meta.get(_aid)
1029
+ if _entry is None:
1030
+ continue
1031
+ _hit = tooluse_index.get(_tuid)
1032
+ if _hit is None:
1033
+ continue
1034
+ _owner = _hit[0]
1035
+ _entry["parent_subagent_key"] = _owner["subagent_key"]
1036
+ _entry["spawn_uuid"] = _owner["anchor"]["uuid"]
1037
+ _entry["spawn_tool_use_id"] = _tuid
1038
+
817
1039
  # ---- Phase 2: fold each tool_result item into its owning assistant item ----
818
1040
  drop = set() # id() of folded placeholder items
819
1041
  for tr in tool_result_items:
@@ -987,6 +1209,66 @@ def _assemble_session(conn, session_id):
987
1209
  it.pop("_has_prose", None)
988
1210
  header_cost = round(header_cost, 6)
989
1211
 
1212
+ # §4 1c — async completion. A background subagent's launch result carries
1213
+ # status:"async_launched" and NO totals; completion arrives as a separate
1214
+ # <task-notification> meta row (text populated by Phase 4). Join it back, and
1215
+ # derive any totals Claude Code never provided from the child's own thread.
1216
+ _notif_status = {} # spawn tool_use_id -> status
1217
+ for it in items:
1218
+ if it["kind"] == "meta" and it.get("meta_kind") == "notification":
1219
+ body = it.get("text") or ""
1220
+ tu = re.search(r"<tool-use-id>([^<]+)</tool-use-id>", body)
1221
+ stx = re.search(r"<status>([^<]+)</status>", body)
1222
+ if tu and stx:
1223
+ _notif_status[tu.group(1).strip()] = stx.group(1).strip()
1224
+ for _tuid, _status in _notif_status.items():
1225
+ _link = agent_link.get(_tuid)
1226
+ if _link is not None and _link[0] in subagent_meta:
1227
+ subagent_meta[_link[0]]["status"] = _status # upgrades async_launched -> completed
1228
+
1229
+ # Derived totals: any child still missing a count gets it from its own
1230
+ # subagent_key bucket. tool-count = tool_call/tool_use blocks; duration =
1231
+ # (max_ts - min_ts) ms; tokens = sum of per-turn token totals (now stamped).
1232
+ # Authoritative values always win; only missing keys are filled.
1233
+ # Bucket items by subagent_key in ONE document-ordered pass so the loop
1234
+ # below indexes in O(1) instead of re-walking all items per child.
1235
+ _items_by_subagent_key: dict[str, list] = {}
1236
+ for it in items:
1237
+ _sk = it.get("subagent_key")
1238
+ if _sk is not None:
1239
+ _items_by_subagent_key.setdefault(_sk, []).append(it)
1240
+ for _aid, _entry in subagent_meta.items():
1241
+ if all(_entry.get(_f) is not None
1242
+ for _f in ("total_tokens", "total_duration_ms", "total_tool_use_count")):
1243
+ continue
1244
+ _bucket = _items_by_subagent_key.get(_aid)
1245
+ if not _bucket:
1246
+ continue
1247
+ _derived = False
1248
+ if _entry.get("total_tool_use_count") is None:
1249
+ _entry["total_tool_use_count"] = sum(
1250
+ 1 for it in _bucket for b in it["blocks"]
1251
+ if b.get("kind") in ("tool_call", "tool_use"))
1252
+ _derived = True
1253
+ if _entry.get("total_duration_ms") is None:
1254
+ _ms = [m for m in (_iso_ms(it.get("ts")) for it in _bucket) if m is not None]
1255
+ if len(_ms) >= 2:
1256
+ _entry["total_duration_ms"] = max(_ms) - min(_ms)
1257
+ _derived = True
1258
+ if _entry.get("total_tokens") is None:
1259
+ _tok, _any = 0, False
1260
+ for it in _bucket:
1261
+ t = it.get("tokens")
1262
+ if t:
1263
+ _any = True
1264
+ _tok += (t.get("input", 0) + t.get("output", 0)
1265
+ + t.get("cache_creation", 0) + t.get("cache_read", 0))
1266
+ if _any:
1267
+ _entry["total_tokens"] = _tok
1268
+ _derived = True
1269
+ if _derived:
1270
+ _entry["totals_derived"] = True
1271
+
990
1272
  # Stamp cache-failure markers (spec §1) AFTER tokens are on each item and
991
1273
  # while `items` is still document-ordered (the running-max walk is
992
1274
  # order-dependent). Healthy turns get no key (absent, not zero). Shared
@@ -1026,6 +1308,20 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1026
1308
  _pl = _project_label(_latest(logical, 10))
1027
1309
  _title = _session_titles_map(conn, [session_id]).get(session_id) or _pl or session_id
1028
1310
 
1311
+ # Jump-to-latest target (spec §3): the {session_id, uuid, id} of the
1312
+ # conversation's FINAL rendered turn, sourced from the tail of the
1313
+ # whole-session assembled item list (so it lands on a real grouped item, not
1314
+ # a folded fragment). Codex P2 #4: assembled anchors carry session_id=None
1315
+ # (only the returned PAGE's anchors are patched), so build it EXPLICITLY with
1316
+ # the request session_id rather than copying items[-1]["anchor"] verbatim.
1317
+ # None only for a genuinely empty conversation (an existing session always
1318
+ # has >=1 item, so this stays non-None for any non-None return). Computed on
1319
+ # the unsliced list so it is the SAME regardless of which page was requested.
1320
+ last_anchor = None
1321
+ if items:
1322
+ _la = items[-1]["anchor"]
1323
+ last_anchor = {"session_id": session_id, "uuid": _la["uuid"], "id": _la["id"]}
1324
+
1029
1325
  # Cursor pagination over the item list (anchored to each item's canonical id).
1030
1326
  # A non-None `after` that matches no item's anchor (stale/deleted cursor)
1031
1327
  # yields an EMPTY page — never silently re-serves the head (M1).
@@ -1046,6 +1342,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1046
1342
  "last_activity_utc": logical[-1][2],
1047
1343
  "cost_usd": header_cost,
1048
1344
  "models": sorted({r[6] for r in logical if r[6]}),
1345
+ "last_anchor": last_anchor,
1049
1346
  "items": [],
1050
1347
  "subagent_meta": subagent_meta,
1051
1348
  "page": {"next_after": None, "has_more": False},
@@ -1081,6 +1378,7 @@ def get_conversation(conn, session_id, *, after=None, limit=500):
1081
1378
  "last_activity_utc": last[2],
1082
1379
  "cost_usd": header_cost,
1083
1380
  "models": models,
1381
+ "last_anchor": last_anchor,
1084
1382
  "items": page,
1085
1383
  "subagent_meta": subagent_meta,
1086
1384
  "page": {"next_after": next_after, "has_more": has_more},
@@ -0,0 +1,166 @@
1
+ """Pure date-range parsing for dashboard browse filters (spec §2).
2
+
3
+ The CLI's ``_parse_cli_date_range`` is argparse-coupled and returns a CLI exit
4
+ code (Codex pre-plan P1 #3), so the dashboard cannot reuse it directly. This
5
+ module is the decoupled sibling: it maps a ``(date_from, date_to)`` pair to
6
+ UTC-ISO boundary strings that lexicographically compare against the stored
7
+ ``last_activity_utc`` / ``MAX(timestamp_utc)`` value, or raises ``ValueError`` —
8
+ which the handler maps to HTTP 400.
9
+
10
+ HALF-OPEN, precision-safe bounds (Task 2 review Finding 1)
11
+ ----------------------------------------------------------
12
+ Stored timestamps are RAW JSONL passthrough of MIXED precision: both
13
+ whole-second ``YYYY-MM-DDTHH:MM:SSZ`` and millisecond
14
+ ``YYYY-MM-DDTHH:MM:SS.mmmZ`` occur in real ``~/.claude/projects`` data. A
15
+ lexicographic SQL string compare on those values is only correct if the bounds
16
+ are chosen so the lex order matches the chronological order at the day edges.
17
+ ASCII ``Z`` (0x5A) sorts AFTER ``.`` (0x2E) and after the digits ``0``-``9``
18
+ (0x30-0x39), so a whole-second ``...00:00:00Z`` lower bound and an inclusive
19
+ ``...23:59:59.999999Z`` upper bound BOTH mis-compare at the boundaries:
20
+
21
+ * a midnight row ``...T00:00:00.000Z`` is wrongly EXCLUDED by ``>=
22
+ ...T00:00:00Z`` (``.000Z`` < ``00Z`` because ``.`` < ``Z``);
23
+ * a last-ms row ``...T23:59:59.999Z`` is wrongly EXCLUDED by ``<=
24
+ ...T23:59:59.999999Z`` (``.999Z`` > ``.999999Z`` because ``Z`` > ``9``);
25
+ * a whole-second ``...T23:59:59Z`` row is wrongly EXCLUDED by the same upper
26
+ bound (``Z`` > ``.``).
27
+
28
+ The fix is a HALF-OPEN interval ``[start_of_day(date_from),
29
+ start_of_next_day(date_to))``:
30
+
31
+ * lower bound = start-of-day of ``date_from`` (in ``display.tz`` → UTC),
32
+ compared with ``>=``;
33
+ * upper bound = start-of-day of ``(date_to + 1 day)`` (in ``display.tz`` →
34
+ UTC), compared with a STRICT ``<`` (NOT the old inclusive end-of-day).
35
+
36
+ Both bounds are formatted with 6-digit microseconds ``...THH:MM:SS.000000Z``.
37
+ The lex algebra then holds for stored values of EVERY precision: ``.000000`` is
38
+ ``<=`` any real fractional part and ``Z`` follows it, so a ``>=`` lower bound of
39
+ ``<day>T00:00:00.000000Z`` includes the same-day midnight row at any precision,
40
+ and a strict-``<`` upper bound of ``<nextday>T00:00:00.000000Z`` includes every
41
+ same-day row (any precision) while excluding next-day midnight. The SQL callers
42
+ (``_lib_conversation_query._rollup_where`` and ``_live_having``) therefore use
43
+ ``last_activity_utc >= ?`` for the lower bound and ``last_activity_utc < ?`` for
44
+ the upper.
45
+
46
+ Semantics mirror the CLI's display-tz date posture (``docs/dashboard-gotchas``):
47
+ a naive *date-only* bound is interpreted in ``display.tz`` (start-of-day for the
48
+ lower bound, start-of-NEXT-day for the upper), then converted to UTC for the
49
+ stored-UTC comparison; a *full-ISO* bound carries its own explicit offset and
50
+ bypasses the dual-form parse (tz-independent), used as a precise instant.
51
+ """
52
+ import datetime as dt
53
+ from zoneinfo import ZoneInfo
54
+
55
+ # Dual-form date spellings the CLI's ``_try_dual_form_date`` accepts for a
56
+ # naive date-only value (no time component): ISO ``YYYY-MM-DD`` and the compact
57
+ # ``YYYYMMDD``. Anything else is rejected as a malformed date.
58
+ _DUAL_FORMS = ("%Y-%m-%d", "%Y%m%d")
59
+
60
+
61
+ def _has_explicit_offset(raw):
62
+ """True iff ``raw`` carries an explicit UTC offset / ``Z`` — i.e. it is a
63
+ PRECISE instant, not a wall-clock date(-time) needing localization.
64
+
65
+ Finding 3: an offset-less ``T`` form like ``2026-06-15T08:30:00`` parses to a
66
+ NAIVE datetime; the old predicate routed it to the full-ISO bypass and then
67
+ the day-bound logic overwrote its time, silently discarding ``08:30:00``. We
68
+ require a trailing ``Z`` or a ``+``/``-`` sign in the TIME portion (after the
69
+ ``T``) before treating the value as a precise timestamp; an offset-less ``T``
70
+ falls through to the date-only day-bound path (its date wins, its naive time
71
+ is intentionally not used as a sub-day cut)."""
72
+ if raw.endswith("Z"):
73
+ return True
74
+ t = raw.find("T")
75
+ if t == -1:
76
+ return False
77
+ # A '+' or '-' in the time portion is an explicit offset (the date portion's
78
+ # own '-' separators are before the 'T', so they never match here).
79
+ tail = raw[t + 1:]
80
+ return "+" in tail or "-" in tail
81
+
82
+
83
+ def _parse_one(raw):
84
+ """``raw`` -> a naive ``datetime`` (date-only / offset-less spelling, to be
85
+ localized in display.tz by ``_to_utc_iso``) OR an aware ``datetime``
86
+ (full-ISO spelling WITH an explicit offset, carries its own instant).
87
+ ``None``/empty passes through as ``None``. Raises ``ValueError`` on an
88
+ unrecognized spelling.
89
+
90
+ Finding 3: only an input with an explicit offset (``Z`` or a ``+``/``-`` in
91
+ the time portion, or a parsed non-None ``tzinfo``) is treated as a precise
92
+ instant; an offset-less ``…THH:MM:SS`` is parsed but returned NAIVE so the
93
+ day-bound localization applies (its date is what matters)."""
94
+ if raw is None or raw == "":
95
+ return None
96
+ # Precise instant: explicit offset present — bypass the dual-form parse so it
97
+ # stays tz-independent, matching the CLI's full-ISO posture.
98
+ if _has_explicit_offset(raw):
99
+ d = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
100
+ if d.tzinfo is not None:
101
+ return d
102
+ # Belt-and-suspenders: a recognized-offset spelling that still parsed
103
+ # naive (should not happen) degrades to the day-bound path below.
104
+ return d
105
+ if "T" in raw:
106
+ # Offset-less datetime (e.g. 2026-06-15T08:30:00) -> NAIVE; the day-bound
107
+ # path uses only its date (Finding 3 — do not silently keep the time).
108
+ return dt.datetime.fromisoformat(raw)
109
+ for fmt in _DUAL_FORMS:
110
+ try:
111
+ return dt.datetime.strptime(raw, fmt)
112
+ except ValueError:
113
+ continue
114
+ raise ValueError(f"bad date: {raw!r}")
115
+
116
+
117
+ def _utc_micro_iso(u):
118
+ """Format an aware UTC ``datetime`` as the lex-safe boundary string
119
+ ``YYYY-MM-DDTHH:MM:SS.000000Z`` (always 6-digit microseconds + ``Z``)."""
120
+ return u.strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
121
+
122
+
123
+ def _to_utc_iso(d, tz, *, upper):
124
+ """An aware-or-naive ``datetime`` -> a half-open UTC-ISO boundary string.
125
+
126
+ Naive (date-only / offset-less) value: localized in ``tz``, snapped to
127
+ start-of-day; for the UPPER bound it is the start of the NEXT day (so the
128
+ strict-``<`` comparison covers the whole requested last day). Aware value
129
+ (explicit-offset full-ISO): used as the precise instant verbatim, converted
130
+ to UTC. Always emits 6-digit-microsecond ``...Z`` so the lex compare is
131
+ precision-safe against any stored value (see module docstring)."""
132
+ if d.tzinfo is None: # naive date-only / offset-less -> localize in display tz
133
+ d = d.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=tz)
134
+ if upper:
135
+ # start-of-NEXT-day: half-open exclusive upper. Add a day in the LOCAL
136
+ # zone (DST-correct: re-localize the resulting wall date so a 23/25h
137
+ # day still lands on the next midnight) before converting to UTC.
138
+ nxt = (d + dt.timedelta(days=1)).date()
139
+ d = dt.datetime(nxt.year, nxt.month, nxt.day, tzinfo=tz)
140
+ u = d.astimezone(dt.timezone.utc)
141
+ return _utc_micro_iso(u)
142
+
143
+
144
+ def parse_filter_date_range(date_from, date_to, *, tz_name):
145
+ """``(date_from, date_to)`` -> ``(start_iso|None, end_iso|None)``.
146
+
147
+ The returned pair is a HALF-OPEN interval ``[start, end)``: callers compare
148
+ ``stored >= start`` (inclusive lower) and ``stored < end`` (EXCLUSIVE upper —
149
+ ``end`` is the start of the day AFTER ``date_to``, NOT an inclusive
150
+ end-of-day). Both bounds are 6-digit-microsecond ``...Z`` strings chosen so a
151
+ lexicographic compare against the mixed-precision stored timestamps is
152
+ chronologically correct at the day edges (see the module docstring).
153
+
154
+ Each input is an optional ``YYYY-MM-DD`` / ``YYYYMMDD`` (date-only,
155
+ localized in ``tz_name``), an offset-less ``...THH:MM:SS`` (date-only day
156
+ bound — its naive time is NOT used, Finding 3), or a full ISO-8601 string
157
+ WITH an explicit offset (used as a precise instant; the upper stays
158
+ exclusive). ``tz_name`` is an IANA key (e.g. ``Etc/UTC``,
159
+ ``America/New_York``); a falsy ``tz_name`` defaults to UTC. Raises
160
+ ``ValueError`` on a malformed date — the handler maps that to HTTP 400."""
161
+ tz = ZoneInfo(tz_name) if tz_name else dt.timezone.utc
162
+ df = _parse_one(date_from)
163
+ dtt = _parse_one(date_to)
164
+ start = _to_utc_iso(df, tz, upper=False) if df is not None else None
165
+ end = _to_utc_iso(dtt, tz, upper=True) if dtt is not None else None
166
+ return start, end