blun-king-cli 9.1.597 → 9.1.600
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 +39 -0
- package/README.md +17 -1
- package/agent-spine-plugin/CHANGELOG.md +1 -1
- package/agent-spine-plugin/docs/host-integration.md +1 -15
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install.js +1 -6
- package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
- package/agent-spine-plugin/scripts/release-check.js +2 -3
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
- package/agent-spine-plugin/src/hook.js +40 -40
- package/agent-spine-plugin/src/lib/briefing.js +6 -3
- package/agent-spine-plugin/src/lib/hook-context.js +63 -64
- package/agent-spine-plugin/src/lib/hook-output.js +115 -94
- package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
- package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
- package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
- package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
- package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
- package/agent-spine-plugin/src/lib/source-roots.js +26 -16
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
- package/bin/core-bootstrap.js +18 -10
- package/bin/launcher-mode.js +5 -0
- package/bin/launcher-runtime.js +27 -0
- package/bin/verify-bundled-agent-sources.cjs +28 -2
- package/blun.mjs +798 -143
- package/bundled-agent-sources.json +192 -155
- package/package.json +1 -1
- package/standard-skills/translate-native/README.md +1334 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
- package/standard-skills/translate-native/integrations/website_localization.py +234 -10
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
- package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
- package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
- package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
- package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/commercial-localization.md +96 -17
- package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/VERSION +1 -1
- package/standard-tools/language-guard/blun_language_guard.py +3 -1
- package/standard-tools/language-guard/check_commercial_review.py +69 -9
- package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/provenance.json +2 -2
- package/standard-tools/manifest.json +1 -1
- package/worker-host.mjs +215 -82
- package/agent-spine-plugin/scripts/check-install-king.js +0 -37
|
@@ -38,6 +38,12 @@ _DELIVERY = _load_module(
|
|
|
38
38
|
"blun_website_localization_composed_cms_source_delivery",
|
|
39
39
|
_ROOT / "integrations" / "website_localization_cms_source_delivery.py",
|
|
40
40
|
)
|
|
41
|
+
_HTTP = _load_module(
|
|
42
|
+
"blun_website_localization_composed_cms_source_delivery_http",
|
|
43
|
+
_ROOT
|
|
44
|
+
/ "integrations"
|
|
45
|
+
/ "website_localization_cms_source_delivery_http.py",
|
|
46
|
+
)
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
class DurableCMSSourceDeliveryRuntimeBlocked(RuntimeError):
|
|
@@ -68,6 +74,8 @@ def _delivery_failure(error: Exception) -> DurableCMSSourceDeliveryRuntimeBlocke
|
|
|
68
74
|
return _blocked("source_delivery_runtime.idempotency_collision")
|
|
69
75
|
if code == "source_delivery.status_not_found":
|
|
70
76
|
return _blocked("source_delivery_runtime.status_not_found")
|
|
77
|
+
if code == "source_delivery.source_status_unavailable":
|
|
78
|
+
return _blocked("source_delivery_runtime.source_status_unavailable")
|
|
71
79
|
return _blocked("source_delivery_runtime.outbox_blocked")
|
|
72
80
|
|
|
73
81
|
|
|
@@ -207,6 +215,175 @@ def _prepare_database_file(database_path: str) -> Callable[[], None]:
|
|
|
207
215
|
return guard
|
|
208
216
|
|
|
209
217
|
|
|
218
|
+
def preflight_existing_durable_cms_source_delivery(
|
|
219
|
+
database: str | os.PathLike[str],
|
|
220
|
+
client: Any,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Validate an existing durable generation locally without changing it."""
|
|
223
|
+
|
|
224
|
+
path = _database_path(database)
|
|
225
|
+
if path == ":memory:":
|
|
226
|
+
return
|
|
227
|
+
_validate_database_parent(path)
|
|
228
|
+
try:
|
|
229
|
+
identity = _validate_database_file(path)
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
delivery_digest = getattr(client, "expected_capabilities_sha256", None)
|
|
234
|
+
legacy_delivery_digest = getattr(
|
|
235
|
+
client, "legacy_expected_capabilities_sha256", delivery_digest,
|
|
236
|
+
)
|
|
237
|
+
runtime_digest = getattr(
|
|
238
|
+
client, "expected_runtime_capabilities_sha256", None,
|
|
239
|
+
)
|
|
240
|
+
rendering_digest = getattr(
|
|
241
|
+
client, "expected_commercial_rendering_registry_sha256", None,
|
|
242
|
+
)
|
|
243
|
+
terminal_receiver_digest = getattr(
|
|
244
|
+
client, "expected_terminal_receiver_capabilities_sha256", None,
|
|
245
|
+
)
|
|
246
|
+
if not all(
|
|
247
|
+
isinstance(value, str) and _DELIVERY.SHA256.fullmatch(value) is not None
|
|
248
|
+
for value in (
|
|
249
|
+
delivery_digest, legacy_delivery_digest, runtime_digest,
|
|
250
|
+
rendering_digest,
|
|
251
|
+
terminal_receiver_digest,
|
|
252
|
+
)
|
|
253
|
+
):
|
|
254
|
+
raise _blocked("source_delivery_runtime.configuration_invalid")
|
|
255
|
+
expected_binding = _DELIVERY._capability_binding_row(
|
|
256
|
+
delivery_digest, runtime_digest, rendering_digest,
|
|
257
|
+
terminal_receiver_digest,
|
|
258
|
+
)
|
|
259
|
+
legacy_binding = _DELIVERY._legacy_capability_binding_row(
|
|
260
|
+
legacy_delivery_digest, runtime_digest, rendering_digest,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
connection = None
|
|
264
|
+
try:
|
|
265
|
+
connection = sqlite3.connect(
|
|
266
|
+
Path(path).as_uri() + "?mode=ro",
|
|
267
|
+
uri=True,
|
|
268
|
+
timeout=0,
|
|
269
|
+
isolation_level=None,
|
|
270
|
+
)
|
|
271
|
+
connection.row_factory = sqlite3.Row
|
|
272
|
+
_validate_database_file(path, identity)
|
|
273
|
+
binding_objects = connection.execute(
|
|
274
|
+
"SELECT type, name, tbl_name, sql FROM sqlite_master "
|
|
275
|
+
"WHERE name = ?",
|
|
276
|
+
(_DELIVERY.CAPABILITY_BINDING_TABLE,),
|
|
277
|
+
).fetchall()
|
|
278
|
+
|
|
279
|
+
meta_columns = tuple(
|
|
280
|
+
row["name"] for row in connection.execute(
|
|
281
|
+
"PRAGMA table_info(cms_source_delivery_meta)"
|
|
282
|
+
).fetchall()
|
|
283
|
+
)
|
|
284
|
+
outbox_columns = tuple(
|
|
285
|
+
row["name"] for row in connection.execute(
|
|
286
|
+
"PRAGMA table_info(cms_source_delivery_outbox)"
|
|
287
|
+
).fetchall()
|
|
288
|
+
)
|
|
289
|
+
empty_file = not binding_objects and not meta_columns and not outbox_columns
|
|
290
|
+
if empty_file:
|
|
291
|
+
unexpected = connection.execute(
|
|
292
|
+
"SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
|
|
293
|
+
"LIMIT 1"
|
|
294
|
+
).fetchone()
|
|
295
|
+
if unexpected is None:
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
if (
|
|
299
|
+
meta_columns != _DELIVERY._META_COLUMNS
|
|
300
|
+
or outbox_columns != _DELIVERY._COLUMNS
|
|
301
|
+
):
|
|
302
|
+
raise _blocked("source_delivery_runtime.database_schema_altered")
|
|
303
|
+
meta = connection.execute(
|
|
304
|
+
"SELECT singleton, schema_version FROM cms_source_delivery_meta"
|
|
305
|
+
).fetchall()
|
|
306
|
+
if (
|
|
307
|
+
len(meta) != 1
|
|
308
|
+
or tuple(meta[0]) != (1, _DELIVERY.SCHEMA_VERSION)
|
|
309
|
+
):
|
|
310
|
+
raise _blocked("source_delivery_runtime.database_schema_altered")
|
|
311
|
+
|
|
312
|
+
has_work = connection.execute(
|
|
313
|
+
"SELECT 1 FROM cms_source_delivery_outbox LIMIT 1"
|
|
314
|
+
).fetchone()
|
|
315
|
+
if not binding_objects:
|
|
316
|
+
if has_work is not None:
|
|
317
|
+
raise _blocked("source_delivery_runtime.database_generation_unbound")
|
|
318
|
+
return
|
|
319
|
+
if len(binding_objects) != 1:
|
|
320
|
+
raise _blocked("source_delivery_runtime.database_generation_invalid")
|
|
321
|
+
binding_object = tuple(binding_objects[0])
|
|
322
|
+
current_object = (
|
|
323
|
+
"table",
|
|
324
|
+
_DELIVERY.CAPABILITY_BINDING_TABLE,
|
|
325
|
+
_DELIVERY.CAPABILITY_BINDING_TABLE,
|
|
326
|
+
_DELIVERY.CAPABILITY_BINDING_SQL,
|
|
327
|
+
)
|
|
328
|
+
legacy_object = (
|
|
329
|
+
"table",
|
|
330
|
+
_DELIVERY.CAPABILITY_BINDING_TABLE,
|
|
331
|
+
_DELIVERY.CAPABILITY_BINDING_TABLE,
|
|
332
|
+
_DELIVERY.LEGACY_CAPABILITY_BINDING_SQL,
|
|
333
|
+
)
|
|
334
|
+
if binding_object not in {current_object, legacy_object}:
|
|
335
|
+
raise _blocked("source_delivery_runtime.database_generation_invalid")
|
|
336
|
+
columns = tuple(
|
|
337
|
+
(row[1], row[2], row[3], row[5])
|
|
338
|
+
for row in connection.execute(
|
|
339
|
+
f"PRAGMA table_info({_DELIVERY.CAPABILITY_BINDING_TABLE})"
|
|
340
|
+
).fetchall()
|
|
341
|
+
)
|
|
342
|
+
if binding_object == legacy_object:
|
|
343
|
+
rows = connection.execute(
|
|
344
|
+
f"SELECT singleton, schema, database_role, "
|
|
345
|
+
f"delivery_capabilities_sha256, runtime_capabilities_sha256, "
|
|
346
|
+
f"commercial_rendering_registry_sha256, binding_sha256 "
|
|
347
|
+
f"FROM {_DELIVERY.CAPABILITY_BINDING_TABLE}"
|
|
348
|
+
).fetchall()
|
|
349
|
+
if (
|
|
350
|
+
columns != _DELIVERY.LEGACY_CAPABILITY_BINDING_COLUMNS
|
|
351
|
+
or len(rows) != 1
|
|
352
|
+
):
|
|
353
|
+
raise _blocked(
|
|
354
|
+
"source_delivery_runtime.database_generation_invalid"
|
|
355
|
+
)
|
|
356
|
+
if tuple(rows[0]) != legacy_binding:
|
|
357
|
+
raise _blocked(
|
|
358
|
+
"source_delivery_runtime.database_generation_mismatch"
|
|
359
|
+
)
|
|
360
|
+
if has_work is not None:
|
|
361
|
+
raise _blocked(
|
|
362
|
+
"source_delivery_runtime.database_generation_unbound"
|
|
363
|
+
)
|
|
364
|
+
_validate_database_file(path, identity)
|
|
365
|
+
return
|
|
366
|
+
rows = connection.execute(
|
|
367
|
+
f"SELECT singleton, schema, database_role, "
|
|
368
|
+
f"delivery_capabilities_sha256, runtime_capabilities_sha256, "
|
|
369
|
+
f"commercial_rendering_registry_sha256, "
|
|
370
|
+
f"terminal_receiver_capabilities_sha256, binding_sha256 "
|
|
371
|
+
f"FROM {_DELIVERY.CAPABILITY_BINDING_TABLE}"
|
|
372
|
+
).fetchall()
|
|
373
|
+
if columns != _DELIVERY.CAPABILITY_BINDING_COLUMNS or len(rows) != 1:
|
|
374
|
+
raise _blocked("source_delivery_runtime.database_generation_invalid")
|
|
375
|
+
if tuple(rows[0]) != expected_binding:
|
|
376
|
+
raise _blocked("source_delivery_runtime.database_generation_mismatch")
|
|
377
|
+
_validate_database_file(path, identity)
|
|
378
|
+
except DurableCMSSourceDeliveryRuntimeBlocked:
|
|
379
|
+
raise
|
|
380
|
+
except sqlite3.Error:
|
|
381
|
+
raise _blocked("source_delivery_runtime.database_preflight_failed") from None
|
|
382
|
+
finally:
|
|
383
|
+
if connection is not None:
|
|
384
|
+
connection.close()
|
|
385
|
+
|
|
386
|
+
|
|
210
387
|
def _duration(value: Any, code: str, *, maximum: float = 86_400) -> float:
|
|
211
388
|
if (
|
|
212
389
|
isinstance(value, bool)
|
|
@@ -253,6 +430,7 @@ class DurableCMSSourceDeliveryRuntime:
|
|
|
253
430
|
*,
|
|
254
431
|
worker_id: str,
|
|
255
432
|
lease_seconds: float,
|
|
433
|
+
http_authenticator: Callable[[dict[str, Any]], Any] | None = None,
|
|
256
434
|
):
|
|
257
435
|
self._connection = connection
|
|
258
436
|
self._guard = guard
|
|
@@ -266,6 +444,13 @@ class DurableCMSSourceDeliveryRuntime:
|
|
|
266
444
|
self._worker_thread: threading.Thread | None = None
|
|
267
445
|
self._worker_state = "unmanaged"
|
|
268
446
|
self._worker_error_code: str | None = None
|
|
447
|
+
self.http = (
|
|
448
|
+
None
|
|
449
|
+
if http_authenticator is None
|
|
450
|
+
else _HTTP.CMSSourceDeliveryHTTPApplication(
|
|
451
|
+
self, http_authenticator,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
269
454
|
|
|
270
455
|
def __repr__(self) -> str:
|
|
271
456
|
return f"DurableCMSSourceDeliveryRuntime(state={self.state!r})"
|
|
@@ -350,9 +535,40 @@ class DurableCMSSourceDeliveryRuntime:
|
|
|
350
535
|
def status(self, operation: str, request_id: str) -> Any:
|
|
351
536
|
return self._call("status", operation, request_id)
|
|
352
537
|
|
|
538
|
+
def source_status(
|
|
539
|
+
self, event_id: str, site_id: str, payload_sha256: str,
|
|
540
|
+
) -> Mapping[str, Any]:
|
|
541
|
+
"""Read the source lifecycle through the exact accepted outbox row."""
|
|
542
|
+
|
|
543
|
+
return self._call(
|
|
544
|
+
"source_status", event_id, site_id, payload_sha256,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
def source_readiness(self) -> Mapping[str, Any]:
|
|
548
|
+
"""Read the immutable downstream source-service readiness contract."""
|
|
549
|
+
|
|
550
|
+
return self._call("source_readiness")
|
|
551
|
+
|
|
552
|
+
def source_health(self) -> Mapping[str, Any]:
|
|
553
|
+
"""Read the immutable downstream source-service health contract."""
|
|
554
|
+
|
|
555
|
+
return self._call("source_health")
|
|
556
|
+
|
|
353
557
|
def health(self) -> Any:
|
|
354
558
|
return self._call("health")
|
|
355
559
|
|
|
560
|
+
def capability_binding(self) -> dict[str, Any]:
|
|
561
|
+
"""Return the locally verified durable outbox generation."""
|
|
562
|
+
|
|
563
|
+
return self._call("capability_binding")
|
|
564
|
+
|
|
565
|
+
@property
|
|
566
|
+
def expected_capabilities_sha256(self) -> str:
|
|
567
|
+
"""Return the immutable source-service contract pinned by the outbox."""
|
|
568
|
+
|
|
569
|
+
self._assert_owner()
|
|
570
|
+
return self._outbox.capabilities_sha256
|
|
571
|
+
|
|
356
572
|
def run_once(self) -> Any:
|
|
357
573
|
return self._call(
|
|
358
574
|
"run_once",
|
|
@@ -593,9 +809,12 @@ def open_durable_cms_source_delivery(
|
|
|
593
809
|
lease_seconds: float | int = 600,
|
|
594
810
|
base_delay_seconds: float | int = 5,
|
|
595
811
|
max_delay_seconds: float | int = 300,
|
|
812
|
+
http_authenticator: Callable[[dict[str, Any]], Any] | None = None,
|
|
596
813
|
) -> DurableCMSSourceDeliveryRuntime:
|
|
597
814
|
"""Validate configuration, then open one guarded durable outbox."""
|
|
598
815
|
|
|
816
|
+
if http_authenticator is not None and not callable(http_authenticator):
|
|
817
|
+
raise _blocked("source_delivery_runtime.configuration_invalid")
|
|
599
818
|
path = _database_path(database)
|
|
600
819
|
timeout = _sqlite_timeout(sqlite_timeout_seconds)
|
|
601
820
|
outbox_options = {
|
|
@@ -635,6 +854,7 @@ def open_durable_cms_source_delivery(
|
|
|
635
854
|
outbox,
|
|
636
855
|
worker_id=worker_id,
|
|
637
856
|
lease_seconds=lease,
|
|
857
|
+
http_authenticator=http_authenticator,
|
|
638
858
|
)
|
|
639
859
|
|
|
640
860
|
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bind the source-delivery sidecar client to the durable website outbox.
|
|
3
|
+
|
|
4
|
+
The website outbox and the sidecar intentionally own different retry loops.
|
|
5
|
+
This adapter keeps their budgets separate: the outbox retries durable sidecar
|
|
6
|
+
acceptance, while ``sidecar_delivery_max_attempts`` controls the sidecar's
|
|
7
|
+
delivery to the source service and the per-call ``max_attempts`` controls the
|
|
8
|
+
source service itself.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Mapping
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ADAPTER_SCHEMA = "blun.cms-source-delivery-sidecar-outbox-adapter.v3"
|
|
21
|
+
LEGACY_ADAPTER_SCHEMA = "blun.cms-source-delivery-sidecar-outbox-adapter.v2"
|
|
22
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
23
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
24
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
25
|
+
CHANGE_SCHEMA = "blun.cms-content-change.v2"
|
|
26
|
+
CANCELLATION_SCHEMA = "blun.cms-content-cancellation.v1"
|
|
27
|
+
TOMBSTONE_SCHEMA = "blun.cms-content-tombstone.v1"
|
|
28
|
+
CHANGE_RESPONSE_SCHEMA = "blun.cms-source-change-enqueue-response.v3"
|
|
29
|
+
REMOVAL_RESPONSE_SCHEMA = "blun.cms-source-removal-enqueue-response.v3"
|
|
30
|
+
SIDECAR_QUEUE_RESPONSE_SCHEMA = "blun.cms-source-delivery-queue-response.v1"
|
|
31
|
+
QUEUE_FIELDS = {
|
|
32
|
+
"operation", "request_id", "event_id", "site_id", "payload_sha256",
|
|
33
|
+
"remote_capabilities_sha256", "status", "attempts",
|
|
34
|
+
"delivery_max_attempts", "source_max_attempts", "next_attempt_at",
|
|
35
|
+
"lease_expires_at", "lease_expired", "last_error_code",
|
|
36
|
+
"response_sha256",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CMSSourceDeliverySidecarAdapterBlocked(RuntimeError):
|
|
41
|
+
"""Content-free failure compatible with the website delivery outbox."""
|
|
42
|
+
|
|
43
|
+
cms_source_client_failure = True
|
|
44
|
+
|
|
45
|
+
def __init__(self, code: str, *, retryable: bool):
|
|
46
|
+
if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
|
|
47
|
+
code = "source_delivery_sidecar_adapter.blocked"
|
|
48
|
+
retryable = False
|
|
49
|
+
if not isinstance(retryable, bool):
|
|
50
|
+
retryable = False
|
|
51
|
+
super().__init__(code)
|
|
52
|
+
self.code = code
|
|
53
|
+
self.retryable = retryable
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _attempts(value: Any) -> int:
|
|
57
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 20:
|
|
58
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
59
|
+
"source_delivery_sidecar_adapter.attempts_invalid", retryable=False,
|
|
60
|
+
)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _canonical(value: Any) -> bytes:
|
|
65
|
+
try:
|
|
66
|
+
return json.dumps(
|
|
67
|
+
value, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
68
|
+
separators=(",", ":"),
|
|
69
|
+
).encode("utf-8")
|
|
70
|
+
except (TypeError, ValueError, RecursionError):
|
|
71
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
72
|
+
"source_delivery_sidecar_adapter.request_invalid", retryable=False,
|
|
73
|
+
) from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _failure(error: Exception) -> CMSSourceDeliverySidecarAdapterBlocked:
|
|
77
|
+
code = getattr(error, "code", None)
|
|
78
|
+
retryable = getattr(error, "retryable", None)
|
|
79
|
+
if (
|
|
80
|
+
getattr(error, "cms_source_delivery_client_failure", False) is True
|
|
81
|
+
and isinstance(code, str)
|
|
82
|
+
and ERROR_CODE.fullmatch(code) is not None
|
|
83
|
+
and isinstance(retryable, bool)
|
|
84
|
+
):
|
|
85
|
+
return CMSSourceDeliverySidecarAdapterBlocked(code, retryable=retryable)
|
|
86
|
+
return CMSSourceDeliverySidecarAdapterBlocked(
|
|
87
|
+
"source_delivery_sidecar_adapter.client_failure", retryable=False,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class CMSSourceDeliverySidecarOutboxAdapter:
|
|
92
|
+
"""Project validated sidecar acceptance into the durable outbox contract."""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
client: Any,
|
|
97
|
+
*,
|
|
98
|
+
sidecar_delivery_max_attempts: int = 5,
|
|
99
|
+
runtime_capabilities_sha256: str | None = None,
|
|
100
|
+
commercial_rendering_registry_sha256: str | None = None,
|
|
101
|
+
terminal_receiver_capabilities_sha256: str | None = None,
|
|
102
|
+
):
|
|
103
|
+
sidecar_digest = getattr(client, "expected_capabilities_sha256", None)
|
|
104
|
+
remote_digest = getattr(client, "expected_remote_capabilities_sha256", None)
|
|
105
|
+
timeout = getattr(client, "timeout", None)
|
|
106
|
+
if (
|
|
107
|
+
not callable(getattr(client, "submit_change", None))
|
|
108
|
+
or not callable(getattr(client, "submit_removal", None))
|
|
109
|
+
or not isinstance(sidecar_digest, str)
|
|
110
|
+
or SHA256.fullmatch(sidecar_digest) is None
|
|
111
|
+
or not isinstance(remote_digest, str)
|
|
112
|
+
or SHA256.fullmatch(remote_digest) is None
|
|
113
|
+
or len({
|
|
114
|
+
runtime_capabilities_sha256 is None,
|
|
115
|
+
commercial_rendering_registry_sha256 is None,
|
|
116
|
+
terminal_receiver_capabilities_sha256 is None,
|
|
117
|
+
}) != 1
|
|
118
|
+
or runtime_capabilities_sha256 is not None and (
|
|
119
|
+
not isinstance(runtime_capabilities_sha256, str)
|
|
120
|
+
or SHA256.fullmatch(runtime_capabilities_sha256) is None
|
|
121
|
+
or not isinstance(commercial_rendering_registry_sha256, str)
|
|
122
|
+
or SHA256.fullmatch(commercial_rendering_registry_sha256) is None
|
|
123
|
+
or not isinstance(terminal_receiver_capabilities_sha256, str)
|
|
124
|
+
or SHA256.fullmatch(terminal_receiver_capabilities_sha256) is None
|
|
125
|
+
)
|
|
126
|
+
or isinstance(timeout, bool)
|
|
127
|
+
or not isinstance(timeout, (int, float))
|
|
128
|
+
or not math.isfinite(float(timeout))
|
|
129
|
+
or not 0 < float(timeout) <= 300
|
|
130
|
+
):
|
|
131
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
132
|
+
"source_delivery_sidecar_adapter.client_invalid", retryable=False,
|
|
133
|
+
)
|
|
134
|
+
delivery_attempts = _attempts(sidecar_delivery_max_attempts)
|
|
135
|
+
binding = {
|
|
136
|
+
"schema": ADAPTER_SCHEMA,
|
|
137
|
+
"sidecar_capabilities_sha256": sidecar_digest,
|
|
138
|
+
"remote_capabilities_sha256": remote_digest,
|
|
139
|
+
"sidecar_delivery_max_attempts": delivery_attempts,
|
|
140
|
+
"runtime_capabilities_sha256": runtime_capabilities_sha256,
|
|
141
|
+
"commercial_rendering_registry_sha256": (
|
|
142
|
+
commercial_rendering_registry_sha256
|
|
143
|
+
),
|
|
144
|
+
"terminal_receiver_capabilities_sha256": (
|
|
145
|
+
terminal_receiver_capabilities_sha256
|
|
146
|
+
),
|
|
147
|
+
}
|
|
148
|
+
legacy_binding = {
|
|
149
|
+
"schema": LEGACY_ADAPTER_SCHEMA,
|
|
150
|
+
"sidecar_capabilities_sha256": sidecar_digest,
|
|
151
|
+
"remote_capabilities_sha256": remote_digest,
|
|
152
|
+
"sidecar_delivery_max_attempts": delivery_attempts,
|
|
153
|
+
"runtime_capabilities_sha256": runtime_capabilities_sha256,
|
|
154
|
+
"commercial_rendering_registry_sha256": (
|
|
155
|
+
commercial_rendering_registry_sha256
|
|
156
|
+
),
|
|
157
|
+
}
|
|
158
|
+
self.client = client
|
|
159
|
+
self.sidecar_capabilities_sha256 = sidecar_digest
|
|
160
|
+
self.remote_capabilities_sha256 = remote_digest
|
|
161
|
+
self.sidecar_delivery_max_attempts = delivery_attempts
|
|
162
|
+
self.expected_runtime_capabilities_sha256 = (
|
|
163
|
+
runtime_capabilities_sha256
|
|
164
|
+
)
|
|
165
|
+
self.expected_commercial_rendering_registry_sha256 = (
|
|
166
|
+
commercial_rendering_registry_sha256
|
|
167
|
+
)
|
|
168
|
+
self.expected_terminal_receiver_capabilities_sha256 = (
|
|
169
|
+
terminal_receiver_capabilities_sha256
|
|
170
|
+
)
|
|
171
|
+
self.legacy_expected_capabilities_sha256 = hashlib.sha256(
|
|
172
|
+
_canonical(legacy_binding)
|
|
173
|
+
).hexdigest()
|
|
174
|
+
self.expected_capabilities_sha256 = hashlib.sha256(
|
|
175
|
+
_canonical(binding)
|
|
176
|
+
).hexdigest()
|
|
177
|
+
self.timeout = float(timeout)
|
|
178
|
+
|
|
179
|
+
def __repr__(self) -> str:
|
|
180
|
+
return "CMSSourceDeliverySidecarOutboxAdapter(configured=True)"
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def _identity(value: Mapping[str, Any]) -> tuple[str, str, str, str]:
|
|
184
|
+
if not isinstance(value, Mapping):
|
|
185
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
186
|
+
"source_delivery_sidecar_adapter.request_invalid", retryable=False,
|
|
187
|
+
)
|
|
188
|
+
schema = value.get("schema")
|
|
189
|
+
site_id = value.get("site_id")
|
|
190
|
+
event_id = value.get("event_id")
|
|
191
|
+
if schema == CHANGE_SCHEMA:
|
|
192
|
+
operation, request_id = "change", event_id
|
|
193
|
+
elif schema == CANCELLATION_SCHEMA:
|
|
194
|
+
operation, request_id = "cancellation", value.get("cancellation_id")
|
|
195
|
+
elif schema == TOMBSTONE_SCHEMA:
|
|
196
|
+
operation, request_id = "tombstone", value.get("tombstone_id")
|
|
197
|
+
else:
|
|
198
|
+
operation, request_id = "", None
|
|
199
|
+
if (
|
|
200
|
+
operation == ""
|
|
201
|
+
or not all(
|
|
202
|
+
isinstance(item, str) and TOKEN.fullmatch(item) is not None
|
|
203
|
+
for item in (site_id, event_id, request_id)
|
|
204
|
+
)
|
|
205
|
+
):
|
|
206
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
207
|
+
"source_delivery_sidecar_adapter.request_invalid", retryable=False,
|
|
208
|
+
)
|
|
209
|
+
return operation, request_id, event_id, site_id
|
|
210
|
+
|
|
211
|
+
def _submit(
|
|
212
|
+
self, value: Mapping[str, Any], *, max_attempts: int, change: bool,
|
|
213
|
+
) -> Mapping[str, Any]:
|
|
214
|
+
source_attempts = _attempts(max_attempts)
|
|
215
|
+
operation, request_id, event_id, site_id = self._identity(value)
|
|
216
|
+
if change != (operation == "change"):
|
|
217
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
218
|
+
"source_delivery_sidecar_adapter.request_invalid", retryable=False,
|
|
219
|
+
)
|
|
220
|
+
payload_hash = hashlib.sha256(_canonical(dict(value))).hexdigest()
|
|
221
|
+
try:
|
|
222
|
+
if change:
|
|
223
|
+
response = self.client.submit_change(
|
|
224
|
+
value, source_max_attempts=source_attempts,
|
|
225
|
+
delivery_max_attempts=self.sidecar_delivery_max_attempts,
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
response = self.client.submit_removal(
|
|
229
|
+
value, source_max_attempts=source_attempts,
|
|
230
|
+
delivery_max_attempts=self.sidecar_delivery_max_attempts,
|
|
231
|
+
)
|
|
232
|
+
except Exception as error:
|
|
233
|
+
raise _failure(error) from error
|
|
234
|
+
|
|
235
|
+
queue = response.get("queue") if isinstance(response, Mapping) else None
|
|
236
|
+
status = queue.get("status") if isinstance(queue, Mapping) else None
|
|
237
|
+
attempts = queue.get("attempts") if isinstance(queue, Mapping) else None
|
|
238
|
+
next_attempt = (
|
|
239
|
+
queue.get("next_attempt_at") if isinstance(queue, Mapping) else None
|
|
240
|
+
)
|
|
241
|
+
lease_expires = (
|
|
242
|
+
queue.get("lease_expires_at") if isinstance(queue, Mapping) else None
|
|
243
|
+
)
|
|
244
|
+
last_error = (
|
|
245
|
+
queue.get("last_error_code") if isinstance(queue, Mapping) else None
|
|
246
|
+
)
|
|
247
|
+
response_hash = (
|
|
248
|
+
queue.get("response_sha256") if isinstance(queue, Mapping) else None
|
|
249
|
+
)
|
|
250
|
+
if (
|
|
251
|
+
not isinstance(response, Mapping)
|
|
252
|
+
or set(response) != {"schema", "queue", "capabilities_sha256"}
|
|
253
|
+
or response.get("schema") != SIDECAR_QUEUE_RESPONSE_SCHEMA
|
|
254
|
+
or response.get("capabilities_sha256") != self.sidecar_capabilities_sha256
|
|
255
|
+
or not isinstance(queue, Mapping)
|
|
256
|
+
or set(queue) != QUEUE_FIELDS
|
|
257
|
+
or queue.get("operation") != operation
|
|
258
|
+
or queue.get("request_id") != request_id
|
|
259
|
+
or queue.get("event_id") != event_id
|
|
260
|
+
or queue.get("site_id") != site_id
|
|
261
|
+
or queue.get("payload_sha256") != payload_hash
|
|
262
|
+
or queue.get("remote_capabilities_sha256") != self.remote_capabilities_sha256
|
|
263
|
+
or queue.get("source_max_attempts") != source_attempts
|
|
264
|
+
or queue.get("delivery_max_attempts") != self.sidecar_delivery_max_attempts
|
|
265
|
+
or status not in {"pending", "leased", "retry_wait", "succeeded", "failed"}
|
|
266
|
+
or isinstance(attempts, bool)
|
|
267
|
+
or not isinstance(attempts, int)
|
|
268
|
+
or not 0 <= attempts <= self.sidecar_delivery_max_attempts
|
|
269
|
+
or isinstance(next_attempt, bool)
|
|
270
|
+
or not isinstance(next_attempt, (int, float))
|
|
271
|
+
or not math.isfinite(float(next_attempt))
|
|
272
|
+
or float(next_attempt) < 0
|
|
273
|
+
or not isinstance(queue.get("lease_expired"), bool)
|
|
274
|
+
or (status == "leased") != (lease_expires is not None)
|
|
275
|
+
or lease_expires is not None and (
|
|
276
|
+
isinstance(lease_expires, bool)
|
|
277
|
+
or not isinstance(lease_expires, (int, float))
|
|
278
|
+
or not math.isfinite(float(lease_expires))
|
|
279
|
+
or float(lease_expires) < 0
|
|
280
|
+
)
|
|
281
|
+
or last_error is not None and (
|
|
282
|
+
not isinstance(last_error, str)
|
|
283
|
+
or ERROR_CODE.fullmatch(last_error) is None
|
|
284
|
+
)
|
|
285
|
+
or response_hash is not None and (
|
|
286
|
+
not isinstance(response_hash, str)
|
|
287
|
+
or SHA256.fullmatch(response_hash) is None
|
|
288
|
+
)
|
|
289
|
+
or (status == "succeeded") != (response_hash is not None)
|
|
290
|
+
or status in {"retry_wait", "failed"} and last_error is None
|
|
291
|
+
):
|
|
292
|
+
raise CMSSourceDeliverySidecarAdapterBlocked(
|
|
293
|
+
"source_delivery_sidecar_adapter.response_invalid", retryable=False,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# This private projection records sidecar acceptance only. It is never
|
|
297
|
+
# exposed as source-service status; downstream state remains separate.
|
|
298
|
+
projected = {
|
|
299
|
+
"schema": CHANGE_RESPONSE_SCHEMA if change else REMOVAL_RESPONSE_SCHEMA,
|
|
300
|
+
"operation": operation,
|
|
301
|
+
"request_id": request_id,
|
|
302
|
+
"event_id": event_id,
|
|
303
|
+
"payload_sha256": payload_hash,
|
|
304
|
+
"status": "pending",
|
|
305
|
+
"attempts": 0,
|
|
306
|
+
"max_attempts": source_attempts,
|
|
307
|
+
"capabilities_sha256": self.expected_capabilities_sha256,
|
|
308
|
+
}
|
|
309
|
+
if self.expected_runtime_capabilities_sha256 is not None:
|
|
310
|
+
projected["capability_binding"] = {
|
|
311
|
+
"schema": "blun.cms-source-capability-binding.v2",
|
|
312
|
+
"status": "verified",
|
|
313
|
+
"capabilities_sha256": (
|
|
314
|
+
self.expected_runtime_capabilities_sha256
|
|
315
|
+
),
|
|
316
|
+
"commercial_rendering_registry_sha256": (
|
|
317
|
+
self.expected_commercial_rendering_registry_sha256
|
|
318
|
+
),
|
|
319
|
+
"database_roles": ["changes", "removals", "lifecycle"],
|
|
320
|
+
}
|
|
321
|
+
return projected
|
|
322
|
+
|
|
323
|
+
def submit_change(
|
|
324
|
+
self, value: Mapping[str, Any], *, max_attempts: int = 5,
|
|
325
|
+
) -> Mapping[str, Any]:
|
|
326
|
+
return self._submit(value, max_attempts=max_attempts, change=True)
|
|
327
|
+
|
|
328
|
+
def submit_removal(
|
|
329
|
+
self, value: Mapping[str, Any], *, max_attempts: int = 5,
|
|
330
|
+
) -> Mapping[str, Any]:
|
|
331
|
+
return self._submit(value, max_attempts=max_attempts, change=False)
|