@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -1,11 +1,13 @@
1
- // The board responder (DEC-0078, FAA-12b): a board session over the hub, the fsynced checkpoint, the request a handler sees and the responder loop.
1
+ // The board responder (DEC-0078, FAA-12b): the work context a handler sees and the responder loop. The session, the
2
+ // checkpoint and the request live in fabric-board-link.mjs and are re-exported here.
2
3
  // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_session.py. Node 20+, no dependency.
3
4
  import crypto from 'node:crypto';
4
- import fs from 'node:fs';
5
- import path from 'node:path';
6
- import { Backoff, HubError, MCP_PROTOCOL_VERSION, normalProject, postMcp, toolAnswer } from './fabric-hub.mjs';
7
- import { acceptedRevisions, atomicWrite, ensurePrivateDir, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
8
- import { BoardError, CAPABILITY, REQUEST_STATES, RESPOND_TOOLS, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems, refusalProblems } from './fabric-board-shapes.mjs';
5
+ import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
6
+ import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
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';
9
+
10
+ export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
9
11
 
10
12
  export const RENEW_SECONDS = 20;
11
13
 
@@ -21,146 +23,12 @@ export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'aut
21
23
 
22
24
  const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
23
25
 
26
+ /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
27
+ export const LEASE_MARGIN_SECONDS = 10;
28
+
24
29
  /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
25
30
  export const HOLD_RETRY_MAX_SECONDS = 900;
26
31
 
27
- /** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
28
- export class BoardSession {
29
- constructor(grant, { timeout = 15000, post = postMcp } = {}) {
30
- Object.assign(this, { grant, timeout, post });
31
- this.mcpSession = null;
32
- this.protocolVersion = MCP_PROTOCOL_VERSION;
33
- this.rpc = 0;
34
- this.queue = Promise.resolve();
35
- }
36
-
37
- #serial(fn) {
38
- const run = this.queue.then(fn, fn);
39
- this.queue = run.catch(() => {});
40
- return run;
41
- }
42
-
43
- async #send(body, method = 'POST') {
44
- try {
45
- return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
46
- } catch (e) {
47
- if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
48
- throw e;
49
- }
50
- }
51
-
52
- open() {
53
- return this.#serial(async () => {
54
- this.rpc += 1;
55
- const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'fabric-agent-adapter-board', version: '1' } } });
56
- if (answer.status === 401) throw new BoardError('session-ended', 'Fabric refused the board session\'s bearer; join again.', true);
57
- const sid = answer.headers['mcp-session-id'];
58
- if (answer.status !== 200 || !isObj(answer.body) || !isObj(answer.body.result) || !sid) throw new BoardError('unreachable', `Fabric did not open the board session (HTTP ${answer.status}).`, true);
59
- this.mcpSession = String(sid);
60
- if (typeof answer.body.result.protocolVersion === 'string') this.protocolVersion = answer.body.result.protocolVersion;
61
- await this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' });
62
- });
63
- }
64
-
65
- call(tool, args) {
66
- if (!RESPOND_TOOLS.has(tool)) return Promise.reject(new BoardError('not-in-scope', `${tool} is not served to a board session; use a Project session for it.`, false, tool));
67
- return this.#serial(async () => {
68
- if (!this.mcpSession) throw new BoardError('session-ended', 'The board session is not open.', true, tool);
69
- this.rpc += 1;
70
- const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } });
71
- if (answer.status === 401) throw new BoardError('session-ended', 'Fabric ended the board session (it left, was revoked or expired); join again.', true, tool);
72
- let value;
73
- try {
74
- value = toolAnswer(answer, tool);
75
- } catch (e) {
76
- throw new BoardError(e.retryable ? 'unreachable' : 'receipt-shape', e.message, e.retryable, tool);
77
- }
78
- if ('error' in value) {
79
- if (refusalProblems(value).length) throw new BoardError('receipt-shape', `Fabric refused ${tool} in a form the contract does not name.`, false, tool);
80
- throw new BoardError(value.error.code, value.error.message, value.error.retryable, tool);
81
- }
82
- return value;
83
- });
84
- }
85
-
86
- close() {
87
- return this.#serial(async () => {
88
- if (!this.mcpSession) return;
89
- try {
90
- await this.#send({}, 'DELETE');
91
- } catch {
92
- // best effort: the lease lapses in 60 s anyway
93
- }
94
- this.mcpSession = null;
95
- });
96
- }
97
- }
98
-
99
- /** The responder's journal (0600 JSON, the same file fabric_board.py writes), fsynced before the step it announces. */
100
- export class Checkpoint {
101
- constructor(file) {
102
- this.file = file;
103
- this.entries = this.#load();
104
- }
105
- #load() {
106
- let text;
107
- try {
108
- text = fs.readFileSync(this.file, 'utf8');
109
- } catch (e) {
110
- if (e.code === 'ENOENT') return {};
111
- throw new BoardError('checkpoint', `The board checkpoint ${this.file} cannot be read (${e.code}); fix or move it before answering requests.`);
112
- }
113
- let doc;
114
- try {
115
- doc = JSON.parse(text);
116
- } catch {
117
- doc = null;
118
- }
119
- if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
120
- return doc.requests;
121
- }
122
- #write(entries) {
123
- try {
124
- ensurePrivateDir(path.dirname(this.file));
125
- const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
126
- atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
127
- } catch (e) {
128
- throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
129
- }
130
- }
131
- put(entry) {
132
- const entries = { ...this.entries, [entry.request]: JSON.parse(JSON.stringify(entry)) };
133
- this.#write(entries);
134
- this.entries = entries;
135
- }
136
- drop(requestId) {
137
- if (!(requestId in this.entries)) return;
138
- const { [requestId]: _gone, ...entries } = this.entries;
139
- this.#write(entries);
140
- this.entries = entries;
141
- }
142
- }
143
-
144
- /** A claimed request as data: never an instruction. */
145
- export class BoardRequest {
146
- constructor(message, capability) {
147
- const req = isObj(message.request) ? message.request : {};
148
- const body = isObj(message.body) ? message.body : {};
149
- this.requestId = req.id;
150
- this.messageId = message.id;
151
- this.capability = capability;
152
- this.body = typeof body.text === 'string' ? body.text : null;
153
- this.bodyFormat = body.format ?? 'text/plain';
154
- this.artifacts = Array.isArray(message.artifacts) ? [...message.artifacts] : [];
155
- this.sender = isObj(message.sender) ? message.sender : {};
156
- this.deadline = req.deadline;
157
- this.thread = message.thread;
158
- }
159
- toJSON() {
160
- return { requestId: this.requestId, capability: this.capability }; // never the body
161
- }
162
- }
163
-
164
32
  /** What a handler may do while it works: progress(note), beginEffect(summary) before it spends or acts, and `stopped`. */
