@passioncode-ai/fabric-agent-adapter 0.81.0 → 0.82.0
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/CHANGELOG.md +78 -0
- package/README.md +5 -3
- package/SECURITY.md +4 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
|
@@ -70,8 +70,9 @@ class HubError(Exception):
|
|
|
70
70
|
def listener_refusal(exc: Any) -> HubError:
|
|
71
71
|
"""The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
|
|
72
72
|
contract's state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted`
|
|
73
|
-
when this process's socket table hides other processes' sockets,
|
|
74
|
-
otherwise the owner's kind or the check's
|
|
73
|
+
when this process's socket table hides other processes' sockets, or, on macOS, holds no row for this connection
|
|
74
|
+
(DEC-0155 RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's
|
|
75
|
+
reason (`another-account`, or `no-row`, which only Linux and Windows give), retryable."""
|
|
75
76
|
owner = getattr(exc, "owner", None)
|
|
76
77
|
why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
|
|
77
78
|
reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
|
|
@@ -94,25 +95,37 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
|
|
|
94
95
|
return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
def
|
|
98
|
-
"""
|
|
98
|
+
def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
|
|
99
|
+
"""pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: OpenProcess with query access only. Refused
|
|
100
|
+
with ERROR_ACCESS_DENIED (5), another account's or an elevated process: None, as Node's process.kill(pid, 0)
|
|
101
|
+
answers EPERM there (FAA-304); refused otherwise, no process. `kernel` and `last_error` stand in for kernel32 and
|
|
102
|
+
ctypes.get_last_error in a test."""
|
|
103
|
+
import ctypes
|
|
104
|
+
if kernel is None:
|
|
105
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
|
106
|
+
last_error = ctypes.get_last_error # type: ignore[attr-defined]
|
|
107
|
+
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
108
|
+
if not handle:
|
|
109
|
+
return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
|
|
110
|
+
try:
|
|
111
|
+
code = ctypes.c_ulong()
|
|
112
|
+
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
113
|
+
finally:
|
|
114
|
+
kernel.CloseHandle(handle)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pid_alive(pid: int) -> Optional[bool]:
|
|
118
|
+
"""True: a process of this OS account runs under `pid`; False: none does. None (falsy): a process of another account
|
|
119
|
+
does (EPERM; on Windows, OpenProcess refused with access denied), as when the id hub.json names was reused: not
|
|
120
|
+
verified, never called running (FAA-304, the session hooks' answer since FAA-254). Never signals it."""
|
|
99
121
|
if pid <= 0:
|
|
100
122
|
return False
|
|
101
123
|
if sys.platform == "win32":
|
|
102
|
-
|
|
103
|
-
kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
104
|
-
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
105
|
-
if not handle:
|
|
106
|
-
return False
|
|
107
|
-
try:
|
|
108
|
-
code = ctypes.c_ulong()
|
|
109
|
-
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
110
|
-
finally:
|
|
111
|
-
kernel.CloseHandle(handle)
|
|
124
|
+
return _windows_pid_alive(pid)
|
|
112
125
|
try:
|
|
113
126
|
os.kill(pid, 0)
|
|
114
127
|
except PermissionError:
|
|
115
|
-
return
|
|
128
|
+
return None # another account's process under a reused id: not verified, and not this account's Fabric
|
|
116
129
|
except OSError:
|
|
117
130
|
return False
|
|
118
131
|
return True
|
|
@@ -142,7 +155,8 @@ def _read_small(path: Path) -> Optional[str]:
|
|
|
142
155
|
def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
|
|
143
156
|
now: Optional[float] = None) -> Dict[str, Any]:
|
|
144
157
|
"""`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
|
|
145
|
-
features}` or raises `HubError` (`not-running`, retryable; `not-a-hub
|
|
158
|
+
features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`, retryable when hub.json names a process of
|
|
159
|
+
another OS account, which is not verified, FAA-304). Reads no token. `estate` is
|
|
146
160
|
`{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
|
|
147
161
|
checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
|
|
148
162
|
root = Path(root) if root is not None else fabric_root()
|
|
@@ -174,7 +188,11 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
|
|
|
174
188
|
token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
|
|
175
189
|
if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
|
|
176
190
|
raise not_hub("names a door token outside the registry root")
|
|
177
|
-
|
|
191
|
+
live = alive(pid)
|
|
192
|
+
if live is None: # FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
|
|
193
|
+
raise HubError("not-a-hub", "Fabric's hub.json names process %d, which runs under another OS account: not "
|
|
194
|
+
"verified." % pid, True)
|
|
195
|
+
if not live:
|
|
178
196
|
raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
|
|
179
197
|
features = doc.get("features")
|
|
180
198
|
features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
"""One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
|
|
2
|
-
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
2
|
+
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
3
|
+
|
|
4
|
+
A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path beside the file
|
|
5
|
+
lock (an abstract unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in.
|
|
6
|
+
macOS has no such name: flock (and the Node twin's O_EXLOCK) lock an inode, so after a data import swaps the data
|
|
7
|
+
folder a second copy locks the new file and starts. There the holder watches its lock instead
|
|
8
|
+
(``InstanceLock.watch``, started by ``hold_single_instance``): every ``LOCK_WATCH_SECONDS`` it compares the inode it
|
|
9
|
+
locked with the file at the path, and once the path names another file it stops with 75 and one sentence naming the
|
|
10
|
+
path, before writing anything more into a folder that is no longer the one it read. Two copies then overlap for at
|
|
11
|
+
most one interval. A path-keyed name on macOS was weighed and left: it needs a file in a folder outside the data folder
|
|
12
|
+
that the system never cleans and every copy can write, which the standard library cannot promise."""
|
|
3
13
|
from __future__ import annotations
|
|
4
14
|
|
|
5
15
|
import errno
|
|
@@ -10,6 +20,7 @@ import posixpath
|
|
|
10
20
|
import re
|
|
11
21
|
import socket
|
|
12
22
|
import sys
|
|
23
|
+
import threading
|
|
13
24
|
import time
|
|
14
25
|
from pathlib import Path
|
|
15
26
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
@@ -46,6 +57,11 @@ LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
|
46
57
|
LOCK_BACKOFF_MAX_SECONDS = 30.0
|
|
47
58
|
|
|
48
59
|
|
|
60
|
+
#: How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's
|
|
61
|
+
#: path (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
|
|
62
|
+
LOCK_WATCH_SECONDS = 2.0
|
|
63
|
+
|
|
64
|
+
|
|
49
65
|
class AlreadyRunning(ServiceError):
|
|
50
66
|
def __init__(self, holder_pid: Optional[int], lock_path: Path):
|
|
51
67
|
self.holder_pid = holder_pid
|
|
@@ -67,6 +83,7 @@ class InstanceLock:
|
|
|
67
83
|
self.path = Path(data_dir) / "service.lock"
|
|
68
84
|
self._fd: Optional[int] = None
|
|
69
85
|
self._name: Any = None
|
|
86
|
+
self._watch: Optional[threading.Event] = None
|
|
70
87
|
|
|
71
88
|
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
72
89
|
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
@@ -157,7 +174,41 @@ class InstanceLock:
|
|
|
157
174
|
self._fd = fd
|
|
158
175
|
return True
|
|
159
176
|
|
|
177
|
+
def watch(self, on_lost: Optional[Callable[["InstanceLock"], None]] = None,
|
|
178
|
+
interval: float = LOCK_WATCH_SECONDS) -> "InstanceLock":
|
|
179
|
+
"""Re-check, every ``interval`` seconds on a daemon thread, that the file this copy locked is still the file at
|
|
180
|
+
the path (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second
|
|
181
|
+
copy made), call ``on_lost(lock)`` once and stop watching. The default stops the process with 75 and one
|
|
182
|
+
sentence (``stop_lost_holder``): it runs on the watch thread, so it cannot raise into the service. A path that
|
|
183
|
+
is gone or cannot be read now is read as nothing there yet and tried again next time: a second copy has to make
|
|
184
|
+
a file there to start, and the watch then sees that file. ``release`` ends the watch. Windows does not rename a
|
|
185
|
+
folder holding an open file: there it is a no-op, as for a lock not held."""
|
|
186
|
+
if WINDOWS or self._fd is None or self._watch is not None:
|
|
187
|
+
return self
|
|
188
|
+
fd, stop, react = self._fd, threading.Event(), on_lost or stop_lost_holder
|
|
189
|
+
|
|
190
|
+
def run() -> None:
|
|
191
|
+
while not stop.wait(interval):
|
|
192
|
+
try:
|
|
193
|
+
here, held = os.stat(str(self.path)), os.fstat(fd)
|
|
194
|
+
except OSError:
|
|
195
|
+
continue
|
|
196
|
+
# release() sets the event before it closes the descriptor, so a number reused after the close is never
|
|
197
|
+
# read as this lock's file: the event is checked after the stat.
|
|
198
|
+
if (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino) or stop.is_set():
|
|
199
|
+
continue
|
|
200
|
+
stop.set()
|
|
201
|
+
react(self)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
self._watch = stop
|
|
205
|
+
threading.Thread(target=run, name="fabric-lock-watch", daemon=True).start()
|
|
206
|
+
return self
|
|
207
|
+
|
|
160
208
|
def release(self) -> None:
|
|
209
|
+
if self._watch is not None:
|
|
210
|
+
self._watch.set()
|
|
211
|
+
self._watch = None
|
|
161
212
|
if self._fd is not None:
|
|
162
213
|
os.close(self._fd)
|
|
163
214
|
self._fd = None
|
|
@@ -166,6 +217,22 @@ class InstanceLock:
|
|
|
166
217
|
self._name = None
|
|
167
218
|
|
|
168
219
|
|
|
220
|
+
def lost_lock_message(lock_path: Path) -> str:
|
|
221
|
+
"""The one sentence a holder whose lock moved under it stops with (FAA-303)."""
|
|
222
|
+
return ("%s is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy "
|
|
223
|
+
"started on the folder now at that path runs alone." % lock_path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stop_lost_holder(lock: InstanceLock) -> None:
|
|
227
|
+
"""``InstanceLock.watch``'s default: print ``lost_lock_message`` and end the process with 75 at once. ``os._exit``,
|
|
228
|
+
not a graceful stop: a shutdown would write this copy's state into the folder now at the path, which is not the
|
|
229
|
+
one it read; the kit's journals make an abrupt stop recoverable (LC-03)."""
|
|
230
|
+
try:
|
|
231
|
+
print(lost_lock_message(lock.path), file=sys.stderr, flush=True)
|
|
232
|
+
finally:
|
|
233
|
+
os._exit(EXIT_ALREADY_RUNNING)
|
|
234
|
+
|
|
235
|
+
|
|
169
236
|
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
170
237
|
"""The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
|
|
171
238
|
if platform.startswith("linux"):
|
|
@@ -329,7 +396,8 @@ def add_supervisor_argument(parser: Any) -> Any:
|
|
|
329
396
|
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
330
397
|
sleep: Callable[[float], None] = time.sleep,
|
|
331
398
|
clock: Callable[[], float] = time.monotonic,
|
|
332
|
-
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None
|
|
399
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
400
|
+
on_lost: Optional[Callable[[InstanceLock], None]] = None) -> InstanceLock:
|
|
333
401
|
"""Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
|
|
334
402
|
|
|
335
403
|
Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
|
|
@@ -337,7 +405,10 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
337
405
|
exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
|
|
338
406
|
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
339
407
|
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
340
|
-
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
408
|
+
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
409
|
+
|
|
410
|
+
Where the lock has no path-keyed name (macOS) the held lock is watched (``InstanceLock.watch``, FAA-303): a folder
|
|
411
|
+
swapped under the running copy stops it with 75; ``on_lost`` replaces that reaction."""
|
|
341
412
|
marker = supervisor_marker(env, argv)
|
|
342
413
|
if marker["kind"] == "conflict":
|
|
343
414
|
print(marker_conflict_message(marker), file=sys.stderr)
|
|
@@ -349,7 +420,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
349
420
|
announced = False
|
|
350
421
|
while True:
|
|
351
422
|
try:
|
|
352
|
-
|
|
423
|
+
lock = InstanceLock(data_dir).acquire()
|
|
353
424
|
except AlreadyRunning as exc:
|
|
354
425
|
remaining = deadline - clock()
|
|
355
426
|
if remaining <= 0:
|
|
@@ -360,3 +431,5 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
360
431
|
announced = True
|
|
361
432
|
sleep(min(delay, remaining))
|
|
362
433
|
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
434
|
+
continue
|
|
435
|
+
return lock.watch(on_lost) if lock_name(lock.path) is None else lock
|