@passioncode-ai/fabric-agent-adapter 0.44.0 → 0.44.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +9 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +13 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +5 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +263 -67
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/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/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -30,77 +30,60 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
30
30
|
import fabric_service as _fs # noqa: E402
|
|
31
31
|
from fabric_base import store_lock # noqa: E402 - one lock per store path (R3)
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if set(value) != {"0"}:
|
|
88
|
-
return value
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def child_traceparent(parent: Optional[str]) -> str:
|
|
92
|
-
"""A new span under `parent`; a missing or broken parent starts a new trace (W3C: restart)."""
|
|
93
|
-
parsed = parse_traceparent(parent)
|
|
94
|
-
trace_id = parsed["trace_id"] if parsed else secrets.token_hex(16)
|
|
95
|
-
flags = parsed["flags"] if parsed else "01"
|
|
96
|
-
return "00-%s-%s-%s" % (trace_id, _span_id(), flags)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def trace_ids(traceparent: Optional[str]) -> Dict[str, str]:
|
|
100
|
-
"""The `traceId`/`spanId` pair an event about this span carries (C3.4 c), or {}."""
|
|
101
|
-
parsed = parse_traceparent(traceparent)
|
|
102
|
-
return {"trace_id": parsed["trace_id"], "span_id": parsed["span_id"]} if parsed else {}
|
|
103
|
-
|
|
33
|
+
from fabric_interop_rules import ( # noqa: E402,F401 - the rule layer (FAA-92)
|
|
34
|
+
_days_from_civil,
|
|
35
|
+
PROTOCOL,
|
|
36
|
+
EXTENSION_KEY,
|
|
37
|
+
MCP_REVISION,
|
|
38
|
+
HANDSHAKE_REVISIONS,
|
|
39
|
+
CONTRACT_VERSION,
|
|
40
|
+
JOB_STATES,
|
|
41
|
+
OUTCOMES,
|
|
42
|
+
ENVELOPE_REQUIRED,
|
|
43
|
+
JOB_HANDLE_SCHEMA,
|
|
44
|
+
TERMINAL,
|
|
45
|
+
_TRACEPARENT,
|
|
46
|
+
_JOB_ID,
|
|
47
|
+
_KEY,
|
|
48
|
+
_SECRET_WORDS,
|
|
49
|
+
InteropError,
|
|
50
|
+
UnknownJob,
|
|
51
|
+
parse_traceparent,
|
|
52
|
+
_span_id,
|
|
53
|
+
child_traceparent,
|
|
54
|
+
trace_ids,
|
|
55
|
+
_usage,
|
|
56
|
+
QUOTA_EXHAUSTED,
|
|
57
|
+
OUTCOME_UNKNOWN,
|
|
58
|
+
LIMIT_REACHED,
|
|
59
|
+
INTEROP_NULL_USAGE,
|
|
60
|
+
PROTOCOLS_READ,
|
|
61
|
+
USAGE_NUMBERS,
|
|
62
|
+
_UTC_INSTANT,
|
|
63
|
+
_PAUSE,
|
|
64
|
+
_ERROR_CODE,
|
|
65
|
+
_MAX_STATUS_MESSAGE,
|
|
66
|
+
_MAX_ERROR_MESSAGE,
|
|
67
|
+
_ABSENT,
|
|
68
|
+
_code_points,
|
|
69
|
+
_own,
|
|
70
|
+
utc_time,
|
|
71
|
+
quota_data_ok,
|
|
72
|
+
_job_of,
|
|
73
|
+
read_job_pause,
|
|
74
|
+
job_caller_action,
|
|
75
|
+
_is_integer,
|
|
76
|
+
intent_idempotency_key,
|
|
77
|
+
reads_null_usage,
|
|
78
|
+
request_meta,
|
|
79
|
+
usage_findings,
|
|
80
|
+
usage_toward,
|
|
81
|
+
_job_findings,
|
|
82
|
+
job_limit_rules,
|
|
83
|
+
pause_message,
|
|
84
|
+
intent_digest,
|
|
85
|
+
repeated_key_decision,
|
|
86
|
+
)
|
|
104
87
|
|
|
105
88
|
# --- C3.1 capabilities as tools --------------------------------------------------
|
|
106
89
|
|
|
@@ -175,31 +158,6 @@ def unknown_job_result(job_id: str, traceparent: Optional[str] = None) -> Dict[s
|
|
|
175
158
|
|
|
176
159
|
# --- C3.2 the result envelope ------------------------------------------------------
|
|
177
160
|
|
|
178
|
-
def _usage(usage: Any, nulls: bool = False) -> Dict[str, Any]:
|
|
179
|
-
"""A usage object as fabric-interop/0.1 reads it, or with `nulls` as 0.2 does: a number nobody measured is null and
|
|
180
|
-
`partial` a boolean (DEC-0087)."""
|
|
181
|
-
if not isinstance(usage, dict):
|
|
182
|
-
raise InteropError("usage must be an object.")
|
|
183
|
-
for key in ("inputTokens", "outputTokens", "wallMs"):
|
|
184
|
-
if key not in usage:
|
|
185
|
-
raise InteropError("usage.%s is required." % key)
|
|
186
|
-
for key, value in usage.items():
|
|
187
|
-
if nulls and key == "partial":
|
|
188
|
-
if not isinstance(value, bool):
|
|
189
|
-
raise InteropError("usage.partial must be a boolean.")
|
|
190
|
-
continue
|
|
191
|
-
if key not in USAGE_NUMBERS:
|
|
192
|
-
raise InteropError("usage.%s is not a usage field." % key)
|
|
193
|
-
if nulls and value is None:
|
|
194
|
-
continue
|
|
195
|
-
numeric = isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
196
|
-
if not numeric or value < 0 or (key != "costUsd" and not isinstance(value, int)):
|
|
197
|
-
raise InteropError("usage.%s must be a non-negative %s." % (key, "number" if key == "costUsd" else "integer"))
|
|
198
|
-
if nulls and usage_findings(usage, ""):
|
|
199
|
-
raise InteropError("usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).")
|
|
200
|
-
return dict(usage)
|
|
201
|
-
|
|
202
|
-
|
|
203
161
|
def result_envelope(*, outcome: str, done: List[Dict[str, Any]], proof: List[Dict[str, Any]], scope: Dict[str, Any],
|
|
204
162
|
not_verified: List[Dict[str, Any]], output: Any, usage: Dict[str, Any], producer: Dict[str, Any],
|
|
205
163
|
artifacts: Optional[List[Dict[str, Any]]] = None, traceparent: Optional[str] = None,
|
|
@@ -228,279 +186,6 @@ def result_envelope(*, outcome: str, done: List[Dict[str, Any]], proof: List[Dic
|
|
|
228
186
|
return envelope
|
|
229
187
|
|
|
230
188
|
|
|
231
|
-
# --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
|
|
232
|
-
# #region job-limits-kit, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
|
|
233
|
-
# The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json). Every function
|
|
234
|
-
# here answers and never raises on malformed input.
|
|
235
|
-
|
|
236
|
-
QUOTA_EXHAUSTED = "quota-exhausted"
|
|
237
|
-
OUTCOME_UNKNOWN = "outcome-unknown"
|
|
238
|
-
LIMIT_REACHED = "limit-reached"
|
|
239
|
-
INTEROP_NULL_USAGE = "fabric-interop/0.2"
|
|
240
|
-
#: The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087).
|
|
241
|
-
PROTOCOLS_READ = ("fabric-interop/0.1", INTEROP_NULL_USAGE)
|
|
242
|
-
USAGE_NUMBERS = ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens", "costUsd", "wallMs")
|
|
243
|
-
_UTC_INSTANT = re.compile(r"^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z\Z")
|
|
244
|
-
_PAUSE = re.compile(r"^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?\Z")
|
|
245
|
-
_ERROR_CODE = re.compile(r"^[a-z][a-z0-9-]{1,63}\Z")
|
|
246
|
-
_MAX_STATUS_MESSAGE = 500
|
|
247
|
-
_MAX_ERROR_MESSAGE = 2000
|
|
248
|
-
_ABSENT = object()
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
def _code_points(value: str) -> int:
|
|
252
|
-
"""JSON Schema's length: code points. A Python str holds code points, a lone surrogate counting once as in JavaScript."""
|
|
253
|
-
return len(value)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def _own(value: Any, key: str) -> Any:
|
|
257
|
-
return value.get(key, _ABSENT) if isinstance(value, dict) else _ABSENT
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
from fabric_time import days_from_civil as _days_from_civil # noqa: E402 - the core's (FAA-65)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
def utc_time(value: Any) -> Optional[int]:
|
|
264
|
-
"""A UTC instant (`interop-common#/$defs/utcInstant`) as milliseconds since the epoch, or None: the pattern and a
|
|
265
|
-
day that exists, as the contract's strictTime reads it."""
|
|
266
|
-
match = _UTC_INSTANT.match(value) if isinstance(value, str) else None
|
|
267
|
-
if not match:
|
|
268
|
-
return None
|
|
269
|
-
year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
|
|
270
|
-
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
271
|
-
days = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
272
|
-
if not 1 <= month <= 12 or not 1 <= day <= days[month - 1] or hour > 23 or minute > 59:
|
|
273
|
-
return None
|
|
274
|
-
millis = int((match.group(7) or "").ljust(3, "0"))
|
|
275
|
-
return ((_days_from_civil(year, month, day) * 24 + hour) * 60 + minute) * 60000 + second * 1000 + millis
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def quota_data_ok(data: Any) -> bool:
|
|
279
|
-
"""Whether `data` is quota-exhausted's: closed, `retryable` a boolean, `resetsAt` a UTC instant, only with retryable true."""
|
|
280
|
-
retryable = _own(data, "retryable")
|
|
281
|
-
if not isinstance(data, dict) or not isinstance(retryable, bool):
|
|
282
|
-
return False
|
|
283
|
-
if any(name not in ("retryable", "resetsAt") for name in data):
|
|
284
|
-
return False
|
|
285
|
-
if "resetsAt" not in data:
|
|
286
|
-
return True
|
|
287
|
-
return retryable and utc_time(data["resetsAt"]) is not None
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _job_of(answer: Any) -> Optional[Dict[str, Any]]:
|
|
291
|
-
job = _own(answer, "job")
|
|
292
|
-
job_id = _own(job, "id")
|
|
293
|
-
if not isinstance(job, dict) or not isinstance(job_id, str) or not job_id or _own(job, "status") not in JOB_STATES:
|
|
294
|
-
return None
|
|
295
|
-
return job
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
def read_job_pause(answer: Any) -> Dict[str, Any]:
|
|
299
|
-
"""Whether a fabric.job.get answer ({job}) is a job a limit paused: working, its statusMessage `quota-exhausted` or
|
|
300
|
-
`quota-exhausted until <UTC instant>`, either optionally followed by `: <text>`. One that starts quota-exhausted and
|
|
301
|
-
breaks the grammar is malformed."""
|
|
302
|
-
job = _job_of(answer)
|
|
303
|
-
message = _own(job, "statusMessage")
|
|
304
|
-
if job is None or job.get("status") != "working" or not isinstance(message, str) or not message.startswith(QUOTA_EXHAUSTED):
|
|
305
|
-
return {"paused": False}
|
|
306
|
-
match = _PAUSE.match(message)
|
|
307
|
-
if not match or _code_points(message) > _MAX_STATUS_MESSAGE:
|
|
308
|
-
return {"paused": False, "malformed": True}
|
|
309
|
-
until = match.group(1)
|
|
310
|
-
if until is None:
|
|
311
|
-
return {"paused": True}
|
|
312
|
-
return {"paused": True, "resetsAt": until} if utc_time(until) is not None else {"paused": False, "malformed": True}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def job_caller_action(answer: Any, now: Any = _ABSENT) -> Dict[str, Any]:
|
|
316
|
-
"""What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`, a UTC instant: poll, wait, answer,
|
|
317
|
-
done, cancelled, resend (under the same key), person, reconcile, failed or invalid. Never "submit anew"."""
|
|
318
|
-
at = utc_time(now)
|
|
319
|
-
job = _job_of(answer)
|
|
320
|
-
if at is None or job is None:
|
|
321
|
-
return {"action": "invalid"}
|
|
322
|
-
status = job["status"]
|
|
323
|
-
if status == "working":
|
|
324
|
-
pause = read_job_pause(answer)
|
|
325
|
-
if "malformed" in pause:
|
|
326
|
-
return {"action": "invalid"}
|
|
327
|
-
if not pause["paused"]:
|
|
328
|
-
return {"action": "poll"}
|
|
329
|
-
if "resetsAt" not in pause:
|
|
330
|
-
return {"action": "wait"}
|
|
331
|
-
return {"action": "wait", "notBefore": pause["resetsAt"]} if utc_time(pause["resetsAt"]) > at else {"action": "poll"}
|
|
332
|
-
if status in ("input_required", "completed", "cancelled"):
|
|
333
|
-
return {"action": {"input_required": "answer", "completed": "done", "cancelled": "cancelled"}[status]}
|
|
334
|
-
error = _own(job, "error")
|
|
335
|
-
code = _own(error, "code")
|
|
336
|
-
message = _own(error, "message")
|
|
337
|
-
code_ok = (isinstance(code, str) and bool(_ERROR_CODE.match(code))) or _is_integer(code)
|
|
338
|
-
shape_ok = isinstance(error, dict) and all(name in ("code", "message", "data") for name in error)
|
|
339
|
-
if not shape_ok or not code_ok or not isinstance(message, str) or not message or _code_points(message) > _MAX_ERROR_MESSAGE:
|
|
340
|
-
return {"action": "invalid"}
|
|
341
|
-
if code == OUTCOME_UNKNOWN:
|
|
342
|
-
return {"action": "reconcile"}
|
|
343
|
-
if code == LIMIT_REACHED:
|
|
344
|
-
return {"action": "person"}
|
|
345
|
-
if code != QUOTA_EXHAUSTED:
|
|
346
|
-
return {"action": "failed"}
|
|
347
|
-
data = _own(error, "data")
|
|
348
|
-
if not quota_data_ok(data):
|
|
349
|
-
return {"action": "invalid"}
|
|
350
|
-
if not data["retryable"]:
|
|
351
|
-
return {"action": "person"}
|
|
352
|
-
if "resetsAt" in data and utc_time(data["resetsAt"]) > at:
|
|
353
|
-
return {"action": "resend", "notBefore": data["resetsAt"]}
|
|
354
|
-
return {"action": "resend"}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def _is_integer(value: Any) -> bool:
|
|
358
|
-
"""JavaScript's Number.isInteger for a JSON value."""
|
|
359
|
-
if isinstance(value, bool):
|
|
360
|
-
return False
|
|
361
|
-
if isinstance(value, int):
|
|
362
|
-
return True
|
|
363
|
-
return isinstance(value, float) and value == value and value not in (float("inf"), float("-inf")) and value == int(value)
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def intent_idempotency_key(intent: Any) -> Optional[str]:
|
|
367
|
-
"""The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 from the first 16
|
|
368
|
-
bytes of the SHA-256 of its RFC 8785 form. The same intent gives the same key on every resend and machine; None
|
|
369
|
-
for another shape or an input with no RFC 8785 form."""
|
|
370
|
-
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
371
|
-
names = ("agentId", "capability", "input", "request")
|
|
372
|
-
if not isinstance(intent, dict) or any(name not in names for name in intent):
|
|
373
|
-
return None
|
|
374
|
-
agent_id, capability, given = intent.get("agentId"), intent.get("capability"), intent.get("input")
|
|
375
|
-
if not isinstance(agent_id, str) or not agent_id or not isinstance(capability, str) or not capability or not isinstance(given, dict):
|
|
376
|
-
return None
|
|
377
|
-
form = {"agentId": agent_id, "capability": capability, "input": given}
|
|
378
|
-
if "request" in intent:
|
|
379
|
-
request = intent["request"]
|
|
380
|
-
if not isinstance(request, str) or not request or _code_points(request) > 256:
|
|
381
|
-
return None
|
|
382
|
-
form["request"] = request
|
|
383
|
-
try:
|
|
384
|
-
canonical = _fd.jcs(form)
|
|
385
|
-
except (TypeError, ValueError, RecursionError):
|
|
386
|
-
return None
|
|
387
|
-
raw = bytearray(hashlib.sha256(canonical.encode("utf-8")).digest()[:16])
|
|
388
|
-
raw[6] = (raw[6] & 0x0F) | 0x80
|
|
389
|
-
raw[8] = (raw[8] & 0x3F) | 0x80
|
|
390
|
-
hexed = raw.hex()
|
|
391
|
-
return "%s-%s-%s-%s-%s" % (hexed[:8], hexed[8:12], hexed[12:16], hexed[16:20], hexed[20:])
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def reads_null_usage(request_meta: Any) -> bool:
|
|
395
|
-
"""Whether a provider may write null and partial in usage toward this caller: its _meta.protocols names fabric-interop/0.2."""
|
|
396
|
-
protocols = _own(request_meta, "protocols")
|
|
397
|
-
return isinstance(protocols, list) and INTEROP_NULL_USAGE in protocols
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def request_meta(traceparent: Optional[str] = None) -> Dict[str, Any]:
|
|
401
|
-
"""A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087)."""
|
|
402
|
-
meta: Dict[str, Any] = {"protocols": list(PROTOCOLS_READ)}
|
|
403
|
-
if parse_traceparent(traceparent):
|
|
404
|
-
meta["traceparent"] = traceparent
|
|
405
|
-
return meta
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
def usage_findings(usage: Any, at: str) -> List[Dict[str, str]]:
|
|
409
|
-
"""FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null."""
|
|
410
|
-
if not isinstance(usage, dict) or usage.get("partial", _ABSENT) is not False:
|
|
411
|
-
return []
|
|
412
|
-
unknown = [name for name in USAGE_NUMBERS if name in usage and usage[name] is None]
|
|
413
|
-
if not unknown:
|
|
414
|
-
return []
|
|
415
|
-
return [{"code": "FAC-SEM-119", "instancePath": "%s/partial" % at,
|
|
416
|
-
"message": "partial is false while %s is null: an unknown number makes the usage partial" % ", ".join(unknown)}]
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
def usage_toward(usage: Any, request_meta: Any = None) -> Dict[str, Any]:
|
|
420
|
-
"""The usage a provider writes toward a caller (the gate): toward a reader of fabric-interop/0.2, null for a number
|
|
421
|
-
nobody measured and partial true beside it; toward any other, the measured lower bound (null as 0, no partial)."""
|
|
422
|
-
if reads_null_usage(request_meta):
|
|
423
|
-
checked = _usage(usage, nulls=True)
|
|
424
|
-
if any(checked.get(name, 0) is None for name in USAGE_NUMBERS) and "partial" not in checked:
|
|
425
|
-
checked["partial"] = True
|
|
426
|
-
return checked
|
|
427
|
-
if not isinstance(usage, dict):
|
|
428
|
-
raise InteropError("usage must be an object.")
|
|
429
|
-
lower = {key: (0 if value is None else value) for key, value in usage.items() if key != "partial"}
|
|
430
|
-
return _usage(lower)
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def _job_findings(value: Any) -> List[Dict[str, str]]:
|
|
434
|
-
findings: List[Dict[str, str]] = []
|
|
435
|
-
if "malformed" in read_job_pause(value):
|
|
436
|
-
findings.append({"code": "FAC-SEM-119", "instancePath": "/job/statusMessage",
|
|
437
|
-
"message": "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters"})
|
|
438
|
-
error = _own(_own(value, "job"), "error")
|
|
439
|
-
data = _own(error, "data")
|
|
440
|
-
resets_at = _own(data, "resetsAt")
|
|
441
|
-
if _own(error, "code") == QUOTA_EXHAUSTED and isinstance(resets_at, str) and utc_time(resets_at) is None:
|
|
442
|
-
findings.append({"code": "FAC-SEM-119", "instancePath": "/job/error/data/resetsAt", "message": "resetsAt is a UTC instant on a day that exists"})
|
|
443
|
-
result = _own(_own(value, "job"), "result")
|
|
444
|
-
findings.extend(usage_findings(_own(result, "usage"), "/job/result/usage"))
|
|
445
|
-
return findings
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
def job_limit_rules(kind: str, value: Any) -> Optional[List[Dict[str, str]]]:
|
|
449
|
-
"""FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller, {answer, now, action}: the
|
|
450
|
-
act equals job_caller_action's). None for another kind."""
|
|
451
|
-
if kind == "interop-job":
|
|
452
|
-
return _job_findings(value)
|
|
453
|
-
if kind == "usage":
|
|
454
|
-
return usage_findings(value, "")
|
|
455
|
-
if kind == "result":
|
|
456
|
-
return usage_findings(_own(value, "usage"), "/usage")
|
|
457
|
-
if kind == "job-caller":
|
|
458
|
-
expected = job_caller_action(_own(value, "answer"), _own(value, "now"))
|
|
459
|
-
if json.dumps(_own(value, "action"), sort_keys=True, default=str) == json.dumps(expected, sort_keys=True):
|
|
460
|
-
return []
|
|
461
|
-
return [{"code": "FAC-SEM-120", "instancePath": "/action", "message": "the caller answers %s as jobCallerAction does" % json.dumps(expected, separators=(",", ":"))}]
|
|
462
|
-
return None
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def pause_message(resets_at: Optional[str] = None, text: Optional[str] = None) -> str:
|
|
466
|
-
"""A paused job's statusMessage in the pause grammar; raises InteropError for one a reader would call malformed."""
|
|
467
|
-
message = QUOTA_EXHAUSTED + ("" if resets_at is None else " until %s" % resets_at) + ("" if text is None else ": %s" % text)
|
|
468
|
-
if "malformed" in read_job_pause({"job": {"id": "x", "status": "working", "statusMessage": message}}):
|
|
469
|
-
raise InteropError("A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.")
|
|
470
|
-
return message
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def intent_digest(capability: str, arguments: Any) -> str:
|
|
474
|
-
"""What a key is kept beside (FAA-77): the SHA-256 of the RFC 8785 form of [capability, input], so a key admits
|
|
475
|
-
only the intent it was first sent with. An input with no RFC 8785 form is refused."""
|
|
476
|
-
import fabric_delegation as _fd # noqa: E402 - beside this file: RFC 8785
|
|
477
|
-
try:
|
|
478
|
-
return "sha256:" + hashlib.sha256(_fd.jcs([capability, arguments]).encode("utf-8")).hexdigest()
|
|
479
|
-
except (TypeError, ValueError, RecursionError):
|
|
480
|
-
raise InteropError("A job's input with an idempotency key needs a JSON form (RFC 8785).") from None
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
def repeated_key_decision(job: Any, now: Any, limit_holds: Optional[bool] = None, approval_granted: bool = False) -> str:
|
|
484
|
-
"""The provider's answer to a repeated key: "run" (the same intent runs again, once) or "same" (answer with that job).
|
|
485
|
-
A quota-exhausted job runs again once the limit no longer holds: `limit_holds` False when the provider knows it, else
|
|
486
|
-
retryable with its reset passed at `now`; a limit-reached job once its approval is granted; anything else, and
|
|
487
|
-
outcome-unknown above all, never."""
|
|
488
|
-
if not isinstance(job, dict) or job.get("status") != "failed":
|
|
489
|
-
return "same"
|
|
490
|
-
error = _own(job, "error")
|
|
491
|
-
code = _own(error, "code")
|
|
492
|
-
if code == LIMIT_REACHED:
|
|
493
|
-
return "run" if approval_granted is True else "same"
|
|
494
|
-
if code != QUOTA_EXHAUSTED:
|
|
495
|
-
return "same"
|
|
496
|
-
if limit_holds is not None:
|
|
497
|
-
return "run" if limit_holds is False else "same"
|
|
498
|
-
data = _own(error, "data")
|
|
499
|
-
at = utc_time(now)
|
|
500
|
-
if not quota_data_ok(data) or not data["retryable"] or at is None:
|
|
501
|
-
return "same"
|
|
502
|
-
return "run" if "resetsAt" not in data or utc_time(data["resetsAt"]) <= at else "same"
|
|
503
|
-
# #endregion job-limits-kit
|
|
504
189
|
|
|
505
190
|
|
|
506
191
|
# --- C3.3 awaiting a choice ------------------------------------------------------------
|