@passioncode-ai/fabric-agent-adapter 0.6.3 → 0.8.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 +104 -1
- package/README.md +28 -5
- package/bin/fabric-agent-adapter.js +18 -0
- 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 +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,109 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 - 2026-10-04
|
|
4
|
+
|
|
5
|
+
Agents report what they spend (Fabric Agent Contract DEC-0021), and a service may keep its MCP
|
|
6
|
+
credential apart from the host's token (DEC-0024). The contract pin moves to the DEC-0024 merge;
|
|
7
|
+
bundles issued under the DEC-0021, DEC-0020 and DEC-0019 revisions stay valid.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Usage report in both kits.** `make_usage_receipt` / `makeUsageReceipt` record one model call
|
|
12
|
+
from the provider's own numbers (no prompt, output or caller). `usage_report` / `usageReport`
|
|
13
|
+
build the `service-usage.schema.json` answer: 31 UTC days per provider and model, day totals
|
|
14
|
+
that are the sums of the rows (FAC-SEM-025), and an unknown cost as `null`, never `0`.
|
|
15
|
+
`JsonlUsageLedger` keeps receipts as 0600 JSON lines pruned to the window. A line torn by a
|
|
16
|
+
killed writer is skipped and never glued to the next receipt. The Python and Node twins
|
|
17
|
+
produce the same report from the same receipts (`test/fixtures/usage/receipts.json`).
|
|
18
|
+
- `sample_service.py` declares `surfaces.usage` and serves the report behind the token.
|
|
19
|
+
- `check_service.py` adds `usage.requires-token` and `usage.report` when a service declares the
|
|
20
|
+
surface.
|
|
21
|
+
- `references/usage.md`; SKILL.md Step 5 and the protocol reference name the surface; trigger
|
|
22
|
+
and scenario evals `agent-reports-its-own-spend`.
|
|
23
|
+
|
|
24
|
+
### Added (DEC-0024)
|
|
25
|
+
|
|
26
|
+
- **A service may keep its MCP credential apart from the host's token.** When the well-known
|
|
27
|
+
document says `surfaces.mcp.auth: "own"`, `check_service.py` no longer calls MCP with the
|
|
28
|
+
descriptor's token as a caller. It checks that the surface refuses that token
|
|
29
|
+
(`interop.mcp-own-auth`) and leaves the caller rules NOT_RUN with the reason. Before this, two
|
|
30
|
+
services that separate the roles on purpose were reported as `interop.tools-match` FAIL
|
|
31
|
+
(401/403). `references/surfaces-and-auth.md` explains when to do it. The contract pin moves to
|
|
32
|
+
`23f9fda4c05f8a3852246ee98d4f2adf74ed0875`; `9091d3d` joins the supported earlier revisions.
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **MCP 2026-07-28 standard headers.** The probe's MCP calls now send `Mcp-Method`, and `Mcp-Name`
|
|
37
|
+
for `tools/call`. Without them a server on the official 2026-07-28 SDK answers HTTP 400 (seen live
|
|
38
|
+
against a local agent on 2026-10-04: `tools/list failed: HTTP 400 from /mcp`).
|
|
39
|
+
`fabric_interop.McpToolServer.handle(message, headers)` checks them the way the specification
|
|
40
|
+
requires: a mismatch is refused with `-32020` whatever the revision, and a request that declares
|
|
41
|
+
2026-07-28 must carry them. Callers that pass no headers keep the old behaviour.
|
|
42
|
+
`mcp_header_problem` / `mcp_request_headers` are in both kits (Node: `mcpHeaderProblem`,
|
|
43
|
+
`mcpRequestHeaders`). The sample service answers a mismatch with HTTP 400.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Contract pin `df55c8c` → `9091d3d` (lock, scaffolder default, metadata). The supported earlier
|
|
48
|
+
revisions are now a reviewed list of two, `test/validate.py` checks it, and
|
|
49
|
+
`test_contract_revisions` runs against real checkouts of `2ce3922` and `9091d3d`.
|
|
50
|
+
|
|
51
|
+
## 0.7.0 - 2026-10-04
|
|
52
|
+
|
|
53
|
+
The service kit meets the organization's
|
|
54
|
+
[product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md)
|
|
55
|
+
(LC-01…LC-15). Findings F7, F8, F9, F16 of the 2026-10-03 lifecycle audit.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- **An upgrade no longer switches back on a service the operator switched off (LC-14, F7).**
|
|
60
|
+
`launchd_install` called `launchctl enable` on every run. It now reads `launchctl
|
|
61
|
+
print-disabled` and enables only on a first install (no override recorded); a disabled label
|
|
62
|
+
stays disabled and unloaded, its plist is still rewritten, and the answer is
|
|
63
|
+
`{"disabled": true, ...}`. `force_enable=True` is the operator's explicit switch-on.
|
|
64
|
+
Tests: `test_fabric_service_lifecycle.LaunchdInstallTests` (fake `launchctl`, throwaway labels).
|
|
65
|
+
- **A duplicate copy under `KeepAlive` no longer respawns every 10 s (LC-03, F8).** A
|
|
66
|
+
launchd-supervised copy (`FABRIC_SERVICE_SUPERVISOR=launchd`, now written by `launchd_plist`)
|
|
67
|
+
backs off in-process on a held lock — 0.5 s doubling to 30 s, 300 s in all — takes over if the
|
|
68
|
+
holder leaves, and exits 75 only then. Started by hand it still exits 75 at once (the contract's
|
|
69
|
+
one-copy rule). Python and Node. Tests: `SupervisedLockTests`, `fabric-service-lifecycle.test.mjs`.
|
|
70
|
+
- **Uninstall is symmetric with install (LC-14, F9).** `launchd_uninstall` waits until launchd
|
|
71
|
+
reports the job gone (raises, removing nothing, at its timeout), removes the plist, resets the
|
|
72
|
+
override to `enabled`, and with `purge=True` removes the service's data, logs and cache. It now
|
|
73
|
+
returns a summary dict instead of `None`. Tests: `LaunchdUninstallTests`.
|
|
74
|
+
- **The installers leave launcher-managed hub links alone (F16).** `install.sh` and
|
|
75
|
+
`npx @passioncode-ai/fabric-agent-adapter` (even with `--force`) skip a `~/.agents/skills/<name>`
|
|
76
|
+
that is a symlink, naming `npx @passioncode-ai/passioncode@latest update` when it points into
|
|
77
|
+
`~/.passioncode`. Tests: `test_installer.InstallerTests.test_launcher_managed_hub_links_survive_force`,
|
|
78
|
+
`test_install_sh_leaves_launcher_links_alone`.
|
|
79
|
+
|
|
80
|
+
### Added
|
|
81
|
+
|
|
82
|
+
- **`Drain` (Python and Node, LC-01, F9):** SIGTERM/SIGINT stops new work (`work()` raises
|
|
83
|
+
`Stopping`), drains in-flight work for 8 s, hands over, and hard-exits (`EXIT_HARD_STOP`, 70)
|
|
84
|
+
2 s later if the hand-over hangs. `sample_service.py` uses it and answers `503` while stopping.
|
|
85
|
+
- **`RotatingLog` and `cap_stdout_log` / `capStdoutLog` (LC-12, F9):** JSON-lines log rotated by
|
|
86
|
+
size, 5 × 5 MB, files 0600 in a 0700 directory; the launchd stdout file is capped at start.
|
|
87
|
+
- **`prune_releases` / `pruneReleases` (LC-11, LC-15):** keeps the running release and the one
|
|
88
|
+
before it, removes older ones, never removes `current` after a rollback.
|
|
89
|
+
|
|
90
|
+
### Changed
|
|
91
|
+
|
|
92
|
+
- **CO-193 contract adoption (source, pending release):** new bundles use the shared
|
|
93
|
+
capability-name schema and accept underscore names. Complete previously issued bundles
|
|
94
|
+
remain checkable at their explicitly supported legacy revision. The checker rejects
|
|
95
|
+
forged locks, revision mismatches and modified checkouts; unavailable dependencies remain
|
|
96
|
+
`NOT_RUN`. This adds no COM runtime, grant or admission authority.
|
|
97
|
+
- Align validation triggers with the operator's 2026-09-25 CI policy: replace automatic
|
|
98
|
+
push/PR runs with manual dispatch, retaining the reusable release-validation workflow.
|
|
99
|
+
Existing test jobs and commands are unchanged; no new nightly schedule is asserted.
|
|
100
|
+
|
|
101
|
+
- `launchd_plist`'s default `ExitTimeOut` is 15 s (was 40): above the drain and its hard exit, so
|
|
102
|
+
launchd's SIGKILL never comes first, and inside LC-01's 10 s quit bound for the service itself.
|
|
103
|
+
- `references/lifecycle.md` links the org contract and maps each LC rule to the kit.
|
|
104
|
+
- Three scenario evals: `operator-off-survives-upgrade`, `duplicate-under-keepalive`,
|
|
105
|
+
`stop-drains-and-uninstall-is-symmetric`.
|
|
106
|
+
|
|
3
107
|
## 0.6.3 - 2026-10-04
|
|
4
108
|
|
|
5
109
|
### Changed
|
|
@@ -348,4 +452,3 @@ Fabric Agent Contract DEC-0017. **Contract pin** moves from `a22dea3` to `9cd778
|
|
|
348
452
|
- Add a standard-library project inspector, non-destructive scaffolder, and checker.
|
|
349
453
|
- Add contract pinning and independent conformance gate reporting.
|
|
350
454
|
- Add trigger/scenario evals, unit tests, repository validator, and plugin packaging.
|
|
351
|
-
|
package/README.md
CHANGED
|
@@ -188,15 +188,38 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
188
188
|
|
|
189
189
|
## Contract pin
|
|
190
190
|
|
|
191
|
-
The
|
|
191
|
+
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
192
192
|
|
|
193
193
|
- version: `0.1.0`
|
|
194
|
-
- commit: `
|
|
194
|
+
- commit: `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`
|
|
195
195
|
|
|
196
|
-
Every other mention of the contract revision — this section, the skills' metadata, the
|
|
196
|
+
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
197
197
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
|
198
|
-
where one does not. The
|
|
199
|
-
|
|
198
|
+
where one does not. The sole live legacy exception is the immutable
|
|
199
|
+
`SUPPORTED_CONTRACT_COMMITS` declaration in the scaffolder: previously issued bundles
|
|
200
|
+
keep their selected legacy lock and require that exact clean contract checkout. The
|
|
201
|
+
checker validates the lock's contract, repository, version and supported full SHA before
|
|
202
|
+
running its selected schema. Unknown locks, modified checkouts and revision mismatches fail.
|
|
203
|
+
Missing checkouts or dependencies are `NOT_RUN`, never admission.
|
|
204
|
+
|
|
205
|
+
There is no automatic upgrade or downgrade. An intentional migration requires review of
|
|
206
|
+
the whole bundle and validation against the new selected schema; editing a lock alone
|
|
207
|
+
does not prove runtime compatibility. Underscore names such as `receive_project_message`
|
|
208
|
+
are declarations only: they confer no COM grant or authority. Legacy revisions cannot
|
|
209
|
+
validate those names. The contract repository is normative. Updating the default pin
|
|
210
|
+
requires fixture review, complete validation and a new adapter release before propagation.
|
|
211
|
+
|
|
212
|
+
To run both compiled-schema integration arms, install the frozen dependencies in separate
|
|
213
|
+
clean checkouts at the supported revisions, then run:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/current-contract \
|
|
217
|
+
python3 -m unittest discover -s test -p test_contract_revisions.py -v
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
221
|
+
This source checkout is 0.8.0 pending release (0.7.0 was never released); the verified published GitHub/npm version
|
|
222
|
+
on 2026-10-04 is 0.6.3. Source delivery does not update installed skills or live sessions.
|
|
200
223
|
|
|
201
224
|
## License
|
|
202
225
|
|
|
@@ -48,6 +48,15 @@ function copyDir(src, dest) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// A link in the hub belongs to whoever made it: 'launcher' for the PassionCode launcher's
|
|
52
|
+
// ~/.passioncode/... links, 'other' for any other link, null for a directory or nothing.
|
|
53
|
+
function linkOwner(dest) {
|
|
54
|
+
let info;
|
|
55
|
+
try { info = fs.lstatSync(dest); } catch { return null; }
|
|
56
|
+
if (!info.isSymbolicLink()) return null;
|
|
57
|
+
return fs.readlinkSync(dest).split(path.sep).includes('.passioncode') ? 'launcher' : 'other';
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
function pluginInstalled(home) {
|
|
52
61
|
try {
|
|
53
62
|
const data = JSON.parse(fs.readFileSync(path.join(home, '.claude/plugins/installed_plugins.json'), 'utf8'));
|
|
@@ -129,6 +138,15 @@ function main(argv) {
|
|
|
129
138
|
const destRoot = target === 'claude' ? path.join(home, '.claude', 'skills') : path.join(home, '.agents', 'skills');
|
|
130
139
|
for (const name of names) {
|
|
131
140
|
const dest = path.join(destRoot, name);
|
|
141
|
+
const owner = linkOwner(dest);
|
|
142
|
+
if (owner) {
|
|
143
|
+
// One owner per artefact on disk: a plain copy over someone else's link freezes the skill
|
|
144
|
+
// at this version, and its owner stops updating it. --force does not override this.
|
|
145
|
+
console.log(owner === 'launcher'
|
|
146
|
+
? `skip: ${name} at ${dest} is managed by the PassionCode launcher — update it with: npx @passioncode-ai/passioncode@latest update`
|
|
147
|
+
: `skip: ${name} at ${dest} is a link managed elsewhere — remove the link first to install a copy here`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
132
150
|
if (fs.existsSync(dest) && !force) {
|
|
133
151
|
console.log(`skip: ${name} already installed at ${dest} (rerun with --force to overwrite)`);
|
|
134
152
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.8.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.8.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,13 @@ 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 `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`.
|
|
74
|
+
|
|
75
|
+
This is the default for new bundles. When checking an issued legacy bundle, preserve its
|
|
76
|
+
explicit supported lock and use that exact clean checkout. Never silently upgrade or
|
|
77
|
+
downgrade it. Unknown repository/version/SHA locks and mismatched checkouts fail; missing
|
|
78
|
+
dependencies remain `NOT_RUN`. Capability names may contain underscores at this default
|
|
79
|
+
revision; declaring a COM name does not confer a grant, admission or wider authority.
|
|
74
80
|
|
|
75
81
|
Read the pinned contract's guide
|
|
76
82
|
`docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -16,7 +16,11 @@ from urllib.parse import urlparse
|
|
|
16
16
|
|
|
17
17
|
CONTRACT_VERSION = "0.1.0"
|
|
18
18
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
19
|
-
CONTRACT_COMMIT = "
|
|
19
|
+
CONTRACT_COMMIT = "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
|
|
20
|
+
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
21
|
+
# Only this declaration may name a legacy revision in live files.
|
|
22
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
23
|
+
# #endregion supported-contract-revisions
|
|
20
24
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
21
25
|
MCP_REVISION = "2026-07-28"
|
|
22
26
|
A2A_VERSION = "1.0"
|
|
@@ -357,7 +361,9 @@ def _walk_keys(value: Any, prefix: str = "") -> Iterable[Tuple[str, Any]]:
|
|
|
357
361
|
yield from _walk_keys(item, "%s[%d]" % (prefix, index))
|
|
358
362
|
|
|
359
363
|
|
|
360
|
-
def _contract_shape(manifest: Path, contract: Path) -> Tuple[str, List[str]]:
|
|
364
|
+
def _contract_shape(manifest: Path, contract: Path, selected_commit: str) -> Tuple[str, List[str]]:
|
|
365
|
+
if selected_commit not in SUPPORTED_CONTRACT_COMMITS:
|
|
366
|
+
return "FAIL", ["unsupported selected contract revision"]
|
|
361
367
|
if not contract.is_dir():
|
|
362
368
|
return "NOT_RUN", ["contract checkout is not a directory: %s" % contract]
|
|
363
369
|
try:
|
|
@@ -367,8 +373,19 @@ def _contract_shape(manifest: Path, contract: Path) -> Tuple[str, List[str]]:
|
|
|
367
373
|
).stdout.strip()
|
|
368
374
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
369
375
|
return "NOT_RUN", ["cannot read contract commit: %s" % exc]
|
|
370
|
-
if head !=
|
|
371
|
-
return "
|
|
376
|
+
if head != selected_commit:
|
|
377
|
+
return "FAIL", ["contract checkout is %s; selected lock requires %s" % (head, selected_commit)]
|
|
378
|
+
try:
|
|
379
|
+
dirty = subprocess.run(
|
|
380
|
+
["git", "status", "--porcelain", "--untracked-files=normal"], cwd=str(contract), check=True,
|
|
381
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
382
|
+
).stdout.strip()
|
|
383
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
384
|
+
return "NOT_RUN", ["cannot verify clean contract checkout: %s" % exc]
|
|
385
|
+
if dirty:
|
|
386
|
+
return "FAIL", ["contract checkout has modifications; exact selected schema is required"]
|
|
387
|
+
if not (contract / "node_modules/.bin/tsx").is_file():
|
|
388
|
+
return "NOT_RUN", ["pinned contract dependencies are missing; run pnpm install --frozen-lockfile"]
|
|
372
389
|
|
|
373
390
|
code = """import {readFile} from 'node:fs/promises';
|
|
374
391
|
import {createValidator,validateDocument} from './src/validator.ts';
|
|
@@ -391,8 +408,10 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
|
|
|
391
408
|
return "NOT_RUN", ["contract shape validation timed out"]
|
|
392
409
|
if run.returncode != 0:
|
|
393
410
|
detail = (run.stdout + "\n" + run.stderr).strip()
|
|
411
|
+
if run.returncode != 3:
|
|
412
|
+
return "NOT_RUN", [detail[-4000:] or "contract validator could not run"]
|
|
394
413
|
return "FAIL", [detail[-4000:] or "contract validator failed"]
|
|
395
|
-
return "PASS", ["manifest validated by
|
|
414
|
+
return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
|
|
396
415
|
|
|
397
416
|
|
|
398
417
|
def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
@@ -406,11 +425,19 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
|
406
425
|
manifest = _load_json(manifest_path, errors)
|
|
407
426
|
lock = _load_json(root / "fabric-contract.lock.json", errors)
|
|
408
427
|
profile_kind = None
|
|
428
|
+
lock_errors: List[str] = []
|
|
409
429
|
if isinstance(lock, dict):
|
|
430
|
+
if lock.get("contract") != "fabric-agent-contract":
|
|
431
|
+
lock_errors.append("contract lock contract must be fabric-agent-contract")
|
|
432
|
+
if lock.get("repository") != CONTRACT_REPOSITORY:
|
|
433
|
+
lock_errors.append("contract lock repository must be %s" % CONTRACT_REPOSITORY)
|
|
410
434
|
if lock.get("version") != CONTRACT_VERSION:
|
|
411
|
-
|
|
412
|
-
if lock.get("commit")
|
|
413
|
-
|
|
435
|
+
lock_errors.append("contract lock version must be %s" % CONTRACT_VERSION)
|
|
436
|
+
if lock.get("commit") not in SUPPORTED_CONTRACT_COMMITS:
|
|
437
|
+
lock_errors.append("contract lock commit must be an explicitly supported immutable revision")
|
|
438
|
+
else:
|
|
439
|
+
lock_errors.append("contract lock must be an object")
|
|
440
|
+
errors.extend(lock_errors)
|
|
414
441
|
|
|
415
442
|
if isinstance(manifest, dict):
|
|
416
443
|
if manifest.get("contractVersion") != CONTRACT_VERSION:
|
|
@@ -462,8 +489,10 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
|
|
|
462
489
|
|
|
463
490
|
shape_status = "NOT_RUN"
|
|
464
491
|
shape_receipts = ["pass --contract PATH pointing at the pinned checkout"]
|
|
465
|
-
if
|
|
466
|
-
shape_status, shape_receipts =
|
|
492
|
+
if lock_errors:
|
|
493
|
+
shape_status, shape_receipts = "FAIL", lock_errors
|
|
494
|
+
elif contract is not None and manifest_path.is_file():
|
|
495
|
+
shape_status, shape_receipts = _contract_shape(manifest_path, contract, lock["commit"])
|
|
467
496
|
|
|
468
497
|
local_status = "PASS" if not errors else "FAIL"
|
|
469
498
|
ready = local_status == "PASS" and shape_status == "PASS" and not warnings
|
|
@@ -5,9 +5,10 @@ description: >-
|
|
|
5
5
|
macOS machine, or making an ONLINE agent or dashboard (https origin, the remote placement) a
|
|
6
6
|
Fabric service — «сделай агенту дашборд», «локальный сервис агента», «онлайн-дашборд в Fabric»,
|
|
7
7
|
«подключи агента к Fabric Dashboards», "make this agent a local service", "make an online
|
|
8
|
-
dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint"
|
|
8
|
+
dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint", «расходы агента
|
|
9
|
+
на модели». Covers
|
|
9
10
|
fabric-service/0.1: surfaces, token and one-time login, state, one copy, launchd, descriptor,
|
|
10
|
-
well-known document, events; online: the https guard, the token-gated well-known document, the
|
|
11
|
+
well-known document, events, the usage report (spend per model); online: the https guard, the token-gated well-known document, the
|
|
11
12
|
__Host- cookie, registering it on the operator's computer; ships Python and Node kits, a TLS
|
|
12
13
|
sample and a live probe. NOT for a one-off script or cron job, a hosted product with no agent
|
|
13
14
|
behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
|
|
@@ -15,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
15
16
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
16
17
|
metadata:
|
|
17
18
|
author: PassionCode.ai
|
|
18
|
-
version: "0.
|
|
19
|
+
version: "0.8.0"
|
|
19
20
|
contract-version: "0.1.0"
|
|
20
21
|
extension: "fabric-service/0.1"
|
|
21
|
-
extension-commit: "
|
|
22
|
+
extension-commit: "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
|
|
22
23
|
---
|
|
23
24
|
|
|
24
25
|
# Building Fabric services
|
|
@@ -90,7 +91,9 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
|
90
91
|
2. **One copy, locked before any side effect.** Take the exclusive lock on
|
|
91
92
|
`<data>/service.lock` first — before resuming jobs, starting a scheduler, migrating
|
|
92
93
|
a store or even creating a token. Held → print one sentence naming the holder's pid
|
|
93
|
-
and exit **75**. Binding the port is not a lock.
|
|
94
|
+
and exit **75**. Binding the port is not a lock. Under launchd the kit backs off
|
|
95
|
+
in-process first (up to 300 s), so a duplicate is a few starts an hour, not a
|
|
96
|
+
`KeepAlive` respawn every 10 s. *(A second copy re-queued the first
|
|
94
97
|
copy's running jobs in its startup hook, before its bind failed.)*
|
|
95
98
|
3. **launchd is the only supervisor.** `RunAtLoad` true, `KeepAlive` true,
|
|
96
99
|
`ThrottleInterval` 10, `ExitTimeOut` above your drain time. Never start yourself
|
|
@@ -133,7 +136,7 @@ Startup order is the part that goes wrong; keep it exactly:
|
|
|
133
136
|
import fabric_service as fs
|
|
134
137
|
|
|
135
138
|
dirs = fs.service_dirs("example-agent")
|
|
136
|
-
lock = fs.hold_single_instance(dirs["data"]) # 1. lock — exits 75 if held
|
|
139
|
+
lock = fs.hold_single_instance(dirs["data"]) # 1. lock — exits 75 if held (backs off first under launchd)
|
|
137
140
|
token = fs.ensure_token(dirs["data"] / "service.token") # 2. only now touch state
|
|
138
141
|
log = fs.JsonlEventLog(dirs["data"] / "events.jsonl") # (or a view over your own log)
|
|
139
142
|
codes = fs.LoginCodes(dirs["data"] / "auth")
|
|
@@ -153,8 +156,10 @@ On every request call `check_request(port, host, origin, sec_fetch_site)` and an
|
|
|
153
156
|
| `POST /fabric/v1/login-code` | service token | `LoginCodes.issue()` |
|
|
154
157
|
| `GET /fabric/v1/login?code=` | the code | `LoginCodes.redeem(code)` → `Set-Cookie: session_cookie_header(...)`, 302 to the dashboard |
|
|
155
158
|
|
|
156
|
-
`SIGTERM` drains in-flight work
|
|
157
|
-
|
|
159
|
+
`SIGTERM` drains in-flight work, then exits — 10 s at most, inside `ExitTimeOut`
|
|
160
|
+
(`fs.Drain` / `new Drain()`: `with drain.work():` refuses new work once a stop began);
|
|
161
|
+
interrupted work resumes on the next start. Log through `RotatingLog` (5 × 5 MB, 0600)
|
|
162
|
+
and call `cap_stdout_log` once at start.
|
|
158
163
|
|
|
159
164
|
The MCP surface (`POST /mcp`, service token) is `fabric_interop.McpToolServer.handle`
|
|
160
165
|
in Python; jobs live in `fabric_interop.JobStore(dirs["data"] / "jobs")`, created after
|
|
@@ -175,8 +180,11 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
|
175
180
|
2. `launchd_plist(...)` then `launchd_install(...)` — writes and lints the plist,
|
|
176
181
|
`bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
|
|
177
182
|
error, then polls the well-known document until it answers with **this** identity.
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
A label the operator disabled stays disabled: only a first install enables.
|
|
184
|
+
3. Uninstall: `launchd_uninstall` (waits for the unload, removes the plist, resets the
|
|
185
|
+
override; `purge=True` removes data only when the operator asks), `remove_descriptor`.
|
|
186
|
+
4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
|
|
187
|
+
and the one before it.
|
|
180
188
|
|
|
181
189
|
Code runs from an immutable release directory; an upgrade writes a new release,
|
|
182
190
|
rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
|
|
@@ -199,6 +207,11 @@ raises no banners of its own: the host is the one channel. Read
|
|
|
199
207
|
[the events reference](references/events-and-notifications.md) for mapping an existing
|
|
200
208
|
log, retention and notification policy.
|
|
201
209
|
|
|
210
|
+
An agent that calls paid models reports **what it spent**: one receipt per call from the
|
|
211
|
+
provider's own usage numbers, served behind the token at `surfaces.usage` as the DEC-0021 usage
|
|
212
|
+
report. An unknown cost is null, never 0. Read [the usage reference](references/usage.md)
|
|
213
|
+
before wiring it.
|
|
214
|
+
|
|
202
215
|
## Step 6 — verify
|
|
203
216
|
|
|
204
217
|
Run the probe against the live service — it reads the descriptor, then checks every
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Lifecycle — supervisor, install, release layout, state
|
|
2
2
|
|
|
3
|
+
This page implements the organization's
|
|
4
|
+
[product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md)
|
|
5
|
+
(rules LC-01…LC-15) for `fabric-service/0.1` services. Where the two differ, the contract
|
|
6
|
+
wins and this page is the defect. The rule each section serves:
|
|
7
|
+
|
|
8
|
+
| Contract rule | Here | Kit (Python / Node) |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
11
|
+
| LC-03 a job is bounded and exclusive | [one copy under launchd](#one-copy-under-launchd) | `hold_single_instance` / `holdSingleInstance` |
|
|
12
|
+
| LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
|
|
13
|
+
| LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
14
|
+
| LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
15
|
+
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
|
|
16
|
+
| LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
|
|
17
|
+
|
|
18
|
+
## Contents
|
|
19
|
+
|
|
20
|
+
- [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
|
|
21
|
+
- [Install, upgrade, uninstall](#install-upgrade-uninstall)
|
|
22
|
+
- [One copy under launchd](#one-copy-under-launchd)
|
|
23
|
+
- [Directories](#directories)
|
|
24
|
+
- [Logs](#logs)
|
|
25
|
+
- [Shutdown](#shutdown)
|
|
26
|
+
- [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
|
|
27
|
+
- [Linux](#linux)
|
|
28
|
+
|
|
3
29
|
## launchd plist (generate it with `launchd_plist`)
|
|
4
30
|
|
|
5
31
|
| Key | Value | Why |
|
|
@@ -9,9 +35,9 @@
|
|
|
9
35
|
| `RunAtLoad` | `true` | back at every login |
|
|
10
36
|
| `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
|
|
11
37
|
| `ThrottleInterval` | `10` | bounds a crash loop |
|
|
12
|
-
| `ExitTimeOut` | drain
|
|
38
|
+
| `ExitTimeOut` | above the drain and its hard exit (15 s default; `Drain` ends by 10 s) | SIGKILL arrives after it, so it must never come first |
|
|
13
39
|
| `ProcessType` | `Standard` — never `Background`, and no `Nice` > 0 or `LowPriorityIO` | a host probes the service and agents call it while the Mac is busy; a background job is starved for tens of seconds under load and every host reports an outage that never happened (the probe's `lifecycle.priority` rule fails it) |
|
|
14
|
-
| `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets | never a secret value — the plist is world-readable in backups |
|
|
40
|
+
| `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off |
|
|
15
41
|
| `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
|
|
16
42
|
|
|
17
43
|
## Install, upgrade, uninstall
|
|
@@ -19,17 +45,41 @@
|
|
|
19
45
|
1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
|
|
20
46
|
(a venv or `node_modules` inside it). Never edit a release after it is written.
|
|
21
47
|
2. `write_descriptor` (port and id claims are checked here).
|
|
22
|
-
3. `launchd_install`: write + `plutil -lint`,
|
|
23
|
-
`
|
|
48
|
+
3. `launchd_install`: write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
|
|
49
|
+
`enable` **only when no override is recorded** (a first install), `bootout` and wait
|
|
50
|
+
until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
|
|
24
51
|
then poll the well-known document until it answers with this `id.instance`.
|
|
25
52
|
4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
|
|
26
|
-
again
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
again, then `prune_releases(releases_dir, current=<new release>)`: the running release
|
|
54
|
+
and the one before it stay for rollback, older ones go (LC-11, LC-15).
|
|
55
|
+
5. Uninstall = `launchd_uninstall` + `remove_descriptor`. Data stays; `purge=True`
|
|
56
|
+
(with `service_id`) removes data, logs and cache when the operator asks.
|
|
57
|
+
|
|
58
|
+
**The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
|
|
59
|
+
`disable` (off survives a login) and starts it with `enable` + `bootstrap`; restart is
|
|
60
|
+
`kickstart -k`. An install or upgrade never re-enables a label the operator disabled: it
|
|
61
|
+
rewrites the plist, so the new release is what starts once they turn it back on, and
|
|
62
|
+
answers `{"disabled": true, ...}` instead of the well-known document. `force_enable=True`
|
|
63
|
+
is for an operator who asked for the service to be switched on. The service never
|
|
31
64
|
implements these verbs itself — its CLI calls launchctl the same way.
|
|
32
65
|
|
|
66
|
+
**Uninstall is symmetric.** `launchd_uninstall` boots the job out and **waits** until
|
|
67
|
+
launchd reports it gone (it raises, removing nothing, if the job is still loaded at the
|
|
68
|
+
timeout), removes the plist, and resets the override to `enabled`. launchctl has no verb
|
|
69
|
+
that deletes an override, and a leftover `disabled` would make a later fresh install stay
|
|
70
|
+
off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
|
|
71
|
+
operator's real jobs.
|
|
72
|
+
|
|
73
|
+
## One copy under launchd
|
|
74
|
+
|
|
75
|
+
Started by hand, a copy that finds `service.lock` held prints one sentence naming the
|
|
76
|
+
holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
|
|
77
|
+
(10 s), forever. So a copy whose plist carries `FABRIC_SERVICE_SUPERVISOR=launchd` backs
|
|
78
|
+
off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
|
|
79
|
+
and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
|
|
80
|
+
nothing while it waits. Two installs sharing one data directory are still the defect to
|
|
81
|
+
remove; the back-off only stops the loop from costing the machine.
|
|
82
|
+
|
|
33
83
|
## Directories
|
|
34
84
|
|
|
35
85
|
| What | macOS | Linux |
|
|
@@ -46,15 +96,28 @@ anything the operator would miss belongs in data.
|
|
|
46
96
|
|
|
47
97
|
## Logs
|
|
48
98
|
|
|
49
|
-
One structured log (JSON lines) rotated by size (
|
|
50
|
-
|
|
51
|
-
|
|
99
|
+
One structured log (JSON lines) rotated by size — `RotatingLog(dirs["logs"] / "service.jsonl")`,
|
|
100
|
+
5 × 5 MB by default, files 0600 in a 0700 directory — plus the launchd stdout file, which
|
|
101
|
+
`cap_stdout_log` copies to `.1` and truncates at start once it passes 5 MB (launchd holds it
|
|
102
|
+
open for append, so it is truncated in place, never renamed). Configure logging once, in the
|
|
103
|
+
entry point. Never log a token, a cookie, a login code or a request body that may carry one.
|
|
52
104
|
|
|
53
105
|
## Shutdown
|
|
54
106
|
|
|
55
107
|
On `SIGTERM`: stop accepting new work, append a `service.stopping` event, drain
|
|
56
|
-
in-flight work
|
|
57
|
-
|
|
108
|
+
in-flight work, persist what was interrupted so the next start resumes it, release the
|
|
109
|
+
lock, exit 0 — within 10 s with work in flight (LC-01). `Drain` does the timing:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
drain = fs.Drain() # drain 8 s, hard exit 2 s later (EXIT_HARD_STOP, 70)
|
|
113
|
+
drain.install(lambda drained: server.shutdown(),
|
|
114
|
+
on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
|
|
115
|
+
with drain.work(): # raises fs.Stopping once a stop has begun -> answer 503
|
|
116
|
+
handle(request)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Work longer than the drain is journalled before it starts and resumes on the next start;
|
|
120
|
+
it is never a reason to raise the deadline.
|
|
58
121
|
|
|
59
122
|
## Being managed by a host lifecycle broker
|
|
60
123
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
4
|
-
(`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`
|
|
4
|
+
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021). The contract's schemas are normative;
|
|
5
5
|
this page is the working summary. Extension key:
|
|
6
6
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
7
7
|
|
|
@@ -47,7 +47,8 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
|
|
|
47
47
|
- `status`: `starting | ready | degraded | stopping`. No answer means `down`.
|
|
48
48
|
- `build` needs `commit` or `digest` (`sha256:<64 hex>`).
|
|
49
49
|
- `summary`: at most six tiles; `attention: true` counts toward the host's badge.
|
|
50
|
-
- `surfaces.events` is required; `dashboard` and `mcp` when the service has them
|
|
50
|
+
- `surfaces.events` is required; `dashboard` and `mcp` when the service has them; `usage`
|
|
51
|
+
(DEC-0021) when the service reports its spend — see [the usage reference](usage.md).
|
|
51
52
|
|
|
52
53
|
## Events page — `GET /fabric/v1/events?after=<cursor>&limit=<n>`
|
|
53
54
|
|
|
@@ -81,3 +82,4 @@ single use, at most 120 s, recorded as used before it is honoured. `GET` that UR
|
|
|
81
82
|
| `FAC-SEM-010` | no two descriptors claim one port or one `id.instance` |
|
|
82
83
|
| `FAC-SEM-011` | `ready` carries no degraded source |
|
|
83
84
|
| `FAC-SEM-012` | commands start with an absolute or `~/` executable |
|
|
85
|
+
| `FAC-SEM-025` | a usage report adds up; an all-unpriced row costs `null`, not `0` (DEC-0021) |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md
CHANGED
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
origin, stateless JSON responses unless the tools stream.
|
|
7
7
|
- Authenticate with the service token in the declared header. OAuth is for remote
|
|
8
8
|
multi-user servers, not for a loopback service.
|
|
9
|
+
- **Or keep the two roles apart (DEC-0024).** The descriptor's token is the host's: it reads
|
|
10
|
+
events and the usage report, and it mints operator login codes. An MCP token sits in every
|
|
11
|
+
client's `~/.claude.json`. When that matters, give agents their own token (`service.token`
|
|
12
|
+
for agents, `host.token` named by the descriptor), declare `surfaces.mcp.auth: "own"`, and
|
|
13
|
+
make `/mcp` refuse the host's token. `check_service.py` then checks only that refusal
|
|
14
|
+
(`interop.mcp-own-auth`) and leaves the caller rules NOT_RUN.
|
|
9
15
|
- Register it into a client config with the service's own command
|
|
10
16
|
(`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
|
|
11
17
|
atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
|