@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.
Files changed (19) hide show
  1. package/CHANGELOG.md +104 -1
  2. package/README.md +28 -5
  3. package/bin/fabric-agent-adapter.js +18 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +9 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
@@ -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"]
@@ -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 hold_single_instance(data_dir: Path) -> InstanceLock:
184
- """Take the lock or exit 75 with one sentence naming the holder."""
185
- try:
186
- return InstanceLock(data_dir).acquire()
187
- except AlreadyRunning as exc:
188
- print(str(exc), file=sys.stderr)
189
- raise SystemExit(EXIT_ALREADY_RUNNING)
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 = 40) -> bytes:
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) -> Dict[str, Any]:
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
- _launchctl("enable", target)
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) -> None:
793
- _launchctl("bootout", "%s/%s" % (_domain(), label))
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
- pass
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 -----------------------------------------------