@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.
- package/CHANGELOG.md +122 -3
- package/README.md +6 -3
- package/SECURITY.md +4 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +7 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +16 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +31 -15
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +21 -148
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check_cli.py +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/README.md +76 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/config.example.json +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/data-agent/data_agent.py +366 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +32 -17
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +4 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support-agent.mjs +115 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +63 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/a2a.md +98 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +20 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/ops-digest.md +75 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +7 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +25 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +10 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +70 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_a2a.py +158 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a-server.mjs +274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-a2a.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +54 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +45 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +16 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-connections.mjs +26 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +18 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +6 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +21 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +3 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +20 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +24 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ops-digest.mjs +455 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +17 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +18 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +55 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +15 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-socket-proof.mjs +180 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +5 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-tailnet-identity.mjs +468 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_a2a_server.py +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +16 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +97 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +35 -48
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +15 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +27 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +18 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +23 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +31 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +18 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +9 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +55 -96
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +15 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +43 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py +647 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +16 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +20 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +93 -93
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +37 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_socket_proof.py +390 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +3 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_tailnet_identity.py +492 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +365 -53
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample-remote-service.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +16 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/tailnet-groups.schema.json +69 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +5 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +16 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-that-answer-people.md +14 -9
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +18 -4
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +2 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/admit.py +162 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +14 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +7 -4
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +10 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +2 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +16 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +79 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +147 -45
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ops_digest.py
ADDED
|
@@ -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}
|