@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +50 -5
- 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 +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +12 -4
- 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/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +26 -6
- 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 +24 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +57 -76
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- 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 +12 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +7 -3
- 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 +54 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +33 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -7
- 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_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +26 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +24 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +136 -3
- 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 +118 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +72 -7
- 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-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +11 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +114 -20
- 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 +120 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +40 -3
- 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_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +28 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +110 -9
- 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 +469 -29
- 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 +18 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- 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/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +12 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- 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/source-and-instance.md +44 -7
- 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/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +26 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- 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 +3 -2
- 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,266 @@
|
|
|
1
|
+
// Work a service holds by its own limits, said to its caller and its operator (FAA-298): a held job's statusMessage in
|
|
2
|
+
// the contract's pause grammar (`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`; the
|
|
3
|
+
// pinned interop-job schema is closed, so `waiting {reason, since, until, message}` rides statusMessage, never a field
|
|
4
|
+
// of the job), one push per hold, and the operator's scoped lift. The twin of fabric_holds.py, held to the same
|
|
5
|
+
// vectors (test/fixtures/holds/waiting-vectors.json). Part of the Node service kit (layer L3, after fabric-interop).
|
|
6
|
+
// Node 20+, no dependency.
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { ServiceError, nowIso } from './fabric-base.mjs';
|
|
10
|
+
import { atomicWrite } from './fabric-paths.mjs';
|
|
11
|
+
import { decisionShape } from './fabric-actions.mjs';
|
|
12
|
+
import { QUOTA_EXHAUSTED, codePoints, pauseMessage, readJobPause, utcTime } from './fabric-interop-rules.mjs';
|
|
13
|
+
import { secretValue } from './fabric-secrets.mjs';
|
|
14
|
+
|
|
15
|
+
/** The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap. */
|
|
16
|
+
export const HOLD_REASONS = Object.freeze(['daily-quota', 'caller-share', 'concurrency', 'money']);
|
|
17
|
+
/** How the push names each reason to a person. */
|
|
18
|
+
export const REASON_WORDS = Object.freeze({
|
|
19
|
+
'daily-quota': 'the daily quota', 'caller-share': 'its per-caller share',
|
|
20
|
+
concurrency: 'the concurrency cap', money: 'the money cap',
|
|
21
|
+
});
|
|
22
|
+
/** The push's kind: a decision for the operator (level notice, notify true). */
|
|
23
|
+
export const HOLD_EVENT_KIND = 'limit.held';
|
|
24
|
+
/** The record of a lift applied (level info, no notify). */
|
|
25
|
+
export const LIFT_EVENT_KIND = 'limit.lifted';
|
|
26
|
+
/** The decision action a lift carries (DEC-0121 AP-2 shape). */
|
|
27
|
+
export const LIFT_ACTION = 'limit.lift';
|
|
28
|
+
const MAX_STATUS_MESSAGE = 500;
|
|
29
|
+
const WAITING = /^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?$/;
|
|
30
|
+
const SIZES = ['jobs', 'usd', 'limit'];
|
|
31
|
+
|
|
32
|
+
const instant = (value, name) => {
|
|
33
|
+
if (!Number.isFinite(utcTime(value))) throw new ServiceError(`${name} is a UTC instant (Z, at most 3 fraction digits).`);
|
|
34
|
+
return value;
|
|
35
|
+
};
|
|
36
|
+
const reasonOk = (reason, what) => {
|
|
37
|
+
if (!HOLD_REASONS.includes(reason)) throw new ServiceError(`A ${what}'s reason is one of ${HOLD_REASONS.join(', ')}.`);
|
|
38
|
+
};
|
|
39
|
+
const squash = (text) => String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
40
|
+
|
|
41
|
+
/** A held job's statusMessage; the message is shortened, never refused, to fit 500 characters. */
|
|
42
|
+
export function waitingMessage(reason, since, until = null, message = null) {
|
|
43
|
+
reasonOk(reason, 'hold');
|
|
44
|
+
instant(since, 'since');
|
|
45
|
+
if (until !== null && until !== undefined) instant(until, 'until');
|
|
46
|
+
else until = null;
|
|
47
|
+
let text = `waiting ${reason} since ${since}`;
|
|
48
|
+
const head = QUOTA_EXHAUSTED.length + (until === null ? 0 : ' until '.length + until.length) + ': '.length;
|
|
49
|
+
if (message !== null && message !== undefined) {
|
|
50
|
+
const squashed = squash(message);
|
|
51
|
+
const room = MAX_STATUS_MESSAGE - head - codePoints(text) - ': '.length;
|
|
52
|
+
if (squashed && room > 0) {
|
|
53
|
+
text += ': ' + (codePoints(squashed) <= room ? squashed : [...squashed].slice(0, room - 3).join('').trimEnd() + '...');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return pauseMessage(until === null ? { text } : { resetsAt: until, text });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds, else null. */
|
|
60
|
+
export function readWaiting(answer) {
|
|
61
|
+
const pause = readJobPause(answer);
|
|
62
|
+
if (!pause.paused) return null;
|
|
63
|
+
let rest = answer.job.statusMessage.slice(QUOTA_EXHAUSTED.length);
|
|
64
|
+
if ('resetsAt' in pause) rest = rest.slice(' until '.length + pause.resetsAt.length);
|
|
65
|
+
if (!rest.startsWith(': ')) return null;
|
|
66
|
+
const match = WAITING.exec(rest.slice(2));
|
|
67
|
+
if (!match || !Number.isFinite(utcTime(match[2]))) return null;
|
|
68
|
+
const waiting = { reason: match[1], since: match[2] };
|
|
69
|
+
if ('resetsAt' in pause) waiting.until = pause.resetsAt;
|
|
70
|
+
if (match[3] !== undefined) waiting.message = match[3];
|
|
71
|
+
return waiting;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Put one job of a fabric-interop JobStore on hold (JobStore.pause with the waiting text). */
|
|
75
|
+
export function holdJob(jobs, jobId, reason, since, until = null, message = null) {
|
|
76
|
+
let text = waitingMessage(reason, since, until, message).slice(QUOTA_EXHAUSTED.length);
|
|
77
|
+
if (until !== null && until !== undefined) text = text.slice(' until '.length + until.length);
|
|
78
|
+
return jobs.pause(jobId, until === null || until === undefined ? { text: text.slice(2) } : { resetsAt: until, text: text.slice(2) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The push's one sentence: who, what, why, when it frees by itself, and the question. */
|
|
82
|
+
export function holdSentence(caller, work, reason, until) {
|
|
83
|
+
const frees = until !== null && until !== undefined ? `it frees by itself at ${until}`
|
|
84
|
+
: (['concurrency', 'caller-share'].includes(reason) ? 'it frees by itself when a running job ends' : 'it does not free by itself');
|
|
85
|
+
return `${work} for ${caller} is held by ${REASON_WORDS[reason]}; ${frees}. Raise the limit for it?`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function text(value, name, high = 200) {
|
|
89
|
+
if (typeof value !== 'string' || !value.trim() || value.length > high) throw new ServiceError(`${name} is a text of 1 to ${high} characters.`);
|
|
90
|
+
if (secretValue(value)) throw new ServiceError(`${name} looks like a credential; a hold names callers and work, never a secret.`);
|
|
91
|
+
return squash(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const newId = () => crypto.randomUUID().replaceAll('-', '');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The holds a service's limits put on work and the operator's lifts, in one file (0600, written atomically).
|
|
98
|
+
* `events`, when given, is a function `({kind, level, text, subject, notify}) => event` that writes the push and the
|
|
99
|
+
* lift record to the service's event log; without it they are returned for the service to write.
|
|
100
|
+
*/
|
|
101
|
+
export class HoldBook {
|
|
102
|
+
constructor(file, { events = null, clock = nowIso } = {}) {
|
|
103
|
+
this.file = file;
|
|
104
|
+
this.events = events;
|
|
105
|
+
this.clock = clock;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
load() {
|
|
109
|
+
if (!fs.existsSync(this.file)) return { holds: [], lifts: [] };
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
113
|
+
} catch {
|
|
114
|
+
throw new ServiceError('The hold book is not JSON; it is kept as it is.');
|
|
115
|
+
}
|
|
116
|
+
if (data === null || typeof data !== 'object' || !Array.isArray(data.holds) || !Array.isArray(data.lifts)) {
|
|
117
|
+
throw new ServiceError('The hold book is not {holds, lifts}; it is kept as it is.');
|
|
118
|
+
}
|
|
119
|
+
return data;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
save(data) { atomicWrite(this.file, JSON.stringify(data, null, 1) + '\n', 0o600); }
|
|
123
|
+
|
|
124
|
+
now(now) { return instant(now ?? this.clock(), 'now'); }
|
|
125
|
+
|
|
126
|
+
emit(kind, level, sentence, subject, notify) {
|
|
127
|
+
const event = { kind, level, text: sentence, subject };
|
|
128
|
+
if (notify) event.notify = true;
|
|
129
|
+
return this.events ? this.events(event) : event;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
static expire(data, at) {
|
|
133
|
+
for (const hold of data.holds) {
|
|
134
|
+
if (hold.closed === null && hold.until !== null && utcTime(hold.until) <= at) {
|
|
135
|
+
hold.closed = 'expired';
|
|
136
|
+
hold.closedAt = hold.until;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
static live(lift, at) {
|
|
142
|
+
if (lift.from !== null && lift.from !== undefined && utcTime(lift.from) > at) return false;
|
|
143
|
+
if (lift.until !== null && lift.until !== undefined && utcTime(lift.until) <= at) return false;
|
|
144
|
+
return (lift.left ?? 1) > 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Hold `work` for `caller` by `reason`: {hold, statusMessage, push}; push only when this call opened the hold. */
|
|
148
|
+
hold({ caller, work, reason, until = null, message = null, jobId = null, now = null }) {
|
|
149
|
+
caller = text(caller, 'caller');
|
|
150
|
+
work = text(work, 'work');
|
|
151
|
+
reasonOk(reason, 'hold');
|
|
152
|
+
const at = this.now(now);
|
|
153
|
+
if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) {
|
|
154
|
+
throw new ServiceError(`until ${until} has passed: the limit no longer holds the work.`);
|
|
155
|
+
}
|
|
156
|
+
const data = this.load();
|
|
157
|
+
HoldBook.expire(data, utcTime(at));
|
|
158
|
+
let hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
|
|
159
|
+
const opened = hold === undefined;
|
|
160
|
+
if (opened) {
|
|
161
|
+
hold = { id: newId(), reason, caller, work, since: at, until, jobs: [], closed: null };
|
|
162
|
+
data.holds.push(hold);
|
|
163
|
+
}
|
|
164
|
+
if (jobId !== null && !hold.jobs.includes(String(jobId))) hold.jobs.push(String(jobId));
|
|
165
|
+
this.save(data);
|
|
166
|
+
const statusMessage = waitingMessage(reason, hold.since, hold.until, message);
|
|
167
|
+
const push = opened ? this.emit(HOLD_EVENT_KIND, 'notice', holdSentence(caller, work, reason, until),
|
|
168
|
+
{ type: 'limit', id: `${reason}:${caller}`.slice(0, 200), label: REASON_WORDS[reason] }, true) : null;
|
|
169
|
+
return { hold: { ...hold }, statusMessage, push };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again. */
|
|
173
|
+
release({ reason, caller, now = null }) {
|
|
174
|
+
const at = this.now(now);
|
|
175
|
+
const data = this.load();
|
|
176
|
+
const hold = data.holds.find((h) => h.closed === null && h.reason === reason && h.caller === caller);
|
|
177
|
+
if (!hold) return null;
|
|
178
|
+
hold.closed = 'released';
|
|
179
|
+
hold.closedAt = at;
|
|
180
|
+
this.save(data);
|
|
181
|
+
return { ...hold };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
openHolds(now = null) {
|
|
185
|
+
const data = this.load();
|
|
186
|
+
HoldBook.expire(data, utcTime(this.now(now)));
|
|
187
|
+
return data.holds.filter((h) => h.closed === null).map((h) => ({ ...h }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Record the operator's answer as a scoped lift: exactly one of jobs, usd or limit (limit needs until). */
|
|
191
|
+
lift({ reason, decision, caller = null, jobId = null, jobs = null, usd = null, limit = null, until = null, now = null }) {
|
|
192
|
+
reasonOk(reason, 'lift');
|
|
193
|
+
if (!decisionShape(decision) || decision.action !== LIFT_ACTION) {
|
|
194
|
+
throw new ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
|
|
195
|
+
+ "words verbatim): operatorDecision({ action: 'limit.lift', instruction: words }).");
|
|
196
|
+
}
|
|
197
|
+
const sizes = { jobs, usd, limit };
|
|
198
|
+
const given = SIZES.filter((name) => sizes[name] !== null && sizes[name] !== undefined);
|
|
199
|
+
if (given.length !== 1) throw new ServiceError('A lift is one of jobs, usd or limit.');
|
|
200
|
+
const kind = given[0];
|
|
201
|
+
const size = sizes[kind];
|
|
202
|
+
if (typeof size !== 'number' || !Number.isFinite(size) || size <= 0 || (kind !== 'usd' && !Number.isInteger(size))) {
|
|
203
|
+
throw new ServiceError(`A lift's ${kind} is a positive ${kind === 'usd' ? 'number' : 'integer'}.`);
|
|
204
|
+
}
|
|
205
|
+
const at = this.now(now);
|
|
206
|
+
if (until !== null && utcTime(instant(until, 'until')) <= utcTime(at)) throw new ServiceError("A lift's window ends after now.");
|
|
207
|
+
if (kind === 'limit' && until === null) {
|
|
208
|
+
throw new ServiceError('A raised limit needs its window\'s end (until): it falls back when the window ends.');
|
|
209
|
+
}
|
|
210
|
+
const record = { id: newId(), reason, caller: caller === null ? null : text(caller, 'caller'),
|
|
211
|
+
job: jobId === null ? null : String(jobId), from: at, until, [kind]: size, decision: { ...decision }, at };
|
|
212
|
+
if (kind !== 'limit') record.left = size;
|
|
213
|
+
const data = this.load();
|
|
214
|
+
HoldBook.expire(data, utcTime(at));
|
|
215
|
+
for (const hold of data.holds) {
|
|
216
|
+
if (hold.closed === null && hold.reason === reason && (record.caller === null || record.caller === hold.caller)
|
|
217
|
+
&& (jobId === null || hold.jobs.includes(String(jobId)))) {
|
|
218
|
+
hold.closed = 'lifted';
|
|
219
|
+
hold.closedAt = at;
|
|
220
|
+
hold.lift = record.id;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
data.lifts.push(record);
|
|
224
|
+
this.save(data);
|
|
225
|
+
const scope = record.caller ? `for ${record.caller}` : 'for every caller';
|
|
226
|
+
const amount = { jobs: `${size} more job(s)`, usd: `USD ${size} more`, limit: `the limit at ${size}` }[kind];
|
|
227
|
+
this.emit(LIFT_EVENT_KIND, 'info', `The operator lifted ${REASON_WORDS[reason]} ${scope}: ${amount}${until ? ` until ${until}` : ''}.`,
|
|
228
|
+
{ type: 'limit', id: `${reason}:${record.caller ?? '*'}`, label: REASON_WORDS[reason] }, false);
|
|
229
|
+
return { ...record };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
static matching(data, reason, caller, jobId, at, kind) {
|
|
233
|
+
return data.lifts.filter((l) => l.reason === reason && kind in l && HoldBook.live(l, at)
|
|
234
|
+
&& (l.caller === null || l.caller === caller) && (l.job === null || l.job === jobId));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Whether a lift lets work past the limit: {admitted: true, lift, left} spends one job or `costUsd`, else {admitted: false}. */
|
|
238
|
+
admit({ reason, caller, jobId = null, costUsd = null, now = null }) {
|
|
239
|
+
const at = utcTime(this.now(now));
|
|
240
|
+
const data = this.load();
|
|
241
|
+
const kind = costUsd !== null ? 'usd' : 'jobs';
|
|
242
|
+
const found = HoldBook.matching(data, reason, caller, jobId === null ? null : String(jobId), at, kind)
|
|
243
|
+
.sort((a, b) => ((a.job === null) - (b.job === null)) || ((a.caller === null) - (b.caller === null)));
|
|
244
|
+
for (const lift of found) {
|
|
245
|
+
const need = kind === 'usd' ? costUsd : 1;
|
|
246
|
+
if (lift.left >= need) {
|
|
247
|
+
lift.left = kind === 'usd' ? Math.round((lift.left - need) * 1e6) / 1e6 : lift.left - 1;
|
|
248
|
+
this.save(data);
|
|
249
|
+
return { admitted: true, lift: lift.id, left: lift.left };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { admitted: false };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The limit in force: `base`, or the highest live raised limit for (reason, caller). */
|
|
256
|
+
limit({ reason, base, caller = null, now = null }) {
|
|
257
|
+
const at = utcTime(this.now(now));
|
|
258
|
+
return Math.max(base, ...HoldBook.matching(this.load(), reason, caller, null, at, 'limit').map((l) => l.limit));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every lift with `live`: whether it still applies now. */
|
|
262
|
+
lifts(now = null) {
|
|
263
|
+
const at = utcTime(this.now(now));
|
|
264
|
+
return this.load().lifts.map((l) => ({ ...l, live: HoldBook.live(l, at) }));
|
|
265
|
+
}
|
|
266
|
+
}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -16,9 +16,12 @@
|
|
|
16
16
|
// The transport is injected in tests: async ({ method, url, headers, body, timeoutS, maxBytes }) =>
|
|
17
17
|
// { status, headers, body: Uint8Array }, throwing on a connection error (a `TimeoutError` name is a timeout) or an
|
|
18
18
|
// HttpError. Node 20 or newer, no dependencies. Part of the service kit (FAA-65, layer L4).
|
|
19
|
+
//
|
|
20
|
+
// Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
|
|
19
21
|
|
|
20
22
|
import http from 'node:http';
|
|
21
|
-
import
|
|
23
|
+
import net from 'node:net';
|
|
24
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED, UNKNOWN, method as tableMethod, owner as ownerOf, refusal, serverEnd } from './fabric-peer-owner.mjs';
|
|
22
25
|
|
|
23
26
|
export const RETRY_STATUSES = Object.freeze([408, 425, 429, 500, 502, 503, 504]);
|
|
24
27
|
export const IDEMPOTENT_METHODS = Object.freeze(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
|
|
@@ -27,7 +30,7 @@ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
|
27
30
|
|
|
28
31
|
export class HttpError extends Error {
|
|
29
32
|
constructor(message, { status = null, url = '', attempts = 0, retryAfterS = null, body = new Uint8Array(0),
|
|
30
|
-
kind = 'http' } = {}) {
|
|
33
|
+
kind = 'http', reason = null } = {}) {
|
|
31
34
|
super(message);
|
|
32
35
|
this.name = 'HttpError';
|
|
33
36
|
this.status = status;
|
|
@@ -36,6 +39,8 @@ export class HttpError extends Error {
|
|
|
36
39
|
this.retryAfterS = retryAfterS;
|
|
37
40
|
this.body = body.slice(0, 2048);
|
|
38
41
|
this.kind = kind; // http, connection, timeout, too-large, refused, foreign-user
|
|
42
|
+
// For foreign-user, why (FAA-272): table-restricted (never clears), the owner's kind, or the check's reason.
|
|
43
|
+
this.reason = reason;
|
|
39
44
|
}
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -154,6 +159,61 @@ function checkHeaders(headers) {
|
|
|
154
159
|
|
|
155
160
|
const LOOPBACK_AGENT = new CheckedAgent();
|
|
156
161
|
|
|
162
|
+
// Whether a connected socket reached this computer's loopback, by the address it connected to, not by the URL's
|
|
163
|
+
// spelling: a name the hosts file maps to 127.0.0.1, `0.0.0.0` and `[::ffff:127.0.0.1]` arrive here (FAA-260).
|
|
164
|
+
export function peerIsLoopback(address) {
|
|
165
|
+
const a = String(address ?? '').split('%', 1)[0].toLowerCase();
|
|
166
|
+
if (a === '::1' || a === '0.0.0.0' || a === '::') return true;
|
|
167
|
+
const v4 = /^(?:::ffff:)?(\d{1,3})\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(a);
|
|
168
|
+
if (v4) return v4[1] === '127';
|
|
169
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(a);
|
|
170
|
+
return hex !== null && (parseInt(hex[1], 16) >> 8) === 127;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// An http.Agent for plain http to a host not spelled as loopback (`insecure`): when the connected socket's peer is a
|
|
174
|
+
// loopback address, its server end is told as CheckedAgent tells it and refused before a byte is written unless it is
|
|
175
|
+
// this OS account's process (DEC-0154 HA-2; 0.74.0 unit readings 7 and 8, FAA-260). Another peer is used as it is.
|
|
176
|
+
export class PeerCheckedAgent extends http.Agent {
|
|
177
|
+
constructor({ ask = {}, ...options } = {}) {
|
|
178
|
+
super(options);
|
|
179
|
+
this.ask = ask;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
createConnection(options, callback) {
|
|
183
|
+
let done = false;
|
|
184
|
+
const finish = (err, socket) => { if (!done) { done = true; callback(err, socket); } };
|
|
185
|
+
const port = Number(options.port);
|
|
186
|
+
const socket = net.connect({ host: options.host, port, noDelay: true, autoSelectFamily: true,
|
|
187
|
+
...(options.lookup ? { lookup: options.lookup } : {}) });
|
|
188
|
+
socket.once('error', (err) => finish(err));
|
|
189
|
+
socket.once('connect', async () => {
|
|
190
|
+
if (!peerIsLoopback(socket.remoteAddress)) {
|
|
191
|
+
socket.removeAllListeners('error');
|
|
192
|
+
finish(null, socket);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
socket.pause();
|
|
196
|
+
let who;
|
|
197
|
+
try {
|
|
198
|
+
who = await serverEnd(socket, this.ask);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
who = ownerOf(UNKNOWN, { method: tableMethod(), reason: `check: ${err?.name ?? 'Error'}` });
|
|
201
|
+
}
|
|
202
|
+
const refused = refusal(who, port, null);
|
|
203
|
+
if (refused) {
|
|
204
|
+
socket.destroy();
|
|
205
|
+
finish(new ListenerRefused(refused[0], `${refused[1]}; nothing was sent`, who));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
socket.removeAllListeners('error');
|
|
209
|
+
socket.resume();
|
|
210
|
+
finish(null, socket);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const PLAIN_AGENT = new PeerCheckedAgent();
|
|
216
|
+
|
|
157
217
|
// Plain http to a loopback host (DEC-0154 HA-2): node:http over a connection checked before a byte is written, no
|
|
158
218
|
// redirect followed, the body read to one byte past maxBytes. fetch cannot hand its socket to the check.
|
|
159
219
|
export function checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes, agent = LOOPBACK_AGENT }) {
|
|
@@ -184,7 +244,9 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
|
|
|
184
244
|
req.on('error', (err) => {
|
|
185
245
|
clearTimeout(timer);
|
|
186
246
|
if (err instanceof ListenerRefused) {
|
|
187
|
-
|
|
247
|
+
const why = String(err.owner?.reason ?? err.owner?.kind ?? err.code ?? 'unknown');
|
|
248
|
+
const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
|
|
249
|
+
reject(new HttpError(`${method} ${redactUrl(url)}: foreign-user: ${err.message}`, { url: redactUrl(url), kind: 'foreign-user', reason }));
|
|
188
250
|
} else reject(err);
|
|
189
251
|
});
|
|
190
252
|
if (body) req.write(body);
|
|
@@ -192,12 +254,15 @@ export function checkedLoopbackTransport({ method, url, headers, body, timeoutS,
|
|
|
192
254
|
});
|
|
193
255
|
}
|
|
194
256
|
|
|
195
|
-
// The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http
|
|
196
|
-
//
|
|
257
|
+
// The default transport: fetch, no redirects followed, the body read to one byte past maxBytes; plain http goes
|
|
258
|
+
// through checkedLoopbackTransport, its connection checked when it reaches loopback (FAA-260).
|
|
197
259
|
export async function fetchTransport({ method, url, headers, body, timeoutS, maxBytes }) {
|
|
198
260
|
const target = new URL(url);
|
|
199
|
-
if (target.protocol === 'http:'
|
|
200
|
-
|
|
261
|
+
if (target.protocol === 'http:') {
|
|
262
|
+
// plain http never goes through fetch: a URL spelled as loopback is checked on every connection, any other is
|
|
263
|
+
// checked when its connected peer is a loopback address (FAA-260)
|
|
264
|
+
return checkedLoopbackTransport({ method, url, headers, body, timeoutS, maxBytes,
|
|
265
|
+
...(isLoopback(target.hostname) ? {} : { agent: PLAIN_AGENT }) });
|
|
201
266
|
}
|
|
202
267
|
const res = await fetch(url, { method, headers, body: body ?? undefined, redirect: 'manual',
|
|
203
268
|
signal: AbortSignal.timeout(Math.max(1, Math.ceil(timeoutS * 1000))) });
|
|
@@ -27,7 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { atomicWrite, ensurePrivateDir, readToken } from './fabric-service.mjs';
|
|
29
29
|
import { hubKeyCertified } from './fabric-estate.mjs';
|
|
30
|
-
import { CheckedAgent, ListenerRefused } from './fabric-peer-owner.mjs';
|
|
30
|
+
import { CheckedAgent, ListenerRefused, TABLE_RESTRICTED } from './fabric-peer-owner.mjs';
|
|
31
31
|
|
|
32
32
|
// DEC-0154 HA-2: every connection to the door is checked before a byte goes out; kept alive, so a busy session reads the
|
|
33
33
|
// socket table once per connection, not once per call. A free socket is closed after 2 s idle, before a server's own
|
|
@@ -53,6 +53,20 @@ export class HubError extends Error {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The HubError for a hub port the peer-owner check refused (DEC-0154 HA-2): code `foreign-user`, the pinned contract's
|
|
58
|
+
* state name, with the owner's reason in the sentence and in `data.reason` (FAA-272): `table-restricted` when this
|
|
59
|
+
* runtime's socket table hides other processes' sockets, which never clears, so it is not retryable; otherwise the
|
|
60
|
+
* owner's kind or the check's reason (`another-account`, `no-row`), retryable as before.
|
|
61
|
+
*/
|
|
62
|
+
export function listenerRefusal(e) {
|
|
63
|
+
const kind = e?.owner?.kind ?? null;
|
|
64
|
+
const why = String(e?.owner?.reason ?? kind ?? e?.code ?? 'unknown');
|
|
65
|
+
const reason = why.startsWith(TABLE_RESTRICTED) ? TABLE_RESTRICTED : why.split(':', 1)[0];
|
|
66
|
+
const shown = kind && e?.owner?.reason ? `${kind}: ${e.owner.reason}` : String(kind ?? why);
|
|
67
|
+
return new HubError('foreign-user', `The hub's port is not held by this OS account's process (${shown}); nothing was sent.`, reason !== TABLE_RESTRICTED, { reason });
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
/** Fabric's registry root, where hub.json lives (Fabric agentRegistry.ts#registryDirs). */
|
|
57
71
|
export function fabricRoot(platform = process.platform, env = process.env) {
|
|
58
72
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
@@ -202,7 +216,7 @@ export function postMcp(origin, body, bearer, { session, timeout = 15000, method
|
|
|
202
216
|
});
|
|
203
217
|
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
204
218
|
req.on('error', (e) => reject(e instanceof ListenerRefused
|
|
205
|
-
?
|
|
219
|
+
? listenerRefusal(e)
|
|
206
220
|
: new HubError('hub-unavailable', `Fabric's hub did not answer (${e.code ?? e.message}).`, true)));
|
|
207
221
|
if (payload) req.write(payload);
|
|
208
222
|
req.end();
|