@passioncode-ai/passioncode 0.1.8 → 0.1.10
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 +23 -0
- package/README.md +22 -5
- package/SECURITY.md +1 -1
- package/bin/passioncode.js +5 -5
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +20 -4
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +57 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +15 -2
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +267 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +19 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +77 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +177 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +215 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +470 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +14 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +118 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +9 -3
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -15,10 +15,10 @@ license: PolyForm-Noncommercial-1.0.0 OR LicenseRef-PolyForm-Internal-Use-1.0.0
|
|
|
15
15
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). No network or package install; the contract checkout is optional.
|
|
16
16
|
metadata:
|
|
17
17
|
author: PassionCode.ai
|
|
18
|
-
version: "0.
|
|
18
|
+
version: "0.5.1"
|
|
19
19
|
contract-version: "0.1.0"
|
|
20
20
|
extension: "fabric-service/0.1"
|
|
21
|
-
extension-commit: "
|
|
21
|
+
extension-commit: "9cd778eb6f14b977f9a5b4930826f62dc95c5619"
|
|
22
22
|
---
|
|
23
23
|
|
|
24
24
|
# Building Fabric services
|
|
@@ -73,6 +73,13 @@ Answer before writing code, and record the answers in the project README:
|
|
|
73
73
|
Read [the surfaces and auth reference](references/surfaces-and-auth.md) when wiring
|
|
74
74
|
MCP registration into a client config, the login flow or the CSRF header.
|
|
75
75
|
|
|
76
|
+
When other agents or Fabric call the service over MCP, it follows `fabric-interop/0.1`:
|
|
77
|
+
each manifest capability is the tool of its name, long work is a job with a stable id,
|
|
78
|
+
a question for a person is an elicitation, and every call carries one trace. Read
|
|
79
|
+
[the interop reference](references/interop.md) before writing an MCP tool; the helpers
|
|
80
|
+
are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
|
|
81
|
+
[`scripts/fabric-interop.mjs`](scripts/fabric-interop.mjs).
|
|
82
|
+
|
|
76
83
|
## Step 2 — the non-negotiables
|
|
77
84
|
|
|
78
85
|
1. **Loopback only.** Bind `127.0.0.1`. Refuse any `Host` other than
|
|
@@ -144,6 +151,13 @@ On every request call `check_request(port, host, origin, sec_fetch_site)` and an
|
|
|
144
151
|
`SIGTERM` drains in-flight work within `ExitTimeOut`, then exits; interrupted work
|
|
145
152
|
resumes on the next start.
|
|
146
153
|
|
|
154
|
+
The MCP surface (`POST /mcp`, service token) is `fabric_interop.McpToolServer.handle`
|
|
155
|
+
in Python; jobs live in `fabric_interop.JobStore(dirs["data"] / "jobs")`, created after
|
|
156
|
+
the lock like every other piece of state. Add `"mcp": {"path": "/mcp", "transport":
|
|
157
|
+
"streamable-http", "capabilities": [...]}` to the well-known surfaces, and point the
|
|
158
|
+
descriptor's `fabricManifest` at a manifest whose service key names this
|
|
159
|
+
`<id>.<instance>` — `sample_service.py register` shows both.
|
|
160
|
+
|
|
147
161
|
## Step 4 — install it
|
|
148
162
|
|
|
149
163
|
The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
@@ -186,7 +200,9 @@ python3 <skill-dir>/scripts/check_service.py <id>[.<instance>]
|
|
|
186
200
|
It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, well-known
|
|
187
201
|
shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token file,
|
|
188
202
|
events auth and shape, single-use login, state outside code, instance lock, plist,
|
|
189
|
-
launchd pid equals answering pid
|
|
203
|
+
launchd pid equals answering pid, and the `interop.*` rules: manifest link, capability
|
|
204
|
+
list, tools equal to the manifest, job tools, unknown job, trace propagation, event
|
|
205
|
+
trace pairs) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass —
|
|
190
206
|
name it in the report.
|
|
191
207
|
|
|
192
208
|
Then prove the lock: start a second copy by hand against the same data directory; it
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Being called: `fabric-interop/0.1`
|
|
2
|
+
|
|
3
|
+
Normative source: the Fabric Agent Contract's `docs/specification/interop.md` (DEC-0016, rulings DEC-0017)
|
|
4
|
+
at the commit this plugin pins. This page says how the kit implements it; where the two
|
|
5
|
+
differ, the contract is right.
|
|
6
|
+
|
|
7
|
+
A service that other agents — and Fabric — call does five things. Each has one helper in
|
|
8
|
+
`scripts/fabric_interop.py` (Python) and `scripts/fabric-interop.mjs` (Node).
|
|
9
|
+
|
|
10
|
+
| Rule | What the service does | Helper |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| C3.1 capability = tool | serves each `mcp` capability of its manifest as the MCP tool of the same name, with the manifest's input schema unchanged, its output schema unchanged — or, for a job, wrapped as `oneOf[result envelope, job handle]` — and annotations from the effect | `tool_for_capability` / `toolForCapability`, `job_tool_output_schema`, `expected_annotations` |
|
|
13
|
+
| C3.2 long work = job | a capability whose work can outlive one request declares `"job": true` in its interop block, returns `{"job": {"id", "status": "working"}}`, and serves `fabric.job.get` and `fabric.job.cancel` | `JobStore`, `McpToolServer` (Python), `unknown_job_result` |
|
|
14
|
+
| C3.2 result | a completed job carries the full result envelope — the shape a synchronous call returns: `id`, `contractVersion`, `outcome`, `done`, `proof`, `scope`, `notVerified`, `artifacts`, `createdAt`, `producer`, `output`, `usage`, and its `trace` | `result_envelope` / `resultEnvelope` |
|
|
15
|
+
| C3.3 a person decides | the job goes `input_required` with an elicitation: a titled single-select in form mode, and URL mode for anything secret | `choice_request`, `form_request`, `url_request` |
|
|
16
|
+
| C3.4 trace | every call runs as a child span of the caller's `_meta.traceparent`; outgoing calls carry the same trace; the result envelope records it; events about traced work carry `traceId` and `spanId`, events about untraced work none | `child_traceparent`, `trace_ids`, `make_event(..., trace_id=, span_id=)` |
|
|
17
|
+
|
|
18
|
+
The interop block sits in the manifest under the capability's `extensions`:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
"extensions": { "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1": { "job": true } }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That key has one spelling (the contract's `src/extensions.ts`); copy it, never retype it.
|
|
25
|
+
|
|
26
|
+
## The kit
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import fabric_interop as fi
|
|
30
|
+
|
|
31
|
+
jobs = fi.JobStore(data_dir / "jobs") # one private JSON file per job; ids survive restarts
|
|
32
|
+
server = fi.McpToolServer("example-agent", "1.0.0", jobs=jobs,
|
|
33
|
+
on_job_started=start_report, on_input=resume_report)
|
|
34
|
+
server.add_tool(fi.tool_for_capability(capability, input_schema, output_schema), handler)
|
|
35
|
+
response = server.handle(json_rpc_message) # POST /mcp body in, JSON response out (None: 202)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- A handler returns the value for `structuredContent`; for a job it returns
|
|
39
|
+
`ctx.start_job()`, and `on_job_started(job_id, ctx)` does or schedules the work.
|
|
40
|
+
- `jobs.request_input(job_id, {"title_choice": fi.choice_request(...)}, "Two titles are ready.")`
|
|
41
|
+
stops for a person. Fabric answers through `fabric.job.get` with `inputResponses`; the
|
|
42
|
+
server calls `on_input(job_id, answers, ctx)` with the answers that matched.
|
|
43
|
+
- `jobs.complete(job_id, fi.result_envelope(outcome=..., producer=..., ...))`,
|
|
44
|
+
`jobs.fail(job_id, code, message)`, `jobs.cancel(job_id)`. A terminal job never changes
|
|
45
|
+
again. A result with unverified claims is `partial`, never `succeeded`.
|
|
46
|
+
- The envelope's `trace` is authoritative for a stored result (DEC-0017): `complete` fills
|
|
47
|
+
it with the span of the call that started the job and refuses one naming another span,
|
|
48
|
+
and `fabric.job.get` answers with that same traceparent in `_meta` (FAC-SEM-022). The
|
|
49
|
+
contract's FAC-SEM-019 records a result without a trace as an incomplete span.
|
|
50
|
+
- Log events about the work with `**fi.trace_ids(ctx.traceparent)` so the pair lands on
|
|
51
|
+
the event.
|
|
52
|
+
|
|
53
|
+
`McpToolServer` answers `server/discover`, `tools/list` and `tools/call` with JSON
|
|
54
|
+
responses — enough for Fabric and for the probe. A service already on an MCP SDK keeps
|
|
55
|
+
the SDK and uses only the helpers. The Node kit has the helpers and the same job file
|
|
56
|
+
format, without the dispatcher. `scripts/sample_service.py` is the worked example:
|
|
57
|
+
`sample.echo` answers at once, `sample.draft` is a job that stops for a title choice.
|
|
58
|
+
|
|
59
|
+
The contract owner's rulings (DEC-0017) the kit follows: a job tool's `outputSchema` is the
|
|
60
|
+
self-contained union `oneOf[result envelope, job handle]` (`job_tool_output_schema`), so
|
|
61
|
+
its `structuredContent` always conforms; an event about untraced work carries no trace pair.
|
|
62
|
+
|
|
63
|
+
## What the probe checks
|
|
64
|
+
|
|
65
|
+
`check_service.py` adds these rules; they read, and never call a capability.
|
|
66
|
+
|
|
67
|
+
| Rule | PASS when | NOT_RUN when |
|
|
68
|
+
|---|---|---|
|
|
69
|
+
| `interop.manifest-link` | the manifest `fabricManifest` names carries the service key with this `<id>.<instance>` (G-07) | the descriptor names no manifest |
|
|
70
|
+
| `interop.well-known-capabilities` | every name in `surfaces.mcp.capabilities` is a manifest capability | the surface lists none |
|
|
71
|
+
| `interop.tools-match` | every `mcp` capability is served as its tool, schemas equal to the files beside the manifest (matched by `$id`; a job's output wrapped in the DEC-0017 union), annotations derived | no manifest, no MCP surface, or a schema is not beside the manifest |
|
|
72
|
+
| `interop.job-tools` | a `job: true` capability comes with `fabric.job.get` and `fabric.job.cancel` | no capability is a job |
|
|
73
|
+
| `interop.unknown-job` | `fabric.job.get` for a made-up id answers `isError` with `unknown-job` | `fabric.job.get` is not served |
|
|
74
|
+
| `interop.trace-propagation` | the answer's `_meta.traceparent` has the probe's trace id and a new span | the answer carries no traceparent |
|
|
75
|
+
| `interop.events-trace` | every event with a trace carries both `traceId` and `spanId`, well formed | no events page was read |
|
|
76
|
+
|
|
77
|
+
`python3 scripts/check_service.py <id>` prints them with the rest; `--json` for a machine.
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `9cd778eb6f14b977f9a5b4930826f62dc95c5619`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
|
|
5
5
|
this page is the working summary. Extension key:
|
|
6
6
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
Every rule gets PASS, FAIL or NOT_RUN with its evidence. Exit 0 when nothing
|
|
9
9
|
FAILs, 1 when something does, 2 on a usage error. The probe only reads, except
|
|
10
10
|
that it redeems one login code it asked for itself (that creates one session).
|
|
11
|
+
|
|
12
|
+
The interop.* rules (fabric-interop/0.1) read the manifest the descriptor names and
|
|
13
|
+
the service's MCP surface: tools/list, and fabric.job.get for an id that does not
|
|
14
|
+
exist. They never call a capability.
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
17
|
from __future__ import annotations
|
|
@@ -20,6 +24,7 @@ import os
|
|
|
20
24
|
from pathlib import Path
|
|
21
25
|
import plistlib
|
|
22
26
|
import re
|
|
27
|
+
import secrets
|
|
23
28
|
import shutil
|
|
24
29
|
import stat
|
|
25
30
|
import subprocess
|
|
@@ -29,6 +34,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
29
34
|
|
|
30
35
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
36
|
import fabric_service as fs # noqa: E402
|
|
37
|
+
import fabric_interop as fi # noqa: E402
|
|
32
38
|
|
|
33
39
|
Result = Dict[str, str]
|
|
34
40
|
|
|
@@ -42,6 +48,8 @@ class Probe:
|
|
|
42
48
|
self.results: List[Result] = []
|
|
43
49
|
self.wk: Optional[Dict[str, Any]] = None
|
|
44
50
|
self.token: Optional[str] = None
|
|
51
|
+
self.events: Optional[List[Dict[str, Any]]] = None
|
|
52
|
+
self.sent_traceparent: Optional[str] = None
|
|
45
53
|
try:
|
|
46
54
|
self.port = fs.port_of(str(descriptor.get("origin", "")))
|
|
47
55
|
except fs.ServiceError:
|
|
@@ -184,6 +192,7 @@ class Probe:
|
|
|
184
192
|
except (ValueError, KeyError, AssertionError):
|
|
185
193
|
self.add("events.page", "FAIL", "not an events page")
|
|
186
194
|
return
|
|
195
|
+
self.events = events
|
|
187
196
|
bad = []
|
|
188
197
|
for e in events:
|
|
189
198
|
if e.get("level") not in fs.LEVELS or not re.match(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$", str(e.get("kind", ""))):
|
|
@@ -215,6 +224,173 @@ class Probe:
|
|
|
215
224
|
self.add("login.single-use", "PASS" if ok else "FAIL",
|
|
216
225
|
"first redeem HTTP %d (%s), second HTTP %d" % (status1, "cookie ok" if "HttpOnly" in cookie else "no HttpOnly cookie", status2))
|
|
217
226
|
|
|
227
|
+
# interop (fabric-interop/0.1) ----------------------------------------------------
|
|
228
|
+
# #region probe-interop — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#what-the-probe-checks
|
|
229
|
+
def mcp_call(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
230
|
+
"""One JSON-RPC request to the MCP surface, as a child of a fresh probe trace."""
|
|
231
|
+
path = (((self.wk or {}).get("surfaces") or {}).get("mcp") or {}).get("path", "/mcp")
|
|
232
|
+
self.sent_traceparent = fi.child_traceparent(None)
|
|
233
|
+
body = dict(params)
|
|
234
|
+
body["_meta"] = {"io.modelcontextprotocol/protocolVersion": fi.MCP_REVISION,
|
|
235
|
+
"io.modelcontextprotocol/clientCapabilities": {}, "traceparent": self.sent_traceparent}
|
|
236
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream",
|
|
237
|
+
"MCP-Protocol-Version": fi.MCP_REVISION}
|
|
238
|
+
headers.update(self.auth_headers())
|
|
239
|
+
message = json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": body}).encode()
|
|
240
|
+
status, resp_headers, raw = self.request("POST", path, headers, message)
|
|
241
|
+
if status != 200:
|
|
242
|
+
raise OSError("HTTP %d from %s" % (status, path))
|
|
243
|
+
if resp_headers.get("content-type", "").startswith("text/event-stream"):
|
|
244
|
+
data = [line[5:].strip() for line in raw.decode().splitlines() if line.startswith("data:")]
|
|
245
|
+
raw = (data[-1] if data else "null").encode()
|
|
246
|
+
return json.loads(raw)
|
|
247
|
+
|
|
248
|
+
def load_manifest(self) -> Tuple[Optional[Dict[str, Any]], Optional[Path], str]:
|
|
249
|
+
named = self.d.get("fabricManifest")
|
|
250
|
+
if not named:
|
|
251
|
+
return None, None, "the descriptor names no fabricManifest"
|
|
252
|
+
try:
|
|
253
|
+
path = fs.expand(str(named))
|
|
254
|
+
return json.loads(path.read_text(encoding="utf-8")), path, str(path)
|
|
255
|
+
except (fs.ServiceError, OSError, ValueError) as exc:
|
|
256
|
+
return None, None, "manifest %s does not resolve: %s" % (named, exc)
|
|
257
|
+
|
|
258
|
+
def resolve_schema(self, manifest_path: Path, uri: str) -> Optional[Dict[str, Any]]:
|
|
259
|
+
"""A schema named by URI, found by its $id among the JSON files beside the manifest. Nothing is fetched."""
|
|
260
|
+
for candidate in sorted(manifest_path.parent.glob("fabric/schemas/*.json")) + sorted(manifest_path.parent.glob("*.schema.json")):
|
|
261
|
+
try:
|
|
262
|
+
doc = json.loads(candidate.read_text(encoding="utf-8"))
|
|
263
|
+
except (OSError, ValueError):
|
|
264
|
+
continue
|
|
265
|
+
if isinstance(doc, dict) and doc.get("$id") == uri:
|
|
266
|
+
return doc
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
def interop_rules(self) -> None:
|
|
270
|
+
manifest, manifest_path, where = self.load_manifest()
|
|
271
|
+
me = "%s.%s" % (self.d.get("id"), self.d.get("instance", "default"))
|
|
272
|
+
if manifest is None:
|
|
273
|
+
self.add("interop.manifest-link", "NOT_RUN" if not self.d.get("fabricManifest") else "FAIL", where)
|
|
274
|
+
else:
|
|
275
|
+
block = ((manifest.get("provider") or {}).get("extensions") or {}).get(fs.EXTENSION_KEY) or {}
|
|
276
|
+
named = block.get("descriptor")
|
|
277
|
+
self.add("interop.manifest-link", "PASS" if named == me else "FAIL",
|
|
278
|
+
"%s names %s" % (where, named) if named else "%s carries no %s block naming %s" % (where, fs.EXTENSION_KEY, me))
|
|
279
|
+
capabilities = [c for c in (manifest or {}).get("capabilities", []) if isinstance(c, dict)]
|
|
280
|
+
names = {c.get("name") for c in capabilities}
|
|
281
|
+
mcp_surface = ((self.wk or {}).get("surfaces") or {}).get("mcp")
|
|
282
|
+
listed = (mcp_surface or {}).get("capabilities")
|
|
283
|
+
if listed is None or manifest is None:
|
|
284
|
+
self.add("interop.well-known-capabilities", "NOT_RUN", "no capability list on the MCP surface" if listed is None else where)
|
|
285
|
+
else:
|
|
286
|
+
extra = sorted(set(listed) - names)
|
|
287
|
+
self.add("interop.well-known-capabilities", "FAIL" if extra else "PASS",
|
|
288
|
+
("listed but not in the manifest: " + ", ".join(extra)) if extra else "%d listed, all in the manifest" % len(listed))
|
|
289
|
+
if not mcp_surface or not self.token:
|
|
290
|
+
for rule in ("interop.tools-match", "interop.job-tools", "interop.unknown-job", "interop.trace-propagation"):
|
|
291
|
+
self.add(rule, "NOT_RUN", "no MCP surface" if not mcp_surface else "no readable token")
|
|
292
|
+
else:
|
|
293
|
+
try:
|
|
294
|
+
listing = self.mcp_call("tools/list", {})
|
|
295
|
+
except (OSError, ValueError) as exc:
|
|
296
|
+
self.add("interop.tools-match", "FAIL", "tools/list failed: %s" % exc)
|
|
297
|
+
listing = None
|
|
298
|
+
if listing is not None:
|
|
299
|
+
sent = self.sent_traceparent
|
|
300
|
+
tools = {t.get("name"): t for t in (listing.get("result") or {}).get("tools", []) if isinstance(t, dict)}
|
|
301
|
+
self.tools_match_rule(capabilities, manifest_path, tools)
|
|
302
|
+
self.job_tools_rule(capabilities, tools)
|
|
303
|
+
self.unknown_job_rule(list(tools))
|
|
304
|
+
self.trace_rule(listing, sent)
|
|
305
|
+
self.events_trace_rule(self.events)
|
|
306
|
+
|
|
307
|
+
def tools_match_rule(self, capabilities: List[Dict[str, Any]], manifest_path: Optional[Path], tools: Dict[str, Any]) -> None:
|
|
308
|
+
"""FAC-SEM-017: each mcp capability is served as the tool of its name, with its schemas and derived annotations."""
|
|
309
|
+
served = [c for c in capabilities if (c.get("profile") or {}).get("kind") == "mcp"]
|
|
310
|
+
if manifest_path is None or not served:
|
|
311
|
+
self.add("interop.tools-match", "NOT_RUN", "no manifest with an mcp capability")
|
|
312
|
+
return
|
|
313
|
+
problems, unresolved = [], []
|
|
314
|
+
for cap in served:
|
|
315
|
+
name = cap.get("name")
|
|
316
|
+
tool = tools.get(name)
|
|
317
|
+
if tool is None:
|
|
318
|
+
problems.append("%s is not served as a tool" % name)
|
|
319
|
+
continue
|
|
320
|
+
for side in ("inputSchema", "outputSchema"):
|
|
321
|
+
schema = self.resolve_schema(manifest_path, str(cap.get(side)))
|
|
322
|
+
if schema is None:
|
|
323
|
+
unresolved.append("%s %s" % (name, cap.get(side)))
|
|
324
|
+
elif side == "outputSchema" and fi.is_job_capability(cap):
|
|
325
|
+
if tool.get(side) != fi.job_tool_output_schema(schema):
|
|
326
|
+
problems.append("%s is a job: its outputSchema must be oneOf[result envelope, job handle] around %s (DEC-0017)" % (name, cap.get(side)))
|
|
327
|
+
elif tool.get(side) != schema:
|
|
328
|
+
problems.append("%s serves an %s that differs from %s" % (name, side, cap.get(side)))
|
|
329
|
+
annotations = tool.get("annotations") or {}
|
|
330
|
+
for hint, value in fi.expected_annotations(cap.get("effect", ""), cap.get("idempotency", "")).items():
|
|
331
|
+
if annotations.get(hint) is not value:
|
|
332
|
+
problems.append("%s lacks %s (effect %s, idempotency %s)" % (name, hint, cap.get("effect"), cap.get("idempotency")))
|
|
333
|
+
if problems:
|
|
334
|
+
self.add("interop.tools-match", "FAIL", "; ".join(problems))
|
|
335
|
+
elif unresolved:
|
|
336
|
+
self.add("interop.tools-match", "NOT_RUN", "schemas not found beside the manifest: " + ", ".join(unresolved))
|
|
337
|
+
else:
|
|
338
|
+
self.add("interop.tools-match", "PASS", "%d capabilities served with their schemas and annotations" % len(served))
|
|
339
|
+
|
|
340
|
+
def job_tools_rule(self, capabilities: List[Dict[str, Any]], tools: Dict[str, Any]) -> None:
|
|
341
|
+
jobs = [c.get("name") for c in capabilities if ((c.get("extensions") or {}).get(fi.EXTENSION_KEY) or {}).get("job") is True]
|
|
342
|
+
if not jobs:
|
|
343
|
+
self.add("interop.job-tools", "NOT_RUN", "no capability declares job: true")
|
|
344
|
+
return
|
|
345
|
+
missing = [n for n in ("fabric.job.get", "fabric.job.cancel") if n not in tools]
|
|
346
|
+
self.add("interop.job-tools", "FAIL" if missing else "PASS",
|
|
347
|
+
("job capabilities %s, but %s not served" % (", ".join(jobs), " and ".join(missing))) if missing else "fabric.job.get and fabric.job.cancel served")
|
|
348
|
+
|
|
349
|
+
def unknown_job_rule(self, tool_names: List[str]) -> None:
|
|
350
|
+
"""C3.2: an unknown id answers isError with unknown-job, never a fresh job."""
|
|
351
|
+
if "fabric.job.get" not in tool_names:
|
|
352
|
+
self.add("interop.unknown-job", "NOT_RUN", "fabric.job.get is not served")
|
|
353
|
+
return
|
|
354
|
+
probe_id = "probe-unknown-" + secrets.token_hex(6)
|
|
355
|
+
try:
|
|
356
|
+
answer = self.mcp_call("tools/call", {"name": "fabric.job.get", "arguments": {"id": probe_id}})
|
|
357
|
+
except (OSError, ValueError) as exc:
|
|
358
|
+
self.add("interop.unknown-job", "FAIL", "fabric.job.get failed: %s" % exc)
|
|
359
|
+
return
|
|
360
|
+
result = answer.get("result") or {}
|
|
361
|
+
ok = result.get("isError") is True and "unknown-job" in json.dumps(result)
|
|
362
|
+
self.add("interop.unknown-job", "PASS" if ok else "FAIL",
|
|
363
|
+
"isError with unknown-job for %s" % probe_id if ok else "an unknown id did not answer isError with unknown-job")
|
|
364
|
+
|
|
365
|
+
def trace_rule(self, answer: Dict[str, Any], sent: Optional[str]) -> None:
|
|
366
|
+
"""C3.4: work runs as a child span of the caller — same trace, new span."""
|
|
367
|
+
answered = fi.parse_traceparent(((answer.get("result") or {}).get("_meta") or {}).get("traceparent"))
|
|
368
|
+
mine = fi.parse_traceparent(sent)
|
|
369
|
+
if answered is None or mine is None:
|
|
370
|
+
self.add("interop.trace-propagation", "NOT_RUN", "the answer carries no _meta.traceparent (C3.4 does not require it)")
|
|
371
|
+
elif answered["trace_id"] == mine["trace_id"] and answered["span_id"] != mine["span_id"]:
|
|
372
|
+
self.add("interop.trace-propagation", "PASS", "answered as a child span of trace %s" % mine["trace_id"])
|
|
373
|
+
else:
|
|
374
|
+
self.add("interop.trace-propagation", "FAIL", "answered with trace %s span %s to a call in trace %s span %s"
|
|
375
|
+
% (answered["trace_id"], answered["span_id"], mine["trace_id"], mine["span_id"]))
|
|
376
|
+
|
|
377
|
+
def events_trace_rule(self, events: Optional[List[Dict[str, Any]]]) -> None:
|
|
378
|
+
"""C3.4 c: an event about traced work carries traceId and spanId together, well formed."""
|
|
379
|
+
if events is None:
|
|
380
|
+
self.add("interop.events-trace", "NOT_RUN", "no events page was read")
|
|
381
|
+
return
|
|
382
|
+
bad, traced = [], 0
|
|
383
|
+
for e in events:
|
|
384
|
+
has = ("traceId" in e, "spanId" in e)
|
|
385
|
+
if has == (False, False):
|
|
386
|
+
continue
|
|
387
|
+
traced += 1
|
|
388
|
+
if has != (True, True) or not fi.parse_traceparent("00-%s-%s-01" % (e.get("traceId"), e.get("spanId"))):
|
|
389
|
+
bad.append(str(e.get("id")))
|
|
390
|
+
self.add("interop.events-trace", "FAIL" if bad else "PASS",
|
|
391
|
+
("events with a broken trace pair: " + ", ".join(bad)) if bad else "%d of %d events traced, every pair whole" % (traced, len(events)))
|
|
392
|
+
# #endregion probe-interop
|
|
393
|
+
|
|
218
394
|
# lifecycle ----------------------------------------------------------------
|
|
219
395
|
def lifecycle_rules(self) -> None:
|
|
220
396
|
life = self.d.get("lifecycle") or {}
|
|
@@ -315,6 +491,7 @@ class Probe:
|
|
|
315
491
|
self.network_rules()
|
|
316
492
|
self.auth_rules()
|
|
317
493
|
self.login_rules()
|
|
494
|
+
self.interop_rules()
|
|
318
495
|
self.lifecycle_rules()
|
|
319
496
|
return self.results
|
|
320
497
|
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Reference kit for the fabric-interop/0.1 extension — Node.js 20+, no dependencies.
|
|
2
|
+
// The Node twin of fabric_interop.py: the same rules and the same job file format, so a
|
|
3
|
+
// job written by one kit is read by the other. The minimal MCP dispatcher is Python-only;
|
|
4
|
+
// a Node service uses the official MCP SDK for the wire and these helpers for the rules.
|
|
5
|
+
// Normative source: fabric-agent-contract docs/specification/interop.md (DEC-0016, rulings DEC-0017).
|
|
6
|
+
// #region interop-kit-node — docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#the-kit
|
|
7
|
+
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { ServiceError, atomicWrite, ensurePrivateDir, nowIso } from './fabric-service.mjs';
|
|
12
|
+
|
|
13
|
+
export const PROTOCOL = 'fabric-interop/0.1';
|
|
14
|
+
export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
|
|
15
|
+
export const MCP_REVISION = '2026-07-28';
|
|
16
|
+
export const TERMINAL = ['completed', 'failed', 'cancelled'];
|
|
17
|
+
export const CONTRACT_VERSION = '0.1.0';
|
|
18
|
+
const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
|
|
19
|
+
const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
|
|
20
|
+
// The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
|
|
21
|
+
export const JOB_HANDLE_SCHEMA = {
|
|
22
|
+
type: 'object', required: ['job'], additionalProperties: false,
|
|
23
|
+
properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
|
|
24
|
+
properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
28
|
+
const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
29
|
+
const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
30
|
+
const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
|
|
31
|
+
const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
|
|
32
|
+
|
|
33
|
+
export class InteropError extends ServiceError {}
|
|
34
|
+
export class UnknownJob extends InteropError {
|
|
35
|
+
constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- C3.4 trace context ---------------------------------------------------------------
|
|
39
|
+
export function parseTraceparent(value) {
|
|
40
|
+
const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
|
|
41
|
+
if (!m) return null;
|
|
42
|
+
const [, version, traceId, spanId, flags] = m;
|
|
43
|
+
if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
|
|
44
|
+
return { version, traceId, spanId, flags };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
|
|
48
|
+
|
|
49
|
+
export function childTraceparent(parent) {
|
|
50
|
+
const p = parseTraceparent(parent);
|
|
51
|
+
return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const traceIds = (traceparent) => {
|
|
55
|
+
const p = parseTraceparent(traceparent);
|
|
56
|
+
return p ? { traceId: p.traceId, spanId: p.spanId } : {};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// --- C3.1 capabilities as tools ----------------------------------------------------------
|
|
60
|
+
export function expectedAnnotations(effect, idempotency) {
|
|
61
|
+
const hints = {};
|
|
62
|
+
if (effect === 'none') hints.readOnlyHint = true;
|
|
63
|
+
if (['delete', 'merge', 'deploy', 'change-policy'].includes(effect)) hints.destructiveHint = true;
|
|
64
|
+
if (idempotency === 'required') hints.idempotentHint = true;
|
|
65
|
+
return hints;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// DEC-0017: a job-backed tool serves oneOf[result envelope, job handle]; the manifest keeps the pure output schema.
|
|
69
|
+
export const jobToolOutputSchema = (outputSchema) => ({ oneOf: [{ type: 'object', required: [...ENVELOPE_REQUIRED], properties: { output: outputSchema } }, JOB_HANDLE_SCHEMA] });
|
|
70
|
+
export const isJobCapability = (capability) => capability.job === true || capability.extensions?.[EXTENSION_KEY]?.job === true;
|
|
71
|
+
|
|
72
|
+
export function toolForCapability(capability, inputSchema, outputSchema, title) {
|
|
73
|
+
const served = isJobCapability(capability) ? jobToolOutputSchema(outputSchema) : outputSchema;
|
|
74
|
+
const tool = { name: capability.name, inputSchema, outputSchema: served, annotations: expectedAnnotations(capability.effect, capability.idempotency) };
|
|
75
|
+
if (capability.description) tool.description = capability.description;
|
|
76
|
+
if (title) tool.title = title;
|
|
77
|
+
return tool;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function toolResult(structured, { isError = false, traceparent } = {}) {
|
|
81
|
+
const result = { resultType: 'complete', content: [{ type: 'text', text: JSON.stringify(structured) }], structuredContent: structured, isError };
|
|
82
|
+
if (traceparent) result._meta = { traceparent };
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const unknownJobResult = (jobId, traceparent) =>
|
|
87
|
+
toolResult({ error: { code: 'unknown-job', message: `No job ${jobId} here.` } }, { isError: true, traceparent });
|
|
88
|
+
|
|
89
|
+
// --- C3.2 the result envelope --------------------------------------------------------------
|
|
90
|
+
function checkUsage(usage) {
|
|
91
|
+
if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
|
|
92
|
+
for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
|
|
93
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
94
|
+
if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
|
|
95
|
+
const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
|
|
96
|
+
if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
|
|
97
|
+
}
|
|
98
|
+
return { ...usage };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The full result envelope (contract result.schema.json, DEC-0017): the shape a synchronous call returns.
|
|
102
|
+
export function resultEnvelope({ outcome, done, proof, scope, notVerified, output, usage, producer, artifacts = [], traceparent, id, createdAt }) {
|
|
103
|
+
if (!OUTCOMES.includes(outcome)) throw new InteropError(`outcome must be one of ${OUTCOMES.join(', ')}.`);
|
|
104
|
+
for (const [label, value] of [['done', done], ['proof', proof], ['notVerified', notVerified], ['artifacts', artifacts]]) {
|
|
105
|
+
if (!Array.isArray(value)) throw new InteropError(`${label} must be a list, even when empty.`);
|
|
106
|
+
}
|
|
107
|
+
if (outcome === 'succeeded' && notVerified.length) throw new InteropError('A succeeded result cannot keep unverified claims (FAC-SEM-001); report partial.');
|
|
108
|
+
if (!scope || typeof scope !== 'object' || !producer || typeof producer !== 'object') throw new InteropError('scope and producer must be objects.');
|
|
109
|
+
const envelope = {
|
|
110
|
+
id: id ?? `urn:fabric:result:${crypto.randomBytes(12).toString('hex')}`, contractVersion: CONTRACT_VERSION, outcome,
|
|
111
|
+
done: [...done], proof: [...proof], scope: { ...scope }, notVerified: [...notVerified], artifacts: [...artifacts],
|
|
112
|
+
createdAt: createdAt ?? nowIso(), producer: { ...producer }, output, usage: checkUsage(usage),
|
|
113
|
+
};
|
|
114
|
+
if (parseTraceparent(traceparent)) envelope.trace = { traceparent };
|
|
115
|
+
return envelope;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- C3.3 awaiting a choice ------------------------------------------------------------------
|
|
119
|
+
export function formRequest(message, properties, required) {
|
|
120
|
+
for (const [field, schema] of Object.entries(properties)) {
|
|
121
|
+
const words = ['title', 'description', 'format'].map((k) => schema[k] ?? '').join(' ');
|
|
122
|
+
if (SECRET_WORDS.test(field) || SECRET_WORDS.test(words)) throw new InteropError(`Form mode cannot ask for ${field}: a secret goes through urlRequest.`);
|
|
123
|
+
if (!['string', 'number', 'integer', 'boolean', 'array'].includes(schema.type)) throw new InteropError(`Form field ${field} must be a primitive.`);
|
|
124
|
+
}
|
|
125
|
+
const requestedSchema = { type: 'object', properties };
|
|
126
|
+
if (required?.length) requestedSchema.required = [...required];
|
|
127
|
+
return { method: 'elicitation/create', params: { mode: 'form', message, requestedSchema } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function choiceRequest(message, field, options, title) {
|
|
131
|
+
const schema = { type: 'string', oneOf: options.map(([value, label]) => ({ const: value, title: label })) };
|
|
132
|
+
if (title) schema.title = title;
|
|
133
|
+
return formRequest(message, { [field]: schema }, [field]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function urlRequest(message, url) {
|
|
137
|
+
if (!/^(https:\/\/|http:\/\/127\.0\.0\.1|http:\/\/localhost)/.test(url)) throw new InteropError('A URL-mode request needs an https URL (or this machine\'s loopback).');
|
|
138
|
+
return { method: 'elicitation/create', params: { mode: 'url', message, url } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- C3.2 jobs: the same file format as fabric_interop.JobStore ------------------------------
|
|
142
|
+
export class JobStore {
|
|
143
|
+
constructor(dir) { this.dir = dir; }
|
|
144
|
+
|
|
145
|
+
file(jobId) {
|
|
146
|
+
if (!JOB_ID.test(jobId ?? '')) throw new UnknownJob(String(jobId));
|
|
147
|
+
return path.join(this.dir, `${jobId}.json`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
load(jobId) {
|
|
151
|
+
let text;
|
|
152
|
+
try { text = fs.readFileSync(this.file(jobId), 'utf8'); } catch (e) { if (e.code === 'ENOENT') throw new UnknownJob(jobId); throw e; }
|
|
153
|
+
try { return JSON.parse(text); } catch { throw new InteropError(`Job ${jobId} is unreadable on disk.`); }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
save(record) {
|
|
157
|
+
record.job.updatedAt = nowIso();
|
|
158
|
+
ensurePrivateDir(this.dir);
|
|
159
|
+
atomicWrite(this.file(record.job.id), `${JSON.stringify(record)}\n`, 0o600);
|
|
160
|
+
return { ...record.job };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
create(capability, input, { traceparent, pollIntervalMs } = {}) {
|
|
164
|
+
const id = `job_${crypto.randomBytes(12).toString('hex')}`;
|
|
165
|
+
const job = { id, status: 'working' };
|
|
166
|
+
if (pollIntervalMs) job.pollIntervalMs = Math.trunc(pollIntervalMs);
|
|
167
|
+
this.save({ job, private: { capability, input, traceparent: parseTraceparent(traceparent) ? traceparent : null } });
|
|
168
|
+
return { job: { id, status: 'working' } };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
get(jobId) { return { ...this.load(jobId).job }; }
|
|
172
|
+
traceparent(jobId) { return this.load(jobId).private.traceparent ?? null; }
|
|
173
|
+
|
|
174
|
+
transition(jobId, status, fields = {}) {
|
|
175
|
+
const record = this.load(jobId);
|
|
176
|
+
if (TERMINAL.includes(record.job.status)) throw new InteropError(`Job ${jobId} is already ${record.job.status}; a terminal job does not change.`);
|
|
177
|
+
for (const key of ['inputRequests', 'result', 'error', 'statusMessage']) delete record.job[key];
|
|
178
|
+
record.job.status = status;
|
|
179
|
+
for (const [key, value] of Object.entries(fields)) if (value !== undefined && value !== null) record.job[key] = value;
|
|
180
|
+
return this.save(record);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
working(jobId, statusMessage) { return this.transition(jobId, 'working', { statusMessage }); }
|
|
184
|
+
|
|
185
|
+
requestInput(jobId, inputRequests, statusMessage) {
|
|
186
|
+
const entries = Object.entries(inputRequests ?? {});
|
|
187
|
+
if (!entries.length) throw new InteropError('input_required needs at least one input request.');
|
|
188
|
+
for (const [key, request] of entries) if (!KEY.test(key) || request?.method !== 'elicitation/create') throw new InteropError(`Input request ${key} must be a keyed elicitation/create request.`);
|
|
189
|
+
return this.transition(jobId, 'input_required', { inputRequests, statusMessage });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
answer(jobId, inputResponses) {
|
|
193
|
+
const job = this.get(jobId);
|
|
194
|
+
if (job.status !== 'input_required') return {};
|
|
195
|
+
const matched = Object.fromEntries(Object.entries(inputResponses ?? {}).filter(([key, value]) =>
|
|
196
|
+
key in (job.inputRequests ?? {}) && ['accept', 'decline', 'cancel'].includes(value?.action)));
|
|
197
|
+
if (Object.keys(matched).length) this.working(jobId);
|
|
198
|
+
return matched;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// The envelope carries the job's trace, authoritative for the stored result (DEC-0017, FAC-SEM-022).
|
|
202
|
+
complete(jobId, envelope) {
|
|
203
|
+
if (!envelope || !ENVELOPE_REQUIRED.every((k) => k in envelope)) throw new InteropError('A completed job carries the full result envelope; build it with resultEnvelope.');
|
|
204
|
+
const stored = this.traceparent(jobId);
|
|
205
|
+
const given = envelope.trace?.traceparent;
|
|
206
|
+
const ids = (v) => { const p = parseTraceparent(v); return p ? `${p.traceId}-${p.spanId}` : null; };
|
|
207
|
+
if (stored && given && ids(given) !== ids(stored)) throw new InteropError(`Job ${jobId} ran in span ${stored}; its result names another trace.`);
|
|
208
|
+
const result = stored && !given ? { ...envelope, trace: { traceparent: stored } } : envelope;
|
|
209
|
+
return this.transition(jobId, 'completed', { result });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fail(jobId, code, message) { return this.transition(jobId, 'failed', { error: { code, message } }); }
|
|
213
|
+
cancel(jobId) { return this.transition(jobId, 'cancelled'); }
|
|
214
|
+
}
|
|
215
|
+
// #endregion interop-kit-node
|
|
@@ -22,6 +22,8 @@ const INSTANCE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
22
22
|
const KIND = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$/;
|
|
23
23
|
const ORIGIN = /^http:\/\/127\.0\.0\.1:([0-9]{3,5})$/;
|
|
24
24
|
const CODE = /^[A-Za-z0-9_-]{16,256}$/;
|
|
25
|
+
const TRACE_ID = /^(?!0{32}$)[0-9a-f]{32}$/;
|
|
26
|
+
const SPAN_ID = /^(?!0{16}$)[0-9a-f]{16}$/;
|
|
25
27
|
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
26
28
|
|
|
27
29
|
export class ServiceError extends Error {}
|
|
@@ -281,16 +283,21 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
|
|
|
281
283
|
return doc;
|
|
282
284
|
}
|
|
283
285
|
|
|
284
|
-
|
|
286
|
+
// An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
|
|
287
|
+
// traceIds(traceparent) in fabric-interop.mjs gives both.
|
|
288
|
+
export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId } = {}) {
|
|
285
289
|
if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
|
|
286
290
|
if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
|
|
287
291
|
const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
|
|
288
292
|
if (!sentence) throw new ServiceError('An event needs a sentence.');
|
|
289
293
|
if (link !== undefined && (!link.startsWith('/') || link.startsWith('//'))) throw new ServiceError('link must be a path on this service.');
|
|
294
|
+
if ((traceId === undefined) !== (spanId === undefined)) throw new ServiceError('An event carries traceId and spanId together, or neither.');
|
|
295
|
+
if (traceId !== undefined && !(TRACE_ID.test(traceId) && SPAN_ID.test(spanId))) throw new ServiceError('traceId is 32 and spanId 16 lowercase hex characters, not all zeros.');
|
|
290
296
|
const event = { id: String(id), at, kind, level, text: sentence.slice(0, 500) };
|
|
291
297
|
if (subject) event.subject = subject;
|
|
292
298
|
if (link) event.link = link;
|
|
293
299
|
if (notify) event.notify = true;
|
|
300
|
+
if (traceId !== undefined) Object.assign(event, { traceId, spanId });
|
|
294
301
|
return event;
|
|
295
302
|
}
|
|
296
303
|
|