@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,358 @@
|
|
|
1
|
+
"""The call queue and the load report of DEC-0104 (contract `src/agent-network.ts`, FAC-SEM-131 to FAC-SEM-133), as the
|
|
2
|
+
contract's reference answers them: a node that hosts a job-backed agent queues its jobs per (agentRef, context) with
|
|
3
|
+
these and reports its load as `service-load/1` under `extensions/load/0.1`. Part of the service kit (FAA-289, layer L3);
|
|
4
|
+
the twin of fabric-load.mjs. Standard library only. Nothing here raises on what it reads, except `load_extension`, which
|
|
5
|
+
refuses a declaration a reader would treat as not declared.
|
|
6
|
+
|
|
7
|
+
- job_caller_key(accepted, credential): the opaque key of a job's caller (an accepted delegation's principal, its
|
|
8
|
+
outermost actor and a partner organization ending the chain; else the credential the node knows the caller by).
|
|
9
|
+
- next_queued_job(queue, running, limits): the job the node starts next, or None (fairness between callers).
|
|
10
|
+
- admit_job(queue, running, job, limits, retry_after_seconds): {admit: True}, or the `queue-full` refusal with its data.
|
|
11
|
+
- queue_status_message(position, length), read_queue_position(status_message): `queued <position> of <length>`.
|
|
12
|
+
- job_queue_rules(value): FAC-SEM-131 over {queue, running, limits, started}.
|
|
13
|
+
- load_report_rules(report): FAC-SEM-132 on a `service-load/1` report; load_report_problems(report): its schema.
|
|
14
|
+
- load_view(well_known), load_extension_rules(well_known): the declaration as a reader reads it, and FAC-SEM-133.
|
|
15
|
+
- load_extension(path, window_seconds, limits): the `extensions` entry a service declares.
|
|
16
|
+
|
|
17
|
+
The kit has no JSON Schema validator: the two shapes are written here from the schemas as the contract's validator
|
|
18
|
+
reads them (ECMAScript's \\s in patterns, lengths in code points, `date-time` as ajv-formats' full mode). Times in the
|
|
19
|
+
queue are UTC instants (`interop-common#/$defs/utcInstant`); times in the report are RFC 3339 read strictly (the
|
|
20
|
+
contract's strictTime). The wording of a schema problem is the kit's, not ajv's."""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import math
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
import sys as _sys # noqa: E402
|
|
28
|
+
from pathlib import Path as _Path # noqa: E402
|
|
29
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
30
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
31
|
+
|
|
32
|
+
from fabric_base import ServiceError # noqa: E402
|
|
33
|
+
from fabric_connections import date_time # noqa: E402 - ajv-formats' date-time, as the contract's validator reads it
|
|
34
|
+
from fabric_jsvalue import ( # noqa: E402
|
|
35
|
+
MISSING, _JS_SPACE, double, get, is_number, json_stringify, objects, safe_integer, same, show,
|
|
36
|
+
)
|
|
37
|
+
from fabric_time import parse_rfc3339_ms # noqa: E402 - the contract's strictTime
|
|
38
|
+
|
|
39
|
+
LOAD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/load/0.1"
|
|
40
|
+
LOAD_REPORT_TYPE = "service-load/1"
|
|
41
|
+
#: The refusal code, the same as fabric_interop_rules.QUEUE_FULL (a test holds them equal); not imported because the
|
|
42
|
+
#: facade fabric_service, which re-exports this module, comes before the interop rules in the kit's order.
|
|
43
|
+
_QUEUE_FULL = "queue-full"
|
|
44
|
+
QUEUE_FULL_MESSAGE = "the agent's queue for this context is full: start again later"
|
|
45
|
+
#: The longest queue a status message names (`queued <position> of <length>`, six digits).
|
|
46
|
+
QUEUE_LENGTH_MAX = 999999
|
|
47
|
+
DEFAULT_RETRY_AFTER_SECONDS = 60
|
|
48
|
+
|
|
49
|
+
_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", re.ASCII)
|
|
50
|
+
_QUEUED = re.compile(r"queued ([1-9][0-9]{0,5}) of ([1-9][0-9]{0,5})", re.ASCII)
|
|
51
|
+
_ORIGIN_PATH = re.compile("/(?!/)[^%s]*" % re.escape(_JS_SPACE))
|
|
52
|
+
_CONTEXT = re.compile(r"(?:personal|org:[A-Za-z0-9][A-Za-z0-9_-]{0,127})", re.ASCII)
|
|
53
|
+
_KEY_PATH = "/extensions/" + LOAD_EXTENSION_KEY.replace("~", "~0").replace("/", "~1")
|
|
54
|
+
_LIMITS = (("concurrency", 1, 1024), ("perCaller", 1, 1024), ("queueMax", 0, 100000))
|
|
55
|
+
_ROW_COUNTS = ("queued", "running", "completed", "failed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _utc_ms(value: Any) -> float:
|
|
59
|
+
"""A UTC instant (`T`, `Z`, at most three fraction digits, a day that exists) in milliseconds, else NaN."""
|
|
60
|
+
return parse_rfc3339_ms(value) if isinstance(value, str) and _UTC_INSTANT.fullmatch(value) else math.nan
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _strict_ms(value: Any) -> float:
|
|
64
|
+
return parse_rfc3339_ms(value) if isinstance(value, str) else math.nan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _utf16(value: str) -> bytes:
|
|
68
|
+
"""A key that orders strings as JavaScript's `<` does: by UTF-16 code units."""
|
|
69
|
+
return value.encode("utf-16-be", "surrogatepass")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _string(value: Any) -> bool:
|
|
73
|
+
return isinstance(value, str)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- The call queue (fabric-interop, DEC-0104) ---
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def job_caller_key(accepted: Any = MISSING, credential: Any = MISSING) -> Optional[str]:
|
|
80
|
+
"""The key of a job's caller at a node that queues (DEC-0104), as the contract's jobCallerKey: for a call whose
|
|
81
|
+
delegation the node accepted (verify_delegation's answer), its `principal` and `caller` and the partner
|
|
82
|
+
organization when the chain ends in one (DEC-0090); for a call with no token, the credential the node knows the
|
|
83
|
+
caller by (a gate's pass with `principal: None` is that case too). A refused verdict has no key, and neither has
|
|
84
|
+
an unreadable call: None. The key is opaque; only equality matters."""
|
|
85
|
+
if isinstance(accepted, dict) and not (get(accepted, "ok") is True and get(accepted, "principal") is None):
|
|
86
|
+
principal, caller = get(accepted, "principal"), get(accepted, "caller")
|
|
87
|
+
if get(accepted, "ok") is not True or not _string(principal) or principal == "" or not _string(caller) or caller == "":
|
|
88
|
+
return None
|
|
89
|
+
chain = get(accepted, "chain")
|
|
90
|
+
chain = chain if isinstance(chain, list) else []
|
|
91
|
+
last = chain[-1] if chain else MISSING
|
|
92
|
+
partner = [last] if len(chain) > 1 and _string(last) and last.startswith("org:") else []
|
|
93
|
+
return json_stringify(["delegated", principal, caller] + partner)
|
|
94
|
+
return json_stringify(["credential", credential]) if _string(credential) and credential != "" else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _limits_ok(limits: Any) -> bool:
|
|
98
|
+
if not isinstance(limits, dict):
|
|
99
|
+
return False
|
|
100
|
+
concurrency, per_caller, queue_max = get(limits, "concurrency"), get(limits, "perCaller"), get(limits, "queueMax")
|
|
101
|
+
return (all(safe_integer(n) and double(n) >= 1 for n in (concurrency, per_caller))
|
|
102
|
+
and safe_integer(queue_max) and double(queue_max) >= 0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _waiting_job(job: Any) -> bool:
|
|
106
|
+
return isinstance(job, dict) and _string(get(job, "id")) and _string(get(job, "caller")) and math.isfinite(_utc_ms(get(job, "at")))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def next_queued_job(queue: Any, running: Any, limits: Any) -> Optional[str]:
|
|
110
|
+
"""The job the hosting node starts next for one (agentRef, context), or None (DEC-0104). Nothing starts while
|
|
111
|
+
`concurrency` jobs run (every running job holds a slot, its caller readable or not). Of the waiting jobs whose
|
|
112
|
+
caller runs fewer than `perCaller`, the one whose caller runs the fewest wins; between those callers, the one whose
|
|
113
|
+
oldest waiting job arrived first; within a caller, its oldest. Arrival ties break by id (UTF-16 order)."""
|
|
114
|
+
if not _limits_ok(limits):
|
|
115
|
+
return None
|
|
116
|
+
if (len(running) if isinstance(running, list) else 0) >= double(limits["concurrency"]):
|
|
117
|
+
return None
|
|
118
|
+
running_of: Dict[str, int] = {}
|
|
119
|
+
for job in objects(running):
|
|
120
|
+
if _string(get(job, "caller")):
|
|
121
|
+
running_of[job["caller"]] = running_of.get(job["caller"], 0) + 1
|
|
122
|
+
waiting = [job for job in objects(queue) if _waiting_job(job) and running_of.get(job["caller"], 0) < double(limits["perCaller"])]
|
|
123
|
+
waiting.sort(key=lambda job: (_utc_ms(job["at"]), _utf16(job["id"])))
|
|
124
|
+
best: Optional[Dict[str, Any]] = None
|
|
125
|
+
for job in waiting:
|
|
126
|
+
if best is None or running_of.get(job["caller"], 0) < running_of.get(best["caller"], 0):
|
|
127
|
+
best = job
|
|
128
|
+
return None if best is None else best["id"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def admit_job(queue: Any, running: Any, job: Any, limits: Any, retry_after_seconds: Any = MISSING) -> Dict[str, Any]:
|
|
132
|
+
"""Whether a start is admitted (DEC-0104): it is when the job would start at once (next_queued_job over the queue
|
|
133
|
+
with it added answers its id), or when fewer than `queueMax` jobs wait; otherwise {admit: False, error} with the
|
|
134
|
+
`queue-full` refusal and its `retryAfterSeconds` (1 to 3600, else 60). Unreadable limits or job refuse."""
|
|
135
|
+
waiting = objects(queue)
|
|
136
|
+
if _limits_ok(limits) and _waiting_job(job) and (
|
|
137
|
+
next_queued_job(waiting + [job], running, limits) == job["id"] or len(waiting) < double(limits["queueMax"])):
|
|
138
|
+
return {"admit": True}
|
|
139
|
+
after = retry_after_seconds if safe_integer(retry_after_seconds) and 1 <= double(retry_after_seconds) <= 3600 else DEFAULT_RETRY_AFTER_SECONDS
|
|
140
|
+
return {"admit": False, "error": {"code": _QUEUE_FULL, "message": QUEUE_FULL_MESSAGE,
|
|
141
|
+
"data": {"retryable": True, "retryAfterSeconds": int(after)}}}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def queue_status_message(position: Any, length: Any) -> Optional[str]:
|
|
145
|
+
"""The `statusMessage` of a waiting job (DEC-0104): `queued <position> of <length>`, 1 is next, 1 <= position <=
|
|
146
|
+
length <= 999999; None for anything else."""
|
|
147
|
+
if not (safe_integer(position) and safe_integer(length)):
|
|
148
|
+
return None
|
|
149
|
+
if not 1 <= double(position) <= double(length) <= QUEUE_LENGTH_MAX:
|
|
150
|
+
return None
|
|
151
|
+
return "queued %d of %d" % (int(position), int(length))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def read_queue_position(status_message: Any) -> Optional[Dict[str, int]]:
|
|
155
|
+
"""A waiting job's place, read from its `statusMessage`, or None when it is not the queue's grammar."""
|
|
156
|
+
match = _QUEUED.fullmatch(status_message) if _string(status_message) else None
|
|
157
|
+
if not match:
|
|
158
|
+
return None
|
|
159
|
+
position, length = int(match.group(1)), int(match.group(2))
|
|
160
|
+
return {"position": position, "length": length} if 1 <= position <= length else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _finding(code: str, instance_path: str, message: str) -> Dict[str, str]:
|
|
164
|
+
return {"code": code, "instancePath": instance_path, "message": message}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def job_queue_rules(value: Any) -> List[Dict[str, str]]:
|
|
168
|
+
"""FAC-SEM-131 on {queue, running, limits, started} (`started`: the id the node started, or None): it is
|
|
169
|
+
next_queued_job's."""
|
|
170
|
+
want = next_queued_job(get(value, "queue"), get(value, "running"), get(value, "limits"))
|
|
171
|
+
started = get(value, "started")
|
|
172
|
+
if same(None if started is MISSING else started, want):
|
|
173
|
+
return []
|
|
174
|
+
return [_finding("FAC-SEM-131", "/started", "nothing may start now" if want is None else "the job to start is %s" % show(want))]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- The load report (DEC-0104) ---
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_report_rules(report: Any) -> List[Dict[str, str]]:
|
|
181
|
+
"""FAC-SEM-132 on a `service-load/1` report: windows in order one `windowSeconds` apart, none after `generatedAt`,
|
|
182
|
+
one row per context, p50 at most p95, a run time only where a job ended."""
|
|
183
|
+
findings: List[Dict[str, str]] = []
|
|
184
|
+
flag = lambda path, message: findings.append(_finding("FAC-SEM-132", path, message)) # noqa: E731
|
|
185
|
+
seconds = get(report, "windowSeconds")
|
|
186
|
+
step = double(seconds) * 1000 if safe_integer(seconds) else math.nan
|
|
187
|
+
generated = _strict_ms(get(report, "generatedAt"))
|
|
188
|
+
previous: Any = MISSING
|
|
189
|
+
for index, window in enumerate(objects(get(report, "windows"))):
|
|
190
|
+
start = _strict_ms(get(window, "start"))
|
|
191
|
+
if start > generated:
|
|
192
|
+
flag("/windows/%d/start" % index, "a window starts after the report was generated")
|
|
193
|
+
if previous is not MISSING and start - previous != step:
|
|
194
|
+
flag("/windows/%d/start" % index, "windows are not in order one windowSeconds apart")
|
|
195
|
+
previous = start
|
|
196
|
+
seen = set()
|
|
197
|
+
for at, row in enumerate(objects(get(window, "byContext"))):
|
|
198
|
+
path = "/windows/%d/byContext/%d" % (index, at)
|
|
199
|
+
context = get(row, "context")
|
|
200
|
+
if _string(context) and context in seen:
|
|
201
|
+
flag(path + "/context", "context %s appears twice in one window" % show(context))
|
|
202
|
+
if _string(context):
|
|
203
|
+
seen.add(context)
|
|
204
|
+
for name in ("waitMs", "runMs"):
|
|
205
|
+
pct = get(row, name)
|
|
206
|
+
p50, p95 = get(pct, "p50"), get(pct, "p95")
|
|
207
|
+
if isinstance(pct, dict) and is_number(p50) and is_number(p95) and double(p50) > double(p95):
|
|
208
|
+
flag("%s/%s" % (path, name), "p50 is above p95")
|
|
209
|
+
if isinstance(get(row, "runMs"), dict) and same(get(row, "completed"), 0) and same(get(row, "failed"), 0):
|
|
210
|
+
flag(path + "/runMs", "a run time with no job ended in the window")
|
|
211
|
+
return findings
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _integer(value: Any, low: float, high: Optional[float], at: str, errors: List[str]) -> None:
|
|
215
|
+
"""JSON Schema's integer with its bounds, as ajv reads it: a number with no fraction (2.0 is one), never a bool."""
|
|
216
|
+
if not is_number(value) or not math.isfinite(double(value)) or double(value) != math.floor(double(value)):
|
|
217
|
+
errors.append("%s type" % at)
|
|
218
|
+
elif double(value) < low:
|
|
219
|
+
errors.append("%s minimum" % at)
|
|
220
|
+
elif high is not None and double(value) > high:
|
|
221
|
+
errors.append("%s maximum" % at)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _members(value: Dict[str, Any], allowed: tuple, required: tuple, at: str, errors: List[str]) -> None:
|
|
225
|
+
errors.extend("%s/%s additionalProperties" % (at, key) for key in value if key not in allowed)
|
|
226
|
+
errors.extend("%s required %s" % (at or "/", key) for key in required if key not in value)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _timestamp(value: Any, at: str, errors: List[str]) -> None:
|
|
230
|
+
if not _string(value):
|
|
231
|
+
errors.append("%s type" % at)
|
|
232
|
+
elif not date_time(value):
|
|
233
|
+
errors.append("%s format" % at)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _percentiles(value: Any, at: str, errors: List[str]) -> None:
|
|
237
|
+
if not isinstance(value, dict):
|
|
238
|
+
errors.append("%s type" % at)
|
|
239
|
+
return
|
|
240
|
+
_members(value, ("p50", "p95"), ("p50", "p95"), at, errors)
|
|
241
|
+
for name in ("p50", "p95"):
|
|
242
|
+
if name in value:
|
|
243
|
+
_integer(value[name], 0, None, "%s/%s" % (at, name), errors)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _row_problems(row: Any, at: str, errors: List[str]) -> None:
|
|
247
|
+
if not isinstance(row, dict):
|
|
248
|
+
errors.append("%s type" % at)
|
|
249
|
+
return
|
|
250
|
+
_members(row, ("context",) + _ROW_COUNTS + ("waitMs", "runMs"), ("context",) + _ROW_COUNTS, at, errors)
|
|
251
|
+
if "context" in row and not (_string(row["context"]) and _CONTEXT.fullmatch(row["context"])):
|
|
252
|
+
errors.append("%s/context %s" % (at, "pattern" if _string(row["context"]) else "type"))
|
|
253
|
+
for name in _ROW_COUNTS:
|
|
254
|
+
if name in row:
|
|
255
|
+
_integer(row[name], 0, None, "%s/%s" % (at, name), errors)
|
|
256
|
+
for name in ("waitMs", "runMs"):
|
|
257
|
+
if name in row:
|
|
258
|
+
_percentiles(row[name], "%s/%s" % (at, name), errors)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def load_report_problems(report: Any) -> List[str]:
|
|
262
|
+
"""service-load.schema.json: what makes `report` not a `service-load/1` report, each as `<path> <keyword>`; empty
|
|
263
|
+
for a valid one. Counts and durations only: a member the schema does not name (a caller, a job id) is refused."""
|
|
264
|
+
if not isinstance(report, dict):
|
|
265
|
+
return ["/ type"]
|
|
266
|
+
errors: List[str] = []
|
|
267
|
+
_members(report, ("type", "generatedAt", "windowSeconds", "windows"), ("type", "generatedAt", "windowSeconds", "windows"), "", errors)
|
|
268
|
+
if "type" in report and not same(report["type"], LOAD_REPORT_TYPE):
|
|
269
|
+
errors.append("/type const")
|
|
270
|
+
if "generatedAt" in report:
|
|
271
|
+
_timestamp(report["generatedAt"], "/generatedAt", errors)
|
|
272
|
+
if "windowSeconds" in report:
|
|
273
|
+
_integer(report["windowSeconds"], 60, 86400, "/windowSeconds", errors)
|
|
274
|
+
windows = report.get("windows", MISSING)
|
|
275
|
+
if windows is not MISSING and not isinstance(windows, list):
|
|
276
|
+
errors.append("/windows type")
|
|
277
|
+
elif isinstance(windows, list):
|
|
278
|
+
if len(windows) > 168:
|
|
279
|
+
errors.append("/windows maxItems")
|
|
280
|
+
for index, window in enumerate(windows):
|
|
281
|
+
at = "/windows/%d" % index
|
|
282
|
+
if not isinstance(window, dict):
|
|
283
|
+
errors.append("%s type" % at)
|
|
284
|
+
continue
|
|
285
|
+
_members(window, ("start", "byContext"), ("start", "byContext"), at, errors)
|
|
286
|
+
if "start" in window:
|
|
287
|
+
_timestamp(window["start"], at + "/start", errors)
|
|
288
|
+
rows = window.get("byContext", MISSING)
|
|
289
|
+
if rows is not MISSING and not isinstance(rows, list):
|
|
290
|
+
errors.append("%s/byContext type" % at)
|
|
291
|
+
elif isinstance(rows, list):
|
|
292
|
+
if len(rows) > 64:
|
|
293
|
+
errors.append("%s/byContext maxItems" % at)
|
|
294
|
+
for row_index, row in enumerate(rows):
|
|
295
|
+
_row_problems(row, "%s/byContext/%d" % (at, row_index), errors)
|
|
296
|
+
return errors
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _declaration_problems(value: Any) -> List[str]:
|
|
300
|
+
"""service-load-extension.schema.json, each problem as `<path> <keyword>`; empty for a valid declaration."""
|
|
301
|
+
if not isinstance(value, dict):
|
|
302
|
+
return ["/ type"]
|
|
303
|
+
errors: List[str] = []
|
|
304
|
+
_members(value, ("path", "windowSeconds", "limits"), ("path", "windowSeconds", "limits"), "", errors)
|
|
305
|
+
path = value.get("path", MISSING)
|
|
306
|
+
if path is not MISSING:
|
|
307
|
+
if not _string(path):
|
|
308
|
+
errors.append("/path type")
|
|
309
|
+
else:
|
|
310
|
+
if len(path) > 256: # code points, as ajv counts them; the originPath's own 2048 is wider
|
|
311
|
+
errors.append("/path maxLength")
|
|
312
|
+
if not _ORIGIN_PATH.fullmatch(path):
|
|
313
|
+
errors.append("/path pattern")
|
|
314
|
+
if "windowSeconds" in value:
|
|
315
|
+
_integer(value["windowSeconds"], 60, 86400, "/windowSeconds", errors)
|
|
316
|
+
limits = value.get("limits", MISSING)
|
|
317
|
+
if limits is not MISSING:
|
|
318
|
+
if not isinstance(limits, dict):
|
|
319
|
+
errors.append("/limits type")
|
|
320
|
+
else:
|
|
321
|
+
_members(limits, tuple(name for name, _, _ in _LIMITS), tuple(name for name, _, _ in _LIMITS), "/limits", errors)
|
|
322
|
+
for name, low, high in _LIMITS:
|
|
323
|
+
if name in limits:
|
|
324
|
+
_integer(limits[name], low, high, "/limits/%s" % name, errors)
|
|
325
|
+
return errors
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def load_view(well_known: Any) -> Dict[str, Any]:
|
|
329
|
+
"""The load declaration as a reader reads it (DEC-0104): `declared` when the extension key is present, `valid` when
|
|
330
|
+
its value is the schema's, `load` the declaration when valid (else None) and `errors` its problems. A reader treats
|
|
331
|
+
an invalid value as not declared and reports it (FAC-SEM-133)."""
|
|
332
|
+
extensions = get(well_known, "extensions")
|
|
333
|
+
if not isinstance(extensions, dict) or LOAD_EXTENSION_KEY not in extensions:
|
|
334
|
+
return {"declared": False, "valid": False, "load": None, "errors": []}
|
|
335
|
+
value = extensions[LOAD_EXTENSION_KEY]
|
|
336
|
+
errors = _declaration_problems(value)
|
|
337
|
+
return {"declared": True, "valid": not errors, "load": None if errors else value, "errors": errors}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_extension_rules(well_known: Any) -> List[Dict[str, str]]:
|
|
341
|
+
"""FAC-SEM-133 on a well-known document: a value under the load extension key is valid against
|
|
342
|
+
service-load-extension.schema.json; one finding at the key otherwise."""
|
|
343
|
+
view = load_view(well_known)
|
|
344
|
+
if not view["declared"] or view["valid"]:
|
|
345
|
+
return []
|
|
346
|
+
return [_finding("FAC-SEM-133", _KEY_PATH, "the load declaration is invalid (%s); a reader treats it as not declared "
|
|
347
|
+
"and reports it (DEC-0104)" % "; ".join(view["errors"]))]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def load_extension(path: Any, window_seconds: Any, limits: Any) -> Dict[str, Any]:
|
|
351
|
+
"""The `extensions` entry a service declares (DEC-0104): where it serves its `service-load/1` report (an origin
|
|
352
|
+
path, served under the descriptor token like the usage report), the report's window and the queue's limits per
|
|
353
|
+
(agentRef, context). Raises ServiceError on a declaration a reader would treat as not declared."""
|
|
354
|
+
value = {"path": path, "windowSeconds": window_seconds, "limits": dict(limits) if isinstance(limits, dict) else limits}
|
|
355
|
+
problems = _declaration_problems(value)
|
|
356
|
+
if problems:
|
|
357
|
+
raise ServiceError("Load declaration: %s." % "; ".join(problems))
|
|
358
|
+
return {LOAD_EXTENSION_KEY: value}
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -700,8 +700,76 @@ def compose(draft: Dict[str, Any], files: Dict[str, bytes]) -> Tuple[Dict[str, A
|
|
|
700
700
|
return pkg, notes
|
|
701
701
|
|
|
702
702
|
|
|
703
|
+
#: The agent's source manifest (contract DEC-0149, ``fabric-source/1``): when the folder carries one, ``pack`` writes
|
|
704
|
+
#: ``secrets[]`` from its secret slots and ``state[]`` from its store slots' paths, and FAC-SEM-160 holds them equal.
|
|
705
|
+
SOURCE_FILE = "fabric.source.json"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _source_manifest(files: Dict[str, bytes]) -> Optional[Dict[str, Any]]:
|
|
709
|
+
"""The folder's source manifest, or None when it has none; a refusal when it is no ``fabric-source/1`` object."""
|
|
710
|
+
if SOURCE_FILE not in files:
|
|
711
|
+
return None
|
|
712
|
+
source = _load_json(files[SOURCE_FILE], SOURCE_FILE)
|
|
713
|
+
if source.get("format") != "fabric-source/1":
|
|
714
|
+
raise PackageError("%s is not a fabric-source/1 manifest (its format member)." % SOURCE_FILE)
|
|
715
|
+
return source
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def source_members(source: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
719
|
+
"""``secrets[]`` from the secret slots (name, purpose, optional) and ``state[]`` from the store slots (path, purpose)."""
|
|
720
|
+
secrets: List[Dict[str, Any]] = []
|
|
721
|
+
state: List[Dict[str, Any]] = []
|
|
722
|
+
for slot in _objects(source.get("slots")):
|
|
723
|
+
if slot.get("kind") == "secret" and isinstance(slot.get("name"), str):
|
|
724
|
+
secrets.append(dict({"name": slot["name"], "purpose": slot.get("purpose")}, **({"optional": True} if slot.get("optional") is True else {})))
|
|
725
|
+
elif slot.get("kind") == "store" and isinstance(slot.get("path"), str):
|
|
726
|
+
state.append({"path": slot["path"], "purpose": slot.get("purpose")})
|
|
727
|
+
return secrets, state
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def source_package_findings(source: Any, pkg: Any) -> List[Dict[str, str]]:
|
|
731
|
+
"""FAC-SEM-160 (contract ``sourcePackageFindings``): the package's secrets[] are the source's secret slots, by name
|
|
732
|
+
and optional; its state[] paths are the store slots' paths when state[] holds any entry."""
|
|
733
|
+
if not isinstance(source, dict) or not isinstance(pkg, dict):
|
|
734
|
+
return []
|
|
735
|
+
out: List[Dict[str, str]] = []
|
|
736
|
+
|
|
737
|
+
def flag(at: str, message: str) -> None:
|
|
738
|
+
out.append({"code": "FAC-SEM-160", "instancePath": at, "message": message})
|
|
739
|
+
slots = _objects(source.get("slots"))
|
|
740
|
+
secrets = {s["name"]: s.get("optional") is True for s in slots if s.get("kind") == "secret" and isinstance(s.get("name"), str)}
|
|
741
|
+
stores = list(dict.fromkeys(s["path"] for s in slots if s.get("kind") == "store" and isinstance(s.get("path"), str)))
|
|
742
|
+
packaged = [(e, i) for i, e in enumerate(pkg.get("secrets") if isinstance(pkg.get("secrets"), list) else []) if isinstance(e, dict)]
|
|
743
|
+
names = {e.get("name") for e, _ in packaged}
|
|
744
|
+
for name in secrets:
|
|
745
|
+
if name not in names:
|
|
746
|
+
flag("/package/secrets", "the source's secret slot %s is not in the package's secrets[]" % name)
|
|
747
|
+
for entry, index in packaged:
|
|
748
|
+
name = entry.get("name")
|
|
749
|
+
if not isinstance(name, str) or name not in secrets:
|
|
750
|
+
flag("/package/secrets/%d/name" % index, "secrets[%d] is no secret slot of the source" % index)
|
|
751
|
+
elif secrets[name] != (entry.get("optional") is True):
|
|
752
|
+
flag("/package/secrets/%d/optional" % index, "secrets[%d] is %soptional in the source" % (index, "" if secrets[name] else "not "))
|
|
753
|
+
state = pkg.get("state")
|
|
754
|
+
if not isinstance(state, list) or not state:
|
|
755
|
+
return out
|
|
756
|
+
paths = {e.get("path") for e in _objects(state)}
|
|
757
|
+
for path in stores:
|
|
758
|
+
if path not in paths:
|
|
759
|
+
flag("/package/state", "a store slot's path of the source is not in the package's state[]")
|
|
760
|
+
for index, entry in ((i, e) for i, e in enumerate(state) if isinstance(e, dict)):
|
|
761
|
+
if not isinstance(entry.get("path"), str) or entry["path"] not in stores:
|
|
762
|
+
flag("/package/state/%d/path" % index, "state[%d] is no store slot of the source" % index)
|
|
763
|
+
return out
|
|
764
|
+
|
|
765
|
+
|
|
703
766
|
def _all_problems(pkg: Dict[str, Any], files: Dict[str, bytes]) -> List[Dict[str, str]]:
|
|
704
767
|
problems = [dict(p, rule="schema") for p in schema_problems(pkg)]
|
|
768
|
+
if SOURCE_FILE in files:
|
|
769
|
+
try:
|
|
770
|
+
problems += [dict(p, rule=p["code"]) for p in source_package_findings(_source_manifest(files), pkg)]
|
|
771
|
+
except PackageError as exc:
|
|
772
|
+
problems.append({"rule": "source", "instancePath": "/files", "message": str(exc)})
|
|
705
773
|
problems += [dict(p, rule="path") for p in path_problems(pkg)]
|
|
706
774
|
problems += [dict(p, rule=p["code"]) for p in package_findings(pkg)]
|
|
707
775
|
problems += [{"rule": "folder", "problem": p["problem"], "instancePath": p["path"], "message": FOLDER_MESSAGES.get(p["problem"], p["problem"])}
|
|
@@ -716,6 +784,24 @@ FOLDER_MESSAGES = {
|
|
|
716
784
|
}
|
|
717
785
|
|
|
718
786
|
|
|
787
|
+
def _secrets_lacking(draft: Dict[str, Any], secrets: List[Dict[str, Any]]) -> List[str]:
|
|
788
|
+
"""The names a hand-written secrets[] declares that the source manifest has no secret slot for, in their order."""
|
|
789
|
+
have = {s["name"] for s in secrets}
|
|
790
|
+
declared = draft.get("secrets") if isinstance(draft.get("secrets"), list) else []
|
|
791
|
+
lacking: List[str] = []
|
|
792
|
+
for entry in declared:
|
|
793
|
+
name = entry.get("name") if isinstance(entry, dict) else None
|
|
794
|
+
if isinstance(name, str) and name not in have and name not in lacking:
|
|
795
|
+
lacking.append(name)
|
|
796
|
+
return lacking
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _lacking_message(lacking: List[str]) -> str:
|
|
800
|
+
return ("%s declares no secret slot for %s, which %s's secrets[] names: pack writes secrets[] from the manifest's "
|
|
801
|
+
"slots and never drops a declared secret. Add each as a slot of kind secret to %s (or take it out of "
|
|
802
|
+
"secrets[]), then pack again" % (SOURCE_FILE, ", ".join(lacking), PACKAGE_FILE, SOURCE_FILE))
|
|
803
|
+
|
|
804
|
+
|
|
719
805
|
def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
720
806
|
"""Pack ``folder`` (see the module's doc). The answer names the digest, ``agentRef`` and every problem; with
|
|
721
807
|
``apply`` and no problem, ``fabric.package.json`` is rewritten."""
|
|
@@ -725,8 +811,24 @@ def pack(folder: Any, apply: bool = False) -> Dict[str, Any]:
|
|
|
725
811
|
raise PackageError("Write %s in the folder first: schema, kind, id, version, publisher, licence and, for an agent, "
|
|
726
812
|
"resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest." % PACKAGE_FILE)
|
|
727
813
|
draft = _load_json(files[PACKAGE_FILE], PACKAGE_FILE)
|
|
814
|
+
source = _source_manifest(files)
|
|
815
|
+
written: List[str] = []
|
|
816
|
+
refused: List[Dict[str, str]] = []
|
|
817
|
+
if source is not None: # DEC-0149: secrets[] and state[] come from the source manifest's slots
|
|
818
|
+
secrets, state = source_members(source)
|
|
819
|
+
lacking = _secrets_lacking(draft, secrets)
|
|
820
|
+
if lacking: # a declared secret is never dropped: the manifest gains its slot first
|
|
821
|
+
refused.append({"rule": "source", "instancePath": "/secrets", "message": _lacking_message(lacking)})
|
|
822
|
+
elif secrets or "secrets" in draft:
|
|
823
|
+
draft["secrets"] = secrets
|
|
824
|
+
written.append("secrets[]")
|
|
825
|
+
if state and draft.get("state") != []: # an empty state[] is a copy packed without state, kept
|
|
826
|
+
draft["state"] = state
|
|
827
|
+
written.append("state[]")
|
|
728
828
|
pkg, notes = compose(draft, files)
|
|
729
|
-
|
|
829
|
+
if written:
|
|
830
|
+
notes.append("%s written from %s" % (" and ".join(written), SOURCE_FILE))
|
|
831
|
+
problems = refused + _all_problems(pkg, files)
|
|
730
832
|
suite = pkg.get("evals", {}).get("suite") if isinstance(pkg.get("evals"), dict) else None
|
|
731
833
|
if isinstance(suite, str) and suite in files and any(p.get("instancePath") == "/evals/receipt/suite" for p in problems):
|
|
732
834
|
notes.append("the suite packed is %s: run it again and write that receipt" % sha256_of(files[suite]))
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
"""peer_owner: which OS account owns each end of a loopback TCP connection.
|
|
2
2
|
|
|
3
|
-
peer_owner 1.
|
|
4
|
-
DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py
|
|
5
|
-
commit f86f0622 (passioncode-ai/project-observatory-dashboard, the
|
|
3
|
+
peer_owner 1.2.0+kit.5: Observatory's peer_owner 1.2.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
|
|
4
|
+
DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py`, first at
|
|
5
|
+
commit f86f0622 (1.1.0) and read against commit 2180235c (1.2.0, passioncode-ai/project-observatory-dashboard, the
|
|
6
|
+
same licence): the kit's line names 1.2.0 since kit.5 (FAA-258), whose Linux rule kit.1 to kit.4 already carried
|
|
7
|
+
and the contract's CT-254 states (v0.1.0+2026-11-07.3). Observatory's 1.3.x (this account's own sockets through
|
|
8
|
+
libproc, the contract's DEC-0155 RT-2) is not in it yet (FAA-292). It differs
|
|
6
9
|
from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
|
|
7
10
|
frozen dataclasses: a dataclass with postponed annotations needs its module in sys.modules,
|
|
8
11
|
and a service may load a kit file by its path without one), and in writing `Optional[X]`
|
|
@@ -11,7 +14,8 @@
|
|
|
11
14
|
SID before the system's (a client running as root or SYSTEM owns root's listener), and in
|
|
12
15
|
two Linux and selection rules: on Linux `server_end` reads an accepted connection's own row
|
|
13
16
|
and, for one not accept()ed yet (printed with uid 0 and inode 0), the listener row on its
|
|
14
|
-
local address (state 0A;
|
|
17
|
+
local address (state 0A; 1.2.0's rule, where the kit names its reasons `not-accepted`, `several-rows` and `closed`
|
|
18
|
+
and Observatory `no-listener`, `listener-no-socket`, `listeners-disagree` and `no-socket`), `peer` reads a client
|
|
15
19
|
row with inode 0 (a closed client) as unknown, and several rows of one owner are that owner;
|
|
16
20
|
a row printed twice by a `/proc` read under churn counts once, and a `/proc` question that
|
|
17
21
|
finds no row or several reads the table once more; ctypes is imported where it is used, so a
|
|
@@ -19,7 +23,8 @@
|
|
|
19
23
|
macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
|
|
20
24
|
`TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
|
|
21
25
|
`parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
|
|
22
|
-
the child's port is waited on with `selectors` and read under one deadline (FAA-273).
|
|
26
|
+
the child's port is waited on with `selectors` and read under one deadline (FAA-273); since kit.4 a number is ASCII
|
|
27
|
+
digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
|
|
23
28
|
A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
|
|
24
29
|
created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
|
|
25
30
|
The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
|
|
@@ -110,7 +115,7 @@ import time
|
|
|
110
115
|
from typing import NamedTuple, Optional, Tuple
|
|
111
116
|
|
|
112
117
|
# This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
|
|
113
|
-
PEER_OWNER_VERSION = "1.
|
|
118
|
+
PEER_OWNER_VERSION = "1.2.0+kit.5" # Observatory's 1.2.0 with the kit's differences above
|
|
114
119
|
|
|
115
120
|
THIS_ACCOUNT = "this-account"
|
|
116
121
|
ANOTHER_ACCOUNT = "another-account"
|
|
@@ -121,7 +126,8 @@ KINDS = (THIS_ACCOUNT, ANOTHER_ACCOUNT, SYSTEM, UNKNOWN, NONE)
|
|
|
121
126
|
|
|
122
127
|
#: The most table bytes one read accepts. The spec of this module asked for 1 MB; measured,
|
|
123
128
|
#: `pcblist_n` costs about 860 bytes a socket, so 1 MB would hold about 1,200 sockets and a busy
|
|
124
|
-
#: developer machine passes that. 4 MiB holds about
|
|
129
|
+
#: developer machine passes that. 4 MiB holds about 6,800 at the 616 bytes a socket measured on macOS 26
|
|
130
|
+
#: (0.74.0 product reading 11, FAA-260).
|
|
125
131
|
MAX_TABLE_BYTES = 4 * 1024 * 1024
|
|
126
132
|
#: Every subprocess this module starts is stopped after this many seconds.
|
|
127
133
|
TIMEOUT_SECONDS = 2.0
|
|
@@ -231,6 +237,11 @@ def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Op
|
|
|
231
237
|
|
|
232
238
|
# -- parsers: pure functions over captured text or bytes (the tests' fixtures) ---------------------------------------
|
|
233
239
|
|
|
240
|
+
def _ascii_digits(text: str) -> bool:
|
|
241
|
+
"""`[0-9]+` as the Node twin reads a number; `str.isdigit` also takes `٤` and `²` (FAA-273 item 4)."""
|
|
242
|
+
return text != "" and text.isascii() and text.isdigit()
|
|
243
|
+
|
|
244
|
+
|
|
234
245
|
def parse_netstat(text: str) -> list[Row]:
|
|
235
246
|
"""`netstat -anv -p tcp` on macOS. Process names may hold spaces ("Some App:123"), so the
|
|
236
247
|
`process:pid` column is what is left between the leading and the trailing columns, both
|
|
@@ -246,6 +257,10 @@ def parse_netstat(text: str) -> list[Row]:
|
|
|
246
257
|
# "Local Address" and "Foreign Address" are two header words each and one data column each.
|
|
247
258
|
leading = at - sum(1 for w in names[:at] if w == "Address")
|
|
248
259
|
trailing = len(names) - at - 1
|
|
260
|
+
if not any(line.split()[:1] and line.split()[0].startswith("tcp") for line in lines[header + 1:]):
|
|
261
|
+
# The caller's own connection is in any table it can see: a header and no tcp row is the restricted table
|
|
262
|
+
# (FAA-273 item 3), not `no-row`.
|
|
263
|
+
raise TableUnreadable(f"{TABLE_RESTRICTED}: netstat printed only its header to this process")
|
|
249
264
|
rows: list[Row] = []
|
|
250
265
|
for line in lines[header + 1:]:
|
|
251
266
|
tok = line.split()
|
|
@@ -259,7 +274,7 @@ def parse_netstat(text: str) -> list[Row]:
|
|
|
259
274
|
pid = None
|
|
260
275
|
if ":" in process:
|
|
261
276
|
tail = process.rsplit(":", 1)[1]
|
|
262
|
-
pid = int(tail) if tail
|
|
277
|
+
pid = int(tail) if _ascii_digits(tail) else None
|
|
263
278
|
rows.append(Row(local, remote, tok[5] == "LISTEN", None, pid, tok[5] == "TIME_WAIT"))
|
|
264
279
|
return rows
|
|
265
280
|
|
|
@@ -283,7 +298,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
|
|
|
283
298
|
if not line.startswith("n") or ":" not in line:
|
|
284
299
|
continue
|
|
285
300
|
host, port = line[1:].rsplit(":", 1)
|
|
286
|
-
if port
|
|
301
|
+
if _ascii_digits(port):
|
|
287
302
|
found.append((norm_host(host.strip("[]")), int(port)))
|
|
288
303
|
return found
|
|
289
304
|
|
|
@@ -291,7 +306,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
|
|
|
291
306
|
def parse_ps_uid(text: str) -> Optional[int]:
|
|
292
307
|
"""`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
|
|
293
308
|
text = text.strip()
|
|
294
|
-
return int(text) if text
|
|
309
|
+
return int(text) if _ascii_digits(text) else None
|
|
295
310
|
|
|
296
311
|
|
|
297
312
|
def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
|
|
@@ -540,7 +555,7 @@ def _child_listener_seen(rows_of) -> Optional[bool]:
|
|
|
540
555
|
port = None
|
|
541
556
|
try:
|
|
542
557
|
line = _child_line(child, deadline)
|
|
543
|
-
if line is None or not line.strip()
|
|
558
|
+
if line is None or not _ascii_digits(line.strip()):
|
|
544
559
|
return None
|
|
545
560
|
port = int(line.strip())
|
|
546
561
|
try:
|
|
@@ -794,7 +809,7 @@ def _owner_of(local: tuple[str, int], remote: tuple[str, int], server: bool = Fa
|
|
|
794
809
|
# (inet_csk_listen_stop), so that listener answers. An accepted connection's own row carries the uid of the
|
|
795
810
|
# process that accepted it (sock_graft): it answers itself, so a listener swapped after another account
|
|
796
811
|
# accepted the client cannot pass (Project Observatory's 1.2.0 reading, board thread ced00f6e; the contract's
|
|
797
|
-
#
|
|
812
|
+
# CT-254, HA-2's Linux source, released in v0.1.0+2026-11-07.3).
|
|
798
813
|
# A server row its owner already closed (an orphan) also has inode 0 and reads so: no process holds it, so
|
|
799
814
|
# nothing sent to it reaches anyone (0.74.0 unit reading 6).
|
|
800
815
|
return _one_owner(_listeners_of(rows, found[0].local), how, "not-accepted")
|
|
@@ -846,7 +861,7 @@ def server_end(sock: socket.socket) -> Owner:
|
|
|
846
861
|
server's and whose remote address is this socket's own, which is the server process's
|
|
847
862
|
accepted socket, so a listener swapped after `listen` cannot pass for the one that answers.
|
|
848
863
|
On Linux a connection not yet accept()ed has no owner of its own (inode 0): its listener
|
|
849
|
-
answers (the contract's
|
|
864
|
+
answers (the contract's CT-254, HA-2's Linux source)."""
|
|
850
865
|
try:
|
|
851
866
|
own, server = sock.getsockname(), sock.getpeername()
|
|
852
867
|
except OSError as exc:
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py
CHANGED
|
@@ -20,7 +20,7 @@ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
|
|
|
20
20
|
"fabric-operator-channel": ("0.1",), "fabric-package": ("1",),
|
|
21
21
|
"fabric-peer-share": ("1",), "fabric-peer-share-envelope": ("1",), "fabric-peer-share-report": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
|
|
22
22
|
"fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
|
|
23
|
-
"fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
23
|
+
"fabric-settings-backup": ("1",), "fabric-source": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "machine-report": ("1",), "mandate": ("1",), "memory": ("0.1",), "node-summary": ("1",),
|
|
24
24
|
"operator-source": ("1",), "org-adaptation": ("1",), "org-adaptation-receipt": ("1",), "org-authority": ("1",), "org-authority-draft": ("1",), "org-key-set": ("1",), "org-key-set-draft": ("1",),
|
|
25
25
|
"org-membership": ("1",), "org-root-registration": ("1",), "pairing-message": ("1",),
|
|
26
26
|
"peer-share-deletion": ("1",), "peer-share-frame": ("1",), "peer-share-revocation": ("1",),
|