@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- 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/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- 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)
|