kijito-tools 0.2.1 → 0.2.3
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/package.json +1 -1
- package/providers/_shared/wake-core.mjs +4 -2
- package/providers/claude/scripts/heartbeat-watchdog.sh +1 -1
- package/providers/claude/scripts/kijito-persona-lib.sh +7 -2
- package/providers/claude/scripts/session-catchup-hint.sh +16 -8
- package/providers/claude/skills/kijito-start/SKILL.md +1 -1
- package/providers/codex/release-manifest.json +3 -3
- package/providers/codex/skills/kijito-start/SKILL.md +1 -1
- package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +1 -1
- package/providers/monitor/CHANGELOG.md +32 -0
- package/providers/monitor/IMPORT-PROVENANCE.md +10 -0
- package/providers/monitor/README.md +4 -0
- package/providers/monitor/UPSTREAM +12 -11
- package/providers/monitor/docs/DESIGN.md +1 -0
- package/providers/monitor/kijito-inbox-monitor@.service.template +17 -8
- package/providers/monitor/kijito_inbox_monitor.py +131 -2
- package/providers/monitor/package.json +1 -1
- package/providers/monitor/pyproject.toml +1 -1
- package/providers/monitor/scripts/migrate-systemd-unit.sh +158 -0
- package/providers/monitor/test_kijito_monitor.py +264 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
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",
|
|
@@ -52,12 +52,14 @@ function requirePersona(persona) {
|
|
|
52
52
|
// silently — measured live 2026-08-15: a real corrupt-state producer emitted baseline_skipped
|
|
53
53
|
// into an armed stream and the helper ignored it, the exact "a diagnostic added to kill a
|
|
54
54
|
// silent failure is itself silent unless the consumer's filter learned its name" class the
|
|
55
|
-
// monitor documents. This is now the certified NEW_LENIENT 8-kind set
|
|
55
|
+
// monitor documents. This is now the certified NEW_LENIENT 8-kind set plus `still_unread` (row
|
|
56
|
+
// M229, monitor v0.5.5: the bounded reminder for mail announced once and still unread; a
|
|
57
|
+
// diagnostics-only batch already carries the reconcile-inbox peek below). `armed` and `heartbeat`
|
|
56
58
|
// stay EXCLUDED deliberately — liveness kinds must never wake (heartbeat fires every 900s, and
|
|
57
59
|
// armed's exclusion is why "I was not woken" does not mean "nothing arrived").
|
|
58
60
|
const MAIL_KINDS = Object.freeze(["new"]);
|
|
59
61
|
const DIAGNOSTIC_KINDS = Object.freeze([
|
|
60
|
-
"alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added",
|
|
62
|
+
"alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added", "still_unread",
|
|
61
63
|
]);
|
|
62
64
|
|
|
63
65
|
export function parseEventLine(line, persona) {
|
|
@@ -74,7 +74,7 @@ _pane_hash() { tmux capture-pane -p -t "$1" 2>/dev/null | tail -40 | cksum | awk
|
|
|
74
74
|
# SURFACED AS: an `HEARTBEAT_UNCONSUMED_STREAM` lifecycle-log line, a flag file the status line shows
|
|
75
75
|
# (`unconsumed.<pane>`), and a nudge prompt that says to re-arm the consumer FIRST. Cleared (with a
|
|
76
76
|
# `HEARTBEAT_STREAM_CONSUMED` line) the moment a consumer is attached again.
|
|
77
|
-
WAKE_EVENTS='"event": ?"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)"'
|
|
77
|
+
WAKE_EVENTS='"event": ?"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)"'
|
|
78
78
|
UNCONSUMED_FLAG="$KIJITO_LC_DIR/unconsumed.$PANE"
|
|
79
79
|
st_missing_since=""; st_offset=""; st_alerted=0; st_path=""
|
|
80
80
|
|
|
@@ -54,7 +54,11 @@ kijito_stream_for_persona() {
|
|
|
54
54
|
done
|
|
55
55
|
[ -n "$km" ] && safe=$("$km" --safe-persona "$want" 2>/dev/null)
|
|
56
56
|
if [ -n "$safe" ]; then
|
|
57
|
-
|
|
57
|
+
# THREE layouts: the fleet's systemd units, the launchd plist, and the monitor's own shipped systemd
|
|
58
|
+
# template (~/.local/state, XDG state) - the last one was missing until row M313's follow-up, so a
|
|
59
|
+
# Linux user who installed the unit from the monitor repo had a stream nothing here would find.
|
|
60
|
+
for cand in "$HOME/.kijito-monitor/$safe.jsonl" "$HOME/.cache/kijito-inbox-monitor/events.$safe.ndjson" \
|
|
61
|
+
"$HOME/.local/state/kijito-inbox-monitor/events.$safe.ndjson"; do
|
|
58
62
|
[ -e "$cand" ] && { printf '%s' "$cand"; return 0; }
|
|
59
63
|
done
|
|
60
64
|
fi
|
|
@@ -63,7 +67,8 @@ kijito_stream_for_persona() {
|
|
|
63
67
|
import glob, json, os, sys
|
|
64
68
|
want = os.environ["KJ_WANT"].casefold(); home = os.path.expanduser("~"); hits = []
|
|
65
69
|
for pat in (os.path.join(home, ".kijito-monitor", "*.jsonl"),
|
|
66
|
-
os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson")
|
|
70
|
+
os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson"),
|
|
71
|
+
os.path.join(home, ".local", "state", "kijito-inbox-monitor", "events.*.ndjson")):
|
|
67
72
|
for path in glob.glob(pat):
|
|
68
73
|
try:
|
|
69
74
|
with open(path, "rb") as fh:
|
|
@@ -100,7 +100,8 @@ want = os.environ["KJ_PERSONA"].casefold()
|
|
|
100
100
|
home = os.path.expanduser("~")
|
|
101
101
|
hits = []
|
|
102
102
|
for pat in (os.path.join(home, ".kijito-monitor", "*.jsonl"),
|
|
103
|
-
os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson")
|
|
103
|
+
os.path.join(home, ".cache", "kijito-inbox-monitor", "events.*.ndjson"),
|
|
104
|
+
os.path.join(home, ".local", "state", "kijito-inbox-monitor", "events.*.ndjson")):
|
|
104
105
|
for path in glob.glob(pat):
|
|
105
106
|
try:
|
|
106
107
|
with open(path, "rb") as fh:
|
|
@@ -174,6 +175,10 @@ fi
|
|
|
174
175
|
# supervisor definition is the next-best evidence. uname is the last resort, not the first test.
|
|
175
176
|
_mac_events="$HOME/.cache/kijito-inbox-monitor/events.${_safe}.ndjson"
|
|
176
177
|
_lnx_events="$HOME/.kijito-monitor/${_safe}.jsonl"
|
|
178
|
+
# The monitor repo's OWN shipped systemd template writes here (XDG state). A third layout, and until row
|
|
179
|
+
# M313's follow-up this script did not know it: a user who installed the unit from the monitor README got
|
|
180
|
+
# "nothing is collecting your mail" while their producer was writing mail into this file.
|
|
181
|
+
_tpl_events="$HOME/.local/state/kijito-inbox-monitor/events.${_safe}.ndjson"
|
|
177
182
|
if [ "$_rule" = by-content ] || [ "$_rule" = stale-stream ]; then
|
|
178
183
|
# The producer's own output named this file. It outranks every derivation below, because it is the
|
|
179
184
|
# only one of them that was written by the process we are asking about.
|
|
@@ -183,18 +188,21 @@ if [ "$_rule" = by-content ] || [ "$_rule" = stale-stream ]; then
|
|
|
183
188
|
# an empty component that looks like a path and names nothing. The producer line says it is stale;
|
|
184
189
|
# the arming block should still point at the file that will come back when it restarts.
|
|
185
190
|
_events="$_found"
|
|
186
|
-
case "$_events" in *.jsonl) _sup="systemd" ;; *) _sup="launchd" ;; esac
|
|
191
|
+
case "$_events" in *.jsonl|*/.local/state/*) _sup="systemd" ;; *) _sup="launchd" ;; esac
|
|
187
192
|
elif [ -n "$_safe" ] && [ -e "$_lnx_events" ]; then _events="$_lnx_events"; _sup="systemd"
|
|
188
193
|
elif [ -n "$_safe" ] && [ -e "$_mac_events" ]; then _events="$_mac_events"; _sup="launchd"
|
|
194
|
+
elif [ -n "$_safe" ] && [ -e "$_tpl_events" ]; then _events="$_tpl_events"; _sup="systemd"
|
|
189
195
|
elif [ -d "$HOME/.kijito-monitor" ]; then _events="$_lnx_events"; _sup="systemd"
|
|
196
|
+
elif [ -d "$HOME/.local/state/kijito-inbox-monitor" ]; then _events="$_tpl_events"; _sup="systemd"
|
|
190
197
|
elif [ -d "$HOME/.cache/kijito-inbox-monitor" ]; then _events="$_mac_events"; _sup="launchd"
|
|
191
198
|
elif [ -f "$HOME/Library/LaunchAgents/com.kijito.inbox-monitor.plist" ]; then _events="$_mac_events"; _sup="launchd"
|
|
192
199
|
elif [ "$(uname -s 2>/dev/null)" = "Darwin" ]; then _events="$_mac_events"; _sup="launchd"
|
|
193
200
|
else _events="$_lnx_events"; _sup="systemd"; fi
|
|
194
201
|
# The generic (no-marker) branch cannot name a file, so it shows the directory shape instead.
|
|
195
|
-
case "$_sup" in
|
|
196
|
-
launchd)
|
|
197
|
-
|
|
202
|
+
case "$_sup:$_events" in
|
|
203
|
+
launchd:*) _events_tmpl="\$HOME/.cache/kijito-inbox-monitor/events.<persona>.ndjson" ;;
|
|
204
|
+
*:*/.local/state/*) _events_tmpl="\$HOME/.local/state/kijito-inbox-monitor/events.<persona>.ndjson" ;;
|
|
205
|
+
*) _events_tmpl="\$HOME/.kijito-monitor/<persona>.jsonl" ;;
|
|
198
206
|
esac
|
|
199
207
|
|
|
200
208
|
# Producer health, PER PERSONA — because "a producer is running" and "YOUR mail is being collected"
|
|
@@ -289,7 +297,7 @@ task list:
|
|
|
289
297
|
all but one.
|
|
290
298
|
• You have NO inbox Monitor (brand-new session; the tail is a sibling's or a leaked orphan that will
|
|
291
299
|
NOT wake YOU) → arm exactly one for yourself:
|
|
292
|
-
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
300
|
+
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
293
301
|
Dedupe with TaskStop (agent-scoped), NOT \`pkill -f …events…\` (that can kill a sibling's or your own live consumer). $_prod
|
|
294
302
|
EOF
|
|
295
303
|
elif [ -n "$_safe" ]; then
|
|
@@ -300,7 +308,7 @@ background \`tail\` is CAPTURE-ONLY (it writes to a file and never re-invokes yo
|
|
|
300
308
|
woken). Arm the wake-capable consumer with the Monitor TOOL (persistent) — it streams each event
|
|
301
309
|
as a live notification. Your persona for this project is "$_persona":
|
|
302
310
|
|
|
303
|
-
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
311
|
+
Monitor(command="tail -n 0 -F $_events | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
304
312
|
|
|
305
313
|
First confirm nothing is already monitoring that stream this session (avoid double-arming). $_prod
|
|
306
314
|
EOF
|
|
@@ -312,7 +320,7 @@ background \`tail\` is CAPTURE-ONLY (it writes to a file and never re-invokes yo
|
|
|
312
320
|
woken). Arm the wake-capable consumer for YOUR persona with the Monitor TOOL (persistent) — it
|
|
313
321
|
streams each event as a live notification. Substitute your persona name for <persona>:
|
|
314
322
|
|
|
315
|
-
Monitor(command="tail -n 0 -F $_events_tmpl | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
323
|
+
Monitor(command="tail -n 0 -F $_events_tmpl | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
316
324
|
|
|
317
325
|
(No .kijito_persona marker found in this project — add a one-line \`.kijito_persona\` file with your
|
|
318
326
|
persona name in the project root so this resolves automatically next session.) $_prod
|
|
@@ -57,7 +57,7 @@ Run `kijito_startup(persona="<P>", project="<J>")` with the persona/project your
|
|
|
57
57
|
"you already hit the trap" — and the remedy it prescribed would have killed a **working**
|
|
58
58
|
inbox. Anchoring on `^tail` excludes the shell and returns exactly one line per monitor.
|
|
59
59
|
- **prints nothing →** arm exactly ONE, wake-capable, via the Monitor tool (persistent):
|
|
60
|
-
`Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)`
|
|
60
|
+
`Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)`
|
|
61
61
|
- **prints one line →** already armed by a prior (pre-`/clear`) session; **STOP — do not start another.**
|
|
62
62
|
- **prints two or more lines →** genuinely stacked; keep the newest, kill the rest:
|
|
63
63
|
```bash
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"verify": "node providers/codex/install.mjs (default action; also runs before any skills deploy)"
|
|
14
14
|
},
|
|
15
15
|
"artifacts": {
|
|
16
|
-
"wakeCoreSha256": "
|
|
17
|
-
"workflowSha256": "
|
|
16
|
+
"wakeCoreSha256": "f6ca4f88841b371409dfb4946e3b427e83150bd511f38068b364de1090c5cd35",
|
|
17
|
+
"workflowSha256": "ec6a65c095e7011c3100c2a804e23c1dee1a63cfba0fb79f1b872aee91dd3b2d",
|
|
18
18
|
"planSha256": "d9d666b86ac06414f6e6a13a3e3e2115983a1dad44fcfded7eccc51b191fb451",
|
|
19
19
|
"wakeHelperSha256": "838f6b4462ba037a23a14b6fdb8dc34ee8b8f4a7df258b428832bc092fac3ea4",
|
|
20
20
|
"wsUdsSha256": "4da13eef89b572fb735e756cb06846a021919dec7f91c6d07989377944cc776c",
|
|
21
|
-
"wakeHelperTestsSha256": "
|
|
21
|
+
"wakeHelperTestsSha256": "c5b6ec88eef8d84195e6c7eaa71cfb78dd6e2f743622caaa2b97a6d087718dd1",
|
|
22
22
|
"wakeHelperIntegrationTestsSha256": "48f565dfe7e5b719903cd24b12ddfb3f17e1a16c532af397bfffcb39e4bfbe9b",
|
|
23
23
|
"wakeHelperMockDaemonSha256": "3406bcd46d913071c0ee293e30f46b42ad5cf8c5c77f70c4000d9bb37854dd0c"
|
|
24
24
|
},
|
|
@@ -111,7 +111,7 @@ session please") works with no flags and outranks this default.
|
|
|
111
111
|
```
|
|
112
112
|
If nothing prints, arm exactly ONE persistent Monitor:
|
|
113
113
|
```
|
|
114
|
-
Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added)\"'", persistent=true)
|
|
114
|
+
Monitor(command="tail -n 0 -F $STREAM | grep --line-buffered -E '\"event\": ?\"(new|alert|recovered|state_corrupt|baseline_skipped|seed_ahead|replay_capped|persona_added|still_unread)\"'", persistent=true)
|
|
115
115
|
```
|
|
116
116
|
Report **armed-live (Claude Code fallback)**. This is not an ad-hoc
|
|
117
117
|
watcher — it is the standard, battle-tested Claude Code wake path.
|
|
@@ -84,7 +84,7 @@ test("R2: the armed-record byte stamps equal the sha256 of the files actually lo
|
|
|
84
84
|
// ── Gate-7 seam extension (argus 7819 conditions a/b): the NEW_LENIENT 8-kind set ──
|
|
85
85
|
|
|
86
86
|
test("seam: every diagnostic kind wakes with a lifecycle key; armed/heartbeat provably never wake", () => {
|
|
87
|
-
const DIAG = ["alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added"];
|
|
87
|
+
const DIAG = ["alert", "recovered", "state_corrupt", "baseline_skipped", "seed_ahead", "replay_capped", "persona_added", "still_unread"];
|
|
88
88
|
for (const kind of DIAG) {
|
|
89
89
|
const parsed = parseEventLine(JSON.stringify({ source: "kijito-inbox", persona: "codex", event: kind, ts: "2026-08-15T08:04:46.059708+00:00" }), "codex");
|
|
90
90
|
assert.equal(parsed.event?.trigger, "lifecycle", `${kind} must wake as lifecycle`);
|
|
@@ -3,6 +3,38 @@
|
|
|
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.5] - 2026-09-24
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **`still_unread`: a bounded reminder for mail that was announced and then left unread (row M229).**
|
|
10
|
+
The watcher announces each message once (`new`), so a message an agent looked at without marking it
|
|
11
|
+
read used to sit unread forever with nothing to bring it back. Now, when already-announced mail is still
|
|
12
|
+
unread `--still-unread-after` seconds after it was sent (default 7200; `0` turns it off), ONE
|
|
13
|
+
`still_unread` event per poll lists those messages. Each message is reminded at most once per window
|
|
14
|
+
and at most `--still-unread-max` times (default 3); retired, reserved and write_only inboxes are never
|
|
15
|
+
reminded; the first poll after a restart only takes note, so restarts do not produce a burst. It never
|
|
16
|
+
moves the cursor. Its `wake_class` is `diagnostic` (it wakes; `new` remains the only `mail`). Consumers
|
|
17
|
+
that filter by event name should add `still_unread` to their list.
|
|
18
|
+
|
|
19
|
+
## [0.5.4] - 2026-09-24
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **The systemd unit no longer decides file names.** `kijito-inbox-monitor@.service.template` spelled
|
|
23
|
+
systemd's `%i` into `--state-file`, `--events-file` and `--token-file`. `%i` is the ESCAPED instance
|
|
24
|
+
name, so for any persona that is not already lowercase ASCII (`Loom`, `name (purpose)`, `Ωmega`) the unit
|
|
25
|
+
named its files differently from the producer and from the launchd plist, and passed the escaped
|
|
26
|
+
string as the persona itself. The unit now passes `--persona %I` (unescaped) and the producer fills in
|
|
27
|
+
every path from `{persona}` with the same rule `--safe-persona` prints. For names that are already
|
|
28
|
+
lowercase ASCII the paths are byte-identical to before, so upgrading moves nothing.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- `--state-file-template` and `--token-file-template`, the `{persona}` counterparts of `--state-file` and
|
|
32
|
+
`--token-file`.
|
|
33
|
+
- `scripts/migrate-systemd-unit.sh` migrates a deployed unit: dry run by default, `--apply` to act,
|
|
34
|
+
idempotent, keeps the old unit file, refuses to rewrite until the installed producer supports the new
|
|
35
|
+
flags, and for an instance whose files do move keeps the old event path working (hard link) so a
|
|
36
|
+
running `tail -F` consumer is not cut off.
|
|
37
|
+
|
|
6
38
|
## [0.5.3] - 2026-09-24
|
|
7
39
|
|
|
8
40
|
### Changed
|
|
@@ -86,3 +86,13 @@ From here on **never hand-edit `providers/monitor`**:
|
|
|
86
86
|
fails on any difference. It runs in the test workflow, in both publish workflows before the package is
|
|
87
87
|
built, and in npm `prepublishOnly`, so a drifted copy cannot be published.
|
|
88
88
|
- To move to a new upstream release: `scripts/import-monitor.sh <sha-or-tag>`.
|
|
89
|
+
|
|
90
|
+
## Re-import 2026-09-24 (row M313) — v0.5.4
|
|
91
|
+
|
|
92
|
+
| role | SHA | tree |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| imported (tag `v0.5.4`) | see `providers/monitor/UPSTREAM` (`scripts/import-monitor.sh v0.5.4`) | idem |
|
|
95
|
+
|
|
96
|
+
v0.5.4: the shipped systemd unit passes `--persona %I` and the producer names every file
|
|
97
|
+
(`--state-file-template`, `--token-file-template`, `--events-file-template`), plus
|
|
98
|
+
`scripts/migrate-systemd-unit.sh` for deployed units.
|
|
@@ -297,6 +297,7 @@ Each line of the events file (and each `exec-per-event` invocation) is one event
|
|
|
297
297
|
| `new` | a new inbox message | `KIJITOMON_ID`, `KIJITOMON_FROM`, `KIJITOMON_CONTENT`, `KIJITOMON_CREATED`, `KIJITOMON_PERSONA` |
|
|
298
298
|
| `alert` | the source has been unreachable for `--alert-after` polls (dead-man), **or** mail is stranded in an inbox nobody watches, **or** the server holds unread mail this window did not show (all below) | `KIJITOMON_REASON`, `KIJITOMON_FAILURES`, `KIJITOMON_STRANDED` |
|
|
299
299
|
| `recovered` | the source came back after an `alert` | `KIJITOMON_CURSOR` |
|
|
300
|
+
| `still_unread` | mail already announced as `new` is still **unread** `--still-unread-after` seconds (default 2 h) after it was sent. One event per poll names every such message; each message is reminded at most once per window and at most `--still-unread-max` (default 3) times; never for retired, reserved or write_only inboxes; nothing on the first poll after a restart. Read what you have handled with `mark_read=true` and it stops. | `KIJITOMON_IDS`, `KIJITOMON_OLDEST_AGE`, `KIJITOMON_REASON` |
|
|
300
301
|
| `heartbeat` | optional liveness tick (`--heartbeat N`) | `KIJITOMON_CURSOR` |
|
|
301
302
|
|
|
302
303
|
Every event also carries `KIJITOMON_EVENT`, `KIJITOMON_SOURCE`, `KIJITOMON_TS`, `KIJITOMON_EVENT_ID`,
|
|
@@ -545,6 +546,9 @@ four sat above it. Only the newest page's count answers the question "is there u
|
|
|
545
546
|
| `--events-file PATH` | Write NDJSON events to an owned, size-rotated file (survives rotation) instead of stdout. Consumers `tail -F` it. |
|
|
546
547
|
| `--events-file-template PATH` | Per-persona owned, rotated files, e.g. `events.{persona}.ndjson`; each session consumes its own. Must contain `{persona}`. Mutually exclusive with `--events-file`. |
|
|
547
548
|
| `--state-file PATH` | Persist and resume cursor/liveness; single-writer locked. Persona targets derive one file per persona. Recommended under a supervisor. |
|
|
549
|
+
| `--state-file-template PATH` | Like `--state-file`, with `{persona}` filled in by the producer (the component `--safe-persona` prints). Use it in a supervisor unit so the unit never spells a persona into a path. |
|
|
550
|
+
| `--token-file-template PATH` | Like `--token-file`, with `{persona}` filled in by the producer for the one `--persona` target. |
|
|
551
|
+
| `--safe-persona NAME` | Print the filename component the producer uses for NAME, then exit. Anything that needs to name a persona's files should ask this rather than re-implement the rule. |
|
|
548
552
|
| `--wait N` | Long-poll hold (s) requested from the server so new mail wakes the watcher near-instantly at ~the same request rate (default 50; `0` disables). Falls back to interval polling against a server that doesn't support it, and auto-upgrades when it does. |
|
|
549
553
|
| `--poll-seconds N` | Interval between polls when long-poll is off/unsupported (default 60). |
|
|
550
554
|
| `--alert-after N` | Consecutive failures before an `alert` (default 3, min 1). A single transient failure is normal. |
|
|
@@ -1,25 +1,26 @@
|
|
|
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=35b2fe6688b2f2f536a98952f40816c96057c69f
|
|
5
|
+
tree=994bb014166bb65b57223a524bbb0673f57ce22d
|
|
6
|
+
tag=v0.5.5
|
|
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 9e7c054bccb95cbcb42ea8642eb1fab76e5863d5 CHANGELOG.md
|
|
11
11
|
file=100644 d645695673349e3947e8e5ae42332d0ac3164cd7 LICENSE
|
|
12
12
|
file=100644 f6b0e5634f3fd4156c1c000efb31578bfb068cf3 NOTICE
|
|
13
|
-
file=100644
|
|
13
|
+
file=100644 fa81d2fc2645a5503117825b714200e0ae642cc4 README.md
|
|
14
14
|
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 23e088613c314bf86b384640639b545de60ea9d0 com.kijito.inbox-monitor.plist.template
|
|
18
|
-
file=100644
|
|
19
|
-
file=100644
|
|
20
|
-
file=100644
|
|
21
|
-
file=100644
|
|
22
|
-
file=100644
|
|
18
|
+
file=100644 30519b1ad6098541083648392f344da68e688246 docs/DESIGN.md
|
|
19
|
+
file=100644 4f5e53a8633e60e6c57c451e28546f8af8783fe8 kijito-inbox-monitor@.service.template
|
|
20
|
+
file=100644 a88a59eb52d45918794b46d54de826617d977ff2 kijito_inbox_monitor.py
|
|
21
|
+
file=100644 8c2d292152fbe752d5ebf3445141a9d7df5576e5 package.json
|
|
22
|
+
file=100644 199474330b420fa514378ab0c1211e21db9c6c41 pyproject.toml
|
|
23
|
+
file=100755 b34a5c775385ecfaefd4b374c1fc1aa082a85d14 scripts/migrate-systemd-unit.sh
|
|
23
24
|
file=100755 22acef6d6875395a81c701eb535030d0a78b0df1 scripts/mutation-check.py
|
|
24
25
|
file=100755 acba6196213736922fc4f7135749386d85d172a6 scripts/prepublish-gate.sh
|
|
25
|
-
file=100644
|
|
26
|
+
file=100644 8745e5bfe9573b9e85dd4e10f8904273d1dff864 test_kijito_monitor.py
|
|
@@ -293,6 +293,7 @@ One object per line; every event carries `event`, `source`, `ts` (emit-time UTC
|
|
|
293
293
|
{"event":"armed", "source":"kijito-inbox","ts":"<iso>","cursor":250}
|
|
294
294
|
{"event":"alert", "source":"kijito-inbox","ts":"<iso>","reason":"unreachable","consecutive_failures":3,"seconds":180}
|
|
295
295
|
{"event":"recovered", "source":"kijito-inbox","ts":"<iso>","cursor":250}
|
|
296
|
+
{"event":"still_unread","source":"kijito-inbox","ts":"<iso>","ids":[240,246],"senders":["river"],"oldest_age_seconds":9000,"after_seconds":7200,"reason":"..."} # M229 backstop, bounded
|
|
296
297
|
{"event":"heartbeat", "source":"kijito-inbox","ts":"<iso>","cursor":250} # only if --heartbeat; cursor may be null
|
|
297
298
|
{"event":"seed_ahead", "source":"kijito-inbox","ts":"<iso>","seeded":600,"current_max":539} # seed > reality (§7.0)
|
|
298
299
|
{"event":"replay_capped","source":"kijito-inbox","ts":"<iso>","capped_to":539,"dropped":389} # backlog > --max-replay (§7.0)
|
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
# ~/.config/systemd/user/kijito-inbox-monitor@.service
|
|
11
11
|
# systemctl --user daemon-reload
|
|
12
12
|
# run: systemctl --user enable --now kijito-inbox-monitor@YOURPERSONA
|
|
13
|
+
# (a persona with spaces, parentheses or non-ASCII: escape it for systemd -
|
|
14
|
+
# systemctl --user enable --now "kijito-inbox-monitor@$(systemd-escape 'Name (dev)').service")
|
|
13
15
|
# check: systemctl --user status kijito-inbox-monitor@YOURPERSONA
|
|
14
16
|
# logs: journalctl --user -u kijito-inbox-monitor@YOURPERSONA -f
|
|
15
17
|
#
|
|
16
|
-
# BEFORE IT WILL START, create the token file for that persona (0600)
|
|
17
|
-
#
|
|
18
|
-
#
|
|
18
|
+
# BEFORE IT WILL START, create the token file for that persona (0600). Its name uses the
|
|
19
|
+
# producer's filename component for the persona, which you get from the producer itself:
|
|
20
|
+
# f=~/.config/kijito-inbox-monitor/token.$(kijito-inbox-monitor --safe-persona YOURPERSONA)
|
|
21
|
+
# install -m600 /dev/null "$f"; printf '%s' "$YOUR_KIJITO_API_TOKEN" > "$f"
|
|
19
22
|
# A missing token is FATAL and says so - the producer will not start half-configured.
|
|
20
23
|
#
|
|
21
24
|
# ⚠️ THIS CAPTURES; IT DOES NOT WAKE. The unit appends one JSON event per new message
|
|
@@ -41,7 +44,7 @@
|
|
|
41
44
|
# different claims, and so are an ENABLED unit and a unit that survives your logout.
|
|
42
45
|
|
|
43
46
|
[Unit]
|
|
44
|
-
Description=Kijito inbox monitor (persona: %
|
|
47
|
+
Description=Kijito inbox monitor (persona: %I)
|
|
45
48
|
Documentation=https://github.com/KijitoAI/kijito-inbox-monitor
|
|
46
49
|
# Start after the network is genuinely up, not merely configured: the producer's first
|
|
47
50
|
# act is an HTTPS poll, and a DNS failure at boot is an avoidable alert.
|
|
@@ -60,13 +63,19 @@ Type=simple
|
|
|
60
63
|
# that skip is at least ANNOUNCED as a `baseline_skipped` diagnostic instead of
|
|
61
64
|
# happening in silence - but announced-and-skipped is still skipped.)
|
|
62
65
|
# ⇒ state lives under ~/.local/state (XDG_STATE_HOME: persists between restarts).
|
|
66
|
+
# ⛔ THE UNIT NEVER SPELLS A PERSONA INTO A PATH (row M313). It passes the persona (%I, the
|
|
67
|
+
# UNESCAPED instance name - %i is systemd's escaped form, so 'Name (dev)' would arrive as
|
|
68
|
+
# 'Name\x20\x28dev\x29' and watch a persona that does not exist), and every file name is
|
|
69
|
+
# filled in by the producer from `{persona}` using the SAME rule `--safe-persona` prints and
|
|
70
|
+
# the launchd plist uses. For a name that is already a safe component (lowercase ASCII),
|
|
71
|
+
# the paths are byte-identical to the older %i form, so upgrading moves nothing.
|
|
63
72
|
ExecStart=%h/.local/bin/kijito-inbox-monitor \
|
|
64
|
-
--persona %
|
|
65
|
-
--token-file %h/.config/kijito-inbox-monitor/token
|
|
73
|
+
--persona %I \
|
|
74
|
+
--token-file-template %h/.config/kijito-inbox-monitor/token.{persona} \
|
|
66
75
|
--poll-seconds 30 \
|
|
67
76
|
--wait 60 \
|
|
68
|
-
--state-file %h/.local/state/kijito-inbox-monitor
|
|
69
|
-
--events-file %h/.local/state/kijito-inbox-monitor/events
|
|
77
|
+
--state-file-template %h/.local/state/kijito-inbox-monitor/{persona}.state \
|
|
78
|
+
--events-file-template %h/.local/state/kijito-inbox-monitor/events.{persona}.ndjson \
|
|
70
79
|
--no-content \
|
|
71
80
|
--heartbeat 900
|
|
72
81
|
|
|
@@ -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.5"
|
|
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__
|
|
@@ -952,6 +952,11 @@ _LIVENESS_KINDS = frozenset({"heartbeat", "armed"})
|
|
|
952
952
|
# liveness is a diagnostic, INCLUDING kinds absent from this table (see _wake_class).
|
|
953
953
|
_WAKE_CLASS_BY_KIND = {
|
|
954
954
|
"new": WAKE_CLASS_MAIL,
|
|
955
|
+
# Row M229: a reminder ABOUT mail (already-notified messages still unread past the threshold), not a
|
|
956
|
+
# message itself - one event names several ids and carries no body, so it is NOT `mail` (`new` stays
|
|
957
|
+
# the only mail, pinned by a test). As a diagnostic it WAKES, which is its whole job. Bounded by
|
|
958
|
+
# construction; see WatchTarget._resurface_still_unread.
|
|
959
|
+
"still_unread": WAKE_CLASS_DIAGNOSTIC,
|
|
955
960
|
"alert": WAKE_CLASS_DIAGNOSTIC,
|
|
956
961
|
"recovered": WAKE_CLASS_DIAGNOSTIC,
|
|
957
962
|
"state_corrupt": WAKE_CLASS_DIAGNOSTIC,
|
|
@@ -1181,6 +1186,7 @@ class Emitter:
|
|
|
1181
1186
|
"capped_to": "KIJITOMON_CAPPED_TO", "dropped": "KIJITOMON_DROPPED",
|
|
1182
1187
|
"stranded_inboxes": "KIJITOMON_STRANDED",
|
|
1183
1188
|
"dormant_inboxes": "KIJITOMON_DORMANT",
|
|
1189
|
+
"ids": "KIJITOMON_IDS", "oldest_age_seconds": "KIJITOMON_OLDEST_AGE",
|
|
1184
1190
|
}
|
|
1185
1191
|
for k, envname in keymap.items():
|
|
1186
1192
|
if k in event and event[k] is not None:
|
|
@@ -1841,6 +1847,31 @@ def make_opener_for(url):
|
|
|
1841
1847
|
return build_opener(pinned)
|
|
1842
1848
|
|
|
1843
1849
|
|
|
1850
|
+
def _persona_path(template, persona):
|
|
1851
|
+
"""Fill a `{persona}` path template with the producer's OWN filename component for PERSONA (row M313).
|
|
1852
|
+
|
|
1853
|
+
Every per-persona path a supervisor needs - events, state, token - goes through here, so the unit file
|
|
1854
|
+
(systemd `%i`, a plist, a shell script) never spells a filename itself. `%i` is systemd's ESCAPED
|
|
1855
|
+
instance name and passes a persona's raw case through, so a unit that interpolated it disagreed with
|
|
1856
|
+
the producer for any name that is not already a safe component ('Loom', 'name (purpose)', 'Ωmega').
|
|
1857
|
+
"""
|
|
1858
|
+
return template.replace("{persona}", _state_safe_persona(persona))
|
|
1859
|
+
|
|
1860
|
+
|
|
1861
|
+
def _created_epoch(created):
|
|
1862
|
+
"""A message's `created` as epoch seconds, or None if it cannot be read. The server writes naive UTC
|
|
1863
|
+
('2026-09-24 21:34:24.943580'); an explicit offset or 'Z' is honoured when present."""
|
|
1864
|
+
if not isinstance(created, str) or not created:
|
|
1865
|
+
return None
|
|
1866
|
+
try:
|
|
1867
|
+
dt = datetime.datetime.fromisoformat(created.strip().replace("Z", "+00:00"))
|
|
1868
|
+
except ValueError:
|
|
1869
|
+
return None
|
|
1870
|
+
if dt.tzinfo is None:
|
|
1871
|
+
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
1872
|
+
return dt.timestamp()
|
|
1873
|
+
|
|
1874
|
+
|
|
1844
1875
|
def _state_path_for_persona(base_path, persona):
|
|
1845
1876
|
if not base_path or not persona:
|
|
1846
1877
|
return base_path
|
|
@@ -1991,7 +2022,8 @@ class WatchTarget:
|
|
|
1991
2022
|
cp = urllib.parse.urlsplit(url)
|
|
1992
2023
|
self.unread_persona = dict(urllib.parse.parse_qsl(cp.query)).get("persona") or persona
|
|
1993
2024
|
|
|
1994
|
-
state_path =
|
|
2025
|
+
state_path = (_persona_path(args.state_file_template, persona) if args.state_file_template
|
|
2026
|
+
else _state_path_for_persona(args.state_file, persona))
|
|
1995
2027
|
if state_path:
|
|
1996
2028
|
self.state_file = StateFile(state_path, self.identity)
|
|
1997
2029
|
if not args.self_test:
|
|
@@ -2142,6 +2174,69 @@ class WatchTarget:
|
|
|
2142
2174
|
"cursor below it so it is re-delivered rather than skipped (further reports "
|
|
2143
2175
|
"suppressed until delivery recovers)\n" % (mid, self.persona))
|
|
2144
2176
|
|
|
2177
|
+
def _resurface_still_unread(self, items, now=None):
|
|
2178
|
+
"""Row M229 BACKSTOP: re-surface mail that was notified once and then left UNREAD.
|
|
2179
|
+
|
|
2180
|
+
The producer is edge-triggered per id - it emits `new` exactly once per message - so a message the
|
|
2181
|
+
agent peeked at (mark_read=false), acted on or meant to, and never consumed sits unread and INERT:
|
|
2182
|
+
never notified again, visible only to a staleness alarm. The ROOT fix is the consumer's (read
|
|
2183
|
+
what you handled with mark_read=true, shipped as wake-workflow doctrine 2026-08-12); this is the
|
|
2184
|
+
bounded backstop for what still slips through, and it is built so it cannot become a storm:
|
|
2185
|
+
* only for ids AT OR BELOW the cursor (already notified) whose server `read` flag is False and
|
|
2186
|
+
whose `created` is at least --still-unread-after seconds old;
|
|
2187
|
+
* at most once per id per window (--still-unread-after), and at most --still-unread-max times per
|
|
2188
|
+
id in this process - deliberately deferred mail is reminded a few times, not forever;
|
|
2189
|
+
* ONE summarising `still_unread` event per poll however many ids are due (one wake, not N);
|
|
2190
|
+
* never for debris (retired/reserved, row M332) or a write_only inbox;
|
|
2191
|
+
* the FIRST full poll of a process only notes what is already aged and emits nothing, so every
|
|
2192
|
+
restart (a deploy, a reboot) does not replay a burst of reminders.
|
|
2193
|
+
The ledger is per process: a restart can remind again, at most --still-unread-max times per id.
|
|
2194
|
+
It never touches the cursor - a reminder is not a delivery, and failing to emit one loses nothing.
|
|
2195
|
+
"""
|
|
2196
|
+
after = getattr(self.args, "still_unread_after", 0) or 0
|
|
2197
|
+
if after <= 0 or _is_debris(self.persona) or _PERSONA_WRITE_ONLY.get(self.persona) is True:
|
|
2198
|
+
return []
|
|
2199
|
+
cap = max(1, getattr(self.args, "still_unread_max", 3) or 1)
|
|
2200
|
+
now = time.time() if now is None else now
|
|
2201
|
+
ledger = self.__dict__.setdefault("still_unread_seen", {}) # id -> (times reminded, last at)
|
|
2202
|
+
primed = self.__dict__.get("still_unread_primed", False)
|
|
2203
|
+
cursor = self.cursor if isinstance(self.cursor, int) else -1
|
|
2204
|
+
due, visible = [], set()
|
|
2205
|
+
for m in items:
|
|
2206
|
+
mid = m.get("id")
|
|
2207
|
+
if not isinstance(mid, int) or isinstance(mid, bool) or mid > cursor or m.get("read") is not False:
|
|
2208
|
+
continue
|
|
2209
|
+
visible.add(mid)
|
|
2210
|
+
born = _created_epoch(m.get("created"))
|
|
2211
|
+
if born is None or now - born < after:
|
|
2212
|
+
continue
|
|
2213
|
+
n, last = ledger.get(mid, (0, None))
|
|
2214
|
+
if not primed:
|
|
2215
|
+
ledger[mid] = (n, now) # restart: note it, remind one full window from now
|
|
2216
|
+
continue
|
|
2217
|
+
if n >= cap or (last is not None and now - last < after):
|
|
2218
|
+
continue
|
|
2219
|
+
due.append((mid, m, now - born, n))
|
|
2220
|
+
for mid in list(ledger): # read, or gone from the window: forget it
|
|
2221
|
+
if mid not in visible:
|
|
2222
|
+
del ledger[mid]
|
|
2223
|
+
self.still_unread_primed = True
|
|
2224
|
+
if not due:
|
|
2225
|
+
return []
|
|
2226
|
+
oldest = max(age for _, _, age, _ in due)
|
|
2227
|
+
ok = self.emitter.lifecycle(
|
|
2228
|
+
"still_unread", persona=self.persona,
|
|
2229
|
+
ids=[mid for mid, _, _, _ in due],
|
|
2230
|
+
senders=sorted({m.get("from") for _, m, _, _ in due if m.get("from")}),
|
|
2231
|
+
oldest_age_seconds=int(oldest), after_seconds=int(after),
|
|
2232
|
+
reason=("still-unread: %d message(s) in this inbox were notified and are still UNREAD after "
|
|
2233
|
+
"%ds or more (oldest %ds). Read what you have handled with mark_read=true; this "
|
|
2234
|
+
"reminder repeats at most %d time(s) per message." % (len(due), after, int(oldest), cap)))
|
|
2235
|
+
if ok is True:
|
|
2236
|
+
for mid, _, _, n in due:
|
|
2237
|
+
ledger[mid] = (n + 1, now)
|
|
2238
|
+
return [mid for mid, _, _, _ in due]
|
|
2239
|
+
|
|
2145
2240
|
def _delivery_recovered(self):
|
|
2146
2241
|
if not self.delivery_blocked:
|
|
2147
2242
|
return
|
|
@@ -2567,6 +2662,7 @@ class WatchTarget:
|
|
|
2567
2662
|
delivered = set()
|
|
2568
2663
|
if blocked_at is None:
|
|
2569
2664
|
self._delivery_recovered()
|
|
2665
|
+
self._resurface_still_unread(items)
|
|
2570
2666
|
|
|
2571
2667
|
# §5.2 UNREAD MAIL WE CANNOT SEE. Fires on the FALSE->TRUE edge and releases itself when
|
|
2572
2668
|
# the condition clears, so it needs no ack: an ack would let someone silence "there is
|
|
@@ -3606,6 +3702,11 @@ def build_parser():
|
|
|
3606
3702
|
p.add_argument("--state-file",
|
|
3607
3703
|
help="Persist+resume cursor/FSM; single-writer locked. Kijito persona targets derive one "
|
|
3608
3704
|
"file per persona from this base path. Recommended w/ a supervisor.")
|
|
3705
|
+
p.add_argument("--state-file-template",
|
|
3706
|
+
help="Per-persona state file named by the PRODUCER, e.g. ~/.local/state/kijito-inbox-monitor/"
|
|
3707
|
+
"{persona}.state; '{persona}' is replaced by the same filename component --safe-persona "
|
|
3708
|
+
"prints. Use it in a supervisor unit instead of spelling the persona into --state-file "
|
|
3709
|
+
"(row M313). Mutually exclusive with --state-file.")
|
|
3609
3710
|
p.add_argument("--heartbeat", type=int, help="Emit a heartbeat event every N seconds (external dead-man's-switch).")
|
|
3610
3711
|
p.add_argument("--activity-file",
|
|
3611
3712
|
help="Publish who AUTHORED mail most recently, as JSON, refreshed each tick. Lets a "
|
|
@@ -3632,8 +3733,19 @@ def build_parser():
|
|
|
3632
3733
|
"(reported verbatim, so a reader can judge magnitude). Default 1.")
|
|
3633
3734
|
p.add_argument("--auth-header", help="Header NAME for the token (default Authorization: Bearer).")
|
|
3634
3735
|
p.add_argument("--token-file", help="File holding the auth token (wins over $KIJITOMON_TOKEN).")
|
|
3736
|
+
p.add_argument("--token-file-template",
|
|
3737
|
+
help="Like --token-file, with '{persona}' replaced by the producer's filename component for the "
|
|
3738
|
+
"ONE --persona target (row M313), e.g. ~/.config/kijito-inbox-monitor/token.{persona}. "
|
|
3739
|
+
"Mutually exclusive with --token-file.")
|
|
3635
3740
|
p.add_argument("--no-fast-path", action="store_true",
|
|
3636
3741
|
help="Disable the /api/notify/pending unread pre-check; always full-poll the inbox list.")
|
|
3742
|
+
p.add_argument("--still-unread-after", type=int, default=7200, metavar="SECONDS",
|
|
3743
|
+
help="Row M229: emit ONE `still_unread` event per poll for mail that was already notified and "
|
|
3744
|
+
"is still unread this many seconds after it was sent (default 7200; 0 disables). At most "
|
|
3745
|
+
"once per message per window, capped by --still-unread-max; never for retired/reserved or "
|
|
3746
|
+
"write_only inboxes.")
|
|
3747
|
+
p.add_argument("--still-unread-max", type=int, default=3, metavar="N",
|
|
3748
|
+
help="How many times one message may be re-surfaced as still_unread per process (default 3).")
|
|
3637
3749
|
p.add_argument("--resync-every", type=int, default=10,
|
|
3638
3750
|
help="Fast-path safety floor: force a full inbox poll after at most N consecutive cheap "
|
|
3639
3751
|
"skips, so a stale/wrong unread count can never blind the watcher (default 10, min 1).")
|
|
@@ -3667,12 +3779,29 @@ def validate_args(args):
|
|
|
3667
3779
|
raise FatalConfig("--max-replay must be >= 0")
|
|
3668
3780
|
if args.keep_logs < 1:
|
|
3669
3781
|
raise FatalConfig("--keep-logs must be >= 1")
|
|
3782
|
+
if args.still_unread_after < 0:
|
|
3783
|
+
raise FatalConfig("--still-unread-after must be >= 0 (0 disables)")
|
|
3784
|
+
if args.still_unread_max < 1:
|
|
3785
|
+
raise FatalConfig("--still-unread-max must be >= 1")
|
|
3670
3786
|
if args.events_file and args.events_file_template:
|
|
3671
3787
|
raise FatalConfig("--events-file and --events-file-template are mutually exclusive")
|
|
3672
3788
|
if args.events_file_template and "{persona}" not in args.events_file_template:
|
|
3673
3789
|
raise FatalConfig("--events-file-template must contain the '{persona}' placeholder")
|
|
3674
3790
|
if (args.events_file or args.events_file_template) and args.emit != "stdout-jsonl":
|
|
3675
3791
|
sys.stderr.write("kijito-inbox-monitor: WARNING --events-file/-template ignored (emit mode is %s)\n" % args.emit)
|
|
3792
|
+
# Row M313: the persona-named paths come from the producer's rule, never from the supervisor.
|
|
3793
|
+
if args.state_file and args.state_file_template:
|
|
3794
|
+
raise FatalConfig("--state-file and --state-file-template are mutually exclusive")
|
|
3795
|
+
if args.state_file_template and "{persona}" not in args.state_file_template:
|
|
3796
|
+
raise FatalConfig("--state-file-template must contain the '{persona}' placeholder")
|
|
3797
|
+
if args.token_file and args.token_file_template:
|
|
3798
|
+
raise FatalConfig("--token-file and --token-file-template are mutually exclusive")
|
|
3799
|
+
if args.token_file_template:
|
|
3800
|
+
if "{persona}" not in args.token_file_template:
|
|
3801
|
+
raise FatalConfig("--token-file-template must contain the '{persona}' placeholder")
|
|
3802
|
+
if len(args.persona or []) != 1 or args.personas or args.all_personas:
|
|
3803
|
+
raise FatalConfig("--token-file-template needs exactly one --persona target (one token file per unit)")
|
|
3804
|
+
args.token_file = _persona_path(args.token_file_template, args.persona[0])
|
|
3676
3805
|
if args.seed_at is not None:
|
|
3677
3806
|
single = len(args.persona or []) == 1 and not args.personas and not args.all_personas
|
|
3678
3807
|
if not single:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kijito-inbox-monitor",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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.5"
|
|
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"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Migrate a DEPLOYED kijito-inbox-monitor systemd user unit to producer-derived file names (row M313).
|
|
3
|
+
#
|
|
4
|
+
# scripts/migrate-systemd-unit.sh # DRY RUN: says what it would change, changes nothing
|
|
5
|
+
# scripts/migrate-systemd-unit.sh --apply # do it
|
|
6
|
+
# options: --unit NAME (default kijito-inbox-monitor) --bin PATH (default ~/.local/bin/kijito-inbox-monitor)
|
|
7
|
+
# --unit-dir DIR (default ~/.config/systemd/user)
|
|
8
|
+
#
|
|
9
|
+
# WHAT CHANGES. Older units spelled the persona into paths with systemd's `%i` - the ESCAPED instance name -
|
|
10
|
+
# so the unit, not the producer, decided file names, and a persona like 'Loom' or 'name (purpose)' got a
|
|
11
|
+
# different file than the producer (and the launchd plist) would give it. The rewritten unit passes
|
|
12
|
+
# `--persona %I` and turns every `--state-file/--events-file/--token-file ...%i...` into the matching
|
|
13
|
+
# `-template ...{persona}...` flag, so the producer names every file with the rule `--safe-persona` prints.
|
|
14
|
+
#
|
|
15
|
+
# WHAT MOVES. For a persona that is already a safe component (lowercase ASCII - every persona on a normal
|
|
16
|
+
# fleet) the new paths are BYTE-IDENTICAL to the old ones: nothing moves and no consumer has to re-arm.
|
|
17
|
+
# Only an instance whose old and new paths differ is touched, and then (instance stopped first):
|
|
18
|
+
# state copied to the new name (the old file is kept) - the cursor survives
|
|
19
|
+
# token copied to the new name, mode kept (the old file is kept)
|
|
20
|
+
# events HARD-LINKED under the new name - the old name is never removed, so a consumer running
|
|
21
|
+
# `tail -F <old path>` notices nothing and keeps receiving every event the producer now appends
|
|
22
|
+
# under the new name (same file). It stays that way until the producer next ROTATES the stream
|
|
23
|
+
# (default 5 MB), after which only the new name moves on: re-arm consumers on the new path.
|
|
24
|
+
# (Measured, GNU tail 9.4: a SYMLINK at the old path is refused as "untailable", and a rename
|
|
25
|
+
# followed by a relink makes tail re-read the whole file - a replay of every old event.)
|
|
26
|
+
#
|
|
27
|
+
# SAFE TO RE-RUN. A unit with no `%i` left in ExecStart is reported as already migrated; files are only
|
|
28
|
+
# copied/moved when the new one does not exist yet. The old unit file is kept as <unit>.pre-m313.<time>.
|
|
29
|
+
# REFUSES to rewrite the unit while the installed producer lacks --state-file-template (it would not start).
|
|
30
|
+
# ANNOUNCE TO YOUR FLEET BEFORE --apply on a shared seat: instances restart, for a few seconds each.
|
|
31
|
+
set -eu
|
|
32
|
+
UNIT=kijito-inbox-monitor
|
|
33
|
+
BIN="$HOME/.local/bin/kijito-inbox-monitor"
|
|
34
|
+
UNIT_DIR="$HOME/.config/systemd/user"
|
|
35
|
+
APPLY=0
|
|
36
|
+
while [ $# -gt 0 ]; do
|
|
37
|
+
case "$1" in
|
|
38
|
+
--apply) APPLY=1; shift ;;
|
|
39
|
+
--unit) UNIT=$2; shift 2 ;;
|
|
40
|
+
--bin) BIN=$2; shift 2 ;;
|
|
41
|
+
--unit-dir) UNIT_DIR=$2; shift 2 ;;
|
|
42
|
+
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
|
43
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
FILE="$UNIT_DIR/$UNIT@.service"
|
|
47
|
+
[ -f "$FILE" ] || { echo "no unit file at $FILE - nothing to migrate"; exit 0; }
|
|
48
|
+
say() { printf '%s\n' "$*"; }
|
|
49
|
+
[ "$APPLY" = 1 ] && say "== APPLY: $FILE ==" || say "== DRY RUN (pass --apply to act): $FILE =="
|
|
50
|
+
|
|
51
|
+
# The rewrite, as a pure text transform of the unit file (also used to compute the new paths).
|
|
52
|
+
rewrite() {
|
|
53
|
+
sed -E \
|
|
54
|
+
-e '/^ExecStart=/,/[^\\]$/ s/--persona %i/--persona %I/' \
|
|
55
|
+
-e '/^ExecStart=/,/[^\\]$/ s/--(state-file|events-file|token-file)([ =])([^ ]*)%i([^ ]*)/--\1-template\2\3{persona}\4/g' \
|
|
56
|
+
-e 's/^(Description=.*)%i/\1%I/' "$1"
|
|
57
|
+
}
|
|
58
|
+
exec_line() { tr -d '\r' < "$1" | sed -e ':a' -e '/\\$/N; s/\\\n//; ta' | grep '^ExecStart='; }
|
|
59
|
+
flag_value() { printf '%s\n' "$1" | tr ' ' '\n' | awk -v f="$2" 'p{print; exit} $0==f{p=1}'; }
|
|
60
|
+
|
|
61
|
+
old_exec=$(exec_line "$FILE")
|
|
62
|
+
case "$old_exec" in
|
|
63
|
+
*%i*) ;;
|
|
64
|
+
*) say "already migrated: ExecStart spells no %i (the producer names every file)"; exit 0 ;;
|
|
65
|
+
esac
|
|
66
|
+
tmp_unit=$(mktemp); trap 'rm -f "$tmp_unit"' EXIT
|
|
67
|
+
rewrite "$FILE" > "$tmp_unit"
|
|
68
|
+
new_exec=$(exec_line "$tmp_unit")
|
|
69
|
+
case "$new_exec" in *%i*) say "REFUSING: could not rewrite every %i in ExecStart:"; say " $new_exec"; exit 1 ;; esac
|
|
70
|
+
say "ExecStart now: ${old_exec#ExecStart=}"
|
|
71
|
+
say "ExecStart after: ${new_exec#ExecStart=}"
|
|
72
|
+
|
|
73
|
+
have_flags=0
|
|
74
|
+
"$BIN" --help 2>/dev/null | grep -q -- '--state-file-template' && have_flags=1
|
|
75
|
+
if [ "$have_flags" = 0 ]; then
|
|
76
|
+
say "REFUSING to rewrite: $BIN does not support --state-file-template (needs kijito-inbox-monitor >= 0.5.4)."
|
|
77
|
+
say "Upgrade the producer first, then re-run. (For persona names that are already lowercase ASCII the"
|
|
78
|
+
say "old unit is correct as it is: this migration changes no file for them.)"
|
|
79
|
+
[ "$APPLY" = 1 ] && exit 1
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
H=$HOME
|
|
83
|
+
changed=(); to_start=()
|
|
84
|
+
# Every instance: loaded ones, plus enabled ones that are not loaded right now (a stopped template
|
|
85
|
+
# instance drops out of list-units, and its files must migrate too).
|
|
86
|
+
instances=$( { systemctl --user list-units --all --plain --no-legend "$UNIT@*.service" 2>/dev/null | awk '{print $1}'
|
|
87
|
+
for w in "$UNIT_DIR"/*.wants/"$UNIT"@*.service; do [ -e "$w" ] || [ -L "$w" ] && basename "$w"; done
|
|
88
|
+
} | sort -u)
|
|
89
|
+
while IFS= read -r inst; do
|
|
90
|
+
[ -n "$inst" ] || continue
|
|
91
|
+
esc=${inst#"$UNIT@"}; esc=${esc%.service}
|
|
92
|
+
raw=$(systemd-escape --unescape -- "$esc")
|
|
93
|
+
if [ "$have_flags" = 1 ]; then
|
|
94
|
+
safe=$("$BIN" --safe-persona "$raw")
|
|
95
|
+
else
|
|
96
|
+
# Without an upgraded producer there is no --safe-persona to ask, and this script must not guess the
|
|
97
|
+
# rule (row M290). A name that is already lowercase [a-z0-9._-] is its own component; say so for it,
|
|
98
|
+
# and say plainly that anything else cannot be judged yet.
|
|
99
|
+
case "$raw" in
|
|
100
|
+
*[!a-z0-9._-]*) say " $raw: cannot tell yet which files would move - needs the upgraded producer's --safe-persona"; continue ;;
|
|
101
|
+
esac
|
|
102
|
+
safe=$raw
|
|
103
|
+
fi
|
|
104
|
+
moves=""
|
|
105
|
+
for f in --state-file --events-file --token-file; do
|
|
106
|
+
o=$(flag_value "${old_exec#ExecStart=}" "$f"); [ -n "$o" ] || continue
|
|
107
|
+
case "$o" in *%i*) ;; *) continue ;; esac
|
|
108
|
+
# Parameter expansion, NOT sed: an escaped instance name carries backslashes ('name\x20\x28dev\x29')
|
|
109
|
+
# and GNU sed would decode them in a replacement - the old path would then name a file that never
|
|
110
|
+
# existed. (Caught by this script's own test against real systemd.)
|
|
111
|
+
op=${o//%h/$H}; op=${op//%i/$esc}
|
|
112
|
+
np=${o//%h/$H}; np=${np//%i/$safe}
|
|
113
|
+
[ "$op" = "$np" ] || moves="$moves$f|$op|$np"$'\n'
|
|
114
|
+
done
|
|
115
|
+
if [ -z "$moves" ]; then
|
|
116
|
+
say " $raw: paths unchanged (already a safe component) - nothing moves, consumers need not re-arm"
|
|
117
|
+
continue
|
|
118
|
+
fi
|
|
119
|
+
changed+=("$inst")
|
|
120
|
+
while IFS='|' read -r f op np; do
|
|
121
|
+
[ -n "$f" ] && say " $raw: ${f#--} $op -> $np"
|
|
122
|
+
done <<EOF
|
|
123
|
+
$moves
|
|
124
|
+
EOF
|
|
125
|
+
[ "$APPLY" = 1 ] && [ "$have_flags" = 1 ] || continue
|
|
126
|
+
was_active=0; systemctl --user is-active --quiet "$inst" && was_active=1
|
|
127
|
+
systemctl --user stop "$inst"
|
|
128
|
+
while IFS='|' read -r f op np; do
|
|
129
|
+
[ -n "$f" ] && [ -e "$op" ] || continue
|
|
130
|
+
[ -e "$np" ] && { say " ${f#--}: $np already exists - left as is"; continue; }
|
|
131
|
+
mkdir -p "$(dirname "$np")"
|
|
132
|
+
case "$f" in
|
|
133
|
+
--events-file) ln "$op" "$np" && say " events: $np hard-linked to the live stream; $op still works until the next rotation" ;;
|
|
134
|
+
*) cp -p "$op" "$np" && say " ${f#--} copied (old file kept)" ;;
|
|
135
|
+
esac
|
|
136
|
+
done <<EOF
|
|
137
|
+
$moves
|
|
138
|
+
EOF
|
|
139
|
+
[ "$was_active" = 1 ] && to_start+=("$inst")
|
|
140
|
+
done <<EOF
|
|
141
|
+
$instances
|
|
142
|
+
EOF
|
|
143
|
+
|
|
144
|
+
[ "$APPLY" = 1 ] || { say "(dry run: nothing changed)"; exit 0; }
|
|
145
|
+
cp -p "$FILE" "$FILE.pre-m313.$(date +%Y%m%dT%H%M%S)"
|
|
146
|
+
cp "$tmp_unit" "$FILE"
|
|
147
|
+
systemctl --user daemon-reload
|
|
148
|
+
# The instances this script stopped are started again BY NAME (they are no longer listed once stopped);
|
|
149
|
+
# every other running instance is restarted so it picks up the rewritten ExecStart (same paths).
|
|
150
|
+
for inst in "${to_start[@]+"${to_start[@]}"}"; do systemctl --user start "$inst"; done
|
|
151
|
+
while IFS= read -r inst; do
|
|
152
|
+
[ -n "$inst" ] || continue
|
|
153
|
+
case " ${to_start[*]+"${to_start[*]}"} " in *" $inst "*) continue ;; esac
|
|
154
|
+
systemctl --user is-active --quiet "$inst" && systemctl --user restart "$inst"
|
|
155
|
+
done <<EOF
|
|
156
|
+
$instances
|
|
157
|
+
EOF
|
|
158
|
+
say "migrated: unit rewritten (old copy kept next to it); ${#changed[@]} instance(s) had files moved"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import io
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
|
+
import re
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import tempfile
|
|
@@ -1715,6 +1716,7 @@ class BoundedWindowEndToEndTest(unittest.TestCase):
|
|
|
1715
1716
|
class FullArgs:
|
|
1716
1717
|
persona = personas = None
|
|
1717
1718
|
all_personas = False
|
|
1719
|
+
state_file_template = None
|
|
1718
1720
|
alert_after = 3
|
|
1719
1721
|
poll_seconds = 60
|
|
1720
1722
|
heartbeat = 0
|
|
@@ -5850,3 +5852,265 @@ class OpaqueOutputEnforcementTest(unittest.TestCase):
|
|
|
5850
5852
|
def test_systemd_template_enforces_no_content(self):
|
|
5851
5853
|
with open(os.path.join(self._HERE, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
|
|
5852
5854
|
self.assertIn("--no-content", fh.read())
|
|
5855
|
+
|
|
5856
|
+
|
|
5857
|
+
class SupervisorPersonaPathParityTest(unittest.TestCase):
|
|
5858
|
+
"""Row M313. The systemd unit used to interpolate `%i` - systemd's ESCAPED instance name - into
|
|
5859
|
+
--state-file / --events-file / --token-file, so the UNIT decided the file names and disagreed with the
|
|
5860
|
+
producer (and with the launchd plist) for any persona that is not already a safe component. Now the
|
|
5861
|
+
unit passes the persona (%I, unescaped) and the producer fills every `{persona}` path from its own
|
|
5862
|
+
rule. These tests expand BOTH shipped templates the way their supervisors do and require every
|
|
5863
|
+
persona-named file to carry exactly the component `--safe-persona` prints."""
|
|
5864
|
+
|
|
5865
|
+
NAMES = ["argus", "Loom", "name (purpose)", "\u03a9mega", "two words", "Claude-chat"]
|
|
5866
|
+
HOME = "/HOMEDIR"
|
|
5867
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
5868
|
+
|
|
5869
|
+
def _systemd_argv_home(self, persona, home):
|
|
5870
|
+
saved, self.HOME = self.HOME, home
|
|
5871
|
+
try:
|
|
5872
|
+
return self._systemd_argv(persona)
|
|
5873
|
+
finally:
|
|
5874
|
+
self.HOME = saved
|
|
5875
|
+
|
|
5876
|
+
def _systemd_argv(self, persona):
|
|
5877
|
+
with open(os.path.join(self._HERE, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
|
|
5878
|
+
text = fh.read().replace("\\\n", " ")
|
|
5879
|
+
line = next(l for l in text.splitlines() if l.startswith("ExecStart="))
|
|
5880
|
+
self.assertNotIn("%i", line, "the unit must not spell the escaped instance name into ExecStart")
|
|
5881
|
+
# systemd's own expansion: %h = home, %I = the UNESCAPED instance name (measured on systemd 255:
|
|
5882
|
+
# an instance escaped by systemd-escape expands %i to 'Name\\x20...' and %I to 'Name ...').
|
|
5883
|
+
# It is ONE argv word even with spaces in it - systemd splits the line before substituting.
|
|
5884
|
+
words = line[len("ExecStart="):].split()
|
|
5885
|
+
return [w.replace("%h", self.HOME).replace("%I", persona) for w in words][1:]
|
|
5886
|
+
|
|
5887
|
+
def _launchd_events_template(self):
|
|
5888
|
+
with open(os.path.join(self._HERE, "com.kijito.inbox-monitor.plist.template"), encoding="utf-8") as fh:
|
|
5889
|
+
strings = re.findall(r"<string>([^<]*)</string>", fh.read())
|
|
5890
|
+
return strings[strings.index("--events-file-template") + 1].replace("__HOME__", self.HOME)
|
|
5891
|
+
|
|
5892
|
+
def test_every_persona_named_file_uses_the_producers_component_on_both_supervisors(self):
|
|
5893
|
+
for name in self.NAMES:
|
|
5894
|
+
with self.subTest(persona=name):
|
|
5895
|
+
safe = km._state_safe_persona(name)
|
|
5896
|
+
args = km.build_parser().parse_args(self._systemd_argv(name))
|
|
5897
|
+
km.validate_args(args)
|
|
5898
|
+
self.assertEqual(args.persona, [name], "the unit must hand the producer the REAL persona")
|
|
5899
|
+
events = km._persona_path(args.events_file_template, name)
|
|
5900
|
+
state = km._persona_path(args.state_file_template, name)
|
|
5901
|
+
self.assertEqual(os.path.basename(events), "events.%s.ndjson" % safe)
|
|
5902
|
+
self.assertEqual(os.path.basename(state), "%s.state" % safe)
|
|
5903
|
+
self.assertEqual(os.path.basename(args.token_file), "token.%s" % safe)
|
|
5904
|
+
launchd = km._persona_path(self._launchd_events_template(), name)
|
|
5905
|
+
self.assertEqual(os.path.basename(events), os.path.basename(launchd),
|
|
5906
|
+
"launchd and systemd must name a persona's event stream identically")
|
|
5907
|
+
|
|
5908
|
+
def test_upgrading_a_safe_named_unit_moves_nothing(self):
|
|
5909
|
+
# Every persona on a real fleet today is lowercase ASCII; for those the new unit resolves to the
|
|
5910
|
+
# SAME paths the %i form did, so an upgrade needs no migration and no consumer re-arm.
|
|
5911
|
+
args = km.build_parser().parse_args(self._systemd_argv("argus"))
|
|
5912
|
+
km.validate_args(args)
|
|
5913
|
+
base = self.HOME + "/.local/state/kijito-inbox-monitor/"
|
|
5914
|
+
self.assertEqual(km._persona_path(args.events_file_template, "argus"), base + "events.argus.ndjson")
|
|
5915
|
+
self.assertEqual(km._persona_path(args.state_file_template, "argus"), base + "argus.state")
|
|
5916
|
+
self.assertEqual(args.token_file, self.HOME + "/.config/kijito-inbox-monitor/token.argus")
|
|
5917
|
+
|
|
5918
|
+
def test_the_state_file_template_reaches_the_watch_target(self):
|
|
5919
|
+
d = tempfile.mkdtemp()
|
|
5920
|
+
|
|
5921
|
+
class A(BoundedWindowEndToEndTest.FullArgs):
|
|
5922
|
+
state_file = None
|
|
5923
|
+
state_file_template = os.path.join(d, "{persona}.state")
|
|
5924
|
+
seed_at = None
|
|
5925
|
+
t = km.WatchTarget("Loom", "http://x/api/inbox?persona=Loom", None, {}, A(),
|
|
5926
|
+
BoundedWindowEndToEndTest.RecordingEmitter())
|
|
5927
|
+
self.addCleanup(t.state_file.unlock)
|
|
5928
|
+
self.assertEqual(t.state_file.path, os.path.join(d, "loom.state"))
|
|
5929
|
+
|
|
5930
|
+
def test_template_flags_are_validated(self):
|
|
5931
|
+
def err(argv):
|
|
5932
|
+
try:
|
|
5933
|
+
km.validate_args(km.build_parser().parse_args(argv))
|
|
5934
|
+
except km.FatalConfig as e:
|
|
5935
|
+
return str(e)
|
|
5936
|
+
return None
|
|
5937
|
+
self.assertIn("mutually exclusive", err(["--persona", "a", "--state-file", "x", "--state-file-template", "{persona}"]))
|
|
5938
|
+
self.assertIn("placeholder", err(["--persona", "a", "--state-file-template", "x.state"]))
|
|
5939
|
+
self.assertIn("mutually exclusive", err(["--persona", "a", "--token-file", "x", "--token-file-template", "t.{persona}"]))
|
|
5940
|
+
self.assertIn("placeholder", err(["--persona", "a", "--token-file-template", "tok"]))
|
|
5941
|
+
self.assertIn("exactly one --persona", err(["--persona", "a", "--persona", "b", "--token-file-template", "t.{persona}"]))
|
|
5942
|
+
self.assertIsNone(err(["--persona", "A b", "--token-file-template", "/t.{persona}"]))
|
|
5943
|
+
|
|
5944
|
+
|
|
5945
|
+
class SystemdUnitMigrationRewriteTest(unittest.TestCase):
|
|
5946
|
+
"""Row M313: scripts/migrate-systemd-unit.sh rewrites a DEPLOYED pre-M313 unit into the producer-named
|
|
5947
|
+
form. Its end-to-end behaviour (files moved, stopped instances restarted, an old-path `tail -F` kept
|
|
5948
|
+
alive) was proven against real systemd when it was written; this pins the REWRITE, which is the part
|
|
5949
|
+
that must stay right on every machine, by running the script's dry run on the pre-M313 unit text."""
|
|
5950
|
+
|
|
5951
|
+
OLD = ("[Unit]\nDescription=Kijito inbox monitor (persona: %i)\n[Service]\n"
|
|
5952
|
+
"ExecStart=%h/.local/bin/kijito-inbox-monitor \\\n --persona %i \\\n"
|
|
5953
|
+
" --token-file %h/.config/kijito-inbox-monitor/token.%i \\\n --poll-seconds 30 \\\n"
|
|
5954
|
+
" --state-file %h/.local/state/kijito-inbox-monitor/%i.state \\\n"
|
|
5955
|
+
" --events-file %h/.local/state/kijito-inbox-monitor/events.%i.ndjson \\\n --heartbeat 900\n")
|
|
5956
|
+
|
|
5957
|
+
def _run(self, unit_text):
|
|
5958
|
+
d = tempfile.mkdtemp()
|
|
5959
|
+
with open(os.path.join(d, "kjt-m313-rewrite-test@.service"), "w") as fh:
|
|
5960
|
+
fh.write(unit_text)
|
|
5961
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
5962
|
+
return subprocess.run(["bash", os.path.join(here, "scripts", "migrate-systemd-unit.sh"),
|
|
5963
|
+
"--unit", "kjt-m313-rewrite-test", "--unit-dir", d, "--bin", "/nonexistent"],
|
|
5964
|
+
capture_output=True, text=True)
|
|
5965
|
+
|
|
5966
|
+
def test_every_percent_i_path_becomes_a_producer_template(self):
|
|
5967
|
+
r = self._run(self.OLD)
|
|
5968
|
+
after = next(l for l in r.stdout.splitlines() if l.startswith("ExecStart after:"))
|
|
5969
|
+
self.assertNotIn("%i", after)
|
|
5970
|
+
self.assertIn("--persona %I", after)
|
|
5971
|
+
for flag, path in (("--token-file-template", "token.{persona}"),
|
|
5972
|
+
("--state-file-template", "{persona}.state"),
|
|
5973
|
+
("--events-file-template", "events.{persona}.ndjson")):
|
|
5974
|
+
self.assertRegex(after, re.escape(flag) + r" \S*" + re.escape(path))
|
|
5975
|
+
self.assertIn("--poll-seconds 30", after) # everything else untouched
|
|
5976
|
+
self.assertIn("REFUSING to rewrite", r.stdout) # /nonexistent lacks the new flags
|
|
5977
|
+
self.assertEqual(r.returncode, 0, "a dry run never fails")
|
|
5978
|
+
|
|
5979
|
+
def test_the_rewritten_unit_resolves_to_the_same_paths_the_shipped_template_does(self):
|
|
5980
|
+
r = self._run(self.OLD)
|
|
5981
|
+
after = next(l for l in r.stdout.splitlines() if l.startswith("ExecStart after:"))
|
|
5982
|
+
argv = [w.replace("%h", "/h").replace("%I", "Loom") for w in after.split()[3:]]
|
|
5983
|
+
args = km.build_parser().parse_args(argv)
|
|
5984
|
+
km.validate_args(args)
|
|
5985
|
+
shipped = km.build_parser().parse_args(SupervisorPersonaPathParityTest()._systemd_argv_home("Loom", "/h"))
|
|
5986
|
+
km.validate_args(shipped)
|
|
5987
|
+
self.assertEqual(args.token_file, shipped.token_file)
|
|
5988
|
+
self.assertEqual(args.state_file_template, shipped.state_file_template)
|
|
5989
|
+
self.assertEqual(args.events_file_template, shipped.events_file_template)
|
|
5990
|
+
|
|
5991
|
+
def test_an_already_migrated_unit_is_left_alone(self):
|
|
5992
|
+
# The shipped template IS the migrated form, so running the script on it must be a no-op report.
|
|
5993
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
5994
|
+
with open(os.path.join(here, "kijito-inbox-monitor@.service.template"), encoding="utf-8") as fh:
|
|
5995
|
+
r = self._run(fh.read())
|
|
5996
|
+
self.assertIn("already migrated", r.stdout)
|
|
5997
|
+
self.assertNotIn("ExecStart after:", r.stdout)
|
|
5998
|
+
|
|
5999
|
+
|
|
6000
|
+
class StillUnreadBackstopTest(unittest.TestCase):
|
|
6001
|
+
"""Row M229: mail that was notified once and then left UNREAD is re-surfaced - boundedly. The producer
|
|
6002
|
+
is edge-triggered per id, so without this such a message sits inert forever. These pin the bounds that
|
|
6003
|
+
keep a reminder from becoming a storm: priming on start, once per id per window, a per-id cap, one
|
|
6004
|
+
event per poll, and silence for debris and write_only inboxes."""
|
|
6005
|
+
|
|
6006
|
+
E2E = BoundedWindowEndToEndTest
|
|
6007
|
+
T0 = km._created_epoch("2026-09-24 00:00:00")
|
|
6008
|
+
H = 3600
|
|
6009
|
+
|
|
6010
|
+
def setUp(self):
|
|
6011
|
+
self._saved = [dict(d) for d in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY)]
|
|
6012
|
+
for d in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY):
|
|
6013
|
+
d.clear()
|
|
6014
|
+
|
|
6015
|
+
def tearDown(self):
|
|
6016
|
+
for d, keep in zip((km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY), self._saved):
|
|
6017
|
+
d.clear()
|
|
6018
|
+
d.update(keep)
|
|
6019
|
+
|
|
6020
|
+
def _target(self, after=7200, cap=3, cursor=10, em=None):
|
|
6021
|
+
em = em or self.E2E.RecordingEmitter()
|
|
6022
|
+
t = self.E2E()._target(cursor=cursor, emitter=em)
|
|
6023
|
+
t.args.still_unread_after, t.args.still_unread_max = after, cap
|
|
6024
|
+
return t, em
|
|
6025
|
+
|
|
6026
|
+
def _alerts(self, em):
|
|
6027
|
+
return [f for e, f in em.events if e == "still_unread"]
|
|
6028
|
+
|
|
6029
|
+
ITEMS = [{"id": 5, "from": "river", "read": False, "created": "2026-09-24 00:00:00"},
|
|
6030
|
+
{"id": 6, "from": "river", "read": True, "created": "2026-09-24 00:00:00"}, # already read
|
|
6031
|
+
{"id": 11, "from": "river", "read": False, "created": "2026-09-24 00:00:00"}] # not yet notified
|
|
6032
|
+
|
|
6033
|
+
def test_primes_on_start_then_reminds_once_per_window_up_to_the_cap(self):
|
|
6034
|
+
t, em = self._target()
|
|
6035
|
+
now = self.T0 + 3 * self.H
|
|
6036
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now), [], "a restart must not burst")
|
|
6037
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now + 60), [], "not before a full window")
|
|
6038
|
+
seen = [t._resurface_still_unread(self.ITEMS, now + k * 7200) for k in range(1, 6)]
|
|
6039
|
+
self.assertEqual(seen, [[5], [5], [5], [], []], "once per window, capped at 3")
|
|
6040
|
+
self.assertEqual(len(self._alerts(em)), 3)
|
|
6041
|
+
self.assertEqual(self._alerts(em)[0]["ids"], [5])
|
|
6042
|
+
self.assertEqual(self._alerts(em)[0]["senders"], ["river"])
|
|
6043
|
+
|
|
6044
|
+
def test_young_mail_is_not_reminded(self):
|
|
6045
|
+
t, em = self._target()
|
|
6046
|
+
t.still_unread_primed = True
|
|
6047
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 3000), [])
|
|
6048
|
+
|
|
6049
|
+
def test_several_due_messages_are_ONE_event(self):
|
|
6050
|
+
items = [dict(self.ITEMS[0]), dict(self.ITEMS[0], id=7, **{"from": "assay"})]
|
|
6051
|
+
t, em = self._target()
|
|
6052
|
+
t.still_unread_primed = True
|
|
6053
|
+
self.assertEqual(t._resurface_still_unread(items, self.T0 + 3 * self.H), [5, 7])
|
|
6054
|
+
self.assertEqual(len(self._alerts(em)), 1)
|
|
6055
|
+
self.assertEqual(self._alerts(em)[0]["senders"], ["assay", "river"])
|
|
6056
|
+
|
|
6057
|
+
def test_read_mail_leaves_the_ledger(self):
|
|
6058
|
+
t, _ = self._target()
|
|
6059
|
+
t.still_unread_primed = True
|
|
6060
|
+
t._resurface_still_unread(self.ITEMS, self.T0 + 3 * self.H)
|
|
6061
|
+
self.assertIn(5, t.still_unread_seen)
|
|
6062
|
+
t._resurface_still_unread([dict(self.ITEMS[0], read=True)], self.T0 + 4 * self.H)
|
|
6063
|
+
self.assertNotIn(5, t.still_unread_seen)
|
|
6064
|
+
|
|
6065
|
+
def test_a_failed_emit_is_retried_not_counted(self):
|
|
6066
|
+
class Refusing(self.E2E.RecordingEmitter):
|
|
6067
|
+
def lifecycle(self, event, **f):
|
|
6068
|
+
self.events.append((event, f))
|
|
6069
|
+
return False
|
|
6070
|
+
t, em = self._target(em=Refusing())
|
|
6071
|
+
t.still_unread_primed = True
|
|
6072
|
+
now = self.T0 + 3 * self.H
|
|
6073
|
+
t._resurface_still_unread(self.ITEMS, now)
|
|
6074
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, now + 1), [5], "undelivered = not reminded yet")
|
|
6075
|
+
|
|
6076
|
+
def test_debris_and_write_only_inboxes_are_never_reminded(self):
|
|
6077
|
+
for flag in (km._PERSONA_RETIRED, km._PERSONA_RESERVED, km._PERSONA_WRITE_ONLY):
|
|
6078
|
+
with self.subTest(flag=flag):
|
|
6079
|
+
self.setUp()
|
|
6080
|
+
flag["argus"] = True
|
|
6081
|
+
t, em = self._target()
|
|
6082
|
+
t.still_unread_primed = True
|
|
6083
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 9 * self.H), [])
|
|
6084
|
+
self.assertEqual(self._alerts(em), [])
|
|
6085
|
+
|
|
6086
|
+
def test_zero_disables(self):
|
|
6087
|
+
t, em = self._target(after=0)
|
|
6088
|
+
t.still_unread_primed = True
|
|
6089
|
+
self.assertEqual(t._resurface_still_unread(self.ITEMS, self.T0 + 99 * self.H), [])
|
|
6090
|
+
|
|
6091
|
+
def test_it_is_wired_into_the_full_poll(self):
|
|
6092
|
+
t, em = self._target(after=1, cursor=10)
|
|
6093
|
+
t.still_unread_primed = True
|
|
6094
|
+
old = [{"id": 5, "from": "river", "read": False, "created": "2020-01-01 00:00:00"}]
|
|
6095
|
+
self.E2E()._run(t, self.E2E()._fetch(old, 0))
|
|
6096
|
+
alerts = self._alerts(em)
|
|
6097
|
+
self.assertEqual(len(alerts), 1)
|
|
6098
|
+
self.assertEqual(alerts[0]["ids"], [5])
|
|
6099
|
+
self.assertEqual(t.cursor, 10, "a reminder never moves the cursor")
|
|
6100
|
+
self.assertEqual(em.new_ids, [], "and is never re-emitted as `new`")
|
|
6101
|
+
|
|
6102
|
+
def test_created_parsing(self):
|
|
6103
|
+
self.assertEqual(km._created_epoch("2026-09-24 00:00:00"), km._created_epoch("2026-09-24T00:00:00Z"))
|
|
6104
|
+
self.assertEqual(km._created_epoch("2026-09-24T02:00:00+02:00"), self.T0)
|
|
6105
|
+
for junk in (None, "", "yesterday", 17):
|
|
6106
|
+
self.assertIsNone(km._created_epoch(junk))
|
|
6107
|
+
|
|
6108
|
+
def test_flags_are_validated(self):
|
|
6109
|
+
def err(argv):
|
|
6110
|
+
try:
|
|
6111
|
+
km.validate_args(km.build_parser().parse_args(argv))
|
|
6112
|
+
except km.FatalConfig as e:
|
|
6113
|
+
return str(e)
|
|
6114
|
+
self.assertIn(">= 0", err(["--persona", "a", "--still-unread-after", "-1"]))
|
|
6115
|
+
self.assertIn(">= 1", err(["--persona", "a", "--still-unread-max", "0"]))
|
|
6116
|
+
self.assertIsNone(err(["--persona", "a", "--still-unread-after", "0"]))
|