@passioncode-ai/fabric-agent-adapter 0.35.3 → 0.36.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 +48 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +45 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +37 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +67 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +252 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +4 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +109 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +194 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +82 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +356 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +10 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/share-registration.schema.json +218 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +20 -5
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import crypto from 'node:crypto';
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
|
|
12
|
+
import { jcs } from './fabric-delegation.mjs';
|
|
12
13
|
|
|
13
14
|
export const PROTOCOL = 'fabric-interop/0.1';
|
|
14
15
|
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
|
|
@@ -94,19 +95,25 @@ export const unknownJobResult = (jobId, traceparent) =>
|
|
|
94
95
|
toolResult({ error: { code: 'unknown-job', message: `No job ${jobId} here.` } }, { isError: true, traceparent });
|
|
95
96
|
|
|
96
97
|
// --- C3.2 the result envelope --------------------------------------------------------------
|
|
97
|
-
function checkUsage(usage) {
|
|
98
|
+
function checkUsage(usage, nulls = false) {
|
|
98
99
|
if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
|
|
99
100
|
for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
|
|
100
101
|
for (const [key, value] of Object.entries(usage)) {
|
|
102
|
+
if (nulls && key === 'partial') {
|
|
103
|
+
if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
101
106
|
if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
|
|
107
|
+
if (nulls && value === null) continue;
|
|
102
108
|
const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
|
|
103
109
|
if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
|
|
104
110
|
}
|
|
111
|
+
if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
|
|
105
112
|
return { ...usage };
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
// The full result envelope (contract result.schema.json, DEC-0017): the shape a synchronous call returns.
|
|
109
|
-
export function resultEnvelope({ outcome, done, proof, scope, notVerified, output, usage, producer, artifacts = [], traceparent, id, createdAt }) {
|
|
116
|
+
export function resultEnvelope({ outcome, done, proof, scope, notVerified, output, usage, producer, artifacts = [], traceparent, id, createdAt, requestMeta }) {
|
|
110
117
|
if (!OUTCOMES.includes(outcome)) throw new InteropError(`outcome must be one of ${OUTCOMES.join(', ')}.`);
|
|
111
118
|
for (const [label, value] of [['done', done], ['proof', proof], ['notVerified', notVerified], ['artifacts', artifacts]]) {
|
|
112
119
|
if (!Array.isArray(value)) throw new InteropError(`${label} must be a list, even when empty.`);
|
|
@@ -116,12 +123,206 @@ export function resultEnvelope({ outcome, done, proof, scope, notVerified, outpu
|
|
|
116
123
|
const envelope = {
|
|
117
124
|
id: id ?? `urn:fabric:result:${crypto.randomBytes(12).toString('hex')}`, contractVersion: CONTRACT_VERSION, outcome,
|
|
118
125
|
done: [...done], proof: [...proof], scope: { ...scope }, notVerified: [...notVerified], artifacts: [...artifacts],
|
|
119
|
-
createdAt: createdAt ?? nowIso(), producer: { ...producer }, output, usage:
|
|
126
|
+
createdAt: createdAt ?? nowIso(), producer: { ...producer }, output, usage: usageToward(usage, requestMeta),
|
|
120
127
|
};
|
|
121
128
|
if (parseTraceparent(traceparent)) envelope.trace = { traceparent };
|
|
122
129
|
return envelope;
|
|
123
130
|
}
|
|
124
131
|
|
|
132
|
+
// --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
|
|
133
|
+
// #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
|
|
134
|
+
// The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
|
|
135
|
+
// fabric_interop.py. Every function here answers and never throws on malformed input.
|
|
136
|
+
|
|
137
|
+
export const QUOTA_EXHAUSTED = 'quota-exhausted';
|
|
138
|
+
export const OUTCOME_UNKNOWN = 'outcome-unknown';
|
|
139
|
+
export const LIMIT_REACHED = 'limit-reached';
|
|
140
|
+
export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
|
|
141
|
+
/** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
|
|
142
|
+
export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
|
|
143
|
+
const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
|
|
144
|
+
const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
|
|
145
|
+
const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
|
|
146
|
+
const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
|
|
147
|
+
const MAX_STATUS_MESSAGE = 500;
|
|
148
|
+
const MAX_ERROR_MESSAGE = 2000;
|
|
149
|
+
const codePoints = (value) => [...value].length;
|
|
150
|
+
const isString = (value) => typeof value === 'string';
|
|
151
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
152
|
+
const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
153
|
+
|
|
154
|
+
/** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
|
|
155
|
+
export function utcTime(value) {
|
|
156
|
+
const m = isString(value) ? UTC_INSTANT.exec(value) : null;
|
|
157
|
+
if (!m) return Number.NaN;
|
|
158
|
+
const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
|
|
159
|
+
const date = new Date(0);
|
|
160
|
+
date.setUTCFullYear(year, month - 1, day);
|
|
161
|
+
if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
|
|
162
|
+
date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
|
|
163
|
+
return date.getTime();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function quotaDataOk(data) {
|
|
167
|
+
const retryable = own(data, 'retryable');
|
|
168
|
+
if (!isObject(data) || typeof retryable !== 'boolean') return false;
|
|
169
|
+
if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
|
|
170
|
+
if (!Object.hasOwn(data, 'resetsAt')) return true;
|
|
171
|
+
return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jobOf(answer) {
|
|
175
|
+
const job = own(answer, 'job');
|
|
176
|
+
const id = own(job, 'id');
|
|
177
|
+
if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
|
|
178
|
+
return job;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function readJobPause(answer) {
|
|
182
|
+
const job = jobOf(answer);
|
|
183
|
+
const message = own(job, 'statusMessage');
|
|
184
|
+
if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
|
|
185
|
+
const match = PAUSE.exec(message);
|
|
186
|
+
if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
|
|
187
|
+
const until = match[1];
|
|
188
|
+
if (until === undefined) return { paused: true };
|
|
189
|
+
return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
|
|
193
|
+
export function jobCallerAction(answer, now) {
|
|
194
|
+
const at = utcTime(now);
|
|
195
|
+
const job = jobOf(answer);
|
|
196
|
+
if (!Number.isFinite(at) || !job) return { action: 'invalid' };
|
|
197
|
+
const status = own(job, 'status');
|
|
198
|
+
if (status === 'working') {
|
|
199
|
+
const pause = readJobPause(answer);
|
|
200
|
+
if ('malformed' in pause) return { action: 'invalid' };
|
|
201
|
+
if (!pause.paused) return { action: 'poll' };
|
|
202
|
+
if (pause.resetsAt === undefined) return { action: 'wait' };
|
|
203
|
+
return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
|
|
204
|
+
}
|
|
205
|
+
if (status === 'input_required') return { action: 'answer' };
|
|
206
|
+
if (status === 'completed') return { action: 'done' };
|
|
207
|
+
if (status === 'cancelled') return { action: 'cancelled' };
|
|
208
|
+
const error = own(job, 'error');
|
|
209
|
+
const code = own(error, 'code');
|
|
210
|
+
const message = own(error, 'message');
|
|
211
|
+
const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
|
|
212
|
+
const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
|
|
213
|
+
if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
|
|
214
|
+
if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
|
|
215
|
+
if (code === LIMIT_REACHED) return { action: 'person' };
|
|
216
|
+
if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
|
|
217
|
+
const data = own(error, 'data');
|
|
218
|
+
if (!quotaDataOk(data)) return { action: 'invalid' };
|
|
219
|
+
if (!data.retryable) return { action: 'person' };
|
|
220
|
+
return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
|
|
224
|
+
export function intentIdempotencyKey(intent) {
|
|
225
|
+
const names = ['agentId', 'capability', 'input', 'request'];
|
|
226
|
+
if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
|
|
227
|
+
const [agentId, capability, input, request] = names.map((name) => own(intent, name));
|
|
228
|
+
if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
|
|
229
|
+
if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
|
|
230
|
+
let form;
|
|
231
|
+
try {
|
|
232
|
+
form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
|
|
237
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x80;
|
|
238
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
239
|
+
const hex = bytes.toString('hex');
|
|
240
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function readsNullUsage(requestMeta) {
|
|
244
|
+
const protocols = own(requestMeta, 'protocols');
|
|
245
|
+
return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
|
|
249
|
+
export function requestMeta(traceparent) {
|
|
250
|
+
const meta = { protocols: [...PROTOCOLS_READ] };
|
|
251
|
+
if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
|
|
252
|
+
return meta;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
|
|
256
|
+
export function usageFindings(usage, at) {
|
|
257
|
+
if (!isObject(usage) || usage.partial !== false) return [];
|
|
258
|
+
const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
|
|
259
|
+
if (unknown.length === 0) return [];
|
|
260
|
+
return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
|
|
264
|
+
export function usageToward(usage, meta) {
|
|
265
|
+
if (readsNullUsage(meta)) {
|
|
266
|
+
const checked = checkUsage(usage, true);
|
|
267
|
+
if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
|
|
268
|
+
return checked;
|
|
269
|
+
}
|
|
270
|
+
if (!isObject(usage)) throw new InteropError('usage must be an object.');
|
|
271
|
+
return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function jobFindings(value) {
|
|
275
|
+
const findings = [];
|
|
276
|
+
if ('malformed' in readJobPause(value)) {
|
|
277
|
+
findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
|
|
278
|
+
}
|
|
279
|
+
const error = own(own(value, 'job'), 'error');
|
|
280
|
+
const data = own(error, 'data');
|
|
281
|
+
if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
|
|
282
|
+
findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
|
|
283
|
+
}
|
|
284
|
+
findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
|
|
285
|
+
return findings;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
289
|
+
|
|
290
|
+
/** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
|
|
291
|
+
export function jobLimitRules(kind, value) {
|
|
292
|
+
if (kind === 'interop-job') return jobFindings(value);
|
|
293
|
+
if (kind === 'usage') return usageFindings(value, '');
|
|
294
|
+
if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
|
|
295
|
+
if (kind === 'job-caller') {
|
|
296
|
+
const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
|
|
297
|
+
return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
|
|
303
|
+
export function pauseMessage({ resetsAt, text } = {}) {
|
|
304
|
+
const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
|
|
305
|
+
if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
|
|
306
|
+
throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
|
|
307
|
+
}
|
|
308
|
+
return message;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
|
|
312
|
+
export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
|
|
313
|
+
if (!isObject(job) || job.status !== 'failed') return 'same';
|
|
314
|
+
const error = own(job, 'error');
|
|
315
|
+
const code = own(error, 'code');
|
|
316
|
+
if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
|
|
317
|
+
if (code !== QUOTA_EXHAUSTED) return 'same';
|
|
318
|
+
if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
|
|
319
|
+
const data = own(error, 'data');
|
|
320
|
+
const at = utcTime(now);
|
|
321
|
+
if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
|
|
322
|
+
return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
|
|
323
|
+
}
|
|
324
|
+
// #endregion job-limits-kit-node
|
|
325
|
+
|
|
125
326
|
// --- C3.3 awaiting a choice ------------------------------------------------------------------
|
|
126
327
|
export function formRequest(message, properties, required) {
|
|
127
328
|
for (const [field, schema] of Object.entries(properties)) {
|
|
@@ -167,14 +368,40 @@ export class JobStore {
|
|
|
167
368
|
return { ...record.job };
|
|
168
369
|
}
|
|
169
370
|
|
|
170
|
-
|
|
371
|
+
// With idempotencyKey the key is kept per caller (the authenticated caller, DEC-0087): a key that already names a job
|
|
372
|
+
// starts nothing and throws, unless repeat() answers 'run'; then the key names the new job.
|
|
373
|
+
create(capability, input, { traceparent, pollIntervalMs, caller = null, idempotencyKey, now, limitHolds, approvalGranted = false } = {}) {
|
|
374
|
+
if (idempotencyKey !== undefined) {
|
|
375
|
+
const decision = this.repeat(caller, idempotencyKey, now ?? nowIso(), { limitHolds, approvalGranted });
|
|
376
|
+
if (decision.decision === 'same') throw new InteropError(`Key ${idempotencyKey} already names job ${decision.job.id}; answer with that job (JobStore.repeat).`);
|
|
377
|
+
}
|
|
171
378
|
const id = `job_${crypto.randomBytes(12).toString('hex')}`;
|
|
172
379
|
const job = { id, status: 'working' };
|
|
173
380
|
if (pollIntervalMs) job.pollIntervalMs = Math.trunc(pollIntervalMs);
|
|
174
381
|
this.save({ job, private: { capability, input, traceparent: parseTraceparent(traceparent) ? traceparent : null } });
|
|
382
|
+
if (idempotencyKey !== undefined) atomicWrite(this.keyFile(caller, idempotencyKey), `${JSON.stringify({ job: id })}\n`, 0o600);
|
|
175
383
|
return { job: { id, status: 'working' } };
|
|
176
384
|
}
|
|
177
385
|
|
|
386
|
+
keyFile(caller, key) {
|
|
387
|
+
if (typeof key !== 'string' || key.length === 0) throw new InteropError('An idempotency key is a non-empty string.');
|
|
388
|
+
const dir = path.join(this.dir, 'keys');
|
|
389
|
+
ensurePrivateDir(dir);
|
|
390
|
+
return path.join(dir, `${crypto.createHash('sha256').update(JSON.stringify([caller ?? null, key]), 'utf8').digest('hex')}.json`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// The answer to a key this caller sent before: {decision: 'new'|'same'|'run', job}.
|
|
394
|
+
repeat(caller, key, now, options = {}) {
|
|
395
|
+
let id;
|
|
396
|
+
try { id = JSON.parse(fs.readFileSync(this.keyFile(caller, key), 'utf8')).job; } catch (e) {
|
|
397
|
+
if (e.code === 'ENOENT') return { decision: 'new', job: null };
|
|
398
|
+
throw new InteropError(`Key ${key} is unreadable on disk.`);
|
|
399
|
+
}
|
|
400
|
+
let job;
|
|
401
|
+
try { job = this.get(id); } catch (e) { if (e instanceof UnknownJob) return { decision: 'new', job: null }; throw e; }
|
|
402
|
+
return { decision: repeatedKeyDecision(job, now, options), job };
|
|
403
|
+
}
|
|
404
|
+
|
|
178
405
|
get(jobId) { return { ...this.load(jobId).job }; }
|
|
179
406
|
traceparent(jobId) { return this.load(jobId).private.traceparent ?? null; }
|
|
180
407
|
|
|
@@ -216,7 +443,27 @@ export class JobStore {
|
|
|
216
443
|
return this.transition(jobId, 'completed', { result });
|
|
217
444
|
}
|
|
218
445
|
|
|
219
|
-
fail(jobId, code, message) { return this.transition(jobId, 'failed', { error: { code, message } }); }
|
|
446
|
+
fail(jobId, code, message, data) { return this.transition(jobId, 'failed', { error: data === undefined || data === null ? { code, message } : { code, message, data } }); }
|
|
447
|
+
|
|
448
|
+
// A job a limit paused and its provider will resume: working, the pause grammar, pollIntervalMs reaching the reset.
|
|
449
|
+
pause(jobId, { resetsAt, text } = {}) {
|
|
450
|
+
const statusMessage = pauseMessage({ resetsAt, text });
|
|
451
|
+
if (resetsAt !== undefined) {
|
|
452
|
+
const wait = utcTime(resetsAt) - Date.now();
|
|
453
|
+
if (wait > 0) { const record = this.load(jobId); record.job.pollIntervalMs = Math.trunc(Math.min(wait, 3600000)); this.save(record); }
|
|
454
|
+
}
|
|
455
|
+
return this.transition(jobId, 'working', { statusMessage });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// A job a limit stopped and its provider will not resume: failed, quota-exhausted, data {retryable, resetsAt?}.
|
|
459
|
+
quotaExhausted(jobId, message, { retryable, resetsAt } = {}) {
|
|
460
|
+
const data = resetsAt === undefined ? { retryable } : { retryable, resetsAt };
|
|
461
|
+
if (!quotaDataOk(data)) throw new InteropError('quota-exhausted data is {retryable: boolean, resetsAt?: UTC instant}, resetsAt only with retryable true.');
|
|
462
|
+
return this.fail(jobId, QUOTA_EXHAUSTED, message, data);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// A job whose effect may have happened: failed, outcome-unknown; its key never runs again.
|
|
466
|
+
outcomeUnknown(jobId, message, data) { return this.fail(jobId, OUTCOME_UNKNOWN, message, data); }
|
|
220
467
|
cancel(jobId) { return this.transition(jobId, 'cancelled'); }
|
|
221
468
|
}
|
|
222
469
|
// #endregion interop-kit-node
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs
CHANGED
|
@@ -367,10 +367,11 @@ function routeEvent(value) {
|
|
|
367
367
|
});
|
|
368
368
|
const expectProbes = (count) => {
|
|
369
369
|
const indexes = probes.map(indexOf);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
370
|
+
// Compared with 0…count−1 before any list is built, and the message lists at most 64 candidates, so a count from an
|
|
371
|
+
// index of 2**32 allocates nothing and never throws (contract route-rules.ts at DEC-0084, from this kit's report).
|
|
372
|
+
if (indexes.length === count && indexes.every((index, position) => index === position)) return;
|
|
373
|
+
const expected = count <= 64 ? Array.from({ length: count }, (_, index) => index).join(', ') || 'none' : `0 to ${count - 1}`;
|
|
374
|
+
wrong('/probes', `the walk must record candidates ${expected} in order; it records ${indexes.join(', ') || 'none'}`);
|
|
374
375
|
};
|
|
375
376
|
if (event.kind === 'runner-exhausted') {
|
|
376
377
|
expectProbes(list.length);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -862,12 +862,13 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
862
862
|
|
|
863
863
|
/** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
|
|
864
864
|
export const PROTOCOL_REVISIONS = new Map(Object.entries({
|
|
865
|
-
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
865
|
+
'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'enroll-token': ['1'], entitlement: ['1'], 'entitlement-key-set': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
|
|
866
866
|
'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
|
|
867
|
-
'fabric-interop': ['0.1'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
867
|
+
'fabric-interop': ['0.1', '0.2'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
|
|
868
868
|
'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
|
|
869
869
|
'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
|
|
870
|
-
memory: ['0.1'],
|
|
870
|
+
memory: ['0.1'], 'pairing-message': ['1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'],
|
|
871
|
+
'share-registration': ['1'], 'share-registration-receipt': ['1'], 'window-lease': ['1'],
|
|
871
872
|
}));
|
|
872
873
|
export const MAX_PROTOCOLS = 64;
|
|
873
874
|
const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Share grants into an organization and their registrations (contract DEC-0086), the Node kit; the twin of
|
|
2
|
+
// fabric_share.py, answer for answer (test/fixtures/contract/share-vectors.json). Node built-ins only.
|
|
3
|
+
// FAC-SEM-117 (a share grant's id, window and agentRef, with parent), FAC-SEM-118 (a share-registration/1 document) and
|
|
4
|
+
// FAC-SEM-045's DEC-0086 clause (a grant.usage carries its grant's own org: context). Deriving, deciding and joining
|
|
5
|
+
// registrations belong to the sharer's node and the organization server, not to this kit. Every function answers and
|
|
6
|
+
// never throws on malformed input. Normative source: the contract's src/share-registration.ts.
|
|
7
|
+
// #region share-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md#share-grants-into-an-organization
|
|
8
|
+
|
|
9
|
+
import { estateTime, grantContextFindings } from './fabric-estate.mjs';
|
|
10
|
+
import { sameIdentifier } from './fabric-identifiers.mjs';
|
|
11
|
+
|
|
12
|
+
export const SHARE_REGISTRATION_TYPE = 'share-registration/1';
|
|
13
|
+
export const SHARE_REGISTRATION_RECEIPT_TYPE = 'share-registration-receipt/1';
|
|
14
|
+
export const SHARE_GRANT_ID = /^urn:fabric:share:(?!0{32}$)[0-9a-f]{32}$/;
|
|
15
|
+
export const ORG_CONTEXT = /^org:[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
16
|
+
const INSTANT = /^[0-9]{4}-[0-9]{2}-[0-9]{2}[Tt][0-9]{2}:[0-9]{2}:[0-5][0-9](?:\.[0-9]{1,3})?(?:[Zz]|[+-][0-9]{2}:[0-9]{2})$/;
|
|
17
|
+
const SUB_MILLISECOND = /:[0-9]{2}\.[0-9]{4,}/;
|
|
18
|
+
|
|
19
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
const isString = (value) => typeof value === 'string';
|
|
21
|
+
const list = (value) => (Array.isArray(value) ? value.filter(isObject) : []);
|
|
22
|
+
const indexed = (value) => (Array.isArray(value) ? value.flatMap((item, index) => (isObject(item) ? [[item, index]] : [])) : []);
|
|
23
|
+
const show = (value) => {
|
|
24
|
+
if (typeof value === 'string') return value;
|
|
25
|
+
try { return String(value); } catch { return JSON.stringify(value) ?? typeof value; }
|
|
26
|
+
};
|
|
27
|
+
const finding = (code, instancePath, message) => ({ code, instancePath, message });
|
|
28
|
+
const isOrg = (value) => isString(value) && value.startsWith('org:');
|
|
29
|
+
|
|
30
|
+
export const isShareGrantId = (value) => isString(value) && SHARE_GRANT_ID.test(value);
|
|
31
|
+
export const isOrgContext = (value) => isString(value) && ORG_CONTEXT.test(value);
|
|
32
|
+
|
|
33
|
+
/** FAC-SEM-117 on one grant: an organization's share grant has a share grant id and a millisecond window. */
|
|
34
|
+
export function shareGrantIdFindings(grant, at) {
|
|
35
|
+
if (!isObject(grant) || !isOrg(grant.context)) return [];
|
|
36
|
+
const findings = [];
|
|
37
|
+
const id = isObject(grant.meta) ? grant.meta.id : undefined;
|
|
38
|
+
if (!isShareGrantId(id)) findings.push(finding('FAC-SEM-117', `${at}/meta/id`, 'a share grant into an organization is named urn:fabric:share: and 32 lower-case hex digits from a CSPRNG, never a name, an address or an estate id'));
|
|
39
|
+
for (const name of ['startsAt', 'expiresAt']) {
|
|
40
|
+
if (isString(grant[name]) && SUB_MILLISECOND.test(grant[name])) findings.push(finding('FAC-SEM-117', `${at}/${name}`, 'a share grant into an organization carries its window with at most three fraction digits'));
|
|
41
|
+
}
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Kind grant as the contract's contextRules answers it: FAC-SEM-110 (DEC-0082), then FAC-SEM-117 (DEC-0086). */
|
|
46
|
+
export const grantFindings = (grant, at = '') => [...grantContextFindings(grant, at), ...shareGrantIdFindings(grant, at)];
|
|
47
|
+
|
|
48
|
+
function agentsUnder(record, context, id) {
|
|
49
|
+
const placements = list(record.machines).flatMap((machine) => list(machine.packages));
|
|
50
|
+
return new Set(placements.filter((p) => p.context === context && isString(id) && sameIdentifier(p.grant, id)).map((p) => show(p.agentRef)));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** FAC-SEM-117 on a record {record, parent?}: id and window, one agentRef, and the same agentRef as in parent. */
|
|
54
|
+
export function orgShareGrantsFindings(value) {
|
|
55
|
+
const record = isObject(value) && isObject(value.record) ? value.record : {};
|
|
56
|
+
const parent = isObject(value) && isObject(value.parent) ? value.parent : undefined;
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const [grant, index] of indexed(record.shareGrants)) {
|
|
59
|
+
const at = `/record/shareGrants/${index}`;
|
|
60
|
+
findings.push(...shareGrantIdFindings(grant, at));
|
|
61
|
+
if (!isOrg(grant.context)) continue;
|
|
62
|
+
const id = isObject(grant.meta) ? grant.meta.id : undefined;
|
|
63
|
+
const refs = agentsUnder(record, grant.context, id);
|
|
64
|
+
if (refs.size > 1) findings.push(finding('FAC-SEM-117', at, `the placements naming this grant run ${refs.size} agents: a share grant into an organization runs one agentRef`));
|
|
65
|
+
if (!parent) continue;
|
|
66
|
+
const before = list(parent.shareGrants).find((old) => old.context === grant.context && isObject(old.meta) && isString(id) && sameIdentifier(old.meta.id, id));
|
|
67
|
+
const earlier = before ? agentsUnder(parent, before.context, id) : new Set();
|
|
68
|
+
if (earlier.size > 0 && [...refs].some((ref) => !earlier.has(ref))) {
|
|
69
|
+
findings.push(finding('FAC-SEM-117', at, 'this grant ran another agent in the earlier revision given as parent: a share grant into an organization keeps its agentRef; move the share by revoking it and minting a new id'));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return findings;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const time = (value) => (isString(value) && INSTANT.test(value) ? estateTime(value) : Number.NaN);
|
|
76
|
+
|
|
77
|
+
/** FAC-SEM-118 on one share-registration/1 document. */
|
|
78
|
+
export function registrationFindings(value) {
|
|
79
|
+
if (!isObject(value) || value.type !== SHARE_REGISTRATION_TYPE) return [];
|
|
80
|
+
const names = value.state === 'active' ? ['startsAt', 'expiresAt'] : ['withdrawnAt'];
|
|
81
|
+
const unreadable = names.filter((name) => !Number.isFinite(time(value[name])));
|
|
82
|
+
const findings = unreadable.map((name) => finding('FAC-SEM-118', `/${name}`, 'an instant is an RFC 3339 date-time with an offset, on a day that exists, with at most three fraction digits'));
|
|
83
|
+
if (value.state === 'active' && !unreadable.length && time(value.startsAt) >= time(value.expiresAt)) findings.push(finding('FAC-SEM-118', '/expiresAt', "an active registration's window ends after it starts"));
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** FAC-SEM-045's DEC-0086 clause on {binding, batch}: a grant.usage carries org: and the certificate's org.id as written. */
|
|
88
|
+
export function grantUsageContextFindings(value) {
|
|
89
|
+
const binding = isObject(value) && isObject(value.binding) ? value.binding : {};
|
|
90
|
+
const org = isObject(binding.org) ? binding.org.id : undefined;
|
|
91
|
+
const batch = isObject(value) && isObject(value.batch) ? value.batch : {};
|
|
92
|
+
const findings = [];
|
|
93
|
+
list(batch.events).forEach((event, i) => {
|
|
94
|
+
if (event.kind !== 'grant.usage') return;
|
|
95
|
+
const context = isObject(event.data) ? event.data.context : undefined;
|
|
96
|
+
if (typeof org !== 'string' || context !== `org:${org}`) findings.push(finding('FAC-SEM-045', `/batch/events/${i}/data/context`, "a grant's usage carries its grant's own context, org: and the client certificate's org.id; another organization's is refused as attribution"));
|
|
97
|
+
});
|
|
98
|
+
return findings;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Kinds grant, org-share-grants, share-registration and device-attribution (045's grant.usage clause); undefined otherwise. */
|
|
102
|
+
export function shareRules(kind, value) {
|
|
103
|
+
if (kind === 'grant') return grantFindings(value, '');
|
|
104
|
+
if (kind === 'org-share-grants') return orgShareGrantsFindings(value);
|
|
105
|
+
if (kind === 'share-registration') return registrationFindings(value);
|
|
106
|
+
if (kind === 'device-attribution') return grantUsageContextFindings(value);
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
// #endregion share-kit-node
|