@passioncode-ai/fabric-agent-adapter 0.78.0 → 0.80.2
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 +256 -0
- package/README.md +14 -7
- package/bin/fabric-agent-adapter.js +26 -4
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +23 -8
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +34 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +11 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +14 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +30 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +48 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +54 -20
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +8 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +4 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +55 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +49 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +41 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +24 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +543 -37
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +12 -5
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +42 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +6 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +13 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +13 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +32 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +27 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +59 -38
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +52 -26
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +169 -46
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +146 -19
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs
CHANGED
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
14
|
import fs from 'node:fs';
|
|
15
|
+
import http from 'node:http';
|
|
15
16
|
import net from 'node:net';
|
|
16
17
|
import { hubKeyCertified } from './fabric-estate.mjs';
|
|
17
18
|
import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
|
|
19
|
+
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs'; // a loopback key set from this account's listener only (FAA-255)
|
|
18
20
|
import { effectClosure } from './fabric-mandate.mjs'; // defined once beside the mandate that reads it too (DEC-0125)
|
|
19
21
|
|
|
20
22
|
export const REFUSAL_CODES = Object.freeze([
|
|
@@ -411,8 +413,34 @@ export const actionDigest = (action) => `sha256:${crypto.createHash('sha256').up
|
|
|
411
413
|
|
|
412
414
|
const loopback = (host) => host === 'localhost' || (net.isIP(host.replace(/^\[|\]$/g, '')) !== 0 && (/^127\./.test(host) || host.replace(/^\[|\]$/g, '') === '::1'));
|
|
413
415
|
|
|
416
|
+
const KEY_SET_MAX = 65536;
|
|
417
|
+
const KEY_SET_AGENT = new CheckedAgent({ keepAlive: false });
|
|
418
|
+
|
|
419
|
+
// Plain http, which a key set source allows on loopback only: through a CheckedAgent, so the listener's owner is told
|
|
420
|
+
// before a byte is written and keys another OS account's process serves are never read (DEC-0154 HA-2, FAA-255).
|
|
421
|
+
function loopbackKeySet(source, agent) {
|
|
422
|
+
return new Promise((resolve, reject) => {
|
|
423
|
+
const request = http.get(source.split('#')[0], { agent, headers: { Accept: 'application/json' }, timeout: 5000 }, (response) => {
|
|
424
|
+
if (response.statusCode < 200 || response.statusCode > 299) { response.resume(); reject(new Error(`HTTP ${response.statusCode}`)); return; }
|
|
425
|
+
let text = '';
|
|
426
|
+
response.setEncoding('utf8');
|
|
427
|
+
response.on('data', (chunk) => { text += chunk; if (text.length > KEY_SET_MAX) { request.destroy(); reject(new Error('the key set is too large')); } });
|
|
428
|
+
response.on('end', () => { try { resolve(JSON.parse(text)); } catch (e) { reject(e); } });
|
|
429
|
+
response.on('error', reject);
|
|
430
|
+
});
|
|
431
|
+
request.on('timeout', () => request.destroy(new Error('the key set did not arrive within 5 s')));
|
|
432
|
+
request.on('error', (e) => reject(e instanceof ListenerRefused ? new Error(`foreign-user: ${e.message}`) : e));
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Read a key set source: an https URL (fetch, no redirect), a loopback http URL (checked listener) or a file. */
|
|
437
|
+
export async function fetchKeySet(source, { agent = KEY_SET_AGENT } = {}) {
|
|
438
|
+
if (/^http:\/\//.test(source)) return loopbackKeySet(source, agent);
|
|
439
|
+
return defaultFetch(source);
|
|
440
|
+
}
|
|
441
|
+
|
|
414
442
|
async function defaultFetch(source) {
|
|
415
|
-
if (/^https
|
|
443
|
+
if (/^https:\/\//.test(source)) {
|
|
416
444
|
const response = await fetch(source, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(5000) });
|
|
417
445
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
418
446
|
if (Number(response.headers.get('content-length') ?? 0) > 65536) throw new Error('the key set is too large');
|
|
@@ -428,7 +456,7 @@ async function defaultFetch(source) {
|
|
|
428
456
|
* `await get(kid)` answers null when no set younger than 10 minutes can be had (issuer-unavailable).
|
|
429
457
|
*/
|
|
430
458
|
export class JwksCache {
|
|
431
|
-
constructor(source, { fetch: fetcher =
|
|
459
|
+
constructor(source, { fetch: fetcher = fetchKeySet, clock = () => Date.now() / 1000 } = {}) {
|
|
432
460
|
if (typeof source !== 'string' || !source) throw new Error('the key set needs a source');
|
|
433
461
|
if (/^https?:\/\//.test(source)) {
|
|
434
462
|
const url = new URL(source);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// How a service is found (fabric-service/0.1): its descriptor, peers and app descriptors (DEC-0061, DEC-0069), build identity and the well-known document.
|
|
2
2
|
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_descriptor.py. Node 20+, no dependency.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import fs from 'node:fs';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
import { credentialName, repositoryCredential, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
|
|
6
|
-
import { ID, INSTANCE, LOCAL_PATH, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
7
|
+
import { ID, INSTANCE, LOCAL_PATH, PRIMARY_CAPABILITY, PROTOCOL, ServiceError, TASK, UNIT, expand } from './fabric-base.mjs';
|
|
7
8
|
import { atomicWrite, ensurePrivateDir, servicesDir } from './fabric-paths.mjs';
|
|
9
|
+
import { ANOTHER_ACCOUNT } from './fabric-peer-owner.mjs'; // L0: who listens on a port (DEC-0154 HA-2, FAA-225)
|
|
8
10
|
import { protocolsFindings } from './fabric-protocols.mjs';
|
|
9
11
|
import { CONNECTIONS_EXTENSION_KEY, connectionsView } from './fabric-connections.mjs';
|
|
10
12
|
import { ACTIONS_EXTENSION_KEY, actionsExtension, actionsExtensionRules } from './fabric-actions.mjs';
|
|
@@ -15,7 +17,7 @@ import { ROUTES } from './fabric-usage.mjs';
|
|
|
15
17
|
// document's `extensions`. The kit is assets/dashboard-kit; its version is the one in kit.json.
|
|
16
18
|
export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/dashboard/0.1';
|
|
17
19
|
|
|
18
|
-
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.
|
|
20
|
+
export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.6';
|
|
19
21
|
|
|
20
22
|
export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
|
|
21
23
|
|
|
@@ -313,9 +315,43 @@ export function sourceRefusal(d, dir = servicesDir()) {
|
|
|
313
315
|
return `Descriptor ${d?.id}.${instance} ${NO_SOURCE_REPOSITORY}. ${PASS_SOURCE}`;
|
|
314
316
|
}
|
|
315
317
|
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
318
|
+
// The child that answers portOwner: a plain listen on 127.0.0.1:<port> that succeeds means nothing holds it (the table
|
|
319
|
+
// is not read); one that fails asks fabric-peer-owner's listener. A child, because writeDescriptor is synchronous.
|
|
320
|
+
const PORT_OWNER_CHILD = `
|
|
321
|
+
import net from 'node:net';
|
|
322
|
+
const [port, url] = process.argv.slice(1);
|
|
323
|
+
const say = (value) => process.stdout.write(JSON.stringify(value));
|
|
324
|
+
const server = net.createServer();
|
|
325
|
+
server.once('error', async () => {
|
|
326
|
+
try { const { listener } = await import(url); const who = await listener(Number(port)); say([who.kind, Number.isInteger(who.uid) ? who.uid : null]); }
|
|
327
|
+
catch { say(['unknown', null]); }
|
|
328
|
+
});
|
|
329
|
+
server.listen({ port: Number(port), host: '127.0.0.1', exclusive: true }, () => server.close(() => say(null)));
|
|
330
|
+
`;
|
|
331
|
+
|
|
332
|
+
/** Who listens on 127.0.0.1:<port> now, as [kind, uid] from the kit's fabric-peer-owner, or null when nothing holds it
|
|
333
|
+
* (FAA-225, DEC-0154 HA-2). */
|
|
334
|
+
export function portOwner(port) {
|
|
335
|
+
const done = spawnSync(process.execPath, ['--input-type=module', '-e', PORT_OWNER_CHILD, String(port),
|
|
336
|
+
new URL('./fabric-peer-owner.mjs', import.meta.url).href], { encoding: 'utf8', timeout: 10000 });
|
|
337
|
+
try { return JSON.parse(done.stdout); } catch { return ['unknown', null]; }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Why an install may not take `port`: another OS account listens on it (FAA-225). This account, the system (launchd
|
|
341
|
+
* holds a socket-activated job's socket), nobody and an unreadable table pass. The recorded port per account and its
|
|
342
|
+
* claim file wait on contract DEC-0160. */
|
|
343
|
+
export function portListenerRefusal(port, reader = portOwner) {
|
|
344
|
+
const found = reader(port);
|
|
345
|
+
if (!Array.isArray(found) || found[0] !== ANOTHER_ACCOUNT) return null;
|
|
346
|
+
const uid = Number.isInteger(found[1]) ? ` (uid ${found[1]})` : '';
|
|
347
|
+
return `Port ${port} is listened on by another OS account${uid}: choose a port no other account holds (DEC-0154, FAA-225).`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Installer-only. Refuses a port or id.instance another descriptor claims (a port is a claim), and a port another OS
|
|
351
|
+
* account listens on (FAA-225; `portOwner(port) -> [kind, uid] | null` reads it, the kit's own by default).
|
|
352
|
+
* FAA-198: a new descriptor names its source.repository; rewriting one already installed without it only warns
|
|
353
|
+
* (process.emitWarning, code FABRIC_NO_SOURCE_REPOSITORY), so an installed agent's update keeps working. */
|
|
354
|
+
export function writeDescriptor(d, dir = servicesDir(), { portOwner: reader = portOwner } = {}) {
|
|
319
355
|
const problems = validateDescriptor(d);
|
|
320
356
|
if (problems.length) throw new ServiceError(`Descriptor is invalid: ${problems.join('; ')}.`);
|
|
321
357
|
const me = `${d.id}.${d.instance}`;
|
|
@@ -334,6 +370,8 @@ export function writeDescriptor(d, dir = servicesDir()) {
|
|
|
334
370
|
try { otherPort = portOf(String(other.origin ?? '')); } catch { continue; }
|
|
335
371
|
if (otherPort === port) throw new ServiceError(`Port ${port} is already claimed by ${key} (${file}).`);
|
|
336
372
|
}
|
|
373
|
+
const held = port === null ? null : portListenerRefusal(port, reader);
|
|
374
|
+
if (held) throw new ServiceError(held);
|
|
337
375
|
ensurePrivateDir(dir);
|
|
338
376
|
const target = descriptorPath(d.id, d.instance, dir);
|
|
339
377
|
atomicWrite(target, `${JSON.stringify(d, null, 2)}\n`, 0o600);
|
|
@@ -418,6 +456,9 @@ export function configurationProblems(doc) {
|
|
|
418
456
|
// `a2a` (DEC-0122) is surfaces.a2a, where the service serves its public Agent Card (a2aSurface builds one); passed here
|
|
419
457
|
// or in `surfaces`, never both, and refused when it is not the schema's. A reader pinned before DEC-0122 refuses the
|
|
420
458
|
// member, so a service carries it only once its readers are pinned at or after it.
|
|
459
|
+
// FAA-244: DEC-0140 PR-1 reserves the primary responder's capability; a service never publishes it.
|
|
460
|
+
const reserved = (where) => `${where}: ${PRIMARY_CAPABILITY} is reserved for a Project's primary responder, a session (DEC-0140 PR-1); a service never serves it as a tool or declares it.`;
|
|
461
|
+
|
|
421
462
|
export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols, actions, a2a }) {
|
|
422
463
|
if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
|
|
423
464
|
if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
|
|
@@ -430,6 +471,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
430
471
|
const problems = a2aSurfaceProblems(surfaces.a2a);
|
|
431
472
|
if (problems.length) throw new ServiceError(`surfaces.a2a: ${problems.join('; ')}.`);
|
|
432
473
|
}
|
|
474
|
+
if (Array.isArray(surfaces.mcp?.capabilities) && surfaces.mcp.capabilities.includes(PRIMARY_CAPABILITY)) throw new ServiceError(reserved('surfaces.mcp.capabilities'));
|
|
433
475
|
if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
|
|
434
476
|
const doc = {
|
|
435
477
|
protocol: PROTOCOL,
|
|
@@ -461,6 +503,7 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
461
503
|
// compared with the capabilities this document's MCP surface lists.
|
|
462
504
|
const findings = actionsExtensionRules({ extensions: { [ACTIONS_EXTENSION_KEY]: extensions[ACTIONS_EXTENSION_KEY] }, surfaces });
|
|
463
505
|
if (findings.length) throw new ServiceError(`Actions declaration: ${findings.map((f) => f.message).join('; ')}.`);
|
|
506
|
+
if ((extensions[ACTIONS_EXTENSION_KEY]?.actions ?? []).some((a) => a?.tool === PRIMARY_CAPABILITY)) throw new ServiceError(reserved("an action's tool"));
|
|
464
507
|
}
|
|
465
508
|
doc.extensions = { ...extensions };
|
|
466
509
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
// The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
|
|
17
17
|
// { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
|
|
18
18
|
// HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
|
|
19
|
+
//
|
|
20
|
+
// Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
19
21
|
|
|
20
22
|
import http from 'node:http';
|
|
21
|
-
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
23
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
|
|
22
24
|
|
|
23
25
|
export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
|
|
24
26
|
export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
|
|
@@ -27,7 +29,7 @@ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
27
29
|
|
|
28
30
|
export class HttpError extends Error {
|
|
29
31
|
constructor(message, { status = null, url = '', attempts = 0, retryAfterS = null, body = new Uint8Array(0),
|
|
30
|
-
kind = 'http' } = {}) {
|
|
32
|
+
kind = 'http', reason = null } = {}) {
|
|
31
33
|
super(message);
|
|
32
34
|
this.name = 'HttpError';
|
|
33
35
|
this.status = status;
|
|
@@ -36,6 +38,8 @@ export class HttpError extends Error {
|
|
|
36
38
|
this.retryAfterS = retryAfterS;
|
|
37
39
|
this.body = body.slice(0, 2048);
|
|
38
40
|
this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
|
|
41
|
+
// For foreign-user, why (FAA-272): table-restricted (never clears), the owner's kind, or the check's reason.
|
|
42
|
+
this.reason = reason;
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
45
|
|
|
@@ -184,7 +188,9 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
|
|
|
184
188
|
req.on('error', (err) => {
|
|
185
189
|
clearTimeout(timer);
|
|
186
190
|
if (err instanceof ListenerRefused) {
|
|
187
|
-
|
|
191
|
+
const why = String(err.owner?.reason ?? err.owner?.kind ?? err.code ?? 'unknown');
|
|
192
|
+
const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
|
|
193
|
+
reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user', reason }));
|
|
188
194
|
} else reject(err);
|
|
189
195
|
});
|
|
190
196
|
if (body) req.write(body);
|
|
@@ -27,7 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
|
|
29
29
|
import { hubKeyCertified } from './fabric-estate.mjs';
|
|
30
|
-
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
30
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
|
|
31
31
|
|
|
32
32
|
// DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
|
|
33
33
|
// socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
|
|
@@ -53,6 +53,20 @@ export class HubError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
|
|
58
|
+
* state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
|
|
59
|
+
* runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
|
|
60
|
+
* owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
|
|
61
|
+
*/
|
|
62
|
+
export function listenerRefusal(e) {
|
|
63
|
+
const kind = e?.owner?.kind ?? null;
|
|
64
|
+
const why = String(e?.owner?.reason ?? kind ?? e?.code ?? 'unknown');
|
|
65
|
+
const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
|
|
66
|
+
const shown = kind && e?.owner?.reason ? `${kind}: ${e.owner.reason}` : String(kind ?? why);
|
|
67
|
+
return new HubError('foreign-user', `The hub's port is not held by this OS account's process (${shown}); nothing was sent.`, reason !== TABLE_RESTRICTED, { reason });
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
/** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
|
|
57
71
|
export function fabricRoot(platform = process.platform, env = process.env) {
|
|
58
72
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
@@ -202,7 +216,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
|
|
|
202
216
|
});
|
|
203
217
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
204
218
|
req.on('error', (e) => reject(e instanceof ListenerRefused
|
|
205
|
-
?
|
|
219
|
+
? listenerRefusal(e)
|
|
206
220
|
: new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
|
|
207
221
|
if (payload) req.write(payload);
|
|
208
222
|
req.end();
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// The call queue and the load report of DEC-0104 (contract src/agent-network.ts, FAC-SEM-131 to FAC-SEM-133), as the
|
|
2
|
+
// contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
|
|
3
|
+
// these and reports its load as service-load/1 under extensions/load/0.1. Part of the Node service kit (FAA-289, layer
|
|
4
|
+
// L3); the twin of fabric_load.py. Node 20+, no dependency. Nothing here throws on what it reads, except
|
|
5
|
+
// loadExtension, which refuses a declaration a reader would treat as not declared.
|
|
6
|
+
//
|
|
7
|
+
// - jobCallerKey(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
|
|
8
|
+
// outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
|
|
9
|
+
// - nextQueuedJob(queue, running, limits): the job the node starts next, or undefined (fairness between callers).
|
|
10
|
+
// - admitJob(queue, running, job, limits, retryAfterSeconds): {admit: true}, or the queue-full refusal with its data.
|
|
11
|
+
// - queueStatusMessage(position, length), readQueuePosition(statusMessage): `queued <position> of <length>`.
|
|
12
|
+
// - jobQueueRules(value): FAC-SEM-131 over {queue, running, limits, started}.
|
|
13
|
+
// - loadReportRules(report): FAC-SEM-132 on a service-load/1 report; loadReportProblems(report): its schema.
|
|
14
|
+
// - loadView(wellKnown), loadExtensionRules(wellKnown): the declaration as a reader reads it, and FAC-SEM-133.
|
|
15
|
+
// - loadExtension({ path, windowSeconds, limits }): the `extensions` entry a service declares.
|
|
16
|
+
//
|
|
17
|
+
// The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
|
|
18
|
+
// reads them (lengths in code points, date-time as ajv-formats' full mode). Times in the queue are UTC instants
|
|
19
|
+
// (interop-common#/$defs/utcInstant); times in the report are RFC 3339 read strictly (the contract's strictTime). The
|
|
20
|
+
// wording of a schema problem is the kit's, not ajv's.
|
|
21
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
22
|
+
import { dateTime } from './fabric-connections.mjs'; // ajv-formats' date-time, as the contract's validator reads it
|
|
23
|
+
import { isObject, isString, objects, show } from './fabric-jsvalue.mjs';
|
|
24
|
+
import { parseRfc3339Ms } from './fabric-time.mjs'; // the contract's strictTime
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{ id: string, caller: string, at: string }} QueuedJob A job waiting at the hosting node: its id, its
|
|
28
|
+
* caller's key (jobCallerKey) and when it arrived (a UTC instant).
|
|
29
|
+
* @typedef {{ id: string, caller?: string }} RunningJob A job running: its id and caller.
|
|
30
|
+
* @typedef {{ concurrency: number, perCaller: number, queueMax: number }} QueueLimits The queue's limits per
|
|
31
|
+
* (agentRef, context), as the load extension declares them.
|
|
32
|
+
* @typedef {{ admit: true } | { admit: false, error: { code: 'queue-full', message: string,
|
|
33
|
+
* data: { retryable: true, retryAfterSeconds: number } } }} Admission
|
|
34
|
+
* @typedef {{ code: string, instancePath: string, message: string }} Finding
|
|
35
|
+
* @typedef {{ declared: boolean, valid: boolean, load: { path: string, windowSeconds: number, limits: QueueLimits } | null,
|
|
36
|
+
* errors: string[] }} LoadView
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export const LOAD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/load/0.1';
|
|
40
|
+
export const LOAD_REPORT_TYPE = 'service-load/1';
|
|
41
|
+
// The refusal code, the same as fabric-interop-rules.mjs's QUEUE_FULL (a test holds them equal); not imported because
|
|
42
|
+
// the facade fabric-service.mjs, which re-exports this module, comes before the interop rules in the kit's order.
|
|
43
|
+
const QUEUE_FULL = 'queue-full';
|
|
44
|
+
export const QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later";
|
|
45
|
+
/** The longest queue a status message names (`queued <position> of <length>`, six digits). */
|
|
46
|
+
export const QUEUE_LENGTH_MAX = 999999;
|
|
47
|
+
export const DEFAULT_RETRY_AFTER_SECONDS = 60;
|
|
48
|
+
|
|
49
|
+
const UTC_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[0-5]\d(?:\.\d{1,3})?Z$/;
|
|
50
|
+
const QUEUED = /^queued ([1-9]\d{0,5}) of ([1-9]\d{0,5})$/;
|
|
51
|
+
const ORIGIN_PATH = /^\/(?!\/)[^\s]*$/u;
|
|
52
|
+
const CONTEXT = /^(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})$/;
|
|
53
|
+
const KEY_PATH = `/extensions/${LOAD_EXTENSION_KEY.replace(/~/g, '~0').replace(/\//g, '~1')}`;
|
|
54
|
+
const LIMITS = [['concurrency', 1, 1024], ['perCaller', 1, 1024], ['queueMax', 0, 100000]];
|
|
55
|
+
const ROW_COUNTS = ['queued', 'running', 'completed', 'failed'];
|
|
56
|
+
|
|
57
|
+
/** An own member, or undefined. */
|
|
58
|
+
const member = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
59
|
+
/** A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN. */
|
|
60
|
+
const utcMs = (value) => (isString(value) && UTC_INSTANT.test(value) ? parseRfc3339Ms(value) : Number.NaN);
|
|
61
|
+
const strictMs = (value) => (isString(value) ? parseRfc3339Ms(value) : Number.NaN);
|
|
62
|
+
const codePoints = (value) => [...value].length;
|
|
63
|
+
|
|
64
|
+
// --- The call queue (fabric-interop, DEC-0104) ---
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
|
|
68
|
+
* delegation the node accepted (verifyDelegation's answer), its `principal` and `caller` and the partner organization
|
|
69
|
+
* when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the caller by (a
|
|
70
|
+
* gate's pass with `principal: null` is that case too). A refused verdict has no key, and neither has an unreadable
|
|
71
|
+
* call: undefined. The key is opaque; only equality matters.
|
|
72
|
+
* @param {unknown} accepted
|
|
73
|
+
* @param {unknown} credential
|
|
74
|
+
* @returns {string | undefined}
|
|
75
|
+
*/
|
|
76
|
+
export function jobCallerKey(accepted, credential) {
|
|
77
|
+
if (isObject(accepted) && !(member(accepted, 'ok') === true && member(accepted, 'principal') === null)) {
|
|
78
|
+
const principal = member(accepted, 'principal');
|
|
79
|
+
const caller = member(accepted, 'caller');
|
|
80
|
+
if (member(accepted, 'ok') !== true || !isString(principal) || principal === '' || !isString(caller) || caller === '') return undefined;
|
|
81
|
+
const given = member(accepted, 'chain');
|
|
82
|
+
const chain = Array.isArray(given) ? given : [];
|
|
83
|
+
const last = chain[chain.length - 1];
|
|
84
|
+
const partner = chain.length > 1 && isString(last) && last.startsWith('org:') ? [last] : [];
|
|
85
|
+
return JSON.stringify(['delegated', principal, caller, ...partner]);
|
|
86
|
+
}
|
|
87
|
+
return isString(credential) && credential !== '' ? JSON.stringify(['credential', credential]) : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const limitsOk = (limits) => isObject(limits)
|
|
91
|
+
&& [member(limits, 'concurrency'), member(limits, 'perCaller')].every((n) => Number.isSafeInteger(n) && n >= 1)
|
|
92
|
+
&& Number.isSafeInteger(member(limits, 'queueMax')) && limits.queueMax >= 0;
|
|
93
|
+
const waitingJob = (job) => isObject(job) && isString(member(job, 'id')) && isString(member(job, 'caller')) && Number.isFinite(utcMs(member(job, 'at')));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The job the hosting node starts next for one (agentRef, context), or undefined (DEC-0104). Nothing starts while
|
|
97
|
+
* `concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose caller
|
|
98
|
+
* runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose oldest
|
|
99
|
+
* waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order).
|
|
100
|
+
* @param {unknown} queue
|
|
101
|
+
* @param {unknown} running
|
|
102
|
+
* @param {unknown} limits
|
|
103
|
+
* @returns {string | undefined}
|
|
104
|
+
*/
|
|
105
|
+
export function nextQueuedJob(queue, running, limits) {
|
|
106
|
+
if (!limitsOk(limits)) return undefined;
|
|
107
|
+
if ((Array.isArray(running) ? running.length : 0) >= limits.concurrency) return undefined;
|
|
108
|
+
const runningOf = new Map();
|
|
109
|
+
for (const job of objects(running)) if (isString(member(job, 'caller'))) runningOf.set(job.caller, (runningOf.get(job.caller) ?? 0) + 1);
|
|
110
|
+
const waiting = objects(queue)
|
|
111
|
+
.filter((job) => waitingJob(job) && (runningOf.get(job.caller) ?? 0) < limits.perCaller)
|
|
112
|
+
.sort((a, b) => utcMs(a.at) - utcMs(b.at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
113
|
+
let best;
|
|
114
|
+
for (const job of waiting) if (best === undefined || (runningOf.get(job.caller) ?? 0) < (runningOf.get(best.caller) ?? 0)) best = job;
|
|
115
|
+
return best?.id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a start is admitted (DEC-0104): it is when the job would start at once (nextQueuedJob over the queue with it
|
|
120
|
+
* added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: false, error} with the queue-full
|
|
121
|
+
* refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse.
|
|
122
|
+
* @param {unknown} queue
|
|
123
|
+
* @param {unknown} running
|
|
124
|
+
* @param {unknown} job
|
|
125
|
+
* @param {unknown} limits
|
|
126
|
+
* @param {unknown} retryAfterSeconds
|
|
127
|
+
* @returns {Admission}
|
|
128
|
+
*/
|
|
129
|
+
export function admitJob(queue, running, job, limits, retryAfterSeconds) {
|
|
130
|
+
const waiting = objects(queue);
|
|
131
|
+
if (limitsOk(limits) && waitingJob(job) && (nextQueuedJob([...waiting, job], running, limits) === job.id || waiting.length < limits.queueMax)) return { admit: true };
|
|
132
|
+
const after = Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds >= 1 && retryAfterSeconds <= 3600 ? retryAfterSeconds : DEFAULT_RETRY_AFTER_SECONDS;
|
|
133
|
+
return { admit: false, error: { code: QUEUE_FULL, message: QUEUE_FULL_MESSAGE, data: { retryable: true, retryAfterSeconds: after } } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <= length
|
|
138
|
+
* <= 999999; undefined for anything else.
|
|
139
|
+
* @param {unknown} position
|
|
140
|
+
* @param {unknown} length
|
|
141
|
+
* @returns {string | undefined}
|
|
142
|
+
*/
|
|
143
|
+
export const queueStatusMessage = (position, length) => (Number.isSafeInteger(position) && Number.isSafeInteger(length)
|
|
144
|
+
&& position >= 1 && position <= length && length <= QUEUE_LENGTH_MAX ? `queued ${position} of ${length}` : undefined);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A waiting job's place, read from its `statusMessage`, or undefined when it is not the queue's grammar.
|
|
148
|
+
* @param {unknown} statusMessage
|
|
149
|
+
* @returns {{ position: number, length: number } | undefined}
|
|
150
|
+
*/
|
|
151
|
+
export function readQueuePosition(statusMessage) {
|
|
152
|
+
const match = isString(statusMessage) ? QUEUED.exec(statusMessage) : null;
|
|
153
|
+
if (!match) return undefined;
|
|
154
|
+
const position = Number(match[1]);
|
|
155
|
+
const length = Number(match[2]);
|
|
156
|
+
return position >= 1 && position <= length ? { position, length } : undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const finding = (code, instancePath, message) => ({ code, instancePath, message });
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or null): it is nextQueuedJob's.
|
|
163
|
+
* @param {unknown} value
|
|
164
|
+
* @returns {Finding[]}
|
|
165
|
+
*/
|
|
166
|
+
export function jobQueueRules(value) {
|
|
167
|
+
const want = nextQueuedJob(member(value, 'queue'), member(value, 'running'), member(value, 'limits')) ?? null;
|
|
168
|
+
if ((member(value, 'started') ?? null) === want) return [];
|
|
169
|
+
return [finding('FAC-SEM-131', '/started', want === null ? 'nothing may start now' : `the job to start is ${show(want)}`)];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- The load report (DEC-0104) ---
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* FAC-SEM-132 on a service-load/1 report: windows in order one `windowSeconds` apart, none after `generatedAt`, one
|
|
176
|
+
* row per context, p50 at most p95, a run time only where a job ended.
|
|
177
|
+
* @param {unknown} report
|
|
178
|
+
* @returns {Finding[]}
|
|
179
|
+
*/
|
|
180
|
+
export function loadReportRules(report) {
|
|
181
|
+
const findings = [];
|
|
182
|
+
const flag = (instancePath, message) => findings.push(finding('FAC-SEM-132', instancePath, message));
|
|
183
|
+
const seconds = member(report, 'windowSeconds');
|
|
184
|
+
const step = Number.isSafeInteger(seconds) ? seconds * 1000 : Number.NaN;
|
|
185
|
+
const generated = strictMs(member(report, 'generatedAt'));
|
|
186
|
+
let previous;
|
|
187
|
+
objects(member(report, 'windows')).forEach((window, index) => {
|
|
188
|
+
const start = strictMs(member(window, 'start'));
|
|
189
|
+
if (start > generated) flag(`/windows/${index}/start`, 'a window starts after the report was generated');
|
|
190
|
+
if (previous !== undefined && start - previous !== step) flag(`/windows/${index}/start`, 'windows are not in order one windowSeconds apart');
|
|
191
|
+
previous = start;
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
objects(member(window, 'byContext')).forEach((row, at) => {
|
|
194
|
+
const path = `/windows/${index}/byContext/${at}`;
|
|
195
|
+
const context = member(row, 'context');
|
|
196
|
+
if (isString(context) && seen.has(context)) flag(`${path}/context`, `context ${show(context)} appears twice in one window`);
|
|
197
|
+
if (isString(context)) seen.add(context);
|
|
198
|
+
for (const name of ['waitMs', 'runMs']) {
|
|
199
|
+
const pct = member(row, name);
|
|
200
|
+
if (isObject(pct) && typeof member(pct, 'p50') === 'number' && typeof member(pct, 'p95') === 'number' && pct.p50 > pct.p95) flag(`${path}/${name}`, 'p50 is above p95');
|
|
201
|
+
}
|
|
202
|
+
if (isObject(member(row, 'runMs')) && member(row, 'completed') === 0 && member(row, 'failed') === 0) flag(`${path}/runMs`, 'a run time with no job ended in the window');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction, never a boolean. */
|
|
209
|
+
function integer(value, low, high, at, errors) {
|
|
210
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value % 1 !== 0) errors.push(`${at} type`);
|
|
211
|
+
else if (value < low) errors.push(`${at} minimum`);
|
|
212
|
+
else if (high !== undefined && value > high) errors.push(`${at} maximum`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function members(value, allowed, required, at, errors) {
|
|
216
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) errors.push(`${at}/${key} additionalProperties`);
|
|
217
|
+
for (const key of required) if (!Object.hasOwn(value, key)) errors.push(`${at || '/'} required ${key}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function timestamp(value, at, errors) {
|
|
221
|
+
if (!isString(value)) errors.push(`${at} type`);
|
|
222
|
+
else if (!dateTime(value)) errors.push(`${at} format`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function percentiles(value, at, errors) {
|
|
226
|
+
if (!isObject(value)) {
|
|
227
|
+
errors.push(`${at} type`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
members(value, ['p50', 'p95'], ['p50', 'p95'], at, errors);
|
|
231
|
+
for (const name of ['p50', 'p95']) if (Object.hasOwn(value, name)) integer(value[name], 0, undefined, `${at}/${name}`, errors);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function rowProblems(row, at, errors) {
|
|
235
|
+
if (!isObject(row)) {
|
|
236
|
+
errors.push(`${at} type`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
members(row, ['context', ...ROW_COUNTS, 'waitMs', 'runMs'], ['context', ...ROW_COUNTS], at, errors);
|
|
240
|
+
if (Object.hasOwn(row, 'context') && !(isString(row.context) && CONTEXT.test(row.context))) errors.push(`${at}/context ${isString(row.context) ? 'pattern' : 'type'}`);
|
|
241
|
+
for (const name of ROW_COUNTS) if (Object.hasOwn(row, name)) integer(row[name], 0, undefined, `${at}/${name}`, errors);
|
|
242
|
+
for (const name of ['waitMs', 'runMs']) if (Object.hasOwn(row, name)) percentiles(row[name], `${at}/${name}`, errors);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* service-load.schema.json: what makes `report` not a service-load/1 report, each as `<path> <keyword>`; empty for a
|
|
247
|
+
* valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused.
|
|
248
|
+
* @param {unknown} report
|
|
249
|
+
* @returns {string[]}
|
|
250
|
+
*/
|
|
251
|
+
export function loadReportProblems(report) {
|
|
252
|
+
if (!isObject(report)) return ['/ type'];
|
|
253
|
+
const errors = [];
|
|
254
|
+
const names = ['type', 'generatedAt', 'windowSeconds', 'windows'];
|
|
255
|
+
members(report, names, names, '', errors);
|
|
256
|
+
if (Object.hasOwn(report, 'type') && report.type !== LOAD_REPORT_TYPE) errors.push('/type const');
|
|
257
|
+
if (Object.hasOwn(report, 'generatedAt')) timestamp(report.generatedAt, '/generatedAt', errors);
|
|
258
|
+
if (Object.hasOwn(report, 'windowSeconds')) integer(report.windowSeconds, 60, 86400, '/windowSeconds', errors);
|
|
259
|
+
if (Object.hasOwn(report, 'windows') && !Array.isArray(report.windows)) errors.push('/windows type');
|
|
260
|
+
else if (Object.hasOwn(report, 'windows')) {
|
|
261
|
+
if (report.windows.length > 168) errors.push('/windows maxItems');
|
|
262
|
+
report.windows.forEach((window, index) => {
|
|
263
|
+
const at = `/windows/${index}`;
|
|
264
|
+
if (!isObject(window)) {
|
|
265
|
+
errors.push(`${at} type`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
members(window, ['start', 'byContext'], ['start', 'byContext'], at, errors);
|
|
269
|
+
if (Object.hasOwn(window, 'start')) timestamp(window.start, `${at}/start`, errors);
|
|
270
|
+
if (Object.hasOwn(window, 'byContext') && !Array.isArray(window.byContext)) errors.push(`${at}/byContext type`);
|
|
271
|
+
else if (Object.hasOwn(window, 'byContext')) {
|
|
272
|
+
if (window.byContext.length > 64) errors.push(`${at}/byContext maxItems`);
|
|
273
|
+
window.byContext.forEach((row, rowIndex) => rowProblems(row, `${at}/byContext/${rowIndex}`, errors));
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return errors;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration. */
|
|
281
|
+
function declarationProblems(value) {
|
|
282
|
+
if (!isObject(value)) return ['/ type'];
|
|
283
|
+
const errors = [];
|
|
284
|
+
members(value, ['path', 'windowSeconds', 'limits'], ['path', 'windowSeconds', 'limits'], '', errors);
|
|
285
|
+
if (Object.hasOwn(value, 'path')) {
|
|
286
|
+
if (!isString(value.path)) errors.push('/path type');
|
|
287
|
+
else {
|
|
288
|
+
if (codePoints(value.path) > 256) errors.push('/path maxLength'); // the originPath's own 2048 is wider
|
|
289
|
+
if (!ORIGIN_PATH.test(value.path)) errors.push('/path pattern');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (Object.hasOwn(value, 'windowSeconds')) integer(value.windowSeconds, 60, 86400, '/windowSeconds', errors);
|
|
293
|
+
if (Object.hasOwn(value, 'limits')) {
|
|
294
|
+
const { limits } = value;
|
|
295
|
+
if (!isObject(limits)) errors.push('/limits type');
|
|
296
|
+
else {
|
|
297
|
+
const names = LIMITS.map(([name]) => name);
|
|
298
|
+
members(limits, names, names, '/limits', errors);
|
|
299
|
+
for (const [name, low, high] of LIMITS) if (Object.hasOwn(limits, name)) integer(limits[name], low, high, `/limits/${name}`, errors);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return errors;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when its
|
|
307
|
+
* value is the schema's, `load` the declaration when valid (else null) and `errors` its problems. A reader treats an
|
|
308
|
+
* invalid value as not declared and reports it (FAC-SEM-133).
|
|
309
|
+
* @param {unknown} wellKnown
|
|
310
|
+
* @returns {LoadView}
|
|
311
|
+
*/
|
|
312
|
+
export function loadView(wellKnown) {
|
|
313
|
+
const extensions = member(wellKnown, 'extensions');
|
|
314
|
+
if (!isObject(extensions) || !Object.hasOwn(extensions, LOAD_EXTENSION_KEY)) return { declared: false, valid: false, load: null, errors: [] };
|
|
315
|
+
const value = extensions[LOAD_EXTENSION_KEY];
|
|
316
|
+
const errors = declarationProblems(value);
|
|
317
|
+
return { declared: true, valid: errors.length === 0, load: errors.length === 0 ? value : null, errors };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
|
|
322
|
+
* service-load-extension.schema.json; one finding at the key otherwise.
|
|
323
|
+
* @param {unknown} wellKnown
|
|
324
|
+
* @returns {Finding[]}
|
|
325
|
+
*/
|
|
326
|
+
export function loadExtensionRules(wellKnown) {
|
|
327
|
+
const view = loadView(wellKnown);
|
|
328
|
+
if (!view.declared || view.valid) return [];
|
|
329
|
+
return [finding('FAC-SEM-133', KEY_PATH, `the load declaration is invalid (${view.errors.join('; ')}); a reader treats it as not declared and reports it (DEC-0104)`)];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The `extensions` entry a service declares (DEC-0104): where it serves its service-load/1 report (an origin path,
|
|
334
|
+
* served under the descriptor token like the usage report), the report's window and the queue's limits per
|
|
335
|
+
* (agentRef, context). Throws ServiceError on a declaration a reader would treat as not declared.
|
|
336
|
+
* @param {{ path: string, windowSeconds: number, limits: QueueLimits }} declaration
|
|
337
|
+
* @returns {Record<string, { path: string, windowSeconds: number, limits: QueueLimits }>}
|
|
338
|
+
*/
|
|
339
|
+
export function loadExtension({ path, windowSeconds, limits } = {}) {
|
|
340
|
+
const value = { path, windowSeconds, limits: isObject(limits) ? { ...limits } : limits };
|
|
341
|
+
const problems = declarationProblems(value);
|
|
342
|
+
if (problems.length) throw new ServiceError(`Load declaration: ${problems.join('; ')}.`);
|
|
343
|
+
return { [LOAD_EXTENSION_KEY]: value };
|
|
344
|
+
}
|