@passioncode-ai/passioncode 0.2.45 → 0.2.46
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 +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The board responder's slot records (FAA-155, FAA-163): the generation it holds each slot at, a move to another
|
|
2
|
+
// session and its take-back, and a capability whose enroll was refused, retried on its own. Part of the board
|
|
3
|
+
// connector (FAA-65, layer L4); Responder (fabric-board-session.mjs) extends SlotKeeper.
|
|
4
|
+
import { BoardError, LEASE_SECONDS, isInt, isObj } from './fabric-board-shapes.mjs';
|
|
5
|
+
|
|
6
|
+
// The answer may have been stored: send it again with the same key (BoardError).
|
|
7
|
+
export const LOST = ['unreachable', 'session-ended'];
|
|
8
|
+
|
|
9
|
+
/** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
|
|
10
|
+
export const LEASE_MARGIN_SECONDS = 10;
|
|
11
|
+
|
|
12
|
+
/** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
|
|
13
|
+
export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
14
|
+
|
|
15
|
+
/** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
|
|
16
|
+
export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
|
|
17
|
+
'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
|
|
18
|
+
|
|
19
|
+
/** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
|
|
20
|
+
export class SlotKeeper {
|
|
21
|
+
// -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
|
|
22
|
+
/** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
|
|
23
|
+
heldAt(capability, generation) {
|
|
24
|
+
this.note(capability, { project: this.project, generation });
|
|
25
|
+
this.confirmed[capability] = generation;
|
|
26
|
+
delete this.said[`board.slot.refused:${capability}`]; // held again: a later refusal is said again
|
|
27
|
+
delete this.said[`board.slot.contested:${capability}`]; // and a later contest, by enroll or replace
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The slot record, best effort: one that cannot be written now is written at the next receipt. */
|
|
31
|
+
note(capability, record) {
|
|
32
|
+
try {
|
|
33
|
+
this.checkpoint.slot(capability, record);
|
|
34
|
+
} catch {
|
|
35
|
+
// the request steps fail closed on their own
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
|
|
41
|
+
* drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
|
|
42
|
+
*/
|
|
43
|
+
async ask(capability, op, args) {
|
|
44
|
+
let held = this.checkpoint.slots[capability] ?? {};
|
|
45
|
+
if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
|
|
46
|
+
const slot = this.slots[capability] ?? {};
|
|
47
|
+
if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
|
|
48
|
+
&& this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
|
|
49
|
+
return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
|
|
50
|
+
}
|
|
51
|
+
if (isObj(held.asking)) {
|
|
52
|
+
// An earlier lost ask is settled first; it may show the slot moved.
|
|
53
|
+
if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
|
|
54
|
+
held = this.checkpoint.slots[capability] ?? {};
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
this.checkpoint.slot(capability, { ...held, asking: { op, args } });
|
|
58
|
+
} catch {
|
|
59
|
+
return this.call(op, args);
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return await this.call(op, args);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (!LOST.includes(e.code)) this.note(capability, held);
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
|
|
71
|
+
* only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
|
|
72
|
+
* taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
|
|
73
|
+
*/
|
|
74
|
+
async replay(capability, held, before) {
|
|
75
|
+
const { asking, ...rest } = held;
|
|
76
|
+
let record = rest;
|
|
77
|
+
if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
|
|
78
|
+
try {
|
|
79
|
+
const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
|
|
80
|
+
const generation = answer.slot?.generation;
|
|
81
|
+
const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
|
|
82
|
+
if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (LOST.includes(e.code)) throw e;
|
|
85
|
+
if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
this.note(capability, Object.keys(record).length ? record : null);
|
|
89
|
+
return record;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
|
|
94
|
+
* it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
|
|
95
|
+
*/
|
|
96
|
+
async gone(capability, status = null) {
|
|
97
|
+
let held = this.checkpoint.slots[capability] ?? {};
|
|
98
|
+
if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
|
|
99
|
+
let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
|
|
100
|
+
if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
|
|
101
|
+
if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
|
|
102
|
+
if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) {
|
|
103
|
+
if (isInt(now, 1) && isInt(held.generation, 1) && !('moved' in held)) this.confirmed[capability] = held.generation; // com.status shows it unmoved (0.56.0 seam r3)
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
// On the fence queue (FAA-158): a fenced call in flight lands first, and a takeBack meanwhile wins.
|
|
107
|
+
return this.locked(() => this.moved(capability, held, now));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The slot moved past `held` to `now`: stop its work, keep its accepted requests held, say it once. */
|
|
111
|
+
moved(capability, held, now) {
|
|
112
|
+
const record = this.checkpoint.slots[capability] ?? {};
|
|
113
|
+
if (record.project !== this.project || record.generation !== held.generation) return false; // a takeBack forgot it
|
|
114
|
+
if (this.current?.entry.capability === capability) {
|
|
115
|
+
this.current.ctx.stopped = true;
|
|
116
|
+
this.current = null;
|
|
117
|
+
}
|
|
118
|
+
for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
|
|
119
|
+
if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
|
|
120
|
+
else if (!['unknown', 'lapsed'].includes(entry.step)) {
|
|
121
|
+
// Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
|
|
122
|
+
// transfer if the slot is this service's again; never a com.responder_replace on its own.
|
|
123
|
+
entry.step = entry.effect ? 'unknown' : 'lapsed';
|
|
124
|
+
this.checkpoint.put(entry);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
|
|
128
|
+
this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
|
|
129
|
+
delete this.confirmed[capability];
|
|
130
|
+
if (!('moved' in held)) { // once, even across restarts
|
|
131
|
+
this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it there and left its accepted work \`accepted\` (work begun is the new holder's to settle). Its take-back action brings the slot back once the other session lets it go, or at a later restart or reconnect while accepted work waits; a restart alone does not. Without it, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
|
|
132
|
+
this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
|
|
139
|
+
* tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
|
|
140
|
+
*/
|
|
141
|
+
takeBack(capability) {
|
|
142
|
+
if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
|
|
143
|
+
this.checkpoint.slot(capability, null);
|
|
144
|
+
if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
|
|
145
|
+
delete this.said['board.slot.moved'];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// -- one capability's refusal (FAA-163) --
|
|
149
|
+
/**
|
|
150
|
+
* An enroll or replace of one capability was refused: its accepted work waits `lapsed` (a transfer takes it back once
|
|
151
|
+
* the slot is held) and the capability asks again after a doubling pause; the others go on.
|
|
152
|
+
*/
|
|
153
|
+
retryLater(capability, entries, e) {
|
|
154
|
+
if (this.slots[capability]?.state === 'held') return; // the replace landed: what failed came after it
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (!['unknown', 'lapsed'].includes(entry.step)) {
|
|
157
|
+
entry.step = 'lapsed';
|
|
158
|
+
this.checkpoint.put(entry);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const slot = this.slots[capability] ?? {};
|
|
162
|
+
const tries = slot.state === 'retry' ? (slot.tries ?? 0) + 1 : 1;
|
|
163
|
+
const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (tries - 1));
|
|
164
|
+
this.slots[capability] = { state: 'retry', tries, retryAt: this.now() + wait, renewedAt: this.now() };
|
|
165
|
+
if (!SESSION_CODES.has(e.code)) { // once per episode (saidAs), also after a silent session refusal // a refusal of the board session is the fault's to say
|
|
166
|
+
this.emit('board.slot.refused', 'warning', `Fabric refused to give this service the ${capability} slot (${e.code}); it asks again later. Its other capabilities go on.`, { episode: capability, saidAs: `board.slot.refused:${capability}`, capability, reason: e.code });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Runs one capability's enroll or replace (FAA-163): a refusal of the board session is raised after the capability is left to retry. */
|
|
171
|
+
async settle(capability, act, entries = []) {
|
|
172
|
+
try {
|
|
173
|
+
await act();
|
|
174
|
+
} catch (e) {
|
|
175
|
+
if (!(e instanceof BoardError)) throw e;
|
|
176
|
+
this.retryLater(capability, entries, e);
|
|
177
|
+
if (SESSION_CODES.has(e.code)) throw e;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -140,7 +140,9 @@ function authorityOk(uri) {
|
|
|
140
140
|
const port = m[2];
|
|
141
141
|
return Boolean(m[1]) && (port === undefined || port === '' || (/^[0-9]{1,5}$/.test(port) && Number(port) <= 65535));
|
|
142
142
|
}
|
|
143
|
-
|
|
143
|
+
// A special scheme reads a user:password@ after any run of slashes, none included, as `new URL` does (0.56.0 seam r7).
|
|
144
|
+
// To the last @, a user name may hold one; any other scheme keeps a backslash in the user information (seam r8).
|
|
145
|
+
const URI_PASSWORD = /^(?:(?:[Hh][Tt][Tt][Pp][Ss]?|[Ww][Ss][Ss]?|[Ff][Tt][Pp]):[/\\]*[^/\\?#]*:[^/\\?#]*@|(?![Hh][Tt][Tt][Pp][Ss]?:|[Ww][Ss][Ss]?:|[Ff][Tt][Pp]:)[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]*:[^/?#]*@)/;
|
|
144
146
|
const RESIDENCY = /^(A[B-Z]|[B-PR-WY][A-Z]|Q[A-L]|Z[A-Y])$/;
|
|
145
147
|
|
|
146
148
|
const RUNNER_KIND_NAME = /^[a-z][a-z0-9-]{1,62}$/;
|
|
@@ -190,8 +192,8 @@ export function descriptorMemberProblems(d) {
|
|
|
190
192
|
if ('repository' in source) {
|
|
191
193
|
const repository = source.repository;
|
|
192
194
|
if (!(typeof repository === 'string' && SOURCE_URI.test(repository.replace(IP_LITERAL, '$1h')))) problems.push('source.repository is a URI (RFC 3986), such as https://github.com/owner/name');
|
|
195
|
+
else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential'); // before the host
|
|
193
196
|
else if (!authorityOk(repository)) problems.push('source.repository names a host, and a port of digits up to 65535 when it has one');
|
|
194
|
-
else if (URI_PASSWORD.test(repository)) problems.push('source.repository carries no password: it names the repository, never a credential');
|
|
195
197
|
places.push(['/source/repository', repository]);
|
|
196
198
|
}
|
|
197
199
|
}
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs
CHANGED
|
@@ -54,6 +54,8 @@ export class Response {
|
|
|
54
54
|
export function redactUrl(url) {
|
|
55
55
|
let parts;
|
|
56
56
|
try { parts = new URL(String(url)); } catch { return '<unparseable url>'; }
|
|
57
|
+
// Only http and https are sent; any other scheme keeps no more than its name (0.56.0 product r10).
|
|
58
|
+
if (parts.protocol !== 'http:' && parts.protocol !== 'https:') return `${parts.protocol}<redacted>`;
|
|
57
59
|
return `${parts.protocol}//${parts.host}${parts.pathname}`;
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -230,13 +230,16 @@ const UNREADABLE_JOB_CAPABILITY = "The operator's mandate cannot be read for thi
|
|
|
230
230
|
* covered, or saying the mandate expired or cannot be read; never the operator's words or `source`; with `fault`
|
|
231
231
|
* mandate-capability-missing beside it when the job's capability cannot read the step, MJ-1); or {covered: false,
|
|
232
232
|
* message: null} with no mandate or a T2 caller's (MD-5), with `fault` when the service's own clock does not read.
|
|
233
|
+
* Undefined and null are no mandate: a call that carried `_meta.mandate: null` passes `{}`, so it reads as
|
|
234
|
+
* mandate-malformed here as it does at the answer (FAA-178).
|
|
233
235
|
*/
|
|
234
236
|
export function jobStep({ mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project } = {}) {
|
|
235
237
|
if (mandate === undefined || mandate === null) return { covered: false, message: null };
|
|
236
238
|
if (!EFFECT_CLASSES.includes(effect)) throw new InteropError(`A job's approval step names its effect class (one of ${EFFECT_CLASSES.join(', ')}; DEC-0125).`);
|
|
237
239
|
const call = { capability, action: key, effect };
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
+
const spend = mandateSpend(spendUsd); // read as actionGate reads it, so the start and the answer agree (FAA-139)
|
|
241
|
+
if (spend !== undefined) call.spendUsd = spend;
|
|
242
|
+
if (project !== undefined && project !== null) call.project = typeof project === 'string' ? project : UNREADABLE_PROJECT;
|
|
240
243
|
const covers = { mandate, tier, now, self, call };
|
|
241
244
|
if (typeof callerAgent === 'string' && callerAgent.length > 0) covers.callerAgent = callerAgent;
|
|
242
245
|
const answer = mandateCovers(covers);
|
|
@@ -271,6 +274,14 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
|
|
|
271
274
|
* mandateCapability: a job's kept step's capability the mandate is read under (DEC-0141 MJ-1; the service's own input,
|
|
272
275
|
* never a caller's); unusable, the refusal is operator-decision-required.
|
|
273
276
|
*/
|
|
277
|
+
/** The project of a call whose project could not be read: never in a mandate's projects (FAA-139). */
|
|
278
|
+
export const UNREADABLE_PROJECT = '\u0000unreadable';
|
|
279
|
+
|
|
280
|
+
/** A spend estimate as a mandate reads it (FAA-139): a finite number stays, anything else is none (a ceiling refuses it). */
|
|
281
|
+
export function mandateSpend(value) {
|
|
282
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
274
285
|
export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
|
|
275
286
|
const inputs = { action, tier, caller, elicited: elicited === true, now };
|
|
276
287
|
if (isObject(meta) && Object.hasOwn(meta, 'decision')) inputs.decision = meta.decision;
|
|
@@ -279,7 +290,8 @@ export function actionGate({ action, tier, caller, meta, target, elicited = fals
|
|
|
279
290
|
}
|
|
280
291
|
if (self !== undefined && self !== null && isObject(meta) && Object.hasOwn(meta, 'mandate')) {
|
|
281
292
|
Object.assign(inputs, { mandate: meta.mandate, self });
|
|
282
|
-
|
|
293
|
+
const read = project === undefined || project === null || typeof project === 'string' ? project : UNREADABLE_PROJECT; // fail closed (FAA-139)
|
|
294
|
+
for (const [name, value] of [['effect', effect], ['callerAgent', callerAgent], ['spendUsd', mandateSpend(spendUsd)], ['project', read], ['mandateCapability', mandateCapability]]) {
|
|
283
295
|
if (value !== undefined && value !== null) inputs[name] = value;
|
|
284
296
|
}
|
|
285
297
|
}
|
|
@@ -100,11 +100,22 @@ export class Client {
|
|
|
100
100
|
if (!Number.isInteger(retries) || retries < 0 || retries > 5) {
|
|
101
101
|
throw new OpenRouterError('retries is an integer from 0 to 5', { kind: 'config' });
|
|
102
102
|
}
|
|
103
|
+
// A query or fragment would take `/chat/completions` (0.56.0 unit r19, FAA-187 (17)); refused before checkUrl, as
|
|
104
|
+
// the Python twin does, so a base checkUrl would also refuse is kind config in both (unit r27).
|
|
105
|
+
if (typeof baseUrl !== 'string') { // as Python's check_url answers a base that is not a string (unit r28)
|
|
106
|
+
throw new http.HttpError('the URL does not parse', { url: '<unparseable url>', kind: 'refused' });
|
|
107
|
+
}
|
|
108
|
+
if (/[?#]/.test(baseUrl)) {
|
|
109
|
+
throw new OpenRouterError('the base URL holds no query or fragment', { kind: 'config' });
|
|
110
|
+
}
|
|
103
111
|
http.checkUrl(baseUrl);
|
|
104
112
|
checkAttribution(projectId, undefined);
|
|
105
113
|
Object.assign(this, { ceilingUsd, ledger, keyEnv, window, attribution, projectId, unmeasuredCallUsd, deadlineS, retries });
|
|
106
114
|
this.prices = Object.fromEntries(Object.entries(prices ?? {}).map(([k, v]) => [k, { ...v }]));
|
|
107
|
-
|
|
115
|
+
// The base URL as checkUrl read it: `new URL` removes tabs and line breaks anywhere and trims C0 controls and
|
|
116
|
+
// spaces from its ends, so `.../v1/ ` and `.../v1\n/` are composed as `.../v1/chat/completions`, as `new URL`
|
|
117
|
+
// composed them in 0.55.0 (0.56.0 product r20, unit r22, seam r22; the Python twin refuses what urllib cannot send).
|
|
118
|
+
this.baseUrl = baseUrl.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+|[\x00-\x20]+$/g, '').replace(/\/+$/, '');
|
|
108
119
|
this.env = env;
|
|
109
120
|
this.transport = transport;
|
|
110
121
|
this.sleep = sleep ?? ((s) => new Promise((resolve) => setTimeout(resolve, s * 1000)));
|
|
@@ -106,6 +106,17 @@ export function feedDataFindings(page) {
|
|
|
106
106
|
return findings;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/** The first place under a member that breaks the bound, for the refusal (FAA-122): `cited[0]` nests an object. */
|
|
110
|
+
function tooDeep(key, member) {
|
|
111
|
+
const entries = Array.isArray(member) ? member.map((leaf, i) => [`${key}[${i}]`, leaf]) : isPlain(member) ? Object.entries(member).map(([inner, leaf]) => [`${key}.${inner}`, leaf]) : [];
|
|
112
|
+
if (entries.length > 32) return `: it has ${entries.length} entries`;
|
|
113
|
+
const bad = entries.find(([, leaf]) => !scalar(leaf));
|
|
114
|
+
if (!bad) return '';
|
|
115
|
+
const leaf = bad[1];
|
|
116
|
+
const what = typeof leaf === 'string' ? 'a string over 500 characters' : Array.isArray(leaf) ? 'a list' : isPlain(leaf) ? 'an object' : 'not a scalar';
|
|
117
|
+
return `: data.value.${bad[0]} is ${what}; nest two levels at most (flatten it into lists or members of their own)`;
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
/** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
|
|
110
121
|
export function feedDataProblems(data) {
|
|
111
122
|
if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
|
|
@@ -117,7 +128,7 @@ export function feedDataProblems(data) {
|
|
|
117
128
|
if (scalar(member)) continue;
|
|
118
129
|
if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
|
|
119
130
|
if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
|
|
120
|
-
problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
|
|
131
|
+
problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members${tooDeep(key, member)}`);
|
|
121
132
|
}
|
|
122
133
|
if (problems.length) return problems;
|
|
123
134
|
return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
|
|
@@ -30,15 +30,18 @@ class BoardSession:
|
|
|
30
30
|
DELETE, which releases every slot it holds (DEC-0073 §5). Thread-safe; the bearer never leaves the headers."""
|
|
31
31
|
|
|
32
32
|
def __init__(self, grant: _hub.BoardGrant, *, timeout: float = 15.0, post: Callable[..., Any] = _hub.post_mcp):
|
|
33
|
-
|
|
33
|
+
# not above 0 is the default: the transports read 0 in opposite ways (seam r8)
|
|
34
|
+
self.grant, self.timeout, self.post = grant, timeout if timeout is not None and timeout > 0 else 15.0, post
|
|
34
35
|
self.mcp_session: Optional[str] = None
|
|
35
36
|
self.protocol_version = _hub.MCP_PROTOCOL_VERSION
|
|
36
37
|
self._lock = threading.Lock()
|
|
37
38
|
self._rpc = 0
|
|
38
39
|
|
|
39
|
-
def _send(self, body: Dict[str, Any], method: str = "POST"):
|
|
40
|
+
def _send(self, body: Dict[str, Any], method: str = "POST", timeout: Optional[float] = None):
|
|
40
41
|
try:
|
|
41
|
-
return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session,
|
|
42
|
+
return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session,
|
|
43
|
+
# none or not above 0 is the session's own: the transports read 0 in opposite ways
|
|
44
|
+
timeout=timeout if timeout is not None and timeout > 0 else self.timeout,
|
|
42
45
|
method=method, protocol_version=self.protocol_version if self.mcp_session else None)
|
|
43
46
|
except _hub.HubError as exc:
|
|
44
47
|
raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
|
|
@@ -83,15 +86,21 @@ class BoardSession:
|
|
|
83
86
|
raise BoardError(e["code"], e["message"], e["retryable"], op=tool)
|
|
84
87
|
return value
|
|
85
88
|
|
|
86
|
-
def close(self) ->
|
|
89
|
+
def close(self, timeout: Optional[float] = None) -> bool:
|
|
90
|
+
"""Sends the DELETE (waiting `timeout` seconds at most when above 0, else the session's); True when Fabric answered it with a
|
|
91
|
+
2xx, so the slots are free now. False otherwise, a 404 (the session was already gone) included: a move made
|
|
92
|
+
before it ended may be read only at the next com.status, so the slot records stay; the lease lapses in 60 s."""
|
|
87
93
|
with self._lock:
|
|
88
94
|
if self.mcp_session is None:
|
|
89
|
-
return
|
|
95
|
+
return False
|
|
90
96
|
try:
|
|
91
|
-
self._send({}, method="DELETE")
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
answer = self._send({}, method="DELETE", timeout=timeout)
|
|
98
|
+
status = answer[0] if isinstance(answer, (tuple, list)) and answer and isinstance(answer[0], int) else 0
|
|
99
|
+
except Exception: # noqa: BLE001 - any failure is no release, as the Node twin's catch reads it
|
|
100
|
+
status = 0
|
|
101
|
+
finally:
|
|
102
|
+
self.mcp_session = None # an answer it cannot read is no release, as the Node twin reads it
|
|
103
|
+
return 200 <= status < 300
|
|
95
104
|
|
|
96
105
|
|
|
97
106
|
class Checkpoint:
|
|
@@ -158,20 +167,30 @@ class Checkpoint:
|
|
|
158
167
|
self.slots = slots
|
|
159
168
|
|
|
160
169
|
|
|
161
|
-
def leave(self, project: str) -> None:
|
|
162
|
-
"""At a clean leave (the session's DELETE released the slots): forget the record of each slot this
|
|
163
|
-
|
|
170
|
+
def leave(self, project: str, held: Dict[str, Any]) -> None:
|
|
171
|
+
"""At a clean leave (the session's DELETE released the slots): forget the record of each slot this session
|
|
172
|
+
confirmed (`held`: capability to the generation it held, or com.status showed unmoved; required) at that
|
|
173
|
+
generation or below, for `project`, with no request left in the checkpoint and not moved away. A record no
|
|
174
|
+
confirmation covers (a slot that may have moved while the service was down, not yet compared with com.status)
|
|
175
|
+
stays (0.56.0 seam r2, r3). A later generation is then another
|
|
164
176
|
session taking a free slot, not a move, and a kit that keeps no record (rolled back to before 0.53.0) leaves
|
|
165
177
|
nothing behind that reads as one. A record that cannot be written stays."""
|
|
166
178
|
busy = {e.get("capability") for e in list(self.entries.values())}
|
|
167
179
|
for capability, record in list(self.slots.items()):
|
|
168
|
-
if isinstance(record, dict) and record.get("project") == project and "moved" not in record
|
|
180
|
+
if (isinstance(record, dict) and record.get("project") == project and "moved" not in record
|
|
181
|
+
and capability not in busy and _covered(record.get("generation"), held.get(capability))):
|
|
169
182
|
try:
|
|
170
183
|
self.slot(capability, None)
|
|
171
184
|
except BoardError:
|
|
172
185
|
pass
|
|
173
186
|
|
|
174
187
|
|
|
188
|
+
def _covered(recorded: Any, confirmed: Any) -> bool:
|
|
189
|
+
"""A record at or below a generation this session confirmed: a lower one is a write that failed before the hold."""
|
|
190
|
+
whole = lambda v: isinstance(v, int) and not isinstance(v, bool) and v >= 1 # noqa: E731
|
|
191
|
+
return whole(recorded) and whole(confirmed) and recorded <= confirmed
|
|
192
|
+
|
|
193
|
+
|
|
175
194
|
class BoardRequest:
|
|
176
195
|
"""A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
|
|
177
196
|
principal (with provenance); `artifacts` its references, unverified."""
|