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
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
"""SQLite-backed commitment store."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
import threading
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# Statuses that mean "still awaiting action". 'overdue' is a pending item
|
|
18
|
+
# whose due date passed (the condition worker flips it so the overdue event
|
|
19
|
+
# fires exactly once) — every open-work query must include BOTH, or a flipped
|
|
20
|
+
# item silently vanishes from dedup lists, prompt sections, and workspace
|
|
21
|
+
# ingest while still being owed.
|
|
22
|
+
OPEN_STATUSES = ("pending", "overdue")
|
|
23
|
+
|
|
24
|
+
# How a resolution outcome maps onto the terminal status. 'done' is the only
|
|
25
|
+
# outcome that counts as kept; everything else is a cancellation whose reason
|
|
26
|
+
# the system learns from (see resolution_stats / recent_rejections).
|
|
27
|
+
OUTCOME_TO_STATUS = {
|
|
28
|
+
"done": "fulfilled",
|
|
29
|
+
"invalid": "cancelled",
|
|
30
|
+
"duplicate": "cancelled",
|
|
31
|
+
"wont_do": "cancelled",
|
|
32
|
+
"obsolete": "cancelled",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
RESOLUTION_RECOVERY_CAPABILITY = "commitment_resolution_recovery_v1"
|
|
37
|
+
_RESOLUTION_RECOVERY_SCHEMA = "ColonyCommitmentResolutionRecoveryV1"
|
|
38
|
+
_RESOLUTION_OPERATION_TABLE = "commitment_resolution_operations"
|
|
39
|
+
_RESOLUTION_OPERATION_UPDATE_TRIGGER = (
|
|
40
|
+
"commitment_resolution_operations_no_update"
|
|
41
|
+
)
|
|
42
|
+
_RESOLUTION_OPERATION_DELETE_TRIGGER = (
|
|
43
|
+
"commitment_resolution_operations_no_delete"
|
|
44
|
+
)
|
|
45
|
+
_RESOLUTION_BOUND_DELETE_TRIGGER = "commitment_resolution_bound_row_no_delete"
|
|
46
|
+
|
|
47
|
+
_RESOLUTION_OPERATION_TABLE_SQL = """\
|
|
48
|
+
CREATE TABLE commitment_resolution_operations (
|
|
49
|
+
operation_id TEXT PRIMARY KEY,
|
|
50
|
+
commitment_id TEXT NOT NULL UNIQUE,
|
|
51
|
+
outcome TEXT NOT NULL,
|
|
52
|
+
note TEXT NOT NULL,
|
|
53
|
+
note_digest TEXT NOT NULL,
|
|
54
|
+
resolved_by TEXT NOT NULL,
|
|
55
|
+
status TEXT NOT NULL,
|
|
56
|
+
resolved_at TEXT NOT NULL,
|
|
57
|
+
record_digest TEXT NOT NULL UNIQUE
|
|
58
|
+
)"""
|
|
59
|
+
|
|
60
|
+
_RESOLUTION_OPERATION_UPDATE_TRIGGER_SQL = """\
|
|
61
|
+
CREATE TRIGGER commitment_resolution_operations_no_update
|
|
62
|
+
BEFORE UPDATE ON commitment_resolution_operations
|
|
63
|
+
BEGIN
|
|
64
|
+
SELECT RAISE(ABORT, 'commitment resolution operations are immutable');
|
|
65
|
+
END"""
|
|
66
|
+
|
|
67
|
+
_RESOLUTION_OPERATION_DELETE_TRIGGER_SQL = """\
|
|
68
|
+
CREATE TRIGGER commitment_resolution_operations_no_delete
|
|
69
|
+
BEFORE DELETE ON commitment_resolution_operations
|
|
70
|
+
BEGIN
|
|
71
|
+
SELECT RAISE(ABORT, 'commitment resolution operations are immutable');
|
|
72
|
+
END"""
|
|
73
|
+
|
|
74
|
+
_RESOLUTION_BOUND_DELETE_TRIGGER_SQL = """\
|
|
75
|
+
CREATE TRIGGER commitment_resolution_bound_row_no_delete
|
|
76
|
+
BEFORE DELETE ON commitments
|
|
77
|
+
WHEN EXISTS (
|
|
78
|
+
SELECT 1 FROM commitment_resolution_operations
|
|
79
|
+
WHERE commitment_id=OLD.id
|
|
80
|
+
)
|
|
81
|
+
BEGIN
|
|
82
|
+
SELECT RAISE(ABORT, 'operation-bound commitments cannot be deleted');
|
|
83
|
+
END"""
|
|
84
|
+
|
|
85
|
+
_RESOLUTION_RECOVERY_OBJECTS = {
|
|
86
|
+
_RESOLUTION_OPERATION_TABLE: (
|
|
87
|
+
"table", _RESOLUTION_OPERATION_TABLE,
|
|
88
|
+
_RESOLUTION_OPERATION_TABLE_SQL,
|
|
89
|
+
),
|
|
90
|
+
_RESOLUTION_OPERATION_UPDATE_TRIGGER: (
|
|
91
|
+
"trigger", _RESOLUTION_OPERATION_TABLE,
|
|
92
|
+
_RESOLUTION_OPERATION_UPDATE_TRIGGER_SQL,
|
|
93
|
+
),
|
|
94
|
+
_RESOLUTION_OPERATION_DELETE_TRIGGER: (
|
|
95
|
+
"trigger", _RESOLUTION_OPERATION_TABLE,
|
|
96
|
+
_RESOLUTION_OPERATION_DELETE_TRIGGER_SQL,
|
|
97
|
+
),
|
|
98
|
+
_RESOLUTION_BOUND_DELETE_TRIGGER: (
|
|
99
|
+
"trigger", "commitments", _RESOLUTION_BOUND_DELETE_TRIGGER_SQL,
|
|
100
|
+
),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_RESOLUTION_OPERATION_COLUMNS = (
|
|
104
|
+
(0, "operation_id", "TEXT", 0, None, 1),
|
|
105
|
+
(1, "commitment_id", "TEXT", 1, None, 0),
|
|
106
|
+
(2, "outcome", "TEXT", 1, None, 0),
|
|
107
|
+
(3, "note", "TEXT", 1, None, 0),
|
|
108
|
+
(4, "note_digest", "TEXT", 1, None, 0),
|
|
109
|
+
(5, "resolved_by", "TEXT", 1, None, 0),
|
|
110
|
+
(6, "status", "TEXT", 1, None, 0),
|
|
111
|
+
(7, "resolved_at", "TEXT", 1, None, 0),
|
|
112
|
+
(8, "record_digest", "TEXT", 1, None, 0),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
_RESOLUTION_OPERATION_INDEXES = {
|
|
116
|
+
"sqlite_autoindex_commitment_resolution_operations_1": (
|
|
117
|
+
"pk", "operation_id",
|
|
118
|
+
),
|
|
119
|
+
"sqlite_autoindex_commitment_resolution_operations_2": (
|
|
120
|
+
"u", "commitment_id",
|
|
121
|
+
),
|
|
122
|
+
"sqlite_autoindex_commitment_resolution_operations_3": (
|
|
123
|
+
"u", "record_digest",
|
|
124
|
+
),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class CommitmentResolutionSchemaError(RuntimeError):
|
|
129
|
+
"""The durable resolution-recovery schema is absent or untrustworthy."""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _sql_signature(sql: Optional[str]) -> str:
|
|
133
|
+
"""Stable exact-DDL comparison without depending on indentation."""
|
|
134
|
+
|
|
135
|
+
return " ".join((sql or "").strip().rstrip(";").split())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CommitmentResolutionConflict(ValueError):
|
|
139
|
+
"""A terminal commitment does not match a bound cascade operation."""
|
|
140
|
+
|
|
141
|
+
settlement_error_code = "operation_conflict"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _canonical_json(value: Dict[str, Any]) -> str:
|
|
145
|
+
return json.dumps(
|
|
146
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _operation_material(
|
|
151
|
+
*,
|
|
152
|
+
operation_id: str,
|
|
153
|
+
commitment_id: str,
|
|
154
|
+
outcome: str,
|
|
155
|
+
note: str,
|
|
156
|
+
note_digest: str,
|
|
157
|
+
resolved_by: str,
|
|
158
|
+
status: str,
|
|
159
|
+
resolved_at: str,
|
|
160
|
+
) -> Dict[str, Any]:
|
|
161
|
+
if (
|
|
162
|
+
type(operation_id) is not str or not operation_id
|
|
163
|
+
or operation_id != operation_id.strip() or len(operation_id) > 192
|
|
164
|
+
):
|
|
165
|
+
raise ValueError("commitment resolution operation_id is invalid")
|
|
166
|
+
if type(commitment_id) is not str or not commitment_id:
|
|
167
|
+
raise ValueError("commitment resolution operation commitment is invalid")
|
|
168
|
+
if outcome not in OUTCOME_TO_STATUS or OUTCOME_TO_STATUS[outcome] != status:
|
|
169
|
+
raise ValueError("commitment resolution operation outcome is invalid")
|
|
170
|
+
if type(note) is not str or len(note) > 300:
|
|
171
|
+
raise ValueError("commitment resolution operation note is invalid")
|
|
172
|
+
if (
|
|
173
|
+
type(note_digest) is not str or len(note_digest) != 64
|
|
174
|
+
or any(ch not in "0123456789abcdef" for ch in note_digest)
|
|
175
|
+
):
|
|
176
|
+
raise ValueError("commitment resolution operation note digest is invalid")
|
|
177
|
+
if type(resolved_by) is not str or len(resolved_by) > 128:
|
|
178
|
+
raise ValueError("commitment resolution operation resolver is invalid")
|
|
179
|
+
if type(resolved_at) is not str or not resolved_at:
|
|
180
|
+
raise ValueError("commitment resolution operation time is invalid")
|
|
181
|
+
payload = {
|
|
182
|
+
"schema": "ColonyCommitmentResolutionOperationV1",
|
|
183
|
+
"version": 1,
|
|
184
|
+
"operation_id": operation_id,
|
|
185
|
+
"commitment_id": commitment_id,
|
|
186
|
+
"outcome": outcome,
|
|
187
|
+
"note": note,
|
|
188
|
+
"note_digest": note_digest,
|
|
189
|
+
"resolved_by": resolved_by,
|
|
190
|
+
"status": status,
|
|
191
|
+
"resolved_at": resolved_at,
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
**payload,
|
|
195
|
+
"record_digest": hashlib.sha256(
|
|
196
|
+
_canonical_json(payload).encode("utf-8")
|
|
197
|
+
).hexdigest(),
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_desc(text: str) -> str:
|
|
202
|
+
"""Lowercased, punctuation-collapsed form used for duplicate detection."""
|
|
203
|
+
out = []
|
|
204
|
+
for ch in (text or "").lower():
|
|
205
|
+
out.append(ch if ch.isalnum() else " ")
|
|
206
|
+
return " ".join("".join(out).split())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _similar_desc(a: str, b: str) -> bool:
|
|
210
|
+
"""True when two normalized descriptions describe the same item: exact,
|
|
211
|
+
containment, or high token overlap (Jaccard >= 0.6)."""
|
|
212
|
+
if not a or not b:
|
|
213
|
+
return False
|
|
214
|
+
if a == b or a in b or b in a:
|
|
215
|
+
return True
|
|
216
|
+
ta, tb = set(a.split()), set(b.split())
|
|
217
|
+
if not ta or not tb:
|
|
218
|
+
return False
|
|
219
|
+
return len(ta & tb) / len(ta | tb) >= 0.6
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CommitmentStore:
|
|
223
|
+
"""Persistent store for commitment tracking.
|
|
224
|
+
|
|
225
|
+
Thread-safe via a threading lock. All datetime values stored as
|
|
226
|
+
ISO 8601 UTC strings.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, db_path: Path) -> None:
|
|
230
|
+
self._db_path = db_path
|
|
231
|
+
self._lock = threading.Lock()
|
|
232
|
+
self._init_db()
|
|
233
|
+
|
|
234
|
+
def _connect(self) -> sqlite3.Connection:
|
|
235
|
+
conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
|
236
|
+
conn.row_factory = sqlite3.Row
|
|
237
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
238
|
+
return conn
|
|
239
|
+
|
|
240
|
+
def _init_db(self) -> None:
|
|
241
|
+
with self._lock:
|
|
242
|
+
conn = self._connect()
|
|
243
|
+
try:
|
|
244
|
+
conn.executescript("""
|
|
245
|
+
CREATE TABLE IF NOT EXISTS commitments (
|
|
246
|
+
id TEXT PRIMARY KEY,
|
|
247
|
+
person_id TEXT NOT NULL,
|
|
248
|
+
description TEXT NOT NULL,
|
|
249
|
+
made_at TEXT NOT NULL,
|
|
250
|
+
due_at TEXT,
|
|
251
|
+
fulfilled_at TEXT,
|
|
252
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
253
|
+
source_context TEXT,
|
|
254
|
+
source_type TEXT NOT NULL DEFAULT 'manual',
|
|
255
|
+
priority INTEGER NOT NULL DEFAULT 50,
|
|
256
|
+
metadata TEXT
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
CREATE INDEX IF NOT EXISTS idx_commitments_person
|
|
260
|
+
ON commitments(person_id);
|
|
261
|
+
CREATE INDEX IF NOT EXISTS idx_commitments_status
|
|
262
|
+
ON commitments(status);
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_commitments_due
|
|
264
|
+
ON commitments(due_at) WHERE status = 'pending';
|
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_commitments_person_status
|
|
266
|
+
ON commitments(person_id, status);
|
|
267
|
+
""")
|
|
268
|
+
conn.commit()
|
|
269
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
270
|
+
existing = self._resolution_recovery_object_names(conn)
|
|
271
|
+
expected = set(_RESOLUTION_RECOVERY_OBJECTS)
|
|
272
|
+
if existing and existing != expected:
|
|
273
|
+
raise CommitmentResolutionSchemaError(
|
|
274
|
+
"commitment resolution recovery schema is partial"
|
|
275
|
+
)
|
|
276
|
+
if not existing:
|
|
277
|
+
for _kind, _table, ddl in (
|
|
278
|
+
_RESOLUTION_RECOVERY_OBJECTS[name]
|
|
279
|
+
for name in _RESOLUTION_RECOVERY_OBJECTS
|
|
280
|
+
):
|
|
281
|
+
conn.execute(ddl)
|
|
282
|
+
self._validate_resolution_recovery_schema(conn)
|
|
283
|
+
conn.commit()
|
|
284
|
+
except Exception:
|
|
285
|
+
conn.rollback()
|
|
286
|
+
raise
|
|
287
|
+
finally:
|
|
288
|
+
conn.close()
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _resolution_recovery_object_names(
|
|
292
|
+
conn: sqlite3.Connection,
|
|
293
|
+
) -> set[str]:
|
|
294
|
+
placeholders = ",".join("?" for _ in _RESOLUTION_RECOVERY_OBJECTS)
|
|
295
|
+
rows = conn.execute(
|
|
296
|
+
f"SELECT name FROM sqlite_master WHERE name IN ({placeholders})",
|
|
297
|
+
tuple(_RESOLUTION_RECOVERY_OBJECTS),
|
|
298
|
+
).fetchall()
|
|
299
|
+
return {str(row["name"]) for row in rows}
|
|
300
|
+
|
|
301
|
+
@classmethod
|
|
302
|
+
def _validate_resolution_recovery_schema(
|
|
303
|
+
cls, conn: sqlite3.Connection,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Reject any same-name, partial, or weakened recovery schema."""
|
|
306
|
+
|
|
307
|
+
placeholders = ",".join("?" for _ in _RESOLUTION_RECOVERY_OBJECTS)
|
|
308
|
+
rows = conn.execute(
|
|
309
|
+
"SELECT type,name,tbl_name,sql FROM sqlite_master "
|
|
310
|
+
f"WHERE name IN ({placeholders})",
|
|
311
|
+
tuple(_RESOLUTION_RECOVERY_OBJECTS),
|
|
312
|
+
).fetchall()
|
|
313
|
+
objects = {str(row["name"]): row for row in rows}
|
|
314
|
+
if set(objects) != set(_RESOLUTION_RECOVERY_OBJECTS):
|
|
315
|
+
raise CommitmentResolutionSchemaError(
|
|
316
|
+
"commitment resolution recovery schema is incomplete"
|
|
317
|
+
)
|
|
318
|
+
for name, (object_type, table_name, expected_sql) in (
|
|
319
|
+
_RESOLUTION_RECOVERY_OBJECTS.items()
|
|
320
|
+
):
|
|
321
|
+
row = objects[name]
|
|
322
|
+
if (
|
|
323
|
+
row["type"] != object_type
|
|
324
|
+
or row["tbl_name"] != table_name
|
|
325
|
+
or _sql_signature(row["sql"]) != _sql_signature(expected_sql)
|
|
326
|
+
):
|
|
327
|
+
raise CommitmentResolutionSchemaError(
|
|
328
|
+
"commitment resolution recovery object is invalid"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
protected_triggers = {
|
|
332
|
+
str(row["name"]): str(row["tbl_name"])
|
|
333
|
+
for row in conn.execute(
|
|
334
|
+
"""SELECT name,tbl_name FROM sqlite_master
|
|
335
|
+
WHERE type='trigger'
|
|
336
|
+
AND tbl_name IN (
|
|
337
|
+
'commitments',
|
|
338
|
+
'commitment_resolution_operations'
|
|
339
|
+
)"""
|
|
340
|
+
).fetchall()
|
|
341
|
+
}
|
|
342
|
+
expected_triggers = {
|
|
343
|
+
name: table_name
|
|
344
|
+
for name, (object_type, table_name, _sql) in (
|
|
345
|
+
_RESOLUTION_RECOVERY_OBJECTS.items()
|
|
346
|
+
)
|
|
347
|
+
if object_type == "trigger"
|
|
348
|
+
}
|
|
349
|
+
if protected_triggers != expected_triggers:
|
|
350
|
+
raise CommitmentResolutionSchemaError(
|
|
351
|
+
"commitment resolution recovery trigger set is invalid"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
columns = tuple(
|
|
355
|
+
tuple(row)
|
|
356
|
+
for row in conn.execute(
|
|
357
|
+
"PRAGMA table_info(commitment_resolution_operations)"
|
|
358
|
+
).fetchall()
|
|
359
|
+
)
|
|
360
|
+
if columns != _RESOLUTION_OPERATION_COLUMNS:
|
|
361
|
+
raise CommitmentResolutionSchemaError(
|
|
362
|
+
"commitment resolution recovery columns are invalid"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
indexes: Dict[str, tuple[str, str]] = {}
|
|
366
|
+
for row in conn.execute(
|
|
367
|
+
"PRAGMA index_list(commitment_resolution_operations)"
|
|
368
|
+
).fetchall():
|
|
369
|
+
if int(row["unique"]) != 1 or int(row["partial"]) != 0:
|
|
370
|
+
raise CommitmentResolutionSchemaError(
|
|
371
|
+
"commitment resolution recovery indexes are invalid"
|
|
372
|
+
)
|
|
373
|
+
index_name = str(row["name"])
|
|
374
|
+
quoted_index_name = index_name.replace('"', '""')
|
|
375
|
+
index_columns = conn.execute(
|
|
376
|
+
f'PRAGMA index_info("{quoted_index_name}")'
|
|
377
|
+
).fetchall()
|
|
378
|
+
if len(index_columns) != 1:
|
|
379
|
+
raise CommitmentResolutionSchemaError(
|
|
380
|
+
"commitment resolution recovery indexes are invalid"
|
|
381
|
+
)
|
|
382
|
+
indexes[index_name] = (
|
|
383
|
+
str(row["origin"]), str(index_columns[0]["name"]),
|
|
384
|
+
)
|
|
385
|
+
if indexes != _RESOLUTION_OPERATION_INDEXES:
|
|
386
|
+
raise CommitmentResolutionSchemaError(
|
|
387
|
+
"commitment resolution recovery indexes are invalid"
|
|
388
|
+
)
|
|
389
|
+
if conn.execute(
|
|
390
|
+
"PRAGMA foreign_key_list(commitment_resolution_operations)"
|
|
391
|
+
).fetchall():
|
|
392
|
+
raise CommitmentResolutionSchemaError(
|
|
393
|
+
"commitment resolution recovery constraints are invalid"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def resolution_recovery_readiness(self) -> Dict[str, Any]:
|
|
397
|
+
"""Revalidate and publish the generic durable-recovery contract."""
|
|
398
|
+
|
|
399
|
+
with self._lock:
|
|
400
|
+
conn = self._connect()
|
|
401
|
+
try:
|
|
402
|
+
conn.execute("BEGIN")
|
|
403
|
+
self._validate_resolution_recovery_schema(conn)
|
|
404
|
+
conn.commit()
|
|
405
|
+
except Exception:
|
|
406
|
+
conn.rollback()
|
|
407
|
+
raise
|
|
408
|
+
finally:
|
|
409
|
+
conn.close()
|
|
410
|
+
return {
|
|
411
|
+
"ready": True,
|
|
412
|
+
"capability": RESOLUTION_RECOVERY_CAPABILITY,
|
|
413
|
+
"schema": _RESOLUTION_RECOVERY_SCHEMA,
|
|
414
|
+
"version": 1,
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
|
|
419
|
+
d = dict(row)
|
|
420
|
+
if d.get("metadata"):
|
|
421
|
+
try:
|
|
422
|
+
d["metadata"] = json.loads(d["metadata"])
|
|
423
|
+
except (json.JSONDecodeError, TypeError):
|
|
424
|
+
d["metadata"] = None
|
|
425
|
+
else:
|
|
426
|
+
d["metadata"] = None
|
|
427
|
+
return d
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _operation_receipt(row: sqlite3.Row) -> Dict[str, Any]:
|
|
431
|
+
receipt = {
|
|
432
|
+
"schema": "ColonyCommitmentResolutionOperationV1",
|
|
433
|
+
"version": 1,
|
|
434
|
+
"operation_id": row["operation_id"],
|
|
435
|
+
"commitment_id": row["commitment_id"],
|
|
436
|
+
"outcome": row["outcome"],
|
|
437
|
+
"note": row["note"],
|
|
438
|
+
"note_digest": row["note_digest"],
|
|
439
|
+
"resolved_by": row["resolved_by"],
|
|
440
|
+
"status": row["status"],
|
|
441
|
+
"resolved_at": row["resolved_at"],
|
|
442
|
+
"record_digest": row["record_digest"],
|
|
443
|
+
}
|
|
444
|
+
expected = _operation_material(
|
|
445
|
+
operation_id=receipt["operation_id"],
|
|
446
|
+
commitment_id=receipt["commitment_id"],
|
|
447
|
+
outcome=receipt["outcome"],
|
|
448
|
+
note=receipt["note"],
|
|
449
|
+
note_digest=receipt["note_digest"],
|
|
450
|
+
resolved_by=receipt["resolved_by"],
|
|
451
|
+
status=receipt["status"],
|
|
452
|
+
resolved_at=receipt["resolved_at"],
|
|
453
|
+
)
|
|
454
|
+
if receipt != expected:
|
|
455
|
+
raise ValueError(
|
|
456
|
+
"commitment resolution operation integrity check failed"
|
|
457
|
+
)
|
|
458
|
+
return receipt
|
|
459
|
+
|
|
460
|
+
def get_resolution_operation(
|
|
461
|
+
self, commitment_id: str,
|
|
462
|
+
) -> Optional[Dict[str, Any]]:
|
|
463
|
+
"""Return the immutable exact-operation proof for one commitment."""
|
|
464
|
+
|
|
465
|
+
with self._lock:
|
|
466
|
+
conn = self._connect()
|
|
467
|
+
try:
|
|
468
|
+
conn.execute("BEGIN")
|
|
469
|
+
self._validate_resolution_recovery_schema(conn)
|
|
470
|
+
row = conn.execute(
|
|
471
|
+
"""SELECT * FROM commitment_resolution_operations
|
|
472
|
+
WHERE commitment_id=?""",
|
|
473
|
+
(commitment_id,),
|
|
474
|
+
).fetchone()
|
|
475
|
+
receipt = (
|
|
476
|
+
self._operation_receipt(row) if row is not None else None
|
|
477
|
+
)
|
|
478
|
+
conn.commit()
|
|
479
|
+
return receipt
|
|
480
|
+
except Exception:
|
|
481
|
+
conn.rollback()
|
|
482
|
+
raise
|
|
483
|
+
finally:
|
|
484
|
+
conn.close()
|
|
485
|
+
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
# CRUD
|
|
488
|
+
# ------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def create(
|
|
491
|
+
self,
|
|
492
|
+
person_id: str,
|
|
493
|
+
description: str,
|
|
494
|
+
due_at: Optional[str] = None,
|
|
495
|
+
priority: int = 50,
|
|
496
|
+
source_type: str = "manual",
|
|
497
|
+
source_context: Optional[str] = None,
|
|
498
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
499
|
+
*, dedupe: bool = False,
|
|
500
|
+
) -> Dict[str, Any]:
|
|
501
|
+
"""Create, optionally reusing the same open obligation under one write lock."""
|
|
502
|
+
commitment_id = str(uuid.uuid4())
|
|
503
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
504
|
+
|
|
505
|
+
# Validate due_at is in the future AND normalize it to canonical UTC ISO.
|
|
506
|
+
# get_overdue() compares due_at as a STRING against a +00:00 `now`, so a
|
|
507
|
+
# naive or non-UTC-offset stored value sorts wrong — overdue commitments
|
|
508
|
+
# then surface late or never (a forgotten promise). Persist the
|
|
509
|
+
# normalized value, not the caller's raw string.
|
|
510
|
+
if due_at:
|
|
511
|
+
try:
|
|
512
|
+
due_dt = datetime.fromisoformat(due_at)
|
|
513
|
+
if due_dt.tzinfo is None:
|
|
514
|
+
due_dt = due_dt.replace(tzinfo=timezone.utc)
|
|
515
|
+
due_dt = due_dt.astimezone(timezone.utc)
|
|
516
|
+
if due_dt < datetime.now(timezone.utc):
|
|
517
|
+
raise ValueError("due_at must be in the future")
|
|
518
|
+
due_at = due_dt.isoformat()
|
|
519
|
+
except ValueError:
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
meta_json = json.dumps(metadata) if metadata else None
|
|
523
|
+
|
|
524
|
+
with self._lock:
|
|
525
|
+
conn = self._connect()
|
|
526
|
+
try:
|
|
527
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
528
|
+
if dedupe:
|
|
529
|
+
existing = self._find_open_duplicate(conn, person_id, description)
|
|
530
|
+
if existing is not None:
|
|
531
|
+
conn.commit()
|
|
532
|
+
return {**existing, "deduped": True}
|
|
533
|
+
conn.execute(
|
|
534
|
+
"""INSERT INTO commitments
|
|
535
|
+
(id, person_id, description, made_at, due_at, status,
|
|
536
|
+
source_type, source_context, priority, metadata)
|
|
537
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
538
|
+
(commitment_id, person_id, description, now, due_at,
|
|
539
|
+
"pending", source_type, source_context, priority, meta_json),
|
|
540
|
+
)
|
|
541
|
+
conn.commit()
|
|
542
|
+
row = conn.execute(
|
|
543
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
544
|
+
).fetchone()
|
|
545
|
+
return self._row_to_dict(row)
|
|
546
|
+
finally:
|
|
547
|
+
conn.close()
|
|
548
|
+
|
|
549
|
+
def get(self, commitment_id: str) -> Optional[Dict[str, Any]]:
|
|
550
|
+
"""Get a single commitment by ID. Returns None if not found."""
|
|
551
|
+
with self._lock:
|
|
552
|
+
conn = self._connect()
|
|
553
|
+
try:
|
|
554
|
+
row = conn.execute(
|
|
555
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
556
|
+
).fetchone()
|
|
557
|
+
return self._row_to_dict(row) if row else None
|
|
558
|
+
finally:
|
|
559
|
+
conn.close()
|
|
560
|
+
|
|
561
|
+
def list(
|
|
562
|
+
self,
|
|
563
|
+
person_id: Optional[str] = None,
|
|
564
|
+
status: Optional[List[str]] = None,
|
|
565
|
+
overdue_only: bool = False,
|
|
566
|
+
limit: int = 50,
|
|
567
|
+
offset: int = 0,
|
|
568
|
+
) -> Dict[str, Any]:
|
|
569
|
+
"""List commitments with optional filters.
|
|
570
|
+
|
|
571
|
+
Returns dict with commitments, total, limit, offset.
|
|
572
|
+
"""
|
|
573
|
+
conditions: List[str] = []
|
|
574
|
+
params: List[Any] = []
|
|
575
|
+
|
|
576
|
+
if person_id:
|
|
577
|
+
conditions.append("person_id = ?")
|
|
578
|
+
params.append(person_id)
|
|
579
|
+
|
|
580
|
+
if overdue_only:
|
|
581
|
+
conditions.append("status = 'overdue'")
|
|
582
|
+
elif status:
|
|
583
|
+
placeholders = ",".join("?" for _ in status)
|
|
584
|
+
conditions.append(f"status IN ({placeholders})")
|
|
585
|
+
params.extend(status)
|
|
586
|
+
|
|
587
|
+
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
588
|
+
|
|
589
|
+
with self._lock:
|
|
590
|
+
conn = self._connect()
|
|
591
|
+
try:
|
|
592
|
+
total = conn.execute(
|
|
593
|
+
f"SELECT COUNT(*) FROM commitments {where}", params
|
|
594
|
+
).fetchone()[0]
|
|
595
|
+
|
|
596
|
+
rows = conn.execute(
|
|
597
|
+
f"SELECT * FROM commitments {where} ORDER BY made_at DESC LIMIT ? OFFSET ?",
|
|
598
|
+
params + [limit, offset],
|
|
599
|
+
).fetchall()
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
"commitments": [self._row_to_dict(r) for r in rows],
|
|
603
|
+
"total": total,
|
|
604
|
+
"limit": limit,
|
|
605
|
+
"offset": offset,
|
|
606
|
+
}
|
|
607
|
+
finally:
|
|
608
|
+
conn.close()
|
|
609
|
+
|
|
610
|
+
def update(
|
|
611
|
+
self,
|
|
612
|
+
commitment_id: str,
|
|
613
|
+
status: Optional[str] = None,
|
|
614
|
+
fulfilled_at: Optional[str] = None,
|
|
615
|
+
description: Optional[str] = None,
|
|
616
|
+
due_at: Optional[str] = None,
|
|
617
|
+
priority: Optional[int] = None,
|
|
618
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
619
|
+
) -> Optional[Dict[str, Any]]:
|
|
620
|
+
"""Update a commitment. Returns updated record or None if not found.
|
|
621
|
+
|
|
622
|
+
Validates status transitions:
|
|
623
|
+
pending → fulfilled, overdue, cancelled
|
|
624
|
+
overdue → fulfilled, cancelled
|
|
625
|
+
fulfilled, cancelled → no transitions allowed (terminal)
|
|
626
|
+
"""
|
|
627
|
+
VALID_TRANSITIONS = {
|
|
628
|
+
"pending": {"fulfilled", "overdue", "cancelled"},
|
|
629
|
+
"overdue": {"fulfilled", "cancelled"},
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
with self._lock:
|
|
633
|
+
conn = self._connect()
|
|
634
|
+
try:
|
|
635
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
636
|
+
self._validate_resolution_recovery_schema(conn)
|
|
637
|
+
current = conn.execute(
|
|
638
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
639
|
+
).fetchone()
|
|
640
|
+
if not current:
|
|
641
|
+
conn.commit()
|
|
642
|
+
return None
|
|
643
|
+
|
|
644
|
+
current_status = current["status"]
|
|
645
|
+
|
|
646
|
+
if status and status != current_status:
|
|
647
|
+
allowed = VALID_TRANSITIONS.get(current_status, set())
|
|
648
|
+
if status not in allowed:
|
|
649
|
+
raise ValueError(
|
|
650
|
+
f"Cannot transition from '{current_status}' to '{status}'"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Auto-fill fulfilled_at when transitioning to fulfilled
|
|
654
|
+
if status == "fulfilled" and not fulfilled_at:
|
|
655
|
+
fulfilled_at = datetime.now(timezone.utc).isoformat()
|
|
656
|
+
|
|
657
|
+
# Build UPDATE statement
|
|
658
|
+
updates: List[str] = []
|
|
659
|
+
params: List[Any] = []
|
|
660
|
+
|
|
661
|
+
if status is not None:
|
|
662
|
+
updates.append("status = ?")
|
|
663
|
+
params.append(status)
|
|
664
|
+
if fulfilled_at is not None:
|
|
665
|
+
updates.append("fulfilled_at = ?")
|
|
666
|
+
params.append(fulfilled_at)
|
|
667
|
+
if description is not None:
|
|
668
|
+
updates.append("description = ?")
|
|
669
|
+
params.append(description)
|
|
670
|
+
if due_at is not None:
|
|
671
|
+
updates.append("due_at = ?")
|
|
672
|
+
params.append(due_at)
|
|
673
|
+
if priority is not None:
|
|
674
|
+
updates.append("priority = ?")
|
|
675
|
+
params.append(priority)
|
|
676
|
+
if metadata is not None:
|
|
677
|
+
updates.append("metadata = ?")
|
|
678
|
+
params.append(json.dumps(metadata))
|
|
679
|
+
|
|
680
|
+
if not updates:
|
|
681
|
+
conn.commit()
|
|
682
|
+
return self._row_to_dict(current)
|
|
683
|
+
|
|
684
|
+
params.append(commitment_id)
|
|
685
|
+
conn.execute(
|
|
686
|
+
f"UPDATE commitments SET {', '.join(updates)} WHERE id = ?",
|
|
687
|
+
params,
|
|
688
|
+
)
|
|
689
|
+
conn.commit()
|
|
690
|
+
|
|
691
|
+
row = conn.execute(
|
|
692
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
693
|
+
).fetchone()
|
|
694
|
+
return self._row_to_dict(row)
|
|
695
|
+
except Exception:
|
|
696
|
+
conn.rollback()
|
|
697
|
+
raise
|
|
698
|
+
finally:
|
|
699
|
+
conn.close()
|
|
700
|
+
|
|
701
|
+
def delete(self, commitment_id: str) -> bool:
|
|
702
|
+
"""Delete a commitment. Only allowed for terminal states.
|
|
703
|
+
|
|
704
|
+
Returns True if deleted, False if not found or not terminal.
|
|
705
|
+
"""
|
|
706
|
+
with self._lock:
|
|
707
|
+
conn = self._connect()
|
|
708
|
+
try:
|
|
709
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
710
|
+
self._validate_resolution_recovery_schema(conn)
|
|
711
|
+
current = conn.execute(
|
|
712
|
+
"SELECT status FROM commitments WHERE id = ?", (commitment_id,)
|
|
713
|
+
).fetchone()
|
|
714
|
+
if not current:
|
|
715
|
+
conn.commit()
|
|
716
|
+
return False
|
|
717
|
+
if current["status"] not in ("fulfilled", "cancelled"):
|
|
718
|
+
conn.commit()
|
|
719
|
+
return False
|
|
720
|
+
operation = conn.execute(
|
|
721
|
+
"""SELECT 1 FROM commitment_resolution_operations
|
|
722
|
+
WHERE commitment_id=?""",
|
|
723
|
+
(commitment_id,),
|
|
724
|
+
).fetchone()
|
|
725
|
+
if operation is not None:
|
|
726
|
+
conn.commit()
|
|
727
|
+
return False
|
|
728
|
+
conn.execute(
|
|
729
|
+
"DELETE FROM commitments WHERE id = ?", (commitment_id,)
|
|
730
|
+
)
|
|
731
|
+
conn.commit()
|
|
732
|
+
return True
|
|
733
|
+
except Exception:
|
|
734
|
+
conn.rollback()
|
|
735
|
+
raise
|
|
736
|
+
finally:
|
|
737
|
+
conn.close()
|
|
738
|
+
|
|
739
|
+
# ------------------------------------------------------------------
|
|
740
|
+
# Query helpers
|
|
741
|
+
# ------------------------------------------------------------------
|
|
742
|
+
|
|
743
|
+
def get_overdue(self) -> List[Dict[str, Any]]:
|
|
744
|
+
"""Get open commitments (pending OR already flipped to overdue) whose
|
|
745
|
+
due_at has passed. Including 'overdue' matters: the condition worker
|
|
746
|
+
flips pending→overdue, and a pending-only query would make flipped
|
|
747
|
+
items invisible to everything that surfaces owed work."""
|
|
748
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
749
|
+
with self._lock:
|
|
750
|
+
conn = self._connect()
|
|
751
|
+
try:
|
|
752
|
+
rows = conn.execute(
|
|
753
|
+
"""SELECT * FROM commitments
|
|
754
|
+
WHERE status IN ('pending', 'overdue')
|
|
755
|
+
AND due_at IS NOT NULL AND due_at < ?
|
|
756
|
+
ORDER BY due_at ASC""",
|
|
757
|
+
(now,),
|
|
758
|
+
).fetchall()
|
|
759
|
+
return [self._row_to_dict(r) for r in rows]
|
|
760
|
+
finally:
|
|
761
|
+
conn.close()
|
|
762
|
+
|
|
763
|
+
def get_pending_for_person(self, person_id: str) -> List[Dict[str, Any]]:
|
|
764
|
+
"""Get OPEN commitments (pending + overdue) for a specific person.
|
|
765
|
+
Callers use this as "what is still owed" — an item that went overdue
|
|
766
|
+
is owed more, not less, so it must stay in this list (it is also the
|
|
767
|
+
dedup list the introspection extractor sees)."""
|
|
768
|
+
with self._lock:
|
|
769
|
+
conn = self._connect()
|
|
770
|
+
try:
|
|
771
|
+
rows = conn.execute(
|
|
772
|
+
"""SELECT * FROM commitments
|
|
773
|
+
WHERE person_id = ? AND status IN ('pending', 'overdue')
|
|
774
|
+
ORDER BY priority DESC, due_at ASC""",
|
|
775
|
+
(person_id,),
|
|
776
|
+
).fetchall()
|
|
777
|
+
return [self._row_to_dict(r) for r in rows]
|
|
778
|
+
finally:
|
|
779
|
+
conn.close()
|
|
780
|
+
|
|
781
|
+
def _find_open_duplicate(self, conn, person_id, description):
|
|
782
|
+
norm = _normalize_desc(description)
|
|
783
|
+
if not norm:
|
|
784
|
+
return None
|
|
785
|
+
rows = conn.execute(
|
|
786
|
+
"SELECT * FROM commitments WHERE person_id=? AND status IN (?,?) ORDER BY made_at DESC",
|
|
787
|
+
(person_id, *OPEN_STATUSES))
|
|
788
|
+
for row in rows:
|
|
789
|
+
if _similar_desc(norm, _normalize_desc(row["description"] or "")):
|
|
790
|
+
return self._row_to_dict(row)
|
|
791
|
+
return None
|
|
792
|
+
|
|
793
|
+
def find_open_duplicate(
|
|
794
|
+
self, person_id: str, description: str
|
|
795
|
+
) -> Optional[Dict[str, Any]]:
|
|
796
|
+
"""Inspect the existing normalized/containment/token-overlap predicate.
|
|
797
|
+
|
|
798
|
+
Writers needing deduplication must use create(dedupe=True), which checks
|
|
799
|
+
and inserts in the same SQLite transaction across independent processes.
|
|
800
|
+
"""
|
|
801
|
+
with self._lock:
|
|
802
|
+
conn = self._connect()
|
|
803
|
+
try:
|
|
804
|
+
return self._find_open_duplicate(conn, person_id, description)
|
|
805
|
+
finally:
|
|
806
|
+
conn.close()
|
|
807
|
+
|
|
808
|
+
# ------------------------------------------------------------------
|
|
809
|
+
# Resolution: settle an item with a reason the system can learn from
|
|
810
|
+
# ------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
def resolve(
|
|
813
|
+
self,
|
|
814
|
+
commitment_id: str,
|
|
815
|
+
outcome: str = "done",
|
|
816
|
+
note: Optional[str] = None,
|
|
817
|
+
resolved_by: str = "owner",
|
|
818
|
+
operation_id: Optional[str] = None,
|
|
819
|
+
) -> Optional[Dict[str, Any]]:
|
|
820
|
+
"""Settle a commitment with an outcome (done | invalid | duplicate |
|
|
821
|
+
wont_do | obsolete). Legacy calls remain state-idempotent. A caller
|
|
822
|
+
supplying ``operation_id`` gets exact idempotency: an existing terminal
|
|
823
|
+
row is accepted only when its stored operation, outcome, note, resolver,
|
|
824
|
+
and resulting status all match. Emits the matching commitment.* event
|
|
825
|
+
on an actual transition."""
|
|
826
|
+
status = OUTCOME_TO_STATUS.get(outcome)
|
|
827
|
+
if status is None:
|
|
828
|
+
raise ValueError(
|
|
829
|
+
f"unknown outcome '{outcome}' (expected one of "
|
|
830
|
+
f"{sorted(OUTCOME_TO_STATUS)})")
|
|
831
|
+
if operation_id is not None and (
|
|
832
|
+
type(operation_id) is not str
|
|
833
|
+
or not operation_id
|
|
834
|
+
or operation_id != operation_id.strip()
|
|
835
|
+
or len(operation_id) > 192
|
|
836
|
+
):
|
|
837
|
+
raise ValueError("commitment resolution operation_id is invalid")
|
|
838
|
+
full_note = note or ""
|
|
839
|
+
note_text = full_note[:300]
|
|
840
|
+
resolver = str(resolved_by)
|
|
841
|
+
expected_resolution = {
|
|
842
|
+
"outcome": outcome,
|
|
843
|
+
"note": note_text,
|
|
844
|
+
"note_digest": hashlib.sha256(full_note.encode("utf-8")).hexdigest(),
|
|
845
|
+
"by": resolver,
|
|
846
|
+
}
|
|
847
|
+
if operation_id is not None:
|
|
848
|
+
expected_resolution["operation_id"] = operation_id
|
|
849
|
+
|
|
850
|
+
transitioned = False
|
|
851
|
+
row: Optional[Dict[str, Any]] = None
|
|
852
|
+
with self._lock:
|
|
853
|
+
conn = self._connect()
|
|
854
|
+
try:
|
|
855
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
856
|
+
self._validate_resolution_recovery_schema(conn)
|
|
857
|
+
stored = conn.execute(
|
|
858
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
859
|
+
).fetchone()
|
|
860
|
+
stored_operation = conn.execute(
|
|
861
|
+
"""SELECT * FROM commitment_resolution_operations
|
|
862
|
+
WHERE commitment_id=?""",
|
|
863
|
+
(commitment_id,),
|
|
864
|
+
).fetchone()
|
|
865
|
+
operation = (
|
|
866
|
+
self._operation_receipt(stored_operation)
|
|
867
|
+
if stored_operation is not None else None
|
|
868
|
+
)
|
|
869
|
+
if stored is None:
|
|
870
|
+
if operation is not None:
|
|
871
|
+
raise CommitmentResolutionConflict(
|
|
872
|
+
"operation-bound commitment row is missing"
|
|
873
|
+
)
|
|
874
|
+
conn.commit()
|
|
875
|
+
return None
|
|
876
|
+
current = self._row_to_dict(stored)
|
|
877
|
+
if current["status"] in ("fulfilled", "cancelled"):
|
|
878
|
+
if operation_id is not None:
|
|
879
|
+
expected_operation = {
|
|
880
|
+
"operation_id": operation_id,
|
|
881
|
+
"commitment_id": commitment_id,
|
|
882
|
+
"outcome": outcome,
|
|
883
|
+
"note": note_text,
|
|
884
|
+
"note_digest": expected_resolution["note_digest"],
|
|
885
|
+
"resolved_by": resolver,
|
|
886
|
+
"status": status,
|
|
887
|
+
}
|
|
888
|
+
if (
|
|
889
|
+
operation is None
|
|
890
|
+
or any(
|
|
891
|
+
operation.get(name) != value
|
|
892
|
+
for name, value in expected_operation.items()
|
|
893
|
+
)
|
|
894
|
+
or current["status"] != status
|
|
895
|
+
):
|
|
896
|
+
raise CommitmentResolutionConflict(
|
|
897
|
+
"terminal commitment has a different operation"
|
|
898
|
+
)
|
|
899
|
+
conn.commit()
|
|
900
|
+
return current
|
|
901
|
+
if current["status"] not in OPEN_STATUSES:
|
|
902
|
+
raise CommitmentResolutionConflict(
|
|
903
|
+
"commitment is not open or terminal"
|
|
904
|
+
)
|
|
905
|
+
if operation is not None:
|
|
906
|
+
raise CommitmentResolutionConflict(
|
|
907
|
+
"open commitment already has a resolution operation"
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
meta = dict(current.get("metadata") or {})
|
|
911
|
+
resolved_at = datetime.now(timezone.utc).isoformat()
|
|
912
|
+
meta["resolution"] = {
|
|
913
|
+
**expected_resolution,
|
|
914
|
+
"at": resolved_at,
|
|
915
|
+
}
|
|
916
|
+
if operation_id is not None:
|
|
917
|
+
operation = _operation_material(
|
|
918
|
+
operation_id=operation_id,
|
|
919
|
+
commitment_id=commitment_id,
|
|
920
|
+
outcome=outcome,
|
|
921
|
+
note=note_text,
|
|
922
|
+
note_digest=expected_resolution["note_digest"],
|
|
923
|
+
resolved_by=resolver,
|
|
924
|
+
status=status,
|
|
925
|
+
resolved_at=resolved_at,
|
|
926
|
+
)
|
|
927
|
+
conn.execute(
|
|
928
|
+
"""INSERT INTO commitment_resolution_operations
|
|
929
|
+
(operation_id,commitment_id,outcome,note,note_digest,
|
|
930
|
+
resolved_by,status,resolved_at,record_digest)
|
|
931
|
+
VALUES (?,?,?,?,?,?,?,?,?)""",
|
|
932
|
+
(
|
|
933
|
+
operation["operation_id"],
|
|
934
|
+
operation["commitment_id"], operation["outcome"],
|
|
935
|
+
operation["note"], operation["note_digest"],
|
|
936
|
+
operation["resolved_by"], operation["status"],
|
|
937
|
+
operation["resolved_at"], operation["record_digest"],
|
|
938
|
+
),
|
|
939
|
+
)
|
|
940
|
+
fulfilled_at = (
|
|
941
|
+
resolved_at
|
|
942
|
+
if status == "fulfilled" else current.get("fulfilled_at")
|
|
943
|
+
)
|
|
944
|
+
changed = conn.execute(
|
|
945
|
+
"""UPDATE commitments
|
|
946
|
+
SET status=?,fulfilled_at=?,metadata=?
|
|
947
|
+
WHERE id=? AND status=?""",
|
|
948
|
+
(
|
|
949
|
+
status, fulfilled_at, json.dumps(meta),
|
|
950
|
+
commitment_id, current["status"],
|
|
951
|
+
),
|
|
952
|
+
).rowcount
|
|
953
|
+
if changed != 1:
|
|
954
|
+
raise CommitmentResolutionConflict(
|
|
955
|
+
"commitment changed before resolution was recorded"
|
|
956
|
+
)
|
|
957
|
+
conn.commit()
|
|
958
|
+
stored = conn.execute(
|
|
959
|
+
"SELECT * FROM commitments WHERE id = ?", (commitment_id,)
|
|
960
|
+
).fetchone()
|
|
961
|
+
row = self._row_to_dict(stored)
|
|
962
|
+
transitioned = True
|
|
963
|
+
except Exception:
|
|
964
|
+
conn.rollback()
|
|
965
|
+
raise
|
|
966
|
+
finally:
|
|
967
|
+
conn.close()
|
|
968
|
+
|
|
969
|
+
if transitioned and row is not None:
|
|
970
|
+
try:
|
|
971
|
+
from apsimo.events.broadcaster import emit
|
|
972
|
+
emit(f"commitment.{status}", {
|
|
973
|
+
"commitment_id": row["id"],
|
|
974
|
+
"person_id": row["person_id"],
|
|
975
|
+
"outcome": outcome,
|
|
976
|
+
"resolved_by": resolver,
|
|
977
|
+
})
|
|
978
|
+
except Exception:
|
|
979
|
+
pass
|
|
980
|
+
return row
|
|
981
|
+
|
|
982
|
+
def resolution_stats(self, days: int = 30) -> Dict[str, Any]:
|
|
983
|
+
"""Per-source_type counts of how items created in the window ended up:
|
|
984
|
+
fulfilled, cancelled (with outcome breakdown), still open. This is the
|
|
985
|
+
calibration signal for whatever generates items — a source whose items
|
|
986
|
+
keep getting cancelled as invalid should get more conservative."""
|
|
987
|
+
cutoff = datetime.now(timezone.utc).timestamp() - days * 86400
|
|
988
|
+
with self._lock:
|
|
989
|
+
conn = self._connect()
|
|
990
|
+
try:
|
|
991
|
+
rows = conn.execute("SELECT * FROM commitments").fetchall()
|
|
992
|
+
finally:
|
|
993
|
+
conn.close()
|
|
994
|
+
by_source: Dict[str, Dict[str, Any]] = {}
|
|
995
|
+
for r in rows:
|
|
996
|
+
d = self._row_to_dict(r)
|
|
997
|
+
try:
|
|
998
|
+
made = datetime.fromisoformat(d["made_at"]).timestamp()
|
|
999
|
+
except (ValueError, TypeError):
|
|
1000
|
+
continue
|
|
1001
|
+
if made < cutoff:
|
|
1002
|
+
continue
|
|
1003
|
+
src = d.get("source_type") or "manual"
|
|
1004
|
+
s = by_source.setdefault(src, {
|
|
1005
|
+
"created": 0, "fulfilled": 0, "cancelled": 0,
|
|
1006
|
+
"open": 0, "outcomes": {},
|
|
1007
|
+
})
|
|
1008
|
+
s["created"] += 1
|
|
1009
|
+
status = d.get("status")
|
|
1010
|
+
if status in OPEN_STATUSES:
|
|
1011
|
+
s["open"] += 1
|
|
1012
|
+
elif status in ("fulfilled", "cancelled"):
|
|
1013
|
+
s[status] += 1
|
|
1014
|
+
res = (d.get("metadata") or {}).get("resolution") or {}
|
|
1015
|
+
oc = res.get("outcome") or (
|
|
1016
|
+
"done" if status == "fulfilled" else "unspecified")
|
|
1017
|
+
s["outcomes"][oc] = s["outcomes"].get(oc, 0) + 1
|
|
1018
|
+
return {"days": days, "by_source": by_source}
|
|
1019
|
+
|
|
1020
|
+
def recent_rejections(
|
|
1021
|
+
self, limit: int = 6,
|
|
1022
|
+
source_types: Optional[List[str]] = None,
|
|
1023
|
+
) -> List[Dict[str, Any]]:
|
|
1024
|
+
"""Most recently cancelled items judged invalid or duplicate — the
|
|
1025
|
+
negative examples the extraction side injects into its prompt so the
|
|
1026
|
+
same bad item is not recorded again."""
|
|
1027
|
+
with self._lock:
|
|
1028
|
+
conn = self._connect()
|
|
1029
|
+
try:
|
|
1030
|
+
rows = conn.execute(
|
|
1031
|
+
"""SELECT * FROM commitments WHERE status = 'cancelled'
|
|
1032
|
+
ORDER BY made_at DESC LIMIT 200""",
|
|
1033
|
+
).fetchall()
|
|
1034
|
+
finally:
|
|
1035
|
+
conn.close()
|
|
1036
|
+
out: List[Dict[str, Any]] = []
|
|
1037
|
+
for r in rows:
|
|
1038
|
+
d = self._row_to_dict(r)
|
|
1039
|
+
if source_types and (d.get("source_type") or "manual") not in source_types:
|
|
1040
|
+
continue
|
|
1041
|
+
res = (d.get("metadata") or {}).get("resolution") or {}
|
|
1042
|
+
if res.get("outcome") not in ("invalid", "duplicate"):
|
|
1043
|
+
continue
|
|
1044
|
+
out.append({
|
|
1045
|
+
"description": d.get("description") or "",
|
|
1046
|
+
"outcome": res.get("outcome"),
|
|
1047
|
+
"note": res.get("note") or "",
|
|
1048
|
+
"at": res.get("at") or "",
|
|
1049
|
+
})
|
|
1050
|
+
if len(out) >= limit:
|
|
1051
|
+
break
|
|
1052
|
+
return out
|