@passioncode-ai/passioncode 0.1.29 → 0.1.30

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 (26) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/family.json +2 -2
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +3 -3
  5. package/payload/manifest.json +12 -12
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +9 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
  20. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
  21. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +1 -1
  22. package/payload/plugins/observatory-log/hooks/session-start.sh +25 -11
  23. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +1 -1
  24. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +2 -2
  25. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +1 -1
  26. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -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 -----------------------------------------------
@@ -20,9 +20,7 @@ import json
20
20
  import os
21
21
  from http.server import BaseHTTPRequestHandler
22
22
  from pathlib import Path
23
- import signal
24
23
  import sys
25
- import threading
26
24
  from typing import Any, Dict, Optional
27
25
  from urllib.parse import parse_qs, urlparse
28
26
 
@@ -66,12 +64,15 @@ class Service:
66
64
  self.degraded = [{"source": "demo", "reason": args.degraded}] if args.degraded else []
67
65
  self.started_at = fs.now_iso()
68
66
  self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
67
+ self.drain = fs.Drain() # LC-01: SIGTERM drains in-flight calls, then the server stops
69
68
 
70
69
  def start(self) -> None:
71
70
  # Rule: the lock comes before ANY side effect, including creating the token.
72
71
  self.lock = fs.hold_single_instance(self.data)
73
72
  self.token = fs.ensure_token(self.token_file)
74
73
  self.log = fs.JsonlEventLog(self.data / "events.jsonl")
74
+ self.usage = fs.JsonlUsageLedger(self.data / "usage.jsonl") # DEC-0021
75
+ self.usage.prune()
75
76
  self.codes = fs.LoginCodes(self.data / "auth")
76
77
  self.jobs = fi.JobStore(self.data / "jobs")
77
78
  self.mcp = fi.McpToolServer(self.id, VERSION, jobs=self.jobs, on_job_started=self.draft_started, on_input=self.draft_answered)
@@ -117,6 +118,8 @@ class Service:
117
118
  "binding": {"id": "urn:fabric:binding:sample.draft", "revision": 1, "contentHash": "sha256:" + "0" * 64}, "writeScopes": []},
118
119
  not_verified=[{"claim": "NOTE", "reason": "no checker has read the note"}],
119
120
  output={"title": title, "body": body}, usage={"inputTokens": 0, "outputTokens": 0, "wallMs": 1}))
121
+ # The sample writes from a template: one call that costs a known $0 (DEC-0021 usage report).
122
+ self.usage.record(fs.make_usage_receipt("local", "sample-template", input_tokens=0, output_tokens=0, cost_usd=0.0, cost_basis="price-list"))
120
123
  self.log.append("job.completed", "info", "The note %s is drafted." % title, **fi.trace_ids(ctx.traceparent))
121
124
 
122
125
  def well_known(self) -> Dict[str, Any]:
@@ -125,7 +128,8 @@ class Service:
125
128
  started_at=self.started_at, status="ready", degraded=self.degraded,
126
129
  summary=[{"label": "Events", "value": len(self.log.fetch(None, fs.EVENTS_MAX_LIMIT))}],
127
130
  surfaces={"dashboard": {"path": "/", "login": True}, "events": {"path": "/fabric/v1/events"},
128
- "mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]}},
131
+ "mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]},
132
+ "usage": {"path": fs.USAGE_PATH}},
129
133
  )
130
134
 
131
135
 
@@ -187,6 +191,10 @@ def make_handler(svc: Service):
187
191
  except fs.ServiceError as exc:
188
192
  return self._send(400, {"error": str(exc)})
189
193
  return self._send(200, page)
194
+ if url.path == fs.USAGE_PATH:
195
+ if not self._token_ok():
196
+ return
197
+ return self._send(200, svc.usage.report(service_id=svc.id, instance=svc.instance))
190
198
  if url.path == "/mcp":
191
199
  # No server-to-client stream here: Streamable HTTP says 405 for a GET.
192
200
  return self._send(405, {"error": "POST JSON-RPC to /mcp."}, {"Allow": "POST"})
@@ -216,7 +224,14 @@ def make_handler(svc: Service):
216
224
  message = json.loads(self.rfile.read(length) or b"null")
217
225
  except ValueError:
218
226
  return self._send(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error."}})
219
- response = svc.mcp.handle(message)
227
+ try:
228
+ with svc.drain.work():
229
+ response = svc.mcp.handle(message, self.headers)
230
+ except fs.Stopping as exc:
231
+ return self._send(503, {"jsonrpc": "2.0", "id": None, "error": {"code": -32000, "message": str(exc)}},
232
+ {"Retry-After": "5"})
233
+ if response is not None and (response.get("error") or {}).get("code") == fi.HEADER_MISMATCH:
234
+ return self._send(400, response)
220
235
  return self._send(202) if response is None else self._send(200, response)
221
236
  if url.path == "/fabric/v1/login-code":
222
237
  if not self._token_ok():
@@ -247,12 +262,10 @@ def serve(args: argparse.Namespace) -> int:
247
262
  svc.start()
248
263
  server = make_server(svc)
249
264
 
250
- def stop(signum: int, _frame: Any) -> None:
251
- svc.log.append("service.stopping", "info", "%s is stopping." % svc.name)
252
- threading.Thread(target=server.shutdown, daemon=True).start()
253
-
254
- signal.signal(signal.SIGTERM, stop)
255
- signal.signal(signal.SIGINT, stop)
265
+ # Stop taking calls, let in-flight ones finish (8 s at most), then end serve_forever; a
266
+ # hand-over that hangs is cut by the kit's hard exit, inside launchd's ExitTimeOut.
267
+ svc.drain.install(lambda drained: server.shutdown(),
268
+ on_stopping=lambda: svc.log.append("service.stopping", "info", "%s is stopping." % svc.name))
256
269
  try:
257
270
  server.serve_forever(poll_interval=0.2)
258
271
  finally: