cdx-manager 0.18.4 → 0.18.6

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.
@@ -0,0 +1,48 @@
1
+ """What a newly created session inherits, once the user has said yes once.
2
+
3
+ Accepting agent alerts during `cdx tray install` has to mean two different
4
+ things, and only saying one of them is what made the feature feel broken: the
5
+ sessions that exist today get alerts, and the ones created tomorrow get them
6
+ too. Without the second, a user who accepted alerts finds their next session
7
+ silent and has no reason to suspect a per-session opt-in they never saw.
8
+
9
+ Stored beside the mute state rather than in the session file, because it is not
10
+ a property of any session: it is the answer to a question asked once, and a
11
+ session created later has to be able to read it.
12
+ """
13
+ import json
14
+ import os
15
+
16
+ STATE_VERSION = 1
17
+
18
+
19
+ def state_path(base_dir):
20
+ return os.path.join(base_dir, "tray", "defaults.json")
21
+
22
+
23
+ def alerts_default(base_dir):
24
+ """Whether a new supported session starts with alerts on.
25
+
26
+ Off unless explicitly accepted. Anything unreadable, unrecognised or from a
27
+ version this build does not know reads as off: inheriting an opt-in from a
28
+ file we cannot understand would enable provider hooks nobody agreed to.
29
+ """
30
+ try:
31
+ with open(state_path(base_dir), encoding="utf-8") as handle:
32
+ state = json.load(handle)
33
+ except (OSError, ValueError):
34
+ return False
35
+ if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
36
+ return False
37
+ return state.get("notify") is True
38
+
39
+
40
+ def set_alerts_default(base_dir, enabled):
41
+ """Record the answer, and report what it now is."""
42
+ path = state_path(base_dir)
43
+ os.makedirs(os.path.dirname(path), exist_ok=True)
44
+ temp = f"{path}.{os.getpid()}.tmp"
45
+ with open(temp, "w", encoding="utf-8") as handle:
46
+ json.dump({"version": STATE_VERSION, "notify": bool(enabled)}, handle, indent=2)
47
+ os.replace(temp, path)
48
+ return bool(enabled)
@@ -101,12 +101,18 @@ def tray_is_listening(base_dir, now=None):
101
101
 
102
102
  # --- the hook's side -------------------------------------------------------
103
103
 
104
- def publish(base_dir, title, message, kind="complete", now=None, event_id=None):
104
+ def publish(base_dir, title, message, kind="complete", now=None, event_id=None, details=None):
105
105
  """Hand one event to the tray. Returns True only if it was written.
106
106
 
107
107
  False means the caller must deliver it directly, so every failure here has
108
108
  to be a False and never an exception: the alternative is a provider hook
109
109
  that raises inside someone's agent turn.
110
+
111
+ `details` carries the same event as fields, so a companion does not have to
112
+ read a sentence to know which session it belongs to. It is written beside
113
+ the rendered title and message, never instead of them: a companion that
114
+ predates the fields ignores an unknown key and shows what it always showed,
115
+ and a newer one handed a malformed `details` still has the sentence.
110
116
  """
111
117
  now = time.time() if now is None else now
112
118
  try:
@@ -119,6 +125,8 @@ def publish(base_dir, title, message, kind="complete", now=None, event_id=None):
119
125
  "title": title,
120
126
  "message": message,
121
127
  }
128
+ if isinstance(details, dict) and details:
129
+ event["details"] = details
122
130
  path = events_path(base_dir)
123
131
  os.makedirs(os.path.dirname(path), exist_ok=True)
124
132
  line = json.dumps(event, ensure_ascii=False) + "\n"
