@passioncode-ai/fabric-agent-adapter 0.59.0 → 0.61.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 +195 -0
- package/README.md +2 -1
- 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 +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +93 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +23 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +117 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +33 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +80 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +24 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +146 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +31 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +80 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +33 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +19 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +23 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +17 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +86 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +14 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +210 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +2 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -27
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import { HubError, MCP_PROTOCOL_VERSION, postMcp, toolAnswer } from './fabric-hub.mjs';
|
|
7
7
|
import { atomicWrite, ensurePrivateDir } from './fabric-service.mjs';
|
|
8
8
|
import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-board-shapes.mjs';
|
|
9
|
+
import { UNREADABLE_PROJECT, mandateSpend } from './fabric-interop-rules.mjs'; // FAA-139
|
|
10
|
+
import { isAgentId, mandateCovers } from './fabric-mandate.mjs';
|
|
11
|
+
import { formatRfc3339 } from './fabric-time.mjs';
|
|
9
12
|
|
|
10
13
|
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
11
14
|
export class BoardSession {
|
|
@@ -179,4 +182,118 @@ export class BoardRequest {
|
|
|
179
182
|
toJSON() {
|
|
180
183
|
return { requestId: this.requestId, capability: this.capability }; // never the body
|
|
181
184
|
}
|
|
185
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
186
|
+
return this.toJSON(); // console.log(request) prints no body either
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// -- the operator's mandate in a board request (FAA-135; contract project-comms.md, DEC-0125 MD-3) ---------------------
|
|
191
|
+
|
|
192
|
+
/** `mandateFrom` naming every trusted sender of the board the service is installed on: the operator's personal board. */
|
|
193
|
+
export const PERSONAL_BOARD = 'personal-board';
|
|
194
|
+
const OPENER = /^```json[ \t]*$/;
|
|
195
|
+
const CLOSER = /^```[ \t]*$/;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The bodies of the ```json blocks, each fence on its own line: one pass over the lines, linear in the body. Lines end
|
|
199
|
+
* at "\\n" alone, one "\\r" before it dropped, as in the Python twin (never U+2028 or a lone "\\r"); an unclosed
|
|
200
|
+
* opener ends the scan.
|
|
201
|
+
*/
|
|
202
|
+
function jsonBlocks(body) {
|
|
203
|
+
const lines = body.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
204
|
+
const blocks = [];
|
|
205
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
206
|
+
if (!OPENER.test(lines[i])) continue;
|
|
207
|
+
let j = i + 1;
|
|
208
|
+
while (j < lines.length && !CLOSER.test(lines[j])) j += 1;
|
|
209
|
+
if (j === lines.length) break;
|
|
210
|
+
blocks.push(lines.slice(i + 1, j).join('\n'));
|
|
211
|
+
i = j;
|
|
212
|
+
}
|
|
213
|
+
return blocks;
|
|
214
|
+
}
|
|
215
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
216
|
+
/** A Project id as Fabric's board compares it: a uuid lower-cased, any other id as written (normalProject). */
|
|
217
|
+
const fold = (project) => (UUID.test(project) ? project.toLowerCase() : project);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The mandate a request carries, read only when the service opted in (`agentId` and `mandateFrom` set), the board
|
|
221
|
+
* verified the sender (`provenance: trusted`) and its Project is one `mandateFrom` names (or `PERSONAL_BOARD`): a T1
|
|
222
|
+
* caller by project-comms. Answers `{ mandate, unread }`: `mandate` null when none is read, `unread` `mandate-blocks`
|
|
223
|
+
* (more than one) or `mandate-unreadable` (a block naming `mandate/1` that is not JSON). A `source` in the block is
|
|
224
|
+
* dropped: a board block carries none (OA-7).
|
|
225
|
+
*/
|
|
226
|
+
export function readMandate(request, mandateFrom, agentId) {
|
|
227
|
+
const none = { mandate: null, unread: null };
|
|
228
|
+
if (agentId == null || mandateFrom == null || typeof request.body !== 'string') return none;
|
|
229
|
+
const sender = isObj(request.sender) ? request.sender : {};
|
|
230
|
+
const principal = isObj(sender.principal) ? sender.principal : {};
|
|
231
|
+
if (principal.provenance !== 'trusted') return none;
|
|
232
|
+
if (typeof sender.project !== 'string' || !sender.project) return none; // comms-message requires it; the caller
|
|
233
|
+
if (mandateFrom !== PERSONAL_BOARD && !mandateFrom.has(fold(sender.project))) return none;
|
|
234
|
+
const found = [];
|
|
235
|
+
for (const block of jsonBlocks(request.body)) {
|
|
236
|
+
let value;
|
|
237
|
+
try {
|
|
238
|
+
value = JSON.parse(block);
|
|
239
|
+
} catch {
|
|
240
|
+
if (block.includes('mandate/1')) return { mandate: null, unread: 'mandate-unreadable' };
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (isObj(value) && value.kind === 'mandate/1') {
|
|
244
|
+
delete value.source; // a Project declares no protocols: a board block carries no source (OA-7)
|
|
245
|
+
found.push(value);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (found.length > 1) return { mandate: null, unread: 'mandate-blocks' };
|
|
249
|
+
return found.length ? { mandate: found[0], unread: null } : none;
|
|
182
250
|
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `mandateCovers` for a call under a board request's mandate, its sender placed as T1 and no caller agent id (the
|
|
254
|
+
* chain's end alone binds it, MD-3). A covered answer's `recordAs` names `caller`, the sender Project the board
|
|
255
|
+
* verified (MD-3 records the caller). `wall` answers milliseconds. Null when no mandate travelled. `spendUsd` and
|
|
256
|
+
* `project` are read failing closed, as the kit's other mandate readers read them (FAA-139): a spend that is not a
|
|
257
|
+
* finite number is none (a ceiling refuses it), a `project` that is not a string, null included (the contract's
|
|
258
|
+
* mandateCovers refuses a null project), unreadable; a call that names none leaves `project` out. Only the five
|
|
259
|
+
* members mandateCovers reads are read, each once: a spend as an own member (an inherited one is none, which a ceiling
|
|
260
|
+
* refuses), a capability, action, effect and project as the contract reads them, a plain [[Get]] whose undefined is
|
|
261
|
+
* absent (an inherited, non-enumerable, getter or Proxy member included); the rest of the call is never touched. A
|
|
262
|
+
* getter whose value changes is answered on its one read.
|
|
263
|
+
*/
|
|
264
|
+
export function covers(mandate, call, agentId, wall, caller = null) {
|
|
265
|
+
if (mandate == null || agentId == null) return null;
|
|
266
|
+
if (call !== null && typeof call === 'object' && !Array.isArray(call)) {
|
|
267
|
+
const read = {};
|
|
268
|
+
for (const key of ['capability', 'action', 'effect']) {
|
|
269
|
+
const value = call[key]; // the contract's read: whatever [[Get]] answers, undefined being absent
|
|
270
|
+
if (value !== undefined) read[key] = value;
|
|
271
|
+
}
|
|
272
|
+
const spend = mandateSpend(Object.hasOwn(call, 'spendUsd') ? call.spendUsd : undefined);
|
|
273
|
+
if (spend !== undefined) read.spendUsd = spend;
|
|
274
|
+
const project = call.project;
|
|
275
|
+
if (project !== undefined) read.project = typeof project === 'string' ? project : UNREADABLE_PROJECT;
|
|
276
|
+
call = read;
|
|
277
|
+
}
|
|
278
|
+
const clock = wall();
|
|
279
|
+
let now = '';
|
|
280
|
+
try {
|
|
281
|
+
if (typeof clock === 'number' && Number.isFinite(clock)) now = formatRfc3339(clock);
|
|
282
|
+
} catch { // finite but outside the dates a clock can name
|
|
283
|
+
now = '';
|
|
284
|
+
}
|
|
285
|
+
const answer = mandateCovers({ mandate, tier: 'T1', now, self: agentId, call });
|
|
286
|
+
return answer.recordAs ? { ok: true, recordAs: { ...answer.recordAs, caller } } : answer;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The responder's opt-in, checked: answers `mandateFrom` as the responder keeps it (a Set or the string). */
|
|
290
|
+
export function mandateOptions(agentId, mandateFrom) {
|
|
291
|
+
if (agentId != null && !isAgentId(agentId)) throw new BoardError('invalid_arguments', 'agentId must be the service\'s registry agent id.');
|
|
292
|
+
if (mandateFrom == null || mandateFrom === PERSONAL_BOARD) return mandateFrom ?? null;
|
|
293
|
+
const projects = typeof mandateFrom === 'string' || mandateFrom instanceof String || typeof mandateFrom?.[Symbol.iterator] !== 'function' ? null : [...mandateFrom];
|
|
294
|
+
if (projects === null || !projects.every((p) => typeof p === 'string' && p)) {
|
|
295
|
+
throw new BoardError('invalid_arguments', `mandateFrom is '${PERSONAL_BOARD}' or a collection of Project ids.`);
|
|
296
|
+
}
|
|
297
|
+
return new Set(projects.map(fold));
|
|
298
|
+
}
|
|
299
|
+
|
|
@@ -5,10 +5,10 @@ import crypto from 'node:crypto';
|
|
|
5
5
|
import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
|
|
6
6
|
import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
7
7
|
import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
|
|
8
|
-
import { BoardRequest, BoardSession, Checkpoint } from './fabric-board-link.mjs';
|
|
8
|
+
import { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD, covers, mandateOptions, readMandate } from './fabric-board-link.mjs';
|
|
9
9
|
import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
|
|
10
10
|
|
|
11
|
-
export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
|
|
11
|
+
export { BoardRequest, BoardSession, Checkpoint, PERSONAL_BOARD }; // re-exported: callers import them from here since 0.40.0
|
|
12
12
|
export { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES }; // since 0.56.0 defined in fabric-board-slots.mjs
|
|
13
13
|
|
|
14
14
|
export const RENEW_SECONDS = 20;
|
|
@@ -34,10 +34,23 @@ const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
|
|
|
34
34
|
export class WorkContext {
|
|
35
35
|
#responder;
|
|
36
36
|
#entry;
|
|
37
|
-
|
|
37
|
+
#caller;
|
|
38
|
+
constructor(responder, entry, mandate = null, caller = null) {
|
|
38
39
|
this.#responder = responder;
|
|
39
40
|
this.#entry = entry;
|
|
41
|
+
this.#caller = caller;
|
|
40
42
|
this.stopped = false;
|
|
43
|
+
this.mandate = mandate; // the operator's mandate the request carried, when the responder reads one (FAA-135)
|
|
44
|
+
}
|
|
45
|
+
/** Whether the request's mandate covers `call` (`mandateCovers`' answer); null when none travelled. */
|
|
46
|
+
mandateCovers(call) {
|
|
47
|
+
return covers(this.mandate, call, this.#responder.agentId, this.#responder.wall, this.#caller);
|
|
48
|
+
}
|
|
49
|
+
toJSON() {
|
|
50
|
+
return { stopped: this.stopped }; // never the mandate: it holds the operator's words
|
|
51
|
+
}
|
|
52
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
53
|
+
return this.toJSON(); // console.log(ctx) prints no mandate either
|
|
41
54
|
}
|
|
42
55
|
async progress(note) {
|
|
43
56
|
if (this.stopped) throw new Stopped();
|
|
@@ -69,13 +82,15 @@ function noticeSubject(value, project, episode) {
|
|
|
69
82
|
*/
|
|
70
83
|
export class Responder extends SlotKeeper {
|
|
71
84
|
constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
|
|
72
|
-
now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
|
|
85
|
+
now = () => performance.now() / 1000, rand = Math.random, sessionFactory, agentId = null, mandateFrom = null, wall = Date.now } = {}) {
|
|
73
86
|
super();
|
|
74
87
|
if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
|
|
75
88
|
const caps = [...(capabilities ?? [])];
|
|
76
89
|
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
|
|
77
90
|
Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
|
|
78
91
|
this.project = normalProject(projectId);
|
|
92
|
+
// FAA-135: read the operator's mandate from a trusted request of his own Project (opt-in; null reads none)
|
|
93
|
+
Object.assign(this, { mandateFrom: mandateOptions(agentId, mandateFrom), agentId: agentId ?? null, wall });
|
|
79
94
|
this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
|
|
80
95
|
this.checkpoint = new Checkpoint(checkpointFile);
|
|
81
96
|
this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
|
|
@@ -88,6 +103,7 @@ export class Responder extends SlotKeeper {
|
|
|
88
103
|
this.waitUntil = 0;
|
|
89
104
|
this.stopping = false;
|
|
90
105
|
this.claimKeys = {};
|
|
106
|
+
this.probing = new Set(); // moved slots whose move-back claim lost its answer: asked again under the same key
|
|
91
107
|
this.said = {};
|
|
92
108
|
this.rr = 0;
|
|
93
109
|
this.reconcileAt = 0;
|
|
@@ -172,6 +188,7 @@ export class Responder extends SlotKeeper {
|
|
|
172
188
|
this.state = 'connecting';
|
|
173
189
|
this.slots = {}; // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
|
|
174
190
|
this.confirmed = {}; // nor its confirmations: a slot may have moved between the two sessions
|
|
191
|
+
this.probing = new Set(); // nor its lost move-back claims
|
|
175
192
|
const grant = await this.hub.joinBoard(this.project, this.credentialFile);
|
|
176
193
|
const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
|
|
177
194
|
const session = this.sessionFactory(grant);
|
|
@@ -462,7 +479,9 @@ export class Responder extends SlotKeeper {
|
|
|
462
479
|
/** Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's. */
|
|
463
480
|
leaseLeft() {
|
|
464
481
|
const renewed = this.current ? [this.current.renewedAt ?? 0]
|
|
465
|
-
: Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0)
|
|
482
|
+
: [...Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0), // a lapsed slot holds no lease (FAA-163)
|
|
483
|
+
...Object.entries(this.slots).filter(([c, s]) => this.probing.has(c) && 'probeAt' in s && this.now() - s.probeAt < LEASE_SECONDS - LEASE_MARGIN_SECONDS)
|
|
484
|
+
.map(([, s]) => s.probeAt)]; // a move-back claim whose answer was lost holds one from its send, while more than the 10 s margin of that lease is left (FAA-165)
|
|
466
485
|
return renewed.length && this.session ? Math.min(...renewed) + LEASE_SECONDS - this.now() : 0;
|
|
467
486
|
}
|
|
468
487
|
|
|
@@ -528,7 +547,10 @@ export class Responder extends SlotKeeper {
|
|
|
528
547
|
this.suspect.delete(capability);
|
|
529
548
|
}
|
|
530
549
|
for (const [capability, slot] of Object.entries(this.slots)) {
|
|
531
|
-
if (slot.state === 'moved')
|
|
550
|
+
if (slot.state === 'moved') { // never enrolled again on its own, even when the new holder lapses (FAA-155)
|
|
551
|
+
if (this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.movedBack(capability); // unless com.status shows a later generation it may hold (FAA-165)
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
532
554
|
if (slot.state === 'paused') {
|
|
533
555
|
// still held: a longer pause; lifted: the slot is ours again
|
|
534
556
|
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0) && !(await this.gone(capability))) await this.enroll(capability); // a move is read first
|
|
@@ -672,7 +694,11 @@ export class Responder extends SlotKeeper {
|
|
|
672
694
|
}
|
|
673
695
|
|
|
674
696
|
async runWork(entry, request) {
|
|
675
|
-
const
|
|
697
|
+
const { mandate, unread } = readMandate(request, this.mandateFrom, this.agentId);
|
|
698
|
+
if (unread) { // by its code alone: the block holds the operator's words
|
|
699
|
+
this.emit('board.request.mandate_unread', 'warning', `Request ${entry.request} carries a mandate the kit does not read (${unread}); it is worked without one.`, { request: entry.request, reason: unread });
|
|
700
|
+
}
|
|
701
|
+
const ctx = new WorkContext(this, entry, mandate, isObj(request.sender) ? request.sender.project ?? null : null);
|
|
676
702
|
const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
|
|
677
703
|
this.current = work;
|
|
678
704
|
work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs
CHANGED
|
@@ -16,6 +16,8 @@ export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
|
16
16
|
export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
|
|
17
17
|
'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
|
|
18
18
|
|
|
19
|
+
const sameRecord = (a, b) => JSON.stringify(a ?? {}) === JSON.stringify(b ?? {});
|
|
20
|
+
|
|
19
21
|
/** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
|
|
20
22
|
export class SlotKeeper {
|
|
21
23
|
// -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
|
|
@@ -127,13 +129,88 @@ export class SlotKeeper {
|
|
|
127
129
|
this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
|
|
128
130
|
this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
|
|
129
131
|
delete this.confirmed[capability];
|
|
132
|
+
delete this.claimKeys[capability]; // the move returned that claim to the queue: its receipt is dead
|
|
130
133
|
if (!('moved' in held)) { // once, even across restarts
|
|
131
|
-
this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it
|
|
134
|
+
this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it; accepted work stays \`accepted\` (work begun is the new holder's). The operator brings it back by take-back (once the other session lets it go, or at a restart with accepted work waiting; a restart alone does not) or, where Fabric offers it, a move back here. Else ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
|
|
132
135
|
this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
|
|
133
136
|
}
|
|
134
137
|
return true;
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
/**
|
|
141
|
+
* A moved slot asked about once per later generation (FAA-165): the operator may move it back to this session
|
|
142
|
+
* (DEC-0140 PR-2), which this client cannot hear, so when com.status shows a generation past the recorded move, one
|
|
143
|
+
* com.claim asks whether the slot is its own. A claim that lands makes it held again and starts what it handed out; a
|
|
144
|
+
* lease_expired one (the move reached this session but its lease lapsed) enrolls again; a fenced one is another
|
|
145
|
+
* session's, recorded so the same generation is not asked about twice. Never an enroll or a replace on a slot another
|
|
146
|
+
* session holds: a slot moved off stays off unless it is handed back.
|
|
147
|
+
*/
|
|
148
|
+
async movedBack(capability) {
|
|
149
|
+
if (this.current || this.stopping) return; // a claim now could hand out a second request, or start work while the responder stops
|
|
150
|
+
const held = this.checkpoint.slots[capability] ?? {};
|
|
151
|
+
if (held.project !== this.project || !isInt(held.moved, 1)) return;
|
|
152
|
+
if (this.slots[capability]) this.slots[capability].renewedAt = this.now();
|
|
153
|
+
const now = ((await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
|
|
154
|
+
if (!isInt(now, 1) || now <= held.moved) return;
|
|
155
|
+
if (!this.probing.has(capability) || !this.claimKeys[capability]) { // a fresh key: one from before the move may hold a dead receipt
|
|
156
|
+
this.claimKeys[capability] = this.key('claim');
|
|
157
|
+
if (this.slots[capability]) delete this.slots[capability].probeAt; // and a send time of its own
|
|
158
|
+
}
|
|
159
|
+
let answer;
|
|
160
|
+
const sent = this.now();
|
|
161
|
+
try {
|
|
162
|
+
answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
if (!(e instanceof BoardError)) throw e; // not the board's answer
|
|
165
|
+
if (e.code === 'unreachable') { // the claim may have landed: asked again under this key, never left to the fault
|
|
166
|
+
this.probing.add(capability);
|
|
167
|
+
if (this.slots[capability] && !('probeAt' in this.slots[capability])) this.slots[capability].probeAt = sent; // its lease runs from the first send
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.probing.delete(capability);
|
|
171
|
+
if (e.code === 'held') return; // the operator's hold: asked again at the next renewal
|
|
172
|
+
if (e.code === 'idempotency_window_expired' || e.code === 'capacity_exceeded') {
|
|
173
|
+
delete this.claimKeys[capability];
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (e.code === 'lease_expired') {
|
|
177
|
+
delete this.claimKeys[capability]; // a dead attempt's receipt may sit under it
|
|
178
|
+
if (await this.back(capability, held, now)) {
|
|
179
|
+
this.slots[capability].renewedAt = -Infinity; // lapsed: the enroll below is recorded first
|
|
180
|
+
await this.enroll(capability);
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (SESSION_CODES.has(e.code) && e.code !== 'not_authorized') throw e; // the board session's fault, not this slot's
|
|
185
|
+
// fenced, or another refusal of this claim: not this session's slot at this generation; a takeBack meanwhile wins (FAA-158)
|
|
186
|
+
await this.locked(() => { if (sameRecord(this.checkpoint.slots[capability], held)) this.note(capability, { ...held, moved: now }); });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.probing.delete(capability);
|
|
190
|
+
const generation = isObj(answer.attempt) && isInt(answer.attempt.generation, 1) ? answer.attempt.generation : now;
|
|
191
|
+
if (generation < now) { // a replay from before a later move: dead, asked again fresh
|
|
192
|
+
delete this.claimKeys[capability];
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!(await this.back(capability, held, generation))) return;
|
|
196
|
+
if ('request' in answer) {
|
|
197
|
+
delete this.claimKeys[capability];
|
|
198
|
+
this.slots[capability].renewedAt = this.now(); // the claim just landed: its lease runs (finding 38)
|
|
199
|
+
await this.start(capability, answer);
|
|
200
|
+
} else await this.enroll(capability); // renewed now: whether an empty com.claim renews the lease is not the contract's to say
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The slot is this session's again at `generation`: held, its record rewritten without the move (FAA-165). */
|
|
204
|
+
back(capability, held, generation) {
|
|
205
|
+
return this.locked(() => {
|
|
206
|
+
if (!sameRecord(this.checkpoint.slots[capability], held)) return false; // a takeBack forgot it; its own next tick enrolls
|
|
207
|
+
this.slots[capability] = { state: 'held', generation, renewedAt: -Infinity }; // no lease yet
|
|
208
|
+
this.heldAt(capability, generation);
|
|
209
|
+
delete this.said['board.slot.moved']; // a later move is said again
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
137
214
|
/**
|
|
138
215
|
* The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
|
|
139
216
|
* tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
|
|
@@ -141,6 +218,8 @@ export class SlotKeeper {
|
|
|
141
218
|
takeBack(capability) {
|
|
142
219
|
if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
|
|
143
220
|
this.checkpoint.slot(capability, null);
|
|
221
|
+
delete this.claimKeys[capability]; // a receipt from before the take-back may be dead
|
|
222
|
+
this.probing.delete(capability);
|
|
144
223
|
if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
|
|
145
224
|
delete this.said['board.slot.moved'];
|
|
146
225
|
}
|
|
@@ -282,6 +282,16 @@ export function mandateSpend(value) {
|
|
|
282
282
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** A job's approval step {effect, spendUsd?, project?} as a job file keeps it: read as the gate reads it, so a job's
|
|
286
|
+
* start and its answer agree (0.61.0 seam reading 32, readings 33 and 34): a spend through mandateSpend (dropped when none), a project
|
|
287
|
+
* as a primitive string, else UNREADABLE_PROJECT (dropped when null or undefined). */
|
|
288
|
+
export function keptStep(step) {
|
|
289
|
+
const { effect, spendUsd, project } = step; // each member read once (seam reading 34)
|
|
290
|
+
const spend = mandateSpend(spendUsd);
|
|
291
|
+
return { effect, ...(spend !== undefined ? { spendUsd: spend } : {}),
|
|
292
|
+
...(project !== undefined && project !== null ? { project: typeof project === 'string' ? project : UNREADABLE_PROJECT } : {}) };
|
|
293
|
+
}
|
|
294
|
+
|
|
285
295
|
export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
|
|
286
296
|
const inputs = { action, tier, caller, elicited: elicited === true, now };
|
|
287
297
|
if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -41,6 +41,7 @@ export {
|
|
|
41
41
|
jobStep,
|
|
42
42
|
actionGate,
|
|
43
43
|
mandateSpend,
|
|
44
|
+
keptStep,
|
|
44
45
|
UNREADABLE_PROJECT,
|
|
45
46
|
actionRefusalToolResult,
|
|
46
47
|
usageFindings,
|
|
@@ -49,7 +50,7 @@ export {
|
|
|
49
50
|
pauseMessage,
|
|
50
51
|
repeatedKeyDecision,
|
|
51
52
|
} from './fabric-interop-rules.mjs';
|
|
52
|
-
import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
|
|
53
|
+
import { PROTOCOL, EXTENSION_KEY, MCP_REVISION, TERMINAL, CONTRACT_VERSION, JOB_HANDLE_SCHEMA, InteropError, UnknownJob, parseTraceparent, childTraceparent, traceIds, QUOTA_EXHAUSTED, QUEUE_FULL, OUTCOME_UNKNOWN, LIMIT_REACHED, INTEROP_NULL_USAGE, PROTOCOLS_READ, utcTime, quotaDataOk, readJobPause, jobCallerAction, intentIdempotencyKey, readsNullUsage, requestMeta, usageFindings, usageToward, jobLimitRules, pauseMessage, repeatedKeyDecision, keptStep, OUTCOMES, ENVELOPE_REQUIRED, JOB_ID, KEY, SECRET_WORDS } from './fabric-interop-rules.mjs'; // the rule layer (FAA-92)
|
|
53
54
|
|
|
54
55
|
// --- C3.1 capabilities as tools ----------------------------------------------------------
|
|
55
56
|
export function expectedAnnotations(effect, idempotency) {
|
|
@@ -266,23 +267,28 @@ export class JobStore {
|
|
|
266
267
|
|
|
267
268
|
// `steps` (DEC-0125) maps a request key to the approval step it asks for, {effect, spendUsd?, project?}, kept in the
|
|
268
269
|
// job's private part (the format fabric_interop.py writes) so a service reads an answer's _meta.mandate for that step.
|
|
270
|
+
// A step is kept as the gate reads it (keptStep), so a job's start and its answer agree.
|
|
269
271
|
requestInput(jobId, inputRequests, statusMessage, { steps } = {}) {
|
|
272
|
+
const entries = Object.entries(inputRequests ?? {});
|
|
273
|
+
if (!entries.length || Array.isArray(inputRequests) || typeof inputRequests !== 'object') throw new InteropError('input_required needs a JSON object of at least one input request.'); // a JSON object of requests, first (unit 38, 40)
|
|
274
|
+
const kept = Object.create(null); // the value checked is the value kept (seam 34); a key __proto__ stays a key (35)
|
|
270
275
|
for (const [key, step] of Object.entries(steps ?? {})) {
|
|
271
|
-
|
|
276
|
+
kept[key] = Object.hasOwn(inputRequests, key) && step !== null && step !== undefined ? keptStep(step) : {};
|
|
277
|
+
if (!EFFECT_CLASSES.includes(kept[key].effect)) throw new InteropError(`Step ${key}: an input request's key with its effect class (DEC-0125).`);
|
|
272
278
|
}
|
|
273
|
-
const entries
|
|
274
|
-
if (!entries.length) throw new InteropError('input_required needs at least one input request.');
|
|
275
|
-
for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
279
|
+
for (const [key, request] of entries) if (!KEY.test(key) || request === null || typeof request !== 'object' || Array.isArray(request) || request.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
276
280
|
const [finding] = inputRequestLinkFindings({ inputRequests }); // FAC-SEM-143 (D12)
|
|
277
281
|
if (finding) throw new InteropError(`${finding.instancePath}: ${finding.message}`);
|
|
278
|
-
return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps:
|
|
282
|
+
return this.transition(jobId, 'input_required', { inputRequests, statusMessage }, { steps: kept });
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
answer(jobId, inputResponses) {
|
|
282
286
|
const job = this.get(jobId);
|
|
283
287
|
if (job.status !== 'input_required') return {};
|
|
284
|
-
const
|
|
285
|
-
|
|
288
|
+
const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); // as Python's dict (seam r37)
|
|
289
|
+
const asked = plain(job.inputRequests) ? job.inputRequests : {};
|
|
290
|
+
const matched = Object.fromEntries(Object.entries(plain(inputResponses) ? inputResponses : {}).filter(([key, value]) =>
|
|
291
|
+
Object.hasOwn(asked, key) && ['accept', 'decline', 'cancel'].includes(value?.action))); // an asked key, never an inherited name (seam 36)
|
|
286
292
|
// The answered question's kept steps go in the same write: none outlives its answer.
|
|
287
293
|
if (Object.keys(matched).length) this.transition(jobId, 'working', {}, { steps: {} });
|
|
288
294
|
return matched;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -43,9 +43,19 @@ export class InstanceLock {
|
|
|
43
43
|
if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
|
|
44
44
|
throw error;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
try {
|
|
47
|
+
fs.ftruncateSync(this.fd, 0);
|
|
48
|
+
fs.writeSync(this.fd, String(process.pid));
|
|
49
|
+
fs.fsyncSync(this.fd);
|
|
50
|
+
} catch (error) { // a full disk or an I/O error: let the lock go, or nobody can (the caller drops this object)
|
|
51
|
+
const fd = this.fd;
|
|
52
|
+
this.fd = null;
|
|
53
|
+
try {
|
|
54
|
+
fs.closeSync(fd);
|
|
55
|
+
} catch { // a deferred write error (NFS) at the close: the lock still goes, the first error stands
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
49
59
|
return this;
|
|
50
60
|
}
|
|
51
61
|
// Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
|
|
@@ -66,7 +76,17 @@ export class InstanceLock {
|
|
|
66
76
|
this.server.listen(name, resolve);
|
|
67
77
|
});
|
|
68
78
|
this.server.unref();
|
|
69
|
-
|
|
79
|
+
try {
|
|
80
|
+
atomicWrite(this.path, String(process.pid));
|
|
81
|
+
} catch (error) { // as on macOS: the name is let go with the failed write, the first error standing
|
|
82
|
+
const server = this.server;
|
|
83
|
+
this.server = null;
|
|
84
|
+
try {
|
|
85
|
+
server.close();
|
|
86
|
+
} catch { // nothing to add to the first error
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
70
90
|
return this;
|
|
71
91
|
}
|
|
72
92
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs
CHANGED
|
@@ -45,6 +45,9 @@ export const OPERATOR_SOURCE_PROTOCOL = 'operator-source/1';
|
|
|
45
45
|
const SCOPE_MEMBERS = new Set(['capabilities', 'actions', 'effects', 'perCallMaxUsd', 'projects']);
|
|
46
46
|
const STEP_MEMBERS = new Set(['agent', 'at', 'scope']);
|
|
47
47
|
const AGENT_ID = /^[a-z][a-z0-9-]{1,62}(\.[a-z][a-z0-9-]{0,31})?$/; // common.schema.json agentId
|
|
48
|
+
|
|
49
|
+
/** A registry agent id (common.schema.json agentId): a provider or service id, optionally `.instance`. */
|
|
50
|
+
export const isAgentId = (value) => typeof value === 'string' && AGENT_ID.test(value);
|
|
48
51
|
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/; // common.schema.json capabilityName
|
|
49
52
|
const REQUEST_KEY = /^[A-Za-z0-9_.-]{1,64}$/; // interop-common.schema.json requestKey
|
|
50
53
|
const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
CHANGED
|
@@ -504,12 +504,17 @@ export function compose(draft, files) {
|
|
|
504
504
|
return { pkg, notes };
|
|
505
505
|
}
|
|
506
506
|
|
|
507
|
+
/** What a folder problem means, where its code alone does not say what to change (FAA-193). */
|
|
508
|
+
const FOLDER_MESSAGES = {
|
|
509
|
+
'provider-manifest': 'provider-manifest: the provider manifest must be a JSON object, and when it carries costPresets fabric.package.json must carry the same costPresets (copy them; pack does not)',
|
|
510
|
+
};
|
|
511
|
+
|
|
507
512
|
function allProblems(pkg, files) {
|
|
508
513
|
return [
|
|
509
514
|
...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
|
|
510
515
|
...pathProblems(pkg).map((p) => ({ ...p, rule: 'path' })),
|
|
511
516
|
...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
|
|
512
|
-
...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
|
|
517
|
+
...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: FOLDER_MESSAGES[p.problem] ?? p.problem })),
|
|
513
518
|
];
|
|
514
519
|
}
|
|
515
520
|
|