@passioncode-ai/fabric-agent-adapter 0.39.0 → 0.40.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 (115) hide show
  1. package/CHANGELOG.md +126 -1
  2. package/README.md +25 -17
  3. package/bin/fabric-agent-adapter.js +6 -6
  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 +13 -13
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  105. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,308 @@
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)."""
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import hashlib
7
+ import ntpath
8
+ import os
9
+ import posixpath
10
+ import re
11
+ import socket
12
+ import sys
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Callable, Dict, List, Optional, Sequence
16
+
17
+ import sys as _sys # noqa: E402
18
+ from pathlib import Path as _Path # noqa: E402
19
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
20
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
21
+ from fabric_base import (
22
+ SUPERVISOR_ENV,
23
+ ServiceError,
24
+ WINDOWS,
25
+ _O_BINARY,
26
+ )
27
+ from fabric_platform import (
28
+ _close_handle,
29
+ _create_first_pipe,
30
+ _pipe_exists,
31
+ )
32
+ from fabric_paths import (
33
+ ensure_private_dir,
34
+ )
35
+
36
+
37
+ EXIT_ALREADY_RUNNING = 75
38
+
39
+
40
+ LOCK_WAIT_SUPERVISED_SECONDS = 300.0
41
+
42
+
43
+ LOCK_BACKOFF_FIRST_SECONDS = 0.5
44
+
45
+
46
+ LOCK_BACKOFF_MAX_SECONDS = 30.0
47
+
48
+
49
+ class AlreadyRunning(ServiceError):
50
+ def __init__(self, holder_pid: Optional[int], lock_path: Path):
51
+ self.holder_pid = holder_pid
52
+ self.lock_path = lock_path
53
+ # FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
54
+ who = "process %d" % holder_pid if holder_pid else "an unknown process"
55
+ super().__init__("Another copy is already running (%s holds %s)." % (who, lock_path))
56
+
57
+
58
+ class InstanceLock:
59
+ """Exclusive flock on <data>/service.lock, taken BEFORE any side effect.
60
+
61
+ Binding a port is not a lock: a second copy that resumes jobs before its
62
+ bind fails has already done damage. The lock is released by the kernel when
63
+ the process dies, so a crash never leaves it stale.
64
+ """
65
+
66
+ def __init__(self, data_dir: Path):
67
+ self.path = Path(data_dir) / "service.lock"
68
+ self._fd: Optional[int] = None
69
+ self._name: Any = None
70
+
71
+ #: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
72
+ #: holder may rewrite the pid and another process may still read it (locking past the end of
73
+ #: a file is allowed there).
74
+ WINDOWS_LOCK_OFFSET = 1 << 30
75
+
76
+ def acquire(self) -> "InstanceLock":
77
+ ensure_private_dir(self.path.parent)
78
+ fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | _O_BINARY, 0o600)
79
+ try:
80
+ if WINDOWS:
81
+ import msvcrt
82
+
83
+ os.lseek(fd, self.WINDOWS_LOCK_OFFSET, os.SEEK_SET)
84
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
85
+ os.lseek(fd, 0, os.SEEK_SET)
86
+ else:
87
+ import fcntl
88
+
89
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
90
+ except OSError as exc:
91
+ os.close(fd)
92
+ if exc.errno in (errno.EWOULDBLOCK, errno.EAGAIN, errno.EACCES, getattr(errno, "EDEADLOCK", -1)):
93
+ raise AlreadyRunning(_read_pid(self.path), self.path) from None
94
+ raise
95
+ try:
96
+ self._name = _hold_lock_name(self.path)
97
+ except BaseException:
98
+ os.close(fd)
99
+ raise
100
+ os.ftruncate(fd, 0)
101
+ os.write(fd, str(os.getpid()).encode())
102
+ os.fsync(fd)
103
+ self._fd = fd
104
+ return self
105
+
106
+ def release(self) -> None:
107
+ if self._fd is not None:
108
+ os.close(self._fd)
109
+ self._fd = None
110
+ if self._name is not None:
111
+ _drop_lock_name(self._name)
112
+ self._name = None
113
+
114
+
115
+ def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
116
+ """The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
117
+ if platform.startswith("linux"):
118
+ digest = hashlib.sha256(posixpath.abspath(str(lock_path)).encode()).hexdigest()[:32]
119
+ return "\0fabric-service-" + digest
120
+ if platform == "win32":
121
+ # Windows paths compare without case: C:\Users and c:\users are one lock.
122
+ digest = hashlib.sha256(ntpath.normcase(ntpath.abspath(str(lock_path))).encode()).hexdigest()[:32]
123
+ return "\\\\.\\pipe\\fabric-service-" + digest
124
+ return None
125
+
126
+
127
+ def _hold_lock_name(lock_path: Path) -> Any:
128
+ name = lock_name(lock_path)
129
+ if name is None:
130
+ return None
131
+ if WINDOWS:
132
+ handle = _create_first_pipe(name)
133
+ if handle is None:
134
+ raise AlreadyRunning(_read_pid(lock_path), lock_path)
135
+ return handle
136
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
137
+ try:
138
+ sock.bind(name)
139
+ except OSError as exc:
140
+ sock.close()
141
+ if exc.errno == errno.EADDRINUSE:
142
+ raise AlreadyRunning(_read_pid(lock_path), lock_path) from None
143
+ raise
144
+ # Held by its descriptor, like the file lock: a socket object would close when collected.
145
+ return sock.detach()
146
+
147
+
148
+ def _drop_lock_name(held: Any) -> None:
149
+ if WINDOWS:
150
+ _close_handle(held)
151
+ else:
152
+ os.close(held)
153
+
154
+
155
+ def lock_name_held(lock_path: Path) -> Optional[bool]:
156
+ """True when some process holds the lock's name (a running service of either kit); None where there is no name
157
+ to hold. It only looks — /proc/net/unix on Linux, WaitNamedPipeW on Windows — and never takes the name, so a
158
+ probe cannot make a service that is starting at that moment fail."""
159
+ name = lock_name(lock_path)
160
+ if name is None:
161
+ return None
162
+ if WINDOWS:
163
+ return _pipe_exists(name)
164
+ try:
165
+ with open("/proc/net/unix", encoding="utf-8", errors="replace") as table:
166
+ return any(line.rstrip("\n").endswith(" @" + name[1:]) for line in table)
167
+ except OSError:
168
+ return None
169
+
170
+
171
+ def _read_pid(path: Path) -> Optional[int]:
172
+ try:
173
+ text = path.read_text().strip()
174
+ return int(text) if re.fullmatch(r"[0-9]{1,10}", text, re.ASCII) else None
175
+ except OSError:
176
+ return None
177
+
178
+
179
+ #: The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node.
180
+ SUPERVISORS = ("launchd", "systemd", "task-scheduler", "node")
181
+
182
+
183
+ #: The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action).
184
+ SUPERVISOR_ARG = "--fabric-supervisor"
185
+
186
+
187
+ def _marker_arguments(argv: Sequence[Any]) -> List[str]:
188
+ """The marker values an argument vector carries after the executable and before ``--``. A bare
189
+ ``--fabric-supervisor`` followed by nothing, by ``--`` or by another ``--…`` flag is a marker with an empty value
190
+ and consumes nothing."""
191
+ values: List[str] = []
192
+ index = 1
193
+ while index < len(argv):
194
+ item = argv[index]
195
+ if item == "--":
196
+ break
197
+ if item == SUPERVISOR_ARG:
198
+ following = argv[index + 1] if index + 1 < len(argv) else None
199
+ if isinstance(following, str) and not following.startswith("--"):
200
+ values.append(following)
201
+ index += 1
202
+ else:
203
+ values.append("")
204
+ elif isinstance(item, str) and item.startswith(SUPERVISOR_ARG + "="):
205
+ values.append(item[len(SUPERVISOR_ARG) + 1:])
206
+ index += 1
207
+ return values
208
+
209
+
210
+ def supervisor_marker(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> Dict[str, Any]:
211
+ """How this copy was started (DEC-0057), from ``FABRIC_SERVICE_SUPERVISOR=<manager>`` and
212
+ ``--fabric-supervisor <manager>`` / ``--fabric-supervisor=<manager>`` (``env`` and ``argv`` default to this process's):
213
+
214
+ - ``{"kind": "supervised", "manager", "forms"}`` — every marker present names the same manager of SUPERVISORS;
215
+ - ``{"kind": "conflict", "values", "forms"}`` — the markers disagree: the copy is unsupervised and exits 75 at once,
216
+ before it takes the lock (an empty or bare argument beside a variable disagrees with it);
217
+ - ``{"kind": "manual", "forms", "unreadable"?}`` — no marker (only an empty variable counts as none), or one that
218
+ names no manager.
219
+
220
+ A copy never infers supervision from its parent process. The contract's ``supervisorMarker``."""
221
+ env = os.environ if env is None else env
222
+ argv = sys.argv if argv is None else argv
223
+ variable = env.get(SUPERVISOR_ENV) if isinstance(env, dict) or hasattr(env, "get") else None
224
+ from_env = [variable] if isinstance(variable, str) and variable != "" else []
225
+ from_args = _marker_arguments(list(argv) if isinstance(argv, (list, tuple)) else [])
226
+ forms = (["environment"] if from_env else []) + (["argument"] if from_args else [])
227
+ values: List[str] = []
228
+ for value in from_env + from_args:
229
+ if value not in values:
230
+ values.append(value)
231
+ if len(values) > 1:
232
+ return {"kind": "conflict", "values": values, "forms": forms}
233
+ if not values:
234
+ return {"kind": "manual", "forms": forms}
235
+ if values[0] in SUPERVISORS:
236
+ return {"kind": "supervised", "manager": values[0], "forms": forms}
237
+ return {"kind": "manual", "unreadable": values[0], "forms": forms}
238
+
239
+
240
+ def lock_wait_budget_seconds(marker: Dict[str, Any]) -> float:
241
+ """How long a copy started so may wait for the lock: 300 s when supervised, else not at all."""
242
+ return LOCK_WAIT_SUPERVISED_SECONDS if marker.get("kind") == "supervised" else 0.0
243
+
244
+
245
+ def marker_conflict_message(marker: Dict[str, Any]) -> str:
246
+ """The one sentence a copy whose markers disagree exits with, naming the markers present."""
247
+ shown = ", ".join(repr(v) for v in marker["values"])
248
+ if marker["forms"] == ["environment", "argument"]:
249
+ return "%s and %s disagree (%s); not starting unsupervised." % (SUPERVISOR_ENV, SUPERVISOR_ARG, shown)
250
+ return "%s is given with different values (%s); not starting unsupervised." % (SUPERVISOR_ARG, shown)
251
+
252
+
253
+ def supervised(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
254
+ """True when a supervisor that restarts on exit started this process and said so with the marker
255
+ (``launchd_plist`` and ``systemd_unit`` set the variable, ``task_xml`` writes the argument, a node sets either)."""
256
+ return supervisor_marker(env, argv)["kind"] == "supervised"
257
+
258
+
259
+ def supervised_by_launchd(env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> bool:
260
+ """True when launchd started this process from a plist written by ``launchd_plist``.
261
+
262
+ The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
263
+ ``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
264
+ return supervisor_marker(env, argv).get("manager") == "launchd"
265
+
266
+
267
+ def add_supervisor_argument(parser: Any) -> Any:
268
+ """Let a service's own argparse parser accept the marker in both spellings, and a bare one, and ignore it
269
+ (DEC-0057: argparse exits 2 on an unknown flag). The kit reads the marker from ``sys.argv`` itself."""
270
+ import argparse
271
+ parser.add_argument(SUPERVISOR_ARG, nargs="?", const="", default=None, help=argparse.SUPPRESS)
272
+ return parser
273
+
274
+
275
+ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
276
+ sleep: Callable[[float], None] = time.sleep,
277
+ clock: Callable[[], float] = time.monotonic,
278
+ env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> InstanceLock:
279
+ """Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
280
+
281
+ Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
282
+ marker), a held lock exits 75 at once. Under a supervisor that restarts on exit an immediate
283
+ exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
284
+ (0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
285
+ leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
286
+ ``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's."""
287
+ marker = supervisor_marker(env, argv)
288
+ if marker["kind"] == "conflict":
289
+ print(marker_conflict_message(marker), file=sys.stderr)
290
+ raise SystemExit(EXIT_ALREADY_RUNNING)
291
+ if wait_seconds is None:
292
+ wait_seconds = lock_wait_budget_seconds(marker)
293
+ deadline = clock() + max(0.0, wait_seconds)
294
+ delay = LOCK_BACKOFF_FIRST_SECONDS
295
+ announced = False
296
+ while True:
297
+ try:
298
+ return InstanceLock(data_dir).acquire()
299
+ except AlreadyRunning as exc:
300
+ remaining = deadline - clock()
301
+ if remaining <= 0:
302
+ print(str(exc), file=sys.stderr)
303
+ raise SystemExit(EXIT_ALREADY_RUNNING)
304
+ if not announced:
305
+ print("%s Waiting up to %.0f s for it to exit." % (exc, remaining), file=sys.stderr, flush=True)
306
+ announced = True
307
+ sleep(min(delay, remaining))
308
+ delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
@@ -0,0 +1,202 @@
1
+ """Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
2
+ Part of the service kit (FAA-65, layer L3)."""
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from pathlib import Path
14
+ from typing import Any, Callable, Dict, Optional, Sequence
15
+
16
+ import sys as _sys # noqa: E402
17
+ from pathlib import Path as _Path # noqa: E402
18
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
19
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
20
+ from fabric_base import (
21
+ DRAIN_SECONDS,
22
+ ServiceError,
23
+ _O_BINARY,
24
+ now_iso,
25
+ )
26
+ from fabric_paths import (
27
+ ensure_private_dir,
28
+ )
29
+
30
+
31
+ DRAIN_GRACE_SECONDS = 2.0
32
+
33
+
34
+ EXIT_HARD_STOP = 70
35
+
36
+
37
+ # LC-12: logs rotate by size, 5 x 5 MB by default.
38
+ LOG_MAX_BYTES = 5 * 1024 * 1024
39
+
40
+
41
+ LOG_BACKUPS = 5
42
+
43
+
44
+ class RotatingLog:
45
+ """One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
46
+ 0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
47
+
48
+ Never pass a token, a cookie, a login code or a request body that may carry one."""
49
+
50
+ def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
51
+ if max_bytes < 256 or backups < 1:
52
+ raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
53
+ self.path = Path(path)
54
+ self.max_bytes = max_bytes
55
+ self.backups = backups
56
+ self._lock = threading.Lock()
57
+
58
+ def _line(self, record: Dict[str, Any]) -> bytes:
59
+ data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
60
+ if len(data) <= self.max_bytes:
61
+ return data
62
+ record = dict(record, truncated=True)
63
+ message = str(record.get("message", ""))
64
+ while message:
65
+ message = message[: len(message) // 2]
66
+ record["message"] = message
67
+ data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
68
+ if len(data) <= self.max_bytes:
69
+ return data
70
+ return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
71
+ "truncated": True}) + "\n").encode()
72
+
73
+ def write(self, level: str, message: str, **fields: Any) -> None:
74
+ record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
75
+ record.update(fields)
76
+ data = self._line(record)
77
+ with self._lock:
78
+ ensure_private_dir(self.path.parent)
79
+ try:
80
+ size = self.path.stat().st_size
81
+ except FileNotFoundError:
82
+ size = 0
83
+ if size and size + len(data) > self.max_bytes:
84
+ self._rotate()
85
+ fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
86
+ try:
87
+ os.write(fd, data)
88
+ finally:
89
+ os.close(fd)
90
+
91
+ def _rotate(self) -> None:
92
+ for index in range(self.backups - 1, 0, -1):
93
+ older = Path("%s.%d" % (self.path, index))
94
+ if older.exists():
95
+ os.replace(older, "%s.%d" % (self.path, index + 1))
96
+ os.replace(self.path, "%s.1" % self.path)
97
+
98
+
99
+ def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
100
+ """Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
101
+ truncated in place. launchd holds the file open for append, so a rename would leave it
102
+ writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
103
+ path = Path(path)
104
+ try:
105
+ size = path.stat().st_size
106
+ except FileNotFoundError:
107
+ return False
108
+ if size <= max_bytes:
109
+ return False
110
+ backup = Path("%s.1" % path)
111
+ shutil.copyfile(path, backup)
112
+ os.chmod(backup, 0o600)
113
+ with open(path, "r+b") as handle:
114
+ handle.truncate(0)
115
+ return True
116
+
117
+
118
+ class Stopping(ServiceError):
119
+ """The service is stopping and takes no new work."""
120
+
121
+
122
+ class Drain:
123
+ """SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
124
+ over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
125
+ (``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
126
+
127
+ drain = fs.Drain()
128
+ drain.install(lambda drained: server.shutdown(),
129
+ on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
130
+ with drain.work(): # raises fs.Stopping once a stop has begun
131
+ ...
132
+ """
133
+
134
+ def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
135
+ self.deadline = deadline
136
+ self.grace = grace
137
+ self._cond = threading.Condition()
138
+ self._inflight = 0
139
+ self._stopping = False
140
+
141
+ @property
142
+ def stopping(self) -> bool:
143
+ return self._stopping
144
+
145
+ @property
146
+ def inflight(self) -> int:
147
+ return self._inflight
148
+
149
+ @contextlib.contextmanager
150
+ def work(self):
151
+ with self._cond:
152
+ if self._stopping:
153
+ raise Stopping("The service is stopping and takes no new work.")
154
+ self._inflight += 1
155
+ try:
156
+ yield
157
+ finally:
158
+ with self._cond:
159
+ self._inflight -= 1
160
+ self._cond.notify_all()
161
+
162
+ def request_stop(self) -> bool:
163
+ """Refuse new work from now on; False when a stop had already begun."""
164
+ with self._cond:
165
+ if self._stopping:
166
+ return False
167
+ self._stopping = True
168
+ return True
169
+
170
+ def wait(self, timeout: Optional[float] = None) -> bool:
171
+ """Wait for in-flight work to finish; True when it did inside ``timeout``."""
172
+ end = None if timeout is None else time.monotonic() + timeout
173
+ with self._cond:
174
+ while self._inflight > 0:
175
+ left = None if end is None else end - time.monotonic()
176
+ if left is not None and left <= 0:
177
+ return False
178
+ self._cond.wait(left)
179
+ return True
180
+
181
+ def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
182
+ signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
183
+ """Install the handlers (main thread only). A second signal changes nothing: the hard-exit
184
+ timer already holds the deadline."""
185
+ def handler(_signum: int, _frame: Any) -> None:
186
+ if self.request_stop():
187
+ threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
188
+
189
+ for sig in signals:
190
+ signal.signal(sig, handler)
191
+ return self
192
+
193
+ def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
194
+ hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
195
+ hard.daemon = True
196
+ hard.start()
197
+ if on_stopping is not None:
198
+ try:
199
+ on_stopping()
200
+ except Exception: # a failing stop notice must not stop the drain
201
+ traceback.print_exc()
202
+ on_stop(self.wait(self.deadline))