pocketshell 0.4.45__tar.gz → 0.4.47__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.45 → pocketshell-0.4.47}/PKG-INFO +2 -2
- {pocketshell-0.4.45 → pocketshell-0.4.47}/pyproject.toml +22 -14
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cards.py +142 -49
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/daemon.py +82 -43
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/engines.py +339 -55
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/tree.py +99 -65
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage.py +46 -85
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage_reset.py +11 -7
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/conftest.py +6 -1
- pocketshell-0.4.47/tests/data/quse-0.0.15-usage.json +226 -0
- pocketshell-0.4.47/tests/data/quse-0.0.15-usage.ndjson +6 -0
- pocketshell-0.4.47/tests/data/quse-0.0.15-usage.provenance.txt +32 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents.py +48 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cards.py +199 -2
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_daemon.py +106 -19
- pocketshell-0.4.47/tests/test_engines.py +759 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_tree.py +151 -23
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage.py +321 -139
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage_capture.py +26 -22
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_usage_reset.py +26 -2
- {pocketshell-0.4.45 → pocketshell-0.4.47}/uv.lock +6 -6
- pocketshell-0.4.45/tests/data/quse-0.0.14-usage.json +0 -184
- pocketshell-0.4.45/tests/data/quse-0.0.14-usage.provenance.txt +0 -18
- pocketshell-0.4.45/tests/test_engines.py +0 -231
- {pocketshell-0.4.45 → pocketshell-0.4.47}/.gitignore +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/README.md +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/README.md +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/pocketshell-usage-capture.service +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/scheduler/pocketshell-usage-capture.timer +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/scripts/sync-readme-exit-codes.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agent_card_push.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agents.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/agents_kind.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/aplexer.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cgroup_agents.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/env.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/github.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/jobs.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/profiles.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/prune_attachments.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/push.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/resume.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/send.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/serve.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/session_enum.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/sessions.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/src/pocketshell/usage_capture.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/__init__.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.11-usage.json +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.13-usage.json +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/quse-0.0.9-usage.json +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/data/sshd_anchor/Dockerfile.from-unanchored-base +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agent_card_push.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agent_log.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_kind.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_kind_target.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_agents_source_target.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cards_push_notify.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cgroup_agents.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_cli.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_daemon_fallback.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_env.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_github.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_hooks.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_jobs.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_logs.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_profiles.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_prune_attachments.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_push.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_qr_share.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_repos.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_resume.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_send.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_serve.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_session_enum.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_sessions.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_ssh_fixture_penalties.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_watch_ci.py +0 -0
- {pocketshell-0.4.45 → pocketshell-0.4.47}/tests/test_watch_ci_cancelled.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.47
|
|
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
|
|
@@ -21,7 +21,7 @@ Requires-Python: >=3.11
|
|
|
21
21
|
Requires-Dist: click>=8.2.0
|
|
22
22
|
Requires-Dist: google-auth>=2.0.0
|
|
23
23
|
Requires-Dist: pyyaml>=6.0
|
|
24
|
-
Requires-Dist: quse==0.0.
|
|
24
|
+
Requires-Dist: quse==0.0.15
|
|
25
25
|
Requires-Dist: tmuxctl>=0.3.5
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest>=8.4.0; extra == 'dev'
|
|
@@ -14,7 +14,7 @@ name = "pocketshell"
|
|
|
14
14
|
# scripts/check-pypi-version.sh enforces the stamped value matches the tag;
|
|
15
15
|
# scripts/check-version-coupling.sh enforces the derivation itself is
|
|
16
16
|
# single-sourced. See tools/pocketshell/README.md ("Version coupling").
|
|
17
|
-
version = "0.4.
|
|
17
|
+
version = "0.4.47"
|
|
18
18
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
19
19
|
readme = "README.md"
|
|
20
20
|
requires-python = ">=3.11"
|
|
@@ -43,16 +43,19 @@ dependencies = [
|
|
|
43
43
|
# Usage backend + single source of truth for the unified provider schema
|
|
44
44
|
# (issue #1318). Pinned exactly: `pocketshell usage --json` expects quse's
|
|
45
45
|
# provider-keyed `--json` document and fails loudly on schema drift, so the
|
|
46
|
-
# version is frozen, not a range. The published 0.0.
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
46
|
+
# version is frozen, not a range. The published 0.0.15 wheel (upstream
|
|
47
|
+
# a86959e, issue #2293) is the six-provider CANONICAL producer: every
|
|
48
|
+
# record already carries the top-level `windows` map the Android parser
|
|
49
|
+
# reads, so the host boundary only injects the provider key and forwards
|
|
50
|
+
# the record unchanged. #2283's `short_term` / `long_term` translation for
|
|
51
|
+
# the older wheel is hard-cut (D22) — a legacy-shaped record now fails
|
|
52
|
+
# loudly. 0.0.15 is also the first published wheel that reports the `go`
|
|
53
|
+
# provider (OpenCode on the Go backend), so the OpenCode Go quota finally
|
|
54
|
+
# reaches the Usage panel; it is a provider of the pinned wheel, not a
|
|
55
|
+
# separate producer contract. 0.0.13 (#2195) historically added the `grok`
|
|
56
|
+
# provider; 0.0.11 (#1564) labeled each Codex window from its actual
|
|
57
|
+
# `limit_window_seconds`.
|
|
58
|
+
"quse==0.0.15",
|
|
56
59
|
# FCM HTTP v1 push delivery (#690): service-account OAuth2 bearer minting
|
|
57
60
|
# for `pocketshell push` / the `usage --capture` reset-push send. Imported
|
|
58
61
|
# lazily and fail-soft — a host without it (or without a Firebase
|
|
@@ -126,11 +129,16 @@ exclude = [
|
|
|
126
129
|
]
|
|
127
130
|
|
|
128
131
|
[tool.uv]
|
|
132
|
+
# CI and local frozen-sync checks use the same installer semantics. Bump this
|
|
133
|
+
# deliberately together with .github/workflows/tests.yml and validate the
|
|
134
|
+
# committed lock before merging.
|
|
135
|
+
required-version = "==0.10.11"
|
|
129
136
|
# Keep the lockfile's reproducibility cutoff project-local. The host may have
|
|
130
137
|
# a rolling global `exclude-newer` policy that predates deliberately pinned
|
|
131
|
-
# releases (quse 0.0.
|
|
132
|
-
# `uv.lock` options cutoff and must be used when checking this lock.
|
|
133
|
-
|
|
138
|
+
# releases (quse 0.0.15 and tmuxctl 0.3.5); this exact value is also the
|
|
139
|
+
# `uv.lock` options cutoff and must be used when checking this lock. Moved
|
|
140
|
+
# past quse 0.0.15's 2026-08-28T12:09:07Z PyPI upload for issue #2293.
|
|
141
|
+
exclude-newer = "2026-08-28T22:00:00Z"
|
|
134
142
|
|
|
135
143
|
[tool.pytest.ini_options]
|
|
136
144
|
testpaths = ["tests"]
|
|
@@ -58,6 +58,12 @@ from __future__ import annotations
|
|
|
58
58
|
import os
|
|
59
59
|
import re
|
|
60
60
|
import sys
|
|
61
|
+
import base64
|
|
62
|
+
import errno
|
|
63
|
+
import fcntl
|
|
64
|
+
import logging
|
|
65
|
+
import tempfile
|
|
66
|
+
from contextlib import contextmanager
|
|
61
67
|
from dataclasses import dataclass
|
|
62
68
|
from datetime import datetime, timezone
|
|
63
69
|
from pathlib import Path
|
|
@@ -67,6 +73,10 @@ import click
|
|
|
67
73
|
|
|
68
74
|
# Permissions for the per-session card file (owner-only, matches tree.py).
|
|
69
75
|
NEW_FILE_MODE = 0o600
|
|
76
|
+
_LOGGER = logging.getLogger(__name__)
|
|
77
|
+
_UNSUPPORTED_SYNC_ERRNOS = {errno.EINVAL, errno.ENOTSUP}
|
|
78
|
+
if hasattr(errno, "EOPNOTSUPP"):
|
|
79
|
+
_UNSUPPORTED_SYNC_ERRNOS.add(errno.EOPNOTSUPP)
|
|
70
80
|
|
|
71
81
|
# Default checklist card id used when ``--id`` is omitted: a session has exactly
|
|
72
82
|
# one "default" checklist unless the agent names them, which keeps the common
|
|
@@ -107,7 +117,7 @@ class CardPaths:
|
|
|
107
117
|
cards_dir: Path
|
|
108
118
|
|
|
109
119
|
def session_file(self, session: str) -> Path:
|
|
110
|
-
return self.cards_dir / f"{
|
|
120
|
+
return self.cards_dir / f"{_encode_session(session)}.yaml"
|
|
111
121
|
|
|
112
122
|
|
|
113
123
|
# Env override for the cards root, so tests (and a non-default deployment) can
|
|
@@ -136,16 +146,10 @@ def resolve_paths(
|
|
|
136
146
|
return CardPaths(cards_dir=base_home / ".pocketshell" / "cards")
|
|
137
147
|
|
|
138
148
|
|
|
139
|
-
def
|
|
140
|
-
"""
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
etc.; replace anything outside ``[A-Za-z0-9._-]`` so the name maps to one
|
|
144
|
-
flat file (no traversal, no nested dirs).
|
|
145
|
-
"""
|
|
146
|
-
safe = re.sub(r"[^A-Za-z0-9._-]+", "_", session.strip())
|
|
147
|
-
safe = safe.strip(".") or "session"
|
|
148
|
-
return safe
|
|
149
|
+
def _encode_session(session: str) -> str:
|
|
150
|
+
"""Reversibly encode a tmux session as one safe path segment."""
|
|
151
|
+
encoded = base64.urlsafe_b64encode(session.encode("utf-8")).decode("ascii")
|
|
152
|
+
return "s-" + encoded.rstrip("=")
|
|
149
153
|
|
|
150
154
|
|
|
151
155
|
def _ensure_dir(path: Path) -> None:
|
|
@@ -156,25 +160,90 @@ def _ensure_dir(path: Path) -> None:
|
|
|
156
160
|
pass
|
|
157
161
|
|
|
158
162
|
|
|
163
|
+
def _durability_barrier(fd: int, *, path: Path, kind: str) -> bool:
|
|
164
|
+
"""Fsync one file or directory, tolerating only unsupported barriers."""
|
|
165
|
+
while True:
|
|
166
|
+
try:
|
|
167
|
+
os.fsync(fd)
|
|
168
|
+
return True
|
|
169
|
+
except OSError as error:
|
|
170
|
+
if error.errno == errno.EINTR:
|
|
171
|
+
continue
|
|
172
|
+
if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
|
|
173
|
+
_LOGGER.warning(
|
|
174
|
+
"durability barrier unavailable for %s %s: %s; "
|
|
175
|
+
"continuing with atomic publication",
|
|
176
|
+
kind,
|
|
177
|
+
path,
|
|
178
|
+
error,
|
|
179
|
+
)
|
|
180
|
+
return False
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _fsync_directory(path: Path) -> bool:
|
|
185
|
+
"""Fsync a directory after rename when the filesystem supports it."""
|
|
186
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
187
|
+
try:
|
|
188
|
+
fd = os.open(str(path), flags)
|
|
189
|
+
except OSError as error:
|
|
190
|
+
if error.errno in _UNSUPPORTED_SYNC_ERRNOS:
|
|
191
|
+
_LOGGER.warning(
|
|
192
|
+
"durability barrier unavailable for directory %s: %s; "
|
|
193
|
+
"continuing with atomic publication",
|
|
194
|
+
path,
|
|
195
|
+
error,
|
|
196
|
+
)
|
|
197
|
+
return False
|
|
198
|
+
raise
|
|
199
|
+
try:
|
|
200
|
+
return _durability_barrier(fd, path=path, kind="directory")
|
|
201
|
+
finally:
|
|
202
|
+
os.close(fd)
|
|
203
|
+
|
|
204
|
+
|
|
159
205
|
def _write_private(path: Path, text: str) -> None:
|
|
160
|
-
"""
|
|
206
|
+
"""Publish ``text`` atomically with file and directory durability barriers."""
|
|
161
207
|
_ensure_dir(path.parent)
|
|
162
|
-
|
|
163
|
-
|
|
208
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
209
|
+
tmp = Path(tmp_name)
|
|
210
|
+
open_fd = fd
|
|
164
211
|
try:
|
|
165
|
-
with os.fdopen(fd, "
|
|
166
|
-
|
|
212
|
+
with os.fdopen(fd, "wb") as handle:
|
|
213
|
+
open_fd = -1
|
|
214
|
+
handle.write(text.encode("utf-8"))
|
|
215
|
+
handle.flush()
|
|
216
|
+
os.chmod(tmp, NEW_FILE_MODE)
|
|
217
|
+
_durability_barrier(handle.fileno(), path=tmp, kind="file")
|
|
218
|
+
os.replace(tmp, path)
|
|
219
|
+
_fsync_directory(path.parent)
|
|
167
220
|
except BaseException:
|
|
221
|
+
if open_fd >= 0:
|
|
222
|
+
try:
|
|
223
|
+
os.close(open_fd)
|
|
224
|
+
except OSError:
|
|
225
|
+
pass
|
|
168
226
|
try:
|
|
169
|
-
|
|
227
|
+
tmp.unlink()
|
|
170
228
|
except FileNotFoundError:
|
|
171
229
|
pass
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
172
232
|
raise
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@contextmanager
|
|
236
|
+
def _session_lock(path: Path):
|
|
237
|
+
"""Hold the cross-process advisory lock for one session document."""
|
|
238
|
+
_ensure_dir(path.parent)
|
|
239
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
240
|
+
fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, NEW_FILE_MODE)
|
|
241
|
+
with os.fdopen(fd, "a+") as lock:
|
|
242
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
243
|
+
try:
|
|
244
|
+
yield
|
|
245
|
+
finally:
|
|
246
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
178
247
|
|
|
179
248
|
|
|
180
249
|
# ---------------------------------------------------------------------------
|
|
@@ -427,9 +496,7 @@ def _import_yaml() -> Any:
|
|
|
427
496
|
return yaml
|
|
428
497
|
|
|
429
498
|
|
|
430
|
-
def
|
|
431
|
-
"""Return the session's list of cards (empty when none / unreadable)."""
|
|
432
|
-
path = paths.session_file(session)
|
|
499
|
+
def _read_cards_file(path: Path, session: str) -> list[dict[str, Any]]:
|
|
433
500
|
try:
|
|
434
501
|
raw = path.read_text(encoding="utf-8")
|
|
435
502
|
except (FileNotFoundError, IsADirectoryError, PermissionError):
|
|
@@ -443,14 +510,28 @@ def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
|
|
|
443
510
|
return []
|
|
444
511
|
if not isinstance(doc, Mapping):
|
|
445
512
|
return []
|
|
513
|
+
if doc.get("session") != session:
|
|
514
|
+
return []
|
|
446
515
|
cards = doc.get("cards")
|
|
447
516
|
if not isinstance(cards, list):
|
|
448
517
|
return []
|
|
449
518
|
return [dict(c) for c in cards if isinstance(c, Mapping)]
|
|
450
519
|
|
|
451
520
|
|
|
521
|
+
def read_cards(session: str, *, paths: CardPaths) -> list[dict[str, Any]]:
|
|
522
|
+
"""Return the session's list of cards (empty when none / unreadable)."""
|
|
523
|
+
return _read_cards_file(paths.session_file(session), session)
|
|
524
|
+
|
|
525
|
+
|
|
452
526
|
def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths) -> Path:
|
|
453
527
|
"""Atomically persist the session's card list as YAML. Returns the path."""
|
|
528
|
+
path = paths.session_file(session)
|
|
529
|
+
with _session_lock(path):
|
|
530
|
+
_write_cards_file(path, session, cards)
|
|
531
|
+
return path
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _write_cards_file(path: Path, session: str, cards: list[dict[str, Any]]) -> None:
|
|
454
535
|
yaml = _import_yaml()
|
|
455
536
|
doc = {
|
|
456
537
|
"schema": 1,
|
|
@@ -459,9 +540,22 @@ def write_cards(session: str, cards: list[dict[str, Any]], *, paths: CardPaths)
|
|
|
459
540
|
"cards": cards,
|
|
460
541
|
}
|
|
461
542
|
text = yaml.safe_dump(doc, sort_keys=False, default_flow_style=False)
|
|
462
|
-
path = paths.session_file(session)
|
|
463
543
|
_write_private(path, text)
|
|
464
|
-
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _mutate_cards(
|
|
547
|
+
session: str,
|
|
548
|
+
*,
|
|
549
|
+
paths: CardPaths,
|
|
550
|
+
mutation: Callable[[list[dict[str, Any]]], Any],
|
|
551
|
+
) -> tuple[Path, Any]:
|
|
552
|
+
"""Apply one read-modify-write transaction under the session lock."""
|
|
553
|
+
path = paths.session_file(session)
|
|
554
|
+
with _session_lock(path):
|
|
555
|
+
cards = _read_cards_file(path, session)
|
|
556
|
+
result = mutation(cards)
|
|
557
|
+
_write_cards_file(path, session, cards)
|
|
558
|
+
return path, result
|
|
465
559
|
|
|
466
560
|
|
|
467
561
|
# ---------------------------------------------------------------------------
|
|
@@ -513,11 +607,13 @@ def upsert_card(session: str, card: dict[str, Any], *, paths: CardPaths) -> Path
|
|
|
513
607
|
Replace preserves nothing of the old card — a re-push of a checklist by the
|
|
514
608
|
agent is a full replace (hard-cut semantics; the new list is authoritative).
|
|
515
609
|
"""
|
|
516
|
-
cards
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
610
|
+
def mutate(cards: list[dict[str, Any]]) -> None:
|
|
611
|
+
card_id = card["id"]
|
|
612
|
+
cards[:] = [c for c in cards if c.get("id") != card_id]
|
|
613
|
+
cards.append(card)
|
|
614
|
+
|
|
615
|
+
path, _ = _mutate_cards(session, paths=paths, mutation=mutate)
|
|
616
|
+
return path
|
|
521
617
|
|
|
522
618
|
|
|
523
619
|
def apply_interaction(
|
|
@@ -532,23 +628,20 @@ def apply_interaction(
|
|
|
532
628
|
Raises :class:`ValueError` when the card id is not found in the session, or
|
|
533
629
|
when the type's :meth:`apply_interaction` rejects the interaction.
|
|
534
630
|
"""
|
|
535
|
-
cards
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
target["state"] = new_state
|
|
550
|
-
target["updated_at"] = _now_iso()
|
|
551
|
-
write_cards(session, cards, paths=paths)
|
|
631
|
+
def mutate(cards: list[dict[str, Any]]) -> dict[str, Any]:
|
|
632
|
+
target = next((card for card in cards if card.get("id") == card_id), None)
|
|
633
|
+
if target is None:
|
|
634
|
+
raise ValueError(f"no card with id {card_id!r} in session {session!r}")
|
|
635
|
+
handler = get_card_type(target.get("type", ""))
|
|
636
|
+
if handler is None:
|
|
637
|
+
raise ValueError(f"card {card_id!r} has unknown type {target.get('type')!r}")
|
|
638
|
+
body = target.get("body", {}) if isinstance(target.get("body"), Mapping) else {}
|
|
639
|
+
state = target.get("state", {}) if isinstance(target.get("state"), Mapping) else {}
|
|
640
|
+
target["state"] = handler.apply_interaction(dict(body), dict(state), interaction)
|
|
641
|
+
target["updated_at"] = _now_iso()
|
|
642
|
+
return target
|
|
643
|
+
|
|
644
|
+
_, target = _mutate_cards(session, paths=paths, mutation=mutate)
|
|
552
645
|
return target
|
|
553
646
|
|
|
554
647
|
|
|
@@ -56,6 +56,7 @@ versions when available) without RPC parameters or command output.
|
|
|
56
56
|
from __future__ import annotations
|
|
57
57
|
|
|
58
58
|
import json
|
|
59
|
+
import fcntl
|
|
59
60
|
import logging
|
|
60
61
|
import os
|
|
61
62
|
import re
|
|
@@ -356,6 +357,13 @@ def resolve_pid_path(socket_path: Optional[Path] = None) -> Path:
|
|
|
356
357
|
return socket_path.with_suffix(".pid")
|
|
357
358
|
|
|
358
359
|
|
|
360
|
+
def resolve_lock_path(socket_path: Optional[Path] = None) -> Path:
|
|
361
|
+
"""Return the lifetime-ownership lock beside the daemon socket."""
|
|
362
|
+
if socket_path is None:
|
|
363
|
+
socket_path = resolve_socket_path()
|
|
364
|
+
return socket_path.with_suffix(".lock")
|
|
365
|
+
|
|
366
|
+
|
|
359
367
|
def _ensure_socket_dir(socket_path: Path) -> None:
|
|
360
368
|
"""Create the socket's parent dir with mode 0700 if missing."""
|
|
361
369
|
parent = socket_path.parent
|
|
@@ -890,6 +898,10 @@ class _RpcError(Exception):
|
|
|
890
898
|
self.data = data
|
|
891
899
|
|
|
892
900
|
|
|
901
|
+
class _DaemonAlreadyOwned(Exception):
|
|
902
|
+
"""Another daemon holds the socket's lifetime lock."""
|
|
903
|
+
|
|
904
|
+
|
|
893
905
|
class Daemon:
|
|
894
906
|
"""Unix-socket JSON-RPC 2.0 server for ``pocketshell`` subcommands.
|
|
895
907
|
|
|
@@ -922,6 +934,7 @@ class Daemon:
|
|
|
922
934
|
self._cache = _Cache(clock=clock)
|
|
923
935
|
self._clock = clock
|
|
924
936
|
self._server_sock: Optional[socket.socket] = None
|
|
937
|
+
self._lifecycle_lock: Optional[Any] = None
|
|
925
938
|
self._stop_event = threading.Event()
|
|
926
939
|
self._last_activity = self._clock()
|
|
927
940
|
|
|
@@ -934,9 +947,17 @@ class Daemon:
|
|
|
934
947
|
|
|
935
948
|
def _bind(self) -> socket.socket:
|
|
936
949
|
_ensure_socket_dir(self.socket_path)
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
950
|
+
lock_path = resolve_lock_path(self.socket_path)
|
|
951
|
+
lock_fd = os.open(str(lock_path), os.O_RDWR | os.O_CREAT, 0o600)
|
|
952
|
+
lock = os.fdopen(lock_fd, "a+")
|
|
953
|
+
try:
|
|
954
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
955
|
+
except BlockingIOError:
|
|
956
|
+
lock.close()
|
|
957
|
+
raise _DaemonAlreadyOwned
|
|
958
|
+
self._lifecycle_lock = lock
|
|
959
|
+
|
|
960
|
+
# Only the lifetime-lock owner may replace a proven stale endpoint.
|
|
940
961
|
try:
|
|
941
962
|
os.unlink(self.socket_path)
|
|
942
963
|
except FileNotFoundError:
|
|
@@ -948,9 +969,14 @@ class Daemon:
|
|
|
948
969
|
# process umask (no fchmod hook on AF_UNIX at bind time).
|
|
949
970
|
old_umask = os.umask(0o077)
|
|
950
971
|
try:
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
972
|
+
try:
|
|
973
|
+
sock.bind(str(self.socket_path))
|
|
974
|
+
finally:
|
|
975
|
+
os.umask(old_umask)
|
|
976
|
+
except BaseException:
|
|
977
|
+
sock.close()
|
|
978
|
+
self._release_lifecycle_lock()
|
|
979
|
+
raise
|
|
954
980
|
sock.listen(8)
|
|
955
981
|
# Defence in depth: chmod the socket explicitly in case some
|
|
956
982
|
# platform ignored the umask.
|
|
@@ -994,7 +1020,10 @@ class Daemon:
|
|
|
994
1020
|
Idle timeout 0 disables the idle exit (used by future systemd
|
|
995
1021
|
``Type=simple`` always-on mode).
|
|
996
1022
|
"""
|
|
997
|
-
|
|
1023
|
+
try:
|
|
1024
|
+
self._server_sock = self._bind()
|
|
1025
|
+
except _DaemonAlreadyOwned:
|
|
1026
|
+
return
|
|
998
1027
|
self._write_pid_file()
|
|
999
1028
|
self._last_activity = self._clock()
|
|
1000
1029
|
# Trap SIGTERM so `daemon stop` (and systemd Stop) shuts us
|
|
@@ -1057,6 +1086,17 @@ class Daemon:
|
|
|
1057
1086
|
self._server_sock = None
|
|
1058
1087
|
self._remove_socket_file()
|
|
1059
1088
|
self._remove_pid_file()
|
|
1089
|
+
self._release_lifecycle_lock()
|
|
1090
|
+
|
|
1091
|
+
def _release_lifecycle_lock(self) -> None:
|
|
1092
|
+
lock = self._lifecycle_lock
|
|
1093
|
+
if lock is None:
|
|
1094
|
+
return
|
|
1095
|
+
self._lifecycle_lock = None
|
|
1096
|
+
try:
|
|
1097
|
+
fcntl.flock(lock, fcntl.LOCK_UN)
|
|
1098
|
+
finally:
|
|
1099
|
+
lock.close()
|
|
1060
1100
|
|
|
1061
1101
|
# -- request handling ------------------------------------------------
|
|
1062
1102
|
|
|
@@ -1647,15 +1687,10 @@ def stop_daemon(
|
|
|
1647
1687
|
) -> bool:
|
|
1648
1688
|
"""Stop a running daemon and remove its socket.
|
|
1649
1689
|
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
2. Wait up to ``timeout`` seconds for the socket file to disappear.
|
|
1655
|
-
3. Fall through to the RPC ``daemon.shutdown`` method if the PID
|
|
1656
|
-
file is missing (legacy or test-spawned daemons).
|
|
1657
|
-
4. Always remove a stale socket file at the end so the next start
|
|
1658
|
-
has a clean slate.
|
|
1690
|
+
A responsive socket proves daemon identity, so shutdown always goes over
|
|
1691
|
+
RPC. A numeric PID file alone is never trusted: the OS may have reused a
|
|
1692
|
+
stale PID for an unrelated process. Stale paths are removed only while
|
|
1693
|
+
holding the same exclusive lifetime lock used by startup.
|
|
1659
1694
|
|
|
1660
1695
|
Returns ``True`` if a daemon was running and is now stopped,
|
|
1661
1696
|
``False`` if no daemon was running.
|
|
@@ -1663,41 +1698,45 @@ def stop_daemon(
|
|
|
1663
1698
|
socket_path = socket_path or resolve_socket_path()
|
|
1664
1699
|
pid_path = resolve_pid_path(socket_path)
|
|
1665
1700
|
|
|
1666
|
-
pid = read_pid(pid_path)
|
|
1667
1701
|
was_running = is_daemon_running(socket_path)
|
|
1668
1702
|
|
|
1669
|
-
if
|
|
1670
|
-
try:
|
|
1671
|
-
os.kill(pid, signal.SIGTERM)
|
|
1672
|
-
except ProcessLookupError:
|
|
1673
|
-
pid = None
|
|
1674
|
-
except PermissionError:
|
|
1675
|
-
pid = None
|
|
1676
|
-
|
|
1677
|
-
if was_running and pid is None:
|
|
1678
|
-
# No PID file or kill failed; try the RPC shutdown instead.
|
|
1703
|
+
if was_running:
|
|
1679
1704
|
try:
|
|
1680
1705
|
call("daemon.shutdown", socket_path=socket_path, timeout=timeout)
|
|
1681
1706
|
except (DaemonClientError, RuntimeError, OSError):
|
|
1682
1707
|
pass
|
|
1683
1708
|
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
time.
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1709
|
+
if was_running:
|
|
1710
|
+
# Give the proven daemon time to remove its owned paths. A stale,
|
|
1711
|
+
# unresponsive socket has nobody to wait for and proceeds directly to
|
|
1712
|
+
# ownership-locked cleanup below.
|
|
1713
|
+
deadline = time.monotonic() + timeout
|
|
1714
|
+
while time.monotonic() < deadline:
|
|
1715
|
+
if not socket_path.exists():
|
|
1716
|
+
break
|
|
1717
|
+
time.sleep(0.05)
|
|
1718
|
+
|
|
1719
|
+
# Clean stale metadata only if no daemon owns the lifecycle lock. Re-probe
|
|
1720
|
+
# after acquiring it so a concurrent startup cannot be mistaken for stale.
|
|
1721
|
+
_ensure_socket_dir(socket_path)
|
|
1722
|
+
lock_fd = os.open(str(resolve_lock_path(socket_path)), os.O_RDWR | os.O_CREAT, 0o600)
|
|
1723
|
+
lock = os.fdopen(lock_fd, "a+")
|
|
1697
1724
|
try:
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1725
|
+
try:
|
|
1726
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
1727
|
+
except BlockingIOError:
|
|
1728
|
+
return was_running
|
|
1729
|
+
if not is_daemon_running(socket_path):
|
|
1730
|
+
try:
|
|
1731
|
+
os.unlink(socket_path)
|
|
1732
|
+
except FileNotFoundError:
|
|
1733
|
+
pass
|
|
1734
|
+
try:
|
|
1735
|
+
pid_path.unlink()
|
|
1736
|
+
except FileNotFoundError:
|
|
1737
|
+
pass
|
|
1738
|
+
finally:
|
|
1739
|
+
lock.close()
|
|
1701
1740
|
|
|
1702
1741
|
return was_running
|
|
1703
1742
|
|