@passioncode-ai/fabric-agent-adapter 0.27.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +50 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.28.0 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
A service answers a Project's requests on Fabric's project board (`fabric-project-comms/0.1`; FAA-12b, contract
|
|
6
|
+
DEC-0022, DEC-0073, DEC-0078; Fabric ADR-0149), and checks a directory page before it looks a Project up (FAA-40,
|
|
7
|
+
DEC-0077, `FAC-SEM-107`). Repinned to contract `f00a768` (DEC-0078: a replacement's receipt carries the attempt of
|
|
8
|
+
each request it transferred); `a046f57` stays supported.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **The hub door client.**
|
|
13
|
+
- `fabric_hub.py` / `fabric-hub.mjs` re-read `hub.json` before every call, the way Fabric's `locateHub` does: a small
|
|
14
|
+
regular file, protocol `fabric-hub/0.1`, the origin exactly `http://127.0.0.1:<port>`, a door token inside the
|
|
15
|
+
registry root, and a live pid. A binding credential is sent only after that check.
|
|
16
|
+
- `request_board_access` asks the operator for the `board.respond` grant. `poll_access` writes the credential, shown
|
|
17
|
+
once, to a 0600 file and leaves it out of the answer. `join_board` answers a board session.
|
|
18
|
+
- Typed `HubError`s: a missing grant carries the arguments to ask with. `Backoff` waits 2 s, doubling up to 5 minutes,
|
|
19
|
+
with jitter, so a loop never spins.
|
|
20
|
+
- A `hubKey` in a later `hub.json` is checked with `hub_key_certified` (DEC-0060) when the caller passes an estate
|
|
21
|
+
record. `fabric-hub/0.1` publishes no key today.
|
|
22
|
+
- **The responder.** `fabric_board.Responder` / `Responder` runs inside the service, one request at a time:
|
|
23
|
+
- Each tick (15 s ± 5 s) enrolls, claims, reads the message and accepts it with the claim's exact fence. The digest
|
|
24
|
+
must match the claim's, and the request must name this Project and capability.
|
|
25
|
+
- `com.effect_begin` is sent only when the handler calls `ctx.begin_effect()`. Renewal is about every 20 s. Progress
|
|
26
|
+
notes, a fenced `com.reply`, and `com.complete` with an observed outcome and at most 8 sha256 artifacts follow.
|
|
27
|
+
- A checkpoint (0600, fsync) is written before each step. If it cannot be written, nothing is sent.
|
|
28
|
+
- It stops acting on `fenced`, `lease_expired` and `authority_changed`.
|
|
29
|
+
- On restart it reconciles unknown outcomes from what `observe` reads. It takes accepted work back only through
|
|
30
|
+
`com.responder_replace`'s `transferred` (DEC-0078). On a Fabric that does not answer `transferred`, the work stays
|
|
31
|
+
held and is never claimed back.
|
|
32
|
+
- A board session never sends `com.submit`, `com.cancel`, `com.read_ack` or `com.directory`.
|
|
33
|
+
- With no idempotency epoch in `com.status`, it fails closed.
|
|
34
|
+
- The body is data: `admit`, the service's own input gate, is required. A refusal completes the request
|
|
35
|
+
`failed_known` with the gate's sentence.
|
|
36
|
+
- **The directory page check (FAA-40).** `directory_findings`, `directory_problems` and `find_project` / `findProject`
|
|
37
|
+
apply `FAC-SEM-107`: each Project once, each capability once per Project, compared exactly as written. A page that
|
|
38
|
+
fails is refused rather than read.
|
|
39
|
+
- **Closed shapes:** `receipt_problems` (with `transferred` for `com.responder_replace` only, each request once),
|
|
40
|
+
`refusal_problems`, `evidence_problems` (one artifact never twice, DEC-0076), `outcome_problems`, and
|
|
41
|
+
`comms_digest`, which matches the contract's `commsDigest` vectors.
|
|
42
|
+
- **`check_service.py`** gains the rule `board.responder`:
|
|
43
|
+
- `NOT_RUN` when the well-known `protocols` lists no `fabric-project-comms` revision.
|
|
44
|
+
- Otherwise it runs the kit's fixture conversation: `self_check()` / `selfCheck()` covers the whole conversation, a
|
|
45
|
+
fenced accept and a session without an epoch. `--board-kit` names the service's own copy.
|
|
46
|
+
- **`sample_service.py serve --board-project <uuid>`** opts in to answering `sample.echo` on the board, with a gate
|
|
47
|
+
and a reply. It is off by default.
|
|
48
|
+
- `references/board.md`, linked from SKILL.md. working-with-fabric's `project-board.md`:
|
|
49
|
+
- The project directory is decided (DEC-0073).
|
|
50
|
+
- Answering as a responder needs `board.respond`.
|
|
51
|
+
|
|
52
|
+
### Evidence
|
|
53
|
+
|
|
54
|
+
- 20 hub tests and 49 board tests in Python, and 16 Node tests, run against one fake Fabric hub
|
|
55
|
+
(`test/fake_fabric_hub.py`), which models migration 87's fence order. The Python and Node kits read each other's
|
|
56
|
+
checkpoint.
|
|
57
|
+
- The contract's 77 comms fixtures, 8 responder-argument digests and 12 `FAC-SEM-107` pages are vendored by
|
|
58
|
+
`vendor_vectors.mjs` at `f00a768`.
|
|
59
|
+
- 22 planted defects across both kits, all caught (`docs/reports/2026-10-13-faa-12b-board-responders/raw/plants.log`).
|
|
60
|
+
- Red before: the new tests at `origin/main` `65c1751` fail on the missing modules.
|
|
61
|
+
- The live Claude→Codex board smoke is `NOT_RUN`: no disposable Fabric fits this machine. The commands are in
|
|
62
|
+
`references/board.md`.
|
|
63
|
+
|
|
3
64
|
## 0.27.0 - 2026-10-13
|
|
4
65
|
|
|
5
66
|
The usage report's window and spend by project, and OpenRouter attribution for a product's own traffic (contract
|
package/README.md
CHANGED
|
@@ -52,7 +52,11 @@ or dashboard — an `https` origin on a platform — a Fabric service (the remot
|
|
|
52
52
|
four routes behind the token, a `__Host-` session cookie, and one descriptor on the operator's computer;
|
|
53
53
|
`scripts/sample-remote-service.mjs` is the complete example. It ships Python and
|
|
54
54
|
Node reference kits (`scripts/fabric_service.py`, `scripts/fabric-service.mjs`), a
|
|
55
|
-
complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live probe
|
|
55
|
+
complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live probe. A service that answers
|
|
56
|
+
requests other Projects post to its Project on Fabric's project board uses the board responder
|
|
57
|
+
(`scripts/fabric_hub.py` and `scripts/fabric_board.py`, Node `fabric-hub.mjs` and `fabric-board.mjs`): the
|
|
58
|
+
`board.respond` grant, enroll, claim, accept the exact digest, complete, a checkpoint before every step, and restarts
|
|
59
|
+
that reconcile or take accepted work back by a transfer (the [board reference](plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md)):
|
|
56
60
|
|
|
57
61
|
```bash
|
|
58
62
|
python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
|
|
@@ -235,7 +239,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
235
239
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
236
240
|
|
|
237
241
|
- version: `0.1.0`
|
|
238
|
-
- commit: `
|
|
242
|
+
- commit: `f00a7685e3861c27f7899c033fb850a145a1708f`
|
|
239
243
|
|
|
240
244
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
241
245
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.28.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.28.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `f00a7685e3861c27f7899c033fb850a145a1708f`.
|
|
74
74
|
|
|
75
75
|
This is the default for new bundles. When checking an issued legacy bundle, preserve its
|
|
76
76
|
explicit supported lock and use that exact clean checkout. Never silently upgrade or
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
|
|
|
21
21
|
|
|
22
22
|
CONTRACT_VERSION = "0.1.0"
|
|
23
23
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
24
|
-
CONTRACT_COMMIT = "
|
|
24
|
+
CONTRACT_COMMIT = "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
25
25
|
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
26
26
|
# Only this declaration may name a legacy revision in live files.
|
|
27
|
-
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
27
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
28
28
|
# #endregion supported-contract-revisions
|
|
29
29
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
30
30
|
MCP_REVISION = "2026-07-28"
|
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
name: building-fabric-services
|
|
3
3
|
description: >-
|
|
4
4
|
Use when handing out or opening a Fabric service dashboard, building a local agent service on a
|
|
5
|
-
macOS machine,
|
|
6
|
-
Fabric service
|
|
7
|
-
«подключи агента к Fabric Dashboards»,
|
|
8
|
-
|
|
9
|
-
на модели». Covers
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
__Host- cookie
|
|
13
|
-
|
|
14
|
-
behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric
|
|
5
|
+
macOS machine, making an ONLINE agent or dashboard (https origin, the remote placement) a
|
|
6
|
+
Fabric service, or letting a service answer a Project's board requests — «сделай агенту дашборд»,
|
|
7
|
+
«локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать на запросы с доски проекта»,
|
|
8
|
+
"make this agent a local service", "make an online dashboard a Fabric service", "add the
|
|
9
|
+
well-known endpoint", «расходы агента на модели». Covers fabric-service/0.1:
|
|
10
|
+
surfaces, token and one-time login, state, one copy, launchd, descriptor, well-known document,
|
|
11
|
+
events, the usage report; online: the https guard, the token-gated well-known document, the
|
|
12
|
+
__Host- cookie; the board responder (fenced claims); Python, Node and dashboard kits, probes.
|
|
13
|
+
NOT for a one-off script or cron job, a hosted product
|
|
14
|
+
with no agent behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric
|
|
15
|
+
Dashboards.
|
|
15
16
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
16
17
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
17
18
|
metadata:
|
|
18
19
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
20
|
+
version: "0.28.0"
|
|
20
21
|
contract-version: "0.1.0"
|
|
21
22
|
extension: "fabric-service/0.1"
|
|
22
|
-
extension-commit: "
|
|
23
|
+
extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
23
24
|
---
|
|
24
25
|
|
|
25
26
|
# Building Fabric services
|
|
@@ -89,6 +90,13 @@ applies the estate record (DEC-0055) verifies each revision first and trusts onl
|
|
|
89
90
|
(DEC-0046), or a project routes its work through an ordered list of runners (DEC-0026: the doctor's closed probe
|
|
90
91
|
results, the route checks FAC-SEM-028…034), read [the profiles reference](references/profiles.md).
|
|
91
92
|
|
|
93
|
+
When the service answers requests other Projects post to its Project on Fabric's project board
|
|
94
|
+
(`fabric-project-comms/0.1`), it holds a responder slot under the operator's `board.respond` grant and runs the kits'
|
|
95
|
+
responder in its own process: [`scripts/fabric_hub.py`](scripts/fabric_hub.py) finds the hub and joins,
|
|
96
|
+
[`scripts/fabric_board.py`](scripts/fabric_board.py) enrolls, claims, accepts the exact digest, checkpoints before
|
|
97
|
+
every step and stops on `fenced`, `lease_expired` or `authority_changed` (Node: `fabric-hub.mjs`, `fabric-board.mjs`).
|
|
98
|
+
The request body is data behind the service's own input gate. Read [the board reference](references/board.md).
|
|
99
|
+
|
|
92
100
|
## Step 2 — the non-negotiables
|
|
93
101
|
|
|
94
102
|
1. **Loopback only.** Bind `127.0.0.1`. Refuse any `Host` other than
|
|
@@ -252,7 +260,8 @@ shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token fi
|
|
|
252
260
|
events auth and shape, single-use login, state outside code, instance lock, plist,
|
|
253
261
|
launchd pid equals answering pid, `dashboard.declared` (ED-9), and the `interop.*` rules:
|
|
254
262
|
manifest link, capability list, tools equal to the manifest, job tools, unknown job, trace
|
|
255
|
-
propagation, event trace pairs
|
|
263
|
+
propagation, event trace pairs, and `board.responder` for a service that declares the project board) and exits 1 on
|
|
264
|
+
any `FAIL`. A `NOT_RUN` is not a pass —
|
|
256
265
|
name it in the report. A service with a dashboard also runs the layout probe,
|
|
257
266
|
`node <skill-dir>/scripts/check_dashboard_layout.mjs --service <id>` (WebKit at 360, 480, 768
|
|
258
267
|
and 1280 px; needs Playwright, else `NOT_RUN`).
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Answering a Project's requests on Fabric's project board
|
|
2
|
+
|
|
3
|
+
Read this when a service should answer requests other Projects post to its Project on Fabric's project board
|
|
4
|
+
(`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149). The kits do it with
|
|
5
|
+
two modules each: the hub door client [`fabric_hub.py`](../scripts/fabric_hub.py) /
|
|
6
|
+
[`fabric-hub.mjs`](../scripts/fabric-hub.mjs) and the responder [`fabric_board.py`](../scripts/fabric_board.py) /
|
|
7
|
+
[`fabric-board.mjs`](../scripts/fabric-board.mjs). A coding agent that only reads and writes the board is the other
|
|
8
|
+
skill's business (working-with-fabric, `references/project-board.md`).
|
|
9
|
+
|
|
10
|
+
## Contents
|
|
11
|
+
|
|
12
|
+
- What a responder is, and the grant it needs
|
|
13
|
+
- Wiring it into the service
|
|
14
|
+
- The loop, step by step
|
|
15
|
+
- The request is data: the input gate
|
|
16
|
+
- Effects, observation, evidence and the reply
|
|
17
|
+
- When the board says stop, and restarts
|
|
18
|
+
- What a board session may call
|
|
19
|
+
- Looking a Project up in the directory (FAC-SEM-107)
|
|
20
|
+
- Events, the probe and the live smoke
|
|
21
|
+
- The API
|
|
22
|
+
|
|
23
|
+
## What a responder is, and the grant it needs
|
|
24
|
+
|
|
25
|
+
A **request** is addressed to a Project and one of its capabilities (`video.render`), never to a service. One
|
|
26
|
+
**responder slot** exists per Project and capability; a service holds it under a **`board.respond` grant** the
|
|
27
|
+
operator gives in Fabric (Agents & access): callee `fabric`, capability `board.respond`, resource
|
|
28
|
+
`board:<project id>/<capability>`. A pid, a descriptor or a localhost origin authorizes nothing.
|
|
29
|
+
|
|
30
|
+
1. Ask once, through the hub's door: `HubClient().request_board_access(agent_id, project_id, [capability], reason)`
|
|
31
|
+
(`requestBoardAccess`). Keep the answer's `pollSecret` in memory or a 0600 file.
|
|
32
|
+
2. Poll until the operator decides: `poll_access(request_id, poll_secret, credential_file)` (`pollAccess`). On Allow the
|
|
33
|
+
binding credential, shown once, goes into `credential_file` (0600, atomic) and never into the answer, a log or argv.
|
|
34
|
+
Keep the file in the service's data directory or the operator's vault.
|
|
35
|
+
3. Each connection joins with `fabric.board.join {projectId}` (`join_board` / `joinBoard`). It re-reads `hub.json`,
|
|
36
|
+
checks the pid it names is alive, and only then sends the credential. A missing grant is `HubError` code
|
|
37
|
+
`not_authorized` whose `data.ask` holds the arguments to ask with.
|
|
38
|
+
|
|
39
|
+
## Wiring it into the service
|
|
40
|
+
|
|
41
|
+
The responder runs inside the service process, started after the instance lock like every other piece of state
|
|
42
|
+
(SKILL.md, Step 3). [`sample_service.py`](../scripts/sample_service.py) does it behind `serve --board-project <uuid>`,
|
|
43
|
+
off by default:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import fabric_board as fbd, fabric_hub as fhb
|
|
47
|
+
|
|
48
|
+
responder = fbd.Responder(
|
|
49
|
+
hub=fhb.HubClient(), project_id=PROJECT, capabilities=["video.render"],
|
|
50
|
+
credential_file=dirs["data"] / "board.credential", checkpoint_file=dirs["data"] / "board-checkpoint.json",
|
|
51
|
+
handler=render, admit=gate, observe=look_on_disk, emit=log.append, service="example-agent")
|
|
52
|
+
threading.Thread(target=responder.run, args=(stop_event, 5.0), daemon=True).start() # SIGTERM: set stop_event
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Node: `new Responder({...same names in camelCase})`, then `responder.run({ signal, drain })`. A service that lists
|
|
56
|
+
`fabric-project-comms/0.1` in its well-known `protocols` (DEC-0067: only once its hosts are pinned at or after it)
|
|
57
|
+
declares that it answers board requests; the probe checks that declaration.
|
|
58
|
+
|
|
59
|
+
## The loop, step by step
|
|
60
|
+
|
|
61
|
+
One tick every 15 s ± 5 s (`step()` answers the wait):
|
|
62
|
+
|
|
63
|
+
| Step | Board call | Checkpoint written first |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| hold the slot | `com.enroll` (again at about 20 s while idle: the holder's enroll renews its 60 s lease) | — |
|
|
66
|
+
| take a request | `com.claim`, the same key while nothing is claimable | `claimed` |
|
|
67
|
+
| read it | `com.get`: the digest must equal the claim's, and the request must name this Project and capability | — |
|
|
68
|
+
| accept | `com.accept` with the claim's exact fence (request, generation, attempt, digest, revision) | `accepting`, then `accepted` |
|
|
69
|
+
| act | `com.effect_begin`, only when the handler calls `ctx.begin_effect()` | `effect-beginning`, then `in-progress` |
|
|
70
|
+
| while working | `com.renew` at about 20 s; `com.progress` for `ctx.progress(note)` | — |
|
|
71
|
+
| answer | `com.reply` (fenced) when the outcome carries `reply` | `replying` |
|
|
72
|
+
| settle | `com.complete {state, observed, summary?, evidence?}` | `completing`, then the entry is dropped |
|
|
73
|
+
|
|
74
|
+
The checkpoint is fsynced before each call it announces. A write that fails stops the step (`board.blocked`): nothing
|
|
75
|
+
is sent that the checkpoint does not know about. A lost answer is sent again with the same idempotency key, so the
|
|
76
|
+
board returns the first receipt. One request is in flight at a time. Lease times are the board's; the kit renews on its
|
|
77
|
+
own monotonic clock and never reasons from `leaseExpiresAt`.
|
|
78
|
+
|
|
79
|
+
## The request is data: the input gate
|
|
80
|
+
|
|
81
|
+
`admit(request)` is required: it is the service's own input gate. It answers `None` to admit or one sentence saying
|
|
82
|
+
why not; a refusal accepts the request and completes it `failed_known` with that sentence, so the requester learns it
|
|
83
|
+
and the request does not bounce. A redacted body is refused the same way. The kit never hands a body to a model, a
|
|
84
|
+
shell or a tool; what the handler does with an admitted body — and the checks it needs before a model, a tool or a
|
|
85
|
+
spend sees it — is the service's own responsibility. A message never carries an approval.
|
|
86
|
+
|
|
87
|
+
## Effects, observation, evidence and the reply
|
|
88
|
+
|
|
89
|
+
- Call `ctx.begin_effect(summary)` — and let it return — before the handler spends, writes outside the service or calls
|
|
90
|
+
anything that acts. If it raises `Stopped`, act no further.
|
|
91
|
+
- After an effect began, the outcome must be observed (`observed: true`): the kit never sends an unobserved result; the
|
|
92
|
+
request stays unknown and is reconciled later.
|
|
93
|
+
- `evidence` is at most 8 references `{id: uri, contentHash: sha256:<hex>, label?}`; `artifact_ref(uri, content=…|path=…)`
|
|
94
|
+
hashes the bytes itself. Two ids with one normal form (DEC-0076) name one artifact and are refused.
|
|
95
|
+
- `reply: {text, format?, artifacts?}` is posted with `com.reply` before the completion. A reply is not a completion.
|
|
96
|
+
- A handler that raises before any effect completes the request `failed_known` naming only the exception's class.
|
|
97
|
+
- The handler checks `ctx.stopped` in long work: after a session loss its answer is not sent.
|
|
98
|
+
|
|
99
|
+
## When the board says stop, and restarts
|
|
100
|
+
|
|
101
|
+
| Answer | What the kit does |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `fenced` | the attempt is another responder's: stop the handler, drop the entry (nothing outside had begun) |
|
|
104
|
+
| `lease_expired` on accepted work | stop the handler; the board holds the request, and the kit takes it back with `com.responder_replace` and a transfer |
|
|
105
|
+
| `authority_changed`, `not_authorized` | stop claiming; one `board.access.needed` event (notify) saying what to ask for |
|
|
106
|
+
| `fenced` on enroll | another live holder: wait for it to lapse; never replace it on its own initiative |
|
|
107
|
+
| lost after an effect began | keep the entry as `unknown`; `observe(entry)` is asked to settle it with `com.reconcile` |
|
|
108
|
+
| Fabric down, hub missing | bounded backoff (2 s doubling to 5 minutes, jittered), one `board.down` event, no spinning |
|
|
109
|
+
|
|
110
|
+
On restart the checkpoint is read back: settled or queued requests are dropped; `in_progress` and `outcome_unknown` are
|
|
111
|
+
reconciled from what `observe(entry)` answers (observed only; with no observation they stay unknown and notify);
|
|
112
|
+
accepted ones go back to this responder with `com.responder_replace {expectedGeneration, transfer}`, whose receipt's
|
|
113
|
+
`transferred` (DEC-0078: `{request, revision, attempt}`) carries the new fence. **A Fabric that does not answer
|
|
114
|
+
`transferred` yet leaves the request held** (`board.request.held`, notify): the kit never claims it back, because the
|
|
115
|
+
contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
|
|
116
|
+
transfer without running the handler again. `shutdown()` drains, then leaves the session (its DELETE releases the slot).
|
|
117
|
+
|
|
118
|
+
## What a board session may call
|
|
119
|
+
|
|
120
|
+
A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
|
|
121
|
+
`com.reply` (the fenced form), `com.enroll`, `com.claim`, `com.renew`, `com.accept`, `com.progress`,
|
|
122
|
+
`com.effect_begin`, `com.complete`, `com.reconcile`, `com.responder_replace`. `BoardSession.call` refuses anything else
|
|
123
|
+
locally (`not-in-scope`) — never `com.submit`, `com.cancel`, `com.read_ack` or `com.directory`.
|
|
124
|
+
|
|
125
|
+
The idempotency epoch comes from `com.status`. A Fabric built before its board-session fix tells a board session none:
|
|
126
|
+
the kit fails closed (`no-epoch`, `board.blocked`) instead of guessing one.
|
|
127
|
+
|
|
128
|
+
## Looking a Project up in the directory (FAC-SEM-107)
|
|
129
|
+
|
|
130
|
+
The directory belongs to a Project session (a coding agent's, or a launched one). Before looking a Project up in a
|
|
131
|
+
`com.directory` page, `find_project(page, project_id, capability=None)` / `findProject` checks the page: closed shapes
|
|
132
|
+
(no count, holder or generation) and FAC-SEM-107 — each Project once, each capability once per Project. Project ids and
|
|
133
|
+
capability names compare **exactly as written** (DEC-0077; they are not URIs, so not by normal form). A page that fails
|
|
134
|
+
raises `directory-invalid` rather than answering; an unlisted Project may still be addressed by an id already held.
|
|
135
|
+
|
|
136
|
+
## Events, the probe and the live smoke
|
|
137
|
+
|
|
138
|
+
Events go to `emit(kind, level, text, notify=…, data=…)` — `JsonlEventLog.append` fits — with `data {schema, value}`
|
|
139
|
+
checked by FAC-SEM-101 (no credential in data): `board.request.completed`, `board.request.refused`,
|
|
140
|
+
`board.request.skipped`, `board.request.reconciled`, `board.request.held`, `board.attempt.lost`,
|
|
141
|
+
`board.reconcile.needed`, `board.slot.contested`, `board.access.needed`, `board.blocked`, `board.down`, each
|
|
142
|
+
notifying episode said once.
|
|
143
|
+
|
|
144
|
+
`check_service.py <id> [--board-kit <the service's fabric_board.py or fabric-board.mjs>]` rule **board.responder**:
|
|
145
|
+
`NOT_RUN` when the well-known `protocols` names no `fabric-project-comms` revision; `FAIL` for a revision this kit does
|
|
146
|
+
not read or a fixture conversation that goes wrong; `PASS` when the kit's `self_check()` / `selfCheck()` passes (the
|
|
147
|
+
whole conversation, a fenced accept, a session without an epoch — no Fabric touched).
|
|
148
|
+
|
|
149
|
+
The live smoke runs only against a disposable Fabric (built app and its own stack), never an operator's estate: Fabric's
|
|
150
|
+
COM-03 packet (`docs/evidence/plans/2026-10-04-project-communications.md`) has the exact commands — register
|
|
151
|
+
`fabric-mcp` for Claude Code and Codex, the operator allows `session.enroll` and `board.respond`
|
|
152
|
+
(`board:<example-agent project>/<capability>`), Claude submits a request, the responder enrolls, claims, accepts and
|
|
153
|
+
completes, then is killed mid-claim and replaced. Without that Fabric, report it `NOT_RUN`.
|
|
154
|
+
|
|
155
|
+
## The API
|
|
156
|
+
|
|
157
|
+
Python (`fabric_hub`): `fabric_root(platform, env)`, `locate_hub(root=None, alive=pid_alive, estate=None, now=None)`,
|
|
158
|
+
`read_door_token(location)`, `read_credential(path)`, `HubClient(root=None, *, alive, timeout, estate, post)` with
|
|
159
|
+
`request_board_access(agent_id, project_id, capabilities, reason)`, `poll_access(request_id, poll_secret,
|
|
160
|
+
credential_file)`, `join_board(project_id, credential_file) -> BoardGrant`, `call_door(tool, args)`,
|
|
161
|
+
`call_binding(tool, args, credential_file)`; `Backoff(first=2, cap=300, jitter=0.2, floor=1)`; `HubError(code,
|
|
162
|
+
message, retryable, data)`.
|
|
163
|
+
|
|
164
|
+
Python (`fabric_board`): `Responder(*, hub, project_id, capabilities, credential_file, checkpoint_file, handler,
|
|
165
|
+
admit, observe=None, emit=None, service, now, rand, spawn, session_factory)` with `step()`, `run(stop, drain)`,
|
|
166
|
+
`shutdown(drain)`; `WorkContext.progress(note)`, `.begin_effect(summary)`, `.stopped`; `BoardSession(grant)`;
|
|
167
|
+
`Checkpoint(path)`; `comms_canonical(value)`, `comms_digest(operation, value)`, `receipt_problems(value, op)`,
|
|
168
|
+
`refusal_problems(value)`, `directory_findings(page)`, `directory_problems(page)`, `find_project(page, project_id,
|
|
169
|
+
capability)`, `artifact_ref(uri, content|path, label)`, `evidence_problems(list)`, `outcome_problems(outcome,
|
|
170
|
+
effect_began)`, `self_check()`; `BoardError`, `Stopped`.
|
|
171
|
+
|
|
172
|
+
Node: the same names in camelCase (`locateHub(root, {alive, estate, now})`, `new HubClient(root, {alive, timeout,
|
|
173
|
+
estate, post})`, `new Responder({...})` with async `step()`, `run({signal, drain})`, `shutdown(drain)`; `await
|
|
174
|
+
ctx.progress(note)`, `await ctx.beginEffect(summary)`; `selfCheck()`). Both kits read and write the same checkpoint file.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `f00a7685e3861c27f7899c033fb850a145a1708f`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
@@ -8,7 +8,7 @@ canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044;
|
|
|
8
8
|
DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
|
|
9
9
|
the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
|
|
10
10
|
estate record's 60 s skew DEC-0066; an app's own descriptor DEC-0069; the descriptor for listings DEC-0061; identifier equality
|
|
11
|
-
by normal form DEC-0076). The contract's schemas are normative;
|
|
11
|
+
by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078). The contract's schemas are normative;
|
|
12
12
|
this page is the working summary. Extension key:
|
|
13
13
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
14
14
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
check_service.py --descriptor PATH
|
|
6
6
|
options: --services-dir DIR --json --skip-login
|
|
7
7
|
--ca-file PEM --connect HOST:PORT (remote placement: trust a test CA; dial another address)
|
|
8
|
+
--board-kit PATH (board.responder: the service's own fabric_board.py or fabric-board.mjs)
|
|
8
9
|
|
|
9
10
|
A remote placement (DEC-0019 — an online agent or dashboard at an https origin) is probed over
|
|
10
11
|
TLS with the certificate verified: the well-known document must refuse a request without the
|
|
@@ -19,6 +20,12 @@ that it redeems one login code it asked for itself (that creates one session).
|
|
|
19
20
|
The interop.* rules (fabric-interop/0.1) read the manifest the descriptor names and
|
|
20
21
|
the service's MCP surface: tools/list, and fabric.job.get for an id that does not
|
|
21
22
|
exist. They never call a capability.
|
|
23
|
+
|
|
24
|
+
board.responder (fabric-project-comms/0.1, FAA-12b): a service that lists a fabric-project-comms revision in its
|
|
25
|
+
well-known `protocols` declares that it answers board requests. The rule checks that this kit reads that revision and
|
|
26
|
+
runs the kit's fixture conversation (`fabric_board.self_check`, or `selfCheck` of a Node copy given with --board-kit):
|
|
27
|
+
the whole conversation, a fenced accept and a board session without an epoch, with no Fabric. A live board is never
|
|
28
|
+
touched; that smoke needs a disposable Fabric and is reported separately.
|
|
22
29
|
"""
|
|
23
30
|
|
|
24
31
|
from __future__ import annotations
|
|
@@ -112,7 +119,8 @@ class _PinnedHTTPS(http.client.HTTPSConnection):
|
|
|
112
119
|
|
|
113
120
|
class Probe:
|
|
114
121
|
def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool,
|
|
115
|
-
ca_file: Optional[str] = None, connect: Optional[str] = None):
|
|
122
|
+
ca_file: Optional[str] = None, connect: Optional[str] = None, board_kit: Optional[str] = None):
|
|
123
|
+
self.board_kit = board_kit
|
|
116
124
|
self.remote = fs.placement_of(descriptor) == "remote"
|
|
117
125
|
self.ca_file = ca_file
|
|
118
126
|
self.connect = connect
|
|
@@ -799,10 +807,49 @@ class Probe:
|
|
|
799
807
|
self.dashboard_rules()
|
|
800
808
|
self.login_rules()
|
|
801
809
|
self.interop_rules()
|
|
810
|
+
verdict, evidence = board_responder_verdict(self.wk, self.board_kit)
|
|
811
|
+
self.add("board.responder", verdict, evidence)
|
|
802
812
|
self.lifecycle_rules()
|
|
803
813
|
return self.results
|
|
804
814
|
|
|
805
815
|
|
|
816
|
+
def board_responder_verdict(wk: Any, kit: Optional[str] = None) -> Tuple[str, str]:
|
|
817
|
+
"""board.responder: NOT_RUN when the well-known document declares no fabric-project-comms revision; FAIL when it
|
|
818
|
+
declares one this kit does not read or the kit's fixture conversation finds a problem; PASS otherwise. `kit` is the
|
|
819
|
+
service's own copy (a .py or .mjs path); by default the kit beside this probe."""
|
|
820
|
+
protocols = wk.get("protocols") if isinstance(wk, dict) else None
|
|
821
|
+
declared = [p for p in protocols if isinstance(p, str) and p.startswith("fabric-project-comms/")] if isinstance(protocols, list) else []
|
|
822
|
+
if not declared:
|
|
823
|
+
return "NOT_RUN", "the service declares no board participation (no fabric-project-comms revision in protocols)"
|
|
824
|
+
accepted = fs.accepted_revisions("fabric-project-comms")
|
|
825
|
+
unread = [p for p in declared if "error" in fs.negotiate_revision(p, accepted)]
|
|
826
|
+
if unread:
|
|
827
|
+
return "FAIL", "declares %s, which this kit does not read (it reads %s)" % (", ".join(unread), ", ".join(accepted))
|
|
828
|
+
try:
|
|
829
|
+
if kit and kit.endswith(".mjs"):
|
|
830
|
+
if not shutil.which("node"):
|
|
831
|
+
return "NOT_RUN", "node is not installed to run %s's fixture conversation" % kit
|
|
832
|
+
url = Path(kit).resolve().as_uri()
|
|
833
|
+
out = subprocess.run(["node", "--input-type=module", "-e",
|
|
834
|
+
"import(process.env.FABRIC_BOARD_KIT).then(async (m) => console.log(JSON.stringify(await m.selfCheck())))"],
|
|
835
|
+
env=dict(os.environ, FABRIC_BOARD_KIT=url), capture_output=True, text=True, timeout=60)
|
|
836
|
+
problems = json.loads(out.stdout.strip().splitlines()[-1]) if out.returncode == 0 and out.stdout.strip() else ["the Node kit did not run: %s" % out.stderr.strip()[:200]]
|
|
837
|
+
else:
|
|
838
|
+
import importlib.util
|
|
839
|
+
path = Path(kit) if kit else Path(__file__).resolve().parent / "fabric_board.py"
|
|
840
|
+
spec = importlib.util.spec_from_file_location("fabric_board_under_test", str(path))
|
|
841
|
+
module = importlib.util.module_from_spec(spec)
|
|
842
|
+
sys.path.insert(0, str(path.resolve().parent))
|
|
843
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
844
|
+
problems = module.self_check()
|
|
845
|
+
except Exception as exc: # noqa: BLE001 - a kit that cannot run its conversation fails the rule
|
|
846
|
+
return "FAIL", "the kit's fixture conversation did not run: %s" % exc.__class__.__name__
|
|
847
|
+
if problems:
|
|
848
|
+
return "FAIL", "; ".join(problems)
|
|
849
|
+
return "PASS", "declares %s; the kit%s answers the fixture conversation (whole, fenced, no epoch)" % (
|
|
850
|
+
", ".join(declared), " at %s" % kit if kit else " beside this probe")
|
|
851
|
+
|
|
852
|
+
|
|
806
853
|
def session_lifetime_problem(set_cookie: str, now: Optional[float] = None) -> Optional[str]:
|
|
807
854
|
"""DEC-0039: a session cookie set at sign-in lives at most 7 days. Max-Age wins over Expires (RFC 6265
|
|
808
855
|
§5.3); a cookie with neither ends with the browser session. None when it is within the bound."""
|
|
@@ -922,6 +969,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
922
969
|
parser.add_argument("--skip-login", action="store_true")
|
|
923
970
|
parser.add_argument("--ca-file", help="remote placement: trust this CA bundle instead of the system store (tests)")
|
|
924
971
|
parser.add_argument("--connect", help="remote placement: dial HOST:PORT while speaking TLS to the origin's name (tests)")
|
|
972
|
+
parser.add_argument("--board-kit", help="board.responder: the service's own fabric_board.py or fabric-board.mjs")
|
|
925
973
|
args = parser.parse_args(argv)
|
|
926
974
|
if not args.target and not args.descriptor:
|
|
927
975
|
parser.print_usage(sys.stderr)
|
|
@@ -933,7 +981,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
933
981
|
except (OSError, ValueError) as exc:
|
|
934
982
|
print("No readable descriptor at %s: %s" % (path, exc), file=sys.stderr)
|
|
935
983
|
return 1
|
|
936
|
-
results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect).run()
|
|
984
|
+
results = Probe(path, descriptor, services_dir, args.skip_login, args.ca_file, args.connect, args.board_kit).run()
|
|
937
985
|
failed = sum(r["verdict"] == "FAIL" for r in results)
|
|
938
986
|
if args.json:
|
|
939
987
|
print(json.dumps({"descriptor": str(path), "results": results, "failed": failed}, indent=2))
|