pocketshell 0.3.11__tar.gz → 0.3.13__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.3.11 → pocketshell-0.3.13}/PKG-INFO +1 -1
- {pocketshell-0.3.11 → pocketshell-0.3.13}/pyproject.toml +1 -1
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/daemon.py +79 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/jobs.py +201 -26
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/sessions.py +86 -4
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_daemon.py +66 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_jobs.py +71 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_sessions.py +33 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/uv.lock +1 -1
- {pocketshell-0.3.11 → pocketshell-0.3.13}/.gitignore +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/README.md +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/__init__.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/__main__.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/agent_log.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/cli.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/env.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/hooks.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/logs.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/qr_share.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/repos.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/src/pocketshell/usage.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/__init__.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_agent_log.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_cli.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_env.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_hooks.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_logs.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_qr_share.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_repos.py +0 -0
- {pocketshell-0.3.11 → pocketshell-0.3.13}/tests/test_usage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pocketshell
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.13
|
|
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.3.
|
|
11
|
+
version = "0.3.13"
|
|
12
12
|
description = "Unified server-side Python utility for the PocketShell Android client."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -85,6 +85,12 @@ METHOD_TTLS: Mapping[str, float] = {
|
|
|
85
85
|
# rarely change minute-to-minute; the longer window keeps the
|
|
86
86
|
# Android picker fast without burning rate-limit quota.
|
|
87
87
|
"repos.list_remote": 300.0,
|
|
88
|
+
# tmuxctl-backed lists are relatively cheap, but Android may poll
|
|
89
|
+
# them from dashboards. Keep the window short so daemon-side
|
|
90
|
+
# mutations can invalidate immediately and external tmux changes are
|
|
91
|
+
# not hidden for long.
|
|
92
|
+
"sessions.list": 5.0,
|
|
93
|
+
"jobs.list": 5.0,
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
# Length-prefix is a 4-byte unsigned big-endian integer. ``struct``
|
|
@@ -460,6 +466,67 @@ def _repos_open_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
460
466
|
return _repos.daemon_handler_open(dict(params))
|
|
461
467
|
|
|
462
468
|
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
# Methods: sessions.* / jobs.*
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _sessions_list_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
475
|
+
"""Delegate ``sessions.list`` to the existing tmuxctl-backed wrapper."""
|
|
476
|
+
from pocketshell import sessions as _sessions
|
|
477
|
+
|
|
478
|
+
return _sessions.daemon_handler_list(dict(params))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _jobs_list_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
482
|
+
"""Delegate ``jobs.list`` to the existing tmuxctl-backed wrapper."""
|
|
483
|
+
from pocketshell import jobs as _jobs
|
|
484
|
+
|
|
485
|
+
return _jobs.daemon_handler_list(dict(params))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _jobs_show_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
489
|
+
"""Delegate ``jobs.show`` to the existing tmuxctl-backed wrapper."""
|
|
490
|
+
from pocketshell import jobs as _jobs
|
|
491
|
+
|
|
492
|
+
return _jobs.daemon_handler_show(dict(params))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _jobs_trigger_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
496
|
+
"""Delegate ``jobs.trigger`` to the existing tmuxctl-backed wrapper."""
|
|
497
|
+
from pocketshell import jobs as _jobs
|
|
498
|
+
|
|
499
|
+
return _jobs.daemon_handler_trigger(dict(params))
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _jobs_add_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
503
|
+
"""Delegate ``jobs.add`` to the existing tmuxctl-backed wrapper."""
|
|
504
|
+
from pocketshell import jobs as _jobs
|
|
505
|
+
|
|
506
|
+
return _jobs.daemon_handler_add(dict(params))
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _jobs_edit_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
510
|
+
"""Delegate ``jobs.edit`` to the existing tmuxctl-backed wrapper."""
|
|
511
|
+
from pocketshell import jobs as _jobs
|
|
512
|
+
|
|
513
|
+
return _jobs.daemon_handler_edit(dict(params))
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _jobs_remove_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
517
|
+
"""Delegate ``jobs.remove`` to the existing tmuxctl-backed wrapper."""
|
|
518
|
+
from pocketshell import jobs as _jobs
|
|
519
|
+
|
|
520
|
+
return _jobs.daemon_handler_remove(dict(params))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _jobs_status_handler(params: Mapping[str, Any]) -> dict[str, Any]:
|
|
524
|
+
"""Delegate ``jobs.status`` to the scheduler status helper."""
|
|
525
|
+
from pocketshell import jobs as _jobs
|
|
526
|
+
|
|
527
|
+
return _jobs.daemon_handler_status(dict(params))
|
|
528
|
+
|
|
529
|
+
|
|
463
530
|
# Single shared registry; tests can register additional methods via
|
|
464
531
|
# :meth:`Daemon.register_method` on a fresh instance without touching
|
|
465
532
|
# this dict.
|
|
@@ -469,6 +536,14 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
|
|
|
469
536
|
"repos.list_remote": _repos_list_remote_handler,
|
|
470
537
|
"repos.clone": _repos_clone_handler,
|
|
471
538
|
"repos.open": _repos_open_handler,
|
|
539
|
+
"sessions.list": _sessions_list_handler,
|
|
540
|
+
"jobs.list": _jobs_list_handler,
|
|
541
|
+
"jobs.show": _jobs_show_handler,
|
|
542
|
+
"jobs.trigger": _jobs_trigger_handler,
|
|
543
|
+
"jobs.add": _jobs_add_handler,
|
|
544
|
+
"jobs.edit": _jobs_edit_handler,
|
|
545
|
+
"jobs.remove": _jobs_remove_handler,
|
|
546
|
+
"jobs.status": _jobs_status_handler,
|
|
472
547
|
}
|
|
473
548
|
|
|
474
549
|
|
|
@@ -485,6 +560,10 @@ DEFAULT_METHODS: Mapping[str, RpcHandler] = {
|
|
|
485
560
|
# change as soon as a clone lands.
|
|
486
561
|
METHOD_CACHE_INVALIDATIONS: Mapping[str, tuple[str, ...]] = {
|
|
487
562
|
"repos.clone": ("repos.list_local",),
|
|
563
|
+
"jobs.add": ("jobs.list",),
|
|
564
|
+
"jobs.edit": ("jobs.list",),
|
|
565
|
+
"jobs.remove": ("jobs.list",),
|
|
566
|
+
"jobs.trigger": ("jobs.list",),
|
|
488
567
|
}
|
|
489
568
|
|
|
490
569
|
|
|
@@ -79,7 +79,7 @@ from __future__ import annotations
|
|
|
79
79
|
import shutil
|
|
80
80
|
import subprocess
|
|
81
81
|
import sys
|
|
82
|
-
from typing import Optional, Sequence
|
|
82
|
+
from typing import Any, Optional, Sequence
|
|
83
83
|
|
|
84
84
|
import click
|
|
85
85
|
|
|
@@ -146,6 +146,150 @@ def _run_tmuxctl(args: Sequence[str]) -> int:
|
|
|
146
146
|
return completed.returncode
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
def _run_tmuxctl_capture(args: Sequence[str]) -> dict[str, Any]:
|
|
150
|
+
"""Invoke ``tmuxctl`` and return a daemon-friendly raw envelope."""
|
|
151
|
+
tmuxctl_path = _resolve_tmuxctl_binary()
|
|
152
|
+
if tmuxctl_path is None:
|
|
153
|
+
return {
|
|
154
|
+
"stdout": "",
|
|
155
|
+
"stderr": _tmuxctl_missing_message() + "\n",
|
|
156
|
+
"returncode": 127,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
completed = subprocess.run(
|
|
160
|
+
[tmuxctl_path, *args],
|
|
161
|
+
check=False,
|
|
162
|
+
capture_output=True,
|
|
163
|
+
text=True,
|
|
164
|
+
)
|
|
165
|
+
return {
|
|
166
|
+
"stdout": completed.stdout,
|
|
167
|
+
"stderr": completed.stderr,
|
|
168
|
+
"returncode": completed.returncode,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _emit_envelope(ctx: click.Context, envelope: dict[str, Any]) -> None:
|
|
173
|
+
"""Proxy a daemon/subprocess envelope to stdout/stderr and exit code."""
|
|
174
|
+
if envelope.get("stdout"):
|
|
175
|
+
sys.stdout.write(str(envelope["stdout"]))
|
|
176
|
+
if envelope.get("stderr"):
|
|
177
|
+
sys.stderr.write(str(envelope["stderr"]))
|
|
178
|
+
exit_code = int(envelope.get("returncode", 0))
|
|
179
|
+
if exit_code != 0:
|
|
180
|
+
ctx.exit(exit_code)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _try_daemon_jobs_call(
|
|
184
|
+
method: str,
|
|
185
|
+
params: dict[str, Any],
|
|
186
|
+
*,
|
|
187
|
+
timeout: float = 5.0,
|
|
188
|
+
) -> Optional[dict[str, Any]]:
|
|
189
|
+
"""Dispatch a jobs RPC to the daemon; return ``None`` on miss/error."""
|
|
190
|
+
from pocketshell import daemon as _daemon
|
|
191
|
+
|
|
192
|
+
socket_path = _daemon.resolve_socket_path()
|
|
193
|
+
if not socket_path.exists():
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
result = _daemon.call(
|
|
198
|
+
method,
|
|
199
|
+
params=params,
|
|
200
|
+
socket_path=socket_path,
|
|
201
|
+
timeout=timeout,
|
|
202
|
+
)
|
|
203
|
+
except (_daemon.DaemonClientError, RuntimeError, OSError):
|
|
204
|
+
return None
|
|
205
|
+
if not isinstance(result, dict):
|
|
206
|
+
return None
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _extra_args(params: dict[str, Any]) -> list[str]:
|
|
211
|
+
raw = params.get("extra_args")
|
|
212
|
+
if not isinstance(raw, list):
|
|
213
|
+
return []
|
|
214
|
+
return [item for item in raw if isinstance(item, str)]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def daemon_handler_list(params: dict[str, Any]) -> dict[str, Any]:
|
|
218
|
+
"""JSON-RPC handler for ``jobs.list``."""
|
|
219
|
+
args: list[str] = ["jobs", "list"]
|
|
220
|
+
session = params.get("session")
|
|
221
|
+
if isinstance(session, str) and session:
|
|
222
|
+
args.extend(["--session", session])
|
|
223
|
+
args.extend(_extra_args(params))
|
|
224
|
+
return _run_tmuxctl_capture(args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def daemon_handler_show(params: dict[str, Any]) -> dict[str, Any]:
|
|
228
|
+
"""JSON-RPC handler for ``jobs.show``."""
|
|
229
|
+
job_id = params.get("job_id")
|
|
230
|
+
args: list[str] = ["jobs", "show", str(job_id)]
|
|
231
|
+
args.extend(_extra_args(params))
|
|
232
|
+
return _run_tmuxctl_capture(args)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def daemon_handler_trigger(params: dict[str, Any]) -> dict[str, Any]:
|
|
236
|
+
"""JSON-RPC handler for ``jobs.trigger``."""
|
|
237
|
+
job_id = params.get("job_id")
|
|
238
|
+
args: list[str] = ["jobs", "trigger", str(job_id)]
|
|
239
|
+
args.extend(_extra_args(params))
|
|
240
|
+
return _run_tmuxctl_capture(args)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def daemon_handler_add(params: dict[str, Any]) -> dict[str, Any]:
|
|
244
|
+
"""JSON-RPC handler for ``jobs.add``."""
|
|
245
|
+
session_name = params.get("session_name")
|
|
246
|
+
every = params.get("every")
|
|
247
|
+
args: list[str] = ["jobs", "add", str(session_name), "--every", str(every)]
|
|
248
|
+
message = params.get("message")
|
|
249
|
+
if isinstance(message, str):
|
|
250
|
+
args.extend(["--message", message])
|
|
251
|
+
if bool(params.get("start_now")):
|
|
252
|
+
args.append("--start-now")
|
|
253
|
+
args.extend(_extra_args(params))
|
|
254
|
+
return _run_tmuxctl_capture(args)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def daemon_handler_edit(params: dict[str, Any]) -> dict[str, Any]:
|
|
258
|
+
"""JSON-RPC handler for ``jobs.edit``."""
|
|
259
|
+
job_id = params.get("job_id")
|
|
260
|
+
args: list[str] = ["jobs", "edit", str(job_id)]
|
|
261
|
+
session = params.get("session")
|
|
262
|
+
if isinstance(session, str):
|
|
263
|
+
args.extend(["--session", session])
|
|
264
|
+
every = params.get("every")
|
|
265
|
+
if isinstance(every, str):
|
|
266
|
+
args.extend(["--every", every])
|
|
267
|
+
message = params.get("message")
|
|
268
|
+
if isinstance(message, str):
|
|
269
|
+
args.extend(["--message", message])
|
|
270
|
+
if bool(params.get("enable")):
|
|
271
|
+
args.append("--enable")
|
|
272
|
+
if bool(params.get("disable")):
|
|
273
|
+
args.append("--disable")
|
|
274
|
+
args.extend(_extra_args(params))
|
|
275
|
+
return _run_tmuxctl_capture(args)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def daemon_handler_remove(params: dict[str, Any]) -> dict[str, Any]:
|
|
279
|
+
"""JSON-RPC handler for ``jobs.remove``."""
|
|
280
|
+
job_id = params.get("job_id")
|
|
281
|
+
args: list[str] = ["jobs", "remove", str(job_id)]
|
|
282
|
+
args.extend(_extra_args(params))
|
|
283
|
+
return _run_tmuxctl_capture(args)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def daemon_handler_status(_params: dict[str, Any]) -> dict[str, Any]:
|
|
287
|
+
"""JSON-RPC handler for ``jobs.status``."""
|
|
288
|
+
if _is_daemon_running():
|
|
289
|
+
return {"stdout": "running\n", "stderr": "", "returncode": 0}
|
|
290
|
+
return {"stdout": "not running\n", "stderr": "", "returncode": 3}
|
|
291
|
+
|
|
292
|
+
|
|
149
293
|
@click.group(
|
|
150
294
|
name="jobs",
|
|
151
295
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
@@ -191,9 +335,13 @@ def jobs_list(ctx: click.Context, session: Optional[str]) -> None:
|
|
|
191
335
|
# `ctx.args` holds any extras we ignored (e.g. `--json` once
|
|
192
336
|
# tmuxctl supports it). Forward verbatim, position preserved.
|
|
193
337
|
args.extend(ctx.args)
|
|
194
|
-
|
|
195
|
-
if
|
|
196
|
-
|
|
338
|
+
params: dict[str, Any] = {"extra_args": list(ctx.args)}
|
|
339
|
+
if session:
|
|
340
|
+
params["session"] = session
|
|
341
|
+
envelope = _try_daemon_jobs_call("jobs.list", params)
|
|
342
|
+
if envelope is None:
|
|
343
|
+
envelope = _run_tmuxctl_capture(args)
|
|
344
|
+
_emit_envelope(ctx, envelope)
|
|
197
345
|
|
|
198
346
|
|
|
199
347
|
@jobs_group.command(
|
|
@@ -209,9 +357,13 @@ def jobs_list(ctx: click.Context, session: Optional[str]) -> None:
|
|
|
209
357
|
def jobs_show(ctx: click.Context, job_id: int) -> None:
|
|
210
358
|
"""Show details for one job (delegates to `tmuxctl jobs show`)."""
|
|
211
359
|
args: list[str] = ["jobs", "show", str(job_id), *ctx.args]
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
ctx.
|
|
360
|
+
envelope = _try_daemon_jobs_call(
|
|
361
|
+
"jobs.show",
|
|
362
|
+
{"job_id": job_id, "extra_args": list(ctx.args)},
|
|
363
|
+
)
|
|
364
|
+
if envelope is None:
|
|
365
|
+
envelope = _run_tmuxctl_capture(args)
|
|
366
|
+
_emit_envelope(ctx, envelope)
|
|
215
367
|
|
|
216
368
|
|
|
217
369
|
@jobs_group.command(
|
|
@@ -234,9 +386,13 @@ def jobs_trigger(ctx: click.Context, job_id: int) -> None:
|
|
|
234
386
|
brief calls for parity, not a reimplementation.
|
|
235
387
|
"""
|
|
236
388
|
args: list[str] = ["jobs", "trigger", str(job_id), *ctx.args]
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
ctx.
|
|
389
|
+
envelope = _try_daemon_jobs_call(
|
|
390
|
+
"jobs.trigger",
|
|
391
|
+
{"job_id": job_id, "extra_args": list(ctx.args)},
|
|
392
|
+
)
|
|
393
|
+
if envelope is None:
|
|
394
|
+
envelope = _run_tmuxctl_capture(args)
|
|
395
|
+
_emit_envelope(ctx, envelope)
|
|
240
396
|
|
|
241
397
|
|
|
242
398
|
# ----- jobs add / edit / remove --------------------------------------
|
|
@@ -294,9 +450,17 @@ def jobs_add(
|
|
|
294
450
|
if start_now:
|
|
295
451
|
args.append("--start-now")
|
|
296
452
|
args.extend(ctx.args)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
453
|
+
params: dict[str, Any] = {
|
|
454
|
+
"session_name": session_name,
|
|
455
|
+
"every": every,
|
|
456
|
+
"message": message,
|
|
457
|
+
"start_now": start_now,
|
|
458
|
+
"extra_args": list(ctx.args),
|
|
459
|
+
}
|
|
460
|
+
envelope = _try_daemon_jobs_call("jobs.add", params)
|
|
461
|
+
if envelope is None:
|
|
462
|
+
envelope = _run_tmuxctl_capture(args)
|
|
463
|
+
_emit_envelope(ctx, envelope)
|
|
300
464
|
|
|
301
465
|
|
|
302
466
|
@jobs_group.command(
|
|
@@ -374,9 +538,19 @@ def jobs_edit(
|
|
|
374
538
|
if disable:
|
|
375
539
|
args.append("--disable")
|
|
376
540
|
args.extend(ctx.args)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
541
|
+
params = {
|
|
542
|
+
"job_id": job_id,
|
|
543
|
+
"session": session,
|
|
544
|
+
"every": every,
|
|
545
|
+
"message": message,
|
|
546
|
+
"enable": enable,
|
|
547
|
+
"disable": disable,
|
|
548
|
+
"extra_args": list(ctx.args),
|
|
549
|
+
}
|
|
550
|
+
envelope = _try_daemon_jobs_call("jobs.edit", params)
|
|
551
|
+
if envelope is None:
|
|
552
|
+
envelope = _run_tmuxctl_capture(args)
|
|
553
|
+
_emit_envelope(ctx, envelope)
|
|
380
554
|
|
|
381
555
|
|
|
382
556
|
@jobs_group.command(
|
|
@@ -397,9 +571,13 @@ def jobs_remove(ctx: click.Context, job_id: int) -> None:
|
|
|
397
571
|
`tmuxctl jobs remove JOB_ID`.
|
|
398
572
|
"""
|
|
399
573
|
args: list[str] = ["jobs", "remove", str(job_id), *ctx.args]
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
ctx.
|
|
574
|
+
envelope = _try_daemon_jobs_call(
|
|
575
|
+
"jobs.remove",
|
|
576
|
+
{"job_id": job_id, "extra_args": list(ctx.args)},
|
|
577
|
+
)
|
|
578
|
+
if envelope is None:
|
|
579
|
+
envelope = _run_tmuxctl_capture(args)
|
|
580
|
+
_emit_envelope(ctx, envelope)
|
|
403
581
|
|
|
404
582
|
|
|
405
583
|
# ----- daemon subgroup ------------------------------------------------
|
|
@@ -493,13 +671,10 @@ def daemon_status(ctx: click.Context) -> None:
|
|
|
493
671
|
- 0 -> daemon process is alive
|
|
494
672
|
- 3 -> daemon process is NOT running
|
|
495
673
|
"""
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
# `systemctl is-active` returns 3 for `inactive`; matching that lets
|
|
501
|
-
# shell-script consumers `&& echo up || echo down` consistently.
|
|
502
|
-
ctx.exit(3)
|
|
674
|
+
envelope = _try_daemon_jobs_call("jobs.status", {})
|
|
675
|
+
if envelope is None:
|
|
676
|
+
envelope = daemon_handler_status({})
|
|
677
|
+
_emit_envelope(ctx, envelope)
|
|
503
678
|
|
|
504
679
|
|
|
505
680
|
@daemon_group.command("stop")
|
|
@@ -41,7 +41,7 @@ from __future__ import annotations
|
|
|
41
41
|
import shutil
|
|
42
42
|
import subprocess
|
|
43
43
|
import sys
|
|
44
|
-
from typing import Optional, Sequence
|
|
44
|
+
from typing import Any, Optional, Sequence
|
|
45
45
|
|
|
46
46
|
import click
|
|
47
47
|
|
|
@@ -98,6 +98,87 @@ def _run_tmuxctl(args: Sequence[str]) -> int:
|
|
|
98
98
|
return completed.returncode
|
|
99
99
|
|
|
100
100
|
|
|
101
|
+
def _run_tmuxctl_capture(args: Sequence[str]) -> dict[str, Any]:
|
|
102
|
+
"""Invoke ``tmuxctl`` and return a daemon-friendly raw envelope."""
|
|
103
|
+
tmuxctl_path = _resolve_tmuxctl_binary()
|
|
104
|
+
if tmuxctl_path is None:
|
|
105
|
+
return {
|
|
106
|
+
"stdout": "",
|
|
107
|
+
"stderr": _tmuxctl_missing_message() + "\n",
|
|
108
|
+
"returncode": 127,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
completed = subprocess.run(
|
|
112
|
+
[tmuxctl_path, *args],
|
|
113
|
+
check=False,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
)
|
|
117
|
+
return {
|
|
118
|
+
"stdout": completed.stdout,
|
|
119
|
+
"stderr": completed.stderr,
|
|
120
|
+
"returncode": completed.returncode,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _emit_envelope(ctx: click.Context, envelope: dict[str, Any]) -> None:
|
|
125
|
+
"""Proxy a daemon/subprocess envelope to stdout/stderr and exit code."""
|
|
126
|
+
if envelope.get("stdout"):
|
|
127
|
+
sys.stdout.write(str(envelope["stdout"]))
|
|
128
|
+
if envelope.get("stderr"):
|
|
129
|
+
sys.stderr.write(str(envelope["stderr"]))
|
|
130
|
+
exit_code = int(envelope.get("returncode", 0))
|
|
131
|
+
if exit_code != 0:
|
|
132
|
+
ctx.exit(exit_code)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _try_daemon_sessions_list(
|
|
136
|
+
*,
|
|
137
|
+
sort_by: Optional[str],
|
|
138
|
+
extra_args: Sequence[str],
|
|
139
|
+
) -> Optional[dict[str, Any]]:
|
|
140
|
+
"""Dispatch ``sessions.list`` to the daemon; return ``None`` on miss."""
|
|
141
|
+
from pocketshell import daemon as _daemon
|
|
142
|
+
|
|
143
|
+
socket_path = _daemon.resolve_socket_path()
|
|
144
|
+
if not socket_path.exists():
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
params: dict[str, Any] = {"extra_args": list(extra_args)}
|
|
148
|
+
if sort_by:
|
|
149
|
+
params["sort_by"] = sort_by
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
result = _daemon.call(
|
|
153
|
+
"sessions.list",
|
|
154
|
+
params=params,
|
|
155
|
+
socket_path=socket_path,
|
|
156
|
+
timeout=5.0,
|
|
157
|
+
)
|
|
158
|
+
except (_daemon.DaemonClientError, RuntimeError, OSError):
|
|
159
|
+
return None
|
|
160
|
+
if not isinstance(result, dict):
|
|
161
|
+
return None
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def daemon_handler_list(params: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
"""JSON-RPC handler for ``sessions.list``.
|
|
167
|
+
|
|
168
|
+
Returns the same raw stdout/stderr/returncode envelope as the
|
|
169
|
+
one-shot subprocess path so the CLI can preserve byte-identical
|
|
170
|
+
output while moving the process spawn into the daemon.
|
|
171
|
+
"""
|
|
172
|
+
args: list[str] = ["list"]
|
|
173
|
+
sort_by = params.get("sort_by")
|
|
174
|
+
if isinstance(sort_by, str) and sort_by:
|
|
175
|
+
args.extend(["--by", sort_by])
|
|
176
|
+
extra_args = params.get("extra_args")
|
|
177
|
+
if isinstance(extra_args, list):
|
|
178
|
+
args.extend(str(item) for item in extra_args if isinstance(item, str))
|
|
179
|
+
return _run_tmuxctl_capture(args)
|
|
180
|
+
|
|
181
|
+
|
|
101
182
|
@click.group(
|
|
102
183
|
name="sessions",
|
|
103
184
|
context_settings={"help_option_names": ["-h", "--help"]},
|
|
@@ -149,6 +230,7 @@ def sessions_list(ctx: click.Context, sort_by: Optional[str]) -> None:
|
|
|
149
230
|
# `ctx.args` holds any extras we ignored (e.g. `--json` once
|
|
150
231
|
# tmuxctl supports it). Forward verbatim, position preserved.
|
|
151
232
|
args.extend(ctx.args)
|
|
152
|
-
|
|
153
|
-
if
|
|
154
|
-
|
|
233
|
+
envelope = _try_daemon_sessions_list(sort_by=sort_by, extra_args=ctx.args)
|
|
234
|
+
if envelope is None:
|
|
235
|
+
envelope = _run_tmuxctl_capture(args)
|
|
236
|
+
_emit_envelope(ctx, envelope)
|
|
@@ -728,6 +728,72 @@ def test_cache_invalidate_method_drops_only_that_method() -> None:
|
|
|
728
728
|
assert cache.invalidate_method("repos.list_local") == 0
|
|
729
729
|
|
|
730
730
|
|
|
731
|
+
def _dispatch_in_memory(daemon: daemon_mod.Daemon, method: str, params: dict) -> dict:
|
|
732
|
+
"""Send one request through ``Daemon._handle_one`` using a socketpair."""
|
|
733
|
+
client, server = socket.socketpair()
|
|
734
|
+
try:
|
|
735
|
+
daemon_mod.send_json(
|
|
736
|
+
client,
|
|
737
|
+
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
|
738
|
+
)
|
|
739
|
+
daemon._handle_one(server)
|
|
740
|
+
response = daemon_mod.recv_json(client)
|
|
741
|
+
assert isinstance(response, dict)
|
|
742
|
+
return response
|
|
743
|
+
finally:
|
|
744
|
+
client.close()
|
|
745
|
+
server.close()
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def test_jobs_mutation_invalidates_jobs_list_cache(tmp_path: Path) -> None:
|
|
749
|
+
"""Successful job mutations evict the cached ``jobs.list`` envelope."""
|
|
750
|
+
calls = {"list": 0}
|
|
751
|
+
|
|
752
|
+
def list_handler(_params: dict) -> dict:
|
|
753
|
+
calls["list"] += 1
|
|
754
|
+
return {"stdout": f"list-{calls['list']}\n", "stderr": "", "returncode": 0}
|
|
755
|
+
|
|
756
|
+
def add_handler(_params: dict) -> dict:
|
|
757
|
+
return {"stdout": "created\n", "stderr": "", "returncode": 0}
|
|
758
|
+
|
|
759
|
+
daemon = daemon_mod.Daemon(
|
|
760
|
+
socket_path=tmp_path / "daemon.sock",
|
|
761
|
+
methods={"jobs.list": list_handler, "jobs.add": add_handler},
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
first = _dispatch_in_memory(daemon, "jobs.list", {})
|
|
765
|
+
assert first["result"]["stdout"] == "list-1\n"
|
|
766
|
+
assert first["cached"] is False
|
|
767
|
+
|
|
768
|
+
cached = _dispatch_in_memory(daemon, "jobs.list", {})
|
|
769
|
+
assert cached["result"]["stdout"] == "list-1\n"
|
|
770
|
+
assert cached["cached"] is True
|
|
771
|
+
assert calls["list"] == 1
|
|
772
|
+
|
|
773
|
+
mutation = _dispatch_in_memory(daemon, "jobs.add", {"session_name": "work"})
|
|
774
|
+
assert mutation["result"]["returncode"] == 0
|
|
775
|
+
|
|
776
|
+
after = _dispatch_in_memory(daemon, "jobs.list", {})
|
|
777
|
+
assert after["result"]["stdout"] == "list-2\n"
|
|
778
|
+
assert after["cached"] is False
|
|
779
|
+
assert calls["list"] == 2
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def test_daemon_registry_includes_sessions_and_jobs_methods() -> None:
|
|
783
|
+
assert "sessions.list" in daemon_mod.DEFAULT_METHODS
|
|
784
|
+
assert "jobs.list" in daemon_mod.DEFAULT_METHODS
|
|
785
|
+
assert "jobs.show" in daemon_mod.DEFAULT_METHODS
|
|
786
|
+
assert "jobs.trigger" in daemon_mod.DEFAULT_METHODS
|
|
787
|
+
assert "jobs.add" in daemon_mod.DEFAULT_METHODS
|
|
788
|
+
assert "jobs.edit" in daemon_mod.DEFAULT_METHODS
|
|
789
|
+
assert "jobs.remove" in daemon_mod.DEFAULT_METHODS
|
|
790
|
+
assert "jobs.status" in daemon_mod.DEFAULT_METHODS
|
|
791
|
+
assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.add"] == ("jobs.list",)
|
|
792
|
+
assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.edit"] == ("jobs.list",)
|
|
793
|
+
assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.remove"] == ("jobs.list",)
|
|
794
|
+
assert daemon_mod.METHOD_CACHE_INVALIDATIONS["jobs.trigger"] == ("jobs.list",)
|
|
795
|
+
|
|
796
|
+
|
|
731
797
|
def test_failed_usage_fetch_is_not_cached(
|
|
732
798
|
sandbox_socket: Path,
|
|
733
799
|
tmp_path: Path,
|
|
@@ -130,6 +130,39 @@ def test_jobs_list_forwards_to_tmuxctl_and_proxies_stdout() -> None:
|
|
|
130
130
|
assert invoked == ["/fake/tmuxctl", "jobs", "list"]
|
|
131
131
|
|
|
132
132
|
|
|
133
|
+
def test_jobs_list_uses_daemon_when_available() -> None:
|
|
134
|
+
payload = _fake_jobs_table()
|
|
135
|
+
runner = CliRunner()
|
|
136
|
+
with patch(
|
|
137
|
+
"pocketshell.jobs._try_daemon_jobs_call",
|
|
138
|
+
return_value={"stdout": payload, "stderr": "", "returncode": 0},
|
|
139
|
+
) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
|
|
140
|
+
result = runner.invoke(jobs_group, ["list", "--session", "work"])
|
|
141
|
+
assert result.exit_code == 0, result.output
|
|
142
|
+
assert result.output == payload
|
|
143
|
+
daemon_call.assert_called_once_with(
|
|
144
|
+
"jobs.list",
|
|
145
|
+
{"extra_args": [], "session": "work"},
|
|
146
|
+
)
|
|
147
|
+
run.assert_not_called()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_jobs_list_falls_back_when_daemon_misses() -> None:
|
|
151
|
+
payload = _fake_jobs_table()
|
|
152
|
+
runner = CliRunner()
|
|
153
|
+
with patch("pocketshell.jobs._try_daemon_jobs_call", return_value=None), patch(
|
|
154
|
+
"pocketshell.jobs._resolve_tmuxctl_binary", return_value="/fake/tmuxctl"
|
|
155
|
+
), patch(
|
|
156
|
+
"pocketshell.jobs.subprocess.run",
|
|
157
|
+
return_value=_fake_completed(stdout=payload),
|
|
158
|
+
) as run:
|
|
159
|
+
result = runner.invoke(jobs_group, ["list"])
|
|
160
|
+
assert result.exit_code == 0, result.output
|
|
161
|
+
assert result.output == payload
|
|
162
|
+
invoked: Sequence[str] = run.call_args.args[0]
|
|
163
|
+
assert invoked == ["/fake/tmuxctl", "jobs", "list"]
|
|
164
|
+
|
|
165
|
+
|
|
133
166
|
def test_jobs_list_forwards_unknown_options_verbatim() -> None:
|
|
134
167
|
"""The wrapper does NOT enumerate every flag `tmuxctl jobs list`
|
|
135
168
|
grows. The brief calls out a future `--json` flag in particular —
|
|
@@ -196,6 +229,31 @@ def test_jobs_trigger_forwards_id() -> None:
|
|
|
196
229
|
assert invoked == ["/fake/tmuxctl", "jobs", "trigger", "7"]
|
|
197
230
|
|
|
198
231
|
|
|
232
|
+
def test_jobs_mutation_uses_daemon_when_available() -> None:
|
|
233
|
+
runner = CliRunner()
|
|
234
|
+
with patch(
|
|
235
|
+
"pocketshell.jobs._try_daemon_jobs_call",
|
|
236
|
+
return_value={"stdout": "Created job 7\n", "stderr": "", "returncode": 0},
|
|
237
|
+
) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
|
|
238
|
+
result = runner.invoke(
|
|
239
|
+
jobs_group,
|
|
240
|
+
["add", "work", "--every", "15m", "--message", "poke claude"],
|
|
241
|
+
)
|
|
242
|
+
assert result.exit_code == 0, result.output
|
|
243
|
+
assert result.output == "Created job 7\n"
|
|
244
|
+
daemon_call.assert_called_once_with(
|
|
245
|
+
"jobs.add",
|
|
246
|
+
{
|
|
247
|
+
"session_name": "work",
|
|
248
|
+
"every": "15m",
|
|
249
|
+
"message": "poke claude",
|
|
250
|
+
"start_now": False,
|
|
251
|
+
"extra_args": [],
|
|
252
|
+
},
|
|
253
|
+
)
|
|
254
|
+
run.assert_not_called()
|
|
255
|
+
|
|
256
|
+
|
|
199
257
|
def test_jobs_trigger_proxies_unknown_subcommand_failure() -> None:
|
|
200
258
|
"""If `tmuxctl` has not yet implemented `jobs trigger`, the wrapper
|
|
201
259
|
must surface the failure unchanged rather than silently swallowing
|
|
@@ -600,6 +658,19 @@ def test_jobs_daemon_status_running_reports_zero() -> None:
|
|
|
600
658
|
assert invoked[2].startswith("(^|/)") or invoked[2].startswith("(")
|
|
601
659
|
|
|
602
660
|
|
|
661
|
+
def test_jobs_daemon_status_uses_daemon_when_available() -> None:
|
|
662
|
+
runner = CliRunner()
|
|
663
|
+
with patch(
|
|
664
|
+
"pocketshell.jobs._try_daemon_jobs_call",
|
|
665
|
+
return_value={"stdout": "running\n", "stderr": "", "returncode": 0},
|
|
666
|
+
) as daemon_call, patch("pocketshell.jobs.subprocess.run") as run:
|
|
667
|
+
result = runner.invoke(jobs_group, ["daemon", "status"])
|
|
668
|
+
assert result.exit_code == 0, result.output
|
|
669
|
+
assert result.output == "running\n"
|
|
670
|
+
daemon_call.assert_called_once_with("jobs.status", {})
|
|
671
|
+
run.assert_not_called()
|
|
672
|
+
|
|
673
|
+
|
|
603
674
|
def test_jobs_daemon_status_not_running_reports_three() -> None:
|
|
604
675
|
runner = CliRunner()
|
|
605
676
|
with patch("pocketshell.jobs.shutil.which", return_value="/fake/pgrep"), patch(
|
|
@@ -106,6 +106,39 @@ def test_sessions_list_forwards_to_tmuxctl_and_proxies_stdout() -> None:
|
|
|
106
106
|
assert invoked == ["/fake/tmuxctl", "list"]
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def test_sessions_list_uses_daemon_when_available() -> None:
|
|
110
|
+
payload = _fake_sessions_table()
|
|
111
|
+
runner = CliRunner()
|
|
112
|
+
with patch(
|
|
113
|
+
"pocketshell.sessions._try_daemon_sessions_list",
|
|
114
|
+
return_value={"stdout": payload, "stderr": "", "returncode": 0},
|
|
115
|
+
) as daemon_call, patch("pocketshell.sessions.subprocess.run") as run:
|
|
116
|
+
result = runner.invoke(sessions_group, ["list", "--by", "activity"])
|
|
117
|
+
assert result.exit_code == 0, result.output
|
|
118
|
+
assert result.output == payload
|
|
119
|
+
daemon_call.assert_called_once_with(sort_by="activity", extra_args=[])
|
|
120
|
+
run.assert_not_called()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_sessions_list_falls_back_when_daemon_misses() -> None:
|
|
124
|
+
payload = _fake_sessions_table()
|
|
125
|
+
runner = CliRunner()
|
|
126
|
+
with patch(
|
|
127
|
+
"pocketshell.sessions._try_daemon_sessions_list",
|
|
128
|
+
return_value=None,
|
|
129
|
+
), patch(
|
|
130
|
+
"pocketshell.sessions._resolve_tmuxctl_binary", return_value="/fake/tmuxctl"
|
|
131
|
+
), patch(
|
|
132
|
+
"pocketshell.sessions.subprocess.run",
|
|
133
|
+
return_value=_fake_completed(stdout=payload),
|
|
134
|
+
) as run:
|
|
135
|
+
result = runner.invoke(sessions_group, ["list"])
|
|
136
|
+
assert result.exit_code == 0, result.output
|
|
137
|
+
assert result.output == payload
|
|
138
|
+
invoked: Sequence[str] = run.call_args.args[0]
|
|
139
|
+
assert invoked == ["/fake/tmuxctl", "list"]
|
|
140
|
+
|
|
141
|
+
|
|
109
142
|
def test_sessions_list_forwards_by_activity() -> None:
|
|
110
143
|
runner = CliRunner()
|
|
111
144
|
with patch(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|