@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- package/README.md +18 -5
- package/SECURITY.md +25 -8
- package/bin/fabric-agent-adapter.js +24 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +116 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -77
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +899 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +23 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +47 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +19 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +146 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +79 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +269 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +73 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +89 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +39 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +8 -153
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +86 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +324 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +34 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +35 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +77 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +156 -133
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +257 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +246 -24
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +16 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +33 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +16 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +46 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +18 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -13
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Work a service holds by its own limits, said to its caller and its operator (FAA-298). Part of the service kit
|
|
2
|
+
(layer L3, after fabric_interop).
|
|
3
|
+
|
|
4
|
+
A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
|
|
5
|
+
says so three ways:
|
|
6
|
+
|
|
7
|
+
- to the caller, on the job: `waiting_message(reason, since, until, message)` is the job's `statusMessage`. The pinned
|
|
8
|
+
`interop-job` schema is closed (`additionalProperties: false`), so `waiting {reason, since, until, message}` cannot
|
|
9
|
+
be a field of the job; it rides the contract's pause grammar (DEC-0087) as its text:
|
|
10
|
+
`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`. A caller that reads only the pause
|
|
11
|
+
grammar waits (and until `until`, when given); `read_waiting(answer)` gives the four parts back. `JobStore.pause`
|
|
12
|
+
takes the same statusMessage through `hold_job`.
|
|
13
|
+
- to the operator, once per hold: `HoldBook.hold` opens a hold per (reason, caller), or joins the open one, and returns
|
|
14
|
+
the push only when it opens: one `notify` event naming the caller, the work, the reason and when the hold frees by
|
|
15
|
+
itself, with the question whether to raise the limit. Further jobs held by the same limit for the same caller join
|
|
16
|
+
the hold silently; a hold is closed by `release`, by its `until` passing, or by a lift that covers it.
|
|
17
|
+
- the answer, as a scoped lift: `HoldBook.lift` records the operator's words (a DEC-0121 decision, action
|
|
18
|
+
`limit.lift`) with its scope (the reason, optionally one caller and one job) and its size (a number of jobs, an
|
|
19
|
+
amount of money, or a raised limit for a time window such as 100 a day for today). `admit` spends a jobs or money
|
|
20
|
+
lift; `limit` gives the limit in force. A lift falls back by itself when its window ends or its amount is spent.
|
|
21
|
+
|
|
22
|
+
`HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
|
|
23
|
+
lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
|
|
24
|
+
the contract's detector reads as one.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
import sys as _sys # noqa: E402
|
|
35
|
+
from pathlib import Path as _Path # noqa: E402
|
|
36
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
37
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
38
|
+
from fabric_base import ServiceError, now_iso, store_lock # noqa: E402
|
|
39
|
+
from fabric_paths import atomic_write # noqa: E402
|
|
40
|
+
from fabric_actions import decision_shape # noqa: E402
|
|
41
|
+
from fabric_interop_rules import pause_message, read_job_pause, utc_time, QUOTA_EXHAUSTED # noqa: E402
|
|
42
|
+
import fabric_secrets # noqa: E402
|
|
43
|
+
|
|
44
|
+
#: The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap.
|
|
45
|
+
HOLD_REASONS = ("daily-quota", "caller-share", "concurrency", "money")
|
|
46
|
+
#: How the push names each reason to a person.
|
|
47
|
+
REASON_WORDS = {"daily-quota": "the daily quota", "caller-share": "its per-caller share",
|
|
48
|
+
"concurrency": "the concurrency cap", "money": "the money cap"}
|
|
49
|
+
#: The push's kind: a decision for the operator (level notice, notify true; events-and-notifications.md).
|
|
50
|
+
HOLD_EVENT_KIND = "limit.held"
|
|
51
|
+
#: The record of a lift applied (level info, no notify).
|
|
52
|
+
LIFT_EVENT_KIND = "limit.lifted"
|
|
53
|
+
#: The decision action a lift carries (DEC-0121 AP-2 shape).
|
|
54
|
+
LIFT_ACTION = "limit.lift"
|
|
55
|
+
_MAX_STATUS_MESSAGE = 500
|
|
56
|
+
_WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
|
|
57
|
+
_SIZES = ("jobs", "usd", "limit")
|
|
58
|
+
#: One run of ECMAScript's whitespace, the class both twins squash with (FAA-316): Python's str.split() also takes
|
|
59
|
+
#: U+001C to U+001F and U+0085 and not U+FEFF, so the twins wrote different statusMessages.
|
|
60
|
+
_SPACES = re.compile("[%s]+" % fabric_secrets.JS_SPACE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _squash(text: str) -> str:
|
|
64
|
+
"""Runs of ECMAScript whitespace as one space, none at either end (the Node twin's `squash`)."""
|
|
65
|
+
return _SPACES.sub(" ", text).strip(" ")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _instant(value: Any, name: str) -> str:
|
|
69
|
+
if utc_time(value) is None:
|
|
70
|
+
raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def waiting_message(reason: str, since: str, until: Optional[str] = None, message: Optional[str] = None) -> str:
|
|
75
|
+
"""A held job's statusMessage: the pause grammar, its text `waiting <reason> since <since>[: <message>]`. The
|
|
76
|
+
message is shortened, never refused, to fit the contract's 500 characters; a reason or an instant outside the
|
|
77
|
+
grammar raises ServiceError."""
|
|
78
|
+
if reason not in HOLD_REASONS:
|
|
79
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
80
|
+
_instant(since, "since")
|
|
81
|
+
if until is not None:
|
|
82
|
+
_instant(until, "until")
|
|
83
|
+
text = "waiting %s since %s" % (reason, since)
|
|
84
|
+
head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
|
|
85
|
+
if message is not None:
|
|
86
|
+
message = _squash(str(message))
|
|
87
|
+
room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
|
|
88
|
+
if message and room > 0:
|
|
89
|
+
text += ": " + (message if len(message) <= room else message[:room - 3].rstrip(" ") + "...")
|
|
90
|
+
return pause_message(until, text)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def read_waiting(answer: Any) -> Optional[Dict[str, Any]]:
|
|
94
|
+
"""`{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds (`waiting_message`), else
|
|
95
|
+
None: a plain pause, a queue position or a finished job is not a hold."""
|
|
96
|
+
pause = read_job_pause(answer)
|
|
97
|
+
if not pause.get("paused"):
|
|
98
|
+
return None
|
|
99
|
+
status = answer["job"]["statusMessage"]
|
|
100
|
+
rest = status[len(QUOTA_EXHAUSTED):]
|
|
101
|
+
if "resetsAt" in pause:
|
|
102
|
+
rest = rest[len(" until ") + len(pause["resetsAt"]):]
|
|
103
|
+
if not rest.startswith(": "):
|
|
104
|
+
return None
|
|
105
|
+
match = _WAITING.match(rest[2:])
|
|
106
|
+
if not match or utc_time(match.group(2)) is None:
|
|
107
|
+
return None
|
|
108
|
+
waiting: Dict[str, Any] = {"reason": match.group(1), "since": match.group(2)}
|
|
109
|
+
if "resetsAt" in pause:
|
|
110
|
+
waiting["until"] = pause["resetsAt"]
|
|
111
|
+
if match.group(3) is not None:
|
|
112
|
+
waiting["message"] = match.group(3)
|
|
113
|
+
return waiting
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def hold_job(jobs: Any, job_id: str, reason: str, since: str, until: Optional[str] = None,
|
|
117
|
+
message: Optional[str] = None) -> Dict[str, Any]:
|
|
118
|
+
"""Put one job of a fabric_interop JobStore on hold: working, its statusMessage `waiting_message(...)`, its
|
|
119
|
+
pollIntervalMs reaching `until` (JobStore.pause). Returns the job."""
|
|
120
|
+
text = waiting_message(reason, since, until, message)[len(QUOTA_EXHAUSTED):]
|
|
121
|
+
if until is not None:
|
|
122
|
+
text = text[len(" until ") + len(until):]
|
|
123
|
+
return jobs.pause(job_id, resets_at=until, text=text[2:])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) -> str:
|
|
127
|
+
"""The push's one sentence: who, what, why, when it frees by itself, and the question."""
|
|
128
|
+
frees = ("it frees by itself at %s" % until) if until is not None else (
|
|
129
|
+
"it frees by itself when a running job ends" if reason in ("concurrency", "caller-share")
|
|
130
|
+
else "it does not free by itself")
|
|
131
|
+
return "%s for %s is held by %s; %s. Raise the limit for it?" % (work, caller, REASON_WORDS[reason], frees)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _text(value: Any, name: str, high: int = 200) -> str:
|
|
135
|
+
if not isinstance(value, str) or not _squash(value) or len(value) > high:
|
|
136
|
+
raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
|
|
137
|
+
if fabric_secrets.secret_value(value):
|
|
138
|
+
raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
|
|
139
|
+
return _squash(value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class HoldBook:
|
|
143
|
+
"""The holds a service's limits put on work and the operator's lifts, in one file (FAA-298).
|
|
144
|
+
|
|
145
|
+
`events`, when given, is the service's event log (an object with `append(kind, level, text, subject=..., **extra)`,
|
|
146
|
+
such as fabric_events.JsonlEventLog): the push and the lift record are written there. Without it they are returned
|
|
147
|
+
for the service to write."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, path: Path, events: Any = None, clock: Callable[[], str] = now_iso):
|
|
150
|
+
self.path = Path(path)
|
|
151
|
+
self.events = events
|
|
152
|
+
self.clock = clock
|
|
153
|
+
self._lock = store_lock(self.path)
|
|
154
|
+
|
|
155
|
+
# --- the file -------------------------------------------------------------------------
|
|
156
|
+
def _load(self) -> Dict[str, Any]:
|
|
157
|
+
if not self.path.exists():
|
|
158
|
+
return {"holds": [], "lifts": []}
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
161
|
+
except ValueError:
|
|
162
|
+
raise ServiceError("The hold book %s is not JSON; it is kept as it is." % self.path.name) from None
|
|
163
|
+
if not isinstance(data, dict) or not isinstance(data.get("holds"), list) or not isinstance(data.get("lifts"), list):
|
|
164
|
+
raise ServiceError("The hold book %s is not {holds, lifts}; it is kept as it is." % self.path.name)
|
|
165
|
+
return data
|
|
166
|
+
|
|
167
|
+
def _save(self, data: Dict[str, Any]) -> None:
|
|
168
|
+
atomic_write(self.path, (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode(), 0o600)
|
|
169
|
+
|
|
170
|
+
def _now(self, now: Optional[str]) -> str:
|
|
171
|
+
return _instant(now if now is not None else self.clock(), "now")
|
|
172
|
+
|
|
173
|
+
def _emit(self, kind: str, level: str, text: str, subject: Dict[str, str], notify: bool) -> Dict[str, Any]:
|
|
174
|
+
if self.events is not None:
|
|
175
|
+
return self.events.append(kind, level, text, subject=subject, notify=notify)
|
|
176
|
+
out: Dict[str, Any] = {"kind": kind, "level": level, "text": text, "subject": subject}
|
|
177
|
+
if notify:
|
|
178
|
+
out["notify"] = True
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _expire(data: Dict[str, Any], at: int) -> None:
|
|
183
|
+
for hold in data["holds"]:
|
|
184
|
+
if hold.get("closed") is None and hold.get("until") is not None and utc_time(hold["until"]) <= at:
|
|
185
|
+
hold["closed"], hold["closedAt"] = "expired", hold["until"]
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _live(lift: Dict[str, Any], at: int) -> bool:
|
|
189
|
+
if lift.get("from") is not None and utc_time(lift["from"]) > at:
|
|
190
|
+
return False
|
|
191
|
+
if lift.get("until") is not None and utc_time(lift["until"]) <= at:
|
|
192
|
+
return False
|
|
193
|
+
return lift.get("left", 1) > 0
|
|
194
|
+
|
|
195
|
+
# --- holds ----------------------------------------------------------------------------
|
|
196
|
+
def hold(self, *, caller: str, work: str, reason: str, until: Optional[str] = None, message: Optional[str] = None,
|
|
197
|
+
job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
198
|
+
"""Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
|
|
199
|
+
call opened the hold, None when the work joined an open one (one push per hold)."""
|
|
200
|
+
caller, work = _text(caller, "caller"), _text(work, "work")
|
|
201
|
+
if reason not in HOLD_REASONS:
|
|
202
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
203
|
+
at = self._now(now)
|
|
204
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
205
|
+
raise ServiceError("until %s has passed: the limit no longer holds the work." % until)
|
|
206
|
+
with self._lock:
|
|
207
|
+
data = self._load()
|
|
208
|
+
self._expire(data, utc_time(at))
|
|
209
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
210
|
+
and h["caller"] == caller), None)
|
|
211
|
+
opened = hold is None
|
|
212
|
+
if opened:
|
|
213
|
+
hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
|
|
214
|
+
"until": until, "jobs": [], "closed": None}
|
|
215
|
+
data["holds"].append(hold)
|
|
216
|
+
if job_id is not None and job_id not in hold["jobs"]:
|
|
217
|
+
hold["jobs"].append(str(job_id))
|
|
218
|
+
self._save(data)
|
|
219
|
+
status = waiting_message(reason, hold["since"], hold["until"], message)
|
|
220
|
+
push = None
|
|
221
|
+
if opened:
|
|
222
|
+
push = self._emit(HOLD_EVENT_KIND, "notice", hold_sentence(caller, work, reason, until),
|
|
223
|
+
{"type": "limit", "id": "%s:%s" % (reason, caller)[:200], "label": REASON_WORDS[reason]}, True)
|
|
224
|
+
return {"hold": dict(hold), "statusMessage": status, "push": push}
|
|
225
|
+
|
|
226
|
+
def release(self, *, reason: str, caller: str, now: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
227
|
+
"""Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again."""
|
|
228
|
+
at = self._now(now)
|
|
229
|
+
with self._lock:
|
|
230
|
+
data = self._load()
|
|
231
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
232
|
+
and h["caller"] == caller), None)
|
|
233
|
+
if hold is None:
|
|
234
|
+
return None
|
|
235
|
+
hold["closed"], hold["closedAt"] = "released", at
|
|
236
|
+
self._save(data)
|
|
237
|
+
return dict(hold)
|
|
238
|
+
|
|
239
|
+
def open_holds(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
240
|
+
data = self._load()
|
|
241
|
+
self._expire(data, utc_time(self._now(now)))
|
|
242
|
+
return [dict(h) for h in data["holds"] if h.get("closed") is None]
|
|
243
|
+
|
|
244
|
+
# --- lifts ----------------------------------------------------------------------------
|
|
245
|
+
def lift(self, *, reason: str, decision: Dict[str, Any], caller: Optional[str] = None, job_id: Optional[str] = None,
|
|
246
|
+
jobs: Optional[int] = None, usd: Optional[float] = None, limit: Optional[int] = None,
|
|
247
|
+
until: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
248
|
+
"""Record the operator's answer as a scoped lift: exactly one of `jobs` (that many more jobs), `usd` (that much
|
|
249
|
+
more money) or `limit` (the limit raised to that value, which needs `until`, the window's end). `decision` is
|
|
250
|
+
the operator's words in decision.schema.json's shape with action `limit.lift`. The open hold it covers closes
|
|
251
|
+
as lifted. Returns the lift."""
|
|
252
|
+
if reason not in HOLD_REASONS:
|
|
253
|
+
raise ServiceError("A lift's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
254
|
+
if not decision_shape(decision) or decision.get("action") != LIFT_ACTION:
|
|
255
|
+
raise ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
|
|
256
|
+
"words verbatim): fabric_actions.operator_decision('limit.lift', words).")
|
|
257
|
+
sizes = {"jobs": jobs, "usd": usd, "limit": limit}
|
|
258
|
+
given = [name for name in _SIZES if sizes[name] is not None]
|
|
259
|
+
if len(given) != 1:
|
|
260
|
+
raise ServiceError("A lift is one of jobs, usd or limit.")
|
|
261
|
+
size = sizes[given[0]]
|
|
262
|
+
if isinstance(size, bool) or not isinstance(size, (int, float)) or size <= 0 or (
|
|
263
|
+
given[0] != "usd" and int(size) != size):
|
|
264
|
+
raise ServiceError("A lift's %s is a positive %s." % (given[0], "number" if given[0] == "usd" else "integer"))
|
|
265
|
+
at = self._now(now)
|
|
266
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
267
|
+
raise ServiceError("A lift's window ends after now.")
|
|
268
|
+
if given[0] == "limit" and until is None:
|
|
269
|
+
raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
|
|
270
|
+
record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
|
|
271
|
+
"job": None if job_id is None else str(job_id), "from": at, "until": until,
|
|
272
|
+
given[0]: size, "decision": dict(decision), "at": at}
|
|
273
|
+
if given[0] != "limit":
|
|
274
|
+
record["left"] = size
|
|
275
|
+
with self._lock:
|
|
276
|
+
data = self._load()
|
|
277
|
+
self._expire(data, utc_time(at))
|
|
278
|
+
for hold in data["holds"]:
|
|
279
|
+
if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
|
|
280
|
+
job_id is None or str(job_id) in hold["jobs"]):
|
|
281
|
+
hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
|
|
282
|
+
data["lifts"].append(record)
|
|
283
|
+
self._save(data)
|
|
284
|
+
scope = ("for %s" % record["caller"]) if record["caller"] else "for every caller"
|
|
285
|
+
amount = {"jobs": "%s more job(s)", "usd": "USD %s more", "limit": "the limit at %s"}[given[0]] % size
|
|
286
|
+
self._emit(LIFT_EVENT_KIND, "info", "The operator lifted %s %s: %s%s." % (
|
|
287
|
+
REASON_WORDS[reason], scope, amount, (" until %s" % until) if until else ""),
|
|
288
|
+
{"type": "limit", "id": "%s:%s" % (reason, record["caller"] or "*"), "label": REASON_WORDS[reason]}, False)
|
|
289
|
+
return dict(record)
|
|
290
|
+
|
|
291
|
+
def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
|
|
292
|
+
kind: str) -> List[Dict[str, Any]]:
|
|
293
|
+
return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
|
|
294
|
+
and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
|
|
295
|
+
|
|
296
|
+
def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
|
|
297
|
+
now: Optional[str] = None) -> Dict[str, Any]:
|
|
298
|
+
"""Whether a lift lets work past the limit that would hold it: {admitted: True, lift} spends one job of a jobs
|
|
299
|
+
lift, or `cost_usd` of a money lift (the narrowest scope first: a job's, a caller's, every caller's), else
|
|
300
|
+
{admitted: False}. A raised limit is read with `limit`, not spent here."""
|
|
301
|
+
at = utc_time(self._now(now))
|
|
302
|
+
with self._lock:
|
|
303
|
+
data = self._load()
|
|
304
|
+
kind = "usd" if cost_usd is not None else "jobs"
|
|
305
|
+
for lift in sorted(self._matching(data, reason, caller, None if job_id is None else str(job_id), at, kind),
|
|
306
|
+
key=lambda l: (l.get("job") is None, l.get("caller") is None)):
|
|
307
|
+
need = cost_usd if kind == "usd" else 1
|
|
308
|
+
if lift["left"] >= need:
|
|
309
|
+
lift["left"] = round(lift["left"] - need, 6) if kind == "usd" else lift["left"] - 1
|
|
310
|
+
self._save(data)
|
|
311
|
+
return {"admitted": True, "lift": lift["id"], "left": lift["left"]}
|
|
312
|
+
return {"admitted": False}
|
|
313
|
+
|
|
314
|
+
def limit(self, *, reason: str, base: float, caller: Optional[str] = None, now: Optional[str] = None) -> float:
|
|
315
|
+
"""The limit in force: `base`, or the highest live raised limit for (reason, caller); `base` again once the
|
|
316
|
+
window ends."""
|
|
317
|
+
at = utc_time(self._now(now))
|
|
318
|
+
raised = [l["limit"] for l in self._matching(self._load(), reason, caller, None, at, "limit")]
|
|
319
|
+
return max([base] + raised)
|
|
320
|
+
|
|
321
|
+
def lifts(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
322
|
+
"""Every lift with `live`: whether it still applies now."""
|
|
323
|
+
at = utc_time(self._now(now))
|
|
324
|
+
return [dict(l, live=self._live(l, at)) for l in self._load()["lifts"]]
|
|
@@ -73,8 +73,9 @@ class HttpError(Exception):
|
|
|
73
73
|
self.retry_after_s = retry_after_s
|
|
74
74
|
self.body = bytes(body[:2048])
|
|
75
75
|
self.kind = kind # http (an answer), connection, timeout, too-large, refused, foreign-user
|
|
76
|
-
#: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets,
|
|
77
|
-
#: never clears), the owner's kind (`another-account`,
|
|
76
|
+
#: For foreign-user, why (FAA-272): `table-restricted` (this process sees no other process's sockets, or on
|
|
77
|
+
#: macOS no row for this connection, DEC-0155 RT-1; it never clears), the owner's kind (`another-account`,
|
|
78
|
+
#: `system`) or the check's reason (`no-row`, from Linux and Windows only).
|
|
78
79
|
self.reason = reason
|
|
79
80
|
|
|
80
81
|
|
|
@@ -361,13 +362,43 @@ class _CheckedHTTPConnection(_po.CheckedHTTPConnection):
|
|
|
361
362
|
self.vouch_pid = None # a loopback URL is never a process this caller started (FAA-267)
|
|
362
363
|
|
|
363
364
|
|
|
365
|
+
def _peer_is_loopback(sock: Any) -> bool:
|
|
366
|
+
"""Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
|
|
367
|
+
spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0`, `::ffff:127.0.0.1` and a proxy on loopback all
|
|
368
|
+
arrive here (0.74.0 unit readings 7, 8 and 11; FAA-260)."""
|
|
369
|
+
try:
|
|
370
|
+
host = str(sock.getpeername()[0]).split("%", 1)[0]
|
|
371
|
+
address = ipaddress.ip_address(host)
|
|
372
|
+
except (OSError, ValueError, TypeError, IndexError, AttributeError):
|
|
373
|
+
return False
|
|
374
|
+
mapped = getattr(address, "ipv4_mapped", None)
|
|
375
|
+
return bool((mapped or address).is_loopback or address.is_unspecified)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class _PlainHTTPConnection(http.client.HTTPConnection):
|
|
379
|
+
"""Plain http to a host not spelled as loopback (`insecure=True`) or to a proxy from the environment: when the
|
|
380
|
+
connected socket's peer is a loopback address, its server end is told as `_CheckedHTTPConnection` tells it, and
|
|
381
|
+
another account's, root's or an unknown owner is refused before a byte is sent (DEC-0154 HA-2, FAA-260)."""
|
|
382
|
+
|
|
383
|
+
def connect(self) -> None:
|
|
384
|
+
super().connect()
|
|
385
|
+
if not _peer_is_loopback(self.sock):
|
|
386
|
+
return
|
|
387
|
+
owner = _po.server_end(self.sock)
|
|
388
|
+
port = self.sock.getpeername()[1]
|
|
389
|
+
refused = _po.refusal(owner, port, None)
|
|
390
|
+
if refused is not None:
|
|
391
|
+
self.close()
|
|
392
|
+
raise _po.ListenerRefused(refused[0], "%s; nothing was sent" % refused[1], owner)
|
|
393
|
+
|
|
394
|
+
|
|
364
395
|
def _opener(watchdog: _Watchdog, direct: bool = False) -> urllib.request.OpenerDirector:
|
|
365
396
|
"""`direct`: a loopback URL never goes through a proxy from the environment (`http_proxy`), so the connection the
|
|
366
397
|
owner check reads is the one to the URL's own port (DEC-0154 HA-2), and a bearer never reaches a proxy."""
|
|
367
398
|
class Http(urllib.request.HTTPHandler):
|
|
368
399
|
def http_open(self, req):
|
|
369
400
|
local = _is_loopback((urllib.parse.urlsplit(req.full_url).hostname or "").lower())
|
|
370
|
-
return self.do_open(_watched(_CheckedHTTPConnection if local else
|
|
401
|
+
return self.do_open(_watched(_CheckedHTTPConnection if local else _PlainHTTPConnection, watchdog), req)
|
|
371
402
|
|
|
372
403
|
class Https(urllib.request.HTTPSHandler):
|
|
373
404
|
def https_open(self, req):
|
|
@@ -70,8 +70,9 @@ class HubError(Exception):
|
|
|
70
70
|
def listener_refusal(exc: Any) -> HubError:
|
|
71
71
|
"""The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
|
|
72
72
|
contract's state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted`
|
|
73
|
-
when this process's socket table hides other processes' sockets,
|
|
74
|
-
otherwise the owner's kind or the check's
|
|
73
|
+
when this process's socket table hides other processes' sockets, or, on macOS, holds no row for this connection
|
|
74
|
+
(DEC-0155 RT-1, FAA-312), which never clears, so it is not retryable; otherwise the owner's kind or the check's
|
|
75
|
+
reason (`another-account`, or `no-row`, which only Linux and Windows give), retryable."""
|
|
75
76
|
owner = getattr(exc, "owner", None)
|
|
76
77
|
why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
|
|
77
78
|
reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
|
|
@@ -94,25 +95,37 @@ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = No
|
|
|
94
95
|
return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
|
|
95
96
|
|
|
96
97
|
|
|
97
|
-
def
|
|
98
|
-
"""
|
|
98
|
+
def _windows_pid_alive(pid: int, kernel: Any = None, last_error: Any = None) -> Optional[bool]:
|
|
99
|
+
"""pid_alive on Windows, where os.kill(pid, 0) would send CTRL_C_EVENT: OpenProcess with query access only. Refused
|
|
100
|
+
with ERROR_ACCESS_DENIED (5), another account's or an elevated process: None, as Node's process.kill(pid, 0)
|
|
101
|
+
answers EPERM there (FAA-304); refused otherwise, no process. `kernel` and `last_error` stand in for kernel32 and
|
|
102
|
+
ctypes.get_last_error in a test."""
|
|
103
|
+
import ctypes
|
|
104
|
+
if kernel is None:
|
|
105
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
|
106
|
+
last_error = ctypes.get_last_error # type: ignore[attr-defined]
|
|
107
|
+
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
108
|
+
if not handle:
|
|
109
|
+
return None if last_error() == 5 else False # ERROR_ACCESS_DENIED: not verified, never called running
|
|
110
|
+
try:
|
|
111
|
+
code = ctypes.c_ulong()
|
|
112
|
+
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
113
|
+
finally:
|
|
114
|
+
kernel.CloseHandle(handle)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def pid_alive(pid: int) -> Optional[bool]:
|
|
118
|
+
"""True: a process of this OS account runs under `pid`; False: none does. None (falsy): a process of another account
|
|
119
|
+
does (EPERM; on Windows, OpenProcess refused with access denied), as when the id hub.json names was reused: not
|
|
120
|
+
verified, never called running (FAA-304, the session hooks' answer since FAA-254). Never signals it."""
|
|
99
121
|
if pid <= 0:
|
|
100
122
|
return False
|
|
101
123
|
if sys.platform == "win32":
|
|
102
|
-
|
|
103
|
-
kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
104
|
-
handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
|
|
105
|
-
if not handle:
|
|
106
|
-
return False
|
|
107
|
-
try:
|
|
108
|
-
code = ctypes.c_ulong()
|
|
109
|
-
return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
|
|
110
|
-
finally:
|
|
111
|
-
kernel.CloseHandle(handle)
|
|
124
|
+
return _windows_pid_alive(pid)
|
|
112
125
|
try:
|
|
113
126
|
os.kill(pid, 0)
|
|
114
127
|
except PermissionError:
|
|
115
|
-
return
|
|
128
|
+
return None # another account's process under a reused id: not verified, and not this account's Fabric
|
|
116
129
|
except OSError:
|
|
117
130
|
return False
|
|
118
131
|
return True
|
|
@@ -142,7 +155,8 @@ def _read_small(path: Path) -> Optional[str]:
|
|
|
142
155
|
def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
|
|
143
156
|
now: Optional[float] = None) -> Dict[str, Any]:
|
|
144
157
|
"""`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
|
|
145
|
-
features}` or raises `HubError` (`not-running`, retryable; `not-a-hub
|
|
158
|
+
features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`, retryable when hub.json names a process of
|
|
159
|
+
another OS account, which is not verified, FAA-304). Reads no token. `estate` is
|
|
146
160
|
`{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
|
|
147
161
|
checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
|
|
148
162
|
root = Path(root) if root is not None else fabric_root()
|
|
@@ -174,7 +188,11 @@ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_a
|
|
|
174
188
|
token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
|
|
175
189
|
if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
|
|
176
190
|
raise not_hub("names a door token outside the registry root")
|
|
177
|
-
|
|
191
|
+
live = alive(pid)
|
|
192
|
+
if live is None: # FAA-304: another account's process under that id, so not this account's Fabric (as FAA-254)
|
|
193
|
+
raise HubError("not-a-hub", "Fabric's hub.json names process %d, which runs under another OS account: not "
|
|
194
|
+
"verified." % pid, True)
|
|
195
|
+
if not live:
|
|
178
196
|
raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
|
|
179
197
|
features = doc.get("features")
|
|
180
198
|
features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
"""One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock, the supervisor marker
|
|
2
|
-
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
2
|
+
in both its forms, and the wait budget under a supervisor. Part of the service kit (FAA-65, layer L3).
|
|
3
|
+
|
|
4
|
+
A lock that moves under its holder (FAA-303). Linux and Windows hold a name keyed by the lock's path beside the file
|
|
5
|
+
lock (an abstract unix socket, a named pipe), so a folder swapped under a running copy cannot let a second copy in.
|
|
6
|
+
macOS has no such name: flock (and the Node twin's O_EXLOCK) lock an inode, so after a data import swaps the data
|
|
7
|
+
folder a second copy locks the new file and starts. There the holder watches its lock instead
|
|
8
|
+
(``InstanceLock.watch``, started by ``hold_single_instance``): every ``LOCK_WATCH_SECONDS`` it compares the inode it
|
|
9
|
+
locked with the file at the path, and once the path names another file it stops with 75 and one sentence naming the
|
|
10
|
+
path, before writing anything more into a folder that is no longer the one it read. Two copies then overlap for at
|
|
11
|
+
most one interval. A path-keyed name on macOS was weighed and left: it needs a file in a folder outside the data folder
|
|
12
|
+
that the system never cleans and every copy can write, which the standard library cannot promise."""
|
|
3
13
|
from __future__ import annotations
|
|
4
14
|
|
|
5
15
|
import errno
|
|
@@ -10,6 +20,7 @@ import posixpath
|
|
|
10
20
|
import re
|
|
11
21
|
import socket
|
|
12
22
|
import sys
|
|
23
|
+
import threading
|
|
13
24
|
import time
|
|
14
25
|
from pathlib import Path
|
|
15
26
|
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
@@ -46,6 +57,11 @@ LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
|
46
57
|
LOCK_BACKOFF_MAX_SECONDS = 30.0
|
|
47
58
|
|
|
48
59
|
|
|
60
|
+
#: How often a holder that has no path-keyed name (macOS) compares the inode it locked with the file at the lock's
|
|
61
|
+
#: path (FAA-303): one stat and one fstat; two copies overlap for at most this long after a folder swap.
|
|
62
|
+
LOCK_WATCH_SECONDS = 2.0
|
|
63
|
+
|
|
64
|
+
|
|
49
65
|
class AlreadyRunning(ServiceError):
|
|
50
66
|
def __init__(self, holder_pid: Optional[int], lock_path: Path):
|
|
51
67
|
self.holder_pid = holder_pid
|
|
@@ -67,6 +83,7 @@ class InstanceLock:
|
|
|
67
83
|
self.path = Path(data_dir) / "service.lock"
|
|
68
84
|
self._fd: Optional[int] = None
|
|
69
85
|
self._name: Any = None
|
|
86
|
+
self._watch: Optional[threading.Event] = None
|
|
70
87
|
|
|
71
88
|
#: Windows locks a byte range, not the file: one byte far past the pid the file holds, so the
|
|
72
89
|
#: holder may rewrite the pid and another process may still read it (locking past the end of
|
|
@@ -157,7 +174,41 @@ class InstanceLock:
|
|
|
157
174
|
self._fd = fd
|
|
158
175
|
return True
|
|
159
176
|
|
|
177
|
+
def watch(self, on_lost: Optional[Callable[["InstanceLock"], None]] = None,
|
|
178
|
+
interval: float = LOCK_WATCH_SECONDS) -> "InstanceLock":
|
|
179
|
+
"""Re-check, every ``interval`` seconds on a daemon thread, that the file this copy locked is still the file at
|
|
180
|
+
the path (FAA-303); the first time the path names another file (a swapped folder's lock, or the one a second
|
|
181
|
+
copy made), call ``on_lost(lock)`` once and stop watching. The default stops the process with 75 and one
|
|
182
|
+
sentence (``stop_lost_holder``): it runs on the watch thread, so it cannot raise into the service. A path that
|
|
183
|
+
is gone or cannot be read now is read as nothing there yet and tried again next time: a second copy has to make
|
|
184
|
+
a file there to start, and the watch then sees that file. ``release`` ends the watch. Windows does not rename a
|
|
185
|
+
folder holding an open file: there it is a no-op, as for a lock not held."""
|
|
186
|
+
if WINDOWS or self._fd is None or self._watch is not None:
|
|
187
|
+
return self
|
|
188
|
+
fd, stop, react = self._fd, threading.Event(), on_lost or stop_lost_holder
|
|
189
|
+
|
|
190
|
+
def run() -> None:
|
|
191
|
+
while not stop.wait(interval):
|
|
192
|
+
try:
|
|
193
|
+
here, held = os.stat(str(self.path)), os.fstat(fd)
|
|
194
|
+
except OSError:
|
|
195
|
+
continue
|
|
196
|
+
# release() sets the event before it closes the descriptor, so a number reused after the close is never
|
|
197
|
+
# read as this lock's file: the event is checked after the stat.
|
|
198
|
+
if (here.st_dev, here.st_ino) == (held.st_dev, held.st_ino) or stop.is_set():
|
|
199
|
+
continue
|
|
200
|
+
stop.set()
|
|
201
|
+
react(self)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
self._watch = stop
|
|
205
|
+
threading.Thread(target=run, name="fabric-lock-watch", daemon=True).start()
|
|
206
|
+
return self
|
|
207
|
+
|
|
160
208
|
def release(self) -> None:
|
|
209
|
+
if self._watch is not None:
|
|
210
|
+
self._watch.set()
|
|
211
|
+
self._watch = None
|
|
161
212
|
if self._fd is not None:
|
|
162
213
|
os.close(self._fd)
|
|
163
214
|
self._fd = None
|
|
@@ -166,6 +217,22 @@ class InstanceLock:
|
|
|
166
217
|
self._name = None
|
|
167
218
|
|
|
168
219
|
|
|
220
|
+
def lost_lock_message(lock_path: Path) -> str:
|
|
221
|
+
"""The one sentence a holder whose lock moved under it stops with (FAA-303)."""
|
|
222
|
+
return ("%s is no longer the file this copy locked (the data folder moved or was swapped?): stopping, so a copy "
|
|
223
|
+
"started on the folder now at that path runs alone." % lock_path)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stop_lost_holder(lock: InstanceLock) -> None:
|
|
227
|
+
"""``InstanceLock.watch``'s default: print ``lost_lock_message`` and end the process with 75 at once. ``os._exit``,
|
|
228
|
+
not a graceful stop: a shutdown would write this copy's state into the folder now at the path, which is not the
|
|
229
|
+
one it read; the kit's journals make an abrupt stop recoverable (LC-03)."""
|
|
230
|
+
try:
|
|
231
|
+
print(lost_lock_message(lock.path), file=sys.stderr, flush=True)
|
|
232
|
+
finally:
|
|
233
|
+
os._exit(EXIT_ALREADY_RUNNING)
|
|
234
|
+
|
|
235
|
+
|
|
169
236
|
def lock_name(lock_path: Path, platform: str = sys.platform) -> Optional[str]:
|
|
170
237
|
"""The name the Node kit holds for this lock file; None where it holds none (macOS: O_EXLOCK)."""
|
|
171
238
|
if platform.startswith("linux"):
|
|
@@ -329,7 +396,8 @@ def add_supervisor_argument(parser: Any) -> Any:
|
|
|
329
396
|
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
330
397
|
sleep: Callable[[float], None] = time.sleep,
|
|
331
398
|
clock: Callable[[], float] = time.monotonic,
|
|
332
|
-
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None
|
|
399
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
400
|
+
on_lost: Optional[Callable[[InstanceLock], None]] = None) -> InstanceLock:
|
|
333
401
|
"""Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078).
|
|
334
402
|
|
|
335
403
|
Markers that disagree exit 75 at once, before the lock, naming them. Started by hand (no
|
|
@@ -337,7 +405,10 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
337
405
|
exit is a respawn every few seconds, forever; so a supervised copy first backs off in-process
|
|
338
406
|
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
339
407
|
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
340
|
-
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
408
|
+
``wait_seconds`` overrides the choice (0 = never wait); ``env`` and ``argv`` default to this process's.
|
|
409
|
+
|
|
410
|
+
Where the lock has no path-keyed name (macOS) the held lock is watched (``InstanceLock.watch``, FAA-303): a folder
|
|
411
|
+
swapped under the running copy stops it with 75; ``on_lost`` replaces that reaction."""
|
|
341
412
|
marker = supervisor_marker(env, argv)
|
|
342
413
|
if marker["kind"] == "conflict":
|
|
343
414
|
print(marker_conflict_message(marker), file=sys.stderr)
|
|
@@ -349,7 +420,7 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
349
420
|
announced = False
|
|
350
421
|
while True:
|
|
351
422
|
try:
|
|
352
|
-
|
|
423
|
+
lock = InstanceLock(data_dir).acquire()
|
|
353
424
|
except AlreadyRunning as exc:
|
|
354
425
|
remaining = deadline - clock()
|
|
355
426
|
if remaining <= 0:
|
|
@@ -360,3 +431,5 @@ def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *
|
|
|
360
431
|
announced = True
|
|
361
432
|
sleep(min(delay, remaining))
|
|
362
433
|
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
434
|
+
continue
|
|
435
|
+
return lock.watch(on_lost) if lock_name(lock.path) is None else lock
|