@passioncode-ai/fabric-agent-adapter 0.18.0 → 0.20.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 +93 -0
- package/README.md +2 -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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +3 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +140 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +440 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +217 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +535 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,98 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
Runner routes in both kits: the closed probe results and the route checks FAC-SEM-028…034. These come from contract
|
|
6
|
+
DEC-0026 and DEC-0029, at the pin (FAA-11).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **The closed probe result.** `runner_check` / `runnerCheck` answer with `probe`. The values follow the walk's order:
|
|
11
|
+
- `not-installed`: no catalogued binary is on PATH;
|
|
12
|
+
- `not-responding`: the version probe did not answer, failed or did not match, or the `auth` probe did not answer;
|
|
13
|
+
- `not-connected`: the runner answers but is signed out;
|
|
14
|
+
- null: the runner can serve.
|
|
15
|
+
|
|
16
|
+
The doctor still names every problem it found.
|
|
17
|
+
- **The route checks.** `route_problems(kind, value)` / `routeProblems(kind, value)` are the contract's `routeRules`,
|
|
18
|
+
finding for finding: code, path and message.
|
|
19
|
+
- `runner-route`: one candidate per runner kind (FAC-SEM-028), and every candidate can attach or spawn (029).
|
|
20
|
+
- `route-bundle`:
|
|
21
|
+
- provider revisions are admitted by id, revision and hash (030);
|
|
22
|
+
- the binding agrees with the route (032);
|
|
23
|
+
- kinds and drives are in the runner catalogue (033).
|
|
24
|
+
- `route-event` tells the truth about its route (034):
|
|
25
|
+
- candidates by position;
|
|
26
|
+
- probes in order;
|
|
27
|
+
- the session, hold and recovery policy.
|
|
28
|
+
- `PROBE_RESULTS` lists the closed vocabulary of a passed-over candidate.
|
|
29
|
+
- **Tests.** `test/fixtures/contract/route-vectors.json` holds 43 cases, generated by `make_route_vectors.mjs` from the
|
|
30
|
+
contract's own `src/route-rules.ts` at the pin. Both kits answer every case exactly.
|
|
31
|
+
- Mutation testing: 23 planted defects, all caught.
|
|
32
|
+
- **Docs.** `references/profiles.md` gains *Runner routes* and the probe result. `SKILL.md` points to it. The scenario
|
|
33
|
+
eval `runner-routes-and-probe-results` and a trigger came first.
|
|
34
|
+
|
|
35
|
+
## 0.19.0 - 2026-10-12
|
|
36
|
+
|
|
37
|
+
Settings backups, every spending limit and the feed client's token header in both kits. These are the
|
|
38
|
+
service-side surfaces the pinned contract already carries: DEC-0025 and DEC-0027 (FAA-13).
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **Settings backups (`fabric-settings-backup/1`, FAC-SEM-035).** Each function exists in both kits.
|
|
43
|
+
- **Location.** Snapshots live under `<root>/settings-backups/<id>/<instance>/`, outside the data directory.
|
|
44
|
+
`<root>` is per system: `~/Library/Application Support/PassionCode`, `%APPDATA%\PassionCode` or
|
|
45
|
+
`$XDG_CONFIG_HOME/PassionCode`.
|
|
46
|
+
- **Writing.** `make_settings_backup` builds a snapshot from allow-listed tables of JSON scalars. It refuses
|
|
47
|
+
secret tables, credential columns and fractions.
|
|
48
|
+
- `write_settings_backup` writes atomically, 0600 in a 0700 folder.
|
|
49
|
+
- It does not rewrite an unchanged snapshot.
|
|
50
|
+
- It removes a temporary file an interrupted write left behind.
|
|
51
|
+
- It keeps the 14 newest snapshots.
|
|
52
|
+
- `backup_due` decides when the next snapshot is due.
|
|
53
|
+
- **Reading.** `read_settings_backup` refuses a damaged file and names why.
|
|
54
|
+
- `newest_intact_backup` skips damaged files and empty snapshots.
|
|
55
|
+
- `restore_plan` adds rows and never overwrites them. It names what it drops, leaves to defaults or refuses.
|
|
56
|
+
- **The checksum** uses the settings canonical form: `settings_canonical_json` and `settings_backup_digest`.
|
|
57
|
+
They are checked against the contract's vectors.
|
|
58
|
+
- **Parity.** Both kits write byte-identical files.
|
|
59
|
+
- **Every spending limit (DEC-0027, FAC-SEM-031).**
|
|
60
|
+
- `usage_report` and `usageReport` take a `budgets` list.
|
|
61
|
+
- A machine day or month limit is counted from the receipts, so it always agrees with `budget`.
|
|
62
|
+
- A per-order limit gets `spentUsd: null`.
|
|
63
|
+
- The report is refused when its limits break FAC-SEM-031.
|
|
64
|
+
- `usage_limit_problems` and `usageLimitProblems` check a report.
|
|
65
|
+
- **The feed client (FAC-SEM-036).** `token_header` and `tokenHeader` give the one header the descriptor
|
|
66
|
+
declares. `check_service.py` now sends its token through it.
|
|
67
|
+
- **An independent review found 20 moderate defects; all are fixed with tests.**
|
|
68
|
+
- **The readers follow the schema exactly**, and the two kits accept the same files:
|
|
69
|
+
- a trailing newline is refused (Python matches a whole string now);
|
|
70
|
+
- a timestamp is any RFC 3339 date-time with a real calendar date, including an offset;
|
|
71
|
+
- a string holds at most 1 MiB and a row at most 512 values;
|
|
72
|
+
- every `extensions` key is a URI;
|
|
73
|
+
- NaN is refused;
|
|
74
|
+
- in Node, invalid UTF-8 and a `1.0` integer are refused the way Python refuses them.
|
|
75
|
+
- **A damaged file never stops anything.** The scan skips it, the digest does not crash, a damaged newest file
|
|
76
|
+
does not suppress a good snapshot, and an impossible file name makes a snapshot due.
|
|
77
|
+
- **Safer writes:**
|
|
78
|
+
- only a temporary file older than an hour is removed;
|
|
79
|
+
- the snapshot just written is never pruned, even after the clock steps back.
|
|
80
|
+
- **The restore preview** refuses a key the snapshot holds twice.
|
|
81
|
+
- **Limits:**
|
|
82
|
+
- a machine limit over a rolling window is counted from the receipts;
|
|
83
|
+
- a limit that is not an object is refused.
|
|
84
|
+
- **Both descriptor validators** check `auth.header` and `auth.scheme` the way the schema does.
|
|
85
|
+
- **Tests.**
|
|
86
|
+
- The contract's fixtures at the pin are copied to `test/fixtures/contract/dec-0025-0027/`.
|
|
87
|
+
- The Windows CI job runs both kits' new cases.
|
|
88
|
+
- Mutation testing: 79 planted defects, all caught.
|
|
89
|
+
- **Docs.**
|
|
90
|
+
- `references/settings-backups.md` is new.
|
|
91
|
+
- `usage.md` covers every limit.
|
|
92
|
+
- `events-and-notifications.md` covers reading a feed.
|
|
93
|
+
- Updated: `SKILL.md` step 5 and the lifecycle reference's directory table.
|
|
94
|
+
- The scenario eval `settings-backups-and-every-limit` and two triggers came first.
|
|
95
|
+
|
|
3
96
|
## 0.18.0 - 2026-10-12
|
|
4
97
|
|
|
5
98
|
Services on Linux and Windows in both kits (contract DEC-0032, DEC-0033; FAA-21): the supervisor files the
|
package/README.md
CHANGED
|
@@ -230,8 +230,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
|
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
233
|
-
This source checkout is 0.
|
|
234
|
-
were never released); 0.
|
|
233
|
+
This source checkout is 0.20.0. 0.19.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
|
|
234
|
+
were never released); 0.20.0 is published by its `v0.20.0` tag. Source delivery does not update installed skills or live sessions.
|
|
235
235
|
|
|
236
236
|
## License
|
|
237
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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.20.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ 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.20.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
11
11
|
---
|
|
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
16
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 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
17
|
metadata:
|
|
18
18
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.20.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
22
|
extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
@@ -84,7 +84,8 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
|
84
84
|
service checks each call's delegation token (DEC-0039): read [the delegation reference](references/delegation.md);
|
|
85
85
|
the verifier is [`scripts/fabric_delegation.py`](scripts/fabric_delegation.py) /
|
|
86
86
|
[`scripts/fabric-delegation.mjs`](scripts/fabric-delegation.mjs). When Fabric chooses the service's runner and models
|
|
87
|
-
(DEC-0046),
|
|
87
|
+
(DEC-0046), or a project routes its work through an ordered list of runners (DEC-0026: the doctor's closed probe
|
|
88
|
+
results, the route checks FAC-SEM-028…034), read [the profiles reference](references/profiles.md).
|
|
88
89
|
|
|
89
90
|
## Step 2 — the non-negotiables
|
|
90
91
|
|
|
@@ -228,8 +229,12 @@ log, retention and notification policy.
|
|
|
228
229
|
|
|
229
230
|
An agent that calls paid models reports **what it spent**: one receipt per call from the
|
|
230
231
|
provider's own usage numbers, served behind the token at `surfaces.usage` as the DEC-0021 usage
|
|
231
|
-
report. An unknown cost is null, never 0.
|
|
232
|
-
before wiring it.
|
|
232
|
+
report. An unknown cost is null, never 0. It lists **every** spending limit the service applies
|
|
233
|
+
in `budgets`, enforced or not. Read [the usage reference](references/usage.md) before wiring it.
|
|
234
|
+
|
|
235
|
+
A service whose store holds what the operator decided keeps **settings backups** outside its data
|
|
236
|
+
directory, restores only on a fresh database and never overwrites. Read
|
|
237
|
+
[the settings backups reference](references/settings-backups.md).
|
|
233
238
|
|
|
234
239
|
## Step 6 — verify
|
|
235
240
|
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
The feed is a view: never copy rows into a second store that can drift from the first.
|
|
13
13
|
|
|
14
|
+
## Reading a feed (FAC-SEM-036)
|
|
15
|
+
|
|
16
|
+
A client of `GET /fabric/v1/events` — or of any token-protected route — sends the token in the
|
|
17
|
+
header the descriptor's `auth.header` names, in the form `auth.scheme` names: the raw token for
|
|
18
|
+
`none`, `Bearer <token>` for `Bearer`. Never assume `Authorization: Bearer`: a client that did
|
|
19
|
+
was refused on every poll and silently dropped every notify event. `token_header(auth, token)` /
|
|
20
|
+
`tokenHeader(auth, token)` gives the one header; the token never travels in another header or
|
|
21
|
+
in the URL.
|
|
22
|
+
|
|
14
23
|
## Writing an event
|
|
15
24
|
|
|
16
25
|
- `kind`: dotted, stable, past tense or state (`job.failed`, `job.awaiting_choice`,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -88,6 +88,7 @@ remove; the back-off only stops the loop from costing the machine.
|
|
|
88
88
|
| logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` | `%LOCALAPPDATA%\<id>\Logs\` |
|
|
89
89
|
| cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` | `%LOCALAPPDATA%\<id>\Cache\` |
|
|
90
90
|
| code | `~/.local/share/<id>/releases/…` | same | under `%LOCALAPPDATA%` too, never a network share |
|
|
91
|
+
| settings backups ([DEC-0025](settings-backups.md)) | `~/Library/Application Support/PassionCode/settings-backups/<id>/<instance>/` | `${XDG_CONFIG_HOME:-~/.config}/PassionCode/settings-backups/…` | `%APPDATA%\PassionCode\settings-backups\…` |
|
|
91
92
|
|
|
92
93
|
A repository that versions the data itself (a registry, a plan) may hold the data; the
|
|
93
94
|
service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
|
|
@@ -13,6 +13,7 @@ Python: [`scripts/fabric_profiles.py`](../scripts/fabric_profiles.py); Node:
|
|
|
13
13
|
- Checking the documents
|
|
14
14
|
- Declaring and showing what applies
|
|
15
15
|
- The doctor
|
|
16
|
+
- Runner routes
|
|
16
17
|
- Usage by route
|
|
17
18
|
|
|
18
19
|
## Resolving the model profile
|
|
@@ -58,6 +59,28 @@ catalogued binary found on `PATH`, only with the catalogued version argv (and `a
|
|
|
58
59
|
the entry's timeout (at most 5 s); an entry outside these rules is refused and nothing runs. `model_usable(model,
|
|
59
60
|
catalogue, policy)` names why a model cannot be used here: not catalogued, or a class the project does not allow.
|
|
60
61
|
|
|
62
|
+
`probe` is the contract's closed probe result for that runner, the one Fabric's walk records (DEC-0026):
|
|
63
|
+
`not-installed` (no catalogued binary on `PATH`), `not-responding` (the version probe did not answer in its bound,
|
|
64
|
+
failed or did not match the catalogue's pattern, or the `auth` probe did not answer), `not-connected` (it answers but
|
|
65
|
+
the `auth` probe says signed out), or `None`/`null` when the runner can serve. The order is the walk's: installed,
|
|
66
|
+
then responding, then connected. `problems` still names every problem the doctor found.
|
|
67
|
+
|
|
68
|
+
## Runner routes
|
|
69
|
+
|
|
70
|
+
A project may pin a **runner route** (DEC-0026, DEC-0029, `runners.md` §"Runner routes"): the ordered terminal agents
|
|
71
|
+
that serve one capability, each with a session policy (`attach`, `spawn`), what happens when all are unavailable
|
|
72
|
+
(`exhausted`) and whether a conversation returns to a preferred runner (`recovery`). Fabric walks it; a service that
|
|
73
|
+
pins, records or shows routes checks them with `route_problems(kind, value)` / `routeProblems(kind, value)`, the
|
|
74
|
+
contract's `routeRules` finding for finding:
|
|
75
|
+
|
|
76
|
+
| `kind` | value | rules |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `runner-route` | the route | one candidate per runner kind (FAC-SEM-028); a candidate can attach or spawn (029) |
|
|
79
|
+
| `route-bundle` | `{route, admissions, binding?, catalogue?}` | every candidate's provider revision admitted for the capability by id, revision and hash (030); the binding agrees — project, capability, local-runner, a candidate as provider, this route as `runnerRoute` (032); every kind and named drive is in the runner catalogue (033) |
|
|
80
|
+
| `route-event` | `{route, event}` | the event names this route, project and capability; candidates by position; the probes cover every candidate above the selection in order (all for an exhausted walk, none for a sticky relaunch); attach, spawn, `no-held-session` and `spawn-failed` obey the session policy; `held` only under `exhausted: hold`; `preferred-available` only under `recovery: reprobe`, moving up (034) |
|
|
81
|
+
|
|
82
|
+
A probe result in an event is one of `PROBE_RESULTS`; a host never appends a runner the route does not name.
|
|
83
|
+
|
|
61
84
|
## Usage by route
|
|
62
85
|
|
|
63
86
|
`make_usage_receipt(..., route="subscription"|"key")` records how the call was paid — the operator's own login in its
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Settings backups — what the operator decided survives the data directory (DEC-0025)
|
|
2
|
+
|
|
3
|
+
Fabric Agent Contract DEC-0025, `docs/specification/service.md` → *Settings backup*, schema
|
|
4
|
+
`schemas/settings-backup.schema.json`, rule `FAC-SEM-035`. A service whose store holds what the operator
|
|
5
|
+
decided — which projects it serves, how each is bound, which secret slot each binding uses, their overrides —
|
|
6
|
+
SHOULD keep settings backups, so a lost, purged or wrongly migrated data directory costs the operator nothing.
|
|
7
|
+
|
|
8
|
+
## Where
|
|
9
|
+
|
|
10
|
+
`<root>/settings-backups/<id>/<instance>/settings-<UTC %Y%m%dT%H%M%S%fZ>.json`, outside the data directory:
|
|
11
|
+
|
|
12
|
+
| System | `<root>` (`settings_backup_root` / `settingsBackupRoot`) |
|
|
13
|
+
|---|---|
|
|
14
|
+
| macOS | `~/Library/Application Support/PassionCode` |
|
|
15
|
+
| Windows | `%APPDATA%\PassionCode` |
|
|
16
|
+
| Linux | `$XDG_CONFIG_HOME/PassionCode`, default `~/.config/PassionCode` |
|
|
17
|
+
|
|
18
|
+
`settings_backup_dir(id, instance)` / `settingsBackupDir(id, instance)` names the folder. Directories are 0700
|
|
19
|
+
(on Windows a protected ACL), files 0600, writes atomic.
|
|
20
|
+
|
|
21
|
+
## Take a snapshot
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
dirs = fs.settings_backup_dir(ID, INSTANCE)
|
|
25
|
+
if fs.backup_due(dirs): # none yet, or the newest is older than 24 hours
|
|
26
|
+
doc = fs.make_settings_backup(service_id=ID, instance=INSTANCE, version=VERSION, schema=SCHEMA_VERSION,
|
|
27
|
+
tables=allow_listed_tables(db), reason="start") # or daily, uninstall, manual
|
|
28
|
+
fs.write_settings_backup(doc, dirs) # None when unchanged; keeps the 14 newest
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const dir = k.settingsBackupDir(ID, INSTANCE);
|
|
33
|
+
if (k.backupDue(dir)) k.writeSettingsBackup(k.makeSettingsBackup({ id: ID, instance: INSTANCE, version, schema, tables, reason: 'daily' }), dir);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Tables on an allow-list**, each `{"columns": [...], "rows": [[...], ...]}` of JSON scalars. A fraction is its
|
|
37
|
+
decimal string (`"0.25"`), binary data is never stored, and an integer stays within ±(2^53 − 1).
|
|
38
|
+
- **Secrets by name only.** `make_settings_backup` refuses a table that can only hold connections, tokens,
|
|
39
|
+
secrets, credentials, passwords, login codes or sessions, and a column whose name says it holds a credential
|
|
40
|
+
value (`access_token`, `client_secret`); `secret_name` and `token_slot` pass. The names cannot see values, so
|
|
41
|
+
back up what is on the allow-list, never everything except a deny-list.
|
|
42
|
+
- **When:** at start when `backup_due`, then daily; on uninstall a last one, read-only (open the store without
|
|
43
|
+
migrating or writing). An unchanged snapshot is not rewritten; a temporary file an interrupted write left is
|
|
44
|
+
removed first. A fresh, empty database writes none: it would hide the good snapshot before it is restored.
|
|
45
|
+
- Both kits write byte-identical files (`test/fabric-service-backups.test.mjs`, last case).
|
|
46
|
+
|
|
47
|
+
## Read and restore
|
|
48
|
+
|
|
49
|
+
`read_settings_backup` / `readSettingsBackup` refuses a file whose checksum, counts or row widths disagree with
|
|
50
|
+
its tables, that carries a value with no canonical form, or whose `format` it does not know, naming why
|
|
51
|
+
(`FAC-SEM-035`). The checksum is SHA-256 of the settings canonical form of `tables`
|
|
52
|
+
(`settings_canonical_json` / `settingsCanonicalJson`: Python's `json.dumps(sort_keys=True,
|
|
53
|
+
ensure_ascii=False)`).
|
|
54
|
+
|
|
55
|
+
Restore **adds and never overwrites**:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
path, doc, skipped = fs.newest_intact_backup(dirs) # the newest intact snapshot with content; damaged ones named
|
|
59
|
+
plan = fs.restore_plan(doc, {"projects": {"columns": [...], "key": ["id"], "required": ["id", "name"],
|
|
60
|
+
"existing": existing_keys("projects")}})
|
|
61
|
+
# plan["tables"]["projects"]: insert, present, droppedColumns, defaultedColumns, missingRequired, refused
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Automatic restore** only on a *fresh* database — no domain records **and** the service has never started —
|
|
65
|
+
so a restart never undoes a deletion. Insert `plan[...]["insert"]` and add every row the database refuses to
|
|
66
|
+
`refused` with its key and reason.
|
|
67
|
+
- **Manual restore** shows the plan first; applying it is the operator's act, never an agent's.
|
|
@@ -50,6 +50,29 @@ left out.
|
|
|
50
50
|
**Budget.** Pass `budget` only if the service **enforces** that limit itself. The report states
|
|
51
51
|
it, and the host only shows it.
|
|
52
52
|
|
|
53
|
+
**Every limit (DEC-0027).** Pass `budgets` to list every spending limit the service applies,
|
|
54
|
+
enforced or not, so the spend view shows all of them — the operator asked for that on 2026-10-07:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
ledger.report(service_id=ID, budget={"period": "month", "limitUsd": 100}, budgets=[
|
|
58
|
+
{"id": "machine.monthly", "scope": "machine", "kind": "monthly", "period": "month", "limitUsd": 100, "enforced": True},
|
|
59
|
+
{"id": "project.demo.daily", "scope": "project", "subject": "demo", "kind": "daily", "windowSeconds": 86400,
|
|
60
|
+
"limitUsd": 5, "spentUsd": 0.31, "enforced": True},
|
|
61
|
+
{"id": "job.cap", "scope": "job", "kind": "per_job", "limitUsd": 2, "enforced": True},
|
|
62
|
+
])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- A machine limit with a `period` and no `spentUsd` is counted by the kit from the receipts, the
|
|
66
|
+
way `budget` is, so `budget` and its twin in `budgets` never disagree. A per-order limit
|
|
67
|
+
(`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend.
|
|
68
|
+
- `scope` is `machine`, `project`, `pool` or `job`; a `project` or `pool` limit names its
|
|
69
|
+
`subject`. A limit has at most one window — `period`, `windowSeconds` or `since` — and it agrees
|
|
70
|
+
with the kind (`daily`: the day or 86400 s; `monthly`: the month or 28–31 days; `velocity`: a
|
|
71
|
+
rolling window). `tripped: true` (work stopped by it now) needs `enforced: true`. An unknown
|
|
72
|
+
kind is shown generically, never refused.
|
|
73
|
+
- The kits refuse a report that breaks these (`usage_limit_problems` / `usageLimitProblems`,
|
|
74
|
+
`FAC-SEM-031`). A breach is not refused: the report shows it, and the host shows it first.
|
|
75
|
+
|
|
53
76
|
Call `ledger.prune()` at start (or daily) to keep the file at the reported window (LC-12).
|
|
54
77
|
|
|
55
78
|
`JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -162,10 +162,9 @@ class Probe:
|
|
|
162
162
|
conn.close()
|
|
163
163
|
|
|
164
164
|
def auth_headers(self) -> Dict[str, str]:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return {header: ("Bearer " + value) if auth.get("scheme", "Bearer") == "Bearer" else value}
|
|
165
|
+
# The header and form the descriptor declares, never an assumed `Authorization: Bearer` (FAC-SEM-036).
|
|
166
|
+
name, value = fs.token_header(self.d.get("auth"), self.token or "")
|
|
167
|
+
return {name: value}
|
|
169
168
|
|
|
170
169
|
# descriptor ------------------------------------------------------------
|
|
171
170
|
def descriptor_rules(self) -> None:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
CHANGED
|
@@ -126,6 +126,10 @@ function which(name, searchPath) {
|
|
|
126
126
|
* runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
|
|
127
127
|
* catalogued binary on PATH is run, only with the catalogued version (and auth) argv, without a shell, within its
|
|
128
128
|
* timeout (at most 5 s). An entry outside these rules is refused and nothing runs.
|
|
129
|
+
*
|
|
130
|
+
* `probe` is the contract's closed probe result for a walk (DEC-0026): `not-installed`, `not-responding` (the version
|
|
131
|
+
* probe did not answer, failed or did not match), `not-connected` (signed out by the `auth` probe), or null when the
|
|
132
|
+
* runner can serve. The twin of fabric_profiles.runner_check.
|
|
129
133
|
*/
|
|
130
134
|
export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnSync } = {}) {
|
|
131
135
|
const { kind, binaries } = entry ?? {};
|
|
@@ -136,10 +140,10 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
|
|
|
136
140
|
const timeoutMs = version.timeoutMs ?? MAX_PROBE_MS;
|
|
137
141
|
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_PROBE_MS) throw new ProfileError(`version.timeoutMs is at most ${MAX_PROBE_MS}`);
|
|
138
142
|
const authArgv = 'auth' in entry ? argvOf(entry.auth, 'auth') : null;
|
|
139
|
-
const out = { kind, installed: false, binary: null, version: null, signedIn: null, problems: [] };
|
|
143
|
+
const out = { kind, installed: false, binary: null, version: null, signedIn: null, probe: 'not-installed', problems: [] };
|
|
140
144
|
const binary = binaries.map((name) => which(name, searchPath)).find(Boolean) ?? null;
|
|
141
145
|
if (!binary) { out.problems.push(`${kind} is not installed: none of ${binaries.join(', ')} is on PATH`); return out; }
|
|
142
|
-
Object.assign(out, { installed: true, binary });
|
|
146
|
+
Object.assign(out, { installed: true, binary, probe: 'not-responding' });
|
|
143
147
|
const probe = (argv) => {
|
|
144
148
|
const result = run(binary, argv, { encoding: 'utf8', timeout: timeoutMs, shell: false });
|
|
145
149
|
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGTERM') { out.problems.push(`${kind} ${argv.join(' ')} did not answer within ${timeoutMs} ms`); return null; }
|
|
@@ -153,10 +157,12 @@ export function runnerCheck(entry, { searchPath = process.env.PATH, run = spawnS
|
|
|
153
157
|
if (match && match.length > 1) out.version = match[1];
|
|
154
158
|
else out.problems.push(`${kind} answered, but its version did not match the catalogue's pattern`);
|
|
155
159
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
const signed = authArgv ? probe(authArgv) : null;
|
|
161
|
+
if (signed) { out.signedIn = signed.status === 0; if (!out.signedIn) out.problems.push(`${kind} is installed but not signed in`); }
|
|
162
|
+
// The walk's order (runners.md): installed, then responding, then connected. The doctor still reports every problem.
|
|
163
|
+
if (out.version === null || (authArgv && !signed)) out.probe = 'not-responding';
|
|
164
|
+
else if (out.signedIn === false) out.probe = 'not-connected';
|
|
165
|
+
else out.probe = null;
|
|
160
166
|
return out;
|
|
161
167
|
}
|
|
162
168
|
|
|
@@ -194,3 +200,131 @@ export function configurationSummary(doc) {
|
|
|
194
200
|
if (resolved.modelProfile) tiles.push({ label: 'Model', value: [resolved.modelProfile, (resolved.models ?? [])[0]].filter(Boolean).join(' · ') });
|
|
195
201
|
return tiles;
|
|
196
202
|
}
|
|
203
|
+
|
|
204
|
+
// ---- runner routes (DEC-0026, DEC-0029; FAC-SEM-028…034) ----
|
|
205
|
+
// The contract's routeRules (src/route-rules.ts), finding for finding: code, path and message. The twin of
|
|
206
|
+
// fabric_profiles.route_problems.
|
|
207
|
+
|
|
208
|
+
/** The closed probe results a walk records for a candidate it passed over (runner-route-event.schema.json). */
|
|
209
|
+
export const PROBE_RESULTS = Object.freeze(['not-catalogued', 'not-admitted', 'not-installed', 'not-responding', 'not-connected', 'no-held-session', 'quota-unknown', 'refused', 'spawn-failed']);
|
|
210
|
+
|
|
211
|
+
const routeCandidates = (value) => (Array.isArray(value?.candidates) ? value.candidates.filter(isObject) : []);
|
|
212
|
+
const objectAt = (value, key) => (isObject(value?.[key]) ? value[key] : {});
|
|
213
|
+
const refKey = (ref) => (isObject(ref) ? `${String(ref.id)}@${String(ref.revision)}#${String(ref.contentHash)}` : '');
|
|
214
|
+
const sameRef = (a, b) => refKey(a) !== '' && refKey(a) === refKey(b);
|
|
215
|
+
|
|
216
|
+
function runnerRoute(value) {
|
|
217
|
+
const findings = [];
|
|
218
|
+
const kinds = new Set();
|
|
219
|
+
routeCandidates(value).forEach((candidate, index) => {
|
|
220
|
+
const kind = String(candidate.runnerKind);
|
|
221
|
+
if (kinds.has(kind)) findings.push(finding('FAC-SEM-028', `/candidates/${index}/runnerKind`, `runner kind ${kind} appears in the route more than once`));
|
|
222
|
+
kinds.add(kind);
|
|
223
|
+
const session = objectAt(candidate, 'session');
|
|
224
|
+
if (session.attach === 'never' && session.spawn === 'never') findings.push(finding('FAC-SEM-029', `/candidates/${index}/session`, `candidate ${kind} can never run: attach and spawn are both "never"`));
|
|
225
|
+
});
|
|
226
|
+
return findings;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function routeBundle(value) {
|
|
230
|
+
const route = objectAt(value, 'route');
|
|
231
|
+
const admissions = Array.isArray(value.admissions) ? value.admissions.filter(isObject) : [];
|
|
232
|
+
const admitted = new Set(admissions.filter((a) => a.state === 'admitted' && a.capability === route.capability).map((a) => refKey(a.provider)));
|
|
233
|
+
const findings = [];
|
|
234
|
+
routeCandidates(route).forEach((candidate, index) => {
|
|
235
|
+
const key = refKey(candidate.provider);
|
|
236
|
+
if (!admitted.has(key)) findings.push(finding('FAC-SEM-030', `/route/candidates/${index}/provider`, `candidate ${String(candidate.runnerKind)} names provider ${key}, which is not a revision admitted for ${String(route.capability)}`));
|
|
237
|
+
});
|
|
238
|
+
if (isObject(value.binding)) {
|
|
239
|
+
const binding = value.binding;
|
|
240
|
+
const disagree = (where, message) => findings.push(finding('FAC-SEM-032', `/binding/${where}`, message));
|
|
241
|
+
if (binding.project !== route.project) disagree('project', `the binding is for ${String(binding.project)}, the route for ${String(route.project)}`);
|
|
242
|
+
if (binding.capability !== route.capability) disagree('capability', `the binding binds ${String(binding.capability)}, the route orders ${String(route.capability)}`);
|
|
243
|
+
if (binding.profileKind !== 'local-runner') disagree('profileKind', `a runner route serves the local-runner profile, not ${String(binding.profileKind)}`);
|
|
244
|
+
if (!routeCandidates(route).some((c) => sameRef(c.provider, binding.provider))) disagree('provider', `the binding's provider ${refKey(binding.provider)} is not a candidate of the route`);
|
|
245
|
+
if (binding.runnerRoute !== undefined && !sameRef(binding.runnerRoute, route.meta)) disagree('runnerRoute', `the binding pins ${refKey(binding.runnerRoute)}, not this route ${refKey(route.meta)}`);
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(value.catalogue)) {
|
|
248
|
+
const byKind = new Map(value.catalogue.filter(isObject).map((entry) => [String(entry.kind), entry]));
|
|
249
|
+
routeCandidates(route).forEach((candidate, index) => {
|
|
250
|
+
const kind = String(candidate.runnerKind);
|
|
251
|
+
const entry = byKind.get(kind);
|
|
252
|
+
if (!entry) { findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/runnerKind`, `runner kind ${kind} has no entry in the host's catalogue`)); return; }
|
|
253
|
+
if (candidate.drive !== undefined && !Object.hasOwn(objectAt(entry, 'drives'), String(candidate.drive))) findings.push(finding('FAC-SEM-033', `/route/candidates/${index}/drive`, `the catalogue entry for ${kind} offers no drive ${String(candidate.drive)}`));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return findings;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function routeEvent(value) {
|
|
260
|
+
const route = objectAt(value, 'route');
|
|
261
|
+
const event = objectAt(value, 'event');
|
|
262
|
+
const list = routeCandidates(route);
|
|
263
|
+
const findings = [];
|
|
264
|
+
const wrong = (where, message) => findings.push(finding('FAC-SEM-034', `/event${where}`, message));
|
|
265
|
+
if (!sameRef(event.route, route.meta)) wrong('/route', `the event names route ${refKey(event.route)}, not ${refKey(route.meta)}`);
|
|
266
|
+
if (event.project !== route.project) wrong('/project', `the event is for ${String(event.project)}, the route for ${String(route.project)}`);
|
|
267
|
+
if (event.capability !== route.capability) wrong('/capability', `the event names ${String(event.capability)}, the route orders ${String(route.capability)}`);
|
|
268
|
+
const indexOf = (ref) => (typeof ref.index === 'number' && Number.isInteger(ref.index) ? ref.index : -1);
|
|
269
|
+
const candidateAt = (ref, where) => {
|
|
270
|
+
const index = indexOf(ref);
|
|
271
|
+
const candidate = index >= 0 ? list[index] : undefined;
|
|
272
|
+
if (!candidate || candidate.runnerKind !== ref.runnerKind || (ref.provider !== undefined && !sameRef(candidate.provider, ref.provider))) {
|
|
273
|
+
wrong(where, `candidate ${String(ref.index)} (${String(ref.runnerKind)}) is not that position of the route`);
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
return candidate;
|
|
277
|
+
};
|
|
278
|
+
const probes = Array.isArray(event.probes) ? event.probes.filter(isObject) : [];
|
|
279
|
+
probes.forEach((probe, position) => {
|
|
280
|
+
const candidate = candidateAt(probe, `/probes/${position}`);
|
|
281
|
+
if (candidate && probe.result === 'no-held-session' && objectAt(candidate, 'session').spawn !== 'never') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} may spawn, so finding no held session does not pass it over`);
|
|
282
|
+
if (candidate && probe.result === 'spawn-failed' && objectAt(candidate, 'session').spawn !== 'allowed') wrong(`/probes/${position}/result`, `${String(probe.runnerKind)} has spawn: never, so it cannot have failed to spawn`);
|
|
283
|
+
});
|
|
284
|
+
const expectProbes = (count) => {
|
|
285
|
+
const indexes = probes.map(indexOf);
|
|
286
|
+
const expected = Array.from({ length: Math.max(count, 0) }, (_, index) => index);
|
|
287
|
+
if (indexes.length !== expected.length || indexes.some((index, position) => index !== expected[position])) {
|
|
288
|
+
wrong('/probes', `the walk must record candidates ${expected.join(', ') || 'none'} in order; it records ${indexes.join(', ') || 'none'}`);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
if (event.kind === 'runner-exhausted') {
|
|
292
|
+
expectProbes(list.length);
|
|
293
|
+
if (event.answer === 'held' && route.exhausted !== 'hold') wrong('/answer', 'the route does not declare exhausted: hold, so an exhausted walk answers capability-unavailable');
|
|
294
|
+
return findings;
|
|
295
|
+
}
|
|
296
|
+
const selected = objectAt(event, 'selected');
|
|
297
|
+
const chosen = candidateAt(selected, '/selected');
|
|
298
|
+
if (chosen) {
|
|
299
|
+
const session = objectAt(chosen, 'session');
|
|
300
|
+
if (selected.session === 'attached' && session.attach !== 'preferred') wrong('/selected/session', `${String(selected.runnerKind)} has attach: never, so the host cannot attach it`);
|
|
301
|
+
if (selected.session === 'spawned' && session.spawn !== 'allowed') wrong('/selected/session', `${String(selected.runnerKind)} has spawn: never, so the host cannot spawn it`);
|
|
302
|
+
if (chosen.drive !== undefined && selected.drive !== undefined && selected.drive !== chosen.drive) wrong('/selected/drive', `the route runs ${String(selected.runnerKind)} through ${String(chosen.drive)}, not ${String(selected.drive)}`);
|
|
303
|
+
if (event.sticky === true) {
|
|
304
|
+
// A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
305
|
+
if (route.recovery === 'reprobe') wrong('/sticky', 'a route with recovery: reprobe walks from the top at every launch');
|
|
306
|
+
if (event.conversation === undefined) wrong('/sticky', 'only a conversation can keep its runner');
|
|
307
|
+
expectProbes(0);
|
|
308
|
+
} else expectProbes(indexOf(selected));
|
|
309
|
+
}
|
|
310
|
+
if (event.kind === 'runner-switched') {
|
|
311
|
+
const from = objectAt(event, 'from');
|
|
312
|
+
candidateAt(from, '/from');
|
|
313
|
+
if (indexOf(from) === indexOf(selected)) wrong('/from', 'a switch moves to another candidate');
|
|
314
|
+
if (event.reason === 'preferred-available') {
|
|
315
|
+
if (route.recovery !== 'reprobe') wrong('/reason', 'only a route with recovery: reprobe moves a conversation back to a preferred runner');
|
|
316
|
+
if (!(indexOf(selected) < indexOf(from))) wrong('/reason', 'preferred-available moves to a higher-preference candidate');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return findings;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The contract's routeRules: findings for a `runner-route` (FAC-SEM-028, 029), a `route-bundle` `{route, admissions,
|
|
324
|
+
* binding?, catalogue?}` (030, 032, 033) or a `route-event` `{route, event}` (034); undefined for another kind or a
|
|
325
|
+
* value that is not an object.
|
|
326
|
+
*/
|
|
327
|
+
export function routeProblems(kind, value) {
|
|
328
|
+
if (!isObject(value)) return undefined;
|
|
329
|
+
return { 'runner-route': runnerRoute, 'route-bundle': routeBundle, 'route-event': routeEvent }[kind]?.(value);
|
|
330
|
+
}
|