pocketshell 0.4.17__tar.gz → 0.4.18__tar.gz
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.
- {pocketshell-0.4.17 → pocketshell-0.4.18}/PKG-INFO +1 -1
- {pocketshell-0.4.17 → pocketshell-0.4.18}/pyproject.toml +1 -1
- pocketshell-0.4.18/src/pocketshell/agent_card_push.py +263 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/cards.py +11 -1
- pocketshell-0.4.18/tests/test_agent_card_push.py +279 -0
- pocketshell-0.4.18/tests/test_watch_ci.py +600 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/.gitignore +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/README.md +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/scheduler/README.md +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/agents.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/agents_kind.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/daemon.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/tree.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/src/pocketshell/usage_reset.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/__init__.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_agents.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_agents_kind.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_cards.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_cli.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_daemon.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_env.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_github.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_logs.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_push.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_repos.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_resume.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_tree.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_usage.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_usage_capture.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/tests/test_usage_reset.py +0 -0
- {pocketshell-0.4.17 → pocketshell-0.4.18}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.18
|
|
4
4
|
Summary: Unified server-side Python utility for the PocketShell Android client.
|
|
5
5
|
Project-URL: Homepage, https://github.com/alexeygrigorev/pocketshell
|
|
6
6
|
Project-URL: Issues, https://github.com/alexeygrigorev/pocketshell/issues
|
|
@@ -8,7 +8,7 @@ name = "pocketshell"
|
|
|
8
8
|
# scripts/check-pypi-version.sh enforces this; .github/workflows/build.yml
|
|
9
9
|
# runs that check before publishing to PyPI. See
|
|
10
10
|
# tools/pocketshell/README.md ("Release flow") for the bump procedure.
|
|
11
|
-
version = "0.4.
|
|
11
|
+
version = "0.4.18"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Server-side FCM push when an agent posts a typed card (epic #859, Slice D).
|
|
2
|
+
|
|
3
|
+
This is the card sibling of :mod:`pocketshell.push` (which owns the usage-reset
|
|
4
|
+
FCM path, #690). When a running agent calls ``pocketshell push checklist`` the
|
|
5
|
+
host writes/updates a per-session card (:mod:`pocketshell.cards`); this module
|
|
6
|
+
fires a best-effort FCM **data** push so the phone surfaces a heads-up
|
|
7
|
+
notification that opens the session's card feed.
|
|
8
|
+
|
|
9
|
+
Why a separate module (not inside :mod:`pocketshell.cards`)
|
|
10
|
+
----------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
The card store and the FCM device token live in **two different state homes**
|
|
13
|
+
(the #859 research spike's risk #1): cards under ``~/.pocketshell/cards/`` and
|
|
14
|
+
the registered device token under the usage state dir
|
|
15
|
+
(``$XDG_STATE_HOME/pocketshell/usage/push-token.json``). Rather than have
|
|
16
|
+
:mod:`pocketshell.cards` import the token storage directly, the bridge lives
|
|
17
|
+
here: it reads the token via :func:`pocketshell.push.read_token` and sends via
|
|
18
|
+
:class:`pocketshell.push.FcmSender` — the exact same sender/credential the
|
|
19
|
+
usage-reset path uses. Adding a second card type later (``note``) reuses this
|
|
20
|
+
verbatim.
|
|
21
|
+
|
|
22
|
+
Hard-cut (D22): the app receive path adds a NEW ``type=agent_card`` payload
|
|
23
|
+
sibling to ``usage_reset`` — this module never emits ``usage_reset``.
|
|
24
|
+
|
|
25
|
+
Fail-soft (matches :func:`pocketshell.push.push_reset_events`): no registered
|
|
26
|
+
token, no service-account credential, or no :mod:`google.auth` installed → the
|
|
27
|
+
trigger no-ops and returns ``None`` WITHOUT raising. ``pocketshell push
|
|
28
|
+
checklist`` must never break because Firebase isn't set up.
|
|
29
|
+
|
|
30
|
+
De-dup
|
|
31
|
+
------
|
|
32
|
+
|
|
33
|
+
A successful send records a deterministic **content key** under the cards dir
|
|
34
|
+
(``push-sent.jsonl``) so re-pushing the *same* card (an agent re-runs the same
|
|
35
|
+
``push checklist``) does not re-POST, while an *updated* card (different items /
|
|
36
|
+
title) produces a new key and DOES push. The app's ``PushDedupStore`` is a
|
|
37
|
+
second line of defence on the device; this is the server-side primary guard.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import hashlib
|
|
43
|
+
import json
|
|
44
|
+
import socket
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, Optional
|
|
47
|
+
|
|
48
|
+
from pocketshell import push as push_mod
|
|
49
|
+
from pocketshell.cards import CardPaths
|
|
50
|
+
from pocketshell.usage_capture import _append_history, resolve_paths as resolve_usage_paths
|
|
51
|
+
|
|
52
|
+
# The app contract: an agent-card push is a `type=agent_card` FCM data message.
|
|
53
|
+
AGENT_CARD_PUSH_TYPE = "agent_card"
|
|
54
|
+
|
|
55
|
+
# Per-session content-key log so a re-push of an unchanged card never re-POSTs.
|
|
56
|
+
# Lives under the cards dir (NOT the usage dir) — co-located with the cards it
|
|
57
|
+
# de-dups. One small file is plenty: cards are rare and bounded.
|
|
58
|
+
SENT_LOG_FILENAME = "push-sent.jsonl"
|
|
59
|
+
DEFAULT_SENT_LOG_MAX_LINES = 1000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sent_log_file(paths: CardPaths) -> Path:
|
|
63
|
+
"""Return the per-content-key already-pushed log path for ``paths``."""
|
|
64
|
+
return paths.cards_dir / SENT_LOG_FILENAME
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sent_card_keys(paths: CardPaths) -> set[str]:
|
|
68
|
+
"""Return the set of card content keys already successfully pushed."""
|
|
69
|
+
path = sent_log_file(paths)
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return set()
|
|
72
|
+
keys: set[str] = set()
|
|
73
|
+
try:
|
|
74
|
+
text = path.read_text(encoding="utf-8")
|
|
75
|
+
except OSError:
|
|
76
|
+
return set()
|
|
77
|
+
for line in text.splitlines():
|
|
78
|
+
if not line.strip():
|
|
79
|
+
continue
|
|
80
|
+
try:
|
|
81
|
+
parsed = json.loads(line)
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
continue
|
|
84
|
+
if isinstance(parsed, dict):
|
|
85
|
+
key = parsed.get("card_key")
|
|
86
|
+
if isinstance(key, str) and key:
|
|
87
|
+
keys.add(key)
|
|
88
|
+
return keys
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mark_sent(
|
|
92
|
+
card_key: str,
|
|
93
|
+
*,
|
|
94
|
+
paths: CardPaths,
|
|
95
|
+
sent_log_max_lines: int = DEFAULT_SENT_LOG_MAX_LINES,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Record ``card_key`` as successfully pushed so it never re-POSTs."""
|
|
98
|
+
_append_history(
|
|
99
|
+
sent_log_file(paths),
|
|
100
|
+
{"card_key": card_key},
|
|
101
|
+
history_max_lines=sent_log_max_lines,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _summarise(card: dict[str, Any]) -> str:
|
|
106
|
+
"""One-line human summary of a card via its registered type, fail-soft.
|
|
107
|
+
|
|
108
|
+
Reuses the same per-type ``summarise`` the CLI's ``push status`` uses so the
|
|
109
|
+
notification body matches what the agent sees. Falls back to a generic line
|
|
110
|
+
for an unknown type (forward-compatible).
|
|
111
|
+
"""
|
|
112
|
+
from pocketshell.cards import get_card_type
|
|
113
|
+
|
|
114
|
+
card_type = card.get("type", "")
|
|
115
|
+
handler = get_card_type(card_type) if isinstance(card_type, str) else None
|
|
116
|
+
body = card.get("body", {})
|
|
117
|
+
state = card.get("state", {})
|
|
118
|
+
if handler is not None and isinstance(body, dict) and isinstance(state, dict):
|
|
119
|
+
try:
|
|
120
|
+
return handler.summarise(dict(body), dict(state))
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
return f"{card_type or 'card'} updated"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _host_alias() -> str:
|
|
127
|
+
"""Best-effort host identifier for the deep-link, never raising.
|
|
128
|
+
|
|
129
|
+
The host CLI does NOT know the app's host alias (the #859 research spike's
|
|
130
|
+
risk #2). The most reliable thing it can emit is its own resolvable
|
|
131
|
+
hostname, which the app matches against ``HostEntity.hostname`` /
|
|
132
|
+
``HostEntity.name`` to route the tap to the right host's session feed. When
|
|
133
|
+
the match is ambiguous or absent the app falls back to its home screen — it
|
|
134
|
+
never opens the WRONG host.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
fqdn = socket.getfqdn().strip()
|
|
138
|
+
if fqdn and fqdn.lower() != "localhost":
|
|
139
|
+
return fqdn
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
try:
|
|
143
|
+
return socket.gethostname().strip()
|
|
144
|
+
except Exception:
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def card_to_data(
|
|
149
|
+
session: str,
|
|
150
|
+
card: dict[str, Any],
|
|
151
|
+
*,
|
|
152
|
+
host: Optional[str] = None,
|
|
153
|
+
) -> dict[str, str]:
|
|
154
|
+
"""Map a card + its session to the app's ``agent_card`` data-message keys.
|
|
155
|
+
|
|
156
|
+
Keys (mirrors the app's ``AgentCardPushPayload`` contract):
|
|
157
|
+
|
|
158
|
+
- ``type`` = ``agent_card``
|
|
159
|
+
- ``session`` = the tmux session the card belongs to (deep-link target)
|
|
160
|
+
- ``host`` = best-effort host hostname for host resolution (may be empty)
|
|
161
|
+
- ``card_id`` = the card id
|
|
162
|
+
- ``card_type`` = e.g. ``checklist``
|
|
163
|
+
- ``title`` = the card title (falls back to a type-derived label)
|
|
164
|
+
- ``summary`` = the per-type one-line summary (e.g. ``checklist 1/3 checked``)
|
|
165
|
+
- ``card_key`` = the content-hash de-dup identity (also the app dedup key)
|
|
166
|
+
"""
|
|
167
|
+
card_type = str(card.get("type", "") or "")
|
|
168
|
+
card_id = str(card.get("id", "") or "")
|
|
169
|
+
title = card.get("title")
|
|
170
|
+
title_str = title.strip() if isinstance(title, str) and title.strip() else _default_title(card_type)
|
|
171
|
+
summary = _summarise(card)
|
|
172
|
+
resolved_host = host if host is not None else _host_alias()
|
|
173
|
+
card_key = content_key(session, card)
|
|
174
|
+
return {
|
|
175
|
+
"type": AGENT_CARD_PUSH_TYPE,
|
|
176
|
+
"session": session,
|
|
177
|
+
"host": resolved_host or "",
|
|
178
|
+
"card_id": card_id,
|
|
179
|
+
"card_type": card_type,
|
|
180
|
+
"title": title_str,
|
|
181
|
+
"summary": summary,
|
|
182
|
+
"card_key": card_key,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _default_title(card_type: str) -> str:
|
|
187
|
+
if card_type == "checklist":
|
|
188
|
+
return "Checklist"
|
|
189
|
+
if card_type:
|
|
190
|
+
return card_type[:1].upper() + card_type[1:]
|
|
191
|
+
return "Card"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def content_key(session: str, card: dict[str, Any]) -> str:
|
|
195
|
+
"""Deterministic content key for a card (drives server-side de-dup).
|
|
196
|
+
|
|
197
|
+
Hashes the session + card id + type + title + body + state — NOT
|
|
198
|
+
``updated_at`` (which changes on every push) — so an identical re-push
|
|
199
|
+
yields the SAME key (suppressed) while any content/state change yields a NEW
|
|
200
|
+
key (pushed). Stable across processes (``sort_keys``).
|
|
201
|
+
"""
|
|
202
|
+
payload = {
|
|
203
|
+
"session": session,
|
|
204
|
+
"id": card.get("id"),
|
|
205
|
+
"type": card.get("type"),
|
|
206
|
+
"title": card.get("title"),
|
|
207
|
+
"body": card.get("body"),
|
|
208
|
+
"state": card.get("state"),
|
|
209
|
+
}
|
|
210
|
+
blob = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
211
|
+
digest = hashlib.sha256(blob.encode("utf-8")).hexdigest()[:16]
|
|
212
|
+
return f"{session}|{card.get('id')}|{digest}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def notify_card_pushed(
|
|
216
|
+
session: str,
|
|
217
|
+
card: dict[str, Any],
|
|
218
|
+
*,
|
|
219
|
+
card_paths: CardPaths,
|
|
220
|
+
host: Optional[str] = None,
|
|
221
|
+
sender: Optional["push_mod.FcmSender"] = None,
|
|
222
|
+
env: Optional[dict[str, str]] = None,
|
|
223
|
+
sent_log_max_lines: int = DEFAULT_SENT_LOG_MAX_LINES,
|
|
224
|
+
) -> Optional[str]:
|
|
225
|
+
"""Best-effort FCM push for a freshly upserted ``card``. Fail-soft.
|
|
226
|
+
|
|
227
|
+
Returns the ``card_key`` actually pushed this call, or ``None`` when push
|
|
228
|
+
isn't configured / nothing new was sent. NEVER raises — the ``push
|
|
229
|
+
checklist`` CLI call must succeed even when Firebase isn't set up.
|
|
230
|
+
|
|
231
|
+
Guards, in order (mirroring :func:`pocketshell.push.push_reset_events`):
|
|
232
|
+
|
|
233
|
+
1. No registered device token -> ``None``.
|
|
234
|
+
2. No service-account credential / no ``google.auth`` -> ``None`` (pre-S0).
|
|
235
|
+
3. Already pushed this exact content (de-dup) -> ``None``.
|
|
236
|
+
4. On a successful send: record the content key so it never re-POSTs.
|
|
237
|
+
On a failed send: leave it un-recorded so the next push RETRIES.
|
|
238
|
+
"""
|
|
239
|
+
try:
|
|
240
|
+
usage_paths = resolve_usage_paths(env=env)
|
|
241
|
+
token = push_mod.read_token(usage_paths)
|
|
242
|
+
if token is None:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
if sender is None:
|
|
246
|
+
sa_path = push_mod._resolve_service_account_path(usage_paths, env=env)
|
|
247
|
+
if sa_path is None:
|
|
248
|
+
return None
|
|
249
|
+
sender = push_mod.FcmSender.from_service_account(sa_path)
|
|
250
|
+
if sender is None:
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
data = card_to_data(session, card, host=host)
|
|
254
|
+
card_key = data["card_key"]
|
|
255
|
+
if card_key in sent_card_keys(card_paths):
|
|
256
|
+
return None
|
|
257
|
+
if sender.send_data_message(token=token, data=data):
|
|
258
|
+
_mark_sent(card_key, paths=card_paths, sent_log_max_lines=sent_log_max_lines)
|
|
259
|
+
return card_key
|
|
260
|
+
return None
|
|
261
|
+
except Exception:
|
|
262
|
+
# Absolute fail-soft backstop: a card push must never wedge the CLI.
|
|
263
|
+
return None
|
|
@@ -632,7 +632,17 @@ def register_push_card_commands(push_group: click.Group) -> None:
|
|
|
632
632
|
build_kwargs={"items": parsed},
|
|
633
633
|
preset_checked=preset_checked,
|
|
634
634
|
)
|
|
635
|
-
|
|
635
|
+
card_paths = resolve_paths()
|
|
636
|
+
path = upsert_card(target, card, paths=card_paths)
|
|
637
|
+
# Slice D (#859): fire a best-effort FCM push so the phone surfaces a
|
|
638
|
+
# heads-up notification opening this session's card feed. Fail-soft —
|
|
639
|
+
# imported lazily so a host without the push deps still pushes the card.
|
|
640
|
+
try:
|
|
641
|
+
from pocketshell.agent_card_push import notify_card_pushed
|
|
642
|
+
|
|
643
|
+
notify_card_pushed(target, card, card_paths=card_paths)
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
636
646
|
click.echo(
|
|
637
647
|
f"checklist {card_id!r} ({len(parsed)} items) -> session {target!r} ({path})"
|
|
638
648
|
)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Unit tests for the agent-card FCM push trigger (epic #859, Slice D).
|
|
2
|
+
|
|
3
|
+
Covers, per the issue acceptance criteria:
|
|
4
|
+
- `pocketshell push checklist` fires an `agent_card` FCM data push carrying
|
|
5
|
+
session / card_type / title / summary / card_key (REPRODUCE-FIRST: this is
|
|
6
|
+
the load-bearing success-path test — red before the trigger was wired).
|
|
7
|
+
- Fail-soft: no registered token -> no push, the CLI call still succeeds.
|
|
8
|
+
- Fail-soft: no service-account credential -> no push, CLI still succeeds.
|
|
9
|
+
- Server-side de-dup: re-pushing the SAME card does not re-POST; an UPDATED
|
|
10
|
+
card (new content key) DOES push.
|
|
11
|
+
- The data-message keys match the app's `AgentCardPushPayload` contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from click.testing import CliRunner
|
|
20
|
+
|
|
21
|
+
from pocketshell import agent_card_push, cards as cards_mod, push as push_mod
|
|
22
|
+
from pocketshell.cli import cli
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _RecordingSender(push_mod.FcmSender):
|
|
26
|
+
"""An FcmSender that records sends without touching the network."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, ok: bool = True) -> None:
|
|
29
|
+
super().__init__(project_id="test-project", credentials=object())
|
|
30
|
+
self.ok = ok
|
|
31
|
+
self.calls: list[dict[str, Any]] = []
|
|
32
|
+
|
|
33
|
+
def send_data_message(self, *, token: str, data: dict[str, str]) -> bool:
|
|
34
|
+
self.calls.append({"token": token, "data": dict(data)})
|
|
35
|
+
return self.ok
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _card_paths(tmp_path: Path) -> cards_mod.CardPaths:
|
|
39
|
+
return cards_mod.CardPaths(cards_dir=tmp_path / "cards")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _checklist_card(title: str = "Deploy", checked: list[str] | None = None) -> dict[str, Any]:
|
|
43
|
+
return cards_mod.build_card(
|
|
44
|
+
card_type="checklist",
|
|
45
|
+
card_id="checklist",
|
|
46
|
+
title=title,
|
|
47
|
+
build_kwargs={
|
|
48
|
+
"items": [
|
|
49
|
+
{"id": "build-0", "text": "build"},
|
|
50
|
+
{"id": "ship-1", "text": "ship"},
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
preset_checked=checked or [],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _state_env(tmp_path: Path, *, session: str = "demo", with_token: bool = True) -> dict[str, str]:
|
|
58
|
+
"""CLI/trigger env: relocate BOTH the cards store and the usage state dir.
|
|
59
|
+
|
|
60
|
+
`XDG_STATE_HOME` relocates the usage dir (where the device token lives);
|
|
61
|
+
`POCKETSHELL_CARDS_DIR` relocates the cards store + its sent-log.
|
|
62
|
+
"""
|
|
63
|
+
state_home = tmp_path / "state"
|
|
64
|
+
env = {
|
|
65
|
+
"XDG_STATE_HOME": str(state_home),
|
|
66
|
+
"POCKETSHELL_CARDS_DIR": str(tmp_path / "cards"),
|
|
67
|
+
"TMUX": f"/tmp/x,1,0 {session}",
|
|
68
|
+
}
|
|
69
|
+
if with_token:
|
|
70
|
+
usage_paths = push_mod.resolve_paths(env=env)
|
|
71
|
+
push_mod.register_token("device-token-abc", paths=usage_paths)
|
|
72
|
+
return env
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# notify_card_pushed — success path + payload contract (REPRODUCE-FIRST)
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_notify_card_pushed_sends_agent_card_data_message(tmp_path: Path) -> None:
|
|
81
|
+
env = _state_env(tmp_path)
|
|
82
|
+
sender = _RecordingSender()
|
|
83
|
+
card = _checklist_card(checked=["build-0"])
|
|
84
|
+
|
|
85
|
+
card_key = agent_card_push.notify_card_pushed(
|
|
86
|
+
"demo",
|
|
87
|
+
card,
|
|
88
|
+
card_paths=_card_paths(tmp_path),
|
|
89
|
+
host="agent-box.example",
|
|
90
|
+
sender=sender,
|
|
91
|
+
env=env,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
assert card_key is not None
|
|
95
|
+
assert len(sender.calls) == 1
|
|
96
|
+
data = sender.calls[0]["data"]
|
|
97
|
+
# The app's AgentCardPushPayload contract.
|
|
98
|
+
assert data["type"] == "agent_card"
|
|
99
|
+
assert data["session"] == "demo"
|
|
100
|
+
assert data["host"] == "agent-box.example"
|
|
101
|
+
assert data["card_id"] == "checklist"
|
|
102
|
+
assert data["card_type"] == "checklist"
|
|
103
|
+
assert data["title"] == "Deploy"
|
|
104
|
+
assert data["summary"] == "checklist 1/2 checked"
|
|
105
|
+
assert data["card_key"] == card_key
|
|
106
|
+
assert sender.calls[0]["token"] == "device-token-abc"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_notify_card_pushed_default_host_is_resolvable_hostname(tmp_path: Path) -> None:
|
|
110
|
+
env = _state_env(tmp_path)
|
|
111
|
+
sender = _RecordingSender()
|
|
112
|
+
agent_card_push.notify_card_pushed(
|
|
113
|
+
"demo", _checklist_card(), card_paths=_card_paths(tmp_path), sender=sender, env=env
|
|
114
|
+
)
|
|
115
|
+
# host best-effort: a non-None string (may be empty in an exotic sandbox,
|
|
116
|
+
# but the KEY is always present so the app can parse it).
|
|
117
|
+
assert "host" in sender.calls[0]["data"]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Fail-soft guards
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_notify_card_pushed_no_token_is_noop(tmp_path: Path) -> None:
|
|
126
|
+
env = _state_env(tmp_path, with_token=False)
|
|
127
|
+
sender = _RecordingSender()
|
|
128
|
+
result = agent_card_push.notify_card_pushed(
|
|
129
|
+
"demo", _checklist_card(), card_paths=_card_paths(tmp_path), sender=sender, env=env
|
|
130
|
+
)
|
|
131
|
+
assert result is None
|
|
132
|
+
assert sender.calls == []
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_notify_card_pushed_no_credential_is_noop(tmp_path: Path) -> None:
|
|
136
|
+
# Token present but NO sender passed and NO service-account JSON on disk ->
|
|
137
|
+
# FcmSender.from_service_account path can't resolve -> no-op (pre-S0 host).
|
|
138
|
+
env = _state_env(tmp_path)
|
|
139
|
+
result = agent_card_push.notify_card_pushed(
|
|
140
|
+
"demo", _checklist_card(), card_paths=_card_paths(tmp_path), sender=None, env=env
|
|
141
|
+
)
|
|
142
|
+
assert result is None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Server-side de-dup
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_notify_card_pushed_dedups_identical_repush(tmp_path: Path) -> None:
|
|
151
|
+
env = _state_env(tmp_path)
|
|
152
|
+
paths = _card_paths(tmp_path)
|
|
153
|
+
sender = _RecordingSender()
|
|
154
|
+
card = _checklist_card(checked=["build-0"])
|
|
155
|
+
|
|
156
|
+
first = agent_card_push.notify_card_pushed("demo", card, card_paths=paths, sender=sender, env=env)
|
|
157
|
+
# An identical re-push (same content; build_card bumps updated_at but the
|
|
158
|
+
# content key ignores it) must NOT re-POST.
|
|
159
|
+
second = agent_card_push.notify_card_pushed("demo", card, card_paths=paths, sender=sender, env=env)
|
|
160
|
+
|
|
161
|
+
assert first is not None
|
|
162
|
+
assert second is None
|
|
163
|
+
assert len(sender.calls) == 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_notify_card_pushed_updated_card_pushes_again(tmp_path: Path) -> None:
|
|
167
|
+
env = _state_env(tmp_path)
|
|
168
|
+
paths = _card_paths(tmp_path)
|
|
169
|
+
sender = _RecordingSender()
|
|
170
|
+
|
|
171
|
+
agent_card_push.notify_card_pushed(
|
|
172
|
+
"demo", _checklist_card(checked=[]), card_paths=paths, sender=sender, env=env
|
|
173
|
+
)
|
|
174
|
+
# A genuine state change (an item now checked) is a NEW content key -> push.
|
|
175
|
+
updated = agent_card_push.notify_card_pushed(
|
|
176
|
+
"demo", _checklist_card(checked=["build-0"]), card_paths=paths, sender=sender, env=env
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
assert updated is not None
|
|
180
|
+
assert len(sender.calls) == 2
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_notify_card_pushed_failed_send_not_marked_retries(tmp_path: Path) -> None:
|
|
184
|
+
env = _state_env(tmp_path)
|
|
185
|
+
paths = _card_paths(tmp_path)
|
|
186
|
+
card = _checklist_card()
|
|
187
|
+
|
|
188
|
+
failing = _RecordingSender(ok=False)
|
|
189
|
+
assert (
|
|
190
|
+
agent_card_push.notify_card_pushed("demo", card, card_paths=paths, sender=failing, env=env)
|
|
191
|
+
is None
|
|
192
|
+
)
|
|
193
|
+
# A later attempt with a now-working sender must RETRY (the first failure
|
|
194
|
+
# left the key un-marked).
|
|
195
|
+
ok = _RecordingSender(ok=True)
|
|
196
|
+
assert (
|
|
197
|
+
agent_card_push.notify_card_pushed("demo", card, card_paths=paths, sender=ok, env=env)
|
|
198
|
+
is not None
|
|
199
|
+
)
|
|
200
|
+
assert len(ok.calls) == 1
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_content_key_ignores_updated_at(tmp_path: Path) -> None:
|
|
204
|
+
a = _checklist_card()
|
|
205
|
+
b = dict(a)
|
|
206
|
+
b["updated_at"] = "2099-01-01T00:00:00Z" # different timestamp, same content
|
|
207
|
+
assert agent_card_push.content_key("demo", a) == agent_card_push.content_key("demo", b)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_content_key_changes_on_state_change(tmp_path: Path) -> None:
|
|
211
|
+
a = _checklist_card(checked=[])
|
|
212
|
+
b = _checklist_card(checked=["build-0"])
|
|
213
|
+
assert agent_card_push.content_key("demo", a) != agent_card_push.content_key("demo", b)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# CLI integration — `push checklist` fires the trigger, fail-soft otherwise
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_cli_push_checklist_fires_trigger_when_configured(tmp_path: Path, monkeypatch: Any) -> None:
|
|
222
|
+
env = _state_env(tmp_path)
|
|
223
|
+
sender = _RecordingSender()
|
|
224
|
+
monkeypatch.setattr(cards_mod, "_tmux_current_session", lambda: "demo")
|
|
225
|
+
# Force the lazy trigger to use our recording sender (no real Firebase).
|
|
226
|
+
real_notify = agent_card_push.notify_card_pushed
|
|
227
|
+
|
|
228
|
+
def _notify(session, card, **kwargs): # type: ignore[no-untyped-def]
|
|
229
|
+
kwargs.setdefault("sender", sender)
|
|
230
|
+
kwargs.setdefault("env", env)
|
|
231
|
+
return real_notify(session, card, **kwargs)
|
|
232
|
+
|
|
233
|
+
monkeypatch.setattr(agent_card_push, "notify_card_pushed", _notify)
|
|
234
|
+
|
|
235
|
+
runner = CliRunner()
|
|
236
|
+
result = runner.invoke(
|
|
237
|
+
cli,
|
|
238
|
+
["push", "checklist", "--title", "Release"],
|
|
239
|
+
input="- [ ] build\n- [x] ship\n",
|
|
240
|
+
env=env,
|
|
241
|
+
)
|
|
242
|
+
assert result.exit_code == 0, result.output
|
|
243
|
+
assert len(sender.calls) == 1
|
|
244
|
+
data = sender.calls[0]["data"]
|
|
245
|
+
assert data["type"] == "agent_card"
|
|
246
|
+
assert data["session"] == "demo"
|
|
247
|
+
assert data["card_type"] == "checklist"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_cli_push_checklist_succeeds_when_push_unconfigured(tmp_path: Path, monkeypatch: Any) -> None:
|
|
251
|
+
# No token registered -> the trigger no-ops, but the CLI call STILL succeeds
|
|
252
|
+
# (the agent's `push checklist` must never break on an unconfigured host).
|
|
253
|
+
env = _state_env(tmp_path, with_token=False)
|
|
254
|
+
monkeypatch.setattr(cards_mod, "_tmux_current_session", lambda: "demo")
|
|
255
|
+
runner = CliRunner()
|
|
256
|
+
result = runner.invoke(
|
|
257
|
+
cli,
|
|
258
|
+
["push", "checklist", "--title", "Release"],
|
|
259
|
+
input="- [ ] build\n",
|
|
260
|
+
env=env,
|
|
261
|
+
)
|
|
262
|
+
assert result.exit_code == 0, result.output
|
|
263
|
+
assert "checklist" in result.output
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def test_cli_push_checklist_succeeds_even_if_trigger_raises(tmp_path: Path, monkeypatch: Any) -> None:
|
|
267
|
+
# The trigger is best-effort: even a bug that raises must not fail the CLI.
|
|
268
|
+
env = _state_env(tmp_path, with_token=False)
|
|
269
|
+
monkeypatch.setattr(cards_mod, "_tmux_current_session", lambda: "demo")
|
|
270
|
+
|
|
271
|
+
def _boom(*_a: Any, **_k: Any) -> None:
|
|
272
|
+
raise RuntimeError("boom")
|
|
273
|
+
|
|
274
|
+
monkeypatch.setattr(agent_card_push, "notify_card_pushed", _boom)
|
|
275
|
+
runner = CliRunner()
|
|
276
|
+
result = runner.invoke(
|
|
277
|
+
cli, ["push", "checklist"], input="- [ ] build\n", env=env
|
|
278
|
+
)
|
|
279
|
+
assert result.exit_code == 0, result.output
|