apsimo 1.3.0__py3-none-any.whl
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.
- apsimo/__init__.py +38 -0
- apsimo/__main__.py +6 -0
- apsimo/agent/__init__.py +6 -0
- apsimo/agent/client.py +276 -0
- apsimo/agent/models.py +46 -0
- apsimo/agents/__init__.py +20 -0
- apsimo/agents/models.py +264 -0
- apsimo/agents/store.py +861 -0
- apsimo/agents/websocket.py +522 -0
- apsimo/api/__init__.py +1 -0
- apsimo/api/auth_telemetry.py +287 -0
- apsimo/api/authority.py +1203 -0
- apsimo/api/contact_grants.py +347 -0
- apsimo/api/middleware.py +483 -0
- apsimo/api/routers/__init__.py +1 -0
- apsimo/api/routers/commitment_work.py +265 -0
- apsimo/api/routers/context_gate.py +123 -0
- apsimo/api/routers/executions.py +140 -0
- apsimo/api/routers/followup_plans.py +147 -0
- apsimo/api/routers/governed_actions.py +162 -0
- apsimo/api/routers/host.py +14473 -0
- apsimo/api/routers/initiative_work.py +115 -0
- apsimo/api/routers/mining.py +104 -0
- apsimo/api/routers/observations.py +110 -0
- apsimo/api/routers/social_state.py +225 -0
- apsimo/api/routers/task_queue.py +2715 -0
- apsimo/api/routers/temporal_followups.py +251 -0
- apsimo/api/routers/transport.py +110 -0
- apsimo/api/routers/transport_ingress_api.py +240 -0
- apsimo/api/schemas/__init__.py +1 -0
- apsimo/api/schemas/host.py +1949 -0
- apsimo/autonomy/cli.py +110 -0
- apsimo/autonomy/condition_worker.py +437 -0
- apsimo/autonomy/config.py +424 -0
- apsimo/autonomy/loop.py +4316 -0
- apsimo/autonomy/registry.py +339 -0
- apsimo/autonomy/scheduler.py +1822 -0
- apsimo/autonomy/synthesis.py +449 -0
- apsimo/backup.py +962 -0
- apsimo/beliefs/__init__.py +23 -0
- apsimo/beliefs/contradictions.py +109 -0
- apsimo/beliefs/decay.py +61 -0
- apsimo/beliefs/engine.py +479 -0
- apsimo/beliefs/models.py +67 -0
- apsimo/beliefs/promotion.py +41 -0
- apsimo/beliefs/resolve.py +58 -0
- apsimo/beliefs/source_claims.py +690 -0
- apsimo/beliefs/source_projection.py +883 -0
- apsimo/beliefs/source_time.py +208 -0
- apsimo/beliefs/store.py +133 -0
- apsimo/briefings/aggregators.py +824 -0
- apsimo/briefings/composer.py +420 -0
- apsimo/briefings/config.py +55 -0
- apsimo/briefings/delivery.py +439 -0
- apsimo/briefings/engagement.py +97 -0
- apsimo/briefings/engine.py +274 -0
- apsimo/briefings/enhancer.py +99 -0
- apsimo/briefings/models.py +183 -0
- apsimo/briefings/scheduler.py +382 -0
- apsimo/briefings/store.py +435 -0
- apsimo/chain/__init__.py +48 -0
- apsimo/chain/block.py +100 -0
- apsimo/chain/cli.py +704 -0
- apsimo/chain/genesis.py +443 -0
- apsimo/chain/identity.py +416 -0
- apsimo/chain/keys.py +1025 -0
- apsimo/chain/local_keys.py +187 -0
- apsimo/chain/manager.py +290 -0
- apsimo/chain/node.py +163 -0
- apsimo/chain/plugin_transactions.py +371 -0
- apsimo/chain/protocol.py +220 -0
- apsimo/chain/state_machine.py +676 -0
- apsimo/chain/storage.py +503 -0
- apsimo/chain/transactions.py +250 -0
- apsimo/chain/validation.py +397 -0
- apsimo/channels/__init__.py +1 -0
- apsimo/channels/manifest.py +31 -0
- apsimo/channels/migrations/001_channels_schema.sql +12 -0
- apsimo/channels/phone_gateways.py +42 -0
- apsimo/channels/presence.py +188 -0
- apsimo/channels/router.py +235 -0
- apsimo/channels/store.py +231 -0
- apsimo/cli.py +2688 -0
- apsimo/cognition/__init__.py +11 -0
- apsimo/cognition/charter.py +398 -0
- apsimo/cognition/drive_governance.py +3530 -0
- apsimo/cognition/evidence_pipeline.py +1627 -0
- apsimo/cognition/external_events.py +932 -0
- apsimo/cognition/goal_spine.py +3488 -0
- apsimo/cognition/introspection.py +214 -0
- apsimo/cognition/prompt.py +150 -0
- apsimo/cognition/runtime.py +108 -0
- apsimo/cognition/trigger.py +154 -0
- apsimo/commitments/__init__.py +18 -0
- apsimo/commitments/local_work.py +355 -0
- apsimo/commitments/store.py +1052 -0
- apsimo/commitments/work.py +91 -0
- apsimo/compat.py +53 -0
- apsimo/compression/__init__.py +467 -0
- apsimo/connectors/__init__.py +21 -0
- apsimo/connectors/base.py +152 -0
- apsimo/connectors/caldav_calendar.py +125 -0
- apsimo/connectors/fs_documents.py +85 -0
- apsimo/connectors/imap_email.py +138 -0
- apsimo/connectors/manager.py +218 -0
- apsimo/connectors/webhook_pull.py +88 -0
- apsimo/contacts/__init__.py +33 -0
- apsimo/contacts/comms.py +357 -0
- apsimo/contacts/config.py +79 -0
- apsimo/contacts/exporters/__init__.py +1 -0
- apsimo/contacts/exporters/vcard.py +71 -0
- apsimo/contacts/identity_links.py +251 -0
- apsimo/contacts/importer.py +280 -0
- apsimo/contacts/importers/__init__.py +1 -0
- apsimo/contacts/importers/batch.py +43 -0
- apsimo/contacts/importers/macos_contacts.py +101 -0
- apsimo/contacts/migrations/001_contacts_schema.sql +141 -0
- apsimo/contacts/migrations/002_trust_scopes.sql +36 -0
- apsimo/contacts/migrations/003_open_gateway_enum.sql +32 -0
- apsimo/contacts/migrations/004_contact_provision_operations.sql +18 -0
- apsimo/contacts/migrations/005_identity_links.sql +27 -0
- apsimo/contacts/models.py +308 -0
- apsimo/contacts/scoring.py +16 -0
- apsimo/contacts/store.py +1623 -0
- apsimo/contacts/transport_ingress.py +252 -0
- apsimo/contacts/world_bridge.py +314 -0
- apsimo/contextgate/__init__.py +69 -0
- apsimo/contextgate/chunker.py +169 -0
- apsimo/contextgate/estimate.py +54 -0
- apsimo/contextgate/gate.py +313 -0
- apsimo/contextgate/retrieve.py +115 -0
- apsimo/delivery/__init__.py +16 -0
- apsimo/delivery/bridge.py +1260 -0
- apsimo/delivery/channels.py +526 -0
- apsimo/delivery/classification.py +50 -0
- apsimo/delivery/rate_limiter.py +268 -0
- apsimo/delivery/reachout_policy.py +206 -0
- apsimo/directed/__init__.py +22 -0
- apsimo/directed/audit.py +167 -0
- apsimo/directed/intake.py +95 -0
- apsimo/directed/models.py +191 -0
- apsimo/directed/service.py +509 -0
- apsimo/directives/__init__.py +25 -0
- apsimo/directives/evidence.py +87 -0
- apsimo/directives/extractor.py +188 -0
- apsimo/directives/guard.py +364 -0
- apsimo/directives/models.py +206 -0
- apsimo/directives/service.py +372 -0
- apsimo/directives/store.py +167 -0
- apsimo/doctor.py +2173 -0
- apsimo/environment.py +43 -0
- apsimo/events/__init__.py +33 -0
- apsimo/events/broadcaster.py +98 -0
- apsimo/events/bus.py +217 -0
- apsimo/events/journal.py +863 -0
- apsimo/events/stream.py +131 -0
- apsimo/events/types.py +150 -0
- apsimo/execution_results.py +357 -0
- apsimo/feedback/__init__.py +5 -0
- apsimo/feedback/store.py +76 -0
- apsimo/feeds/__init__.py +19 -0
- apsimo/feeds/cli.py +84 -0
- apsimo/feeds/engine.py +437 -0
- apsimo/feeds/example-feed.yaml +77 -0
- apsimo/feeds/hermes_cron.py +126 -0
- apsimo/feeds/manager.py +235 -0
- apsimo/feeds/spec.py +250 -0
- apsimo/feeds/template.py +202 -0
- apsimo/gate/__init__.py +18 -0
- apsimo/gate/audit.py +61 -0
- apsimo/gate/communication_policy.py +166 -0
- apsimo/gate/config.py +72 -0
- apsimo/gate/context_provenance.py +170 -0
- apsimo/gate/env_risk.py +226 -0
- apsimo/gate/guard_audit.py +353 -0
- apsimo/gate/layers/__init__.py +1 -0
- apsimo/gate/layers/base.py +15 -0
- apsimo/gate/layers/l1_recipient.py +66 -0
- apsimo/gate/layers/l2_pii.py +134 -0
- apsimo/gate/layers/l3_cross_context.py +50 -0
- apsimo/gate/layers/l4_trust_tier.py +78 -0
- apsimo/gate/layers/l5_injection.py +199 -0
- apsimo/gate/layers/l6_review.py +86 -0
- apsimo/gate/layers/l7_delay.py +100 -0
- apsimo/gate/layers/tom2_epistemic.py +185 -0
- apsimo/gate/models.py +64 -0
- apsimo/gate/pending_dispatch.py +5 -0
- apsimo/gate/pipeline.py +206 -0
- apsimo/gate/rejection.py +259 -0
- apsimo/gate/response_guard.py +700 -0
- apsimo/gate/rulesets/injection_v1.yaml +51 -0
- apsimo/gate/surface_policy.py +189 -0
- apsimo/gate/taint.py +226 -0
- apsimo/genesis.json +9 -0
- apsimo/goals/__init__.py +100 -0
- apsimo/goals/config.py +38 -0
- apsimo/goals/decomposer.py +421 -0
- apsimo/goals/engine.py +617 -0
- apsimo/goals/inference.py +354 -0
- apsimo/goals/models.py +302 -0
- apsimo/goals/priority.py +270 -0
- apsimo/goals/queue_bridge.py +149 -0
- apsimo/goals/replan.py +450 -0
- apsimo/goals/schema.sql +89 -0
- apsimo/goals/store.py +692 -0
- apsimo/governed_actions.py +1708 -0
- apsimo/harness_integration/__init__.py +45 -0
- apsimo/harness_integration/context.py +41 -0
- apsimo/harness_integration/skills.py +231 -0
- apsimo/identity/__init__.py +26 -0
- apsimo/identity/participants.py +181 -0
- apsimo/identity/resolver.py +329 -0
- apsimo/identity_bootstrap/__init__.py +5 -0
- apsimo/identity_bootstrap/builder.py +208 -0
- apsimo/identity_bootstrap/corpus.py +443 -0
- apsimo/identity_bootstrap/models.py +54 -0
- apsimo/identity_bootstrap/runner.py +353 -0
- apsimo/identity_bootstrap/seeders/__init__.py +25 -0
- apsimo/identity_bootstrap/seeders/briefings.py +109 -0
- apsimo/identity_bootstrap/seeders/chain.py +57 -0
- apsimo/identity_bootstrap/seeders/goals.py +128 -0
- apsimo/identity_bootstrap/seeders/memory.py +191 -0
- apsimo/identity_bootstrap/seeders/neo4j_cognition.py +79 -0
- apsimo/identity_bootstrap/seeders/relationship.py +152 -0
- apsimo/identity_bootstrap/seeders/sessions.py +67 -0
- apsimo/identity_bootstrap/seeders/skills.py +92 -0
- apsimo/identity_bootstrap/seeders/task_queue.py +72 -0
- apsimo/identity_bootstrap/seeders/world_model.py +143 -0
- apsimo/identity_bootstrap/self_query.py +92 -0
- apsimo/identity_bootstrap/self_reflection.py +155 -0
- apsimo/identity_bootstrap/skill.py +37 -0
- apsimo/identity_bootstrap/verifier.py +436 -0
- apsimo/initiatives/__init__.py +20 -0
- apsimo/initiatives/action_registry.py +454 -0
- apsimo/initiatives/approval_authority.py +2105 -0
- apsimo/initiatives/approval_policy.py +123 -0
- apsimo/initiatives/assignment.py +263 -0
- apsimo/initiatives/backup_evidence.py +100 -0
- apsimo/initiatives/context_freshness.py +103 -0
- apsimo/initiatives/models.py +318 -0
- apsimo/initiatives/native_work.py +270 -0
- apsimo/initiatives/standing_approvals.py +232 -0
- apsimo/initiatives/store.py +1081 -0
- apsimo/initiatives/temporal_followup.py +410 -0
- apsimo/intelligence/__init__.py +1 -0
- apsimo/intelligence/cognition/__init__.py +24 -0
- apsimo/intelligence/cognition/gap_detector.py +148 -0
- apsimo/intelligence/cognition/metalearner.py +547 -0
- apsimo/intelligence/cognition/metrics_collector.py +217 -0
- apsimo/intelligence/cognition/performance_index.py +299 -0
- apsimo/intelligence/cognition/registry.py +192 -0
- apsimo/intelligence/cognition/strategy_adjuster.py +222 -0
- apsimo/intelligence/cognition/types.py +16 -0
- apsimo/intelligence/components/__init__.py +66 -0
- apsimo/intelligence/components/anomaly_detector.py +413 -0
- apsimo/intelligence/components/initiative_engine.py +2643 -0
- apsimo/intelligence/components/preference_learner.py +521 -0
- apsimo/intelligence/components/research_orchestrator.py +358 -0
- apsimo/intelligence/components/self_directed_thinker.py +221 -0
- apsimo/intelligence/components/self_reflector.py +252 -0
- apsimo/intelligence/components/session_continuity.py +154 -0
- apsimo/intelligence/components/task_planner.py +320 -0
- apsimo/intelligence/components/tool_learner.py +217 -0
- apsimo/intelligence/graph/__init__.py +79 -0
- apsimo/intelligence/graph/client.py +2483 -0
- apsimo/intelligence/graph/consolidator.py +405 -0
- apsimo/intelligence/graph/distiller.py +312 -0
- apsimo/intelligence/graph/migrations.py +129 -0
- apsimo/intelligence/graph/queries.py +248 -0
- apsimo/intelligence/graph/recall.py +281 -0
- apsimo/intelligence/graph/reconciler.py +144 -0
- apsimo/intelligence/graph/schema.py +337 -0
- apsimo/intelligence/graph/selection.py +252 -0
- apsimo/intelligence/learning/__init__.py +17 -0
- apsimo/intelligence/learning/continuous_learner.py +245 -0
- apsimo/intelligence/learning/feedback_store.py +321 -0
- apsimo/intelligence/mind_model/__init__.py +1 -0
- apsimo/intelligence/mind_model/graph_baseline.py +136 -0
- apsimo/intelligence/mind_model/signal_collector.py +361 -0
- apsimo/intelligence/relationships/__init__.py +11 -0
- apsimo/intelligence/relationships/profiler.py +389 -0
- apsimo/intelligence/relationships/scorer.py +560 -0
- apsimo/intelligence/relationships/signal_floor.py +66 -0
- apsimo/intelligence/relationships/trust_tiers.py +300 -0
- apsimo/intelligence/synthesis/__init__.py +40 -0
- apsimo/intelligence/synthesis/connection_discoverer.py +379 -0
- apsimo/intelligence/synthesis/cross_domain_analyzer.py +287 -0
- apsimo/intelligence/synthesis/insight_deliverer.py +171 -0
- apsimo/intelligence/synthesis/insight_store.py +79 -0
- apsimo/intelligence/synthesis/insight_validator.py +183 -0
- apsimo/intelligence/synthesis/novelty_scorer.py +267 -0
- apsimo/intelligence/turn_middleware/__init__.py +15 -0
- apsimo/intelligence/turn_middleware/memory_sync.py +119 -0
- apsimo/mcp/__init__.py +41 -0
- apsimo/mcp/__main__.py +6 -0
- apsimo/mcp/config.py +287 -0
- apsimo/mcp/server.py +501 -0
- apsimo/migrations.py +187 -0
- apsimo/mining/__init__.py +27 -0
- apsimo/mining/corpus.py +239 -0
- apsimo/mining/escalations.py +289 -0
- apsimo/mining/models.py +169 -0
- apsimo/mining/store.py +210 -0
- apsimo/models/__init__.py +30 -0
- apsimo/models/memory.py +80 -0
- apsimo/models/mesh.py +72 -0
- apsimo/models/person.py +104 -0
- apsimo/models/signal.py +108 -0
- apsimo/observations/__init__.py +15 -0
- apsimo/observations/store.py +277 -0
- apsimo/patterns/__init__.py +6 -0
- apsimo/patterns/extract.py +187 -0
- apsimo/patterns/store.py +227 -0
- apsimo/persona/__init__.py +1 -0
- apsimo/persona/engine.py +611 -0
- apsimo/persona/manifest.py +140 -0
- apsimo/projects/__init__.py +28 -0
- apsimo/projects/engine.py +1681 -0
- apsimo/projects/event_outbox.py +188 -0
- apsimo/projects/models.py +216 -0
- apsimo/projects/planner.py +181 -0
- apsimo/projects/store.py +1446 -0
- apsimo/proposals/__init__.py +12 -0
- apsimo/proposals/engine.py +114 -0
- apsimo/proposals/models.py +207 -0
- apsimo/qualification/__init__.py +1 -0
- apsimo/qualification/cases.py +75 -0
- apsimo/qualification/cli.py +51 -0
- apsimo/qualification/memory_cases.py +209 -0
- apsimo/qualification/records.py +92 -0
- apsimo/qualification/report.py +87 -0
- apsimo/qualification/runner.py +311 -0
- apsimo/qualification/structured_cases.py +131 -0
- apsimo/reasoning/__init__.py +13 -0
- apsimo/reasoning/executor.py +506 -0
- apsimo/reasoning/loop.py +373 -0
- apsimo/reasoning/native_tools/__init__.py +16 -0
- apsimo/reasoning/native_tools/calculate.py +141 -0
- apsimo/reasoning/native_tools/file_ops.py +150 -0
- apsimo/reasoning/native_tools/web_search.py +49 -0
- apsimo/reasoning/tool_policy.py +182 -0
- apsimo/redact/__init__.py +176 -0
- apsimo/repos/__init__.py +5 -0
- apsimo/repos/mirrors.py +204 -0
- apsimo/research/__init__.py +41 -0
- apsimo/research/artifact.py +482 -0
- apsimo/research/gatherer.py +387 -0
- apsimo/research/pipeline.py +513 -0
- apsimo/research/search/__init__.py +7 -0
- apsimo/research/search/base.py +41 -0
- apsimo/research/search/brave.py +59 -0
- apsimo/research/search/cache.py +51 -0
- apsimo/research/search/duckduckgo.py +103 -0
- apsimo/research/search/orchestrator.py +119 -0
- apsimo/research/search/serpapi.py +59 -0
- apsimo/research/search/tavily.py +59 -0
- apsimo/research/synthesizer.py +309 -0
- apsimo/router/__init__.py +30 -0
- apsimo/router/complexity_scorer.py +148 -0
- apsimo/router/endpoints.py +153 -0
- apsimo/router/fallback.py +58 -0
- apsimo/router/functions.py +243 -0
- apsimo/router/native_policy.py +52 -0
- apsimo/router/router.py +762 -0
- apsimo/router/self_learning.py +174 -0
- apsimo/router/tiers.py +677 -0
- apsimo/sandbox/__init__.py +21 -0
- apsimo/sandbox/backend.py +195 -0
- apsimo/sandbox/manager.py +173 -0
- apsimo/scope_bounds.py +7 -0
- apsimo/secrets/__init__.py +6 -0
- apsimo/secrets/backends/__init__.py +8 -0
- apsimo/secrets/backends/base.py +42 -0
- apsimo/secrets/backends/env.py +110 -0
- apsimo/secrets/backends/keyring.py +72 -0
- apsimo/secrets/backends/onepassword.py +232 -0
- apsimo/secrets/cli.py +191 -0
- apsimo/secrets/manager.py +160 -0
- apsimo/secrets/migration.py +101 -0
- apsimo/secrets/types.py +98 -0
- apsimo/seed.py +41 -0
- apsimo/self_model/__init__.py +37 -0
- apsimo/self_model/appraisals.py +673 -0
- apsimo/self_model/benchmark.py +1314 -0
- apsimo/self_model/brief.py +40 -0
- apsimo/self_model/event_concerns.py +1128 -0
- apsimo/self_model/execution_forecasts.py +353 -0
- apsimo/self_model/expectations.py +1595 -0
- apsimo/self_model/experiments.py +1150 -0
- apsimo/self_model/journal.py +148 -0
- apsimo/self_model/judgments.py +705 -0
- apsimo/self_model/native_outcomes.py +55 -0
- apsimo/self_model/params.py +220 -0
- apsimo/self_model/perspective.py +246 -0
- apsimo/self_model/reconcile.py +183 -0
- apsimo/self_model/reply_forecasts.py +381 -0
- apsimo/self_model/runtime_forecasts.py +296 -0
- apsimo/self_model/runtime_models.py +67 -0
- apsimo/self_model/settlement.py +207 -0
- apsimo/self_model/situation.py +1731 -0
- apsimo/self_model/store.py +883 -0
- apsimo/self_model/supervised.py +137 -0
- apsimo/self_model/thinker.py +99 -0
- apsimo/self_model/trust.py +388 -0
- apsimo/self_model/workspace.py +2388 -0
- apsimo/server.py +4197 -0
- apsimo/services/__init__.py +1 -0
- apsimo/services/agent_bridge.py +474 -0
- apsimo/services/initiative_executor.py +914 -0
- apsimo/services/instance.py +297 -0
- apsimo/sessions/__init__.py +22 -0
- apsimo/sessions/config.py +13 -0
- apsimo/sessions/context_loader.py +88 -0
- apsimo/sessions/federation_session.py +75 -0
- apsimo/sessions/isolated_session.py +98 -0
- apsimo/sessions/reports.py +84 -0
- apsimo/sessions/store.py +148 -0
- apsimo/setup.py +2818 -0
- apsimo/setup_hermes.py +879 -0
- apsimo/setup_local_work.py +218 -0
- apsimo/setup_native_goals.py +134 -0
- apsimo/setup_native_reviews.py +115 -0
- apsimo/skills/__init__.py +10 -0
- apsimo/skills/base.py +108 -0
- apsimo/skills/budget.py +28 -0
- apsimo/skills/executor.py +493 -0
- apsimo/skills/executors/__init__.py +1 -0
- apsimo/skills/executors/behavioral_correction.py +75 -0
- apsimo/skills/executors/capability_gap.py +38 -0
- apsimo/skills/executors/data_quality.py +163 -0
- apsimo/skills/executors/knowledge_acquisition.py +41 -0
- apsimo/skills/executors/operational_hygiene.py +185 -0
- apsimo/skills/executors/subsystem_health.py +169 -0
- apsimo/skills/hermes_export.py +431 -0
- apsimo/skills/index.py +123 -0
- apsimo/skills/learning/__init__.py +21 -0
- apsimo/skills/learning/novelty_detector.py +206 -0
- apsimo/skills/learning/pattern_extractor.py +199 -0
- apsimo/skills/learning/triggers.py +159 -0
- apsimo/skills/loader.py +246 -0
- apsimo/skills/migrations/002_progressive_loading.sql +6 -0
- apsimo/skills/migrations/backfill_triggers.py +20 -0
- apsimo/skills/models.py +202 -0
- apsimo/skills/packager.py +128 -0
- apsimo/skills/protocols.py +70 -0
- apsimo/skills/registry.py +191 -0
- apsimo/skills/runtime.py +58 -0
- apsimo/skills/sandbox_runner.py +229 -0
- apsimo/skills/scheduler.py +129 -0
- apsimo/skills/schema.py +79 -0
- apsimo/skills/security/__init__.py +12 -0
- apsimo/skills/security/guards.py +53 -0
- apsimo/skills/security/scanner.py +223 -0
- apsimo/skills_memory/__init__.py +26 -0
- apsimo/skills_memory/distill.py +159 -0
- apsimo/skills_memory/models.py +85 -0
- apsimo/skills_memory/retrieve.py +62 -0
- apsimo/skills_memory/store.py +172 -0
- apsimo/surprise/__init__.py +6 -0
- apsimo/surprise/accumulation.py +57 -0
- apsimo/surprise/scorer.py +102 -0
- apsimo/surprise/store.py +203 -0
- apsimo/task_queue/__init__.py +69 -0
- apsimo/task_queue/action_receipts.py +148 -0
- apsimo/task_queue/approval_relay_canary.py +108 -0
- apsimo/task_queue/config.py +85 -0
- apsimo/task_queue/contract.py +361 -0
- apsimo/task_queue/events.py +130 -0
- apsimo/task_queue/governor.py +1031 -0
- apsimo/task_queue/handlers/__init__.py +16 -0
- apsimo/task_queue/handlers/base.py +37 -0
- apsimo/task_queue/handlers/inference.py +640 -0
- apsimo/task_queue/handlers/monitoring.py +116 -0
- apsimo/task_queue/handlers/registry.py +75 -0
- apsimo/task_queue/handlers/subtask_handler.py +173 -0
- apsimo/task_queue/handlers/system_maintenance.py +147 -0
- apsimo/task_queue/mesh_integration.py +111 -0
- apsimo/task_queue/models.py +317 -0
- apsimo/task_queue/queue_manager.py +8286 -0
- apsimo/task_queue/routing.py +287 -0
- apsimo/task_queue/scheduler.py +252 -0
- apsimo/task_queue/schema.sql +197 -0
- apsimo/task_queue/work_control.py +342 -0
- apsimo/task_queue/worker.py +993 -0
- apsimo/telemetry.py +145 -0
- apsimo/tom/__init__.py +6 -0
- apsimo/tom/affect.py +387 -0
- apsimo/tom/approvals.py +171 -0
- apsimo/tom/arcs.py +896 -0
- apsimo/tom/asymmetry.py +131 -0
- apsimo/tom/eligibility.py +248 -0
- apsimo/tom/engagement.py +214 -0
- apsimo/tom/exposure.py +214 -0
- apsimo/tom/extractor.py +306 -0
- apsimo/tom/fact_adapters.py +144 -0
- apsimo/tom/facts.py +326 -0
- apsimo/tom/integration.py +592 -0
- apsimo/tom/leveled.py +118 -0
- apsimo/tom/levels.py +247 -0
- apsimo/tom/recipient_audit.py +995 -0
- apsimo/tom/recipient_simulator.py +593 -0
- apsimo/tom/source_lineage.py +93 -0
- apsimo/tom/tom2.py +277 -0
- apsimo/tom/visibility.py +559 -0
- apsimo/tom/visibility_store.py +414 -0
- apsimo/tools/__init__.py +0 -0
- apsimo/tools/definitions.py +740 -0
- apsimo/tools/handlers.py +943 -0
- apsimo/toolsmith/__init__.py +26 -0
- apsimo/toolsmith/authority.py +166 -0
- apsimo/toolsmith/engine.py +559 -0
- apsimo/toolsmith/integrity.py +100 -0
- apsimo/toolsmith/miner.py +145 -0
- apsimo/toolsmith/policy.py +110 -0
- apsimo/toolsmith/registry.py +635 -0
- apsimo/turns/__init__.py +17 -0
- apsimo/turns/audio.py +134 -0
- apsimo/turns/documents.py +235 -0
- apsimo/turns/executions.py +486 -0
- apsimo/turns/hermes_history.py +245 -0
- apsimo/turns/hermes_kanban.py +268 -0
- apsimo/turns/hermes_work.py +96 -0
- apsimo/turns/idempotency.py +752 -0
- apsimo/turns/local_work.py +115 -0
- apsimo/turns/media.py +581 -0
- apsimo/turns/reported_workers.py +196 -0
- apsimo/turns/source_annotations.py +283 -0
- apsimo/turns/source_attribution.py +154 -0
- apsimo/turns/source_read.py +351 -0
- apsimo/turns/source_vectors.py +263 -0
- apsimo/turns/video.py +210 -0
- apsimo/util/autonomy_preset.py +220 -0
- apsimo/util/instance.py +92 -0
- apsimo/util/model_output.py +25 -0
- apsimo/util/quiet_hours.py +27 -0
- apsimo/util/session_safety.py +37 -0
- apsimo/util/temporal.py +343 -0
- apsimo/vector/__init__.py +75 -0
- apsimo/vector/backfill.py +171 -0
- apsimo/vector/caption.py +114 -0
- apsimo/vector/collections.py +51 -0
- apsimo/vector/config.py +102 -0
- apsimo/vector/embedder.py +670 -0
- apsimo/vector/image_preprocess.py +406 -0
- apsimo/vector/image_store.py +296 -0
- apsimo/vector/indexes.py +162 -0
- apsimo/vector/migrate.py +334 -0
- apsimo/vector/multimodal_provider.py +417 -0
- apsimo/vector/multimodal_types.py +87 -0
- apsimo/vector/openai_provider.py +119 -0
- apsimo/vector/query.py +49 -0
- apsimo/vector/reranker.py +565 -0
- apsimo/vector/safety_image.py +159 -0
- apsimo/vector/scanner.py +197 -0
- apsimo/vector/setup.py +289 -0
- apsimo/vector/store.py +533 -0
- apsimo/vector/tiers.py +263 -0
- apsimo/work_orders.py +925 -0
- apsimo/workers/__init__.py +21 -0
- apsimo/workers/agent_bridge.py +640 -0
- apsimo/workers/colony_worker.py +382 -0
- apsimo/workers/queue_worker.py +441 -0
- apsimo/workers/skills_sync.py +152 -0
- apsimo/world_model/__init__.py +71 -0
- apsimo/world_model/causal_maintenance.py +131 -0
- apsimo/world_model/causal_policy.py +43 -0
- apsimo/world_model/causal_query.py +125 -0
- apsimo/world_model/confidence.py +54 -0
- apsimo/world_model/config.py +64 -0
- apsimo/world_model/constants.py +97 -0
- apsimo/world_model/entities.py +145 -0
- apsimo/world_model/expectation_resolvers.py +177 -0
- apsimo/world_model/extraction/__init__.py +7 -0
- apsimo/world_model/extraction/base.py +62 -0
- apsimo/world_model/extraction/conversation_extractor.py +262 -0
- apsimo/world_model/extraction/detector.py +74 -0
- apsimo/world_model/extraction/document_extractor.py +78 -0
- apsimo/world_model/extraction/formats/__init__.py +24 -0
- apsimo/world_model/extraction/formats/csv_fmt.py +68 -0
- apsimo/world_model/extraction/formats/html_fmt.py +72 -0
- apsimo/world_model/extraction/formats/json_fmt.py +68 -0
- apsimo/world_model/extraction/formats/pdf.py +43 -0
- apsimo/world_model/extraction/formats/text.py +27 -0
- apsimo/world_model/extraction/llm_extractor.py +164 -0
- apsimo/world_model/extraction/pipeline.py +73 -0
- apsimo/world_model/integrations/__init__.py +5 -0
- apsimo/world_model/integrations/mind_model_bridge.py +115 -0
- apsimo/world_model/integrations/social_intel_bridge.py +120 -0
- apsimo/world_model/jobs/__init__.py +4 -0
- apsimo/world_model/jobs/extraction_job.py +168 -0
- apsimo/world_model/llm_extract.py +572 -0
- apsimo/world_model/neo4j/__init__.py +5 -0
- apsimo/world_model/neo4j/backend.py +654 -0
- apsimo/world_model/observations.py +155 -0
- apsimo/world_model/populator.py +307 -0
- apsimo/world_model/postgres/__init__.py +1 -0
- apsimo/world_model/postgres/backend.py +683 -0
- apsimo/world_model/relationships.py +25 -0
- apsimo/world_model/resolution/__init__.py +13 -0
- apsimo/world_model/resolution/entity_resolver.py +232 -0
- apsimo/world_model/resolution/merge_audit.py +16 -0
- apsimo/world_model/resolution/merge_workflow.py +117 -0
- apsimo/world_model/source_reports.py +121 -0
- apsimo/world_model/sqlite/__init__.py +4 -0
- apsimo/world_model/sqlite/backend.py +855 -0
- apsimo/world_model/sqlite/schema.sql +132 -0
- apsimo/world_model/store.py +545 -0
- apsimo-1.3.0.dist-info/METADATA +78 -0
- apsimo-1.3.0.dist-info/RECORD +614 -0
- apsimo-1.3.0.dist-info/WHEEL +5 -0
- apsimo-1.3.0.dist-info/entry_points.txt +11 -0
- apsimo-1.3.0.dist-info/licenses/LICENSE +21 -0
- apsimo-1.3.0.dist-info/top_level.txt +2 -0
- colony_sidecar/__init__.py +4 -0
apsimo/events/journal.py
ADDED
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
"""Durable append-only event journal for Colony.
|
|
2
|
+
|
|
3
|
+
Every externally visible host event is persisted before it is offered to the
|
|
4
|
+
live WebSocket stream. Events remain file-per-record for compatibility with
|
|
5
|
+
existing operators and recovery tooling. A small cursor plus a per-sequence
|
|
6
|
+
prune index make the steady-state append path constant-time; a directory scan
|
|
7
|
+
is only needed when migrating or repairing missing cursor metadata.
|
|
8
|
+
|
|
9
|
+
Sequence allocation is protected by both an in-process lock and ``flock`` so
|
|
10
|
+
threads and sidecar processes sharing a state directory cannot allocate the
|
|
11
|
+
same sequence. The cursor is advanced before the event file is written. A
|
|
12
|
+
crash may therefore leave a sequence gap, but can never cause a sequence to be
|
|
13
|
+
reused or a live event to precede its durable record.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import threading
|
|
24
|
+
import uuid
|
|
25
|
+
from contextlib import contextmanager
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
29
|
+
|
|
30
|
+
try: # Colony is deployed on Linux; retain a safe process-local fallback.
|
|
31
|
+
import fcntl
|
|
32
|
+
except ImportError: # pragma: no cover - exercised only on non-POSIX hosts
|
|
33
|
+
fcntl = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
_CURSOR_FILENAME = ".cursor"
|
|
38
|
+
_LOCK_FILENAME = ".lock"
|
|
39
|
+
_INDEX_DIRNAME = ".sequence-index"
|
|
40
|
+
_EVENT_KEY_DIRNAME = ".event-keys"
|
|
41
|
+
_PROCESS_LOCK = threading.RLock()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _state_dir() -> Path:
|
|
45
|
+
return Path(os.environ.get("COLONY_STATE_DIR", ".")).resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _journal_dir() -> Path:
|
|
49
|
+
configured = os.environ.get("COLONY_EVENT_JOURNAL_DIR", "").strip()
|
|
50
|
+
return Path(configured).resolve() if configured else _state_dir() / "events"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _retention() -> int:
|
|
54
|
+
try:
|
|
55
|
+
return max(1, int(os.environ.get("COLONY_EVENT_JOURNAL_RETENTION", "500")))
|
|
56
|
+
except ValueError:
|
|
57
|
+
return 500
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_seq(seq: int) -> str:
|
|
61
|
+
return str(seq).zfill(6)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _estimate_tokens(text: str) -> int:
|
|
65
|
+
"""Rough token estimate retained for callers of the legacy helper."""
|
|
66
|
+
return len(text.split())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fsync_directory(directory: Path) -> None:
|
|
70
|
+
"""Best-effort directory fsync so a rename survives a host crash."""
|
|
71
|
+
try:
|
|
72
|
+
fd = os.open(str(directory), os.O_RDONLY)
|
|
73
|
+
except OSError:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
os.fsync(fd)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
finally:
|
|
80
|
+
os.close(fd)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
84
|
+
"""Durably replace ``path`` without exposing a partial record.
|
|
85
|
+
|
|
86
|
+
The temporary filename is unique. The previous implementation used one
|
|
87
|
+
shared ``.tmp`` name, which allowed concurrent appenders to overwrite one
|
|
88
|
+
another before rename.
|
|
89
|
+
"""
|
|
90
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
|
92
|
+
fd: Optional[int] = None
|
|
93
|
+
try:
|
|
94
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
95
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
96
|
+
fd = None # fdopen owns it now
|
|
97
|
+
handle.write(content)
|
|
98
|
+
handle.flush()
|
|
99
|
+
os.fsync(handle.fileno())
|
|
100
|
+
os.replace(tmp, path)
|
|
101
|
+
_fsync_directory(path.parent)
|
|
102
|
+
except Exception:
|
|
103
|
+
if fd is not None:
|
|
104
|
+
try:
|
|
105
|
+
os.close(fd)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
try:
|
|
109
|
+
tmp.unlink()
|
|
110
|
+
except OSError:
|
|
111
|
+
pass
|
|
112
|
+
raise
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _checksum(payload: str) -> str:
|
|
116
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _valid_record_checksum(raw: Dict[str, Any]) -> bool:
|
|
120
|
+
"""Verify new canonical and legacy journal checksums.
|
|
121
|
+
|
|
122
|
+
Very early/manual journal files had no checksum, so absence remains a
|
|
123
|
+
compatibility case. A present but incorrect checksum is corruption.
|
|
124
|
+
"""
|
|
125
|
+
expected = raw.get("checksum")
|
|
126
|
+
if not expected:
|
|
127
|
+
return True
|
|
128
|
+
unsigned = {key: value for key, value in raw.items() if key != "checksum"}
|
|
129
|
+
canonical = json.dumps(
|
|
130
|
+
unsigned,
|
|
131
|
+
ensure_ascii=False,
|
|
132
|
+
separators=(",", ":"),
|
|
133
|
+
sort_keys=True,
|
|
134
|
+
) + "\n"
|
|
135
|
+
if _checksum(canonical) == expected:
|
|
136
|
+
return True
|
|
137
|
+
legacy = json.dumps(unsigned, ensure_ascii=False) + "\n"
|
|
138
|
+
return _checksum(legacy) == expected
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def _journal_lock(journal_dir: Path) -> Iterator[None]:
|
|
143
|
+
"""Serialize cursor allocation and journal mutation across processes."""
|
|
144
|
+
journal_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with _PROCESS_LOCK:
|
|
146
|
+
with (journal_dir / _LOCK_FILENAME).open("a+b") as lock_file:
|
|
147
|
+
if fcntl is not None:
|
|
148
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
149
|
+
try:
|
|
150
|
+
yield
|
|
151
|
+
finally:
|
|
152
|
+
if fcntl is not None:
|
|
153
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _event_file_sequence(path: Path) -> Optional[int]:
|
|
157
|
+
prefix = path.name.split(".", 1)[0]
|
|
158
|
+
return int(prefix) if prefix.isdigit() else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _event_files(journal_dir: Path) -> list[tuple[int, Path]]:
|
|
162
|
+
records: list[tuple[int, Path]] = []
|
|
163
|
+
for path in journal_dir.glob("*.json"):
|
|
164
|
+
seq = _event_file_sequence(path)
|
|
165
|
+
if seq is not None:
|
|
166
|
+
records.append((seq, path))
|
|
167
|
+
records.sort(key=lambda item: (item[0], item[1].name))
|
|
168
|
+
return records
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _cursor_path(journal_dir: Path) -> Path:
|
|
172
|
+
return journal_dir / _CURSOR_FILENAME
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _index_dir(journal_dir: Path) -> Path:
|
|
176
|
+
return journal_dir / _INDEX_DIRNAME
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _event_key_path(journal_dir: Path, event_key: str) -> Path:
|
|
180
|
+
return journal_dir / _EVENT_KEY_DIRNAME / _event_key_digest(event_key)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _event_key_request_digest(
|
|
184
|
+
event_type: str,
|
|
185
|
+
data: Dict[str, Any],
|
|
186
|
+
occurred_at: Optional[str],
|
|
187
|
+
) -> str:
|
|
188
|
+
return _checksum(json.dumps(
|
|
189
|
+
{
|
|
190
|
+
"type": event_type,
|
|
191
|
+
"data": data,
|
|
192
|
+
"occurredAt": occurred_at or "",
|
|
193
|
+
},
|
|
194
|
+
ensure_ascii=False,
|
|
195
|
+
separators=(",", ":"),
|
|
196
|
+
sort_keys=True,
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def event_record_request_digest(
|
|
201
|
+
event_type: str,
|
|
202
|
+
data: Dict[str, Any],
|
|
203
|
+
occurred_at: Optional[str],
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Return the immutable keyed-append request identity for a caller."""
|
|
206
|
+
|
|
207
|
+
return _event_key_request_digest(event_type, data, occurred_at)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _event_key_digest(event_key: str) -> str:
|
|
211
|
+
return hashlib.sha256(event_key.encode("utf-8")).hexdigest()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _write_cursor(journal_dir: Path, last_seq: int, pruned_through: int) -> None:
|
|
215
|
+
payload = json.dumps(
|
|
216
|
+
{"lastSeq": last_seq, "prunedThrough": pruned_through},
|
|
217
|
+
separators=(",", ":"),
|
|
218
|
+
sort_keys=True,
|
|
219
|
+
) + "\n"
|
|
220
|
+
_atomic_write(_cursor_path(journal_dir), payload)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _write_index_entry(journal_dir: Path, seq: int, filenames: list[str]) -> None:
|
|
224
|
+
payload = json.dumps(filenames, separators=(",", ":")) + "\n"
|
|
225
|
+
_atomic_write(_index_dir(journal_dir) / _format_seq(seq), payload)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _rebuild_cursor(journal_dir: Path) -> tuple[int, int]:
|
|
229
|
+
"""One-time migration/repair scan for legacy file-only journals."""
|
|
230
|
+
files = _event_files(journal_dir)
|
|
231
|
+
by_seq: dict[int, list[str]] = {}
|
|
232
|
+
for seq, path in files:
|
|
233
|
+
by_seq.setdefault(seq, []).append(path.name)
|
|
234
|
+
try:
|
|
235
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
236
|
+
marker_digest = str(raw.get("eventKeyDigest") or "")
|
|
237
|
+
if re.fullmatch(r"[a-f0-9]{64}", marker_digest):
|
|
238
|
+
by_seq[seq].append(f"@event-key:{marker_digest}")
|
|
239
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
240
|
+
pass
|
|
241
|
+
for seq, filenames in by_seq.items():
|
|
242
|
+
_write_index_entry(journal_dir, seq, filenames)
|
|
243
|
+
|
|
244
|
+
last_seq = files[-1][0] if files else 0
|
|
245
|
+
pruned_through = (files[0][0] - 1) if files else 0
|
|
246
|
+
_write_cursor(journal_dir, last_seq, pruned_through)
|
|
247
|
+
return last_seq, pruned_through
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _load_cursor(journal_dir: Path) -> tuple[int, int]:
|
|
251
|
+
try:
|
|
252
|
+
raw = json.loads(_cursor_path(journal_dir).read_text(encoding="utf-8"))
|
|
253
|
+
last_seq = int(raw["lastSeq"])
|
|
254
|
+
pruned_through = int(raw.get("prunedThrough", 0))
|
|
255
|
+
# Legacy test/development journals may contain sequence zero, whose
|
|
256
|
+
# natural predecessor is -1. Newly allocated production sequences
|
|
257
|
+
# still begin at one.
|
|
258
|
+
if last_seq < 0 or pruned_through < -1 or pruned_through > last_seq:
|
|
259
|
+
raise ValueError("invalid journal cursor")
|
|
260
|
+
return last_seq, pruned_through
|
|
261
|
+
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError):
|
|
262
|
+
logger.info("Rebuilding event-journal cursor from existing records")
|
|
263
|
+
return _rebuild_cursor(journal_dir)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _read_index_entry(path: Path) -> list[str]:
|
|
267
|
+
try:
|
|
268
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
269
|
+
if isinstance(value, list):
|
|
270
|
+
return [str(item) for item in value]
|
|
271
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
272
|
+
pass
|
|
273
|
+
return []
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _prune_indexed_events(
|
|
277
|
+
journal_dir: Path,
|
|
278
|
+
current_seq: int,
|
|
279
|
+
keep: int,
|
|
280
|
+
pruned_through: int,
|
|
281
|
+
) -> int:
|
|
282
|
+
"""Prune expired sequences without scanning the journal in steady state."""
|
|
283
|
+
cutoff = current_seq - max(1, keep)
|
|
284
|
+
if cutoff <= pruned_through:
|
|
285
|
+
return pruned_through
|
|
286
|
+
|
|
287
|
+
index_dir = _index_dir(journal_dir)
|
|
288
|
+
for seq in range(pruned_through + 1, cutoff + 1):
|
|
289
|
+
index_path = index_dir / _format_seq(seq)
|
|
290
|
+
filenames = _read_index_entry(index_path)
|
|
291
|
+
prune_ok = True
|
|
292
|
+
needs_fallback = not filenames
|
|
293
|
+
if filenames:
|
|
294
|
+
for filename in filenames:
|
|
295
|
+
if filename.startswith("@event-key:"):
|
|
296
|
+
marker_digest = filename.removeprefix("@event-key:")
|
|
297
|
+
if not re.fullmatch(r"[a-f0-9]{64}", marker_digest):
|
|
298
|
+
logger.warning(
|
|
299
|
+
"Ignoring unsafe event-key index entry %r", filename,
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
if not prune_ok:
|
|
303
|
+
# The event file remains. Its marker was changed to
|
|
304
|
+
# pruning, so keyed replay cannot duplicate it while a
|
|
305
|
+
# later retention pass retries deletion.
|
|
306
|
+
continue
|
|
307
|
+
marker_path = journal_dir / _EVENT_KEY_DIRNAME / marker_digest
|
|
308
|
+
try:
|
|
309
|
+
marker = json.loads(
|
|
310
|
+
marker_path.read_text(encoding="utf-8")
|
|
311
|
+
)
|
|
312
|
+
except FileNotFoundError:
|
|
313
|
+
continue
|
|
314
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
315
|
+
logger.warning(
|
|
316
|
+
"Failed to read journal event-key marker %s",
|
|
317
|
+
marker_path, exc_info=True,
|
|
318
|
+
)
|
|
319
|
+
prune_ok = False
|
|
320
|
+
continue
|
|
321
|
+
# The inbox receipt is the only authority allowed to
|
|
322
|
+
# acknowledge this key. Retention removes event content,
|
|
323
|
+
# but preserves fixed-size projection metadata so an
|
|
324
|
+
# interrupted inbox finalization cannot resurrect it.
|
|
325
|
+
marker["state"] = "pruned"
|
|
326
|
+
marker.pop("filename", None)
|
|
327
|
+
try:
|
|
328
|
+
_atomic_write(
|
|
329
|
+
marker_path,
|
|
330
|
+
json.dumps(
|
|
331
|
+
marker,
|
|
332
|
+
ensure_ascii=False,
|
|
333
|
+
separators=(",", ":"),
|
|
334
|
+
sort_keys=True,
|
|
335
|
+
) + "\n",
|
|
336
|
+
)
|
|
337
|
+
except OSError:
|
|
338
|
+
logger.warning(
|
|
339
|
+
"Failed to retain journal event-key tombstone %s",
|
|
340
|
+
marker_path, exc_info=True,
|
|
341
|
+
)
|
|
342
|
+
prune_ok = False
|
|
343
|
+
continue
|
|
344
|
+
# Index contents are internal, but still constrain deletion to
|
|
345
|
+
# a basename beneath the journal directory.
|
|
346
|
+
if Path(filename).name != filename:
|
|
347
|
+
logger.warning("Ignoring unsafe journal index entry %r", filename)
|
|
348
|
+
needs_fallback = True
|
|
349
|
+
continue
|
|
350
|
+
marker_path: Optional[Path] = None
|
|
351
|
+
event_path = journal_dir / filename
|
|
352
|
+
try:
|
|
353
|
+
raw = json.loads(event_path.read_text(encoding="utf-8"))
|
|
354
|
+
marker_digest = str(raw.get("eventKeyDigest") or "")
|
|
355
|
+
if re.fullmatch(r"[a-f0-9]{64}", marker_digest):
|
|
356
|
+
marker_path = (
|
|
357
|
+
journal_dir / _EVENT_KEY_DIRNAME / marker_digest
|
|
358
|
+
)
|
|
359
|
+
if marker_path.exists():
|
|
360
|
+
marker = json.loads(
|
|
361
|
+
marker_path.read_text(encoding="utf-8")
|
|
362
|
+
)
|
|
363
|
+
marker["state"] = "pruning"
|
|
364
|
+
_atomic_write(
|
|
365
|
+
marker_path,
|
|
366
|
+
json.dumps(
|
|
367
|
+
marker,
|
|
368
|
+
ensure_ascii=False,
|
|
369
|
+
separators=(",", ":"),
|
|
370
|
+
sort_keys=True,
|
|
371
|
+
) + "\n",
|
|
372
|
+
)
|
|
373
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError):
|
|
374
|
+
marker_path = None
|
|
375
|
+
try:
|
|
376
|
+
event_path.unlink()
|
|
377
|
+
except FileNotFoundError:
|
|
378
|
+
pass
|
|
379
|
+
except OSError:
|
|
380
|
+
logger.warning("Failed to prune journal record %s", filename,
|
|
381
|
+
exc_info=True)
|
|
382
|
+
prune_ok = False
|
|
383
|
+
if needs_fallback:
|
|
384
|
+
# Exceptional repair path for an interrupted legacy migration.
|
|
385
|
+
for path in journal_dir.glob(f"{_format_seq(seq)}.*.json"):
|
|
386
|
+
try:
|
|
387
|
+
path.unlink()
|
|
388
|
+
except OSError:
|
|
389
|
+
logger.warning("Failed to prune journal record %s", path,
|
|
390
|
+
exc_info=True)
|
|
391
|
+
prune_ok = False
|
|
392
|
+
if not prune_ok:
|
|
393
|
+
# Keep the cursor immediately before the failed sequence so a
|
|
394
|
+
# later append retries instead of silently abandoning retention.
|
|
395
|
+
return seq - 1
|
|
396
|
+
try:
|
|
397
|
+
index_path.unlink()
|
|
398
|
+
except FileNotFoundError:
|
|
399
|
+
pass
|
|
400
|
+
except OSError:
|
|
401
|
+
logger.warning("Failed to remove journal index %s", index_path,
|
|
402
|
+
exc_info=True)
|
|
403
|
+
return seq - 1
|
|
404
|
+
return cutoff
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def append_event_record(
|
|
408
|
+
event_type: str,
|
|
409
|
+
data: Dict[str, Any],
|
|
410
|
+
*,
|
|
411
|
+
occurred_at: Optional[str] = None,
|
|
412
|
+
event_key: Optional[str] = None,
|
|
413
|
+
) -> Optional[Dict[str, Any]]:
|
|
414
|
+
"""Durably append an event and return its canonical journal record.
|
|
415
|
+
|
|
416
|
+
``event_key`` enables a crash-recoverable exactly-once append. Its marker
|
|
417
|
+
is written after sequence reservation but before the event file, so a
|
|
418
|
+
retry either returns or finishes the same canonical record.
|
|
419
|
+
|
|
420
|
+
``None`` means the event was not made durable and therefore must not be
|
|
421
|
+
published to the live stream. Sequence gaps are intentionally permitted
|
|
422
|
+
after an interrupted or failed write; sequence reuse is not.
|
|
423
|
+
"""
|
|
424
|
+
journal_dir = _journal_dir()
|
|
425
|
+
try:
|
|
426
|
+
with _journal_lock(journal_dir):
|
|
427
|
+
normalized_key = str(event_key or "").strip()
|
|
428
|
+
if len(normalized_key) > 256:
|
|
429
|
+
raise ValueError("event_key exceeds 256 characters")
|
|
430
|
+
request_digest = (
|
|
431
|
+
_event_key_request_digest(event_type, data, occurred_at)
|
|
432
|
+
if normalized_key else ""
|
|
433
|
+
)
|
|
434
|
+
marker_path = (
|
|
435
|
+
_event_key_path(journal_dir, normalized_key)
|
|
436
|
+
if normalized_key else None
|
|
437
|
+
)
|
|
438
|
+
last_seq, pruned_through = _load_cursor(journal_dir)
|
|
439
|
+
if marker_path is not None and marker_path.exists():
|
|
440
|
+
marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
441
|
+
key_digest = _event_key_digest(normalized_key)
|
|
442
|
+
if (
|
|
443
|
+
marker.get("eventKeyDigest") != key_digest
|
|
444
|
+
or marker.get("requestDigest") != request_digest
|
|
445
|
+
):
|
|
446
|
+
raise ValueError("immutable event_key replay mismatch")
|
|
447
|
+
seq = int(marker.get("seq") or 0)
|
|
448
|
+
recorded_at = str(marker.get("recordedAt") or "")
|
|
449
|
+
event_id = str(marker.get("ulid") or "")
|
|
450
|
+
state = str(marker.get("state") or "")
|
|
451
|
+
if not seq or not recorded_at or not event_id:
|
|
452
|
+
raise ValueError("event_key marker metadata is invalid")
|
|
453
|
+
if state in {"pruning", "pruned"} or seq <= pruned_through:
|
|
454
|
+
# Never reconstruct event content after retention has
|
|
455
|
+
# started. The request digest above proves this is the same
|
|
456
|
+
# immutable request; only its original durable metadata is
|
|
457
|
+
# needed to truthfully finish the inbox transaction.
|
|
458
|
+
if state != "pruned":
|
|
459
|
+
marker["state"] = "pruned"
|
|
460
|
+
marker.pop("filename", None)
|
|
461
|
+
_atomic_write(
|
|
462
|
+
marker_path,
|
|
463
|
+
json.dumps(
|
|
464
|
+
marker,
|
|
465
|
+
ensure_ascii=False,
|
|
466
|
+
separators=(",", ":"),
|
|
467
|
+
sort_keys=True,
|
|
468
|
+
) + "\n",
|
|
469
|
+
)
|
|
470
|
+
return {
|
|
471
|
+
"seq": seq,
|
|
472
|
+
"ulid": event_id,
|
|
473
|
+
"recordedAt": recorded_at,
|
|
474
|
+
"retained": False,
|
|
475
|
+
}
|
|
476
|
+
if state != "active":
|
|
477
|
+
raise ValueError("event_key marker state is invalid")
|
|
478
|
+
filename = str(marker.get("filename") or "")
|
|
479
|
+
if (
|
|
480
|
+
Path(filename).name != filename
|
|
481
|
+
or not filename or not recorded_at or not event_id
|
|
482
|
+
):
|
|
483
|
+
raise ValueError("event_key marker filename is invalid")
|
|
484
|
+
event_payload = {
|
|
485
|
+
"seq": seq,
|
|
486
|
+
"ulid": event_id,
|
|
487
|
+
"type": event_type,
|
|
488
|
+
"recordedAt": recorded_at,
|
|
489
|
+
"occurredAt": occurred_at or recorded_at,
|
|
490
|
+
"data": data,
|
|
491
|
+
"eventKeyDigest": key_digest,
|
|
492
|
+
}
|
|
493
|
+
unsigned = json.dumps(
|
|
494
|
+
event_payload,
|
|
495
|
+
ensure_ascii=False,
|
|
496
|
+
separators=(",", ":"),
|
|
497
|
+
sort_keys=True,
|
|
498
|
+
) + "\n"
|
|
499
|
+
payload_with_checksum = json.dumps(
|
|
500
|
+
{**event_payload, "checksum": _checksum(unsigned)},
|
|
501
|
+
ensure_ascii=False,
|
|
502
|
+
separators=(",", ":"),
|
|
503
|
+
sort_keys=True,
|
|
504
|
+
) + "\n"
|
|
505
|
+
event_path = journal_dir / filename
|
|
506
|
+
if event_path.exists():
|
|
507
|
+
persisted = json.loads(event_path.read_text(encoding="utf-8"))
|
|
508
|
+
if (
|
|
509
|
+
not _valid_record_checksum(persisted)
|
|
510
|
+
or {
|
|
511
|
+
key: value for key, value in persisted.items()
|
|
512
|
+
if key != "checksum"
|
|
513
|
+
} != event_payload
|
|
514
|
+
):
|
|
515
|
+
raise ValueError("event_key record is corrupt")
|
|
516
|
+
else:
|
|
517
|
+
_write_index_entry(
|
|
518
|
+
journal_dir,
|
|
519
|
+
int(event_payload["seq"]),
|
|
520
|
+
[filename, f"@event-key:{key_digest}"],
|
|
521
|
+
)
|
|
522
|
+
_atomic_write(event_path, payload_with_checksum)
|
|
523
|
+
return event_payload
|
|
524
|
+
|
|
525
|
+
seq = last_seq + 1
|
|
526
|
+
|
|
527
|
+
# Reserve the sequence before writing the record. A crash can burn
|
|
528
|
+
# a number, but cannot allow a later event to reuse it.
|
|
529
|
+
_write_cursor(journal_dir, seq, pruned_through)
|
|
530
|
+
|
|
531
|
+
event_id = (
|
|
532
|
+
hashlib.sha256(normalized_key.encode("utf-8")).hexdigest()[:26]
|
|
533
|
+
if normalized_key else uuid.uuid4().hex[:26]
|
|
534
|
+
)
|
|
535
|
+
recorded_at = datetime.now(timezone.utc).isoformat()
|
|
536
|
+
event_payload: Dict[str, Any] = {
|
|
537
|
+
"seq": seq,
|
|
538
|
+
"ulid": event_id,
|
|
539
|
+
"type": event_type,
|
|
540
|
+
"recordedAt": recorded_at,
|
|
541
|
+
"occurredAt": occurred_at or recorded_at,
|
|
542
|
+
"data": data,
|
|
543
|
+
}
|
|
544
|
+
if normalized_key:
|
|
545
|
+
event_payload["eventKeyDigest"] = _event_key_digest(
|
|
546
|
+
normalized_key
|
|
547
|
+
)
|
|
548
|
+
unsigned = json.dumps(
|
|
549
|
+
event_payload,
|
|
550
|
+
ensure_ascii=False,
|
|
551
|
+
separators=(",", ":"),
|
|
552
|
+
sort_keys=True,
|
|
553
|
+
) + "\n"
|
|
554
|
+
payload_with_checksum = json.dumps(
|
|
555
|
+
{**event_payload, "checksum": _checksum(unsigned)},
|
|
556
|
+
ensure_ascii=False,
|
|
557
|
+
separators=(",", ":"),
|
|
558
|
+
sort_keys=True,
|
|
559
|
+
) + "\n"
|
|
560
|
+
|
|
561
|
+
filename = f"{_format_seq(seq)}.{event_id}.json"
|
|
562
|
+
if marker_path is not None:
|
|
563
|
+
_atomic_write(
|
|
564
|
+
marker_path,
|
|
565
|
+
json.dumps(
|
|
566
|
+
{
|
|
567
|
+
"state": "active",
|
|
568
|
+
"eventKeyDigest": _event_key_digest(normalized_key),
|
|
569
|
+
"requestDigest": request_digest,
|
|
570
|
+
"filename": filename,
|
|
571
|
+
"seq": seq,
|
|
572
|
+
"ulid": event_id,
|
|
573
|
+
"recordedAt": recorded_at,
|
|
574
|
+
},
|
|
575
|
+
ensure_ascii=False,
|
|
576
|
+
separators=(",", ":"),
|
|
577
|
+
sort_keys=True,
|
|
578
|
+
) + "\n",
|
|
579
|
+
)
|
|
580
|
+
# Write the prune pointer first. If the process dies before the
|
|
581
|
+
# record write, a later prune sees a harmless pointer to no file.
|
|
582
|
+
index_filenames = [filename]
|
|
583
|
+
if normalized_key:
|
|
584
|
+
index_filenames.append(
|
|
585
|
+
f"@event-key:{_event_key_digest(normalized_key)}"
|
|
586
|
+
)
|
|
587
|
+
_write_index_entry(journal_dir, seq, index_filenames)
|
|
588
|
+
_atomic_write(journal_dir / filename, payload_with_checksum)
|
|
589
|
+
|
|
590
|
+
new_pruned_through = _prune_indexed_events(
|
|
591
|
+
journal_dir, seq, _retention(), pruned_through
|
|
592
|
+
)
|
|
593
|
+
if new_pruned_through != pruned_through:
|
|
594
|
+
_write_cursor(journal_dir, seq, new_pruned_through)
|
|
595
|
+
return event_payload
|
|
596
|
+
except Exception:
|
|
597
|
+
logger.error("Failed to append event %s to journal", event_type, exc_info=True)
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def acknowledge_event_record(
|
|
602
|
+
event_key: str,
|
|
603
|
+
*,
|
|
604
|
+
expected_seq: Optional[int] = None,
|
|
605
|
+
expected_event_id: Optional[str] = None,
|
|
606
|
+
expected_recorded_at: Optional[str] = None,
|
|
607
|
+
expected_request_digest: Optional[str] = None,
|
|
608
|
+
) -> bool:
|
|
609
|
+
"""Release a keyed projection marker after its owning receipt commits.
|
|
610
|
+
|
|
611
|
+
The key itself is never persisted. A missing marker is already
|
|
612
|
+
acknowledged, making this safe to retry after a process restart.
|
|
613
|
+
"""
|
|
614
|
+
journal_dir = _journal_dir()
|
|
615
|
+
try:
|
|
616
|
+
normalized_key = str(event_key or "").strip()
|
|
617
|
+
if not normalized_key or len(normalized_key) > 256:
|
|
618
|
+
raise ValueError("event_key is invalid")
|
|
619
|
+
expected_values = (
|
|
620
|
+
expected_seq,
|
|
621
|
+
expected_event_id,
|
|
622
|
+
expected_recorded_at,
|
|
623
|
+
expected_request_digest,
|
|
624
|
+
)
|
|
625
|
+
if any(value is not None for value in expected_values) and not all(
|
|
626
|
+
value is not None for value in expected_values
|
|
627
|
+
):
|
|
628
|
+
raise ValueError("expected journal projection receipt is incomplete")
|
|
629
|
+
if expected_seq is not None and (
|
|
630
|
+
type(expected_seq) is not int or expected_seq < 1
|
|
631
|
+
):
|
|
632
|
+
raise ValueError("expected journal sequence is invalid")
|
|
633
|
+
if expected_event_id is not None and (
|
|
634
|
+
type(expected_event_id) is not str
|
|
635
|
+
or not expected_event_id
|
|
636
|
+
or expected_event_id != expected_event_id.strip()
|
|
637
|
+
):
|
|
638
|
+
raise ValueError("expected journal event ID is invalid")
|
|
639
|
+
if expected_recorded_at is not None and (
|
|
640
|
+
type(expected_recorded_at) is not str
|
|
641
|
+
or not expected_recorded_at
|
|
642
|
+
or expected_recorded_at != expected_recorded_at.strip()
|
|
643
|
+
):
|
|
644
|
+
raise ValueError("expected journal recorded time is invalid")
|
|
645
|
+
if expected_request_digest is not None and not re.fullmatch(
|
|
646
|
+
r"[a-f0-9]{64}", expected_request_digest,
|
|
647
|
+
):
|
|
648
|
+
raise ValueError("expected journal request digest is invalid")
|
|
649
|
+
with _journal_lock(journal_dir):
|
|
650
|
+
marker_path = _event_key_path(journal_dir, normalized_key)
|
|
651
|
+
try:
|
|
652
|
+
marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
653
|
+
except FileNotFoundError:
|
|
654
|
+
return True
|
|
655
|
+
if marker.get("eventKeyDigest") != _event_key_digest(normalized_key):
|
|
656
|
+
raise ValueError("event_key marker digest mismatch")
|
|
657
|
+
if expected_seq is not None and (
|
|
658
|
+
type(marker.get("seq")) is not int
|
|
659
|
+
or marker.get("seq") != expected_seq
|
|
660
|
+
or marker.get("ulid") != expected_event_id
|
|
661
|
+
or marker.get("recordedAt") != expected_recorded_at
|
|
662
|
+
or marker.get("requestDigest") != expected_request_digest
|
|
663
|
+
):
|
|
664
|
+
raise ValueError("event_key marker projection receipt mismatch")
|
|
665
|
+
marker_path.unlink()
|
|
666
|
+
_fsync_directory(marker_path.parent)
|
|
667
|
+
return True
|
|
668
|
+
except Exception:
|
|
669
|
+
logger.error("Failed to acknowledge journal event key", exc_info=True)
|
|
670
|
+
return False
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def append_event(event_type: str, data: Dict[str, Any]) -> int:
|
|
674
|
+
"""Compatibility wrapper returning the assigned sequence or ``-1``."""
|
|
675
|
+
record = append_event_record(event_type, data)
|
|
676
|
+
return int(record["seq"]) if record is not None else -1
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def current_sequence() -> int:
|
|
680
|
+
"""Return the journal's durable high-water sequence."""
|
|
681
|
+
journal_dir = _journal_dir()
|
|
682
|
+
try:
|
|
683
|
+
with _journal_lock(journal_dir):
|
|
684
|
+
last_seq, _ = _load_cursor(journal_dir)
|
|
685
|
+
return last_seq
|
|
686
|
+
except Exception:
|
|
687
|
+
logger.error("Failed to read event-journal cursor", exc_info=True)
|
|
688
|
+
return 0
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _prune_events(current_seq: int, keep: int) -> None:
|
|
692
|
+
"""Compatibility helper used by older maintenance code and tests."""
|
|
693
|
+
journal_dir = _journal_dir()
|
|
694
|
+
try:
|
|
695
|
+
with _journal_lock(journal_dir):
|
|
696
|
+
last_seq, pruned_through = _load_cursor(journal_dir)
|
|
697
|
+
new_pruned = _prune_indexed_events(
|
|
698
|
+
journal_dir, current_seq, keep, pruned_through
|
|
699
|
+
)
|
|
700
|
+
if new_pruned != pruned_through:
|
|
701
|
+
_write_cursor(journal_dir, max(last_seq, current_seq), new_pruned)
|
|
702
|
+
except Exception:
|
|
703
|
+
logger.debug("Prune failed", exc_info=True)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _parse_timestamp(value: str) -> Optional[datetime]:
|
|
707
|
+
if not value or value == "0":
|
|
708
|
+
return None
|
|
709
|
+
try:
|
|
710
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
711
|
+
if parsed.tzinfo is None:
|
|
712
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
713
|
+
return parsed.astimezone(timezone.utc)
|
|
714
|
+
except (TypeError, ValueError):
|
|
715
|
+
return None
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def replay_events(
|
|
719
|
+
since: str = "",
|
|
720
|
+
limit: int = 500,
|
|
721
|
+
types: Optional[List[str]] = None,
|
|
722
|
+
newest_first: bool = False,
|
|
723
|
+
*,
|
|
724
|
+
after_seq: Optional[int] = None,
|
|
725
|
+
until_seq: Optional[int] = None,
|
|
726
|
+
) -> Dict[str, Any]:
|
|
727
|
+
"""Replay a consistent journal snapshot by sequence and/or timestamp.
|
|
728
|
+
|
|
729
|
+
``after_seq`` is the authoritative reconnect cursor when supplied. The
|
|
730
|
+
timestamp remains supported for older clients. ``until_seq`` lets a
|
|
731
|
+
WebSocket handshake replay to a captured high-water mark while live events
|
|
732
|
+
accumulate in its bounded subscriber buffer.
|
|
733
|
+
"""
|
|
734
|
+
journal_dir = _journal_dir()
|
|
735
|
+
if not journal_dir.exists():
|
|
736
|
+
return {
|
|
737
|
+
"events": [], "lastSeq": 0, "hasMore": False,
|
|
738
|
+
"firstAvailableSeq": 0, "journalLastSeq": 0,
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
since_dt = _parse_timestamp(since)
|
|
742
|
+
limit = max(1, int(limit))
|
|
743
|
+
event_types = set(types) if types else None
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
with _journal_lock(journal_dir):
|
|
747
|
+
journal_last_seq, _ = _load_cursor(journal_dir)
|
|
748
|
+
files = _event_files(journal_dir)
|
|
749
|
+
first_available = files[0][0] if files else 0
|
|
750
|
+
|
|
751
|
+
# Validate the complete retained generation before applying query
|
|
752
|
+
# filters or pagination. A page boundary must never hide a second
|
|
753
|
+
# file for an already-returned sequence (or a file whose embedded
|
|
754
|
+
# identity differs from its filename): cursor consumers would skip
|
|
755
|
+
# that record forever on their next ``after_seq`` replay.
|
|
756
|
+
sequence_counts: Dict[int, int] = {}
|
|
757
|
+
for seq, _path in files:
|
|
758
|
+
sequence_counts[seq] = sequence_counts.get(seq, 0) + 1
|
|
759
|
+
duplicate_sequences = {
|
|
760
|
+
seq for seq, count in sequence_counts.items() if count > 1
|
|
761
|
+
}
|
|
762
|
+
corrupt_count = sum(
|
|
763
|
+
count - 1 for count in sequence_counts.values() if count > 1
|
|
764
|
+
)
|
|
765
|
+
validated: list[tuple[int, Path, Dict[str, Any]]] = []
|
|
766
|
+
for seq, path in files:
|
|
767
|
+
if seq in duplicate_sequences:
|
|
768
|
+
# Neither file is authoritative when one sequence has more
|
|
769
|
+
# than one durable identity. Report the collision and
|
|
770
|
+
# withhold the ambiguous records from every page.
|
|
771
|
+
continue
|
|
772
|
+
try:
|
|
773
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
774
|
+
except (json.JSONDecodeError, OSError):
|
|
775
|
+
corrupt_count += 1
|
|
776
|
+
continue
|
|
777
|
+
if not isinstance(raw, dict) or not _valid_record_checksum(raw):
|
|
778
|
+
corrupt_count += 1
|
|
779
|
+
continue
|
|
780
|
+
raw_sequence = raw.get("seq")
|
|
781
|
+
if raw_sequence is not None and (
|
|
782
|
+
type(raw_sequence) is not int or raw_sequence != seq
|
|
783
|
+
):
|
|
784
|
+
corrupt_count += 1
|
|
785
|
+
continue
|
|
786
|
+
parts = path.name.split(".")
|
|
787
|
+
filename_event_id = parts[1] if len(parts) > 2 else ""
|
|
788
|
+
raw_event_id = raw.get("ulid")
|
|
789
|
+
if raw_event_id not in (None, "") and (
|
|
790
|
+
type(raw_event_id) is not str
|
|
791
|
+
or not filename_event_id
|
|
792
|
+
or raw_event_id != filename_event_id
|
|
793
|
+
):
|
|
794
|
+
corrupt_count += 1
|
|
795
|
+
continue
|
|
796
|
+
validated.append((seq, path, raw))
|
|
797
|
+
|
|
798
|
+
ordered = (
|
|
799
|
+
list(reversed(validated)) if newest_first else validated
|
|
800
|
+
)
|
|
801
|
+
events: List[Dict[str, Any]] = []
|
|
802
|
+
has_more = False
|
|
803
|
+
for seq, path, raw in ordered:
|
|
804
|
+
if after_seq is not None and seq <= after_seq:
|
|
805
|
+
continue
|
|
806
|
+
if until_seq is not None and seq > until_seq:
|
|
807
|
+
continue
|
|
808
|
+
|
|
809
|
+
recorded_at = str(raw.get("recordedAt", ""))
|
|
810
|
+
if after_seq is None and since_dt is not None:
|
|
811
|
+
recorded_dt = _parse_timestamp(recorded_at)
|
|
812
|
+
if recorded_dt is None or recorded_dt <= since_dt:
|
|
813
|
+
continue
|
|
814
|
+
event_type = str(raw.get("type", "unknown"))
|
|
815
|
+
if event_types and event_type not in event_types:
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
if len(events) >= limit:
|
|
819
|
+
has_more = True
|
|
820
|
+
break
|
|
821
|
+
parts = path.name.split(".")
|
|
822
|
+
event_id = str(raw.get("ulid") or (parts[1] if len(parts) > 2 else ""))
|
|
823
|
+
events.append({
|
|
824
|
+
"seq": seq,
|
|
825
|
+
"ulid": event_id,
|
|
826
|
+
"type": event_type,
|
|
827
|
+
"recordedAt": recorded_at,
|
|
828
|
+
"occurredAt": raw.get("occurredAt", recorded_at),
|
|
829
|
+
"data": raw.get("data", {}),
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
return {
|
|
833
|
+
"events": events,
|
|
834
|
+
"lastSeq": events[-1]["seq"] if events else 0,
|
|
835
|
+
"hasMore": has_more,
|
|
836
|
+
"firstAvailableSeq": first_available,
|
|
837
|
+
"journalLastSeq": journal_last_seq,
|
|
838
|
+
"corruptCount": corrupt_count,
|
|
839
|
+
}
|
|
840
|
+
except OSError:
|
|
841
|
+
logger.error("Failed to replay event journal", exc_info=True)
|
|
842
|
+
return {
|
|
843
|
+
"events": [], "lastSeq": 0, "hasMore": False,
|
|
844
|
+
"firstAvailableSeq": 0, "journalLastSeq": 0,
|
|
845
|
+
"replayError": "journal_unavailable",
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _file_is_after(
|
|
850
|
+
filepath: Path,
|
|
851
|
+
since: str,
|
|
852
|
+
types: Optional[List[str]] = None,
|
|
853
|
+
) -> bool:
|
|
854
|
+
"""Compatibility predicate for older callers and downstream extensions."""
|
|
855
|
+
try:
|
|
856
|
+
raw = json.loads(filepath.read_text(encoding="utf-8"))
|
|
857
|
+
since_dt = _parse_timestamp(since)
|
|
858
|
+
recorded_dt = _parse_timestamp(str(raw.get("recordedAt", "")))
|
|
859
|
+
if since_dt is not None and (recorded_dt is None or recorded_dt <= since_dt):
|
|
860
|
+
return False
|
|
861
|
+
return not types or raw.get("type", "unknown") in types
|
|
862
|
+
except Exception:
|
|
863
|
+
return False
|