@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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 (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  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 +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -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-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env python3
2
+ """The service kit as a versioned set of modules: build its manifest, install it into a service, report a copy's state.
3
+
4
+ The kit is not one file. ``kit.json`` beside this script lists every module in dependency order, per language, with
5
+ the kit modules it imports and its sha256, and ``history`` keeps the sha256 of every earlier release, so a copy in a
6
+ service can be told apart: ``current``, ``older`` (with the release it came from), or ``modified``.
7
+
8
+ fabric_kit.py install <service-dir> [--lang py|node] [--module NAME ...] [--all] [--dry-run] [--force]
9
+ fabric_kit.py status <service-dir> [--json]
10
+ fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources
11
+
12
+ ``install`` copies the closure of the named modules (default ``fabric_service`` / ``fabric-service``) and nothing else.
13
+ It refuses to overwrite a file it does not recognise (a ``modified`` copy) unless ``--force``. ``status`` also names a
14
+ module a present module imports that is missing from the folder: the copy would fail when that code path runs.
15
+ Standard library only. Exit codes: 0 done or all current, 1 a problem (missing, modified, refused), 2 usage.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import ast
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ import shutil
26
+ import sys
27
+ from pathlib import Path
28
+ from typing import Dict, List
29
+
30
+ HERE = Path(__file__).resolve().parent
31
+ MANIFEST = HERE / "kit.json"
32
+ LANGS = {"py": "python", "node": "node"}
33
+ DEFAULT_ROOT = {"python": "fabric_service", "node": "fabric-service"}
34
+
35
+
36
+ def _sha256(path: Path) -> str:
37
+ return hashlib.sha256(path.read_bytes()).hexdigest()
38
+
39
+
40
+ def python_imports(path: Path, modules: List[str]) -> List[str]:
41
+ """The kit modules a Python file imports, at any depth of its code (a lazy import is a dependency too)."""
42
+ found = set()
43
+ for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
44
+ if isinstance(node, ast.Import):
45
+ found.update(a.name for a in node.names if a.name in modules)
46
+ elif isinstance(node, ast.ImportFrom) and node.module in modules:
47
+ found.add(node.module)
48
+ found.discard(path.stem)
49
+ return sorted(found)
50
+
51
+
52
+ def node_imports(path: Path, modules: List[str]) -> List[str]:
53
+ """The kit modules an ES module imports (static `from './x.mjs'` and dynamic `import('./x.mjs')`)."""
54
+ text = path.read_text(encoding="utf-8")
55
+ found = set(re.findall(r"""(?:from|import\()\s*['"]\./([A-Za-z0-9_-]+)\.mjs['"]""", text)) & set(modules)
56
+ found.discard(path.stem)
57
+ return sorted(found)
58
+
59
+
60
+ def load_manifest() -> Dict:
61
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
62
+
63
+
64
+ def build(check: bool = False) -> List[str]:
65
+ """Refresh every module's imports and sha256 in kit.json, and record this release's digests in its history.
66
+ Returns what changed (with check=True nothing is written)."""
67
+ manifest = load_manifest()
68
+ changed = []
69
+ for lang, entries in (("python", manifest["python"]), ("node", manifest["node"])):
70
+ names = [e["module"] for e in entries]
71
+ for entry in entries:
72
+ path = HERE / entry["file"]
73
+ imports = (python_imports if lang == "python" else node_imports)(path, names)
74
+ digest = _sha256(path)
75
+ if entry.get("imports") != imports or entry.get("sha256") != digest:
76
+ changed.append(entry["file"])
77
+ entry["imports"], entry["sha256"] = imports, digest
78
+ data = sorted({d for e in manifest["python"] + manifest["node"] for d in e.get("data", [])})
79
+ digests = {d: _sha256(HERE / d) for d in data}
80
+ if manifest.get("data") != digests:
81
+ changed.append("data files")
82
+ manifest["data"] = digests
83
+ current = {e["file"]: e["sha256"] for e in manifest["python"] + manifest["node"]}
84
+ current.update(digests)
85
+ if manifest.setdefault("history", {}).get(manifest["version"]) != current:
86
+ manifest["history"][manifest["version"]] = current
87
+ changed.append("kit.json history %s" % manifest["version"])
88
+ if changed and not check:
89
+ MANIFEST.write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
90
+ return changed
91
+
92
+
93
+ def closure(entries: List[Dict], roots: List[str]) -> List[Dict]:
94
+ """The named modules and everything they import, in the manifest's (dependency) order."""
95
+ by_name = {e["module"]: e for e in entries}
96
+ unknown = [r for r in roots if r not in by_name]
97
+ if unknown:
98
+ raise SystemExit("unknown kit module: %s (the manifest lists %s)" % (", ".join(unknown), ", ".join(by_name)))
99
+ wanted, stack = set(), list(roots)
100
+ while stack:
101
+ name = stack.pop()
102
+ if name not in wanted:
103
+ wanted.add(name)
104
+ stack.extend(by_name[name]["imports"])
105
+ return [e for e in entries if e["module"] in wanted]
106
+
107
+
108
+ def state_of(path: Path, manifest: Dict) -> Dict:
109
+ """`current`, `older` with the release a copy came from, or `modified` (a digest no release had)."""
110
+ digest = _sha256(path)
111
+ if any(e["file"] == path.name and e["sha256"] == digest for e in manifest["python"] + manifest["node"]) or \
112
+ manifest.get("data", {}).get(path.name) == digest:
113
+ return {"state": "current", "version": manifest["version"]}
114
+ for version, files in sorted(manifest.get("history", {}).items(), key=lambda kv: [int(x) for x in re.findall(r"[0-9]+", kv[0])], reverse=True):
115
+ if files.get(path.name) == digest:
116
+ return {"state": "older", "version": version}
117
+ return {"state": "modified", "version": None}
118
+
119
+
120
+ def status(service_dir: Path) -> Dict:
121
+ manifest = load_manifest()
122
+ report = {"kit": manifest["version"], "files": [], "missing": []}
123
+ for lang in ("python", "node"):
124
+ entries = manifest[lang]
125
+ present = [e for e in entries if (service_dir / e["file"]).is_file()]
126
+ for e in present:
127
+ report["files"].append({"file": e["file"], **state_of(service_dir / e["file"], manifest)})
128
+ names = {e["module"] for e in present}
129
+ by_name = {e["module"]: e for e in entries}
130
+ def reader(path: Path, modules: List[str]) -> List[str]:
131
+ try:
132
+ return (python_imports if lang == "python" else node_imports)(path, modules)
133
+ except (SyntaxError, ValueError): # a copy someone broke is already `modified`; its imports are unknown
134
+ return []
135
+ # What each copy imports is read from the copy itself (an older release imports other modules than this one);
136
+ # a module it lacks would be installed at this release, so that module's own imports are followed too.
137
+ queue = [(e["file"], reader(service_dir / e["file"], list(by_name))) for e in present]
138
+ reported = set()
139
+ for e in present:
140
+ for d in e.get("data", []):
141
+ if (service_dir / d).is_file():
142
+ if d not in [f["file"] for f in report["files"]]:
143
+ report["files"].append({"file": d, **state_of(service_dir / d, manifest)})
144
+ elif d not in reported:
145
+ reported.add(d)
146
+ report["missing"].append({"file": e["file"], "needs": d})
147
+ while queue:
148
+ importer, deps = queue.pop(0)
149
+ for dep in deps:
150
+ if dep in names or dep in reported:
151
+ continue
152
+ reported.add(dep)
153
+ report["missing"].append({"file": importer, "needs": by_name[dep]["file"]})
154
+ for d in by_name[dep].get("data", []): # the data a missing module reads at run time
155
+ if not (service_dir / d).is_file() and d not in reported:
156
+ reported.add(d)
157
+ report["missing"].append({"file": by_name[dep]["file"], "needs": d})
158
+ queue.append((by_name[dep]["file"], by_name[dep]["imports"]))
159
+ return report
160
+
161
+
162
+ def install(service_dir: Path, lang: str, modules: List[str], dry_run: bool = False, force: bool = False) -> Dict:
163
+ manifest = load_manifest()
164
+ entries = manifest[lang]
165
+ roots = modules or [DEFAULT_ROOT[lang]]
166
+ plan = closure(entries, roots)
167
+ result = {"kit": manifest["version"], "copied": [], "kept": [], "refused": []}
168
+ files = []
169
+ for e in plan:
170
+ files.append(e["file"])
171
+ files.extend(d for d in e.get("data", []) if d not in files)
172
+ for name in files:
173
+ target = service_dir / name
174
+ if target.is_file():
175
+ st = state_of(target, manifest)
176
+ if st["state"] == "current":
177
+ result["kept"].append(name)
178
+ continue
179
+ if st["state"] == "modified" and not force:
180
+ result["refused"].append(name)
181
+ continue
182
+ result["copied"].append(name)
183
+ if not dry_run and not result["refused"]:
184
+ service_dir.mkdir(parents=True, exist_ok=True)
185
+ for name in result["copied"]:
186
+ tmp = service_dir / (".%s.tmp" % name)
187
+ shutil.copyfile(HERE / name, tmp)
188
+ os.replace(tmp, service_dir / name)
189
+ return result
190
+
191
+
192
+ def main(argv: List[str]) -> int:
193
+ parser = argparse.ArgumentParser(prog="fabric_kit.py", description=__doc__.split("\n\n")[0])
194
+ sub = parser.add_subparsers(dest="cmd", required=True)
195
+ p_install = sub.add_parser("install", help="copy a module's closure into a service")
196
+ p_install.add_argument("dir", type=Path)
197
+ p_install.add_argument("--lang", choices=sorted(LANGS), default="py")
198
+ p_install.add_argument("--module", action="append", default=[])
199
+ p_install.add_argument("--all", action="store_true", help="every module of the language")
200
+ p_install.add_argument("--dry-run", action="store_true")
201
+ p_install.add_argument("--force", action="store_true", help="overwrite a copy the kit does not recognise")
202
+ p_status = sub.add_parser("status", help="the state of the kit copies in a service")
203
+ p_status.add_argument("dir", type=Path)
204
+ p_status.add_argument("--json", action="store_true")
205
+ p_build = sub.add_parser("build", help="refresh kit.json from the sources (maintainers)")
206
+ p_build.add_argument("--check", action="store_true")
207
+ args = parser.parse_args(argv)
208
+ if args.cmd == "build":
209
+ changed = build(args.check)
210
+ print(("kit.json is out of date: " if args.check else "rebuilt: ") + ", ".join(changed) if changed else "kit.json is up to date")
211
+ return 1 if (changed and args.check) else 0
212
+ if args.cmd == "status":
213
+ report = status(args.dir)
214
+ if args.json:
215
+ print(json.dumps(report, indent=2))
216
+ else:
217
+ for f in report["files"]:
218
+ print("%-9s %s%s" % (f["state"], f["file"], " (%s)" % f["version"] if f["state"] == "older" else ""))
219
+ for m in report["missing"]:
220
+ print("missing %s, which %s imports" % (m["needs"], m["file"]))
221
+ if not report["files"]:
222
+ print("no kit module in %s" % args.dir)
223
+ bad = not report["files"] or report["missing"] or any(f["state"] != "current" for f in report["files"])
224
+ return 1 if bad else 0
225
+ lang = LANGS[args.lang]
226
+ known = [e["module"] for e in load_manifest()[lang]]
227
+ modules = known if args.all else args.module
228
+ unknown = [m for m in modules if m not in known]
229
+ if unknown:
230
+ print("no such %s kit module: %s (kit.json lists %s)" % (lang, ", ".join(unknown), ", ".join(known)), file=sys.stderr)
231
+ return 2
232
+ result = install(args.dir, lang, modules, args.dry_run, args.force)
233
+ for name in result["copied"]:
234
+ print(("would copy " if args.dry_run else "copied ") + name)
235
+ for name in result["kept"]:
236
+ print("current " + name)
237
+ for name in result["refused"]:
238
+ print("refused %s: the copy there is not one the kit released; --force overwrites it" % name)
239
+ return 1 if result["refused"] else 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main(sys.argv[1:]))
@@ -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)