@passioncode-ai/fabric-agent-adapter 0.82.0 → 0.83.1

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 (114) hide show
  1. package/CHANGELOG.md +122 -3
  2. package/README.md +6 -3
  3. package/SECURITY.md +4 -3
  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 +7 -2
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
  97. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
  98. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
  99. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
  100. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
  101. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
  102. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
  103. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
  104. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
  105. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  106. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  107. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
  108. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
  109. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
  110. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
@@ -0,0 +1,647 @@
1
+ """A service's operations digests (contract DEC-0153, `docs/specification/ops-digest.md` at the pin): every record of its
2
+ operations log counted by operation and outcome in the hour it happened, one `ops.digest.hourly@1` line for each hour
3
+ the service observed itself running in (an empty one included), and one `ops.digest.daily@1` line for each closed UTC
4
+ day that has hourly lines. Counts only: no log line, no path, no identifier (OD-7). `fold_hour` and `fold_day` are the
5
+ contract's reference folds (`src/ops-digest.ts`, its vectors in test/fixtures/ops-digest/); `ops_digest_rules` is
6
+ FAC-SEM-166 and FAC-SEM-167; `OpsDigest` keeps the files (OD-1, OD-5, OD-6): it writes an hour when it closes, keeps the
7
+ hour it stops in for the next run, never fills hours it did not observe, and folds every closed day once, catch-up
8
+ included. `ops_digest_extension` writes the descriptor's OD-9 declaration. Part of the service kit (FAA-206 part 2,
9
+ layer L3). Standard library only."""
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import threading
16
+ import time
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
19
+
20
+ import sys as _sys # noqa: E402
21
+ from pathlib import Path as _Path # noqa: E402
22
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
23
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
24
+
25
+ from fabric_base import ServiceError, _O_BINARY # noqa: E402
26
+ from fabric_jsvalue import indexed_objects # noqa: E402
27
+ from fabric_paths import atomic_write, ensure_private_dir # noqa: E402
28
+ from fabric_secrets import JS_SPACE, secret_value # noqa: E402
29
+ from fabric_time import DAY_MS, days_from_civil # noqa: E402
30
+
31
+ OPS_DIGEST_HOURLY = "ops.digest.hourly@1"
32
+ OPS_DIGEST_DAILY = "ops.digest.daily@1"
33
+ #: The descriptor's extension key (OD-9), protocol `fabric-ops-digest/0.1`.
34
+ OPS_DIGEST_EXTENSION = "https://fabric.passioncode.ai/agent-contract/extensions/ops-digest/0.1"
35
+ #: A failure count is a rise when it is at least this many in the hour (OD-4)...
36
+ RISE_MIN_FAILED = 10
37
+ #: ...and at least this many times the hour before's (an hour before with none, or none recorded, counts as one).
38
+ RISE_FACTOR = 2
39
+ #: An operation's name (OD-2), the contract's `OP_NAME`, and its length cap `OP_NAME_MAX`.
40
+ OP_NAME = re.compile(r"[a-z][A-Za-z0-9_]*(?:[.:/-][A-Za-z0-9_]+)*\Z", re.ASCII)
41
+ OP_NAME_MAX = 64
42
+ #: At most this many operations in a digest's `ops` (OD-3): 4095 named a day plus the one overflow name.
43
+ OPS_MAX = 4096
44
+ #: The overflow name the kit counts the rest under once a day holds 4095 named operations (OD-3); a service that
45
+ #: changes it publishes its own with its outcome map.
46
+ OVERFLOW_OP = "ops.overflow"
47
+ OUTCOMES = ("ok", "failed", "unknown")
48
+ MAX_COUNT = 2 ** 53 - 1
49
+ HOUR_MS = 3600000
50
+
51
+ _WHOLE_HOUR = re.compile(r"\A([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):00:00(?:\.000)?Z\Z")
52
+ _DAY = re.compile(r"\A([0-9]{4})-([0-9]{2})-([0-9]{2})\Z")
53
+ _SLASH = r"(?:[/\\]|%(?:25)?2[fF])"
54
+ #: The contract's `hasHomePath` (src/activity-rules.ts at the pin), JavaScript's \s read as JavaScript reads it.
55
+ HOME_PATH = (
56
+ re.compile(r"(?:\A|[^A-Za-z0-9]|%(?:25)?2[fF])(?:[Uu]sers|home)" + _SLASH + r"[A-Za-z0-9_]"),
57
+ re.compile(r"(?:\A|[^A-Za-z0-9])-(?:[Uu]sers|home)-[A-Za-z0-9]"),
58
+ re.compile("(?:\\A|[" + JS_SPACE + "\"'=/\\\\])~(?:[A-Za-z_][A-Za-z0-9_.-]*)?(?:[/\\\\]|\\Z)"),
59
+ )
60
+ _PRINTABLE = re.compile(r"\A[\x20-\x7e]+\Z")
61
+ _SOURCE = re.compile(r"\A[a-z][a-z0-9-]{0,31}\Z")
62
+ _FILE = re.compile(r"\A(hourly|daily)-([0-9]{4})-([0-9]{2})\.jsonl\Z")
63
+
64
+
65
+ class OpsDigestError(ServiceError):
66
+ """An input no digest holds; its message names a credential-shaped or home-folder value only by its place."""
67
+
68
+
69
+ def has_home_path(value: str) -> bool:
70
+ """Whether `value` holds a home folder, as the contract's `hasHomePath` reads it (FAC-SEM-167, FAC-SEM-168)."""
71
+ return isinstance(value, str) and any(p.search(value) for p in HOME_PATH)
72
+
73
+
74
+ def is_rise(failed: int, previous: int) -> bool:
75
+ """Whether `failed` failures in an hour are a rise against `previous` in the hour before (OD-4)."""
76
+ return failed >= RISE_MIN_FAILED and failed >= RISE_FACTOR * max(1, previous)
77
+
78
+
79
+ def _civil_from_days(z: int) -> Tuple[int, int, int]:
80
+ z += 719468
81
+ era = (z if z >= 0 else z - 146096) // 146097
82
+ doe = z - era * 146097
83
+ yoe = (doe - doe // 1460 + doe // 36524 - doe // 146096) // 365
84
+ doy = doe - (365 * yoe + yoe // 4 - yoe // 100)
85
+ mp = (5 * doy + 2) // 153
86
+ d = doy - (153 * mp + 2) // 5 + 1
87
+ m = mp + (3 if mp < 10 else -9)
88
+ return yoe + era * 400 + (m <= 2), m, d
89
+
90
+
91
+ def _month_days(y: int, m: int) -> int:
92
+ if m == 2:
93
+ return 29 if (y % 4 == 0 and (y % 100 != 0 or y % 400 == 0)) else 28
94
+ return 30 if m in (4, 6, 9, 11) else 31
95
+
96
+
97
+ def whole_hour(value: Any) -> Optional[int]:
98
+ """The instant (ms since the epoch) of a whole UTC hour in the schema's form, or None (a 25th hour, a 30 February)."""
99
+ match = _WHOLE_HOUR.match(value) if isinstance(value, str) else None
100
+ if not match:
101
+ return None
102
+ y, m, d, h = (int(g) for g in match.groups())
103
+ if not (1 <= m <= 12 and 1 <= d <= _month_days(y, m) and h <= 23):
104
+ return None
105
+ return (days_from_civil(y, m, d) * 24 + h) * HOUR_MS
106
+
107
+
108
+ def real_day(value: Any) -> bool:
109
+ """Whether `value` is a real `YYYY-MM-DD` day."""
110
+ return isinstance(value, str) and bool(_DAY.match(value)) and whole_hour(value + "T00:00:00Z") is not None
111
+
112
+
113
+ def iso_hour(at: int) -> str:
114
+ """A whole hour as `Date.prototype.toISOString` writes it (years 0 to 9999)."""
115
+ y, m, d = _civil_from_days(at // DAY_MS)
116
+ return "%04d-%02d-%02dT%02d:00:00.000Z" % (y, m, d, (at % DAY_MS) // HOUR_MS)
117
+
118
+
119
+ def day_of(at: int) -> str:
120
+ """The UTC day of an instant, `YYYY-MM-DD`."""
121
+ return iso_hour(at)[:10]
122
+
123
+
124
+ def _count(n: Any) -> Optional[int]:
125
+ """A count as JavaScript's Number.isSafeInteger reads a JSON number: an integer from 0 to 2^53 - 1 (an integral
126
+ float included, a boolean never), or None."""
127
+ if isinstance(n, bool):
128
+ return None
129
+ if isinstance(n, float):
130
+ if not n.is_integer():
131
+ return None
132
+ n = int(n)
133
+ return n if isinstance(n, int) and 0 <= n <= MAX_COUNT else None
134
+
135
+
136
+ def _hidden(value: str) -> bool:
137
+ return secret_value(value) or has_home_path(value)
138
+
139
+
140
+ def _shown(value: str, instead: str) -> str:
141
+ return instead if _hidden(value) or not _PRINTABLE.match(value) else value
142
+
143
+
144
+ def _op_shown(op: str) -> str:
145
+ return _shown(op, "this operation") if len(op) <= OP_NAME_MAX and OP_NAME.match(op) else "this operation"
146
+
147
+
148
+ def name_refusal(op: Any) -> Optional[str]:
149
+ """What OD-2 refuses in an operation's name (its grammar, OP_NAME_MAX included, or FAC-SEM-167); None when lawful."""
150
+ if not isinstance(op, str) or len(op) > OP_NAME_MAX or not OP_NAME.match(op):
151
+ return "fails OD-2's name grammar"
152
+ return "is refused by FAC-SEM-167" if _hidden(op) else None
153
+
154
+
155
+ def _objects(items: Any, where: str) -> None:
156
+ if not isinstance(items, list):
157
+ raise OpsDigestError("%s is not an array" % where)
158
+ for i, item in enumerate(items):
159
+ if not isinstance(item, dict):
160
+ raise OpsDigestError("the item at %d of %s is not an object" % (i, where))
161
+
162
+
163
+ def _once(ops: Sequence[Dict[str, Any]], where: str) -> None:
164
+ names = set()
165
+ for i, c in enumerate(ops):
166
+ if not isinstance(c.get("op"), str):
167
+ raise OpsDigestError("the operation at %d of %s has no string name" % (i, where))
168
+ if c["op"] in names:
169
+ raise OpsDigestError("%s is named twice in %s" % (_shown(c["op"], "the operation at %d" % i), where))
170
+ names.add(c["op"])
171
+
172
+
173
+ def _sum(ops: Sequence[Dict[str, Any]]) -> Dict[str, int]:
174
+ return {k: sum(c[k] for c in ops) for k in OUTCOMES}
175
+
176
+
177
+ def _safe(digest: Dict[str, Any]) -> Dict[str, Any]:
178
+ counts = [digest["total"][k] for k in OUTCOMES] + [c[k] for c in digest["ops"] for k in OUTCOMES]
179
+ for r in digest["risen"]:
180
+ counts += [r["failed"], r["previous"]] if "previous" in r else [r["failed"]]
181
+ if any(n > MAX_COUNT for n in counts):
182
+ raise OpsDigestError("a sum is beyond 2^53 - 1")
183
+ return digest
184
+
185
+
186
+ def fold_hour(counts: Sequence[Dict[str, Any]], from_: str, previous: Optional[Dict[str, Any]]) -> Dict[str, Any]:
187
+ """The digest of the hour starting at `from_`: `counts` ({op, ok, failed, unknown}, an op may repeat and is
188
+ summed) by operation, sorted by name, and each operation whose failures rose against `previous`, which counts
189
+ only when it is the hour just before (a gap compares with none). Raises OpsDigestError where the contract's
190
+ `foldHour` throws: no hourly digest is written then (OD-3)."""
191
+ start = whole_hour(from_)
192
+ if start is None:
193
+ raise OpsDigestError("%s is not a whole UTC hour" % (_shown(from_, "from") if isinstance(from_, str) else "from"))
194
+ _objects(counts, "counts")
195
+ if _civil_from_days((start + HOUR_MS) // DAY_MS)[0] > 9999:
196
+ raise OpsDigestError("the hour after %s is not a whole UTC hour the schema writes" % _shown(from_, "from"))
197
+ merged: Dict[str, Dict[str, Any]] = {}
198
+ for i, c in enumerate(counts):
199
+ refused = name_refusal(c.get("op"))
200
+ if refused is not None:
201
+ raise OpsDigestError("the operation at %d %s: no hourly digest (OD-3)" % (i, refused))
202
+ values = [_count(c.get(k)) for k in OUTCOMES]
203
+ if None in values:
204
+ raise OpsDigestError("a count of %s is not an integer from 0 to 2^53 - 1" % _shown(c["op"], "the operation at %d" % i))
205
+ m = merged.setdefault(c["op"], {"op": c["op"], "ok": 0, "failed": 0, "unknown": 0})
206
+ for k, n in zip(OUTCOMES, values):
207
+ m[k] += n
208
+ if len(merged) > OPS_MAX:
209
+ raise OpsDigestError("the hour counts %d operations, more than %d: no hourly digest (OD-3)" % (len(merged), OPS_MAX))
210
+ ops = [merged[k] for k in sorted(merged)]
211
+ absent = previous is None
212
+ if not absent and not (isinstance(previous, dict) and isinstance(previous.get("to"), str)):
213
+ raise OpsDigestError("the previous hour is neither absent nor a digest with a string to")
214
+ adjoining = previous["ops"] if not absent and whole_hour(previous["to"]) == start else []
215
+ _objects(adjoining, "the previous hour's ops")
216
+ _once(adjoining, "the previous hour's ops")
217
+ before = {c["op"]: c.get("failed") for c in adjoining}
218
+ for c in ops:
219
+ if c["op"] in before and _count(before[c["op"]]) is None:
220
+ raise OpsDigestError("the previous hour's failures of %s are not an integer from 0 to 2^53 - 1" % _shown(c["op"], "an operation"))
221
+ risen = [{"op": c["op"], "failed": c["failed"], "previous": _count(before.get(c["op"], 0))}
222
+ for c in ops if is_rise(c["failed"], _count(before.get(c["op"], 0)))]
223
+ return _safe({"schema": OPS_DIGEST_HOURLY, "from": iso_hour(start), "to": iso_hour(start + HOUR_MS), "ops": ops,
224
+ "total": _sum(ops), "risen": risen})
225
+
226
+
227
+ def fold_day(day: str, hourly: Sequence[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
228
+ """The day's hourly digests (those whose hour starts on `day`, the first of a repeated hour kept) folded into one,
229
+ or None for a day with no hour of its own (OD-5). Deciding the day is closed is the caller's. Raises
230
+ OpsDigestError where the contract's `foldDay` throws."""
231
+ if not real_day(day):
232
+ raise OpsDigestError("%s is not a real UTC day" % (_shown(day, "day") if isinstance(day, str) else "day"))
233
+ _objects(hourly, "hourly")
234
+ seen, mine = set(), []
235
+ for h in hourly:
236
+ at = whole_hour(h.get("from"))
237
+ if at is None or day_of(at) != day or at in seen:
238
+ continue
239
+ seen.add(at)
240
+ mine.append(h)
241
+ if not mine:
242
+ return None
243
+ merged: Dict[str, Dict[str, Any]] = {}
244
+ rose: Dict[str, Dict[str, int]] = {}
245
+ for h in mine:
246
+ for where in ("ops", "risen"):
247
+ items = h.get(where)
248
+ _objects(items, "%s's %s" % (h["from"], where))
249
+ for i, c in enumerate(items):
250
+ refused = name_refusal(c.get("op"))
251
+ if refused is not None:
252
+ raise OpsDigestError("the operation at %d of %s's %s %s: no daily digest (OD-3)" % (i, h["from"], where, refused))
253
+ for c in h["ops"]:
254
+ values = [_count(c.get(k)) for k in OUTCOMES]
255
+ if None in values:
256
+ raise OpsDigestError("a count of %s in %s is not an integer from 0 to 2^53 - 1" % (_shown(c["op"], "an operation"), h["from"]))
257
+ m = merged.setdefault(c["op"], {"op": c["op"], "ok": 0, "failed": 0, "unknown": 0})
258
+ for k, n in zip(OUTCOMES, values):
259
+ m[k] += n
260
+ _once(h["risen"], "the risen of %s" % h["from"])
261
+ for r in h["risen"]:
262
+ failed = _count(r.get("failed"))
263
+ if failed is None:
264
+ raise OpsDigestError("the rise of %s in %s is not an integer from 0 to 2^53 - 1" % (_shown(r["op"], "an operation"), h["from"]))
265
+ if not any(c.get("op") == r["op"] for c in h["ops"]):
266
+ raise OpsDigestError("%s rose in %s but that hour's ops does not count it" % (_shown(r["op"], "an operation"), h["from"]))
267
+ x = rose.setdefault(r["op"], {"hours": 0, "failed": 0})
268
+ x["hours"] += 1
269
+ x["failed"] += failed
270
+ if len(merged) > OPS_MAX:
271
+ raise OpsDigestError("the day folds %d operations, more than %d: no daily digest (OD-3)" % (len(merged), OPS_MAX))
272
+ ops = [merged[k] for k in sorted(merged)]
273
+ risen = sorted(({"op": op, "hours": x["hours"], "failed": x["failed"]} for op, x in rose.items()),
274
+ key=lambda r: (-r["failed"], r["op"]))
275
+ return _safe({"schema": OPS_DIGEST_DAILY, "day": day, "hours": len(mine), "ops": ops, "total": _sum(ops),
276
+ "risen": risen})
277
+
278
+
279
+ def ops_digest_rules(kind: str, value: Dict[str, Any]) -> Optional[List[Dict[str, str]]]:
280
+ """FAC-SEM-166 and FAC-SEM-167 on one digest of either kind ({code, instancePath, message} each, as the contract's
281
+ `opsDigestRules` words them); None for any other kind. It assumes the schema already passed."""
282
+ if kind not in ("ops-digest-hourly", "ops-digest-daily"):
283
+ return None
284
+ findings: List[Dict[str, str]] = []
285
+
286
+ def window(path: str, message: str) -> None:
287
+ findings.append({"code": "FAC-SEM-166", "instancePath": path, "message": message})
288
+
289
+ def named(path: str, op: str) -> None:
290
+ if secret_value(op):
291
+ findings.append({"code": "FAC-SEM-167", "instancePath": path, "message": "an operation's name is shaped like a credential; it names an operation, never a value"})
292
+ elif has_home_path(op):
293
+ findings.append({"code": "FAC-SEM-167", "instancePath": path, "message": "an operation's name holds a home folder; it names an operation, never a path"})
294
+
295
+ if kind == "ops-digest-hourly":
296
+ start, end = whole_hour(value.get("from")), whole_hour(value.get("to"))
297
+ if start is None:
298
+ window("/from", "from is a whole UTC hour of a real day")
299
+ if end is None:
300
+ window("/to", "to is a whole UTC hour of a real day")
301
+ if start is not None and end is not None and end - start != HOUR_MS:
302
+ window("/to", "to is exactly one hour after from")
303
+ elif not real_day(value.get("day")):
304
+ window("/day", "day is a real UTC day, YYYY-MM-DD")
305
+
306
+ by_op: Dict[str, Dict[str, Any]] = {}
307
+ totals: Dict[str, Optional[int]] = {"ok": 0, "failed": 0, "unknown": 0}
308
+ for entry, index in indexed_objects(value.get("ops")):
309
+ op = entry.get("op")
310
+ if isinstance(op, str):
311
+ if op in by_op:
312
+ window("/ops/%d/op" % index, "%s is counted once" % _op_shown(op))
313
+ else:
314
+ by_op[op] = entry
315
+ named("/ops/%d/op" % index, op)
316
+ for k in OUTCOMES:
317
+ n = _count(entry.get(k))
318
+ totals[k] = None if n is None or totals[k] is None else totals[k] + n
319
+ total = value.get("total") if isinstance(value.get("total"), dict) else None
320
+ if total is not None and isinstance(value.get("ops"), list):
321
+ for k in OUTCOMES:
322
+ want = totals[k]
323
+ if want is None or _count(total.get(k)) is None:
324
+ continue
325
+ if want > MAX_COUNT:
326
+ window("/total/%s" % k, "the sum of ops' %s is beyond 2^53 - 1, so no total.%s can be it" % (k, k))
327
+ elif _count(total.get(k)) != want:
328
+ window("/total/%s" % k, "total.%s is the sum of ops' %s, %d" % (k, k, want))
329
+
330
+ rose = set()
331
+ hours = _count(value.get("hours"))
332
+ for entry, index in indexed_objects(value.get("risen")):
333
+ op = entry.get("op")
334
+ if not isinstance(op, str):
335
+ continue
336
+ at = "/risen/%d" % index
337
+ named(at + "/op", op)
338
+ if op in rose:
339
+ window(at + "/op", "%s is named once among the rises" % _op_shown(op))
340
+ rose.add(op)
341
+ counted = by_op.get(op)
342
+ if counted is None:
343
+ window(at + "/op", "%s rose, so ops counts it" % _op_shown(op))
344
+ continue
345
+ failed, counted_failed = _count(entry.get("failed")), _count(counted.get("failed"))
346
+ if kind == "ops-digest-hourly":
347
+ previous = _count(entry.get("previous"))
348
+ if failed is not None and counted_failed is not None and failed != counted_failed:
349
+ window(at + "/failed", "a rise names the hour's failures of %s, %d" % (_op_shown(op), counted_failed))
350
+ if failed is not None and previous is not None and not is_rise(failed, previous):
351
+ window(at, "%d failures against %d is no rise: at least %d and %d times the hour before (none counts as one)"
352
+ % (failed, previous, RISE_MIN_FAILED, RISE_FACTOR))
353
+ else:
354
+ risen_hours = _count(entry.get("hours"))
355
+ if risen_hours is not None and hours is not None and risen_hours > hours:
356
+ window(at + "/hours", "%s rose in %d hours; the day folds %d" % (_op_shown(op), risen_hours, hours))
357
+ if failed is not None and risen_hours is not None and failed < RISE_MIN_FAILED * risen_hours:
358
+ window(at + "/failed", "%d hours of rises hold at least %d failures" % (risen_hours, RISE_MIN_FAILED * risen_hours))
359
+ if failed is not None and counted_failed is not None and failed > counted_failed:
360
+ window(at + "/failed", "the rises' failures are at most the day's failures of %s, %d" % (_op_shown(op), counted_failed))
361
+ return findings
362
+
363
+
364
+ _HOURLY_KEYS = ("schema", "from", "to", "ops", "total", "risen")
365
+ _DAILY_KEYS = ("schema", "day", "hours", "ops", "total", "risen")
366
+
367
+
368
+ def _shape_ok(line: Any, daily: bool) -> bool:
369
+ """A closed-schema check of the members the kit reads back (OD-6: a reader skips a line that fails the schema)."""
370
+ keys = _DAILY_KEYS if daily else _HOURLY_KEYS
371
+ if not isinstance(line, dict) or sorted(line) != sorted(keys):
372
+ return False
373
+ if line["schema"] != (OPS_DIGEST_DAILY if daily else OPS_DIGEST_HOURLY):
374
+ return False
375
+ if not isinstance(line["ops"], list) or not isinstance(line["risen"], list) or not isinstance(line["total"], dict):
376
+ return False
377
+ if len(line["ops"]) > OPS_MAX or len(line["risen"]) > OPS_MAX:
378
+ return False
379
+ for c in line["ops"]:
380
+ if not (isinstance(c, dict) and sorted(c) == ["failed", "ok", "op", "unknown"] and name_refusal(c["op"]) is None
381
+ and all(_count(c[k]) is not None for k in OUTCOMES)):
382
+ return False
383
+ want = ["failed", "hours", "op"] if daily else ["failed", "op", "previous"]
384
+ for r in line["risen"]:
385
+ if not (isinstance(r, dict) and sorted(r) == want and name_refusal(r["op"]) is None
386
+ and all(_count(r[k]) is not None for k in want if k != "op")):
387
+ return False
388
+ return sorted(line["total"]) == ["failed", "ok", "unknown"] and all(_count(line["total"][k]) is not None for k in OUTCOMES)
389
+
390
+
391
+ def read_digests(folder: Path, kind: str) -> List[Dict[str, Any]]:
392
+ """The digests of `kind` (`hourly` or `daily`) in `folder`, as a reader reads them (OD-6): line by line, oldest
393
+ month first, a line that is not JSON, names another schema or fails the schema or a rule skipped, never stopping
394
+ at one; of two lines for one hour (or one day) the first kept."""
395
+ daily = kind == "daily"
396
+ out, seen = [], set()
397
+ files = sorted(p for p in Path(folder).glob("%s-*.jsonl" % kind) if _FILE.match(p.name)) if Path(folder).is_dir() else []
398
+ for path in files:
399
+ try:
400
+ text = path.read_bytes().decode("utf-8", "replace")
401
+ except OSError:
402
+ continue
403
+ for raw in text.split("\n"):
404
+ try:
405
+ line = json.loads(raw)
406
+ except ValueError:
407
+ continue
408
+ if not _shape_ok(line, daily) or ops_digest_rules("ops-digest-daily" if daily else "ops-digest-hourly", line):
409
+ continue
410
+ key = line["day"] if daily else whole_hour(line["from"])
411
+ if key in seen:
412
+ continue
413
+ seen.add(key)
414
+ out.append(line)
415
+ return out
416
+
417
+
418
+ def _line(digest: Dict[str, Any]) -> bytes:
419
+ return (json.dumps(digest, separators=(",", ":"), ensure_ascii=False) + "\n").encode("utf-8")
420
+
421
+
422
+ def _append(path: Path, data: bytes) -> None:
423
+ """One whole line appended in one write, the file 0600 (OD-6)."""
424
+ fd = os.open(str(path), os.O_WRONLY | os.O_APPEND | os.O_CREAT | _O_BINARY, 0o600)
425
+ try:
426
+ os.write(fd, data)
427
+ finally:
428
+ os.close(fd)
429
+
430
+
431
+ class OpsDigest:
432
+ """The files of one service's operations digests in `folder` (a folder inside its data folder, 0700): count each
433
+ record with `record(op, outcome)`, say the service is running with `tick()` (an hour with a tick and no record is
434
+ written empty), and call `flush()` now and then and `stop()` at the end. `start()` first: it writes every closed
435
+ hour the last run kept, the hour it stopped in included, then folds each closed UTC day that has hourly lines and
436
+ no daily line (catch-up, OD-5). An hour the service did not observe is never written (OD-1). `clock` returns
437
+ milliseconds since the epoch. Thread-safe."""
438
+
439
+ def __init__(self, folder: Path, *, overflow: str = OVERFLOW_OP, clock=None):
440
+ if name_refusal(overflow) is not None:
441
+ raise OpsDigestError("the overflow name %s" % name_refusal(overflow))
442
+ self.folder = Path(folder)
443
+ self.overflow = overflow
444
+ self._clock = clock or (lambda: int(time.time() * 1000))
445
+ self._lock = threading.RLock()
446
+ self._hour: Optional[int] = None
447
+ self._counts: Dict[str, List[int]] = {}
448
+ self._names: set = set()
449
+ self._names_day: Optional[str] = None
450
+ self._started = False
451
+
452
+ @property
453
+ def _kept(self) -> Path:
454
+ return self.folder / "kept-hour.json"
455
+
456
+ def _now(self, at: Optional[int]) -> int:
457
+ return int(self._clock() if at is None else at)
458
+
459
+ def start(self, at: Optional[int] = None) -> Dict[str, int]:
460
+ """Write the closed hour the last run kept, then fold every closed day (OD-1, OD-5); the counts of {hours,
461
+ days} written. A kept hour that is still open is resumed."""
462
+ with self._lock:
463
+ ensure_private_dir(self.folder)
464
+ now, written = self._now(at), {"hours": 0, "days": 0}
465
+ kept = self._read_kept()
466
+ if kept is not None:
467
+ start, counts = kept
468
+ if start + HOUR_MS <= now:
469
+ written["hours"] += self._write_hour(start, counts)
470
+ self._drop_kept()
471
+ else:
472
+ self._hour, self._counts = start, counts
473
+ if self._hour is None:
474
+ self._hour, self._counts = now - now % HOUR_MS, {} # starting is observing itself run (OD-1)
475
+ self._rebuild_names(now)
476
+ written["days"] += self._fold_closed_days(now)
477
+ self._started = True
478
+ return written
479
+
480
+ def record(self, op: str, outcome: str, at: Optional[int] = None) -> None:
481
+ """Count one record of `op` with `outcome` (`ok`, `failed`, any other word `unknown`, OD-3) in the hour it
482
+ happened. A name OD-2 refuses raises OpsDigestError: name the operation, never a value of it."""
483
+ refused = name_refusal(op)
484
+ if refused is not None:
485
+ raise OpsDigestError("the operation's name %s (OD-2): name the operation, never a value of it" % refused)
486
+ with self._lock:
487
+ now = self._advance(self._now(at))
488
+ if op not in self._names and op != self.overflow:
489
+ if len(self._names) >= OPS_MAX - 1:
490
+ op = self.overflow
491
+ else:
492
+ self._names.add(op)
493
+ self._names_day = day_of(now)
494
+ row = self._counts.setdefault(op, [0, 0, 0])
495
+ row[OUTCOMES.index(outcome) if outcome in ("ok", "failed") else 2] += 1
496
+
497
+ def tick(self, at: Optional[int] = None) -> None:
498
+ """The service observed itself running now: this hour is written, empty if nothing was recorded (OD-1)."""
499
+ with self._lock:
500
+ self._advance(self._now(at))
501
+
502
+ def flush(self, at: Optional[int] = None) -> Dict[str, int]:
503
+ """Write the hour that closed, fold a day that closed, and keep the open hour's counts on disk."""
504
+ with self._lock:
505
+ now = self._now(at)
506
+ written = {"hours": 0, "days": 0}
507
+ if self._hour is not None and self._hour + HOUR_MS <= now:
508
+ written["hours"] += self._write_hour(self._hour, self._counts)
509
+ self._hour, self._counts = None, {}
510
+ self._drop_kept()
511
+ written["days"] += self._fold_closed_days(now)
512
+ self._keep()
513
+ return written
514
+
515
+ def stop(self, at: Optional[int] = None) -> Dict[str, int]:
516
+ """`flush`, keeping the open hour with its counts for the next run, which writes it when it has closed."""
517
+ return self.flush(at)
518
+
519
+ def _advance(self, now: int) -> int:
520
+ if not self._started:
521
+ raise OpsDigestError("call start() before recording (OD-1: the kept hour is written first)")
522
+ hour = now - now % HOUR_MS
523
+ if self._hour is not None and hour < self._hour:
524
+ return self._hour # a clock that went back counts in the open hour, never rewrites a closed one
525
+ if self._hour is not None and hour > self._hour:
526
+ self._write_hour(self._hour, self._counts)
527
+ self._counts = {}
528
+ self._fold_closed_days(now)
529
+ if day_of(hour) != self._names_day:
530
+ self._names, self._names_day = set(), day_of(hour)
531
+ self._hour = hour
532
+ return now
533
+
534
+ def _previous(self, start: int) -> Optional[Dict[str, Any]]:
535
+ before = start - HOUR_MS
536
+ for line in reversed(read_digests(self.folder, "hourly")):
537
+ if whole_hour(line["from"]) == before:
538
+ return line
539
+ return None
540
+
541
+ def _write_hour(self, start: int, counts: Dict[str, List[int]]) -> int:
542
+ ensure_private_dir(self.folder)
543
+ frm = iso_hour(start)
544
+ if any(whole_hour(line["from"]) == start for line in read_digests(self.folder, "hourly")):
545
+ return 0 # readers keep the first line of an hour (OD-5): a second one is never written
546
+ rows = [{"op": op, "ok": c[0], "failed": c[1], "unknown": c[2]} for op, c in counts.items()]
547
+ digest = fold_hour(rows, frm, self._previous(start))
548
+ _append(self.folder / ("hourly-%s.jsonl" % frm[:7]), _line(digest))
549
+ return 1
550
+
551
+ def _fold_closed_days(self, now: int) -> int:
552
+ hourly = read_digests(self.folder, "hourly")
553
+ done = {line["day"] for line in read_digests(self.folder, "daily")}
554
+ today, folded = day_of(now), 0
555
+ for day in sorted({line["from"][:10] for line in hourly}):
556
+ if day >= today or day in done:
557
+ continue
558
+ digest = fold_day(day, hourly)
559
+ if digest is not None:
560
+ _append(self.folder / ("daily-%s.jsonl" % day[:7]), _line(digest))
561
+ folded += 1
562
+ return folded
563
+
564
+ def _rebuild_names(self, now: int) -> None:
565
+ """OD-1: the day's named operations from that day's hourly lines and the kept hour, so a restart never names
566
+ more than OD-3 allows."""
567
+ today = day_of(now)
568
+ names = {c["op"] for line in read_digests(self.folder, "hourly") if line["from"][:10] == today for c in line["ops"]}
569
+ if self._hour is not None and day_of(self._hour) == today:
570
+ names |= set(self._counts)
571
+ names.discard(self.overflow)
572
+ self._names, self._names_day = names, today
573
+
574
+ def _keep(self) -> None:
575
+ if self._hour is None:
576
+ return
577
+ ensure_private_dir(self.folder)
578
+ body = {"from": iso_hour(self._hour), "ops": [[op] + c for op, c in sorted(self._counts.items())]}
579
+ atomic_write(self._kept, (json.dumps(body, separators=(",", ":")) + "\n").encode("utf-8"))
580
+
581
+ def _read_kept(self) -> Optional[Tuple[int, Dict[str, List[int]]]]:
582
+ try:
583
+ body = json.loads(self._kept.read_text(encoding="utf-8"))
584
+ start = whole_hour(body.get("from"))
585
+ counts = {row[0]: [int(row[1]), int(row[2]), int(row[3])] for row in body.get("ops", [])
586
+ if isinstance(row, list) and len(row) == 4 and name_refusal(row[0]) is None
587
+ and all(_count(n) is not None for n in row[1:])}
588
+ except (OSError, ValueError, AttributeError, TypeError):
589
+ return None # an unreadable kept hour is not written: never a partial line (OD-1)
590
+ return (start, counts) if start is not None else None
591
+
592
+ def _drop_kept(self) -> None:
593
+ try:
594
+ self._kept.unlink()
595
+ except OSError:
596
+ pass
597
+
598
+
599
+ def outcome_of(word: Any, mapping: Optional[Dict[str, str]] = None) -> str:
600
+ """A log's own outcome word as OD-3 maps it: `ok` and `failed` themselves, a word of the service's published
601
+ `mapping` to its outcome, any other `unknown` (a setup answer, a refusal, a skip, a missing slot)."""
602
+ if word in ("ok", "failed"):
603
+ return word
604
+ mapped = (mapping or {}).get(word) if isinstance(word, str) else None
605
+ return mapped if mapped in OUTCOMES else "unknown"
606
+
607
+
608
+ def _dir_problem(d: Any) -> Optional[str]:
609
+ if not isinstance(d, str) or not d or len(d) > 256:
610
+ return "dir is 1 to 256 characters"
611
+ if d.startswith(("/", "~")) or d.endswith("/") or "//" in d or "\\" in d or ":" in d:
612
+ return "dir is relative to the data folder: forward slashes, no leading / or ~, no drive or :, no trailing /"
613
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7f for ch in d) or re.search("[\ud800-\udfff]", d):
614
+ return "dir holds no control character and no lone surrogate"
615
+ if any(seg in (".", "..") or not seg.rstrip(". ") for seg in d.split("/")):
616
+ return "dir has no ., .. or empty segment"
617
+ if has_home_path(d) or has_home_path(d.replace("./", "/")):
618
+ return "dir holds a home folder"
619
+ if secret_value(d):
620
+ return "dir is shaped like a credential (a run of 40 letters, digits and / mixing cases and a digit counts; "\
621
+ "a space or a dot inside a segment breaks it)"
622
+ return None
623
+
624
+
625
+ def ops_digest_extension(folders: Iterable[Dict[str, str]] = ({"dir": "digests", "source": "app"},)) -> Dict[str, Any]:
626
+ """The descriptor's `extensions` member that says where a collector reads the daily digests (OD-9): 1 to 8
627
+ {dir, source}, `dir` inside the data folder. Only a local descriptor that names `paths.data` carries it. Checks
628
+ the grammar, home folders and credential shapes; the contract's FAC-SEM-168 (its caseless fold of `dir` among
629
+ them) stays the authority."""
630
+ items = [dict(f) for f in folders]
631
+ if not 1 <= len(items) <= 8:
632
+ raise OpsDigestError("the ops-digest declaration holds 1 to 8 folders")
633
+ dirs, sources = set(), set()
634
+ for i, item in enumerate(items):
635
+ if sorted(item) != ["dir", "source"]:
636
+ raise OpsDigestError("folder %d is {dir, source} and nothing else" % i)
637
+ problem = _dir_problem(item["dir"])
638
+ if problem:
639
+ raise OpsDigestError("folder %d: %s (FAC-SEM-168)" % (i, problem))
640
+ if not isinstance(item["source"], str) or not _SOURCE.match(item["source"]) or secret_value(item["source"]):
641
+ raise OpsDigestError("folder %d: source is a label matching ^[a-z][a-z0-9-]{0,31}$" % i)
642
+ key = "/".join(seg.rstrip(". ") for seg in item["dir"].lower().split("/"))
643
+ if key in dirs or item["source"] in sources:
644
+ raise OpsDigestError("folder %d: each dir and each source is named once (FAC-SEM-168)" % i)
645
+ dirs.add(key)
646
+ sources.add(item["source"])
647
+ return {OPS_DIGEST_EXTENSION: items}