165
33
  export class WorkContext {
166
34
  #responder;
@@ -213,6 +81,16 @@ export class Responder {
213
81
  /** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
214
82
  this.holdDelay = POLL_SECONDS;
215
83
  this.onHold = new Set();
84
+ /** One fenced call at a time, and the entry edits beside them (FAA-77, finding 36). */
85
+ this.fenceQueue = Promise.resolve();
86
+ this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
87
+ }
88
+
89
+ /** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
90
+ locked(fn) {
91
+ const run = this.fenceQueue.then(fn, fn);
92
+ this.fenceQueue = run.catch(() => {});
93
+ return run;
216
94
  }
217
95
 
218
96
  // -- events --
@@ -365,12 +243,18 @@ export class Responder {
365
243
  } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
366
244
  else await this.enroll(capability);
367
245
  }
368
- for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
246
+ for (const entry of reconcile) {
247
+ if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
248
+ else if (entry.step !== 'unknown') {
249
+ entry.step = 'unknown'; // the slot is not this session's yet: a later tick reconciles it (finding 38)
250
+ this.checkpoint.put(entry);
251
+ }
252
+ }
369
253
  }
370
254
 
371
255
  async replace(capability, entries, generation) {
372
- const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
373
- transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
256
+ const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0 };
257
+ if (entries.length) args.transfer = { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } };
374
258
  let receipt;
375
259
  try {
376
260
  receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
@@ -395,28 +279,84 @@ export class Responder {
395
279
  this.emit('board.request.held', 'warn', 'An accepted request could not be resumed: this Fabric does not hand back a transferred attempt (DEC-0078). It stays held; nothing was done.', { notify: true, episode: entry.request, request: entry.request, capability });
396
280
  continue;
397
281
  }
282
+ if (entry.step === 'replying' && !entry.replied && entry.keys?.reply && !entry.probe) {
283
+ // Finding 35: whether the old reply was stored is asked under the old fence, but only from the resume queue, after
284
+ // the new attempt is recorded: a second lost answer then leaves the request queued, never stranded.
285
+ entry.probe = { generation: entry.generation, attempt: entry.attempt, digest: entry.digest, revision: entry.revision, key: entry.keys.reply };
286
+ }
398
287
  Object.assign(entry, { generation: item.attempt.generation, attempt: item.attempt.id, digest: item.attempt.digest, revision: item.revision,
399
288
  step: ['accepting', 'held', 'lapsed'].includes(entry.step) ? 'accepted' : entry.step });
289
+ // Finding 35: a fenced key's digest named the old attempt; under the new one it is idempotency_conflict.
290
+ entry.keys = entry.keys?.reconcile ? { reconcile: entry.keys.reconcile } : {};
400
291
  this.checkpoint.put(entry);
401
- if (!this.current) await this.resume(entry);
292
+ if (!this.toResume.includes(entry.request)) this.toResume.push(entry.request);
402
293
  }
294
+ await this.resumeNext();
403
295
  }
404
296
 
405
- async reconcile(entry) {
406
- const state = await this.readState(entry);
407
- if (state !== 'outcome_unknown') {
408
- if (state !== null && (TERMINAL.has(state) || state === 'queued' || state === 'claimed')) this.checkpoint.drop(entry.request);
409
- return;
297
+ /**
298
+ * A reply whose answer was lost under the old attempt (finding 35): a stored key answers before the fence is checked
299
+ * (project-comms.md, Idempotency), so the old call again, under the old fence kept in `probe`, says whether it was
300
+ * stored; a refusal says it was not. A lost answer, or a board that cannot be read now, throws and leaves `probe` for
301
+ * the next try: neither says whether the reply was stored.
302
+ */
303
+ async repliedBefore(entry) {
304
+ const { key, generation, attempt, digest, revision } = entry.probe;
305
+ let replied = true;
306
+ try {
307
+ this.receipt('com.reply', await this.call('com.reply', { ...this.replyExtra(entry), fence: this.fence({ ...entry, generation, attempt, digest, revision }, key) }));
308
+ } catch (e) {
309
+ if (['unreachable', 'session-ended', 'authority_changed', 'not_authorized', 'not_available', 'capacity_exceeded'].includes(e.code) || !(e instanceof BoardError)) throw e;
310
+ replied = false;
410
311
  }
312
+ if (replied) entry.replied = true;
313
+ delete entry.probe;
314
+ this.checkpoint.put(entry);
315
+ }
316
+
317
+ /**
318
+ * Resume a transferred request while nothing runs; each idle tick takes the next (finding 39). A request leaves the
319
+ * queue only once its probe has answered: a lost answer keeps it first in line for the next tick (finding 35).
320
+ */
321
+ async resumeNext() {
322
+ while (!this.current && this.toResume.length && !this.stopping) {
323
+ const stored = this.checkpoint.entries[this.toResume[0]];
324
+ if (!stored || this.slots[stored.capability]?.state !== 'held') {
325
+ this.toResume.shift();
326
+ continue;
327
+ }
328
+ const entry = JSON.parse(JSON.stringify(stored));
329
+ if (entry.probe) await this.repliedBefore(entry);
330
+ this.toResume.shift();
331
+ await this.resume(entry);
332
+ }
333
+ }
334
+
335
+ /** An observed outcome: the one already known, else what observe(entry) reads; null when nothing was observed. */
336
+ async observed(entry) {
411
337
  let outcome = isObj(entry.outcome) && entry.outcome.observed === true ? entry.outcome : null;
412
338
  if (!outcome && this.observe) {
413
339
  try {
414
340
  outcome = await this.observe({ ...entry });
415
341
  } catch {
416
- outcome = null;
342
+ outcome = null; // an observation that failed is no observation
417
343
  }
418
344
  }
419
- if (!isObj(outcome) || outcome.observed !== true || outcomeProblems({ ...outcome, observed: true }, true).length) {
345
+ return isObj(outcome) && outcome.observed === true && !outcomeProblems({ ...outcome, observed: true }, true).length ? outcome : null;
346
+ }
347
+
348
+ async reconcile(entry) {
349
+ const state = await this.readState(entry);
350
+ if (state !== 'outcome_unknown') {
351
+ if (state !== null && (TERMINAL.has(state) || state === 'queued' || state === 'claimed')) this.checkpoint.drop(entry.request);
352
+ else if (state === 'in_progress' && entry.step !== 'unknown') {
353
+ entry.step = 'unknown'; // no next holder took the slot yet: a later tick tries again (finding 38)
354
+ this.checkpoint.put(entry);
355
+ }
356
+ return;
357
+ }
358
+ const outcome = await this.observed(entry);
359
+ if (!outcome) {
420
360
  entry.step = 'unknown';
421
361
  this.checkpoint.put(entry);
422
362
  this.emit('board.reconcile.needed', 'warn', 'A request\'s outcome is unknown: the work may have happened. It stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability });
@@ -447,6 +387,10 @@ export class Responder {
447
387
  if (!this.session) await this.connect();
448
388
  await this.tick();
449
389
  this.backoff.reset();
390
+ if (this.state === 'down') { // a lost call was sent again and answered: the session held throughout
391
+ this.state = 'ready';
392
+ delete this.said['board.down'];
393
+ }
450
394
  return this.interval();
451
395
  } catch (e) {
452
396
  if (!(e instanceof BoardError) && !(e instanceof HubError)) throw e;
@@ -454,9 +398,18 @@ export class Responder {
454
398
  }
455
399
  }
456
400
 
401
+ /** Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's. */
402
+ leaseLeft() {
403
+ const renewed = this.current ? [this.current.renewedAt ?? 0] : Object.values(this.slots).filter((s) => s.state === 'held').map((s) => s.renewedAt ?? 0);
404
+ return renewed.length && this.session ? Math.min(...renewed) + LEASE_SECONDS - this.now() : 0;
405
+ }
406
+
457
407
  async fault(e) {
458
408
  const code = e.code ?? 'unreachable';
459
- const wait = this.backoff.next();
409
+ let wait = this.backoff.next();
410
+ const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
411
+ const keep = code === 'unreachable' && left > 0; // finding 38: the lease holds, so the session and the work stay
412
+ if (keep) wait = Math.max(1, Math.min(wait, left)); // and the call goes again before the lease lapses
460
413
  this.waitUntil = this.now() + wait;
461
414
  if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
462
415
  this.state = 'not-authorized';
@@ -465,13 +418,14 @@ export class Responder {
465
418
  } else if (code === 'held') {
466
419
  this.state = 'paused';
467
420
  this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
468
- } else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
421
+ } else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict'].includes(code)) {
422
+ // idempotency_conflict is a key sent with other content, a defect and not an outage (finding 35)
469
423
  this.state = 'down';
470
424
  await this.dropSession();
471
425
  this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
472
426
  } else {
473
427
  this.state = 'down';
474
- if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) await this.dropSession();
428
+ if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code) && !keep) await this.dropSession();
475
429
  this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
476
430
  }
477
431
  return wait;
@@ -480,6 +434,7 @@ export class Responder {
480
434
  async dropSession() {
481
435
  const work = this.current;
482
436
  this.current = null;
437
+ this.toResume = []; // the next session transfers them again
483
438
  if (work) {
484
439
  const { entry } = work;
485
440
  if (work.done && !work.failure && !outcomeProblems(work.outcome, !!entry.effect).length) {
@@ -511,7 +466,10 @@ export class Responder {
511
466
  }
512
467
  if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
513
468
  }
514
- if (!this.current && !this.stopping) await this.continueLapsed();
469
+ if (!this.current && !this.stopping) {
470
+ await this.continueLapsed();
471
+ await this.resumeNext();
472
+ }
515
473
  if (!this.current && !this.stopping) {
516
474
  await this.reconcilePending();
517
475
  await this.claimNext();
@@ -622,7 +580,7 @@ export class Responder {
622
580
  this.checkpoint.put(entry);
623
581
  let receipt;
624
582
  try {
625
- receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
583
+ receipt = await this.fenced('com.accept', entry, entry.keys.accept);
626
584
  } catch (e) {
627
585
  if (e.code !== 'held') throw e;
628
586
  // A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
@@ -657,16 +615,33 @@ export class Responder {
657
615
  await this.runWork(entry, new BoardRequest(message, entry.capability));
658
616
  }
659
617
 
660
- async fenced(op, entry, args) {
661
- try {
662
- return this.receipt(op, await this.call(op, args));
663
- } catch (e) {
664
- if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
665
- this.lose(entry, e.code);
666
- if (e.code === 'authority_changed') throw e;
667
- return null;
618
+ /**
619
+ * A fenced call, one at a time (finding 36): fence built, sent and receipt applied under the lock. A revision_conflict
620
+ * on com.renew or com.progress (they store nothing) reads the revision and sends once more. On a stop code the attempt
621
+ * is lost: the work stops, and it answers null (authority_changed throws after, so the responder stops claiming).
622
+ */
623
+ async fenced(op, entry, key, extra = {}, apply = null) {
624
+ let again = op === 'com.renew' || op === 'com.progress';
625
+ for (;;) {
626
+ try {
627
+ return await this.locked(async () => {
628
+ const receipt = this.receipt(op, await this.call(op, { ...extra, fence: this.fence(entry, key) }));
629
+ apply?.(receipt);
630
+ return receipt;
631
+ });
632
+ } catch (e) {
633
+ if (e.code === 'revision_conflict' && again) {
634
+ again = false;
635
+ await this.locked(() => this.readState(entry));
636
+ continue;
637
+ }
638
+ if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
639
+ this.lose(entry, e.code);
640
+ if (e.code === 'authority_changed') throw e;
641
+ return null;
642
+ }
643
+ throw e;
668
644
  }
669
- throw e;
670
645
  }
671
646
  }
672
647
 
@@ -688,8 +663,7 @@ export class Responder {
688
663
 
689
664
  async renew(work) {
690
665
  if (this.now() - (work.renewedAt ?? 0) < RENEW_SECONDS - 0.5) return;
691
- const receipt = await this.fenced('com.renew', work.entry, { fence: this.fence(work.entry, this.key('renew')) });
692
- if (!receipt) {
666
+ if (!(await this.fenced('com.renew', work.entry, this.key('renew')))) {
693
667
  this.current = null;
694
668
  return;
695
669
  }
@@ -698,9 +672,8 @@ export class Responder {
698
672
  }
699
673
 
700
674
  async progress(entry, note) {
701
- const args = { fence: this.fence(entry, this.key('progress')) };
702
- if (note !== undefined && note !== null) args.note = { text: clean(note) || 'Working.' };
703
- if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
675
+ const extra = note !== undefined && note !== null ? { note: { text: clean(note) || 'Working.' } } : {};
676
+ if (!(await this.fenced('com.progress', entry, this.key('progress'), extra))) throw new Stopped();
704
677
  }
705
678
 
706
679
  async effectBegin(entry, summary, ctx) {
@@ -708,13 +681,18 @@ export class Responder {
708
681
  entry.step = 'effect-beginning';
709
682
  entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
710
683
  this.checkpoint.put(entry);
711
- const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
684
+ const extra = summary ? { summary: clean(summary) } : {};
685
+ const begun = (receipt) => {
686
+ entry.revision = receipt.request.revision;
687
+ entry.step = 'in-progress';
688
+ this.checkpoint.put(entry);
689
+ };
712
690
  let receipt = null;
713
691
  let lost = 0;
714
692
  let delay = this.holdDelay;
715
693
  for (;;) { // a lost answer: the same key again tells whether it was stored
716
694
  try {
717
- receipt = await this.fenced('com.effect_begin', entry, args);
695
+ receipt = await this.fenced('com.effect_begin', entry, entry.keys.effect_begin, extra, begun);
718
696
  break;
719
697
  } catch (e) {
720
698
  if (e.code === 'held') {
@@ -742,9 +720,6 @@ export class Responder {
742
720
  }
743
721
  }
744
722
  if (!receipt) throw new Stopped();
745
- entry.revision = receipt.request.revision;
746
- entry.step = 'in-progress';
747
- this.checkpoint.put(entry);
748
723
  }
749
724
 
750
725
  async finish(work) {
@@ -755,37 +730,70 @@ export class Responder {
755
730
  }
756
731
  let { outcome } = work;
757
732
  if (work.failure) {
758
- if (entry.effect || work.failure instanceof Stopped) {
733
+ if (entry.effect) {
734
+ await this.settleLive(entry, 'handler-failed');
735
+ return;
736
+ }
737
+ if (work.failure instanceof Stopped) {
759
738
  this.current = null;
760
- if (entry.effect) this.lose(entry, 'handler-failed');
761
739
  return;
762
740
  }
763
741
  outcome = { state: 'failed_known', observed: true, summary: `The service could not do this (${work.failure?.name ?? 'Error'}).` };
764
742
  }
765
743
  const problems = outcomeProblems(outcome, !!entry.effect);
766
744
  if (problems.length) {
767
- this.current = null;
768
- if (entry.effect) this.lose(entry, problems.some((p) => p.includes('observation_required')) ? 'observation_required' : 'outcome');
745
+ this.emit('board.outcome.invalid', 'warn', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
746
+ if (entry.effect) await this.settleLive(entry, problems.some((p) => p.includes('observation_required')) ? 'observation_required' : 'outcome');
769
747
  else {
770
748
  entry.outcome = { state: 'failed_known', observed: true, summary: 'The service answered in a form it cannot send.' };
771
749
  await this.complete(entry);
772
750
  }
773
- this.emit('board.outcome.invalid', 'warn', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
774
751
  return;
775
752
  }
776
753
  entry.outcome = { ...outcome, observed: outcome.observed ?? !entry.effect };
777
754
  await this.complete(entry);
778
755
  }
779
756
 
757
+ /**
758
+ * Finding 37: the handler threw after beginEffect, or answered unobserved, while the attempt is live. Only an observed
759
+ * result settles a started effect (project-comms.md, Request lifecycle), so observe(entry) is completed under the live
760
+ * fence. With nothing observed, com.responder_replace on this responder's own slot is the contract's lost-result
761
+ * operation ("an in_progress one becomes outcome_unknown ... when the next holder takes the slot", Slots end with the
762
+ * session); com.reconcile settles it once something is observed.
763
+ */
764
+ async settleLive(entry, code) {
765
+ const lost = !!this.current?.ctx.stopped; // the attempt is not live: nothing to settle
766
+ const state = lost ? null : await this.readState(entry);
767
+ if (state === 'accepted') { // effect_begin was never stored, so nothing began: a known failure
768
+ entry.effect = false;
769
+ entry.outcome = { state: 'failed_known', observed: true, summary: `The service could not do this (${code}).` };
770
+ await this.complete(entry);
771
+ return;
772
+ }
773
+ const outcome = state === 'in_progress' ? await this.observed(entry) : null;
774
+ if (outcome) {
775
+ entry.outcome = { ...Object.fromEntries(['state', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]])), observed: true };
776
+ await this.complete(entry);
777
+ return;
778
+ }
779
+ this.current = null;
780
+ this.lose(entry, code);
781
+ const slot = this.slots[entry.capability] ?? {};
782
+ if (state === 'in_progress' && slot.state === 'held') await this.replace(entry.capability, [], slot.generation ?? 0);
783
+ }
784
+
785
+ replyExtra(entry) {
786
+ const { reply } = entry.outcome;
787
+ return { body: { text: reply.text, ...(reply.format ? { format: reply.format } : {}) }, ...(reply.artifacts?.length ? { artifacts: reply.artifacts } : {}) };
788
+ }
789
+
780
790
  async complete(entry) {
781
791
  const { outcome } = entry;
782
- const { reply } = outcome;
783
- if (reply && !entry.replied) {
792
+ if (outcome.reply && !entry.replied) {
784
793
  entry.keys.reply ??= this.key('reply');
785
794
  entry.step = 'replying';
786
795
  this.checkpoint.put(entry);
787
- const args = { fence: this.fence(entry, entry.keys.reply), body: { text: reply.text, ...(reply.format ? { format: reply.format } : {}) }, ...(reply.artifacts?.length ? { artifacts: reply.artifacts } : {}) };
788
- if (!(await this.fenced('com.reply', entry, args))) {
796
+ if (!(await this.fenced('com.reply', entry, entry.keys.reply, this.replyExtra(entry)))) {
789
797
  this.current = null;
790
798
  return;
791
799
  }
@@ -796,7 +804,7 @@ export class Responder {
796
804
  entry.step = 'completing';
797
805
  this.checkpoint.put(entry);
798
806
  const sent = Object.fromEntries(['state', 'observed', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
799
- const receipt = await this.fenced('com.complete', entry, { fence: this.fence(entry, entry.keys.complete), outcome: sent });
807
+ const receipt = await this.fenced('com.complete', entry, entry.keys.complete, { outcome: sent });
800
808
  this.current = null;
801
809
  if (!receipt) return;
802
810
  this.checkpoint.drop(entry.request);
@@ -5,7 +5,7 @@ import fs from 'node:fs';
5
5
  import { sameIdentifier } from './fabric-identifiers.mjs';
6
6
 
7
7
  // A service agent answers a Project's requests on Fabric's project board (fabric-project-comms/0.1; contract DEC-0022,
8
- // DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149; FAA-12b, FAA-40). The twin of fabric_board.py — the same steps, the
8
+ // DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149; FAA-12b, FAA-40). The twin of fabric_board.py: the same steps, the
9
9
  // same checkpoint file, the same answers; Node built-ins only. fabric-hub.mjs finds the hub.
10
10
  //
11
11
  // enroll (capability) → claim → read the message → accept (the exact digest) → [effectBegin] → [progress] →
@@ -14,12 +14,12 @@ import { sameIdentifier } from './fabric-identifiers.mjs';
14
14
  // Responder runs that loop inside the service process: one tick every 15 s ± 5 s, claiming when idle and renewing the
15
15
  // 60 s lease (at about 20 s) while work runs. Before it advances a step it writes and fsyncs a checkpoint; a restart
16
16
  // reconciles work whose outcome is unknown (com.reconcile) and takes accepted work back by com.responder_replace with a
17
- // transfer (DEC-0078's receipt member transferred; a Fabric that does not answer it leaves the work held — never claimed
17
+ // transfer (DEC-0078's receipt member transferred; a Fabric that does not answer it leaves the work held, never claimed
18
18
  // back). It stops acting on fenced, lease_expired and authority_changed.
19
19
  //
20
20
  // THE REQUEST BODY IS DATA. admit(request) is the service's own input gate, required: null admits, a sentence refuses
21
- // (the request is accepted and completed failed_known with it). Whatever the handler does with an admitted body — and
22
- // the checks it needs before a model, a tool or a spend sees it — is the service's own responsibility.
21
+ // (the request is accepted and completed failed_known with it). Whatever the handler does with an admitted body (and
22
+ // the checks it needs before a model, a tool or a spend sees it) is the service's own responsibility.
23
23
  //
24
24
  // A board session is served the respond scope only (RESPOND_TOOLS). com.submit, com.cancel, com.read_ack and
25
25
  // com.directory are never sent through one; findProject checks a directory page a Project session read (FAC-SEM-107).
@@ -63,7 +63,7 @@ const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2
63
63
 
64
64
  const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
65
65
 
66
- /** A board refusal (a contract refusal code) or a kit fault — see fabric_board.py's BoardError for the kit's codes. */
66
+ /** A board refusal (a contract refusal code) or a kit fault; see fabric_board.py's BoardError for the kit's codes. */
67
67
  export class BoardError extends Error {
68
68
  constructor(code, message, retryable = false, op = undefined, data = {}) {
69
69
  super(message);
@@ -1,6 +1,6 @@
1
1
  // The project board connector, Node: the facade (fabric-project-comms/0.1; the twin of fabric_board.py).
2
- // Since 0.40.0 (FAA-65) it is three modules: fabric-board-shapes, fabric-board-session and fabric-board-fixture. This file
3
- // re-exports everything it exported at 0.39.1. Node 20+, no dependency.
2
+ // Since 0.40.0 (FAA-65) it is layered modules: fabric-board-shapes, fabric-board-link (since 0.41.0), fabric-board-session and
3
+ // fabric-board-fixture. This file re-exports everything it exported at 0.39.1. Node 20+, no dependency.
4
4
  export {
5
5
  COMMS_PROTOCOL,
6
6
  LEASE_SECONDS,
@@ -22,6 +22,7 @@ const NONCE32 = /^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/;
22
22
  const ASSERTION_MEMBERS = 'alg,authenticatorData,clientDataJSON,credentialId,format,sig';
23
23
 
24
24
  import { isObject, isString, objects as list, show, jsonEqual } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
25
+ import { clockMs as clockOf } from './fabric-time.mjs'; // the core's (FAA-65)
25
26
  const matches = (pattern, value) => isString(value) && pattern.test(value);
26
27
  const REGISTER = new Map([
27
28
  [ENROLL_TOKEN_TYPE, { fields: ENROLL_TOKEN_FIELDS, check: (f) => matches(ESTATE_SCOPE, f.scope) && matches(SHA256, f.genesis) && f.purpose === ENROLL_PURPOSE
@@ -46,9 +47,12 @@ function statementFields(token) {
46
47
  return signingStatement(ENROLL_TOKEN_TYPE, fields) ? fields : undefined;
47
48
  }
48
49
 
49
- /** The reference verifier of an enroll-token/1 under trust {record, genesis, at: Date}; the first failure answers. */
50
+ /**
51
+ * The reference verifier of an enroll-token/1 under trust {record, genesis, at}; `at` is a Date or epoch seconds, as
52
+ * verifyEstateRecord and the Python twin take it. The first failure answers.
53
+ */
50
54
  export function verifyEnrollToken(token, trust) {
51
- const at = isObject(trust) && trust.at instanceof Date ? trust.at.getTime() : Number.NaN;
55
+ const at = isObject(trust) ? clockOf(trust.at) : Number.NaN;
52
56
  if (!Number.isFinite(at)) return { ok: false, problem: 'no-clock' };
53
57
  const fields = statementFields(token);
54
58
  const record = trust.record;
@@ -138,7 +138,8 @@ function keyFor(jwks, kid) {
138
138
  if ((jwk.use !== undefined && jwk.use !== 'sig') || (jwk.alg !== undefined && jwk.alg !== 'EdDSA')) return { problem: 'key-not-for-signing' };
139
139
  if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isNonEmptyString(jwk.x) || jwk.d !== undefined) return { problem: 'unusable-key' };
140
140
  const raw = Buffer.from(jwk.x, 'base64url');
141
- if (raw.length !== 32) return { problem: 'unusable-key' };
141
+ // D4: exactly the 43 characters that write 32 bytes (Buffer reads padding, `+` and stray characters leniently).
142
+ if (raw.length !== 32 || raw.toString('base64url') !== jwk.x) return { problem: 'unusable-key' };
142
143
  if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // before any verification (DEC-0045, CT-29)
143
144
  try {
144
145
  return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }), raw, x: jwk.x };
@@ -12,7 +12,7 @@ import { ROUTES } from './fabric-usage.mjs';
12
12
  // document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
13
13
  export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
14
14
 
15
- export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.1';
15
+ export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.2';
16
16
 
17
17
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
18
18