@@ -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(base_dir, version, download=None, ledger_path=CHECKSUM_LEDGER, target=None, env=None, probe=None):
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
- update(base_dir, version, download=download, ledger_path=ledger_path, target=target, probe=probe)
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
- return {
298
+ outcome = {
275
299
  "aligned": False,
276
300
  "reason": str(error),
277
301
  "previous": state.get("cdx_version"),
278
302
  }
279
- return {"aligned": True, "previous": state.get("cdx_version"), "version": version}
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 = os.path.join(state_dir(base_dir), "companion.previous")
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
- # Only now is the previous one retired.
394
- shutil.rmtree(retired, ignore_errors=True)
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
- return {"state": state, "replaced": previous.get("cdx_version")}
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,163 @@
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 os
16
+ import subprocess
17
+ import time
18
+
19
+ from .logics_view import resolve_logics_manager
20
+ from .tray_plugins import ADAPTER_TIMEOUT_SECONDS, CARD_TTL_SECONDS
21
+
22
+ _PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
23
+
24
+
25
+ def logics_card(env=None, now=None, cache=None, runner=None, executable=None, directories=None):
26
+ """One card, or None when there is nothing truthful to show.
27
+
28
+ None covers every unhappy case on purpose — not installed, not a Logics
29
+ repository, a command that failed or timed out, output that is not the JSON
30
+ we expect. A card saying "Logics is unhappy" would be noise in a menu the
31
+ user opened to look at quota.
32
+ """
33
+ now = time.time() if now is None else now
34
+ cached = _from_cache(cache, now)
35
+ if cached is not None:
36
+ return cached
37
+ status = _status(env=env, runner=runner, executable=executable, directories=directories)
38
+ card = _card_from_status(status)
39
+ _to_cache(cache, now, card)
40
+ return card
41
+
42
+
43
+ def _from_cache(cache, now):
44
+ """A card is reused for its TTL, so a tray polling every 30 seconds does not
45
+ make Logics pay that rate. A manual refresh passes no cache and so bypasses
46
+ this entirely, which is what makes the refresh action mean something."""
47
+ if not isinstance(cache, dict):
48
+ return None
49
+ stamped = cache.get("logics")
50
+ if not isinstance(stamped, dict):
51
+ return None
52
+ if now - stamped.get("at", 0) > CARD_TTL_SECONDS:
53
+ return None
54
+ return stamped.get("card")
55
+
56
+
57
+ def _to_cache(cache, now, card):
58
+ if isinstance(cache, dict):
59
+ cache["logics"] = {"at": now, "card": card}
60
+
61
+
62
+ def _repository_root(directory):
63
+ directory = os.path.realpath(directory or "")
64
+ while directory and directory != os.path.dirname(directory):
65
+ if os.path.exists(os.path.join(directory, ".git")):
66
+ return directory
67
+ directory = os.path.dirname(directory)
68
+ return directory if os.path.exists(os.path.join(directory, ".git")) else None
69
+
70
+
71
+ def _status(env=None, runner=None, executable=None, directories=None):
72
+ """`logics-manager status --format json`, or None however it failed.
73
+
74
+ Resolved through CDX's own lookup rather than a path the tray knows: on a
75
+ Windows host serving CDX from WSL, the tray is not where `logics-manager`
76
+ lives, and the only process that can find it is this one.
77
+ """
78
+ executable = executable or resolve_logics_manager(env or {})
79
+ if not executable:
80
+ return None
81
+ roots = sorted({root for root in (_repository_root(path) for path in (directories or [])) if root})
82
+ if directories is not None and not roots:
83
+ return None
84
+ payloads = []
85
+ for root in roots or [None]:
86
+ try:
87
+ completed = (runner or subprocess.run)(
88
+ [executable, "status", "--format", "json"], capture_output=True,
89
+ text=True, timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
90
+ )
91
+ payload = json.loads(completed.stdout) if getattr(completed, "returncode", 1) == 0 else None
92
+ except (OSError, subprocess.SubprocessError, TypeError, ValueError):
93
+ return None if not payloads else payloads
94
+ if isinstance(payload, dict):
95
+ payloads.append((root, payload))
96
+ return payloads if roots else (payloads[0][1] if payloads else None)
97
+
98
+
99
+ def _card_from_status(status):
100
+ if isinstance(status, list):
101
+ cards = [(root, _card_from_status(payload), len(payload.get("blocked_docs") or [])) for root, payload in status]
102
+ cards = [(root, card, blocked) for root, card, blocked in cards if card]
103
+ if not cards:
104
+ return None
105
+ root, card, _ = sorted(cards, key=lambda item: (-item[2], -len(item[1]["rows"]), item[0] or ""))[0]
106
+ repository_count = len(cards)
107
+ blocked_count = sum(item[2] for item in cards)
108
+ card["summary"] = f"{repository_count} repositories · {blocked_count} blocked"
109
+ prefix = os.path.basename(root or "")
110
+ for row in card["rows"]:
111
+ row["label"] = f"{prefix}: {row['label']}"
112
+ return card
113
+ if not isinstance(status, dict):
114
+ return None
115
+ blocked = [doc for doc in (status.get("blocked_docs") or []) if isinstance(doc, dict)]
116
+ active = [task for task in (status.get("active_tasks") or []) if isinstance(task, dict)]
117
+ if not blocked and not active:
118
+ # Nothing blocked and nothing in flight is not a state worth a row. The
119
+ # summary still says so, because "0 blocked" is the good news a user
120
+ # opening the menu wants to confirm.
121
+ return {
122
+ "title": "Logics",
123
+ "summary": "Nothing blocked, nothing in progress",
124
+ "rows": [],
125
+ "actions": ["logics.open", "logics.refresh"],
126
+ }
127
+ rows = []
128
+ if blocked:
129
+ rows.append(_row(blocked[0], "blocked"))
130
+ if active:
131
+ rows.append(_row(_most_urgent(active), "next"))
132
+ return {
133
+ "title": "Logics",
134
+ "summary": f"{len(blocked)} blocked · {len(active)} in progress",
135
+ "rows": [row for row in rows if row],
136
+ "actions": ["logics.open", "logics.refresh"],
137
+ }
138
+
139
+
140
+ def _most_urgent(tasks):
141
+ """High before medium before low, and least finished first inside a priority.
142
+
143
+ Progress breaks the tie because a task at 90% needs a push and one at 0%
144
+ needs a decision, and the tray has room to point at one of them.
145
+ """
146
+ def rank(task):
147
+ priority = str(task.get("priority") or "").strip().lower()
148
+ return (_PRIORITY_ORDER.get(priority, len(_PRIORITY_ORDER)), _progress(task))
149
+
150
+ return sorted(tasks, key=rank)[0]
151
+
152
+
153
+ def _progress(task):
154
+ value = task.get("progress")
155
+ return value if isinstance(value, (int, float)) else 0
156
+
157
+
158
+ def _row(doc, kind):
159
+ ref = doc.get("ref")
160
+ title = doc.get("title") or ref
161
+ if not isinstance(ref, str) or not ref or not isinstance(title, str):
162
+ return None
163
+ 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, directories=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, directories=directories) if name == "logics" else 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, directories=None):
156
+ from .tray_logics import logics_card
157
+
158
+ return logics_card(env=env, now=now, cache=cache, directories=directories)
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}