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.
- package/CHANGELOG.md +16 -0
- package/bin/_cctally_cache.py +62 -4
- package/bin/_cctally_dashboard.py +123 -3
- package/bin/_cctally_db.py +67 -7
- package/bin/_lib_conversation.py +40 -1
- package/bin/_lib_conversation_query.py +317 -19
- package/bin/_lib_dashboard_dates.py +166 -0
- package/dashboard/static/assets/index-0Wm0BajQ.js +68 -0
- package/dashboard/static/assets/{index-CuAtCrys.css → index-BT9RRCjD.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BOR4FBvt.js +0 -68
|
@@ -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
|
-
|
|
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.
|
|
530
|
-
|
|
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
|
-
|
|
536
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
|
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
|
-
|
|
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
|