@passioncode-ai/fabric-agent-adapter 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +7 -2
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +7 -3
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +20 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +8 -5
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +9 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +186 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +6 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +2 -2
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +1 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +448 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +6 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +6 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +234 -19
  27. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  29. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  30. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +4 -3
  31. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +3 -1
@@ -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
- PROTOCOL = "fabric-interop/0.1"
34
- EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
35
- MCP_REVISION = "2026-07-28"
36
- # Revisions a client may name in `initialize`. MCP 2026-07-28 needs no handshake, but clients
37
- # built on earlier revisions (Claude Code 2.1.285 among them) still open with one, and a server
38
- # that refuses it is marked failed before tools/list is ever called.
39
- HANDSHAKE_REVISIONS = ("2025-03-26", "2025-06-18", "2025-11-25", "2026-07-28")
40
- CONTRACT_VERSION = "0.1.0"
41
- JOB_STATES = ("working", "input_required", "completed", "failed", "cancelled")
42
- OUTCOMES = ("succeeded", "partial", "failed", "cancelled", "blocked")
43
- ENVELOPE_REQUIRED = ["id", "contractVersion", "outcome", "done", "proof", "scope", "notVerified", "artifacts",
44
- "createdAt", "producer", "output", "usage"]
45
- # The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
46
- JOB_HANDLE_SCHEMA: Dict[str, Any] = {
47
- "type": "object", "required": ["job"], "additionalProperties": False,
48
- "properties": {"job": {"type": "object", "required": ["id", "status"], "additionalProperties": False,
49
- "properties": {"id": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9._:-]+$"},
50
- "status": {"const": "working"}}}},
51
- }
52
- TERMINAL = ("completed", "failed", "cancelled")
53
-
54
- _TRACEPARENT = re.compile(r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\Z")
55
- _JOB_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}\Z")
56
- _KEY = re.compile(r"^[A-Za-z0-9_.-]{1,64}\Z")
57
- _SECRET_WORDS = re.compile(r"pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|"
58
- r"(access|auth|bearer|refresh|session)[\s_-]?token|^token\Z|credential", re.IGNORECASE)
59
-
60
-
61
- class InteropError(_fs.ServiceError):
62
- """A rule of fabric-interop/0.1 was violated; the message is one readable sentence."""
63
-
64
-
65
- class UnknownJob(InteropError):
66
- def __init__(self, job_id: str):
67
- self.job_id = job_id
68
- super().__init__("unknown-job: no job %s here." % job_id)
69
-
70
-
71
- # --- C3.4 trace context --------------------------------------------------------
72
-
73
- def parse_traceparent(value: Any) -> Optional[Dict[str, str]]:
74
- """W3C Trace Context Level 1, lowercase hex; version ff and all-zero ids are invalid."""
75
- match = _TRACEPARENT.match(value) if isinstance(value, str) else None
76
- if not match:
77
- return None
78
- version, trace_id, span_id, flags = match.groups()
79
- if version == "ff" or set(trace_id) == {"0"} or set(span_id) == {"0"}:
80
- return None
81
- return {"version": version, "trace_id": trace_id, "span_id": span_id, "flags": flags}
82
-
83
-
84
- def _span_id() -> str:
85
- while True:
86
- value = secrets.token_hex(8)
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 ------------------------------------------------------------