-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathplugin_registry.py
More file actions
211 lines (179 loc) · 7.71 KB
/
plugin_registry.py
File metadata and controls
211 lines (179 loc) · 7.71 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""
Plugin registry: discovery + listing of <deck-source>/plugins/.
Walks the plugins directory once at startup, validates each plugin
folder via plugins.load_plugin, and exposes a queryable map. Unlike
ProfileRegistry there's NO file-watch + hot reload here — plugins
are code, and Python's module/path-resolution semantics + cwd
sensitivity + arbitrary native dependency state make hot-reload
fraught. If the netrunner adds a new plugin or edits an existing
one, they restart the deck. (Plugins were not made to be hot-edited
the way profiles are.)
Pre-P2 of the retool (2026-05-03), plugins lived at <home>/plugins/.
P2 moved them into deck source so the brake hook's deck-source-write
protection prevents constructs from corrupting plugin code at the
filesystem layer; the registry's only responsibility for that move
was retargeting the directory pointer (callers supply the new path
at construction).
Lifecycle is one-shot: scan() at startup, no background work.
Failures during a plugin's load become 'scan_error' events; the
plugin doesn't make it into the registry but the rest still load.
This way a bad plugin can't take down the whole registry.
Public surface:
PluginEvent Notification of a per-plugin scan outcome.
PluginRegistry The registry itself; scan() / get() / all().
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
from plugins import Plugin, PluginValidationError, load_plugin
@dataclass(frozen=True)
class PluginEvent:
"""A single scan outcome.
Event kinds:
'loaded' — plugin loaded successfully (available or not)
'scan_error' — plugin folder failed to load; not registered
'scan_complete' — emitted after the scan finishes; lets
subscribers do a single re-render
"""
kind: str
name: str
plugin: Optional[Plugin] = None
error: Optional[str] = None
source_dir: Optional[Path] = None
PluginEventListener = Callable[[PluginEvent], None]
class PluginRegistry:
"""Disk-backed plugin map.
Lifecycle:
reg = PluginRegistry(path, bus=bus)
bus.subscribe(cb, filter=["plugin.*"])
reg.scan() # synchronous; populates the registry
...
reg.all() # -> list[Plugin], sorted by name
Reads:
reg.get(name) # -> Plugin | None
reg.all() # -> list[Plugin] (sorted by name)
reg.by_category() # -> dict[category, list[Plugin]] (sorted)
reg.available() # -> list[Plugin] where available=True
No async loop. Scan is one-shot at deck startup; if the
netrunner adds a plugin while the deck is running, they restart.
The complexity of safe hot-reload for arbitrary plugin code
isn't worth eating in v1 — and getting it wrong leaves dangling
Python module state in unpredictable shapes.
"""
def __init__(
self,
plugins_dir: Path,
*,
bus: Optional[object] = None,
) -> None:
self.plugins_dir = Path(plugins_dir)
# Bus is the only fan-out path (Phase 8 of the unified-event-
# stream slice retired the legacy `on_event=` callback +
# `_listeners` list + add_listener/remove_listener shims). bus
# may be None in standalone runs.
self.bus = bus
# Authoritative state
self._by_name: dict[str, Plugin] = {}
# ---- emission ----------------------------------------------------------
def _emit(self, event: PluginEvent) -> None:
# PluginEvent's `kind` field maps to the bus's dotted-namespace;
# scan_error escalates to warning severity. Per-callback
# exception isolation lives on the bus itself.
if self.bus is not None:
try:
from event_bus import DeckEvent
severity = (
"warning" if event.kind == "scan_error" else "info"
)
self.bus.publish(DeckEvent(
kind=f"plugin.{event.kind}",
source="plugin_registry",
severity=severity,
payload=event,
))
except Exception as exc:
print(
f"plugin_registry: bus publish error: {exc!r}",
file=sys.stderr,
)
# ---- read API ----------------------------------------------------------
def get(self, name: str) -> Optional[Plugin]:
return self._by_name.get(name)
def all(self) -> list[Plugin]:
return sorted(self._by_name.values(), key=lambda p: p.name)
def by_category(self) -> dict[str, list[Plugin]]:
groups: dict[str, list[Plugin]] = {}
for p in self._by_name.values():
groups.setdefault(p.category, []).append(p)
for cat in groups:
groups[cat].sort(key=lambda p: p.name)
return dict(sorted(groups.items()))
def available(self) -> list[Plugin]:
"""Subset of all() where requires checks passed. Daemon
system prompt should use this rather than all() so it
doesn't suggest plugins the netrunner can't actually run."""
return [p for p in self.all() if p.available]
# ---- lifecycle ---------------------------------------------------------
def scan(self) -> None:
"""Walk the plugins directory once. Idempotent — safe to
call twice (later calls rebuild from scratch). Creates the
directory if missing so the netrunner can drop a plugin in
and restart.
"""
try:
self.plugins_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"plugin_registry: could not create {self.plugins_dir}: "
f"{exc!r} — running with empty registry",
file=sys.stderr,
)
self._emit(PluginEvent(kind="scan_complete", name=""))
return
# Reset state — full rebuild, since we don't track per-folder
# mtimes (no hot reload anyway).
self._by_name.clear()
any_loaded = False
for entry in sorted(self.plugins_dir.iterdir()):
if not entry.is_dir():
continue
if entry.name.startswith("."):
continue
# Plugin name conflict (unlikely but possible if two
# folders declare the same `name` field): last-write-wins
# with a stderr warning. Same posture as profile registry.
try:
plugin = load_plugin(entry)
except PluginValidationError as exc:
self._emit(PluginEvent(
kind="scan_error",
name=entry.name,
error=str(exc),
source_dir=entry,
))
continue
existing = self._by_name.get(plugin.name)
if existing is not None:
print(
f"plugin_registry: warning: {entry.name} declares "
f"name={plugin.name!r}, already owned by "
f"{existing.source_dir} — last-write-wins, "
f"dropping previous owner",
file=sys.stderr,
)
self._by_name[plugin.name] = plugin
any_loaded = True
self._emit(PluginEvent(
kind="loaded",
name=plugin.name,
plugin=plugin,
source_dir=entry,
))
# Always emit scan_complete, even on empty registry — lets
# the TUI know to re-render the (possibly empty) Plugins
# section once instead of guessing.
self._emit(PluginEvent(
kind="scan_complete", name="",
))