kijito-tools 0.2.4 → 0.2.6
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/README.md +11 -0
- package/package.json +1 -1
- package/providers/claude/scripts/kijito-persona-lib.sh +26 -0
- package/providers/claude/scripts/statusline-context.sh +8 -0
- package/providers/codex/release-manifest.json +1 -1
- package/providers/monitor/CHANGELOG.md +11 -0
- package/providers/monitor/UPSTREAM +9 -9
- package/providers/monitor/docs/DESIGN.md +7 -1
- package/providers/monitor/kijito_inbox_monitor.py +27 -4
- package/providers/monitor/package.json +1 -1
- package/providers/monitor/pyproject.toml +1 -1
- package/providers/monitor/test_kijito_monitor.py +81 -0
package/README.md
CHANGED
|
@@ -23,6 +23,17 @@ opt-in per pane, and the skills are convenience wrappers, not requirements.
|
|
|
23
23
|
| `kijito-start` skill | The active, thorough version of session catch-up: load memory, read the current-state pointer and recent lessons, arm the inbox, and resume active work — or, for a new persona, set up identity and the pointer. | Yes |
|
|
24
24
|
| `kijito-qa-memory` skill | Memory curation that requires writing the new memories (not only fixing existing ones), then uses a fresh subagent to confirm a cold start can reconstruct the work. | Yes |
|
|
25
25
|
|
|
26
|
+
### The status line
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
`statusline-context.sh` shows, left to right: the pane's persona (from the project's `.kijito_persona`
|
|
31
|
+
marker, so several agent panes can be told apart), the persona's unread-mail count when
|
|
32
|
+
[kijito-inbox-monitor](https://github.com/KijitoAI/kijito-inbox-monitor) 0.5.7 or later is running and
|
|
33
|
+
has recorded one in the last 10 minutes (shown only when it is above zero), the model, and the context
|
|
34
|
+
window in use. The picture is rendered from the script's real output by
|
|
35
|
+
`scripts/render-statusline-svg.py`, and a test fails if the two disagree.
|
|
36
|
+
|
|
26
37
|
The two skills are conveniences, not the only way in: an agent can run the same catch-up and
|
|
27
38
|
curation by hand from a few prompts. They are packaged as skills because that makes them simple to
|
|
28
39
|
drop into `~/.claude/skills/` and invoke the same way everywhere.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "Installer for kijito-tools: copies the context-tracking, session catch-up, and self-clear scripts plus the Kijito skills into ~/.claude.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -85,6 +85,32 @@ PYSCAN
|
|
|
85
85
|
return 1
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
# kijito_unread_for_persona <persona> -> prints the persona's unread count, or nothing (row M309).
|
|
89
|
+
# The producer (kijito-inbox-monitor >= 0.5.7) writes `unread` into the persona's STATE file on every poll
|
|
90
|
+
# that had a count, and omits it when it had none. Nothing is printed unless a FRESH state file for this
|
|
91
|
+
# persona holds one: a stale or missing figure is a confident wrong number on a pane, which is worse than none.
|
|
92
|
+
# ⛔ THE FILE IS FOUND BY WHAT IT SAYS, NOT BY ITS NAME. Re-deriving the persona->filename rule here would be a
|
|
93
|
+
# third copy of it (the M290 lesson); every state file records its own watched persona in `identity`, so one
|
|
94
|
+
# jq pass over the known layouts asks the files instead. Newest first, and the NEWEST match decides: if it
|
|
95
|
+
# holds no count (unknown), an older file's figure must not stand in for it.
|
|
96
|
+
kijito_unread_for_persona() {
|
|
97
|
+
local want=${1:-} f n files=()
|
|
98
|
+
[ -n "$want" ] || return 1
|
|
99
|
+
command -v jq >/dev/null 2>&1 || return 1
|
|
100
|
+
# rewritten every poll, so anything older than 10 min is a producer that has stopped writing it
|
|
101
|
+
while IFS= read -r f; do [ -n "$f" ] && files+=("$f"); done < <(
|
|
102
|
+
find "$HOME/.kijito-monitor" "$HOME/.cache/kijito-inbox-monitor" "$HOME/.local/state/kijito-inbox-monitor" \
|
|
103
|
+
-maxdepth 1 -type f \( -name '*.state' -o -name 'hive.*.json' -o -name 'state.*.json' \) -mmin -10 \
|
|
104
|
+
2>/dev/null)
|
|
105
|
+
[ "${#files[@]}" -gt 0 ] || return 1
|
|
106
|
+
# shellcheck disable=SC2012 # the paths come from find above; ls is only ordering them by mtime
|
|
107
|
+
n=$(ls -t "${files[@]}" 2>/dev/null | tr '\n' '\0' | xargs -0 jq -rn --arg w "$(printf '%s' "$want" | tr '[:upper:]' '[:lower:]')" '
|
|
108
|
+
first(inputs | select((try (.identity[4] | map(select(.[0] == "persona")) | .[0][1] | ascii_downcase)
|
|
109
|
+
catch null) == $w)) | .unread // empty' 2>/dev/null)
|
|
110
|
+
case "$n" in ''|*[!0-9]*) return 1 ;; esac
|
|
111
|
+
printf '%s' "$n"
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
# kijito_stream_consumed <stream-path> -> 0 if a wake-capable consumer (`tail -n 0 -F …`) reads it.
|
|
89
115
|
# ⚠️ ANCHOR ON WHAT THE PROCESS *IS*. An unanchored pgrep on the events path SELF-MATCHES the producer
|
|
90
116
|
# (its own argv contains that path), and the harness's `bash -c … eval` wrappers carry the same argv —
|
|
@@ -58,6 +58,14 @@ col=$(awk -v p="$pct" 'BEGIN { if (p>=80) printf "\033[31m"; else if (p>=60) pri
|
|
|
58
58
|
if [ -n "$persona" ]; then
|
|
59
59
|
printf '\033[36m%s\033[0m · ' "$(kijito_truncate "$persona" 18)"
|
|
60
60
|
fi
|
|
61
|
+
# ROW M309, second half: the persona's unread count, from the producer's own state file (fresh only).
|
|
62
|
+
# Shown only when non-zero, so a quiet pane keeps the line it had.
|
|
63
|
+
if [ -n "$persona" ]; then
|
|
64
|
+
unread=$(kijito_unread_for_persona "$persona" || true)
|
|
65
|
+
if [ -n "$unread" ] && [ "$unread" -gt 0 ]; then
|
|
66
|
+
printf '\033[33m✉ %s\033[0m · ' "$unread"
|
|
67
|
+
fi
|
|
68
|
+
fi
|
|
61
69
|
# ROW M291: the heartbeat watchdog raises this flag when this pane's persona has unread wake events
|
|
62
70
|
# in its stream and NO consumer reading it (typically after a usage-limit outage ended the loop). It
|
|
63
71
|
# is the one place a human watching the pane will see it; the watchdog removes it on re-arm.
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"artifacts": {
|
|
16
16
|
"wakeCoreSha256": "f6ca4f88841b371409dfb4946e3b427e83150bd511f38068b364de1090c5cd35",
|
|
17
|
-
"workflowSha256": "
|
|
17
|
+
"workflowSha256": "4fcbb75ffc5b309a54fcc1492db983bce5f15353694b098859d048ed59197fb6",
|
|
18
18
|
"planSha256": "d9d666b86ac06414f6e6a13a3e3e2115983a1dad44fcfded7eccc51b191fb451",
|
|
19
19
|
"wakeHelperSha256": "838f6b4462ba037a23a14b6fdb8dc34ee8b8f4a7df258b428832bc092fac3ea4",
|
|
20
20
|
"wsUdsSha256": "4da13eef89b572fb735e756cb06846a021919dec7f91c6d07989377944cc776c",
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
All notable changes to kijito-inbox-monitor are documented in this file.
|
|
4
4
|
The format is based on Keep a Changelog, and this project follows Semantic Versioning.
|
|
5
5
|
|
|
6
|
+
## [0.5.7] - 2026-09-25
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **The state file carries the watched persona's unread count (row M309).** The producer read every persona's
|
|
10
|
+
unread count from `/api/notify/pending` each poll and kept it only in memory, so nothing local - a status
|
|
11
|
+
line, a health check - could show one. Each poll that had a count now writes it as `unread` (a
|
|
12
|
+
non-negative integer); a poll that had no count writes no `unread`, so its absence means unknown, never
|
|
13
|
+
zero. It is written with the fast path on or off. Nothing in the producer reads it back to decide what to
|
|
14
|
+
emit. It is read as strictly as the other persisted fields: anything but a non-negative integer makes the
|
|
15
|
+
file CORRUPT. Files written by older versions, which lack the field, load as before.
|
|
16
|
+
|
|
6
17
|
## [0.5.6] - 2026-09-25
|
|
7
18
|
|
|
8
19
|
### Fixed
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# The upstream release providers/monitor is a byte-exact copy of (row M314).
|
|
2
2
|
# Written by scripts/import-monitor.sh; verified by tests/vendored_monitor_test.sh. Never hand-edit.
|
|
3
3
|
repo=KijitoAI/kijito-inbox-monitor
|
|
4
|
-
sha=
|
|
5
|
-
tree=
|
|
6
|
-
tag=v0.5.
|
|
4
|
+
sha=9ac160aa98d8205a9eabe05378d9fd7ac25d8273
|
|
5
|
+
tree=65ee5cf22640992369c09d6d6f1707116093908b
|
|
6
|
+
tag=v0.5.7
|
|
7
7
|
file=100644 8d88ccb0fd09b9144505f985b4f882db278fac1f .github/workflows/publish-npm.yml
|
|
8
8
|
file=100644 19ce3471a2cffa689be9d261049c1e74e3e7a25e .github/workflows/publish-pypi.yml
|
|
9
9
|
file=100644 4534290a5dd36d18d07a782b784b6b0ad5ee38fc .gitignore
|
|
10
|
-
file=100644
|
|
10
|
+
file=100644 483f8aaa29ab3500ff421315bca2d894b3c40fc6 CHANGELOG.md
|
|
11
11
|
file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
|
|
12
12
|
file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
|
|
13
13
|
file=100644 73d2bf5a5d5d6cb622db014285cd9ada4c705724 README.md
|
|
@@ -15,12 +15,12 @@ file=100644 72f0c148f1e721ac1bb4a38e5cae690355774314 RELEASING.md
|
|
|
15
15
|
file=100755 fca1203267cb726736b0c8f239e2d74c40a7a895 arm-hive-monitor.sh
|
|
16
16
|
file=100755 bbe3f07de13dab8a8a7a4798481156ae4a46ab8f bin/cli.js
|
|
17
17
|
file=100644 fffa8bd23749a321285bd942a3f4f4a98936129c com.kijito.inbox-monitor.plist.template
|
|
18
|
-
file=100644
|
|
18
|
+
file=100644 fc953e43bcd3069a1dc426ddefc751d8f01cf599 docs/DESIGN.md
|
|
19
19
|
file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
|
|
20
|
-
file=100644
|
|
21
|
-
file=100644
|
|
22
|
-
file=100644
|
|
20
|
+
file=100644 db09ae46bf223a01ea0c2903cc886e6e09b21d43 kijito_inbox_monitor.py
|
|
21
|
+
file=100644 9cea8637ea9c61323ac2298da3d521454eabd85a package.json
|
|
22
|
+
file=100644 d0af8757fa4d3c3f4591f64a0e3d67d1637a9210 pyproject.toml
|
|
23
23
|
file=100755 b34a5c775385ecfaefd4b374c1fc1aa082a85d14 scripts/migrate-systemd-unit.sh
|
|
24
24
|
file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
|
|
25
25
|
file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
|
|
26
|
-
file=100644
|
|
26
|
+
file=100644 8d881e5f8562d4ce6b1b22fc97bf9f1ade1d52fb test_kijito_monitor.py
|
|
@@ -592,7 +592,13 @@ read-state-neutral (DONE-WHEN #5 holds after self-test).
|
|
|
592
592
|
- **Content (JSON):** `{"identity":<canonical-id>, "cursor":<int|null>, "state":"UP|DOWN",
|
|
593
593
|
"consecutive_failures":<int>}`, plus the optional pin fields written only when they are in force:
|
|
594
594
|
`emitted_above` (list of int), `gap_alerted` (int), `pin_forced` (true), `pin_evidence_intact` (false),
|
|
595
|
-
`state_corrupt` (true), `pin_release_at` (int).
|
|
595
|
+
`state_corrupt` (true), `pin_release_at` (int). Also `unread_hidden` (true) while the unread-not-shown alarm
|
|
596
|
+
is announced, and `unread` (non-negative int, row M309): the watched persona's unread count as of the poll that
|
|
597
|
+
wrote the file. `unread` is INFORMATIONAL - it is written for local readers (a status line, a health check) and
|
|
598
|
+
nothing in the producer reads it back to decide what to emit. It is written only by a poll whose
|
|
599
|
+
`/api/notify/pending` read succeeded (a persona missing from a good response is a real 0 - the server omits
|
|
600
|
+
personas with nothing pending); a poll without that read writes no `unread`, so its absence means UNKNOWN,
|
|
601
|
+
never zero. A reader should also judge freshness from the file's mtime: the file is rewritten every poll.
|
|
596
602
|
- **Every persisted field is read STRICTLY, and anything unrecognised fails CLOSED** (Loom re-audit 7, HIGH 2).
|
|
597
603
|
Booleans must be JSON booleans and integers must be real integers - a JSON `1` for `pin_forced` used to
|
|
598
604
|
normalise to `false` and silently UNPIN the watermark, letting the replay cap cross the very span the pin was
|
|
@@ -33,7 +33,7 @@ try:
|
|
|
33
33
|
except ImportError: # pragma: no cover - Windows
|
|
34
34
|
fcntl = None
|
|
35
35
|
|
|
36
|
-
__version__ = "0.5.
|
|
36
|
+
__version__ = "0.5.7"
|
|
37
37
|
SOURCE = "kijito-inbox"
|
|
38
38
|
# A named User-Agent is REQUIRED: api.kijito.ai is fronted by a WAF that 403s the default Python-urllib UA.
|
|
39
39
|
USER_AGENT = "kijito-inbox-monitor/%s" % __version__
|
|
@@ -1644,10 +1644,18 @@ class StateFile:
|
|
|
1644
1644
|
# a re-announce after an upgrade costs one event and is honest about the current condition,
|
|
1645
1645
|
# whereas defaulting to True would silence a live condition for the rest of the run.
|
|
1646
1646
|
hidden = d.get("unread_hidden") is True
|
|
1647
|
+
# `unread` (row M309) is read as strictly as every other field: a count that is not a non-negative
|
|
1648
|
+
# integer is evidence the file was not written by us, and the answer to that is the same fail-closed
|
|
1649
|
+
# one. Absent (an older file, or a poll with no count) is fine and means unknown.
|
|
1650
|
+
unread = d.get("unread")
|
|
1651
|
+
if unread is not None and not (_is_int(unread) and unread >= 0):
|
|
1652
|
+
sys.stderr.write("kijito-inbox-monitor: WARNING state-file 'unread' is not a non-negative integer "
|
|
1653
|
+
"(%r); treating the whole file as CORRUPT (fail closed): %s\n" % (unread, self.path))
|
|
1654
|
+
return CORRUPT_STATE
|
|
1647
1655
|
return {"cursor": cursor, "state": state, "failures": failures, "emitted_above": emitted,
|
|
1648
1656
|
"gap_alerted": alerted, "pin_evidence_intact": intact,
|
|
1649
1657
|
"pin_forced": pin_forced, "pin_release_at": release_at,
|
|
1650
|
-
"state_corrupt": state_corrupt, "unread_hidden": hidden}
|
|
1658
|
+
"state_corrupt": state_corrupt, "unread_hidden": hidden, "unread": unread}
|
|
1651
1659
|
|
|
1652
1660
|
def unlock(self):
|
|
1653
1661
|
"""Release the single-writer flock and close the sidecar fd.
|
|
@@ -1664,7 +1672,7 @@ class StateFile:
|
|
|
1664
1672
|
|
|
1665
1673
|
def save(self, cursor, state, failures, emitted_above=None, gap_alerted=None,
|
|
1666
1674
|
pin_forced=False, pin_evidence_intact=True, state_corrupt=False, pin_release_at=None,
|
|
1667
|
-
unread_hidden=False):
|
|
1675
|
+
unread_hidden=False, unread=None):
|
|
1668
1676
|
"""Persist the cursor. Returns True IFF the write is DURABLE (Loom re-audit 8, HIGH 3).
|
|
1669
1677
|
|
|
1670
1678
|
The directory fsync used to be called and its answer thrown away, so a failure returned success
|
|
@@ -1700,6 +1708,11 @@ class StateFile:
|
|
|
1700
1708
|
# act on any faster for being told twice.
|
|
1701
1709
|
if unread_hidden:
|
|
1702
1710
|
d["unread_hidden"] = True
|
|
1711
|
+
# The persona's unread count as of this poll (row M309) - the one LOCAL place a status line or a
|
|
1712
|
+
# health check can read it. INFORMATIONAL: nothing here reads it back to decide what to emit.
|
|
1713
|
+
# Omitted when this poll had no count, so its absence means "unknown", never "zero".
|
|
1714
|
+
if unread is not None:
|
|
1715
|
+
d["unread"] = unread
|
|
1703
1716
|
dirn = os.path.dirname(os.path.abspath(self.path)) or "."
|
|
1704
1717
|
# BOTH OF THESE ARE INSIDE THE GUARD, and they did not used to be (drill, 2026-08-05).
|
|
1705
1718
|
# This function builds a careful "written but not provably durable" path - _fsync_dir fails ->
|
|
@@ -1998,6 +2011,10 @@ class WatchTarget:
|
|
|
1998
2011
|
self.armed = False
|
|
1999
2012
|
self.fast_path = False
|
|
2000
2013
|
self.last_unread = None
|
|
2014
|
+
# What THIS poll learned about the persona's unread count, for the state file (row M309). Kept apart
|
|
2015
|
+
# from `last_unread`, which is the fast-path's wake TRIGGER and only exists while the fast path is on:
|
|
2016
|
+
# reusing it would publish nothing under --no-fast-path, and a status line would read that as zero.
|
|
2017
|
+
self.observed_unread = None
|
|
2001
2018
|
self.skips = 0
|
|
2002
2019
|
self.first_poll = True
|
|
2003
2020
|
self.last_heartbeat = _monotonic()
|
|
@@ -2408,6 +2425,11 @@ class WatchTarget:
|
|
|
2408
2425
|
def poll_once(self, counts_available=False, unread_counts=None):
|
|
2409
2426
|
args = self.args
|
|
2410
2427
|
unread_counts = unread_counts or {}
|
|
2428
|
+
# A count the server did NOT give us this tick is UNKNOWN, never zero: the state file then carries no
|
|
2429
|
+
# `unread` at all, so a reader shows nothing rather than a stale or invented figure. A persona missing
|
|
2430
|
+
# from a GOOD response is a real zero - /api/notify/pending omits personas with nothing pending.
|
|
2431
|
+
self.observed_unread = (unread_counts.get(self.unread_persona, 0)
|
|
2432
|
+
if counts_available and self.unread_persona else None)
|
|
2411
2433
|
|
|
2412
2434
|
skip_full = False
|
|
2413
2435
|
if self.armed and self.fast_path and not args.no_fast_path and self.unread_persona:
|
|
@@ -2832,7 +2854,8 @@ class WatchTarget:
|
|
|
2832
2854
|
pin_evidence_intact=self.pin_evidence_intact,
|
|
2833
2855
|
state_corrupt=self.state_corrupt,
|
|
2834
2856
|
pin_release_at=self.pin_release_at,
|
|
2835
|
-
unread_hidden=self.unread_hidden
|
|
2857
|
+
unread_hidden=self.unread_hidden,
|
|
2858
|
+
unread=self.observed_unread)
|
|
2836
2859
|
# ★ CONSUME THE ANSWER (Loom re-audit 9, MEDIUM). Round 8 taught me to RETURN a durability
|
|
2837
2860
|
# status; this is the same defect one layer out - I produced an answer and then discarded it
|
|
2838
2861
|
# at the call site, which is the exact thing the previous round was about. A cursor whose
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-inbox-monitor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Pointer to the Python package kijito-inbox-monitor. A zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives. Install the real tool with pipx/uv/pip.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "kijito-inbox-monitor"
|
|
10
|
-
version = "0.5.
|
|
10
|
+
version = "0.5.7"
|
|
11
11
|
description = "Zero-dependency watcher that wakes a running agent when new Kijito inbox mail arrives."
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.9"
|
|
@@ -6193,3 +6193,84 @@ class NegativeFailureCountStateTest(unittest.TestCase):
|
|
|
6193
6193
|
|
|
6194
6194
|
def test_zero_still_loads(self):
|
|
6195
6195
|
self.assertIsNot(self._load(0), km.CORRUPT_STATE)
|
|
6196
|
+
|
|
6197
|
+
|
|
6198
|
+
class PersistedUnreadCountTest(unittest.TestCase):
|
|
6199
|
+
"""Row M309: the producer knew every persona's unread count and threw it away, so nothing local - a
|
|
6200
|
+
status line, a health check - could show one. The count now rides in the state file, written by every
|
|
6201
|
+
poll that HAD a count, absent from every poll that did not (absent = unknown, never zero), and read as
|
|
6202
|
+
strictly as every other persisted field."""
|
|
6203
|
+
|
|
6204
|
+
RecordingEmitter = BoundedWindowEndToEndTest.RecordingEmitter
|
|
6205
|
+
FullArgs = BoundedWindowEndToEndTest.FullArgs
|
|
6206
|
+
_target = BoundedWindowEndToEndTest._target
|
|
6207
|
+
_fetch = BoundedWindowEndToEndTest._fetch
|
|
6208
|
+
|
|
6209
|
+
def setUp(self):
|
|
6210
|
+
self._dir = tempfile.TemporaryDirectory()
|
|
6211
|
+
self.addCleanup(self._dir.cleanup)
|
|
6212
|
+
self.path = os.path.join(self._dir.name, "argus.state")
|
|
6213
|
+
|
|
6214
|
+
def _poll(self, counts_available, unread_counts, no_fast_path=True):
|
|
6215
|
+
t = self._target(100, self.RecordingEmitter())
|
|
6216
|
+
t.args.no_fast_path = no_fast_path
|
|
6217
|
+
t.state_file = km.StateFile(self.path, "idx")
|
|
6218
|
+
orig, km.fetch = km.fetch, self._fetch([], 0)
|
|
6219
|
+
try:
|
|
6220
|
+
t.poll_once(counts_available=counts_available, unread_counts=unread_counts)
|
|
6221
|
+
finally:
|
|
6222
|
+
km.fetch = orig
|
|
6223
|
+
with open(self.path) as f:
|
|
6224
|
+
return json.load(f)
|
|
6225
|
+
|
|
6226
|
+
def _load(self, extra):
|
|
6227
|
+
d = {"identity": "idx", "cursor": 100, "state": "UP", "consecutive_failures": 0}
|
|
6228
|
+
d.update(extra)
|
|
6229
|
+
with open(self.path, "w") as f:
|
|
6230
|
+
json.dump(d, f)
|
|
6231
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6232
|
+
try:
|
|
6233
|
+
return km.StateFile(self.path, "idx").load()
|
|
6234
|
+
finally:
|
|
6235
|
+
sys.stderr = err
|
|
6236
|
+
|
|
6237
|
+
def test_a_poll_WITH_a_count_persists_it(self):
|
|
6238
|
+
self.assertEqual(self._poll(True, {"argus": 7, "river": 2})["unread"], 7)
|
|
6239
|
+
|
|
6240
|
+
def test_it_is_persisted_with_the_fast_path_OFF(self):
|
|
6241
|
+
# The fast path's `last_unread` only exists while the fast path is on; the fleet's systemd units
|
|
6242
|
+
# and every --no-fast-path user would otherwise publish nothing at all.
|
|
6243
|
+
self.assertEqual(self._poll(True, {"argus": 3}, no_fast_path=True)["unread"], 3)
|
|
6244
|
+
self.assertEqual(self._poll(True, {"argus": 4}, no_fast_path=False)["unread"], 4)
|
|
6245
|
+
|
|
6246
|
+
def test_a_persona_missing_from_a_GOOD_response_is_zero(self):
|
|
6247
|
+
# /api/notify/pending omits a persona with nothing pending (measured 2026-09-25: river absent).
|
|
6248
|
+
self.assertEqual(self._poll(True, {"river": 2})["unread"], 0)
|
|
6249
|
+
|
|
6250
|
+
def test_a_poll_WITHOUT_a_count_writes_NO_count(self):
|
|
6251
|
+
# Unknown must not be published as zero, and must not leave the PREVIOUS figure standing either.
|
|
6252
|
+
self._poll(True, {"argus": 9})
|
|
6253
|
+
self.assertNotIn("unread", self._poll(False, {}))
|
|
6254
|
+
|
|
6255
|
+
def test_the_count_round_trips_through_load(self):
|
|
6256
|
+
self._poll(True, {"argus": 5})
|
|
6257
|
+
err, sys.stderr = sys.stderr, io.StringIO()
|
|
6258
|
+
try:
|
|
6259
|
+
st = km.StateFile(self.path, "idx").load()
|
|
6260
|
+
finally:
|
|
6261
|
+
sys.stderr = err
|
|
6262
|
+
self.assertIsNot(st, km.CORRUPT_STATE)
|
|
6263
|
+
self.assertEqual(st["unread"], 5)
|
|
6264
|
+
self.assertEqual(st["cursor"], 100)
|
|
6265
|
+
|
|
6266
|
+
def test_an_OLDER_file_without_the_field_still_loads(self):
|
|
6267
|
+
st = self._load({})
|
|
6268
|
+
self.assertIsNot(st, km.CORRUPT_STATE)
|
|
6269
|
+
self.assertIsNone(st["unread"])
|
|
6270
|
+
|
|
6271
|
+
def test_a_malformed_count_is_CORRUPT(self):
|
|
6272
|
+
for bad in (-1, 1.5, "3", True, [], {}):
|
|
6273
|
+
self.assertIs(self._load({"unread": bad}), km.CORRUPT_STATE, "unread=%r must fail closed" % (bad,))
|
|
6274
|
+
|
|
6275
|
+
def test_zero_loads(self):
|
|
6276
|
+
self.assertEqual(self._load({"unread": 0})["unread"], 0)
|