@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
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py
CHANGED
|
@@ -22,6 +22,7 @@ import fabric_service as fs # noqa: E402 - beside this file
|
|
|
22
22
|
from fabric_http import redact_url # noqa: E402
|
|
23
23
|
from fabric_secrets import ascii_decoded, repository_name, secret_argument # noqa: E402
|
|
24
24
|
import fabric_peer_owner as po # noqa: E402 - DEC-0154 HA-2
|
|
25
|
+
import fabric_supervisors as _sup # noqa: E402 - where a LaunchDaemon's plist lives (FAA-278)
|
|
25
26
|
import socket # noqa: E402
|
|
26
27
|
|
|
27
28
|
|
|
@@ -47,7 +48,17 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
|
|
|
47
48
|
except ConnectionRefusedError:
|
|
48
49
|
return "NOT_RUN", "nothing listens on %s (down)" % where
|
|
49
50
|
except OSError as exc: # a full backlog, a timeout: the well-known rule reports what answers, each call checked
|
|
50
|
-
|
|
51
|
+
# The listener table still names who holds the port (0.74.0 product reading 10, FAA-260 item 12): another
|
|
52
|
+
# account's or root's listener is foreign-user without a connection; any other owner is named beside NOT_RUN.
|
|
53
|
+
held = po.listener(port)
|
|
54
|
+
if held.kind in (po.ANOTHER_ACCOUNT, po.SYSTEM):
|
|
55
|
+
why = "another OS account's process" if held.kind == po.ANOTHER_ACCOUNT else "a system account's process"
|
|
56
|
+
return "FAIL", ("foreign-user: %s is listened on by %s (its listener read by %s; the connect failed with %s); "
|
|
57
|
+
"no credential was sent and nothing it answers is read as this service's"
|
|
58
|
+
% (where, why, held.method, type(exc).__name__))
|
|
59
|
+
named = held.kind if held.kind != po.UNKNOWN else "unknown (%s)" % held.reason
|
|
60
|
+
return "NOT_RUN", "could not connect to %s to tell its owner (%s); its listener reads as %s" % (
|
|
61
|
+
where, type(exc).__name__, named)
|
|
51
62
|
try:
|
|
52
63
|
owner = po.server_end(sock)
|
|
53
64
|
finally:
|
|
@@ -60,6 +71,39 @@ def listener_owner_verdict(port: int) -> Tuple[str, str]:
|
|
|
60
71
|
return "FAIL", ("foreign-user: %s is held by %s (read by %s); no credential was sent and nothing it answers is read "
|
|
61
72
|
"as this service's" % (where, why, owner.method))
|
|
62
73
|
|
|
74
|
+
def port_claim_verdict(path: Path, d: Dict[str, Any], directory: Path, port: int, remote: bool) -> Tuple[str, str]:
|
|
75
|
+
"""`descriptor.port-claim`: (verdict, evidence). Another descriptor here declares this `id.instance`, or a local
|
|
76
|
+
origin on the same port, compared by the port as the writers refuse it, so `:08080` beside `:8080` is one port
|
|
77
|
+
(0.74.0 seam reading 10, FAA-260)."""
|
|
78
|
+
me = "%s.%s" % (d.get("id"), d.get("instance", "default"))
|
|
79
|
+
clashes = []
|
|
80
|
+
for other_path, other in fs.read_descriptors(directory):
|
|
81
|
+
key = "%s.%s" % (other.get("id"), other.get("instance", "default"))
|
|
82
|
+
if other_path.resolve() == path.resolve():
|
|
83
|
+
continue
|
|
84
|
+
if key == me:
|
|
85
|
+
clashes.append("%s declared again in %s" % (me, other_path.name))
|
|
86
|
+
# DEC-0069: a descriptor without an origin (an app's) claims no port, so two of them never clash.
|
|
87
|
+
elif not remote and "origin" in d and fs.placement_of(other) != "remote" and port and _port_or_none(
|
|
88
|
+
other.get("origin")) == port:
|
|
89
|
+
clashes.append("port %d also claimed by %s" % (port, key))
|
|
90
|
+
if remote:
|
|
91
|
+
unique = "a remote origin claims no port here"
|
|
92
|
+
elif "origin" not in d:
|
|
93
|
+
unique = "an app's descriptor names no origin, so it claims no port (FAC-SEM-010)"
|
|
94
|
+
else:
|
|
95
|
+
unique = "no other descriptor here declares port %d (HA-1's claim on the computer is FAA-225)" % port
|
|
96
|
+
return ("FAIL" if clashes else "PASS"), ("; ".join(clashes) or unique)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _port_or_none(origin: Any) -> Optional[int]:
|
|
100
|
+
"""The port a local origin claims, or None for an origin the descriptor rules refuse (descriptor.valid names it)."""
|
|
101
|
+
try:
|
|
102
|
+
return fs.port_of(str(origin))
|
|
103
|
+
except fs.ServiceError:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
63
107
|
_LOCAL_PATH = re.compile(r"[/~.\\]|[A-Za-z]:[/\\]") # how a local path starts, never a user before an `@`
|
|
64
108
|
_SCP_HOST = re.compile(r"[^@/:\\]+:") # what follows the `@` of user[:password]@host:path
|
|
65
109
|
_SPECIAL = re.compile(r"(?:https?|wss?|ftp|file):", re.I) # the URL standard's special schemes, read with or without `//`
|
|
@@ -91,9 +135,48 @@ import fabric_actions as fa # noqa: E402 - beside this file (DEC-0121)
|
|
|
91
135
|
import fabric_a2a as fa2 # noqa: E402 - beside this file (DEC-0122)
|
|
92
136
|
|
|
93
137
|
|
|
94
|
-
def
|
|
95
|
-
"""
|
|
138
|
+
def other_domain_copy(label: object, domain: str, user_name: object = None) -> Optional[str]:
|
|
139
|
+
"""The other launchd domain when it also has job `label` loaded, a second copy (FAA-286 item 6), else None. For a
|
|
140
|
+
LaunchDaemon (`system`) that is a gui/<uid> domain: first the one of its plist's UserName (`user_name`), where a
|
|
141
|
+
leftover LaunchAgent of a dedicated account runs (FAA-317), then the probe's own."""
|
|
142
|
+
if domain == "system":
|
|
143
|
+
uids = [_sup._account_uid(user_name)] if isinstance(user_name, str) and user_name else []
|
|
144
|
+
uids.append(getattr(os, "getuid", lambda: 0)())
|
|
145
|
+
others = ["gui/%d" % uid for i, uid in enumerate(uids) if uid is not None and uid not in uids[:i]]
|
|
146
|
+
else:
|
|
147
|
+
others = ["system"]
|
|
148
|
+
for other in others:
|
|
149
|
+
try:
|
|
150
|
+
out = subprocess.run(["launchctl", "print", "%s/%s" % (other, label)], capture_output=True, text=True,
|
|
151
|
+
timeout=10)
|
|
152
|
+
except (OSError, subprocess.SubprocessError):
|
|
153
|
+
continue
|
|
154
|
+
if out.returncode == 0:
|
|
155
|
+
return other
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def in_launch_daemons(plist_path: Path) -> bool:
|
|
160
|
+
"""Whether a plist sits in /Library/LaunchDaemons, where launchd loads it at boot as a LaunchDaemon (FAA-278): the
|
|
161
|
+
supervisors' own rule, imported (FAA-317), which a test may point elsewhere through their SYSTEM_DAEMONS."""
|
|
162
|
+
return _sup._in_daemons(plist_path)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def plist_problems(plist: dict, label: object, token: object = None, *, system: bool = False,
|
|
166
|
+
commands: object = None) -> list:
|
|
167
|
+
"""What makes a service's launchd job unfit to stay up: identity, restart, secrets. A LaunchDaemon (`system`) also
|
|
168
|
+
needs UserName, or it runs as root (FAA-286 item 22), and its descriptor's commands an absolute path, since a
|
|
169
|
+
`~` would be root's home when the job's update runs as root (item 23)."""
|
|
96
170
|
problems = []
|
|
171
|
+
if system and not (isinstance(plist.get("UserName"), str) and plist.get("UserName")):
|
|
172
|
+
problems.append("a LaunchDaemon without UserName runs as root, and every client refuses root's listener "
|
|
173
|
+
"(DEC-0154 HA-2)")
|
|
174
|
+
if system and isinstance(commands, dict):
|
|
175
|
+
for name, argv in sorted(commands.items()):
|
|
176
|
+
first = argv[0] if isinstance(argv, list) and argv else argv
|
|
177
|
+
if isinstance(first, str) and first.startswith("~"):
|
|
178
|
+
problems.append("commands.%s starts with ~, which is root's home for a LaunchDaemon: write the absolute "
|
|
179
|
+
"path" % name)
|
|
97
180
|
if plist.get("Label") != label:
|
|
98
181
|
problems.append("Label %r" % plist.get("Label"))
|
|
99
182
|
if plist.get("RunAtLoad") is not True:
|
|
@@ -729,3 +812,63 @@ def service_checkout(descriptor: Any, repo: Optional[str]) -> Tuple[Optional[Pat
|
|
|
729
812
|
if same_repository(_git_out(Path(top), "remote", "get-url", "origin") or "", source):
|
|
730
813
|
return Path(top), "the checkout of %s that holds %s" % (shown_repository(source), place)
|
|
731
814
|
return None, "no checkout of %s holds the descriptor's commands, manifest or launchd job; pass --repo" % shown_repository(source)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# FAA-298: a service with limits of its own holds work visibly (fabric_holds), never silently.
|
|
818
|
+
_HOLD_USE = "HoldBook|waiting_message|waitingMessage|hold_job|holdJob" # git grep -E is POSIX: no word boundary
|
|
819
|
+
#: the kit's own copies, and a vendored copy of this file (its pattern names the kit's words): neither is a use
|
|
820
|
+
_HOLD_KIT_FILES = ("fabric_holds.py", "fabric-holds.mjs", "check_service_rules.py")
|
|
821
|
+
#: only code holds work: a README or a note naming HoldBook is no use of it (0.81.0 seam reading)
|
|
822
|
+
_HOLD_CODE = ("*.py", "*.mjs", "*.js", "*.ts")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def declared_limits(wk: Any, usage_report: Any = None) -> List[str]:
|
|
826
|
+
"""The limits a service declares: the load declaration's `limits` (DEC-0104), and the usage report's `budget` and
|
|
827
|
+
`budgets` (DEC-0021, DEC-0027) when the probe read one. An invalid load declaration reads as not declared."""
|
|
828
|
+
import fabric_load # noqa: E402 - beside this file
|
|
829
|
+
found: List[str] = []
|
|
830
|
+
view = fabric_load.load_view(wk)
|
|
831
|
+
limits = (view.get("load") or {}).get("limits") if view.get("valid") else None
|
|
832
|
+
if isinstance(limits, dict):
|
|
833
|
+
found += ["load %s %s" % (name, limits[name]) for name in ("concurrency", "perCaller", "queueMax") if name in limits]
|
|
834
|
+
if isinstance(usage_report, dict):
|
|
835
|
+
budget = usage_report.get("budget")
|
|
836
|
+
if isinstance(budget, dict) and "limitUsd" in budget:
|
|
837
|
+
found.append("usage budget USD %s a %s" % (budget.get("limitUsd"), budget.get("period")))
|
|
838
|
+
budgets = usage_report.get("budgets")
|
|
839
|
+
if isinstance(budgets, list) and budgets:
|
|
840
|
+
found.append("usage budgets (%d)" % len(budgets))
|
|
841
|
+
return found
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def holds_verdict(limits: List[str], repo: Optional[Path], why: str,
|
|
845
|
+
budget: float = OPENS_IN_HOST_BUDGET) -> Tuple[str, str]:
|
|
846
|
+
"""`limits.holds` over one checkout: PASS when a tracked file outside the kit's own copy and the tests holds work
|
|
847
|
+
through fabric_holds (HoldBook, waiting_message, hold_job) in a code file (.py, .mjs, .js, .ts); NOT_RUN when it
|
|
848
|
+
declares none, or its checkout or git cannot be read in the budget. A service that declares limits and holds none
|
|
849
|
+
through the kit is NOT_RUN with a migration note naming fabric_holds, never FAIL: services built before the module
|
|
850
|
+
existed could not have adopted it, and whether one holds work silently or refuses it cannot be read from its tree."""
|
|
851
|
+
if not limits:
|
|
852
|
+
return "NOT_RUN", "the service declares no limits (a load declaration's limits, a usage report's budget or budgets)"
|
|
853
|
+
if repo is None:
|
|
854
|
+
return "NOT_RUN", why
|
|
855
|
+
if not shutil.which("git"):
|
|
856
|
+
return "NOT_RUN", "git is not installed"
|
|
857
|
+
try:
|
|
858
|
+
listed = subprocess.run(["git", "-C", str(repo), "grep", "-I", "-l", "-z", "-E", _HOLD_USE, "--"]
|
|
859
|
+
+ list(_HOLD_CODE), capture_output=True,
|
|
860
|
+
timeout=max(0.05, budget))
|
|
861
|
+
except subprocess.TimeoutExpired:
|
|
862
|
+
return "NOT_RUN", "the %.1f s budget ran out while git read %s" % (budget, repo)
|
|
863
|
+
except OSError as exc:
|
|
864
|
+
return "NOT_RUN", "git could not read %s: %s" % (repo, exc)
|
|
865
|
+
if listed.returncode not in (0, 1):
|
|
866
|
+
return "NOT_RUN", "git grep could not read %s" % repo
|
|
867
|
+
users = [p for p in listed.stdout.decode("utf-8", "surrogateescape").split("\0")
|
|
868
|
+
if p and p.rsplit("/", 1)[-1] not in _HOLD_KIT_FILES and not _TEST_PATH.search(p)]
|
|
869
|
+
if users:
|
|
870
|
+
return "PASS", "declares %s; %s hold(s) work through fabric_holds" % ("; ".join(limits), ", ".join(users[:5]))
|
|
871
|
+
return "NOT_RUN", ("migration: declares %s, and no tracked code file outside the kit and the tests holds work "
|
|
872
|
+
"through fabric_holds (HoldBook, waiting_message, hold_job) yet. Adopt fabric_holds so each hold "
|
|
873
|
+
"is said on the job, the operator is pushed once per hold and takes a scoped lift (FAA-298, "
|
|
874
|
+
"references/interop.md); until then this rule is not run" % "; ".join(limits))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// connectionsExtension builds the `extensions` entry a service declares, `state` from its own checks and its degraded[].
|
|
4
4
|
// Part of the Node service kit (FAA-62, layer L3); the twin of fabric_connections.py. Node 20+, no dependency.
|
|
5
5
|
import { ServiceError } from './fabric-base.mjs';
|
|
6
|
-
import { BEARER_INSIDE,
|
|
6
|
+
import { BEARER_INSIDE, hasJwt, longSecret, secretUrl } from './fabric-secrets.mjs';
|
|
7
7
|
import { isObject } from './fabric-jsvalue.mjs';
|
|
8
8
|
|
|
9
9
|
export const CONNECTIONS_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/connections/0.1';
|
|
@@ -112,8 +112,9 @@ const longRun = (value) =>
|
|
|
112
112
|
const cut = at < 0 ? -1 : piece.indexOf('://', at);
|
|
113
113
|
return longSecret(cut < 0 ? piece : piece.slice(0, cut));
|
|
114
114
|
});
|
|
115
|
+
// hasJwt reads a JWT in one pass, where JWT_SHAPE backtracks from every `eyJ` of a long run (FAA-313).
|
|
115
116
|
const credential = (value, runs) =>
|
|
116
|
-
|
|
117
|
+
hasJwt(value) || KEY_SHAPE.test(value) || BEARER_INSIDE.test(value) || (runs && longRun(value)) || urlsIn(value).some(secretUrl);
|
|
117
118
|
|
|
118
119
|
/** What the schema cannot state: an id named twice, a credential in any string member. */
|
|
119
120
|
function beyondShape(list) {
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// effects: { 'example.write': 'draft' }, issuer: wk.auth.delegation.issuer });
|
|
10
10
|
// const verdict = await gate(toolName, req.headers); // before the tool runs
|
|
11
11
|
// if (!verdict.ok) return refusalToolResult(verdict);
|
|
12
|
+
//
|
|
13
|
+
// Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
12
14
|
|
|
13
15
|
import crypto from 'node:crypto';
|
|
14
16
|
import fs from 'node:fs';
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
7
8
|
import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
8
9
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
9
|
-
import { ANOTHER_ACCOUNT } from './fabric-peer-owner.mjs'; // L0: who listens on a port (DEC-0154 HA-2, FAA-225)
|
|
10
|
+
import { ANOTHER_ACCOUNT, SYSTEM, THIS_ACCOUNT, UNKNOWN, normHost, owner as peerOwnerOf, peer as peerOfSocket } from './fabric-peer-owner.mjs'; // L0: who listens on a port and who connected (DEC-0154 HA-2, FAA-225, FAA-227)
|
|
10
11
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
11
12
|
import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
|
|
12
13
|
import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
|
|
@@ -17,7 +18,7 @@ import { ROUTES } from './fabric-usage.mjs';
|
|
|
17
18
|
// document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
18
19
|
export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
19
20
|
|
|
20
|
-
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.
|
|
21
|
+
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.7';
|
|
21
22
|
|
|
22
23
|
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
23
24
|
|
|
@@ -329,6 +330,82 @@ server.once('error', async () => {
|
|
|
329
330
|
server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
|
|
330
331
|
`;
|
|
331
332
|
|
|
333
|
+
// How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
|
|
334
|
+
// FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of it
|
|
335
|
+
// and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account other
|
|
336
|
+
// than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table. The twin
|
|
337
|
+
// of fabric_descriptor.py's PEER_CHECKS and LoopbackHTTPServer.
|
|
338
|
+
export const PEER_CHECKS = Object.freeze(['refuse', 'strict', 'off']);
|
|
339
|
+
|
|
340
|
+
/** Why a server refuses the peer `owner` (a fabric-peer-owner owner) under `mode`, or null to serve it: the owner's
|
|
341
|
+
* kind, or for `unknown` its reason; never an address or anything the peer sent. */
|
|
342
|
+
export function connectionRefusal(owner, mode = 'refuse') {
|
|
343
|
+
if (!PEER_CHECKS.includes(mode)) throw new ServiceError(`peerCheck must be one of ${PEER_CHECKS.join(', ')}.`);
|
|
344
|
+
if (mode === 'off' || owner === null || owner === undefined) return null;
|
|
345
|
+
const kind = owner.kind ?? UNKNOWN;
|
|
346
|
+
if (kind === ANOTHER_ACCOUNT) return kind;
|
|
347
|
+
if (mode === 'strict' && kind !== THIS_ACCOUNT) {
|
|
348
|
+
if (kind === SYSTEM && owner.uid === 0) return null;
|
|
349
|
+
return owner.reason || kind;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Whether a connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the only peers
|
|
355
|
+
* whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read counts as
|
|
356
|
+
* loopback, so its owner is still read from the table. The twin of fabric_descriptor.py's is_loopback_peer. */
|
|
357
|
+
export function isLoopbackPeer(address) {
|
|
358
|
+
if (typeof address !== 'string') return true;
|
|
359
|
+
const host = normHost(address);
|
|
360
|
+
if (net.isIPv4(host)) return host.split('.')[0] === '127';
|
|
361
|
+
if (net.isIPv6(host)) return host === '::1';
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const connectionOwners = new WeakMap();
|
|
366
|
+
|
|
367
|
+
/** The owner of the peer behind an HTTP request's socket (`req.socket`) a loopbackServer served, or null. */
|
|
368
|
+
export function connectionOwner(socket) {
|
|
369
|
+
return connectionOwners.get(socket) ?? null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** A net.Server to listen on in place of `httpServer` (DEC-0154 HA-2's server side, FAA-227): each connection arrives
|
|
373
|
+
* paused, its peer is told from the computer's socket table (`peer`, fabric-peer-owner's by default), and only a peer
|
|
374
|
+
* `peerCheck` serves is handed to `httpServer` (never listened on itself), which then reads the request; a refused one
|
|
375
|
+
* is destroyed unread, counted in `peerRefusals`, and reported to `onRefused(owner, reason)` (default: one line on
|
|
376
|
+
* stderr with the owner's kind, uid and pid). Handlers find their peer's owner with `connectionOwner(req.socket)`.
|
|
377
|
+
* The check applies to loopback peers only: a peer at another address (a server bound to 0.0.0.0) is no local
|
|
378
|
+
* account, so its table is not read and it is served under `refuse` and `strict` alike, with no owner.
|
|
379
|
+
* Close the returned server to stop accepting; `httpServer.closeAllConnections()` ends the served ones. */
|
|
380
|
+
export function loopbackServer(httpServer, { peerCheck = 'refuse', peer = peerOfSocket, onRefused } = {}) {
|
|
381
|
+
connectionRefusal(null, peerCheck);
|
|
382
|
+
const report = onRefused ?? ((who, reason) => process.stderr.write(
|
|
383
|
+
`fabric: refused a loopback peer (${reason}, uid ${who?.uid ?? 'None'}, pid ${who?.pid ?? 'None'}) before reading it (DEC-0154)\n`));
|
|
384
|
+
const gate = net.createServer({ pauseOnConnect: true }, async (socket) => {
|
|
385
|
+
let who = null;
|
|
386
|
+
if (peerCheck !== 'off' && isLoopbackPeer(socket.remoteAddress)) {
|
|
387
|
+
try {
|
|
388
|
+
who = await peer(socket);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
who = peerOwnerOf(UNKNOWN, { reason: `reader: ${err?.name ?? 'Error'}` });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const reason = connectionRefusal(who, peerCheck);
|
|
394
|
+
if (reason !== null) {
|
|
395
|
+
gate.peerRefusals += 1;
|
|
396
|
+
socket.destroy();
|
|
397
|
+
report(who, reason);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (socket.destroyed) return;
|
|
401
|
+
if (who !== null) connectionOwners.set(socket, who);
|
|
402
|
+
httpServer.emit('connection', socket);
|
|
403
|
+
socket.resume();
|
|
404
|
+
});
|
|
405
|
+
gate.peerRefusals = 0;
|
|
406
|
+
return gate;
|
|
407
|
+
}
|
|
408
|
+
|
|
332
409
|
/** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
|
|
333
410
|
* (FAA-225, DEC-0154 HA-2). */
|
|
334
411
|
export function portOwner(port) {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Work a service holds by its own limits, said to its caller and its operator (FAA-298): a held job's statusMessage in
|
|
2
|
+
// the contract's pause grammar (`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`; the
|
|
3
|
+
// pinned interop-job schema is closed, so `waiting {reason, since, until, message}` rides statusMessage, never a field
|
|
4
|
+
// of the job), one push per hold, and the operator's scoped lift. The twin of fabric_holds.py, held to the same
|
|
5
|
+
// vectors (test/fixtures/holds/waiting-vectors.json). Part of the Node service kit (layer L3, after fabric-interop).
|
|
6
|
+
// Node 20+, no dependency.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { ServiceError, nowIso } from './fabric-base.mjs';
|
|
10
|
+
import { atomicWrite } from './fabric-paths.mjs';
|
|
11
|
+
import { decisionShape } from './fabric-actions.mjs';
|
|
12
|
+
import { QUOTA_EXHAUSTED, codePoints, pauseMessage, readJobPause, utcTime } from './fabric-interop-rules.mjs';
|
|
13
|
+
import { secretValue } from './fabric-secrets.mjs';
|
|
14
|
+
|
|
15
|
+
/** The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap. */
|
|
16
|
+
export const HOLD_REASONS = Object.freeze(['daily-quota', 'caller-share', 'concurrency', 'money']);
|
|
17
|
+
/** How the push names each reason to a person. */
|
|
18
|
+
export const REASON_WORDS = Object.freeze({
|
|
19
|
+
'daily-quota': 'the daily quota', 'caller-share': 'its per-caller share',
|
|
20
|
+
concurrency: 'the concurrency cap', money: 'the money cap',
|
|
21
|
+
});
|
|
22
|
+
/** The push's kind: a decision for the operator (level notice, notify true). */
|
|
23
|
+
export const HOLD_EVENT_KIND = 'limit.held';
|
|
24
|
+
/** The record of a lift applied (level info, no notify). */
|
|
25
|
+
export const LIFT_EVENT_KIND = 'limit.lifted';
|
|
26
|
+
/** The decision action a lift carries (DEC-0121 AP-2 shape). */
|
|
27
|
+
export const LIFT_ACTION = 'limit.lift';
|
|
28
|
+
const MAX_STATUS_MESSAGE = 500;
|
|
29
|
+
const WAITING = /^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?$/;
|
|
30
|
+
const SIZES = ['jobs', 'usd', 'limit'];
|
|
31
|
+
|
|
32
|
+
const instant = (value, name) => {
|
|
33
|
+
if (!Number.isFinite(utcTime(value))) throw new ServiceError(`${name} is a UTC instant (Z, at most 3 fraction digits).`);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const reasonOk = (reason, what) => {
|
|
37
|
+
if (!HOLD_REASONS.includes(reason)) throw new ServiceError(`A ${what}'s reason is one of ${HOLD_REASONS.join(', ')}.`);
|
|
38
|
+
};
|
|
39
|
+
// One run of ECMAScript's whitespace, written out as fabric_secrets.JS_SPACE is, the class both twins squash with
|
|
40
|
+
// (FAA-316): Python's str.split() also takes U+001C to U+001F and U+0085 and not U+FEFF.
|
|
41
|
+
const SPACES = /[\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
|
|
42
|
+
const squash = (text) => String(text).replace(SPACES, ' ').replace(/^ | $/g, '');
|
|
43
|
+
|
|
44
|
+
/** A held job's statusMessage; the message is shortened, never refused, to fit 500 characters. */
|
|
45
|
+
export function waitingMessage(reason, since, until = null, message = null) {
|
|
46
|
+
reasonOk(reason, 'hold');
|
|
47
|
+
instant(since, 'since');
|
|
48
|
+
if (until !== null && until !== undefined) instant(until, 'until');
|
|
49
|
+
else until = null;
|
|
50
|
+
let text = `waiting ${reason} since ${since}`;
|
|
51
|
+
const head = QUOTA_EXHAUSTED.length + (until === null ? 0 : ' until '.length + until.length) + ': '.length;
|
|
52
|
+
if (message !== null && message !== undefined) {
|
|
53
|
+
const squashed = squash(message);
|
|
54
|
+
const room = MAX_STATUS_MESSAGE - head - codePoints(text) - ': '.length;
|
|
55
|
+
if (squashed && room > 0) {
|
|
56
|
+
text += ': ' + (codePoints(squashed) <= room ? squashed : [...squashed].slice(0, room - 3).join('').replace(/ $/, '') + '...');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return pauseMessage(until === null ? { text } : { resetsAt: until, text });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds, else null. */
|
|
63
|
+
export function readWaiting(answer) {
|
|
64
|
+
const pause = readJobPause(answer);
|
|
65
|
+
if (!pause.paused) return null;
|
|
66
|
+
let rest = answer.job.statusMessage.slice(QUOTA_EXHAUSTED.length);
|
|
67
|
+
if ('resetsAt' in pause) rest = rest.slice(' until '.length + pause.resetsAt.length);
|
|
68
|
+
if (!rest.startsWith(': ')) return null;
|
|
69
|
+
const match = WAITING.exec(rest.slice(2));
|
|
70
|
+
if (!match || !Number.isFinite(utcTime(match[2]))) return null;
|
|
71
|
+
const waiting = { reason: match[1], since: match[2] };
|
|
72
|
+
if ('resetsAt' in pause) waiting.until = pause.resetsAt;
|
|
73
|
+
if (match[3] !== undefined) waiting.message = match[3];
|
|
74
|
+
return waiting;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Put one job of a fabric-interop JobStore on hold (JobStore.pause with the waiting text). */
|
|
78
|
+
export function holdJob(jobs, jobId, reason, since, until = null, message = null) {
|
|
79
|
+
let text = waitingMessage(reason, since, until, message).slice(QUOTA_EXHAUSTED.length);
|
|
80
|
+
if (until !== null && until !== undefined) text = text.slice(' until '.length + until.length);
|
|
81
|
+
return jobs.pause(jobId, until === null || until === undefined ? { text: text.slice(2) } : { resetsAt: until, text: text.slice(2) });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The push's one sentence: who, what, why, when it frees by itself, and the question. */
|
|
85
|
+
export function holdSentence(caller, work, reason, until) {
|
|
86
|
+
const frees = until !== null && until !== undefined ? `it frees by itself at ${until}`
|
|
87
|
+
: (['concurrency', 'caller-share'].includes(reason) ? 'it frees by itself when a running job ends' : 'it does not free by itself');
|
|
88
|
+
return `${work} for ${caller} is held by ${REASON_WORDS[reason]}; ${frees}. Raise the limit for it?`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function text(value, name, high = 200) {
|
|
92
|
+
if (typeof value !== 'string' || !squash(value) || value.length > high) throw new ServiceError(`${name} is a text of 1 to ${high} characters.`);
|
|
93
|
+
if (secretValue(value)) throw new ServiceError(`${name} looks like a credential; a hold names callers and work, never a secret.`);
|
|
94
|
+
return squash(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const newId = () => crypto.randomUUID().replaceAll('-', '');
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The holds a service's limits put on work and the operator's lifts, in one file (0600, written atomically).
|
|
101
|
+
* `events`, when given, is a function `({kind, level, text, subject, notify}) => event` that writes the push and the
|
|
102
|
+
* lift record to the service's event log; without it they are returned for the service to write.
|
|
103
|
+
*/
|
|
104
|
+
export class HoldBook {
|
|
105
|
+
constructor(file, { events = null, clock = nowIso } = {}) {
|
|
106
|
+
this.file = file;
|
|
107
|
+
this.events = events;
|
|
108
|
+
this.clock = clock;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
load() {
|
|
112
|
+
if (!fs.existsSync(this.file)) return { holds: [], lifts: [] };
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
116
|
+
} catch {
|
|
117
|
+
throw new ServiceError('The hold book is not JSON; it is kept as it is.');
|
|
118
|
+
}
|
|
119
|
+
if (data === null || typeof data !== 'object' || !Array.isArray(data.holds) || !Array.isArray(data.lifts)) {
|
|
120
|
+
throw new ServiceError('The hold book is not {holds, lifts}; it is kept as it is.');
|
|
121
|
+
}
|
|
122
|
+
return data;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
save(data) { atomicWrite(this.file, JSON.stringify(data, null, 1) + '\n', 0o600); }
|
|
126
|
+
|
|
127
|
+
now(now) { return instant(now ?? this.clock(), 'now'); }
|
|
128
|
+
|
|
129
|
+
emit(kind, level, sentence, subject, notify) {
|
|
130
|
+
const event = { kind, level, text: sentence, subject };
|
|
131
|
+
if (notify) event.notify = true;
|
|
132
|
+
return this.events ? this.events(event) : event;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
static expire(data, at) {
|
|
136
|
+
for (const hold of data.holds) {
|
|
137
|
+
if (hold.closed === null && hold.until !== null && utcTime(hold.until) <= at) {
|
|
138
|
+
hold.closed = 'expired';
|
|
139
|
+
hold.closedAt = hold.until;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
static live(lift, at) {
|
|
145
|
+
if (lift.from !== null && lift.from !== undefined && utcTime(lift.from) > at) return false;
|
|
146
|
+
if (lift.until !== null && lift.until !== undefined && utcTime(lift.until) <= at) return false;
|
|
147
|
+
return (lift.left ?? 1) > 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Hold `work` for `caller` by `reason`: {hold, statusMessage, push}; push only when this call opened the hold. */
|
|
151
|
+
hold({ caller, work, reason, until = null, message = null, jobId = null, now = null }) {
|
|
152
|
+
caller = text(caller, 'caller');
|
|
153
|
+
work = text(work, 'work');
|
|
154
|
+
reasonOk(reason, 'hold');
|
|
155
|
+
const at = this.now(now);
|
|
156
|
+
if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) {
|
|
157
|
+
throw new ServiceError(`until ${until} has passed: the limit no longer holds the work.`);
|
|
158
|
+
}
|
|
159
|
+
const data = this.load();
|
|
160
|
+
HoldBook.expire(data, utcTime(at));
|
|
161
|
+
let hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
|
|
162
|
+
const opened = hold === undefined;
|
|
163
|
+
if (opened) {
|
|
164
|
+
hold = { id: newId(), reason, caller, work, since: at, until, jobs: [], closed: null };
|
|
165
|
+
data.holds.push(hold);
|
|
166
|
+
}
|
|
167
|
+
if (jobId !== null && !hold.jobs.includes(String(jobId))) hold.jobs.push(String(jobId));
|
|
168
|
+
this.save(data);
|
|
169
|
+
const statusMessage = waitingMessage(reason, hold.since, hold.until, message);
|
|
170
|
+
const push = opened ? this.emit(HOLD_EVENT_KIND, 'notice', holdSentence(caller, work, reason, until),
|
|
171
|
+
{ type: 'limit', id: `${reason}:${caller}`.slice(0, 200), label: REASON_WORDS[reason] }, true) : null;
|
|
172
|
+
return { hold: { ...hold }, statusMessage, push };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again. */
|
|
176
|
+
release({ reason, caller, now = null }) {
|
|
177
|
+
const at = this.now(now);
|
|
178
|
+
const data = this.load();
|
|
179
|
+
const hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
|
|
180
|
+
if (!hold) return null;
|
|
181
|
+
hold.closed = 'released';
|
|
182
|
+
hold.closedAt = at;
|
|
183
|
+
this.save(data);
|
|
184
|
+
return { ...hold };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
openHolds(now = null) {
|
|
188
|
+
const data = this.load();
|
|
189
|
+
HoldBook.expire(data, utcTime(this.now(now)));
|
|
190
|
+
return data.holds.filter((h) => h.closed === null).map((h) => ({ ...h }));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Record the operator's answer as a scoped lift: exactly one of jobs, usd or limit (limit needs until). */
|
|
194
|
+
lift({ reason, decision, caller = null, jobId = null, jobs = null, usd = null, limit = null, until = null, now = null }) {
|
|
195
|
+
reasonOk(reason, 'lift');
|
|
196
|
+
if (!decisionShape(decision) || decision.action !== LIFT_ACTION) {
|
|
197
|
+
throw new ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
|
|
198
|
+
+ "words verbatim): operatorDecision({ action: 'limit.lift', instruction: words }).");
|
|
199
|
+
}
|
|
200
|
+
const sizes = { jobs, usd, limit };
|
|
201
|
+
const given = SIZES.filter((name) => sizes[name] !== null && sizes[name] !== undefined);
|
|
202
|
+
if (given.length !== 1) throw new ServiceError('A lift is one of jobs, usd or limit.');
|
|
203
|
+
const kind = given[0];
|
|
204
|
+
const size = sizes[kind];
|
|
205
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || (kind !== 'usd' && !Number.isInteger(size))) {
|
|
206
|
+
throw new ServiceError(`A lift's ${kind} is a positive ${kind === 'usd' ? 'number' : 'integer'}.`);
|
|
207
|
+
}
|
|
208
|
+
const at = this.now(now);
|
|
209
|
+
if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) throw new ServiceError("A lift's window ends after now.");
|
|
210
|
+
if (kind === 'limit' && until === null) {
|
|
211
|
+
throw new ServiceError('A raised limit needs its window\'s end (until): it falls back when the window ends.');
|
|
212
|
+
}
|
|
213
|
+
const record = { id: newId(), reason, caller: caller === null ? null : text(caller, 'caller'),
|
|
214
|
+
job: jobId === null ? null : String(jobId), from: at, until, [kind]: size, decision: { ...decision }, at };
|
|
215
|
+
if (kind !== 'limit') record.left = size;
|
|
216
|
+
const data = this.load();
|
|
217
|
+
HoldBook.expire(data, utcTime(at));
|
|
218
|
+
for (const hold of data.holds) {
|
|
219
|
+
if (hold.closed === null && hold.reason === reason && (record.caller === null || record.caller === hold.caller)
|
|
220
|
+
&& (jobId === null || hold.jobs.includes(String(jobId)))) {
|
|
221
|
+
hold.closed = 'lifted';
|
|
222
|
+
hold.closedAt = at;
|
|
223
|
+
hold.lift = record.id;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
data.lifts.push(record);
|
|
227
|
+
this.save(data);
|
|
228
|
+
const scope = record.caller ? `for ${record.caller}` : 'for every caller';
|
|
229
|
+
const amount = { jobs: `${size} more job(s)`, usd: `USD ${size} more`, limit: `the limit at ${size}` }[kind];
|
|
230
|
+
this.emit(LIFT_EVENT_KIND, 'info', `The operator lifted ${REASON_WORDS[reason]} ${scope}: ${amount}${until ? ` until ${until}` : ''}.`,
|
|
231
|
+
{ type: 'limit', id: `${reason}:${record.caller ?? '*'}`, label: REASON_WORDS[reason] }, false);
|
|
232
|
+
return { ...record };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
static matching(data, reason, caller, jobId, at, kind) {
|
|
236
|
+
return data.lifts.filter((l) => l.reason === reason && kind in l && HoldBook.live(l, at)
|
|
237
|
+
&& (l.caller === null || l.caller === caller) && (l.job === null || l.job === jobId));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Whether a lift lets work past the limit: {admitted: true, lift, left} spends one job or `costUsd`, else {admitted: false}. */
|
|
241
|
+
admit({ reason, caller, jobId = null, costUsd = null, now = null }) {
|
|
242
|
+
const at = utcTime(this.now(now));
|
|
243
|
+
const data = this.load();
|
|
244
|
+
const kind = costUsd !== null ? 'usd' : 'jobs';
|
|
245
|
+
const found = HoldBook.matching(data, reason, caller, jobId === null ? null : String(jobId), at, kind)
|
|
246
|
+
.sort((a, b) => ((a.job === null) - (b.job === null)) || ((a.caller === null) - (b.caller === null)));
|
|
247
|
+
for (const lift of found) {
|
|
248
|
+
const need = kind === 'usd' ? costUsd : 1;
|
|
249
|
+
if (lift.left >= need) {
|
|
250
|
+
lift.left = kind === 'usd' ? Math.round((lift.left - need) * 1e6) / 1e6 : lift.left - 1;
|
|
251
|
+
this.save(data);
|
|
252
|
+
return { admitted: true, lift: lift.id, left: lift.left };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return { admitted: false };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The limit in force: `base`, or the highest live raised limit for (reason, caller). */
|
|
259
|
+
limit({ reason, base, caller = null, now = null }) {
|
|
260
|
+
const at = utcTime(this.now(now));
|
|
261
|
+
return Math.max(base, ...HoldBook.matching(this.load(), reason, caller, null, at, 'limit').map((l) => l.limit));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Every lift with `live`: whether it still applies now. */
|
|
265
|
+
lifts(now = null) {
|
|
266
|
+
const at = utcTime(this.now(now));
|
|
267
|
+
return this.load().lifts.map((l) => ({ ...l, live: HoldBook.live(l, at) }));
|
|
268
|
+
}
|
|
269
|
+
}
|