cdx-manager 0.9.13 → 0.9.15

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.
@@ -19,7 +19,7 @@ _KEY_VALUE_PATTERNS = [
19
19
  ("usage_pct", re.compile(r"usage_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
20
20
  ("remaining_5h_pct", re.compile(r"remaining_?5h_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
21
21
  ("remaining_week_pct", re.compile(r"remaining_?week_pct\s*[:=]\s*(\d{1,3})%?", re.I)),
22
- ("credits", re.compile(r"credits?\s*[:=]\s*([\d, ]*\d[\d, ]*)\s*(?:credits?)?", re.I)),
22
+ ("credits", re.compile(r"credits?\s*[:=]\s*(\d[\d, ]*(?:\.\d+)?)\s*(?:credits?)?", re.I)),
23
23
  ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
24
24
  ("remaining_week_pct", re.compile(r"weekly\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})%\s*left", re.I)),
25
25
  ("remaining_5h_pct", re.compile(r"5h\s+limit\s*:\s*\[[^\]]*\]\s*(\d{1,3})(?:%|\b)", re.I)),
@@ -94,6 +94,15 @@ def _coerce_percentage(value):
94
94
  return None
95
95
 
96
96
 
97
+ def _is_zero_credit_balance(value):
98
+ if value in (None, ""):
99
+ return True
100
+ try:
101
+ return float(str(value).replace(",", "").strip()) == 0
102
+ except (TypeError, ValueError):
103
+ return False
104
+
105
+
97
106
  def _extract_structured_rate_limits(record):
98
107
  if not isinstance(record, dict):
99
108
  return None
@@ -123,7 +132,11 @@ def _extract_structured_rate_limits(record):
123
132
  }
124
133
 
125
134
  balance = credits.get("balance")
126
- if balance not in (None, ""):
135
+ if balance not in (None, "") and not (
136
+ _is_zero_credit_balance(balance)
137
+ and not credits.get("hasCredits")
138
+ and not credits.get("unlimited")
139
+ ):
127
140
  result["credits"] = str(balance).strip()
128
141
 
129
142
  result["reset_at"] = result["reset_week_at"] or result["reset_5h_at"]
@@ -277,6 +290,8 @@ def _extract_jsonl_texts(file_path, provider=None):
277
290
  "timestamp": record.get("timestamp"),
278
291
  "text": structured["raw_status_text"],
279
292
  "structured": structured,
293
+ # Exact API data, as trustworthy as a /status screen.
294
+ "trusted": True,
280
295
  })
281
296
  payload_texts = _collect_text_values(record.get("payload") or {})
282
297
  for candidate in payload_texts:
@@ -296,7 +311,11 @@ def _extract_log_block(file_path, provider=None):
296
311
  text = _safe_read_text(file_path)
297
312
  if not text:
298
313
  return []
299
- return _extract_status_blocks_from_text(text, provider=provider, source_ref=file_path, timestamp=None)
314
+ items = _extract_status_blocks_from_text(text, provider=provider, source_ref=file_path, timestamp=None)
315
+ for item in items:
316
+ # A /status screen captured in a terminal log is authoritative.
317
+ item["trusted"] = True
318
+ return items
300
319
 
301
320
 
302
321
  def _parse_month_index(name):
@@ -422,7 +441,14 @@ def extract_named_statuses_from_text(text):
422
441
  for field, pattern in _KEY_VALUE_PATTERNS:
423
442
  if field not in result:
424
443
  m = pattern.search(normalized)
425
- if m:
444
+ if not m:
445
+ continue
446
+ if field == "credits":
447
+ # Keep the decimal part; a zero balance is no credit at all.
448
+ value = m[1].replace(",", "").replace(" ", "")
449
+ if not _is_zero_credit_balance(value):
450
+ result[field] = value
451
+ else:
426
452
  result[field] = int(re.sub(r"\D", "", m[1]))
427
453
 
428
454
  # Claude "Current session / Current week" block
@@ -596,12 +622,49 @@ def _sort_recent(paths):
596
622
  }
597
623
  return sorted(
598
624
  candidate_stats,
599
- key=lambda fp: candidate_stats[fp].st_mtime,
625
+ key=lambda fp: (candidate_stats[fp].st_mtime, fp),
600
626
  reverse=True,
601
627
  )
602
628
 
603
629
 
604
- def find_latest_status_artifact(root_dir, provider=None, expected_account_email=None):
630
+ _STATUS_VALUE_FIELDS = (
631
+ "usage_pct",
632
+ "remaining_5h_pct",
633
+ "remaining_week_pct",
634
+ "credits",
635
+ "reset_5h_at",
636
+ "reset_week_at",
637
+ "reset_at",
638
+ )
639
+
640
+
641
+ def _backfill_missing_fields(target, source):
642
+ for field in _STATUS_VALUE_FIELDS:
643
+ if target.get(field) is None and source.get(field) is not None:
644
+ target[field] = source[field]
645
+
646
+
647
+ def _record_timestamp_epoch(value):
648
+ """Best-effort epoch seconds from a record timestamp (ISO string, epoch s or ms)."""
649
+ if value in (None, ""):
650
+ return None
651
+ try:
652
+ number = float(value)
653
+ except (TypeError, ValueError):
654
+ try:
655
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
656
+ except (TypeError, ValueError):
657
+ return None
658
+ # ponytail: >1e11 means epoch milliseconds (that's year 5138 in seconds)
659
+ return number / 1000 if number > 1e11 else number
660
+
661
+
662
+ def find_latest_status_artifact(
663
+ root_dir,
664
+ provider=None,
665
+ expected_account_email=None,
666
+ trust_unattributed_structured=True,
667
+ ):
605
668
  priority_candidates, history_candidates = _collect_candidate_files(root_dir)
606
669
  candidates = (
607
670
  _sort_recent(priority_candidates)
@@ -634,35 +697,48 @@ def find_latest_status_artifact(root_dir, provider=None, expected_account_email=
634
697
  }
635
698
  if not parsed:
636
699
  continue
637
- ts = candidate.get("timestamp")
638
- try:
639
- score = float(ts) if ts else 0
640
- except (TypeError, ValueError):
641
- score = 0
700
+ # Score on the record's own time so sources stay comparable: falling
701
+ # back to file mtime would let a stale block in an actively-appended
702
+ # jsonl outrank a genuinely fresher terminal log.
703
+ epoch = _record_timestamp_epoch(candidate.get("timestamp"))
642
704
  src_file = re.sub(r":\d+$", "", candidate["source_ref"])
643
705
  stat = _safe_stat(src_file)
644
- if not score and stat:
706
+ if epoch is not None:
707
+ score = epoch
708
+ elif stat:
645
709
  score = stat.st_mtime
646
- priority = 2 if src_file.endswith(".log") else 1
710
+ else:
711
+ score = 0
712
+ # Each record producer stamps "trusted" where it knows the source kind.
713
+ # Untrusted records are text scraped from jsonl payloads: they can be
714
+ # conversational noise quoting an old status block.
715
+ trusted = bool(candidate.get("trusted"))
716
+ # Structured payloads carry no account identity, so the email guard
717
+ # above cannot filter them. In roots shared by several accounts the
718
+ # caller demotes them below account-verified /status screens.
719
+ if trusted and candidate.get("structured") and not trust_unattributed_structured:
720
+ trusted = False
721
+ priority = 2 if trusted else 1
647
722
 
648
723
  if best is None or (priority, score) >= (best["priority"], best["score"]):
724
+ previous = best
649
725
  best = {
650
726
  "priority": priority,
651
727
  "score": score,
652
728
  "source_ref": candidate["source_ref"],
729
+ "structured": bool(candidate.get("structured")),
653
730
  **parsed,
654
731
  }
655
- if ts:
656
- try:
657
- best["updated_at"] = datetime.fromtimestamp(
658
- float(ts) / 1000, tz=timezone.utc
659
- ).astimezone().isoformat()
660
- except (TypeError, ValueError):
661
- pass
662
- if "updated_at" not in best:
663
- if stat:
664
- best["updated_at"] = datetime.fromtimestamp(
665
- stat.st_mtime, tz=timezone.utc
666
- ).astimezone().isoformat()
732
+ if score:
733
+ best["updated_at"] = datetime.fromtimestamp(
734
+ score, tz=timezone.utc
735
+ ).astimezone().isoformat()
736
+ # A fresher-but-partial winner (e.g. rate_limits with only the
737
+ # primary window) must not erase fields an equally trusted
738
+ # candidate provided.
739
+ if previous and previous["priority"] == priority:
740
+ _backfill_missing_fields(best, previous)
741
+ elif priority == best["priority"]:
742
+ _backfill_missing_fields(best, parsed)
667
743
 
668
744
  return best
@@ -1,4 +1,4 @@
1
- from datetime import datetime
1
+ from datetime import datetime, timedelta
2
2
  from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
3
3
 
4
4
  from .cli_render import (
@@ -328,12 +328,25 @@ def _parse_reset_timestamp(value):
328
328
  return parsed.timestamp()
329
329
  except (TypeError, ValueError):
330
330
  pass
331
- try:
332
- parsed = datetime.strptime(text, "%b %d %H:%M")
333
- except (TypeError, ValueError):
334
- return None
335
331
  now = datetime.now().astimezone()
336
- parsed = parsed.replace(year=now.year, tzinfo=now.tzinfo)
332
+ parsed = None
333
+ # Parse with an explicit year: strptime's implicit 1900 rejects "Feb 29",
334
+ # and the current year may not hold the date at all around new year.
335
+ for year in (now.year, now.year + 1):
336
+ try:
337
+ parsed = datetime.strptime(f"{year} {text}", "%Y %b %d %H:%M").replace(tzinfo=now.tzinfo)
338
+ break
339
+ except (TypeError, ValueError):
340
+ continue
341
+ if parsed is None:
342
+ return None
343
+ # A year-less date nearly a year in the past is a year wrap ("Jan 2"
344
+ # rendered on Dec 31), not a reset that passed months ago.
345
+ if parsed < now - timedelta(days=182):
346
+ try:
347
+ parsed = parsed.replace(year=parsed.year + 1)
348
+ except ValueError:
349
+ pass
337
350
  return parsed.timestamp()
338
351
 
339
352
 
@@ -236,6 +236,10 @@ def check_logics_manager_for_update(base_dir, env=None, now_fn=None, runner=None
236
236
  return None
237
237
 
238
238
  latest_version = fetch_latest_logics_manager_version(env=env)
239
+ if not latest_version:
240
+ # Don't cache a failed fetch: a None latest_version would hide a
241
+ # real update for the whole TTL after the registry recovers.
242
+ return None
239
243
  payload = {
240
244
  "checked_at": now_ts,
241
245
  "latest_version": latest_version,
@@ -204,7 +204,9 @@ def run_update_plan(plan, runner=None, env=None):
204
204
  "stdout": _result_text(result, "stdout"),
205
205
  "stderr": _result_text(result, "stderr"),
206
206
  })
207
- if code not in (0, None):
207
+ # A runner that reports no return code is a failure, not a success:
208
+ # treating None as ok would let a failed step continue the plan.
209
+ if code != 0:
208
210
  break
209
211
  return results
210
212