cdx-manager 0.18.4 → 0.18.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -6
- package/VERSION +1 -0
- package/changelogs/CHANGELOGS_0_18_5.md +147 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +192 -12
- package/src/cli.py +24 -9
- package/src/cli_args.py +1 -1
- package/src/commands/maintenance.py +22 -13
- package/src/commands/tray.py +245 -6
- package/src/session_service.py +23 -1
- package/src/tray_contract.py +11 -2
- package/src/tray_defaults.py +48 -0
- package/src/tray_events.py +9 -1
- package/src/tray_install.py +101 -8
- package/src/tray_logics.py +141 -0
- package/src/tray_plugin_actions.py +62 -0
- package/src/tray_plugins.py +163 -0
- package/src/tray_restart.py +152 -0
- package/src/tray_terminal.py +80 -0
package/src/tray_install.py
CHANGED
|
@@ -251,32 +251,89 @@ def install(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, targe
|
|
|
251
251
|
return state
|
|
252
252
|
|
|
253
253
|
|
|
254
|
-
def align_companion(
|
|
254
|
+
def align_companion(
|
|
255
|
+
base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, env=None,
|
|
256
|
+
probe=None, spawn=None, stop=None, start=None,
|
|
257
|
+
):
|
|
255
258
|
"""Bring an installed companion up to the CDX release that just landed.
|
|
256
259
|
|
|
257
260
|
`item_081` AC2 asks for the two to stay aligned, and until now nothing did
|
|
258
261
|
it: `cdx update` moved CDX and left the companion behind, so the user had to
|
|
259
262
|
notice the drift and reinstall by hand.
|
|
260
263
|
|
|
264
|
+
A running companion is stopped first, replaced, and started again. Stopping
|
|
265
|
+
before touching anything on disk is not politeness: a running executable
|
|
266
|
+
cannot be replaced on Windows, and a directory holding one cannot reliably
|
|
267
|
+
be renamed either. It also means a companion that refuses to stop leaves the
|
|
268
|
+
files exactly as they were — the user keeps a working tray on the old
|
|
269
|
+
version, which is a far better outcome than a half-replaced one.
|
|
270
|
+
|
|
261
271
|
Returns a dict describing what happened, and never raises. The CDX update
|
|
262
272
|
has already succeeded by the time this runs — failing it because a companion
|
|
263
273
|
could not be replaced would undo a good outcome for a worse reason. Every
|
|
264
274
|
refusal is reported instead.
|
|
265
275
|
"""
|
|
276
|
+
from .tray_restart import start_companion, stop_running_companion
|
|
277
|
+
|
|
266
278
|
state = read_state(base_dir)
|
|
267
279
|
if not state:
|
|
268
280
|
return {"aligned": False, "reason": "no companion is installed"}
|
|
269
281
|
if state.get("cdx_version") == version:
|
|
270
282
|
return {"aligned": False, "reason": "already aligned"}
|
|
283
|
+
|
|
284
|
+
halted = (stop or stop_running_companion)(env=env)
|
|
285
|
+
if halted.get("was_running") and not halted.get("stopped"):
|
|
286
|
+
return {
|
|
287
|
+
"aligned": False,
|
|
288
|
+
"reason": halted.get("reason", "the running tray could not be stopped"),
|
|
289
|
+
"previous": state.get("cdx_version"),
|
|
290
|
+
}
|
|
291
|
+
|
|
271
292
|
try:
|
|
272
|
-
|
|
293
|
+
result = update(
|
|
294
|
+
base_dir, version,
|
|
295
|
+
download=download, ledger_path=ledger_path, target=target, probe=probe,
|
|
296
|
+
)
|
|
273
297
|
except TrayInstallError as error:
|
|
274
|
-
|
|
298
|
+
outcome = {
|
|
275
299
|
"aligned": False,
|
|
276
300
|
"reason": str(error),
|
|
277
301
|
"previous": state.get("cdx_version"),
|
|
278
302
|
}
|
|
279
|
-
|
|
303
|
+
# Nothing was replaced, so the companion we stopped is the one to bring
|
|
304
|
+
# back. Leaving the user with no tray because an update failed would be
|
|
305
|
+
# a worse outcome than the failure itself.
|
|
306
|
+
if halted.get("was_running"):
|
|
307
|
+
outcome["restarted"] = (start or start_companion)(
|
|
308
|
+
state["executable"], spawn=spawn, env=env,
|
|
309
|
+
).get("started", False)
|
|
310
|
+
return outcome
|
|
311
|
+
|
|
312
|
+
aligned = {"aligned": True, "previous": state.get("cdx_version"), "version": version}
|
|
313
|
+
if not halted.get("was_running"):
|
|
314
|
+
# Files updated, and no tray to bring back. Starting one the user had
|
|
315
|
+
# not started themselves would be deciding for them.
|
|
316
|
+
aligned["restarted"] = False
|
|
317
|
+
aligned["restart_reason"] = "no tray was running"
|
|
318
|
+
return aligned
|
|
319
|
+
|
|
320
|
+
started = (start or start_companion)(result["state"]["executable"], spawn=spawn, env=env)
|
|
321
|
+
if started.get("started"):
|
|
322
|
+
aligned["restarted"] = True
|
|
323
|
+
return aligned
|
|
324
|
+
|
|
325
|
+
# The replacement passed its probe and still did not come up here. Put back
|
|
326
|
+
# the one that was proven to work, and start that — a rollback that leaves
|
|
327
|
+
# no running tray has only done half the job.
|
|
328
|
+
aligned["restarted"] = False
|
|
329
|
+
aligned["restart_reason"] = started.get("reason", "the replacement did not start")
|
|
330
|
+
aligned["rolled_back"] = restore_previous(base_dir, result.get("previous_state"))
|
|
331
|
+
if aligned["rolled_back"]:
|
|
332
|
+
aligned["aligned"] = False
|
|
333
|
+
aligned["restarted"] = (start or start_companion)(
|
|
334
|
+
state["executable"], spawn=spawn, env=env,
|
|
335
|
+
).get("started", False)
|
|
336
|
+
return aligned
|
|
280
337
|
|
|
281
338
|
|
|
282
339
|
def _extract(archive, destination):
|
|
@@ -385,22 +442,58 @@ def update(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target
|
|
|
385
442
|
)
|
|
386
443
|
|
|
387
444
|
live = os.path.join(state_dir(base_dir), "companion")
|
|
388
|
-
retired =
|
|
445
|
+
retired = previous_dir(base_dir)
|
|
389
446
|
shutil.rmtree(retired, ignore_errors=True)
|
|
390
447
|
if os.path.isdir(live):
|
|
391
448
|
os.rename(live, retired)
|
|
392
449
|
os.rename(staged, live)
|
|
393
|
-
#
|
|
394
|
-
|
|
450
|
+
# The retired copy stays. It used to be deleted here, which left nothing to
|
|
451
|
+
# go back to the moment the replacement turned out not to start — and that
|
|
452
|
+
# is precisely when it is needed. The next update clears it, so at most one
|
|
453
|
+
# extra copy is ever on disk.
|
|
395
454
|
|
|
396
455
|
state = {
|
|
397
456
|
**fresh,
|
|
398
457
|
"executable": fresh["executable"].replace(staged, live, 1),
|
|
399
458
|
"paths": [live],
|
|
400
459
|
}
|
|
460
|
+
_record_state(base_dir, state)
|
|
461
|
+
return {"state": state, "replaced": previous.get("cdx_version"), "previous_state": previous}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def previous_dir(base_dir):
|
|
465
|
+
"""The last working companion, kept until the next update replaces it."""
|
|
466
|
+
return os.path.join(state_dir(base_dir), "companion.previous")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _record_state(base_dir, state):
|
|
401
470
|
with open(state_path(base_dir), "w", encoding="utf-8") as handle:
|
|
402
471
|
json.dump(state, handle, indent=2)
|
|
403
|
-
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def restore_previous(base_dir, previous_state):
|
|
475
|
+
"""Put back the companion that was proven to work.
|
|
476
|
+
|
|
477
|
+
Only called when the replacement failed to start, and it has to be able to
|
|
478
|
+
fail quietly itself: a rollback that raises would turn a bad update into an
|
|
479
|
+
unusable install, which is the one outcome worse than not updating.
|
|
480
|
+
"""
|
|
481
|
+
live = os.path.join(state_dir(base_dir), "companion")
|
|
482
|
+
retired = previous_dir(base_dir)
|
|
483
|
+
if not os.path.isdir(retired):
|
|
484
|
+
return False
|
|
485
|
+
try:
|
|
486
|
+
broken = os.path.join(state_dir(base_dir), "companion.rejected")
|
|
487
|
+
shutil.rmtree(broken, ignore_errors=True)
|
|
488
|
+
if os.path.isdir(live):
|
|
489
|
+
os.rename(live, broken)
|
|
490
|
+
os.rename(retired, live)
|
|
491
|
+
shutil.rmtree(broken, ignore_errors=True)
|
|
492
|
+
if previous_state:
|
|
493
|
+
_record_state(base_dir, previous_state)
|
|
494
|
+
return True
|
|
495
|
+
except OSError:
|
|
496
|
+
return False
|
|
404
497
|
|
|
405
498
|
|
|
406
499
|
def _probe(executable):
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The bundled Logics card: what is blocked, what is next, and nothing else.
|
|
2
|
+
|
|
3
|
+
Built from `logics-manager status --format json` and from nothing else. Not for
|
|
4
|
+
lack of richer sources — the repository is right there — but because a tray card
|
|
5
|
+
that read documents would be a second implementation of Logics' own view, drifting
|
|
6
|
+
from it at its own pace. One command, one contract, and when it changes the card
|
|
7
|
+
breaks loudly in one place instead of quietly in several.
|
|
8
|
+
|
|
9
|
+
Two rows at most, and the choice of which two is the whole design: the first
|
|
10
|
+
blocked document, because it is what stops work, and the highest-priority active
|
|
11
|
+
task, because it is what to do instead. A tray menu that listed everything would
|
|
12
|
+
be a worse `logics-manager status`, run more often.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from .logics_view import resolve_logics_manager
|
|
19
|
+
from .tray_plugins import ADAPTER_TIMEOUT_SECONDS, CARD_TTL_SECONDS
|
|
20
|
+
|
|
21
|
+
_PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def logics_card(env=None, now=None, cache=None, runner=None, executable=None):
|
|
25
|
+
"""One card, or None when there is nothing truthful to show.
|
|
26
|
+
|
|
27
|
+
None covers every unhappy case on purpose — not installed, not a Logics
|
|
28
|
+
repository, a command that failed or timed out, output that is not the JSON
|
|
29
|
+
we expect. A card saying "Logics is unhappy" would be noise in a menu the
|
|
30
|
+
user opened to look at quota.
|
|
31
|
+
"""
|
|
32
|
+
now = time.time() if now is None else now
|
|
33
|
+
cached = _from_cache(cache, now)
|
|
34
|
+
if cached is not None:
|
|
35
|
+
return cached
|
|
36
|
+
status = _status(env=env, runner=runner, executable=executable)
|
|
37
|
+
card = _card_from_status(status)
|
|
38
|
+
_to_cache(cache, now, card)
|
|
39
|
+
return card
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _from_cache(cache, now):
|
|
43
|
+
"""A card is reused for its TTL, so a tray polling every 30 seconds does not
|
|
44
|
+
make Logics pay that rate. A manual refresh passes no cache and so bypasses
|
|
45
|
+
this entirely, which is what makes the refresh action mean something."""
|
|
46
|
+
if not isinstance(cache, dict):
|
|
47
|
+
return None
|
|
48
|
+
stamped = cache.get("logics")
|
|
49
|
+
if not isinstance(stamped, dict):
|
|
50
|
+
return None
|
|
51
|
+
if now - stamped.get("at", 0) > CARD_TTL_SECONDS:
|
|
52
|
+
return None
|
|
53
|
+
return stamped.get("card")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _to_cache(cache, now, card):
|
|
57
|
+
if isinstance(cache, dict):
|
|
58
|
+
cache["logics"] = {"at": now, "card": card}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _status(env=None, runner=None, executable=None):
|
|
62
|
+
"""`logics-manager status --format json`, or None however it failed.
|
|
63
|
+
|
|
64
|
+
Resolved through CDX's own lookup rather than a path the tray knows: on a
|
|
65
|
+
Windows host serving CDX from WSL, the tray is not where `logics-manager`
|
|
66
|
+
lives, and the only process that can find it is this one.
|
|
67
|
+
"""
|
|
68
|
+
executable = executable or resolve_logics_manager(env or {})
|
|
69
|
+
if not executable:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
completed = (runner or subprocess.run)(
|
|
73
|
+
[executable, "status", "--format", "json"],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=ADAPTER_TIMEOUT_SECONDS,
|
|
77
|
+
check=False,
|
|
78
|
+
)
|
|
79
|
+
except (OSError, subprocess.SubprocessError):
|
|
80
|
+
return None
|
|
81
|
+
if getattr(completed, "returncode", 1) != 0:
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
payload = json.loads(completed.stdout)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
return payload if isinstance(payload, dict) else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _card_from_status(status):
|
|
91
|
+
if not isinstance(status, dict):
|
|
92
|
+
return None
|
|
93
|
+
blocked = [doc for doc in (status.get("blocked_docs") or []) if isinstance(doc, dict)]
|
|
94
|
+
active = [task for task in (status.get("active_tasks") or []) if isinstance(task, dict)]
|
|
95
|
+
if not blocked and not active:
|
|
96
|
+
# Nothing blocked and nothing in flight is not a state worth a row. The
|
|
97
|
+
# summary still says so, because "0 blocked" is the good news a user
|
|
98
|
+
# opening the menu wants to confirm.
|
|
99
|
+
return {
|
|
100
|
+
"title": "Logics",
|
|
101
|
+
"summary": "Nothing blocked, nothing in progress",
|
|
102
|
+
"rows": [],
|
|
103
|
+
"actions": ["logics.open", "logics.refresh"],
|
|
104
|
+
}
|
|
105
|
+
rows = []
|
|
106
|
+
if blocked:
|
|
107
|
+
rows.append(_row(blocked[0], "blocked"))
|
|
108
|
+
if active:
|
|
109
|
+
rows.append(_row(_most_urgent(active), "next"))
|
|
110
|
+
return {
|
|
111
|
+
"title": "Logics",
|
|
112
|
+
"summary": f"{len(blocked)} blocked · {len(active)} in progress",
|
|
113
|
+
"rows": [row for row in rows if row],
|
|
114
|
+
"actions": ["logics.open", "logics.refresh"],
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _most_urgent(tasks):
|
|
119
|
+
"""High before medium before low, and least finished first inside a priority.
|
|
120
|
+
|
|
121
|
+
Progress breaks the tie because a task at 90% needs a push and one at 0%
|
|
122
|
+
needs a decision, and the tray has room to point at one of them.
|
|
123
|
+
"""
|
|
124
|
+
def rank(task):
|
|
125
|
+
priority = str(task.get("priority") or "").strip().lower()
|
|
126
|
+
return (_PRIORITY_ORDER.get(priority, len(_PRIORITY_ORDER)), _progress(task))
|
|
127
|
+
|
|
128
|
+
return sorted(tasks, key=rank)[0]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _progress(task):
|
|
132
|
+
value = task.get("progress")
|
|
133
|
+
return value if isinstance(value, (int, float)) else 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _row(doc, kind):
|
|
137
|
+
ref = doc.get("ref")
|
|
138
|
+
title = doc.get("title") or ref
|
|
139
|
+
if not isinstance(ref, str) or not ref or not isinstance(title, str):
|
|
140
|
+
return None
|
|
141
|
+
return {"label": f"{kind}: {title}", "action": f"logics.focus:{ref}"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""What a card action is allowed to do.
|
|
2
|
+
|
|
3
|
+
The tray sends back an action id, never a command, and this is the only place
|
|
4
|
+
that turns one into behaviour. Everything an integration can cause the machine
|
|
5
|
+
to do is written here, in full, as a fixed vocabulary: refresh a card, open the
|
|
6
|
+
Logics viewer, or open it focused on one document.
|
|
7
|
+
|
|
8
|
+
The consequence worth stating: an adapter cannot extend this by producing a
|
|
9
|
+
different id. An id CDX does not recognise is refused, and refusal is the
|
|
10
|
+
default branch rather than the exception — so an integration gaining a new
|
|
11
|
+
capability is a change to this file, reviewed like any other.
|
|
12
|
+
"""
|
|
13
|
+
from .errors import CdxError
|
|
14
|
+
|
|
15
|
+
ACTION_UNKNOWN = "tray_plugin_action_unknown"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def perform_action(action, ctx):
|
|
19
|
+
"""Run one action id. Never raises for a bad id: it reports."""
|
|
20
|
+
if not isinstance(action, str) or "." not in action:
|
|
21
|
+
return _unknown(action)
|
|
22
|
+
plugin, rest = action.split(".", 1)
|
|
23
|
+
verb, _, reference = rest.partition(":")
|
|
24
|
+
if plugin != "logics":
|
|
25
|
+
return _unknown(action)
|
|
26
|
+
if verb == "refresh":
|
|
27
|
+
# The card is rebuilt by the next `cdx tray status --refresh`, which the
|
|
28
|
+
# tray issues itself. Nothing to do here beyond saying so, and saying so
|
|
29
|
+
# is what keeps the action id meaningful rather than silently ignored.
|
|
30
|
+
return {"ok": True, "code": None, "message": "The Logics card refreshes on the next tray refresh."}
|
|
31
|
+
if verb == "open":
|
|
32
|
+
return _open_viewer(ctx, None)
|
|
33
|
+
if verb == "focus" and reference:
|
|
34
|
+
return _open_viewer(ctx, reference)
|
|
35
|
+
return _unknown(action)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _open_viewer(ctx, reference):
|
|
39
|
+
from .logics_view import missing_logics_manager_failure, resolve_logics_manager, run_logics_viewer
|
|
40
|
+
|
|
41
|
+
executable = resolve_logics_manager(ctx.get("env") or {})
|
|
42
|
+
if not executable:
|
|
43
|
+
failure = missing_logics_manager_failure()
|
|
44
|
+
return {"ok": False, "code": failure["code"], "message": failure["message"]}
|
|
45
|
+
extra = ["--focus", reference] if reference else []
|
|
46
|
+
try:
|
|
47
|
+
run_logics_viewer(
|
|
48
|
+
executable, ctx.get("cwd"), env=ctx.get("env"),
|
|
49
|
+
extra_args=extra, runner=ctx.get("spawn_detached_runner"),
|
|
50
|
+
)
|
|
51
|
+
except (OSError, CdxError) as error:
|
|
52
|
+
return {"ok": False, "code": "logics_viewer_failed", "message": str(error)}
|
|
53
|
+
where = f" focused on {reference}" if reference else ""
|
|
54
|
+
return {"ok": True, "code": None, "message": f"Opened the Logics viewer{where}."}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _unknown(action):
|
|
58
|
+
return {
|
|
59
|
+
"ok": False,
|
|
60
|
+
"code": ACTION_UNKNOWN,
|
|
61
|
+
"message": f"CDX does not know the tray action {action!r}.",
|
|
62
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Cards the tray shows for tools other than CDX itself.
|
|
2
|
+
|
|
3
|
+
The shape of this is a deliberate refusal. A plugin system would mean discovery,
|
|
4
|
+
trust, packaging, versioning and a lifecycle — and there is exactly one
|
|
5
|
+
integration asking for it, which is not evidence that any of that is needed. So
|
|
6
|
+
there is no marketplace, no installation from a URL, no plugin-defined command,
|
|
7
|
+
and nothing runs inside the tray process. What exists is narrower and testable:
|
|
8
|
+
|
|
9
|
+
- **CDX owns the registry.** Adapters are functions in this repository, named
|
|
10
|
+
here. Nothing is discovered at runtime, so nothing unknown can run.
|
|
11
|
+
- **Adapters produce data, never behaviour.** A card is a summary, at most two
|
|
12
|
+
rows, and action ids drawn from a fixed vocabulary. An adapter cannot ask
|
|
13
|
+
the tray to run a command, because it cannot express one.
|
|
14
|
+
- **Failure is silence.** A slow, broken, absent or malformed adapter costs
|
|
15
|
+
its own card and nothing else. The session rows that are the reason the tray
|
|
16
|
+
exists are never at risk from an integration nobody depends on.
|
|
17
|
+
- **The tray executes nothing.** Cards ride the snapshot CDX already builds,
|
|
18
|
+
and every action goes back through CDX. `adr_005` put the transport there;
|
|
19
|
+
an integration is not a reason to open a second one.
|
|
20
|
+
"""
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
CARD_SCHEMA_MAJOR = 1
|
|
26
|
+
# How long a card stays usable before CDX asks its adapter again. The tray polls
|
|
27
|
+
# every 30 to 60 seconds and must not make an integration pay that rate, so a
|
|
28
|
+
# card outlives a poll and a manual refresh bypasses it.
|
|
29
|
+
CARD_TTL_SECONDS = 60
|
|
30
|
+
# What an adapter is allowed to spend. It runs inside `cdx tray status`, which a
|
|
31
|
+
# user is waiting on, so this is small on purpose.
|
|
32
|
+
ADAPTER_TIMEOUT_SECONDS = 5
|
|
33
|
+
|
|
34
|
+
_MAX_ROWS = 2
|
|
35
|
+
_MAX_TEXT = 80
|
|
36
|
+
# Action ids are `<plugin>.<verb>` or `<plugin>.<verb>:<ref>`, and the reference
|
|
37
|
+
# is restricted to what a Logics ref can contain. This is the whole reason an
|
|
38
|
+
# action cannot become a command: there is no character in here that a shell
|
|
39
|
+
# would treat as one.
|
|
40
|
+
_ACTION_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,15}\.[a-z][a-z0-9_]{0,15}(:[A-Za-z0-9_.-]{1,120})?$")
|
|
41
|
+
|
|
42
|
+
STATE_VERSION = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def state_path(base_dir):
|
|
46
|
+
return os.path.join(base_dir, "tray", "plugins.json")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enabled_plugins(base_dir):
|
|
50
|
+
"""The plugins the user turned on. Nothing is on by default.
|
|
51
|
+
|
|
52
|
+
An unreadable or unknown-version state file reads as nothing enabled: an
|
|
53
|
+
integration is opt-in, and inheriting one from a file we cannot parse is
|
|
54
|
+
the opposite of opt-in.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
with open(state_path(base_dir), encoding="utf-8") as handle:
|
|
58
|
+
state = json.load(handle)
|
|
59
|
+
except (OSError, ValueError):
|
|
60
|
+
return []
|
|
61
|
+
if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
|
|
62
|
+
return []
|
|
63
|
+
enabled = state.get("enabled")
|
|
64
|
+
return [name for name in enabled if name in ADAPTERS] if isinstance(enabled, list) else []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_plugin_enabled(base_dir, name, enabled):
|
|
68
|
+
"""Turn one plugin on or off, and report what is on afterwards."""
|
|
69
|
+
if name not in ADAPTERS:
|
|
70
|
+
raise KeyError(name)
|
|
71
|
+
current = set(enabled_plugins(base_dir))
|
|
72
|
+
current.add(name) if enabled else current.discard(name)
|
|
73
|
+
path = state_path(base_dir)
|
|
74
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
75
|
+
temp = f"{path}.{os.getpid()}.tmp"
|
|
76
|
+
with open(temp, "w", encoding="utf-8") as handle:
|
|
77
|
+
json.dump({"version": STATE_VERSION, "enabled": sorted(current)}, handle, indent=2)
|
|
78
|
+
os.replace(temp, path)
|
|
79
|
+
return sorted(current)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _text(value, limit=_MAX_TEXT):
|
|
83
|
+
"""Printable, single-line and bounded. A card is a menu row, not a document."""
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
return ""
|
|
86
|
+
value = " ".join("".join(c if c.isprintable() else " " for c in value).split())
|
|
87
|
+
return value[: limit - 1] + "…" if len(value) > limit else value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def bounded_card(raw, plugin):
|
|
91
|
+
"""One adapter's output, or None if it cannot be trusted as a card.
|
|
92
|
+
|
|
93
|
+
Everything is checked rather than assumed, including from an adapter in this
|
|
94
|
+
repository: the check is what lets the tray render a card without knowing
|
|
95
|
+
which adapter produced it, and a first-party bug is not less of a bug.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(raw, dict):
|
|
98
|
+
return None
|
|
99
|
+
title = _text(raw.get("title"))
|
|
100
|
+
summary = _text(raw.get("summary"))
|
|
101
|
+
if not title or not summary:
|
|
102
|
+
return None
|
|
103
|
+
rows = []
|
|
104
|
+
for row in (raw.get("rows") or [])[:_MAX_ROWS]:
|
|
105
|
+
if not isinstance(row, dict):
|
|
106
|
+
continue
|
|
107
|
+
label = _text(row.get("label"))
|
|
108
|
+
action = row.get("action")
|
|
109
|
+
if not label or not _valid_action(action, plugin):
|
|
110
|
+
continue
|
|
111
|
+
rows.append({"label": label, "action": action})
|
|
112
|
+
actions = [a for a in (raw.get("actions") or []) if _valid_action(a, plugin)]
|
|
113
|
+
return {
|
|
114
|
+
"plugin": plugin,
|
|
115
|
+
"schema": CARD_SCHEMA_MAJOR,
|
|
116
|
+
"title": title,
|
|
117
|
+
"summary": summary,
|
|
118
|
+
"rows": rows,
|
|
119
|
+
"actions": actions[:_MAX_ROWS],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _valid_action(action, plugin):
|
|
124
|
+
return (
|
|
125
|
+
isinstance(action, str)
|
|
126
|
+
and bool(_ACTION_PATTERN.match(action))
|
|
127
|
+
and action.split(".", 1)[0] == plugin
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None):
|
|
132
|
+
"""Every enabled plugin's card, bounded, with failures dropped.
|
|
133
|
+
|
|
134
|
+
Never raises. An adapter that throws, hangs past its timeout, returns
|
|
135
|
+
nonsense or is simply not installed contributes nothing, and the caller
|
|
136
|
+
cannot tell the difference — which is the point: the tray's own data must
|
|
137
|
+
not depend on how an integration is having its day.
|
|
138
|
+
"""
|
|
139
|
+
registry = ADAPTERS if adapters is None else adapters
|
|
140
|
+
cards = []
|
|
141
|
+
for name in enabled_plugins(base_dir):
|
|
142
|
+
adapter = registry.get(name)
|
|
143
|
+
if not adapter:
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
raw = adapter(env=env, now=now, cache=cache)
|
|
147
|
+
except Exception: # noqa: BLE001 - an integration cannot break the tray
|
|
148
|
+
continue
|
|
149
|
+
card = bounded_card(raw, name)
|
|
150
|
+
if card:
|
|
151
|
+
cards.append(card)
|
|
152
|
+
return cards
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _logics_adapter(env=None, now=None, cache=None):
|
|
156
|
+
from .tray_logics import logics_card
|
|
157
|
+
|
|
158
|
+
return logics_card(env=env, now=now, cache=cache)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# The registry. A name here is the only way an adapter can run, and adding one
|
|
162
|
+
# is a change to this repository — which is the trust model, stated as code.
|
|
163
|
+
ADAPTERS = {"logics": _logics_adapter}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Replacing a companion that is currently running.
|
|
2
|
+
|
|
3
|
+
`cdx update` used to leave the running tray on its old binary and tell the user
|
|
4
|
+
to quit it from its menu. That is a correct thing to say and a poor thing to
|
|
5
|
+
have to do: the whole point of the companion is that nobody thinks about it.
|
|
6
|
+
|
|
7
|
+
The order here is what makes it safe, and it is the reverse of what looks
|
|
8
|
+
natural. The running companion is asked to stop *before* anything on disk moves,
|
|
9
|
+
so the replacement never has to fight a live process for its own files — on
|
|
10
|
+
Windows a running executable cannot be replaced at all, and a directory holding
|
|
11
|
+
one cannot reliably be renamed either. Stop, replace, start, and if the start
|
|
12
|
+
fails, put back the one that was proven to work and start that instead.
|
|
13
|
+
|
|
14
|
+
Nothing here kills anything. A companion that does not stop within the bounded
|
|
15
|
+
wait is left alone and reported, because a tray the user is reading a menu from
|
|
16
|
+
is not a process to terminate over a version number.
|
|
17
|
+
"""
|
|
18
|
+
import os
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
from .tray_instance import _is_alive, companion_instance, lock_path
|
|
22
|
+
|
|
23
|
+
# How long to wait for a companion to notice the request and exit.
|
|
24
|
+
#
|
|
25
|
+
# It polls several times a second, so this is generous by two orders of
|
|
26
|
+
# magnitude — deliberately. On Windows the menu is modal: while the user has it
|
|
27
|
+
# open, the message pump is stopped and the companion notices nothing at all.
|
|
28
|
+
# Ten seconds covers reading a menu and closing it; beyond that, the honest
|
|
29
|
+
# answer is to leave it running and say so.
|
|
30
|
+
STOP_TIMEOUT_SECONDS = 10.0
|
|
31
|
+
_POLL_SECONDS = 0.1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stop_path(env=None):
|
|
35
|
+
"""Where a stop request is written. Beside the lock, by the same rule."""
|
|
36
|
+
return os.path.join(os.path.dirname(lock_path(env=env)), "companion.stop")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def request_stop(env=None):
|
|
40
|
+
"""Ask the running companion to exit. False when it could not be asked."""
|
|
41
|
+
path = stop_path(env=env)
|
|
42
|
+
try:
|
|
43
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
44
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
45
|
+
handle.write("stop")
|
|
46
|
+
return True
|
|
47
|
+
except OSError:
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clear_stop(env=None):
|
|
52
|
+
"""Remove a stop request nobody consumed.
|
|
53
|
+
|
|
54
|
+
A companion that had already died leaves the request on disk, and the next
|
|
55
|
+
one to start would read it and quit immediately — a tray that refuses to
|
|
56
|
+
stay open, with nothing on screen explaining why.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
os.remove(stop_path(env=env))
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def wait_for_exit(pid, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
|
|
65
|
+
"""Wait for one process to go, up to `timeout`. True if it did."""
|
|
66
|
+
sleep = time.sleep if sleep is None else sleep
|
|
67
|
+
alive = _is_alive if alive is None else alive
|
|
68
|
+
deadline = time.monotonic() + timeout
|
|
69
|
+
while True:
|
|
70
|
+
if not alive(pid):
|
|
71
|
+
return True
|
|
72
|
+
if time.monotonic() >= deadline:
|
|
73
|
+
return False
|
|
74
|
+
sleep(_POLL_SECONDS)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def stop_running_companion(env=None, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
|
|
78
|
+
"""Stop the companion if one is running, and say what happened.
|
|
79
|
+
|
|
80
|
+
`stopped` is True only when a live companion was asked and observed to go.
|
|
81
|
+
Everything else is a reason, and every reason is one the caller can act on
|
|
82
|
+
without guessing: nothing was running, the request could not be written, or
|
|
83
|
+
it did not exit in time.
|
|
84
|
+
"""
|
|
85
|
+
instance = companion_instance(env=env)
|
|
86
|
+
pid = instance.get("pid")
|
|
87
|
+
if not pid:
|
|
88
|
+
# A stale lock is not a running tray, but it is worth clearing the stop
|
|
89
|
+
# request that may be sitting beside it from an earlier attempt.
|
|
90
|
+
clear_stop(env=env)
|
|
91
|
+
return {"stopped": False, "was_running": False, "reason": "no tray companion is running"}
|
|
92
|
+
if not request_stop(env=env):
|
|
93
|
+
return {
|
|
94
|
+
"stopped": False,
|
|
95
|
+
"was_running": True,
|
|
96
|
+
"pid": pid,
|
|
97
|
+
"reason": "the stop request could not be written",
|
|
98
|
+
}
|
|
99
|
+
if not wait_for_exit(pid, timeout=timeout, sleep=sleep, alive=alive):
|
|
100
|
+
# It is still running and it still owns its binary. Withdrawing the
|
|
101
|
+
# request matters: leaving it would stop the companion later, at a
|
|
102
|
+
# moment nobody connected to an update.
|
|
103
|
+
clear_stop(env=env)
|
|
104
|
+
return {
|
|
105
|
+
"stopped": False,
|
|
106
|
+
"was_running": True,
|
|
107
|
+
"pid": pid,
|
|
108
|
+
"reason": (
|
|
109
|
+
f"the running tray did not stop within {timeout:g}s. "
|
|
110
|
+
"An open tray menu blocks it on Windows; close the menu and run: cdx tray launch"
|
|
111
|
+
),
|
|
112
|
+
}
|
|
113
|
+
clear_stop(env=env)
|
|
114
|
+
return {"stopped": True, "was_running": True, "pid": pid}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def start_companion(executable, spawn=None, verify=None, env=None, settle=None):
|
|
118
|
+
"""Start a companion and confirm it is actually running.
|
|
119
|
+
|
|
120
|
+
Spawning is not evidence. A binary that exits immediately — a missing
|
|
121
|
+
library, a quarantined bundle, a companion that reads a stale stop request —
|
|
122
|
+
leaves a successful spawn and no tray, which is exactly the "reported
|
|
123
|
+
success before the replacement is usable" this must not do.
|
|
124
|
+
"""
|
|
125
|
+
from .tray_install import launch_command
|
|
126
|
+
|
|
127
|
+
spawn = spawn or _spawn_detached
|
|
128
|
+
settle = time.sleep if settle is None else settle
|
|
129
|
+
try:
|
|
130
|
+
spawn(launch_command(executable))
|
|
131
|
+
except Exception as error: # noqa: BLE001 - any launch failure is a reason
|
|
132
|
+
return {"started": False, "reason": str(error)}
|
|
133
|
+
# It has to register its lock before it counts as running, and it does that
|
|
134
|
+
# a moment after exec.
|
|
135
|
+
for _ in range(20):
|
|
136
|
+
settle(_POLL_SECONDS)
|
|
137
|
+
instance = (verify or companion_instance)(env=env)
|
|
138
|
+
if instance.get("pid"):
|
|
139
|
+
return {"started": True, "pid": instance["pid"]}
|
|
140
|
+
return {"started": False, "reason": "it was launched but never registered as running"}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _spawn_detached(command):
|
|
144
|
+
import subprocess
|
|
145
|
+
|
|
146
|
+
subprocess.Popen( # noqa: S603 (path comes from our own install state)
|
|
147
|
+
command,
|
|
148
|
+
stdout=subprocess.DEVNULL,
|
|
149
|
+
stderr=subprocess.DEVNULL,
|
|
150
|
+
stdin=subprocess.DEVNULL,
|
|
151
|
+
start_new_session=True,
|
|
152
|
+
)
|