@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 +79 -0
- package/README.md +9 -3
- 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 +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs
CHANGED
|
@@ -1,1237 +1,42 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'authority_changed']));
|
|
44
|
-
const REQUEST_STATES = ['queued', 'claimed', 'accepted', 'in_progress', 'completed', 'failed_known', 'cancelled', 'expired', 'outcome_unknown'];
|
|
45
|
-
const EFFECT_STATES = ['not_started', 'started', 'succeeded_observed', 'failed_observed', 'unknown'];
|
|
46
|
-
const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
|
|
47
|
-
/**
|
|
48
|
-
* DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
|
|
49
|
-
* `held`: it settles, reports on or keeps alive started work, cancels or reads. The contract's HOLD_SCOPES.
|
|
50
|
-
*/
|
|
51
|
-
export const HOLD_SCOPES = Object.freeze({ 'com.submit': ['thread'], 'com.reply': ['thread'], 'com.enroll': ['project', 'slot'],
|
|
52
|
-
'com.responder_replace': ['project', 'slot'], 'com.claim': ['thread', 'project', 'slot'], 'com.accept': ['thread', 'project', 'slot'],
|
|
53
|
-
'com.effect_begin': ['thread', 'project', 'slot'] });
|
|
54
|
-
/** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
|
|
55
|
-
export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
56
|
-
const OPAQUE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
57
|
-
const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
|
|
58
|
-
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
|
|
59
|
-
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
60
|
-
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
|
|
61
|
-
const URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
|
|
62
|
-
|
|
63
|
-
/** A board refusal (a contract refusal code) or a kit fault — see fabric_board.py's BoardError for the kit's codes. */
|
|
64
|
-
export class BoardError extends Error {
|
|
65
|
-
constructor(code, message, retryable = false, op = undefined, data = {}) {
|
|
66
|
-
super(message);
|
|
67
|
-
this.name = 'BoardError';
|
|
68
|
-
Object.assign(this, { code, retryable, op, data });
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Thrown inside a handler (from ctx.progress / ctx.beginEffect) when the attempt is no longer this responder's. */
|
|
73
|
-
export class Stopped extends Error {
|
|
74
|
-
constructor() {
|
|
75
|
-
super('the attempt is no longer this responder\'s');
|
|
76
|
-
this.name = 'Stopped';
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// --- the contract's digest -----------------------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
const LONE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
83
|
-
|
|
84
|
-
/** RFC 8785 as the board takes it: keys by UTF-16 code unit, integers only (|n| ≤ 2^53−1), no lone surrogate. */
|
|
85
|
-
export function commsCanonical(value) {
|
|
86
|
-
if (value === null || typeof value === 'boolean') return JSON.stringify(value);
|
|
87
|
-
if (typeof value === 'number') {
|
|
88
|
-
if (!Number.isSafeInteger(value)) throw new RangeError('canonical JSON admits integers within ±(2^53−1) only');
|
|
89
|
-
return String(value === 0 ? 0 : value);
|
|
90
|
-
}
|
|
91
|
-
if (typeof value === 'string') {
|
|
92
|
-
if (LONE.test(value)) throw new RangeError('canonical JSON refuses a lone surrogate');
|
|
93
|
-
return JSON.stringify(value);
|
|
94
|
-
}
|
|
95
|
-
if (Array.isArray(value)) return `[${value.map(commsCanonical).join(',')}]`;
|
|
96
|
-
if (typeof value === 'object') {
|
|
97
|
-
const keys = Object.keys(value);
|
|
98
|
-
for (const k of keys) commsCanonical(k);
|
|
99
|
-
return `{${keys.sort().map((k) => {
|
|
100
|
-
if (value[k] === undefined) throw new RangeError('canonical JSON has no undefined');
|
|
101
|
-
return `${commsCanonical(k)}:${commsCanonical(value[k])}`;
|
|
102
|
-
}).join(',')}}`;
|
|
103
|
-
}
|
|
104
|
-
throw new RangeError(`canonical JSON has no ${typeof value}`);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** sha256:<hex> over `fabric-project-comms/0.1 NUL <operation> NUL <canonical JSON>` (the contract's commsDigest). */
|
|
108
|
-
export function commsDigest(operation, value) {
|
|
109
|
-
if (typeof operation !== 'string' || operation === '' || operation.includes('\u0000')) throw new RangeError('an operation is a non-empty name without NUL');
|
|
110
|
-
return `sha256:${crypto.createHash('sha256').update(`${COMMS_PROTOCOL}\u0000${operation}\u0000${commsCanonical(value)}`, 'utf8').digest('hex')}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// --- closed shapes ------------------------------------------------------------------------------------------------
|
|
114
|
-
|
|
115
|
-
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
116
|
-
const isInt = (v, min) => Number.isSafeInteger(v) && v >= min;
|
|
117
|
-
const str = (v, re) => typeof v === 'string' && re.test(v);
|
|
118
|
-
|
|
119
|
-
function closed(value, at, required, allowed, problems) {
|
|
120
|
-
if (!isObj(value)) {
|
|
121
|
-
problems.push(`${at} is an object`);
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
for (const k of required) if (!(k in value)) problems.push(`${at}/${k} is required`);
|
|
125
|
-
for (const k of Object.keys(value)) if (!allowed.includes(k)) problems.push(`${at}/${k} is not a member the contract names`);
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function attemptProblems(a, at, problems) {
|
|
130
|
-
const keys = ['id', 'generation', 'digest', 'leaseExpiresAt'];
|
|
131
|
-
if (!closed(a, at, keys, keys, problems)) return;
|
|
132
|
-
if (!str(a.id, OPAQUE)) problems.push(`${at}/id is a board id`);
|
|
133
|
-
if (!isInt(a.generation, 1)) problems.push(`${at}/generation is an integer ≥ 1`);
|
|
134
|
-
if (!str(a.digest, SHA256)) problems.push(`${at}/digest is sha256:<hex>`);
|
|
135
|
-
if (!str(a.leaseExpiresAt, TIMESTAMP)) problems.push(`${at}/leaseExpiresAt is a timestamp`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** comms-receipt at the pin, closed at every level; transferred answers com.responder_replace only, each request once. */
|
|
139
|
-
export function receiptProblems(value, op) {
|
|
140
|
-
const problems = [];
|
|
141
|
-
const allowed = ['ok', 'repeated', 'message', 'thread', 'at', 'request', 'slot', 'attempt', 'transferred'];
|
|
142
|
-
if (!closed(value, '', ['ok', 'repeated'], allowed, problems)) return problems;
|
|
143
|
-
if (value.ok !== true) problems.push('/ok is true');
|
|
144
|
-
if (typeof value.repeated !== 'boolean') problems.push('/repeated is a boolean');
|
|
145
|
-
for (const k of ['message', 'thread']) if (k in value && !str(value[k], OPAQUE)) problems.push(`/${k} is a board id`);
|
|
146
|
-
if ('at' in value && !str(value.at, TIMESTAMP)) problems.push('/at is a timestamp');
|
|
147
|
-
if ('request' in value) {
|
|
148
|
-
const r = value.request;
|
|
149
|
-
const keys = ['id', 'state', 'effect', 'revision'];
|
|
150
|
-
if (closed(r, '/request', keys, [...keys, 'settledBy'], problems)) {
|
|
151
|
-
if (!str(r.id, OPAQUE)) problems.push('/request/id is a board id');
|
|
152
|
-
if (!REQUEST_STATES.includes(r.state)) problems.push('/request/state is a request state');
|
|
153
|
-
if (!EFFECT_STATES.includes(r.effect)) problems.push('/request/effect is an effect state');
|
|
154
|
-
if (!isInt(r.revision, 1)) problems.push('/request/revision is an integer ≥ 1');
|
|
155
|
-
if ('settledBy' in r) {
|
|
156
|
-
// DEC-0081: present only when the estate owner settled it; absent, the responder did.
|
|
157
|
-
if (r.settledBy !== 'owner') problems.push('/request/settledBy is owner');
|
|
158
|
-
if (!['completed', 'failed_known'].includes(r.state)) problems.push('/request/settledBy names a settled request (completed or failed_known)');
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if ('slot' in value) {
|
|
163
|
-
const s = value.slot;
|
|
164
|
-
const keys = ['project', 'capability', 'generation', 'leaseExpiresAt'];
|
|
165
|
-
if (closed(s, '/slot', keys, keys, problems)) {
|
|
166
|
-
if (!str(s.project, PROJECT)) problems.push('/slot/project is a Project id');
|
|
167
|
-
if (!str(s.capability, CAPABILITY)) problems.push('/slot/capability is a capability name');
|
|
168
|
-
if (!isInt(s.generation, 1)) problems.push('/slot/generation is an integer ≥ 1');
|
|
169
|
-
if (!str(s.leaseExpiresAt, TIMESTAMP)) problems.push('/slot/leaseExpiresAt is a timestamp');
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
if ('attempt' in value) attemptProblems(value.attempt, '/attempt', problems);
|
|
173
|
-
if ('transferred' in value) {
|
|
174
|
-
const t = value.transferred;
|
|
175
|
-
if (op !== undefined && op !== 'com.responder_replace') problems.push('/transferred answers com.responder_replace only (DEC-0078)');
|
|
176
|
-
if (!Array.isArray(t) || t.length < 1 || t.length > 64) problems.push('/transferred is 1 to 64 items');
|
|
177
|
-
else {
|
|
178
|
-
const seen = new Set();
|
|
179
|
-
t.forEach((item, i) => {
|
|
180
|
-
const at = `/transferred/${i}`;
|
|
181
|
-
const keys = ['request', 'revision', 'attempt'];
|
|
182
|
-
if (!closed(item, at, keys, keys, problems)) return;
|
|
183
|
-
if (!str(item.request, OPAQUE)) problems.push(`${at}/request is a board id`);
|
|
184
|
-
else if (seen.has(item.request)) problems.push(`${at}/request names a request already listed (each request once, DEC-0078)`);
|
|
185
|
-
seen.add(item.request);
|
|
186
|
-
if (!isInt(item.revision, 1)) problems.push(`${at}/revision is an integer ≥ 1`);
|
|
187
|
-
attemptProblems(item.attempt, `${at}/attempt`, problems);
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return problems;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/** comms-refusal: {error: {code, message, retryable}}, closed, a known code, one sentence ≤ 300. */
|
|
195
|
-
export function refusalProblems(value) {
|
|
196
|
-
const problems = [];
|
|
197
|
-
if (closed(value, '', ['error'], ['error'], problems)) {
|
|
198
|
-
const e = value.error;
|
|
199
|
-
const keys = ['code', 'message', 'retryable'];
|
|
200
|
-
if (closed(e, '/error', keys, keys, problems)) {
|
|
201
|
-
if (!REFUSAL_CODES.has(e.code)) problems.push('/error/code is a refusal code the contract names');
|
|
202
|
-
if (typeof e.message !== 'string' || e.message.length < 1 || e.message.length > 300) problems.push('/error/message is 1 to 300 characters');
|
|
203
|
-
if (typeof e.retryable !== 'boolean') problems.push('/error/retryable is a boolean');
|
|
204
|
-
else if (e.code === 'held' && e.retryable !== true) problems.push('/error/retryable is true for held: the operator lifts a hold (DEC-0079)');
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
return problems;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** FAC-SEM-107 (DEC-0077), the contract's commsDirectory: compared exactly as written; never throws. */
|
|
211
|
-
export function directoryFindings(page) {
|
|
212
|
-
const findings = [];
|
|
213
|
-
const projects = isObj(page) && Array.isArray(page.projects) ? page.projects : [];
|
|
214
|
-
const seen = new Set();
|
|
215
|
-
projects.forEach((project, p) => {
|
|
216
|
-
if (!isObj(project)) return;
|
|
217
|
-
if (typeof project.id === 'string') {
|
|
218
|
-
if (seen.has(project.id)) findings.push({ code: 'FAC-SEM-107', instancePath: `/projects/${p}/id`, message: `a directory page lists each Project once: ${project.id} is listed again` });
|
|
219
|
-
seen.add(project.id);
|
|
220
|
-
}
|
|
221
|
-
const names = new Set();
|
|
222
|
-
(Array.isArray(project.capabilities) ? project.capabilities : []).forEach((entry, c) => {
|
|
223
|
-
if (!isObj(entry) || typeof entry.capability !== 'string') return;
|
|
224
|
-
if (names.has(entry.capability)) findings.push({ code: 'FAC-SEM-107', instancePath: `/projects/${p}/capabilities/${c}/capability`, message: `a Project lists each capability once: ${entry.capability} is listed again` });
|
|
225
|
-
names.add(entry.capability);
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
return findings;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** comms-directory, closed at every level, then FAC-SEM-107. */
|
|
232
|
-
export function directoryProblems(page) {
|
|
233
|
-
const problems = [];
|
|
234
|
-
const top = ['protocol', 'snapshot', 'projects', 'cursor'];
|
|
235
|
-
if (!closed(page, '', top, top, problems)) return problems;
|
|
236
|
-
if (page.protocol !== COMMS_PROTOCOL) problems.push(`/protocol is ${COMMS_PROTOCOL}`);
|
|
237
|
-
if (closed(page.snapshot, '/snapshot', ['readAt'], ['readAt', 'stale'], problems)) {
|
|
238
|
-
if (!str(page.snapshot.readAt, TIMESTAMP)) problems.push('/snapshot/readAt is a timestamp');
|
|
239
|
-
if ('stale' in page.snapshot && typeof page.snapshot.stale !== 'boolean') problems.push('/snapshot/stale is a boolean');
|
|
240
|
-
}
|
|
241
|
-
if (page.cursor !== null && !str(page.cursor, OPAQUE)) problems.push('/cursor is a board id or null');
|
|
242
|
-
if (!Array.isArray(page.projects) || page.projects.length > 100) return [...problems, '/projects is at most 100 Projects'];
|
|
243
|
-
page.projects.forEach((project, p) => {
|
|
244
|
-
const at = `/projects/${p}`;
|
|
245
|
-
if (!closed(project, at, ['id', 'name', 'capabilities'], ['id', 'name', 'capabilities'], problems)) return;
|
|
246
|
-
if (!str(project.id, PROJECT)) problems.push(`${at}/id is a Project id`);
|
|
247
|
-
if (typeof project.name !== 'string' || project.name.length < 1 || project.name.length > 120) problems.push(`${at}/name is 1 to 120 characters`);
|
|
248
|
-
if (!Array.isArray(project.capabilities) || project.capabilities.length > 64) {
|
|
249
|
-
problems.push(`${at}/capabilities is at most 64 entries`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
project.capabilities.forEach((entry, c) => {
|
|
253
|
-
const cat = `${at}/capabilities/${c}`;
|
|
254
|
-
if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder', 'held'], problems)) return;
|
|
255
|
-
if (!str(entry.capability, CAPABILITY)) problems.push(`${cat}/capability is a capability name`);
|
|
256
|
-
if (!['active', 'stale', 'absent'].includes(entry.responder)) problems.push(`${cat}/responder is active, stale or absent`);
|
|
257
|
-
if ('held' in entry && typeof entry.held !== 'boolean') problems.push(`${cat}/held is a boolean (DEC-0079)`);
|
|
258
|
-
});
|
|
259
|
-
});
|
|
260
|
-
return [...problems, ...directoryFindings(page).map((f) => `${f.instancePath} ${f.message}`)];
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* DEC-0079, the contract's holdRefuses: true when a board refuses `tool` on `target` with `held` under `holds`. `target`
|
|
265
|
-
* is what the board resolved ({thread, project, capability, fenced, repeat}); `holds` is {threads, projects, slots:
|
|
266
|
-
* [{project, capability}]}. Ids compare as written; an inherited or mistyped member counts as absent; never throws.
|
|
267
|
-
*/
|
|
268
|
-
export function holdRefuses(tool, target, holds) {
|
|
269
|
-
const own = (value, key) => (isObj(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
270
|
-
const scopes = typeof tool === 'string' && Object.hasOwn(HOLD_SCOPES, tool) ? HOLD_SCOPES[tool] : undefined;
|
|
271
|
-
if (!scopes || !isObj(target)) return false;
|
|
272
|
-
if (own(target, 'repeat') === true) return false;
|
|
273
|
-
if (tool === 'com.reply' && own(target, 'fenced') === true) return false;
|
|
274
|
-
const text = (v) => (typeof v === 'string' ? v : undefined);
|
|
275
|
-
const strings = (v) => new Set(Array.isArray(v) ? v.filter((item) => typeof item === 'string') : []);
|
|
276
|
-
const thread = text(own(target, 'thread'));
|
|
277
|
-
const project = text(own(target, 'project'));
|
|
278
|
-
const capability = text(own(target, 'capability'));
|
|
279
|
-
if (scopes.includes('thread') && thread !== undefined && strings(own(holds, 'threads')).has(thread)) return true;
|
|
280
|
-
if (scopes.includes('project') && project !== undefined && strings(own(holds, 'projects')).has(project)) return true;
|
|
281
|
-
const slots = own(holds, 'slots');
|
|
282
|
-
if (scopes.includes('slot') && project !== undefined && capability !== undefined && Array.isArray(slots)) {
|
|
283
|
-
return slots.some((slot) => own(slot, 'project') === project && own(slot, 'capability') === capability);
|
|
284
|
-
}
|
|
285
|
-
return false;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/** Look a Project up in one com.directory page a Project session read; the page is checked first (FAC-SEM-107). */
|
|
289
|
-
export function findProject(page, projectId, capability) {
|
|
290
|
-
const problems = directoryProblems(page);
|
|
291
|
-
if (problems.length) throw new BoardError('directory-invalid', `The directory page cannot be read: ${problems.slice(0, 3).join('; ')}.`);
|
|
292
|
-
const entry = page.projects.find((p) => p.id === projectId);
|
|
293
|
-
if (!entry) return null;
|
|
294
|
-
return capability === undefined || entry.capabilities.some((c) => c.capability === capability) ? entry : null;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// --- evidence -----------------------------------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
/** One artifact reference {id, contentHash, label?}; the hash computed here from `content` or the file at `file`. */
|
|
300
|
-
export function artifactRef(uri, { content, file, label } = {}) {
|
|
301
|
-
if ((content === undefined) === (file === undefined)) throw new BoardError('evidence', 'Give the artifact\'s bytes or its path, one of them.');
|
|
302
|
-
const hash = crypto.createHash('sha256').update(content !== undefined ? content : fs.readFileSync(file)).digest('hex');
|
|
303
|
-
const ref = { id: uri, contentHash: `sha256:${hash}`, ...(label !== undefined ? { label } : {}) };
|
|
304
|
-
const problems = evidenceProblems([ref]);
|
|
305
|
-
if (problems.length) throw new BoardError('evidence', problems.join('; '));
|
|
306
|
-
return ref;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/** At most 8 references, closed, and no artifact twice: two ids with one normal form (DEC-0076) name one artifact. */
|
|
310
|
-
export function evidenceProblems(evidence) {
|
|
311
|
-
if (!Array.isArray(evidence) || evidence.length > EVIDENCE_MAX) return [`evidence is a list of at most ${EVIDENCE_MAX} artifact references`];
|
|
312
|
-
const problems = [];
|
|
313
|
-
evidence.forEach((ref, i) => {
|
|
314
|
-
const at = `evidence/${i}`;
|
|
315
|
-
if (!closed(ref, at, ['id', 'contentHash'], ['id', 'contentHash', 'label'], problems)) return;
|
|
316
|
-
if (!str(ref.id, URI)) problems.push(`${at}/id is a URI`);
|
|
317
|
-
if (!str(ref.contentHash, SHA256)) problems.push(`${at}/contentHash is sha256:<hex>`);
|
|
318
|
-
if ('label' in ref && (typeof ref.label !== 'string' || ref.label.length > 120)) problems.push(`${at}/label is at most 120 characters`);
|
|
319
|
-
for (let j = 0; j < i; j += 1) if (isObj(evidence[j]) && sameIdentifier(evidence[j].id, ref.id)) problems.push(`${at}/id names the artifact evidence/${j} already names`);
|
|
320
|
-
});
|
|
321
|
-
return problems;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/** comms-complete's outcome plus the kit's optional reply {text, format?, artifacts?}. */
|
|
325
|
-
export function outcomeProblems(outcome, effectBegan) {
|
|
326
|
-
const problems = [];
|
|
327
|
-
if (!closed(outcome, 'outcome', ['state'], ['state', 'observed', 'summary', 'evidence', 'reply'], problems)) return problems;
|
|
328
|
-
if (!['completed', 'failed_known'].includes(outcome.state)) problems.push('outcome/state is completed or failed_known');
|
|
329
|
-
const observed = 'observed' in outcome ? outcome.observed : !effectBegan;
|
|
330
|
-
if (typeof observed !== 'boolean') problems.push('outcome/observed is a boolean');
|
|
331
|
-
else if (effectBegan && observed !== true) problems.push('observation_required: after an effect began only an observed result settles the request');
|
|
332
|
-
if ('summary' in outcome && (typeof outcome.summary !== 'string' || outcome.summary.length < 1 || outcome.summary.length > SUMMARY_MAX)) problems.push(`outcome/summary is 1 to ${SUMMARY_MAX} characters`);
|
|
333
|
-
if ('evidence' in outcome) problems.push(...evidenceProblems(outcome.evidence));
|
|
334
|
-
if ('reply' in outcome && closed(outcome.reply, 'outcome/reply', ['text'], ['text', 'format', 'artifacts'], problems)) {
|
|
335
|
-
const { text } = outcome.reply;
|
|
336
|
-
if (typeof text !== 'string' || !text || Buffer.byteLength(text, 'utf8') > 65536) problems.push('outcome/reply/text is 1 to 65,536 UTF-8 bytes');
|
|
337
|
-
if ('format' in outcome.reply && !['text/plain', 'text/markdown'].includes(outcome.reply.format)) problems.push('outcome/reply/format is text/plain or text/markdown');
|
|
338
|
-
if ('artifacts' in outcome.reply) problems.push(...evidenceProblems(outcome.reply.artifacts).map((p) => p.replace('evidence', 'outcome/reply/artifacts')));
|
|
339
|
-
}
|
|
340
|
-
return problems;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// --- the board session ----------------------------------------------------------------------------------------------
|
|
344
|
-
|
|
345
|
-
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
346
|
-
export class BoardSession {
|
|
347
|
-
constructor(grant, { timeout = 15000, post = postMcp } = {}) {
|
|
348
|
-
Object.assign(this, { grant, timeout, post });
|
|
349
|
-
this.mcpSession = null;
|
|
350
|
-
this.protocolVersion = MCP_PROTOCOL_VERSION;
|
|
351
|
-
this.rpc = 0;
|
|
352
|
-
this.queue = Promise.resolve();
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
#serial(fn) {
|
|
356
|
-
const run = this.queue.then(fn, fn);
|
|
357
|
-
this.queue = run.catch(() => {});
|
|
358
|
-
return run;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
async #send(body, method = 'POST') {
|
|
362
|
-
try {
|
|
363
|
-
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
364
|
-
} catch (e) {
|
|
365
|
-
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);
|
|
366
|
-
throw e;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
open() {
|
|
371
|
-
return this.#serial(async () => {
|
|
372
|
-
this.rpc += 1;
|
|
373
|
-
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' } } });
|
|
374
|
-
if (answer.status === 401) throw new BoardError('session-ended', 'Fabric refused the board session\'s bearer; join again.', true);
|
|
375
|
-
const sid = answer.headers['mcp-session-id'];
|
|
376
|
-
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);
|
|
377
|
-
this.mcpSession = String(sid);
|
|
378
|
-
if (typeof answer.body.result.protocolVersion === 'string') this.protocolVersion = answer.body.result.protocolVersion;
|
|
379
|
-
await this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
call(tool, args) {
|
|
384
|
-
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));
|
|
385
|
-
return this.#serial(async () => {
|
|
386
|
-
if (!this.mcpSession) throw new BoardError('session-ended', 'The board session is not open.', true, tool);
|
|
387
|
-
this.rpc += 1;
|
|
388
|
-
const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } });
|
|
389
|
-
if (answer.status === 401) throw new BoardError('session-ended', 'Fabric ended the board session (it left, was revoked or expired); join again.', true, tool);
|
|
390
|
-
let value;
|
|
391
|
-
try {
|
|
392
|
-
value = toolAnswer(answer, tool);
|
|
393
|
-
} catch (e) {
|
|
394
|
-
throw new BoardError(e.retryable ? 'unreachable' : 'receipt-shape', e.message, e.retryable, tool);
|
|
395
|
-
}
|
|
396
|
-
if ('error' in value) {
|
|
397
|
-
if (refusalProblems(value).length) throw new BoardError('receipt-shape', `Fabric refused ${tool} in a form the contract does not name.`, false, tool);
|
|
398
|
-
throw new BoardError(value.error.code, value.error.message, value.error.retryable, tool);
|
|
399
|
-
}
|
|
400
|
-
return value;
|
|
401
|
-
});
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
close() {
|
|
405
|
-
return this.#serial(async () => {
|
|
406
|
-
if (!this.mcpSession) return;
|
|
407
|
-
try {
|
|
408
|
-
await this.#send({}, 'DELETE');
|
|
409
|
-
} catch {
|
|
410
|
-
// best effort: the lease lapses in 60 s anyway
|
|
411
|
-
}
|
|
412
|
-
this.mcpSession = null;
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// --- the checkpoint ------------------------------------------------------------------------------------------------
|
|
418
|
-
|
|
419
|
-
/** The responder's journal (0600 JSON, the same file fabric_board.py writes), fsynced before the step it announces. */
|
|
420
|
-
export class Checkpoint {
|
|
421
|
-
constructor(file) {
|
|
422
|
-
this.file = file;
|
|
423
|
-
this.entries = this.#load();
|
|
424
|
-
}
|
|
425
|
-
#load() {
|
|
426
|
-
let text;
|
|
427
|
-
try {
|
|
428
|
-
text = fs.readFileSync(this.file, 'utf8');
|
|
429
|
-
} catch (e) {
|
|
430
|
-
if (e.code === 'ENOENT') return {};
|
|
431
|
-
throw new BoardError('checkpoint', `The board checkpoint ${this.file} cannot be read (${e.code}); fix or move it before answering requests.`);
|
|
432
|
-
}
|
|
433
|
-
let doc;
|
|
434
|
-
try {
|
|
435
|
-
doc = JSON.parse(text);
|
|
436
|
-
} catch {
|
|
437
|
-
doc = null;
|
|
438
|
-
}
|
|
439
|
-
if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
|
|
440
|
-
return doc.requests;
|
|
441
|
-
}
|
|
442
|
-
#write(entries) {
|
|
443
|
-
try {
|
|
444
|
-
ensurePrivateDir(path.dirname(this.file));
|
|
445
|
-
const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
|
|
446
|
-
atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
|
|
447
|
-
} catch (e) {
|
|
448
|
-
throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
put(entry) {
|
|
452
|
-
const entries = { ...this.entries, [entry.request]: JSON.parse(JSON.stringify(entry)) };
|
|
453
|
-
this.#write(entries);
|
|
454
|
-
this.entries = entries;
|
|
455
|
-
}
|
|
456
|
-
drop(requestId) {
|
|
457
|
-
if (!(requestId in this.entries)) return;
|
|
458
|
-
const { [requestId]: _gone, ...entries } = this.entries;
|
|
459
|
-
this.#write(entries);
|
|
460
|
-
this.entries = entries;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// --- the request a handler sees ------------------------------------------------------------------------------------
|
|
465
|
-
|
|
466
|
-
/** A claimed request as data: never an instruction. */
|
|
467
|
-
export class BoardRequest {
|
|
468
|
-
constructor(message, capability) {
|
|
469
|
-
const req = isObj(message.request) ? message.request : {};
|
|
470
|
-
const body = isObj(message.body) ? message.body : {};
|
|
471
|
-
this.requestId = req.id;
|
|
472
|
-
this.messageId = message.id;
|
|
473
|
-
this.capability = capability;
|
|
474
|
-
this.body = typeof body.text === 'string' ? body.text : null;
|
|
475
|
-
this.bodyFormat = body.format ?? 'text/plain';
|
|
476
|
-
this.artifacts = Array.isArray(message.artifacts) ? [...message.artifacts] : [];
|
|
477
|
-
this.sender = isObj(message.sender) ? message.sender : {};
|
|
478
|
-
this.deadline = req.deadline;
|
|
479
|
-
this.thread = message.thread;
|
|
480
|
-
}
|
|
481
|
-
toJSON() {
|
|
482
|
-
return { requestId: this.requestId, capability: this.capability }; // never the body
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/** What a handler may do while it works: progress(note), beginEffect(summary) before it spends or acts, and `stopped`. */
|
|
487
|
-
export class WorkContext {
|
|
488
|
-
#responder;
|
|
489
|
-
#entry;
|
|
490
|
-
constructor(responder, entry) {
|
|
491
|
-
this.#responder = responder;
|
|
492
|
-
this.#entry = entry;
|
|
493
|
-
this.stopped = false;
|
|
494
|
-
}
|
|
495
|
-
async progress(note) {
|
|
496
|
-
if (this.stopped) throw new Stopped();
|
|
497
|
-
await this.#responder.progress(this.#entry, note, this);
|
|
498
|
-
}
|
|
499
|
-
async beginEffect(summary) {
|
|
500
|
-
if (this.stopped) throw new Stopped();
|
|
501
|
-
await this.#responder.effectBegin(this.#entry, summary, this);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// --- the responder --------------------------------------------------------------------------------------------------
|
|
506
|
-
|
|
507
|
-
const clean = (text, max = SUMMARY_MAX) => String(text).split(/\s+/).filter(Boolean).join(' ').slice(0, max);
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* One service's responder for one Project and its capabilities, one request at a time. handler(request, ctx) answers
|
|
511
|
-
* the outcome (may be async); admit(request) is the input gate; observe(entry) reads the world after a restart;
|
|
512
|
-
* emit(kind, level, text, extra) receives events. run({signal}) ticks until aborted; step() is one tick.
|
|
513
|
-
*/
|
|
514
|
-
export class Responder {
|
|
515
|
-
constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
|
|
516
|
-
now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
|
|
517
|
-
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.');
|
|
518
|
-
const caps = [...(capabilities ?? [])];
|
|
519
|
-
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}.');
|
|
520
|
-
Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
|
|
521
|
-
this.project = normalProject(projectId);
|
|
522
|
-
this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
|
|
523
|
-
this.checkpoint = new Checkpoint(checkpointFile);
|
|
524
|
-
this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
|
|
525
|
-
this.backoff = new Backoff({ rand });
|
|
526
|
-
this.session = null;
|
|
527
|
-
this.epoch = null;
|
|
528
|
-
this.slots = {};
|
|
529
|
-
this.current = null;
|
|
530
|
-
this.state = 'idle';
|
|
531
|
-
this.waitUntil = 0;
|
|
532
|
-
this.stopping = false;
|
|
533
|
-
this.claimKeys = {};
|
|
534
|
-
this.said = {};
|
|
535
|
-
this.rr = 0;
|
|
536
|
-
this.reconcileAt = 0;
|
|
537
|
-
/** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
|
|
538
|
-
this.holdDelay = POLL_SECONDS;
|
|
539
|
-
this.onHold = new Set();
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
// -- events --
|
|
543
|
-
emit(kind, level, text, { notify = false, episode, ...value } = {}) {
|
|
544
|
-
if (episode !== undefined) {
|
|
545
|
-
if (this.said[kind] === episode) return;
|
|
546
|
-
this.said[kind] = episode;
|
|
547
|
-
}
|
|
548
|
-
let data = { schema: EVENT_SCHEMA, value: Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null)) };
|
|
549
|
-
if (feedDataProblems(data).length) data = undefined; // FAC-SEM-101: drop the data, keep the sentence
|
|
550
|
-
if (!this.emitTo) return;
|
|
551
|
-
try {
|
|
552
|
-
this.emitTo(kind, level, text, { notify, ...(data ? { data } : {}) });
|
|
553
|
-
} catch {
|
|
554
|
-
// an event that cannot be written never stops the responder
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// -- keys and fences --
|
|
559
|
-
key(op) {
|
|
560
|
-
return `${this.service}.${op}.${crypto.randomBytes(8).toString('hex')}`;
|
|
561
|
-
}
|
|
562
|
-
idem(key) {
|
|
563
|
-
if (this.epoch === null) throw new BoardError('no-epoch', 'Fabric told this board session no idempotency epoch.', false, 'com.status');
|
|
564
|
-
return { epoch: this.epoch, key };
|
|
565
|
-
}
|
|
566
|
-
fence(entry, key) {
|
|
567
|
-
return { request: entry.request, generation: entry.generation, attempt: entry.attempt, digest: entry.digest, expectedRevision: entry.revision, idempotency: this.idem(key) };
|
|
568
|
-
}
|
|
569
|
-
receipt(op, answer) {
|
|
570
|
-
const problems = receiptProblems(answer, op);
|
|
571
|
-
if (problems.length) throw new BoardError('receipt-shape', `Fabric answered ${op} with a receipt the contract does not name (${problems[0]}).`, false, op);
|
|
572
|
-
return answer;
|
|
573
|
-
}
|
|
574
|
-
call(op, args) {
|
|
575
|
-
if (!this.session) return Promise.reject(new BoardError('session-ended', 'No board session.', true, op));
|
|
576
|
-
return this.session.call(op, args);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// -- connecting --
|
|
580
|
-
async status() {
|
|
581
|
-
const status = await this.call('com.status', {});
|
|
582
|
-
const verdict = negotiateRevision(status.protocol, acceptedRevisions('fabric-project-comms'));
|
|
583
|
-
if (verdict.error) throw new BoardError('schema-unsupported', `Fabric's board speaks ${status.protocol}, which this kit does not read.`, false, 'com.status', verdict.error.data);
|
|
584
|
-
if (status.board?.state === 'unavailable') throw new BoardError('not_available', 'Fabric\'s board cannot be read right now.', true, 'com.status');
|
|
585
|
-
const epoch = status.idempotency?.epoch;
|
|
586
|
-
// Fail closed: a guessed epoch would store keys under a namespace the board never named.
|
|
587
|
-
if (!isInt(epoch, 1)) throw new BoardError('no-epoch', 'Fabric told this board session no idempotency epoch (Fabric before its board-session fix); the responder waits rather than guess one.', false, 'com.status');
|
|
588
|
-
this.epoch = epoch;
|
|
589
|
-
return status;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async connect() {
|
|
593
|
-
this.state = 'connecting';
|
|
594
|
-
const grant = await this.hub.joinBoard(this.project, this.credentialFile);
|
|
595
|
-
const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
|
|
596
|
-
const session = this.sessionFactory(grant);
|
|
597
|
-
await session.open();
|
|
598
|
-
this.session = session;
|
|
599
|
-
const status = await this.status();
|
|
600
|
-
if (missing.length) {
|
|
601
|
-
this.emit('board.access.needed', 'warn', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Agents & access).`, { notify: true, episode: missing.join(','), capabilities: missing });
|
|
602
|
-
}
|
|
603
|
-
this.slots = {};
|
|
604
|
-
await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
|
|
605
|
-
this.state = 'ready';
|
|
606
|
-
for (const kind of ['board.down', 'board.blocked', ...(missing.length ? [] : ['board.access.needed'])]) delete this.said[kind];
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
async enroll(capability) {
|
|
610
|
-
const args = { idempotency: this.idem(this.key('enroll')), capability };
|
|
611
|
-
let receipt;
|
|
612
|
-
try {
|
|
613
|
-
receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
|
|
614
|
-
} catch (e) {
|
|
615
|
-
if (e.code === 'held') {
|
|
616
|
-
this.pause(capability);
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
if (e.code === 'fenced') {
|
|
620
|
-
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
621
|
-
this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
throw e;
|
|
625
|
-
}
|
|
626
|
-
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
627
|
-
delete this.said['board.slot.contested'];
|
|
628
|
-
this.lifted(capability);
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
/**
|
|
632
|
-
* The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and never act
|
|
633
|
-
* around the hold. Accepted work in progress continues; only new work waits.
|
|
634
|
-
*/
|
|
635
|
-
pause(capability) {
|
|
636
|
-
const slot = this.slots[capability] ?? {};
|
|
637
|
-
const pauses = slot.state === 'paused' ? (slot.pauses ?? 0) + 1 : 1;
|
|
638
|
-
const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (pauses - 1));
|
|
639
|
-
this.slots[capability] = { state: 'paused', pauses, retryAt: this.now() + wait, renewedAt: this.now() };
|
|
640
|
-
if (!this.onHold.has(capability)) { // once per hold, per capability
|
|
641
|
-
this.onHold.add(capability);
|
|
642
|
-
this.emit('board.slot.held', 'info', `The operator holds new work for ${capability} on this Project; the service waits and asks again later. Work already accepted continues.`, { capability });
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
lifted(capability) {
|
|
647
|
-
if (!this.onHold.delete(capability)) return;
|
|
648
|
-
this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
// -- restart: reconcile and transfer --
|
|
652
|
-
async readState(entry) {
|
|
653
|
-
const message = await this.call('com.get', { message: entry.message });
|
|
654
|
-
const req = isObj(message) ? message.request : null;
|
|
655
|
-
if (!isObj(req) || req.id !== entry.request || !REQUEST_STATES.includes(req.state)) return null;
|
|
656
|
-
entry.revision = req.revision ?? entry.revision;
|
|
657
|
-
return req.state;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
async recover(status, capabilities) {
|
|
661
|
-
const transfer = {};
|
|
662
|
-
const reconcile = [];
|
|
663
|
-
for (const entry of Object.values(this.checkpoint.entries)) {
|
|
664
|
-
if (!capabilities.includes(entry.capability)) continue;
|
|
665
|
-
let state;
|
|
666
|
-
try {
|
|
667
|
-
state = await this.readState(entry);
|
|
668
|
-
} catch (e) {
|
|
669
|
-
if (e.code === 'not_authorized') {
|
|
670
|
-
this.checkpoint.drop(entry.request);
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
throw e;
|
|
674
|
-
}
|
|
675
|
-
if (state === null || TERMINAL.has(state) || state === 'queued' || state === 'claimed') this.checkpoint.drop(entry.request);
|
|
676
|
-
else if (state === 'accepted') (transfer[entry.capability] ??= []).push(entry);
|
|
677
|
-
else reconcile.push(entry);
|
|
678
|
-
}
|
|
679
|
-
const responders = (status.responders ?? []).filter(isObj);
|
|
680
|
-
const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
|
|
681
|
-
const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
|
|
682
|
-
for (const capability of capabilities) {
|
|
683
|
-
if (onHold.has(capability)) {
|
|
684
|
-
for (const entry of transfer[capability] ?? []) {
|
|
685
|
-
entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
|
|
686
|
-
this.checkpoint.put(entry);
|
|
687
|
-
}
|
|
688
|
-
this.pause(capability);
|
|
689
|
-
} else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
|
|
690
|
-
else await this.enroll(capability);
|
|
691
|
-
}
|
|
692
|
-
for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
async replace(capability, entries, generation) {
|
|
696
|
-
const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
|
|
697
|
-
transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
|
|
698
|
-
let receipt;
|
|
699
|
-
try {
|
|
700
|
-
receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
|
|
701
|
-
} catch (e) {
|
|
702
|
-
if (e.code !== 'held') throw e;
|
|
703
|
-
for (const entry of entries) {
|
|
704
|
-
entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
|
|
705
|
-
this.checkpoint.put(entry);
|
|
706
|
-
}
|
|
707
|
-
this.pause(capability);
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
711
|
-
this.lifted(capability);
|
|
712
|
-
const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
|
|
713
|
-
for (const entry of entries) {
|
|
714
|
-
const item = moved.get(entry.request);
|
|
715
|
-
if (!item) {
|
|
716
|
-
// A board pinned before DEC-0078 hands back no attempt. Never claim it back: it stays held.
|
|
717
|
-
entry.step = 'held';
|
|
718
|
-
this.checkpoint.put(entry);
|
|
719
|
-
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 });
|
|
720
|
-
continue;
|
|
721
|
-
}
|
|
722
|
-
Object.assign(entry, { generation: item.attempt.generation, attempt: item.attempt.id, digest: item.attempt.digest, revision: item.revision,
|
|
723
|
-
step: ['accepting', 'held', 'lapsed'].includes(entry.step) ? 'accepted' : entry.step });
|
|
724
|
-
this.checkpoint.put(entry);
|
|
725
|
-
if (!this.current) await this.resume(entry);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
async reconcile(entry) {
|
|
730
|
-
const state = await this.readState(entry);
|
|
731
|
-
if (state !== 'outcome_unknown') {
|
|
732
|
-
if (state !== null && (TERMINAL.has(state) || state === 'queued' || state === 'claimed')) this.checkpoint.drop(entry.request);
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
let outcome = isObj(entry.outcome) && entry.outcome.observed === true ? entry.outcome : null;
|
|
736
|
-
if (!outcome && this.observe) {
|
|
737
|
-
try {
|
|
738
|
-
outcome = await this.observe({ ...entry });
|
|
739
|
-
} catch {
|
|
740
|
-
outcome = null;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (!isObj(outcome) || outcome.observed !== true || outcomeProblems({ ...outcome, observed: true }, true).length) {
|
|
744
|
-
entry.step = 'unknown';
|
|
745
|
-
this.checkpoint.put(entry);
|
|
746
|
-
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 });
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
const sent = Object.fromEntries(['state', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
|
|
750
|
-
sent.observed = true;
|
|
751
|
-
entry.keys ??= {};
|
|
752
|
-
entry.keys.reconcile ??= this.key('reconcile');
|
|
753
|
-
entry.step = 'reconciling';
|
|
754
|
-
this.checkpoint.put(entry);
|
|
755
|
-
this.receipt('com.reconcile', await this.call('com.reconcile', { idempotency: this.idem(entry.keys.reconcile), request: entry.request, digest: entry.digest, outcome: sent }));
|
|
756
|
-
this.checkpoint.drop(entry.request);
|
|
757
|
-
this.emit('board.request.reconciled', 'info', 'A request whose outcome was unknown was settled from what this service observed.', { request: entry.request, capability: entry.capability, state: sent.state });
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// -- one tick --
|
|
761
|
-
interval() {
|
|
762
|
-
return Math.max(1, POLL_SECONDS + POLL_JITTER_SECONDS * (2 * this.rand() - 1));
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/** One tick; answers the seconds to wait before the next. */
|
|
766
|
-
async step() {
|
|
767
|
-
if (this.stopping) return DRAIN_SECONDS;
|
|
768
|
-
const now = this.now();
|
|
769
|
-
if (now < this.waitUntil) return this.waitUntil - now;
|
|
770
|
-
try {
|
|
771
|
-
if (!this.session) await this.connect();
|
|
772
|
-
await this.tick();
|
|
773
|
-
this.backoff.reset();
|
|
774
|
-
return this.interval();
|
|
775
|
-
} catch (e) {
|
|
776
|
-
if (!(e instanceof BoardError) && !(e instanceof HubError)) throw e;
|
|
777
|
-
return this.fault(e);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
async fault(e) {
|
|
782
|
-
const code = e.code ?? 'unreachable';
|
|
783
|
-
const wait = this.backoff.next();
|
|
784
|
-
this.waitUntil = this.now() + wait;
|
|
785
|
-
if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
|
|
786
|
-
this.state = 'not-authorized';
|
|
787
|
-
await this.dropSession();
|
|
788
|
-
this.emit('board.access.needed', 'warn', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Agents & access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
|
|
789
|
-
} else if (code === 'held') {
|
|
790
|
-
this.state = 'paused';
|
|
791
|
-
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 });
|
|
792
|
-
} else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
|
|
793
|
-
this.state = 'down';
|
|
794
|
-
await this.dropSession();
|
|
795
|
-
this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
|
|
796
|
-
} else {
|
|
797
|
-
this.state = 'down';
|
|
798
|
-
if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) await this.dropSession();
|
|
799
|
-
this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
|
|
800
|
-
}
|
|
801
|
-
return wait;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
|
-
async dropSession() {
|
|
805
|
-
const work = this.current;
|
|
806
|
-
this.current = null;
|
|
807
|
-
if (work) {
|
|
808
|
-
const { entry } = work;
|
|
809
|
-
if (work.done && !work.failure && !outcomeProblems(work.outcome, !!entry.effect).length) {
|
|
810
|
-
entry.outcome = { ...work.outcome, observed: work.outcome.observed ?? !entry.effect };
|
|
811
|
-
try {
|
|
812
|
-
this.checkpoint.put(entry);
|
|
813
|
-
} catch {
|
|
814
|
-
// the next start reruns it
|
|
815
|
-
}
|
|
816
|
-
} else work.ctx.stopped = true;
|
|
817
|
-
}
|
|
818
|
-
const session = this.session;
|
|
819
|
-
this.session = null;
|
|
820
|
-
if (session) await session.close();
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
async tick() {
|
|
824
|
-
const work = this.current;
|
|
825
|
-
if (work) {
|
|
826
|
-
if (work.done) await this.finish(work);
|
|
827
|
-
else await this.renew(work);
|
|
828
|
-
}
|
|
829
|
-
const heldByWork = work && this.current ? work.entry.capability : null;
|
|
830
|
-
for (const [capability, slot] of Object.entries(this.slots)) {
|
|
831
|
-
if (slot.state === 'paused') {
|
|
832
|
-
// still held: a longer pause; lifted: the slot is ours again
|
|
833
|
-
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
|
|
834
|
-
continue;
|
|
835
|
-
}
|
|
836
|
-
if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
|
|
837
|
-
}
|
|
838
|
-
if (!this.current && !this.stopping) await this.continueLapsed();
|
|
839
|
-
if (!this.current && !this.stopping) {
|
|
840
|
-
await this.reconcilePending();
|
|
841
|
-
await this.claimNext();
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
async continueLapsed() {
|
|
846
|
-
const lapsed = {};
|
|
847
|
-
for (const entry of Object.values(this.checkpoint.entries)) {
|
|
848
|
-
if (entry.step === 'lapsed' && this.slots[entry.capability]?.state === 'held') (lapsed[entry.capability] ??= []).push(JSON.parse(JSON.stringify(entry)));
|
|
849
|
-
}
|
|
850
|
-
for (const [capability, entries] of Object.entries(lapsed)) {
|
|
851
|
-
const live = [];
|
|
852
|
-
for (const e of entries) {
|
|
853
|
-
if (await this.readState(e) === 'accepted') live.push(e);
|
|
854
|
-
else this.checkpoint.drop(e.request);
|
|
855
|
-
}
|
|
856
|
-
if (live.length) await this.replace(capability, live, this.slots[capability].generation ?? 0);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
async reconcilePending() {
|
|
861
|
-
if (this.now() < this.reconcileAt) return;
|
|
862
|
-
this.reconcileAt = this.now() + 300;
|
|
863
|
-
for (const entry of Object.values(this.checkpoint.entries)) {
|
|
864
|
-
if (entry.step === 'unknown' && this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
async claimNext() {
|
|
869
|
-
const held = this.capabilities.filter((c) => this.slots[c]?.state === 'held');
|
|
870
|
-
for (let i = 0; i < held.length; i += 1) {
|
|
871
|
-
const capability = held[(this.rr + i) % held.length];
|
|
872
|
-
this.claimKeys[capability] ??= this.key('claim');
|
|
873
|
-
let answer;
|
|
874
|
-
try {
|
|
875
|
-
answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
|
|
876
|
-
} catch (e) {
|
|
877
|
-
if (e.code === 'lease_expired') {
|
|
878
|
-
await this.enroll(capability);
|
|
879
|
-
continue;
|
|
880
|
-
}
|
|
881
|
-
if (e.code === 'idempotency_window_expired' || e.code === 'capacity_exceeded') {
|
|
882
|
-
delete this.claimKeys[capability];
|
|
883
|
-
await this.status();
|
|
884
|
-
continue;
|
|
885
|
-
}
|
|
886
|
-
if (e.code === 'fenced') {
|
|
887
|
-
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
if (e.code === 'held') {
|
|
891
|
-
this.pause(capability);
|
|
892
|
-
continue;
|
|
893
|
-
}
|
|
894
|
-
throw e;
|
|
895
|
-
}
|
|
896
|
-
if (!('request' in answer)) continue; // nothing claimable; the same key polls again
|
|
897
|
-
delete this.claimKeys[capability];
|
|
898
|
-
this.rr += 1;
|
|
899
|
-
await this.start(capability, answer);
|
|
900
|
-
return;
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// -- a claimed request --
|
|
905
|
-
async start(capability, receipt) {
|
|
906
|
-
if (!receipt.attempt || !receipt.message) throw new BoardError('receipt-shape', 'com.claim answered a request without its attempt or message.', false, 'com.claim');
|
|
907
|
-
const { attempt } = receipt;
|
|
908
|
-
const entry = { request: receipt.request.id, message: receipt.message, capability, generation: attempt.generation, attempt: attempt.id, digest: attempt.digest,
|
|
909
|
-
revision: receipt.request.revision, step: 'claimed', keys: {}, effect: false };
|
|
910
|
-
this.checkpoint.put(entry);
|
|
911
|
-
const message = await this.call('com.get', { message: entry.message });
|
|
912
|
-
const req = isObj(message) ? message.request : null;
|
|
913
|
-
if (message.digest !== entry.digest) {
|
|
914
|
-
this.checkpoint.drop(entry.request);
|
|
915
|
-
this.emit('board.request.skipped', 'warn', 'A claimed request was not accepted: the message read back carries another digest than the claim. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'digest-mismatch' });
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
if (!isObj(req) || req.id !== entry.request || req.capability !== capability || req.target !== this.project) {
|
|
919
|
-
this.checkpoint.drop(entry.request);
|
|
920
|
-
this.emit('board.request.skipped', 'warn', 'A claimed request was not accepted: it is not addressed to this Project and capability. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'not-addressed' });
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
const request = new BoardRequest(message, capability);
|
|
924
|
-
let reason;
|
|
925
|
-
if (message.redacted === true || typeof request.body !== 'string') reason = 'The request\'s body was redacted, so there is nothing to act on.';
|
|
926
|
-
else {
|
|
927
|
-
try {
|
|
928
|
-
reason = (await this.admit(request)) ?? null;
|
|
929
|
-
} catch {
|
|
930
|
-
reason = 'The service\'s input gate could not read this request.';
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
if (!(await this.accept(entry))) return;
|
|
934
|
-
if (reason !== null && reason !== undefined) {
|
|
935
|
-
entry.outcome = { state: 'failed_known', observed: true, summary: clean(`Refused by this service's input gate: ${reason}`) };
|
|
936
|
-
await this.complete(entry);
|
|
937
|
-
this.emit('board.request.refused', 'info', 'A request was refused by the service\'s input gate and completed as failed.', { request: entry.request, capability });
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
await this.runWork(entry, request);
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
async accept(entry) {
|
|
944
|
-
entry.keys.accept ??= this.key('accept');
|
|
945
|
-
entry.step = 'accepting';
|
|
946
|
-
this.checkpoint.put(entry);
|
|
947
|
-
let receipt;
|
|
948
|
-
try {
|
|
949
|
-
receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
|
|
950
|
-
} catch (e) {
|
|
951
|
-
if (e.code !== 'held') throw e;
|
|
952
|
-
// A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
|
|
953
|
-
this.checkpoint.drop(entry.request);
|
|
954
|
-
this.emit('board.request.waiting', 'info', 'A claimed request was not accepted: the operator holds it. Nothing was done; it waits on the board.', { request: entry.request, capability: entry.capability });
|
|
955
|
-
this.pause(entry.capability);
|
|
956
|
-
return false;
|
|
957
|
-
}
|
|
958
|
-
if (!receipt) return false; // the claim was lost before it was accepted; nothing was done
|
|
959
|
-
entry.revision = receipt.request.revision;
|
|
960
|
-
entry.step = 'accepted';
|
|
961
|
-
this.checkpoint.put(entry);
|
|
962
|
-
return true;
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
async runWork(entry, request) {
|
|
966
|
-
const ctx = new WorkContext(this, entry);
|
|
967
|
-
const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
|
|
968
|
-
this.current = work;
|
|
969
|
-
work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
|
|
970
|
-
// A handler that answers at once is finished in this tick; a longer one is renewed by the next ticks.
|
|
971
|
-
await Promise.race([work.promise, new Promise((resolve) => setImmediate(resolve))]);
|
|
972
|
-
if (work.done && this.current === work) await this.finish(work);
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
async resume(entry) {
|
|
976
|
-
if (isObj(entry.outcome)) {
|
|
977
|
-
await this.complete(entry);
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
const message = await this.call('com.get', { message: entry.message });
|
|
981
|
-
await this.runWork(entry, new BoardRequest(message, entry.capability));
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
async fenced(op, entry, args) {
|
|
985
|
-
try {
|
|
986
|
-
return this.receipt(op, await this.call(op, args));
|
|
987
|
-
} catch (e) {
|
|
988
|
-
if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
|
|
989
|
-
this.lose(entry, e.code);
|
|
990
|
-
if (e.code === 'authority_changed') throw e;
|
|
991
|
-
return null;
|
|
992
|
-
}
|
|
993
|
-
throw e;
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
lose(entry, code) {
|
|
998
|
-
if (this.current && this.current.entry === entry) this.current.ctx.stopped = true;
|
|
999
|
-
if (entry.effect) {
|
|
1000
|
-
entry.step = 'unknown';
|
|
1001
|
-
this.checkpoint.put(entry);
|
|
1002
|
-
this.emit('board.reconcile.needed', 'warn', 'The attempt was lost after its effect began; the outcome stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability, reason: code });
|
|
1003
|
-
} else if (code === 'lease_expired' && !['claimed', 'accepting'].includes(entry.step)) {
|
|
1004
|
-
entry.step = 'lapsed';
|
|
1005
|
-
this.checkpoint.put(entry);
|
|
1006
|
-
this.emit('board.attempt.lost', 'warn', 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.', { request: entry.request, capability: entry.capability, reason: code });
|
|
1007
|
-
} else {
|
|
1008
|
-
this.checkpoint.drop(entry.request);
|
|
1009
|
-
this.emit('board.attempt.lost', 'warn', `The board took a request back from this service (${code}); it stopped working on it, and nothing had been done outside the service.`, { request: entry.request, capability: entry.capability, reason: code });
|
|
1010
|
-
}
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
async renew(work) {
|
|
1014
|
-
if (this.now() - (work.renewedAt ?? 0) < RENEW_SECONDS - 0.5) return;
|
|
1015
|
-
const receipt = await this.fenced('com.renew', work.entry, { fence: this.fence(work.entry, this.key('renew')) });
|
|
1016
|
-
if (!receipt) {
|
|
1017
|
-
this.current = null;
|
|
1018
|
-
return;
|
|
1019
|
-
}
|
|
1020
|
-
work.renewedAt = this.now();
|
|
1021
|
-
(this.slots[work.entry.capability] ??= {}).renewedAt = this.now();
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
async progress(entry, note) {
|
|
1025
|
-
const args = { fence: this.fence(entry, this.key('progress')) };
|
|
1026
|
-
if (note !== undefined && note !== null) args.note = { text: clean(note) || 'Working.' };
|
|
1027
|
-
if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
async effectBegin(entry, summary, ctx) {
|
|
1031
|
-
entry.keys.effect_begin ??= this.key('effect');
|
|
1032
|
-
entry.step = 'effect-beginning';
|
|
1033
|
-
entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
|
|
1034
|
-
this.checkpoint.put(entry);
|
|
1035
|
-
const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
|
|
1036
|
-
let receipt = null;
|
|
1037
|
-
let lost = 0;
|
|
1038
|
-
let delay = this.holdDelay;
|
|
1039
|
-
for (;;) { // a lost answer: the same key again tells whether it was stored
|
|
1040
|
-
try {
|
|
1041
|
-
receipt = await this.fenced('com.effect_begin', entry, args);
|
|
1042
|
-
break;
|
|
1043
|
-
} catch (e) {
|
|
1044
|
-
if (e.code === 'held') {
|
|
1045
|
-
// The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick renews it;
|
|
1046
|
-
// com.renew is never held), and ask again with the same key.
|
|
1047
|
-
entry.effect = false;
|
|
1048
|
-
entry.step = 'effect-held';
|
|
1049
|
-
this.checkpoint.put(entry);
|
|
1050
|
-
this.emit('board.request.waiting', 'info', 'The operator holds this request before its effect: the service waits and does nothing outside it until the hold is lifted.', { episode: entry.request, request: entry.request, capability: entry.capability });
|
|
1051
|
-
const until = Date.now() + delay * 1000;
|
|
1052
|
-
while (Date.now() < until) {
|
|
1053
|
-
if (ctx?.stopped) throw new Stopped();
|
|
1054
|
-
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, until - Date.now()))));
|
|
1055
|
-
}
|
|
1056
|
-
if (ctx?.stopped) throw new Stopped();
|
|
1057
|
-
delay = Math.min(HOLD_RETRY_MAX_SECONDS, delay * 2);
|
|
1058
|
-
entry.effect = true;
|
|
1059
|
-
entry.step = 'effect-beginning';
|
|
1060
|
-
this.checkpoint.put(entry);
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
if (e.code !== 'unreachable' || lost === 2) throw new Stopped();
|
|
1064
|
-
lost += 1;
|
|
1065
|
-
await new Promise((resolve) => setTimeout(resolve, lost * 1000));
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
if (!receipt) throw new Stopped();
|
|
1069
|
-
entry.revision = receipt.request.revision;
|
|
1070
|
-
entry.step = 'in-progress';
|
|
1071
|
-
this.checkpoint.put(entry);
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
async finish(work) {
|
|
1075
|
-
const { entry } = work;
|
|
1076
|
-
if (work.ctx.stopped && !(entry.request in this.checkpoint.entries)) {
|
|
1077
|
-
this.current = null;
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
1080
|
-
let { outcome } = work;
|
|
1081
|
-
if (work.failure) {
|
|
1082
|
-
if (entry.effect || work.failure instanceof Stopped) {
|
|
1083
|
-
this.current = null;
|
|
1084
|
-
if (entry.effect) this.lose(entry, 'handler-failed');
|
|
1085
|
-
return;
|
|
1086
|
-
}
|
|
1087
|
-
outcome = { state: 'failed_known', observed: true, summary: `The service could not do this (${work.failure?.name ?? 'Error'}).` };
|
|
1088
|
-
}
|
|
1089
|
-
const problems = outcomeProblems(outcome, !!entry.effect);
|
|
1090
|
-
if (problems.length) {
|
|
1091
|
-
this.current = null;
|
|
1092
|
-
if (entry.effect) this.lose(entry, problems.some((p) => p.includes('observation_required')) ? 'observation_required' : 'outcome');
|
|
1093
|
-
else {
|
|
1094
|
-
entry.outcome = { state: 'failed_known', observed: true, summary: 'The service answered in a form it cannot send.' };
|
|
1095
|
-
await this.complete(entry);
|
|
1096
|
-
}
|
|
1097
|
-
this.emit('board.outcome.invalid', 'warn', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
entry.outcome = { ...outcome, observed: outcome.observed ?? !entry.effect };
|
|
1101
|
-
await this.complete(entry);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
async complete(entry) {
|
|
1105
|
-
const { outcome } = entry;
|
|
1106
|
-
const { reply } = outcome;
|
|
1107
|
-
if (reply && !entry.replied) {
|
|
1108
|
-
entry.keys.reply ??= this.key('reply');
|
|
1109
|
-
entry.step = 'replying';
|
|
1110
|
-
this.checkpoint.put(entry);
|
|
1111
|
-
const args = { fence: this.fence(entry, entry.keys.reply), body: { text: reply.text, ...(reply.format ? { format: reply.format } : {}) }, ...(reply.artifacts?.length ? { artifacts: reply.artifacts } : {}) };
|
|
1112
|
-
if (!(await this.fenced('com.reply', entry, args))) {
|
|
1113
|
-
this.current = null;
|
|
1114
|
-
return;
|
|
1115
|
-
}
|
|
1116
|
-
entry.replied = true;
|
|
1117
|
-
this.checkpoint.put(entry);
|
|
1118
|
-
}
|
|
1119
|
-
entry.keys.complete ??= this.key('complete');
|
|
1120
|
-
entry.step = 'completing';
|
|
1121
|
-
this.checkpoint.put(entry);
|
|
1122
|
-
const sent = Object.fromEntries(['state', 'observed', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
|
|
1123
|
-
const receipt = await this.fenced('com.complete', entry, { fence: this.fence(entry, entry.keys.complete), outcome: sent });
|
|
1124
|
-
this.current = null;
|
|
1125
|
-
if (!receipt) return;
|
|
1126
|
-
this.checkpoint.drop(entry.request);
|
|
1127
|
-
this.emit('board.request.completed', 'info', `A request was answered: ${sent.state === 'completed' ? 'completed' : 'failed, as known'}.`, { request: entry.request, capability: entry.capability, state: sent.state });
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// -- running --
|
|
1131
|
-
/** Tick until `signal` aborts, then shutdown(). */
|
|
1132
|
-
async run({ signal, drain = DRAIN_SECONDS } = {}) {
|
|
1133
|
-
while (!signal?.aborted) {
|
|
1134
|
-
const wait = await this.step();
|
|
1135
|
-
await new Promise((resolve) => {
|
|
1136
|
-
const timer = setTimeout(resolve, wait * 1000);
|
|
1137
|
-
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
1138
|
-
});
|
|
1139
|
-
}
|
|
1140
|
-
await this.shutdown(drain);
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/** Stop claiming, give running work up to `drain` seconds, then leave the session (its DELETE releases the slots). */
|
|
1144
|
-
async shutdown(drain = DRAIN_SECONDS) {
|
|
1145
|
-
this.stopping = true;
|
|
1146
|
-
const work = this.current;
|
|
1147
|
-
if (work && !work.done) {
|
|
1148
|
-
await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
|
|
1149
|
-
if (!work.done) work.ctx.stopped = true;
|
|
1150
|
-
}
|
|
1151
|
-
if (work && work.done && this.session) {
|
|
1152
|
-
try {
|
|
1153
|
-
await this.finish(work);
|
|
1154
|
-
} catch {
|
|
1155
|
-
// the checkpoint keeps it
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
if (this.session) {
|
|
1159
|
-
await this.session.close();
|
|
1160
|
-
this.session = null;
|
|
1161
|
-
}
|
|
1162
|
-
this.state = 'stopped';
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// --- a fixture conversation (check_service.py, rule board.responder) -------------------------------------------------
|
|
1167
|
-
|
|
1168
|
-
const FIXTURE_PROJECT = '3f2a6b1c-0d4e-4f5a-8b6c-7d8e9f0a1b2c';
|
|
1169
|
-
const FIXTURE_DIGEST = `sha256:${'a'.repeat(64)}`;
|
|
1170
|
-
|
|
1171
|
-
function fixtureAnswers(variant) {
|
|
1172
|
-
const lease = '2026-10-13T10:02:00Z';
|
|
1173
|
-
const status = { protocol: COMMS_PROTOCOL, board: { state: 'ready' }, project: FIXTURE_PROJECT, responders: [], transport: { telegram: 'off' }, unread: 0, idempotency: { epoch: 3 } };
|
|
1174
|
-
if (variant === 'no-epoch') delete status.idempotency;
|
|
1175
|
-
const request = { id: 'req_A1b2C3d4E5', state: 'claimed', effect: 'not_started', revision: 2 };
|
|
1176
|
-
return {
|
|
1177
|
-
'com.status': status,
|
|
1178
|
-
'com.enroll': { ok: true, repeated: false, slot: { project: FIXTURE_PROJECT, capability: 'fixture.render', generation: 2, leaseExpiresAt: lease } },
|
|
1179
|
-
'com.claim': { ok: true, repeated: false, message: 'msg_K9j8H7g6F5', request, attempt: { id: 'att_Zx9Cv8Bn7M', generation: 2, digest: FIXTURE_DIGEST, leaseExpiresAt: lease } },
|
|
1180
|
-
'com.get': { id: 'msg_K9j8H7g6F5', thread: 'thr_8Hc2kQ9xA1', kind: 'request', at: '2026-10-13T10:00:00Z', digest: FIXTURE_DIGEST,
|
|
1181
|
-
sender: { project: 'proj-requester', principal: { kind: 'agent', id: 's-1', provenance: 'trusted' } }, body: { text: 'Render the fixture.' },
|
|
1182
|
-
request: { id: 'req_A1b2C3d4E5', target: FIXTURE_PROJECT, capability: 'fixture.render', state: 'claimed', effect: 'not_started', revision: 2 } },
|
|
1183
|
-
'com.accept': variant === 'fenced' ? { error: { code: 'fenced', message: 'A newer responder holds this slot; this call was not committed.', retryable: false } }
|
|
1184
|
-
: { ok: true, repeated: false, request: { ...request, state: 'accepted', revision: 3 } },
|
|
1185
|
-
'com.complete': { ok: true, repeated: false, request: { ...request, state: 'completed', revision: 4 } },
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
/** One scripted conversation per path through this kit, with no Fabric; answers the problems found (empty: it passes). */
|
|
1190
|
-
export async function selfCheck() {
|
|
1191
|
-
const os = await import('node:os');
|
|
1192
|
-
const problems = [];
|
|
1193
|
-
for (const variant of ['whole', 'fenced', 'no-epoch']) {
|
|
1194
|
-
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fabric-board-fixture-'));
|
|
1195
|
-
try {
|
|
1196
|
-
const root = path.join(tmp, 'fabric');
|
|
1197
|
-
fs.mkdirSync(root, { mode: 0o700 });
|
|
1198
|
-
const door = path.join(root, 'hub-door-token');
|
|
1199
|
-
fs.writeFileSync(door, 'd'.repeat(43), { mode: 0o600 });
|
|
1200
|
-
fs.writeFileSync(path.join(root, 'hub.json'), JSON.stringify({ protocol: 'fabric-hub/0.1', origin: 'http://127.0.0.1:47070', mcp: '/mcp', doorTokenFile: door, pid: process.pid, startedAt: '2026-10-13T10:00:00Z' }));
|
|
1201
|
-
const cred = path.join(tmp, 'credential');
|
|
1202
|
-
fs.writeFileSync(cred, 'c'.repeat(43), { mode: 0o600 });
|
|
1203
|
-
const answers = fixtureAnswers(variant);
|
|
1204
|
-
const calls = [];
|
|
1205
|
-
const handled = [];
|
|
1206
|
-
const post = async (origin, body, bearer, { method = 'POST' } = {}) => {
|
|
1207
|
-
if (method === 'DELETE') return { status: 200, headers: {}, body: null };
|
|
1208
|
-
if (body.method === 'initialize') return { status: 200, headers: { 'mcp-session-id': 'fixture-session' }, body: { jsonrpc: '2.0', id: body.id, result: { protocolVersion: MCP_PROTOCOL_VERSION } } };
|
|
1209
|
-
if (body.method !== 'tools/call') return { status: 202, headers: {}, body: null };
|
|
1210
|
-
const tool = body.params.name;
|
|
1211
|
-
calls.push([tool, body.params.arguments]);
|
|
1212
|
-
if (tool === 'fabric.board.join') return { status: 200, headers: {}, body: { jsonrpc: '2.0', id: body.id, result: { structuredContent: { session: { bearer: 'b'.repeat(43), sessionId: 's-fixture', projectId: FIXTURE_PROJECT, capabilities: ['fixture.render'] } } } } };
|
|
1213
|
-
const value = answers[tool] ?? { error: { code: 'unsupported_capability', message: 'not in the fixture', retryable: false } };
|
|
1214
|
-
return { status: 200, headers: {}, body: { jsonrpc: '2.0', id: body.id, result: { content: [{ type: 'text', text: JSON.stringify(value) }] } } };
|
|
1215
|
-
};
|
|
1216
|
-
const { HubClient } = await import('./fabric-hub.mjs');
|
|
1217
|
-
const responder = new Responder({ hub: new HubClient(root, { post }), projectId: FIXTURE_PROJECT, capabilities: ['fixture.render'], credentialFile: cred,
|
|
1218
|
-
checkpointFile: path.join(tmp, 'checkpoint.json'), handler: (request) => { handled.push(request.body); return { state: 'completed', summary: 'Rendered.' }; },
|
|
1219
|
-
admit: () => null, now: () => 0, rand: () => 0.5, sessionFactory: (grant) => new BoardSession(grant, { post }) });
|
|
1220
|
-
await responder.step();
|
|
1221
|
-
const tools = calls.map(([t]) => t);
|
|
1222
|
-
if (tools.some((t) => !RESPOND_TOOLS.has(t) && t !== 'fabric.board.join')) problems.push(`${variant}: a tool outside the respond scope was sent`);
|
|
1223
|
-
if (variant === 'whole') {
|
|
1224
|
-
if (tools.join(',') !== 'fabric.board.join,com.status,com.enroll,com.claim,com.get,com.accept,com.complete') problems.push(`whole: the calls were ${tools.join(', ')}`);
|
|
1225
|
-
const accept = calls.find(([t]) => t === 'com.accept')?.[1]?.fence ?? {};
|
|
1226
|
-
if (accept.digest !== FIXTURE_DIGEST || accept.generation !== 2 || accept.attempt !== 'att_Zx9Cv8Bn7M') problems.push('whole: com.accept did not carry the claim\'s exact fence');
|
|
1227
|
-
if (handled.join('|') !== 'Render the fixture.') problems.push('whole: the handler did not see the request once');
|
|
1228
|
-
if (Object.keys(responder.checkpoint.entries).length) problems.push('whole: the checkpoint kept a settled request');
|
|
1229
|
-
} else if (variant === 'fenced') {
|
|
1230
|
-
if (handled.length || tools.includes('com.complete')) problems.push('fenced: the kit kept acting after com.accept answered fenced');
|
|
1231
|
-
} else if (tools.includes('com.enroll') || responder.state !== 'down') problems.push('no-epoch: the kit did not fail closed without an idempotency epoch');
|
|
1232
|
-
} finally {
|
|
1233
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
1234
|
-
}
|
|
1235
|
-
}
|
|
1236
|
-
return problems;
|
|
1237
|
-
}
|
|
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.
|
|
4
|
+
export {
|
|
5
|
+
COMMS_PROTOCOL,
|
|
6
|
+
LEASE_SECONDS,
|
|
7
|
+
SUMMARY_MAX,
|
|
8
|
+
EVIDENCE_MAX,
|
|
9
|
+
RESPOND_TOOLS,
|
|
10
|
+
REFUSAL_CODES,
|
|
11
|
+
HOLD_SCOPES,
|
|
12
|
+
BoardError,
|
|
13
|
+
Stopped,
|
|
14
|
+
commsCanonical,
|
|
15
|
+
commsDigest,
|
|
16
|
+
receiptProblems,
|
|
17
|
+
refusalProblems,
|
|
18
|
+
directoryFindings,
|
|
19
|
+
directoryProblems,
|
|
20
|
+
holdRefuses,
|
|
21
|
+
findProject,
|
|
22
|
+
artifactRef,
|
|
23
|
+
evidenceProblems,
|
|
24
|
+
outcomeProblems,
|
|
25
|
+
} from './fabric-board-shapes.mjs';
|
|
26
|
+
export {
|
|
27
|
+
RENEW_SECONDS,
|
|
28
|
+
POLL_SECONDS,
|
|
29
|
+
POLL_JITTER_SECONDS,
|
|
30
|
+
DRAIN_SECONDS,
|
|
31
|
+
EVENT_SCHEMA,
|
|
32
|
+
STOP_CODES,
|
|
33
|
+
HOLD_RETRY_MAX_SECONDS,
|
|
34
|
+
BoardSession,
|
|
35
|
+
Checkpoint,
|
|
36
|
+
BoardRequest,
|
|
37
|
+
WorkContext,
|
|
38
|
+
Responder,
|
|
39
|
+
} from './fabric-board-session.mjs';
|
|
40
|
+
export {
|
|
41
|
+
selfCheck,
|
|
42
|
+
} from './fabric-board-fixture.mjs';
|