@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +5 -3
  3. package/SECURITY.md +4 -2
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +9 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +9 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +19 -1
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +239 -7
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +3 -3
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +3 -16
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +9 -1
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +22 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +11 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +19 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +7 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +22 -11
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +6 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +87 -20
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +29 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +3 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +1 -1
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +12 -4
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +3 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -138
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +239 -7
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +117 -19
  47. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  48. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +9 -1
  49. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +19 -1
  50. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  51. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
  52. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
  53. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
  54. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
  55. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
  56. 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, which never clears, so it is not retryable;
74
- otherwise the owner's kind or the check's reason (`another-account`, `no-row`), retryable as before."""
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 pid_alive(pid: int) -> bool:
98
- """Whether a process with this id runs as this user (Fabric `hub.ts#pidAlive`); never signals it."""
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
- import ctypes
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 False # another user's process under a reused id: not this user's Fabric
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`). Reads no token. `estate` is
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
- if not alive(pid):
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) -> InstanceLock:
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
- return InstanceLock(data_dir).acquire()
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