@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Settings backups (DEC-0025, FAC-SEM-035): the canonical form, the digest, writing, listing, checking and
|
|
2
|
+
restoring them. Part of the service kit (FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import sys as _sys # noqa: E402
|
|
16
|
+
from pathlib import Path as _Path # noqa: E402
|
|
17
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
18
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
19
|
+
from fabric_base import (
|
|
20
|
+
ServiceError,
|
|
21
|
+
_ID,
|
|
22
|
+
_INSTANCE,
|
|
23
|
+
_require_id,
|
|
24
|
+
)
|
|
25
|
+
from fabric_paths import (
|
|
26
|
+
atomic_write,
|
|
27
|
+
ensure_private_dir,
|
|
28
|
+
)
|
|
29
|
+
from fabric_usage import (
|
|
30
|
+
_is_timestamp,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
SETTINGS_BACKUP_FORMAT = "fabric-settings-backup/1"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SETTINGS_BACKUP_KEEP = 14
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SETTINGS_BACKUP_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_STALE_TEMP_SECONDS = 60 * 60
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_SAFE_INTEGER = 2 ** 53 - 1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_TABLE = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SECRET_TABLE = re.compile(r"^((.+_)?(account_connections?|account_connection_drafts?|tokens?|secrets?|credentials?|passwords?|login_codes?)|(operator_)?sessions?)$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_COLUMN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_SECRET_COLUMN = re.compile(r"^(?:.+_)?(?:[Tt]oken|[Ss]ecret|[Pp]assword|[Pp]assphrase|[Aa]pi_?[Kk]ey|[Pp]rivate_[Kk]ey|[Cc]ookie|[Ll]ogin_[Cc]ode|[Oo]tp|[Cc]redential)s?$")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_REASON = re.compile(r"^[a-z][a-z0-9_.-]{0,63}$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:\S*")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_MAX_CELL_CHARS = 1048576
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_MAX_ROW_VALUES = 512
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _canonical_check(value: Any, at: str = "") -> None:
|
|
80
|
+
if value is None or isinstance(value, bool):
|
|
81
|
+
return
|
|
82
|
+
if isinstance(value, str):
|
|
83
|
+
# CT-25 (DEC-0052): Python cannot encode a lone surrogate as UTF-8, so no two languages agree on its bytes.
|
|
84
|
+
if _SURROGATE.search(value):
|
|
85
|
+
raise ServiceError("%s: a lone surrogate has no canonical form" % (at or "/"))
|
|
86
|
+
return
|
|
87
|
+
if isinstance(value, int):
|
|
88
|
+
if abs(value) > _SAFE_INTEGER:
|
|
89
|
+
raise ServiceError("%s: %d is outside ±(2^53 − 1) and has no canonical form; store it as a string" % (at or "/", value))
|
|
90
|
+
return
|
|
91
|
+
if isinstance(value, float):
|
|
92
|
+
raise ServiceError("%s: a fraction has no canonical form; a settings backup stores it as its decimal string" % (at or "/"))
|
|
93
|
+
if isinstance(value, list):
|
|
94
|
+
for i, item in enumerate(value):
|
|
95
|
+
_canonical_check(item, "%s/%d" % (at, i))
|
|
96
|
+
return
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
for key, item in value.items():
|
|
99
|
+
if not isinstance(key, str):
|
|
100
|
+
raise ServiceError("%s: object keys are strings" % (at or "/"))
|
|
101
|
+
_canonical_check(key, "%s (a key)" % (at or "/"))
|
|
102
|
+
_canonical_check(item, "%s/%s" % (at, key))
|
|
103
|
+
return
|
|
104
|
+
raise ServiceError("%s: %s is not a JSON value" % (at or "/", type(value).__name__))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def settings_canonical_json(value: Any) -> str:
|
|
108
|
+
"""The settings canonical form (DEC-0025, DEC-0043): keys sorted by code point at every level, ", " and ": ",
|
|
109
|
+
strings escaped as JSON requires and nothing else, integers in decimal — Python's
|
|
110
|
+
``json.dumps(value, sort_keys=True, ensure_ascii=False)``. Refuses a fraction, an integer outside ±(2^53 − 1)
|
|
111
|
+
and anything that is not JSON."""
|
|
112
|
+
_canonical_check(value)
|
|
113
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def settings_backup_digest(tables: Any) -> str:
|
|
117
|
+
"""The ``sha256`` a settings backup carries: SHA-256 of the UTF-8 canonical JSON of ``tables``."""
|
|
118
|
+
return hashlib.sha256(settings_canonical_json(tables).encode("utf-8")).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def settings_backup_problems(doc: Any) -> List[str]:
|
|
122
|
+
"""Why a reader refuses this settings backup: its shape (``settings-backup.schema.json``) and FAC-SEM-035 —
|
|
123
|
+
the checksum is the digest of the tables, counts name exactly the tables and their rows, every row is as wide as
|
|
124
|
+
its columns, and every value has a canonical form. Empty means intact."""
|
|
125
|
+
if not isinstance(doc, dict):
|
|
126
|
+
return ["a settings backup is a JSON object"]
|
|
127
|
+
problems: List[str] = []
|
|
128
|
+
if doc.get("format") != SETTINGS_BACKUP_FORMAT:
|
|
129
|
+
problems.append("format must be %s, not %r: a reader refuses a format it does not know" % (SETTINGS_BACKUP_FORMAT, doc.get("format")))
|
|
130
|
+
for key in ("format", "service", "created_at", "reason", "sha256", "counts", "tables"):
|
|
131
|
+
if key not in doc:
|
|
132
|
+
problems.append("missing %s" % key)
|
|
133
|
+
for key in doc:
|
|
134
|
+
if key not in ("format", "service", "created_at", "reason", "sha256", "counts", "tables", "extensions"):
|
|
135
|
+
problems.append("%s is not a settings backup field" % key)
|
|
136
|
+
if "extensions" in doc:
|
|
137
|
+
if not isinstance(doc["extensions"], dict):
|
|
138
|
+
problems.append("extensions is an object")
|
|
139
|
+
else:
|
|
140
|
+
for key in doc["extensions"]:
|
|
141
|
+
if not _URI.fullmatch(key):
|
|
142
|
+
problems.append("extensions key %r is not a URI" % key)
|
|
143
|
+
service = doc.get("service")
|
|
144
|
+
if "service" in doc:
|
|
145
|
+
if not isinstance(service, dict) or set(service) != {"id", "instance", "version", "schema"}:
|
|
146
|
+
problems.append("service is {id, instance, version, schema}")
|
|
147
|
+
else:
|
|
148
|
+
if not _ID.fullmatch(str(service["id"])):
|
|
149
|
+
problems.append("service.id must match %s" % _ID.pattern)
|
|
150
|
+
if not _INSTANCE.fullmatch(str(service["instance"])):
|
|
151
|
+
problems.append("service.instance must match %s" % _INSTANCE.pattern)
|
|
152
|
+
if not (isinstance(service["version"], str) and len(service["version"]) <= 64):
|
|
153
|
+
problems.append("service.version is a string of at most 64 characters")
|
|
154
|
+
schema = service["schema"]
|
|
155
|
+
if not ((isinstance(schema, int) and not isinstance(schema, bool) and schema >= 0)
|
|
156
|
+
or (isinstance(schema, str) and 1 <= len(schema) <= 64)):
|
|
157
|
+
problems.append("service.schema is a whole number or a short string")
|
|
158
|
+
if "created_at" in doc and not _is_timestamp(doc["created_at"]):
|
|
159
|
+
problems.append("created_at is a UTC timestamp")
|
|
160
|
+
if "reason" in doc and not (isinstance(doc["reason"], str) and _REASON.fullmatch(doc["reason"])):
|
|
161
|
+
problems.append("reason must match %s" % _REASON.pattern)
|
|
162
|
+
if "sha256" in doc and not (isinstance(doc["sha256"], str) and _SHA256.fullmatch(doc["sha256"])):
|
|
163
|
+
problems.append("sha256 is 64 lowercase hex digits")
|
|
164
|
+
tables = doc.get("tables") if isinstance(doc.get("tables"), dict) else {}
|
|
165
|
+
counts = doc.get("counts") if isinstance(doc.get("counts"), dict) else {}
|
|
166
|
+
if "tables" in doc and not isinstance(doc.get("tables"), dict):
|
|
167
|
+
problems.append("tables is an object")
|
|
168
|
+
if "counts" in doc and not isinstance(doc.get("counts"), dict):
|
|
169
|
+
problems.append("counts is an object")
|
|
170
|
+
for name in list(tables) + [n for n in counts if n not in tables]:
|
|
171
|
+
if not _TABLE.fullmatch(name) or _SECRET_TABLE.fullmatch(name):
|
|
172
|
+
problems.append("table %s is never backed up: it can only hold connections, tokens, secrets, credentials, "
|
|
173
|
+
"passwords, login codes or sessions (or its name is not a table name)" % name)
|
|
174
|
+
for name, count in counts.items():
|
|
175
|
+
if not (isinstance(count, int) and not isinstance(count, bool) and count >= 0):
|
|
176
|
+
problems.append("counts.%s is a whole number" % name)
|
|
177
|
+
canonical = True
|
|
178
|
+
for name, table in tables.items():
|
|
179
|
+
if not isinstance(table, dict) or set(table) != {"columns", "rows"}:
|
|
180
|
+
problems.append("tables.%s is {columns, rows}" % name)
|
|
181
|
+
continue
|
|
182
|
+
columns, rows = table["columns"], table["rows"]
|
|
183
|
+
if not (isinstance(columns, list) and 1 <= len(columns) <= 512 and len(set(map(str, columns))) == len(columns)):
|
|
184
|
+
problems.append("tables.%s.columns is 1 to 512 distinct names" % name)
|
|
185
|
+
columns = columns if isinstance(columns, list) else []
|
|
186
|
+
for column in columns:
|
|
187
|
+
if not (isinstance(column, str) and _COLUMN.fullmatch(column)) or _SECRET_COLUMN.fullmatch(str(column)):
|
|
188
|
+
problems.append("column %s.%s is refused: its name says it holds a credential value (name the secret, "
|
|
189
|
+
"never store it: secret_name, token_slot)" % (name, column))
|
|
190
|
+
if not isinstance(rows, list):
|
|
191
|
+
problems.append("tables.%s.rows is a list" % name)
|
|
192
|
+
continue
|
|
193
|
+
width = len(columns)
|
|
194
|
+
for i, row in enumerate(rows):
|
|
195
|
+
at = "/tables/%s/rows/%d" % (name, i)
|
|
196
|
+
if not isinstance(row, list):
|
|
197
|
+
problems.append("%s is a list of values" % at)
|
|
198
|
+
continue
|
|
199
|
+
if len(row) != width:
|
|
200
|
+
problems.append("FAC-SEM-035 %s: row has %d values for %d columns" % (at, len(row), width))
|
|
201
|
+
if len(row) > _MAX_ROW_VALUES:
|
|
202
|
+
problems.append("%s holds more than %d values" % (at, _MAX_ROW_VALUES))
|
|
203
|
+
for j, cell in enumerate(row):
|
|
204
|
+
if isinstance(cell, float) or (isinstance(cell, int) and not isinstance(cell, bool) and abs(cell) > _SAFE_INTEGER):
|
|
205
|
+
canonical = False
|
|
206
|
+
problems.append("FAC-SEM-035 %s/%d: a fraction or an unsafe integer has no canonical form; store it as a string" % (at, j))
|
|
207
|
+
elif isinstance(cell, str) and _SURROGATE.search(cell):
|
|
208
|
+
canonical = False
|
|
209
|
+
problems.append("FAC-SEM-035 %s/%d: a string with a lone surrogate cannot be encoded as UTF-8" % (at, j))
|
|
210
|
+
elif isinstance(cell, str) and len(cell) > _MAX_CELL_CHARS:
|
|
211
|
+
problems.append("%s/%d is longer than %d characters" % (at, j, _MAX_CELL_CHARS))
|
|
212
|
+
elif not (cell is None or isinstance(cell, (bool, int, str))):
|
|
213
|
+
canonical = False
|
|
214
|
+
problems.append("%s/%d is not a JSON scalar" % (at, j))
|
|
215
|
+
if counts.get(name) != len(rows):
|
|
216
|
+
problems.append("FAC-SEM-035 /counts/%s: counts says %s for %d rows" % (name, counts.get(name, "nothing"), len(rows)))
|
|
217
|
+
for name in counts:
|
|
218
|
+
if name not in tables:
|
|
219
|
+
problems.append("FAC-SEM-035 /counts/%s: counts names %s, which the backup does not carry" % (name, name))
|
|
220
|
+
if canonical and isinstance(doc.get("tables"), dict):
|
|
221
|
+
try:
|
|
222
|
+
digest: Optional[str] = settings_backup_digest(tables)
|
|
223
|
+
except (ServiceError, UnicodeError, TypeError, ValueError) as exc: # a name no canonical form can carry
|
|
224
|
+
digest = None
|
|
225
|
+
problems.append("FAC-SEM-035 /tables: the tables have no canonical form (%s)" % exc)
|
|
226
|
+
if digest is not None and doc.get("sha256") != digest:
|
|
227
|
+
problems.append("FAC-SEM-035 /sha256: the checksum does not match the tables: the file is damaged and a reader refuses it")
|
|
228
|
+
return problems
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def settings_backup_root(platform: str = sys.platform) -> Path:
|
|
232
|
+
"""Where every service keeps its settings backups — outside its data directory (DEC-0025)."""
|
|
233
|
+
if platform == "darwin":
|
|
234
|
+
return Path.home() / "Library/Application Support/PassionCode"
|
|
235
|
+
if platform == "win32":
|
|
236
|
+
appdata = os.environ.get("APPDATA")
|
|
237
|
+
return (Path(appdata) if appdata else Path.home() / "AppData" / "Roaming") / "PassionCode"
|
|
238
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "PassionCode"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def settings_backup_dir(service_id: str, instance: str = "default", root: Optional[Path] = None) -> Path:
|
|
242
|
+
_require_id(service_id)
|
|
243
|
+
if not (isinstance(instance, str) and _INSTANCE.fullmatch(instance)):
|
|
244
|
+
raise ServiceError("instance must match %s" % _INSTANCE.pattern)
|
|
245
|
+
return (root or settings_backup_root()) / "settings-backups" / service_id / instance
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def make_settings_backup(*, service_id: str, instance: str = "default", version: str, schema: Any,
|
|
249
|
+
tables: Dict[str, Dict[str, Any]], reason: str, now: Optional[_dt.datetime] = None) -> Dict[str, Any]:
|
|
250
|
+
"""A snapshot of the operator's decisions and bindings: the tables on the service's allow-list, each
|
|
251
|
+
``{"columns": [...], "rows": [[...], ...]}`` of JSON scalars (a fraction as its decimal string). Refuses a file a
|
|
252
|
+
reader would refuse."""
|
|
253
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
254
|
+
doc = {"format": SETTINGS_BACKUP_FORMAT,
|
|
255
|
+
"service": {"id": service_id, "instance": instance, "version": version, "schema": schema},
|
|
256
|
+
"created_at": now.astimezone(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), "reason": reason,
|
|
257
|
+
"sha256": "", "counts": {name: len(table.get("rows") or []) for name, table in tables.items()}, "tables": tables}
|
|
258
|
+
try:
|
|
259
|
+
doc["sha256"] = settings_backup_digest(tables)
|
|
260
|
+
except ServiceError as exc:
|
|
261
|
+
raise ServiceError("this snapshot has no canonical form: %s" % exc) from None
|
|
262
|
+
problems = settings_backup_problems(doc)
|
|
263
|
+
if problems:
|
|
264
|
+
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
265
|
+
return doc
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _backup_name(doc: Dict[str, Any]) -> str:
|
|
269
|
+
stamp = doc["created_at"].replace("-", "").replace(":", "")
|
|
270
|
+
head, _, frac = stamp.rstrip("Z").partition(".")
|
|
271
|
+
return "settings-%s%sZ.json" % (head, (frac + "000000")[:6])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def settings_backups(directory: Path) -> List[Path]:
|
|
275
|
+
"""The snapshot files, newest first (their names sort in time order)."""
|
|
276
|
+
directory = Path(directory)
|
|
277
|
+
if not directory.is_dir():
|
|
278
|
+
return []
|
|
279
|
+
return sorted((p for p in directory.iterdir() if re.fullmatch(r"settings-[0-9]{8}T[0-9]{12}Z\.json", p.name)), reverse=True)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _no_constant(name: str) -> Any:
|
|
283
|
+
raise ValueError("%s is not JSON" % name)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def read_settings_backup(path: Path) -> Dict[str, Any]:
|
|
287
|
+
"""A snapshot a reader may trust, or ServiceError naming why it is refused (FAC-SEM-035)."""
|
|
288
|
+
try:
|
|
289
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"), parse_constant=_no_constant)
|
|
290
|
+
except (OSError, ValueError) as exc:
|
|
291
|
+
raise ServiceError("settings backup %s cannot be read: %s" % (path, exc)) from None
|
|
292
|
+
problems = settings_backup_problems(doc)
|
|
293
|
+
if problems:
|
|
294
|
+
raise ServiceError("settings backup %s is refused: %s" % (path, "; ".join(problems)))
|
|
295
|
+
return doc
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def write_settings_backup(doc: Dict[str, Any], directory: Path, *, keep: int = SETTINGS_BACKUP_KEEP) -> Optional[Path]:
|
|
299
|
+
"""Write a snapshot made by ``make_settings_backup``: atomically, 0600 in a 0700 directory, named by its time.
|
|
300
|
+
A temporary file an interrupted write left is removed first; a snapshot whose checksum equals the newest file's
|
|
301
|
+
is not written again (None). After a write, all but the ``keep`` newest are removed (at least 14)."""
|
|
302
|
+
problems = settings_backup_problems(doc)
|
|
303
|
+
if problems:
|
|
304
|
+
raise ServiceError("this snapshot would be refused: " + "; ".join(problems))
|
|
305
|
+
directory = Path(directory)
|
|
306
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
307
|
+
ensure_private_dir(directory)
|
|
308
|
+
stale = time.time() - _STALE_TEMP_SECONDS
|
|
309
|
+
for left in directory.iterdir():
|
|
310
|
+
# Only a temporary file an interrupted write left: one a concurrent writer is still filling is younger.
|
|
311
|
+
try:
|
|
312
|
+
if left.name.startswith(".settings-") and left.stat().st_mtime < stale:
|
|
313
|
+
left.unlink()
|
|
314
|
+
except OSError:
|
|
315
|
+
pass
|
|
316
|
+
existing = settings_backups(directory)
|
|
317
|
+
if existing:
|
|
318
|
+
try:
|
|
319
|
+
if read_settings_backup(existing[0])["sha256"] == doc["sha256"]:
|
|
320
|
+
return None
|
|
321
|
+
except Exception: # noqa: BLE001 - a damaged newest file is no reason to skip a good snapshot
|
|
322
|
+
pass
|
|
323
|
+
path = directory / _backup_name(doc)
|
|
324
|
+
atomic_write(path, json.dumps(doc, ensure_ascii=False, indent=2).encode("utf-8") + b"\n", 0o600)
|
|
325
|
+
# The newest by name, but never the one just written: a clock that stepped back must not delete it.
|
|
326
|
+
for old in [p for p in settings_backups(directory) if p != path][max(keep, SETTINGS_BACKUP_KEEP) - 1:]:
|
|
327
|
+
old.unlink()
|
|
328
|
+
return path
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def backup_due(directory: Path, now: Optional[_dt.datetime] = None) -> bool:
|
|
332
|
+
"""True when there is no snapshot or the newest is older than 24 hours: take one at start or on the daily tick."""
|
|
333
|
+
newest = settings_backups(directory)
|
|
334
|
+
if not newest:
|
|
335
|
+
return True
|
|
336
|
+
try:
|
|
337
|
+
stamp = _dt.datetime.strptime(newest[0].name[len("settings-"):-len("Z.json")], "%Y%m%dT%H%M%S%f").replace(tzinfo=_dt.timezone.utc)
|
|
338
|
+
except ValueError:
|
|
339
|
+
return True # a name no calendar has: take a fresh snapshot rather than trust it
|
|
340
|
+
return ((now or _dt.datetime.now(_dt.timezone.utc)) - stamp).total_seconds() > SETTINGS_BACKUP_MAX_AGE_SECONDS
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def newest_intact_backup(directory: Path) -> Tuple[Optional[Path], Optional[Dict[str, Any]], List[Tuple[Path, str]]]:
|
|
344
|
+
"""The source of an automatic restore: the newest snapshot that is intact and has content. Damaged ones are
|
|
345
|
+
skipped and returned with the reason, for the restore report."""
|
|
346
|
+
skipped: List[Tuple[Path, str]] = []
|
|
347
|
+
for path in settings_backups(directory):
|
|
348
|
+
try:
|
|
349
|
+
doc = read_settings_backup(path)
|
|
350
|
+
except Exception as exc: # noqa: BLE001 - one damaged file never stops the scan
|
|
351
|
+
skipped.append((path, str(exc)))
|
|
352
|
+
continue
|
|
353
|
+
if any(doc["counts"].values()):
|
|
354
|
+
return path, doc, skipped
|
|
355
|
+
return None, None, skipped
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def restore_plan(doc: Dict[str, Any], current: Dict[str, Dict[str, Any]]) -> Dict[str, Any]:
|
|
359
|
+
"""What restoring ``doc`` would do — it adds and never overwrites (DEC-0025). ``current`` describes the database
|
|
360
|
+
now, per table: ``columns`` (all of them), ``key`` (the primary key columns), ``required`` (columns without a
|
|
361
|
+
default) and ``existing`` (the primary keys present, as tuples). Per table: the rows to insert (dicts over the
|
|
362
|
+
columns both sides have), how many are already present, the snapshot columns dropped, the current columns left
|
|
363
|
+
to their defaults, the required columns the snapshot lacks, and every row refused with its key and reason.
|
|
364
|
+
``unknownTables`` names snapshot tables the service no longer has. The service applies ``insert`` and adds any
|
|
365
|
+
row its database refuses to ``refused``; a manual restore shows this plan first and applying it is the operator's act."""
|
|
366
|
+
problems = settings_backup_problems(doc)
|
|
367
|
+
if problems:
|
|
368
|
+
raise ServiceError("a damaged snapshot is never restored: " + "; ".join(problems))
|
|
369
|
+
plan: Dict[str, Any] = {"source": {"created_at": doc["created_at"], "sha256": doc["sha256"]}, "tables": {}, "unknownTables": []}
|
|
370
|
+
for name, table in doc["tables"].items():
|
|
371
|
+
now_table = current.get(name)
|
|
372
|
+
if now_table is None:
|
|
373
|
+
plan["unknownTables"].append(name)
|
|
374
|
+
continue
|
|
375
|
+
columns = list(now_table.get("columns") or [])
|
|
376
|
+
key = list(now_table.get("key") or [])
|
|
377
|
+
required = list(now_table.get("required") or [])
|
|
378
|
+
existing = {tuple(k) for k in now_table.get("existing") or []}
|
|
379
|
+
shared = [c for c in table["columns"] if c in columns]
|
|
380
|
+
missing = [c for c in required if c not in table["columns"]]
|
|
381
|
+
entry = {"insert": [], "present": 0, "droppedColumns": [c for c in table["columns"] if c not in columns],
|
|
382
|
+
"defaultedColumns": [c for c in columns if c not in table["columns"]], "missingRequired": missing, "refused": []}
|
|
383
|
+
planned = set()
|
|
384
|
+
for row in table["rows"]:
|
|
385
|
+
values = dict(zip(table["columns"], row))
|
|
386
|
+
ident = [values.get(c) for c in key]
|
|
387
|
+
if any(c not in table["columns"] for c in key):
|
|
388
|
+
entry["refused"].append({"key": ident, "reason": "the primary key is not in the snapshot"})
|
|
389
|
+
elif tuple(ident) in planned:
|
|
390
|
+
entry["refused"].append({"key": ident, "reason": "the snapshot holds this key twice"})
|
|
391
|
+
elif tuple(ident) in existing:
|
|
392
|
+
entry["present"] += 1
|
|
393
|
+
elif missing:
|
|
394
|
+
entry["refused"].append({"key": ident, "reason": "required column %s is not in the snapshot" % ", ".join(missing)})
|
|
395
|
+
else:
|
|
396
|
+
planned.add(tuple(ident))
|
|
397
|
+
entry["insert"].append({c: values[c] for c in shared})
|
|
398
|
+
plan["tables"][name] = entry
|
|
399
|
+
return plan
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""What every module of the service kit shares (FAA-65, layer L3): the protocol name, ServiceError, the
|
|
2
|
+
platform, ids and now_iso. Standard library only."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _dt
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PROTOCOL = "fabric-service/0.1"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/service/0.1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
|
|
18
|
+
# contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
|
|
19
|
+
# off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
|
|
20
|
+
# that is a few starts an hour instead of one every 10 s.
|
|
21
|
+
SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# LC-01: SIGTERM reaches exit in at most 10 s with work in flight — drain 8 s, then hand over,
|
|
25
|
+
# and a hard exit 2 s later if the hand-over itself hangs. ExitTimeOut sits above both.
|
|
26
|
+
DRAIN_SECONDS = 8.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_ID = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_INSTANCE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# DEC-0032: a local path is POSIX (`/…`, `~/…`) or Windows (`C:\…`, `C:/…`, `~\…`); a network
|
|
36
|
+
# share (`\\server\…`, `\\?\…`) is never a local path.
|
|
37
|
+
# DEC-0057: `//server/…` and `/\\server…` are shares on Windows too, so a leading slash is followed by neither.
|
|
38
|
+
_LOCAL_PATH = re.compile(r"^(~[\\/]|/(?![\\/])|[A-Za-z]:[\\/])")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_UNIT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:_.@-]{0,250}\.service$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_TASK = re.compile(r"^\\[A-Za-z0-9 ._\\-]{1,254}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
WINDOWS = os.name == "nt"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Without O_BINARY a Windows descriptor is in text mode and every LF written becomes CRLF.
|
|
51
|
+
_O_BINARY = getattr(os, "O_BINARY", 0)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ServiceError(Exception):
|
|
55
|
+
"""A rule of the extension was violated; the message is one readable sentence."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def now_iso() -> str:
|
|
59
|
+
return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def expand(path: str) -> Path:
|
|
63
|
+
"""Paths in a descriptor may start with ~/ (or ~\\ on Windows); everything else must be
|
|
64
|
+
absolute, and a network share is never a local path (DEC-0032)."""
|
|
65
|
+
if not _LOCAL_PATH.match(str(path)):
|
|
66
|
+
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
67
|
+
p = Path(os.path.expanduser(path))
|
|
68
|
+
if not p.is_absolute():
|
|
69
|
+
raise ServiceError("Path %r must be absolute or start with ~/." % path)
|
|
70
|
+
return p
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_id(service_id: str) -> None:
|
|
74
|
+
if not (isinstance(service_id, str) and _ID.fullmatch(service_id)):
|
|
75
|
+
raise ServiceError("Service id %r must match %s." % (service_id, _ID.pattern))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = [name for name in dir() if not name.startswith("_")]
|