@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1
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 +122 -3
- package/README.md +6 -3
- package/SECURITY.md +4 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -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 +2 -2
- 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/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
|
@@ -7,7 +7,7 @@ import crypto from 'node:crypto';
|
|
|
7
7
|
import { ServiceError } from './fabric-base.mjs';
|
|
8
8
|
import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
|
|
9
9
|
import { actionDecision, decisionShape, jobCapability } from './fabric-actions.mjs'; // DEC-0121
|
|
10
|
-
import { EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandateCovers, mandateProblem, withoutSource } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
|
|
10
|
+
import { EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, isAgentId, mandateCovers, mandateProblem, withoutSource } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
|
|
11
11
|
import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
12
12
|
import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
|
|
13
13
|
|
|
@@ -277,6 +277,25 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
|
|
|
277
277
|
/** The project of a call whose project could not be read: never in a mandate's projects (FAA-139). */
|
|
278
278
|
export const UNREADABLE_PROJECT = '\u0000unreadable';
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* The caller's agent id when a service's own hook answered something no chain can hold (an id the registry refuses, not
|
|
282
|
+
* a string, or the hook threw): passed as callerAgent it matches no agent, so every mandate reads
|
|
283
|
+
* mandate-not-for-this-call (fail closed, FAA-135). Never an agent id (isAgentId refuses it).
|
|
284
|
+
*/
|
|
285
|
+
export const UNREADABLE_AGENT = '\u0000unreadable-agent';
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* FAA-135: the caller's agent id as a service's own hook named it from a per-agent credential (contract delegation.md,
|
|
289
|
+
* `surfaces.mcp.auth: own`), read as the mandate chain binds it: pass the answer as actionGate's and jobStep's
|
|
290
|
+
* `callerAgent`. null or undefined (no per-agent credential) is null, and the chain's end alone binds the mandate, as
|
|
291
|
+
* before; an agent id the registry accepts is that id; anything else is UNREADABLE_AGENT. A hook that throws: pass
|
|
292
|
+
* UNREADABLE_AGENT. The Python twin's McpToolServer(identify_agent=) reads it so; never a name the call carries.
|
|
293
|
+
*/
|
|
294
|
+
export function identifiedAgent(value) {
|
|
295
|
+
if (value === null || value === undefined) return null;
|
|
296
|
+
return isAgentId(value) ? value : UNREADABLE_AGENT;
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
/** A spend estimate as a mandate reads it (FAA-139): a finite number stays, anything else is none (a ceiling refuses it). */
|
|
281
300
|
export function mandateSpend(value) {
|
|
282
301
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs
CHANGED
|
@@ -43,6 +43,8 @@ export {
|
|
|
43
43
|
mandateSpend,
|
|
44
44
|
keptStep,
|
|
45
45
|
UNREADABLE_PROJECT,
|
|
46
|
+
UNREADABLE_AGENT,
|
|
47
|
+
identifiedAgent,
|
|
46
48
|
actionRefusalToolResult,
|
|
47
49
|
usageFindings,
|
|
48
50
|
usageToward,
|
|
@@ -271,6 +273,9 @@ export class JobStore {
|
|
|
271
273
|
requestInput(jobId, inputRequests, statusMessage, { steps } = {}) {
|
|
272
274
|
const entries = Object.entries(inputRequests ?? {});
|
|
273
275
|
if (!entries.length || Array.isArray(inputRequests) || typeof inputRequests !== 'object') throw new InteropError('input_required needs a JSON object of at least one input request.'); // a JSON object of requests, first (unit 38, 40)
|
|
276
|
+
if (steps !== null && steps !== undefined && (typeof steps !== 'object' || Array.isArray(steps))) { // FAA-196 (26)
|
|
277
|
+
throw new InteropError('steps maps an input request\'s key to its approval step: a JSON object, or none.');
|
|
278
|
+
}
|
|
274
279
|
const kept = Object.create(null); // the value checked is the value kept (seam 34); a key __proto__ stays a key (35)
|
|
275
280
|
for (const [key, step] of Object.entries(steps ?? {})) {
|
|
276
281
|
kept[key] = Object.hasOwn(inputRequests, key) && step !== null && step !== undefined ? keptStep(step) : {};
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs
CHANGED
|
@@ -172,13 +172,26 @@ export class InstanceLock {
|
|
|
172
172
|
return this;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Let the lock go. Never throws, so a release in a `finally` never replaces the caller's own outcome (FAA-196 (11)):
|
|
177
|
+
* the descriptor and the name's server are forgotten before they are closed, so a retry never closes a descriptor
|
|
178
|
+
* reused since, and a close that fails still closes the server.
|
|
179
|
+
*/
|
|
175
180
|
release() {
|
|
176
181
|
if (this.timer !== null) clearInterval(this.timer);
|
|
177
|
-
|
|
178
|
-
if (this.server) this.server.close();
|
|
182
|
+
const { fd, server } = this;
|
|
179
183
|
this.timer = null;
|
|
180
184
|
this.fd = null;
|
|
181
185
|
this.server = null;
|
|
186
|
+
try {
|
|
187
|
+
if (fd !== null) fs.closeSync(fd);
|
|
188
|
+
} catch { // the descriptor is gone either way; the kernel frees the lock with it
|
|
189
|
+
} finally {
|
|
190
|
+
try {
|
|
191
|
+
server?.close();
|
|
192
|
+
} catch { // nothing to add to the caller's own outcome
|
|
193
|
+
}
|
|
194
|
+
}
|
|
182
195
|
}
|
|
183
196
|
}
|
|
184
197
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs
CHANGED
|
@@ -193,6 +193,11 @@ const invalid = (detail) => ({ ok: false, code: MANDATE_INVALID, data: { detail
|
|
|
193
193
|
* being where the operator said the words when the mandate carries it (DEC-0126 OA-6). `callerAgent` is
|
|
194
194
|
* passed only when the callee authenticated the caller's agent id; without one the chain's end alone binds it. A missing
|
|
195
195
|
* or non-object `call` is read as a call that names nothing (the reference would throw).
|
|
196
|
+
*
|
|
197
|
+
* A call is a plain object of its own data properties (FAA-196 (19)): its members are read as own members only, where
|
|
198
|
+
* the contract's reference reads plain properties, so an inherited or getter `project`, `action` or `spendUsd` is none
|
|
199
|
+
* here and may be covered where the reference refuses it. Build the call as an object literal of the values the work
|
|
200
|
+
* names, as the kit's own readers do (the board's `covers`, actionGate, jobStep).
|
|
196
201
|
*/
|
|
197
202
|
export function mandateCovers(input) {
|
|
198
203
|
const field = (key) => member(input, key);
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs
CHANGED
|
@@ -86,6 +86,26 @@ function checkAttribution(projectId, taskRunId) {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Why a base URL is not sent the same by both twins, read from the URL as sent (its ends trimmed), or null: a backslash
|
|
90
|
+
// anywhere (new URL reads it as a slash), and after a `//` authority a path holding anything but URL path characters
|
|
91
|
+
// and %XX escapes (Python refused a space or a character outside ASCII where new URL percent-encoded it, and new URL
|
|
92
|
+
// removes a tab or a line break) or a dot segment, escaped or not (new URL collapses it). The Python twin's
|
|
93
|
+
// base_url_problem is the same rule (FAA-187 (18), kit-openrouter-cases.json).
|
|
94
|
+
export function baseUrlProblem(base) {
|
|
95
|
+
const text = String(base).replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '');
|
|
96
|
+
if (text.includes('\\')) return 'the base URL holds a backslash';
|
|
97
|
+
if (/[\t\n\r]/.test(text)) return 'the base URL holds a tab or a line break'; // new URL removes them and sends the rest
|
|
98
|
+
const rest = text.slice(text.indexOf(':') + 1);
|
|
99
|
+
if (text.indexOf(':') < 0 || !rest.startsWith('//')) return null; // no authority as written: checkUrl reads it
|
|
100
|
+
const after = rest.slice(2);
|
|
101
|
+
const path = after.includes('/') ? after.slice(after.indexOf('/')) : '';
|
|
102
|
+
if (!/^(?:\/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f]{2})*)*$/.test(path)) {
|
|
103
|
+
return 'the base URL\'s path holds a character outside URL path characters and %XX escapes';
|
|
104
|
+
}
|
|
105
|
+
if (path.split('/').slice(1).some((seg) => ['.', '..'].includes(seg.replace(/%2e/gi, '.')))) return 'the base URL\'s path holds a dot segment';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
export class Client {
|
|
90
110
|
constructor({ ceilingUsd, ledger, prices, keyEnv = 'OPENROUTER_API_KEY', window = 'day', baseUrl = BASE_URL,
|
|
91
111
|
attribution = null, projectId, unmeasuredCallUsd = null, deadlineS = 120, retries = 1, env = process.env,
|
|
@@ -108,13 +128,14 @@ export class Client {
|
|
|
108
128
|
if (/[?#]/.test(baseUrl)) {
|
|
109
129
|
throw new OpenRouterError('the base URL holds no query or fragment', { kind: 'config' });
|
|
110
130
|
}
|
|
131
|
+
const problem = baseUrlProblem(baseUrl);
|
|
132
|
+
if (problem) throw new OpenRouterError(`${problem} (both kits would not send it alike)`, { kind: 'config' });
|
|
111
133
|
http.checkUrl(baseUrl);
|
|
112
134
|
checkAttribution(projectId, undefined);
|
|
113
135
|
Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
|
|
114
136
|
this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
|
|
115
|
-
// The base URL as checkUrl read it:
|
|
116
|
-
//
|
|
117
|
-
// composed them in 0.55.0 (0.56.0 product r20, unit r22, seam r22; the Python twin refuses what urllib cannot send).
|
|
137
|
+
// The base URL as checkUrl read it: its ends trimmed of C0 controls and spaces, so `.../v1/ ` is composed as
|
|
138
|
+
// `.../v1/chat/completions` (0.56.0 product r20); a tab or line break inside is refused above since FAA-187 (18).
|
|
118
139
|
this.baseUrl = baseUrl.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/\/+$/, '');
|
|
119
140
|
this.env = env;
|
|
120
141
|
this.transport = transport;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// A service's operations digests (contract DEC-0153, docs/specification/ops-digest.md at the pin): every record of its
|
|
2
|
+
// operations log counted by operation and outcome in the hour it happened, one ops.digest.hourly@1 line for each hour
|
|
3
|
+
// the service observed itself running in (an empty one included), and one ops.digest.daily@1 line for each closed UTC
|
|
4
|
+
// day that has hourly lines. Counts only: no log line, no path, no identifier (OD-7). foldHour and foldDay are the
|
|
5
|
+
// contract's reference folds (src/ops-digest.ts, its vectors in test/fixtures/ops-digest/); opsDigestRules is
|
|
6
|
+
// FAC-SEM-166 and FAC-SEM-167; OpsDigest keeps the files (OD-1, OD-5, OD-6); opsDigestExtension writes the
|
|
7
|
+
// descriptor's OD-9 declaration. Part of the Node service kit (FAA-206 part 2, layer L3); the twin of
|
|
8
|
+
// fabric_ops_digest.py. Node 20+, no dependency.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { ServiceError } from './fabric-base.mjs';
|
|
12
|
+
import { indexed, isObject } from './fabric-jsvalue.mjs';
|
|
13
|
+
import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
|
|
14
|
+
import { secretValue } from './fabric-secrets.mjs';
|
|
15
|
+
import { DAY_MS } from './fabric-time.mjs';
|
|
16
|
+
|
|
17
|
+
export const OPS_DIGEST_HOURLY = 'ops.digest.hourly@1';
|
|
18
|
+
export const OPS_DIGEST_DAILY = 'ops.digest.daily@1';
|
|
19
|
+
/** The descriptor's extension key (OD-9), protocol fabric-ops-digest/0.1. */
|
|
20
|
+
export const OPS_DIGEST_EXTENSION = 'https://fabric.passioncode.ai/agent-contract/extensions/ops-digest/0.1';
|
|
21
|
+
export const RISE_MIN_FAILED = 10;
|
|
22
|
+
export const RISE_FACTOR = 2;
|
|
23
|
+
/** An operation's name (OD-2), the contract's OP_NAME, and its length cap OP_NAME_MAX. */
|
|
24
|
+
export const OP_NAME = /^[a-z][A-Za-z0-9_]*(?:[.:/-][A-Za-z0-9_]+)*$/;
|
|
25
|
+
export const OP_NAME_MAX = 64;
|
|
26
|
+
export const OPS_MAX = 4096;
|
|
27
|
+
/** The overflow name the kit counts the rest under once a day holds 4095 named operations (OD-3). */
|
|
28
|
+
export const OVERFLOW_OP = 'ops.overflow';
|
|
29
|
+
export const OUTCOMES = Object.freeze(['ok', 'failed', 'unknown']);
|
|
30
|
+
const HOUR_MS = 3_600_000;
|
|
31
|
+
const WHOLE_HOUR = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):00:00(?:\.000)?Z$/;
|
|
32
|
+
const DAY = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/;
|
|
33
|
+
const SLASH = String.raw`(?:[\/\\]|%(?:25)?2[fF])`;
|
|
34
|
+
/** The contract's hasHomePath (src/activity-rules.ts at the pin). */
|
|
35
|
+
export const HOME_PATH = Object.freeze([
|
|
36
|
+
new RegExp(String.raw`(?:^|[^A-Za-z0-9]|%(?:25)?2[fF])(?:[Uu]sers|home)${SLASH}[A-Za-z0-9_]`),
|
|
37
|
+
/(?:^|[^A-Za-z0-9])-(?:[Uu]sers|home)-[A-Za-z0-9]/,
|
|
38
|
+
/(?:^|[\s"'=\/\\])~(?:[A-Za-z_][A-Za-z0-9_.-]*)?(?:[\/\\]|$)/,
|
|
39
|
+
]);
|
|
40
|
+
const PRINTABLE = /^[\x20-\x7e]+$/;
|
|
41
|
+
const SOURCE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
42
|
+
const FILE = /^(hourly|daily)-([0-9]{4})-([0-9]{2})\.jsonl$/;
|
|
43
|
+
|
|
44
|
+
/** An input no digest holds; its message names a credential-shaped or home-folder value only by its place. */
|
|
45
|
+
export class OpsDigestError extends ServiceError {}
|
|
46
|
+
|
|
47
|
+
export const hasHomePath = (value) => typeof value === 'string' && HOME_PATH.some((p) => p.test(value));
|
|
48
|
+
export const isRise = (failed, previous) => failed >= RISE_MIN_FAILED && failed >= RISE_FACTOR * Math.max(1, previous);
|
|
49
|
+
|
|
50
|
+
/** The instant of a whole UTC hour in the schema's form, or null (a 25th hour, a 30 February). */
|
|
51
|
+
export function wholeHour(value) {
|
|
52
|
+
const match = typeof value === 'string' ? WHOLE_HOUR.exec(value) : null;
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
const [year, month, day, hour] = match.slice(1).map(Number);
|
|
55
|
+
const at = new Date(0);
|
|
56
|
+
at.setUTCFullYear(year, month - 1, day);
|
|
57
|
+
at.setUTCHours(hour);
|
|
58
|
+
return at.getUTCFullYear() === year && at.getUTCMonth() === month - 1 && at.getUTCDate() === day && at.getUTCHours() === hour ? at.getTime() : null;
|
|
59
|
+
}
|
|
60
|
+
export const realDay = (value) => typeof value === 'string' && DAY.test(value) && wholeHour(`${value}T00:00:00Z`) !== null;
|
|
61
|
+
export const isoHour = (at) => new Date(at).toISOString();
|
|
62
|
+
export const dayOf = (at) => isoHour(at).slice(0, 10);
|
|
63
|
+
|
|
64
|
+
const count = (n) => (typeof n === 'number' && Number.isSafeInteger(n) && n >= 0 ? n : null);
|
|
65
|
+
const hidden = (value) => secretValue(value) || hasHomePath(value);
|
|
66
|
+
const shown = (value, instead) => (hidden(value) || !PRINTABLE.test(value) ? instead : value);
|
|
67
|
+
const opShown = (op) => (op.length <= OP_NAME_MAX && OP_NAME.test(op) ? shown(op, 'this operation') : 'this operation');
|
|
68
|
+
const byName = (a, b) => (a.op < b.op ? -1 : 1);
|
|
69
|
+
|
|
70
|
+
/** What OD-2 refuses in an operation's name (its grammar, OP_NAME_MAX included, or FAC-SEM-167); null when lawful. */
|
|
71
|
+
export function nameRefusal(op) {
|
|
72
|
+
if (typeof op !== 'string' || op.length > OP_NAME_MAX || !OP_NAME.test(op)) return "fails OD-2's name grammar";
|
|
73
|
+
return hidden(op) ? 'is refused by FAC-SEM-167' : null;
|
|
74
|
+
}
|
|
75
|
+
function allObjects(list, where) {
|
|
76
|
+
if (!Array.isArray(list)) throw new OpsDigestError(`${where} is not an array`);
|
|
77
|
+
for (let i = 0; i < list.length; i += 1) if (!isObject(list[i])) throw new OpsDigestError(`the item at ${i} of ${where} is not an object`);
|
|
78
|
+
}
|
|
79
|
+
function once(ops, where) {
|
|
80
|
+
const names = new Set();
|
|
81
|
+
ops.forEach((c, i) => {
|
|
82
|
+
if (typeof c.op !== 'string') throw new OpsDigestError(`the operation at ${i} of ${where} has no string name`);
|
|
83
|
+
if (names.has(c.op)) throw new OpsDigestError(`${shown(c.op, `the operation at ${i}`)} is named twice in ${where}`);
|
|
84
|
+
names.add(c.op);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
const sum = (ops) => ops.reduce((t, c) => ({ ok: t.ok + c.ok, failed: t.failed + c.failed, unknown: t.unknown + c.unknown }), { ok: 0, failed: 0, unknown: 0 });
|
|
88
|
+
function safe(digest) {
|
|
89
|
+
const counts = [digest.total, ...digest.ops].flatMap((c) => [c.ok, c.failed, c.unknown])
|
|
90
|
+
.concat(digest.risen.flatMap((r) => ('previous' in r ? [r.failed, r.previous] : [r.failed])));
|
|
91
|
+
if (!counts.every((n) => count(n) !== null)) throw new OpsDigestError('a sum is beyond 2^53 - 1');
|
|
92
|
+
return digest;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The digest of the hour starting at from (the contract's foldHour); throws OpsDigestError where it throws. */
|
|
96
|
+
export function foldHour(counts, from, previous) {
|
|
97
|
+
const start = wholeHour(from);
|
|
98
|
+
if (start === null) throw new OpsDigestError(`${typeof from === 'string' ? shown(from, 'from') : 'from'} is not a whole UTC hour`);
|
|
99
|
+
allObjects(counts, 'counts');
|
|
100
|
+
if (wholeHour(isoHour(start + HOUR_MS)) === null) throw new OpsDigestError(`the hour after ${shown(from, 'from')} is not a whole UTC hour the schema writes`);
|
|
101
|
+
const merged = new Map();
|
|
102
|
+
counts.forEach((c, i) => {
|
|
103
|
+
const refused = nameRefusal(c.op);
|
|
104
|
+
if (refused !== null) throw new OpsDigestError(`the operation at ${i} ${refused}: no hourly digest (OD-3)`);
|
|
105
|
+
if (![c.ok, c.failed, c.unknown].every((n) => count(n) !== null)) throw new OpsDigestError(`a count of ${shown(c.op, `the operation at ${i}`)} is not an integer from 0 to 2^53 - 1`);
|
|
106
|
+
const m = merged.get(c.op) ?? { op: c.op, ok: 0, failed: 0, unknown: 0 };
|
|
107
|
+
m.ok += c.ok; m.failed += c.failed; m.unknown += c.unknown;
|
|
108
|
+
merged.set(c.op, m);
|
|
109
|
+
});
|
|
110
|
+
if (merged.size > OPS_MAX) throw new OpsDigestError(`the hour counts ${merged.size} operations, more than ${OPS_MAX}: no hourly digest (OD-3)`);
|
|
111
|
+
const ops = [...merged.values()].sort(byName);
|
|
112
|
+
const absent = previous === null || previous === undefined;
|
|
113
|
+
if (!absent && !(isObject(previous) && typeof previous.to === 'string')) throw new OpsDigestError('the previous hour is neither absent nor a digest with a string to');
|
|
114
|
+
const adjoining = !absent && wholeHour(previous.to) === start ? previous.ops : [];
|
|
115
|
+
allObjects(adjoining, "the previous hour's ops");
|
|
116
|
+
once(adjoining, "the previous hour's ops");
|
|
117
|
+
const before = new Map(adjoining.map((c) => [c.op, c.failed]));
|
|
118
|
+
for (const c of ops) if (before.has(c.op) && count(before.get(c.op)) === null) throw new OpsDigestError(`the previous hour's failures of ${shown(c.op, 'an operation')} are not an integer from 0 to 2^53 - 1`);
|
|
119
|
+
const risen = ops.filter((c) => isRise(c.failed, before.get(c.op) ?? 0)).map((c) => ({ op: c.op, failed: c.failed, previous: before.get(c.op) ?? 0 }));
|
|
120
|
+
return safe({ schema: OPS_DIGEST_HOURLY, from: isoHour(start), to: isoHour(start + HOUR_MS), ops, total: sum(ops), risen });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The day's hourly digests folded into one (the contract's foldDay), or null for a day with no hour of its own. */
|
|
124
|
+
export function foldDay(day, hourly) {
|
|
125
|
+
if (!realDay(day)) throw new OpsDigestError(`${typeof day === 'string' ? shown(day, 'day') : 'day'} is not a real UTC day`);
|
|
126
|
+
allObjects(hourly, 'hourly');
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const mine = hourly.filter((h) => {
|
|
129
|
+
const at = wholeHour(h.from);
|
|
130
|
+
if (at === null || dayOf(at) !== day || seen.has(at)) return false;
|
|
131
|
+
seen.add(at);
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
if (mine.length === 0) return null;
|
|
135
|
+
const merged = new Map();
|
|
136
|
+
const rose = new Map();
|
|
137
|
+
for (const h of mine) {
|
|
138
|
+
for (const where of ['ops', 'risen']) {
|
|
139
|
+
allObjects(h[where], `${h.from}'s ${where}`);
|
|
140
|
+
h[where].forEach((c, i) => {
|
|
141
|
+
const refused = nameRefusal(c.op);
|
|
142
|
+
if (refused !== null) throw new OpsDigestError(`the operation at ${i} of ${h.from}'s ${where} ${refused}: no daily digest (OD-3)`);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const c of h.ops) {
|
|
146
|
+
if (![c.ok, c.failed, c.unknown].every((n) => count(n) !== null)) throw new OpsDigestError(`a count of ${shown(c.op, 'an operation')} in ${h.from} is not an integer from 0 to 2^53 - 1`);
|
|
147
|
+
const m = merged.get(c.op) ?? { op: c.op, ok: 0, failed: 0, unknown: 0 };
|
|
148
|
+
m.ok += c.ok; m.failed += c.failed; m.unknown += c.unknown;
|
|
149
|
+
merged.set(c.op, m);
|
|
150
|
+
}
|
|
151
|
+
once(h.risen, `the risen of ${h.from}`);
|
|
152
|
+
for (const r of h.risen) {
|
|
153
|
+
if (count(r.failed) === null) throw new OpsDigestError(`the rise of ${shown(r.op, 'an operation')} in ${h.from} is not an integer from 0 to 2^53 - 1`);
|
|
154
|
+
if (!h.ops.some((c) => c.op === r.op)) throw new OpsDigestError(`${shown(r.op, 'an operation')} rose in ${h.from} but that hour's ops does not count it`);
|
|
155
|
+
const x = rose.get(r.op) ?? { hours: 0, failed: 0 };
|
|
156
|
+
x.hours += 1; x.failed += r.failed;
|
|
157
|
+
rose.set(r.op, x);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (merged.size > OPS_MAX) throw new OpsDigestError(`the day folds ${merged.size} operations, more than ${OPS_MAX}: no daily digest (OD-3)`);
|
|
161
|
+
const ops = [...merged.values()].sort(byName);
|
|
162
|
+
const risen = [...rose.entries()].map(([op, x]) => ({ op, ...x })).sort((a, b) => b.failed - a.failed || byName(a, b));
|
|
163
|
+
return safe({ schema: OPS_DIGEST_DAILY, day, hours: mine.length, ops, total: sum(ops), risen });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** FAC-SEM-166 and FAC-SEM-167 on one digest of either kind; undefined for any other kind. */
|
|
167
|
+
export function opsDigestRules(kind, value) {
|
|
168
|
+
if (!(kind === 'ops-digest-hourly' || kind === 'ops-digest-daily')) return undefined;
|
|
169
|
+
const findings = [];
|
|
170
|
+
const window = (instancePath, message) => findings.push({ code: 'FAC-SEM-166', instancePath, message });
|
|
171
|
+
const named = (instancePath, op) => {
|
|
172
|
+
if (secretValue(op)) findings.push({ code: 'FAC-SEM-167', instancePath, message: "an operation's name is shaped like a credential; it names an operation, never a value" });
|
|
173
|
+
else if (hasHomePath(op)) findings.push({ code: 'FAC-SEM-167', instancePath, message: "an operation's name holds a home folder; it names an operation, never a path" });
|
|
174
|
+
};
|
|
175
|
+
if (kind === 'ops-digest-hourly') {
|
|
176
|
+
const from = wholeHour(value.from);
|
|
177
|
+
const to = wholeHour(value.to);
|
|
178
|
+
if (from === null) window('/from', 'from is a whole UTC hour of a real day');
|
|
179
|
+
if (to === null) window('/to', 'to is a whole UTC hour of a real day');
|
|
180
|
+
if (from !== null && to !== null && to - from !== HOUR_MS) window('/to', 'to is exactly one hour after from');
|
|
181
|
+
} else if (!realDay(value.day)) {
|
|
182
|
+
window('/day', 'day is a real UTC day, YYYY-MM-DD');
|
|
183
|
+
}
|
|
184
|
+
const byOp = new Map();
|
|
185
|
+
const totals = { ok: 0, failed: 0, unknown: 0 };
|
|
186
|
+
for (const [entry, index] of indexed(value.ops)) {
|
|
187
|
+
const op = entry.op;
|
|
188
|
+
if (typeof op === 'string') {
|
|
189
|
+
if (byOp.has(op)) window(`/ops/${index}/op`, `${opShown(op)} is counted once`);
|
|
190
|
+
else byOp.set(op, entry);
|
|
191
|
+
named(`/ops/${index}/op`, op);
|
|
192
|
+
}
|
|
193
|
+
for (const k of OUTCOMES) {
|
|
194
|
+
const n = count(entry[k]);
|
|
195
|
+
totals[k] = n === null || totals[k] === null ? null : totals[k] + n;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const total = isObject(value.total) ? value.total : null;
|
|
199
|
+
if (total !== null && Array.isArray(value.ops)) {
|
|
200
|
+
for (const k of OUTCOMES) {
|
|
201
|
+
const want = totals[k];
|
|
202
|
+
if (want === null || count(total[k]) === null) continue;
|
|
203
|
+
if (!Number.isSafeInteger(want)) window(`/total/${k}`, `the sum of ops' ${k} is beyond 2^53 - 1, so no total.${k} can be it`);
|
|
204
|
+
else if (total[k] !== want) window(`/total/${k}`, `total.${k} is the sum of ops' ${k}, ${want}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const rose = new Set();
|
|
208
|
+
const hours = count(value.hours);
|
|
209
|
+
for (const [entry, index] of indexed(value.risen)) {
|
|
210
|
+
const op = entry.op;
|
|
211
|
+
if (typeof op !== 'string') continue;
|
|
212
|
+
const at = `/risen/${index}`;
|
|
213
|
+
named(`${at}/op`, op);
|
|
214
|
+
if (rose.has(op)) window(`${at}/op`, `${opShown(op)} is named once among the rises`);
|
|
215
|
+
rose.add(op);
|
|
216
|
+
const counted = byOp.get(op);
|
|
217
|
+
if (counted === undefined) { window(`${at}/op`, `${opShown(op)} rose, so ops counts it`); continue; }
|
|
218
|
+
const failed = count(entry.failed);
|
|
219
|
+
const countedFailed = count(counted.failed);
|
|
220
|
+
if (kind === 'ops-digest-hourly') {
|
|
221
|
+
const previous = count(entry.previous);
|
|
222
|
+
if (failed !== null && countedFailed !== null && failed !== countedFailed) window(`${at}/failed`, `a rise names the hour's failures of ${opShown(op)}, ${countedFailed}`);
|
|
223
|
+
if (failed !== null && previous !== null && !isRise(failed, previous)) window(at, `${failed} failures against ${previous} is no rise: at least ${RISE_MIN_FAILED} and ${RISE_FACTOR} times the hour before (none counts as one)`);
|
|
224
|
+
} else {
|
|
225
|
+
const risenHours = count(entry.hours);
|
|
226
|
+
if (risenHours !== null && hours !== null && risenHours > hours) window(`${at}/hours`, `${opShown(op)} rose in ${risenHours} hours; the day folds ${hours}`);
|
|
227
|
+
if (failed !== null && risenHours !== null && failed < RISE_MIN_FAILED * risenHours) window(`${at}/failed`, `${risenHours} hours of rises hold at least ${RISE_MIN_FAILED * risenHours} failures`);
|
|
228
|
+
if (failed !== null && countedFailed !== null && failed > countedFailed) window(`${at}/failed`, `the rises' failures are at most the day's failures of ${opShown(op)}, ${countedFailed}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return findings;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const sameKeys = (o, keys) => Object.keys(o).sort().join(',') === [...keys].sort().join(',');
|
|
235
|
+
function shapeOk(line, daily) {
|
|
236
|
+
if (!isObject(line) || !sameKeys(line, daily ? ['schema', 'day', 'hours', 'ops', 'total', 'risen'] : ['schema', 'from', 'to', 'ops', 'total', 'risen'])) return false;
|
|
237
|
+
if (line.schema !== (daily ? OPS_DIGEST_DAILY : OPS_DIGEST_HOURLY)) return false;
|
|
238
|
+
if (!Array.isArray(line.ops) || !Array.isArray(line.risen) || !isObject(line.total)) return false;
|
|
239
|
+
if (line.ops.length > OPS_MAX || line.risen.length > OPS_MAX) return false;
|
|
240
|
+
for (const c of line.ops) {
|
|
241
|
+
if (!(isObject(c) && sameKeys(c, ['failed', 'ok', 'op', 'unknown']) && nameRefusal(c.op) === null && OUTCOMES.every((k) => count(c[k]) !== null))) return false;
|
|
242
|
+
}
|
|
243
|
+
const want = daily ? ['failed', 'hours', 'op'] : ['failed', 'op', 'previous'];
|
|
244
|
+
for (const r of line.risen) {
|
|
245
|
+
if (!(isObject(r) && sameKeys(r, want) && nameRefusal(r.op) === null && want.filter((k) => k !== 'op').every((k) => count(r[k]) !== null))) return false;
|
|
246
|
+
}
|
|
247
|
+
return sameKeys(line.total, ['failed', 'ok', 'unknown']) && OUTCOMES.every((k) => count(line.total[k]) !== null);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The digests of kind (hourly or daily) in folder as a reader reads them (OD-6); the first line of an hour or day kept. */
|
|
251
|
+
export function readDigests(folder, kind) {
|
|
252
|
+
const daily = kind === 'daily';
|
|
253
|
+
const out = [];
|
|
254
|
+
const seen = new Set();
|
|
255
|
+
let names = [];
|
|
256
|
+
try { names = fs.readdirSync(folder).filter((n) => n.startsWith(`${kind}-`) && FILE.test(n)).sort(); } catch { return out; }
|
|
257
|
+
for (const name of names) {
|
|
258
|
+
let text;
|
|
259
|
+
try { text = fs.readFileSync(path.join(folder, name)).toString('utf8'); } catch { continue; }
|
|
260
|
+
for (const raw of text.split('\n')) {
|
|
261
|
+
let line;
|
|
262
|
+
try { line = JSON.parse(raw); } catch { continue; }
|
|
263
|
+
if (!shapeOk(line, daily) || opsDigestRules(daily ? 'ops-digest-daily' : 'ops-digest-hourly', line).length) continue;
|
|
264
|
+
const key = daily ? line.day : wholeHour(line.from);
|
|
265
|
+
if (seen.has(key)) continue;
|
|
266
|
+
seen.add(key);
|
|
267
|
+
out.push(line);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const lineOf = (digest) => `${JSON.stringify(digest)}\n`;
|
|
274
|
+
function append(file, data) {
|
|
275
|
+
const fd = fs.openSync(file, 'a', 0o600);
|
|
276
|
+
try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The files of one service's operations digests in folder; see fabric_ops_digest.OpsDigest. */
|
|
280
|
+
export class OpsDigest {
|
|
281
|
+
constructor(folder, { overflow = OVERFLOW_OP, clock } = {}) {
|
|
282
|
+
if (nameRefusal(overflow) !== null) throw new OpsDigestError(`the overflow name ${nameRefusal(overflow)}`);
|
|
283
|
+
this.folder = folder;
|
|
284
|
+
this.overflow = overflow;
|
|
285
|
+
this.clock = clock ?? (() => Date.now());
|
|
286
|
+
this.hour = null;
|
|
287
|
+
this.counts = new Map();
|
|
288
|
+
this.names = new Set();
|
|
289
|
+
this.namesDay = null;
|
|
290
|
+
this.started = false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
get kept() { return path.join(this.folder, 'kept-hour.json'); }
|
|
294
|
+
now(at) { return Math.trunc(at ?? this.clock()); }
|
|
295
|
+
|
|
296
|
+
start(at) {
|
|
297
|
+
ensurePrivateDir(this.folder);
|
|
298
|
+
const now = this.now(at);
|
|
299
|
+
const written = { hours: 0, days: 0 };
|
|
300
|
+
const kept = this.readKept();
|
|
301
|
+
if (kept !== null) {
|
|
302
|
+
if (kept.start + HOUR_MS <= now) { written.hours += this.writeHour(kept.start, kept.counts); this.dropKept(); }
|
|
303
|
+
else { this.hour = kept.start; this.counts = kept.counts; }
|
|
304
|
+
}
|
|
305
|
+
if (this.hour === null) { this.hour = now - (now % HOUR_MS); this.counts = new Map(); }
|
|
306
|
+
this.rebuildNames(now);
|
|
307
|
+
written.days += this.foldClosedDays(now);
|
|
308
|
+
this.started = true;
|
|
309
|
+
return written;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
record(op, outcome, at) {
|
|
313
|
+
const refused = nameRefusal(op);
|
|
314
|
+
if (refused !== null) throw new OpsDigestError(`the operation's name ${refused} (OD-2): name the operation, never a value of it`);
|
|
315
|
+
const now = this.advance(this.now(at));
|
|
316
|
+
let name = op;
|
|
317
|
+
if (!this.names.has(name) && name !== this.overflow) {
|
|
318
|
+
if (this.names.size >= OPS_MAX - 1) name = this.overflow;
|
|
319
|
+
else this.names.add(name);
|
|
320
|
+
}
|
|
321
|
+
this.namesDay = dayOf(now);
|
|
322
|
+
const row = this.counts.get(name) ?? [0, 0, 0];
|
|
323
|
+
row[outcome === 'ok' ? 0 : outcome === 'failed' ? 1 : 2] += 1;
|
|
324
|
+
this.counts.set(name, row);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
tick(at) { this.advance(this.now(at)); }
|
|
328
|
+
|
|
329
|
+
flush(at) {
|
|
330
|
+
const now = this.now(at);
|
|
331
|
+
const written = { hours: 0, days: 0 };
|
|
332
|
+
if (this.hour !== null && this.hour + HOUR_MS <= now) {
|
|
333
|
+
written.hours += this.writeHour(this.hour, this.counts);
|
|
334
|
+
this.hour = null; this.counts = new Map();
|
|
335
|
+
this.dropKept();
|
|
336
|
+
}
|
|
337
|
+
written.days += this.foldClosedDays(now);
|
|
338
|
+
this.keep();
|
|
339
|
+
return written;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
stop(at) { return this.flush(at); }
|
|
343
|
+
|
|
344
|
+
advance(now) {
|
|
345
|
+
if (!this.started) throw new OpsDigestError('call start() before recording (OD-1: the kept hour is written first)');
|
|
346
|
+
const hour = now - (now % HOUR_MS);
|
|
347
|
+
if (this.hour !== null && hour < this.hour) return this.hour;
|
|
348
|
+
if (this.hour !== null && hour > this.hour) {
|
|
349
|
+
this.writeHour(this.hour, this.counts);
|
|
350
|
+
this.counts = new Map();
|
|
351
|
+
this.foldClosedDays(now);
|
|
352
|
+
}
|
|
353
|
+
if (dayOf(hour) !== this.namesDay) { this.names = new Set(); this.namesDay = dayOf(hour); }
|
|
354
|
+
this.hour = hour;
|
|
355
|
+
return now;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
previous(start) {
|
|
359
|
+
const before = start - HOUR_MS;
|
|
360
|
+
const lines = readDigests(this.folder, 'hourly');
|
|
361
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) if (wholeHour(lines[i].from) === before) return lines[i];
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
writeHour(start, counts) {
|
|
366
|
+
ensurePrivateDir(this.folder);
|
|
367
|
+
const from = isoHour(start);
|
|
368
|
+
if (readDigests(this.folder, 'hourly').some((line) => wholeHour(line.from) === start)) return 0;
|
|
369
|
+
const rows = [...counts.entries()].map(([op, c]) => ({ op, ok: c[0], failed: c[1], unknown: c[2] }));
|
|
370
|
+
const digest = foldHour(rows, from, this.previous(start));
|
|
371
|
+
append(path.join(this.folder, `hourly-${from.slice(0, 7)}.jsonl`), lineOf(digest));
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
foldClosedDays(now) {
|
|
376
|
+
const hourly = readDigests(this.folder, 'hourly');
|
|
377
|
+
const done = new Set(readDigests(this.folder, 'daily').map((line) => line.day));
|
|
378
|
+
const today = dayOf(now);
|
|
379
|
+
let folded = 0;
|
|
380
|
+
for (const day of [...new Set(hourly.map((line) => line.from.slice(0, 10)))].sort()) {
|
|
381
|
+
if (day >= today || done.has(day)) continue;
|
|
382
|
+
const digest = foldDay(day, hourly);
|
|
383
|
+
if (digest !== null) { append(path.join(this.folder, `daily-${day.slice(0, 7)}.jsonl`), lineOf(digest)); folded += 1; }
|
|
384
|
+
}
|
|
385
|
+
return folded;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
rebuildNames(now) {
|
|
389
|
+
const today = dayOf(now);
|
|
390
|
+
const names = new Set();
|
|
391
|
+
for (const line of readDigests(this.folder, 'hourly')) if (line.from.slice(0, 10) === today) for (const c of line.ops) names.add(c.op);
|
|
392
|
+
if (this.hour !== null && dayOf(this.hour) === today) for (const op of this.counts.keys()) names.add(op);
|
|
393
|
+
names.delete(this.overflow);
|
|
394
|
+
this.names = names; this.namesDay = today;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
keep() {
|
|
398
|
+
if (this.hour === null) return;
|
|
399
|
+
ensurePrivateDir(this.folder);
|
|
400
|
+
const ops = [...this.counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([op, c]) => [op, ...c]);
|
|
401
|
+
atomicWrite(this.kept, `${JSON.stringify({ from: isoHour(this.hour), ops })}\n`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
readKept() {
|
|
405
|
+
try {
|
|
406
|
+
const body = JSON.parse(fs.readFileSync(this.kept, 'utf8'));
|
|
407
|
+
const start = wholeHour(body.from);
|
|
408
|
+
if (start === null) return null;
|
|
409
|
+
const counts = new Map();
|
|
410
|
+
for (const row of body.ops ?? []) {
|
|
411
|
+
if (Array.isArray(row) && row.length === 4 && nameRefusal(row[0]) === null && row.slice(1).every((n) => count(n) !== null)) counts.set(row[0], row.slice(1));
|
|
412
|
+
}
|
|
413
|
+
return { start, counts };
|
|
414
|
+
} catch { return null; }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
dropKept() { try { fs.unlinkSync(this.kept); } catch { /* already gone */ } }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** A log's own outcome word as OD-3 maps it; any word the service's published mapping does not place is unknown. */
|
|
421
|
+
export function outcomeOf(word, mapping = {}) {
|
|
422
|
+
if (word === 'ok' || word === 'failed') return word;
|
|
423
|
+
const mapped = typeof word === 'string' && Object.hasOwn(mapping ?? {}, word) ? mapping[word] : undefined;
|
|
424
|
+
return OUTCOMES.includes(mapped) ? mapped : 'unknown';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// String.prototype.isWellFormed is Node 20; the kit's floor is Node 18 (package.json engines).
|
|
428
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
429
|
+
function dirProblem(d) {
|
|
430
|
+
if (typeof d !== 'string' || !d || d.length > 256) return 'dir is 1 to 256 characters';
|
|
431
|
+
if (d.startsWith('/') || d.startsWith('~') || d.endsWith('/') || d.includes('//') || d.includes('\\') || d.includes(':')) return 'dir is relative to the data folder: forward slashes, no leading / or ~, no drive or :, no trailing /';
|
|
432
|
+
if ([...d].some((ch) => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f) || LONE_SURROGATE.test(d)) return 'dir holds no control character and no lone surrogate';
|
|
433
|
+
if (d.split('/').some((seg) => seg === '.' || seg === '..' || !seg.replace(/[. ]+$/, ''))) return 'dir has no ., .. or empty segment';
|
|
434
|
+
if (hasHomePath(d) || hasHomePath(d.replaceAll('./', '/'))) return 'dir holds a home folder';
|
|
435
|
+
if (secretValue(d)) return 'dir is shaped like a credential (a run of 40 letters, digits and / mixing cases and a digit counts; a space or a dot inside a segment breaks it)';
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The descriptor's extensions member that says where a collector reads the daily digests (OD-9). */
|
|
440
|
+
export function opsDigestExtension(folders = [{ dir: 'digests', source: 'app' }]) {
|
|
441
|
+
const items = [...folders].map((f) => ({ ...f }));
|
|
442
|
+
if (items.length < 1 || items.length > 8) throw new OpsDigestError('the ops-digest declaration holds 1 to 8 folders');
|
|
443
|
+
const dirs = new Set();
|
|
444
|
+
const sources = new Set();
|
|
445
|
+
items.forEach((item, i) => {
|
|
446
|
+
if (!sameKeys(item, ['dir', 'source'])) throw new OpsDigestError(`folder ${i} is {dir, source} and nothing else`);
|
|
447
|
+
const problem = dirProblem(item.dir);
|
|
448
|
+
if (problem) throw new OpsDigestError(`folder ${i}: ${problem} (FAC-SEM-168)`);
|
|
449
|
+
if (typeof item.source !== 'string' || !SOURCE.test(item.source) || secretValue(item.source)) throw new OpsDigestError(`folder ${i}: source is a label matching ^[a-z][a-z0-9-]{0,31}$`);
|
|
450
|
+
const key = item.dir.toLowerCase().split('/').map((seg) => seg.replace(/[. ]+$/, '')).join('/');
|
|
451
|
+
if (dirs.has(key) || sources.has(item.source)) throw new OpsDigestError(`folder ${i}: each dir and each source is named once (FAC-SEM-168)`);
|
|
452
|
+
dirs.add(key); sources.add(item.source);
|
|
453
|
+
});
|
|
454
|
+
return { [OPS_DIGEST_EXTENSION]: items };
|
|
455
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
CHANGED
|
@@ -269,10 +269,25 @@ export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...age
|
|
|
269
269
|
|
|
270
270
|
// --- the folder check and the verifier -------------------------------------------------------------------------------
|
|
271
271
|
|
|
272
|
+
const replacingUtf8 = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
273
|
+
// How deep the arrays and objects of a JSON text nest, strings skipped (the Python kit's _nesting).
|
|
274
|
+
const nestingOf = (text) => {
|
|
275
|
+
let depth = 0;
|
|
276
|
+
let deepest = 0;
|
|
277
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
278
|
+
const c = text[i];
|
|
279
|
+
if (c === '"') { i += 1; while (i < text.length && text[i] !== '"') i += text[i] === '\\' ? 2 : 1; } else if (c === '{' || c === '[') { depth += 1; deepest = Math.max(deepest, depth); } else if (c === '}' || c === ']') depth -= 1;
|
|
280
|
+
}
|
|
281
|
+
return deepest;
|
|
282
|
+
};
|
|
283
|
+
// A plugin or provider manifest as the contract's checkPackageFolder reads it: decoded with replacement characters
|
|
284
|
+
// (FAA-194 (29)); nesting past MAX_NESTING, the limit both kits share, is no manifest (FAA-194 (27)).
|
|
272
285
|
const parsed = (bytes) => {
|
|
273
286
|
if (bytes === undefined) return undefined;
|
|
287
|
+
const text = replacingUtf8.decode(bytes);
|
|
288
|
+
if (nestingOf(text) > MAX_NESTING) return undefined;
|
|
274
289
|
try {
|
|
275
|
-
const value = JSON.parse(
|
|
290
|
+
const value = JSON.parse(text);
|
|
276
291
|
return isObject(value) ? value : undefined;
|
|
277
292
|
} catch {
|
|
278
293
|
return undefined;
|
|
@@ -437,7 +452,7 @@ export function readFolder(folder) {
|
|
|
437
452
|
}
|
|
438
453
|
};
|
|
439
454
|
walk(folder, '');
|
|
440
|
-
if (refused.length) throw new PackageError(`Not packed:
|
|
455
|
+
if (refused.length) throw new PackageError(`Not packed: pack a clean copy (what Git would commit, references/package.md); delete only litter and credential files in place: ${refused.sort().join('; ')}`);
|
|
441
456
|
return files;
|
|
442
457
|
}
|
|
443
458
|
|