@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.80.2
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 +134 -0
- package/bin/fabric-agent-adapter.js +26 -4
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- 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-links.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +41 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +35 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +20 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +340 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py
CHANGED
|
@@ -13,7 +13,8 @@ not re-export this module, so a service that does not run per context keeps the
|
|
|
13
13
|
An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
|
|
14
14
|
checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
|
|
15
15
|
``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
|
|
16
|
-
DEC-0128), so the service passes ``
|
|
16
|
+
DEC-0128), so the service passes ``context_gate_options(info, record)`` (its context and its pin's issuer, FAA-150) to its
|
|
17
|
+
delegation gate. The contract lets Fabric
|
|
17
18
|
write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
|
|
18
19
|
organization instance on today's records."""
|
|
19
20
|
from __future__ import annotations
|
|
@@ -33,7 +34,8 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
33
34
|
from fabric_base import ServiceError, _INSTANCE # noqa: E402
|
|
34
35
|
from fabric_descriptor import descriptor_path # noqa: E402
|
|
35
36
|
from fabric_estate import parse_context # noqa: E402
|
|
36
|
-
from fabric_lock import InstanceLock, hold_single_instance # noqa: E402
|
|
37
|
+
from fabric_lock import (InstanceLock, LOCK_BACKOFF_FIRST_SECONDS, LOCK_BACKOFF_MAX_SECONDS, hold_single_instance, # noqa: E402
|
|
38
|
+
lock_wait_budget_seconds, supervisor_marker)
|
|
37
39
|
from fabric_paths import atomic_write, instance_dirs # noqa: E402
|
|
38
40
|
from fabric_share import instance_share_grant # noqa: E402
|
|
39
41
|
from fabric_time import clock_ms # noqa: E402
|
|
@@ -125,9 +127,49 @@ def context_instance(service_id: str, context: Any, *, record: Optional[Dict[str
|
|
|
125
127
|
return answer
|
|
126
128
|
|
|
127
129
|
|
|
128
|
-
def
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
def context_gate_options(info: Any, record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
131
|
+
"""What ``delegation_gate`` takes from this instance's context (FAA-150): ``estate_context``, and for an organization
|
|
132
|
+
whose context ``record`` pins, ``issuer``: the pin's context issuer (DL-14a, DEC-0128), so an organization's instance
|
|
133
|
+
refuses a token the personal hub key signed without wiring either by hand. ``info`` is ``context_instance``'s answer
|
|
134
|
+
(or ``hold_context_instance``'s); ``record`` the verified estate revision applied. Spread it after the service's own
|
|
135
|
+
estate issuer: ``delegation_gate(..., **{"issuer": estate_issuer, **context_gate_options(info, record)})``. Raises
|
|
136
|
+
ContextRefused (``context-invalid``) for an answer with no context."""
|
|
137
|
+
context = info.get("context") if isinstance(info, dict) else None
|
|
138
|
+
parsed = parse_context(context)
|
|
139
|
+
if parsed is None:
|
|
140
|
+
raise ContextRefused("context-invalid")
|
|
141
|
+
options: Dict[str, Any] = {"estate_context": context}
|
|
142
|
+
pins = record.get("organizations") if isinstance(record, dict) else None
|
|
143
|
+
if parsed["kind"] == "org" and isinstance(pins, list):
|
|
144
|
+
for pin in pins:
|
|
145
|
+
if isinstance(pin, dict) and pin.get("org") == context and isinstance(pin.get("iss"), str):
|
|
146
|
+
options["issuer"] = pin["iss"]
|
|
147
|
+
break
|
|
148
|
+
return options
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _refuse(service_id: Any, context: Any, code: str, wait_seconds: Optional[float] = 0.0, *,
|
|
152
|
+
sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic,
|
|
153
|
+
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None) -> None:
|
|
154
|
+
"""One sentence naming the code, then exit 78. Under a supervisor that restarts on exit (launchd's KeepAlive with
|
|
155
|
+
ThrottleInterval 10, systemd's Restart=always) an exit at once is a respawn every few seconds, forever (FAA-279);
|
|
156
|
+
so a supervised copy first waits the lock's back-off (0.5 s doubling to 30 s, 300 s in all), holding nothing, and
|
|
157
|
+
exits 78 when it runs out: a few restarts an hour. ``wait_seconds`` overrides the choice as the lock's does."""
|
|
158
|
+
if wait_seconds is None:
|
|
159
|
+
wait_seconds = lock_wait_budget_seconds(supervisor_marker(env, argv))
|
|
160
|
+
wait_seconds = max(0.0, wait_seconds)
|
|
161
|
+
tail = " Waiting %.0f s before exiting, so its supervisor does not restart it every few seconds." % wait_seconds \
|
|
162
|
+
if wait_seconds > 0 else ""
|
|
163
|
+
print("%s will not run for %s (%s): %s.%s" % (service_id, _shown(context), code, _REASONS[code], tail),
|
|
164
|
+
file=sys.stderr, flush=True)
|
|
165
|
+
deadline = clock() + wait_seconds
|
|
166
|
+
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
167
|
+
while True:
|
|
168
|
+
remaining = deadline - clock()
|
|
169
|
+
if remaining <= 0:
|
|
170
|
+
raise SystemExit(EXIT_CONTEXT_REFUSED)
|
|
171
|
+
sleep(min(delay, remaining))
|
|
172
|
+
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
131
173
|
|
|
132
174
|
|
|
133
175
|
def _marker_matches(path: Path, expected: Dict[str, str]) -> Optional[bool]:
|
|
@@ -152,7 +194,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
152
194
|
env: Optional[Dict[str, str]] = None, argv: Optional[Sequence[str]] = None,
|
|
153
195
|
services_directory: Optional[Path] = None) -> Tuple[Dict[str, Any], InstanceLock]:
|
|
154
196
|
"""Run as ``context``'s one instance, before any side effect (brief D17): ``context_instance`` (a refusal prints one
|
|
155
|
-
sentence naming the code and exits 78, nothing created); then ``hold_single_instance`` on the instance's data
|
|
197
|
+
sentence naming the code and exits 78, nothing created; a supervised copy backs off first, FAA-279); then ``hold_single_instance`` on the instance's data
|
|
156
198
|
folder (its markers, its supervised wait, its exit 75); then the marker ``<data>/fabric-context.json``: absent, it
|
|
157
199
|
is written (0600, atomic); equal, it is kept; another context or instance, or unreadable, releases the lock and
|
|
158
200
|
exits 78 (``context-mismatch``). Returns the instance and the held lock."""
|
|
@@ -160,7 +202,7 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
160
202
|
info = context_instance(service_id, context, record=record, machine_id=machine_id, agent_ref=agent_ref, at=at,
|
|
161
203
|
personal_instance=personal_instance, services_directory=services_directory)
|
|
162
204
|
except ContextRefused as refused:
|
|
163
|
-
_refuse(service_id, context, refused.code)
|
|
205
|
+
_refuse(service_id, context, refused.code, wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
164
206
|
lock = hold_single_instance(info["dirs"]["data"], wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
165
207
|
expected = {"service": service_id, "instance": info["instance"], "context": context}
|
|
166
208
|
marker = Path(info["dirs"]["data"]) / CONTEXT_MARKER
|
|
@@ -173,5 +215,5 @@ def hold_context_instance(service_id: str, context: Any, *, record: Optional[Dic
|
|
|
173
215
|
raise
|
|
174
216
|
if matches is False:
|
|
175
217
|
lock.release()
|
|
176
|
-
_refuse(service_id, context, "context-mismatch")
|
|
218
|
+
_refuse(service_id, context, "context-mismatch", wait_seconds, sleep=sleep, clock=clock, env=env, argv=argv)
|
|
177
219
|
return info, lock
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069),
|
|
2
2
|
build identity and the well-known document, the loopback HTTP server. Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
3
4
|
from __future__ import annotations
|
|
4
5
|
|
|
5
6
|
import hashlib
|
|
@@ -7,6 +8,7 @@ import http.server
|
|
|
7
8
|
import json
|
|
8
9
|
import os
|
|
9
10
|
import re
|
|
11
|
+
import socket
|
|
10
12
|
import socketserver
|
|
11
13
|
import subprocess
|
|
12
14
|
import warnings
|
|
@@ -29,6 +31,7 @@ from fabric_base import (
|
|
|
29
31
|
expand,
|
|
30
32
|
now_iso,
|
|
31
33
|
)
|
|
34
|
+
import fabric_peer_owner # noqa: E402 - L0: who listens on a port (DEC-0154 HA-2, FAA-225)
|
|
32
35
|
from fabric_paths import (
|
|
33
36
|
atomic_write,
|
|
34
37
|
ensure_private_dir,
|
|
@@ -477,8 +480,35 @@ def source_refusal(descriptor: Dict[str, Any], directory: Optional[Path] = None)
|
|
|
477
480
|
return "Descriptor %s %s. %s" % (me, NO_SOURCE_REPOSITORY, PASS_SOURCE)
|
|
478
481
|
|
|
479
482
|
|
|
480
|
-
def
|
|
481
|
-
"""
|
|
483
|
+
def port_owner(port: int) -> Optional[Tuple[str, Optional[int]]]:
|
|
484
|
+
"""Who listens on 127.0.0.1:<port> now, as (kind, uid) from the kit's fabric_peer_owner, or None when a plain bind
|
|
485
|
+
of that address succeeds (nothing holds it, and the socket table is not read). FAA-225, DEC-0154 HA-2."""
|
|
486
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
487
|
+
try:
|
|
488
|
+
probe.bind(("127.0.0.1", port))
|
|
489
|
+
return None
|
|
490
|
+
except OSError:
|
|
491
|
+
pass
|
|
492
|
+
owner = fabric_peer_owner.listener(port)
|
|
493
|
+
return owner.kind, owner.uid
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def port_listener_refusal(port: int, reader: Optional[Any] = None) -> Optional[str]:
|
|
497
|
+
"""Why an install may not take ``port``: another OS account listens on it (FAA-225). This account, the system
|
|
498
|
+
(launchd holds a socket-activated job's socket), nobody and an unreadable table pass: the refusal is for a port
|
|
499
|
+
that is surely another account's. The recorded port per account and its claim file wait on contract DEC-0160."""
|
|
500
|
+
found = (reader or port_owner)(port)
|
|
501
|
+
if not found or found[0] != fabric_peer_owner.ANOTHER_ACCOUNT:
|
|
502
|
+
return None
|
|
503
|
+
uid = " (uid %d)" % found[1] if isinstance(found[1], int) else ""
|
|
504
|
+
return ("Port %d is listened on by another OS account%s: choose a port no other account holds (DEC-0154, "
|
|
505
|
+
"FAA-225)." % (port, uid))
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = None, *, _stacklevel: int = 2,
|
|
509
|
+
port_owner: Optional[Any] = None) -> Path:
|
|
510
|
+
"""Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another
|
|
511
|
+
OS account listens on (FAA-225; ``port_owner(port) -> (kind, uid) | None`` reads it, the kit's own by default).
|
|
482
512
|
|
|
483
513
|
FAA-198: a new descriptor names its ``source.repository``; rewriting one already installed without it only
|
|
484
514
|
warns (``NoSourceRepositoryWarning``), so an installed agent's update keeps working."""
|
|
@@ -505,6 +535,9 @@ def write_descriptor(descriptor: Dict[str, Any], directory: Optional[Path] = Non
|
|
|
505
535
|
continue
|
|
506
536
|
if other_port == port:
|
|
507
537
|
raise ServiceError("Port %d is already claimed by %s (%s)." % (port, key, path))
|
|
538
|
+
held = port_listener_refusal(port, port_owner) if port is not None else None
|
|
539
|
+
if held:
|
|
540
|
+
raise ServiceError(held)
|
|
508
541
|
ensure_private_dir(root)
|
|
509
542
|
target = descriptor_path(descriptor["id"], descriptor["instance"], root)
|
|
510
543
|
atomic_write(target, (json.dumps(descriptor, indent=2, ensure_ascii=False) + "\n").encode(), 0o600)
|
|
@@ -28,6 +28,7 @@ The transport is injected in tests: `transport(method, url, headers, body, timeo
|
|
|
28
28
|
`(status, headers, body_bytes)` or raises `OSError` (a connection error, `TimeoutError` included) or `HttpError`.
|
|
29
29
|
Standard library only. Part of the service kit (FAA-65, layer L4).
|
|
30
30
|
"""
|
|
31
|
+
# Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
31
32
|
from __future__ import annotations
|
|
32
33
|
|
|
33
34
|
import datetime as _dt
|
|
@@ -64,7 +65,7 @@ class HttpError(Exception):
|
|
|
64
65
|
refused URL or a body over the cap; `url` is redacted; `body` holds at most 2048 bytes of an answer's body."""
|
|
65
66
|
|
|
66
67
|
def __init__(self, message: str, *, status: Optional[int] = None, url: str = "", attempts: int = 0,
|
|
67
|
-
retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http"):
|
|
68
|
+
retry_after_s: Optional[float] = None, body: bytes = b"", kind: str = "http", reason: Optional[str] = None):
|
|
68
69
|
super().__init__(message)
|
|
69
70
|
self.status = status
|
|
70
71
|
self.url = url
|
|
@@ -72,6 +73,9 @@ class HttpError(Exception):
|
|
|
72
73
|
self.retry_after_s = retry_after_s
|
|
73
74
|
self.body = bytes(body[:2048])
|
|
74
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, which
|
|
77
|
+
#: never clears), the owner's kind (`another-account`, `system`) or the check's reason (`no-row`).
|
|
78
|
+
self.reason = reason
|
|
75
79
|
|
|
76
80
|
|
|
77
81
|
class Response:
|
|
@@ -385,7 +389,10 @@ def urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optio
|
|
|
385
389
|
return _send(req, timeout_s, max_bytes, end, watchdog)
|
|
386
390
|
except _po.ListenerRefused as exc:
|
|
387
391
|
where = "%s %s" % (method, redact_url(url))
|
|
388
|
-
|
|
392
|
+
owner = getattr(exc, "owner", None)
|
|
393
|
+
why = str(getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", "") or "unknown")
|
|
394
|
+
reason = _po.TABLE_RESTRICTED if why.startswith(_po.TABLE_RESTRICTED) else why.split(":", 1)[0]
|
|
395
|
+
raise HttpError("%s: foreign-user: %s" % (where, exc), url=redact_url(url), kind="foreign-user", reason=reason) from None
|
|
389
396
|
except (OSError, http.client.HTTPException) as exc:
|
|
390
397
|
if watchdog.fired:
|
|
391
398
|
raise TimeoutError("the answer did not arrive within the deadline")
|
|
@@ -67,6 +67,20 @@ class HubError(Exception):
|
|
|
67
67
|
return "HubError(%r, %r)" % (self.code, self.message)
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def listener_refusal(exc: Any) -> HubError:
|
|
71
|
+
"""The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned
|
|
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, which never clears, so it is not retryable;
|
|
74
|
+
otherwise the owner's kind or the check's reason (`another-account`, `no-row`), retryable as before."""
|
|
75
|
+
owner = getattr(exc, "owner", None)
|
|
76
|
+
why = (getattr(owner, "reason", None) or getattr(owner, "kind", None) or getattr(exc, "code", None) or "unknown")
|
|
77
|
+
reason = _po.TABLE_RESTRICTED if str(why).startswith(_po.TABLE_RESTRICTED) else str(why).split(":", 1)[0]
|
|
78
|
+
kind = getattr(owner, "kind", None)
|
|
79
|
+
shown = "%s: %s" % (kind, owner.reason) if kind and getattr(owner, "reason", None) else str(kind or why)
|
|
80
|
+
return HubError("foreign-user", "The hub's port is not held by this OS account's process (%s); nothing was sent." % shown,
|
|
81
|
+
reason != _po.TABLE_RESTRICTED, {"reason": reason})
|
|
82
|
+
|
|
83
|
+
|
|
70
84
|
# --- where Fabric is --------------------------------------------------------------------------------------------
|
|
71
85
|
|
|
72
86
|
def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
|
|
@@ -233,8 +247,7 @@ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[s
|
|
|
233
247
|
raw = resp.read(4 * 1024 * 1024)
|
|
234
248
|
got = {k.lower(): v for k, v in resp.getheaders()}
|
|
235
249
|
except _po.ListenerRefused as exc:
|
|
236
|
-
raise
|
|
237
|
-
% (exc.owner.kind if exc.owner else exc.code), True) from None
|
|
250
|
+
raise listener_refusal(exc) from None
|
|
238
251
|
except (OSError, http.client.HTTPException) as exc:
|
|
239
252
|
raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
|
|
240
253
|
finally:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -825,7 +825,7 @@ class McpToolServer:
|
|
|
825
825
|
gated = self._gate_action(action, "fabric.job.get", {"id": job_id, "inputResponses": {key: value}}, None, None,
|
|
826
826
|
headers, tier, meta, caller, mandate)
|
|
827
827
|
if not gated["ok"]:
|
|
828
|
-
return gated
|
|
828
|
+
return dict(gated, capability=private.get("capability")) # the one read names the fault (FAA-186)
|
|
829
829
|
records[key] = gated["record"]
|
|
830
830
|
return {"ok": True, "records": records}
|
|
831
831
|
|
|
@@ -933,7 +933,7 @@ class McpToolServer:
|
|
|
933
933
|
if not inputs["ok"] and "invalid" in inputs:
|
|
934
934
|
return self._error(rid, -32602, inputs["invalid"])
|
|
935
935
|
if not inputs["ok"]:
|
|
936
|
-
stored =
|
|
936
|
+
stored = inputs.get("capability") # read once, by _gate_inputs (FAA-186)
|
|
937
937
|
self._fault(stored if isinstance(stored, str) else name, inputs) # as the start heard it (FAA-179)
|
|
938
938
|
import fabric_delegation as _fd # noqa: E402 - DL-19
|
|
939
939
|
return {"jsonrpc": "2.0", "id": rid, "result": _fd.refusal_tool_result(inputs["refusal"], traceparent=span)}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""The call queue and the load report of DEC-0104 (contract `src/agent-network.ts`, FAC-SEM-131 to FAC-SEM-133), as the
|
|
2
|
+
contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
|
|
3
|
+
these and reports its load as `service-load/1` under `extensions/load/0.1`. Part of the service kit (FAA-289, layer L3);
|
|
4
|
+
the twin of fabric-load.mjs. Standard library only. Nothing here raises on what it reads, except `load_extension`, which
|
|
5
|
+
refuses a declaration a reader would treat as not declared.
|
|
6
|
+
|
|
7
|
+
- job_caller_key(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
|
|
8
|
+
outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
|
|
9
|
+
- next_queued_job(queue, running, limits): the job the node starts next, or None (fairness between callers).
|
|
10
|
+
- admit_job(queue, running, job, limits, retry_after_seconds): {admit: True}, or the `queue-full` refusal with its data.
|
|
11
|
+
- queue_status_message(position, length), read_queue_position(status_message): `queued <position> of <length>`.
|
|
12
|
+
- job_queue_rules(value): FAC-SEM-131 over {queue, running, limits, started}.
|
|
13
|
+
- load_report_rules(report): FAC-SEM-132 on a `service-load/1` report; load_report_problems(report): its schema.
|
|
14
|
+
- load_view(well_known), load_extension_rules(well_known): the declaration as a reader reads it, and FAC-SEM-133.
|
|
15
|
+
- load_extension(path, window_seconds, limits): the `extensions` entry a service declares.
|
|
16
|
+
|
|
17
|
+
The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
|
|
18
|
+
reads them (ECMAScript's \\s in patterns, lengths in code points, `date-time` as ajv-formats' full mode). Times in the
|
|
19
|
+
queue are UTC instants (`interop-common#/$defs/utcInstant`); times in the report are RFC 3339 read strictly (the
|
|
20
|
+
contract's strictTime). The wording of a schema problem is the kit's, not ajv's."""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
import sys as _sys # noqa: E402
|
|
28
|
+
from pathlib import Path as _Path # noqa: E402
|
|
29
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
30
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
from fabric_base import ServiceError # noqa: E402
|
|
33
|
+
from fabric_connections import date_time # noqa: E402 - ajv-formats' date-time, as the contract's validator reads it
|
|
34
|
+
from fabric_jsvalue import ( # noqa: E402
|
|
35
|
+
MISSING, _JS_SPACE, double, get, is_number, json_stringify, objects, safe_integer, same, show,
|
|
36
|
+
)
|
|
37
|
+
from fabric_time import parse_rfc3339_ms # noqa: E402 - the contract's strictTime
|
|
38
|
+
|
|
39
|
+
LOAD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/load/0.1"
|
|
40
|
+
LOAD_REPORT_TYPE = "service-load/1"
|
|
41
|
+
#: The refusal code, the same as fabric_interop_rules.QUEUE_FULL (a test holds them equal); not imported because the
|
|
42
|
+
#: facade fabric_service, which re-exports this module, comes before the interop rules in the kit's order.
|
|
43
|
+
_QUEUE_FULL = "queue-full"
|
|
44
|
+
QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later"
|
|
45
|
+
#: The longest queue a status message names (`queued <position> of <length>`, six digits).
|
|
46
|
+
QUEUE_LENGTH_MAX = 999999
|
|
47
|
+
DEFAULT_RETRY_AFTER_SECONDS = 60
|
|
48
|
+
|
|
49
|
+
_UTC_INSTANT = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]{1,3})?Z", re.ASCII)
|
|
50
|
+
_QUEUED = re.compile(r"queued ([1-9][0-9]{0,5}) of ([1-9][0-9]{0,5})", re.ASCII)
|
|
51
|
+
_ORIGIN_PATH = re.compile("/(?!/)[^%s]*" % re.escape(_JS_SPACE))
|
|
52
|
+
_CONTEXT = re.compile(r"(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})", re.ASCII)
|
|
53
|
+
_KEY_PATH = "/extensions/" + LOAD_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
54
|
+
_LIMITS = (("concurrency", 1, 1024), ("perCaller", 1, 1024), ("queueMax", 0, 100000))
|
|
55
|
+
_ROW_COUNTS = ("queued", "running", "completed", "failed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _utc_ms(value: Any) -> float:
|
|
59
|
+
"""A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN."""
|
|
60
|
+
return parse_rfc3339_ms(value) if isinstance(value, str) and _UTC_INSTANT.fullmatch(value) else math.nan
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strict_ms(value: Any) -> float:
|
|
64
|
+
return parse_rfc3339_ms(value) if isinstance(value, str) else math.nan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _utf16(value: str) -> bytes:
|
|
68
|
+
"""A key that orders strings as JavaScript's `<` does: by UTF-16 code units."""
|
|
69
|
+
return value.encode("utf-16-be", "surrogatepass")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _string(value: Any) -> bool:
|
|
73
|
+
return isinstance(value, str)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- The call queue (fabric-interop, DEC-0104) ---
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def job_caller_key(accepted: Any = MISSING, credential: Any = MISSING) -> Optional[str]:
|
|
80
|
+
"""The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
|
|
81
|
+
delegation the node accepted (verify_delegation's answer), its `principal` and `caller` and the partner
|
|
82
|
+
organization when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the
|
|
83
|
+
caller by (a gate's pass with `principal: None` is that case too). A refused verdict has no key, and neither has
|
|
84
|
+
an unreadable call: None. The key is opaque; only equality matters."""
|
|
85
|
+
if isinstance(accepted, dict) and not (get(accepted, "ok") is True and get(accepted, "principal") is None):
|
|
86
|
+
principal, caller = get(accepted, "principal"), get(accepted, "caller")
|
|
87
|
+
if get(accepted, "ok") is not True or not _string(principal) or principal == "" or not _string(caller) or caller == "":
|
|
88
|
+
return None
|
|
89
|
+
chain = get(accepted, "chain")
|
|
90
|
+
chain = chain if isinstance(chain, list) else []
|
|
91
|
+
last = chain[-1] if chain else MISSING
|
|
92
|
+
partner = [last] if len(chain) > 1 and _string(last) and last.startswith("org:") else []
|
|
93
|
+
return json_stringify(["delegated", principal, caller] + partner)
|
|
94
|
+
return json_stringify(["credential", credential]) if _string(credential) and credential != "" else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _limits_ok(limits: Any) -> bool:
|
|
98
|
+
if not isinstance(limits, dict):
|
|
99
|
+
return False
|
|
100
|
+
concurrency, per_caller, queue_max = get(limits, "concurrency"), get(limits, "perCaller"), get(limits, "queueMax")
|
|
101
|
+
return (all(safe_integer(n) and double(n) >= 1 for n in (concurrency, per_caller))
|
|
102
|
+
and safe_integer(queue_max) and double(queue_max) >= 0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _waiting_job(job: Any) -> bool:
|
|
106
|
+
return isinstance(job, dict) and _string(get(job, "id")) and _string(get(job, "caller")) and math.isfinite(_utc_ms(get(job, "at")))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def next_queued_job(queue: Any, running: Any, limits: Any) -> Optional[str]:
|
|
110
|
+
"""The job the hosting node starts next for one (agentRef, context), or None (DEC-0104). Nothing starts while
|
|
111
|
+
`concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose
|
|
112
|
+
caller runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose
|
|
113
|
+
oldest waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order)."""
|
|
114
|
+
if not _limits_ok(limits):
|
|
115
|
+
return None
|
|
116
|
+
if (len(running) if isinstance(running, list) else 0) >= double(limits["concurrency"]):
|
|
117
|
+
return None
|
|
118
|
+
running_of: Dict[str, int] = {}
|
|
119
|
+
for job in objects(running):
|
|
120
|
+
if _string(get(job, "caller")):
|
|
121
|
+
running_of[job["caller"]] = running_of.get(job["caller"], 0) + 1
|
|
122
|
+
waiting = [job for job in objects(queue) if _waiting_job(job) and running_of.get(job["caller"], 0) < double(limits["perCaller"])]
|
|
123
|
+
waiting.sort(key=lambda job: (_utc_ms(job["at"]), _utf16(job["id"])))
|
|
124
|
+
best: Optional[Dict[str, Any]] = None
|
|
125
|
+
for job in waiting:
|
|
126
|
+
if best is None or running_of.get(job["caller"], 0) < running_of.get(best["caller"], 0):
|
|
127
|
+
best = job
|
|
128
|
+
return None if best is None else best["id"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def admit_job(queue: Any, running: Any, job: Any, limits: Any, retry_after_seconds: Any = MISSING) -> Dict[str, Any]:
|
|
132
|
+
"""Whether a start is admitted (DEC-0104): it is when the job would start at once (next_queued_job over the queue
|
|
133
|
+
with it added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: False, error} with the
|
|
134
|
+
`queue-full` refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse."""
|
|
135
|
+
waiting = objects(queue)
|
|
136
|
+
if _limits_ok(limits) and _waiting_job(job) and (
|
|
137
|
+
next_queued_job(waiting + [job], running, limits) == job["id"] or len(waiting) < double(limits["queueMax"])):
|
|
138
|
+
return {"admit": True}
|
|
139
|
+
after = retry_after_seconds if safe_integer(retry_after_seconds) and 1 <= double(retry_after_seconds) <= 3600 else DEFAULT_RETRY_AFTER_SECONDS
|
|
140
|
+
return {"admit": False, "error": {"code": _QUEUE_FULL, "message": QUEUE_FULL_MESSAGE,
|
|
141
|
+
"data": {"retryable": True, "retryAfterSeconds": int(after)}}}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def queue_status_message(position: Any, length: Any) -> Optional[str]:
|
|
145
|
+
"""The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <=
|
|
146
|
+
length <= 999999; None for anything else."""
|
|
147
|
+
if not (safe_integer(position) and safe_integer(length)):
|
|
148
|
+
return None
|
|
149
|
+
if not 1 <= double(position) <= double(length) <= QUEUE_LENGTH_MAX:
|
|
150
|
+
return None
|
|
151
|
+
return "queued %d of %d" % (int(position), int(length))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_queue_position(status_message: Any) -> Optional[Dict[str, int]]:
|
|
155
|
+
"""A waiting job's place, read from its `statusMessage`, or None when it is not the queue's grammar."""
|
|
156
|
+
match = _QUEUED.fullmatch(status_message) if _string(status_message) else None
|
|
157
|
+
if not match:
|
|
158
|
+
return None
|
|
159
|
+
position, length = int(match.group(1)), int(match.group(2))
|
|
160
|
+
return {"position": position, "length": length} if 1 <= position <= length else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _finding(code: str, instance_path: str, message: str) -> Dict[str, str]:
|
|
164
|
+
return {"code": code, "instancePath": instance_path, "message": message}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def job_queue_rules(value: Any) -> List[Dict[str, str]]:
|
|
168
|
+
"""FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or None): it is
|
|
169
|
+
next_queued_job's."""
|
|
170
|
+
want = next_queued_job(get(value, "queue"), get(value, "running"), get(value, "limits"))
|
|
171
|
+
started = get(value, "started")
|
|
172
|
+
if same(None if started is MISSING else started, want):
|
|
173
|
+
return []
|
|
174
|
+
return [_finding("FAC-SEM-131", "/started", "nothing may start now" if want is None else "the job to start is %s" % show(want))]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- The load report (DEC-0104) ---
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_report_rules(report: Any) -> List[Dict[str, str]]:
|
|
181
|
+
"""FAC-SEM-132 on a `service-load/1` report: windows in order one `windowSeconds` apart, none after `generatedAt`,
|
|
182
|
+
one row per context, p50 at most p95, a run time only where a job ended."""
|
|
183
|
+
findings: List[Dict[str, str]] = []
|
|
184
|
+
flag = lambda path, message: findings.append(_finding("FAC-SEM-132", path, message)) # noqa: E731
|
|
185
|
+
seconds = get(report, "windowSeconds")
|
|
186
|
+
step = double(seconds) * 1000 if safe_integer(seconds) else math.nan
|
|
187
|
+
generated = _strict_ms(get(report, "generatedAt"))
|
|
188
|
+
previous: Any = MISSING
|
|
189
|
+
for index, window in enumerate(objects(get(report, "windows"))):
|
|
190
|
+
start = _strict_ms(get(window, "start"))
|
|
191
|
+
if start > generated:
|
|
192
|
+
flag("/windows/%d/start" % index, "a window starts after the report was generated")
|
|
193
|
+
if previous is not MISSING and start - previous != step:
|
|
194
|
+
flag("/windows/%d/start" % index, "windows are not in order one windowSeconds apart")
|
|
195
|
+
previous = start
|
|
196
|
+
seen = set()
|
|
197
|
+
for at, row in enumerate(objects(get(window, "byContext"))):
|
|
198
|
+
path = "/windows/%d/byContext/%d" % (index, at)
|
|
199
|
+
context = get(row, "context")
|
|
200
|
+
if _string(context) and context in seen:
|
|
201
|
+
flag(path + "/context", "context %s appears twice in one window" % show(context))
|
|
202
|
+
if _string(context):
|
|
203
|
+
seen.add(context)
|
|
204
|
+
for name in ("waitMs", "runMs"):
|
|
205
|
+
pct = get(row, name)
|
|
206
|
+
p50, p95 = get(pct, "p50"), get(pct, "p95")
|
|
207
|
+
if isinstance(pct, dict) and is_number(p50) and is_number(p95) and double(p50) > double(p95):
|
|
208
|
+
flag("%s/%s" % (path, name), "p50 is above p95")
|
|
209
|
+
if isinstance(get(row, "runMs"), dict) and same(get(row, "completed"), 0) and same(get(row, "failed"), 0):
|
|
210
|
+
flag(path + "/runMs", "a run time with no job ended in the window")
|
|
211
|
+
return findings
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _integer(value: Any, low: float, high: Optional[float], at: str, errors: List[str]) -> None:
|
|
215
|
+
"""JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction (2.0 is one), never a bool."""
|
|
216
|
+
if not is_number(value) or not math.isfinite(double(value)) or double(value) != math.floor(double(value)):
|
|
217
|
+
errors.append("%s type" % at)
|
|
218
|
+
elif double(value) < low:
|
|
219
|
+
errors.append("%s minimum" % at)
|
|
220
|
+
elif high is not None and double(value) > high:
|
|
221
|
+
errors.append("%s maximum" % at)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _members(value: Dict[str, Any], allowed: tuple, required: tuple, at: str, errors: List[str]) -> None:
|
|
225
|
+
errors.extend("%s/%s additionalProperties" % (at, key) for key in value if key not in allowed)
|
|
226
|
+
errors.extend("%s required %s" % (at or "/", key) for key in required if key not in value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _timestamp(value: Any, at: str, errors: List[str]) -> None:
|
|
230
|
+
if not _string(value):
|
|
231
|
+
errors.append("%s type" % at)
|
|
232
|
+
elif not date_time(value):
|
|
233
|
+
errors.append("%s format" % at)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _percentiles(value: Any, at: str, errors: List[str]) -> None:
|
|
237
|
+
if not isinstance(value, dict):
|
|
238
|
+
errors.append("%s type" % at)
|
|
239
|
+
return
|
|
240
|
+
_members(value, ("p50", "p95"), ("p50", "p95"), at, errors)
|
|
241
|
+
for name in ("p50", "p95"):
|
|
242
|
+
if name in value:
|
|
243
|
+
_integer(value[name], 0, None, "%s/%s" % (at, name), errors)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _row_problems(row: Any, at: str, errors: List[str]) -> None:
|
|
247
|
+
if not isinstance(row, dict):
|
|
248
|
+
errors.append("%s type" % at)
|
|
249
|
+
return
|
|
250
|
+
_members(row, ("context",) + _ROW_COUNTS + ("waitMs", "runMs"), ("context",) + _ROW_COUNTS, at, errors)
|
|
251
|
+
if "context" in row and not (_string(row["context"]) and _CONTEXT.fullmatch(row["context"])):
|
|
252
|
+
errors.append("%s/context %s" % (at, "pattern" if _string(row["context"]) else "type"))
|
|
253
|
+
for name in _ROW_COUNTS:
|
|
254
|
+
if name in row:
|
|
255
|
+
_integer(row[name], 0, None, "%s/%s" % (at, name), errors)
|
|
256
|
+
for name in ("waitMs", "runMs"):
|
|
257
|
+
if name in row:
|
|
258
|
+
_percentiles(row[name], "%s/%s" % (at, name), errors)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_report_problems(report: Any) -> List[str]:
|
|
262
|
+
"""service-load.schema.json: what makes `report` not a `service-load/1` report, each as `<path> <keyword>`; empty
|
|
263
|
+
for a valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused."""
|
|
264
|
+
if not isinstance(report, dict):
|
|
265
|
+
return ["/ type"]
|
|
266
|
+
errors: List[str] = []
|
|
267
|
+
_members(report, ("type", "generatedAt", "windowSeconds", "windows"), ("type", "generatedAt", "windowSeconds", "windows"), "", errors)
|
|
268
|
+
if "type" in report and not same(report["type"], LOAD_REPORT_TYPE):
|
|
269
|
+
errors.append("/type const")
|
|
270
|
+
if "generatedAt" in report:
|
|
271
|
+
_timestamp(report["generatedAt"], "/generatedAt", errors)
|
|
272
|
+
if "windowSeconds" in report:
|
|
273
|
+
_integer(report["windowSeconds"], 60, 86400, "/windowSeconds", errors)
|
|
274
|
+
windows = report.get("windows", MISSING)
|
|
275
|
+
if windows is not MISSING and not isinstance(windows, list):
|
|
276
|
+
errors.append("/windows type")
|
|
277
|
+
elif isinstance(windows, list):
|
|
278
|
+
if len(windows) > 168:
|
|
279
|
+
errors.append("/windows maxItems")
|
|
280
|
+
for index, window in enumerate(windows):
|
|
281
|
+
at = "/windows/%d" % index
|
|
282
|
+
if not isinstance(window, dict):
|
|
283
|
+
errors.append("%s type" % at)
|
|
284
|
+
continue
|
|
285
|
+
_members(window, ("start", "byContext"), ("start", "byContext"), at, errors)
|
|
286
|
+
if "start" in window:
|
|
287
|
+
_timestamp(window["start"], at + "/start", errors)
|
|
288
|
+
rows = window.get("byContext", MISSING)
|
|
289
|
+
if rows is not MISSING and not isinstance(rows, list):
|
|
290
|
+
errors.append("%s/byContext type" % at)
|
|
291
|
+
elif isinstance(rows, list):
|
|
292
|
+
if len(rows) > 64:
|
|
293
|
+
errors.append("%s/byContext maxItems" % at)
|
|
294
|
+
for row_index, row in enumerate(rows):
|
|
295
|
+
_row_problems(row, "%s/byContext/%d" % (at, row_index), errors)
|
|
296
|
+
return errors
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _declaration_problems(value: Any) -> List[str]:
|
|
300
|
+
"""service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration."""
|
|
301
|
+
if not isinstance(value, dict):
|
|
302
|
+
return ["/ type"]
|
|
303
|
+
errors: List[str] = []
|
|
304
|
+
_members(value, ("path", "windowSeconds", "limits"), ("path", "windowSeconds", "limits"), "", errors)
|
|
305
|
+
path = value.get("path", MISSING)
|
|
306
|
+
if path is not MISSING:
|
|
307
|
+
if not _string(path):
|
|
308
|
+
errors.append("/path type")
|
|
309
|
+
else:
|
|
310
|
+
if len(path) > 256: # code points, as ajv counts them; the originPath's own 2048 is wider
|
|
311
|
+
errors.append("/path maxLength")
|
|
312
|
+
if not _ORIGIN_PATH.fullmatch(path):
|
|
313
|
+
errors.append("/path pattern")
|
|
314
|
+
if "windowSeconds" in value:
|
|
315
|
+
_integer(value["windowSeconds"], 60, 86400, "/windowSeconds", errors)
|
|
316
|
+
limits = value.get("limits", MISSING)
|
|
317
|
+
if limits is not MISSING:
|
|
318
|
+
if not isinstance(limits, dict):
|
|
319
|
+
errors.append("/limits type")
|
|
320
|
+
else:
|
|
321
|
+
_members(limits, tuple(name for name, _, _ in _LIMITS), tuple(name for name, _, _ in _LIMITS), "/limits", errors)
|
|
322
|
+
for name, low, high in _LIMITS:
|
|
323
|
+
if name in limits:
|
|
324
|
+
_integer(limits[name], low, high, "/limits/%s" % name, errors)
|
|
325
|
+
return errors
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def load_view(well_known: Any) -> Dict[str, Any]:
|
|
329
|
+
"""The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when
|
|
330
|
+
its value is the schema's, `load` the declaration when valid (else None) and `errors` its problems. A reader treats
|
|
331
|
+
an invalid value as not declared and reports it (FAC-SEM-133)."""
|
|
332
|
+
extensions = get(well_known, "extensions")
|
|
333
|
+
if not isinstance(extensions, dict) or LOAD_EXTENSION_KEY not in extensions:
|
|
334
|
+
return {"declared": False, "valid": False, "load": None, "errors": []}
|
|
335
|
+
value = extensions[LOAD_EXTENSION_KEY]
|
|
336
|
+
errors = _declaration_problems(value)
|
|
337
|
+
return {"declared": True, "valid": not errors, "load": None if errors else value, "errors": errors}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_extension_rules(well_known: Any) -> List[Dict[str, str]]:
|
|
341
|
+
"""FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
|
|
342
|
+
service-load-extension.schema.json; one finding at the key otherwise."""
|
|
343
|
+
view = load_view(well_known)
|
|
344
|
+
if not view["declared"] or view["valid"]:
|
|
345
|
+
return []
|
|
346
|
+
return [_finding("FAC-SEM-133", _KEY_PATH, "the load declaration is invalid (%s); a reader treats it as not declared "
|
|
347
|
+
"and reports it (DEC-0104)" % "; ".join(view["errors"]))]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def load_extension(path: Any, window_seconds: Any, limits: Any) -> Dict[str, Any]:
|
|
351
|
+
"""The `extensions` entry a service declares (DEC-0104): where it serves its `service-load/1` report (an origin
|
|
352
|
+
path, served under the descriptor token like the usage report), the report's window and the queue's limits per
|
|
353
|
+
(agentRef, context). Raises ServiceError on a declaration a reader would treat as not declared."""
|
|
354
|
+
value = {"path": path, "windowSeconds": window_seconds, "limits": dict(limits) if isinstance(limits, dict) else limits}
|
|
355
|
+
problems = _declaration_problems(value)
|
|
356
|
+
if problems:
|
|
357
|
+
raise ServiceError("Load declaration: %s." % "; ".join(problems))
|
|
358
|
+
return {LOAD_EXTENSION_KEY: value}
|