@passioncode-ai/fabric-agent-adapter 0.6.3 → 0.8.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 +104 -1
- package/README.md +28 -5
- package/bin/fabric-agent-adapter.js +18 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py
CHANGED
|
@@ -382,6 +382,52 @@ JOB_REQUEST_SCHEMA = {"type": "object", "required": ["id"], "additionalPropertie
|
|
|
382
382
|
"inputResponses": {"type": "object"}}}
|
|
383
383
|
|
|
384
384
|
|
|
385
|
+
NAMED_METHODS = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}
|
|
386
|
+
HEADER_MISMATCH = -32020 # the code the official 2026-07-28 server SDK answers a header/body mismatch with
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def mcp_header_problem(message: Any, headers: Any) -> Optional[str]:
|
|
390
|
+
"""MCP 2026-07-28 Streamable HTTP mirrors the body in `Mcp-Method` (every request and
|
|
391
|
+
notification) and `Mcp-Name` (`params.name` or `params.uri` of tools/call, prompts/get,
|
|
392
|
+
resources/read). A server MUST reject a mismatch; a request that declares 2026-07-28 must
|
|
393
|
+
carry them. Earlier revisions may omit them, but what they send must still agree.
|
|
394
|
+
`headers` is any case-insensitive mapping with `.get` (http.server's message, a dict)."""
|
|
395
|
+
if not isinstance(message, dict) or headers is None:
|
|
396
|
+
return None
|
|
397
|
+
def header(name: str) -> Optional[str]:
|
|
398
|
+
value = headers.get(name) if hasattr(headers, "get") else None
|
|
399
|
+
if value is None and isinstance(headers, dict):
|
|
400
|
+
value = next((v for k, v in headers.items() if k.lower() == name.lower()), None)
|
|
401
|
+
if isinstance(value, (list, tuple)):
|
|
402
|
+
value = value[0] if value else None
|
|
403
|
+
return None if value is None else str(value)
|
|
404
|
+
declared = header("MCP-Protocol-Version") == MCP_REVISION
|
|
405
|
+
method = message.get("method")
|
|
406
|
+
sent = header("Mcp-Method")
|
|
407
|
+
if sent is None and declared:
|
|
408
|
+
return "the body names method %s but the required Mcp-Method header is absent" % method
|
|
409
|
+
if sent is not None and sent != method:
|
|
410
|
+
return "Mcp-Method %s disagrees with the body's method %s" % (sent, method)
|
|
411
|
+
field = NAMED_METHODS.get(str(method))
|
|
412
|
+
if field:
|
|
413
|
+
value = (message.get("params") or {}).get(field)
|
|
414
|
+
named = header("Mcp-Name")
|
|
415
|
+
if named is None and declared:
|
|
416
|
+
return "the body carries params.%s but the required Mcp-Name header is absent" % field
|
|
417
|
+
if named is not None and named != str(value):
|
|
418
|
+
return "Mcp-Name disagrees with the body's params.%s" % field
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def mcp_request_headers(method: str, params: Dict[str, Any]) -> Dict[str, str]:
|
|
423
|
+
"""The standard headers a 2026-07-28 client sends with one request."""
|
|
424
|
+
out = {"MCP-Protocol-Version": MCP_REVISION, "Mcp-Method": method}
|
|
425
|
+
field = NAMED_METHODS.get(method)
|
|
426
|
+
if field and params.get(field) is not None:
|
|
427
|
+
out["Mcp-Name"] = str(params[field])
|
|
428
|
+
return out
|
|
429
|
+
|
|
430
|
+
|
|
385
431
|
class McpToolServer:
|
|
386
432
|
"""tools/list and tools/call for an MCP 2026-07-28 server, with fabric.job.get/cancel built in.
|
|
387
433
|
|
|
@@ -428,9 +474,15 @@ class McpToolServer:
|
|
|
428
474
|
def _error(rid: Any, code: int, message: str) -> Dict[str, Any]:
|
|
429
475
|
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
|
430
476
|
|
|
431
|
-
def handle(self, message: Any) -> Optional[Dict[str, Any]]:
|
|
477
|
+
def handle(self, message: Any, headers: Any = None) -> Optional[Dict[str, Any]]:
|
|
478
|
+
"""One JSON-RPC message. Pass the HTTP request headers so the 2026-07-28 standard headers
|
|
479
|
+
are checked (`mcp_header_problem`); a mismatch answers HEADER_MISMATCH even for a
|
|
480
|
+
notification, so the transport can return HTTP 400."""
|
|
432
481
|
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str):
|
|
433
482
|
return self._error(message.get("id") if isinstance(message, dict) else None, -32600, "Invalid request.")
|
|
483
|
+
problem = mcp_header_problem(message, headers)
|
|
484
|
+
if problem:
|
|
485
|
+
return self._error(message.get("id"), HEADER_MISMATCH, "Bad Request: the request headers and body disagree: " + problem)
|
|
434
486
|
if "id" not in message:
|
|
435
487
|
return None # a notification gets no response
|
|
436
488
|
rid = message["id"]
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py
CHANGED
|
@@ -11,6 +11,7 @@ Normative source: fabric-agent-contract docs/specification/service.md (DEC-0015)
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import contextlib
|
|
14
15
|
import datetime as _dt
|
|
15
16
|
import errno
|
|
16
17
|
import hashlib
|
|
@@ -22,12 +23,16 @@ from pathlib import Path
|
|
|
22
23
|
import plistlib
|
|
23
24
|
import re
|
|
24
25
|
import secrets
|
|
26
|
+
import shutil
|
|
27
|
+
import signal
|
|
25
28
|
import socketserver
|
|
26
29
|
import stat
|
|
27
30
|
import subprocess
|
|
28
31
|
import sys
|
|
29
32
|
import tempfile
|
|
33
|
+
import threading
|
|
30
34
|
import time
|
|
35
|
+
import traceback
|
|
31
36
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Tuple
|
|
32
37
|
import urllib.error
|
|
33
38
|
import urllib.parse
|
|
@@ -36,6 +41,22 @@ import urllib.request
|
|
|
36
41
|
PROTOCOL = "fabric-service/0.1"
|
|
37
42
|
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
38
43
|
EXIT_ALREADY_RUNNING = 75
|
|
44
|
+
# Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
|
|
45
|
+
# the lock held backs off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive
|
|
46
|
+
# with ThrottleInterval 10 that is a few starts an hour instead of one every 10 s.
|
|
47
|
+
SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
|
|
48
|
+
LOCK_WAIT_SUPERVISED_SECONDS = 300.0
|
|
49
|
+
LOCK_BACKOFF_FIRST_SECONDS = 0.5
|
|
50
|
+
LOCK_BACKOFF_MAX_SECONDS = 30.0
|
|
51
|
+
# LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, then hand over,
|
|
52
|
+
# and a hard exit 2 s later if the hand-over itself hangs. ExitTimeOut sits above both.
|
|
53
|
+
DRAIN_SECONDS = 8.0
|
|
54
|
+
DRAIN_GRACE_SECONDS = 2.0
|
|
55
|
+
EXIT_HARD_STOP = 70
|
|
56
|
+
DEFAULT_EXIT_TIMEOUT = 15
|
|
57
|
+
# LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
58
|
+
LOG_MAX_BYTES = 5 * 1024 * 1024
|
|
59
|
+
LOG_BACKUPS = 5
|
|
39
60
|
LOGIN_CODE_TTL_SECONDS = 120
|
|
40
61
|
EVENTS_DEFAULT_LIMIT = 50
|
|
41
62
|
EVENTS_MAX_LIMIT = 200
|
|
@@ -180,13 +201,42 @@ def _read_pid(path: Path) -> Optional[int]:
|
|
|
180
201
|
return None
|
|
181
202
|
|
|
182
203
|
|
|
183
|
-
def
|
|
184
|
-
"""
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
204
|
+
def supervised_by_launchd() -> bool:
|
|
205
|
+
"""True when launchd started this process from a plist written by ``launchd_plist``.
|
|
206
|
+
|
|
207
|
+
The plist says so explicitly (``FABRIC_SERVICE_SUPERVISOR=launchd``); the parent process or
|
|
208
|
+
``XPC_SERVICE_NAME`` is not evidence — a terminal inside an app carries one too."""
|
|
209
|
+
return os.environ.get(SUPERVISOR_ENV) == "launchd"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def hold_single_instance(data_dir: Path, wait_seconds: Optional[float] = None, *,
|
|
213
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
214
|
+
clock: Callable[[], float] = time.monotonic) -> InstanceLock:
|
|
215
|
+
"""Take the lock or exit 75 with one sentence naming the holder.
|
|
216
|
+
|
|
217
|
+
Started by hand, a held lock exits 75 at once. Under launchd (KeepAlive) an immediate exit
|
|
218
|
+
is a respawn every ThrottleInterval, forever; so a supervised copy first backs off in-process
|
|
219
|
+
(0.5 s doubling to 30 s, ``LOCK_WAIT_SUPERVISED_SECONDS`` in all), takes over if the holder
|
|
220
|
+
leaves, and exits 75 only when the wait runs out. Nothing has been touched while it waits.
|
|
221
|
+
``wait_seconds`` overrides the choice (0 = never wait)."""
|
|
222
|
+
if wait_seconds is None:
|
|
223
|
+
wait_seconds = LOCK_WAIT_SUPERVISED_SECONDS if supervised_by_launchd() else 0.0
|
|
224
|
+
deadline = clock() + max(0.0, wait_seconds)
|
|
225
|
+
delay = LOCK_BACKOFF_FIRST_SECONDS
|
|
226
|
+
announced = False
|
|
227
|
+
while True:
|
|
228
|
+
try:
|
|
229
|
+
return InstanceLock(data_dir).acquire()
|
|
230
|
+
except AlreadyRunning as exc:
|
|
231
|
+
remaining = deadline - clock()
|
|
232
|
+
if remaining <= 0:
|
|
233
|
+
print(str(exc), file=sys.stderr)
|
|
234
|
+
raise SystemExit(EXIT_ALREADY_RUNNING)
|
|
235
|
+
if not announced:
|
|
236
|
+
print("%s Waiting up to %.0f s for it to exit." % (exc, remaining), file=sys.stderr, flush=True)
|
|
237
|
+
announced = True
|
|
238
|
+
sleep(min(delay, remaining))
|
|
239
|
+
delay = min(delay * 2, LOCK_BACKOFF_MAX_SECONDS)
|
|
190
240
|
|
|
191
241
|
|
|
192
242
|
# --- token -------------------------------------------------------------------
|
|
@@ -533,6 +583,324 @@ class JsonlEventLog:
|
|
|
533
583
|
return [r for r in rows if int(r["id"]) > floor][:limit]
|
|
534
584
|
|
|
535
585
|
|
|
586
|
+
# --- usage report (DEC-0021) -------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
USAGE_PATH = "/fabric/v1/usage"
|
|
589
|
+
USAGE_DAYS = 31
|
|
590
|
+
COST_BASES = ("provider", "price-list", "unknown")
|
|
591
|
+
_PROVIDER = re.compile(r"^[a-z][a-z0-9._-]{0,63}$")
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def make_usage_receipt(provider: str, model: str, *, input_tokens: int, output_tokens: int,
|
|
595
|
+
cache_read_tokens: int = 0, cache_write_tokens: int = 0,
|
|
596
|
+
cost_usd: Optional[float] = None, cost_basis: Optional[str] = None,
|
|
597
|
+
at: Optional[str] = None) -> Dict[str, Any]:
|
|
598
|
+
"""One model call, from the provider's own usage numbers. No prompt, output or caller.
|
|
599
|
+
|
|
600
|
+
`cost_usd` is None when the call cannot be priced: the report counts it as unpriced and
|
|
601
|
+
never as $0. `cost_basis` is `provider` (the provider reported the charge) or `price-list`
|
|
602
|
+
(computed from a published price list); it is `unknown` when there is no cost."""
|
|
603
|
+
if not _PROVIDER.match(provider or ""):
|
|
604
|
+
raise ServiceError("provider must be a lowercase name such as anthropic or openrouter.")
|
|
605
|
+
model = str(model or "").strip()
|
|
606
|
+
if not model or len(model) > 128:
|
|
607
|
+
raise ServiceError("model must be 1 to 128 characters.")
|
|
608
|
+
counts = {"inputTokens": input_tokens, "outputTokens": output_tokens,
|
|
609
|
+
"cacheReadTokens": cache_read_tokens, "cacheWriteTokens": cache_write_tokens}
|
|
610
|
+
for name, value in counts.items():
|
|
611
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
612
|
+
raise ServiceError("%s must be a non-negative integer." % name)
|
|
613
|
+
if cost_usd is not None and (isinstance(cost_usd, bool) or not isinstance(cost_usd, (int, float)) or cost_usd < 0 or cost_usd != cost_usd):
|
|
614
|
+
raise ServiceError("cost_usd must be a non-negative number or None.")
|
|
615
|
+
basis = cost_basis or ("unknown" if cost_usd is None else "provider")
|
|
616
|
+
if basis not in COST_BASES or (cost_usd is None) != (basis == "unknown"):
|
|
617
|
+
raise ServiceError("cost_basis is provider or price-list with a cost, unknown without one.")
|
|
618
|
+
return {"at": at or now_iso(), "provider": provider, "model": model, **counts,
|
|
619
|
+
"costUsd": None if cost_usd is None else float(cost_usd), "costBasis": basis}
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _add_totals(row: Dict[str, Any], receipt: Dict[str, Any]) -> None:
|
|
623
|
+
row["calls"] += 1
|
|
624
|
+
for name in ("inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
625
|
+
row[name] += int(receipt.get(name) or 0)
|
|
626
|
+
if receipt.get("costUsd") is None:
|
|
627
|
+
row["unpricedCalls"] += 1
|
|
628
|
+
else:
|
|
629
|
+
row["_cost"] += float(receipt["costUsd"])
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
|
|
633
|
+
cost = row.pop("_cost")
|
|
634
|
+
row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else round(cost, 6)
|
|
635
|
+
return row
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def usage_report(receipts: Iterable[Dict[str, Any]], *, service_id: str, instance: str = "default",
|
|
639
|
+
now: Optional[_dt.datetime] = None,
|
|
640
|
+
budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
641
|
+
"""The `service-usage.schema.json` answer: the last 31 UTC days, oldest first, per provider
|
|
642
|
+
and model. Days without calls are omitted. A row whose calls are all unpriced has
|
|
643
|
+
`costUsd: None`; totals are the sums of the model rows (FAC-SEM-025)."""
|
|
644
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
645
|
+
first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
|
|
646
|
+
last = now.strftime("%Y-%m-%d")
|
|
647
|
+
days: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
648
|
+
bases: Dict[Tuple[str, str, str], set] = {}
|
|
649
|
+
for r in receipts:
|
|
650
|
+
date = str(r.get("at", ""))[:10]
|
|
651
|
+
if not (first <= date <= last):
|
|
652
|
+
continue
|
|
653
|
+
key = (r["provider"], r["model"])
|
|
654
|
+
row = days.setdefault(date, {}).setdefault("%s\u0000%s" % key, {
|
|
655
|
+
"provider": r["provider"], "model": r["model"], "calls": 0, "unpricedCalls": 0,
|
|
656
|
+
"inputTokens": 0, "outputTokens": 0, "cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0})
|
|
657
|
+
_add_totals(row, r)
|
|
658
|
+
bases.setdefault((date,) + key, set()).add(r.get("costBasis") or "unknown")
|
|
659
|
+
out_days = []
|
|
660
|
+
for date in sorted(days):
|
|
661
|
+
models = []
|
|
662
|
+
for row in days[date].values():
|
|
663
|
+
seen = bases[(date, row["provider"], row["model"])]
|
|
664
|
+
row["costBasis"] = next(iter(seen)) if len(seen) == 1 else "mixed"
|
|
665
|
+
models.append(_close_totals(row))
|
|
666
|
+
models.sort(key=lambda m: (m["provider"], m["model"]))
|
|
667
|
+
day = {"date": date, "calls": 0, "unpricedCalls": 0, "inputTokens": 0, "outputTokens": 0,
|
|
668
|
+
"cacheReadTokens": 0, "cacheWriteTokens": 0, "_cost": 0.0}
|
|
669
|
+
for m in models:
|
|
670
|
+
for name in ("calls", "unpricedCalls", "inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"):
|
|
671
|
+
day[name] += m[name]
|
|
672
|
+
day["_cost"] += m["costUsd"] or 0.0
|
|
673
|
+
day = _close_totals(day)
|
|
674
|
+
day["byModel"] = models
|
|
675
|
+
out_days.append(day)
|
|
676
|
+
report: Dict[str, Any] = {"protocol": PROTOCOL, "service": {"id": service_id, "instance": instance},
|
|
677
|
+
"generatedAt": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "currency": "USD", "days": out_days}
|
|
678
|
+
if budget is not None:
|
|
679
|
+
if budget.get("period") not in ("day", "month") or not isinstance(budget.get("limitUsd"), (int, float)) or budget["limitUsd"] <= 0:
|
|
680
|
+
raise ServiceError("budget is {period: day|month, limitUsd > 0}.")
|
|
681
|
+
prefix = last if budget["period"] == "day" else last[:7]
|
|
682
|
+
window = [d for d in out_days if d["date"].startswith(prefix)]
|
|
683
|
+
unknown = bool(window) and all(d["costUsd"] is None for d in window)
|
|
684
|
+
spent = None if unknown else round(sum(d["costUsd"] or 0.0 for d in window), 6)
|
|
685
|
+
report["budget"] = {"period": budget["period"], "limitUsd": float(budget["limitUsd"]), "spentUsd": spent}
|
|
686
|
+
return report
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
class JsonlUsageLedger:
|
|
690
|
+
"""Usage receipts for services that keep none yet: append-only JSON lines, mode 0600, pruned
|
|
691
|
+
to the reported window so the file stays bounded (LC-12). One writer process — the service,
|
|
692
|
+
which holds its instance lock; the lock here orders that process's threads."""
|
|
693
|
+
|
|
694
|
+
def __init__(self, path: Path):
|
|
695
|
+
self.path = Path(path)
|
|
696
|
+
self._lock = threading.Lock()
|
|
697
|
+
|
|
698
|
+
def record(self, receipt: Dict[str, Any]) -> None:
|
|
699
|
+
line = json.dumps(receipt, separators=(",", ":")) + "\n"
|
|
700
|
+
with self._lock:
|
|
701
|
+
ensure_private_dir(self.path.parent)
|
|
702
|
+
fd = os.open(str(self.path), os.O_RDWR | os.O_CREAT | os.O_APPEND, 0o600)
|
|
703
|
+
try:
|
|
704
|
+
size = os.fstat(fd).st_size
|
|
705
|
+
if size and os.pread(fd, 1, size - 1) != b"\n":
|
|
706
|
+
line = "\n" + line # a killed writer left a torn line: never glue a receipt to it
|
|
707
|
+
os.write(fd, line.encode("utf-8"))
|
|
708
|
+
finally:
|
|
709
|
+
os.close(fd)
|
|
710
|
+
|
|
711
|
+
def receipts(self) -> List[Dict[str, Any]]:
|
|
712
|
+
if not self.path.exists():
|
|
713
|
+
return []
|
|
714
|
+
out = []
|
|
715
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
716
|
+
try:
|
|
717
|
+
out.append(json.loads(line))
|
|
718
|
+
except ValueError:
|
|
719
|
+
continue # a torn last line from a killed writer is skipped, not fatal
|
|
720
|
+
return out
|
|
721
|
+
|
|
722
|
+
def prune(self, now: Optional[_dt.datetime] = None) -> int:
|
|
723
|
+
"""Drop receipts older than the reported window; returns how many were dropped."""
|
|
724
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
725
|
+
first = (now - _dt.timedelta(days=USAGE_DAYS - 1)).strftime("%Y-%m-%d")
|
|
726
|
+
with self._lock:
|
|
727
|
+
every = self.receipts()
|
|
728
|
+
kept = [r for r in every if str(r.get("at", ""))[:10] >= first]
|
|
729
|
+
dropped = len(every) - len(kept)
|
|
730
|
+
if dropped:
|
|
731
|
+
atomic_write(self.path, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in kept).encode("utf-8"))
|
|
732
|
+
return dropped
|
|
733
|
+
|
|
734
|
+
def report(self, *, service_id: str, instance: str = "default", now: Optional[_dt.datetime] = None,
|
|
735
|
+
budget: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
736
|
+
return usage_report(self.receipts(), service_id=service_id, instance=instance, now=now, budget=budget)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# --- logs (LC-12) ---------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
class RotatingLog:
|
|
742
|
+
"""One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a
|
|
743
|
+
0700 directory. ``service.jsonl`` -> ``.1`` ... ``.<backups>``; the oldest falls off.
|
|
744
|
+
|
|
745
|
+
Never pass a token, a cookie, a login code or a request body that may carry one."""
|
|
746
|
+
|
|
747
|
+
def __init__(self, path: Path, max_bytes: int = LOG_MAX_BYTES, backups: int = LOG_BACKUPS):
|
|
748
|
+
if max_bytes < 256 or backups < 1:
|
|
749
|
+
raise ServiceError("A rotating log needs max_bytes >= 256 and at least one backup.")
|
|
750
|
+
self.path = Path(path)
|
|
751
|
+
self.max_bytes = max_bytes
|
|
752
|
+
self.backups = backups
|
|
753
|
+
self._lock = threading.Lock()
|
|
754
|
+
|
|
755
|
+
def _line(self, record: Dict[str, Any]) -> bytes:
|
|
756
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
757
|
+
if len(data) <= self.max_bytes:
|
|
758
|
+
return data
|
|
759
|
+
record = dict(record, truncated=True)
|
|
760
|
+
message = str(record.get("message", ""))
|
|
761
|
+
while message:
|
|
762
|
+
message = message[: len(message) // 2]
|
|
763
|
+
record["message"] = message
|
|
764
|
+
data = (json.dumps(record, ensure_ascii=False) + "\n").encode()
|
|
765
|
+
if len(data) <= self.max_bytes:
|
|
766
|
+
return data
|
|
767
|
+
return (json.dumps({"at": record.get("at"), "level": record.get("level"), "message": "",
|
|
768
|
+
"truncated": True}) + "\n").encode()
|
|
769
|
+
|
|
770
|
+
def write(self, level: str, message: str, **fields: Any) -> None:
|
|
771
|
+
record: Dict[str, Any] = {"at": now_iso(), "level": str(level), "message": str(message)}
|
|
772
|
+
record.update(fields)
|
|
773
|
+
data = self._line(record)
|
|
774
|
+
with self._lock:
|
|
775
|
+
ensure_private_dir(self.path.parent)
|
|
776
|
+
try:
|
|
777
|
+
size = self.path.stat().st_size
|
|
778
|
+
except FileNotFoundError:
|
|
779
|
+
size = 0
|
|
780
|
+
if size and size + len(data) > self.max_bytes:
|
|
781
|
+
self._rotate()
|
|
782
|
+
fd = os.open(str(self.path), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
783
|
+
try:
|
|
784
|
+
os.write(fd, data)
|
|
785
|
+
finally:
|
|
786
|
+
os.close(fd)
|
|
787
|
+
|
|
788
|
+
def _rotate(self) -> None:
|
|
789
|
+
for index in range(self.backups - 1, 0, -1):
|
|
790
|
+
older = Path("%s.%d" % (self.path, index))
|
|
791
|
+
if older.exists():
|
|
792
|
+
os.replace(older, "%s.%d" % (self.path, index + 1))
|
|
793
|
+
os.replace(self.path, "%s.1" % self.path)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def cap_stdout_log(path: Path, max_bytes: int = LOG_MAX_BYTES) -> bool:
|
|
797
|
+
"""Call once at start: a launchd stdout file over ``max_bytes`` is copied to ``<path>.1`` and
|
|
798
|
+
truncated in place. launchd holds the file open for append, so a rename would leave it
|
|
799
|
+
writing to the old name; truncation keeps its descriptor valid. Returns True when capped."""
|
|
800
|
+
path = Path(path)
|
|
801
|
+
try:
|
|
802
|
+
size = path.stat().st_size
|
|
803
|
+
except FileNotFoundError:
|
|
804
|
+
return False
|
|
805
|
+
if size <= max_bytes:
|
|
806
|
+
return False
|
|
807
|
+
backup = Path("%s.1" % path)
|
|
808
|
+
shutil.copyfile(path, backup)
|
|
809
|
+
os.chmod(backup, 0o600)
|
|
810
|
+
with open(path, "r+b") as handle:
|
|
811
|
+
handle.truncate(0)
|
|
812
|
+
return True
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
# --- shutdown (LC-01) -------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
class Stopping(ServiceError):
|
|
818
|
+
"""The service is stopping and takes no new work."""
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
class Drain:
|
|
822
|
+
"""SIGTERM/SIGINT: stop taking new work, drain in-flight work until ``deadline``, then hand
|
|
823
|
+
over with ``on_stop(drained)``. A hand-over that hangs is cut by a hard exit
|
|
824
|
+
(``EXIT_HARD_STOP``) ``grace`` seconds later, so quit reaches exit inside the lifecycle bound.
|
|
825
|
+
|
|
826
|
+
drain = fs.Drain()
|
|
827
|
+
drain.install(lambda drained: server.shutdown(),
|
|
828
|
+
on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
|
|
829
|
+
with drain.work(): # raises fs.Stopping once a stop has begun
|
|
830
|
+
...
|
|
831
|
+
"""
|
|
832
|
+
|
|
833
|
+
def __init__(self, deadline: float = DRAIN_SECONDS, grace: float = DRAIN_GRACE_SECONDS):
|
|
834
|
+
self.deadline = deadline
|
|
835
|
+
self.grace = grace
|
|
836
|
+
self._cond = threading.Condition()
|
|
837
|
+
self._inflight = 0
|
|
838
|
+
self._stopping = False
|
|
839
|
+
|
|
840
|
+
@property
|
|
841
|
+
def stopping(self) -> bool:
|
|
842
|
+
return self._stopping
|
|
843
|
+
|
|
844
|
+
@property
|
|
845
|
+
def inflight(self) -> int:
|
|
846
|
+
return self._inflight
|
|
847
|
+
|
|
848
|
+
@contextlib.contextmanager
|
|
849
|
+
def work(self):
|
|
850
|
+
with self._cond:
|
|
851
|
+
if self._stopping:
|
|
852
|
+
raise Stopping("The service is stopping and takes no new work.")
|
|
853
|
+
self._inflight += 1
|
|
854
|
+
try:
|
|
855
|
+
yield
|
|
856
|
+
finally:
|
|
857
|
+
with self._cond:
|
|
858
|
+
self._inflight -= 1
|
|
859
|
+
self._cond.notify_all()
|
|
860
|
+
|
|
861
|
+
def request_stop(self) -> bool:
|
|
862
|
+
"""Refuse new work from now on; False when a stop had already begun."""
|
|
863
|
+
with self._cond:
|
|
864
|
+
if self._stopping:
|
|
865
|
+
return False
|
|
866
|
+
self._stopping = True
|
|
867
|
+
return True
|
|
868
|
+
|
|
869
|
+
def wait(self, timeout: Optional[float] = None) -> bool:
|
|
870
|
+
"""Wait for in-flight work to finish; True when it did inside ``timeout``."""
|
|
871
|
+
end = None if timeout is None else time.monotonic() + timeout
|
|
872
|
+
with self._cond:
|
|
873
|
+
while self._inflight > 0:
|
|
874
|
+
left = None if end is None else end - time.monotonic()
|
|
875
|
+
if left is not None and left <= 0:
|
|
876
|
+
return False
|
|
877
|
+
self._cond.wait(left)
|
|
878
|
+
return True
|
|
879
|
+
|
|
880
|
+
def install(self, on_stop: Callable[[bool], None], *, on_stopping: Optional[Callable[[], None]] = None,
|
|
881
|
+
signals: Sequence[int] = (signal.SIGTERM, signal.SIGINT)) -> "Drain":
|
|
882
|
+
"""Install the handlers (main thread only). A second signal changes nothing: the hard-exit
|
|
883
|
+
timer already holds the deadline."""
|
|
884
|
+
def handler(_signum: int, _frame: Any) -> None:
|
|
885
|
+
if self.request_stop():
|
|
886
|
+
threading.Thread(target=self._stop, args=(on_stopping, on_stop), daemon=True).start()
|
|
887
|
+
|
|
888
|
+
for sig in signals:
|
|
889
|
+
signal.signal(sig, handler)
|
|
890
|
+
return self
|
|
891
|
+
|
|
892
|
+
def _stop(self, on_stopping: Optional[Callable[[], None]], on_stop: Callable[[bool], None]) -> None:
|
|
893
|
+
hard = threading.Timer(self.deadline + self.grace, os._exit, args=(EXIT_HARD_STOP,))
|
|
894
|
+
hard.daemon = True
|
|
895
|
+
hard.start()
|
|
896
|
+
if on_stopping is not None:
|
|
897
|
+
try:
|
|
898
|
+
on_stopping()
|
|
899
|
+
except Exception: # a failing stop notice must not stop the drain
|
|
900
|
+
traceback.print_exc()
|
|
901
|
+
on_stop(self.wait(self.deadline))
|
|
902
|
+
|
|
903
|
+
|
|
536
904
|
# --- operator login ----------------------------------------------------------
|
|
537
905
|
|
|
538
906
|
class LoginCodes:
|
|
@@ -710,13 +1078,18 @@ def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Op
|
|
|
710
1078
|
|
|
711
1079
|
def launchd_plist(label: str, program_arguments: Sequence[str], *, working_directory: Path,
|
|
712
1080
|
stdout_path: Path, environment: Optional[Dict[str, str]] = None,
|
|
713
|
-
exit_timeout: int =
|
|
714
|
-
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
|
|
1081
|
+
exit_timeout: int = DEFAULT_EXIT_TIMEOUT) -> bytes:
|
|
1082
|
+
"""RunAtLoad + KeepAlive true + ThrottleInterval 10; no secret may appear in `environment`.
|
|
1083
|
+
|
|
1084
|
+
``ExitTimeOut`` (15 s) sits above the drain and its hard exit (``Drain``: 8 + 2 s), so launchd's
|
|
1085
|
+
SIGKILL never arrives first. ``FABRIC_SERVICE_SUPERVISOR=launchd`` tells the lock to back off
|
|
1086
|
+
instead of exiting into a respawn loop."""
|
|
715
1087
|
env = dict(environment or {})
|
|
716
1088
|
for key in env:
|
|
717
1089
|
if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
|
|
718
1090
|
raise ServiceError("Environment variable %s looks like a secret; pass a *_FILE path instead." % key)
|
|
719
1091
|
env.setdefault("PATH", "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
|
1092
|
+
env[SUPERVISOR_ENV] = "launchd"
|
|
720
1093
|
plist = {
|
|
721
1094
|
"Label": label,
|
|
722
1095
|
"ProgramArguments": list(program_arguments),
|
|
@@ -756,17 +1129,47 @@ def fetch_well_known(origin: str, timeout: float = 2.0) -> Optional[Dict[str, An
|
|
|
756
1129
|
return None
|
|
757
1130
|
|
|
758
1131
|
|
|
1132
|
+
_OVERRIDE_ROW = re.compile(r'^\s*"([^"]+)"\s*=>\s*(\S+)\s*$')
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def launchd_override(label: str) -> Optional[str]:
|
|
1136
|
+
"""The operator's launchd override for ``label``: ``"disabled"``, ``"enabled"``, None when
|
|
1137
|
+
none is recorded (a first install), or ``"unknown"`` when the table cannot be read.
|
|
1138
|
+
|
|
1139
|
+
Reads ``launchctl print-disabled gui/<uid>``; macOS 14+ prints ``=> disabled|enabled``,
|
|
1140
|
+
older releases ``=> true|false`` (true means disabled)."""
|
|
1141
|
+
result = _launchctl("print-disabled", _domain())
|
|
1142
|
+
if result.returncode != 0:
|
|
1143
|
+
return "unknown"
|
|
1144
|
+
for line in result.stdout.splitlines():
|
|
1145
|
+
match = _OVERRIDE_ROW.match(line)
|
|
1146
|
+
if match and match.group(1) == label:
|
|
1147
|
+
return "disabled" if match.group(2) in ("disabled", "true") else "enabled"
|
|
1148
|
+
return None
|
|
1149
|
+
|
|
1150
|
+
|
|
759
1151
|
def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin: str,
|
|
760
|
-
service_id: str, instance: str = "default", timeout: float = 40.0
|
|
1152
|
+
service_id: str, instance: str = "default", timeout: float = 40.0,
|
|
1153
|
+
force_enable: bool = False) -> Dict[str, Any]:
|
|
761
1154
|
"""Write + lint the plist, bootout and WAIT for unload, bootstrap (retry EIO 5), then
|
|
762
|
-
poll the well-known document until it answers with this identity.
|
|
1155
|
+
poll the well-known document until it answers with this identity.
|
|
1156
|
+
|
|
1157
|
+
The operator's intent wins (LC-14): only a first install (no override recorded) enables the
|
|
1158
|
+
label. A label the operator disabled stays disabled and unloaded — the plist is still
|
|
1159
|
+
rewritten, so the new release is what starts once they turn it back on — and the answer is
|
|
1160
|
+
``{"disabled": True, "label", "plist", "loaded"}`` instead of the well-known document.
|
|
1161
|
+
``force_enable`` is for an operator who asked for the service to be switched on."""
|
|
763
1162
|
plist_path = Path(plist_path)
|
|
764
1163
|
atomic_write(plist_path, plist_bytes, 0o644)
|
|
765
1164
|
lint = subprocess.run(["plutil", "-lint", str(plist_path)], capture_output=True, text=True)
|
|
766
1165
|
if lint.returncode != 0:
|
|
767
1166
|
raise ServiceError("plist failed plutil -lint: %s" % lint.stdout.strip())
|
|
768
1167
|
target = "%s/%s" % (_domain(), label)
|
|
769
|
-
|
|
1168
|
+
override = launchd_override(label)
|
|
1169
|
+
if force_enable or override is None:
|
|
1170
|
+
_launchctl("enable", target)
|
|
1171
|
+
elif override == "disabled":
|
|
1172
|
+
return {"disabled": True, "label": label, "plist": str(plist_path), "loaded": launchd_loaded(label)}
|
|
770
1173
|
_launchctl("bootout", target)
|
|
771
1174
|
deadline = time.time() + 15
|
|
772
1175
|
while launchd_loaded(label) and time.time() < deadline:
|
|
@@ -789,12 +1192,66 @@ def launchd_install(label: str, plist_path: Path, plist_bytes: bytes, *, origin:
|
|
|
789
1192
|
raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
|
|
790
1193
|
|
|
791
1194
|
|
|
792
|
-
def launchd_uninstall(label: str, plist_path: Path
|
|
793
|
-
|
|
1195
|
+
def launchd_uninstall(label: str, plist_path: Path, *, timeout: float = 30.0, purge: bool = False,
|
|
1196
|
+
service_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1197
|
+
"""Symmetric with ``launchd_install`` (LC-14): bootout and WAIT until the job is gone, remove
|
|
1198
|
+
the plist, and reset the override to ``enabled`` — launchctl has no verb that deletes an
|
|
1199
|
+
override, and a leftover ``disabled`` would make a later fresh install stay off. Data stays
|
|
1200
|
+
unless ``purge`` (then ``service_id`` names whose data, logs and cache go). A job still
|
|
1201
|
+
loaded at ``timeout`` raises, leaving the plist and data in place."""
|
|
1202
|
+
if purge and not service_id:
|
|
1203
|
+
raise ServiceError("purge needs the service id whose data, logs and cache to remove.")
|
|
1204
|
+
target = "%s/%s" % (_domain(), label)
|
|
1205
|
+
_launchctl("bootout", target)
|
|
1206
|
+
deadline = time.monotonic() + timeout
|
|
1207
|
+
while launchd_loaded(label):
|
|
1208
|
+
if time.monotonic() >= deadline:
|
|
1209
|
+
raise ServiceError("launchd job %s is still loaded %.0f s after bootout; nothing was removed." % (label, timeout))
|
|
1210
|
+
time.sleep(0.1)
|
|
794
1211
|
try:
|
|
795
1212
|
Path(plist_path).unlink()
|
|
1213
|
+
plist_removed = True
|
|
796
1214
|
except FileNotFoundError:
|
|
797
|
-
|
|
1215
|
+
plist_removed = False
|
|
1216
|
+
_launchctl("enable", target)
|
|
1217
|
+
purged: List[str] = []
|
|
1218
|
+
if purge:
|
|
1219
|
+
for directory in service_dirs(str(service_id)).values():
|
|
1220
|
+
if directory.is_symlink():
|
|
1221
|
+
directory.unlink()
|
|
1222
|
+
elif directory.exists():
|
|
1223
|
+
shutil.rmtree(directory)
|
|
1224
|
+
else:
|
|
1225
|
+
continue
|
|
1226
|
+
purged.append(str(directory))
|
|
1227
|
+
return {"unloaded": True, "plistRemoved": plist_removed, "override": "enabled", "purged": purged}
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def prune_releases(releases_dir: Path, keep: int = 2, *, current: Optional[Path] = None) -> List[Path]:
|
|
1231
|
+
"""Keep the release the job runs and the one before it (LC-11 rollback, LC-15); remove
|
|
1232
|
+
older release directories, newest first by modification time. ``current`` (a release or a
|
|
1233
|
+
symlink to one) is never removed, even after a rollback to an older release. Symlinks and
|
|
1234
|
+
files in ``releases_dir`` are left alone. Returns what was removed."""
|
|
1235
|
+
if keep < 2:
|
|
1236
|
+
raise ServiceError("keep at least the current release and the one before it (keep >= 2).")
|
|
1237
|
+
root = Path(releases_dir)
|
|
1238
|
+
if not root.is_dir():
|
|
1239
|
+
return []
|
|
1240
|
+
releases = [p for p in root.iterdir() if p.is_dir() and not p.is_symlink()]
|
|
1241
|
+
releases.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
1242
|
+
keepers: List[Path] = []
|
|
1243
|
+
if current is not None:
|
|
1244
|
+
resolved = Path(current).resolve()
|
|
1245
|
+
keepers.extend(p for p in releases if p.resolve() == resolved)
|
|
1246
|
+
for release in releases:
|
|
1247
|
+
if len(keepers) >= keep:
|
|
1248
|
+
break
|
|
1249
|
+
if release not in keepers:
|
|
1250
|
+
keepers.append(release)
|
|
1251
|
+
removed = [p for p in releases if p not in keepers]
|
|
1252
|
+
for release in removed:
|
|
1253
|
+
shutil.rmtree(release)
|
|
1254
|
+
return removed
|
|
798
1255
|
|
|
799
1256
|
|
|
800
1257
|
# --- helpers for input validation -----------------------------------------------
|