@passioncode-ai/fabric-agent-adapter 0.79.1 → 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 +134 -0
- 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/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- 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 +5 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
- 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 +9 -1
- 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/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 +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
- 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 +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -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-board-session.mjs +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
- 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-descriptor.mjs +41 -3
- 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-peer-owner.mjs +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
- 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_board_session.py +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
- 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_descriptor.py +35 -2
- 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_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 +20 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
- 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 +340 -24
- 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 +3 -1
- 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 +5 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -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 +21 -21
- 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 +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- 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 +143 -36
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
- 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
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs
CHANGED
|
@@ -45,7 +45,7 @@ import http from 'node:http';
|
|
|
45
45
|
import net from 'node:net';
|
|
46
46
|
import os from 'node:os';
|
|
47
47
|
|
|
48
|
-
export const PEER_OWNER_VERSION = '1.1.0+kit.
|
|
48
|
+
export const PEER_OWNER_VERSION = '1.1.0+kit.4'; // the Python twin's, Observatory's 1.1.0 with the kit's differences
|
|
49
49
|
export const THIS_ACCOUNT = 'this-account';
|
|
50
50
|
export const ANOTHER_ACCOUNT = 'another-account';
|
|
51
51
|
export const SYSTEM = 'system';
|
|
@@ -154,6 +154,10 @@ export function parseNetstat(text) {
|
|
|
154
154
|
const at = names.indexOf('process:pid');
|
|
155
155
|
const leading = at - names.slice(0, at).filter((w) => w === 'Address').length;
|
|
156
156
|
const trailing = names.length - at - 1;
|
|
157
|
+
// The caller's own connection is in any table it can see: a header and no tcp row is the restricted table (FAA-273).
|
|
158
|
+
if (!lines.slice(header + 1).some((line) => (line.split(/\s+/).filter(Boolean)[0] ?? '').startsWith('tcp'))) {
|
|
159
|
+
throw new TableUnreadable(`${TABLE_RESTRICTED}: netstat printed only its header to this process`);
|
|
160
|
+
}
|
|
157
161
|
const rows = [];
|
|
158
162
|
for (const line of lines.slice(header + 1)) {
|
|
159
163
|
const tok = line.split(/\s+/).filter(Boolean);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -43,6 +43,10 @@ export {
|
|
|
43
43
|
} from './fabric-protocols.mjs';
|
|
44
44
|
export { ACTIONS_EXTENSION_KEY, actionDecision, actionRules, actionsExtension, actionsExtensionRules, actionsView, decisionShape } from './fabric-actions.mjs';
|
|
45
45
|
export { CONNECTIONS_EXTENSION_KEY, connectionsExtension, connectionsRules, connectionsView } from './fabric-connections.mjs';
|
|
46
|
+
export { // the call queue and the load report (DEC-0104, FAA-289)
|
|
47
|
+
DEFAULT_RETRY_AFTER_SECONDS, LOAD_EXTENSION_KEY, LOAD_REPORT_TYPE, QUEUE_FULL_MESSAGE, QUEUE_LENGTH_MAX, admitJob, jobCallerKey, jobQueueRules,
|
|
48
|
+
loadExtension, loadExtensionRules, loadReportProblems, loadReportRules, loadView, nextQueuedJob, queueStatusMessage, readQueuePosition,
|
|
49
|
+
} from './fabric-load.mjs';
|
|
46
50
|
export {
|
|
47
51
|
EXIT_ALREADY_RUNNING,
|
|
48
52
|
LOCK_WAIT_SUPERVISED_SECONDS,
|
|
@@ -118,7 +122,9 @@ export {
|
|
|
118
122
|
fabricButtonPresence,
|
|
119
123
|
placementOf,
|
|
120
124
|
remoteOriginProblems,
|
|
125
|
+
portListenerRefusal,
|
|
121
126
|
portOf,
|
|
127
|
+
portOwner,
|
|
122
128
|
NO_SOURCE_REPOSITORY,
|
|
123
129
|
namesRepository,
|
|
124
130
|
sourceRefusal,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -115,12 +115,33 @@ export function systemdUnit(unit, programArguments, { workingDirectory, environm
|
|
|
115
115
|
|
|
116
116
|
const systemdDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'systemd', 'user');
|
|
117
117
|
|
|
118
|
+
// FAA-253: the well-known answer at `origin`, polled until this OS account's listener answers as serviceId.instance, as
|
|
119
|
+
// launchdInstall does (the pinned contract's Install row: every supervisor polls). Another account's listener, or one
|
|
120
|
+
// whose owner cannot be told, is never a successful install (DEC-0154 HA-2).
|
|
121
|
+
async function pollInstalled(origin, { serviceId, instance = 'default', timeout = 40, fetchWellKnown = wellKnownOf, sleep = pause, clock = Date.now } = {}) {
|
|
122
|
+
if (!serviceId) throw new ServiceError(`polling the install at ${origin} needs the serviceId it must answer as`);
|
|
123
|
+
const deadline = clock() + Math.max(0, timeout) * 1000;
|
|
124
|
+
let foreign = null;
|
|
125
|
+
for (;;) {
|
|
126
|
+
const doc = await fetchWellKnown(origin);
|
|
127
|
+
foreign = doc instanceof ForeignUserAnswer ? doc.reason : null;
|
|
128
|
+
if (!foreign && doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
129
|
+
if (clock() >= deadline) break;
|
|
130
|
+
await sleep(500);
|
|
131
|
+
}
|
|
132
|
+
if (foreign) throw new ServiceError(`${origin} is not this service's: ${foreign} (DEC-0154 HA-2); the port is another OS account's or its owner cannot be told.`);
|
|
133
|
+
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
/**
|
|
119
137
|
* Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
|
|
120
138
|
* The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so the
|
|
121
|
-
* new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on).
|
|
139
|
+
* new version is what starts once they enable it — unless `forceEnable` (the operator asked for it on). With `origin`
|
|
140
|
+
* (and `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to the unit file once this
|
|
141
|
+
* account's listener answers (FAA-253); without `origin` it stays synchronous.
|
|
122
142
|
*/
|
|
123
|
-
export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false
|
|
143
|
+
export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defaultRun, platform = process.platform, forceEnable = false,
|
|
144
|
+
origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
|
|
124
145
|
if (platform !== 'linux') throw new ServiceError('systemd is the supervisor on Linux only.');
|
|
125
146
|
if (!UNIT.test(unit)) throw new ServiceError(`unit must match ${UNIT.source}`);
|
|
126
147
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
@@ -128,10 +149,14 @@ export function systemdInstall(unit, text, { unitDir = systemdDir(), run = defau
|
|
|
128
149
|
const state = fs.existsSync(file) ? String(run(['systemctl', '--user', 'is-enabled', unit])?.stdout ?? '').trim() : '';
|
|
129
150
|
atomicWrite(file, text, 0o644);
|
|
130
151
|
checkRun(run(['systemctl', '--user', 'daemon-reload']), 'systemctl --user daemon-reload');
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
const started = !(state === 'disabled' && !forceEnable);
|
|
153
|
+
if (started) {
|
|
154
|
+
checkRun(run(['systemctl', '--user', 'enable', unit]), 'systemctl --user enable');
|
|
155
|
+
checkRun(run(['systemctl', '--user', 'restart', unit]), 'systemctl --user restart');
|
|
156
|
+
}
|
|
157
|
+
if (origin === undefined) return file;
|
|
158
|
+
// FAA-253: with an origin it resolves once this account's listener answers; a unit kept off is not polled.
|
|
159
|
+
return started ? pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock }).then(() => file) : Promise.resolve(file);
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
/** Stop it and keep it off across logins (`disable --now`), then remove the unit. */
|
|
@@ -203,9 +228,12 @@ const TASK_DISABLED = /<Settings>(?:(?!<\/Settings>)[\s\S])*<Enabled>false<\/Ena
|
|
|
203
228
|
|
|
204
229
|
/**
|
|
205
230
|
* Create (or replace) the task from its XML without administrator rights, then (re)start it so the new definition
|
|
206
|
-
* is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`.
|
|
231
|
+
* is what runs. A task already installed and disabled stays off (LC-14) unless `forceEnable`. With `origin` (and
|
|
232
|
+
* `serviceId`) it then polls the well-known answer as launchdInstall does and resolves to it (FAA-253); a task kept off
|
|
233
|
+
* resolves to undefined, unpolled. Without `origin` it stays synchronous.
|
|
207
234
|
*/
|
|
208
|
-
export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false
|
|
235
|
+
export function taskInstall(task, xmlText, { run = defaultRun, platform = process.platform, forceEnable = false,
|
|
236
|
+
origin, serviceId, instance, timeout, fetchWellKnown, sleep, clock } = {}) {
|
|
209
237
|
if (platform !== 'win32') throw new ServiceError('Task Scheduler is the supervisor on Windows only.');
|
|
210
238
|
const query = run(['schtasks', '/Query', '/TN', task, '/XML']);
|
|
211
239
|
const existed = query?.status === 0;
|
|
@@ -221,10 +249,11 @@ export function taskInstall(task, xmlText, { run = defaultRun, platform = proces
|
|
|
221
249
|
}
|
|
222
250
|
if (keepOff) {
|
|
223
251
|
checkRun(run(['schtasks', '/Change', '/TN', task, '/DISABLE']), 'schtasks /Change /DISABLE');
|
|
224
|
-
return;
|
|
252
|
+
return origin === undefined ? undefined : Promise.resolve(undefined);
|
|
225
253
|
}
|
|
226
254
|
if (existed) run(['schtasks', '/End', '/TN', task]);
|
|
227
255
|
checkRun(run(['schtasks', '/Run', '/TN', task]), 'schtasks /Run');
|
|
256
|
+
return origin === undefined ? undefined : pollInstalled(origin, { serviceId, instance, timeout, fetchWellKnown, sleep, clock });
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
/** End the running task, then delete it. */
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs
CHANGED
|
@@ -70,7 +70,14 @@ function byProject(receipts, first, last) {
|
|
|
70
70
|
const close = ({ cost, ...rest }) => ({ ...rest, costUsd: rest.calls && rest.unpricedCalls === rest.calls ? null : Math.round(cost * 1e6) / 1e6 });
|
|
71
71
|
return [...projects.keys()].sort().map((key) => {
|
|
72
72
|
const { runs, ...row } = projects.get(key);
|
|
73
|
-
|
|
73
|
+
// A run's cost rounds as the project's does but never above its exact cost: each run and the project round apart,
|
|
74
|
+
// so many runs rounding up summed past the project and failed FAC-SEM-025 (board request f5753c5a).
|
|
75
|
+
const closeRun = (run) => {
|
|
76
|
+
const out = close(run);
|
|
77
|
+
if (out.costUsd !== null && out.costUsd > run.cost) out.costUsd = Math.round((out.costUsd - 0.000001) * 1e6) / 1e6;
|
|
78
|
+
return out;
|
|
79
|
+
};
|
|
80
|
+
const taskRuns = [...runs.keys()].sort().map((id) => closeRun(runs.get(id)));
|
|
74
81
|
return { ...close(row), ...(taskRuns.length ? { taskRuns } : {}) };
|
|
75
82
|
});
|
|
76
83
|
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -168,6 +168,7 @@ class Responder(SlotKeeper):
|
|
|
168
168
|
self.state = "idle"
|
|
169
169
|
self.wait_until = 0.0
|
|
170
170
|
self.stopping = False
|
|
171
|
+
self.shutting_down, self.draining = threading.Event(), None # shutdown began; it found work to drain (FAA-186)
|
|
171
172
|
self.claim_keys: Dict[str, str] = {}
|
|
172
173
|
self._probing: set = set() # moved slots whose move-back claim lost its answer: asked again under the same key
|
|
173
174
|
self._said: Dict[str, str] = {}
|
|
@@ -982,8 +983,8 @@ class Responder(SlotKeeper):
|
|
|
982
983
|
def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
|
|
983
984
|
"""Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
|
|
984
985
|
(its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
|
|
985
|
-
self.stopping = True
|
|
986
|
-
|
|
986
|
+
self.stopping, work = True, self.current
|
|
987
|
+
self.draining = work is not None; self.shutting_down.set() # noqa: E702 - published before it waits (FAA-186)
|
|
987
988
|
if work is not None and not work.done.wait(drain):
|
|
988
989
|
work.ctx.stopped.set()
|
|
989
990
|
if work is not None and work.done.is_set() and self.session is not None:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py
CHANGED
|
@@ -247,3 +247,5 @@ class SlotKeeper:
|
|
|
247
247
|
self._retry_later(capability, entries or [], exc)
|
|
248
248
|
if exc.code in SESSION_CODES:
|
|
249
249
|
raise
|
|
250
|
+
if exc.code == "idempotency_window_expired":
|
|
251
|
+
self._status() # the epoch moved: read it before the retry, which no held slot would do (FAA-186)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
`degraded[]`. Part of the service kit (FAA-62, layer L3). Standard library only."""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import bisect
|
|
7
8
|
import ipaddress
|
|
8
9
|
import re
|
|
10
|
+
import unicodedata
|
|
9
11
|
import urllib.parse
|
|
10
12
|
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
|
11
13
|
|
|
@@ -173,6 +175,92 @@ _FORBIDDEN_HOST = set("\x00\t\n\r #/:<>?@[\\]^|")
|
|
|
173
175
|
_FORBIDDEN_DOMAIN = _FORBIDDEN_HOST | set(chr(c) for c in range(0x20)) | {"%", "\x7f"}
|
|
174
176
|
|
|
175
177
|
|
|
178
|
+
# FAA-98: UTS46 narrowed, not ported. A non-ASCII host is refused only where every Node from 20 to 26 refuses it, so
|
|
179
|
+
# the doubt stays on the closed side (its URL's credentials are read). The two tables are measured by
|
|
180
|
+
# test/fixtures/idna/make_idna_tables.mjs (idna-tables.json, held equal by test_the_idna_tables_are_the_measured_ones):
|
|
181
|
+
# code points refused in every context, and code points removed between letters. The contextual rules read Unicode 3.2
|
|
182
|
+
# classes (`unicodedata.ucd_3_2_0`) and skip what that Unicode does not know, since Node's own UTS46 data lags its
|
|
183
|
+
# Unicode: an Arabic letter of Unicode 14 is no right-to-left letter to it.
|
|
184
|
+
_IDNA_DISALLOWED = (
|
|
185
|
+
(0x80, 0xa0), (0xa8, 0xa8), (0xaf, 0xaf), (0xb4, 0xb4), (0xb8, 0xb8), (0x2d8, 0x2dd), (0x37a, 0x37a),
|
|
186
|
+
(0x384, 0x385), (0x600, 0x605), (0x61c, 0x61c), (0x6dd, 0x6dd), (0x70f, 0x70f), (0x890, 0x891), (0x8e2, 0x8e2),
|
|
187
|
+
(0x1680, 0x1680), (0x1fbd, 0x1fbd), (0x1fbf, 0x1fc1), (0x1fcd, 0x1fcf), (0x1fdd, 0x1fdf), (0x1fed, 0x1fee),
|
|
188
|
+
(0x1ffd, 0x1ffe), (0x2000, 0x200a), (0x200e, 0x200f), (0x2017, 0x2017), (0x2024, 0x2026), (0x2028, 0x202f),
|
|
189
|
+
(0x203e, 0x203e), (0x2047, 0x2049), (0x205f, 0x205f), (0x2066, 0x2069), (0x2100, 0x2101), (0x2105, 0x2106),
|
|
190
|
+
(0x2488, 0x249b), (0x2a74, 0x2a74), (0x2ff0, 0x3000), (0x309b, 0x309c), (0x31ef, 0x31ef), (0x33c2, 0x33c2),
|
|
191
|
+
(0x33c7, 0x33c7), (0x33d8, 0x33d8), (0xe000, 0xf8ff), (0xfc5e, 0xfc63), (0xfdfa, 0xfdfb), (0xfe12, 0xfe13),
|
|
192
|
+
(0xfe16, 0xfe16), (0xfe19, 0xfe19), (0xfe30, 0xfe30), (0xfe47, 0xfe4c), (0xfe52, 0xfe52), (0xfe55, 0xfe56),
|
|
193
|
+
(0xfe5f, 0xfe5f), (0xfe64, 0xfe65), (0xfe68, 0xfe68), (0xfe6a, 0xfe6b), (0xfe70, 0xfe70), (0xfe72, 0xfe72),
|
|
194
|
+
(0xfe74, 0xfe74), (0xfe76, 0xfe76), (0xfe78, 0xfe78), (0xfe7a, 0xfe7a), (0xfe7c, 0xfe7c), (0xfe7e, 0xfe7e),
|
|
195
|
+
(0xff03, 0xff03), (0xff05, 0xff05), (0xff0f, 0xff0f), (0xff1a, 0xff1a), (0xff1c, 0xff1c), (0xff1e, 0xff20),
|
|
196
|
+
(0xff3b, 0xff3e), (0xff5c, 0xff5c), (0xffe3, 0xffe3), (0xfff9, 0xfffd), (0x110bd, 0x110bd), (0x110cd, 0x110cd),
|
|
197
|
+
(0x13430, 0x1343f), (0x1f100, 0x1f100), (0xe0001, 0xe0001), (0xe0020, 0xe007f), (0xf0000, 0xffffd),
|
|
198
|
+
(0x100000, 0x10fffd),
|
|
199
|
+
)
|
|
200
|
+
_IDNA_IGNORED = (
|
|
201
|
+
(0xad, 0xad), (0x34f, 0x34f), (0x180b, 0x180d), (0x180f, 0x180f), (0x200b, 0x200b), (0x2060, 0x2060),
|
|
202
|
+
(0x2064, 0x2064), (0xfe00, 0xfe0f), (0xfeff, 0xfeff), (0x1bca0, 0x1bca3), (0xe0100, 0xe01ef),
|
|
203
|
+
)
|
|
204
|
+
_IDNA_DOTS = re.compile("[.\u3002\uff0e\uff61]")
|
|
205
|
+
_U32 = unicodedata.ucd_3_2_0
|
|
206
|
+
_RTL_OK = {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}
|
|
207
|
+
_JOINERS = "\u200c\u200d"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _in_ranges(ranges: Sequence[tuple], cp: int) -> bool:
|
|
211
|
+
i = bisect.bisect_right(ranges, (cp, 0x10ffff)) - 1
|
|
212
|
+
return i >= 0 and ranges[i][0] <= cp <= ranges[i][1]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _known(c: str) -> bool:
|
|
216
|
+
return _U32.category(c) != "Cn" and unicodedata.category(c) != "Cn"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _joiner_refused(label: str, i: int) -> bool:
|
|
220
|
+
"""CheckJoiners as Node 20 to 26 all read it: they find a joining context, or a virama, anywhere in the label
|
|
221
|
+
(`\u0628a\u200c\u0627` and `\u0628\u200c\u0627\u200d` parse), so only a joiner with nothing but ASCII beside it
|
|
222
|
+
in its label has none for certain."""
|
|
223
|
+
return all(c.isascii() for j, c in enumerate(label) if j != i)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _bidi_refused(label: str) -> bool:
|
|
227
|
+
"""RFC 5893 as Node 20 to 26 all read it: only a label holding R, AL or AN is judged, and its first code point is
|
|
228
|
+
not (Node 26 alone refuses a label opening with a digit or a hyphen)."""
|
|
229
|
+
if not label or not all(_known(c) for c in label) or any(c in _JOINERS for c in label):
|
|
230
|
+
return False # Node 20 to 26 pass a label holding a joiner over (`\u05d0a\u094d\u200d` parses)
|
|
231
|
+
k = [_U32.bidirectional(c) for c in label]
|
|
232
|
+
if not any(x in ("R", "AL", "AN") for x in k):
|
|
233
|
+
return False
|
|
234
|
+
body = list(k)
|
|
235
|
+
while body and body[-1] == "NSM":
|
|
236
|
+
body.pop()
|
|
237
|
+
if not body or body[-1] not in ("R", "AL", "EN", "AN"):
|
|
238
|
+
return True
|
|
239
|
+
return k[0] in ("R", "AL") and (any(x not in _RTL_OK for x in k) or ("EN" in k and "AN" in k))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _idna_host(host: str) -> Optional[str]:
|
|
243
|
+
"""None where WHATWG's domain-to-ASCII refuses a non-ASCII host for certain; else the host as UTS46 maps it, read
|
|
244
|
+
as NFKC with case folding (exact enough for the ASCII rules that follow, which read digits and dots)."""
|
|
245
|
+
if any(_in_ranges(_IDNA_DISALLOWED, ord(c)) for c in host):
|
|
246
|
+
return None
|
|
247
|
+
kept = "".join(c for c in host if not _in_ranges(_IDNA_IGNORED, ord(c)))
|
|
248
|
+
if kept == "":
|
|
249
|
+
return None # nothing left: an empty host
|
|
250
|
+
if not all(_known(c) for c in kept):
|
|
251
|
+
return host # a code point this Python's Unicode does not know: the contextual rules are not read
|
|
252
|
+
mapped = unicodedata.normalize("NFC", unicodedata.normalize("NFKC", unicodedata.normalize("NFKC", kept).casefold()))
|
|
253
|
+
for label in _IDNA_DOTS.split(mapped):
|
|
254
|
+
first = label[:1]
|
|
255
|
+
if first and first not in "\u17b4\u17b5" and _U32.category(first)[0] == "M" and unicodedata.category(first)[0] == "M":
|
|
256
|
+
return None # a label opening with a mark (U+17B4 and U+17B5 are ignored by the newer Nodes)
|
|
257
|
+
if any(c in _JOINERS and _joiner_refused(label, i) for i, c in enumerate(label)):
|
|
258
|
+
return None
|
|
259
|
+
if _bidi_refused(label):
|
|
260
|
+
return None
|
|
261
|
+
return _IDNA_DOTS.sub(".", mapped)
|
|
262
|
+
|
|
263
|
+
|
|
176
264
|
def _ipv4_number(part: str) -> Optional[int]:
|
|
177
265
|
if part == "":
|
|
178
266
|
return None # an empty part fails WHATWG's IPv4 parser (`1..2`); `0x` alone is 0
|
|
@@ -198,10 +286,16 @@ def _domain_ok(host: str) -> bool:
|
|
|
198
286
|
return False
|
|
199
287
|
if any(c in _FORBIDDEN_DOMAIN for c in host):
|
|
200
288
|
return False
|
|
201
|
-
# A non-ASCII host goes through UTS46 in WHATWG; this module
|
|
202
|
-
# valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
289
|
+
# A non-ASCII host goes through UTS46 in WHATWG; this module reads it narrowly (_idna_host, FAA-98). When unsure,
|
|
290
|
+
# the host is taken as valid, so its URL's credentials are read: the doubt then hides a list rather than publish one
|
|
291
|
+
# (round-2 reading). A host UTS46 maps to ASCII is read by the ASCII rules below (`127.0.0.1` is an address).
|
|
203
292
|
if any(ord(c) > 0x7f for c in host):
|
|
204
|
-
|
|
293
|
+
mapped = _idna_host(host)
|
|
294
|
+
if mapped is None:
|
|
295
|
+
return False
|
|
296
|
+
if not mapped.isascii() or any(c in _FORBIDDEN_DOMAIN for c in mapped):
|
|
297
|
+
return True
|
|
298
|
+
host = mapped
|
|
205
299
|
labels = host.split(".")
|
|
206
300
|
if labels and labels[-1] == "" and len(labels) > 1:
|
|
207
301
|
labels = labels[:-1]
|