blun-king-cli 9.1.587 → 9.1.588
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 +11 -185
- package/LIESMICH.txt +51 -13
- package/README.md +44 -47
- package/agent-spine-plugin/.codex-plugin/plugin.json +16 -4
- package/agent-spine-plugin/CHANGELOG.md +37 -5
- package/agent-spine-plugin/README.md +3 -3
- package/agent-spine-plugin/blun.plugin.json +45 -10
- package/agent-spine-plugin/docs/artifact-evaluation.md +93 -0
- package/agent-spine-plugin/docs/host-integration.md +42 -27
- package/agent-spine-plugin/docs/preflight-recall.md +4 -2
- package/agent-spine-plugin/docs/session-timeline.md +97 -236
- package/agent-spine-plugin/docs/world-model.md +25 -0
- package/agent-spine-plugin/hooks/codex.json +1 -1
- package/agent-spine-plugin/hooks/hooks.json +1 -1
- package/agent-spine-plugin/package.json +1 -3
- package/agent-spine-plugin/scripts/check-hosts.js +3 -3
- package/agent-spine-plugin/scripts/release-check.js +10 -5
- package/agent-spine-plugin/scripts/run-checks.js +4 -1
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +32 -6
- package/agent-spine-plugin/src/cli-learning.js +15 -0
- package/agent-spine-plugin/src/cli.js +2 -0
- package/agent-spine-plugin/src/hook.js +32 -32
- package/agent-spine-plugin/src/lib/action-lesson-recall.js +73 -8
- package/agent-spine-plugin/src/lib/briefing.js +146 -36
- package/agent-spine-plugin/src/lib/channel-continuity.js +19 -0
- package/agent-spine-plugin/src/lib/delivery-agent-usage.js +14 -7
- package/agent-spine-plugin/src/lib/gateway-group-response.js +128 -0
- package/agent-spine-plugin/src/lib/gateway-runs.js +24 -15
- package/agent-spine-plugin/src/lib/hook-briefing-use.js +13 -3
- package/agent-spine-plugin/src/lib/hook-context.js +16 -3
- package/agent-spine-plugin/src/lib/hook-output.js +129 -5
- package/agent-spine-plugin/src/lib/hook-timeline.js +5 -3
- package/agent-spine-plugin/src/lib/indexed-memory.js +2 -2
- package/agent-spine-plugin/src/lib/learning-artifact-evaluator.js +114 -0
- package/agent-spine-plugin/src/lib/learning-context.js +11 -4
- package/agent-spine-plugin/src/lib/learning-measurements.js +2 -2
- package/agent-spine-plugin/src/lib/mcp-runtime.js +89 -3
- package/agent-spine-plugin/src/lib/mcp-source-context.js +12 -2
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +91 -8
- package/agent-spine-plugin/src/lib/mcp-world-tools.js +2 -2
- package/agent-spine-plugin/src/lib/owned-file-lock.js +20 -1
- package/agent-spine-plugin/src/lib/persona-runtime.js +2 -2
- package/agent-spine-plugin/src/lib/preflight-delivery-id.js +27 -0
- package/agent-spine-plugin/src/lib/preflight.js +4 -4
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +15 -0
- package/agent-spine-plugin/src/lib/session-timeline-contract.js +12 -4
- package/agent-spine-plugin/src/lib/session-timeline-event-extract.js +36 -7
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +13 -10
- package/agent-spine-plugin/src/lib/session-timeline-invocation.js +1 -1
- package/agent-spine-plugin/src/lib/session-timeline-king.js +14 -0
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +18 -12
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +5 -0
- package/agent-spine-plugin/src/lib/session-timeline-query.js +2 -0
- package/agent-spine-plugin/src/lib/session-timeline-results.js +35 -10
- package/agent-spine-plugin/src/lib/session-timeline-source-open.js +30 -0
- package/agent-spine-plugin/src/lib/session-timeline.js +122 -75
- package/agent-spine-plugin/src/lib/source-roots.js +3 -2
- package/agent-spine-plugin/src/lib/task-knowledge-context.js +22 -1
- package/agent-spine-plugin/src/lib/timeline-continuation-update.js +100 -0
- package/agent-spine-plugin/src/lib/timeline-tool-guard.js +30 -7
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +217 -0
- package/agent-spine-plugin/src/lib/timeline-world-capture.js +233 -0
- package/agent-spine-plugin/src/lib/world-knowledge.js +59 -2
- package/agent-spine-plugin/src/lib/world-model.js +64 -9
- package/agent-spine-plugin/src/worker.js +13 -1
- package/bin/blun.js +43 -28
- package/bin/core-bootstrap.js +5 -4
- package/bin/king.js +43 -28
- package/bin/launcher-mode.js +1 -10
- package/bin/launcher-runtime.js +128 -295
- package/bin/managed-node.js +0 -0
- package/bin/managed-plugin-selection.cjs +0 -1
- package/bin/native-module-repair.js +0 -0
- package/bin/node-runtime.js +0 -0
- package/bin/node-version.js +0 -0
- package/bin/plugin-bootstrap.js +56 -120
- package/bin/private-paths.js +11 -34
- package/bin/standard-tools-bootstrap.js +34 -114
- package/bin/turn-thinking-policy.cjs +3 -11
- package/bin/update-copy.js +200 -0
- package/bin/update-lease.js +0 -0
- package/bin/update-notice.js +136 -289
- package/bin/verify-agent-behavior.cjs +122 -0
- package/bin/verify-agent-components.cjs +104 -0
- package/bin/verify-bundled-agent-sources.cjs +57 -0
- package/blun.mjs +143076 -135288
- package/bundled-agent-sources.json +701 -0
- package/package.json +12 -15
- package/standard-skills/translate-native/README.md +1293 -0
- package/standard-skills/translate-native/SKILL.md +172 -22
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/agents/openai.yaml +18 -0
- package/standard-skills/translate-native/assets/icon.svg +8 -0
- package/standard-skills/translate-native/docs/BLUN_CODE_INTEGRATION.md +76 -0
- package/standard-skills/translate-native/docs/PREMORTEM.md +489 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +2035 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1302 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +136 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HEALTH_HTTP.md +130 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +175 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +86 -0
- package/standard-skills/translate-native/integrations/AGENT_RULES.md +32 -0
- package/standard-skills/translate-native/integrations/adapters/blun-code-language-guard.js +514 -0
- package/standard-skills/translate-native/integrations/adapters/node-language-guard.js +230 -0
- package/standard-skills/translate-native/integrations/audit_log.py +327 -0
- package/standard-skills/translate-native/integrations/claude_language_hook.js +1536 -0
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +42 -0
- package/standard-skills/translate-native/integrations/delivery-policy.example.json +28 -0
- package/standard-skills/translate-native/integrations/enforced_delivery.py +543 -0
- package/standard-skills/translate-native/integrations/guard_service.py +435 -0
- package/standard-skills/translate-native/integrations/language_gateway.py +67 -0
- package/standard-skills/translate-native/integrations/mcp_auth_headers.py +198 -0
- package/standard-skills/translate-native/integrations/mcp_http_gateway.py +429 -0
- package/standard-skills/translate-native/integrations/non_language_html_entities.js +1485 -0
- package/standard-skills/translate-native/integrations/pre_output_guard.py +65 -0
- package/standard-skills/translate-native/integrations/task_router.py +101 -0
- package/standard-skills/translate-native/integrations/website_localization.py +401 -0
- package/standard-skills/translate-native/integrations/website_localization_api.py +581 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +1885 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_campaign.py +1772 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_candidate.py +506 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_http.py +400 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_review_store.py +781 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_reviewer_http.py +500 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_runtime.py +1107 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_suite.py +463 -0
- package/standard-skills/translate-native/integrations/website_localization_cms.py +2835 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +875 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_dispatch.py +805 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_http.py +588 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_lifecycle_monitor.py +991 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +1441 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +414 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +1073 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_removal_dispatch.py +865 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +583 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +964 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +665 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +1153 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +675 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +1125 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification.py +674 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +1469 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +1142 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +634 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +804 -0
- package/standard-skills/translate-native/integrations/website_localization_deepl_baseline.py +922 -0
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +482 -0
- package/standard-skills/translate-native/integrations/website_localization_health.py +1541 -0
- package/standard-skills/translate-native/integrations/website_localization_health_http.py +372 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +297 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_http.py +479 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_intake.py +363 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_queue.py +1449 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_store.py +420 -0
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +235 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +671 -0
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +516 -0
- package/standard-skills/translate-native/integrations/website_localization_release.py +928 -0
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +1008 -0
- package/standard-skills/translate-native/integrations/website_localization_runner.py +276 -0
- package/standard-skills/translate-native/integrations/website_localization_runtime.py +862 -0
- package/standard-skills/translate-native/integrations/website_localization_service.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_supervisor.py +511 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +663 -0
- package/standard-skills/translate-native/provenance.json +3 -4
- package/standard-skills/translate-native/references/commercial-localization.md +177 -0
- package/standard-skills/translate-native/scripts/blun_language_guard.py +7 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +80 -0
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +333 -0
- package/standard-tools/language-guard/LICENSE +21 -0
- package/standard-tools/language-guard/VERSION +1 -0
- package/standard-tools/language-guard/blun_language_guard.py +7 -1
- package/standard-tools/language-guard/check_commercial_review.py +80 -0
- package/standard-tools/language-guard/commercial_localization_profile.py +333 -0
- package/standard-tools/language-guard/language_gateway.py +62 -0
- package/standard-tools/language-guard/pre_output_guard.py +64 -0
- package/standard-tools/language-guard/provenance.json +4 -11
- package/standard-tools/manifest.json +34 -11
- package/telegram-plugin/commands/access.md +2 -10
- package/telegram-plugin/dist/bridge.mjs +64041 -687
- package/telegram-plugin/dist/mcp-server.mjs +72810 -9027
- package/telegram-plugin/dist/noise.mjs +28 -63511
- package/agent-spine-plugin/CONTRIBUTING.md +0 -52
- package/agent-spine-plugin/SECURITY.md +0 -47
- package/agent-spine-plugin/docs/assignment-continuation.md +0 -48
- package/agent-spine-plugin/docs/releasing.md +0 -85
- package/agent-spine-plugin/docs/structured-completion.md +0 -67
- package/bin/abort-listener-policy.cjs +0 -43
- package/bin/active-steer-priority-policy.cjs +0 -24
- package/bin/agent-api-http-adapter.mjs +0 -446
- package/bin/agent-api-private-http-server.mjs +0 -288
- package/bin/agent-api-runtime.mjs +0 -252
- package/bin/agent-api-service-environment.mjs +0 -236
- package/bin/agent-api-service-host.mjs +0 -209
- package/bin/agent-api-service-process.mjs +0 -171
- package/bin/agent-api-session-registry.mjs +0 -428
- package/bin/agent-api-tool-broker.cjs +0 -248
- package/bin/agent-api-turn-controller.mjs +0 -461
- package/bin/agent-api-usage-journal.cjs +0 -259
- package/bin/agent-resume-snapshot.cjs +0 -241
- package/bin/agentspine-king-goal-inbox.mjs +0 -111
- package/bin/agentspine-king-goal-intake.mjs +0 -106
- package/bin/approval-rejection-stop.cjs +0 -15
- package/bin/assistant-message-offload-policy.cjs +0 -284
- package/bin/baseline-skill-performance-policy.cjs +0 -39
- package/bin/bash-search-scope-policy.cjs +0 -49
- package/bin/codebase-search-runtime.cjs +0 -23
- package/bin/cognitive-action-checkpoint.cjs +0 -1104
- package/bin/cognitive-attention-delivery.cjs +0 -76
- package/bin/cognitive-attention-policy.cjs +0 -143
- package/bin/cognitive-attention-runtime.cjs +0 -91
- package/bin/cognitive-context-projection.cjs +0 -73
- package/bin/cognitive-cross-portal-acceptance.cjs +0 -443
- package/bin/cognitive-effective-view.cjs +0 -77
- package/bin/cognitive-focus-projection.cjs +0 -206
- package/bin/cognitive-focus-scope.cjs +0 -37
- package/bin/cognitive-goal-autostart-policy.cjs +0 -72
- package/bin/cognitive-goal-time-trigger-controller.cjs +0 -146
- package/bin/cognitive-memory-adapter.cjs +0 -282
- package/bin/cognitive-memory-command.cjs +0 -293
- package/bin/cognitive-memory-provider.cjs +0 -92
- package/bin/cognitive-salience-policy.cjs +0 -159
- package/bin/cognitive-state-store.cjs +0 -508
- package/bin/cognitive-turn-lifecycle.cjs +0 -624
- package/bin/cognitive-work-focus.cjs +0 -180
- package/bin/compaction-history-archive.cjs +0 -166
- package/bin/compaction-history-startup.cjs +0 -50
- package/bin/compaction-model-policy.cjs +0 -31
- package/bin/compaction-stage-policy.cjs +0 -21
- package/bin/compaction-transaction-policy.cjs +0 -122
- package/bin/config-write-dedup-policy.cjs +0 -27
- package/bin/context-budget-ledger.cjs +0 -31
- package/bin/context-doctor-policy.cjs +0 -70
- package/bin/context-insight-policy.cjs +0 -36
- package/bin/context-performance-policy.cjs +0 -19
- package/bin/context-pressure-policy.cjs +0 -20
- package/bin/cron-run-output.cjs +0 -45
- package/bin/cron-run-store.cjs +0 -145
- package/bin/curiosity-scout-policy.cjs +0 -49
- package/bin/default-model-output-budget-policy.cjs +0 -28
- package/bin/durable-task-resume-policy.cjs +0 -130
- package/bin/durable-task-resume-runtime.cjs +0 -117
- package/bin/durable-task-resume-store.cjs +0 -88
- package/bin/editable-tool-approval-policy.cjs +0 -540
- package/bin/editable-tool-approval-runtime.cjs +0 -99
- package/bin/effective-system-prompt-cache-policy.cjs +0 -33
- package/bin/error-memory-performance-policy.cjs +0 -113
- package/bin/file-observation-policy.cjs +0 -133
- package/bin/foreground-output-capture-policy.cjs +0 -41
- package/bin/generated-source-health.cjs +0 -142
- package/bin/glob-pattern-policy.cjs +0 -13
- package/bin/goal-completion-evidence-policy.cjs +0 -120
- package/bin/grep-output-limit-policy.cjs +0 -39
- package/bin/historical-media-projection-policy.cjs +0 -48
- package/bin/history-offload-pressure-policy.cjs +0 -33
- package/bin/html-to-research-markdown.cjs +0 -147
- package/bin/identity-context-policy.cjs +0 -764
- package/bin/identity-journal-policy.cjs +0 -107
- package/bin/input-draft-persistence.cjs +0 -77
- package/bin/king-tui-function-contract.json +0 -33
- package/bin/launcher-restart-policy.cjs +0 -150
- package/bin/live-response-repetition-guard.cjs +0 -196
- package/bin/llm-config-log-dedup-policy.cjs +0 -76
- package/bin/loop-event-record-policy.cjs +0 -174
- package/bin/managed-context-startup-policy.cjs +0 -27
- package/bin/media-activity-layout-policy.cjs +0 -34
- package/bin/media-auto-retrieval-policy.cjs +0 -90
- package/bin/media-result-policy.cjs +0 -59
- package/bin/micro-compaction-policy.cjs +0 -145
- package/bin/mistake-relevance-policy.cjs +0 -319
- package/bin/model-retry-progress-policy.cjs +0 -46
- package/bin/native-large-file-io.cjs +0 -42
- package/bin/native-runtime-cache.cjs +0 -76
- package/bin/natural-presence-policy.cjs +0 -28
- package/bin/noninteractive-shell-env-policy.cjs +0 -19
- package/bin/observer-hooks.cjs +0 -14
- package/bin/outbound-claim-provenance.cjs +0 -150
- package/bin/oversized-context-offload-policy.cjs +0 -86
- package/bin/pending-media-policy.cjs +0 -182
- package/bin/pending-token-estimate-policy.cjs +0 -41
- package/bin/personal-memory-consent-policy.cjs +0 -72
- package/bin/personal-memory-performance-policy.cjs +0 -12
- package/bin/personality-choice-policy.cjs +0 -101
- package/bin/personality-memory-adapter.cjs +0 -379
- package/bin/personality-mode.cjs +0 -46
- package/bin/personality-setup-policy.cjs +0 -197
- package/bin/proactive-compaction-policy.cjs +0 -25
- package/bin/profile-identity-resolution.cjs +0 -136
- package/bin/profile-runtime.cjs +0 -318
- package/bin/profile-tool-exclusion-policy.cjs +0 -37
- package/bin/programmatic-context-isolation.cjs +0 -25
- package/bin/programmatic-tool-runtime.mjs +0 -627
- package/bin/provider-idle-timeout-policy.cjs +0 -14
- package/bin/provider-model-refresh-deadline.cjs +0 -53
- package/bin/provider-model-refresh-policy.cjs +0 -107
- package/bin/rate-limit-recovery-policy.cjs +0 -47
- package/bin/read-batch-policy.cjs +0 -32
- package/bin/read-continuation-policy.cjs +0 -59
- package/bin/recurring-cron-history-policy.cjs +0 -124
- package/bin/relationship-continuity-policy.cjs +0 -143
- package/bin/relationship-curiosity-policy.cjs +0 -107
- package/bin/relationship-learning-policy.cjs +0 -168
- package/bin/release-artifact-freeze-policy.cjs +0 -30
- package/bin/reload-plugin-bootstrap.cjs +0 -18
- package/bin/reload-queue-policy.cjs +0 -38
- package/bin/repeated-assistant-response-policy.cjs +0 -232
- package/bin/repeated-injection-projection.cjs +0 -107
- package/bin/repeated-user-message-projection.cjs +0 -8
- package/bin/research-page-result.cjs +0 -74
- package/bin/retry-checkpoint-policy.cjs +0 -13
- package/bin/runtime-exit-ledger.cjs +0 -144
- package/bin/scoped-cron-run-policy.cjs +0 -358
- package/bin/session-checkpoint-policy.cjs +0 -25
- package/bin/session-compaction-policy.cjs +0 -84
- package/bin/session-replay-policy.cjs +0 -20
- package/bin/session-replay-window-policy.cjs +0 -40
- package/bin/session-resume-checkpoint.cjs +0 -254
- package/bin/session-scrollback-archive.cjs +0 -229
- package/bin/skill-activation-performance-policy.cjs +0 -69
- package/bin/skill-listing-performance-policy.cjs +0 -92
- package/bin/soul-organization-policy.cjs +0 -78
- package/bin/soul-preservation-policy.cjs +0 -20
- package/bin/startup-preferences.cjs +0 -131
- package/bin/streaming-flush-performance-policy.cjs +0 -28
- package/bin/structured-agent-swarm-output.cjs +0 -325
- package/bin/structured-subagent-output.cjs +0 -252
- package/bin/subagent-context-fork-policy.cjs +0 -155
- package/bin/subagent-max-tokens-handoff-policy.cjs +0 -69
- package/bin/subagent-parent-responsiveness.cjs +0 -19
- package/bin/subagent-skill-policy.cjs +0 -206
- package/bin/subagent-timeout-policy.cjs +0 -182
- package/bin/subagent-tool-policy.cjs +0 -60
- package/bin/subagent-usage-rollup-policy.cjs +0 -29
- package/bin/system-prompt-context-policy.cjs +0 -124
- package/bin/system-prompt-token-cache-policy.cjs +0 -60
- package/bin/telegram-addressed-focus.cjs +0 -55
- package/bin/telegram-addressed-priority.cjs +0 -12
- package/bin/telegram-approval-relay.cjs +0 -290
- package/bin/telegram-bot-priority.cjs +0 -17
- package/bin/telegram-console-status-policy.cjs +0 -174
- package/bin/telegram-context-projection-policy.cjs +0 -141
- package/bin/telegram-delivery-lifecycle.cjs +0 -125
- package/bin/telegram-direct-focus-policy.cjs +0 -273
- package/bin/telegram-mcp-compatibility.cjs +0 -49
- package/bin/telegram-media-delivery-policy.cjs +0 -42
- package/bin/telegram-private-conversation-policy.cjs +0 -185
- package/bin/telegram-queue-handoff-policy.cjs +0 -73
- package/bin/telegram-remote-status-policy.cjs +0 -120
- package/bin/telegram-session-queue-runtime.mjs +0 -306
- package/bin/telegram-text-chunk-policy.cjs +0 -63
- package/bin/telegram-truncated-reply-policy.cjs +0 -37
- package/bin/telegram-urgent-policy.cjs +0 -45
- package/bin/telemetry-spool-policy.cjs +0 -57
- package/bin/thinking-activity-status-policy.cjs +0 -132
- package/bin/thinking-only-guard.cjs +0 -80
- package/bin/todo-list-turn-policy.cjs +0 -131
- package/bin/tool-call-loop-policy.cjs +0 -51
- package/bin/tool-file-persistence.cjs +0 -141
- package/bin/tool-result-offload-policy.cjs +0 -359
- package/bin/tool-result-offload-telemetry.cjs +0 -12
- package/bin/tool-schema-token-cache-policy.cjs +0 -41
- package/bin/tool-stream-preview-policy.cjs +0 -9
- package/bin/tui-functional-contract.cjs +0 -55
- package/bin/turn-tool-performance-policy.cjs +0 -486
- package/bin/usage-cache-efficiency-policy.cjs +0 -26
- package/bin/user-home-path-policy.cjs +0 -13
- package/bin/user-message-offload-policy.cjs +0 -103
- package/bin/user-prompt-hook-origin-policy.cjs +0 -34
- package/bin/user-tool-record-policy.cjs +0 -7
- package/bin/validated-learning-insight-policy.cjs +0 -58
- package/bin/validated-learning-outcome-trace.cjs +0 -107
- package/bin/validated-learning-performance-policy.cjs +0 -53
- package/bin/validated-learning-signal.cjs +0 -463
- package/bin/windows-bash-dialect-policy.cjs +0 -25
- package/bin/windows-node-crash-dump.cjs +0 -110
- package/bin/write-continuation-policy.cjs +0 -69
- package/codebase-index/README.md +0 -82
- package/codebase-index/codebase_index.py +0 -470
- package/standard-skills/agent-browser/SKILL.md +0 -19
- package/standard-skills/agent-browser/references/runtime.md +0 -8
- package/standard-skills/blun-session-inspector/SKILL.md +0 -41
- package/standard-skills/blun-session-inspector/scripts/inspect-session.cjs +0 -437
- package/standard-skills/design-taste-frontend/SKILL.md +0 -1206
- package/standard-skills/full-output-enforcement/SKILL.md +0 -49
- package/standard-skills/high-end-visual-design/SKILL.md +0 -98
- package/standard-skills/image-to-code/SKILL.md +0 -1228
- package/standard-skills/industrial-brutalist-ui/SKILL.md +0 -92
- package/standard-skills/minimalist-ui/SKILL.md +0 -85
- package/standard-skills/motion-design-taste/SKILL.md +0 -74
- package/standard-skills/playwright-testing/SKILL.md +0 -19
- package/standard-skills/playwright-testing/references/runtime.md +0 -7
- package/standard-skills/premortem/SKILL.md +0 -148
- package/standard-skills/redesign-existing-projects/SKILL.md +0 -178
- package/standard-skills/research-evidence/SKILL.md +0 -39
- package/standard-skills/research-evidence/references/evidence-format.md +0 -104
- package/standard-skills/research-evidence/scripts/evidence-collection.cjs +0 -260
- package/standard-skills/research-evidence/scripts/score-report.cjs +0 -130
- package/standard-skills/screenshot-lesen/SKILL.md +0 -52
- package/standard-skills/stitch-design-taste/DESIGN.md +0 -121
- package/standard-skills/stitch-design-taste/SKILL.md +0 -184
- package/standard-skills/telegram-channel/SKILL.md +0 -18
- package/standard-skills/telegram-channel/references/runtime.md +0 -7
- package/standard-skills/venture-flywheel/SKILL.md +0 -32
- package/standard-skills/venture-flywheel/identity/project-identity.cjs +0 -146
- package/standard-skills/venture-flywheel/policy/capability-engine.cjs +0 -114
- package/standard-skills/venture-flywheel/policy/repository-trust.cjs +0 -229
- package/standard-skills/venture-flywheel/references/BEISPIELE-phase0.md +0 -146
- package/standard-skills/venture-flywheel/references/CAPABILITY-MAP.md +0 -34
- package/standard-skills/venture-flywheel/references/SPEC-phase0-identity-trust.md +0 -77
- package/standard-skills/venture-flywheel/references/SPEC-phase0-state-events.md +0 -93
- package/standard-skills/venture-flywheel/schemas/capability-decision.schema.json +0 -13
- package/standard-skills/venture-flywheel/schemas/execution-event.schema.json +0 -44
- package/standard-skills/venture-flywheel/schemas/project-identity.schema.json +0 -32
- package/standard-skills/venture-flywheel/schemas/repository-trust.schema.json +0 -57
- package/standard-skills/venture-flywheel/schemas/run-transition.schema.json +0 -59
- package/standard-skills/venture-flywheel/state/execution-event.cjs +0 -191
- package/standard-skills/venture-flywheel/state/task-state-machine.cjs +0 -190
- package/standard-skills/web-lesen/SKILL.md +0 -73
- package/standard-skills/web-lesen/scripts/crawl_public.py +0 -379
- package/standard-skills/windows-mcp/SKILL.md +0 -19
- package/standard-skills/windows-mcp/references/runtime.md +0 -9
- package/telegram-plugin/DELIVERY.md +0 -36
- package/telegram-plugin/bin/telegram-approval-relay.cjs +0 -290
- package/telegram-plugin/bin/telegram-console-status-policy.cjs +0 -175
- package/telegram-plugin/bin/telegram-delivery-lifecycle.cjs +0 -125
- package/telegram-plugin/bin/telegram-direct-reply-policy.cjs +0 -48
- package/telegram-plugin/bin/telegram-launcher-status-queue.cjs +0 -122
- package/telegram-plugin/bin/telegram-private-conversation-policy.cjs +0 -186
- package/telegram-plugin/bin/telegram-remote-status-policy.cjs +0 -121
- package/telegram-plugin/bin/telegram-reply-parts.cjs +0 -149
- package/telegram-plugin/bin/telegram-text-chunk-policy.cjs +0 -63
- package/telegram-plugin/bin/telegram-typing-keepalive.cjs +0 -89
- package/telegram-plugin/compat/mcp-server-fa511cd1.mjs +0 -73825
- /package/{bin → scripts}/fix-node-pty-perms.js +0 -0
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Durable HTTPS/WSGI receiver for source-CMS terminal notifications.
|
|
3
|
+
|
|
4
|
+
The receiver stores the exact content-free notification before returning its
|
|
5
|
+
acknowledgement. Replays converge on the stored row; changed data under either
|
|
6
|
+
the event or notification identity fails closed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import sqlite3
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import urllib.parse
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from types import MappingProxyType
|
|
24
|
+
from typing import Any, Callable, Iterator, Mapping
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
NOTIFICATION_SCHEMA = "blun.cms-source-terminal-notification.v1"
|
|
28
|
+
ACK_SCHEMA = "blun.cms-source-terminal-notification-ack.v1"
|
|
29
|
+
PROCESSING_ACK_SCHEMA = "blun.cms-terminal-notification-processing-ack.v1"
|
|
30
|
+
AUTH_SCHEMA = "blun.cms-source-terminal-notification-http-auth.v1"
|
|
31
|
+
PRINCIPAL_SCHEMA = "blun.cms-source-terminal-notification-principal.v1"
|
|
32
|
+
ERROR_SCHEMA = "blun.cms-source-terminal-notification-http-error.v1"
|
|
33
|
+
API_SCHEMA = "blun.cms-terminal-receiver-api.v1"
|
|
34
|
+
CAPABILITIES_SCHEMA = "blun.cms-terminal-receiver-capabilities.v1"
|
|
35
|
+
CAPABILITIES_RESPONSE_SCHEMA = (
|
|
36
|
+
"blun.cms-terminal-receiver-capabilities-response.v1"
|
|
37
|
+
)
|
|
38
|
+
HEALTH_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-health.v1"
|
|
39
|
+
HEALTH_RESPONSE_FIELDS = (
|
|
40
|
+
"schema", "status", "runtime_state", "worker_state", "inbox_status",
|
|
41
|
+
"received", "processing_counts", "processing_due", "expired_leases",
|
|
42
|
+
"failed", "error_code",
|
|
43
|
+
)
|
|
44
|
+
WRITE_SCOPE = "terminal-notification:write"
|
|
45
|
+
STATUS_SCOPE = "terminal-notification-status:read"
|
|
46
|
+
READINESS_SCOPE = "terminal-notification-readiness:read"
|
|
47
|
+
CAPABILITIES_SCOPE = "terminal-notification-capabilities:read"
|
|
48
|
+
HEALTH_SCOPE = "terminal-notification-health:read"
|
|
49
|
+
DEFAULT_PATH = "/v1/localization/terminal-notifications"
|
|
50
|
+
STATUS_PATH = DEFAULT_PATH + "/status"
|
|
51
|
+
READINESS_PATH = DEFAULT_PATH + "/readiness"
|
|
52
|
+
CAPABILITIES_PATH = DEFAULT_PATH + "/capabilities"
|
|
53
|
+
HEALTH_PATH = DEFAULT_PATH + "/health"
|
|
54
|
+
STATUS_REQUEST_SCHEMA = "blun.cms-terminal-receiver-status-request.v1"
|
|
55
|
+
STATUS_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-status-response.v1"
|
|
56
|
+
READINESS_RESPONSE_SCHEMA = "blun.cms-terminal-receiver-readiness.v1"
|
|
57
|
+
MAX_BODY_BYTES = 16_384
|
|
58
|
+
MAX_HEADERS = 64
|
|
59
|
+
MAX_HEADER_VALUE_LENGTH = 4_096
|
|
60
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
61
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
62
|
+
NOTIFICATION_ID = re.compile(r"^terminal-[a-f0-9]{64}$")
|
|
63
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
64
|
+
SCHEMA_VERSION = 2
|
|
65
|
+
PROCESSING_STATUSES = ("pending", "leased", "retry_wait", "succeeded", "failed")
|
|
66
|
+
TERMINAL_STATUSES = {
|
|
67
|
+
"cancelled", "deleted", "deletion_failed", "localization_failed",
|
|
68
|
+
"publication_blocked", "publication_failed", "published", "superseded",
|
|
69
|
+
}
|
|
70
|
+
NOTIFICATION_FIELDS = {
|
|
71
|
+
"schema", "notification_id", "event_id", "site_id", "plan_id",
|
|
72
|
+
"website_version", "source_sequence", "job_count", "change_sha256",
|
|
73
|
+
"lifecycle_binding_sha256", "terminal_status", "lifecycle_sha256",
|
|
74
|
+
}
|
|
75
|
+
PRINCIPAL_FIELDS = {
|
|
76
|
+
"schema", "principal_id", "credential_id", "credential_version", "scope",
|
|
77
|
+
"site_id",
|
|
78
|
+
}
|
|
79
|
+
_COLUMNS = (
|
|
80
|
+
"notification_id", "event_id", "site_id", "terminal_status",
|
|
81
|
+
"payload_sha256", "payload_json", "received_at",
|
|
82
|
+
)
|
|
83
|
+
_PROCESSING_COLUMNS = (
|
|
84
|
+
"notification_id", "status", "attempts", "max_attempts",
|
|
85
|
+
"next_attempt_at", "lease_owner", "lease_token", "lease_expires_at",
|
|
86
|
+
"last_error_code", "processed_at", "created_at", "updated_at",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TerminalNotificationReceiverBlocked(RuntimeError):
|
|
91
|
+
"""Stable, content-free receiver failure."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, code: str, status: int):
|
|
94
|
+
super().__init__(code)
|
|
95
|
+
self.code = code
|
|
96
|
+
self.status = status
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TerminalNotificationProcessingFailure(RuntimeError):
|
|
100
|
+
"""Public host-handler failure requesting a bounded processing retry."""
|
|
101
|
+
|
|
102
|
+
cms_terminal_processing_failure = True
|
|
103
|
+
|
|
104
|
+
def __init__(self, code: str, *, retryable: bool):
|
|
105
|
+
if ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool):
|
|
106
|
+
raise ValueError("invalid terminal processing failure")
|
|
107
|
+
super().__init__(code)
|
|
108
|
+
self.code = code
|
|
109
|
+
self.retryable = retryable
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class ReceivedTerminalNotification:
|
|
114
|
+
notification_id: str
|
|
115
|
+
event_id: str
|
|
116
|
+
site_id: str
|
|
117
|
+
terminal_status: str
|
|
118
|
+
payload_sha256: str
|
|
119
|
+
received_at: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class TerminalNotificationInboxHealth:
|
|
124
|
+
status: str
|
|
125
|
+
received: int
|
|
126
|
+
counts: dict[str, int]
|
|
127
|
+
due: int
|
|
128
|
+
expired_leases: int
|
|
129
|
+
failed: int
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class TerminalNotificationProcessingClaim:
|
|
134
|
+
notification_id: str
|
|
135
|
+
event_id: str
|
|
136
|
+
site_id: str
|
|
137
|
+
terminal_status: str
|
|
138
|
+
payload_sha256: str
|
|
139
|
+
attempt: int
|
|
140
|
+
max_attempts: int
|
|
141
|
+
worker_id: str
|
|
142
|
+
lease_token: str
|
|
143
|
+
lease_expires_at: float
|
|
144
|
+
payload: Mapping[str, Any] = field(repr=False, compare=False)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class TerminalNotificationProcessingStatus:
|
|
149
|
+
notification_id: str
|
|
150
|
+
event_id: str
|
|
151
|
+
site_id: str
|
|
152
|
+
terminal_status: str
|
|
153
|
+
payload_sha256: str
|
|
154
|
+
status: str
|
|
155
|
+
attempts: int
|
|
156
|
+
max_attempts: int
|
|
157
|
+
next_attempt_at: float
|
|
158
|
+
lease_expires_at: float | None
|
|
159
|
+
lease_expired: bool
|
|
160
|
+
last_error_code: str | None
|
|
161
|
+
processed_at: float | None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class TerminalNotificationProcessingOutcome:
|
|
166
|
+
notification_id: str
|
|
167
|
+
event_id: str
|
|
168
|
+
status: str
|
|
169
|
+
attempt: int
|
|
170
|
+
error_code: str | None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _blocked(code: str, status: int) -> None:
|
|
174
|
+
raise TerminalNotificationReceiverBlocked("notification_receiver." + code, status)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _canonical(value: Any) -> bytes:
|
|
178
|
+
try:
|
|
179
|
+
result = json.dumps(
|
|
180
|
+
value, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
181
|
+
separators=(",", ":"),
|
|
182
|
+
).encode("utf-8")
|
|
183
|
+
except (TypeError, ValueError, RecursionError):
|
|
184
|
+
_blocked("request_invalid", 400)
|
|
185
|
+
if not result or len(result) > MAX_BODY_BYTES:
|
|
186
|
+
_blocked("request_invalid", 400)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
191
|
+
result: dict[str, Any] = {}
|
|
192
|
+
for key, value in items:
|
|
193
|
+
if key in result:
|
|
194
|
+
raise ValueError("duplicate key")
|
|
195
|
+
result[key] = value
|
|
196
|
+
return result
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _constant(_value: str) -> None:
|
|
200
|
+
raise ValueError("non-finite number")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _token(value: Any) -> bool:
|
|
204
|
+
return isinstance(value, str) and TOKEN.fullmatch(value) is not None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _time(value: Any) -> float:
|
|
208
|
+
if (
|
|
209
|
+
isinstance(value, bool)
|
|
210
|
+
or not isinstance(value, (int, float))
|
|
211
|
+
or not math.isfinite(float(value))
|
|
212
|
+
or float(value) < 0
|
|
213
|
+
):
|
|
214
|
+
_blocked("clock_invalid", 503)
|
|
215
|
+
return float(value)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _duration(value: Any, code: str) -> float:
|
|
219
|
+
result = _time(value)
|
|
220
|
+
if not 0 < result <= 86_400:
|
|
221
|
+
_blocked(code, 503)
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _error(value: Any) -> bool:
|
|
226
|
+
return isinstance(value, str) and ERROR_CODE.fullmatch(value) is not None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _origin(value: Any) -> str:
|
|
230
|
+
if (
|
|
231
|
+
not isinstance(value, str)
|
|
232
|
+
or value != value.strip()
|
|
233
|
+
or len(value) > 2_048
|
|
234
|
+
or any(ord(character) <= 32 or ord(character) == 127 for character in value)
|
|
235
|
+
):
|
|
236
|
+
raise ValueError("origin must be one exact HTTPS origin")
|
|
237
|
+
try:
|
|
238
|
+
parsed = urllib.parse.urlsplit(value)
|
|
239
|
+
hostname = parsed.hostname
|
|
240
|
+
port = parsed.port
|
|
241
|
+
except ValueError:
|
|
242
|
+
raise ValueError("origin must be one exact HTTPS origin") from None
|
|
243
|
+
if (
|
|
244
|
+
parsed.scheme != "https"
|
|
245
|
+
or not hostname
|
|
246
|
+
or parsed.username is not None
|
|
247
|
+
or parsed.password is not None
|
|
248
|
+
or parsed.path
|
|
249
|
+
or parsed.query
|
|
250
|
+
or parsed.fragment
|
|
251
|
+
or (port is not None and not 1 <= port <= 65_535)
|
|
252
|
+
or value != f"https://{parsed.netloc}"
|
|
253
|
+
):
|
|
254
|
+
raise ValueError("origin must be one exact HTTPS origin")
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _parse_notification(body: bytes) -> tuple[dict[str, Any], str]:
|
|
259
|
+
if not isinstance(body, bytes) or not body or len(body) > MAX_BODY_BYTES:
|
|
260
|
+
_blocked("body_invalid", 400)
|
|
261
|
+
try:
|
|
262
|
+
text = body.decode("utf-8")
|
|
263
|
+
if text.startswith("\ufeff"):
|
|
264
|
+
raise ValueError("BOM rejected")
|
|
265
|
+
value = json.loads(
|
|
266
|
+
text, object_pairs_hook=_pairs, parse_constant=_constant,
|
|
267
|
+
)
|
|
268
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
|
|
269
|
+
_blocked("json_invalid", 400)
|
|
270
|
+
if not isinstance(value, dict) or set(value) != NOTIFICATION_FIELDS:
|
|
271
|
+
_blocked("request_invalid", 400)
|
|
272
|
+
if _canonical(value) != body:
|
|
273
|
+
_blocked("canonical_body_required", 400)
|
|
274
|
+
lifecycle_sha = value.get("lifecycle_sha256")
|
|
275
|
+
status = value.get("terminal_status")
|
|
276
|
+
if not (
|
|
277
|
+
value.get("schema") == NOTIFICATION_SCHEMA
|
|
278
|
+
and all(
|
|
279
|
+
_token(value.get(name))
|
|
280
|
+
for name in ("event_id", "site_id", "plan_id", "website_version")
|
|
281
|
+
)
|
|
282
|
+
and all(
|
|
283
|
+
isinstance(value.get(name), int)
|
|
284
|
+
and not isinstance(value.get(name), bool)
|
|
285
|
+
and value[name] > 0
|
|
286
|
+
for name in ("source_sequence", "job_count")
|
|
287
|
+
)
|
|
288
|
+
and all(
|
|
289
|
+
isinstance(value.get(name), str)
|
|
290
|
+
and SHA256.fullmatch(value[name]) is not None
|
|
291
|
+
for name in ("change_sha256", "lifecycle_binding_sha256")
|
|
292
|
+
)
|
|
293
|
+
and status in TERMINAL_STATUSES
|
|
294
|
+
and (
|
|
295
|
+
status in {"cancelled", "superseded"} and lifecycle_sha is None
|
|
296
|
+
or status not in {"cancelled", "superseded"}
|
|
297
|
+
and isinstance(lifecycle_sha, str)
|
|
298
|
+
and SHA256.fullmatch(lifecycle_sha) is not None
|
|
299
|
+
)
|
|
300
|
+
):
|
|
301
|
+
_blocked("request_invalid", 400)
|
|
302
|
+
identity_source = dict(value)
|
|
303
|
+
notification_id = identity_source.pop("notification_id", None)
|
|
304
|
+
expected_id = "terminal-" + hashlib.sha256(_canonical(identity_source)).hexdigest()
|
|
305
|
+
if (
|
|
306
|
+
not isinstance(notification_id, str)
|
|
307
|
+
or NOTIFICATION_ID.fullmatch(notification_id) is None
|
|
308
|
+
or notification_id != expected_id
|
|
309
|
+
):
|
|
310
|
+
_blocked("request_invalid", 400)
|
|
311
|
+
return value, hashlib.sha256(body).hexdigest()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _principal(
|
|
315
|
+
value: Any,
|
|
316
|
+
site_id: str | None,
|
|
317
|
+
scope: str = WRITE_SCOPE,
|
|
318
|
+
) -> dict[str, str]:
|
|
319
|
+
if not isinstance(value, Mapping) or set(value) != PRINCIPAL_FIELDS:
|
|
320
|
+
_blocked("authentication_failed", 401)
|
|
321
|
+
if not (
|
|
322
|
+
value.get("schema") == PRINCIPAL_SCHEMA
|
|
323
|
+
and value.get("scope") == scope
|
|
324
|
+
and (site_id is None or value.get("site_id") == site_id)
|
|
325
|
+
and all(
|
|
326
|
+
_token(value.get(name))
|
|
327
|
+
for name in (
|
|
328
|
+
"principal_id", "credential_id", "credential_version", "site_id",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
):
|
|
332
|
+
_blocked("authorization_failed", 403)
|
|
333
|
+
return dict(value)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def capabilities_payload(notification_path: str = DEFAULT_PATH) -> dict[str, Any]:
|
|
337
|
+
"""Return the exact, content-free receiver contract for integrations."""
|
|
338
|
+
|
|
339
|
+
expected_controls = {
|
|
340
|
+
"capabilities": {
|
|
341
|
+
"method": "GET",
|
|
342
|
+
"path": CAPABILITIES_PATH,
|
|
343
|
+
"scope": CAPABILITIES_SCOPE,
|
|
344
|
+
"request_schema": None,
|
|
345
|
+
"request_fields": [],
|
|
346
|
+
"response_schema": CAPABILITIES_RESPONSE_SCHEMA,
|
|
347
|
+
"response_fields": ["schema", "capabilities"],
|
|
348
|
+
"success_status": 200,
|
|
349
|
+
},
|
|
350
|
+
"health": {
|
|
351
|
+
"method": "GET",
|
|
352
|
+
"path": HEALTH_PATH,
|
|
353
|
+
"scope": HEALTH_SCOPE,
|
|
354
|
+
"request_schema": None,
|
|
355
|
+
"request_fields": [],
|
|
356
|
+
"response_schema": HEALTH_RESPONSE_SCHEMA,
|
|
357
|
+
"response_fields": [
|
|
358
|
+
*HEALTH_RESPONSE_FIELDS, "capabilities_sha256",
|
|
359
|
+
],
|
|
360
|
+
"success_status": 200,
|
|
361
|
+
},
|
|
362
|
+
"readiness": {
|
|
363
|
+
"method": "GET",
|
|
364
|
+
"path": READINESS_PATH,
|
|
365
|
+
"scope": READINESS_SCOPE,
|
|
366
|
+
"request_schema": None,
|
|
367
|
+
"request_fields": [],
|
|
368
|
+
"response_schema": READINESS_RESPONSE_SCHEMA,
|
|
369
|
+
"response_fields": [
|
|
370
|
+
"schema", "status", "worker_state", "inbox_status",
|
|
371
|
+
"error_code", "capabilities_sha256",
|
|
372
|
+
],
|
|
373
|
+
"success_status": 200,
|
|
374
|
+
},
|
|
375
|
+
"status": {
|
|
376
|
+
"method": "POST",
|
|
377
|
+
"path": STATUS_PATH,
|
|
378
|
+
"scope": STATUS_SCOPE,
|
|
379
|
+
"request_schema": STATUS_REQUEST_SCHEMA,
|
|
380
|
+
"request_fields": ["schema", "event_id", "site_id"],
|
|
381
|
+
"response_schema": STATUS_RESPONSE_SCHEMA,
|
|
382
|
+
"response_fields": [
|
|
383
|
+
"schema", "notification_id", "event_id", "site_id",
|
|
384
|
+
"terminal_status", "notification_sha256",
|
|
385
|
+
"processing_status", "attempts", "max_attempts",
|
|
386
|
+
"next_attempt_at", "lease_expires_at", "lease_expired",
|
|
387
|
+
"last_error_code", "processed_at",
|
|
388
|
+
"capabilities_sha256",
|
|
389
|
+
],
|
|
390
|
+
"success_status": 200,
|
|
391
|
+
},
|
|
392
|
+
}
|
|
393
|
+
if (
|
|
394
|
+
not isinstance(notification_path, str)
|
|
395
|
+
or notification_path in {
|
|
396
|
+
STATUS_PATH, READINESS_PATH, CAPABILITIES_PATH, HEALTH_PATH,
|
|
397
|
+
}
|
|
398
|
+
or len({
|
|
399
|
+
WRITE_SCOPE, STATUS_SCOPE, READINESS_SCOPE, CAPABILITIES_SCOPE,
|
|
400
|
+
HEALTH_SCOPE,
|
|
401
|
+
}) != 5
|
|
402
|
+
):
|
|
403
|
+
_blocked("capabilities_invalid", 503)
|
|
404
|
+
operations = {
|
|
405
|
+
**expected_controls,
|
|
406
|
+
"notification": {
|
|
407
|
+
"method": "POST",
|
|
408
|
+
"path": notification_path,
|
|
409
|
+
"scope": WRITE_SCOPE,
|
|
410
|
+
"request_schema": NOTIFICATION_SCHEMA,
|
|
411
|
+
"request_fields": [
|
|
412
|
+
"schema", "notification_id", "event_id", "site_id",
|
|
413
|
+
"plan_id", "website_version", "source_sequence",
|
|
414
|
+
"job_count", "change_sha256", "lifecycle_binding_sha256",
|
|
415
|
+
"terminal_status", "lifecycle_sha256",
|
|
416
|
+
],
|
|
417
|
+
"response_schema": ACK_SCHEMA,
|
|
418
|
+
"response_fields": [
|
|
419
|
+
"schema", "notification_id", "event_id", "site_id",
|
|
420
|
+
"status", "notification_sha256",
|
|
421
|
+
],
|
|
422
|
+
"success_status": 200,
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
capabilities = {
|
|
426
|
+
"schema": CAPABILITIES_SCHEMA,
|
|
427
|
+
"api_schema": API_SCHEMA,
|
|
428
|
+
"authentication_request_schema": AUTH_SCHEMA,
|
|
429
|
+
"principal_schema": PRINCIPAL_SCHEMA,
|
|
430
|
+
"error_schema": ERROR_SCHEMA,
|
|
431
|
+
"limits": {
|
|
432
|
+
"max_body_bytes": MAX_BODY_BYTES,
|
|
433
|
+
"max_headers": MAX_HEADERS,
|
|
434
|
+
"max_header_value_bytes": MAX_HEADER_VALUE_LENGTH,
|
|
435
|
+
"processing_max_attempts_min": 1,
|
|
436
|
+
"processing_max_attempts_max": 20,
|
|
437
|
+
},
|
|
438
|
+
"processing_statuses": list(PROCESSING_STATUSES),
|
|
439
|
+
"terminal_statuses": sorted(TERMINAL_STATUSES),
|
|
440
|
+
"operations": operations,
|
|
441
|
+
}
|
|
442
|
+
try:
|
|
443
|
+
if set(operations) != {
|
|
444
|
+
"capabilities", "health", "notification", "readiness", "status",
|
|
445
|
+
}:
|
|
446
|
+
raise ValueError
|
|
447
|
+
if set(NOTIFICATION_FIELDS) != set(
|
|
448
|
+
operations["notification"]["request_fields"]
|
|
449
|
+
):
|
|
450
|
+
raise ValueError
|
|
451
|
+
encoded = json.dumps(
|
|
452
|
+
capabilities, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
453
|
+
separators=(",", ":"),
|
|
454
|
+
).encode("utf-8")
|
|
455
|
+
if not encoded or len(encoded) > MAX_BODY_BYTES:
|
|
456
|
+
raise ValueError
|
|
457
|
+
except (TypeError, ValueError, RecursionError):
|
|
458
|
+
_blocked("capabilities_invalid", 503)
|
|
459
|
+
return {
|
|
460
|
+
**capabilities,
|
|
461
|
+
"sha256": hashlib.sha256(encoded).hexdigest(),
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@contextmanager
|
|
466
|
+
def _transaction(connection: sqlite3.Connection) -> Iterator[None]:
|
|
467
|
+
if connection.in_transaction:
|
|
468
|
+
_blocked("transaction_nested", 503)
|
|
469
|
+
try:
|
|
470
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
471
|
+
yield
|
|
472
|
+
connection.commit()
|
|
473
|
+
except Exception:
|
|
474
|
+
if connection.in_transaction:
|
|
475
|
+
connection.rollback()
|
|
476
|
+
raise
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class DurableCMSTerminalNotificationInbox:
|
|
480
|
+
"""One process-owned, serialized SQLite terminal-notification ledger."""
|
|
481
|
+
|
|
482
|
+
def __init__(
|
|
483
|
+
self,
|
|
484
|
+
connection: sqlite3.Connection,
|
|
485
|
+
*,
|
|
486
|
+
database_guard: Callable[[], None] | None = None,
|
|
487
|
+
processing_max_attempts: int = 5,
|
|
488
|
+
processing_base_delay_seconds: float | int = 5,
|
|
489
|
+
processing_max_delay_seconds: float | int = 300,
|
|
490
|
+
):
|
|
491
|
+
if not isinstance(connection, sqlite3.Connection):
|
|
492
|
+
raise TypeError("connection must be sqlite3.Connection")
|
|
493
|
+
if database_guard is not None and not callable(database_guard):
|
|
494
|
+
raise TypeError("database_guard must be callable")
|
|
495
|
+
if (
|
|
496
|
+
isinstance(processing_max_attempts, bool)
|
|
497
|
+
or not isinstance(processing_max_attempts, int)
|
|
498
|
+
or not 1 <= processing_max_attempts <= 20
|
|
499
|
+
):
|
|
500
|
+
raise ValueError("processing_max_attempts must be between 1 and 20")
|
|
501
|
+
processing_base_delay_seconds = _duration(
|
|
502
|
+
processing_base_delay_seconds, "processing_delay_invalid"
|
|
503
|
+
)
|
|
504
|
+
processing_max_delay_seconds = _duration(
|
|
505
|
+
processing_max_delay_seconds, "processing_delay_invalid"
|
|
506
|
+
)
|
|
507
|
+
if processing_base_delay_seconds > processing_max_delay_seconds:
|
|
508
|
+
raise ValueError("processing delay range is invalid")
|
|
509
|
+
self.connection = connection
|
|
510
|
+
self.connection.row_factory = sqlite3.Row
|
|
511
|
+
self._lock = threading.RLock()
|
|
512
|
+
self._owner_pid = os.getpid()
|
|
513
|
+
self._database_guard = database_guard or (lambda: None)
|
|
514
|
+
self.processing_max_attempts = processing_max_attempts
|
|
515
|
+
self.processing_base_delay_seconds = processing_base_delay_seconds
|
|
516
|
+
self.processing_max_delay_seconds = processing_max_delay_seconds
|
|
517
|
+
self._initialize()
|
|
518
|
+
|
|
519
|
+
def _owner(self) -> None:
|
|
520
|
+
if os.getpid() != self._owner_pid:
|
|
521
|
+
_blocked("foreign_process", 503)
|
|
522
|
+
|
|
523
|
+
def _initialize(self) -> None:
|
|
524
|
+
with self._lock:
|
|
525
|
+
self._guard()
|
|
526
|
+
with _transaction(self.connection):
|
|
527
|
+
tables = {
|
|
528
|
+
row[0] for row in self.connection.execute(
|
|
529
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
|
530
|
+
"AND name LIKE 'cms_terminal_notification_%'"
|
|
531
|
+
).fetchall()
|
|
532
|
+
}
|
|
533
|
+
if not tables:
|
|
534
|
+
self._create_schema()
|
|
535
|
+
elif tables == {
|
|
536
|
+
"cms_terminal_notification_inbox_meta",
|
|
537
|
+
"cms_terminal_notification_inbox",
|
|
538
|
+
}:
|
|
539
|
+
self._migrate_v1()
|
|
540
|
+
self._validate_schema()
|
|
541
|
+
|
|
542
|
+
def _guard(self) -> None:
|
|
543
|
+
try:
|
|
544
|
+
self._database_guard()
|
|
545
|
+
except TerminalNotificationReceiverBlocked:
|
|
546
|
+
raise
|
|
547
|
+
except Exception:
|
|
548
|
+
_blocked("database_unsafe", 503)
|
|
549
|
+
|
|
550
|
+
def _create_schema(self) -> None:
|
|
551
|
+
self.connection.execute("""
|
|
552
|
+
CREATE TABLE IF NOT EXISTS cms_terminal_notification_inbox_meta (
|
|
553
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
554
|
+
schema_version INTEGER NOT NULL CHECK (schema_version = 2)
|
|
555
|
+
)
|
|
556
|
+
""")
|
|
557
|
+
self.connection.execute("""
|
|
558
|
+
INSERT OR IGNORE INTO cms_terminal_notification_inbox_meta
|
|
559
|
+
VALUES (1, 2)
|
|
560
|
+
""")
|
|
561
|
+
self.connection.execute("""
|
|
562
|
+
CREATE TABLE IF NOT EXISTS cms_terminal_notification_inbox (
|
|
563
|
+
notification_id TEXT PRIMARY KEY,
|
|
564
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
565
|
+
site_id TEXT NOT NULL,
|
|
566
|
+
terminal_status TEXT NOT NULL,
|
|
567
|
+
payload_sha256 TEXT NOT NULL UNIQUE,
|
|
568
|
+
payload_json TEXT NOT NULL,
|
|
569
|
+
received_at REAL NOT NULL
|
|
570
|
+
)
|
|
571
|
+
""")
|
|
572
|
+
self._create_processing_schema()
|
|
573
|
+
|
|
574
|
+
def _create_processing_schema(self) -> None:
|
|
575
|
+
self.connection.execute("""
|
|
576
|
+
CREATE TABLE cms_terminal_notification_processing (
|
|
577
|
+
notification_id TEXT PRIMARY KEY,
|
|
578
|
+
status TEXT NOT NULL CHECK (
|
|
579
|
+
status IN ('pending', 'leased', 'retry_wait',
|
|
580
|
+
'succeeded', 'failed')
|
|
581
|
+
),
|
|
582
|
+
attempts INTEGER NOT NULL CHECK (attempts >= 0),
|
|
583
|
+
max_attempts INTEGER NOT NULL CHECK (
|
|
584
|
+
max_attempts BETWEEN 1 AND 20
|
|
585
|
+
),
|
|
586
|
+
next_attempt_at REAL NOT NULL,
|
|
587
|
+
lease_owner TEXT,
|
|
588
|
+
lease_token TEXT,
|
|
589
|
+
lease_expires_at REAL,
|
|
590
|
+
last_error_code TEXT,
|
|
591
|
+
processed_at REAL,
|
|
592
|
+
created_at REAL NOT NULL,
|
|
593
|
+
updated_at REAL NOT NULL,
|
|
594
|
+
FOREIGN KEY (notification_id)
|
|
595
|
+
REFERENCES cms_terminal_notification_inbox(notification_id)
|
|
596
|
+
ON DELETE RESTRICT,
|
|
597
|
+
CHECK (
|
|
598
|
+
(status = 'leased' AND lease_owner IS NOT NULL
|
|
599
|
+
AND lease_token IS NOT NULL AND lease_expires_at IS NOT NULL)
|
|
600
|
+
OR
|
|
601
|
+
(status <> 'leased' AND lease_owner IS NULL
|
|
602
|
+
AND lease_token IS NULL AND lease_expires_at IS NULL)
|
|
603
|
+
)
|
|
604
|
+
)
|
|
605
|
+
""")
|
|
606
|
+
self.connection.execute("""
|
|
607
|
+
CREATE INDEX cms_terminal_notification_processing_due
|
|
608
|
+
ON cms_terminal_notification_processing (
|
|
609
|
+
status, next_attempt_at, created_at, notification_id
|
|
610
|
+
)
|
|
611
|
+
""")
|
|
612
|
+
|
|
613
|
+
def _migrate_v1(self) -> None:
|
|
614
|
+
self._validate_v1_schema()
|
|
615
|
+
rows = self.connection.execute(
|
|
616
|
+
"SELECT * FROM cms_terminal_notification_inbox "
|
|
617
|
+
"ORDER BY received_at, event_id"
|
|
618
|
+
).fetchall()
|
|
619
|
+
for row in rows:
|
|
620
|
+
self._validated_row(row)
|
|
621
|
+
self._create_processing_schema()
|
|
622
|
+
for row in rows:
|
|
623
|
+
self.connection.execute("""
|
|
624
|
+
INSERT INTO cms_terminal_notification_processing (
|
|
625
|
+
notification_id, status, attempts, max_attempts,
|
|
626
|
+
next_attempt_at, created_at, updated_at
|
|
627
|
+
) VALUES (?, 'pending', 0, ?, ?, ?, ?)
|
|
628
|
+
""", (
|
|
629
|
+
row["notification_id"], self.processing_max_attempts,
|
|
630
|
+
row["received_at"], row["received_at"], row["received_at"],
|
|
631
|
+
))
|
|
632
|
+
self.connection.execute(
|
|
633
|
+
"DROP TABLE cms_terminal_notification_inbox_meta"
|
|
634
|
+
)
|
|
635
|
+
self.connection.execute("""
|
|
636
|
+
CREATE TABLE cms_terminal_notification_inbox_meta (
|
|
637
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
638
|
+
schema_version INTEGER NOT NULL CHECK (schema_version = 2)
|
|
639
|
+
)
|
|
640
|
+
""")
|
|
641
|
+
self.connection.execute(
|
|
642
|
+
"INSERT INTO cms_terminal_notification_inbox_meta VALUES (1, 2)"
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
def _validate_v1_schema(self) -> None:
|
|
646
|
+
meta_columns = tuple(row["name"] for row in self.connection.execute(
|
|
647
|
+
"PRAGMA table_info(cms_terminal_notification_inbox_meta)"
|
|
648
|
+
).fetchall())
|
|
649
|
+
columns = tuple(row["name"] for row in self.connection.execute(
|
|
650
|
+
"PRAGMA table_info(cms_terminal_notification_inbox)"
|
|
651
|
+
).fetchall())
|
|
652
|
+
meta = self.connection.execute(
|
|
653
|
+
"SELECT singleton, schema_version "
|
|
654
|
+
"FROM cms_terminal_notification_inbox_meta"
|
|
655
|
+
).fetchall()
|
|
656
|
+
if (
|
|
657
|
+
meta_columns != ("singleton", "schema_version")
|
|
658
|
+
or columns != _COLUMNS
|
|
659
|
+
or len(meta) != 1
|
|
660
|
+
or tuple(meta[0]) != (1, 1)
|
|
661
|
+
):
|
|
662
|
+
_blocked("schema_altered", 503)
|
|
663
|
+
|
|
664
|
+
def _validate_schema(self) -> None:
|
|
665
|
+
meta_columns = tuple(row["name"] for row in self.connection.execute(
|
|
666
|
+
"PRAGMA table_info(cms_terminal_notification_inbox_meta)"
|
|
667
|
+
).fetchall())
|
|
668
|
+
columns = tuple(row["name"] for row in self.connection.execute(
|
|
669
|
+
"PRAGMA table_info(cms_terminal_notification_inbox)"
|
|
670
|
+
).fetchall())
|
|
671
|
+
processing_columns = tuple(
|
|
672
|
+
row["name"] for row in self.connection.execute(
|
|
673
|
+
"PRAGMA table_info(cms_terminal_notification_processing)"
|
|
674
|
+
).fetchall()
|
|
675
|
+
)
|
|
676
|
+
foreign_keys = [
|
|
677
|
+
tuple(row) for row in self.connection.execute(
|
|
678
|
+
"PRAGMA foreign_key_list(cms_terminal_notification_processing)"
|
|
679
|
+
).fetchall()
|
|
680
|
+
]
|
|
681
|
+
meta = self.connection.execute(
|
|
682
|
+
"SELECT singleton, schema_version "
|
|
683
|
+
"FROM cms_terminal_notification_inbox_meta"
|
|
684
|
+
).fetchall()
|
|
685
|
+
if (
|
|
686
|
+
meta_columns != ("singleton", "schema_version")
|
|
687
|
+
or columns != _COLUMNS
|
|
688
|
+
or processing_columns != _PROCESSING_COLUMNS
|
|
689
|
+
or foreign_keys != [(
|
|
690
|
+
0, 0, "cms_terminal_notification_inbox", "notification_id",
|
|
691
|
+
"notification_id", "NO ACTION", "RESTRICT", "NONE",
|
|
692
|
+
)]
|
|
693
|
+
or len(meta) != 1
|
|
694
|
+
or tuple(meta[0]) != (1, SCHEMA_VERSION)
|
|
695
|
+
):
|
|
696
|
+
_blocked("schema_altered", 503)
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _ack(payload: Mapping[str, Any], payload_sha256: str) -> dict[str, Any]:
|
|
700
|
+
return {
|
|
701
|
+
"schema": ACK_SCHEMA,
|
|
702
|
+
"notification_id": payload["notification_id"],
|
|
703
|
+
"event_id": payload["event_id"],
|
|
704
|
+
"site_id": payload["site_id"],
|
|
705
|
+
"status": "accepted",
|
|
706
|
+
"notification_sha256": payload_sha256,
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
@staticmethod
|
|
710
|
+
def _validated_row(row: sqlite3.Row) -> ReceivedTerminalNotification:
|
|
711
|
+
try:
|
|
712
|
+
parsed, payload_sha256 = _parse_notification(
|
|
713
|
+
row["payload_json"].encode("utf-8")
|
|
714
|
+
)
|
|
715
|
+
if not all(
|
|
716
|
+
row[name] == expected
|
|
717
|
+
for name, expected in {
|
|
718
|
+
"notification_id": parsed["notification_id"],
|
|
719
|
+
"event_id": parsed["event_id"],
|
|
720
|
+
"site_id": parsed["site_id"],
|
|
721
|
+
"terminal_status": parsed["terminal_status"],
|
|
722
|
+
"payload_sha256": payload_sha256,
|
|
723
|
+
}.items()
|
|
724
|
+
):
|
|
725
|
+
raise ValueError("binding mismatch")
|
|
726
|
+
received_at = _time(row["received_at"])
|
|
727
|
+
except TerminalNotificationReceiverBlocked:
|
|
728
|
+
_blocked("stored_binding_invalid", 503)
|
|
729
|
+
except Exception:
|
|
730
|
+
_blocked("stored_binding_invalid", 503)
|
|
731
|
+
return ReceivedTerminalNotification(
|
|
732
|
+
row["notification_id"], row["event_id"], row["site_id"],
|
|
733
|
+
row["terminal_status"], row["payload_sha256"], received_at,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
@staticmethod
|
|
737
|
+
def _validated_processing_row(
|
|
738
|
+
row: sqlite3.Row,
|
|
739
|
+
notification_row: sqlite3.Row,
|
|
740
|
+
) -> None:
|
|
741
|
+
try:
|
|
742
|
+
received = DurableCMSTerminalNotificationInbox._validated_row(
|
|
743
|
+
notification_row
|
|
744
|
+
)
|
|
745
|
+
status = row["status"]
|
|
746
|
+
attempts = row["attempts"]
|
|
747
|
+
max_attempts = row["max_attempts"]
|
|
748
|
+
next_attempt_at = _time(row["next_attempt_at"])
|
|
749
|
+
created_at = _time(row["created_at"])
|
|
750
|
+
updated_at = _time(row["updated_at"])
|
|
751
|
+
lease_values = (
|
|
752
|
+
row["lease_owner"], row["lease_token"], row["lease_expires_at"],
|
|
753
|
+
)
|
|
754
|
+
leased = status == "leased"
|
|
755
|
+
error = row["last_error_code"]
|
|
756
|
+
processed_at = row["processed_at"]
|
|
757
|
+
valid = (
|
|
758
|
+
row["notification_id"] == received.notification_id
|
|
759
|
+
and status in PROCESSING_STATUSES
|
|
760
|
+
and isinstance(attempts, int)
|
|
761
|
+
and not isinstance(attempts, bool)
|
|
762
|
+
and isinstance(max_attempts, int)
|
|
763
|
+
and not isinstance(max_attempts, bool)
|
|
764
|
+
and 0 <= attempts <= max_attempts <= 20
|
|
765
|
+
and max_attempts >= 1
|
|
766
|
+
and leased == all(value is not None for value in lease_values)
|
|
767
|
+
and (not leased or (
|
|
768
|
+
_token(row["lease_owner"])
|
|
769
|
+
and _token(row["lease_token"])
|
|
770
|
+
and _time(row["lease_expires_at"]) > updated_at
|
|
771
|
+
))
|
|
772
|
+
and (leased or all(value is None for value in lease_values))
|
|
773
|
+
and (status in {"retry_wait", "failed"}) == (error is not None)
|
|
774
|
+
and (error is None or _error(error))
|
|
775
|
+
and (status == "succeeded") == (processed_at is not None)
|
|
776
|
+
and (
|
|
777
|
+
processed_at is None
|
|
778
|
+
or _time(processed_at) >= created_at
|
|
779
|
+
)
|
|
780
|
+
and created_at == received.received_at
|
|
781
|
+
and next_attempt_at >= created_at
|
|
782
|
+
and updated_at >= created_at
|
|
783
|
+
and (status == "pending") == (attempts == 0)
|
|
784
|
+
and (status == "pending" or attempts > 0)
|
|
785
|
+
)
|
|
786
|
+
except Exception:
|
|
787
|
+
valid = False
|
|
788
|
+
if not valid:
|
|
789
|
+
_blocked("processing_state_invalid", 503)
|
|
790
|
+
|
|
791
|
+
def _processing_rows(self) -> list[tuple[sqlite3.Row, sqlite3.Row]]:
|
|
792
|
+
notifications = {
|
|
793
|
+
row["notification_id"]: row
|
|
794
|
+
for row in self.connection.execute(
|
|
795
|
+
"SELECT * FROM cms_terminal_notification_inbox"
|
|
796
|
+
).fetchall()
|
|
797
|
+
}
|
|
798
|
+
processing = self.connection.execute(
|
|
799
|
+
"SELECT * FROM cms_terminal_notification_processing"
|
|
800
|
+
).fetchall()
|
|
801
|
+
if len(notifications) != len(processing):
|
|
802
|
+
_blocked("processing_state_invalid", 503)
|
|
803
|
+
result = []
|
|
804
|
+
for row in processing:
|
|
805
|
+
notification = notifications.get(row["notification_id"])
|
|
806
|
+
if notification is None:
|
|
807
|
+
_blocked("processing_state_invalid", 503)
|
|
808
|
+
self._validated_processing_row(row, notification)
|
|
809
|
+
result.append((row, notification))
|
|
810
|
+
return result
|
|
811
|
+
|
|
812
|
+
def accept(
|
|
813
|
+
self,
|
|
814
|
+
payload: Mapping[str, Any],
|
|
815
|
+
body: bytes,
|
|
816
|
+
payload_sha256: str,
|
|
817
|
+
*,
|
|
818
|
+
now: float | int,
|
|
819
|
+
) -> dict[str, Any]:
|
|
820
|
+
self._owner()
|
|
821
|
+
now = _time(now)
|
|
822
|
+
parsed, actual_sha256 = _parse_notification(body)
|
|
823
|
+
if not isinstance(payload, Mapping) or not isinstance(payload_sha256, str):
|
|
824
|
+
_blocked("binding_invalid", 400)
|
|
825
|
+
try:
|
|
826
|
+
copied = dict(payload)
|
|
827
|
+
except Exception:
|
|
828
|
+
_blocked("binding_invalid", 400)
|
|
829
|
+
if (
|
|
830
|
+
copied != parsed
|
|
831
|
+
or payload_sha256 != actual_sha256
|
|
832
|
+
or SHA256.fullmatch(payload_sha256) is None
|
|
833
|
+
):
|
|
834
|
+
_blocked("binding_invalid", 400)
|
|
835
|
+
rendered = body.decode("utf-8")
|
|
836
|
+
with self._lock:
|
|
837
|
+
self._guard()
|
|
838
|
+
self._validate_schema()
|
|
839
|
+
try:
|
|
840
|
+
with _transaction(self.connection):
|
|
841
|
+
self._processing_rows()
|
|
842
|
+
rows = self.connection.execute("""
|
|
843
|
+
SELECT * FROM cms_terminal_notification_inbox
|
|
844
|
+
WHERE notification_id = ? OR event_id = ?
|
|
845
|
+
OR payload_sha256 = ?
|
|
846
|
+
""", (
|
|
847
|
+
parsed["notification_id"], parsed["event_id"], payload_sha256,
|
|
848
|
+
)).fetchall()
|
|
849
|
+
if rows:
|
|
850
|
+
if len(rows) != 1 or not all(
|
|
851
|
+
rows[0][name] == expected
|
|
852
|
+
for name, expected in {
|
|
853
|
+
"notification_id": parsed["notification_id"],
|
|
854
|
+
"event_id": parsed["event_id"],
|
|
855
|
+
"site_id": parsed["site_id"],
|
|
856
|
+
"terminal_status": parsed["terminal_status"],
|
|
857
|
+
"payload_sha256": payload_sha256,
|
|
858
|
+
"payload_json": rendered,
|
|
859
|
+
}.items()
|
|
860
|
+
):
|
|
861
|
+
_blocked("idempotency_collision", 409)
|
|
862
|
+
else:
|
|
863
|
+
self.connection.execute("""
|
|
864
|
+
INSERT INTO cms_terminal_notification_inbox (
|
|
865
|
+
notification_id, event_id, site_id, terminal_status,
|
|
866
|
+
payload_sha256, payload_json, received_at
|
|
867
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
868
|
+
""", (
|
|
869
|
+
parsed["notification_id"], parsed["event_id"],
|
|
870
|
+
parsed["site_id"], parsed["terminal_status"],
|
|
871
|
+
payload_sha256, rendered, now,
|
|
872
|
+
))
|
|
873
|
+
self.connection.execute("""
|
|
874
|
+
INSERT INTO cms_terminal_notification_processing (
|
|
875
|
+
notification_id, status, attempts, max_attempts,
|
|
876
|
+
next_attempt_at, created_at, updated_at
|
|
877
|
+
) VALUES (?, 'pending', 0, ?, ?, ?, ?)
|
|
878
|
+
""", (
|
|
879
|
+
parsed["notification_id"],
|
|
880
|
+
self.processing_max_attempts, now, now, now,
|
|
881
|
+
))
|
|
882
|
+
except TerminalNotificationReceiverBlocked:
|
|
883
|
+
raise
|
|
884
|
+
except sqlite3.Error:
|
|
885
|
+
_blocked("storage_unavailable", 503)
|
|
886
|
+
return self._ack(parsed, payload_sha256)
|
|
887
|
+
|
|
888
|
+
def status(self, event_id: str) -> ReceivedTerminalNotification:
|
|
889
|
+
self._owner()
|
|
890
|
+
if not _token(event_id):
|
|
891
|
+
_blocked("request_invalid", 400)
|
|
892
|
+
with self._lock:
|
|
893
|
+
self._guard()
|
|
894
|
+
self._validate_schema()
|
|
895
|
+
try:
|
|
896
|
+
row = self.connection.execute(
|
|
897
|
+
"SELECT * FROM cms_terminal_notification_inbox WHERE event_id = ?",
|
|
898
|
+
(event_id,),
|
|
899
|
+
).fetchone()
|
|
900
|
+
except sqlite3.Error:
|
|
901
|
+
_blocked("storage_unavailable", 503)
|
|
902
|
+
if row is None:
|
|
903
|
+
_blocked("not_found", 404)
|
|
904
|
+
return self._validated_row(row)
|
|
905
|
+
|
|
906
|
+
@staticmethod
|
|
907
|
+
def _processing_status(
|
|
908
|
+
row: sqlite3.Row,
|
|
909
|
+
notification_row: sqlite3.Row,
|
|
910
|
+
now: float,
|
|
911
|
+
) -> TerminalNotificationProcessingStatus:
|
|
912
|
+
DurableCMSTerminalNotificationInbox._validated_processing_row(
|
|
913
|
+
row, notification_row
|
|
914
|
+
)
|
|
915
|
+
notification = DurableCMSTerminalNotificationInbox._validated_row(
|
|
916
|
+
notification_row
|
|
917
|
+
)
|
|
918
|
+
lease_expires_at = row["lease_expires_at"]
|
|
919
|
+
return TerminalNotificationProcessingStatus(
|
|
920
|
+
notification.notification_id,
|
|
921
|
+
notification.event_id,
|
|
922
|
+
notification.site_id,
|
|
923
|
+
notification.terminal_status,
|
|
924
|
+
notification.payload_sha256,
|
|
925
|
+
row["status"],
|
|
926
|
+
int(row["attempts"]),
|
|
927
|
+
int(row["max_attempts"]),
|
|
928
|
+
float(row["next_attempt_at"]),
|
|
929
|
+
None if lease_expires_at is None else float(lease_expires_at),
|
|
930
|
+
row["status"] == "leased" and float(lease_expires_at) <= now,
|
|
931
|
+
row["last_error_code"],
|
|
932
|
+
None if row["processed_at"] is None else float(row["processed_at"]),
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def claim_processing(
|
|
936
|
+
self,
|
|
937
|
+
worker_id: str,
|
|
938
|
+
*,
|
|
939
|
+
now: float | int,
|
|
940
|
+
lease_seconds: float | int = 600,
|
|
941
|
+
) -> TerminalNotificationProcessingClaim | None:
|
|
942
|
+
self._owner()
|
|
943
|
+
if not _token(worker_id) or len(worker_id) > 128:
|
|
944
|
+
_blocked("processing_worker_invalid", 503)
|
|
945
|
+
now = _time(now)
|
|
946
|
+
lease_seconds = _duration(lease_seconds, "processing_lease_invalid")
|
|
947
|
+
with self._lock:
|
|
948
|
+
self._guard()
|
|
949
|
+
self._validate_schema()
|
|
950
|
+
try:
|
|
951
|
+
with _transaction(self.connection):
|
|
952
|
+
self._processing_rows()
|
|
953
|
+
self.connection.execute("""
|
|
954
|
+
UPDATE cms_terminal_notification_processing
|
|
955
|
+
SET status = CASE WHEN attempts >= max_attempts
|
|
956
|
+
THEN 'failed' ELSE 'retry_wait' END,
|
|
957
|
+
next_attempt_at = ?, lease_owner = NULL,
|
|
958
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
959
|
+
last_error_code = 'processing_lease_expired',
|
|
960
|
+
updated_at = ?
|
|
961
|
+
WHERE status = 'leased' AND lease_expires_at <= ?
|
|
962
|
+
""", (now, now, now))
|
|
963
|
+
row = self.connection.execute("""
|
|
964
|
+
SELECT * FROM cms_terminal_notification_processing
|
|
965
|
+
WHERE status IN ('pending', 'retry_wait')
|
|
966
|
+
AND next_attempt_at <= ? AND attempts < max_attempts
|
|
967
|
+
ORDER BY created_at, notification_id LIMIT 1
|
|
968
|
+
""", (now,)).fetchone()
|
|
969
|
+
if row is None:
|
|
970
|
+
return None
|
|
971
|
+
notification_row = self.connection.execute(
|
|
972
|
+
"SELECT * FROM cms_terminal_notification_inbox "
|
|
973
|
+
"WHERE notification_id = ?",
|
|
974
|
+
(row["notification_id"],),
|
|
975
|
+
).fetchone()
|
|
976
|
+
if notification_row is None:
|
|
977
|
+
_blocked("processing_state_invalid", 503)
|
|
978
|
+
self._validated_processing_row(row, notification_row)
|
|
979
|
+
token = secrets.token_urlsafe(32)
|
|
980
|
+
expires_at = now + lease_seconds
|
|
981
|
+
updated = self.connection.execute("""
|
|
982
|
+
UPDATE cms_terminal_notification_processing
|
|
983
|
+
SET status = 'leased', attempts = attempts + 1,
|
|
984
|
+
lease_owner = ?, lease_token = ?,
|
|
985
|
+
lease_expires_at = ?, last_error_code = NULL,
|
|
986
|
+
updated_at = ?
|
|
987
|
+
WHERE notification_id = ?
|
|
988
|
+
AND status IN ('pending', 'retry_wait')
|
|
989
|
+
""", (
|
|
990
|
+
worker_id, token, expires_at, now,
|
|
991
|
+
row["notification_id"],
|
|
992
|
+
))
|
|
993
|
+
if updated.rowcount != 1:
|
|
994
|
+
_blocked("processing_claim_lost", 503)
|
|
995
|
+
payload, _payload_sha256 = _parse_notification(
|
|
996
|
+
notification_row["payload_json"].encode("utf-8")
|
|
997
|
+
)
|
|
998
|
+
received = self._validated_row(notification_row)
|
|
999
|
+
return TerminalNotificationProcessingClaim(
|
|
1000
|
+
received.notification_id,
|
|
1001
|
+
received.event_id,
|
|
1002
|
+
received.site_id,
|
|
1003
|
+
received.terminal_status,
|
|
1004
|
+
received.payload_sha256,
|
|
1005
|
+
int(row["attempts"]) + 1,
|
|
1006
|
+
int(row["max_attempts"]),
|
|
1007
|
+
worker_id,
|
|
1008
|
+
token,
|
|
1009
|
+
expires_at,
|
|
1010
|
+
MappingProxyType(payload),
|
|
1011
|
+
)
|
|
1012
|
+
except TerminalNotificationReceiverBlocked:
|
|
1013
|
+
raise
|
|
1014
|
+
except sqlite3.Error:
|
|
1015
|
+
_blocked("storage_unavailable", 503)
|
|
1016
|
+
|
|
1017
|
+
def processing_status(
|
|
1018
|
+
self,
|
|
1019
|
+
event_id: str,
|
|
1020
|
+
*,
|
|
1021
|
+
now: float | int,
|
|
1022
|
+
) -> TerminalNotificationProcessingStatus:
|
|
1023
|
+
self._owner()
|
|
1024
|
+
if not _token(event_id):
|
|
1025
|
+
_blocked("request_invalid", 400)
|
|
1026
|
+
now = _time(now)
|
|
1027
|
+
with self._lock:
|
|
1028
|
+
self._guard()
|
|
1029
|
+
self._validate_schema()
|
|
1030
|
+
try:
|
|
1031
|
+
rows = self._processing_rows()
|
|
1032
|
+
except TerminalNotificationReceiverBlocked:
|
|
1033
|
+
raise
|
|
1034
|
+
except sqlite3.Error:
|
|
1035
|
+
_blocked("storage_unavailable", 503)
|
|
1036
|
+
for row, notification_row in rows:
|
|
1037
|
+
if notification_row["event_id"] == event_id:
|
|
1038
|
+
return self._processing_status(row, notification_row, now)
|
|
1039
|
+
_blocked("not_found", 404)
|
|
1040
|
+
|
|
1041
|
+
def _require_processing_claim(
|
|
1042
|
+
self,
|
|
1043
|
+
claim: TerminalNotificationProcessingClaim,
|
|
1044
|
+
now: float,
|
|
1045
|
+
) -> tuple[sqlite3.Row, sqlite3.Row]:
|
|
1046
|
+
if not isinstance(claim, TerminalNotificationProcessingClaim):
|
|
1047
|
+
_blocked("processing_claim_invalid", 503)
|
|
1048
|
+
row = self.connection.execute(
|
|
1049
|
+
"SELECT * FROM cms_terminal_notification_processing "
|
|
1050
|
+
"WHERE notification_id = ?",
|
|
1051
|
+
(claim.notification_id,),
|
|
1052
|
+
).fetchone()
|
|
1053
|
+
notification_row = self.connection.execute(
|
|
1054
|
+
"SELECT * FROM cms_terminal_notification_inbox "
|
|
1055
|
+
"WHERE notification_id = ?",
|
|
1056
|
+
(claim.notification_id,),
|
|
1057
|
+
).fetchone()
|
|
1058
|
+
if row is None or notification_row is None:
|
|
1059
|
+
_blocked("processing_claim_lost", 503)
|
|
1060
|
+
self._validated_processing_row(row, notification_row)
|
|
1061
|
+
received = self._validated_row(notification_row)
|
|
1062
|
+
try:
|
|
1063
|
+
claim_payload = _canonical(dict(claim.payload)).decode("utf-8")
|
|
1064
|
+
except Exception:
|
|
1065
|
+
_blocked("processing_claim_invalid", 503)
|
|
1066
|
+
if not (
|
|
1067
|
+
row["status"] == "leased"
|
|
1068
|
+
and row["lease_owner"] == claim.worker_id
|
|
1069
|
+
and row["lease_token"] == claim.lease_token
|
|
1070
|
+
and int(row["attempts"]) == claim.attempt
|
|
1071
|
+
and int(row["max_attempts"]) == claim.max_attempts
|
|
1072
|
+
and float(row["lease_expires_at"]) == claim.lease_expires_at
|
|
1073
|
+
and notification_row["payload_json"] == claim_payload
|
|
1074
|
+
and (
|
|
1075
|
+
received.notification_id,
|
|
1076
|
+
received.event_id,
|
|
1077
|
+
received.site_id,
|
|
1078
|
+
received.terminal_status,
|
|
1079
|
+
received.payload_sha256,
|
|
1080
|
+
) == (
|
|
1081
|
+
claim.notification_id,
|
|
1082
|
+
claim.event_id,
|
|
1083
|
+
claim.site_id,
|
|
1084
|
+
claim.terminal_status,
|
|
1085
|
+
claim.payload_sha256,
|
|
1086
|
+
)
|
|
1087
|
+
):
|
|
1088
|
+
_blocked("processing_claim_lost", 503)
|
|
1089
|
+
if float(row["lease_expires_at"]) <= now:
|
|
1090
|
+
_blocked("processing_lease_expired", 503)
|
|
1091
|
+
return row, notification_row
|
|
1092
|
+
|
|
1093
|
+
def complete_processing(
|
|
1094
|
+
self,
|
|
1095
|
+
claim: TerminalNotificationProcessingClaim,
|
|
1096
|
+
response: Any,
|
|
1097
|
+
*,
|
|
1098
|
+
now: float | int,
|
|
1099
|
+
) -> TerminalNotificationProcessingStatus:
|
|
1100
|
+
self._owner()
|
|
1101
|
+
now = _time(now)
|
|
1102
|
+
if not isinstance(response, Mapping):
|
|
1103
|
+
_blocked("processing_response_invalid", 503)
|
|
1104
|
+
try:
|
|
1105
|
+
copied = json.loads(_canonical(dict(response)))
|
|
1106
|
+
except Exception:
|
|
1107
|
+
_blocked("processing_response_invalid", 503)
|
|
1108
|
+
expected = {
|
|
1109
|
+
"schema": PROCESSING_ACK_SCHEMA,
|
|
1110
|
+
"notification_id": claim.notification_id,
|
|
1111
|
+
"event_id": claim.event_id,
|
|
1112
|
+
"site_id": claim.site_id,
|
|
1113
|
+
"status": "processed",
|
|
1114
|
+
"notification_sha256": claim.payload_sha256,
|
|
1115
|
+
}
|
|
1116
|
+
if copied != expected:
|
|
1117
|
+
_blocked("processing_response_invalid", 503)
|
|
1118
|
+
with self._lock:
|
|
1119
|
+
self._guard()
|
|
1120
|
+
self._validate_schema()
|
|
1121
|
+
try:
|
|
1122
|
+
with _transaction(self.connection):
|
|
1123
|
+
self._processing_rows()
|
|
1124
|
+
self._require_processing_claim(claim, now)
|
|
1125
|
+
updated = self.connection.execute("""
|
|
1126
|
+
UPDATE cms_terminal_notification_processing
|
|
1127
|
+
SET status = 'succeeded', next_attempt_at = ?,
|
|
1128
|
+
lease_owner = NULL, lease_token = NULL,
|
|
1129
|
+
lease_expires_at = NULL, last_error_code = NULL,
|
|
1130
|
+
processed_at = ?, updated_at = ?
|
|
1131
|
+
WHERE notification_id = ? AND status = 'leased'
|
|
1132
|
+
AND lease_owner = ? AND lease_token = ?
|
|
1133
|
+
""", (
|
|
1134
|
+
now, now, now, claim.notification_id,
|
|
1135
|
+
claim.worker_id, claim.lease_token,
|
|
1136
|
+
))
|
|
1137
|
+
if updated.rowcount != 1:
|
|
1138
|
+
_blocked("processing_completion_lost", 503)
|
|
1139
|
+
except TerminalNotificationReceiverBlocked:
|
|
1140
|
+
raise
|
|
1141
|
+
except sqlite3.Error:
|
|
1142
|
+
_blocked("storage_unavailable", 503)
|
|
1143
|
+
return self.processing_status(claim.event_id, now=now)
|
|
1144
|
+
|
|
1145
|
+
def retry_processing(
|
|
1146
|
+
self,
|
|
1147
|
+
claim: TerminalNotificationProcessingClaim,
|
|
1148
|
+
code: str,
|
|
1149
|
+
*,
|
|
1150
|
+
now: float | int,
|
|
1151
|
+
) -> TerminalNotificationProcessingStatus:
|
|
1152
|
+
return self._finish_processing(claim, code, retryable=True, now=now)
|
|
1153
|
+
|
|
1154
|
+
def fail_processing(
|
|
1155
|
+
self,
|
|
1156
|
+
claim: TerminalNotificationProcessingClaim,
|
|
1157
|
+
code: str,
|
|
1158
|
+
*,
|
|
1159
|
+
now: float | int,
|
|
1160
|
+
) -> TerminalNotificationProcessingStatus:
|
|
1161
|
+
return self._finish_processing(claim, code, retryable=False, now=now)
|
|
1162
|
+
|
|
1163
|
+
def _finish_processing(
|
|
1164
|
+
self,
|
|
1165
|
+
claim: TerminalNotificationProcessingClaim,
|
|
1166
|
+
code: str,
|
|
1167
|
+
*,
|
|
1168
|
+
retryable: bool,
|
|
1169
|
+
now: float | int,
|
|
1170
|
+
) -> TerminalNotificationProcessingStatus:
|
|
1171
|
+
self._owner()
|
|
1172
|
+
if not _error(code):
|
|
1173
|
+
_blocked("processing_error_invalid", 503)
|
|
1174
|
+
now = _time(now)
|
|
1175
|
+
with self._lock:
|
|
1176
|
+
self._guard()
|
|
1177
|
+
self._validate_schema()
|
|
1178
|
+
try:
|
|
1179
|
+
with _transaction(self.connection):
|
|
1180
|
+
self._processing_rows()
|
|
1181
|
+
row, _notification_row = self._require_processing_claim(
|
|
1182
|
+
claim, now
|
|
1183
|
+
)
|
|
1184
|
+
terminal = not retryable or int(row["attempts"]) >= int(
|
|
1185
|
+
row["max_attempts"]
|
|
1186
|
+
)
|
|
1187
|
+
delay = min(
|
|
1188
|
+
self.processing_max_delay_seconds,
|
|
1189
|
+
self.processing_base_delay_seconds
|
|
1190
|
+
* (2 ** max(0, claim.attempt - 1)),
|
|
1191
|
+
)
|
|
1192
|
+
updated = self.connection.execute("""
|
|
1193
|
+
UPDATE cms_terminal_notification_processing
|
|
1194
|
+
SET status = ?, next_attempt_at = ?, lease_owner = NULL,
|
|
1195
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
1196
|
+
last_error_code = ?, processed_at = NULL,
|
|
1197
|
+
updated_at = ?
|
|
1198
|
+
WHERE notification_id = ? AND status = 'leased'
|
|
1199
|
+
AND lease_owner = ? AND lease_token = ?
|
|
1200
|
+
""", (
|
|
1201
|
+
"failed" if terminal else "retry_wait",
|
|
1202
|
+
now if terminal else now + delay,
|
|
1203
|
+
code,
|
|
1204
|
+
now,
|
|
1205
|
+
claim.notification_id,
|
|
1206
|
+
claim.worker_id,
|
|
1207
|
+
claim.lease_token,
|
|
1208
|
+
))
|
|
1209
|
+
if updated.rowcount != 1:
|
|
1210
|
+
_blocked("processing_failure_lost", 503)
|
|
1211
|
+
except TerminalNotificationReceiverBlocked:
|
|
1212
|
+
raise
|
|
1213
|
+
except sqlite3.Error:
|
|
1214
|
+
_blocked("storage_unavailable", 503)
|
|
1215
|
+
return self.processing_status(claim.event_id, now=now)
|
|
1216
|
+
|
|
1217
|
+
def run_next_processing(
|
|
1218
|
+
self,
|
|
1219
|
+
callback: Callable[[Mapping[str, Any]], Any],
|
|
1220
|
+
worker_id: str,
|
|
1221
|
+
*,
|
|
1222
|
+
now: float | int,
|
|
1223
|
+
lease_seconds: float | int = 600,
|
|
1224
|
+
) -> TerminalNotificationProcessingOutcome | None:
|
|
1225
|
+
if not callable(callback):
|
|
1226
|
+
_blocked("processing_callback_invalid", 503)
|
|
1227
|
+
now = _time(now)
|
|
1228
|
+
claim = self.claim_processing(
|
|
1229
|
+
worker_id, now=now, lease_seconds=lease_seconds
|
|
1230
|
+
)
|
|
1231
|
+
if claim is None:
|
|
1232
|
+
return None
|
|
1233
|
+
try:
|
|
1234
|
+
response = callback(dict(claim.payload))
|
|
1235
|
+
status = self.complete_processing(claim, response, now=now)
|
|
1236
|
+
except TerminalNotificationReceiverBlocked as error:
|
|
1237
|
+
if error.code != "notification_receiver.processing_response_invalid":
|
|
1238
|
+
raise
|
|
1239
|
+
status = self.fail_processing(claim, error.code, now=now)
|
|
1240
|
+
except Exception as error:
|
|
1241
|
+
retryable = (
|
|
1242
|
+
getattr(error, "cms_terminal_processing_failure", False) is True
|
|
1243
|
+
and getattr(error, "retryable", None) is True
|
|
1244
|
+
)
|
|
1245
|
+
code = getattr(error, "code", None)
|
|
1246
|
+
if not _error(code):
|
|
1247
|
+
code = "processing_callback_failure"
|
|
1248
|
+
retryable = False
|
|
1249
|
+
status = (
|
|
1250
|
+
self.retry_processing(claim, code, now=now)
|
|
1251
|
+
if retryable
|
|
1252
|
+
else self.fail_processing(claim, code, now=now)
|
|
1253
|
+
)
|
|
1254
|
+
return TerminalNotificationProcessingOutcome(
|
|
1255
|
+
status.notification_id,
|
|
1256
|
+
status.event_id,
|
|
1257
|
+
status.status,
|
|
1258
|
+
status.attempts,
|
|
1259
|
+
status.last_error_code,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
def health(
|
|
1263
|
+
self,
|
|
1264
|
+
*,
|
|
1265
|
+
now: float | int | None = None,
|
|
1266
|
+
) -> TerminalNotificationInboxHealth:
|
|
1267
|
+
self._owner()
|
|
1268
|
+
now = _time(time.time() if now is None else now)
|
|
1269
|
+
with self._lock:
|
|
1270
|
+
self._guard()
|
|
1271
|
+
self._validate_schema()
|
|
1272
|
+
try:
|
|
1273
|
+
integrity = self.connection.execute(
|
|
1274
|
+
"PRAGMA integrity_check"
|
|
1275
|
+
).fetchall()
|
|
1276
|
+
foreign_keys = self.connection.execute(
|
|
1277
|
+
"PRAGMA foreign_key_check"
|
|
1278
|
+
).fetchall()
|
|
1279
|
+
rows = self.connection.execute(
|
|
1280
|
+
"SELECT * FROM cms_terminal_notification_inbox "
|
|
1281
|
+
"ORDER BY received_at, event_id"
|
|
1282
|
+
).fetchall()
|
|
1283
|
+
processing_rows = self._processing_rows()
|
|
1284
|
+
except sqlite3.Error:
|
|
1285
|
+
_blocked("storage_unavailable", 503)
|
|
1286
|
+
if [tuple(row) for row in integrity] != [("ok",)] or foreign_keys:
|
|
1287
|
+
_blocked("integrity_failed", 503)
|
|
1288
|
+
for row in rows:
|
|
1289
|
+
self._validated_row(row)
|
|
1290
|
+
counts = {status: 0 for status in PROCESSING_STATUSES}
|
|
1291
|
+
for row, _notification_row in processing_rows:
|
|
1292
|
+
counts[row["status"]] += 1
|
|
1293
|
+
due = sum(
|
|
1294
|
+
row["status"] in {"pending", "retry_wait"}
|
|
1295
|
+
and float(row["next_attempt_at"]) <= now
|
|
1296
|
+
for row, _notification_row in processing_rows
|
|
1297
|
+
)
|
|
1298
|
+
expired = sum(
|
|
1299
|
+
row["status"] == "leased"
|
|
1300
|
+
and float(row["lease_expires_at"]) <= now
|
|
1301
|
+
for row, _notification_row in processing_rows
|
|
1302
|
+
)
|
|
1303
|
+
failed = counts["failed"]
|
|
1304
|
+
return TerminalNotificationInboxHealth(
|
|
1305
|
+
"blocked" if expired or failed else "ok",
|
|
1306
|
+
len(rows),
|
|
1307
|
+
counts,
|
|
1308
|
+
due,
|
|
1309
|
+
expired,
|
|
1310
|
+
failed,
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
class CMSTerminalNotificationReceiverApplication:
|
|
1315
|
+
"""Authenticate, durably store, and then acknowledge one notification."""
|
|
1316
|
+
|
|
1317
|
+
def __init__(
|
|
1318
|
+
self,
|
|
1319
|
+
inbox: DurableCMSTerminalNotificationInbox,
|
|
1320
|
+
authenticate: Callable[[Mapping[str, Any], Mapping[str, str]], Any],
|
|
1321
|
+
*,
|
|
1322
|
+
origin: str,
|
|
1323
|
+
clock: Callable[[], float | int],
|
|
1324
|
+
path: str = DEFAULT_PATH,
|
|
1325
|
+
require_https: bool = True,
|
|
1326
|
+
):
|
|
1327
|
+
if not isinstance(inbox, DurableCMSTerminalNotificationInbox):
|
|
1328
|
+
raise TypeError("inbox is invalid")
|
|
1329
|
+
if not callable(authenticate) or not callable(clock):
|
|
1330
|
+
raise TypeError("authenticate and clock must be callable")
|
|
1331
|
+
origin = _origin(origin)
|
|
1332
|
+
if (
|
|
1333
|
+
not isinstance(path, str)
|
|
1334
|
+
or not path.isascii()
|
|
1335
|
+
or not path.startswith("/")
|
|
1336
|
+
or path.startswith("//")
|
|
1337
|
+
or "?" in path
|
|
1338
|
+
or "#" in path
|
|
1339
|
+
or len(path) > 256
|
|
1340
|
+
or path in {
|
|
1341
|
+
STATUS_PATH, READINESS_PATH, CAPABILITIES_PATH, HEALTH_PATH,
|
|
1342
|
+
}
|
|
1343
|
+
):
|
|
1344
|
+
raise ValueError("path is invalid")
|
|
1345
|
+
if not isinstance(require_https, bool):
|
|
1346
|
+
raise TypeError("require_https must be boolean")
|
|
1347
|
+
self.inbox = inbox
|
|
1348
|
+
self.authenticate = authenticate
|
|
1349
|
+
self.origin = origin
|
|
1350
|
+
self.clock = clock
|
|
1351
|
+
self.path = path
|
|
1352
|
+
self.require_https = require_https
|
|
1353
|
+
|
|
1354
|
+
@staticmethod
|
|
1355
|
+
def _headers(environ: Mapping[str, Any]) -> dict[str, str]:
|
|
1356
|
+
result: dict[str, str] = {}
|
|
1357
|
+
for key, value in environ.items():
|
|
1358
|
+
if key == "CONTENT_TYPE":
|
|
1359
|
+
name = "content-type"
|
|
1360
|
+
elif key == "CONTENT_LENGTH":
|
|
1361
|
+
name = "content-length"
|
|
1362
|
+
elif isinstance(key, str) and key.startswith("HTTP_"):
|
|
1363
|
+
name = key[5:].replace("_", "-").lower()
|
|
1364
|
+
else:
|
|
1365
|
+
continue
|
|
1366
|
+
if (
|
|
1367
|
+
name in result
|
|
1368
|
+
or not isinstance(value, str)
|
|
1369
|
+
or not value
|
|
1370
|
+
or len(value) > MAX_HEADER_VALUE_LENGTH
|
|
1371
|
+
or "\r" in value
|
|
1372
|
+
or "\n" in value
|
|
1373
|
+
):
|
|
1374
|
+
_blocked("headers_invalid", 400)
|
|
1375
|
+
result[name] = value
|
|
1376
|
+
if len(result) > MAX_HEADERS:
|
|
1377
|
+
_blocked("headers_invalid", 400)
|
|
1378
|
+
return result
|
|
1379
|
+
|
|
1380
|
+
@staticmethod
|
|
1381
|
+
def _send(start_response: Callable[..., Any], status: int, value: Any):
|
|
1382
|
+
body = _canonical(value)
|
|
1383
|
+
phrases = {
|
|
1384
|
+
200: "OK", 400: "Bad Request", 401: "Unauthorized",
|
|
1385
|
+
403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
|
|
1386
|
+
409: "Conflict", 411: "Length Required", 413: "Content Too Large",
|
|
1387
|
+
415: "Unsupported Media Type", 503: "Service Unavailable",
|
|
1388
|
+
}
|
|
1389
|
+
start_response(f"{status} {phrases[status]}", (
|
|
1390
|
+
("Content-Type", "application/json; charset=utf-8"),
|
|
1391
|
+
("Content-Length", str(len(body))),
|
|
1392
|
+
("Cache-Control", "no-store"),
|
|
1393
|
+
("X-Content-Type-Options", "nosniff"),
|
|
1394
|
+
("Referrer-Policy", "no-referrer"),
|
|
1395
|
+
))
|
|
1396
|
+
return [body]
|
|
1397
|
+
|
|
1398
|
+
def __call__(self, environ: Mapping[str, Any], start_response: Callable[..., Any]):
|
|
1399
|
+
try:
|
|
1400
|
+
if not isinstance(environ, Mapping):
|
|
1401
|
+
_blocked("environment_invalid", 400)
|
|
1402
|
+
if environ.get("PATH_INFO") != self.path:
|
|
1403
|
+
_blocked("path_not_found", 404)
|
|
1404
|
+
if environ.get("REQUEST_METHOD") != "POST":
|
|
1405
|
+
_blocked("method_not_allowed", 405)
|
|
1406
|
+
if self.require_https and environ.get("wsgi.url_scheme") != "https":
|
|
1407
|
+
_blocked("https_required", 400)
|
|
1408
|
+
if environ.get("QUERY_STRING") not in {None, ""}:
|
|
1409
|
+
_blocked("query_rejected", 400)
|
|
1410
|
+
if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
|
|
1411
|
+
_blocked("framing_invalid", 400)
|
|
1412
|
+
headers = self._headers(environ)
|
|
1413
|
+
if headers.get("content-type", "").lower().replace(" ", "") != (
|
|
1414
|
+
"application/json;charset=utf-8"
|
|
1415
|
+
):
|
|
1416
|
+
_blocked("content_type", 415)
|
|
1417
|
+
length = headers.get("content-length")
|
|
1418
|
+
if not isinstance(length, str) or not length.isascii() or not length.isdecimal():
|
|
1419
|
+
_blocked("content_length_required", 411)
|
|
1420
|
+
size = int(length)
|
|
1421
|
+
if size <= 0:
|
|
1422
|
+
_blocked("body_invalid", 400)
|
|
1423
|
+
if size > MAX_BODY_BYTES:
|
|
1424
|
+
_blocked("body_too_large", 413)
|
|
1425
|
+
try:
|
|
1426
|
+
body = environ["wsgi.input"].read(size)
|
|
1427
|
+
except Exception:
|
|
1428
|
+
body = None
|
|
1429
|
+
if not isinstance(body, bytes) or len(body) != size:
|
|
1430
|
+
_blocked("body_invalid", 400)
|
|
1431
|
+
payload, body_sha256 = _parse_notification(body)
|
|
1432
|
+
expected_headers = {
|
|
1433
|
+
"idempotency-key": payload["notification_id"],
|
|
1434
|
+
"x-localization-terminal-notification-id": payload["notification_id"],
|
|
1435
|
+
"x-localization-terminal-notification-sha256": body_sha256,
|
|
1436
|
+
}
|
|
1437
|
+
if any(headers.get(name) != value for name, value in expected_headers.items()):
|
|
1438
|
+
_blocked("header_binding_invalid", 400)
|
|
1439
|
+
request = {
|
|
1440
|
+
"schema": AUTH_SCHEMA,
|
|
1441
|
+
"method": "POST",
|
|
1442
|
+
"origin": self.origin,
|
|
1443
|
+
"path": self.path,
|
|
1444
|
+
"notification_id": payload["notification_id"],
|
|
1445
|
+
"event_id": payload["event_id"],
|
|
1446
|
+
"site_id": payload["site_id"],
|
|
1447
|
+
"body_sha256": body_sha256,
|
|
1448
|
+
}
|
|
1449
|
+
try:
|
|
1450
|
+
principal = self.authenticate(dict(request), dict(headers))
|
|
1451
|
+
except Exception:
|
|
1452
|
+
_blocked("authentication_unavailable", 503)
|
|
1453
|
+
_principal(principal, payload["site_id"])
|
|
1454
|
+
acknowledgement = self.inbox.accept(
|
|
1455
|
+
payload, body, body_sha256, now=self.clock(),
|
|
1456
|
+
)
|
|
1457
|
+
return self._send(start_response, 200, acknowledgement)
|
|
1458
|
+
except TerminalNotificationReceiverBlocked as failure:
|
|
1459
|
+
return self._send(start_response, failure.status, {
|
|
1460
|
+
"schema": ERROR_SCHEMA,
|
|
1461
|
+
"status": "BLOCK",
|
|
1462
|
+
"error_code": failure.code,
|
|
1463
|
+
})
|
|
1464
|
+
except Exception:
|
|
1465
|
+
return self._send(start_response, 503, {
|
|
1466
|
+
"schema": ERROR_SCHEMA,
|
|
1467
|
+
"status": "BLOCK",
|
|
1468
|
+
"error_code": "notification_receiver.internal",
|
|
1469
|
+
})
|