@passioncode-ai/fabric-agent-adapter 0.19.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 +32 -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 +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +23 -0
- 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_profiles.py +217 -8
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
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
|
+
|
|
3
35
|
## 0.19.0 - 2026-10-12
|
|
4
36
|
|
|
5
37
|
Settings backups, every spending limit and the feed client's token header in both kits. These are 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
|
|
|
@@ -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/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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -187,7 +187,11 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
187
187
|
run: Callable[..., Any] = subprocess.run) -> Dict[str, Any]:
|
|
188
188
|
"""runners.md C2: is the catalogued runner installed, which version answers, and is it signed in. Only a bare
|
|
189
189
|
catalogued binary found on PATH is run, only with the catalogued version argv (and `auth` argv), without a shell,
|
|
190
|
-
within its timeout (at most 5 s). A catalogue entry outside these rules is refused and nothing runs.
|
|
190
|
+
within its timeout (at most 5 s). A catalogue entry outside these rules is refused and nothing runs.
|
|
191
|
+
|
|
192
|
+
``probe`` is the contract's closed probe result for a walk (DEC-0026): ``not-installed`` (no catalogued binary on
|
|
193
|
+
PATH), ``not-responding`` (the version probe did not answer, failed or did not match the catalogue's pattern),
|
|
194
|
+
``not-connected`` (it answers, but the ``auth`` probe says signed out), or None when the runner can serve."""
|
|
191
195
|
kind = entry.get("kind")
|
|
192
196
|
binaries = entry.get("binaries")
|
|
193
197
|
if not isinstance(binaries, list) or not binaries or not all(isinstance(b, str) and _BARE_BINARY.match(b) and ".." not in b for b in binaries):
|
|
@@ -201,11 +205,13 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
201
205
|
if not isinstance(timeout_ms, int) or isinstance(timeout_ms, bool) or not 0 < timeout_ms <= MAX_PROBE_MS:
|
|
202
206
|
raise ProfileError("version.timeoutMs is at most %d" % MAX_PROBE_MS)
|
|
203
207
|
auth_argv = _argv(entry["auth"], "auth") if "auth" in entry else None
|
|
204
|
-
out: Dict[str, Any] = {"kind": kind, "installed": False, "binary": None, "version": None, "signedIn": None,
|
|
208
|
+
out: Dict[str, Any] = {"kind": kind, "installed": False, "binary": None, "version": None, "signedIn": None,
|
|
209
|
+
"probe": "not-installed", "problems": []}
|
|
205
210
|
binary = next((found for found in (shutil.which(name, path=path) for name in binaries) if found), None)
|
|
206
211
|
if binary is None:
|
|
207
212
|
out["problems"].append("%s is not installed: none of %s is on PATH" % (kind, ", ".join(binaries)))
|
|
208
213
|
return out
|
|
214
|
+
out["probe"] = "not-responding"
|
|
209
215
|
out.update(installed=True, binary=binary)
|
|
210
216
|
|
|
211
217
|
def probe(argv: List[str]) -> Optional[Any]:
|
|
@@ -224,12 +230,18 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
224
230
|
out["version"] = match.group(1)
|
|
225
231
|
else:
|
|
226
232
|
out["problems"].append("%s answered, but its version did not match the catalogue's pattern" % kind)
|
|
227
|
-
if auth_argv is not None
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
+
signed = probe(auth_argv) if auth_argv is not None else None
|
|
234
|
+
if signed is not None:
|
|
235
|
+
out["signedIn"] = signed.returncode == 0
|
|
236
|
+
if not out["signedIn"]:
|
|
237
|
+
out["problems"].append("%s is installed but not signed in" % kind)
|
|
238
|
+
# The walk's order (runners.md): installed, then responding, then connected. The doctor still reports every problem.
|
|
239
|
+
if out["version"] is None or (auth_argv is not None and signed is None):
|
|
240
|
+
out["probe"] = "not-responding"
|
|
241
|
+
elif out["signedIn"] is False:
|
|
242
|
+
out["probe"] = "not-connected"
|
|
243
|
+
else:
|
|
244
|
+
out["probe"] = None
|
|
233
245
|
return out
|
|
234
246
|
|
|
235
247
|
|
|
@@ -275,3 +287,200 @@ def configuration_summary(doc: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
275
287
|
first = (resolved.get("models") or [None])[0]
|
|
276
288
|
tiles.append({"label": "Model", "value": " · ".join(x for x in (resolved["modelProfile"], first) if x)})
|
|
277
289
|
return tiles
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- runner routes (DEC-0026, DEC-0029; FAC-SEM-028…034) ----------------------------------------------------------
|
|
293
|
+
# The contract's routeRules (src/route-rules.ts), ported finding for finding: code, path and message.
|
|
294
|
+
|
|
295
|
+
#: The closed probe results a walk records for a candidate it passed over (runner-route-event.schema.json).
|
|
296
|
+
PROBE_RESULTS = ("not-catalogued", "not-admitted", "not-installed", "not-responding", "not-connected", "no-held-session",
|
|
297
|
+
"quota-unknown", "refused", "spawn-failed")
|
|
298
|
+
_MISSING = object()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _field(value: Dict[str, Any], key: str) -> str:
|
|
302
|
+
"""String(value[key]) as JavaScript writes it: `undefined` for a missing key, `null` for null."""
|
|
303
|
+
found = value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
|
|
304
|
+
if found is _MISSING:
|
|
305
|
+
return "undefined"
|
|
306
|
+
if found is None:
|
|
307
|
+
return "null"
|
|
308
|
+
return _js_string(found)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _object_at(value: Any, key: str) -> Dict[str, Any]:
|
|
312
|
+
found = value.get(key) if isinstance(value, dict) else None
|
|
313
|
+
return found if isinstance(found, dict) else {}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _ref_key(ref: Any) -> str:
|
|
317
|
+
if not isinstance(ref, dict):
|
|
318
|
+
return ""
|
|
319
|
+
return "%s@%s#%s" % (_field(ref, "id"), _field(ref, "revision"), _field(ref, "contentHash"))
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _same_ref(a: Any, b: Any) -> bool:
|
|
323
|
+
return _ref_key(a) != "" and _ref_key(a) == _ref_key(b)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _candidates(value: Any) -> List[Dict[str, Any]]:
|
|
327
|
+
return _list(value.get("candidates") if isinstance(value, dict) else None)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _runner_route(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
331
|
+
findings, kinds = [], set()
|
|
332
|
+
for index, candidate in enumerate(_candidates(value)):
|
|
333
|
+
kind = _field(candidate, "runnerKind")
|
|
334
|
+
if kind in kinds:
|
|
335
|
+
findings.append(_finding("FAC-SEM-028", "/candidates/%d/runnerKind" % index, "runner kind %s appears in the route more than once" % kind))
|
|
336
|
+
kinds.add(kind)
|
|
337
|
+
session = _object_at(candidate, "session")
|
|
338
|
+
if session.get("attach") == "never" and session.get("spawn") == "never":
|
|
339
|
+
findings.append(_finding("FAC-SEM-029", "/candidates/%d/session" % index, 'candidate %s can never run: attach and spawn are both "never"' % kind))
|
|
340
|
+
return findings
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _route_bundle(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
344
|
+
route = _object_at(value, "route")
|
|
345
|
+
admitted = {_ref_key(a.get("provider")) for a in _list(value.get("admissions"))
|
|
346
|
+
if a.get("state") == "admitted" and a.get("capability", _MISSING) == route.get("capability", _MISSING)}
|
|
347
|
+
findings = []
|
|
348
|
+
for index, candidate in enumerate(_candidates(route)):
|
|
349
|
+
key = _ref_key(candidate.get("provider"))
|
|
350
|
+
if key not in admitted:
|
|
351
|
+
findings.append(_finding("FAC-SEM-030", "/route/candidates/%d/provider" % index, "candidate %s names provider %s, which is not a revision admitted for %s"
|
|
352
|
+
% (_field(candidate, "runnerKind"), key, _field(route, "capability"))))
|
|
353
|
+
if isinstance(value.get("binding"), dict):
|
|
354
|
+
findings.extend(_binding_agreement(value["binding"], route))
|
|
355
|
+
if isinstance(value.get("catalogue"), list):
|
|
356
|
+
findings.extend(_catalogue_agreement(_list(value["catalogue"]), route))
|
|
357
|
+
return findings
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _binding_agreement(binding: Dict[str, Any], route: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
361
|
+
findings = []
|
|
362
|
+
|
|
363
|
+
def disagree(path: str, message: str) -> None:
|
|
364
|
+
findings.append(_finding("FAC-SEM-032", "/binding/" + path, message))
|
|
365
|
+
|
|
366
|
+
if binding.get("project", _MISSING) != route.get("project", _MISSING):
|
|
367
|
+
disagree("project", "the binding is for %s, the route for %s" % (_field(binding, "project"), _field(route, "project")))
|
|
368
|
+
if binding.get("capability", _MISSING) != route.get("capability", _MISSING):
|
|
369
|
+
disagree("capability", "the binding binds %s, the route orders %s" % (_field(binding, "capability"), _field(route, "capability")))
|
|
370
|
+
if binding.get("profileKind") != "local-runner":
|
|
371
|
+
disagree("profileKind", "a runner route serves the local-runner profile, not %s" % _field(binding, "profileKind"))
|
|
372
|
+
if not any(_same_ref(c.get("provider"), binding.get("provider")) for c in _candidates(route)):
|
|
373
|
+
disagree("provider", "the binding's provider %s is not a candidate of the route" % _ref_key(binding.get("provider")))
|
|
374
|
+
if "runnerRoute" in binding and not _same_ref(binding["runnerRoute"], route.get("meta")):
|
|
375
|
+
disagree("runnerRoute", "the binding pins %s, not this route %s" % (_ref_key(binding["runnerRoute"]), _ref_key(route.get("meta"))))
|
|
376
|
+
return findings
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _catalogue_agreement(catalogue: List[Dict[str, Any]], route: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
380
|
+
by_kind = {_field(entry, "kind"): entry for entry in catalogue}
|
|
381
|
+
findings = []
|
|
382
|
+
for index, candidate in enumerate(_candidates(route)):
|
|
383
|
+
kind = _field(candidate, "runnerKind")
|
|
384
|
+
entry = by_kind.get(kind)
|
|
385
|
+
if entry is None:
|
|
386
|
+
findings.append(_finding("FAC-SEM-033", "/route/candidates/%d/runnerKind" % index, "runner kind %s has no entry in the host's catalogue" % kind))
|
|
387
|
+
continue
|
|
388
|
+
if "drive" in candidate and _field(candidate, "drive") not in _object_at(entry, "drives"):
|
|
389
|
+
findings.append(_finding("FAC-SEM-033", "/route/candidates/%d/drive" % index, "the catalogue entry for %s offers no drive %s" % (kind, _field(candidate, "drive"))))
|
|
390
|
+
return findings
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _index_of(ref: Dict[str, Any]) -> int:
|
|
394
|
+
index = ref.get("index")
|
|
395
|
+
if isinstance(index, bool) or not isinstance(index, (int, float)) or not float(index).is_integer():
|
|
396
|
+
return -1
|
|
397
|
+
return int(index)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
401
|
+
route = _object_at(value, "route")
|
|
402
|
+
event = _object_at(value, "event")
|
|
403
|
+
candidates = _candidates(route)
|
|
404
|
+
findings: List[Dict[str, str]] = []
|
|
405
|
+
|
|
406
|
+
def wrong(path: str, message: str) -> None:
|
|
407
|
+
findings.append(_finding("FAC-SEM-034", "/event" + path, message))
|
|
408
|
+
|
|
409
|
+
if not _same_ref(event.get("route"), route.get("meta")):
|
|
410
|
+
wrong("/route", "the event names route %s, not %s" % (_ref_key(event.get("route")), _ref_key(route.get("meta"))))
|
|
411
|
+
if event.get("project", _MISSING) != route.get("project", _MISSING):
|
|
412
|
+
wrong("/project", "the event is for %s, the route for %s" % (_field(event, "project"), _field(route, "project")))
|
|
413
|
+
if event.get("capability", _MISSING) != route.get("capability", _MISSING):
|
|
414
|
+
wrong("/capability", "the event names %s, the route orders %s" % (_field(event, "capability"), _field(route, "capability")))
|
|
415
|
+
|
|
416
|
+
def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
|
|
417
|
+
index = _index_of(ref)
|
|
418
|
+
candidate = candidates[index] if 0 <= index < len(candidates) else None
|
|
419
|
+
if (candidate is None or candidate.get("runnerKind", _MISSING) != ref.get("runnerKind", _MISSING)
|
|
420
|
+
or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
|
|
421
|
+
wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
|
|
422
|
+
return None
|
|
423
|
+
return candidate
|
|
424
|
+
|
|
425
|
+
probes = _list(event.get("probes"))
|
|
426
|
+
for position, probe in enumerate(probes):
|
|
427
|
+
candidate = candidate_at(probe, "/probes/%d" % position)
|
|
428
|
+
if candidate is not None and probe.get("result") == "no-held-session" and _object_at(candidate, "session").get("spawn") != "never":
|
|
429
|
+
wrong("/probes/%d/result" % position, "%s may spawn, so finding no held session does not pass it over" % _field(probe, "runnerKind"))
|
|
430
|
+
if candidate is not None and probe.get("result") == "spawn-failed" and _object_at(candidate, "session").get("spawn") != "allowed":
|
|
431
|
+
wrong("/probes/%d/result" % position, "%s has spawn: never, so it cannot have failed to spawn" % _field(probe, "runnerKind"))
|
|
432
|
+
|
|
433
|
+
def expect_probes(count: int) -> None:
|
|
434
|
+
indexes = [_index_of(p) for p in probes]
|
|
435
|
+
expected = list(range(max(count, 0)))
|
|
436
|
+
if indexes != expected:
|
|
437
|
+
wrong("/probes", "the walk must record candidates %s in order; it records %s"
|
|
438
|
+
% (", ".join(map(str, expected)) or "none", ", ".join(map(str, indexes)) or "none"))
|
|
439
|
+
|
|
440
|
+
if event.get("kind") == "runner-exhausted":
|
|
441
|
+
expect_probes(len(candidates))
|
|
442
|
+
if event.get("answer") == "held" and route.get("exhausted") != "hold":
|
|
443
|
+
wrong("/answer", "the route does not declare exhausted: hold, so an exhausted walk answers capability-unavailable")
|
|
444
|
+
return findings
|
|
445
|
+
|
|
446
|
+
selected = _object_at(event, "selected")
|
|
447
|
+
chosen = candidate_at(selected, "/selected")
|
|
448
|
+
if chosen is not None:
|
|
449
|
+
session = _object_at(chosen, "session")
|
|
450
|
+
if selected.get("session") == "attached" and session.get("attach") != "preferred":
|
|
451
|
+
wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
|
|
452
|
+
if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
|
|
453
|
+
wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
|
|
454
|
+
if "drive" in chosen and "drive" in selected and selected["drive"] != chosen["drive"]:
|
|
455
|
+
wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
|
|
456
|
+
if event.get("sticky") is True:
|
|
457
|
+
# A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
458
|
+
if route.get("recovery") == "reprobe":
|
|
459
|
+
wrong("/sticky", "a route with recovery: reprobe walks from the top at every launch")
|
|
460
|
+
if "conversation" not in event:
|
|
461
|
+
wrong("/sticky", "only a conversation can keep its runner")
|
|
462
|
+
expect_probes(0)
|
|
463
|
+
else:
|
|
464
|
+
expect_probes(_index_of(selected))
|
|
465
|
+
|
|
466
|
+
if event.get("kind") == "runner-switched":
|
|
467
|
+
source = _object_at(event, "from")
|
|
468
|
+
candidate_at(source, "/from")
|
|
469
|
+
if _index_of(source) == _index_of(selected):
|
|
470
|
+
wrong("/from", "a switch moves to another candidate")
|
|
471
|
+
if event.get("reason") == "preferred-available":
|
|
472
|
+
if route.get("recovery") != "reprobe":
|
|
473
|
+
wrong("/reason", "only a route with recovery: reprobe moves a conversation back to a preferred runner")
|
|
474
|
+
if not _index_of(selected) < _index_of(source):
|
|
475
|
+
wrong("/reason", "preferred-available moves to a higher-preference candidate")
|
|
476
|
+
return findings
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def route_problems(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
480
|
+
"""The contract's routeRules: findings for a `runner-route` (FAC-SEM-028, 029), a `route-bundle`
|
|
481
|
+
`{route, admissions, binding?, catalogue?}` (030, 032, 033) or a `route-event` `{route, event}` (034); None for
|
|
482
|
+
another kind or a value that is not an object."""
|
|
483
|
+
if not isinstance(value, dict):
|
|
484
|
+
return None
|
|
485
|
+
rules = {"runner-route": _runner_route, "route-bundle": _route_bundle, "route-event": _route_event}.get(kind)
|
|
486
|
+
return rules(value) if rules else None
|
|
@@ -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. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
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
|
---
|