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