@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -1,1404 +1,70 @@
1
- """A service agent answers a Project's requests on Fabric's project board (`fabric-project-comms/0.1`; contract DEC-0022,
2
- DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149; FAA-12b, FAA-40). Standard library only; `fabric_hub.py` finds the hub.
1
+ """The project board connector, Python: the facade (fabric-project-comms/0.1).
3
2
 
4
- What a responder does, in the contract's words (project-comms.md, "Request lifecycle", "Responders and fences"):
5
-
6
- enroll (capability) → claim → read the message → accept (the exact digest) → [effect_begin] → [progress] →
7
- [reply] → complete ({state, observed, summary?, evidence ≤ 8 sha256 artifacts})
8
-
9
- `Responder` runs that loop inside the service process (no new process): one tick every 15 s ± 5 s, which claims when
10
- idle and renews the lease (60 s of database time, renewed at about 20 s) while work runs. Before it advances a step it
11
- writes and fsyncs a checkpoint, so a restart picks up exactly where the last answer was confirmed: `com.reconcile` for
12
- work whose outcome is unknown, `com.responder_replace` with a transfer for accepted work (DEC-0078 hands back the new
13
- attempts in the receipt's `transferred`; a Fabric that does not yet answer it leaves the work held — never claimed back).
14
- It stops acting the moment the board says `fenced`, `lease_expired` or `authority_changed`.
15
-
16
- THE REQUEST BODY IS DATA. The kit never hands a body to a model or a shell. `admit(request)` is the service's own input
17
- gate, required: it answers None to admit or a sentence saying why not; a refusal accepts the request and completes it
18
- `failed_known` with that sentence, so the requester learns it. What the handler does with an admitted body — and the
19
- checks it needs before any model, tool or spend sees it — is the service's own responsibility.
20
-
21
- A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
22
- `com.reply` (the fenced form), `com.enroll`, `com.claim`, `com.renew`, `com.accept`, `com.progress`, `com.effect_begin`,
23
- `com.complete`, `com.reconcile`, `com.responder_replace`. This kit never sends `com.submit`, `com.cancel`,
24
- `com.read_ack` or `com.directory` through one; a directory lookup belongs to a Project session, and `find_project`
25
- checks such a page first (FAC-SEM-107: each Project once, each capability once per Project, compared as written)."""
3
+ Since 0.40.0 (FAA-65) the connector is layered modules, lowest first: fabric_board_shapes (the wire), fabric_board_link
4
+ (session, checkpoint and request; since 0.41.0), fabric_board_session (the responder) and fabric_board_fixture (a scripted
5
+ board for the probe). This file re-exports every name it defined at 0.39.1, so import fabric_board keeps working. Standard library only, Python 3.9+."""
26
6
  from __future__ import annotations
27
7
 
28
- import hashlib
29
- import json
30
- import math
31
- from pathlib import Path
32
- import random
33
- import re
34
- import secrets
35
- import sys
36
- import threading
37
- import time
38
- from typing import Any, Callable, Dict, List, Optional, Sequence
8
+ import sys # noqa: F401 - callers reach these through the facade
9
+ from pathlib import Path # noqa: F401
39
10
 
40
11
  sys.path.insert(0, str(Path(__file__).resolve().parent))
41
- import fabric_hub as _hub # noqa: E402 - beside this file
42
- import fabric_identifiers as _ids # noqa: E402 - beside this file (DEC-0076)
43
- import fabric_service as _fs # noqa: E402 - beside this file
44
-
45
- COMMS_PROTOCOL = "fabric-project-comms/0.1"
46
- LEASE_SECONDS = 60.0
47
- RENEW_SECONDS = 20.0
48
- POLL_SECONDS = 15.0
49
- POLL_JITTER_SECONDS = 5.0
50
- DRAIN_SECONDS = 10.0
51
- SUMMARY_MAX = 2000
52
- EVIDENCE_MAX = 8
53
- EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
54
-
55
- #: The tools a board session is served (Fabric's respond scope); anything else is refused here, before it is sent.
56
- RESPOND_TOOLS = frozenset(["com.status", "com.list", "com.get", "com.reply", "com.enroll", "com.claim", "com.renew",
57
- "com.accept", "com.progress", "com.effect_begin", "com.complete", "com.reconcile",
58
- "com.responder_replace"])
59
- REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorized", "authority_changed",
60
- "unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
61
- "idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
62
- "fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
63
- "cursor_reset_required", "immutable_participants", "held"])
64
- #: On these the attempt is no longer ours: stop acting at once (C4).
65
- STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
66
- REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
67
- "outcome_unknown")
68
- EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
69
- TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
70
- #: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
71
- #: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
72
- #: cancels or reads. The contract's HOLD_SCOPES.
73
- HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
74
- "com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
75
- "com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
76
- #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
77
- HOLD_RETRY_MAX_SECONDS = 900.0
78
-
79
- _OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
80
- _PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
81
- _CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
82
- _SHA256 = re.compile(r"sha256:[a-f0-9]{64}")
83
- _KEY = re.compile(r"[A-Za-z0-9._:-]{8,128}")
84
- _TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})")
85
- _URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+")
86
- _SAFE = 2 ** 53 - 1
87
-
88
-
89
- class BoardError(Exception):
90
- """A board refusal (`code` from the contract's refusal codes) or a kit fault: `unreachable` (the hub did not answer;
91
- a write may have been stored — send it again with the same key), `session-ended`, `not-in-scope`, `receipt-shape`,
92
- `schema-unsupported`, `no-epoch`, `checkpoint`, `digest-mismatch`, `not-addressed`, `directory-invalid`,
93
- `evidence`, `observation_required`. `str()` is one sentence and never carries a token or a body."""
94
-
95
- def __init__(self, code: str, message: str, retryable: bool = False, op: Optional[str] = None,
96
- data: Optional[Dict[str, Any]] = None):
97
- super().__init__(message)
98
- self.code, self.message, self.retryable, self.op, self.data = code, message, retryable, op, data or {}
99
-
100
- def __repr__(self) -> str:
101
- return "BoardError(%r, %r)" % (self.code, self.message)
102
-
103
-
104
- class Stopped(Exception):
105
- """Raised inside a handler (from `ctx.progress` / `ctx.begin_effect`) when the attempt is no longer this
106
- responder's: stop at once and act no further."""
107
-
108
-
109
- # --- the contract's digest (src/comms-digest.ts) ---------------------------------------------------------------
110
-
111
- def comms_canonical(value: Any) -> str:
112
- """RFC 8785 as the board takes it: members sorted by UTF-16 code unit, integers only (|n| ≤ 2^53−1), no lone
113
- surrogate, no normalisation. Raises `ValueError` on anything else rather than guessing a spelling."""
114
- if value is None or isinstance(value, bool):
115
- return json.dumps(value)
116
- if isinstance(value, int):
117
- if abs(value) > _SAFE:
118
- raise ValueError("canonical JSON admits integers within ±(2^53−1) only")
119
- return str(value)
120
- if isinstance(value, float):
121
- if not math.isfinite(value) or not value.is_integer() or abs(value) > _SAFE:
122
- raise ValueError("canonical JSON admits integers within ±(2^53−1) only")
123
- return str(int(value))
124
- if isinstance(value, str):
125
- if any(0xD800 <= ord(c) <= 0xDFFF for c in value):
126
- raise ValueError("canonical JSON refuses a lone surrogate")
127
- return json.dumps(value, ensure_ascii=False)
128
- if isinstance(value, (list, tuple)):
129
- return "[%s]" % ",".join(comms_canonical(v) for v in value)
130
- if isinstance(value, dict):
131
- for k in value:
132
- if not isinstance(k, str):
133
- raise ValueError("canonical JSON has string keys only")
134
- comms_canonical(k) # refuses a lone surrogate in a key too
135
- keys = sorted(value, key=lambda k: k.encode("utf-16-be")) # big-endian UTF-16 bytes sort as code units
136
- return "{%s}" % ",".join("%s:%s" % (comms_canonical(k), comms_canonical(value[k])) for k in keys)
137
- raise ValueError("canonical JSON has no %s" % type(value).__name__)
138
-
139
-
140
- def comms_digest(operation: str, value: Any) -> str:
141
- """`sha256:<hex>` over `fabric-project-comms/0.1 NUL <operation> NUL <canonical JSON>` (the contract's `commsDigest`)."""
142
- if not isinstance(operation, str) or operation == "" or "\0" in operation:
143
- raise ValueError("an operation is a non-empty name without NUL")
144
- data = "%s\0%s\0%s" % (COMMS_PROTOCOL, operation, comms_canonical(value))
145
- return "sha256:" + hashlib.sha256(data.encode("utf-8")).hexdigest()
146
-
147
-
148
- # --- closed shapes: what the kit reads back ------------------------------------------------------------------
149
-
150
- def _is_int(v: Any, minimum: int) -> bool:
151
- return isinstance(v, int) and not isinstance(v, bool) and minimum <= v <= _SAFE
152
-
153
-
154
- def _closed(value: Any, at: str, required: Sequence[str], allowed: Sequence[str], problems: List[str]) -> bool:
155
- if not isinstance(value, dict):
156
- problems.append("%s is an object" % at)
157
- return False
158
- for key in required:
159
- if key not in value:
160
- problems.append("%s/%s is required" % (at, key))
161
- for key in value:
162
- if key not in allowed:
163
- problems.append("%s/%s is not a member the contract names" % (at, key))
164
- return True
165
-
166
-
167
- def _attempt_problems(a: Any, at: str, problems: List[str]) -> None:
168
- keys = ("id", "generation", "digest", "leaseExpiresAt")
169
- if _closed(a, at, keys, keys, problems):
170
- if not (isinstance(a.get("id"), str) and _OPAQUE.fullmatch(a["id"])):
171
- problems.append("%s/id is a board id" % at)
172
- if not _is_int(a.get("generation"), 1):
173
- problems.append("%s/generation is an integer ≥ 1" % at)
174
- if not (isinstance(a.get("digest"), str) and _SHA256.fullmatch(a["digest"])):
175
- problems.append("%s/digest is sha256:<hex>" % at)
176
- if not (isinstance(a.get("leaseExpiresAt"), str) and _TIMESTAMP.fullmatch(a["leaseExpiresAt"])):
177
- problems.append("%s/leaseExpiresAt is a timestamp" % at)
178
-
179
-
180
- def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
181
- """`comms-receipt.schema.json` at the pin, closed at every level (a seq or a holder is refused, C3), plus what a
182
- schema cannot say: `transferred` answers only `com.responder_replace` (DEC-0078) and names each request once."""
183
- problems: List[str] = []
184
- allowed = ("ok", "repeated", "message", "thread", "at", "request", "slot", "attempt", "transferred")
185
- if not _closed(value, "", ("ok", "repeated"), allowed, problems):
186
- return problems
187
- if value.get("ok") is not True:
188
- problems.append("/ok is true")
189
- if not isinstance(value.get("repeated"), bool):
190
- problems.append("/repeated is a boolean")
191
- for key in ("message", "thread"):
192
- if key in value and not (isinstance(value[key], str) and _OPAQUE.fullmatch(value[key])):
193
- problems.append("/%s is a board id" % key)
194
- if "at" in value and not (isinstance(value["at"], str) and _TIMESTAMP.fullmatch(value["at"])):
195
- problems.append("/at is a timestamp")
196
- if "request" in value:
197
- r = value["request"]
198
- keys = ("id", "state", "effect", "revision")
199
- if _closed(r, "/request", keys, keys + ("settledBy",), problems):
200
- if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
201
- problems.append("/request/id is a board id")
202
- if r.get("state") not in REQUEST_STATES:
203
- problems.append("/request/state is a request state")
204
- if r.get("effect") not in EFFECT_STATES:
205
- problems.append("/request/effect is an effect state")
206
- if not _is_int(r.get("revision"), 1):
207
- problems.append("/request/revision is an integer ≥ 1")
208
- if "settledBy" in r:
209
- # DEC-0081: present only when the estate owner settled it; absent, the responder did.
210
- if r["settledBy"] != "owner":
211
- problems.append("/request/settledBy is owner")
212
- if r.get("state") not in ("completed", "failed_known"):
213
- problems.append("/request/settledBy names a settled request (completed or failed_known)")
214
- if "slot" in value:
215
- s = value["slot"]
216
- keys = ("project", "capability", "generation", "leaseExpiresAt")
217
- if _closed(s, "/slot", keys, keys, problems):
218
- if not (isinstance(s.get("project"), str) and _PROJECT.fullmatch(s["project"])):
219
- problems.append("/slot/project is a Project id")
220
- if not (isinstance(s.get("capability"), str) and _CAPABILITY.fullmatch(s["capability"])):
221
- problems.append("/slot/capability is a capability name")
222
- if not _is_int(s.get("generation"), 1):
223
- problems.append("/slot/generation is an integer ≥ 1")
224
- if not (isinstance(s.get("leaseExpiresAt"), str) and _TIMESTAMP.fullmatch(s["leaseExpiresAt"])):
225
- problems.append("/slot/leaseExpiresAt is a timestamp")
226
- if "attempt" in value:
227
- _attempt_problems(value["attempt"], "/attempt", problems)
228
- if "transferred" in value:
229
- t = value["transferred"]
230
- if op is not None and op != "com.responder_replace":
231
- problems.append("/transferred answers com.responder_replace only (DEC-0078)")
232
- if not isinstance(t, list) or not 1 <= len(t) <= 64:
233
- problems.append("/transferred is 1 to 64 items")
234
- else:
235
- seen = set()
236
- for i, item in enumerate(t):
237
- at = "/transferred/%d" % i
238
- keys = ("request", "revision", "attempt")
239
- if _closed(item, at, keys, keys, problems):
240
- rid = item.get("request")
241
- if not (isinstance(rid, str) and _OPAQUE.fullmatch(rid)):
242
- problems.append("%s/request is a board id" % at)
243
- elif rid in seen:
244
- problems.append("%s/request names a request already listed (each request once, DEC-0078)" % at)
245
- seen.add(rid)
246
- if not _is_int(item.get("revision"), 1):
247
- problems.append("%s/revision is an integer ≥ 1" % at)
248
- _attempt_problems(item.get("attempt"), at + "/attempt", problems)
249
- return problems
250
-
251
-
252
- def refusal_problems(value: Any) -> List[str]:
253
- """`comms-refusal.schema.json`: `{error: {code, message, retryable}}`, closed, a known code, one sentence ≤ 300."""
254
- problems: List[str] = []
255
- if _closed(value, "", ("error",), ("error",), problems):
256
- e = value["error"]
257
- keys = ("code", "message", "retryable")
258
- if _closed(e, "/error", keys, keys, problems):
259
- if e.get("code") not in REFUSAL_CODES:
260
- problems.append("/error/code is a refusal code the contract names")
261
- if not (isinstance(e.get("message"), str) and 1 <= len(e["message"]) <= 300):
262
- problems.append("/error/message is 1 to 300 characters")
263
- if not isinstance(e.get("retryable"), bool):
264
- problems.append("/error/retryable is a boolean")
265
- elif e.get("code") == "held" and e["retryable"] is not True:
266
- problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
267
- return problems
268
-
269
-
270
- def directory_findings(page: Any) -> List[Dict[str, str]]:
271
- """FAC-SEM-107 (DEC-0077), the contract's `commsDirectory`: a Project listed twice, or a capability listed twice
272
- within a Project, compared exactly as written (Project ids are opaque and capability names are names, not URIs —
273
- so not by normal form). The finding names the second entry. Never raises."""
274
- findings: List[Dict[str, str]] = []
275
- projects = page.get("projects") if isinstance(page, dict) else None
276
- seen = set()
277
- for p, project in enumerate(projects if isinstance(projects, list) else []):
278
- if not isinstance(project, dict):
279
- continue
280
- pid = project.get("id")
281
- if isinstance(pid, str):
282
- if pid in seen:
283
- findings.append({"code": "FAC-SEM-107", "instancePath": "/projects/%d/id" % p,
284
- "message": "a directory page lists each Project once: %s is listed again" % pid})
285
- seen.add(pid)
286
- caps = project.get("capabilities")
287
- names = set()
288
- for c, entry in enumerate(caps if isinstance(caps, list) else []):
289
- if not isinstance(entry, dict) or not isinstance(entry.get("capability"), str):
290
- continue
291
- if entry["capability"] in names:
292
- findings.append({"code": "FAC-SEM-107", "instancePath": "/projects/%d/capabilities/%d/capability" % (p, c),
293
- "message": "a Project lists each capability once: %s is listed again" % entry["capability"]})
294
- names.add(entry["capability"])
295
- return findings
296
-
297
-
298
- def directory_problems(page: Any) -> List[str]:
299
- """`comms-directory.schema.json`, closed at every level (no count, holder or generation), then FAC-SEM-107."""
300
- problems: List[str] = []
301
- if not _closed(page, "", ("protocol", "snapshot", "projects", "cursor"), ("protocol", "snapshot", "projects", "cursor"), problems):
302
- return problems
303
- if page.get("protocol") != COMMS_PROTOCOL:
304
- problems.append("/protocol is %s" % COMMS_PROTOCOL)
305
- snap = page.get("snapshot")
306
- if _closed(snap, "/snapshot", ("readAt",), ("readAt", "stale"), problems):
307
- if not (isinstance(snap.get("readAt"), str) and _TIMESTAMP.fullmatch(snap["readAt"])):
308
- problems.append("/snapshot/readAt is a timestamp")
309
- if "stale" in snap and not isinstance(snap["stale"], bool):
310
- problems.append("/snapshot/stale is a boolean")
311
- if page.get("cursor") is not None and not (isinstance(page.get("cursor"), str) and _OPAQUE.fullmatch(page["cursor"])):
312
- problems.append("/cursor is a board id or null")
313
- projects = page.get("projects")
314
- if not isinstance(projects, list) or len(projects) > 100:
315
- problems.append("/projects is at most 100 Projects")
316
- return problems
317
- for p, project in enumerate(projects):
318
- at = "/projects/%d" % p
319
- if not _closed(project, at, ("id", "name", "capabilities"), ("id", "name", "capabilities"), problems):
320
- continue
321
- if not (isinstance(project.get("id"), str) and _PROJECT.fullmatch(project["id"])):
322
- problems.append("%s/id is a Project id" % at)
323
- if not (isinstance(project.get("name"), str) and 1 <= len(project["name"]) <= 120):
324
- problems.append("%s/name is 1 to 120 characters" % at)
325
- caps = project.get("capabilities")
326
- if not isinstance(caps, list) or len(caps) > 64:
327
- problems.append("%s/capabilities is at most 64 entries" % at)
328
- continue
329
- for c, entry in enumerate(caps):
330
- cat = "%s/capabilities/%d" % (at, c)
331
- if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
332
- if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
333
- problems.append("%s/capability is a capability name" % cat)
334
- if entry.get("responder") not in ("active", "stale", "absent"):
335
- problems.append("%s/responder is active, stale or absent" % cat)
336
- if "held" in entry and not isinstance(entry["held"], bool):
337
- problems.append("%s/held is a boolean (DEC-0079)" % cat)
338
- return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
339
-
340
-
341
- def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
342
- """DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
343
- ``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
344
- ``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
345
- raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
346
- scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
347
- if not scopes or not isinstance(target, dict):
348
- return False
349
- if target.get("repeat") is True:
350
- return False
351
- if tool == "com.reply" and target.get("fenced") is True:
352
- return False
353
- holds = holds if isinstance(holds, dict) else {}
354
-
355
- def text(value: Any) -> Optional[str]:
356
- return value if isinstance(value, str) else None
357
-
358
- def strings(value: Any) -> set:
359
- return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
360
-
361
- thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
362
- if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
363
- return True
364
- if "project" in scopes and project is not None and project in strings(holds.get("projects")):
365
- return True
366
- slots = holds.get("slots")
367
- if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
368
- return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
369
- for slot in slots)
370
- return False
371
-
372
-
373
- def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
374
- """Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
375
- directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
376
- `BoardError("directory-invalid")` instead of answering from it. Ids and capability names compare exactly as
377
- written (DEC-0077). Answers the entry, or None when the page does not list it (or lists it without `capability`):
378
- an unlisted Project may still be addressable by an id the caller already holds."""
379
- problems = directory_problems(page)
380
- if problems:
381
- raise BoardError("directory-invalid", "The directory page cannot be read: %s." % "; ".join(problems[:3]))
382
- for entry in page["projects"]:
383
- if entry["id"] == project_id:
384
- if capability is None or any(c["capability"] == capability for c in entry["capabilities"]):
385
- return entry
386
- return None
387
- return None
388
-
389
-
390
- # --- evidence -------------------------------------------------------------------------------------------------
391
-
392
- def artifact_ref(uri: str, content: Optional[bytes] = None, path: Optional[Path] = None, label: Optional[str] = None) -> Dict[str, Any]:
393
- """One artifact reference `{id, contentHash, label?}`: `id` is a URI the requester can resolve; the hash is
394
- computed here from `content` or the file at `path`, never trusted from elsewhere."""
395
- if (content is None) == (path is None):
396
- raise BoardError("evidence", "Give the artifact's bytes or its path, one of them.")
397
- digest = hashlib.sha256()
398
- if content is not None:
399
- digest.update(content)
400
- else:
401
- with open(str(path), "rb") as handle:
402
- for chunk in iter(lambda: handle.read(1 << 20), b""):
403
- digest.update(chunk)
404
- ref: Dict[str, Any] = {"id": uri, "contentHash": "sha256:" + digest.hexdigest()}
405
- if label is not None:
406
- ref["label"] = label
407
- problems = evidence_problems([ref])
408
- if problems:
409
- raise BoardError("evidence", "; ".join(problems))
410
- return ref
411
-
412
-
413
- def evidence_problems(evidence: Any) -> List[str]:
414
- """At most 8 references, each `{id: uri, contentHash: sha256:<hex>, label? ≤ 120}`, closed; and no artifact twice:
415
- two ids with one normal form (DEC-0076) name one artifact."""
416
- if not isinstance(evidence, list) or len(evidence) > EVIDENCE_MAX:
417
- return ["evidence is a list of at most %d artifact references" % EVIDENCE_MAX]
418
- problems: List[str] = []
419
- for i, ref in enumerate(evidence):
420
- at = "evidence/%d" % i
421
- if not _closed(ref, at, ("id", "contentHash"), ("id", "contentHash", "label"), problems):
422
- continue
423
- if not (isinstance(ref.get("id"), str) and _URI.fullmatch(ref["id"])):
424
- problems.append("%s/id is a URI" % at)
425
- if not (isinstance(ref.get("contentHash"), str) and _SHA256.fullmatch(ref["contentHash"])):
426
- problems.append("%s/contentHash is sha256:<hex>" % at)
427
- if "label" in ref and not (isinstance(ref["label"], str) and len(ref["label"]) <= 120):
428
- problems.append("%s/label is at most 120 characters" % at)
429
- for j in range(i):
430
- other = evidence[j]
431
- if isinstance(other, dict) and _ids.same_identifier(other.get("id"), ref.get("id")):
432
- problems.append("%s/id names the artifact evidence/%d already names" % (at, j))
433
- return problems
434
-
435
-
436
- def outcome_problems(outcome: Any, effect_began: bool) -> List[str]:
437
- """`comms-complete`'s outcome: state `completed`|`failed_known`, `observed` a boolean (true after an effect began),
438
- summary ≤ 2,000 characters, evidence as `evidence_problems`; plus the kit's optional `reply {text, format?,
439
- artifacts?}` (≤ 65,536 UTF-8 bytes), sent with `com.reply` before the completion."""
440
- problems: List[str] = []
441
- if not _closed(outcome, "outcome", ("state",), ("state", "observed", "summary", "evidence", "reply"), problems):
442
- return problems
443
- if outcome.get("state") not in ("completed", "failed_known"):
444
- problems.append("outcome/state is completed or failed_known")
445
- observed = outcome.get("observed", not effect_began)
446
- if not isinstance(observed, bool):
447
- problems.append("outcome/observed is a boolean")
448
- elif effect_began and observed is not True:
449
- problems.append("observation_required: after an effect began only an observed result settles the request")
450
- if "summary" in outcome and not (isinstance(outcome["summary"], str) and 1 <= len(outcome["summary"]) <= SUMMARY_MAX):
451
- problems.append("outcome/summary is 1 to %d characters" % SUMMARY_MAX)
452
- if "evidence" in outcome:
453
- problems.extend(evidence_problems(outcome["evidence"]))
454
- if "reply" in outcome:
455
- reply = outcome["reply"]
456
- if _closed(reply, "outcome/reply", ("text",), ("text", "format", "artifacts"), problems):
457
- text = reply.get("text")
458
- if not (isinstance(text, str) and text and len(text.encode("utf-8")) <= 65536):
459
- problems.append("outcome/reply/text is 1 to 65,536 UTF-8 bytes")
460
- if "format" in reply and reply["format"] not in ("text/plain", "text/markdown"):
461
- problems.append("outcome/reply/format is text/plain or text/markdown")
462
- if "artifacts" in reply:
463
- problems.extend(p.replace("evidence", "outcome/reply/artifacts", 1) for p in evidence_problems(reply["artifacts"]))
464
- return problems
465
-
466
-
467
- # --- the board session --------------------------------------------------------------------------------------------
468
-
469
- class BoardSession:
470
- """MCP over HTTP with a board session's one-session bearer: `open()` spends it with `initialize` and keeps the
471
- `mcp-session-id`; `call(tool, args)` answers the tool's value or raises `BoardError`; `close()` sends the session's
472
- DELETE, which releases every slot it holds (DEC-0073 §5). Thread-safe; the bearer never leaves the headers."""
473
-
474
- def __init__(self, grant: _hub.BoardGrant, *, timeout: float = 15.0, post: Callable[..., Any] = _hub.post_mcp):
475
- self.grant, self.timeout, self.post = grant, timeout, post
476
- self.mcp_session: Optional[str] = None
477
- self.protocol_version = _hub.MCP_PROTOCOL_VERSION
478
- self._lock = threading.Lock()
479
- self._rpc = 0
480
-
481
- def _send(self, body: Dict[str, Any], method: str = "POST"):
482
- try:
483
- return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session, timeout=self.timeout,
484
- method=method, protocol_version=self.protocol_version if self.mcp_session else None)
485
- except _hub.HubError as exc:
486
- raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
487
- "the same idempotency key.", True) from exc
488
-
489
- def open(self) -> None:
490
- with self._lock:
491
- self._rpc += 1
492
- status, headers, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "initialize", "params": {
493
- "protocolVersion": _hub.MCP_PROTOCOL_VERSION, "capabilities": {},
494
- "clientInfo": {"name": "fabric-agent-adapter-board", "version": "1"}}})
495
- if status == 401:
496
- raise BoardError("session-ended", "Fabric refused the board session's bearer; join again.", True)
497
- if status != 200 or not isinstance(parsed, dict) or not isinstance(parsed.get("result"), dict) or not headers.get("mcp-session-id"):
498
- raise BoardError("unreachable", "Fabric did not open the board session (HTTP %d)." % status, True)
499
- self.mcp_session = headers["mcp-session-id"]
500
- version = parsed["result"].get("protocolVersion")
501
- if isinstance(version, str):
502
- self.protocol_version = version
503
- self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
504
-
505
- def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
506
- if tool not in RESPOND_TOOLS:
507
- raise BoardError("not-in-scope", "%s is not served to a board session; use a Project session for it." % tool, op=tool)
508
- with self._lock:
509
- if self.mcp_session is None:
510
- raise BoardError("session-ended", "The board session is not open.", True, op=tool)
511
- self._rpc += 1
512
- status, _, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "tools/call",
513
- "params": {"name": tool, "arguments": args}})
514
- if status == 401:
515
- raise BoardError("session-ended", "Fabric ended the board session (it left, was revoked or expired); join again.", True, op=tool)
516
- try:
517
- value = _hub.tool_answer(status, parsed, tool)
518
- except _hub.HubError as exc:
519
- raise BoardError("unreachable" if exc.retryable else "receipt-shape", exc.message, exc.retryable, op=tool) from None
520
- if "error" in value:
521
- problems = refusal_problems(value)
522
- if problems:
523
- raise BoardError("receipt-shape", "Fabric refused %s in a form the contract does not name." % tool, op=tool)
524
- e = value["error"]
525
- raise BoardError(e["code"], e["message"], e["retryable"], op=tool)
526
- return value
527
-
528
- def close(self) -> None:
529
- with self._lock:
530
- if self.mcp_session is None:
531
- return
532
- try:
533
- self._send({}, method="DELETE")
534
- except BoardError:
535
- pass # best effort: the lease lapses in 60 s anyway
536
- self.mcp_session = None
537
-
538
-
539
- # --- the checkpoint ---------------------------------------------------------------------------------------------
540
-
541
- class Checkpoint:
542
- """The responder's own journal: one JSON file (0600) holding every request it has claimed and not yet settled, each
543
- with its fence and the idempotency key of the step in flight. Written atomically and fsynced BEFORE the step it
544
- announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken."""
545
-
546
- def __init__(self, path: Path):
547
- self.path = Path(path)
548
- self._lock = threading.Lock()
549
- self.entries: Dict[str, Dict[str, Any]] = self._load()
550
-
551
- def _load(self) -> Dict[str, Dict[str, Any]]:
552
- try:
553
- doc = json.loads(self.path.read_text(encoding="utf-8"))
554
- except FileNotFoundError:
555
- return {}
556
- except (OSError, ValueError) as exc:
557
- raise BoardError("checkpoint", "The board checkpoint %s cannot be read (%s); fix or move it before answering "
558
- "requests." % (self.path, exc.__class__.__name__)) from None
559
- entries = doc.get("requests") if isinstance(doc, dict) and doc.get("version") == 1 else None
560
- if not isinstance(entries, dict):
561
- raise BoardError("checkpoint", "The board checkpoint %s is not one this kit writes." % self.path)
562
- return entries
563
-
564
- def _write(self, entries: Dict[str, Dict[str, Any]]) -> None:
565
- try:
566
- _fs.ensure_private_dir(self.path.parent)
567
- _fs.atomic_write(self.path, (json.dumps({"version": 1, "requests": entries}, indent=1, sort_keys=True) + "\n").encode())
568
- except OSError as exc:
569
- raise BoardError("checkpoint", "The board checkpoint could not be written (%s); nothing was sent." % (exc.strerror or exc.__class__.__name__)) from None
570
-
571
- def put(self, entry: Dict[str, Any]) -> None:
572
- with self._lock:
573
- entries = dict(self.entries)
574
- entries[entry["request"]] = json.loads(json.dumps(entry))
575
- self._write(entries)
576
- self.entries = entries
577
-
578
- def drop(self, request_id: str) -> None:
579
- with self._lock:
580
- if request_id not in self.entries:
581
- return
582
- entries = dict(self.entries)
583
- del entries[request_id]
584
- self._write(entries)
585
- self.entries = entries
586
-
587
-
588
- # --- the request a handler sees --------------------------------------------------------------------------------
589
-
590
- class BoardRequest:
591
- """A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
592
- principal (with provenance); `artifacts` its references, unverified."""
593
-
594
- __slots__ = ("request_id", "message_id", "capability", "body", "body_format", "artifacts", "sender", "deadline", "thread")
595
-
596
- def __init__(self, message: Dict[str, Any], capability: str):
597
- req = message.get("request") or {}
598
- body = message.get("body") or {}
599
- self.request_id, self.message_id, self.capability = req.get("id"), message.get("id"), capability
600
- self.body = body.get("text") if isinstance(body, dict) else None
601
- self.body_format = body.get("format", "text/plain") if isinstance(body, dict) else "text/plain"
602
- self.artifacts = list(message.get("artifacts") or [])
603
- self.sender = message.get("sender") or {}
604
- self.deadline = req.get("deadline")
605
- self.thread = message.get("thread")
606
-
607
- def __repr__(self) -> str: # never the body
608
- return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
609
-
610
-
611
- class WorkContext:
612
- """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
613
- lease); `begin_effect(summary)` must be called — and must return — before the handler spends or acts outside
614
- the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then."""
615
-
616
- def __init__(self, responder: "Responder", entry: Dict[str, Any]):
617
- self._responder, self._entry = responder, entry
618
- self.stopped = threading.Event()
619
-
620
- def progress(self, note: Optional[str] = None) -> None:
621
- if self.stopped.is_set():
622
- raise Stopped()
623
- self._responder._progress(self._entry, note, self)
624
-
625
- def begin_effect(self, summary: Optional[str] = None) -> None:
626
- if self.stopped.is_set():
627
- raise Stopped()
628
- self._responder._effect_begin(self._entry, summary, self)
629
-
630
-
631
- class _Work:
632
- def __init__(self, entry: Dict[str, Any], request: BoardRequest, ctx: WorkContext):
633
- self.entry, self.request, self.ctx = entry, request, ctx
634
- self.done = threading.Event()
635
- self.outcome: Optional[Dict[str, Any]] = None
636
- self.failure: Optional[BaseException] = None
637
- self.renewed_at: Optional[float] = None
638
-
639
-
640
- # --- the responder ------------------------------------------------------------------------------------------------
641
-
642
- Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
643
-
644
-
645
- class Responder:
646
- """One service's responder for one Project and one or more capabilities, one request at a time.
647
-
648
- `handler(request, ctx)` returns the outcome `{state, observed?, summary?, evidence?, reply?}`; `admit(request)`
649
- is the service's input gate (None admits; a sentence refuses); `observe(entry)` reads the world after a restart and
650
- answers an observed outcome or None, for `com.reconcile`. `emit(kind, level, text, **extra)` receives the
651
- responder's events (`JsonlEventLog.append` fits). `run(stop)` loops until `stop` is set; `step()` is one tick and
652
- answers the seconds until the next."""
653
-
654
- def __init__(self, *, hub: _hub.HubClient, project_id: str, capabilities: Sequence[str], credential_file: Path,
655
- checkpoint_file: Path, handler: Handler, admit: Callable[[BoardRequest], Optional[str]],
656
- observe: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
657
- emit: Optional[Callable[..., Any]] = None, service: str = "service",
658
- now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
659
- spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None):
660
- if admit is None or handler is None:
661
- raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
662
- caps = list(capabilities)
663
- if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
664
- raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
665
- self.hub, self.project = hub, _hub._project(project_id)
666
- self.capabilities = caps
667
- self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
668
- self.checkpoint = Checkpoint(Path(checkpoint_file))
669
- self._emit_to, self.service, self.now, self.rand = emit, re.sub(r"[^A-Za-z0-9._-]", "-", service)[:40] or "service", now, rand
670
- self.spawn = spawn or (lambda fn: threading.Thread(target=fn, name="board-handler", daemon=True).start())
671
- self.session_factory = session_factory or (lambda grant: BoardSession(grant))
672
- self.backoff = _hub.Backoff(rand=rand)
673
- self.session: Optional[BoardSession] = None
674
- self.epoch: Optional[int] = None
675
- self.slots: Dict[str, Dict[str, Any]] = {}
676
- self.current: Optional[_Work] = None
677
- self.state = "idle"
678
- self.wait_until = 0.0
679
- self.stopping = False
680
- self.claim_keys: Dict[str, str] = {}
681
- self._said: Dict[str, str] = {}
682
- self._rr = 0
683
- #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
684
- self.hold_delay = POLL_SECONDS
685
- self._on_hold: set = set()
686
-
687
- # -- events -------------------------------------------------------------------------------------------------
688
- def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
689
- if episode is not None:
690
- if self._said.get(kind) == episode:
691
- return # once per episode
692
- self._said[kind] = episode
693
- data = {"schema": EVENT_SCHEMA, "value": {k: v for k, v in value.items() if v is not None}}
694
- problems = _fs.feed_data_problems(data)
695
- if problems: # FAC-SEM-101: never a credential in data; drop the data, keep the sentence
696
- data = None
697
- if self._emit_to is None:
698
- return
699
- try:
700
- self._emit_to(kind, level, text, notify=notify, **({"data": data} if data else {}))
701
- except Exception: # noqa: BLE001 - an event that cannot be written never stops the responder
702
- pass
703
-
704
- # -- keys and fences ------------------------------------------------------------------------------------------
705
- def _key(self, op: str) -> str:
706
- return "%s.%s.%s" % (self.service, op, secrets.token_hex(8))
707
-
708
- def _idem(self, key: str) -> Dict[str, Any]:
709
- if self.epoch is None:
710
- raise BoardError("no-epoch", "Fabric told this board session no idempotency epoch.", op="com.status")
711
- return {"epoch": self.epoch, "key": key}
712
-
713
- def _fence(self, entry: Dict[str, Any], key: str) -> Dict[str, Any]:
714
- return {"request": entry["request"], "generation": entry["generation"], "attempt": entry["attempt"],
715
- "digest": entry["digest"], "expectedRevision": entry["revision"], "idempotency": self._idem(key)}
716
-
717
- def _receipt(self, op: str, answer: Dict[str, Any]) -> Dict[str, Any]:
718
- problems = receipt_problems(answer, op)
719
- if problems:
720
- raise BoardError("receipt-shape", "Fabric answered %s with a receipt the contract does not name (%s)." % (op, problems[0]), op=op)
721
- return answer
722
-
723
- def _call(self, op: str, args: Dict[str, Any]) -> Dict[str, Any]:
724
- if self.session is None:
725
- raise BoardError("session-ended", "No board session.", True, op=op)
726
- return self.session.call(op, args)
727
-
728
- # -- connecting --------------------------------------------------------------------------------------------
729
- def _status(self) -> Dict[str, Any]:
730
- status = self._call("com.status", {})
731
- verdict = _fs.negotiate_revision(status.get("protocol"), _fs.accepted_revisions("fabric-project-comms"))
732
- if "error" in verdict:
733
- raise BoardError("schema-unsupported", "Fabric's board speaks %s, which this kit does not read." % status.get("protocol"),
734
- data=verdict["error"]["data"])
735
- if (status.get("board") or {}).get("state") == "unavailable":
736
- raise BoardError("not_available", "Fabric's board cannot be read right now.", True, op="com.status")
737
- epoch = (status.get("idempotency") or {}).get("epoch")
738
- if not _is_int(epoch, 1):
739
- # Fail closed: a guessed epoch would store keys under a namespace the board never named.
740
- raise BoardError("no-epoch", "Fabric told this board session no idempotency epoch (Fabric before its board-session "
741
- "fix); the responder waits rather than guess one.", op="com.status")
742
- self.epoch = epoch
743
- return status
744
-
745
- def _connect(self) -> None:
746
- self.state = "connecting"
747
- grant = self.hub.join_board(self.project, self.credential_file)
748
- missing = [c for c in self.capabilities if c not in grant.capabilities]
749
- session = self.session_factory(grant)
750
- session.open()
751
- self.session = session
752
- status = self._status()
753
- if missing:
754
- self._emit("board.access.needed", "warn", "The operator has not let this service answer %s on this Project; ask in "
755
- "Fabric (Agents & access)." % ", ".join(missing), notify=True, episode=",".join(missing),
756
- capabilities=missing)
757
- self.slots = {}
758
- self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
759
- self.state = "ready"
760
- for kind in ("board.down", "board.blocked") + (() if missing else ("board.access.needed",)):
761
- self._said.pop(kind, None)
762
-
763
- def _enroll(self, capability: str) -> None:
764
- args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
765
- try:
766
- receipt = self._receipt("com.enroll", self._call("com.enroll", args))
767
- except BoardError as exc:
768
- if exc.code == "held":
769
- self._pause(capability)
770
- return
771
- if exc.code == "fenced":
772
- self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
773
- self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
774
- "waits for it to lapse and never takes it over by itself." % capability, notify=True,
775
- episode=capability, capability=capability)
776
- return
777
- raise
778
- self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
779
- self._said.pop("board.slot.contested", None)
780
- self._lifted(capability)
781
-
782
- def _pause(self, capability: str) -> None:
783
- """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
784
- never act around the hold. Accepted work in progress continues; only new work waits."""
785
- slot = self.slots.get(capability) or {}
786
- pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
787
- wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
788
- self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
789
- if capability not in self._on_hold: # once per hold, per capability
790
- self._on_hold.add(capability)
791
- self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
792
- "asks again later. Work already accepted continues." % capability, capability=capability)
793
-
794
- def _lifted(self, capability: str) -> None:
795
- if capability in self._on_hold:
796
- self._on_hold.discard(capability)
797
- self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
798
- "again." % capability, capability=capability)
799
-
800
- # -- restart: reconcile and transfer -------------------------------------------------------------------------
801
- def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
802
- message = self._call("com.get", {"message": entry["message"]})
803
- req = message.get("request") if isinstance(message, dict) else None
804
- if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("state") not in REQUEST_STATES:
805
- return None
806
- entry["revision"] = req.get("revision", entry.get("revision"))
807
- return req["state"]
808
-
809
- def _recover(self, status: Dict[str, Any], capabilities: List[str]) -> None:
810
- transfer: Dict[str, List[Dict[str, Any]]] = {}
811
- reconcile: List[Dict[str, Any]] = []
812
- for entry in list(self.checkpoint.entries.values()):
813
- if entry.get("capability") not in capabilities:
814
- continue
815
- try:
816
- state = self._read_state(entry)
817
- except BoardError as exc:
818
- if exc.code in ("not_authorized",):
819
- self.checkpoint.drop(entry["request"])
820
- continue
821
- raise
822
- if state is None or state in TERMINAL or state in ("queued", "claimed"):
823
- self.checkpoint.drop(entry["request"]) # settled, or back in the queue for whoever claims next
824
- elif state == "accepted":
825
- transfer.setdefault(entry["capability"], []).append(entry)
826
- else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
827
- reconcile.append(entry)
828
- responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
829
- generations = {r.get("capability"): r.get("generation", 0) for r in responders}
830
- on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
831
- for capability in capabilities:
832
- held = transfer.get(capability)
833
- if capability in on_hold:
834
- for entry in held or []:
835
- entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
836
- self.checkpoint.put(entry)
837
- self._pause(capability)
838
- elif held:
839
- self._replace(capability, held, generations.get(capability, 0))
840
- else:
841
- self._enroll(capability)
842
- for entry in reconcile:
843
- if self.slots.get(entry["capability"], {}).get("state") == "held":
844
- self._reconcile(entry)
845
-
846
- def _replace(self, capability: str, entries: List[Dict[str, Any]], generation: Any) -> None:
847
- if not _is_int(generation, 0):
848
- generation = 0
849
- args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
850
- "transfer": {"requests": [e["request"] for e in entries][:64],
851
- "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
852
- try:
853
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
854
- except BoardError as exc:
855
- if exc.code != "held":
856
- raise
857
- for entry in entries:
858
- entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
859
- self.checkpoint.put(entry)
860
- self._pause(capability)
861
- return
862
- self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
863
- self._lifted(capability)
864
- moved = {item["request"]: item for item in receipt.get("transferred") or []}
865
- for entry in entries:
866
- item = moved.get(entry["request"])
867
- if item is None:
868
- # A board pinned before DEC-0078 hands back no attempt. Never claim it back: it stays held, and the next
869
- # start (or the operator) decides.
870
- entry["step"] = "held"
871
- self.checkpoint.put(entry)
872
- self._emit("board.request.held", "warn", "An accepted request could not be resumed: this Fabric does not hand "
873
- "back a transferred attempt (DEC-0078). It stays held; nothing was done.", notify=True,
874
- episode=entry["request"], request=entry["request"], capability=capability)
875
- continue
876
- entry.update(generation=item["attempt"]["generation"], attempt=item["attempt"]["id"], digest=item["attempt"]["digest"],
877
- revision=item["revision"], step="accepted" if entry.get("step") in ("accepting", "held", "lapsed") else entry.get("step"))
878
- self.checkpoint.put(entry)
879
- if self.current is None:
880
- self._resume(entry)
881
-
882
- def _reconcile(self, entry: Dict[str, Any]) -> None:
883
- state = self._read_state(entry)
884
- if state != "outcome_unknown":
885
- if state in TERMINAL or state in ("queued", "claimed"):
886
- self.checkpoint.drop(entry["request"])
887
- return
888
- outcome = entry.get("outcome") if isinstance(entry.get("outcome"), dict) and entry["outcome"].get("observed") is True else None
889
- if outcome is None and self.observe is not None:
890
- try:
891
- outcome = self.observe(dict(entry))
892
- except Exception: # noqa: BLE001 - an observation that failed is no observation
893
- outcome = None
894
- if not outcome or outcome_problems(dict(outcome, observed=True), True) or outcome.get("observed") is not True:
895
- entry["step"] = "unknown"
896
- self.checkpoint.put(entry)
897
- self._emit("board.reconcile.needed", "warn", "A request's outcome is unknown: the work may have happened. It stays "
898
- "unknown until this service observes what happened.", notify=True, episode=entry["request"],
899
- request=entry["request"], capability=entry["capability"])
900
- return
901
- clean = {k: outcome[k] for k in ("state", "summary", "evidence") if k in outcome}
902
- clean["observed"] = True
903
- args = {"idempotency": self._idem(entry.get("keys", {}).get("reconcile") or self._key("reconcile")), "request": entry["request"],
904
- "digest": entry["digest"], "outcome": clean}
905
- entry.setdefault("keys", {})["reconcile"] = args["idempotency"]["key"]
906
- entry["step"] = "reconciling"
907
- self.checkpoint.put(entry)
908
- self._receipt("com.reconcile", self._call("com.reconcile", args))
909
- self.checkpoint.drop(entry["request"])
910
- self._emit("board.request.reconciled", "info", "A request whose outcome was unknown was settled from what this service "
911
- "observed.", request=entry["request"], capability=entry["capability"], state=clean["state"])
912
-
913
- # -- one tick ------------------------------------------------------------------------------------------------
914
- def interval(self) -> float:
915
- return max(1.0, POLL_SECONDS + POLL_JITTER_SECONDS * (2 * self.rand() - 1))
916
-
917
- def step(self) -> float:
918
- """One tick. Answers the seconds to wait before the next."""
919
- if self.stopping:
920
- return DRAIN_SECONDS
921
- now = self.now()
922
- if now < self.wait_until:
923
- return self.wait_until - now
924
- try:
925
- if self.session is None:
926
- self._connect()
927
- self._tick()
928
- self.backoff.reset()
929
- return self.interval()
930
- except (BoardError, _hub.HubError) as exc:
931
- return self._fault(exc)
932
-
933
- def _fault(self, exc: Exception) -> float:
934
- code = getattr(exc, "code", "unreachable")
935
- wait = self.backoff.next()
936
- self.wait_until = self.now() + wait
937
- if code in ("not_authorized", "authority_changed", "credential"):
938
- self.state = "not-authorized"
939
- self._drop_session()
940
- self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
941
- "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
942
- episode="access", reason=code)
943
- elif code == "held":
944
- self.state = "paused"
945
- self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
946
- "with a growing pause.", episode="held", reason=code)
947
- elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
948
- self.state = "down"
949
- self._drop_session()
950
- self._emit("board.blocked", "error", "The responder stopped answering requests: %s" % getattr(exc, "message", str(exc)),
951
- notify=True, episode=code, reason=code)
952
- else:
953
- self.state = "down"
954
- if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol"):
955
- self._drop_session()
956
- self._emit("board.down", "warn", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
957
- episode="down", reason=code)
958
- return wait
959
-
960
- def _drop_session(self) -> None:
961
- work, self.current = self.current, None
962
- if work is not None:
963
- entry = work.entry
964
- if work.done.is_set() and work.failure is None and not outcome_problems(work.outcome, bool(entry.get("effect"))):
965
- # The answer is known: keep it, so the next session sends it instead of doing the work again.
966
- entry["outcome"] = dict(work.outcome, observed=work.outcome.get("observed", not entry.get("effect")))
967
- try:
968
- self.checkpoint.put(entry)
969
- except BoardError:
970
- pass
971
- else:
972
- work.ctx.stopped.set() # the lease lapses; acting on would outlive the attempt
973
- session, self.session = self.session, None
974
- if session is not None:
975
- session.close()
976
-
977
- def _tick(self) -> None:
978
- work = self.current
979
- if work is not None:
980
- if work.done.is_set():
981
- self._finish(work)
982
- else:
983
- self._renew(work)
984
- held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
985
- for capability, slot in list(self.slots.items()):
986
- if slot.get("state") == "paused":
987
- if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
988
- self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
989
- continue
990
- if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
991
- self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
992
- if self.current is None and not self.stopping:
993
- self._continue_lapsed()
994
- if self.current is None and not self.stopping:
995
- self._reconcile_pending()
996
- self._claim_next()
997
-
998
- def _continue_lapsed(self) -> None:
999
- """Accepted work whose lease lapsed goes back to this responder by `com.responder_replace` with a transfer."""
1000
- lapsed: Dict[str, List[Dict[str, Any]]] = {}
1001
- for entry in self.checkpoint.entries.values():
1002
- if entry.get("step") == "lapsed" and self.slots.get(entry.get("capability"), {}).get("state") == "held":
1003
- lapsed.setdefault(entry["capability"], []).append(json.loads(json.dumps(entry)))
1004
- for capability, entries in lapsed.items():
1005
- live = [e for e in entries if self._read_state(e) == "accepted"]
1006
- for e in entries:
1007
- if e not in live:
1008
- self.checkpoint.drop(e["request"])
1009
- if live:
1010
- self._replace(capability, live, self.slots[capability].get("generation", 0))
1011
-
1012
- def _reconcile_pending(self) -> None:
1013
- """Work whose outcome became unknown while running (its attempt was lost after the effect began) is tried
1014
- again every five minutes: the slot's holder reconciles from what `observe` reads, or it stays unknown."""
1015
- if self.now() < getattr(self, "_reconcile_at", 0.0):
1016
- return
1017
- self._reconcile_at = self.now() + 300.0
1018
- for entry in list(self.checkpoint.entries.values()):
1019
- if entry.get("step") == "unknown" and self.slots.get(entry.get("capability"), {}).get("state") == "held":
1020
- self._reconcile(entry)
1021
-
1022
- def _claim_next(self) -> None:
1023
- held = [c for c in self.capabilities if self.slots.get(c, {}).get("state") == "held"]
1024
- for i in range(len(held)):
1025
- capability = held[(self._rr + i) % len(held)]
1026
- key = self.claim_keys.setdefault(capability, self._key("claim"))
1027
- try:
1028
- answer = self._receipt("com.claim", self._call("com.claim", {"idempotency": self._idem(key), "capability": capability}))
1029
- except BoardError as exc:
1030
- if exc.code == "lease_expired":
1031
- self._enroll(capability) # the slot lapsed: enroll again (a new generation)
1032
- continue
1033
- if exc.code in ("idempotency_window_expired", "capacity_exceeded"):
1034
- self.claim_keys.pop(capability, None)
1035
- self._status()
1036
- continue
1037
- if exc.code == "fenced":
1038
- self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
1039
- continue
1040
- if exc.code == "held":
1041
- self._pause(capability)
1042
- continue
1043
- raise
1044
- if "request" not in answer:
1045
- continue # nothing claimable; the same key polls again
1046
- self.claim_keys.pop(capability, None)
1047
- self._rr += 1
1048
- self._start(capability, answer)
1049
- return
1050
-
1051
- # -- a claimed request -------------------------------------------------------------------------------------
1052
- def _start(self, capability: str, receipt: Dict[str, Any]) -> None:
1053
- if "attempt" not in receipt or "message" not in receipt:
1054
- raise BoardError("receipt-shape", "com.claim answered a request without its attempt or message.", op="com.claim")
1055
- attempt = receipt["attempt"]
1056
- entry = {"request": receipt["request"]["id"], "message": receipt["message"], "capability": capability,
1057
- "generation": attempt["generation"], "attempt": attempt["id"], "digest": attempt["digest"],
1058
- "revision": receipt["request"]["revision"], "step": "claimed", "keys": {}, "effect": False}
1059
- self.checkpoint.put(entry)
1060
- message = self._call("com.get", {"message": entry["message"]})
1061
- req = message.get("request") if isinstance(message, dict) else None
1062
- if message.get("digest") != entry["digest"]:
1063
- self.checkpoint.drop(entry["request"])
1064
- self._emit("board.request.skipped", "warn", "A claimed request was not accepted: the message read back carries "
1065
- "another digest than the claim. Nothing was done; the claim lapses.", request=entry["request"],
1066
- capability=capability, reason="digest-mismatch")
1067
- return
1068
- if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != capability \
1069
- or req.get("target") != self.project:
1070
- self.checkpoint.drop(entry["request"])
1071
- self._emit("board.request.skipped", "warn", "A claimed request was not accepted: it is not addressed to this "
1072
- "Project and capability. Nothing was done; the claim lapses.", request=entry["request"],
1073
- capability=capability, reason="not-addressed")
1074
- return
1075
- request = BoardRequest(message, capability)
1076
- if message.get("redacted") is True or not isinstance(request.body, str):
1077
- reason: Optional[str] = "The request's body was redacted, so there is nothing to act on."
1078
- else:
1079
- try:
1080
- reason = self.admit(request)
1081
- except Exception: # noqa: BLE001 - a gate that fails refuses
1082
- reason = "The service's input gate could not read this request."
1083
- if not self._accept(entry):
1084
- return
1085
- if reason is not None:
1086
- text = ("Refused by this service's input gate: %s" % " ".join(str(reason).split()))[:SUMMARY_MAX]
1087
- entry["outcome"] = {"state": "failed_known", "observed": True, "summary": text}
1088
- self._complete(entry)
1089
- self._emit("board.request.refused", "info", "A request was refused by the service's input gate and completed as "
1090
- "failed.", request=entry["request"], capability=capability)
1091
- return
1092
- self._run(entry, request)
1093
-
1094
- def _accept(self, entry: Dict[str, Any]) -> bool:
1095
- key = entry["keys"].get("accept") or self._key("accept")
1096
- entry["keys"]["accept"], entry["step"] = key, "accepting"
1097
- self.checkpoint.put(entry)
1098
- try:
1099
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1100
- except BoardError as exc:
1101
- if exc.code != "held":
1102
- raise
1103
- # A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
1104
- # waits on the board for the hold to be lifted.
1105
- self.checkpoint.drop(entry["request"])
1106
- self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
1107
- "was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
1108
- self._pause(entry["capability"])
1109
- return False
1110
- if receipt is None:
1111
- return False # the claim was lost before it was accepted; nothing was done
1112
- entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
1113
- self.checkpoint.put(entry)
1114
- return True
1115
-
1116
- def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
1117
- ctx = WorkContext(self, entry)
1118
- work = _Work(entry, request, ctx)
1119
- work.renewed_at = self.now()
1120
- self.current = work
1121
-
1122
- def body() -> None:
1123
- try:
1124
- work.outcome = self.handler(request, ctx)
1125
- except BaseException as exc: # noqa: BLE001 - recorded, decided by _finish
1126
- work.failure = exc
1127
- finally:
1128
- work.done.set()
1129
- self.spawn(body)
1130
- if work.done.is_set():
1131
- self._finish(work)
1132
-
1133
- def _resume(self, entry: Dict[str, Any]) -> None:
1134
- """A transferred request continues: an outcome already known is sent; otherwise the handler runs again (no
1135
- effect had begun, so nothing happened yet)."""
1136
- if isinstance(entry.get("outcome"), dict):
1137
- self._complete(entry)
1138
- return
1139
- message = self._call("com.get", {"message": entry["message"]})
1140
- self._run(entry, BoardRequest(message, entry["capability"]))
1141
-
1142
- def _fenced(self, op: str, entry: Dict[str, Any], args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1143
- """A fenced call. On `fenced`, `lease_expired` or `authority_changed` the attempt is lost: the work stops, and
1144
- answers None (`authority_changed` raises after that, so the responder stops claiming)."""
1145
- try:
1146
- return self._receipt(op, self._call(op, args))
1147
- except BoardError as exc:
1148
- if exc.code in STOP_CODES or exc.code in ("revision_conflict", "invalid_transition", "digest_conflict"):
1149
- self._lose(entry, exc.code)
1150
- if exc.code == "authority_changed":
1151
- raise
1152
- return None
1153
- raise
1154
-
1155
- def _lose(self, entry: Dict[str, Any], code: str) -> None:
1156
- if self.current is not None and self.current.entry is entry:
1157
- self.current.ctx.stopped.set()
1158
- if entry.get("effect"):
1159
- entry["step"] = "unknown"
1160
- self.checkpoint.put(entry)
1161
- self._emit("board.reconcile.needed", "warn", "The attempt was lost after its effect began; the outcome stays unknown "
1162
- "until this service observes what happened.", notify=True, episode=entry["request"],
1163
- request=entry["request"], capability=entry["capability"], reason=code)
1164
- elif code == "lease_expired" and entry.get("step") not in ("claimed", "accepting"):
1165
- # Our own lease lapsed on accepted work: the board holds it (C4). It continues only by a checkpoint transfer
1166
- # to this responder, which the next tick asks for; nothing outside the service had begun.
1167
- entry["step"] = "lapsed"
1168
- self.checkpoint.put(entry)
1169
- self._emit("board.attempt.lost", "warn", "This service's lease on an accepted request lapsed; it stopped working "
1170
- "on it and takes it back by a transfer.", request=entry["request"], capability=entry["capability"], reason=code)
1171
- else:
1172
- self.checkpoint.drop(entry["request"])
1173
- self._emit("board.attempt.lost", "warn", "The board took a request back from this service (%s); it stopped working "
1174
- "on it, and nothing had been done outside the service." % code, request=entry["request"],
1175
- capability=entry["capability"], reason=code)
1176
-
1177
- def _renew(self, work: _Work) -> None:
1178
- entry = work.entry
1179
- if self.now() - (work.renewed_at or 0.0) < RENEW_SECONDS - 0.5:
1180
- return
1181
- receipt = self._fenced("com.renew", entry, {"fence": self._fence(entry, self._key("renew"))})
1182
- if receipt is None:
1183
- self.current = None
1184
- return
1185
- work.renewed_at = self.now()
1186
- self.slots.setdefault(entry["capability"], {})["renewed_at"] = self.now()
1187
-
1188
- def _progress(self, entry: Dict[str, Any], note: Optional[str], ctx: WorkContext) -> None:
1189
- args: Dict[str, Any] = {"fence": self._fence(entry, self._key("progress"))}
1190
- if note is not None:
1191
- args["note"] = {"text": " ".join(str(note).split())[:SUMMARY_MAX] or "Working."}
1192
- if self._fenced("com.progress", entry, args) is None:
1193
- raise Stopped()
1194
-
1195
- def _effect_begin(self, entry: Dict[str, Any], summary: Optional[str], ctx: WorkContext) -> None:
1196
- key = entry["keys"].get("effect_begin") or self._key("effect")
1197
- entry["keys"]["effect_begin"], entry["step"] = key, "effect-beginning"
1198
- entry["effect"] = True # from here a lost attempt is an unknown outcome, never "nothing happened"
1199
- self.checkpoint.put(entry)
1200
- args: Dict[str, Any] = {"fence": self._fence(entry, key)}
1201
- if summary:
1202
- args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
1203
- receipt = None
1204
- lost, delay = 0, self.hold_delay
1205
- while True: # a lost answer: the same key again tells whether it was stored
1206
- try:
1207
- receipt = self._fenced("com.effect_begin", entry, args)
1208
- break
1209
- except BoardError as exc:
1210
- if exc.code == "held":
1211
- # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
1212
- # renews it; com.renew is never held), and ask again with the same key.
1213
- entry["effect"], entry["step"] = False, "effect-held"
1214
- self.checkpoint.put(entry)
1215
- self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
1216
- "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
1217
- request=entry["request"], capability=entry["capability"])
1218
- if ctx.stopped.wait(delay):
1219
- raise Stopped() from None
1220
- delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
1221
- entry["effect"], entry["step"] = True, "effect-beginning"
1222
- self.checkpoint.put(entry)
1223
- continue
1224
- if exc.code != "unreachable" or lost == 2:
1225
- raise Stopped() from None
1226
- lost += 1
1227
- time.sleep(lost)
1228
- if receipt is None:
1229
- raise Stopped()
1230
- entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"
1231
- self.checkpoint.put(entry)
1232
-
1233
- def _finish(self, work: _Work) -> None:
1234
- entry = work.entry
1235
- if work.ctx.stopped.is_set() and entry["request"] not in self.checkpoint.entries:
1236
- self.current = None
1237
- return
1238
- outcome = work.outcome
1239
- if work.failure is not None:
1240
- if entry.get("effect") or isinstance(work.failure, Stopped):
1241
- self.current = None
1242
- if entry.get("effect"):
1243
- self._lose(entry, "handler-failed")
1244
- return
1245
- outcome = {"state": "failed_known", "observed": True,
1246
- "summary": "The service could not do this (%s)." % work.failure.__class__.__name__}
1247
- problems = outcome_problems(outcome, bool(entry.get("effect")))
1248
- if problems:
1249
- self.current = None
1250
- if entry.get("effect"):
1251
- self._lose(entry, "observation_required" if any("observation_required" in p for p in problems) else "outcome")
1252
- else:
1253
- entry["outcome"] = {"state": "failed_known", "observed": True, "summary": "The service answered in a form it cannot send."}
1254
- self._complete(entry)
1255
- self._emit("board.outcome.invalid", "warn", "The handler's outcome was not sent: %s." % problems[0],
1256
- request=entry["request"], capability=entry["capability"])
1257
- return
1258
- entry["outcome"] = dict(outcome, observed=outcome.get("observed", not entry.get("effect")))
1259
- self._complete(entry)
1260
-
1261
- def _complete(self, entry: Dict[str, Any]) -> None:
1262
- outcome = entry["outcome"]
1263
- reply = outcome.get("reply")
1264
- if reply and not entry.get("replied"):
1265
- key = entry["keys"].get("reply") or self._key("reply")
1266
- entry["keys"]["reply"], entry["step"] = key, "replying"
1267
- self.checkpoint.put(entry)
1268
- body = {"text": reply["text"], **({"format": reply["format"]} if "format" in reply else {})}
1269
- args: Dict[str, Any] = {"fence": self._fence(entry, key), "body": body}
1270
- if reply.get("artifacts"):
1271
- args["artifacts"] = reply["artifacts"]
1272
- if self._fenced("com.reply", entry, args) is None:
1273
- self.current = None
1274
- return
1275
- entry["replied"] = True
1276
- self.checkpoint.put(entry)
1277
- key = entry["keys"].get("complete") or self._key("complete")
1278
- entry["keys"]["complete"], entry["step"] = key, "completing"
1279
- self.checkpoint.put(entry)
1280
- sent = {k: outcome[k] for k in ("state", "observed", "summary", "evidence") if k in outcome}
1281
- receipt = self._fenced("com.complete", entry, {"fence": self._fence(entry, key), "outcome": sent})
1282
- self.current = None
1283
- if receipt is None:
1284
- return
1285
- self.checkpoint.drop(entry["request"])
1286
- self._emit("board.request.completed", "info", "A request was answered: %s." % ("completed" if sent["state"] == "completed" else "failed, as known"),
1287
- request=entry["request"], capability=entry["capability"], state=sent["state"])
1288
-
1289
- # -- running ----------------------------------------------------------------------------------------------
1290
- def run(self, stop: threading.Event, drain: float = DRAIN_SECONDS) -> None:
1291
- """Tick until `stop` is set, then `shutdown(drain)`."""
1292
- while not stop.is_set():
1293
- stop.wait(self.step())
1294
- self.shutdown(drain)
1295
-
1296
- def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
1297
- """Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
1298
- (its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
1299
- self.stopping = True
1300
- work = self.current
1301
- if work is not None and not work.done.wait(drain):
1302
- work.ctx.stopped.set()
1303
- if work is not None and work.done.is_set() and self.session is not None:
1304
- try:
1305
- self._finish(work)
1306
- except (BoardError, _hub.HubError):
1307
- pass # the checkpoint keeps it
1308
- if self.session is not None:
1309
- self.session.close()
1310
- self.session = None
1311
- self.state = "stopped"
1312
-
1313
-
1314
- # --- a fixture conversation (check_service.py, rule board.responder) ---------------------------------------------
1315
-
1316
- _FIXTURE_PROJECT = "3f2a6b1c-0d4e-4f5a-8b6c-7d8e9f0a1b2c"
1317
- _FIXTURE_DIGEST = "sha256:" + "a" * 64
1318
-
1319
-
1320
- def _fixture_answers(variant: str) -> Dict[str, Any]:
1321
- """Scripted board answers in the contract's receipt shapes (fixtures comms-receipt-enroll, comms-receipt-claim)."""
1322
- lease = "2026-10-13T10:02:00Z"
1323
- status: Dict[str, Any] = {"protocol": COMMS_PROTOCOL, "board": {"state": "ready"}, "project": _FIXTURE_PROJECT, "responders": [],
1324
- "transport": {"telegram": "off"}, "unread": 0, "idempotency": {"epoch": 3}}
1325
- if variant == "no-epoch":
1326
- del status["idempotency"]
1327
- request = {"id": "req_A1b2C3d4E5", "state": "claimed", "effect": "not_started", "revision": 2}
1328
- return {
1329
- "com.status": status,
1330
- "com.enroll": {"ok": True, "repeated": False, "slot": {"project": _FIXTURE_PROJECT, "capability": "fixture.render", "generation": 2, "leaseExpiresAt": lease}},
1331
- "com.claim": {"ok": True, "repeated": False, "message": "msg_K9j8H7g6F5", "request": request,
1332
- "attempt": {"id": "att_Zx9Cv8Bn7M", "generation": 2, "digest": _FIXTURE_DIGEST, "leaseExpiresAt": lease}},
1333
- "com.get": {"id": "msg_K9j8H7g6F5", "thread": "thr_8Hc2kQ9xA1", "kind": "request", "at": "2026-10-13T10:00:00Z", "digest": _FIXTURE_DIGEST,
1334
- "sender": {"project": "proj-requester", "principal": {"kind": "agent", "id": "s-1", "provenance": "trusted"}},
1335
- "body": {"text": "Render the fixture."},
1336
- "request": {"id": "req_A1b2C3d4E5", "target": _FIXTURE_PROJECT, "capability": "fixture.render", "state": "claimed", "effect": "not_started", "revision": 2}},
1337
- "com.accept": ({"error": {"code": "fenced", "message": "A newer responder holds this slot; this call was not committed.", "retryable": False}}
1338
- if variant == "fenced" else {"ok": True, "repeated": False, "request": dict(request, state="accepted", revision=3)}),
1339
- "com.complete": {"ok": True, "repeated": False, "request": dict(request, state="completed", revision=4)},
1340
- }
1341
-
1342
-
1343
- def self_check() -> List[str]:
1344
- """One scripted conversation per path through this kit, with no Fabric: the whole conversation, a `fenced` accept
1345
- and a board session without an epoch. Answers the problems found; an empty list means this kit answers the fixture
1346
- conversation as the contract asks. Writes only in a temporary folder it removes."""
1347
- import os
1348
- import tempfile
1349
- problems: List[str] = []
1350
- for variant in ("whole", "fenced", "no-epoch"):
1351
- with tempfile.TemporaryDirectory() as tmp:
1352
- root = Path(tmp) / "fabric"
1353
- root.mkdir(mode=0o700)
1354
- door = root / "hub-door-token"
1355
- door.write_text("d" * 43)
1356
- os.chmod(door, 0o600)
1357
- (root / "hub.json").write_text(json.dumps({"protocol": _hub.HUB_PROTOCOL, "origin": "http://127.0.0.1:47070", "mcp": "/mcp",
1358
- "doorTokenFile": str(door), "pid": os.getpid(), "startedAt": "2026-10-13T10:00:00Z"}))
1359
- cred = Path(tmp) / "credential"
1360
- cred.write_text("c" * 43)
1361
- os.chmod(cred, 0o600)
1362
- answers, calls, handled = _fixture_answers(variant), [], []
1363
-
1364
- def post(origin, body, bearer, session=None, timeout=15.0, method="POST", protocol_version=None):
1365
- if method == "DELETE":
1366
- return 200, {}, None
1367
- rpc = body.get("method")
1368
- if rpc == "initialize":
1369
- return 200, {"mcp-session-id": "fixture-session"}, {"jsonrpc": "2.0", "id": body.get("id"), "result": {"protocolVersion": _hub.MCP_PROTOCOL_VERSION}}
1370
- if rpc != "tools/call":
1371
- return 202, {}, None
1372
- tool = body["params"]["name"]
1373
- calls.append((tool, body["params"]["arguments"]))
1374
- if tool == "fabric.board.join":
1375
- value = {"session": {"bearer": "b" * 43, "sessionId": "s-fixture", "projectId": _FIXTURE_PROJECT, "capabilities": ["fixture.render"]}}
1376
- return 200, {}, {"jsonrpc": "2.0", "id": body.get("id"), "result": {"structuredContent": value}}
1377
- value = answers.get(tool, {"error": {"code": "unsupported_capability", "message": "not in the fixture", "retryable": False}})
1378
- return 200, {}, {"jsonrpc": "2.0", "id": body.get("id"), "result": {"content": [{"type": "text", "text": json.dumps(value)}]}}
1379
12
 
1380
- responder = Responder(hub=_hub.HubClient(root, post=post), project_id=_FIXTURE_PROJECT, capabilities=["fixture.render"],
1381
- credential_file=cred, checkpoint_file=Path(tmp) / "checkpoint.json",
1382
- handler=lambda request, ctx: handled.append(request.body) or {"state": "completed", "summary": "Rendered."},
1383
- admit=lambda request: None, now=lambda: 0.0, rand=lambda: 0.5, spawn=lambda fn: fn(),
1384
- session_factory=lambda grant: BoardSession(grant, post=post))
1385
- responder.step()
1386
- tools = [t for t, _ in calls]
1387
- if any(t not in RESPOND_TOOLS and t != "fabric.board.join" for t in tools):
1388
- problems.append("%s: a tool outside the respond scope was sent" % variant)
1389
- if variant == "whole":
1390
- if tools != ["fabric.board.join", "com.status", "com.enroll", "com.claim", "com.get", "com.accept", "com.complete"]:
1391
- problems.append("whole: the calls were %s" % ", ".join(tools))
1392
- accept = dict(calls).get("com.accept", {}).get("fence", {})
1393
- if (accept.get("digest"), accept.get("generation"), accept.get("attempt")) != (_FIXTURE_DIGEST, 2, "att_Zx9Cv8Bn7M"):
1394
- problems.append("whole: com.accept did not carry the claim's exact fence")
1395
- if handled != ["Render the fixture."]:
1396
- problems.append("whole: the handler did not see the request once")
1397
- if responder.checkpoint.entries:
1398
- problems.append("whole: the checkpoint kept a settled request")
1399
- elif variant == "fenced":
1400
- if handled or "com.complete" in tools:
1401
- problems.append("fenced: the kit kept acting after com.accept answered fenced")
1402
- elif "com.enroll" in tools or responder.state != "down":
1403
- problems.append("no-epoch: the kit did not fail closed without an idempotency epoch")
1404
- return problems
13
+ from fabric_board_shapes import ( # noqa: F401
14
+ COMMS_PROTOCOL,
15
+ LEASE_SECONDS,
16
+ SUMMARY_MAX,
17
+ EVIDENCE_MAX,
18
+ RESPOND_TOOLS,
19
+ REFUSAL_CODES,
20
+ REQUEST_STATES,
21
+ EFFECT_STATES,
22
+ HOLD_SCOPES,
23
+ _OPAQUE,
24
+ _PROJECT,
25
+ _CAPABILITY,
26
+ _SHA256,
27
+ _KEY,
28
+ _TIMESTAMP,
29
+ _URI,
30
+ _SAFE,
31
+ BoardError,
32
+ Stopped,
33
+ comms_canonical,
34
+ comms_digest,
35
+ _is_int,
36
+ _closed,
37
+ _attempt_problems,
38
+ receipt_problems,
39
+ refusal_problems,
40
+ directory_findings,
41
+ directory_problems,
42
+ hold_refuses,
43
+ find_project,
44
+ artifact_ref,
45
+ evidence_problems,
46
+ outcome_problems,
47
+ )
48
+ from fabric_board_session import ( # noqa: F401
49
+ RENEW_SECONDS,
50
+ POLL_SECONDS,
51
+ POLL_JITTER_SECONDS,
52
+ DRAIN_SECONDS,
53
+ EVENT_SCHEMA,
54
+ STOP_CODES,
55
+ TERMINAL,
56
+ HOLD_RETRY_MAX_SECONDS,
57
+ BoardSession,
58
+ Checkpoint,
59
+ BoardRequest,
60
+ WorkContext,
61
+ _Work,
62
+ Handler,
63
+ Responder,
64
+ )
65
+ from fabric_board_fixture import ( # noqa: F401
66
+ _FIXTURE_PROJECT,
67
+ _FIXTURE_DIGEST,
68
+ _fixture_answers,
69
+ self_check,
70
+ )